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

pixiv小説で機械学習したらどうなるのっと【学習済みモデルデータ配布あり】

こんばんは。プログラマーのhakatashiです。2ヶ月ぶりですね。普段はpixivコミックpixivノベルの開発を手伝っていますが、今回もそれとは全く関係ない話をします。

pixiv×機械学習

「機械学習」「深層学習」といった単語がプログラマーの間でも広く囁かれるようになって既に幾年月経とうとしています。ここpixivの開発陣においても、人口に膾炙する機械学習の輝かしい成果に関する話題は尽きることがなく、常に最新のトピックに目を光らせています。

そんな取り組みの一環として、今回は弊社が運営するpixivの小説機能の投稿データで機械学習を行ってみたので、簡単に紹介したいと思います。

※この記事における「pixiv小説」とは「pixivの小説投稿機能およびそれによってpixivに投稿された小説」を指し、「pixivノベル」とは異なります。

word2vecとは

自然言語処理における機械学習の応用は古くから様々な手法が提案されていますが、今回試したのは比較的最近発明されたword2vecおよびparagraph2vecの実装です。

word2vec*1とは、ニューラルネットワークを利用して自然言語を解析し、文中に出現した単語の潜在表現をベクトルの形で学習するための手法です。2013年に Google Research (当時)の Tomas Mikolov らによって発表され、一気に世界を騒がせました。実装もすでにオリジナルのものを含めて複数のものが公開されており、気軽に試せる特徴抽出の手法としても注目を集めています。

また、これを発展させた手法として2014年に同氏らによって発表されたのがparagraph2vec*2です。paragraph2vecはword2vecで学習した単語の表現ベクトルをもとに、任意長の単語列、つまり与えられた複合語・文・段落・文章をベクトルとして表現するというものです。これによって単語だけでなく文章の特徴を学習して分類機を作ったり、段落が意味する内容をおおまかに表現したりといったことが可能になってきます。

これらの潜在表現は、それぞれの単語や文章の「意味」をよく表現するものとされています。

学習内容

というわけで、今回はpixiv小説の本文を学習データとして用いて word2vec, paragraph2vec を動かしてみました。

学習概要

  • 学習データ: 2016年6月16日から7月15日に投稿された公開状態のpixiv小説94380件
    • 約2.1GB = 約7億文字
  • 事前処理: mecab-ipadic-NEologdを用いて形態素解析し、活用を落とす
  • 使用した実装: gensim
  • 学習パラメーター
    • size=100
    • alpha=0.025
    • window=8
    • min_count=5
    • max_vocab_size=None
    • sample=0
    • seed=1
    • workers=36
    • min_alpha=0.0001
    • dm=1
    • hs=1
    • negative=0
    • dbow_words=0
    • dm_mean=0
    • dm_concat=0
    • dm_tag_count=1
    • docvecs=None
    • docvecs_mapfile=None
    • comment=None
    • trim_rule=None
    • iter=20

学習には AWS EC2c4.8xlargeインスタンスを使用し、最強のマシンを使って最速で学習することを目指しました。CPU 36コア、メモリ60GBの怪物マシンです。スポットリクエストで3時間ほど立てましたが100円程度のコストで済んだのでとてもコスパがいいです。

学習経過

実時間68分、CPU時間にして1132分を費やして学習を完了し、220MBのモデルデータを吐き出しました。

このデータで少し遊んでみます。

近傍ベクトルの抽出

学習した潜在表現が意味をよく表すならば、潜在表現が似ている単語は意味も似ている、という推測ができます。実際にいくつかの単語をピックアップして近傍ベクトルを探索してみます。

In [3]: model.most_similar(positive=['本丸'])
Out[3]:
[('鎮守府', 0.9428098797798157),
 ('町', 0.9225599765777588),
 ('世界', 0.9113290309906006),
 ('会社', 0.9099076390266418),
 ('屋敷', 0.9055248498916626),
 ('カルデア', 0.9024497866630554),
 ('教会', 0.9000600576400757),
 ('旅館', 0.888569712638855),
 ('事務所', 0.8871119022369385),
 ('施設', 0.8863613605499268)]

In [4]: model.most_similar(positive=['突く'])
Out[4]:
[('穿つ', 0.898361086845398),
 ('抉る', 0.879947304725647),
 ('貫く', 0.85957932472229),
 ('弄る', 0.8574050664901733),
 ('えぐる', 0.852638840675354),
 ('嬲る', 0.8470295667648315),
 ('掻き回す', 0.8393361568450928),
 ('暴く', 0.8372935056686401),
 ('揺さぶる', 0.8311788439750671),
 ('突き飛ばす', 0.8298511505126953)]

In [5]: model.most_similar(positive=['かわいい'])
Out[5]:
[('可愛い', 0.9047838449478149),
 ('かっこいい', 0.8605487942695618),
 ('カッコイイ', 0.7733113765716553),
 ('ずるい', 0.773119330406189),
 ('エロい', 0.7652394771575928),
 ('Cawaii!', 0.7626818418502808),
 ('格好いい', 0.755556583404541),
 ('気持ちいい', 0.7163408398628235),
 ('恐い', 0.71600341796875),
 ('羨ましい', 0.7143834829330444)]

In [6]: model.most_similar(positive=['クチュクチュ'])
Out[6]:
[('ぴちゃぴちゃ', 0.9256783723831177),
 ('ピチャピチャ', 0.9182118773460388),
 ('グチュグチュ', 0.9131280779838562),
 ('にちゃにちゃ', 0.8425255417823792),
 ('ジュルジュル', 0.8150238394737244),
 ('ジュポジュポ', 0.8050651550292969),
 ('クチャクチャ', 0.7919227480888367),
 ('くちゃくちゃ', 0.79010409116745),
 ('カチャカチャ', 0.7856902480125427),
 ('ヌチャヌチャ', 0.7851131558418274)]

In [7]: model.most_similar(positive=['♡'])
Out[7]:
[('♡♡', 0.9300237894058228),
 ('♥', 0.9297479391098022),
 ('❤', 0.889946699142456),
 ('…♡', 0.8837683200836182),
 ('〜', 0.8686712980270386),
 ('♡♡♡', 0.8685170412063599),
 ('…!', 0.8578859567642212),
 ('!!', 0.8562715649604797),
 ('!…', 0.8559427857398987),
 ('……♡', 0.8542546033859253)]

ご覧の通り、おおむね入力された単語と似た意味の単語をうまく学習できているようです。

ベクトルの加減算

word2vecの威力はこれだけじゃありません。単語の「意味」をベクトルとして表現できるということは、ベクトルどうしを足したり引いたりすることによって「意味」の加減算を実現できるということです。

具体的な例を挙げてみます。たとえば「うれしい」から「笑う」を引いて「泣く」を足す――といった計算を考えてみます。

f:id:hakatashi:20160912162131p:plain

この演算が何を表すのかというと、「『笑う』にとっての『うれしい』は、『泣く』にとっての『何』か」、すなわち、「『笑』っているときに『うれしい』ならば、『泣』いているときはどういう感情か」という意味にとらえることができます。

人間に尋ねたら「悲しい」「つらい」などの答えが返ってくるであろう質問ですが、果たして……。

In [8]: model.most_similar(positive=['うれしい', '泣く'], negative=['笑う'])
Out[8]:
[('悲しい', 0.7828458547592163),
 ('さみしい', 0.7670903205871582),
 ('心細い', 0.7660514116287231),
 ('嬉しい', 0.7657840847969055),
 ('辛い', 0.760547399520874),
 ('つらい', 0.7518221735954285),
 ('可笑しい', 0.7516209483146667),
 ('淋しい', 0.7475409507751465),
 ('哀しい', 0.7413027882575989),
 ('寂しい', 0.7387726902961731)]

なんと、「悲しい」「つらい」を含めて、「泣く」につながる形容詞がずらりと出てきました。これがword2vecの真の力です。単語の意味をベクトルとして表現することによって、これまで統一的に扱うことができなかった単語同士の関係をただの加減算で導くことができます。

もちろん、感情や行動に限らず、単語同士の任意の関係に対して適用できます。せっかくなので、pixivっぽい単語でいろいろ試してみましょう。

# 「刀剣男子」 - 「男」 + 「女」= 「艦娘」
In [9]: model.most_similar(positive=['刀剣男士', '女'], negative=['男'])
Out[9]:
[('艦娘', 0.9148841500282288),
 ('使用人', 0.8912219405174255),
 ('サーヴァント', 0.8801556825637817),
 ('魔族', 0.8772220611572266),
 ('兄弟', 0.875967264175415),
 ('英霊', 0.8656594157218933),
 ('弟', 0.8649518489837646),
 ('クラスメイト', 0.8640071153640747),
 ('仲間', 0.8639708757400513),
 ('遊女', 0.8562957048416138)]

# 「チョロ松」 - 「おそ松」 + 「おそ子」 = 「神通」
In [11]: model.most_similar(positive=['チョロ松', 'おそ子'], negative=['おそ松'])
Out[11]:
[('神通', 0.8879709839820862),
 ('川内', 0.8838446736335754),
 ('霧島', 0.8780333399772644),
 ('森山', 0.8707031607627869),
 ('叢雲', 0.8702427744865417),
 ('羽黒', 0.8701692819595337),
 ('龍驤', 0.8699836730957031),
 ('梓', 0.8688686490058899),
 ('アスカ', 0.8686019778251648),
 ('中村', 0.8683927655220032)]

いい感じですね!

SVMによるタグ分類

ここまでの例は全てword2vecでしたが、ここからさらに一歩踏み込んでparagraph2vec*3を使用することによって文書に対するベクトル表現を学習することができます。このベクトル表現を用いてSVMでタグ分類を行ってみます。

pixiv小説における使用率の高いタグ上位100件を取得し、それぞれのタグが付与されている小説とそうでない小説を線形SVMで分類してみました。

結果は学習データの2割をテストとしてランダムに抽出したホールドアウト検証によって評価しました。分類結果の陽性尤度比が高かったタグを挙げると以下のようになります*4

タグ名 陽性尤度比
図書館戦争 3773.205882352229
ラブライブ! 2780.4444444430687
ガールズ&パンツァー 2359.97777777806
火アリ 967.4042288557961
とうらぶちゃんねる 846.2478097621728
暗殺教室 742.8611111111047
アイドルマスターシンデレラガールズ 713.9428571428011
やはり俺の青春ラブコメはまちがっている。 706.7990610328529
勝デク 682.1571428571375
ワールドトリガー 640.50000000006

逆に陽性尤度比が最低の1となったタグとして、「刀剣乱舞」「ヘタリア」「R-18G」「BL」「百合」などがありました。

傾向として、「R-18」「BL」「百合」のような物語の指向を表すタグは陽性尤度比が低く、「図書館戦争」「ラブライブ!」「ガールズ&パンツァー」のような(特に男性向けの)作品名を表すタグは陽性尤度比が高いという結果になりました。これは作品タグにおいては登場人物名のような特定単語の出現から容易に特徴を抽出できることが理由であると考えられます。また女性向け作品の検出性能が低かった理由として、女性向け作品には棲み分けという文化が存在し、そもそも学習データに適切にタグが付与されていない可能性が挙げられるでしょう。いずれにせよ、わずか100次元の特徴ベクトルからちゃんとタグを分類できることが確認できました。

今回は試しませんでしたが、男性向け/女性向けの分類や、評価値に対する回帰分析などへの応用が期待できそうです。

fastTextを用いた学習

また、先日 FaceBook Research よりfastTextという実装が公開されました。これは基本的にword2vecと同様の機能を提供するソフトウェアですが、Subword Information*5 という最近発表された論文の成果を取り入れており、未知語に対しても文字列からその意味ベクトルを推定できるという触れ込みです。

先ほどと同じ学習セットを用いて、fastTextでも学習を試してみました。

$ time ~/fastText/fasttext skipgram -input novel2vec.txt -output fasttext-model -thread 36
Read 371M words
Number of words:  156209
Number of labels: 0
Progress: 100.0%  words/sec/thread: 100665  lr: 0.000000  loss: 0.825489  eta: 0h0m

real    9m36.085s
user    308m1.732s
sys     0m15.748s

速い! paragraph2vecに対応する学習をしていないのでdoc2vecと単純に比較はできませんが、それでも10分弱で2GBのデータを学習できるのは非常に速いように感じます。

まずは既知語に対して近傍ベクトルを調べてみます。

$ python eval.py -m fasttext-model.vec -d queries_vectors_known.txt
Similar words for 舞風:
[{'word': '野分', 'd': 0.80586285154833592}
 {'word': '萩風', 'd': 0.77152429241995357}
 {'word': '不知火', 'd': 0.76732473622749275}
 {'word': '阿武隈', 'd': 0.75577552174637097}
 {'word': '那珂', 'd': 0.74476587290908336}
 {'word': '磯風', 'd': 0.73501355402574298}
 {'word': '江風', 'd': 0.73258339829952712}
 {'word': '五月雨', 'd': 0.73040114439550574}
 {'word': '酒匂', 'd': 0.72673208649685184}
 {'word': '浜風', 'd': 0.72386719312480063}]
Similar words for 獅子王:
[{'word': '同田貫', 'd': 0.91467226350782183}
 {'word': '御手杵', 'd': 0.90396129321683871}
 {'word': '鶴丸', 'd': 0.88538701034923151}
 {'word': '国広', 'd': 0.88395342791147413}
 {'word': '山伏', 'd': 0.88346092891505834}
 {'word': '山姥', 'd': 0.87760788840948545}
 {'word': '薬研', 'd': 0.87609461082091677}
 {'word': '堀川', 'd': 0.87199806127661961}
 {'word': '蛍丸', 'd': 0.86740636107129265}
 {'word': '小狐丸', 'd': 0.86220844858746126}]
Similar words for おそ松:
[{'word': 'チョロ松', 'd': 0.97886221691116659}
 {'word': 'カラ松', 'd': 0.97030669487438259}
 {'word': '一松', 'd': 0.96704631743523262}
 {'word': 'トド松', 'd': 0.95240219973511631}
 {'word': '十四松', 'd': 0.93996749326206075}
 {'word': '兄さん', 'd': 0.9116297869636153}
 {'word': '末弟', 'd': 0.84806329690801963}
 {'word': 'トッティ', 'd': 0.8408382595758942}
 {'word': 'トド', 'd': 0.83302829456087446}
 {'word': '松野', 'd': 0.82494732162084417}]
Similar words for 楽しい:
[{'word': '面白い', 'd': 0.77722764176403869}
 {'word': '愉快', 'd': 0.734471009476235}
 {'word': '楽しみ', 'd': 0.72219305568232817}
 {'word': '嬉しい', 'd': 0.7146308070034274}
 {'word': 'たのしい', 'd': 0.70191349767079725}
 {'word': '羨ましい', 'd': 0.66843652749282112}
 {'word': '愉しい', 'd': 0.66044810801533349}
 {'word': 'はしゃぐ', 'd': 0.65717063863302738}
 {'word': 'おもしろい', 'd': 0.65519289974511874}
 {'word': '仲良い', 'd': 0.65358796703214295}]
Similar words for 泣く:
[{'word': '泣きじゃくる', 'd': 0.80074692761980748}
 {'word': '泣ける', 'd': 0.79896731583051173}
 {'word': '泣き泣き', 'd': 0.78100400879001752}
 {'word': '泣かないで', 'd': 0.77891553179582629}
 {'word': 'もらい泣き', 'd': 0.77467120332999628}
 {'word': '泣き面', 'd': 0.76965783671681831}
 {'word': '泣き', 'd': 0.75434540188827437}
 {'word': '泣かせる', 'd': 0.75290065484792423}
 {'word': 'しゃくりあげる', 'd': 0.75180075788233203}
 {'word': '泣き崩れる', 'd': 0.74950619759133164}]
Similar words for 事務所:
[{'word': '会計事務所', 'd': 0.78621536900870648}
 {'word': '所属事務所', 'd': 0.78480813710496211}
 {'word': '765プロダクション', 'd': 0.76424399482651828}
 {'word': 'プロダクション', 'd': 0.758089014888081}
 {'word': 'オフィス', 'd': 0.7574378828649927}
 {'word': '芸能事務所', 'd': 0.75701749931698448}
 {'word': 'スタジオ', 'd': 0.75257427716381864}
 {'word': 'テレビ局', 'd': 0.75171495197218108}
 {'word': '設計事務所', 'd': 0.73368602218756229}
 {'word': '仕事場', 'd': 0.72309091427451111}]

心なしかword2vecより性能がよい感じでしょうか? 続いて未知語も試してみます。

$ python eval.py -m fasttext-model.vec -d queries_vectors_unknown.txt
Similar words for アイエエエエエエエ:
[{'word': 'アイエエエエエ', 'd': 0.97968790773111303}
 {'word': 'エエエエエエ', 'd': 0.94352488282851887}
 {'word': 'エエエエエエエ', 'd': 0.94109101820664776}
 {'word': 'アイエエエエ', 'd': 0.94055161627268902}
 {'word': 'エエエエエエエエ', 'd': 0.9391001442415432}
 {'word': 'エエエエエ', 'd': 0.93780938955555282}
 {'word': 'エエエエ', 'd': 0.92405663414722494}
 {'word': 'エエエ', 'd': 0.83535388967051738}
 {'word': 'アイエエエ!', 'd': 0.81769303523664116}
 {'word': 'アッーーーー', 'd': 0.72735536009394242}]
Similar words for 上野恩賜公園:
[{'word': '駒場公園', 'd': 0.85406028650569288}
 {'word': '芝公園', 'd': 0.84637044726213462}
 {'word': '奈良公園', 'd': 0.82027172997396058}
 {'word': '動物公園', 'd': 0.81896355488799988}
 {'word': '鉄道の駅', 'd': 0.81845082215954124}
 {'word': '大通公園', 'd': 0.81657524383782953}
 {'word': '代々木公園', 'd': 0.81550803620161383}
 {'word': 'クアラルンプール', 'd': 0.81457616759805329}
 {'word': '諏訪神社', 'd': 0.81398833373141277}
 {'word': '国立公園', 'd': 0.81389513615413511}]
Similar words for 超大和型戦艦:
[{'word': '大和型戦艦', 'd': 0.97030525814957214}
 {'word': '扶桑型戦艦', 'd': 0.86064344190640263}
 {'word': '長門型戦艦', 'd': 0.85995507607342303}
 {'word': '大和型', 'd': 0.85341909830523466}
 {'word': '超弩級戦艦', 'd': 0.83867159417503934}
 {'word': '巡洋戦艦', 'd': 0.82117752707905345}
 {'word': '天龍型軽巡洋艦', 'd': 0.81862008075834503}
 {'word': '球磨型軽巡洋艦', 'd': 0.81471254162147311}
 {'word': '自衛艦隊', 'd': 0.80638281405063394}
 {'word': '妙高型重巡洋艦', 'd': 0.79695507300021684}]
Similar words for 綾風:
[{'word': '疲弊', 'd': 0.91679978096264569}
 {'word': '消耗', 'd': 0.74662124972973221}
 {'word': '疲労困憊', 'd': 0.7154674807955782}
 {'word': '疲労', 'd': 0.70087760531383037}
 {'word': '衰弱', 'd': 0.67832832850478486}
 {'word': '野戦軍', 'd': 0.67407045802872056}
 {'word': '満身創痍', 'd': 0.66518446564387268}
 {'word': 'すり減らす', 'd': 0.65926078013249145}
 {'word': '酷使', 'd': 0.6450691058646586}
 {'word': '心身', 'd': 0.6328005385332619}]
Similar words for 童子切安綱:
[{'word': '童子切', 'd': 0.86721093543747674}
 {'word': '鬼切安綱', 'd': 0.85727993648951251}
 {'word': '安綱', 'd': 0.78455127787405021}
 {'word': '御物', 'd': 0.73853960603982205}
 {'word': '東照権現', 'd': 0.73474613490873864}
 {'word': 'レア4太刀', 'd': 0.71358739674864735}
 {'word': '重文', 'd': 0.69602544539515598}
 {'word': '刀', 'd': 0.69506570623099195}
 {'word': '天下三名槍', 'd': 0.6936632492450413}
 {'word': '源義平', 'd': 0.68183522968796995}]

概ねいい感じですね!

感想

今回はほとんど既存のライブラリを用いてパラメーターも調整せず学習させただけですが、想像以上にいい結果が出てくれました。これを現実のプロダクトにどう応用していくかはとても難しい問題ですが、今後も様々な取り組みを続けていきたいと思います。

モデル配布について

今回ご紹介したdoc2vecとfastTextによる学習済みモデルは https://github.com/pixiv/pixivnovel2vec/releases で配布しています。用法・用量・規約を守って正しく利用しましょう。

広告

pixivでは機械学習にも興味があるタフなエンジニアを募集しています。最近GPUマシンを開発用サーバー群に投入しました。紅莉栖には勝てませんが!!!

*1:Mikolov, Tomas, et al. "Efficient estimation of word representations in vector space." arXiv preprint arXiv:1310.4546 (2013).

*2:Le, Quoc V., and Tomas Mikolov. "Distributed Representations of Sentences and Documents." arXiv preprint arXiv:1405.4053 (2014).

*3:実装としてはgensimのdoc2vec

*4:単純に正答率を用いなかった理由は、そもそも出現が珍しいタグでは全ての小説に対して陰性の判定を出したほうが正答率が上がってしまうためです。

*5:Bojanowski, Piotr, et al. "Enriching Word Vectors with Subword Information." arXiv preprint arXiv:1607.04606 (2016).