😁

GPT-2をファインチューニングしてイナババ怪文書を自動生成するAIを作ってみた

2023/04/28に公開

おいたんのおてがみ、うけとってほしいのら・・・😁

いかにこのAIのしゅつりょくれいをのこす🐱♥️😁
<入力>
釣り大会おっピ!
<出力>
釣り大会おっピ!俺69Cmで優勝したんだけど、賞品でクルクマ貰っちゃったこんどウチに遊びに来る?おいたん,ニコバンバツイストしてあげるからね♪ アザラクさんへ

モデル公開しました(2023/5/5)

以下のサイトでモデル公開中です。
君だけのオリジナル怪文書を生成しよう!
https://huggingface.co/spaces/Oishiyo/zupposhi-maker

Highlights

  • イナババ怪文書をrinna/japanese-gpt2-mediumで学習
  • ズッポシむらの住民名や固有の表現を含んだ怪文書の自動生成に成功

初めに

イナババ怪文書シリーズとは?

イナババ怪文書シリーズとは、ゲーム実況者の稲葉百万鉄氏が「どうぶつの森e+」のプレイ中に作成した文章の総称である。あまりにも珍妙で難解な表現が多数含まれているため、ファンの間では「イナババ(=稲葉百万鉄氏のどうぶつの森e+内でのプレイヤー名)怪文書」と呼ばれている。
詳細は稲葉百万鉄氏の実況動画(https://www.nicovideo.jp/mylist/45062007)を参考までに。
すべては そこにかいてあります。

GPT-2とは?

最近流行りのchatGPTの何世代か前のモデル。ある文章を入力するとその続きの文章を自動生成してくれる。
今回はrinna社が提供しているrinna/japanese-gpt2-mediumという日本語特化のモデルを使用させていただいた。パラメータ数は336Mで、もっと高性能なモデルは(rinnaシリーズ含めても)いくらでもでもある。しかしgoogle colabの無料GPUでもファインチューニングできるため、なにかを手軽に試すには最適だと思われる。

手法

教師データ作成

稲葉百万鉄氏がどうぶつの森e+をプレイした際に作成した文書(住民に送った手紙および村掲示板への書き込み)をExcelで一つ一つ打ち出し、CSVで保存する。
今回はズッポシ村歴[1]で4月~8月の間にイナババが作成したおおよそ270個[2]の文章を対象とした。
OCRを使って動画画面上から文章を抽出できないか試行錯誤したが、無料で使えるOCRライブラリ(pytesseractなど)だと認識精度がイマイチなうえテキストのクリーニングの手間がいるため、断念した。

教師データ作成時には文中のひらがなを漢字に置き換えた。
「どうぶつの森e+」では、文書入力に基本的に漢字が使用できず、イナババ怪文書もこれに準じている。しかし、分かち書きを行う形態素解析器は漢字を含む文章を想定している。今回は文章が正しく分かち書きできることを優先して、教師データでは文章中のひらがなを漢字に置き換えた。

<例>
ほんのりあたたかくて、まるでニコバンバンのたいおんをかんじるようだのら・・・
→ ほんのり温かくて、まるでニコバンバンの体温を感じるようだのら・・・

なお、イナババ怪文書の特徴の一つが、顔文字(😮、🐰、🐱 etc...)が頻出する点であり、これもデータセットに含められるよう、csvの保存形式をBOM付きcsvにしたりなど試行錯誤した。
しかし、rinna/japanese-gpt2-mediumではなぜか絵文字が<unk>に置き換えられてしまう。
tokenizerの方は絵文字に対応しているし、ファインチューニング前のモデルも同様の問題を起こしているので、おそらくモデル自体の問題と思う。
これは別のモデルを使う以外に根本的な解決[3]はないと思われる。

GPT-2のファインチューニング

rinna/japanese-gpt2-mediumをダウンロードし、これをファインチューニングするクラスを以下のように定義した。同クラスにはファインチューニング済みのモデルを使った推論を行う機能も作成した。

ファインチューニング用コード
import csv, json, mojimoji, re, os, sys, emoji, pickle
import numpy as np
from tqdm import tqdm
from pykakasi import kakasi
#torchの読み込み前に環境変数を固定
os.environ['CUDA_LAUNCH_BLOCKING'] = "1"
import torch
import evaluate
from transformers import AutoModelForCausalLM, AutoTokenizer, T5Tokenizer
from transformers import TextDataset, DataCollatorForLanguageModeling
from transformers import Trainer, TrainingArguments, AutoModelWithLMHead
class Zupposhi_maker:

  #GPT2のモデル名
  gpt_model_name = "rinna/japanese-gpt2-medium"

  #データセットのcsvファイル
  csv_path = "path/to/csvfile"
  csv_enc = "utf-8-sig"

  #教師データをGPT用のファイルとして出力する際のパス
  gpt2train_path = "path/to/gptfile"

  #文章の最大長
  min_len = 32
  max_len = 100

  #学習回数,バッチサイズ
  Nepo = 100
  bsize = 4
  
  #途中経過を表示する学習回数の間隔
  logging_steps = 200

  #モデルを保存する間隔
  save_freq = 100000

  #結果の出力先
  odir = "path/to/outputdir"

  #予測時のパラメータ
  top_k = 40 #top-k検索の閾値
  top_p = 1 #top-pの閾値
  num_text = 1 #出力する文の数
  temp = 1.0
  repeat_ngram_size = 1

  #推論にCPUを使用するか
  use_cpu = True

  def __init__(self, ft_path = None, isTrain = True):
    """コンストラクタ

      コンストラクタ。モデルをファイルから読み込む場合と,
      新規作成する場合で動作を分ける.

      Args:
          ft_path : ファインチューニングされたモデルのパス.
                    Noneを指定すると
          train : 学習を行うか
      Returns:
          なし
    """
    print("GPU is available : {}".format(torch.cuda.is_available()))

    #モデルの設定
    self.__SetModel(ft_path)

    #教師データの読み込み
    if isTrain:
      self.__LoadDataSet()


  def __SetModel(self, ft_path = None):
    """GPT2の設定

      GPT2のTokenizerおよびモデルを設定する.
      ユーザー定義後と顔文字も語彙として認識されるように設定する.
      
      Args:
          ft_path : ファインチューニング済みのモデルを読み込む
                    何も指定しないとself.gpt_model_nameの事前学習モデルを
                    ネットからダウンロードする.
      Returns:
          なし
    """
    #GPT2のTokenizerのインスタンスを生成
    self.tokenizer = AutoTokenizer.from_pretrained(
              self.gpt_model_name, use_fast = False
    )
    self.tokenizer.do_lower_case = True # due to some bug of tokenizer config loading



  def __LoadDataSet(self):
      """データセットのロード
      
          怪文書データセットの読み込み

          Args:
              csv_name (string) : csvファイルのファイル名
              Rtest (float) : テスト用のデータの割合
          Returns:
              なし
      """
          
      #csvファイルを読み込む
      data = []
      with open(self.csv_path, "r", encoding = self.csv_enc) as f:
          reader = csv.reader(f, delimiter = ",")
          for row in reader:
            #空行またはコメント行なら読み飛ばす
            if(row[0] == "\n"):
              continue
            if(row[0][0] == "#"):
              continue
            #怪文書なら,読み取り結果をリストに保存
            if(int(row[0]) == 1):
              data.append([row[1], row[2], row[3]])
      
      
      #教師データの成形と,絵文字の抽出
      with open(self.gpt2train_path, "w", encoding = self.csv_enc) as f:
        for row in tqdm(data):
          ret = self.__TextCleaning(row)
          From, Body, To = ret[0], ret[1], ret[2]
          
          #手紙の宛名から本文を予測するタスクを行う.
          text = "<s>" + Body + "</s>"

          #文章の長さを最大max_lenトークンに制限する
          tokens = self.tokenizer.tokenize(text)[:self.max_len]

          text = "".join(tokens).replace('▁', '')
          f.write(text + "\n")
  


  def __TextCleaning(self, texts):
      """テキストの前処理をする

        テキストの前処理を行う.具体的に行うこととしては...
        ・全角/半角スペースの除去
        ・半角数字/アルファベットの全角化
      """
      #半角スペース,タブ,改行改ページを削除
      texts = [re.sub("[\u3000 \t \s \n]", "", t) for t in texts]

      #半角/全角を変換
      texts = [mojimoji.han_to_zen(t) for t in texts]
      return texts
  


  def TrainGPT2(self):
    """GPT2のファインチューニング

      GPT2の設定とファインチューニングをする
    """
    #データセットの設定
    train_dataset = TextDataset(
                      tokenizer = self.tokenizer,
                      file_path = self.gpt2train_path,
                      block_size = self.max_len #文章の長さを揃える必要がある
    )

    #データ入力についての設定
    data_collator = DataCollatorForLanguageModeling(
                      tokenizer=self.tokenizer,
                      mlm= False
    )

    #学習についての設定
    os.makedirs(self.odir + "gpt2-ft",  exist_ok=True) #結果出力先のディレクトリがあれば作成
    training_args = TrainingArguments(
                      output_dir=self.odir + "gpt2-ft", 
                      overwrite_output_dir=True,
                      num_train_epochs=self.Nepo,
                      per_device_train_batch_size=self.bsize, 
                      logging_steps=self.logging_steps,
                      save_steps=self.save_freq
    )

    #上記の設定をtransformerのTrainerクラスに適用
    trainer = Trainer(
                      model =self.model,
                      args=training_args,
                      data_collator = data_collator,
                      train_dataset = train_dataset
    )

    #学習開始
    print("start ... ")
    trainer.train()
    print("finish!")
    
    print("saving...")
    #モデルをCPU/GPUのどちらかに移す
    if self.use_cpu: #推論時にCPUの利用を強制する場合の処理
      device = torch.device('cpu')
    else: #特に指定が無いなら,GPUがあるときはGPUを使い,CPUのみの場合はCPUを使う
      device = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu')
    self.model.to(device)

    #モデルを保存する
    trainer.save_model()
    print("finish!")
  

    
  def GenLetter(self, prompt):
    """怪文書の生成

      GPT2で怪文書を生成する.
      promptに続く文章を生成して出力する

      Args:
          prompt : 文章の先頭
      Retunrs:
          生成された文章のリスト
    """
    #文章をtokenizerでエンコード
    x = self.tokenizer.encode(prompt, return_tensors="pt", add_special_tokens=True)

    if self.use_cpu: #CPUの利用を強制する場合の処理
      device = torch.device('cpu')
    else: #特に指定が無いなら,GPUがあるときはGPUを使い,CPUのみの場合はCPUを使う
      device = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu')
    x = x.to(device)

    #gptによる推論
    with torch.no_grad():
      y = self.model.generate(
                        x, #入力
                        min_length=self.min_len,  # 文章の最小長
                        max_length=self.max_len,  # 文章の最大長
                        do_sample=True,   # 次の単語を確率で選ぶ
                        top_k=self.top_k, # Top-Kサンプリング
                        top_p=self.top_p,  # Top-pサンプリング
                        temperature=self.temp,  # 確率分布の調整
                        no_repeat_ngram_size = self.repeat_ngram_size, #同じ単語を何回繰り返していいか
                        num_return_sequences=self.num_text,  # 生成する文章の数
                        pad_token_id=self.tokenizer.pad_token_id,  # パディングのトークンID
                        bos_token_id=self.tokenizer.bos_token_id,  # テキスト先頭のトークンID
                        eos_token_id=self.tokenizer.eos_token_id,  # テキスト終端のトークンID
                        early_stopping=True
                      )
    
    # 特殊トークンをスキップして推論結果を文章にデコード
    res = self.tokenizer.batch_decode(y, skip_special_tokens=False)
    return res
上記classの使い方
    AI = Zupposhi_maker()
    AI.TrainGPT2()
    ret  = AI.GenLetter(prompt = "<s>おてがみの本文")

google colabで学習させたところ、10 min強で終了。
10分も怪文書読まされ続けてrinnaちゃんかわいそう😔

結果

GPT-2による文書生成

学習後のモデルでいくつか文を生成させてみた。入力文(prompt)を与えると、その続きをイナババ風に書いてくれる。
今回はtop_k = 40, top_p = 0.95, min_len = 25, max_len = 75, temperature = 0.1で生成。
パラメータを変えると文章の感じもかなり変わる。今回はできるだけきれいな文をつくりたかったので、temperatureもtop_kも小さめ。
めちゃめちゃな文も含まれていなくはないが、文章の破綻は比較的少なくかつズッポシむら住民の名前が入った文章が生成された。

以下に生成例を示す。

生成例1

<入力>
ズッポシ村へようこそ!
<出力>
ズッポシ村 へようこそ!イナババンです。なんかセーラー服着たやつらばっかのヤバイ村だと思っているかもしれないけど、実際はキミみたいな庶民的な住民がたくさんいるんだよね~というようなことを言いたいわけだよ... アザラクさん

ズッポシ女子高校お嬢様部

生成例2

<入力>
村長のイナババ です。
<出力>
村長の イナババ です。イナババンさんが、置き手紙を送ってくれました・:*+.★モモチとニコモだけの秘密のお部屋だよ?おいたんの手紙と一緒に送るからね♪ アザラクちゃんへ

きもいのら・・・
やたらとアザラクさんが巻き込まれてるの草

生成例3

<入力>
なんで誘ってくれなかったの?
<出力>
なんで誘ってくれなかったの?何があったかはしらないけれど、ニコバンも仲直りしたいといっています。怒りが収まらないため、「キミを許せない」と書いた手紙を送ります(つづく)

「『キミを許せない』と書いた手紙」は実際のイナババも送りそう。

生成例4

<入力>
ニコバンバン かわいいのら・・・
<出力>
ニコバンバン かわいいのら・・・ イナババンさんへ。平素より大変お世話になっておりますですよ!タヌキや広報部長として、頑張っていますが...もう10日経っちゃったねぇ~!!あっという間だったなぁ★今日からまた一緒に暮らせればと思っておりまするっ!!!モモチと

max_lenで文章長が制限されて文末が切られただけだと思うのだが、「モモチと」の部分が意味深な倒置に思えてならない

生成例5

<入力>
サラ 様ぁ
<出力>
サラ 様ぁ・...まさか、爽やかな手紙より、「キモい」の手紙の方が好きだなんて。とんださんなんですね!仕方がありませんよ!!これからもヨロシクですぅ!!!ドイツ国防軍最高司令部イナババッピ!?ガリっちょ

ドイツ国防軍最高司令部 イナババッピ=ガリッチョ

結論

数百個の潤沢な教師データがあったため、かなり正確なファインチューニングができたと思われる。
巨大なモデルで大量の教師データを使って長時間学習させればより精緻なAIができたかもしれないが、一旦成果が出たということで一区切りする。
zenn初心者 & NLP初心者の記事のため大変読みづらかったと思うが、この記事が誰かの役に立つことを願う。😁

Reference

稲葉百万鉄氏「どうぶつの森e+実況プレイ」
https://www.nicovideo.jp/mylist/45062007
→ 稲葉百万鉄氏の実況プレイ動画へのリンク。

mintmama氏「ズッポシ村手紙集」
https://www.nicovideo.jp/user/1356731/mylist/62497030
→ 怪文書の映っているシーンを有志で切り抜いてくださっている方。教師データの作成時に参考にさせていただいた。

osn_Lofi氏「【自然言語処理】日本語GPT-2モデルをファインチューニングして文章生成をやってみる」
https://zenn.dev/robes/articles/24ee45dd81636a
→ Rinna社のGPT-2をファインチューニングしている記事。こちらではより小さなモデルのrinna/japanese-gpt2-smallを使用している。

脚注
  1. どうぶつの森ではゲーム機内臓時計に合わせてゲーム内の時計も進んでいく。ここではどうぶつの森のゲーム内で進んだ時間のことを「ズッポシ歴」と呼称する。稲葉百万鉄氏はゲーム内時計を進めたり戻したりしてプレイしているため、ズッポシむらの時間と現実時間には大きな乖離がある。 ↩︎

  2. 8月まででやめているのはファインチューニングを行った時点でPart280以降を見ずに温存していたため。 ↩︎

  3. 一応、tokenizerとmodelに無理やり語彙を増やすことは以下のようなコードで可能である。
    しかし、モデルの埋め込み層の対応単語を無理やり増やしている(と思われる)ので、単語の追加がモデルの状態にどんな影響を及ぼすのかは不明

    新語の追加
    #depends-on-the-definition.com/how-to-add-new-tokens-to-huggingface-transformers/を参考にした
    #リストに追加した単語をTokenizerに登録
    new_words = ["新しい単語1", "新しい単語2", "新しい単語3", ...]
    self.tokenizer.add_tokens(new_words, special_tokens = True)
    
    #モデルの読み込み
    self.model = AutoModelForCausalLM.from_pretrained(ft_path)
    #モデルのembedding層の長さを変更し,追加した語彙の分の要素数を増やす
    self.model.resize_token_embeddings(len(self.tokenizer))
    
    
    ↩︎

Discussion