websocketを用いた対話型AIとの会話アプリケーションの開発②

①に引き続き、対話型AIを作る試みです。

https://qiita.com/sumiyoshi01/items/0b1254f7042c3dcbd34d

今回の記事では自然言語処理の基礎と学習データ作成過程、スクレイピングなどたくさんの要素を盛り込んでいるのでぜひ読んでみてください。

# 自然言語処理の基礎
自然言語処理というのは、私たちが普段コミュニケーションで利用する言語を理解し、それに対して適切な出力を行う処理です(すごく大雑把な説明なので、気になる人はしっかり調べましょう！)。
この自然言語処理には、大きく分けて3つのモデルがあります。
1. 形式的言語モデル
    * 有限オートマトン
    入力された文字があらかじめ定まっている最終状態に達すれば受理されます。文字列の各文字を順に読み取り、遷移関数に基づいて状態遷移を繰り返します。受理されなければ、その文字列はオートマトンが定義する言語の一部とは認識されません。

    * 正規言語、正規表現
    正規言語は、正規表現によって記述できる言語のクラスです。正規表現は、特定のパターンにマッチする文字列を認識するための記法であり、テキストの検索、抽出、置換に利用されます。

    * 形式文法
    形式文法は、言語の構造を定義するための一連の生成規則です。文法が定義する言語は、その文法の規則に従って導出できるすべての文字列から構成されます。

2. 統計的言語モデル

    * N-gram
    N-gramモデルは、自然言語処理でテキストを理解するための統計的手法の一つです。N-gramモデルは、テキスト内のN個の連続する単語（または文字）の頻度を利用して、言語をモデル化します。以下はN-gramの確率の基本的な計算方法です。

```math
P(w_n | w_{n-(N-1)}, \ldots , w_{n-1}) = \frac{\text{count}(w_{n-(N-1)}, \ldots , w_{n-1}, w_n)}{\text{count}(w_{n-(N-1)}, \ldots , w_{n-1})}
```

ここで、

```math
P(w_n | w_{n-(N-1)}, \ldots , w_{n-1})
```

は、与えられた単語列が出現した後に、次の単語が$w_n$である条件付き確率です。
$count$は、特定の単語列が訓練データに出現する回数です。
3. ニューラル言語モデル
* 単語の埋め込み(分散)表現
    単語の分散表現では、各単語を多次元の実数ベクトルで表現します。例えば、word2vecの一つの形はSkip-gramモデルです。Skip-gramモデルの目標は、中心の単語が与えられたときに、その周囲の単語の確率を最大化することです。以下はSkip-gramモデルの目的関数の単純な形式です。

```math
L = \sum_{i=1}^{T} \sum_{-m \leq j \leq m, j \neq 0} \log P(w_{i+j} | w_i)
```

$L$は目的関数（対数尤度）です。
$T$はテキストの長さです。
$m$は中心単語の前後にどれだけの単語を考慮するかを示すウィンドウサイズです。
$P(w_{i+j} | w_i) $ は、単語 $w_i$が与えられたときに、単語$w_{i+j}$が周囲に出現する条件付き確率です。

* 再帰的NNを用いた言語モデル
再帰的ニューラルネットワーク（RNN）は、時系列データを処理するためのニューラルネットワークです。自然言語処理において、RNNは文の構造や文脈を捉えるのに適しています。

* 注意機構を用いた言語モデル(GPT)
注意機構を用いた言語モデル、例えばGPT（Generative Pretrained Transformer）は、テキストの各部分が他の部分に与える影響を動的に調整する能力を持ちます。これにより、文脈の理解と長い依存関係を捉えることが可能になります。

# 自然言語処理の選定

上記で述べたように、自然言語処理には様々なモデルがあります。本来であれば、設計の段階でどの処理を主に使うか決めるべきです。しかし、今回はいろいろ試してみたいので処理フローの選定は一旦パスします。

# 学習データ

機械学習で割とネックになるのが、学習データの収集です。フリーのコーパスなどもありますが、それでも不十分なことが多いです。また、自分の思うようなデータでないことも多いです。
そこで考え抜いた結果、私は以下の流れで学習データを自分で蓄積することにしました。
1. とりあえず、会話してみる
2. 少しずつパターンを増やす
3. すべてのやり取りを保存して、これを学習データとする

つまり、一旦出来上がっているアプリケーションで相手の返答を調節しながら人間らしい会話を学習できるようなデータを作っちゃえ！という話です。

## 1. とりあえず、会話してみる
①で作ったアプリケーションでは、すべての言葉に対して"Hello, Client"と返していました。
そこで少し手を加えて、「こんばんは！」に対して「こんばんは!元気ですか？」と返すようにしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3290653/de49d10e-d32a-4d94-2073-04ea4768e572.png)
日本語の会話が成立して安心しました(笑)
この調子でいろんな分野の話をしてみましょう。

## 2. 少しずつパターンを増やす
1に続いて、今度はご飯の話をしてみましょう。
ご飯といっても様々な料理名が出てくる方が面白いですよね。
今回は料理のサイトである[クラシル](https://www.kurashiru.com/)から料理名のみをスクレイピングして会話に組み込んでみました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3290653/302a4888-15e5-e386-b014-cf1358d0f3d9.png)

急に会話が人間らしくなって愛着がわきそうになりました。かわいいですよね。

## 3. すべてのやり取りを保存して、これを学習データとする
やり取りを保存しなければただのお遊びになってしまうので、すべてしっかり記録して「データ」にしましょう。
今回は、`conversation.txt`というファイルにすべて保存します。

# 本題
今までのは何だったんだ？？と思われそうですね。やっと本題です。
上記の機構を作る流れを説明します。

# ディレクトリ構成
ローカルのディレクトリ構成は割愛し、コンテナ内のディレクトリのみ示します。

``` 
notebook/
├── conversation.txt  
├── food.txt          
└── talk/
    ├── templates/
    │   └── Index.html
    ├── foodscrap.py
    └── talkwithme.py    
```

# 各ファイルの説明
少しファイルが増えたので、それぞれ説明します。

* conversation.txt
のちの学習用ファイルになります。すべての会話をここに保存します。
* food.txt
料理のリストになります。先ほど紹介した料理のサイトからスクレイピングしたデータがここに保存されます。
* Index.html
ビュー用のファイルになります。今回はデータを保存するので前回より少しだけ記述を増やしました。
* foodscrap.py
実際にスクレイピングを行うスクリプトが記述されたファイルです。
* talkwithme.py
サーバーの通信を定義するファイルです。返信として何を送信するかもここに記述してあります。

以上のファイルを利用して処理の流れを表すと以下のようになります。
1. foodscrap.pyで料理名をfood.txtに追加する。
2. talkwithme.pyの返信パターンにfood.txtのデータを組み込む。
3. talkwithme.pyを実行し、Index.htmlのビューを確認する。
4. 会話した内容を都度conversatioin.txtに保存する。

# 各ファイルのコードと挙動
.txtのファイルは自動書き込みなので、それ以外のファイルについて説明します。
まず、foodscrap.pyの説明です。
```foodscrap.py
import re
import requests

urls = [
    'https://www.kurashiru.com/recipes/98025ac8-04ee-49d8-a6fc-29260c88149e',
    'https://www.kurashiru.com/recipes/ca3aeb26-8cbd-484c-ba41-c6cb14cf111a',
    'https://www.kurashiru.com/recipes/3979f826-67ea-45e3-92af-2c573d3eaaab'
]

titles = {}

for url in urls:
    response = requests.get(url)
    if response.status_code == 200:
        match = re.search(r"googletag\.pubads\(\)\.setTargeting\('page_title', \[\"(.*?)\"\]\);", response.text)
        if match:
            title = match.group(1)
            titles[url] = title 
    else:
        print(f'Failed to retrieve {url}')

with open('food.txt', 'w', encoding='utf-8') as file:
    for url, title in titles.items():
        file.write(f'{title}\n')

```
これは、スクレイピングを行うスクリプトになっています。
正規表現を利用してタイトルを取得しています。
もっとURLを増やしたらたくさんの料理名を取得できます。
サイトに飛んで以下のようにページのソース表示を行うと、クラス名とかがわかるので、それでうまくやってください。(急に不親切)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3290653/405c61df-d1f1-2ea6-baec-d33e91bdf2eb.png)
これを実行すると
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3290653/2e446133-ca83-7a84-8afb-6d4ec9e6747c.png)
このように`food.txt`に保存されます。

次は、`talkwithme.py`の説明をします。
```talkwithme.py
from flask import Flask, render_template
from flask_socketio import SocketIO
import os
import random

app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*")

message_counter = 0  

def save_message(message):
    global message_counter
    message_counter += 1
    with open('conversation.txt', 'a') as file:
        file.write(f"{message_counter}: {message}\n")  

@app.route('/')
def index():
    return render_template('Index.html')  

@socketio.on('message')
def handle_message(message):
    print('Received message:', message)
    save_message(f'Client: {message}')  
    
    if message == 'こんばんは！':
        response = 'こんばんは!元気ですか？'
    elif message == '元気です':
            response = 'よかったです！'
    elif message == '今日のご飯は何を食べましたか？':
        with open('food.txt', 'r') as file:
            foods = file.read().splitlines()  
        chosen_food = random.choice(foods)  
        response = f'今日は{chosen_food}を食べました。'
    elif message == 'おいしそうですね':
          response ='ありがとうございます！おいしかったです'
    else:
        response = 'どうしましたか？'
    
    socketio.emit('message', response)  
    save_message(f'Server: {response}')  
if __name__ == '__main__':
    if not os.path.exists('conversation.txt'):
        with open('conversation.txt', 'w'):  
            pass
    socketio.run(app, debug=True, port=5000)

```
random関数を入れて、`food.txt`から料理名をランダムで選んでいます。
また、保存するコードも追加しています。
if,elifがネストしているクソコードです。くれぐれもマネしないようにしてくださいね！
近いうちに修正入れます、多分。

上記のコードにデータを送るために`Index.html`にも手を加えます。
```Index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>WebSocket Chat Example</title>
    <style>
        #chat {
            overflow-y: scroll;
            height: 300px;
            border: 1px solid #ccc;
            margin-bottom: 10px;
        }
        .myMessage {
            text-align: right;
            background-color: #d1e7f3;
            margin: 5px;
            padding: 10px;
            border-radius: 10px;
        }
        .otherMessage {
            text-align: left;
            background-color: #f1f1f1;
            margin: 5px;
            padding: 10px;
            border-radius: 10px;
        }
    </style>
</head>
<body>
    <div id="chat"></div>
    <textarea id="messageInput"></textarea>
    <button onclick="sendMessage()">Send Message</button>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/3.0.3/socket.io.js"></script>
    <script>
        const socket = io.connect('http://localhost:5000');

        socket.on('connect', function() {
            console.log('Connected to Server');
        });

        socket.on('message', function(data) {
            console.log('Received message:', data);
            let chat = document.getElementById('chat');
            let messageDiv = document.createElement('div');
            messageDiv.className = 'otherMessage';
            messageDiv.textContent = data;
            chat.appendChild(messageDiv);
            chat.scrollTop = chat.scrollHeight;

            socket.emit('saveMessage', data);
        });

        function sendMessage() {
            const messageInput = document.getElementById('messageInput');
            const message = messageInput.value;
            console.log('Sending message:', message);
            socket.emit('message', message);
            
            let chat = document.getElementById('chat');
            let messageDiv = document.createElement('div');
            messageDiv.className = 'myMessage';
            messageDiv.textContent = message;
            chat.appendChild(messageDiv);
            chat.scrollTop = chat.scrollHeight;

            messageInput.value = '';
        }
    </script>
</body>
</html>
```

そして、`talkwithme.py`を実行すると以下のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3290653/901d53b0-ba86-87f6-d458-441a11b863d7.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3290653/46b634bf-f3a5-0be2-dca1-c8e16d43d995.png)

さらに、これを`conversation.txt`に保存しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3290653/f57bdd4c-b483-38bf-ae7a-c332a087f985.png)

これが今後の学習データになります。

# おわりに
書いているうちに眠気が襲ってきたので、変な記述をしているかもしれません。
オートマトンとかを事細かに説明しようと思い、躍起になって学習していたら深夜になっていました。記事のバランスを考えて結果的に文面での説明になりました。泣きそうです。
次の記事でひょっとしたら自然言語処理の実装をするかもしれないです。でも、もっといろいろな会話をしたいので、なんか要素を増やすかもしれません。どうなるかわかりません。お楽しみに！