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

Line 1: Error: Invalid Blog('by Esehara' )

または私は如何にして心配するのを止めてバグを愛するようになったか

>> Zanmemo

あと何かあれは 「esehara あっと じーめーる」 か @esehara まで

Word2Vec + MeCabで「ボケる」ための単語候補をピックアップするやつをやってみる

近況

f:id:nisemono_san:20150304141727j:plain

はじめに

最近、ちょっと大喜利を始めていて、如何に面白いことを言えるのか、ということを考えたりしているんだけど、考えてみれば、自分は少しプログラミングができるし、むしろ形態素解析や自然言語処理という観点から「質問」と「ボケ」を考えてみると面白いかもしれない、と思って、力技でそういうことをやってみた次第。

今回の方針

とはいえ、何となく「質問に対して上手いボケを返してほしいな」ということであるならば、それこそ単語のランダム検出でもいいという話になってしまうので、ある程度仮説を立てて実装する。今回の仮説としては、「ある文が連想する知識の、派生する知識がその文と結びつけられた場合、人は上手いと思うのではないか」ということだ。

どういうことか。

例えば、謎かけの場合、「Aとときまして、Bととく。その心はCです」と言った際に、一見無関係の文(あるいは単語)が、Cという意味づけによって接続することによって上手い、と思わせることが出来る。とするならば、つまり「Aというお題に対して、Bを連想し、さらにそこから連想されるB'が接続された場合」において、人は上手いと思うのではないか。

例えば、手元には某大喜利サイトの答え一覧があるのだけれど、その中の一つに、こういう答えがある。

お題: 百人の弟がいる生活にありがちなこと 答え: 鬱の字になって寝る

これは秀逸で、解説すると野暮になるけれど、要するに「川の字になって寝る」という家族の知識と、「三人は川の画数が書け、百人いれば画数の多い漢字が書ける」という知識の結果として、「鬱の字になって寝る」という答えになっていると思う。

上記のように上手くいかなくても、近い知識と近い知識の表現が上手く摘出できれば、そこそこ面白い解答を行ってくれるのではないか、というのが現状の仮説。

どういうことをやるのか

まず重要なのは最初に教え込む知識だ。

ただ、「知識を教え込む」といっても、たいそれたことが出来ないので、Word2Vecを利用し、「ある問いと質問を合わせた文字列の単語の距離」を、その単語に対する「思い込みの強さ」と仮定することにする。

そこで、某サイトの10万近い、問いとボケをスクレイピングし(スクレイピングについては過去ブログ記事を参照)、そこからMeCabを利用して単語に分割する。

MeCabの前処理

とはいえ、こういう大喜利のお笑いというのは、流行りや最近の風俗に関する話題が多く、そのままやってしまうと、例えば「ももいろクローバー」を与えた際には

もも   名詞,一般,*,*,*,*,もも,モモ,モモ
いろ  動詞,自立,*,*,一段,命令ro,いる,イロ,イロ
クローバー 名詞,一般,*,*,*,*,クローバー,クローバー,クローバー

といった処理がされてしまう。これだと、正確な単語処理が出来ないので、この手の辞書を何らかの形で補完する必要がある。その辺りに関しては、このブログで説明するよりも、この記事を参考にしたほうが早い

ボケを分かち書きする

Rubyにはnattoというライブラリがある。この後書くけれど、コードの統一性の問題上、本来ならPythonで統一したほうがいいけれど、今回はあえてRubyのほうを使ってみることにした。ちなみにnattoのインストールの仕方はこの記事を参考にするといいかと

ただ重要なことが一つだけあって、例えばバイナリから直接インストールするという方法もアリといえばアリなんだけど、Pythonとスクリプトを同居させる場合、この辺の参照がぐちゃぐちゃになる可能性が高いので、暴力的にバイナリからビルドし、さらにapt-getをインストールするという知性が敗北したようなやり方で突破した。最初からapt-getに統一するとか、そのほうがいいとは思う。

で、無事インストールして、下のコードで分かち書きする(ちなみに元ソースは、Google Driveにもバックアップが欲しいということもあって、一度CSVに変換しています)。分かち書きのときに気をつけたいことは、動詞の「変わってる」とか、その活用は「変わる」みたいに整えたほうが、単語が統一されてより精度が高くなる気がするので、そのようにしている。

require 'natto'
nm = Natto::MeCab.new('-u onomasticon.dic')
open("oogiri.csv") do |file|
  140000.times do
    l = file.gets
    next if l.nil?

    no, title, answer, _, _ = l.split(',')
    text = title.to_s + answer.to_s
    text = "" if answer == "投稿なし" || text.nil?
    nm_result = nm.parse(text)
    puts nm_result.split("\n")
      .map {|line| line.split("\t")}
      .select {|line| line.size > 1}
      .map { |line|
      raw = line[1].split(",")[-3]
      raw == "*" ? line[0] : raw 
    }
      .join(" ")
  end
end

これを使うと、下のような分かち書きのテキストが作られる。

案山子 が 文句 も 言う ぬ に 経つ 続ける 理由 。 動く と 童話 に する れる
優しい嘘 の 具体 例 会う たい がる てる た サボテン の 人 だ よ
最後 まで 話 が わかる ない た
そろそろ アレ の 季節 が やってくる ます た ね アレ の バッタ で 泣く 兄弟
ラーメン を ぶっかけ られる て 一言 口コミ 通り だ
先生 、 僕 の 腸 どうでしょう
こたつ の 中 で 喧嘩 を する ない よう に する 方法 ちゃんと 目 を 見る
人面疽 が 顔 を 出す た 時 の 対処 法 ともだち メダル あげる
花瓶 の すごい ところ 誰 が 持つ て も 花瓶

gensimを使う

本来的には、Word2Vecの実装として、Pythonにはgensimというライブラリがある。これの使い方はword2vecで意味の足し引き - moguranosenshiについて詳しい。本当だったら、Ruby実装のほうを使ってみたかったけれども、これについてはよくわからなかったので調べていない。もし詳細が解る人がいれば教えて頂ければと。

で、基本的には上の分かち書きしたテキストを、Python実装に対してぶっこんでやればいいだけのことになる。

この辺は何度も試行錯誤するのが面倒だったので、一気に保存できるような関数を作成した。

from gensim.models import word2vec
def resave():
    sentence = word2vec.Text8Corpus("oogiri_wakati.txt")
    result_model = word2vec.Word2Vec(sentence)
    result_model.save("oogiri_gensim.model")
    model = result_model

なぜWord2Vecなのか

単純に使いやすかったというのが一つあるけれども、例えばある単語から要素を引いたり、足したりできるということもあって、変に自分で実装するよりは、複数の指定から類似の単語を取り出す、といったことが簡単に出来るWord2Vecはプロトタイプに向いているなという感じで使っている。

model = word2vec.Word2Vec.load("oogiri_gensim.model")
class WordManager(object):
    @classmethod
    def parse(self, words, negative=[]):
        most_similar = [w for w in model.most_similar(
            positive=words, negative=negative)]
        return filter(
            lambda x: x,
            [word_kind(m) for m, _ in most_similar])


def s(words, negative=[], recur=True):
    try:
        word_and_kind = WordManager.parse(words, negative)
        for w in [w for w, _ in word_and_kind]:
            print w
        if recur:
            for word in [w for w, _ in word_and_kind]:
                s([w], [], False)
    except KeyError:
        pass

エラーを握りつぶしていたり、modelをグローバルに置いていたり、dryではないのはご愛嬌。

このWordManagerクラスも、最終的には文章を生成したいための実装がぶっこまれているのだけれど、ここでは省略している。これで、ある単語に対して、ボケとして距離の近い単語が出てくるようになる。

とはいえ、問題は、ある問いに対して、その単語候補を選出することだったので、答えを分かち書きして単語リストにする必要がある。なので、Python版のMeCabも用意して、下のように分割できるようにする。

tagger = MeCab.Tagger("-Ochasen")
def question(text):
    result = tagger.parse(text).decode('utf-8').split('\n')
    result = map(lambda x: x[0],
                 filter(lambda x: x,
                        [word_and_kind_parse(l) for l in result]))
    s(result)


def word_and_kind_parse(line):
    line_word = line.split("\t")
    if len(line_word) < 2:
        return None
    w, _, _, k, _, _ = line_word
    k = k.split(u"-")[0] 
    if k == u"助詞":
        return None
    return w, k               

このようにすることによって、直接、お題を入力することで、候補の単語が出てくるようになる。

ただ、ちょっとだけ注意すると、MeCabの入力はStringでいいのに、word2vecUnicodeじゃないと上手くいかないところは注意したい(Python 2.7の場合)。

さて、これでだいたいの準備が整った。あとは遊ぶだけである。

ちょっとしたメモ ── word2vecでの類似単語の特性について

ただ、10万くらいあると、大体「妥当な推測になってしまう」ので、質問から類似した単語から、さらに類似した単語を取り出すみたいなことをやってみた結果、だいたい同じような単語が並ぶ場合が多いことに気がついた。そりゃそうで、たぶん下のようなことになっているんだろう、という勝手な推測を立てている。

f:id:nisemono_san:20150425093546j:plain

要するに、AからBとCが近い場合、BからもAとCが近いので、結局のところ、単語は重複しがちなんだと思う。

なので、使われた単語の情報はどこかに持っておいて、それと重複する単語は取り除く、みたいな処理をしたほうが、ポップした単語が手に入ると思う。例えば下のような感じだ。

f:id:nisemono_san:20150425093603j:plain

コードは別途密結合しているので、ちょっとパッとは出せないけど、考え方としてはこんな感じ。ただし、これだと再現性が低くなって、比較的ランダムな結果になって、「本当にこいつ、類推してんのか」という気分になるので、スコアがある一定以下だとやり直すとか、そういうことが必要になると思う。具体的には下のようになる。

f:id:nisemono_san:20150425134855p:plain

暫定的なおまけ ── ボケの作り方

ここまで来ると、ある疑問が起きる。

単語を接続して、それらしいボケを作るというのは、まだ人間の「ユーモア」が介入する余地が出てきてしまう。機械に文章を作らせて、その上で「自然なボケかどうか」をこちらで決定するほうが良い筈なのではないか。

そこで、「拙い知識」ながらも、ボケをちょっと作ってみる。 参考になるのは品詞の連結リストだろう。長文になれば大変なことになるけれど、少ない品詞であるならば、比較的「日本語として自然な文章」になることが期待できる。事実、過去のボケを品詞で分析した結果、下のようになる。

5792 名詞
4549    名詞,名詞
4242    名詞,助詞,名詞
3576    名詞,助詞,名詞,助詞,動詞
2713    名詞,助詞,動詞
1553    名詞,助詞,名詞,動詞
1480    名詞,名詞,名詞
1203    名詞,助詞,動詞,助詞,動詞
1151    名詞,助詞,動詞,助動詞
1062    名詞,助詞,名詞,助詞,動詞,助動詞

抜粋してみたところだけ見てもわかるように、基本的には名詞を中心に、助詞をはさんで助詞か動詞をつないでいけばいいことがわかる。で、助詞といっても、大抵は「名詞+が」としておけば無難だと思われるので(名詞が最初に来る場合、そいつは主語だろうみたいな決め打ち)、ボケパターンとして、品詞を決め打ちしている。

    boke_pattern = [
        [u"名詞"],
        [u"名詞", u"名詞"],
        [u"名詞", u"が", u"名詞"],
        [u"名詞", u"が", u"動詞"],
        [u"名詞", u"が", u"名詞", u"な", u"名詞"],
        [u"名詞", u"が", u"名詞", u"助詞", u"動詞"],
    ]

あとはこれからrandom.shuffleして、それを回して、品詞リストから取り出していけば文章が出来る。例えばTweetのようにだ:

課題

とはいえ、上のTweetは上手くいったほうで、全体となると、まだまだボロボロではある。まだまだ精度が足りないので、平均的な文章を生成するのは難しいので、botとかでやりとりするのは困難であろうということが一つ。

それと、会話とかの場合、ある程度無限であり、また知らないお題に対して一定の答えを出すのも、柔軟性の一つなのだが、今回の場合、word2vecで引っかからない場合は、文章が作れなくなるという欠点が存在している。なので、もしキーワードが引っかからない場合に、どういうボケを用意するべきなのか、あるいは、どういうところから知識をピックアップするか、ということが今後の課題になるであろう、ということ。

また、上記の「二つ超えた知識がつながったときに面白みを感じる」という問題に関しては、確かにそれを愚直に実装するとランダム性が生まれ、回答が豊富になるのだが、場当たり性を感じざるを得ず、既存のよりはマシだけれども、しかし本当に「なるほど、そういうつながりがあったか」というまでには、まだまだ精度が足りないという問題がある。なので、もう少しパラメータを調節し、そのランダム性をどう制御するかということが求められると思う。

もう一つとしては、もう少し「助詞」について、あるパターンならこいつとこいつだろう、みたいに指定できると便利かもしれない。現状としてはあまり柔軟性が無いのでアレな感じだ。(例えば、名詞+助詞+名詞ならば、「猿の歯茎」という繋ぎ方だってある筈なのである)

あと、実装をガーッと進めると本当に場当たり的なコードになって汚らしくなるので、研究のコードというのはこういう風にして生まれるのかもしれないな、と思ったりもした。

それと、RubyとPythonを行き来しまくったので、結果「どっちでもええやん」みたいに最後なっていったのが面白かった。

ついでなので、ボケのコア部分のソースコードを参考に置いておきます。よかったらどうぞ。

買うことになった本

Python による日本語自然言語処理に、「第4章の文書生成、第5章の辞書では、それぞれ深層/表層生成、および辞書設計の詳細に触れるなど、幅広い応用について踏み込んで書かれている。」という紹介があったので買った。

自然言語処理―基礎と応用

自然言語処理―基礎と応用

あと下の本は積ん読しっぱなしだけれど、何か知見を得られる気がするので読んでおきたい:

言語処理のための機械学習入門 (自然言語処理シリーズ)

言語処理のための機械学習入門 (自然言語処理シリーズ)

その他読んだもの

新・自然科学としての言語学―生成文法とは何か (ちくま学芸文庫)

新・自然科学としての言語学―生成文法とは何か (ちくま学芸文庫)

直接実装に役に立つわけではないのだけれども、自然言語を形式的に考察するとどういう風なモデルが与えられるのか、ということに関しては非常にタメになったのでよかった。とはいえ、これだけだと、本当に「解った気になる」という感じではあるので、もう少し詳しい内容を知りたいなあ、と思ったので、余裕があったら勉強するかもしれない。