無償！GPU不要！完全ローカル動作！クソ-GPTを公開

:::note info
この記事は[クソアプリAdvent Calendar 2023 14日目](https://qiita.com/advent-calendar/2023/kuso-app)の記事です。
:::

今年はChatGPTとかの大規模言語モデルが流行りましたね。
ただ、無料でできる範囲が限られてたり、API必須だったりでなかなか使い勝手が悪いものが多いです。
それでは自作するしかないということになりますが、学習にはそれなりの性能をもつ高価なGPUが必要になります。

言語モデルの作成にそこまでのモチベーションは無いので、今回はGPU不要でも自然な出力ができる対話型言語モデルを作ってみました。

# できたもの

テキスト形式の学習データを用意して、それに対する質問文を入力してあげるとそれに対する回答を出力してくれます。
(今回は夏目漱石の[吾輩は猫である](https://www.aozora.gr.jp/cards/000148/card789.html)を学習データにしました。)
```
---------------- input ----------------
吾輩に名前はありますか?
---------------- output ---------------
聞いて見るがいい、誰でもいいえと答えるに極っている。
```

```
---------------- input ----------------
吾輩の主人は?
---------------- output ---------------
滅多に吾輩と顔を合せる事がない。
```

ちゃんとした日本語で返せていますし、内容も合っていそうな感じですね。
(プログラムを見るとわかりますが、ちゃんとした日本語になるのは当たり前で、内容もたまたまそれっぽいだけです。)

# 大規模言語モデルは所詮次にくる単語を予測しているだけ

どこかでそんなことを言っている[えらい人](https://www.tokyo-np.co.jp/article/292853)がいた気がします。
確かにその通りなので、これをヒントにモデルを作りました。

モデルの設計はこんな感じ↓
- 学習データから、質問文に似ているところを見つけ出して、その似ているところの次の文字をとってくる。
- 取ってきた文字は質問文の最後くっつけて新しい質問文を作る。
- 同じようにして似ているところの探索と次の文字の結合を繰り返す。
- くっつけてきた文字を出力とする。

# 実装

- 学習データ

```txt:neko.txt
吾輩は猫である。名前はまだ無い。
どこで生れたかとんと見当がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番獰悪な種族であったそうだ。この書生というのは時々我々を捕えて煮て食うという話である。しかしその当時は何という考もなかったから別段恐しいとも思わなかった。ただ彼の掌に載せられてスーと持ち上げられた時何だかフワフワした感じがあったばかりである。掌の上で少し落ちついて書生の顔を見たのがいわゆる人間というものの見始であろう。この時妙なものだと思った感じが今でも残っている。第一毛をもって装飾されべきはずの顔がつるつるしてまるで薬缶だ。その後猫にもだいぶ逢ったがこんな片輪には一度も出会わした事がない。のみならず顔の真中があまりに突起している。そうしてその穴の中から時々ぷうぷうと煙を吹く。どうも咽せぽくて実に弱った。これが人間の飲む煙草というものである事はようやくこの頃知った。
この書生の掌の裏でしばらくはよい心持に坐っておったが、しばらくすると非常な速力で運転し始めた。書生が動くのか自分だけが動くのか分らないが無暗に眼が廻る。胸が悪くなる。到底助からないと思っていると、どさりと音がして眼から火が出た。それまでは記憶しているがあとは何の事やらいくら考え出そうとしても分らない。
ふと気が付いて見ると書生はいない。たくさんおった兄弟が一疋も見えぬ。肝心の母親さえ姿を隠してしまった。その上今までの所とは違って無暗に明るい。眼を明いていられぬくらいだ。はてな何でも容子がおかしいと、のそのそ這い出して見ると非常に痛い。吾輩は藁の上から急に笹原の中へ棄てられたのである。
...
```

- プログラム

```python
import random
import pandas as pd
import Levenshtein


# 学習データの読み込み
with open('neko.txt', 'r', encoding='utf-8') as f:
    sentence = ''.join(f.readlines()).replace('\n', '')

# プロンプトを受け取る
prompt = input('-'*16 + ' input ' + '-'*16 + '\n')
print('-'*16 + ' output ' + '-'*15)

# 出力文字数は32文字としてループする
for _ in range(32):

    # 類似度判定の対象となる文字列長はプロンプトの0.8倍~1.2倍
    min_len = int(len(prompt) * 0.8)
    max_len = int(len(prompt) * 1.2)

    next_word = []
    # min_len ~ max_len の文字列を類似度判定対象としてループ処理
    for n in range(min_len, max_len+1):

        # 学習データのうち連続するn文字すべてに対してプロンプトとの編集距離を計算
        distance = list(map(
            lambda x: Levenshtein.distance(sentence[x:x+n], prompt),
            range(len(sentence) - n + 1)
        ))
        # 判定対象n文字における最短の編集距離
        min_distance = min(distance)
        # 判定対象n文字における最短編集距離になる文字列の次に来る文字を取得
        word = [
            sentence[idx+n]
            for idx, dist
            in enumerate(distance)
            if dist == min_distance
        ]
        # 判定対象n文字における最短編集距離と次に来る文字をDataFrame形式でappend
        next_word.append(pd.DataFrame({
            'distance': min_distance,
            'word': word
        }))

    # 取得されて文字のうち、最短距離を持つものだけを抽出
    next_word = pd.concat(next_word, axis=0).copy()
    next_word = next_word[next_word['distance'] == next_word['distance'].min()].copy()
    # 抽出された文字からランダムに1つ選択
    next_word = random.choice(next_word['word'].tolist())
    # 出力
    print(next_word, end='')
    # プロンプトに文字を追加
    prompt += next_word

    # 来た文字が「。」の場合ループを抜ける
    if next_word == '。':
        break

print('\n', end='')
```

# テスト

実装の通り、質問文に似たような文字列が学習データにあればその続きを出力するようなものなので、柔軟に質問を受け付けるわけではないです。

```
---------------- input ----------------
お前の名前は?
---------------- output ---------------
い所へ無期徒刑に処せられたものと見える。
```

```
---------------- input ----------------
飼い主は良い人ですか?
---------------- output ---------------
「いけるかも知れないが、貰わないね」「それでどうしました」「貰わ
```