LSTMを用いた麻雀に関するコメント文章の生成

まとめ

『ゼロから作るDeep Learning自然言語処理編』(斎藤康毅, 2018)7章では、PTBデータセットで学習した2層LSTMを用いた言語モデルによる文章生成が紹介されている。これがとてもワクワクして面白かったので、データセットを自作し(思ったより大変だった)、ハイパーパラメタを調整して麻雀に関する日本語の文章生成にトライした。また、モデルをほんの少しカスタマイズして、予測候補の可視化もしてみた。結果はまずまずで、比較対象がないので何とも言えないが、test perplexityは約90になった。文章の内容もコメントっぽいものが生成されて楽しいものだった。↓

start:で指定した言葉に続く単語をAIが生成している。「雷電の麻雀は」に続く言葉として、理想は「面白いですよ」か「面白いんです!」だったが、なんだか他人事のような感じになってしまった(笑)

コードとデータセットgithubに上げてみたので、よろしければ遊んでみてください。



――― 多くの有益な記事とオープンソースコミュニティに感謝致します。 ―――




簡単な説明

このAIは言葉の意味を理解している訳ではないので、生成される文章の内容はフィクションです。データから単語の並び方のパターンを学習し、確率的にあり得そうな文章をつくり出しています。データからは過度な批判や誹謗中傷などが含まれる文を極力取り除いてあります。

環境

私はプログラミングも機械学習も初心者なので冗長な箇所や間違いも含まれることをご了承ください。なにかお気付きの点がございましたらご教示頂けると幸いです。


データ収集

対象

今回はAbemaTV麻雀chのコメント取得を考えた。コメントを利用しようと考えた理由は、小説のような長文は少し難しそうだったから。ツイッターでも140文字あり、これもどう区切るかにもよるが少し長い。AbemaTVのコメントなら50文字なのでツイッターの約3分の1だし、50文字目一杯の長文コメントもほとんどない。さらに10月に開幕した麻雀プロリーグ、Mリーグが面白かったので、通称コメランズ(コメントする人たち)の再現を試みた。

文字コード

メモ帳のデフォルトの文字コードANSIとなっているが、これはCP932,Shift-JISなので、pythonutf-8と異なり扱いにくい。毎回変更するのも面倒なので、こちらの記事を参考にデフォルトの文字コードutf-8に変更した(※windows7の場合です!)。このメモ帳に取得したテキストデータを保存していく。pandasなどは使用していない。

スクレイピング

スクレイピングでの取得について、一言ことわりを入れた方がよいと考え、AbemaTVに許可を求めたが公式の回答は頂けなかった。一応法律的にはOKと思われるが、もしご迷惑が掛かるようであれば対応しようと考えている。なによりアベマが好きなので。

参考リンク


コードはざっくりとこんな感じ。

from selenium import webdriver
from bs4 import BeautifulSoup
import time

URL = 'https://abema.tv/now-on-air/mahjong'
MAX_GET = 120
# ブラウザをデスクトップに起動
driver = webdriver.Chrome(executable_path='C:\chromedriver_win32\chromedriver')
# URLページを開く
driver.get(URL)
for _ in range(MAX_GET):
    # ソースの取得更新
    html = driver.page_source.encode('utf-8')
    soup = BeautifulSoup(html, 'html.parser')

    # コメントテキストだけが入ってるタグを全て検索
    comments = soup.find_all('span','spanタグの名前')
    l_get = [com.text for com in comments]

    # ファイルに書き込む処理など

    # 60秒に1回取得
    time.sleep(60)
 # ブラウザ(driver)を終了
driver.quit()

「spanタグの名前」は不定期に変更されるので、chromeまたはfirefoxでコメント欄を開いた状態でCtrl + Shift + i を押してウェブコンソールを開き、該当箇所を確認する。タグ名は、ウェブコンソールの左上にあるマウスマークをクリックした後に、コメントのテキスト部分をクリックすると確認できる。

取得する間隔は60秒とかなりゆったりした設定にした。コメント履歴もある程度残り、それらが一気に更新されるということはあまりないと思ったので。サーバー負荷を考慮して最低1秒以上空けることが通例のようです。

ファイルに書き込むときは、それまでに取得したテキストデータを一旦読み込んで、重複コメントがないようにset()を適用する。ただこれをやると順番がぐちゃぐちゃになるので、会話データとかが欲しいときはこちらの記事のように処理する。

# 過去のコメント:l_read_comments
# 重複除去だけ
l_unique = list(set(l_get) - set(l_read_comments))
#=====
# 順番保持
l_unique = l_read_comments + l_get
l_unique = sorted(set(l_unique), key=l_unique.index)

さて、こうして約4万コメントを収集した。メモ帳に1行1コメントで保存されていて、これが4万行あることになる。自然言語処理に使うデータとしては非常に小さいものでしょう。

データの下処理

方針

ptbを参考にしながら作成するが、麻雀は数字を扱うので数字をNで代替する処理はせずに、出来るだけそのまま残す。その際、数字と単位を連結させて1単語として扱うことにした。しかし、予測候補を見るに、それらを分けても数字と単位の対応関係を学習可能かもしれない。 また、ptbでは未知語を<unk>(※日本語的に読むと完全にアレだがunknownの略)という特殊な文字で置き換えるが、置き換えなしでどれくらい学習出来るか見てみたかったので使わないでやってみることにした。

絵文字除去

多くの場合絵文字は扱いにくいものなので除去する。削除してよいことが分かっているものはre.subやreplace()で削除。❔や❕、㊗など、テキストで代替できるものは同様のやり方でそのままテキスト(?,!,祝)に置換する。

内容も含めて評価したいものは以下のようにして検出する。

import emoji
with open('データファイルのパス', 'r', encoding='utf-8') as rf:
    l_line = [l.strip() for l in rf.readlines()]

for line in l_line:
    for s in line:
        if s in emoji.UNICODE_EMOJI:
            # リストに入れる処理など

メモ帳では絵文字を表示できないので、□とか半角の・とかになっている。これを直接見て確認したいときは、そのファイルをブラウザにドラッグ&ドロップすればよい。

牌表記の統一

麻雀牌は字牌と数牌(マンズ、ピンズ、ソウズの1~9)から構成される。字牌(東南西北白發中)は漢字1字で表現すればいいので特に処理は必要ない。数牌は半角1~9+m/p/sの形式で表記を統一することにした。例えば~「1マン」は「1m」となる。これを正規表現で置換する。漢数字は記憶が曖昧なのだが、ほとんどなかったので手作業で修正したような気がする。

jaconv 0.2.4

import re
import jaconv as jac

# データファイルから読み込む:l_line

for line in l_line:
    l_mps = re.findall(r'[1-91-9]{1,14}[そソソぴピまママわワワ][うウーウーんンン]', line)
    for elem in l_mps:
        elem_mps = re.sub(r'そう|そー|ソウ|ソウ|ソー|ソー|S|s|S|そ|ソ|ソ', 's', elem)
        elem_mps = re.sub(r'ぴん|ピン|ピン|P|p|P', 'p', elem_mps)
        elem_mps = re.sub(r'まん|マン|マン|わん|ワン|ワン|M|m|M', 'm', elem_mps)
        # 数字を全角→半角変換
        elem_mps = jac.z2h(elem_mps, kana=False, digit=True)
        line = line.replace(elem, elem_mps)

{1,14}で直前の数字が最大14回まで繰り返している文字列にヒットするようにしている。麻雀の手牌は14枚で完成するので、例えば1223345777888mのようなチンイツの表現にも対応できる。

NGワードの処理

AbemaTVの方である程度禁止単語の処理は行われているので、直接的なものは比較的少ないが、その言い換え表現等も含めて地道に対応した。また、語彙数が増え過ぎないように、他の競技やスポーツ、時事ネタなど、麻雀と直接関係のない話題も極力弾くようにした。

また、弾いた文は別ファイルに保存しておくと、後で迷惑フィルタのようなものを作るときに活用できる。

表記ゆれ処理

「きたああああああ」「マジかwwww」「わーーーい!」などの繰り返し部分をどのように単語として扱うか。まず、3番目の例のような中膨れタイプは対応が難しかった。わーい、わーーい、わーーーい、・・・と別々のidを振ると語彙数が多くなるし、「わ」「ーーー」「い」と3単語として扱うと上手く学習出来るか自信が持てなかった。したがって、適宜言い換えるか語尾を伸ばす形に変更するなど、ややごまかし気味の対応を取った。

1番目の例は「あああ」という単語の組み合わせとして解釈するようにした。これで語彙数をいたずらに増やさず強調した伸ばし方に対応出来る。他の「ううう」や「おおお」も同様。

2番目のwwwの例も同様に処理することが出来たと思うが、ふと、wの使われ方がどうなってるか興味が湧いたので調べることにした(図1)。なおデータの見やすさを考慮して、横軸の連続数10以降の区間の長さを2としている。

f:id:psycholococolo:20190222200019p:plain:h300:w545
図1. wの連続数ごとの度数

いわゆる単芝が圧倒的に多い。そして意外にwwよりもwwwと3個の方が多い。w*48とか!!

それはともかく、これを基に適当に1,3,8,25の4つにまとめることにした。

from itertools import zip_longest
# データファイルから読み込む:l_line

for line in l_line:
    # ww+以上が複数出てくる文(例:マジかwwwすご過ぎwwwww)
    if len(re.split(r'ww+',line)) >= 2:
        line_l = line[0:line.find('ww')]
        line_r = line[line.rfind('ww')+2:len(line)]
        line_c = line[line.find('ww'):line.rfind('ww')+2]
        # line_cをwwとそれ以外の文字パーツに分ける
        lsm = re.split(r'ww+',line_c)
        lsm = lsm[1:-1]
        lsw = re.split(r'[^ww+]+',line_c)
        # wの数を調整する処理
        l_ww = []
        # ・・・
        # 連結
        center = ''.join([a+b for a,b in zip_longest(l_ww, lsm, fillvalue='')])
        line_cor = line_l + center + line_r

wの数の調整は文字列.count('w')でカウントして、17以上は25、5以上16以下は8、2以上4以下は3とif分岐してreplaceで置換する。置換したww文字列をl_wwにappendしていく。連結は中央部をパタトクカシーのように交互に組み合わせて、最後に合体。

その他処理

なるべく同じ意味の言葉は同じ表記となるよう地道に統一していく。日本語はひらがな、カタカナ、漢字が混在しており、且つコメントは反射的に書かれるものなので誤字脱字も多い。大したことやってないのにこの辺で早くも人間の生の言葉を扱うことの難しさを感じ始める・・・。

分かち書き

最初は以前入れたRMeCabでやろうとしたが文字化けにハマってしまい、次いでJUMAN++を使おうと思ったが上手く設定できず、結局簡単で使いやすいJanomeを使用した。とても便利!! 簡略辞書には人名と麻雀の専門用語を中心に登録した。<読み>の部分は「あ」とか適当に設定しても分かち書きには影響なかった。spaceを含んだ顔文字「└(^ω^ )┐ ┌( ^ω^)┘」などは別ファイルに分けて手作業で処理した。

ptbと同じ形式にしたいので、各文の前後をspaceで連結している。

from janome.tokenizer import Tokenizer
# データファイルから読み込む:l_line
# 簡略辞書を指定して初期化
td = Tokenizer("user_simpledic.csv", udic_type='simpledic', udic_enc='utf-8')
# 形態素解析
l_wakati = [' ' + ' '.join(td.tokenize(line, wakati=True)) + ' ' for line in l_line]

下処理が甘く、分かち書きが上手く出来なかった箇所も多かったので再度の修正を迫られた。表記を整えること、辞書をきちんと作ることが大事。

助詞の分け方について

例えば「これはリーチだよね」の「だよね」は、ツールを使うと正しくは「だ」「よ」「ね」と3つの助動詞と助詞に分けられるが、日常的な感覚だと「だよ」「だよね」で1単語にしてしまってもいいように感じる。これを90年代の大ヒット曲に乗せて「DA・YO・NE問題」と勝手に呼んでいる。それはいいとして、1単語としてまとめた方が選択された時に文が崩れにくいと考えて(保険を掛けたわけですね)、今回のデータでは、「だった」「だって」なども1単語として扱うことにした。

しかし、これも上述した数字と単位の話のように、予測候補を見ると、LSTMの強力さが伺え、細かく分けても助詞の使い方を学習できると思われる。また、まとめると語彙数が増える問題も生じてしまう。

区切り文字について

英語は元々spaceで各単語を区切るし、1単語中にspaceが含まれることは恐らくない。一方、日本語は基本的に単語は連結され、日本語でのspaceは句読点の代わりに用いられたり、顔文字に含まれたりする。したがって、区切り文字としてspaceを使うとちょっとややこしいため、<spc>というタグを用意して区切ることにした。しかし、後で気付いたがこれだとファイルサイズがデカくなるので、例外的なspaceは例えば■とかで代替しておいて、基本的にはspaceで区切ればよかったのだった。または、corpus中に現われない1文字で区切れるとよい。

また、法律的な観点から考えると、コメントを読んで楽しめる要素を減らすために<spc>を使用した方がよいかもしれない。この辺は難しいところ。

低頻度単語について

ここまでで大元のデータ(この後3つに分割する)は一応完成ということにして、corpusやvocabularyについてお手本のptbと比較してみる(表1)。

表1. corpusとvocabularyの比較
ptb 1_9_1
line 42,068 30,287
corpus 929,589 190,962
vocabulary 10,000 7,815
圧縮率voc/cor:% 1.08 4.09
出現1単語数 30 2,263
出現1/voc:% 0.30 28.96

下処理の結果、約3万行までデータが減った。1行あたりの単語数が異なるのでcorpusサイズに差が出た。

圧縮率は自分で勝手に作った指標で、いかに少ない語彙数(vocab)でより大きなcorpusをつくれているかを表したかったもの。値が小さい方が各単語の出現頻度が高くなる(ハズ)。

言語モデルは単語の並び方のパタンを学習するが、その際、corpus中に1、2回しか出現しない単語のパタンを学習することは難しいと考えられる。ptbは出現頻度1回の単語数が30個、vocabularyに対して0.30%しかない。自作データにおいてもバージョンを上げるごとに修正を重ねたが、現状30%弱までしか下げられなかった(図2)。大幅に減らすには低頻度の単語を含む文をcorpusからごっそり取り除いたり、ptbのように未知語として<unk>処理したりするのが有効と思われる。

f:id:psycholococolo:20190307100338p:plain:h300:w545
図2. 低頻度単語数のvocabに占める割合と変化

データの分割

先ほど完成した大元のデータをtrain,valid,testの3つに適当に分割する。分割する理由としては、モデルが学習データ(train)に過度に依存しないように、適宜validで検証しつつ、最後に未知のtestデータで評価し、汎化性能を確認するため。

corpus,vocabulary,低頻度単語数について見てみると、ptbのvalidとtestの低頻度単語数が意外と多かった(表2)。

表2. 分割された両データのcorpusとvocabularyの比較
ptb train valid test 1_9_1 train valid test
line 42,068 3,370 3,761 line 25,887 2,200 2,200
corpus 929,589 73,760 82,430 corpus 164,504 13,953 12,505
vocab 10,000 6,022 6,049 vocab 7,816 1,774 1,659
圧縮率voc/cor 1.08 8.16 7.34 圧縮率voc/cor 4.75 12.71 13.27
出現1単語数 30 2,036 1,883 出現1単語数 2,265 700 613
出現1/voc 0.30 33.81 31.13 出現1/voc 28.98 39.46 36.95

ハイパーパラメタの調整

time_size

分割されたデータについて、1行あたりの単語数を見てみると、ptbも自作データもそれぞれにおいてほぼ同じだった(表3)。

表3. 両データセットの1行あたりの単語数
ptb train valid test 1_9_1 train valid test
min 1 1 1 min 1 1 1
max 82 74 77 max 39 24 26
med 20 20 20 med 5 5 4
mean 21.10 20.89 20.92 mean 5.35 5.34 4.68
sd 10.14 9.98 10.19 sd 3.27 3.01 2.58

表3より、各trainデータの平均単語数について見てみると、ptbは平均21で標準偏差が10なので、大体10~30単語くらいの文が多い。自作データ1_9_1は2~8単語くらいの文が多い。モデルに与える際には改行を<eos>という特殊文字に置換するので平均が1単語分長くなる。本書のモデルは、corpusからミニバッチ数に応じてデータをズラしながら、時系列データを適当な長さ(単語数)で区切るTruncatedBPTTで学習を行う。その区切る長さはハイパーパラメタであるtime_sizeであり、本書では35単語と設定されている。 1行の平均的な単語数を基準に決められたかは定かではないが、大体シンクロしているようにも感じられたので(爆)、自作データのtime_sizeは10~15くらいを適当とした。長過ぎると勾配が届かなくなることもあるし、逆に短過ぎると単語の近い範囲のパタンしか学習できないものと考えられる。

隠れ状態ベクトル

本書でhidden_sizeと定義される隠れ状態ベクトルの次元数とは、LSTMの各セルの重みのこと。最終的にhidden_size:200とした。大きくするほど表現力は高まるが、モデルが複雑になり、過学習も引き起こしやすくなる。最初に本書のいくつかの例を参考に、表2のデータサイズも加味して大体200~400くらいと考えた。hidden_size:200,300,400でそれぞれ30~40epoch学習させた結果、test perplexityはほぼ同じで90程度だった。所要時間はGoogle ColaboratoryのGPUで約30~40分だった。hidden_size:400だと学習途中でtrain perplexityとvalid perplexityに差が出て、やや過学習気味の傾向が見られた。

カスタマイズ

予測候補の可視化

可視化とは言ってみたものの、単純に生成の過程を表示して見ているだけ。

class BetterRnnlmGen(BetterRnnlm):
    def generate(self, start_id, id_to_word, sample_size=30):
        word_ids = [start_id]
        x = start_id
        l_id = [i for i in range(len(id_to_word))]
        
        while len(word_ids) < sample_size:
            x = np.array(x).reshape(1, 1)
            score = self.predict(x).flatten()
            p = softmax(score).flatten()
            # dictを確率pについて降順ソート
            d = dict(zip(l_id, p))
            l_key_val = sorted(d.items(), key=lambda x: -x[1])
            # 上位15単語の予測候補と確率
            l_key = [i[0] for i in l_key_val[:15]]
            l_val = [i[1] for i in l_key_val[:15]]
            a_val = np.array(l_val)
            a_val /= a_val.sum() # 正規化
            l_words = [id_to_word[i] for i in l_key]
            sampled = np.random.choice(l_key, size=1, p=a_val)
            # xを更新することで連続的に生成する
            x = sampled
            word_ids.append(int(x))
            
            print(np.round(a_val * 100, 2))
            print(l_words)
            print(id_to_word[int(sampled)])

        return word_ids

generateメソッドの引数id_to_wordは単語IDと単語文字列から成る辞書を与える。l_idはidが0から順番に並んでいるリスト。pはstart_idがモデルに与えられた際の各単語が次に選ばれる確率。この2つで一旦辞書にして確率の大きい順にソート

そして、やや邪道(?)な気もするが、上位15単語の確率だけを取り出す。l_key_val[0]と最も高い確率の単語だけを選ぶようにすると、ほぼ同じような文章しか生成されない。逆に[:100]とかにすると文章にバリュエーションは出るが、文法的におかしな文も生成されやすくなる。この辺はトレードオフでしょうか。

a_valはそのままnp.random.choiceに与えると「確率の合計が1になってないですよエラー」が出るので正規化してから与える。

x = sampled は地味に大事で、最初よく分からず誤って削除したらstart_idの次(2番目)に来そうな単語が3番目にも4番目にもずっと生成され続けるという事態になった。こんな感じ↓

たかはる ツモれ ううう

「たかはる」というのは選手名で、「たかはるううう」とよくコメントで応援?絶叫?されている。この例では「たかはるツモれ」「たかはるううう」のように、本来はどちらも「たかはる」の次に来るべき単語が連続して生成されてしまっている。

予測候補と確率

生成過程を見てみると画像1のようになる。

f:id:psycholococolo:20190305120947p:plain

画像1. 生成過程の予測候補と確率(助詞)


画像1では、「なん」という単語をモデルに与えたときの2番目に来る候補として、「だよ」20.40%、「だから」12.29%など、予測候補とその確率が表示されている。ここではnp.random.choiceの結果「です」5.09%が選択されている。「<eos>」は文末を表していて、最終的には改行に置換される。したがって、合わせると「なんですか」という1文(?)が生成されたことになる。

単語の分け方について、2番目の予測候補を見ると、「だよ」「だよなぁ」「だよな」「だな」など、同じような表現が同時刻(時刻は2番目の候補を選ぶ段階・ステップという意味)に出てきている。これを「だ」+「よ」などと基本通りに最小単位の助詞・助動詞に分ければ、この時刻では「だ」に集約でき、もっと他の予測候補(なん+!, なん+か)も選択肢に入って来ることが期待できる。その場合「だ」に続いていた「よ」「な」は3番目以降の予測候補として、より高い確率で表れてくるでしょう。このように細かく分けることで同時刻での語彙数を減らせるものと考えられる。

数字と単位についても、画像2のように、よく一緒に用いられているのであれば学習可能であると思われる。まぁ、というか助詞であれ数字であれ、すべて数値として扱っているのでそこに意味的な違いはないということなのでしょう。

f:id:psycholococolo:20190305140156p:plain

画像2. 生成過程の予測候補と確率(数字と単位)

▲データの下処理:方針

▲助詞の分け方について

おわりに

今回生成された文はどこなく曖昧で、バーナム効果っぽい印象も受ける。もちろんそこにはデータの作り方も影響しただろうし、予測候補を上位15単語に限定したことで便利に使える(概して曖昧な)表現が増えたことも考えられる。一律に15単語と区切らないで、softmaxに渡す前のscoreをk-means法で分類するということも可能かもしれない。ただ、それは最終的な結果には影響するかもしれないが本質的ではない。

そもそもコメントは映像から伝わる情報に対する反応なので、元の情報とか出来事自体は映像として共有しているためテキストデータには含まれにくい。したがって、具体的に何を想定しての発言なのかをコメントだけからは掴みづらくなる。

「こ・き・くる・くる・くれ・こい」とか呪文のように唱えてたのを少し思い出した(青春)。当時は無味乾燥なものだったが、AIで文章生成する基礎知識になるなんて・・・。

twitterボットにしたのは遥か遠い将来にseq2seqなりでリプライを利用して麻雀の会話が出来る機能も付けたいなと思ったため。いつになるやら。次はAttention, Transformerをやりたい。

参考

ゼロから作るDeep Learning②自然言語処理編(斎藤康毅, 2018)