Transformerで雑談チャットボットを作ったら躓いた話


はじめに

これは自然言語処理の機械学習を初めてやってみたら躓いた話。製作までの過程を適当に書き記しておきます。
記事投稿時点では上手くいっていないので反面教師ぐらいにしかならないでしょう。
うまくできる方法を知りたい方はこちらをご覧ください。

著者のスペック

  • 大学(正しくは高専専攻科)在学に画像処理と機械学習(多層パーセプトロン)を使った研究をしていた
  • 機械学習の知識はほぼ独学に近い(授業での経験は皆無、ほぼ研究活動時の自学自習のみ)
  • 機械学習をお仕事にしたくて求職中
  • 自然言語処理の経験は皆無、知識もあまりない

そんなペーペーエンジニア見習いの記事になります。あまり参考になりそうにないなと思ったらブラウザバックをおすすめします。

自然言語処理界隈の機械学習を調べる

元々機械学習そのものには触れていた私。しかしながら、自然言語処理の経験は全くないのでとりあえず情報や知見を収集することにした。

最初に飛び込んできたのはgoogleのBERTがすごいということ。なのでその構造や学習の仕組みなども調べてみたが全くの「???????」状態であった。

とにかくBERTがすごいらしい。ならそれを使ってなにか作ってみようと思うに至り、チャットボットを作ろうと思った。

後発のXLNetやALBERTを使ってみようかとも検討した。ただ自分向けに改造がしやすそうなものはBERTを含め無かった。

特にGoogleが非公式に提供しているGithubのBERTリポジトリ、文章分類などは簡単にできそうだったがそれ以外の想定していなそうなタスクをするにはハードルが高そうだった。そのため別の方策を探した。

第1章 2節 右も左もわからないがとりあえずチャットボットを作ってみよう

色々調べてみてtransformerで日英翻訳をやっている方transformerでチャットボットを作っている方に行き着いた。

じゃあtransformerでチャットボットを作ってみればいいのでは?と考えついたので、それを実践してみることにした。

今回使用した材料はこちら

下準備

それでは作り方に移ります。
今回はGoogle colab上で実行するので、notebook形式を前提とします。
コードの全文はこちらから

まずtransformerのインストールから

!pip install keras-transformer

続いてトーカナイザーとして使うsentencepieceのインストール

!pip install sentencepiece

ここでGoogleドライブをマウントしておきます(やり方は略)

続いてコーパスをダウンロードして、整形していきます。

!git clone https://github.com/knok/make-meidai-dialogue.git

リポジトリのあるディレクトリに移動します。

cd "/content/drive/My Drive/Colab Notebooks/make-meidai-dialogue"

makefileを実行して

!make all

元のディレクトリに戻ります。

cd "/content/drive/My Drive/Colab Notebooks"

コーパスをダウンロードしてmakefileを実行するとsequence.txtが生成されます。
この中には
input:~~~~
output:~~~~
の形式で会話文が書かれているので、今後使いやすいように整形していきます。

input_corpus = []
output_corpus = []
for_spm_corpus = []
with open('/content/drive/My Drive/Colab Notebooks/make-meidai-dialogue/sequence.txt') as f:
  for s_line in f:

    if s_line.startswith('input: '):
      input_corpus.append(s_line[6:])
      for_spm_corpus.append(s_line[6:])

    elif s_line.startswith('output: '):
      output_corpus.append(s_line[7:])
      for_spm_corpus.append(s_line[7:])

with open('/content/drive/My Drive/Colab Notebooks/input_corpus.txt', 'w') as f:
  f.writelines(input_corpus)

with open('/content/drive/My Drive/Colab Notebooks/output_corpus.txt', 'w') as f:
  f.writelines(output_corpus)

with open('/content/drive/My Drive/Colab Notebooks/spm_corpus.txt', 'w') as f:
  f.writelines(for_spm_corpus)

transformerに入力するためのテキストと出力用テキスト、sentencepieceに学習させるためにinput:とoutput:部分を除した学習用テキストに分割しました。

前準備

続いてsentencepieceで会話文を分かち書きにします。
spm_corpus.txtを使って学習させてみましょう。

import sentencepiece as spm

# train sentence piece
spm.SentencePieceTrainer.Train("--input=spm_corpus.txt --model_prefix=trained_model --vocab_size=8000 --bos_id=1 --eos_id=2 --pad_id=0 --unk_id=5")

詳細はsentencepieceの公式リポジトリにやり方が書かれているので省略します。

それでは一度sentencepieceで文章を分かち書きしてみます。

sp = spm.SentencePieceProcessor()
sp.Load("trained_model.model")

#test
print(sp.EncodeAsPieces("ああそういうことね"))
print(sp.EncodeAsPieces("なるほどわかった"))
print(sp.EncodeAsPieces("つまり、そのあなたの言いたいことはそういうことですか?"))
print(sp.DecodeIds([0,1,2,3,4,5]))

実行結果です。

['▁ああ', 'そういうこと', 'ね']
['▁なるほど', 'わかった']
['▁', 'つまり', '、', 'その', 'あなた', 'の', '言い', 'たい', 'ことは', 'そういうこと', 'ですか', '?']
、。 ⁇ 

以上のように分割されることがわかりました。

ここからの内容はKeras TransformerのREADME.mdに書かれている内容を一部改変したものになります。

それではパディングとtransformerに適した形式にコーパスを整形させていきます。

import numpy as np

# Generate toy data
encoder_inputs_no_padding = []
encoder_inputs, decoder_inputs, decoder_outputs = [], [], []
max_token_size = 168

with open('/content/drive/My Drive/Colab Notebooks/input_corpus.txt') as input_tokens, open('/content/drive/My Drive/Colab Notebooks/output_corpus.txt') as output_tokens:
  #コーパスから一行ずつ読み込む
  input_tokens = input_tokens.readlines()
  output_tokens = output_tokens.readlines()

  for input_token, output_token in zip(input_tokens, output_tokens):
    if input_token or output_token:
      encode_tokens, decode_tokens = sp.EncodeAsPieces(input_token), sp.EncodeAsPieces(output_token)
      #パディングする
      encode_tokens = ['<s>'] + encode_tokens + ['</s>'] + ['<pad>'] * (max_token_size - len(encode_tokens))
      output_tokens = decode_tokens + ['</s>', '<pad>'] + ['<pad>'] * (max_token_size - len(decode_tokens))
      decode_tokens = ['<s>'] + decode_tokens + ['</s>']  + ['<pad>'] * (max_token_size - len(decode_tokens))


      encode_tokens = list(map(lambda x: sp.piece_to_id(x), encode_tokens))
      decode_tokens = list(map(lambda x: sp.piece_to_id(x), decode_tokens))
      output_tokens = list(map(lambda x: [sp.piece_to_id(x)], output_tokens))

      encoder_inputs_no_padding.append(input_token)
      encoder_inputs.append(encode_tokens)
      decoder_inputs.append(decode_tokens)
      decoder_outputs.append(output_tokens)
    else:
      break

#学習モデルへの入力用に変換する
X = [np.asarray(encoder_inputs), np.asarray(decoder_inputs)]
Y = np.asarray(decoder_outputs)

学習させる

それではtransformerに学習させていきます。

from keras_transformer import get_model

# Build the model
model = get_model(
    token_num=sp.GetPieceSize(),
    embed_dim=32,
    encoder_num=2,
    decoder_num=2,
    head_num=4,
    hidden_dim=128,
    dropout_rate=0.05,
    use_same_embed=True,  # Use different embeddings for different languages
)

model.compile(
    optimizer='adam',
    loss='sparse_categorical_crossentropy',
)
model.summary()

# Train the model
model.fit(
    x=X,
    y=Y,
    epochs=10,
    batch_size=32,
)

実行結果です。

Epoch 1/10
33361/33361 [==============================] - 68s 2ms/step - loss: 0.2818
Epoch 2/10
33361/33361 [==============================] - 66s 2ms/step - loss: 0.2410
Epoch 3/10
33361/33361 [==============================] - 66s 2ms/step - loss: 0.2331
Epoch 4/10
33361/33361 [==============================] - 66s 2ms/step - loss: 0.2274
Epoch 5/10
33361/33361 [==============================] - 66s 2ms/step - loss: 0.2230
Epoch 6/10
33361/33361 [==============================] - 66s 2ms/step - loss: 0.2193
Epoch 7/10
33361/33361 [==============================] - 66s 2ms/step - loss: 0.2163
Epoch 8/10
33361/33361 [==============================] - 66s 2ms/step - loss: 0.2137
Epoch 9/10
33361/33361 [==============================] - 66s 2ms/step - loss: 0.2114
Epoch 10/10
33361/33361 [==============================] - 66s 2ms/step - loss: 0.2094

損失は悪くなさそうですね。

推論させる

学習したモデルで推論を行ってみます。

from keras_transformer import decode

input = "今日はいい天気ですね"
encode = sp.EncodeAsIds(input)

decoded = decode(
    model,
    encode,
    start_token=sp.bos_id(),
    end_token=sp.eos_id(),
    pad_token=sp.pad_id(),
    max_len=170
)

decoded = np.array(decoded,dtype=int)
decoded = decoded.tolist()
print(sp.decode(decoded))

実行結果です。

ね、でも、あの、あの、あの、あの、あの、あの、あの、あの、あの、あの

あれ…全然結果が芳しくありません。これじゃコミュ障です。

ここまでの考察

うまくいかなかった原因はなんでしょうか?

  • 一文の長さが長すぎてうまく学習できていない?
  • コーパスが足りない(今回は質問と回答それぞれ3万文ずつ)
  • パラメータの設定がよくない

などが最初にあげられるでしょうか?

モデルの性能改善に向けて

そこでOptunaを使ってチューニングをしようと試みましたが次の理由でうまくいきませんでした。

  • チューニングにかかる時間が長すぎる(最後までやっていませんが1日は余裕でかかりそう)
  • 上記の理由からColabでの実行は難しい(12時間制限に引っかかるため)
  • Colabではできないので自分のPC内の環境で試す(GPUは使えない)がもっと時間がかかるので断念
  • ではクラウド上ではやるかと考えAWSを試すもGPU付きインスタンスの作成が許可されない(次記事の方法を試している間に許可されましたが…)
  • そもそも途中までやってみた結果もあまり芳しくない(ほぼ精度に変化なしか)

ということで別の方法を試すことにしました。
うまくいった方法はこちら