transformersを用いた疑似会話AIについて

# はじめに
　「SiriとAlexaを対話させてみた」の動画を見て、テキストベースで似たようなことができないかな、と思いました。
　色々探してみたところ、同じようなことを行っている方がおられたので、自分なりに改良してみました。

# 事前学習モデル
　今回は、参考にさせていただいたサイト様と同じく、rinnna社のjapanese-gpt-1bを使用します。

## 開発環境
python 3.9.12
torch 1.12.1
transformers 4.21.3
sentencepiece 0.1.97

# コード
## コンストラクタ
トークナイザとモデルの準備、あとはGPUと後で使うリスト(後述)を作成します。

```create_txt.py
class create_text:
    def __init__(self, model_name):
        self.tokenizer = T5Tokenizer.from_pretrained(model_name)
        self.model = AutoModelForCausalLM.from_pretrained(model_name)
        self.loop_list = []
        device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
        self.model = self.model.to(device)

```

## 文章の作成
　文章を作成する関数です。(そのまんまですね 笑)
- 引数1：文章生成を行うトリガーになるテキストです。この関数では、このテキストの続きになるような文章を作成します。
- 引数2：生成する文章の最大文字数です。あまり長いと会話感が薄れるため、今回は40文字にしました。40文字を出力文がオーバーすると出力文は作り直しです。

　今回は、理論的なことをゴリゴリ書くのではなく、どうやったら会話っぽくなるかを中心に記事を書くため、細かいパラメータ等の説明は省かせていただきます。

```create_txt.py
    #create_txtのクラス内関数
    def generate_text(self, text, max_length = 40):
        token_ids = self.tokenizer.encode(text[-100:], add_special_tokens=False, return_tensors="pt")
        if max_length < len(text): max_length = len(text) + 5
        l = 0
        while True:
            with torch.no_grad():
                output_ids = self.model.generate(token_ids.to(self.model.device),
                                                 max_length = max_length,
                                                 min_length = 15,
                                                 do_sample = True,
                                                 pad_token_id = self.tokenizer.pad_token_id,
                                                 bos_token_id = self.tokenizer.bos_token_id,
                                                 eos_token_id = self.tokenizer.eos_token_id,
                                                 bad_word_ids = [[self.tokenizer.unk_token_id]])
            output = self.adjust(output_ids, text)
            l += 1
            if (4 < len(output)) and (len(output) < max_length): break
        self.loop_list.append(l)
        return output
```

## 生成した文章の調整
　生成した文章を会話っぽくするために、諸々を調整する関数です。
- 引数1：出力文の単語インデックス
- 引数2：出力文以前の全文章

　ざっくり説明すると、出力用にデコードした文章から頭の1文を抽出。直前の文章が同じだった場合は作り直しを行わせるものになっています。
　今回は、文章を1文取り出すようにしていますが、これをしないと「～～、」で出力されて、次の文章が 完全に直前の文章の続きになってしまいます。

```create_txt.py
    #create_txtのクラス内関数
    def adjust(self, output_ids, text):
        earlier_text = text[ text[:-1].rfind("。") + 1:]
        output = self.tokenizer.decode(output_ids.tolist()[0])
        output = output.replace(text[-100:], "").replace("
" ,"")
        output = output[:output.find("。")] + "。"
        if output == earlier_text: output = ""
        return output
```

## 会話関数
　出力された文章をAさん、Bさんに交互に分けてコンソールに出力する関数です。
- 引数1：出力文
- 引数2：Aさん、Bさんを判断する変数

　これは、見たまんまなので説明は省略します。　

```create_txt.py
def talker(text, i):
    player = "Aさん " if i % 2 != 0 else "Bさん "
    print(player, text)
    if i % 2 == 0: print("")
```

## create_txt.py 全コード
ここまで記載したクラス・関数と実行部分を以下に記載します。
```create_txt.py
import torch
from transformers import T5Tokenizer, AutoModelForCausalLM


class create_text:
    def __init__(self, model_name):
        self.tokenizer = T5Tokenizer.from_pretrained(model_name)
        self.model = AutoModelForCausalLM.from_pretrained(model_name)
        self.loop_list = []
        device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
        self.model = self.model.to(device)

    def generate_text(self, text, max_length = 30):
        token_ids = self.tokenizer.encode(text[-100:], add_special_tokens=False, return_tensors="pt")
        if max_length < len(text): max_length = len(text) + 5
        l = 0
        while True:
            with torch.no_grad():
                output_ids = self.model.generate(token_ids.to(self.model.device),
                                                 max_length = max_length,
                                                 min_length = 15,
                                                 do_sample = True,
                                                 pad_token_id = self.tokenizer.pad_token_id,
                                                 bos_token_id =     self.tokenizer.bos_token_id,
                                                 eos_token_id = self.tokenizer.eos_token_id,
                                                 bad_word_ids = [[self.tokenizer.unk_token_id]])
            output = self.adjust(output_ids, text)
            l += 1
            if (4 < len(output)) and (len(output) < 40): break
        self.loop_list.append(l)
        return output

    def adjust(self, output_ids, text):
        earlier_text = text[ text[:-1].rfind("。") + 1:]
        output = self.tokenizer.decode(output_ids.tolist()[0])
        output = output.replace(text[-100:], "").replace("
" ,"")
        output = output[:output.find("。")] + "。"
        if output == earlier_text: output = ""
        return output

def talker(text, i):
    player = "Aさん " if i % 2 != 0 else "Bさん "
    print(player, text)
    if i % 2 == 0: print("")

if __name__ == "__main__":
    input_text='こんにちは。調子はどうですか。'
    model_name = "rinna/japanese-gpt-1b"

    creator = create_text(model_name)
    
    i = 0
    print("Aさん ", input_text)
    
    for i in range(13):
        talk_text = creator.generate_text(input_text)
        talker(talk_text, i)
        input_text += talk_text
        i += 1
    print("各テキストのやり直し回数", creator.loop_list)
```

一番下の行の、creator.loop_listは、generate_textの中で、何回出力文を作り直したかをmainのループ分だけ記録したものです。

# 実行結果
実際に動かした結果を2パターンほど、記載します。
## パターン1「input_txt：こんにちは。調子はどうですか。」
Aさん  こんにちは。調子はどうですか。
Bさん  元気ですか。

Aさん   ぼくはとても元気です。
Bさん   ぼくは昨夜、とてもうれしくなりました。

Aさん   ぼくが書いた夢日記を読んだ人が、ぼくに対して、こう言ってくれたからです。
Bさん   「きみの夢日記を読んで、はじめて、夢について、分かったんだ。

Aさん  ぼくはとても元気です。
Bさん  いつも、とても楽しくて...。

Aさん  ぼくの夢とは、 冒険と発見に満ちた、素晴らしい夢なんだ。
Bさん  きみの夢日記がこんなに楽しいのは、きみの夢日記が、ぼくの夢日記なんだよ。

Aさん   ぼくの大好きなきみは、いつも、ぼくより少し賢い。
Bさん  どんな時も、ぼくのことを信じてくれる。

Aさん  きみは、大切なことを教えてくれた。
Bさん  ぼくたちは、いつでも、一緒にいる。

各テキストのやり直し回数 [3, 2, 2, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1]


...1人喋り、というかポエミーな文章を分割したような会話になりました 笑
「めちゃくちゃ仲良しの双子が互いを大事に思っている」という設定を付加すれば、会話っぽいと言えなくもないですね(無理矢理)

## パターン2「input_txt：京都観光は楽しいな。」
Aさん  京都観光は楽しいな。
Bさん  おみやげに、梅干しは最高ですが・・。

Aさん  ちなみに、私は梅干し大好きなので、これはお土産としては失敗。
Bさん  ただ、お供え用なのか、数個のみですが・・。

Aさん  その代わり、お寺の参道のところでも売っていましたよ。
Bさん  梅干しを供えている人を見たこともあります。

Aさん  こちらはその時のものです。
Bさん  ↓また、お寺では、お供え物の水子人形を飾っていらっしゃるお家もありました。

Aさん  子供のお宮参りをした時のお土産も売っていましたよ。
Bさん  お稲荷さん(神社)です。

Aさん  この神社は日本三大稲荷のひとつです。
Bさん  なので、小さい子だけでなく、大人のお参りも多いですよ。

Aさん  こちらは神社が祀られている森。
Bさん  秋には紅葉した木々に囲まれ、紅葉狩りにも最適です。

各テキストのやり直し回数 [1, 1, 3, 5, 1, 6, 4, 2, 1, 1, 1, 21, 1]


~~京都土産に梅干しは無いと思います(真顔)~~

　パターン1よりは会話っぽいかな～、と思いますが やはり微妙ですね...。
　神社の話に移行した途端、土産の話をしなくなったあたり、直前の文章に大きく左右されていることが伺えますね。
# 考察
　直前の文章にどんどん文章を付け加えることによって、会話っぽくなるのではないかと思いましたが、あんまり良い結果にはなりませんでした。
　これは、単純に「1つの文章として続いているから」であるのが、1番大きな理由だと思われます。会話として眺めても、対話というよりは同じ方向を向いて同調しているだけに見えます。

　これを会話っぽい感じにするのであれば、文章に脈絡があることは大前提ですが、文章作成インスタンスを2つ(Aさん用、Bさん用)作成して、それぞれ一貫したスタンスをもたせる必要があるのかな、と個人的には思います。
　(スタンスとは少し違うかもしれませんが)2つのインスタンスに、出力する前にネガポジを判断させるような関数を組み込んで、インスタンスAはポジティブ文のみ出力、インスタンスBがネガティブ分のみ出力させるように調整すれば、正反対の人格をもつ2人が喋っているように見えたりしないかなー、なんて考えています。
　ちなみにですが、インスタンスを単純に2つ作成して交互に文章作成を行ったり、2つのインスタンスに別々の事前学習モデルを突っ込んで文章作成させるだけでは、同じような結果になりました。

　まだまだ精進が必要そうですね...。(伸び代ッ！！)

# 参考
[雲のメモ帳](https://www.cloudnotes.tech/entry/rinna_gpt_jp "rinnaの日本語特化GPTモデルを触ってみた")