【追記あり】セリフからミリオンライブのアイドルっぽさを判定(してみたかった)
[2019/06/06 追記]
ライブラリとしてPyTorchを使ったバージョンを公開しました。結果まで含めたJupyter NotebookをGitHubに上げています。また、推定できるアイドルの人数を7人から50人に増やしました。精度は6割程度ですが…
詳細は後日公開したいと思います。
気軽に試したい方は↓へ!
[追記ここまで]
冬休みに実家に帰ったのですが、することもないし外は寒いし、でも時間はたくさんあるから流行りの深層学習(Deep Learning)でもやるか!と思い、近くの書店で「ゼロから作るDeep Learning ―Pythonで学ぶディープラーニングの理論と実装」を買って、1週間で1冊終わらせることを目標にちまちまとやっていました。
次にコレを使って何か面白いことでもできないかな〜と思っていたところ、この本でも実装したCNNでテキスト分類ができることを知り、それじゃアイマスのセリフ判定でもやってみるか、と思い立ち実際にやってみました。深層学習とは書いてますが、そこまで深くないと思います。
結果から書くと、今の知識だと7人が限界でした…が、7人までなら未知のデータに対して結構な精度(val_acc: 73.6%)で判定できたので顛末を書いてみます。
したいこと
入力されたセリフがどのアイドルっぽいか、つまり「このセリフはどのアイドルが言いそうか?」ということを目的としました。この分類をする分類器を深層学習で作り、分類器にはCharacter-level CNNを使いました。
やりたいことの大まかな流れとしては、以下の通りです。
- 既存のセリフを訓練データ、そのセリフに対応するアイドル名を訓練ラベル(教師データ)とし、教師あり学習を行う。
- 未知のセリフを分類器に通して、各アイドルっぽさを確率として出力し、降順にソートする。
Character-level CNNとは
文章を扱う自然言語処理で深層学習を使うとなるとLSTMやらが出てくるらしいのですが、今回はChracter-level CNNというものを使うことにしました。Character-level CNNは文字単位で処理を行うため、分かち書きをしなくてもいい(厳密には分かち書きを行う際にしばしば利用される辞書が不要)、「うっうー!」や「はわっ!?」のような特徴的な単語に強い、という有利な点を持っています。CNNのカーネルが畳み込んでいく様子は「 CNNによるテキスト分類 // Speaker Deck 」の10スライド目を見ると分かりやすいと思います。
Character-level CNNを使った際の分類の大まかな流れを具体的に示すと、以下の通りになります。
- 入力された文を文字単位の配列に分解する。
- それぞれの文字をUnicodeポイントに変換する。
- 1文の最大文字列長は200文字とし、200文字未満はゼロ埋めする。
- Unicodeポイントの配列をベクトルの配列に変換する(今回はKerasのEmbeddingレイヤーを利用)
- このベクトルの配列を畳み込みニューラルネットワークに流す。
- 全結合層(1層)に流し、結果を得る。
実行環境
- Intel i7-6700K + 16GB RAM
- Ubuntu 16.04 LTS
- Anaconda3 5.0.1
- Python 3.6.4
- TensorFlow 1.5.0
- Keras 2.1.3
Pythonはもちろん3系を使いました。ライブラリとしてbackendがTensorFlowのKerasを使いました。Anaconda環境でのTensorFlowのインストール方法が公式に丁寧に載っていたので、この方法でインストールしました。Kerasについては、Anaconda Cloudからインストールしました。
データの準備
セリフが無いと学習のしようがありません。芸能人はデータが命!です。 今回は、SeleniumとHeadless Chromeを使い、とあるサイトをスクレイピングしてセリフデータの収集をしました。サーバに負荷をかけないように適度にウェイトを入れたので、データ収集には10時間かかりました。 セリフデータの形式は、「(アイドルのID).txt」とし、各行に1セリフを格納したテキストファイルを作成しました。また、全アイドルの数字ID、アイドルID、アイドル名をJSON形式のデータとして作成しました。
データ読み込み
JSONデータを読み込んでアイドルを列挙したあと、対象アイドルのセリフデータを読み込みます。そして、セリフ1文とそのセリフのアイドルの数字IDを組として、すべてのセリフを結合し巨大な2次元配列を作成します。その後は以下のコードのような流れです。
def load_data(): idols = [] idols = load_json() all_idol_script = load_script(idols) # 訓練データ(入力)の作成 encoded_script_list = encode_text(all_idol_script[:,0]) # テキスト列をUnicodeポイントにエンコードする script = np.array(encoded_script_list) # NumPy配列に変換 # 訓練ラベル(教師データ)の作成 id_list = all_idol_script[:,1] # id列を代入 label = np_utils.to_categorical(id_list) # one-hot表現に変換 return script, label
モデルの定義
KerasのFunctional APIを使い、定義しました。
- Inputレイヤーは、shapeが(batch_size, 最大文字列長)なテンソルを出力する。
- Embeddingレイヤーは、Unicodeポイントの整数(=1文字)の1次元配列をembed_size次元のベクトルの配列に変換する。shapeが(batch_size, 最大文字列長, embed_size)なテンソルを出力する。
- CoreレイヤーのReshapeは、畳み込み層が受け取る入力を生成する。2.の出力に軸としてチャネル数を追加して、shapeが (batch_size, 最大文字列長, embed_size, チャネル)なテンソルに変形し、出力する。
- 複数の畳み込み層(ConvolutionalレイヤーのConv2D)に、同一な3.の出力を入力し、1〜5の異なる高さのカーネルをそれぞれの層で1文字ずつスライドさせて畳み込む。その後、プーリング層(PoolingレイヤーのMaxPooling2D)に流す。
- 畳み込んだ結果を結合(Mergeレイヤーのconcatenate)し、平滑化(CoreレイヤーのFlatten)する。
- 全結合層(CoreレイヤーのDense)に流し、1次元に変換して結果を出力する。
活性化関数はReLUを使っています。厳密には、ZeroPadding2Dを使ってゼロ埋めしたり、Dropoutで正則化したりしています。Batch Normalizationは使っていましたが、あまり効果は出ませんでした。
def create_model(embed_size=256, max_length=200, filter_sizes=(1, 2, 3, 4, 5), filter_num=512): inp = Input(shape=(max_length,)) #print('inp: ' + str(np.shape(inp))) # Embedding層でUnicodeポイントの整数(=1文字)の1次元配列をベクトルの配列に変換する(ベクトルの配列だから、embed_size次元) # ハイパーパラメータ…input_dim=入力の各要素の最大インデックス+1=0xffff=65535=漢字のだいたいの最大値?, output_dim=入力の横幅(次元数)、初期化は一様分布 emb = Embedding(0xffff, embed_size)(inp) #print('emb: ' + str(np.shape(emb))) # 畳み込み層が受け取る入力を生成する。Reshapeで軸のチャンネル数を追加 # Conv層が受け取る入力のshape (batch_size,) + (高さ, 横幅, チャンネル)に変形 emb_ex = Reshape((max_length, embed_size, 1))(emb) #print('emb_ex: ' + str(np.shape(emb_ex))) # 入力の横幅と、フィルタの横幅は一致する # 同一の入力に対して、1〜5の高さの複数のフィルタを1文字ずつスライドさせて畳み込む。その後、プーリング層に流す。 # 1〜5文字ぶんの高さのフィルタを適用している。これは、1-gram〜5-gramを模倣している。 conv_0 = Conv2D(filter_num, (filter_sizes[2], embed_size), activation='relu', input_shape=(max_length, embed_size, 1))(emb_ex) pad_0 = ZeroPadding2D((1,1))(conv_0) conv_0_1 = Conv2D(filter_num, (filter_sizes[2], 3), activation='relu', input_shape=(max_length, embed_size, 1))(pad_0) conv_1 = Conv2D(filter_num, (filter_sizes[3], embed_size), activation='relu', input_shape=(max_length, embed_size, 1))(emb_ex) pad_1 = ZeroPadding2D((1,1))(conv_0) conv_1_1 = Conv2D(filter_num, (filter_sizes[3], 3), activation='relu', input_shape=(max_length, embed_size, 1))(pad_1) conv_2 = Conv2D(filter_num, (filter_sizes[4], embed_size), activation='relu', input_shape=(max_length, embed_size, 1))(emb_ex) pad_2 = ZeroPadding2D((1,1))(conv_0) conv_2_1 = Conv2D(filter_num, (filter_sizes[4], 3), activation='relu', input_shape=(max_length, embed_size, 1))(pad_2) conv_3 = Conv2D(filter_num, (filter_sizes[0], embed_size), activation='relu', input_shape=(max_length, embed_size, 1))(emb_ex) conv_4 = Conv2D(filter_num, (filter_sizes[1], embed_size), activation='relu', input_shape=(max_length, embed_size, 1))(emb_ex) pool_0 = MaxPooling2D(pool_size=(max_length - filter_sizes[2] + 1, 1))(conv_0) pool_0_1 = MaxPooling2D(pool_size=(max_length - filter_sizes[2] + 1, 1))(conv_0_1) pool_1 = MaxPooling2D(pool_size=(max_length - filter_sizes[3] + 1, 1))(conv_1) pool_1_1 = MaxPooling2D(pool_size=(max_length - filter_sizes[3] + 0, 1))(conv_1_1) pool_2 = MaxPooling2D(pool_size=(max_length - filter_sizes[4] + 1, 1))(conv_2) pool_2_1 = MaxPooling2D(pool_size=(max_length - filter_sizes[4] + 0, 1))(conv_2_1) pool_3= MaxPooling2D(pool_size=(max_length - filter_sizes[0] - 3, 1))(conv_3) pool_4= MaxPooling2D(pool_size=(max_length - filter_sizes[1] - 2, 1))(conv_4) # 畳み込んだ結果を結合する。これは、複数のN-gramをまとめて利用していることと同じようになる。 merged_convs = concatenate([pool_0, pool_0_1, pool_1, pool_1_1, pool_2, pool_2_1, pool_3, pool_4]) # 結合した出力を平坦にReshapeして、全結合層に流す。 #reshape = Reshape((filter_num * (len(filter_sizes)),))(merged_convs) reshape = Flatten()(merged_convs) #fc1 = Dense(64, activation='relu')(reshape) #bn1 = BatchNormalization()(fc1) do1 = Dropout(0.5)(reshape) # 正則化(Dropout) fc2 = Dense(7, activation='softmax')(do1) # 出力層(全結合層) model = Model(inputs=inp, outputs=fc2) # モデルの可視化 #plot_model(model, to_file='model.png', show_shapes=True, show_layer_names=True) return model
モデルを可視化すると以下のようになります。
学習
途中で学習を中断しても、中断したところから再開できるようにval_accが改善する度に重み(とモデル)をHDF5形式のバイナリファイルに保存するようにしました。また、epoch毎の損失と精度を保持し、それらを学習の終了後にグラフとして可視化するようにしました。
最適化アルゴリズムはAdam、損失関数はpoissonとしました。
def train(inputs, targets, batch_size=100, epoch_count=15, max_length=200, model_filepath='model.h5', learning_rate=0.001): # 学習率を少しずつ下げる start = learning_rate stop = learning_rate * 0.01 learning_rates = np.linspace(start, stop, epoch_count) model = create_model(max_length=max_length) # オプティマイザはAdam #optimizer = Adam(lr=learning_rate) optimizer = Adam() model.compile(loss='poisson', optimizer=optimizer, metrics=['accuracy']) model.summary() target = os.path.join(tmp_dir, 'weights.*.hdf5') files = [(f, os.path.getmtime(f)) for f in glob.glob(target)] if len(files) != 0: latest_saved_model = sorted(files, key=lambda files: files[1])[-1] model.load_weights(latest_saved_model[0]) # Logging file for each epoch csv_logger_file = tmp_dir + 'clcnn_training.log' # Checkpoint model for each epoch checkpoint_filepath = tmp_dir + 'weights.{epoch:02d}-{loss:.2f}-{acc:.2f}-{val_loss:.2f}-{val_acc:.2f}.hdf5' model.fit(inputs, targets, epochs=epoch_count, batch_size=batch_size, verbose=1, validation_split=0.1, shuffle=True, callbacks=[ LearningRateScheduler(lambda epoch: learning_rates[epoch]), CSVLogger(csv_logger_file), ModelCheckpoint( filepath=checkpoint_filepath, verbose=1, save_best_only=True, save_weights_only=False, monitor='val_acc') ]) model.save(model_filepath) plot_history(model.history)
手元のCPUオンリーな環境では30分以上かかったため、学習に際してはAWS EC2のp2.largeインスタンス(Deep Learning AMI (Amazon Linux) Version 4.0)を使って学習させました。3分程度(216秒)で終わりました。GPUってやっぱすごいですね…。
結果は、訓練データでは97.44% 、検証データでは73.68%の精度となりました。しかし、8人に増やすと訓練データでは99%を超えるものの検証データに対して25%程度の精度と、過学習してしまう惨憺たる結果となってしまいました。
判定してみる
推論フェーズは、別ファイルに独立なコードとして書きました。
def predict(pred_txt, model_filepath='model.h5'): model = load_model(model_filepath) plot_model(model, to_file='model.png', show_shapes=True, show_layer_names=True) for line in pred_txt: _txt = encode_pred_text(line) result = model.predict(np.array([_txt])) return result if __name__ == '__main__': idols = load_json() predict_results = predict(sys.stdin)[0,:] sorted_results = sorted([(i,e) for i,e in enumerate(list(predict_results))], key=lambda x:x[1]*-1) #print(sorted_results) for result in sorted_results: id, prob = result if float(prob)*100 == 0: prob += .01 print('{}\t: {}%'.format(idols[id]['idol_name'], round(float(prob)*100, 2)))
訓練データにはグリマスのデータを利用したため、ミリシタのデータを使って判定してみます。
あの、プロデューサー。私に手伝えることがあれば、言ってください。私も、この劇場…765プロ劇場のために、何かしたいんです。
(tf) deep-ubuntu-user@ubuntu-pc:~/millionlive_script_predictor$ echo 'あの、プロデューサー。私に手伝えることがあれば、言ってください。私も、この劇場…765プロ劇場のために、何かしたいんです。' | python millionlive_cnn_predict.py /home/deep-ubuntu-user/anaconda3/envs/tf/lib/python3.6/site-packages/h5py/__init__.py:36: FutureWarning: Conversion of the second argument of issubdtype from `float` to `np.floating` is deprecated. In future, it will be treated as `np.float64 == np.dtype(float).type`. from ._conv import register_converters as _register_converters Using TensorFlow backend. haruka id: 0 idol_name: 天海春香 chihaya id: 1 idol_name: 如月千早 yukiho id: 2 idol_name: 萩原雪歩 yayoi id: 3 idol_name: 高槻やよい takane id: 4 idol_name: 四条貴音 iori id: 5 idol_name: 水瀬伊織 ami id: 6 idol_name: 双海亜美 2018-02-13 01:25:02.667486: I tensorflow/core/platform/cpu_feature_guard.cc:137] Your CPU supports instructions that this TensorFlow binary was not compiled to use: SSE4.1 SSE4.2 AVX AVX2 FMA 如月千早 : 65.22% 萩原雪歩 : 22.49% 四条貴音 : 9.28% 高槻やよい : 2.58% 天海春香 : 0.39% 水瀬伊織 : 0.03% 双海亜美 : 0.01%
千早っぽさの確率が65.22%で、正しく(?)判定できているようです。
会場の兄ちゃん、姉ちゃーん!めーっちゃ愛してるかんね~!んっふっふ~♪
(tf) deep-ubuntu-user@ubuntu-pc:~/millionlive_script_predictor$ echo '会場の兄ちゃん、姉ちゃーん!めーっちゃ愛してるかんね~!んっふっふ~♪' | python millionlive_cnn_predict.py /home/deep-ubuntu-user/anaconda3/envs/tf/lib/python3.6/site-packages/h5py/__init__.py:36: FutureWarning: Conversion of the second argument of issubdtype from `float` to `np.floating` is deprecated. In future, it will be treated as `np.float64 == np.dtype(float).type`. from ._conv import register_converters as _register_converters Using TensorFlow backend. haruka id: 0 idol_name: 天海春香 chihaya id: 1 idol_name: 如月千早 yukiho id: 2 idol_name: 萩原雪歩 yayoi id: 3 idol_name: 高槻やよい takane id: 4 idol_name: 四条貴音 iori id: 5 idol_name: 水瀬伊織 ami id: 6 idol_name: 双海亜美 2018-02-13 01:32:43.176595: I tensorflow/core/platform/cpu_feature_guard.cc:137] Your CPU supports instructions that this TensorFlow binary was not compiled to use: SSE4.1 SSE4.2 AVX AVX2 FMA 双海亜美 : 64.92% 天海春香 : 27.7% 高槻やよい : 4.96% 水瀬伊織 : 2.26% 萩原雪歩 : 0.16% 如月千早 : 0.0% 四条貴音 : 0.0%
これもいい感じです。春香さんが「んっふっふ~♪」と言う可能性がわずかにある…?
せっかくなので…
リプライを飛ばすとセリフ判定の結果を返すTwitter botを作りました。EC2で稼働させようと思ったのですが、メモリを食いまくって安いインスタンスだとPython自体がすぐkillされて使い物にならないので、当分は自宅PCで動かします。運が良ければ稼働していると思います。
2018/09/05 追記: 当分、動かす予定はありません。Webアプリを作ろうかな、とは思っているのですが…
2019/06/06 追記: 再開しました。
余談
Keras 2.xになってから、Convolution2DがConv2Dに変わって、kernel_sizeの記述がタプルに変わったんだけど、カーネルの「幅と高さ」ってかいてあるから、1.xのnb_row, nb_colの記述する順序が逆になったんかなって思ってたら、結局順序は変わってなかったっていう…
— /skaipéngwɪnz/ (@skypenguins) 2018年1月29日
- EC2で学習させる際は、Jupyter Notebookを使いました。なぜか、matplotlibで可視化したlossとaccuracyのグラフがひとつになってしまいました…。
- GitHubに全コードをアップしてあります。
- 2018/09/05 追記: Google Colaboratoryで学習させたNotebookに差し替えました。
- 2019/06/06 追記: 精度6割で、ついに50人達成!PyTorchバージョンも公開しました。
このセッションでは我々の行ったいくつかの取り組みと、特にアイドルマスターミリオンライブをモチーフに、これまでのセリフデータから「そのキャラらしさ」を人工知能に学習させ、新規セリフの制作や監修を支援するシステムを開発した事例をご紹介します。
参考文献
SeleniumからHeadless Chromeを使ってみた - Qiita
青空文庫で作者っぽさ判定(KERAS + character-level cnn) - Qiita
艦これのセリフ分類をCNNでやる - にほんごのれんしゅう
character-level CNNでクリスマスを生き抜く - Qiita
CNNによるテキスト分類 // Speaker Deck
Kerasが2.0にアップデートされました。 - Qiita
Convolutional Layers - Keras 1.2.2 Documentation