読者です 読者をやめる 読者になる 読者になる

Monthly Hacker's Blog

毎月のテーマに沿ったプログラミング記事を中心に書きます。

ニコニコ動画のコメント次文字予測をChainer LSTMで実装した。

はじめに

こんにちは、さかぱ(@zacapa_23)です。9月下旬に内定式のスピーチに任命され、就職前からパワハラを受けていました。おかげさまで顔と名前を覚えてもらった反面、各所から「新人研修でも期待しているよ」とプレッシャーをかけられました。仕事をこなしても、新たな仕事が降ってくるのは本当だったんですね。

夏休みはMonthly Hackをお休みしていましたが、今月から再開です。テーマはニコニコデータセットを使って何かをするです。nico-opendataに様々なデータセットが用意してあり、いろいろできそうです。

僕のテーマは「ニコニコ動画のコメント次文字予測」。これは以下のQiita記事の前半部分でやっているもので、コメントの断片からコメントの続きを生成することができます。(下記リンクのQiita記事からの引用)

入力 生成結果
┗(^ ┗(^o^ )┓三
日本語 日本語でおk
/hi /hidden
おっく おっくせんまん!おっくせんまん!
らんら らんらんるー
ξ*・ ξ*・ヮ・*
わっふ わっふるわっふる
かわい かわいいww

残念ながらコードが公開されていないので、Qiita記事を参考にChainerで実装し、Github(こちら)で公開します。また最新Chainerの機能を使った実装例はまだまだ少ないので、多くの方々の参考になればと思います。
qiita.com

ニコニコデータセット

まずは以下のリンクの「ニコニコ動画コメント等データ」申請ページからデータを取得します。
情報学研究データリポジトリ ニコニコデータセット
f:id:ron_zacapa:20161011154120p:plain

前処理

次に前処理を行います。行う項目は以下の5つ。(Qiita記事からの引用)

  • 各動画の最後の10コメントを抽出
  • その内、500万コメントをランダムサンプリング
  • コメントをNFKC正規化し、末尾の同一文字繰り返しを2文字までに丸める(「テラワロスwwwwww」→「テラワロスww」)
  • 当該コメント群から利用される文字をカウントし、利用上位5000文字を語彙とする
  • 上位5000文字以外の文字を含むコメントは除外(ほとんど無い)

NFKC正規化については以下が参考になります。pythonでは標準モジュールのunicodedataで対応できます。
Unicode正規化 - Wikipedia
qiita.com

その1(maesyori1.py)

ここでは"各動画の最後の10コメントを抽出"を行います。圧縮されたコメントファイルは既に約50GBだが、展開すると約300GBになるので、tarfileを使って一時的に展開&読み込みをします。またとても時間がかかるのでmultiprocessingで並行処理で高速化。こうして集めたコメントはlast10comments.pklとして配列ごと保存されます。

def get_comment(targz):
    result = []
    tf = tarfile.open(targz, 'r')
    item_list = tf.getnames()[1:]
    for ti in item_list:
        f = tf.extractfile(ti).read()
        comments = f.decode('utf-8').split('\n')
        # 各動画の最後の10コメントを抽出
        comments = comments[-11:-1]
        for comment in comments:
            try:
                comment = eval(comment)['comment']
                result.append(comment)
            except:
                print('null')
    logging.info('end of {}'.format(targz))
    return result

processes = max(1, multiprocessing.cpu_count() - 1)
p = multiprocessing.Pool(processes)
results = p.map(get_comment, targz_list)

その2(maesyori2.py)

ここでは残りの前処理を行います。前処理のコードを2つにわけたのは、500万コメントのランダムサンプリングを再度行えるようにするため。NFKC正規化部分のループは使われている文字辞書を作成しているので並行処理をしていませんsample_texts.pklsample_vocab.pklが無事生成されたら前処理は完了です。

with open('last10comments.pkl', 'rb') as f:
    comments = pickle.load(f)
    comments = [c for tf in comments for c in tf]
    random.shuffle(comments)
    sample_comments = comments[:5000000]

vocab = {}
texts = []
for text in sample_comments:
    before = ''
    before_count = -2
    try:
        text = unicodedata.normalize('NFKC', text)
    except:
        text = ''  # NFKCができない文字(絵文字等)を含んでいる場合

    for i in text:
        if i not in vocab:
            vocab[i] = 1
        else:
            vocab[i] += 1
        if i == before:
            before_count += 1
        else:
            before_count = -2
        before = i

    if before_count >= 1:
        texts.append(text[:-before_count])
    else:
        texts.append(text)

tmp = sorted(vocab.items(), key=lambda x: x[1])
tmp.reverse()
new_vocab = [i for i, j in tmp[:5000]]

def huga(text):
    flag = True
    for i in text:
        if i not in new_vocab:
            flag = False
    if flag:
        return(text)
    else:
        return('')

processes = max(1, multiprocessing.cpu_count() - 1)
p = multiprocessing.Pool(processes)
new_texts = p.map(huga, texts)
new_texts = [i for i in new_texts if i is not None or '']

LSTM学習(nico_lstm.py)

コメント次文字の学習をするコードはchainer/examples/ptb/train_ptb.pyをベースに書きました。

ネットワーク構造

参考Qiita記事ではLSTMモデルの層、ユニット数がわからなかったので650→650のLSTMを2層と全結合を1層で作りました。ぜひ層を増やしたりパラメーターを変えたりして遊んでみてください。

class RNNForLM(chainer.Chain):

    def __init__(self, n_vocab, n_units, train=True):
        super(RNNForLM, self).__init__(
            embed=L.EmbedID(n_vocab, n_units),
            l1=L.LSTM(n_units, n_units),
            l2=L.LSTM(n_units, n_units),
            l3=L.Linear(n_units, n_vocab),
        )
        for param in self.params():
            param.data[...] = np.random.uniform(-0.1, 0.1, param.data.shape)
        self.train = train

    def reset_state(self):
        self.l1.reset_state()
        self.l2.reset_state()

    def __call__(self, x):
        h0 = self.embed(x)
        h1 = self.l1(F.dropout(h0, train=self.train))
        h2 = self.l2(F.dropout(h1, train=self.train))
        y = self.l3(F.dropout(h2, train=self.train))
        return y

学習データの加工

可変長ミニバッチでの実装は難しいのでコメント開始タグ, コメント終了タグを追加します*1。また参考Qiita記事でも以下のように記述していることから、同様のことをやっていると思います。

これは、「何も無い状態からの1文字目」の予測と、「コメント終了」も含んでいます。
出力層(5002次元)の出力について、

開始と終了タグを加え、全てのコメントを1列の文字列にして、ミニバッチ学習を行いました。コメントの平均長は約8文字だったので、各バッチサンプルの文字列の長さ(引数:--bproplen)を10にしました。

# Load nico-douga comments dataset
    with open('sample_vocab.pkl', 'rb') as f:
        vocab = pickle.load(f)
        vocab += ['<soc>', '<eoc>']  # start of comment, end of comment
        index2vocab = {i: word for i, word in enumerate(vocab)}
        vocab2index = {word: i for i, word in enumerate(vocab)}
    with open('sample_texts.pkl', 'rb') as f:
        texts = pickle.load(f)
        train = []
        for text in texts:
            train.append(vocab2index['<soc>'])
            for i in text:
                train.append(vocab2index[i])
            train.append(vocab2index['<eoc>'])

結果(play_lstm.py)

学習を行うと5000イテレーション毎に/result/lstm_model.npzが出力されます。この学習済みモデルを使って遊びます。仕組みとしては、毎回LSTMの状態をリセットし、"+入力文字列"を与え、""が返ってくるまで次文字を予測します。以下の結果は、GPU(TITAN X)で半日ほど回したモデルです。

$ python play_lstm.py
#vocab = 5002
#train = 58260416
>> ┗(^
┗(^o^ )┓三┗(^o^ )┓三┗(^
>> 日本語
日本語でお願いします
>> /hi
/hidded
>> おっく
おっくせんまん
>> らんら
らんらん
>> ξ*・
ξ*・ヮ・)ノ
>> わっふ
わっふー
>> かわい
かわいい
>>

おわりに

ネットワーク構造がブラックボックスのままでしたが、2層のLSTMでも十分にコメントを予測できました。副産物としてTrainerのカスタマイズ方法をだいぶ覚えられたので、他のアイデアもTrainerを使って実装してみたいですね。

p.s. cudnnを使ったNStepLSTMが新たに追加されたみたいなので、本稿のNStepLSTM版を実装中です。完成次第、コード及び記事を公開したいと思います。

ソースコード

github.com

*1:NStepLSTMでは簡単に解決できます。