Paragraph Vector DBOWの憂鬱

はじめに

この記事は Deep Learningやっていき Advent Calendar 2017 - Adventar の24日目の記事となります。

メリークリスマス。お久しぶりの投稿となります。本日はクリスマスイブとなりますが、皆さんはいかがお過ごしでしょうか。

私は一人寂しくクリスマスパーティーの準備(寿司を握る)をしたり、自分で設定した締め切りに追われながらもブログを書いたりして過ごしておりました。

不慣れなDeep LearningでAdvent Calendarに参加してみるというやや無謀な挑戦をしてみたわけですが、今回は『Analysis of the Paragraph Vector Model for Information Retrieval』を読んだので、自らの勉強の為にブログに書きます。

この記事はコツコツ書きためていたものなのですが、途中で「Paragraph VectorってDeepではなくね?」と思い、木構造のLSTMで書き始めましたが間に合いそうにないので、比較的間に合いそうなこちらを投稿することにしました。浅いのにDeepを語ってしまってごめんなさい。

本記事の内容ですが、論文の内容をできるだけ自分の感想を交えて書いただけなので、真面目に勉強したい方は論文を読んだほうが良いと思います。

Paragraph Vector

概要

Paragraph Vectorは『Distributed Representations of Sentences and Documents』で提案されている文書の特徴ベクトルを表現できるようにword2vecを拡張したアルゴリズム

この論文ではParagraph VectorのBag of Wordsバージョン(PV-DBOW)とParagraph VectorのDistributed Memoryバージョン(PV-DM)が提案されている。

gensimのDoc2Vecで実装されているアルゴリズムであり、こちらの名前の方が有名になってしまっているような気がする。『Analysis of the Paragraph Vector Model for Information Retrieval』の論文で取り上げられているのはPV-DBOWなので、今回はPV-DBOWのみを取り上げて簡単に説明する。

PV-DBOW

Word2Vecのおさらい

Paragraph VectorはWord2Vecの拡張とのことなので、一旦Word2Vecについておさらいする。下記の図を見ていただこう。

f:id:gat-chin321:20171224131110p:plain
(図は『Efficient Estimation of Word Representations inVector Space』より引用)

これはSkip-gramと呼ばれる学習方法で、今回取り上げるDBOWの元となっているアルゴリズムである。

下記の記事が直感的にわかりやすいので参考にしてほしい。

絵で理解するWord2vecの仕組み - Qiita

Word2Vec のニューラルネットワーク学習過程を理解する · けんごのお屋敷

Word2Vecでは純粋に学習させると結構な時間がかかってしまう。この問題を解決するため、階層的ソフトマックスや負例サンプリングのどちらかが用いられる。

今回は負例サンプリングのみを使うため、そちらのみを簡単に説明する。

負例サンプリングは「周辺単語の入力から単語を予測する」というタスクから「単語が実データの確率分布かノイズの確率分布かのどちらに生成されたものなのかを予測する」ように置き換えて、ニューラルネットワークを学習させることで高速化する。

これらの具体的な実装については下記のChainerのexampleで公開されている。
chainer/train_word2vec.py at master · chainer/chainer · GitHub

tensorflowは使ったことがないので、よくわからないがあるっぽい。
tensorflow/word2vec_basic.py at r1.2 · tensorflow/tensorflow · GitHub

PV-DBOWの概要

PV-DBOWは下図のようにニューラルネットワークが設計されている。

f:id:gat-chin321:20171224131417p:plain

(図は『Distributed Representations of Sentences and Documents』]より引用)
先ほどのSkip-gramとネットワークの形が似ていることがわかるかと思う。何が変わったのかというと入力部分が文書IDとなった点だ。

f:id:gat-chin321:20171224131537p:plain

つまり、文書IDを入力として、文書内の共起の情報をスライドさせながら埋め込んでいくことで最大公約数的な文書全体の意味を重みに埋め込んでいっているっぽい。
これにより、word2vecによりベクトル化した単語ベクトルのような形で文書のベクトル表現が得られることがわかる。

公式ではないが、PV-DBOWのChainerでの実装は下記のURLで公開されている。
GitHub - monthly-hack/chainer-doc2vec: A Chainer implementation of doc2vec

PV-DBOWの弱点

簡単に説明したところで、PV-DBOWのソフトマックスを用いた出力部分の式をみていこう。
 \begin{equation}
P(w|d) = \frac{exp(\vec{w} \cdot \vec{d})}{\sum_{\acute{w} \in V_w} exp(\vec{\acute{w}} \cdot \vec{d})}
\end{equation}

 P(w|d): ドキュメント dを与えられた時に単語 wが出力される確率

 w: 単語

 d: 文書

 \vec{w}: 単語のベクトル

 \vec{d}: 文書のベクトル

 V_w: 語彙集合

これを負例サンプリングで学習させる場合、下記のような式となる。

  \begin{equation}
l = \sum_{w \in V_w} \sum_{d \in V_d} \#(w, d) log(\sigma(\vec{w} \cdot \vec{d})) + \sum_{w \in V_w} \sum_{d \in V_d} \#(w, d) (k \cdot E_{w_N \sim P_{V}} )
\end{equation}

 \vec{w}: 単語のベクトル

 \vec{d}: 文書のベクトル

 V_w: 語彙集合

 V_d: 文書集合

 k: 負例サンプリングの数

 E_{w_N \sim P_{V}} : ノイズ分布  P_{V} が与えられた時の  log \sigma(- \vec{w_N} \cdot \vec{d}) の期待値

 \#(w, d): ある単語  w の文書  d 内での出現回数

 \sigma(x): シグモイド関数  \sigma(x) = \frac{1}{1+e^{-x}}

文の長さの偏りによる過学習に対する改善

PV-DBOWを検索モデルに適用した場合、Robust04のタイトルをクエリにした際に検索された上位の文書内の語数の傾向として、比較的短い文書が検索結果として表示されやすいという特徴があることが下記の図からみて取れる。

f:id:gat-chin321:20171224131629p:plain
(図は『Analysis of the Paragraph Vector Model for Information Retrieval』より引用)

また、この図ではepoch数が増れば増える分だけその傾向が強くなっていることもわかると思う。
なぜこのような結果になったのかを考えていこう。
まずepoch数が増えれば増えるほど短い文書が表示されやすいのは、勾配計算が原因であると考えるのが自然だと思う。そこで勾配の計算式がどうなっているのか考えてみよう。ある文書dに関する目的関数の偏微分は下記の式で計算される。

 \begin{equation}
\frac{\partial l}{\partial d} = \sum_{w \in V_{w}} \# (w, d) log (\sigma (-\vec{w_N} \cdot \vec{d}))\vec{w} - \sum_{w \in V_w} \# (w, d)(k \cdot E_{w_N \sim P_{V}} \vec{w_N})
\end{equation}

 w_N: ノイズ分布に従ってランダムにサンプリングされる単語

 \vec{w_N}: サンプリングされた単語のベクトル

この数式からdの勾配は単語ベクトルの加重和により求められていることがわかる。そのため、単語数の少ない短い文書の場合、その勾配が全ての単語ベクトルから遠くないところに収束してしまう。したがって、短い文書同士も遠くない方向に収束してしまっており、短い文書が出やすくなると考えられる。

また、epochが増えれば増えるほどその傾向が強くなるのは、epochが増えた分、短い文書は単語ベクトルとの距離がやや離れるが、長い文書については単語ベクトルとの距離が大幅に離れることになる。つまり、短い文書は検索用のクエリーのように短い文書との距離が近く、逆に長い文書は短い文書との距離が遠くなりやすいと言える。

学習のepoch数をそれぞれ異なる値にして学習してみて、文書ベクトルのノルムと文書の長さの関係をグラフにしたものが下記の図である。

f:id:gat-chin321:20171224131754p:plain
(図は『Analysis of the Paragraph Vector Model for Information Retrieval』より引用)

この図はx軸が文書の長さ(つまり、単語数)で、y軸が文書ベクトルのノルムを表しており、青の点はepoch数が5の結果、灰色の点はepoch数が20の結果、黄色の点はepoch数が80の結果となっている。

1000語以上の文書の場合は特に文書ベクトルのノルムはepochの回数による違いは多少黄色がバラついている点がちらほらある程度で、全体的にはあまり違いがないことがわかるかと思う。

その一方で、1000語未満の文書ではepochの回数が増えるにつれて、文書ベクトルのノルムが急速に増加していることが見て取れる。

このことから、PV-DBOWは1000語未満の文書での過学習の問題を抱えていて、文書が短ければ短いほどこの問題が深刻であることがわかった。

この過学習の問題は下記の式のように文書ベクトルにL2正則化を追加することで改善される。

  l'(w, d) = l(w, d) − \frac{\gamma}{|d|}||\vec{d}||^2

 l(w, d): PV-DBOWのための局所的な目的関数

 \gamma: ハイパーパラメーター。論文の実験では10が一番いい結果だったらしい。

 |d|: 文書dの単語の数

 ||\vec{d}||: 文書ベクトル  \vec{d} のノルム

正則化項がマイナスなのが何故なのかよくわからないが、元々の損失関数を最小化しつつ、その最小化をさらに加速させるために文書ベクトルのL2ノルムを大きくするような動きになりそう。

L2正則化の効果は主に下記の2点があるとのこと。

  • epochの回数が増えるにつれて、短い文書と長い文書の両方の文書ベクトルのノルムがほぼ同じになるため、epochの多い学習では短い文書に過学習することはほぼなくなる。
  • 文書ベクトルのノルムに制約がかかることにより、式(1)の確率分布が滑らかになり、検索モデルを平滑化する効果がある。

ノイズ分布に存在する問題と対策

次はPV-DBOWの目的関数について議論する。

『Neural Word Embedding as Implicit Matrix Factorization』のSkip-gramモデルの解析では、式(2)から特定の単語-文書対の局所的な目的関数を以下のように導出する。

  l(w, d)=\#(w, d) log \sigma (\vec{w} \cdot \vec{d}) + k \#(d)P_V(w)log \sigma (-\vec{w} \cdot \vec{d})

 \#(d): 文書 d中の全ての単語数

 \#(w, d): ある単語  w の文書  d 内での出現回数

 x=\vec{w} \cdot \vec{d} と定義すると、 xの目的関数の偏微分は次のようになる。

  \frac{\partial l(w, d)}{\partial x} = \#(w, d) \cdot \sigma (-x) - k \cdot \#(d) \cdot P_V(w) \cdot \sigma (x)

この偏導関数を0と置くと、この式の解は下記になる。

  \vec{w} \cdot \vec{d} = log(\frac{ \#(w, d) }{\#(d)} \cdot \frac{1}{P_V}) - log k

文書中の用語の重み付けは負例サンプリングのノイズ分布 P_Vにより決定される。元々のモデルでは負例サンプリングは全体のコーパスにおける経験的な単語分布を下記のように定義している。

  P_V(w_N) = \frac{\#w_N}{|C|}

 \#(w_N):  w_Nコーパス頻度

 |C|: コーパスのサイズ

この式において、 \frac{\#w_N}{|C|} dにおける wの正規化TFであり、 \frac{1}{P_V(w)} (すなわち、 \frac{|C|}{\#w_N}) は wICF値とみなせる。

ちなみにICFは色々種類(Inverse Community Frequency, Inverse Category Frequency, Inverse Corpus Frequency, Inverse Collection Frequencyなど)があってややこしいが、引用文献を追って行く限りだとおそらくInverse Collection Frequencyのことをさしていて、ICFの式は下記の通りである。

  icf_{t, d} = log (\frac{\Sigma_d dl_d}{\Sigma_d tf_{t,d}})

 tf_{t,d}: 文書 dにおける単語 tの出現数

 \Sigma_d tf_{t,d}: コーパス全体での単語 tが存在する文書の頻度(単語tのコーパス頻度)

 \Sigma_d dl_d: 全文書の長さの総和を求めることでコーパスの全体の単語の数を算出している(コーパスのサイズ)

したがって、負例サンプリングを使うオリジナルのPV-DBOWはTF-ICF重み付け方式に対して最適化される。

しかし、TF-ICFは情報検索における一般的な重み付け方式ではなく、ICFベースの用語の重み付けはコーパス内の単語の頻度に応じてのみ単語の識別能力を計算する仕組みのため、文書構造情報の形式を考慮していない。

経験的には小さな文書グループにしか現れない場合でもコーパス内での出現頻度が高くなる単語が特徴的であると見なされることもあり得るので、PV-DBOWは一般的なNLPのタスクではうまく動作するが、情報検索タスクではうまく動作しないらしい。

このあたりの議論は『Understanding Inverse Document Frequency: On theoretical arguments for IDF』に載っているらしいのでそのうち読みたい。

そこで、この論文ではPV-DBOWの問題に対処するために文書頻度(DF)ベースの負例サンプリング戦略を適用している。

具体的には、負例サンプリングの P_Vを次のように新しいノイズ分布 P_D に置き換える。

  P_D(w_N) = \frac{\#D(w_N)}{|N|}

 \#D(w_N):  w_N の文書頻度

 |N|: 語 w' を含む文書の数 ( |N|=Σ_{w’∈V_w}\#D(w’))

 P_V と一緒に P_D \vec{w} \cdot \vec{d} = log(\frac{\#(w, d)}{\#(d)} \cdot \frac{1}{P_V}) - log k に代入した新たな最適解が下記となる。

  \vec{w} \cdot \vec{d} = log(\frac{\#(w, d)}{\#(d)} \cdot \frac{N}{\#D(w)}) - log k

こうすることで、  \frac{1}{P_V} の部分は wの逆文書頻度(IDF)の変形である  \frac{|N|}{\#D(w)} となる。これで、DFベースのネガティブサンプリングを使用するPV-DBOWはTF-ICFよりも情報検索タスクに向いている重み付けであるTF-IDFの重み付けに対して最適化されるようになった。

(補足) TF-IDFの計算式
  tfidf_{t,d} = tf_{t,d} \cdot idf_{t} \
tf_{t,d} = \frac{n_{t,d}}{\Sigma_{s \in d} n_{s,d}} \
idf_{t} = log \frac{|D|}{df(t)} + 1

 n_{t,d}: ある単語  t の文書  d 内での出現回数

 \Sigma_{s \in d} n_{s,d}: 文書 d内のすべての単語の出現回数の和(文書 d中の全ての単語数)

 |N|: 全文書数

 df(t): ある単語  t が出現する文書の数

従来手法であるコーパス頻度( P_V)と提案手法である文書頻度ベース( P_D)の分布をそれぞれプロットしてみると下記の通りになったようだ。

f:id:gat-chin321:20171224132142p:plain
(図は『Analysis of the Paragraph Vector Model for Information Retrieval』より引用)

ちなみに横軸は単語頻度のログの値(基数は10)を表している。

この図をみると、 P_Vコーパス中での単語の出現頻度が上昇すればするほど指数関数的に増加していて、 P_Dよりもはるかに高い確率を割り当ているような傾向が見られる。

この性質からコーパス頻度 P_Vを利用した場合は言語モデルの学習を行う際に、頻出単語に対しては上手くいかなくなることがあるらしい。

この論文では頻出単語に対しては上手くいかなくなる例として、Robust04のクエリ339(“alzheimers drug treatment”)をあげて説明している。

例えば、“drug(薬)”が“alzheimers(アルツハイマー)”の2倍以上の頻度で出現する場合でも、コーパス頻度ベースの負例サンプリングを用いたPV-DBOWでの予測確率が“alzheimers”(0.042)は“drug”(0.002)よりも高くなってしまう。

これは「単語が実データの確率分布から生成された正解単語かノイズの確率分布から生成された不正解単語かを予測する」ための目的関数の最小化を行う負例サンプリングでは、おそらく正解単語には高い確率が割り当てられ、不正解単語には低い確率が割り当てられるような動きになるので、頻繁にサンプリングされてきやすい単語である“drug”は不正解として判断されやすいため、結果として"drug"の確率が下がるのだろうか?よくわからない。

正直なところ何で“alzheimers"に比べて"drug"の予測確率が低すぎるとダメなのかいまいち良い理解ができない。

ちなみに提案手法の文書頻度ベースの負例サンプリングでは、用語の重み付けが緩和され、より合理的な言語推定が行われるとのこと。例で出た“alzheimers”は0.056、“drug”は0.069となったらしい。

また、最後にコーパス頻度ベースの負例サンプリングの場合と同様に
 \#D(w)^{\eta}(0 \leq \eta \leq 1)
を使用する文書頻度のべき乗バージョンを採用して最終的に下記の通りとなる。

  P_D(w_N) = \frac{\#D(w_N)^{\eta}}{Σ_{w’∈V_w}\#D(w’)^{\eta}}

単語の言い換え関係の捕捉

『Learning Word Representations by Jointly Modeling Syntagmatic and Paradigmatic Relations』に示されているように、用語-文書行列上で分散情報を活用するモデルは主に単語の統語的関係を捕捉するが、語形変化関係を無視するとのこと。

統語的関係の論文中の解説はあまり腑に落ちなかったが、要は同じテキスト領域で共起する単語に関連しているような傾向があること。

例えば、「NBA」は「バスケットボール」と同じ文書中で共起するため、関係があるとのこと。

語形変化のように類似した文脈をもつが、文書では共起しない単語と意味が似ているものもある。

例えば、"subway"と"underground"は同義語であり、同様の状況で発生することもあるが、アメリカ人は通常"subway"を使用するのに対して、英国の人々は"underground"を使用する傾向があるなど。

元々のPV-DBOWは共起数が多い単語は類似の表現を持つ傾向がある。

しかし、これでは同じ文脈では発生するが、同じ文書では起こらない単語間の意味的類似性をモデル化することができない。

クエリ用語は平均で関連ドキュメントの40〜50%が用語の不一致であるので、用語の不一致は情報検索のタスクではよくあることらしいので、こういった単語の語形変化や単語置換関係や用語の不一致などの問題を緩和することは情報検索の分野では重要らしい。

この論文では、例としてRobust04クエリ361(“clothing sweatshops”)を取り上げている。

このクエリと関連する文書では「garment(衣服)」が頻繁に使用されているが、「clothing(衣服)」は関連する文書では使用されていない。

下記の表は文書頻度ベースのネガティブサンプリングおよびL2正則化(EPV-DR)やEPV-DRに joint objectiveを導入したもの(EPV-DRJ)を用いたParagraph Vectorでの検索モデルにおける「clothing」、「garment」および4つの関連ドキュメント間のcos類似度*1を列挙したものである。

f:id:gat-chin321:20171224132412p:plain
(図は『Analysis of the Paragraph Vector Model for Information Retrieval』より引用)

直感的に考えれば、"clothing"は"garment"と同義語であるため、"garment"と同様の確率を受け取って欲しい。しかし、EPV-DRでの結果では"clothing"に比べて"garment"のcos類似度がはるかに低くなり、結果としてこれらの関連文書の"clothing"の確率を低下させてしまっている。

単語の置換関係をモデル化するためにPV-DBOWのjoint learning objectiveを適用する。

下記の図に示すように、モデルの第1の層は文書ベクトルを使用して観測された単語を予測する。

f:id:gat-chin321:20171224132503p:plain
(図は『Analysis of the Paragraph Vector Model for Information Retrieval』より引用)

次に、モデルの第2層は、観測された単語を使用してその文脈を予測する。

PV-DBOWの目的関数を以下のように表現できる。

  l = log(\sigma(\vec{w_i} \cdot \vec{d})) + k \cdot E_{w_N \sim P_D} + \sum_{j=i-L, j \neq i}^{i+L} log \sigma(\vec{w_i} \cdot \vec{c_j}) + k \cdot E_{c_N \sim P_D}

 \vec{c_j}: 単語  \vec{w_j} の文脈ベクトル

 c_N: サンプリングされたコンテキスト

 L:コンテキストウィンドウサイズ

学習の観点から言えば、単語と文脈の間に予測目的を追加すると実際にPV-DBOWの学習目標が正則化される。

この正則化により、joint objectiveをEPV-DRに組み込んだEPV-DRJでは、"clothing"と4つの関連文書との間のcos類似度は大幅に高くなることがわかる。

LA112889-0108("clothing"が出現しない文書)でも、"clothing"と"garment"に類似したcos類似度を持つようになった。

したがって、EPV-DRJベースの検索モデルの言語推定は"clothing"の確率を高くし、最終的な検索性能を向上させる働きがあることがわかった。

実装について

chainer-doc2vecを参考にして、chainerを使って実装してみましたが、実装が正しい自信がない。

chainer-doc2vecのsearch.pyを使えば、学習後に文書間の類似度を算出できます。

あと安月給のため、お家にGPUがないのでちゃんとしたデータセットとかで検証できていません。

クラウド使えとのことですが、業務では消し忘れの実績があることからGPUインスタンスを消し忘れそうで怖いのでビビってできません。

今回は間に合わなかったのでやりませんでしたが、そのうちnardtreeさんの

Google Cloud FunctionをPythonで使う
Google Cloud FunctionをPythonで使う - にほんごのれんしゅう

のような感じで、インスタンスをいい感じに操作できるようにしてから試してみようかなとか考えています。

pythonを強引にインストールするのだるいので、Azureで同じようなことやってみようかと思っています。

その場合は正則化周りの内積計算をnumpyでやってしまっているので、cupyでやるかchainerでやるかしようかとは思います。

というか、そもそもbatchの中のinverseとnormの計算がかけて足せればいいかなと思って内積にしてしまったが、これでいいのだろうか。

あと、サンプルデータで実験したあとに気がついたが、ハイパーパラメーターの gammaを10ではなく1で実験してしまっていたのも悲しい。

#!/usr/bin/env python
import argparse
import collections

import numpy as np
from numpy import linalg as LA
import six

import chainer
from chainer import cuda
import chainer.functions as F
import chainer.initializers as I
import chainer.links as L
import chainer.optimizers as O
from chainer import reporter
from chainer import training
from chainer.training import extensions


parser = argparse.ArgumentParser()
parser.add_argument('--gpu', '-g', default=-1, type=int,
                    help='GPU ID (negative value indicates CPU)')
parser.add_argument('--unit', '-u', default=200, type=int,
                    help='number of units')
parser.add_argument('--window', '-w', default=5, type=int,
                    help='window size')
parser.add_argument('--batchsize', '-b', type=int, default=50,
                    help='learning minibatch size')
parser.add_argument('--epoch', '-e', default=20, type=int,
                    help='number of epochs to learn')
parser.add_argument('--negative-size', default=5, type=int,
                    help='number of negative samples')
parser.add_argument('--out', default='result',
                    help='Directory to output the result')
args = parser.parse_args()

if args.gpu >= 0:
    chainer.cuda.get_device_from_id(args.gpu).use()
    cuda.check_cuda_available()

print('GPU: {}'.format(args.gpu))
print('# unit: {}'.format(args.unit))
print('Window: {}'.format(args.window))
print('Minibatch-size: {}'.format(args.batchsize))
print('# epoch: {}'.format(args.epoch))
print('')


class DistributedBoW(chainer.Chain):

    def __init__(self, n_vocab, n_docs, n_units, loss_func, doc2word_func):
        super(DistributedBoW, self).__init__()
        with self.init_scope():
            self.embed = L.EmbedID(
                n_vocab + n_docs, n_units, initialW=I.Uniform(1. / n_units))
            self.doc2word_func = doc2word_func
            self.loss_func = loss_func

    def __call__(self, x, doc, context, doc_length):
        window = context.shape
        shape = doc.shape
        d = F.broadcast_to(doc[:, None], (shape[0], window[1]))
        d = F.reshape(d, (shape[0] * window[1],))
        e = F.reshape(context, (shape[0] * window[1],))
        d = self.embed(d)

        x = F.broadcast_to(x[:, None], (shape[0], window[1]))
        x = F.reshape(x, (shape[0] * window[1],))

        gamma = 1
        inverse = F.broadcast_to(gamma / doc_length[:, None], (shape[0], window[1]))
        inverse = F.reshape(inverse, (shape[0] * window[1],))
        regularization = np.dot(inverse, LA.norm(d.data, axis=1))
        loss = self.doc2word_func(d, x) - regularization
        x = self.embed(x)
        loss += self.loss_func(x, e)

        reporter.report({'loss': loss}, self)
        return loss


class SoftmaxCrossEntropyLoss(chainer.Chain):

    def __init__(self, n_in, n_out):
        super(SoftmaxCrossEntropyLoss, self).__init__()
        with self.init_scope():
            self.out = L.Linear(n_in, n_out, initialW=0)

    def __call__(self, x, t):
        return F.softmax_cross_entropy(self.out(x), t)


class WindowIterator(chainer.dataset.Iterator):
    def __init__(self, text, label, window, batch_size, docid2length, repeat=True):
        self.text = np.array(text, np.int32)
        self.label = np.array(label, np.int32)
        self.batch_size = batch_size
        self.epoch = 0
        self.is_new_epoch = False
        self._repeat = repeat
        self.window = window
        self.order = np.random.permutation(
            len(text) - window * 2).astype(np.int32)
        self.order += window
        self.current_position = 0
        self.docid2length = docid2length

    def __next__(self):
        if not self._repeat and self.epoch > 0:
            raise StopIteration

        i = self.current_position
        i_end = i + self.batch_size
        position = self.order[i: i_end]
        w = np.random.randint(self.window - 1) + 1
        offset = np.concatenate([np.arange(-w, 0), np.arange(1, w + 1)])
        pos = position[:, None] + offset[None, :]
        context = self.text.take(pos)
        doc = self.label.take(position)
        doc_length = np.array([self.docid2length[did] for did in doc], dtype=np.float32)
        center = self.text.take(position)

        if i_end >= len(self.order):
            np.random.shuffle(self.order)
            self.epoch += 1
            self.is_new_epoch = True
            self.current_position = 0
        else:
            self.is_new_epoch = False
            self.current_position = i_end

        return center, doc, context, doc_length

    @property
    def epoch_detail(self):
        return self.epoch + float(self.current_position) / len(self.order)

    def serialize(self, serializer):
        self.current_position = serializer('current_position',
                                           self.current_position)
        self.epoch = serializer('epoch', self.epoch)
        self.is_new_epoch = serializer('is_new_epoch', self.is_new_epoch)
        if self._order is not None:
            serializer('_order', self._order)


def convert(batch, device):
    center, doc, context, docid2length = batch
    if device >= 0:
        center = cuda.to_gpu(center)
        doc = cuda.to_gpu(doc)
        context = cuda.to_gpu(context)
        docid2length = cuda.to_gpu(docid2length)
    return center, doc, context, docid2length


if args.gpu >= 0:
    cuda.get_device_from_id(args.gpu).use()

with open('./sample/sample_text.txt', 'r') as f:
    text = []
    l_text = []
    vocab = {}
    count = 0
    documents = []
    for item in f:
        item = item.rstrip()
        tmp = item.split(' ')
        # 1行の単語数を追加
        l_text.append(len(tmp))
        # 文書をループ
        for word in tmp:
            # 単語とそのIDを格納する辞書を作成
            if word not in vocab:
                vocab[word] = count
                count += 1
        # 文書をそれぞれ単語IDのリストに変換
        text += [vocab[word] for word in tmp]
        documents.append([vocab[word] for word in tmp])

with open('./sample/sample_title.txt', 'r') as f:
    title = []
    docs = {}
    for doc in f:
        doc = doc.rstrip()
        if doc not in docs:
            # 文書IDは単語IDと重複しないようにしている
            docs[doc] = count
            count += 1
        # 文書IDを格納
        title.append(docs[doc])

len_label = sum(l_text)

# 文書の単語数分だけタイトルラベルを用意する
# 例: 文書0が3個、文書1が4個、文書2が2個の場合
# [0, 0, 0, 1, 1, 1, 1, 2, 2]
label = np.zeros((len_label,))
cursor_label = 0
for i, j in enumerate(l_text):
    tmp = np.broadcast_to(title[i], (j,))
    label[cursor_label:cursor_label + j] = tmp
    cursor_label += j

# 単語IDから単語を復元するための辞書の用意
index2word = {wid: word for word, wid in six.iteritems(vocab)}
# タイトルIDからタイトルを復元するための辞書の用意
index2doc = {did: doc for doc, did in six.iteritems(docs)}
# タイトルIDから文書の長さを復元するための辞書の用意
docid2length = {}
for index, did in enumerate(sorted(list(docs.values()))):
    docid2length[did] = l_text[index]

# np.arrayに変換
text = np.asarray(text).astype(np.int32)
label = label.astype(np.int32)

# 単語ごとの出現文書数をNegativeSamplingに渡す
df = {}
for wid in index2word.keys():
    df[wid] = 0
    for i in range(len(documents)):
        if wid in documents[i]:
            df[wid] += 1
for wid in index2doc.keys():
    df[wid] = 1

n_vocab = len(vocab)
n_docs = len(docs)

print('n_vocab: %d' % n_vocab)
print('n_docs: %d' % n_docs)
print('data length: %d' % len(text))

cs = [df[w] for w in range(len(df))]
loss_func = L.NegativeSampling(args.unit, cs, args.negative_size)
loss_func.W.data[...] = 0
doc2word_func = L.NegativeSampling(args.unit, cs, 1)
doc2word_func.W.data[...] = 0

model = DistributedBoW(n_vocab, n_docs, args.unit, loss_func, doc2word_func)

if args.gpu >= 0:
    model.to_gpu()

optimizer = O.Adam()
optimizer.setup(model)

train_iter = WindowIterator(text, label, args.window, args.batchsize, docid2length)
updater = training.StandardUpdater(
    train_iter, optimizer, converter=convert, device=args.gpu)
trainer = training.Trainer(updater, (args.epoch, 'epoch'), out=args.out)
trainer.extend(extensions.LogReport())
trainer.extend(extensions.PrintReport(['epoch', 'main/loss']))
trainer.extend(extensions.ProgressBar())
trainer.run()

with open('word2vec.model', 'w') as f:
    f.write('%d %d\n' % (len(index2word), args.unit))
    w = cuda.to_cpu(model.embed.W.data[:n_vocab])
    for i, wi in enumerate(w):
        v = ','.join(map(str, wi))
        f.write('%s,%s\n' % (index2word[i], v))

with open('doc2vec.model', 'w') as f:
    f.write('%d %d\n' % (len(index2doc), args.unit))
    w = cuda.to_cpu(model.embed.W.data[n_vocab:])
    for i, wi in enumerate(w, start=n_vocab):
        v = ','.join(map(str, wi))
        f.write('%s,%s\n' % (index2doc[i], v))

サンプルテキストでの出力結果

chainer-doc2vecに置いているsample_text.txtとsample_title.txtで試してみた。

PV-DBOWでの学習結果

>> Anarchism
query: Anarchism
A: 0.23314246535301208
Autism: 0.22692044079303741
Aristotle: 0.21999907493591309
An American in Paris: 0.19898760318756104
Abraham Lincoln: 0.19616368412971497
>> A
query: A
Albedo: 0.32056179642677307
Aristotle: 0.3158291280269623
Autism: 0.3155941367149353
An American in Paris: 0.2853461503982544
Achilles: 0.2599610686302185
>> Albedo
query: Albedo
Autism: 0.33208969235420227
A: 0.32056179642677307
Aristotle: 0.2921576499938965
Alabama: 0.2647320032119751
An American in Paris: 0.24834167957305908

今回のEPV-DRJの結果

>> Anarchism
query: Anarchism
Autism: 0.15463200211524963
A: 0.08547578752040863
Albedo: 0.07183104753494263
An American in Paris: 0.005674820393323898
Aristotle: -0.011234864592552185
>> A
query: A
Albedo: 0.3267810344696045
An American in Paris: 0.3043128550052643
Aristotle: 0.1921233981847763
Achilles: 0.10536286234855652
Academy Award for Best Production Design: 0.09257571399211884
>> Albedo
query: Albedo
A: 0.3267810344696045
An American in Paris: 0.268435537815094
Autism: 0.14453238248825073
Achilles: 0.12083987146615982
Abraham Lincoln: 0.09255712479352951

正直サンプルデータをちゃんと読んでいないから微妙だが、Anarchismから見てAとAlbedoが同じくらいの類似度になっているのは良い感じがする。
PV-DBOWの場合はよくわからないが、AとAlbedoとの距離が近いのにAnarchismから見てAとAlbedoの類似度が全然違う点が違和感がある。
なので、比較的うまく行っているようには見える。

やはりもっといい感じのデータセットを使って実験しないと実装があってそうか微妙だ…。

あとgensimのように既存のドキュメント間の類似度だけではなく、文書のクエリを投げつけたらそれっぽい文書が返ってくるようにしないと検索システムとしては使い物にならなさそう。

終わりに

気力の都合上、論文中の実験結果などは省きました。ごめんなさい。

実装がネット上で見当たらなかったので、泣く泣く実装もどきをしてみました。

数学が全くわかっていないまま、算数力だけで論文を読むのきついなという気持ちになっているので、情報系の学生の方はちゃんと数学を勉強しないと私のようになってしまうので、数学は頑張ってほしい。

特に『Neural Word Embedding as Implicit Matrix Factorization』のImplicit Matrix Factorizationがなんのことをなのかわからず、この記事では省いてしまいました。

さらに自然言語処理言語学的な知識もないと苦しいのかもしれない。統語的関係がどうのとかこの記事に書いているがいまいちよくわかっていない。

読んでいるだけだと理解できた気になっていたが、説明しようとすると全く自分が理解できていないことに気づいてしまい、締め切りも近づいていることに気がついて悲しいことになりました。

正直あまり理解できないところが多いので、間違いがあれば誰か教えてください。

参考文献

Analysis of the Paragraph Vector Model for Information Retrieval
https://ciir-publications.cs.umass.edu/getpdf.php?id=1242

Learning Word Representations by Jointly Modeling Syntagmatic and Paradigmatic Relations
https://pdfs.semanticscholar.org/3393/fc9456087efbe7b5375e7ea98d9067c4cc75.pdf

Neural Word Embedding as Implicit Matrix Factorization
https://papers.nips.cc/paper/5477-neural-word-embedding-as-implicit-matrix-factorization.pdf

chainer-doc2vec
https://github.com/monthly-hack/chainer-doc2vec

Efficient Estimation of Word Representations inVector Space
https://arxiv.org/pdf/1301.3781v3.pdf

Distributed Representations of Sentences and Documents
https://cs.stanford.edu/~quocle/paragraph_vector.pdf

Understanding Inverse Document Frequency: On theoretical arguments for IDF
http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.438.2284&rep=rep1&type=pdf

Learning Word Representations by Jointly Modeling Syntagmatic and Paradigmatic Relations
https://pdfs.semanticscholar.org/3393/fc9456087efbe7b5375e7ea98d9067c4cc75.pdf

*1:完全に余談だが、頻繁に分散表現の類似度を測るのにcos類似度を使うことが多いが、cos類似度ってベクトルの角度しかみていないという理解なので、果たして角度だけの情報で本当に大丈夫なのだろうかと心配になる。