(論文)Cloze-driven Pretraining of Self-attention Networks
はじめに
2018年のNLPの主役は「BERT」で間違いないでしょう。
元の論文はGoogleから発表されており、Googleすごいってなりました。
黙っていないのがPytorchを開発した「Facebook」です。
ってことで、彼らの手法でNERのタスクにおいて僅かですがBERTを抜いてSOTAを達成しました。
今日はその論文を紹介したいと思います。
・Cloze-driven Pretraining of Self-attention Networks
では、さっそく内容に入っていきます。
ELMoとGPTの流れを汲んだモデルみたいです。
(その点ではBERTと同じですね。)
cloze-style training
モデルの特徴は「bi-directional transformer」ということです。
「cloze-style」(穴埋め形式)の訓練手法という点が特徴的です。
前方(left-to-right)と後方(right-to-left)の文脈情報を与えて、中心の単語を予測します。
BERTとの違い
気になるのはやはり「BERT」との違いです。
著者も意識していると思いますし、その違いを本文でも以下のように述べています。
我々のモデルは、系列中の各tokenを予測するbi-directional transformer型の言語モデルであり、
BERTもinput全体を利用することで、bi-directionalを実現している。
しかし、実現にはMasked LMやNext Sentence Predictionといった特殊なレジームを必要としている。
その一方で我々は周辺のtokenから各tokenを予測するモデルであり、1つの損失関数で事足りる。
要は学習時に特殊なことしなくてもよいよ、ってことですかね。
Block structure
各Blockの構造は「Transfomer」みたいなものです。
各Blockは2つのsub-blockから構成されます。
1つはH=16の「multi-head self-attention」です。※予測するtoken以降はマスクしておきます。
もう1つはfeed-forward networkです。
ここまでの入力を整理します。
まず、入力は文の各tokenを分散表現で表した行列です。それにposition embeddingsにより位置情報を足します。更にこの論文では、tokenの文字情報をCNNでエンコードした情報も加えています。
厳密性には欠けますが、入力情報は以下のような行列になるはずです。
これをforwardとbackwardでそれぞれ共通して利用します。
例えば、forwardでは「I, like」から「playing」単語を予測します。
backwardでは「soccer, ...」から「playing」単語を予測します。
ただ、文中のtokenを予測するモデルなのに、そのtoken以降の情報を含めてはいけません。
そこで、forwardとbackwardでそれぞれマスクをかけます。
Combination of representations
forwardとbackwardのtransformerモデルを組み合わせます。
- base modelではsum
- larger modelではconcat
しています。
forwardとbackwardを組み合わせることで、ターゲットtokenの周囲のtoken情報全体を利用することになります。
訓練は各tokenごとに行っているそうです。
Results
このモデルの上にタスク固有のモデルを繋げるそうです。
NERのタスクの場合、biLSTM-CRFを適用させているみたいです。
CoNLL 2003の結果です。
最後に
最近はRNN系ではなく、transformerをベースとしたモデルが主流のようです。
ただ、NERのタスクで言うとRNN系でも十分に精度は高いと思います。
例えば、以下の論文でのDevセットでのF1値は94.74と今回のSOTAとも2ポイント程度しか違いません。
End-to-end Sequence Labeling via Bi-directional LSTM-CNNs-CRF
もちろん、この2ポイントの違い(RNN系とtransformer系のエラー分析)は気になりますが…
(論文)Pretrained language models
はじめに
以前にPretrained language modelsの基本について書きました。
kento1109.hatenablog.com
今回はドメイン領域(Biomedical)での固有表現認識(NER)について適用した論文をまとめます。
どんな論文?
Biomedicalから重要なエンティティ(薬品名、病名、組織名など)を抽出するタスクはかなり昔から研究されており、今でも重要なタスクです。
このドメインで抽出したいエンティティはIREXが定義している一般的なエンティティ(人名、都市名など)とは大きく異なります。下記が医療文書に出現するテキスト例で、赤いマーカーが「disease entities」、黄色いマーカーが「anatomical entities」です。
このようにBiomedicalのエンティティとなる単語はドメイン固有な場合は多く、一般的なコーパス(Wikipediaなど)ではほとんど出現しません。
そこで一般的なコーパスから訓練済モデルを構築するのではく、PubMedデータセットを用いて学習を行い精度向上を達成しました。
また、単語の分散表現をCharacter-Level CNNでも学習している点も面白いと思いました。
手法
NERモデル構造は下図の通りです。
これらのレイヤーのうち、
- character-CNN
- word embedding
- BiLSTM(LSTM_F + LSTM_B)
を言語モデルで事前に学習します。
言語モデルの構造は下図の通りです。
また、NERモデルのうち、
- Decoder
- CRF
はNER固有なので、ランダムな初期値を用います。
順番に各レイヤーを見ていきます。
character-level CNN
単語を文字単位で畳み込んでその特徴量を抽出します。
単語レベルで捉えられない文字の特徴量で単語を表現できることが期待できます。
word embedding
単語レベルの特徴量を抽出します。
言語モデルでは一般的なレイヤーと言えます。
Bidirectional LSTM
character-level CNNとword embeddingの特徴量を結合したものをこの層の入力とします。
(論文)BERT
今日も少し前に流行ったモデルの復習。
今日はBERTに関してまとめる。
これもまた既に素晴らしいまとめがあるのでそちらを参考にしながら復習した。
jalammar.github.io
towardsdatascience.com
BERTとは
「Bidirectional Encoder Representations from Transformers」の略。
BERT自体は事前学習モデルではあるが、これを利用することで様々なタスクのSOTAを達成している。
「A new era of NLP」なんて言われるほど、色々なところで騒がれている。
さて、以下の内容としては
- BERTのモデルについて
- どうやって目的のタスクに適用するか
の2つを中心にまとめようと思う。
※きっちり内容を理解したい場合、上の2つの記事を読むことを勧める。
Transformer Encoder
BERTの「T」は、Transformersの略でもあり、モデル自体は前回まとめたTransformerモデルはベースとなっている。
Seq2Seqモデルではないので、Decoderの部分は不要であり、必要なのはEncoderの部分のみ。
論文中には、以下のハイパーパラメータから成るBERT(BASE)とBERT(LARGE)の2つの実験をしている。
BASE | LARGE | 元のTransformerモデル | |
Encoderの数 | 12 | 24 | 6 |
Headの数 | 12 | 16 | 8 |
元のTransformerと比べて、BASEでも十分にごついモデルであることが分かる。
Transformerでは、Encoderの入力は文の各単語を分散表現したものと文をPositional Encodingしたものを加えたものであった。
BERTの場合は何が異なるのか。
Inputs
特徴的なのは、単文と複文(文のペア)のどちらかを入力とすることが出来る点。
入力情報は以下の埋め込み情報の総和である。
- token embeddings
- segmentation embeddings
- position embeddings
特徴的なのは、[CLS]
と[SEP]
というトークン。
全ての入力の先頭には、[CLS]
というトークンを挿入する。
これは「classification embedding」と呼ばれ、後の分類タスクで利用される。
※分類タスク以外の場合、このトークンは無視される。
[SEP]
トークンは、文の区切りを表すトークン。
先頭文を示す埋め込み表現(A embedding)、次の文を表す埋め込み表現(B embedding)を付与する。
※常に入力を単文とする場合は、A embeddingのみが用いられる。
position embeddingsは、文中の各トークンの位置情報を付与するための情報。
これは、Transformerモデルでも利用されていたPositional Encodingと同じ概念。
※実験では最大512系列までの位置情報を埋め込み表現で利用。
Pre-training Tasks
BERTは教師なし学習なので、Seq2Seqのような入力文と対になる出力は存在しない。
では、どのようにして学習を行うのか。
Feature-based Approaches
ELMOのような従来の言語モデルは、次の単語や前の単語をRNNにより学習することで、事前学習を行っていた。ELMOは前向きの言語モデルと後ろ向きの言語モデルを組み合わせることで、表現力の高い言語モデルを獲得している。
※あくまで各言語モデルを組み合わせているだけで、双方向のモデルではない。
Fine-tuning Approaches
最近の転移学習のトレンド。言語モデルを事前学習し、そのモデルを本来のタスクに転移させる手法。
OpenAI GPTは、Transfomerモデルをベースにした言語モデル。
言語モデルからFine-tuningで本来のタスクを解くことができる。
ELMOは前向きの言語モデルと後ろ向きの言語モデルを組み合わせている。
OpenAI GPTはあくまで前向きの言語モデルからのみ(未来の情報をマスクする)構成されている。
しかし、一般的に前向きモデルと後ろ向きモデルの連結より、双方向モデルの方が連結は密と言える。
できれば、双方向で学習したいが、言語モデルではそれが難しい。
双方向の場合、予測時に未来の情報を利用するためカンニングとなってしまう。
BERTではこの問題をうまく回避して双方向の言語モデルを実現している。
1.Masked LM
BERTでは入力情報の一部をマスクしたMasked LMを提案している。
このモデルは、次の単語や前の単語を予測するのではなく、maskされた単語のみを予測する。
実験では、系列の15%を[MASK]
で置換した。
※ただし、常に[MASK]
に置き換えるのではなく、以下の条件をつけた。
- 80%は
[MASK]
のtokenのまま - 10%は
[MASK]
のtokenを全く別の単語に置換 - 10%は
[MASK]
のtokenを元の単語に戻した
学習は従来の言語モデルと似たアプローチ。
Encoderの出力にClassificaiton Layerを追加し、各単語の確率値をsoftmaxにより計算する。
損失計算では、maskされたtokenの損失値のみを考慮して、残りのtokenは無視する。
2.Next Sentence Prediction
BERTの入力は、複文(文のペア)の入力を許していた。
この目的としては、複文のタスク(QAタスクなど)にも利用可能なモデルを構築すること。
ただし、Masked LMだけでは、そのようなモデルは期待できない。
そこで、「Next Sentence Prediction」という学習を行う。
このタスクは非常に簡単である。
問題は「2つの文(A,B)を与えて、BはAの後に続きそうな文かどうか」を解くこと。
Task specific-Models
ここまでがpre-trainedモデルとしてのBERTのモデルの説明。
では、次に気になるのが「どうやって目的のタスクに適用するか」である。
classification tasks
この場合は非常にシンプルで文の先頭に付与した[CLS]
のEncode結果を入力として分類器を構築する。分類器との接続後、接続した層のパラメータとBERTのパラメータを最適化する。
Named Entity Recognition
NERの場合でも、各エンティティを分類する層をBERTの出力に追加するだけ。
最後に
全体を通して、「WordPiece」というキーワードが多く出現した。
入力は単語単位ではなく、このWordPiece単位でtoken化されているよう。
この辺りの概念がよく整理できていないので、次はこれをまとめたい。
(論文)Transformer
久しぶりにブログを更新する。
今日は「Attention Is All You Need」に関する復習。
もはや2年前の論文で、日本語でも丁寧な解説記事がたくさんある。
とっても今更感があるが、自分自身の理解の定着のためにまとめようと思う。
とりあえず、従来のRNN型のSeq2Seqモデルと何が違うのか、その辺を押さえたい。
後、理解を深めるために適宜コードも入れればと思う。
※コードに関しては以下サイトより引用させて頂いた。
towardsdatascience.com
nlp.seas.harvard.edu
Transformerモデル
論文中の図を引用する。
Decoderの入力となるOutputはターゲットは1tokenずつ右にシフトさせておく。
例えば、ターゲット文が「<BOS> 私 は サッカー が 好き <EOS>」の場合、
「<BOS> 私 は サッカー が 好き」としておく。
また、時刻tのtokenから時刻t+1のtokenを予測するためターゲットを左にシフトさせる。
(ターゲットは「私 は サッカー が 好き <EOS>」としておく。)
モデル自体は従来のSeq2Seqモデルと同じ構造。
新しく出現するキーワードは、
- Positional Encoding
- Multi-Head Attention
- Position-wise Feed-Forward Networks
の3つ。
Positional Encoding
入力の位置情報の付与。
センテンスの文脈情報を理解するためには、各単語の順序が重要となる。
このモデルはRNNのように順序を考慮しないので、位置情報を明示的に付与する必要がある。
Positional Encodingは以下の式に基づいて行う。
pos
はセンテンス中の出現位置 ,i
は次元数(単語の分散表現の次元数)。
例えば、文の先頭位置に出現する単語のPositional Encodingを考える。
論文の単語の分散表現の次元数は512なので、以下のように先頭位置を表すベクトルは得られる。
このようして得られた行列をEmbedding後の文の行列に加算することで、各単語と位置(文脈)情報を考慮した情報が得られる。
Multi-Head Attention
Multi-Head Attention層は以下のようなイメージ。
入力を複数のHeadで分割する。
入力は
各パラメータは,,
実験では,なので,としている。
入力を各重みでの線形変換したもの
をScaled dot-product attentionの入力としている。
Scaled dot-product attention
この論文でのattentionモデルであり、タイトルにもあるようにここがモデルの肝の部分。
入力の,,は先ほど計算したもの。
attentionの計算式は以下の通り。
まず、の部分。
だったので、となる。
これにでスケーリングを行った後、softmaxを適用する。
これにを掛け合わせるので、最終的には、
となる。
これを各head毎に行ったものをConcatして重みで線形変換を行う。
となり、単語数×単語の分散表現の次元数に戻される。
結局のところ、この式変形が何を行っていたかをもう少し深堀してみる。
Query, Key, Value
まず、の部分。
スケーリングを無視して考えると、オレンジのスカラー値はの赤のベクトルとの青のベクトルの内積からなることが分かる。
以下、同様となる。
要するに、各queryと各keyの類似度を計算していることに相当する。
この行列の0行目のベクトルはquery0と各keyの類似度を表現したベクトルを意味する。
これをsoftmaxで正規化しており、各行の総和が1となるような重みベクトル(attention_weight)を作り出す。
最後にこの行列との内積を取る。
各queryのattention_weightとvalueの内積を取ったものをattentionの結果として返す。
なので、最終的な単語ベクトル(オレンジ)は、valueの各ベクトルにattention_weightを掛け合わせた値となることが分かる。
Encoderでは、query, key, valueは全て同じ入力から渡される。(Self-Attention)
一方、Decoderでは、1層目のMulti-Head Attentionは全て出力から渡されるが、
2層目のQueryは出力から、key, valueはEncoderから渡される。(Source-Target-Attention)
Decoderの2層目のMulti-Head Attentionでは、出力の各queryとEncoderの各keyの類似度を取り、attention_weightを計算する。
それに出力のvalueを掛け合わせることで、Encoderのattention_weightを考慮した計算が可能となる。
もう少し具体例を挙げて考える。
Source文が「I like playing soccer」、Target文が「私/は/サッカー/が/好き/です」とする。
EncoderではSource文でのAttentionを計算する。
次に、Decoderでは、Source文とTarget文でAttentionを計算する。
query, key, valueはそれぞれ以下の通り。
※Source文からkey, valueが、Target文からQueryが作られる。
attention_weight()は以下のように計算される。
attention_weightの計算を少しだけやってみる。
例えば、attention_weight(0,0)の値は次のように計算できる。
以下、同様である。
先頭のattention_weightは、Target文の「私」とSource文の各単語の内積を計算した結果となる。
このattention_weightとvalueの行列計算を行う。
attention_weightがこのような値の場合、水色のスカラー値はvalueの「soccer」の値に強く影響される一方、その他の値は0.1でスケーリングされるため、ほとんど影響をうけなくなる。
学習がうまくいくと、以降のネットワーク計算において、最も確率の高い単語として「サッカー」が出力されるようになる。
Mask
ターゲット文が「<BOS> 私 は サッカー が 好き <EOS>」の場合、
「<BOS> 私 は サッカー が 好き」としていた。
これは、時刻tのtokenから時刻t+1のtokenを予測するためであり、
「<BOS> 私 は」の情報から次のtokenが「サッカー」であることを予測していた。
つまり「<BOS> 私 は」の時点で「サッカー」という未来の情報は知るはずがない。
学習時は文として与えられるが、推論時は未来の情報は分からない。
このため、未来の情報にMaskをすることでリークを防ぐ。
Position-wise Feed-Forward Networks
attentionが理解できれば、この層は難しくない。
residual connection
ResNetで用いられている構造。
前の層の入力を足し合わせる。
これは「Positional Encoding & Multi-Head Attention」と「Multi-Head Attention & Position-wise Feed-Forward Networks」で利用されている。
実装例は以下の通り。
class SublayerConnection(nn.Module): """ A residual connection followed by a layer norm. Note for code simplicity the norm is first as opposed to last. """ def __init__(self, size, dropout): super(SublayerConnection, self).__init__() self.norm = LayerNorm(size) self.dropout = nn.Dropout(dropout) def forward(self, x, sublayer): "Apply residual connection to any sublayer with the same size." return x + self.dropout(sublayer(self.norm(x)))
これをEncoder内部で呼ぶ。
class EncoderLayer(nn.Module): "Encoder is made up of self-attn and feed forward (defined below)" def __init__(self, size, self_attn, feed_forward, dropout): super(EncoderLayer, self).__init__() self.self_attn = self_attn self.feed_forward = feed_forward self.sublayer = clones(SublayerConnection(size, dropout), 2) self.size = size def forward(self, x, mask): "Follow Figure 1 (left) for connections." x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask)) return self.sublayer[1](x, self.feed_forward)
Positional Encodingの出力がx
となる。
これをEncoder,Decoderで6層ずつ積んでいる。
こんな感じでTransformerモデルが出来る。
今更だが、attentionとか良い勉強になった。
gensimに依存しない単語の類似度計算
はじめに
前に
gensim
による単語の類似度について書きました。kento1109.hatenablog.com
この手の記事はググればいっぱい出てくるので、
gensim
でモデルを作って単語の類似度を計算するのは難しくないと思います。ただ、LSTMなどで学習した後の単語の分散表現の類似度を測定したい場合に、そのためだけにわざわざgensim
のモデルを構築するのは面倒ですし、無駄かなと思います。
ある単語と別の単語の類似度を測定するだけの場合、そのベクトル同士で測定すれば良いですが、gensim
のmost_similar
関数のような「ある単語のベクトルに近いベクトルの単語をN個取ってくる」機能を実現する場合は少し実装が必要です。
1単語ずつループで回してコサイン類似度を計算する、なんてしていては計算速度が遅くなります。
そこで、scipyとnumpyのライブラリを活用した関数を考えました。
僕が考えたというより、stack overflowで書いている人の内容を整理しただけですが、、
stackoverflow.com
実装
scipyには、cdist
という便利な関数があります。
scipy.spatial.distance.cdist — SciPy v1.2.1 Reference Guide
これは、入力のペアの各組み合わせの距離を計算してくれます。
早速見てみます。
import numpy as np from scipy.spatial import distance x = np.random.uniform(low=-1.0, high=1.0, size=[5, 5]) """ array([[-0.63429221, -0.34524962, 0.33029203, -0.47351972, -0.53888627], [ 0.53165424, -0.70032725, -0.91572034, -0.71147304, -0.75343722], [ 0.41431408, -0.67125034, 0.59739731, 0.48297114, 0.78140535], [-0.62946089, -0.74813722, 0.65404081, 0.08913251, 0.33907134], [ 0.00564964, -0.79616936, 0.17679241, 0.89239204, -0.76633555]]) """ distances = distance.cdist(x, x, "cosine") """ array([[0. , 0.80326347, 1.33463697, 0.50618473, 0.79103524], [0.80326347, 0. , 1.35661241, 1.36218033, 0.85455544], [1.33463697, 1.35661241, 0. , 0.43272935, 0.75444092], [0.50618473, 1.36218033, 0.43272935, 0. , 0.69980619], [0.79103524, 0.85455544, 0.75444092, 0.69980619, 0. ]]) """
この関数は「距離」を計算する関数であり、「類似度」を計算する場合は「1-距離」とする必要があります。
0番目のベクトルとそれぞれのベクトルとの類似度を計算する場合は次のように書きます。
distances = distance.cdist([x[0]], x, "cosine")[0] similarity = 1 - distances # array([ 1. , 0.19673653, -0.33463697, 0.49381527, 0.20896476])
類似度計算は後で行うとして、とりあえず距離のままでいきます。
最も近い距離を計算するためには、np.argmin()
を使います。
min_distance = np.argmin(distances)
# 0
最も距離が近いのは自分自身なので、「0」が返ってきます。
これでは意味がないので、以下のように工夫します。
n = 3 target_index = distances.argsort()[1:n+1] # array([3, 4, 1])
距離が近い1番目~N+1番目までのベクトルを取ってきます。
これにより、自身を除くN個のベクトルが取得可能となりました。
最後に取得したインデックスのベクトルとの類似度を計算します。
target_distance = distances[target_index] target_similarity = 1 - target_distance # [0.49381527 0.20896476 0.19673653]
最後にまとめて関数化します。
def most_similar(idx, X, n=10): distances = distance.cdist([X[idx]], X, "cosine")[0] target_index = distances.argsort()[1:n+1] target_distance = distances[target_index] target_similarity = 1 - target_distance print(target_similarity) most_similar(idx=0, X=x, n=3) # [0.49381527 0.20896476 0.19673653]
このままでは対応するインデックスなどは取れませんが、そこは簡単に修正できれば対応できると思います。
Pytorch:テキストの前処理(torchtext)④
はじめに
torchtextの使い方に関するメモ。
入力の素性をカスタマイズしたい場合について
例えば、各系列に付与したカラムを入力に含めたい場合
(0,1は系列の何らかの情報)
a 1 b 0 c 1 d 0 e 1 f 0
これをどうハンドリングするかについて少し考えた。
簡単な方法
多分、一番簡単な方法は以下のように系列カラムと同じように処理するだけ。
TEXT = data.Field() LABEL = data.Field() train = datasets.SequenceTaggingDataset( path=data_dir, fields=[('text', TEXT), ('label', LABEL)]) TEXT.build_vocab(train) LABEL.build_vocab(train)
これだけで基本的には問題ない。
ただ、思ったのは素性が数値の場合、わざわざ「辞書作り」する必要があるのかってこと。
別に辞書作りをしても全く問題ないが、個人的にはそのまま入力できないかなって思った。
で、どうするのが良いか考えてみた。
そのまま処理する方法
1.入力の数値変換
結論としては、そのまま処理できないので、工夫が必要。
まず、初めにデータをDatasetで読み込んだ場合、全ての入力は「文字列」として扱われる。
train.examples[0].label # ['1', '0', '1']
そして、辞書を作らずにそのまま処理した場合、バッチ化のところで「文字は扱えないよ」って怒られる。
なので、まずはここで入力を数値変換する必要がある。
Datasetでハンドリングできるかもしれないが、ここでは Fieldのpreprocessing
で変換させる。
def to_int(x): return list(map(int, x)) LABEL = data.Field(use_vocab=False, preprocessing=to_int)
これで、入力情報を数値変換できる。
train.examples[0].label # [1, 0, 1]
2.pad値の指定
続いて、バッチのpadding時の値を指定する。
これはデフォルトの場合、<unk>
となっているので、このままではまたも怒られる。
そこで、paddingの値をpad_token
で明示する。
LABEL = data.Field(sequential=True, use_vocab=False, preprocessing=to_int, pad_token=-1)
※指定する値は入力で使用しない値をしておくのが無難。
バッチ処理までをまとめて書くとこんな感じ。
from torchtext import data, datasets data_dir = 'test_ner.txt' def to_int(x): return list(map(int, x)) TEXT = data.Field() LABEL = data.Field(sequential=True, use_vocab=False, preprocessing=to_int, pad_token=-1) train = datasets.SequenceTaggingDataset( path=data_dir, fields=[('text', TEXT), ('label', LABEL)]) TEXT.build_vocab(train) rain_iter = data.BucketIterator(dataset=train, batch_size=2, device=-1, repeat=False) for i, train_batch in enumerate(train_iter): print('text : \n', train_batch.text) print('label : \n', train_batch.label) """ text : tensor([[7]]) label : tensor([[0]]) text : tensor([[5, 2], [6, 3], [1, 4]]) label : tensor([[0, 1], [1, 0], [9, 1]]) """
全く持って自己満足に過ぎないが、備忘録として残しておく。
Stan:LDA
はじめに
自然言語処理の領域では広く知られいるLDA(Latent Dirichlet Allocation)について復習する。
LDAはトピックモデルの1種であり、文書がどのようなトピックから構成されているかを推論するモデル。
推論するパラメータは以下の2つ。
- トピック分布:文書ごとのトピック構成比率
- 単語分布:トピックごとの単語比率
トピックモデルに関する理解はこの1枚に尽きると思う。
Fast and Scalable Algorithms for Topic Modeling | Center for Big Data Analyticsより引用
後、日本語でのLDAの説明としては視覚的にも以下が分かりやすかった。
LDA for Pokemon analysis | haripo.com
モデリング
数式によるLDAはググれば色々出てくるのでここでは割愛する。今回は「尤度計算」の具体例のみを数式で紹介する。これが分かれば、Stan
コードも理解できると思う。
各記号の説明は以下の通り。
- トピック分布()
- 単語分布()
は文書数、はトピック数、は単語数を表す。
これは「各文書内に出現する各単語の尤度(出現確率)」を計算している。
ちなみに単語の生成確率は文書の番目の単語のトピック割り当てを意味する潜在変数を用いると以下のように表せた。
しかし、Stan
では、整数型の潜在変数が使えないので、上式のようにしてsumming outしている。
対数尤度の計算
まず、以下の文書のトピックを考える。
play soccer
全ての文書が「音楽」か「スポーツ」の何れかに関するものであるとき、この文書はどちらに分類されるか。
常識で考えると「スポーツ」トピックから生成された(「スポーツ」トピックの方が混合比が大きい)と考えるのが自然だろう。
ただ、そんな常識を機械は知らない。
では、どう考えればよいか。
それを考えるため、この文書のトピック分布()及び単語分布()が事前にディレクレ分布から以下のように生成されたとする。
- トピック分布()
music | 0.5 |
sports | 0.5 |
- 単語分布()
play | soccer | |
music | 0.01 | 0.01 |
sports | 0.01 | 0.01 |
guitar | 0.01 | 0.01 |
baseball | 0.01 | 0.01 |
the | 0.01 | 0.01 |
※guitar,baseball,theに関しては後で言及する。
この文書()における尤度は以下のように計算できる。
次に尤度が大きくなるよう以下のように単語分布をサンプリングされたとする。(トピック分布は固定)
- 単語分布()
play | soccer | |
music | 0.01 | 0.01 |
sports | 0.01 | 0.1 |
guitar | 0.01 | 0.01 |
baseball | 0.01 | 0.01 |
the | 0.01 | 0.01 |
この場合、尤度は以下のように更新される。
単語分布を更新したことで尤度が大きくなったことが確認できた。
※当然だが、機械は「スポーツ」トピックかは知らないので、逆になることもある。(計算結果、人間がこれは「スポーツ」と判断するに過ぎない。)
次に、単語分布を固定してトピック分布を以下のようにサンプリングし直す。
- トピック分布()
music | 0.3 |
sports | 0.7 |
尤度は以下のように更新される。
さらに尤度が大きくなったことが確認できた。
トピック分布・単語分布は合計1の制約があるので、ある単語の出現確率を大きくすると別の単語の確率を小さくする必要がある。
では、どのように単語の割合を調整するのか。
例えば、最初の文書が
play soccer and baseball
だったとする。
この場合、sportsトピックにおけるsoccerの出現確率の増加分をどのように調整するのが良いか。簡単のため、baseball,guitar,theの何れかの出現確率を小さくして調整するとする。
baseballの確率を小さくした場合、この文書の尤度が小さくなるので、baseballは調整しない。一方、guitar,theの確率を小さくしてもこの文書の尤度には何ら影響はない。では、guitar,theであればどちらでもよいのか。結論から言うと、一般的にはguitarの確率を小さくようにサンプリングされる(勿論、これはコーパスによる)。この文書だけを見ると、どちらも尤度に影響を与えない。しかし、コーパス全体で見た時に、guitar,theはどちらの単語の出現確率が高いか。一般的にはtheの方が高い。なので、theの出現確率を下げると、尤度が小さくなる文書の数が多くなる。一方、guitarはそれほど多くの文書に出現するとは考えられないので尤度が下がる文書の数が限定される。文章全体の尤度を大きくすることを考えた場合、guitarの確率を小さくするのが良いと考えられる。
※baseballは調整しないと書いたが、これは一般的は共起性での話であり、他の文書でbaseball以上にguitarがsoccerと共起する場合、baseballの確率が小さくなるよう調整するかもしれない。
サンプリングを文書全体で繰り返しすることで、同じトピックに高確率で出現する単語同士(soccerとbaseballなど)は「共起性」があると考えることが可能となる。
また、同じようなトピック分布をもつ文書同士は類似した文書である可能性が高いと考えることが可能となる。
さいごに
さいごに、
Stan
コードを載せておく。(Stan モデリング言語: ユーザーガイド・リファレンスマニュアルとほとんど同じであるが・・)
data { int<lower=2> K; // num topics int<lower=2> V; // num words int<lower=1> M; // num docs int<lower=1> N; // total word instances int<lower=1,upper=V> w[N]; // word n int<lower=1,upper=M> doc[N]; // doc ID for word n vector<lower=0>[K] alpha; // topic prior vector<lower=0>[V] beta; // word prior } parameters { simplex[K] theta[M]; // topic dist for doc m simplex[V] phi[K]; // word dist for topic k } model { for (m in 1:M) theta[m] ~ dirichlet(alpha); // prior for (k in 1:K) phi[k] ~ dirichlet(beta); // prior for (n in 1:N) { real gamma[K]; for (k in 1:K) gamma[k] = log(theta[doc[n], k]) + log(phi[k, w[n]]); target += log_sum_exp(gamma); // likelihood; } }
以下のようにしてキックできる。
data <- read.csv("lda.csv") stan.data = list(K=10, V=max(data$w), M=max(data$d), N=nrow(data), w=data$w, doc=data$d, alpha=rep(0.1,10), beta=(0.1,max(data$w)) stan.fit <- stan(file="lda.stan", data=dat)
あまりよい推定結果は得られないかもしれないが、とりあえず動作検証にはなると思う。
テストデータは以下に置いた。
github.com