13億パラメータの日本語特化GPT言語モデルを使ってSlackAIチャットボットを作ってみた1～文章生成機能編～

# 1.はじめに
社内勉強会で発表することになり、題材として[rinna/japanese-gpt-1b](https://huggingface.co/rinna/japanese-gpt-1b)を使用したSlackのAIチャットボットを作成したので、備忘録として残しておきます。

「文章生成機能編」と[「SlackBot実装編」](https://qiita.com/milky04/items/f1a3344844e85fe9bc73)で記事を2つに分けており、当記事では文章生成機能について記載します。

:::note warn
初心者なため、間違った部分や理解の足りていない部分もあるかと思います。
そういった部分がありましたらコメントまたは[Twitter](https://twitter.com/as9784048914215)で指摘して頂けると幸いです。
:::

### [rinna/japanese-gpt-1b](https://huggingface.co/rinna/japanese-gpt-1b)とは
13億パラメータの日本語特化GPT言語モデルです。[rinna株式会社](https://rinna.co.jp/)がオープンソースで公開しています。
今回はこちらを使用して、文章を生成する機能をbotに実装しています。
サンプルコードとほぼ同じものを使用していますが、相違点もあるためそこも含めて解説していきます。

公式ニュースは[こちら](https://rinna.co.jp/%E3%83%8B%E3%83%A5%E3%83%BC%E3%82%B9/f/%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%81%AB%E7%89%B9%E5%8C%96%E3%81%97%E3%81%9F13%E5%84%84%E3%83%91%E3%83%A9%E3%83%A1%E3%83%BC%E3%82%BF%E3%81%AEgpt%E8%A8%80%E8%AA%9E%E3%83%A2%E3%83%87%E3%83%AB%E3%82%92%E5%85%AC%E9%96%8B)です。

# 2.ライブラリインストール
```
pip install transformers sentencepiece torch
```
必要なライブラリをインストールします。

### 2-1.transformers 
モデルを呼び出すために必要な`transformers`をインストールします。

### 2-2.sentencepiece
このモデルでは`sentencepiece`という`tokenizer`が使用されているので、そちらもインストールします。
`tokenizer`は文字列をトークンにエンコード/デコードするために使用します。

### 2-3.torch
テンソルの計算やCPU/GPUの切り替えで`PyTorch`が必要になるため、`torch`もインストールします。

# 3.コード解説
### 3-1.import
```Python:RinnaJapaneseGPT1b.py
from transformers import T5Tokenizer, AutoModelForCausalLM
import re
import torch
```
必要なライブラリを`import`します。
[2](https://qiita.com/drafts/24d7d2abf90e90417d93/edit#2%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)でインストールしたライブラリに加え、正規表現を使用するため`re`も`import`します。

### 3-2.tokenizerとmodelの生成
```Python:RinnaJapaneseGPT1b.py
model_name = 'rinna/japanese-gpt-1b'
tokenizer = T5Tokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name)
```
モデルを指定し、`tokenizer`と`model`を生成します。

### 3-3.GPUが使用可能か判定
```Python:RinnaJapaneseGPT1b.py
if torch.cuda.is_available():
    model = model.to("cuda")
```
GPUが使用可能な環境かを判定します。

`torch.cuda.is_available()`でGPUが使用可能かを確認します。デフォルトはCPUです。
GPUが使用可能であれば、`model.to("cuda")`でGPU上にモデルのコピーを作成し、作成したコピーを`model`に上書きする形で格納します。
こうすることで、モデルをCPUからGPUに切り替えることができます[^cuda]。

なぜGPUを使うかについてですが、GPUは演算能力・並列処理能力に優れており、複雑な計算を行う際はCPUよりも適しているためです。
GPUを使うことで文章生成時間を短縮することができます[^GPU]。

### 3-4.文章生成
受け取ったメッセージに対して文章を生成します。

#### 3-4-1.受け取ったメッセージをエンコード
```Python:RinnaJapaneseGPT1b.py
def generate_text(input_message):
    input_ids = tokenizer.encode(
        "私: " + input_message + "\nAI: ",
        add_special_tokens=False,
        return_tensors="pt"
    )
```
`input_message`に受け取ったメッセージを渡します。

`"私: " + input_message + "\nAI: "`といった対話形式でチャットボットに返答させる文章を入力文として`tokenizer`に与え、エンコードし、`input_ids`に格納します。

なぜ対話形式で与えているのかというと、ユーザーとAIを区別し、対話文と認識できるよう与えることで、返答にふさわしい文章を生成されやすくするためです。

スペシャルトークンを自分で追加するため、`add_special_tokens`を`False`にしてスペシャルトークンの自動追加を無効にします。

`Transformer`モデルの入力でテンソル形式に変換する必要があるため、`return_tensors`を`"pt"`に指定して`PyTorch`のテンソル型で返します[^テンソル]。

#### 3-4-2.generate関数で文章生成
```Python:RinnaJapaneseGPT1b.py
    with torch.no_grad():
        output_sequences = model.generate(
            input_ids.to(model.device),
            max_length=50,
            min_length=10,
            temperature=0.9,
            top_k=50,
            top_p=0.95,
            repetition_penalty=1.0,
            do_sample=True,
            num_return_sequences=1,
            pad_token_id=tokenizer.pad_token_id,
            bos_token_id=tokenizer.bos_token_id,
            eos_token_id=tokenizer.eos_token_id,
            bad_word_ids=[[tokenizer.unk_token_id]]
        )
```
`generate`関数を使い、文章を生成します。

- `torch.no_grad()`
`generate`関数で文章を生成する前に設定します。これを設定することで、テンソル計算時の勾配情報を持たず、メモリ消費を抑えることができます。
ざっくり説明すると、より良い結果を求めていく際の途中経過の計算の値を保持しないことで、メモリ消費を抑えるということです。
詳しくは勾配降下法や誤差逆伝播法で調べてみてください。

- パラメータの設定
`generate`関数で文章を生成するにあたり、パラメータの設定をしていきます。
設定されたパラメータを元にテンソルの計算が行われます。

  - `input_ids.to(model.device)`
`PyTorch`ではモデル学習時にモデルとデータを同じデバイス(GPU/CPU)に置く必要があるため、モデルと同じデバイス(GPU/CPU)上に`input_ids`のコピーを作成します。

  - `max_length`/`min_length`
生成する文章の文字数上限/下限を設定します。
今回はチャットボットなので上限を`50`、下限を`10`と短めに設定しています。

  - `temperature`
値が`1`に近づくほどクリエイティブな文章、`0`に近づくと論理的で正確な返答になる…らしいです。
今回はクリエイティブな文章になるよう`0.9`に設定しています。

  - `top_k`
与えられた文章の続きとして適している確率の高い上位`k`個の候補の単語からランダムに選択します。
今回はある程度絞りつつバラエティを豊かにするため`50`に設定しています。

  - `top_p`
与えられた文章の続きとして適している確率の高い上位候補の単語の確率の合計が`p
`を超えるような最小個数の候補を動的に選択します(`p`の設定できる数字の範囲は`1>p>=0`)。
今回は出来るだけ精度を高めるために`0.95`に設定しています。
どういうことかというと、例えば文章の続きとして以下の単語が候補としてあった場合、今回の`0.95`だと車、電車、バスが選択されます。

  <table>
  <thead>
  <tr>
  <td>候補</td>
  <td>確率</td>
  </tr>
  </thead>
  <tbody>
  <tr>
  <td>車</td>
  <td>50%</td>
  </tr>
  <tr>
  <td>電車</td>
  <td>30%</td>
  </tr>
  <tr>
  <td>バス</td>
  <td>20%</td>
  </tr>
  <tr>
  <td>自転車</td>
  <td>10%</td>
  </tr>
  <tr>
  <td>飛行機</td>
  <td>5%</td>
  </tr>
  </tbody>
  </table>

  - `repetition_penalty`
`1`に近づけると同じ文章の繰り返しを減少する効果があります。
今回は繰り返しを出来るだけ減少するために`1.0`に設定しています。

  - `do_sample=True`
`True`にすることでサンプリングを有効化します。

  - `num_return_sequences`
一度の実行で生成する文章の数を指定します。
今回は1つだけで良いので`1`に設定しています。

  - モデルの入力に必要な情報となるスペシャルトークンを指定します。
    - `pad_token_id=tokenizer.pad_token_id`
使用されていない部分を埋めるためのトークンです[^PAD]。

    - `bos_token_id=tokenizer.bos_token_id`
シーケンス(文章)の始まりを表すトークンです[^BOS]。

    - `eos_token_id=tokenizer.eos_token_id`
シーケンス(文章)の終わりを表すトークンです[^EOS]。

    - `bad_word_ids`
生成が許可されていないトークンIDリストを設定します。
禁止用語等の設定で使用します。

    - `tokenizer.unk_token_id`
未知の語彙を表すトークンです[^UNK]。

#### 3-4-3.生成された文章をデコード
```Python:RinnaJapaneseGPT1b.py
    output_sequence = output_sequences.tolist()[0]
    text = tokenizer.decode(
        output_sequence,
        clean_up_tokenization_spaces=True
        )
    input_ids_length = len(tokenizer.decode(
        input_ids[0],
        clean_up_tokenization_spaces=True
        ))
    total_text = (text[input_ids_length:])
```
生成された文章を`tokenizer`でデコードします。

`output_sequences.tolist()[0]`に格納されているトークン状態の生成された文章を`output_sequence`に格納します[^生成文章]。
`output_sequence`を`tokenizer`でデコードし、`text`に格納します。
エンコードする際にスペースが区切り文字として生成されているため、`clean_up_tokenization_spaces`を`True`にして生成されたスペースをデコードする際に削除します。

チャットボットからの返答だけ得られれば良いので、`text`から最初に与えた入力文を除去します。

最初に与えた入力文である`input_ids[0]`を`tokenizer`でデコードし、その長さを取得して`input_ids_length`に格納します[^入力文]。
生成されたスペースはデコードする際に削除します。
`text`から`input_ids_length`を除去したものを`total_text`に格納します。

#### 3-4-4.デコードされた文章を返す
```Python:RinnaJapaneseGPT1b.py
    pattern = r"AI:|私:|俺:|僕:|あなた:|<unk>|
|[UNK]"
    if re.search(pattern, total_text) != None:
        match_position = [match.span() for match in re.finditer(pattern, total_text)]
        first_match_position = match_position[0][0]
        edited_text = total_text[:first_match_position]
        return edited_text
    else:
        return total_text
```

生成文に`pattern`にある文字列が含まれていた場合、それ以降の文字列を除去します。
含まれていなかった場合はそのまま出力します。

`pattern`に除去したい文字列を指定します。
今回は文章を対話形式で与えており、チャットボットからの返答を1つ得られた後も対話文が続く可能性があります。
そのため、チャットボットからの返答の後に対話文が続いた場合、それ以降の文字列を除去するために`AI:|私:|俺:|僕:|あなた:`を指定しています[^一人称]。
`<unk>|
`は試していて出力されることがあり、`[UNK]`も出力される可能性があると考え、指定しています[^除去]。

正規表現を使い、`if re.search(pattern, total_text) != None:`で生成文に`pattern`にある文字列が含まれているかどうかを判定します。

- 生成文に`pattern`にある文字列が含まれていた場合：
`[match.span() for match in re.finditer(pattern, total_text)]`で文章中の`pattern`にある文字列の位置を全て抽出し、抽出したものを`match_position`に格納します。
`match_position[0][0]`で一番最初に出現した`pattern`にある文字列の位置を抽出し、`first_match_position`に格納します。
`total_text[:first_match_position]`で一番最初に出現した`pattern`にある文字列以降の文字列を除去し、`edited_text`に格納して`return edited_text`で返します。

- 生成文に`pattern`にある文字列が含まれていなかった場合：
`return total_text`でそのまま返します。

### 3-5.コード全文
```Python:RinnaJapaneseGPT1b.py
from transformers import T5Tokenizer, AutoModelForCausalLM
import re
import torch

model_name = 'rinna/japanese-gpt-1b'
tokenizer = T5Tokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name)

if torch.cuda.is_available():
    model = model.to("cuda")
    
def generate_text(input_message):
    input_ids = tokenizer.encode(
        "私: " + input_message + "\nAI: ",
        add_special_tokens=False,
        return_tensors="pt"
    )
    
    with torch.no_grad():
        output_sequences = model.generate(
            input_ids.to(model.device),
            max_length=50,
            min_length=10,
            temperature=0.9,
            top_k=50,
            top_p=0.95,
            repetition_penalty=1.0,
            do_sample=True,
            num_return_sequences=1,
            pad_token_id=tokenizer.pad_token_id,
            bos_token_id=tokenizer.bos_token_id,
            eos_token_id=tokenizer.eos_token_id,
            bad_word_ids=[[tokenizer.unk_token_id]]
        )
        
    output_sequence = output_sequences.tolist()[0]
    text = tokenizer.decode(
        output_sequence,
        clean_up_tokenization_spaces=True
        )
    input_ids_length = len(tokenizer.decode(
        input_ids[0],
        clean_up_tokenization_spaces=True
        ))
    total_text = (text[input_ids_length:])
    
    pattern = r"AI:|私:|俺:|僕:|あなた:|<unk>|
|[UNK]"
    if re.search(pattern, total_text) != None:
        match_position = [match.span() for match in re.finditer(pattern, total_text)]
        first_match_position = match_position[0][0]
        edited_text = total_text[:first_match_position]
        return edited_text
    else:
        return total_text
```
上記コピペでそのまま使用できます。

# 4.テスト実行
```Python:RinnaJapaneseGPT1b.py
print(generate_text("自己紹介をお願いします。"))
```
上記のコードを追加してテスト実行してみます。
`"自己紹介をお願いします。"`の部分は任意のテキストでokです。

```
私はAIの開発者です。 現在は、画像・音声・言語を扱うデータマイニング、機械学習、そして、機械学習を用いた予測モデルを作成している研究テーマに取り組んでいます。 データマイニングは、人間の
```
上記の文章が生成されました。無事実行出来ていますね。
少し怪しい部分もありますが、文章も比較的自然です(AI開発者のAIって面白いですね…)。

# 5.おわりに
今回でメインとなる文章生成機能が完成しました(サンプルコードほぼそのままですが…)。
これだけでも十分遊ぶことはできますが、より楽しめるようSlackBotとして実装します。
[次回](https://qiita.com/milky04/items/f1a3344844e85fe9bc73)に続きます。

# 6.参考記事
- [Microsoftから独立した企業が公開した日本語版GPT-2でチャットボットを作るレシピ](https://axross-recipe.com/recipes/287)
- [Huggingface Transformers 入門 (1) - 事始め](https://note.com/npaka/n/n5bb043191cc9)
- [テキストの前処理 - Keras](https://keras.io/ja/preprocessing/text/)
- [Tokenizer](https://huggingface.co/docs/transformers/main_classes/tokenizer)
- [「torch.nnを用いたディープラーニングモデルの実装方法」](https://colab.research.google.com/github/YutaroOgawa/pytorch_tutorials_jp/blob/main/notebook/1_Learning%20PyTorch/1_6_nn_tutorial_jp.ipynb)
- [勾配法の仕組みを具体例でわかりやすく解説](https://ai-trend.jp/basic-study/neural-network/optimizer/)
- [第3回 なぜニューラルネットワークで学習できるのか?](https://euske.github.io/introdl/lec3/index.html)


[^cuda]:`cuda`は、NVIDIA社が自社製GPU向けに開発・提供しているGPUを利用して汎用の並列計算を行うためのソフトウェア開発・実行環境のことです。
[^GPU]:ディープラーニングで行う行列演算と三次元グラフィックス処理時の行列演算が同じで、GPUとディープラーニングの相性が良かったという背景もあります。ちなみに、`PyTorch`でサポートされているGPUはNVIDIA製のもののみとなります(`TensorFlow`も同様です)。
[^テンソル]:機械学習におけるテンソルは多次元配列とほぼ同義です(=行列)。
[^PAD]:PAD(Padding)
[^BOS]:BOS(Begin Of Sentence)
[^EOS]:EOS(End Of Sentence)
[^UNK]:UNK(Unknown)
[^生成文章]:トークン状態の生成された文章が`output_sequences.tolist()`に二次元リストで格納されています。今回は生成する文章が1つだけなので、0番目を指定しています。
[^入力文]:最初に与えた入力文が`input_ids`に二次元リストで格納されています。入力文は1つだけなので、0番目を指定しています。
[^一人称]:頻出なものや考えられうるものを設定しました。これら以外の一人称等が生成文に含まれていた場合はそのまま出力されてしまいます。
[^除去]:`[UNK]`は `bad_word_ids`に設定しているため、ここで指定する必要はないかもしれません。