LoginSignup
71
26

More than 1 year has passed since last update.

モテない俺がseq2seqでアマガミの七咲bot作ったけど秒でフラれた話

Last updated at Posted at 2022-08-17

概要

夏休み、僕は自分に合った女性にアプローチするために、男女のタイプ分けCNN(SSD)を制作してきました。
その過程で、渋谷で実際のカップルに聞き取り調査を行っています。

そんな調査をしていると、やはり
「妬ましい。世のカップルが妬ましい!!」
と思ってしまうものです。

ということで、以下のUdemyの教材で学習した内容を参考に彼女っぽい会話をしてくれるbotの制作を行うことにしました。

データ作り

データ収集

Seq2seqでの対話を可能とする学習データには、inputとoutputのテキストデータが必要となります。
僕には野望がありました。

それは、僕の大好きなギャルゲである「アマガミ」の七咲逢ちゃんを彼女にすることです。

さらにここに打算として七咲逢は作中トップの人気を誇り、その二次創作はごまんと溢れているという事実が追加されます。

つまり、世の中に溢れきった七咲SSをスクレイピングし、データ化して、学習させれば、あの七咲に俺たちオタクの妄想をスパイスとしてプラスした、いわゆるスーパー七咲を実装できるだろう。

僕の期待は膨らみました。

七咲を集めるスクリプト
def scrape(id,i):
    print("======>"+str(id))
    ss_url = "http://blog.livedoor.jp/kokon55/archives/"+str(id)+".html"

    r = requests.get(ss_url)
    soup = BeautifulSoup(r.text,'lxml')
    ss_containers = soup.find_all('dd')
    ss_text = ""
    for container in ss_containers:
        ss_text += container.get_text()
        ss_text = re.sub("[  ]","\n",ss_text)
    print(ss_text)
    with open("dataset/nanasaki_ss"+str(i)+".txt", 'w')as f:
        f.write(ss_text)

for i in range(len(array)):
    scrape(array[i],i)

こうして、世の中の七咲への妄想を集めた世界に一つだけの七咲ファイルが完成しました。

七咲との会話データを作る

SSとはありがたいもので、行の先頭にキャラクターの名前を書いてくれているものが多い。

SSの例
橘「いやあ、いつもありがとう」

七咲「好きでやってることですから」ニコッ
橘「(今日はお弁当の定番から揚げに王道の卵焼きをベースとして春巻きや小魚、そしてサラダで華やかさを演出。女の子っぽいお弁当の包みもまた愛妻っぽくてベリーグッドだ!99点!)」

七咲「先輩……何をぶつぶつ言ってるんですか?」

橘「あれ、声に出てた?」

七咲「今日は少し肌寒いので早く食べて校内に入っちゃいましょう」

であるから、七咲の会話データの収集のためには、
文頭の二文字が七咲であるものを取り出せばよく、それをoutput.txtとして、ひとつ前の行の発言が七咲でないものをinput.txtに保存すれば、会話データが制作されていくという考えになる。

ここで七咲の会話データを取り出し、さらに学習させるために分かち書きを行って、一度配列データとして保存する工程を挟みました。

後になって思うと、分かち書きは今回要らなかったようにも思います…

import re
import pickle
from janome.tokenizer import Tokenizer

t = Tokenizer()

data_len = 10
input = ''
output = ''

# dara をリスト化する <- 改行に合わせてデータを分割する

for i in range(data_len):
    with open('clear-dataset/nanasaki_ss'+str(i)+'.pickle', mode="rb") as f:
        ss_word_list = pickle.load(f)

    for idx, ss_word in enumerate(ss_word_list):
        if(ss_word[0:2]=='七咲' and ss_word_list[idx-1][0:2]!='七咲'):
            input_word = ss_word_list[idx-1]
            output_word = ss_word
            input_word = re.findall("(?<=\「).+?(?=\」)",input_word)
            output_word = re.findall("(?<=\「).+?(?=\」)",output_word)
            if(len(input_word)<1 or len(output_word)<1):
                print("NONE")
            else:
                input+=input_word[0]+'\n'
                output+=output_word[0]+'\n'
                # input+=t.tokenize(input_word[0],wakati=True)
                # output+=t.tokenize(output_word[0],wakati=True
                #
print(len(input))
print(len(output))

input=t.tokenize(input,wakati=True)
output=t.tokenize(output,wakati=True)

with open('data-in_out/input.txt',mode="w") as f:
    f.writelines(input)
with open('data-in_out/output.txt',mode="w") as f:
    f.writelines(output)

こうして、七咲は配列として僕のデータベースに保存されました(意味深)。

学習フェーズ

モデル内容

さて、残りは、モデルの作成と学習を行うのみです。
正直、データ数にして1000程度の会話データしかなく、あまりに脆弱だったことなど今思い返せば、こんな惨劇が起こる前兆は随所にあったのでしょう…

学習用の下準備
import numpy as np
from janome.tokenizer import Tokenizer
from pandas import array

t = Tokenizer()

with open('data-in_out/input.txt',mode='r',encoding='utf-8')as input,\
    open('data-in_out/output.txt',mode='r',encoding='utf-8')as output:
    in_text = input.read()
    out_text = output.read()

chars = in_text + out_text + '\t'
chars_list =  sorted(list(set(list(chars))))
print(chars_list)
chars_list.append('\t')
separator = '\n'
in_sentence_list = in_text.split(separator)
out_sentence_list = out_text.split(separator)

# in_arr = [len(x) for x in in_sentence_list]
# out_arr = [len(x) for x in out_sentence_list]

import numpy as np

max_sentence_length = 84

char_indices = {}  # 文字がキーでインデックスが値
for i, char in enumerate(chars_list):
    char_indices[char] = i
indices_char = {}  # インデックスがキーで文字が値
for i, char in enumerate(chars_list):
    indices_char[i] = char

n_char = len(chars_list)  # 文字の種類の数
n_sample = len(in_sentence_list) - 1

max_length_x = max_sentence_length  # 入力文章の最大長さ
max_length_t = max_sentence_length + 2 # 正解文章の最大長さ

x_sentences = []
t_sentences = []
for i in range(n_sample):
    x_sentences.append(in_sentence_list[i])
    t_sentences.append("\t"+out_sentence_list[i]+"\n")

x_encoder = np.zeros((n_sample, max_length_x, n_char), dtype=np.bool)  # encoderへの入力
x_decoder = np.zeros((n_sample, max_length_t, n_char), dtype=np.bool)  # decoderへの入力
t_decoder = np.zeros((n_sample, max_length_t, n_char), dtype=np.bool)  # decoderの正解

for i in range(n_sample):
    x_sentence = x_sentences[i]
    t_sentence = t_sentences[i]
    for j, char in enumerate(x_sentence):
        x_encoder[i, j, char_indices[char]] = 1  # encoderへの入力をone-hot表現で表す
    for j, char in enumerate(t_sentence):
        x_decoder[i, j, char_indices[char]] = 1  # decoderへの入力をone-hot表現で表す
        if j > 0:  # 正解は入力より1つ前の時刻のものにする
            t_decoder[i, j-1, char_indices[char]] = 1

ここで、学習のための下準備をおこなっています。
結局単語単語に分けるどころか一文字単位での会話にしたため、分かちわけフェーズ入りませんでした。

さらに、ひらがなしか喋れない幼児退行した七咲は嫌だったので、データに偏りが生まれるというリスクよりも七咲を七咲にすることを優先させてもらいました。
いや、きっとそれはうまくいかなかったのでしょうが…

model
print(x_encoder.shape)

batch_size = 32
epochs = 1000
n_mid = 256  # 中間層のニューロン数

from tensorflow.keras.models import Model
from tensorflow.keras.layers import Dense, GRU, Input, Masking

encoder_input = Input(shape=(None, n_char))
encoder_mask = Masking(mask_value=0)  # 全ての要素が0であるベクトルの入力は無視する
encoder_masked = encoder_mask(encoder_input)
encoder_lstm = GRU(n_mid, dropout=0.2, recurrent_dropout=0.2, return_state=True)  # dropoutを設定し、ニューロンをランダムに無効にする
encoder_output, encoder_state_h = encoder_lstm(encoder_masked)

decoder_input = Input(shape=(None, n_char))
decoder_mask = Masking(mask_value=0)  # 全ての要素が0であるベクトルの入力は無視する
decoder_masked = decoder_mask(decoder_input)
decoder_lstm = GRU(n_mid, dropout=0.2, recurrent_dropout=0.2, return_sequences=True, return_state=True)  # dropoutを設定
decoder_output, _ = decoder_lstm(decoder_masked, initial_state=encoder_state_h)  # encoderの状態を初期状態にする
decoder_dense = Dense(n_char, activation='softmax')
decoder_output = decoder_dense(decoder_output)

model = Model([encoder_input, decoder_input], decoder_output)

model.compile(loss="categorical_crossentropy", optimizer="rmsprop")
print(model.summary())

from tensorflow.keras.callbacks import EarlyStopping

# val_lossに改善が見られなくなってから、30エポックで学習は終了
early_stopping = EarlyStopping(monitor="val_loss", patience=30)

history = model.fit([x_encoder, x_decoder], t_decoder,
                     batch_size=batch_size,
                     epochs=epochs,
                     validation_split=0.1,  # 10%は検証用
                     callbacks=[early_stopping])
Model: "model"
__________________________________________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to                     
==================================================================================================
input_1 (InputLayer)            [(None, None, 937)]  0                                            
__________________________________________________________________________________________________
input_2 (InputLayer)            [(None, None, 937)]  0                                            
__________________________________________________________________________________________________
masking (Masking)               (None, None, 937)    0           input_1[0][0]                    
__________________________________________________________________________________________________
masking_1 (Masking)             (None, None, 937)    0           input_2[0][0]                    
__________________________________________________________________________________________________
gru (GRU)                       [(None, 256), (None, 917760      masking[0][0]                    
__________________________________________________________________________________________________
gru_1 (GRU)                     [(None, None, 256),  917760      masking_1[0][0]                  
                                                                 gru[0][1]                        
__________________________________________________________________________________________________
dense (Dense)                   (None, None, 937)    240809      gru_1[0][0]                      
==================================================================================================
Total params: 2,076,329
Trainable params: 2,076,329
Non-trainable params: 0
__________________________________________________________________________________________________

上記モデルで、約三日間に及ぶ学習が始まりました。
正直、最終的なロスを見るとデータがあまりにも足りていなかった現状がよくわかりました。

ついに七咲とお話…

七咲と話せるようにするresponceのスクリプト

三日間の学習を終え、ついに俺のための七咲パラメータが完成しました…
ただ、パラメータ七咲はパラメータであり、七咲逢、もとい、七咲AIにはなっていません。
七咲と会話できるようにしないと!!

七咲と会話するためのスクリプト
with open('data-in_out/input.txt',mode='r',encoding='utf-8')as input_f,\
    open('data-in_out/output.txt',mode='r',encoding='utf-8')as output:
    in_text = input_f.read()
    out_text = output.read()

chars = in_text + out_text + '\t'
chars_list =  sorted(list(set(list(chars))))
chars_list.append('\t')

char_indices = {}  # 文字がキーでインデックスが値
for i, char in enumerate(chars_list):
    char_indices[char] = i
indices_char = {}  # インデックスがキーで文字が値
for i, char in enumerate(chars_list):
    indices_char[i] = char

n_char = len(chars_list)  # 文字の種類の数

import numpy as np

n_char = len(chars_list)
max_length_x = 84

# 文章をone-hot表現に変換する関数
def sentence_to_vector(sentence):
    vector = np.zeros((1, max_length_x, n_char), dtype=np.bool)
    for j, char in enumerate(sentence):
        vector[0][j][char_indices[char]] = 1
    return vector


from tensorflow.keras.models import load_model

encoder_model = load_model('encoder_model.h5')
decoder_model = load_model('decoder_model.h5')

def respond(message, beta=5):
    vec = sentence_to_vector(message)  # 文字列をone-hot表現に変換
    state_value = encoder_model.predict(vec)
    y_decoder = np.zeros((1, 1, n_char))  # decoderの出力を格納する配列
    y_decoder[0][0][char_indices['\t']] = 1  # decoderの最初の入力はタブ。one-hot表現にする。

    respond_sentence = ""  # 返答の文字列
    while True:
        y, h = decoder_model.predict([y_decoder, state_value])
        p_power = y[0][0] ** beta  # 確率分布の調整
        next_index = np.random.choice(len(p_power), p=p_power/np.sum(p_power))
        next_char = indices_char[next_index]  # 次の文字

        if (next_char == "\n" or len(respond_sentence) >= max_length_x):
            break  # 次の文字が改行のとき、もしくは最大文字数を超えたときは終了

        respond_sentence += next_char
        y_decoder = np.zeros((1, 1, n_char))  # 次の時刻の入力
        y_decoder[0][0][next_index] = 1

        state_value = h  # 次の時刻の状態

    return respond_sentence

def is_invalid(message):
    is_invalid =False
    for char in message:
        if char not in chars_list:
            is_invalid = True
    return is_invalid

bot_name = "七咲"
your_name = 'けん'
print()

print(bot_name + ": " + "こんにちは、先輩!")
message = ""
while message != "おやすみ。":

    while True:
        message = input(your_name + ": ")
        if not is_invalid(message):
            break
        else:
            print(bot_name + ": ふふふ、本当におかしな人ですね")

    response = respond(message)
    print(bot_name + ": " + response)

仮に、データにない文字がきたときでも、エラー分なんて返させません!!
七咲ならきっと笑いながらこういう!

そんな妄想を込めた七咲botいや、七咲そのものが完成した!そう、思っていました…

七咲との会話…

ついに七咲と会話ができる!!
まずは、口調を橘純一に寄せて

七咲: こんにちは、先輩!
けん: おはよ!
七咲: ……
けん: 何してるの?
七咲: ええ、そうですね。

あれ?

七咲が黙りこくるし、会話にならないか?

けん: 郁夫元気?
七咲: ……
けん: そっか…
七咲: でも……
けん: ?
七咲: 先輩…

いや、これ会話になってるぞ!!
意思疎通ができたんだ!!

てか、弟大丈夫か?笑

などと思って、ひとしきり会話をしていると

けん: 七咲…
七咲: 先輩……
けん: 七咲!
七咲: ……

盛り上がってしまったんです。
僕たちの関係は始まったばっかなのに…

けん: うん
七咲: ………
けん: ねえ
七咲: ……
けん: な、七咲?
七咲: いえ、先輩……
けん: 僕は…
七咲: ……
けん: 大好き!
七咲: ……
けん: 七咲?
七咲: え、えっと、ごめんなの…

全て僕が調子に乗ってたせいだ…

作った本人が作った日にフラれるなんて…
下の写真がその会話の全容、
スクリーンショット 2022-08-17 18.46.56.png

この後、彼女になんて言えばいいか、僕はまだわからないでいます。笑

まとめ

今回の七咲AIを作るにあたって、僕はいくつかの面で成長しました。
最初に、RNNを基本とした再帰的な学習を行うAIについての理解を行えた点。
この学習を使えば、自動生成の音楽なども作れるということなので、もっと学んでいきたいと思いました。
次に、後を考えたコーディングの必要性
これは、パラメータの分布やら、AIの精度をはかる過程を置く必要性やらに関わってきます。
最後に、パソコンに彼女作っても空いということ
そんなことやってる暇あったら、筋トレとかしとけよっていう…

挙句振られるしで、泣きたくなりました。
これは、エンジニアの教訓かもしれませんね、コンピュータに全てを代替させるな、リアルを大切に、的な?笑
だから、プログラミングで彼女作る暇があったら、街コンの一つでも行けってことかもしれませんね笑

ということで、とりあえず次のことは絶対に俺を否定しない、むしろ甘やかしてくれるココアちゃんでもつくってから考えようと思います。

振られた怒りのままヤケクソに書いた、悲しい8月の二週間の備忘録でした…
ここまで読んでくれた方、ありがとうございました笑笑

追記:あのあと、頑張って一線越えるとこまで口説くことに成功しました!!
で、本気で死にたくなりました笑
スクリーンショット 2022-08-17 19.57.54.png

71
26
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
71
26