nykergoto’s blog

機械学習とpythonをメインに

文章の埋め込みモデル: Sparse Composite Document Vectors を読んで実装してみた

自然言語処理である単語の意味情報を数値化したいという場合に単語を特定のベクトルに埋め込む(分散表現)手法として word 2 vec があります。 この word2vec と同じような発想で文章自体をベクトル化するという発想があり Doc2Vec やそのたもろもろも方法が存在しています。 今回はその中の一つである SCDV (Sparse Composite Document Vector) を実装したのでその記録です。

そもそも何者か

文章を表現するベクトルを取得する手法です。

どうやってやるか

SCDV はいくつかのフェーズに分かれています。以下では5つのフェーズに分けて説明します。 若干論文の notation と違う所があるのでそこだけ注意していただければと思います。

1. 単語の分散表現を取得する

はじめに文章全体をつかって単語の分散表現を学習します。 Word2Vec や fasttext などが有名なところですね。

以下での話をわかりやすくするため、文章と単語に関してすこし数式を定義します. まず、文章中に現れるすべての単語の数を $N$ とおきます。 そして $i \in \{1, 2, ..., N\}$ 番目の単語に対して分散表現 $w_i \in {\mathbb R}^M$ を得たとします。ここで $M$ は埋め込みベクトルの次元数を表します.

2. 単語を更に複数のクラスタに分類する

1 で取得した単語ベクトルを更に $K$ 個のクラスタに分類します。 このときクラスタリングのモデルとして混合ガウス分布をつかいます。 これによって単語ごとに、先の埋め込みベクトルとは別のクラス分だけのベクトル $p_i \in \mathbb{R}^K$ をえることが出来ます。

混合ガウス分布の学習方法については論文中では特に言及はありませんでしたが、著者の実装では「各クラスタの共分散行列が同じである」という仮定のもとで推定を行うようになっていました。これはおそらく、各クラスタの領域を同じぐらいになるような制限をかけることで各単語の負担率 $p_i$ に極端な偏りがないようにするためなのかなーと考えています。単に計算量を減らすためかもなのであくまで推定ですが。

3. 埋め込み表現とクラス表現を掛けあわせた後に idf をかける

1 で得られた単語の分散表現に 2 で得られたクラスタへの割当を表すベクトルをおのおのかけて各単語ごとに $MK$ 次元のベクトルを作ります。 そして出来上がったベクトルに単語 $i$ の idf 特徴 ${\rm idf}_i \in \mathbb{R}$ を掛けあわせます ここで得られる新しい単語-クラスタ ベクトルを $u_i \in \mathbb{R}^{MK}$ とすると以下のようになります

$$ u_i = {\rm idf}_i \left( \begin{array}{c} p_{i 1} w_i \\ p_{i 2} w_i \\ \vdots \\ p_{i k} w_i \end{array} \right) $$

ここで idf を掛け算しているのは出現回数の多い単語の影響を低くしたいから、です。 とくに 2 でクラスタに分割して次元を多くしたので、出現回数の多い単語が大きい影響度を持つ次元の割合は増えることが予想されるためこの処理は必要なんだろうな、と僕は理解しています。*1

4. 文章ごとに単語クラスタベクトルを足しあわせて正規化する

ここではじめて文章という単位が登場します(ここまでの演算には文章が関係しません)。 $j$ 番目の文章に現れる単語を $L_j$ とし、全部で $J$ 個の文章があるとします。 先ほど得られた単語クラスタベクトルを文章ごとに足しあわせて文章ベクトル $v_j \in \mathbb{R}^{MK}$ を得ます

$$ v_j = \sum_{i \in L_j} u_i $$

さらにこれを各文章ごとに正規化します。論文には After normalizing the vector としか書かれていませんでしたが実装を見るとユークリッドノルムの意味で1になるようにしていました。 それに習うと, 正規化後のベクトルを $\hat{v_j} \in \mathbb{R}^{MK}$ とすると

$$ \hat{v_j} = \frac{v_j}{\| v_j \|_2} $$

となります。

5. ゼロに近いものを 0 に押しつぶす

4 の正規化を終えた時点でじつはほとんどの文章ベクトルの要素が 0 になります。 容量の圧縮や意味の鮮明化の為、ゼロに近いもののうち特定の条件を満たすものをゼロとみなします。具体的には

$$ \hat{v_i} = \begin{cases} \hat{v_i} & {\rm if} | \hat{v_i} | \ge t \\ 0 & {\rm otherwise} \end{cases} $$

とします。ここで $t$ は

$$ \begin{align} t = \frac{p}{100} \times \frac{ | a_{{\rm min}} | + | a_{{\rm max}} | } {2} \\ a_{{\rm min}} = \frac{1}{J} \sum_{j = 1}^{J} {\rm min}\ \hat{v_j} \\ a_{{\rm max}}= \frac{1}{J}\sum_{j = 1}^{J} {\rm max}\ \hat{v_j} \end{align} $$

のように定義される値です。全体の最大最小の $p$ %よりも小さかったら 0 にしましょうということみたいですね。(何故これで良いのかは特に記述がありませんでした。流石にノリで決めては無いと思うんですがよくわかりません)。

どのぐらいいいの?

定量評価のため論文中では SDCV と他の特徴量を SVM で訓練した結果が乗っています。

f:id:dette:20190224031410p:plain
Table1

これを見る限りわかりやすく一番つよいですね。

実装してみる

ここまで読んで楽しそうだったので自分でも実装をやってみました。

github.com

使うのはライブドアニュースのデータセットです。 docker-compose を使うと以下のように環境が作れます。

cp project.env .env
docker-compose build

docker-compose up -d

docker exec -it scdv-jupyter bash

SCDV の作成

作成は src/create.py で行います。著者実装のコードでは単語の分散表現もスクラッチで学習させていましたが今回は学習済みの fasttext 特徴量を用いています。 学習済みモデルは https://qiita.com/Hironsan/items/513b9f93752ecee9e670 よりお借りしました。感謝!!

def main():
    args = vars(get_arguments())

    word_vec = ja_word_vector()

    output_dir = os.path.join(setting.PROCESSED_ROOT)
    n_wv_embed = word_vec.vector_size
    n_components = args['components']

    docs, _ = livedoor_news()
    parsed_docs = create_parsed_document(docs)

    # w2v model と corpus の語彙集合を作成
    vocab_model = set(k for k in word_vec.vocab.keys())
    vocab_docs = set([w for doc in parsed_docs for w in doc])
    out_of_vocabs = len(vocab_docs) - len(vocab_docs & vocab_model)
    print('out of vocabs: {out_of_vocabs}'.format(**locals()))

    # 使う文章に入っているものだけ学習させるため共通集合を取得してその word vector を GMM の入力にする
    use_words = list(vocab_docs & vocab_model)

    # 使う単語分だけ word vector を取得. よって shape = (n_vocabs, n_wv_embed,)
    use_word_vectors = np.array([word_vec[w] for w in use_words])

    # 公式実装: https://github.com/dheeraj7596/SCDV/blob/master/20news/SCDV.py#L32 により tied で学習
    # 共分散行列全部推定する必要が有るほど低次元ではないという判断?
    # -> 多分各クラスの分散を共通化することで各クラスに所属するデータ数を揃えたいとうのがお気持ちっぽい
    clf = GaussianMixture(n_components=n_components, covariance_type='tied', verbose=2)
    clf.fit(use_word_vectors)

    # word probs は各単語のクラスタへの割当確率なので shape = (n_vocabs, n_components,)
    word_probs = clf.predict_proba(use_word_vectors)

    # 単語ごとにクラスタへの割当確率を wv に対して掛け算する
    # shape = (n_vocabs, n_components, n_wv_embed) になる
    word_cluster_vector = use_word_vectors[:, None, :] * word_probs[:, :, None]

    # はじめに文章全体の idf を作成した後, use_word だけの df と left join して
    # 使用している単語の idf を取得
    df_use = pd.DataFrame()
    df_use['word'] = use_words
    df_idf = create_idf_dataframe(parsed_docs)
    df_use = pd.merge(df_use, df_idf, on='word', how='left')
    idf = df_use['idf'].values

    # topic vector を計算するときに concatenation するとあるが
    # 単に 二次元のベクトルに変形して各 vocab に対して idf をかければ OK
    topic_vector = word_cluster_vector.reshape(-1, n_components * n_wv_embed) * idf[:, None]
    # nanで影響が出ないように 0 で埋める
    topic_vector[np.isnan(topic_vector)] = 0
    word_to_topic = dict(zip(use_words, topic_vector))

    np.save(os.path.join(output_dir, 'word_topic_vector.npy'), topic_vector)

    topic_vector = np.load(os.path.join(output_dir, 'word_topic_vector.npy'))
    n_embedding = topic_vector.shape[1]

    cdv_vector = create_document_vector(parsed_docs, word_to_topic, n_embedding)
    np.save(os.path.join(output_dir, 'raw_document_vector.npy'), cdv_vector)

    compressed = compress_document_vector(cdv_vector)
    np.save(os.path.join(output_dir, 'compressed_document_vector.npy'), compressed)

実験

著者の実験では SVM を使っていました。 普段モデリングするときには勾配ブースティング or Neural Network を使うことがおおいので、今回は lightGBM をつかってベンチマークを実装しました。 (実行するスクリプトsrc/SCDV_vs_SWEM.py にあります。)

タスクはライブドアニュースの文章から、そのカテゴリを予測する問題です。 カテゴリは全部で8個あり文章数はおおよそ7000程度です。

戦わせる特徴量は個人的に押しの論文 Baseline Needs More Love: On Simple Word-Embedding-Based Models and Associated Pooling Mechanisms で提案されている Simple Word Embedding Model (SWEM) をつかいます。

SWEM の文章 $k$ の特徴量 $z^k \in \mathbb{R}^{M}$ は以下で表されます

$$ z_j^{k} = \max_{i \in L_k} \ w_{ij}. $$

ここで $w_{ij}$ は単語 $i$ の $j$ 番目の要素であり $L_k$ は $k$ 番目の文章に含まれる単語の添字集合です. 要するに文章中の単語に対して埋め込み次元方向に max をとったものです。 得られる文章ベクトルは単語のものと同じ $M$ 次元になります。

これの n-gram version である SWEM-hier なども提案されていて文章分類などの単純なタスクにおいては CNN や LSTM をつかったリッチなモデルと匹敵、場合によっては勝つ場合もある、単純ですがあまり侮れない特徴量であったりします。

モデルはLightGBM で k=5 の Stratified Fold を行い accuracy により評価します。

結果と感想

Out of Fold の Accuracy を特徴量ごとにプロットしたのが以下のグラフです。

f:id:dette:20190224035914p:plain
学習結果

SCDV の圧縮を行わないバージョンがもっともよいスコアになりました。CV全てで二番目となった SWEM の max-pooling version よりも良い値となりこの特徴量の強さが伺えます。 一方 PCA で圧縮したものはあまりワークしませんでした。これはせっかく混合ガウス分布まで持ちだして拡張したことによって得られる微妙な差分が PCA によって押しつぶされてしまったことが原因と考えられます。

また SCDV は学習がおおげさになるというのも浮き彫りになりました。というのも特徴量の次元が $MK$ なので fasttext の埋め込み次元が 300, 混合ガウス分布の次元が 60 なので各文章ごとに 1800 次元もあるのです。

ですので今回のタスクのような小さい文章セット (1万以下) であっても numpy でなにも考えずに保存すると約 7GB 程度になります。また学習も SWEM に比べて体感 10 ~ 20 倍の時間がかかりました。 以上のことからメモリや計算資源に余裕が有る場合に SCDV を使いさくっとそれなりのベンチマークがほしい時は SWEM という使い分けも良いかも知れません。

まとめ

  • SCDV では単語情報を $M$ 次元ベクトルから更に $K$ 次元の情報を引き出して $MK$ 次元に拡張する。そこから先は普通(たして正規化)
  • livedoor ニュースのデータ・セットを用いた実験では SWEM よりも精度がよかった。が時間とメモリは要る。

*1:SWEMでは特に出現回数に関する考慮はなく精度が出ているため次元拡張の影響を打ち消すという意味合いが強いのかも