この記事は、 ニフティグループ Advent Calendar 2022 11日目の記事です。 はじめに 会員システムグループ N1!Machine Learning Product Engineerの中村です。 ニフティでは11/22にNIFTY Tech Dayというイベントを開催しました。 この時に、ニフティニュースにおける深層自然言語処理によるニュース記事要約について発表をさせていただきましたが、この時に行った転移学習(ファインチューニング)について技術的な解説をしていきます。 モデルの転移学習について 本記事の大まかな実装は sonoisa さんの記事を参考に構築しています。 https://qiita.com/sonoisa/items/a9af64ff641f0bbfed44 言語モデルの転移学習 現在の言語モデルは非常に大規模であり、全く学習されていない状態(スクラッチ)から改めて学習させることは現実的ではありません。そこで考案されたのが転移学習という手法です。 転移学習とは、既に学習された大規模な言語モデルを元に、新しいタスクについての学習を行う手法です。この方法は、新しいタスクの学習データが少ない場合に特に有効です。また、既に学習されたモデルの学習済みの知識を引き継ぐことで、学習がスムーズに進むため、学習時間の短縮が期待されます。 プロンプト学習 https://arxiv.org/abs/1910.10683 今回のニュース記事要約ではT5というモデルの学習を行いますが、この学習にはプロンプトを用いて学習を行います。現在の深層学習モデルは非常に大規模であり、1つのモデルで複数のタスクを実行可能です。プロンプトと呼ばれる接頭辞を頭に付けて学習を行うことで、文章に対して何を行いたいかを指定し、それに合わせたタスクを実行するように学習します。 実装と学習の実行 ここではニフティニュースにおける記事データと要約データを用いて、T5モデルの転移学習を行います。 データセットの準備 ノーマライズ処理 まず、neologdの正規化処理を改変した処理を用いて、正規化処理を定義します # https://github.com/neologd/mecab-ipadic-neologd/wiki/Regexp.ja から引用・一部改変 from __future__ import unicode_literals import re import unicodedata def unicode_normalize(cls, s): pt = re.compile('([{}]+)'.format(cls)) def norm(c): return unicodedata.normalize('NFKC', c) if pt.match(c) else c s = ''.join(norm(x) for x in re.split(pt, s)) s = re.sub('-', '-', s) return s def remove_extra_spaces(s): s = re.sub('[ ]+', ' ', s) blocks = ''.join(('\u4E00-\u9FFF', # CJK UNIFIED IDEOGRAPHS '\u3040-\u309F', # HIRAGANA '\u30A0-\u30FF', # KATAKANA '\u3000-\u303F', # CJK SYMBOLS AND PUNCTUATION '\uFF00-\uFFEF' # HALFWIDTH AND FULLWIDTH FORMS )) basic_latin = '\u0000-\u007F' def remove_space_between(cls1, cls2, s): p = re.compile('([{}]) ([{}])'.format(cls1, cls2)) while p.search(s): s = p.sub(r'\1\2', s) return s s = remove_space_between(blocks, blocks, s) s = remove_space_between(blocks, basic_latin, s) s = remove_space_between(basic_latin, blocks, s) return s def normalize_neologd(s): s = s.strip() s = unicode_normalize('0-9A-Za-z。-゚', s) def maketrans(f, t): return {ord(x): ord(y) for x, y in zip(f, t)} s = re.sub('[˗֊‐‑‒–⁃⁻₋−]+', '-', s) # normalize hyphens s = re.sub('[﹣-ー—―─━ー]+', 'ー', s) # normalize choonpus s = re.sub('[~∼∾〜 ~]+', '〜', s) # normalize tildes (modified by Isao Sonobe) s = s.translate( maketrans('!"#$%&\'()*+,-./:;<=>?@[¥]^_`{|}~。、・「」', '!”#$%&’()*+,-./:;<=>?@[¥]^_`{|}〜。、・「」')) s = remove_extra_spaces(s) s = unicode_normalize('!”#$%&’()*+,-./:;<>?@[¥]^_`{|}〜', s) # keep =,・,「,」 s = re.sub('[’]', '\'', s) s = re.sub('[”]', '"', s) return s そのほかにタブ文字やタグの除去、エスケープシーケンスの復号化などを行い、データセットを整備します。 参考元の処理ではlower処理やスペースの削除などが含まれていますが、例えば「iPhone」などの商品名や、空白によって意図的に区切られている部分が消滅するなどの現象が起こったため、本記事ではその部分の処理を行わないように実装しています。 ニフティニュースでは短文と長文の2種類のタイトルの他、3行要約も作成しており、そのデータにそれぞれプロンプトを付与し学習させます。 import re import numpy as np import pickle from tqdm import tqdm tag_regex = re.compile(r"<[^>]*?>") def normalize_text(text): text = text.replace("\t", " ") text = normalize_neologd(text) text = tag_regex.sub("", text) text = text.replace("&quot;", "\"").replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").replace("&nbsp;", " ") return text all_data = [] count = 0 for index, data in news_data.iterrows(): if data['body'] is None or data['body'] is np.nan or not data['body']: continue normalized_body = normalize_text(data['body']) all_data.append({"text": "keyword: " + normalized_body,"response": normalize_text(data['keyword_str_1']),}) all_data.append({"text": "keyword: " + normalized_body,"response": normalize_text(data['keyword_str_2']),}) all_data.append({"text": "keyword: " + normalized_body,"response": normalize_text(data['keyword_str_3']),}) all_data.append({"text": "topics_title: " + normalized_body,"response": normalize_text(data['topics_article_title']),}) all_data.append({"text": "title: " + normalized_body,"response": normalize_text(data['title']),}) if data['long_title'] is not None and data['long_title'] is not np.nan: all_data.append({"text": "long_title: " + normalized_body,"response": normalize_text(data['long_title']),}) if data['summary_1'] is not None and data['summary_1'] is not np.nan: all_data.append({"text": "summary_1: " + normalized_body,"response": normalize_text(data['summary_1']),}) if data['summary_2'] is not None and data['summary_2'] is not np.nan: all_data.append({"text": "summary_2: " + normalized_body,"response": normalize_text(data['summary_2']),}) if data['summary_3'] is not None and data['summary_3'] is not np.nan: all_data.append({"text": "summary_3: " + normalized_body,"response": normalize_text(data['summary_3']),}) プロンプトも含めた本文であるtextと、それに対応する応答であるresponseを、all_dataという配列に含めた状態になりました。 データセットの分割 データをtrain/validation/testの3つに分割します。 import random from tqdm import tqdm random.seed(1234) random.shuffle(all_data) def to_line(data): text = data["text"] response = data["response"] assert len(text) > 0 and len(response) > 0 return f"{text}\t{response}\n" data_size = len(all_data) train_ratio, val_ratio, test_ratio = 0.95, 0.03, 0.02 with open(f"data/train.tsv", "w", encoding="utf-8") as f_train, \ open(f"data/val.tsv", "w", encoding="utf-8") as f_val, \ open(f"data/test.tsv", "w", encoding="utf-8") as f_test: for i, data in tqdm(enumerate(all_data)): line = to_line(data) if i < train_ratio * data_size: f_train.write(line) elif i < (train_ratio + val_ratio) * data_size: f_val.write(line) else: f_test.write(line) 確認してみると、ランダムにデータが分割されたことがわかります。 学習の実行 モデルの定義などは参考元と同じため、ここでは割愛します。 https://qiita.com/sonoisa/items/a9af64ff641f0bbfed44 事前学習モデルには megagonlabs/t5-base-japanese-web を使用します。 A100(80GB)時の設定について Google ColaboratoryなどでGPUにA100を使う場合、プリインストールされているtorchではCUDAが対応していないというエラーが起きます。その場合には、以下のコマンドで対応するtorchなどをインストールします。 !pip install -qU transformers[ja] pytorch_lightning sentencepiece torch==1.10.0+cu111 torchvision==0.11.1+cu111 torchaudio torchtext -f https://download.pytorch.org/whl/torch_stable.html その他ハイパーパラメータ ハイパーパラメータについては以下のように設定します。 モデルのチェックポイント周りの定義を行っておくと、もしも途中で学習が終了する(PCが止まる、Google Colaboratoryのセッションが切れてしまう)自体に陥っても、学習を途中から始めることが可能なため設定しておくことをおすすめします。 (どうしても時間がかかってしまうような、このような大規模モデルの学習では非常に便利だと感じました) # 学習に用いるハイパーパラメータを設定する args_dict.update({ "max_input_length": 1024, # 入力文の最大トークン数 "max_target_length": 64, # 出力文の最大トークン数 "train_batch_size": 8, "eval_batch_size": 8, "num_train_epochs": 2, }) args = argparse.Namespace(**args_dict) train_params = dict( accumulate_grad_batches=args.gradient_accumulation_steps, gpus=args.n_gpu, max_epochs=args.num_train_epochs, precision= 16 if args.fp_16 else 32, amp_backend='apex', amp_level=args.opt_level, gradient_clip_val=args.max_grad_norm, default_root_dir=f"{MODEL_SAVE_DIR}/checkpoint", ) 以下を実行して、転移学習を行います。 # 転移学習の実行 model = T5FineTuner(args) trainer = pl.Trainer(**train_params) trainer.fit(model) # 最終エポックのモデルを保存 model.tokenizer.save_pretrained(MODEL_DIR) model.model.save_pretrained(MODEL_DIR) 推論処理 以下のコードを実行することで推論処理を行います。 article_body = "本文" MAX_SOURCE_LENGTH = 1024 # 入力される記事本文の最大トークン数 MAX_TARGET_LENGTH = 64 # 生成される出力の最大トークン数 import re import pickle from tqdm import tqdm tag_regex = re.compile(r"<[^>]*?>") def normalize_text(text): text = text.replace("\t", " ") text = normalize_neologd(text) text = tag_regex.sub("", text) return text def preprocess_body(text): return normalize_text(text.replace("\n", " ")) # 推論モード設定 trained_model.eval() # 前処理とトークナイズを行う preprocessed_body = preprocess_body(article_body) inputs = ["title: " + preprocessed_body, "long_title: " + preprocessed_body, "topics_title: " + preprocessed_body, "summary_1: " + preprocessed_body, "summary_2: " + preprocessed_body, "summary_3: " + preprocessed_body] batch = tokenizer.batch_encode_plus( inputs, max_length=MAX_SOURCE_LENGTH, truncation=True, padding="longest", return_tensors="pt") input_ids = batch['input_ids'] input_mask = batch['attention_mask'] if USE_GPU: input_ids = input_ids.cuda() input_mask = input_mask.cuda() # 生成処理を行う outputs = trained_model.generate( input_ids=input_ids, attention_mask=input_mask, max_length=MAX_TARGET_LENGTH, temperature=1.0, # 生成にランダム性を入れる温度パラメータ num_beams=10, # ビームサーチの探索幅 diversity_penalty=1.0, # 生成結果の多様性を生み出すためのペナルティ num_beam_groups=10, # ビームサーチのグループ数 num_return_sequences=1, # 生成する文の数 repetition_penalty=1.5, # 同じ文の繰り返し(モード崩壊)へのペナルティ ) # 生成されたトークン列を文字列に変換する generated = [tokenizer.decode(ids, skip_special_tokens=True, clean_up_tokenization_spaces=False) for ids in outputs] # 生成された文字列を表示する for i, generated_str in enumerate(generated): if i == 0: print(f"title: {generated_str}") elif i == 1: print(f"long_title: {generated_str}") elif i == 2: print(f"topics_title: {generated_str}") else: print(f"summary {i-2}: {generated_str}") # 前処理とトークナイズを行う の部分でプロンプトを与えつつ、トークナイズを行います。 # 生成処理を行う の生成処理ではハイパーパラメータを変更することで、処理時間や精度のトレードオフ関係を調整します。 実際の推論 実際の推論は以下のようになります。(NIFTY Tech Day 2022より) 全てにおいて成功するわけではなく、以下のように失敗する例もあります。 おわりに 今回はNIFTY Tech Day 2022では話せなかった細かい転移学習の手法などについて書いてきました。 深層学習周りの自然言語の歴史や、クラウド上のアーキテクチャに関してもNIFTY Tech Day 2022でお話したので、 興味がある方はぜひ御覧ください。 最近だとChatGPTのような流暢な対話型のAI技術も登場し、いよいよ自然言語処理は人間に近い存在になりつつありますね。 実際に深層自然言語処理による要約生成などに挑戦してどのような苦労があったのか?についてもまたどこかの機会に発表したいと思います。 We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering この記事は、 ニフティグループ Advent Calendar 2022 11日目の記事です。 明日は ニフティライフスタイル のsaikeiさんです。次回もお楽しみに!