自然言語処理のチュートリアル　〜BoWからWord2Vecの理論から自前実装まで〜

# 概要
自然言語処理について勉強を始めたい人へ、簡単な理論から実験コードまで載せています。
実行環境はJupyter notebookやGoogle Colaboratoryなんかを想定しています。
キーワード：分散表現, BoW, Word2Vec, CBoW, skip-gram


# 目次
- [分散表現](#分散表現)
- [Bag of Words](#bagofwords)
    - [次元削減](#次元削減)
    - [Bag of Wordsの参考](#bag-of-wordsの参考)
- [Word2Vec](#word2vec)
    - [CBoW](#cbow)
    - [skip-gram](#skip-gram)
    - [分散表現の図示](#分散表現の図示)
    - [Word2Vecの参考](#word2vecの参考)


# 分散表現
コンピュータに人間の言語(自然言語)を扱わせる上で必須となるのが**分散表現**です。
もちろんですがコンピュータは数値しか取り扱うことができないため、自然言語を何らかの形で数値に変換する必要があります。また、変換する際は変換元の単語をできる限り復元可能な形で変換する必要があります。
変換の方法としては単語をベクトルとして表す方法があり、そうして得られたベクトルを**分散表現**と言います。このベクトルは大抵100〜300次元で表されており、もちろん次元が大きいほど表現力が高くなりますが、その分メモリや計算コストを多く必要とします。

具体例で考えてみます。
例えば

```
自然 言語 処理 に ついて 勉強 する　。
```
という語彙しか存在しない言語を考えます。この場合なら7単語しかないため、この7単語についてそれぞれ

```math
\begin{array}
  a自然 &= (1, 0, 0, 0, 0, 0, 0) \\
  言語 &= (0, 1, 0, 0, 0, 0, 0) \\
  処理 &= (0, 0, 1, 0, 0, 0, 0) \\
  に &= (0, 0, 0, 1, 0, 0, 0) \\
  ついて &= (0, 0, 0, 0, 1, 0, 0) \\
  勉強 &= (0, 0, 0, 0, 0, 1, 0) \\
  する &= (0, 0, 0, 0, 0, 0, 1)
\end{array}
```

といった感じに7次元のベクトルを割り振ると完全に分離された分散表現が得られます。(one-hot表現)
他にも2進数を割り振れば

```math
\begin{array}
  a自然 &= (0, 0, 1) \\
  言語 &= (0, 1, 0) \\
  処理 &= (0, 1, 1) \\
  に &= (1, 0, 0) \\
  ついて &= (1, 0, 1) \\
  勉強 &= (1, 1, 0) \\
  する &= (1, 1, 1)
\end{array}
```

のように3次元のベクトルで分散表現を作成することもできます。
このように、分散表現自体は好きな方法で構築することができますが、上記の分散表現は両方とも利便性の観点から問題があります。

1つ目の分散表現の問題は単純に語彙数に比例してベクトルの次元も大きくなってしまうことです。先の例だとたった7単語しかありませんでしたので全く問題ありませんが、現実では何万何十万という語彙があるため、1つ目のようなやり方ではあっという間にとんでもない量のメモリを食うことになってしまいます。
その点2つ目のやり方だと$2^{20}=1048576$なので、たった20次元で約100万単語に対して一意のベクトルを生成することができます。が、こちらにも問題があります。
それは、分散表現を利用した自然言語処理を行う上で非常に不便、というより利用できないということです。一意のベクトルを作成することができても、それではただ単に各単語にラベルを付けただけであり、単語間の関連や共起性[^1]、意味、役割...といった情報が一切含まれていません。
そのため、ただ単に単語がベクトルになっただけで何もわからない、という状況になってしまいます。この問題は1つ目のやり方でも同様に存在しています。

# Bag of Words
ここまでで挙がった分散表現を作る際の課題をまとめると、

- いかに表現力を維持しつつ次元数を抑えるか
- いかに単語間の関連や共起性、意味、役割などの情報を含めるか

といった感じです。
これらの課題を解決する分散表現の作成方法のうち、ディープラーニング登場以前によく使われていたのがBoW: Bag of Wordsです。
BoWは単語の出現頻度に着目して分散表現を作成する方法で、単語間の関連や意味、役割などは考えませんが共起性は取り込むことができます。

例えば

```
私　は　ラーメン　が　好き　だ　。
私　は　寿司　が　好き　だ　。
彼　は　ラーメン　が　好き　だ　。
```

という文章をBoWで表現すると、

||。|が|だ|は|ラーメン|好き|寿司|彼|私|
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|文章1| 1 | 1 | 1 | 1 | 1 | 1 | 0 | 0 | 1 |
|文章2| 1 | 1 | 1 | 1 | 0 | 1 | 1 | 0 | 1 |
|文章3| 1 | 1 | 1 | 1 | 1 | 1 | 0 | 1 | 0 |

のようになります。単語が出現していれば1、そうでなければ0としています。
ちなみにBoWの分散表現の作り方はいろいろあるため、これはあくまで一例であることに注意してください。

```python
from sklearn.feature_extraction.text import CountVectorizer

corpus = ["私　は　ラーメン　が　好き　だ　。",
          "私　は　寿司　が　好き　だ　。",
          "彼　は　ラーメン　が　好き　だ　。"]

def tokenizer(text):
    return text.split("　")

cv = CountVectorizer(tokenizer=tokenizer)
weights = cv.fit_transform(corpus)
print(cv.get_feature_names())
print(weights.toarray())
```

```
['。', 'が', 'だ', 'は', 'ラーメン', '好き', '寿司', '彼', '私']
[[1 1 1 1 1 1 0 0 1]
 [1 1 1 1 0 1 1 0 1]
 [1 1 1 1 1 1 0 1 0]]
```

これにより**文章のベクトル**が作成できたので、文章の類似度を計算することができます。例えばよく使用されるcos類似度で3つの文章の類似度を計算してみます。

```python
from sklearn.metrics.pairwise import cosine_similarity

x = weights.toarray()
sim = cosine_similarity(x)
for i in range(len(x)):
    for j in range(i+1, len(x)):
        print(f"文章{i+1}と文章{j+1}の類似度：{sim[i, j]}")
```

```
文章1と文章2の類似度：0.8571428571428569
文章1と文章3の類似度：0.8571428571428569
文章2と文章3の類似度：0.7142857142857141
```

続いて単語の分散表現に移ります。単語の分散表現を作るには、「単語の意味は周辺の単語によって形成される」という**分布仮説**をもとに**共起行列**を作る方法があります。

||。|が|だ|は|ラーメン|好き|寿司|彼|私|
|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|。| 0 | 0 | 3 | 0 | 0 | 0 | 0 | 0 | 0 |
|が| 0 | 0 | 0 | 0 | 2 | 3 | 1 | 0 | 0 |
|だ| 3 | 0 | 0 | 0 | 0 | 3 | 0 | 0 | 0 |
|は| 0 | 0 | 0 | 0 | 2 | 0 | 1 | 1 | 2 |
|ラーメン| 0 | 2 | 0 | 2 | 0 | 0 | 0 | 0 | 0 |
|好き| 0 | 3 | 3 | 0 | 0 | 0 | 0 | 0 | 0 |
|寿司| 0 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
|彼| 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
|私| 0 | 0 | 0 | 2 | 0 | 0 | 0 | 0 | 0 |

```python
vocab = cv.get_feature_names()
n_vocab = len(vocab)
w2id = dict(zip(vocab, range(n_vocab)))
id2w = dict(zip(range(n_vocab), vocab))

window_size = 1
cooccurrence = np.zeros((n_vocab, n_vocab), dtype=int)
for text in corpus:
    split_text = tokenizer(text)
    for i, word in enumerate(split_text):
        for j in range(1, window_size+1):
            if i - j >= 0:
                cooccurrence[w2id[word], w2id[split_text[i-j]]] += 1
            if i + j < len(split_text):
                cooccurrence[w2id[word], w2id[split_text[i+j]]] += 1
cooccurrence
```

```
[[0 0 3 0 0 0 0 0 0]
 [0 0 0 0 2 3 1 0 0]
 [3 0 0 0 0 3 0 0 0]
 [0 0 0 0 2 0 1 1 2]
 [0 2 0 2 0 0 0 0 0]
 [0 3 3 0 0 0 0 0 0]
 [0 1 0 1 0 0 0 0 0]
 [0 0 0 1 0 0 0 0 0]
 [0 0 0 2 0 0 0 0 0]]
```

コーパス[^2]が文章3つしかないため酷い精度ですが、一応これで単語の共起行列が生成できました。
これをもとに単語間の類似度を計算してみます。

```python
sim = cosine_similarity(cooccurrence)
for i in range(n_vocab):
    for j in range(i+1, n_vocab):
        print(f"「{id2w[i]}」と「{id2w[j]}」の類似度：{sim[i, j]}")
```

```
「。」と「が」の類似度：0.0
「。」と「だ」の類似度：0.0
「。」と「は」の類似度：0.0
「。」と「ラーメン」の類似度：0.0
「。」と「好き」の類似度：0.7071067811865476
「。」と「寿司」の類似度：0.0
「。」と「彼」の類似度：0.0
「。」と「私」の類似度：0.0
「が」と「だ」の類似度：0.5669467095138409
「が」と「は」の類似度：0.42257712736425834
「が」と「ラーメン」の類似度：0.0
「が」と「好き」の類似度：0.0
「が」と「寿司」の類似度：0.0
「が」と「彼」の類似度：0.0
「が」と「私」の類似度：0.0
「だ」と「は」の類似度：0.0
「だ」と「ラーメン」の類似度：0.0
「だ」と「好き」の類似度：0.0
「だ」と「寿司」の類似度：0.0
「だ」と「彼」の類似度：0.0
「だ」と「私」の類似度：0.0
「は」と「ラーメン」の類似度：0.0
「は」と「好き」の類似度：0.0
「は」と「寿司」の類似度：0.0
「は」と「彼」の類似度：0.0
「は」と「私」の類似度：0.0
「ラーメン」と「好き」の類似度：0.5
「ラーメン」と「寿司」の類似度：0.9999999999999998
「ラーメン」と「彼」の類似度：0.7071067811865475
「ラーメン」と「私」の類似度：0.7071067811865475
「好き」と「寿司」の類似度：0.5
「好き」と「彼」の類似度：0.0
「好き」と「私」の類似度：0.0
「寿司」と「彼」の類似度：0.7071067811865475
「寿司」と「私」の類似度：0.7071067811865475
「彼」と「私」の類似度：1.0
```

これだけ小さなコーパスで、適当すぎる文章から生成した共起行列ですが、それでも助詞同士や主語同士、目的語同士の類似度は高く見積もられていますね。もっと大きなコーパスで試せば良い精度が得られそうな感じがします。

## 次元削減
ところでこの共起行列ですが、当然ながら語彙量が増えるとそれに比例して各単語ベクトルのサイズが大きくなります。語彙量は簡単に数万になりますので、このままではとても扱いきれません。
それに、先の例の共起行列を見ていただけるとわかるとおりほとんどの要素がゼロとなっており無駄が多いです。
そのため、**次元圧縮**という方法で単語ベクトルのサイズを削減することが一般的です。
次元圧縮の方法はPCA:主成分分析やSVD:特異値分解などがあります。
今回は特にSVDについて、実際に先の例の共起行列で試してみましょう。理論面なんかは全て[参考](#bag-of-wordsの参考)に譲ります。

```python
import numpy as np

np.set_printoptions(linewidth=200)
u, s, vh = np.linalg.svd(cooccurrence, full_matrices=False)
print("----- u -----")
print(u)
print("----- s -----")
print(s)
print("----- vh -----")
print(vh)
```

```
----- u -----
[[ 0.00000000e+00  4.34792206e-01  0.00000000e+00  4.40054003e-01  6.62372089e-01  0.00000000e+00 -4.22577127e-01 -8.27511384e-17 -8.30537873e-33]
 [-6.44504833e-01  0.00000000e+00  3.34129985e-01  0.00000000e+00  0.00000000e+00 -6.87728634e-01  0.00000000e+00  0.00000000e+00  0.00000000e+00]
 [-7.37140310e-01  0.00000000e+00 -5.10421773e-01  0.00000000e+00  0.00000000e+00  4.42824769e-01  0.00000000e+00  0.00000000e+00  0.00000000e+00]
 [-2.03070635e-01  0.00000000e+00  7.92355202e-01  0.00000000e+00  0.00000000e+00  5.75269980e-01  0.00000000e+00  0.00000000e+00  0.00000000e+00]
 [ 0.00000000e+00  3.33287257e-01  0.00000000e+00 -6.47457118e-01 -1.12142889e-01  0.00000000e+00 -5.07092553e-01 -4.47213595e-01  5.63262098e-18]
 [ 0.00000000e+00  8.14944633e-01  0.00000000e+00  1.51987849e-01 -3.66324324e-01  0.00000000e+00  4.22577127e-01  5.55111512e-17  8.47409176e-33]
 [ 0.00000000e+00  1.66643628e-01  0.00000000e+00 -3.23728559e-01 -5.60714443e-02  0.00000000e+00 -2.53546276e-01  8.94427191e-01 -1.12652420e-17]
 [ 0.00000000e+00  3.99261526e-02  0.00000000e+00 -2.27706508e-01  2.86827360e-01  0.00000000e+00  2.53546276e-01  2.27905073e-16 -8.94427191e-01]
 [ 0.00000000e+00  7.98523052e-02  0.00000000e+00 -4.55413016e-01  5.73654720e-01  0.00000000e+00  5.07092553e-01  1.17209363e-17  4.47213595e-01]]
----- s -----
[ 5.08615587e+00  5.08615587e+00  3.47972138e+00  3.47972138e+00  2.00563147e+00  2.00563147e+00  1.40746457e-16  2.73691106e-48 -0.00000000e+00]
----- vh -----
[[-4.34792206e-01  0.00000000e+00  0.00000000e+00  0.00000000e+00 -3.33287257e-01 -8.14944633e-01 -1.66643628e-01 -3.99261526e-02 -7.98523052e-02]
 [ 0.00000000e+00  6.44504833e-01  7.37140310e-01  2.03070635e-01 -1.20301362e-16  0.00000000e+00 -4.63952991e-18  7.22075342e-18  1.44415068e-17]
 [-4.40054003e-01  0.00000000e+00  0.00000000e+00  0.00000000e+00  6.47457118e-01 -1.51987849e-01  3.23728559e-01  2.27706508e-01  4.55413016e-01]
 [-0.00000000e+00 -3.34129985e-01  5.10421773e-01 -7.92355202e-01 -4.67674202e-17  0.00000000e+00 -5.11392857e-17 -1.42277527e-17 -2.84555054e-17]
 [-0.00000000e+00 -6.87728634e-01  4.42824769e-01  5.75269980e-01 -8.09271129e-18  0.00000000e+00 -3.18019313e-17 -2.96687350e-17 -5.93374699e-17]
 [ 6.62372089e-01  0.00000000e+00  0.00000000e+00  0.00000000e+00 -1.12142889e-01 -3.66324324e-01 -5.60714443e-02  2.86827360e-01  5.73654720e-01]
 [ 0.00000000e+00 -3.70074342e-17  5.55111512e-17  7.44760246e-17 -4.47213595e-01  0.00000000e+00  8.94427191e-01 -1.33226763e-16  6.66133815e-17]
 [ 0.00000000e+00  2.05432527e-33 -3.08148791e-33  0.00000000e+00  2.48253415e-17  0.00000000e+00 -4.96506831e-17 -8.94427191e-01  4.47213595e-01]
 [-4.22577127e-01  0.00000000e+00  0.00000000e+00  0.00000000e+00 -5.07092553e-01  4.22577127e-01 -2.53546276e-01  2.53546276e-01  5.07092553e-01]]
```

ここで、$s$行列の要素が大きい6番目までで次元圧縮してみましょう。

```python
compress_coocurrence = u[:, :6]@np.diag(s[:6])@vh[:6, :]
print(compress_coocurrence)
```

```
[[ 0.00000000e+00 -8.32044902e-16  3.00000000e+00 -5.57159782e-16 -3.48401238e-16  0.00000000e+00 -1.30815817e-16 -4.52324912e-17 -9.04649823e-17]
 [ 4.88716010e-16  0.00000000e+00  0.00000000e+00  0.00000000e+00  2.00000000e+00  3.00000000e+00  1.00000000e+00 -1.55000252e-16 -3.10000504e-16]
 [ 3.00000000e+00  0.00000000e+00  0.00000000e+00  0.00000000e+00 -1.11962118e-16  3.00000000e+00 -5.59810591e-17  3.23287010e-17  6.46574020e-17]
 [-8.87710888e-17  0.00000000e+00  0.00000000e+00  0.00000000e+00  2.00000000e+00  1.60417625e-16  1.00000000e+00  1.00000000e+00  2.00000000e+00]
 [ 0.00000000e+00  2.00000000e+00 -1.40860966e-15  2.00000000e+00 -9.67431639e-17  0.00000000e+00  1.14503397e-16  5.09679842e-17  1.01935968e-16]
 [ 0.00000000e+00  3.00000000e+00  3.00000000e+00  1.30734645e-16 -5.17429707e-16  0.00000000e+00 -2.29116128e-17  4.42028123e-17  8.84056246e-17]
 [ 0.00000000e+00  1.00000000e+00 -6.05573384e-16  1.00000000e+00 -4.83715819e-17  0.00000000e+00  5.72516985e-17  2.54839921e-17  5.09679842e-17]
 [ 0.00000000e+00 -9.30753913e-17 -5.44453816e-17  1.00000000e+00  7.97124080e-18  0.00000000e+00  2.12836304e-17 -4.32777572e-18 -8.65555144e-18]
 [ 0.00000000e+00 -1.86150783e-16 -1.08890763e-16  2.00000000e+00  1.59424816e-17  0.00000000e+00  4.25672609e-17 -8.65555144e-18 -1.73111029e-17]]
```

圧縮前後の2乗誤差を比較してみましょう。

```python
print(np.linalg.norm(cooccurrence - compress_coocurrence))
```

```
3.526933923021599e-15
```

この結果から分かる通り、圧縮の前後でほとんど情報が失われていませんね。
当然と言えば当然ですが、削減後の次元数が少ないほど元の共起行列とは異なるものになってしまうことに注意が必要です。

```python
import matplotlib.pyplot as plt

diff = []
for i in range(1, n_vocab+1):
    compress_coocurrence = u[:, :i]@np.diag(s[:i])@vh[:i, :]
    diff.append(np.linalg.norm(cooccurrence - compress_coocurrence))

plt.title("SVD compression")
plt.yscale("log")
plt.plot(range(1, n_vocab+1), diff)
plt.grid()
plt.xlabel("compress order")
plt.ylabel("norm error")
plt.show()
```

![SVD_compress.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/640911/7288c31a-ae7c-6e40-ca9f-3cf7cba307ca.png)

グラフから、今回の場合は6次元までの圧縮では元の共起行列の情報をほとんど失わずに保持することができることがわかります。

これのどこが次元削減なのか、と思われる方もいらっしゃると思いますが、もちろんこのままでは次元削減できていません。というのも、SVDによる次元削減は特異値分解に付随するオマケだからです。
生成された$s$行列の各要素の大きさは重要度のようなものを表しており、ある程度の閾値でこれをカットすることで、可能な限り関係情報を維持したまま次元削減することができます。

```python
print(u[:, :6]@np.diag(s[:6]))
```

```
[[ 0.          2.21142093  0.          1.53126532  1.32847431  0.        ]
 [-3.27805204  0.          1.16267925  0.          0.         -1.37933019]
 [-3.74921052  0.         -1.77612555  0.          0.          0.88814329]
 [-1.0328489   0.          2.75717533  0.          0.          1.15377958]
 [ 0.          1.69515094  0.         -2.25297037 -0.22491731  0.        ]
 [ 0.          4.14493543  0.          0.52887537 -0.73471159  0.        ]
 [ 0.          0.84757547  0.         -1.12648519 -0.11245865  0.        ]
 [ 0.          0.20307064  0.         -0.7923552   0.57526998  0.        ]
 [ 0.          0.40614127  0.         -1.5847104   1.15053996  0.        ]]
```

これで$9 \times 9$の共起行列が$9 \times 6$に削減されました。
実際に単語間の類似度を計算してみましょう。

```python
sim = cosine_similarity(u[:, :6]@np.diag(s[:6]))
for i in range(n_vocab):
    for j in range(i+1, n_vocab):
        print(f"「{id2w[i]}」と「{id2w[j]}」の類似度：{sim[i, j]}")
```

```
「。」と「が」の類似度：0.0
「。」と「だ」の類似度：0.0
「。」と「は」の類似度：0.0
「。」と「ラーメン」の類似度：-9.358134352140677e-16
「。」と「好き」の類似度：0.7071067811865472
「。」と「寿司」の類似度：-8.309365609351454e-16
「。」と「彼」の類似度：-3.320011377892241e-16
「。」と「私」の類似度：-3.320011377892241e-16
「が」と「だ」の類似度：0.5669467095138412
「が」と「は」の類似度：0.4225771273642582
「が」と「ラーメン」の類似度：0.0
「が」と「好き」の類似度：0.0
「が」と「寿司」の類似度：0.0
「が」と「彼」の類似度：0.0
「が」と「私」の類似度：0.0
「だ」と「は」の類似度：1.2604230854720464e-16
「だ」と「ラーメン」の類似度：0.0
「だ」と「好き」の類似度：0.0
「だ」と「寿司」の類似度：0.0
「だ」と「彼」の類似度：0.0
「だ」と「私」の類似度：0.0
「は」と「ラーメン」の類似度：0.0
「は」と「好き」の類似度：0.0
「は」と「寿司」の類似度：0.0
「は」と「彼」の類似度：0.0
「は」と「私」の類似度：0.0
「ラーメン」と「好き」の類似度：0.49999999999999967
「ラーメン」と「寿司」の類似度：1.0
「ラーメン」と「彼」の類似度：0.7071067811865475
「ラーメン」と「私」の類似度：0.7071067811865475
「好き」と「寿司」の類似度：0.4999999999999998
「好き」と「彼」の類似度：-1.73340870411744e-16
「好き」と「私」の類似度：-1.73340870411744e-16
「寿司」と「彼」の類似度：0.7071067811865475
「寿司」と「私」の類似度：0.7071067811865475
「彼」と「私」の類似度：1.0000000000000002
```

比較してみると以下のようになります。

|単語1|単語2|圧縮前の類似度|圧縮後の類似度|
|:-:|:-:|:-:|:-:|
|。|が|0.0|0.0|
|。|だ|0.0|0.0|
|。|は|0.0|0.0|
|。|ラーメン|0.0|-9.358134352140677e-16|
|。|好き|0.7071067811865476|0.7071067811865472|
|。|寿司|0.0|-8.309365609351454e-16|
|。|彼|0.0|-3.320011377892241e-16|
|。|私|0.0|-3.320011377892241e-16|
|が|だ|0.5669467095138409|0.5669467095138412|
|が|は|0.42257712736425834|0.4225771273642582|
|が|ラーメン|0.0|0.0|
|が|好き|0.0|0.0|
|が|寿司|0.0|0.0|
|が|彼|0.0|0.0|
|が|私|0.0|0.0|
|だ|は|0.0|1.2604230854720464e-16|
|だ|ラーメン|0.0|0.0|
|だ|好き|0.0|0.0|
|だ|寿司|0.0|0.0|
|だ|彼|0.0|0.0|
|だ|私|0.0|0.0|
|は|ラーメン|0.0|0.0|
|は|好き|0.0|0.0|
|は|寿司|0.0|0.0|
|は|彼|0.0|0.0|
|は|私|0.0|0.0|
|ラーメン|好き|0.5|0.49999999999999967|
|ラーメン|寿司|0.9999999999999998|1.0|
|ラーメン|彼|0.7071067811865475|0.7071067811865475|
|ラーメン|私|0.7071067811865475|0.7071067811865475|
|好き|寿司|0.5|0.4999999999999998|
|好き|彼|0.0|-1.73340870411744e-16|
|好き|私|0.0|-1.73340870411744e-16|
|寿司|彼|0.7071067811865475|0.7071067811865475|
|寿司|私|0.7071067811865475|0.7071067811865475|
|彼|私|1.0|1.0000000000000002|

このように、うまく情報を残しつつ次元を削減することに成功しています。
ただしこのSVDは計算コストが高いため、大規模なコーパスから生成された共起行列を次元圧縮することは現実的ではないという問題もあります。

## Bag of Wordsの参考
- [シンプルだけど奥が深い、単語で表す文章の特徴](https://medium.com/chakki/%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E3%81%A0%E3%81%91%E3%81%A9%E5%A5%A5%E3%81%8C%E6%B7%B1%E3%81%84-%E5%8D%98%E8%AA%9E%E3%81%A7%E8%A1%A8%E3%81%99%E6%96%87%E7%AB%A0%E3%81%AE%E7%89%B9%E5%BE%B4-5e12123e8a60#:~:text=Bag%20of%20Words%E3%81%A8%E3%81%AF,%E3%81%97%E3%81%AA%E3%81%84%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E3%81%AA%E8%A1%A8%E7%8F%BE%E3%81%A7%E3%81%99%E3%80%82)
- [Bag of Wordsについて書いてみる](https://www.pytry3g.com/entry/2018/03/21/181514)
- [単語と図で理解する自然言語処理（word2vec, RNN, LSTM）前編](https://www.takapy.work/entry/2019/01/06/203042)
- [SVD（特異値分解）解説](https://qiita.com/sakami/items/d01fa353b4e1f48623a8)
- [2017-12-14
Pythonで特異値分解(SVD)を理解する](https://ohke.hateblo.jp/entry/2017/12/14/230500)
- [次元削減手法（まとめと実装）PCA, LSI(SVD), LDA, ICA, PLIS](https://qiita.com/Hatomugi/items/d6c8bb1a049d3a84feaa)


# Word2Vec
BoWはシンプルながら強力な手法ですが、先に述べた通り大規模コーパスに対しては無力であることや、近年のディープラーニングの台頭によってその座を奪われてきています。特に2013年にGoogleの研究員であったTomas Mikolov氏が発表した**Word2Vec**という手法が有名です。
この手法とBoWの最大の違いは、単語の分散表現に**概念が含まれていること**です。
BoWではあくまで単語の前後関係や共起によって分散表現を作成していたため、単語のニュアンスの違いなんかを取り込むことができていません。
例えば「彼女は私の母です」と「必要は発明の母」では、「母」という単語のニュアンスが異なることがお分かりいただけると思います。前者は「親」で、後者は「源」ですね。そういったニュアンスをBoWでは拾うことができません。

これの嬉しいところは以下のような計算が成り立つことです。

```math
v_{王} - v_{男性} + v_{女性} = v_{女王}
```

上記のように、「王」の概念から「男性」の概念を引き算し、「女性」の概念を足すと「女王」という別の単語を表現することができます。
これは私たち人間がそうであるように、単語の概念までを機械が学習していることを意味しています。
ただし現段階ではまだ言葉から得られる概念情報しか学習していませんので、私たち人間ほどの表現力を得られているかと言われるとそうではありません。それでも非常に大きなブレイクスルーと言えますね。

そんなWord2Vecによる分散表現はCBoWとskip-gramという二つの方法で得ることができます。

## CBoW
CBoW: Continuous Bag of Wordsは「ある文章(**コンテキスト**)の特定の1単語(**ターゲット**)を隠し、コンテキストからターゲットを推測する」というタスクを解くニューラルネットワークから作成されるWord2Vec分散表現です。
「私はラーメンが好きだ。」をコンテキスト、「ラーメン」をターゲットとしたCBoWのアーキテクチャを説明します。

![CBoW.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/640911/8eca35c1-1a37-84cb-e49b-86d22143b8c2.png)

まず、ターゲット周辺の何単語を参照するか、という**ウィンドウサイズ**を決定します。
その後$W_{\textrm{in}}: n_{\textrm{vocab}} \times n_{\textrm{hidden}}$の重みと行列積を取り、それを平均([元論文](https://arxiv.org/pdf/1301.3781.pdf)では合計)します。
その後、$W_{\textrm{out}}: n_{\textrm{hidden}} \times n_{\textrm{vocab}}$の重みと行列積を取り、その出力を[Softmax関数](https://qiita.com/kuroitu/items/73cd401afd463a78115a#%E3%82%BD%E3%83%95%E3%83%88%E3%83%9E%E3%83%83%E3%82%AF%E3%82%B9%E9%96%A2%E6%95%B0softmax)に通して確率に変換します。
そして、正解ラベルとしてターゲットのone-hotベクトルを与え学習します。
そうして学習を進めた結果得られる$W_{\textrm{in}}$がCBoWによって得られる単語の分散表現になります、
アーキテクチャ全体としては**Auto-Encoder**と呼ばれるアーキテクチャと似ています。詳しくは[参考](https://www.quora.com/Is-word2vec-basically-an-autoencoder)をご覧になってください。

数式も置いておきます。

```math
\textrm{Forward} \\
\begin{align}
  \underbrace{\mathbb{y}_\textrm{hidden}}_{n_\textrm{words} \times n_\textrm{hidden}} &= \underbrace{\mathbb{x}}_{n_\textrm{words} \times n_\textrm{vocab}} \underbrace{\mathbb{W}_\textrm{in}}_{n_\textrm{vocab} \times n_\textrm{hidden}} + \underbrace{\textrm{broadcast}(\underbrace{\mathbb{b}_\textrm{hidden}}_{1 \times n_\textrm{hidden}})}_{n_\textrm{words} \times n_\textrm{hidden}} \\
  \underbrace{\mathbb{y}'_\textrm{hidden}}_{1 \times n_\textrm{hidden}} &= \textrm{sum}(\mathbb{y}_\textrm{hidden}, \textrm{axis}=0) \\
  \underbrace{\mathbb{y}_\textrm{output}}_{1 \times n_\textrm{vocab}} &= \underbrace{\mathbb{y}'_\textrm{hidden}}_{1 \times n_\textrm{vocab}} \underbrace{\mathbb{W}_\textrm{out}}_{n_\textrm{hidden} \times n_\textrm{vocab}} + \underbrace{\mathbb{b}_\textrm{output}}_{1 \times n_\textrm{vocab}} \\
  \underbrace{\mathbb{y}_\textrm{predict}}_{1 \times n_\textrm{vocab}} &= \textrm{Softmax}(\mathbb{y}_\textrm{output})
\end{align}
```

```math
\textrm{Backward} \\
\begin{align}
  \underbrace{\mathbb{\delta}}_{1 \times n_\textrm{vocab}} &= \underbrace{\mathbb{y}_\textrm{predict}}_{1 \times n_\textrm{vocab}} - \underbrace{\mathbb{t}}_{1 \times n_\textrm{vocab}} \\
  \underbrace{\mathbb{\delta}_{\mathbb{W}_\textrm{out}}}_{n_\textrm{hidden} \times n_\textrm{vocab}} &= \underbrace{\mathbb{y}'^\top_\textrm{hidden}}_{n_\textrm{hidden} \times 1} \underbrace{\mathbb{\delta}}_{1 \times n_\textrm{vocab}} \\
  \underbrace{\mathbb{\delta}_{\mathbb{b}_\textrm{out}}}_{1 \times n_\textrm{vocab}} &= \underbrace{\mathbb{\delta}}_{1 \times n_\textrm{vocab}} \\
  \underbrace{\mathbb{\delta}_\textrm{hidden}}_{1 \times n_\textrm{hidden}} &= \underbrace{\mathbb{\delta}}_{1 \times n_\textrm{vocab}} \underbrace{\mathbb{W}^\top_\textrm{out}}_{n_\textrm{vocab} \times n_\textrm{hidden}} \\
  \underbrace{\mathbb{\delta}'_\textrm{hidden}}_{n_\textrm{words} \times n_\textrm{hidden}} &= \underbrace{\textrm{broadcast}(\underbrace{\mathbb{\delta}_\textrm{hidden}}_{1 \times n_\textrm{hidden}})}_{n_\textrm{words} \times n_\textrm{hidden}} \\
  \underbrace{\mathbb{\delta}_{\mathbb{W}_\textrm{in}}}_{n_\textrm{vocab} \times n_\textrm{hidden}} &= \underbrace{\mathbb{x}^\top}_{n_\textrm{vocab} \times n_\textrm{words}} \underbrace{\mathbb{\delta}'_\textrm{hidden}}_{n_\textrm{words} \times n_\textrm{hidden}} \\
  \underbrace{\mathbb{\delta}_{\mathbb{b}_\textrm{hidden}}}_{1 \times n_\textrm{hidden}} &= \underbrace{\textrm{sum}(\underbrace{\mathbb{\delta}'_\textrm{hidden}}_{n_\textrm{words} \times n_\textrm{hidden}}, \textrm{axis}=0)}_{1 \times n_\textrm{hidden}}
\end{align}
```

実際に計算してみた結果が以下になります。

<details><summary>CBoWのPythonコード</summary><div>

```python
from sklearn.feature_extraction.text import CountVectorizer

corpus = ["私　は　ラーメン　が　好き　だ　。",
          "私　は　寿司　が　好き　だ　。",
          "彼　は　ラーメン　が　好き　だ　。"]

def tokenizer(text):
    return text.split("　")

cv = CountVectorizer(tokenizer=tokenizer)
weights = cv.fit_transform(corpus)
print(cv.get_feature_names())
print(weights.toarray())
```

```python
import numpy as np


def one_hot(word):
    one_hot_vec = np.zeros(len(cv.vocabulary_))
    one_hot_vec[cv.vocabulary_[word]] = 1
    return one_hot_vec


def get_surround(words, pointer, window_size):
    input = words[max(pointer-window_size, 0)
                : min(pointer+window_size+1, len(words))]
    if pointer-window_size < 0:
        del input[pointer]
    elif pointer+window_size+1 > len(words):
        del input[pointer+window_size+1-len(words)]
    else:
        del input[window_size]
    return input
```

```python
# CBoW
import matplotlib.pyplot as plt
from tqdm.notebook import trange


np.random.seed(42)

n_vocab = len(cv.vocabulary_)
epoch = 10000
window_size = 1
n_hidden = 6
lr = 1e-2
threshold = 1e-8
interval = 100

words_list = [text.split("\u3000") for text in corpus]
cbow_weight = 1e-2*np.random.randn(n_vocab, n_hidden)
cbow_bias = 1e-2*np.random.randn(1, n_hidden)
out_weight = 1e-2*np.random.randn(n_hidden, n_vocab)
out_bias = 1e-2*np.random.randn(1, n_vocab)

prev_loss = 0.
losses = []
for t in trange(epoch):
    loss_per_epoch = 0.
    for words in words_list:
        indices = list(range(len(words)))
        np.random.shuffle(indices)
        for i in indices:
            # Ready inputs
            input = get_surround(words, i, window_size)
            ans = words[i]
            x = np.array([one_hot(word) for word in input])
            y = np.array(one_hot(ans)).reshape(1, -1)

            # Forward
            hidden = (x@cbow_weight + cbow_bias).mean(axis=0, keepdims=True)
            output = hidden@out_weight + out_bias
            predict = np.exp(output)/np.sum(np.exp(output), keepdims=True)

            # Compute loss
            loss = np.sum(-y*np.log(predict+1e-8))
            loss_per_epoch += loss

            # Backward
            delta = predict - y
            d_out_weight = hidden.T@delta
            d_out_bias = delta.copy()
            d_hidden = delta@out_weight.T
            d_hidden = np.tile(d_hidden, x.shape[0])\
                         .reshape(x.shape[0], -1)*x.shape[0]
            d_hidden_weight = x.T@d_hidden
            d_hidden_bias = np.sum(d_hidden, axis=0, keepdims=True)

            # Update
            cbow_weight -= lr*d_hidden_weight
            cbow_bias -= lr*d_hidden_bias
            out_weight -= lr*d_out_weight
            out_bias -= lr*d_out_bias
    if t%interval == 0:
        losses.append(loss_per_epoch)
    if abs(prev_loss-loss_per_epoch) < threshold:
        break
    else:
        prev_loss = loss_per_epoch
print(f"epoch {t}: final loss = {loss_per_epoch}")

plt.title("Loss")
plt.xlabel(f"epoch [*{interval}]")
plt.ylabel("error")
plt.yscale("log")
plt.grid()
plt.plot(losses)
```
</div>少し複雑なためコードの概説をしておきます。<div>

- 関数`one_hot`：ある単語についてのone-hotベクトル表現を得る関数
- 関数`get_surround`：あるターゲット単語(`pointer`で指示される)の周辺`2*window_size`の単語を取得する関数。ターゲット単語を含まないように取り除く処理のせいで少し複雑に見えると思います。

```python
words_list = [text.split("\u3000") for text in corpus]
cbow_weight = 1e-2*np.random.randn(n_vocab, n_hidden)
cbow_bias = 1e-2*np.random.randn(1, n_hidden)
out_weight = 1e-2*np.random.randn(n_hidden, n_vocab)
out_bias = 1e-2*np.random.randn(1, n_vocab)
```
</div>

ここではコーパスを単語に分割した`words_list`と$W_\textrm{in}$と$W_\textrm{out}$を用意しています。$W_\textrm{in}$は`cbow_weight`という名前の変数として用意しています。また、バイアスも同様に用意しています。
ちなみに`"\u300"`はUnicodeの半角スペースに対応しています。`corpus`に含まれる半角スペースがなぜか全て置き換わっていたためこうしています。もしこれでうまくいかない場合は普通に半角スペースでやってみてください。<div>

```python
    for words in words_list:
        indices = list(range(len(words)))
        np.random.shuffle(indices)
```
</div>ここでは、単語リストのアクセス順をランダムにシャッフルしています。これは過学習を抑制する目的でよく用いられるので形式上やっていますが、正直今回のコーパスは小さすぎるためあまり気にしないで大丈夫だと思われます。<div>

```python
            # Ready inputs
            input = get_surround(words, i, window_size)
            ans = words[i]
            x = np.array([one_hot(word) for word in input])
            y = np.array(one_hot(ans)).reshape(1, -1)
```
</div>

ここでは`get_surround`関数でターゲット単語`ans`の周辺単語を取得し、`x`と`y`にそれぞれのone-hotベクトルを保持しています。<div>

```python
            # Forward
            hidden = (x@cbow_weight + cbow_bias).mean(axis=0, keepdims=True)
            output = hidden@out_weight + out_bias
            predict = np.exp(output)/np.sum(np.exp(output), keepdims=True)
```
</div>ここでは上図の通りの流れで確率表現までを計算しています。また、上図とは異なりそれぞれの出力にはバイアスを加算していることに注意してください。(おそらくバイアスを足さなくても問題はない...と思われます)<div>

```python
            # Compute loss
            loss = np.sum(-y*np.log(predict+1e-8))
            loss_per_epoch += loss
```
</div>

活性化関数にSoftmax関数を用いていることもあるため、ここでは[交差エントロピー誤差](https://qiita.com/kuroitu/items/a6725ac0139d8eeb1e19#%E4%BA%A4%E5%B7%AE%E3%82%A8%E3%83%B3%E3%83%88%E3%83%AD%E3%83%94%E3%83%BC)を用いて誤差を計算しています。$1e-8$を足しているのは$\log$関数の入力がゼロにならないようにするためです。<div>

```python
            # Backward
            delta = predict - y
            d_out_weight = hidden.T@delta
            d_out_bias = delta.copy()
            d_hidden = delta@out_weight.T
            d_hidden = np.tile(d_hidden, x.shape[0])\
                         .reshape(x.shape[0], -1)*x.shape[0]
            d_hidden_weight = x.T@d_hidden
            d_hidden_bias = np.sum(d_hidden, axis=0, keepdims=True)
```
</div>

逆伝播の計算については[過去記事](https://qiita.com/kuroitu/items/ea6ed8f614e65ec44976#%E8%A1%8C%E5%88%97%E3%81%A7%E3%81%AE%E9%80%86%E4%BC%9D%E6%92%AD%E7%90%86%E8%AB%96)などをご覧いただければと思います。バッチに対応したコードではないため一部異なる部分もありますが、バッチサイズ1と見做して考えればわかるかと思います。
また、`d_hidden = np.tile(d_hidden, x.shape[0]).reshape(x.shape[0], -1)`の処理についてですが、順伝播の際に平均を取った部分があったと思いますが、その処理のために行列のサイズが変更されてしまったためコピーするような形で形状を復元しています。さらに平均を計算する際には`x.shape[0]`つまりデータの個数で割り算しているため`x.shape[0]`を掛けることで辻褄を合わせています。<div>

```python
            # Update
            cbow_weight -= lr*d_hidden_weight
            cbow_bias -= lr*d_hidden_bias
            out_weight -= lr*d_out_weight
            out_bias -= lr*d_out_bias
```
</div>

パラメータの更新は[SGD](https://qiita.com/kuroitu/items/36a58b37690d570dc618#sgd)で行っています。
</details>

ウィンドウサイズは、論文では10を用いているようですが、今回は小さすぎるコーパスに対する学習なので`window_size = 1`で計算しています。

```python
print(cbow_weight)
```

```
[[ 0.33182162  3.29596091  1.80855084  1.71448059  0.9885287  -2.93372024]
 [ 4.42062037  0.58321929  3.12210101  0.49071031 -0.82889288 -4.7565911 ]
 [ 3.32298824 -3.11871086 -2.03274293 -0.06673988  0.10913592 -4.49169164]
 [-1.35534626  0.54776265  2.10413095 -0.57199091  0.1944737   1.90349456]
 [-1.96240284 -1.21610264 -1.89181389 -0.67280184 -0.62877591  3.64013407]
 [-1.85480889  4.8385026  -2.0222571   0.02124056  3.03893178  0.33977704]
 [-1.55839835 -0.98132815 -1.80242746 -0.7266289  -0.3734114   3.06063833]
 [-0.43488751 -1.61185204  0.1345924   0.13392771 -1.3739515   1.340451  ]
 [-0.90463249 -2.11036897  0.00860975  0.11729932 -1.68082579  2.09484829]]
```

学習した結果を見てみましょう。

```python
from pprint import pprint


vocab = sorted(cv.vocabulary_, key=lambda x: x[0])
for words in words_list:
    indices = list(range(len(words)))
    for i in indices:
        # Ready inputs
        input = get_surround(words, i, window_size)
        ans = words[i]
        x = np.array([one_hot(word) for word in input])
        y = np.array(one_hot(ans)).reshape(1, -1)
        print(f"入力: {input}\t正解: {ans}")

        # Forward
        hidden = (x@cbow_weight + cbow_bias).mean(axis=0, keepdims=True)
        output = hidden@out_weight + out_bias
        predict = np.exp(output)/np.sum(np.exp(output), keepdims=True)
        pprint(dict(zip(vocab, predict[0])))
        print("-----")
```

<details><summary>推論結果</summary><div>

```python
入力: ['は']	正解: 私
{'。': 1.0609735644032356e-10,
 'が': 0.0005148065620589125,
 'だ': 2.1652549537413823e-05,
 'は': 0.0007521753056085488,
 'ラーメン': 3.314204030492522e-05,
 '好き': 1.2783030853537752e-09,
 '寿司': 2.1799386307850823e-05,
 '彼': 0.34903517227537956,
 '私': 0.6496212504964022}
-----
入力: ['私', 'ラーメン']	正解: は
{'。': 5.822687037111103e-05,
 'が': 9.343894336440986e-05,
 'だ': 4.751967933819793e-11,
 'は': 0.9998316175288404,
 'ラーメン': 9.296578097367661e-11,
 '好き': 6.931537725801257e-11,
 '寿司': 8.518601274566906e-11,
 '彼': 8.025138435562832e-06,
 '私': 8.691224001699407e-06}
-----
入力: ['は', 'が']	正解: ラーメン
{'。': 6.695462930240341e-09,
 'が': 2.517612237837813e-08,
 'だ': 0.0003030921861392993,
 'は': 6.603990952301419e-07,
 'ラーメン': 0.665660793437573,
 '好き': 0.0010368999907446406,
 '寿司': 0.33231286513481473,
 '彼': 0.0002646629535473338,
 '私': 0.0004209940265004653}
-----
入力: ['ラーメン', '好き']	正解: が
{'。': 6.666590824923218e-07,
 'が': 0.9997484882015913,
 'だ': 8.677312162209899e-05,
 'は': 0.00012453918538858817,
 'ラーメン': 1.7253303891964961e-10,
 '好き': 1.0228938051243059e-10,
 '寿司': 1.6177153059318543e-10,
 '彼': 1.6972152424687814e-05,
 '私': 2.2560243296882435e-05}
-----
入力: ['が', 'だ']	正解: 好き
{'。': 0.0007981490846108894,
 'が': 5.771128414025364e-15,
 'だ': 4.622410091849169e-09,
 'は': 2.4469538972070834e-11,
 'ラーメン': 0.0002961118604773813,
 '好き': 0.9987447674593978,
 '寿司': 0.00016096694858177426,
 '彼': 2.404278737146331e-14,
 '私': 2.2805764162340577e-14}
-----
入力: ['好き', '。']	正解: だ
{'。': 4.523072097641837e-12,
 'が': 0.00020279548379928985,
 'だ': 0.9997925761310679,
 'は': 1.4116428170736628e-11,
 'ラーメン': 1.544836591094253e-06,
 '好き': 1.9502427789887074e-08,
 '寿司': 9.952116906697878e-07,
 '彼': 7.57800405177982e-07,
 '私': 1.3110153785717838e-06}
-----
入力: ['だ']	正解: 。
{'。': 0.9992140113641594,
 'が': 1.792813031379192e-17,
 'だ': 3.337671866742168e-15,
 'は': 6.843918640824542e-13,
 'ラーメン': 4.346423325570966e-12,
 '好き': 0.0007859886274165869,
 '寿司': 3.3897721929460857e-12,
 '彼': 1.9089723897061755e-21,
 '私': 1.2634112679770324e-21}
-----
入力: ['は']	正解: 私
{'。': 1.0609735644032356e-10,
 'が': 0.0005148065620589125,
 'だ': 2.1652549537413823e-05,
 'は': 0.0007521753056085488,
 'ラーメン': 3.314204030492522e-05,
 '好き': 1.2783030853537752e-09,
 '寿司': 2.1799386307850823e-05,
 '彼': 0.34903517227537956,
 '私': 0.6496212504964022}
-----
入力: ['私', '寿司']	正解: は
{'。': 0.0001825088749576217,
 'が': 0.00017818910334115634,
 'だ': 2.887585203154446e-10,
 'は': 0.9996099456123885,
 'ラーメン': 5.729797501855008e-10,
 '好き': 5.643549517865313e-10,
 '寿司': 5.151183506617318e-10,
 '彼': 1.405576538396299e-05,
 '私': 1.5298702717070157e-05}
-----
入力: ['は', 'が']	正解: 寿司
{'。': 6.695462930240341e-09,
 'が': 2.517612237837813e-08,
 'だ': 0.0003030921861392993,
 'は': 6.603990952301419e-07,
 'ラーメン': 0.665660793437573,
 '好き': 0.0010368999907446406,
 '寿司': 0.33231286513481473,
 '彼': 0.0002646629535473338,
 '私': 0.0004209940265004653}
-----
入力: ['寿司', '好き']	正解: が
{'。': 1.0956090794981646e-06,
 'が': 0.9996207490623651,
 'だ': 0.0002764635051340212,
 'は': 6.528313551888231e-05,
 'ラーメン': 5.575447181204144e-10,
 '好き': 4.366611753980282e-10,
 '寿司': 5.128993013401411e-10,
 '彼': 1.55858385199604e-05,
 '私': 2.082134227742978e-05}
-----
入力: ['が', 'だ']	正解: 好き
{'。': 0.0007981490846108894,
 'が': 5.771128414025364e-15,
 'だ': 4.622410091849169e-09,
 'は': 2.4469538972070834e-11,
 'ラーメン': 0.0002961118604773813,
 '好き': 0.9987447674593978,
 '寿司': 0.00016096694858177426,
 '彼': 2.404278737146331e-14,
 '私': 2.2805764162340577e-14}
-----
入力: ['好き', '。']	正解: だ
{'。': 4.523072097641837e-12,
 'が': 0.00020279548379928985,
 'だ': 0.9997925761310679,
 'は': 1.4116428170736628e-11,
 'ラーメン': 1.544836591094253e-06,
 '好き': 1.9502427789887074e-08,
 '寿司': 9.952116906697878e-07,
 '彼': 7.57800405177982e-07,
 '私': 1.3110153785717838e-06}
-----
入力: ['だ']	正解: 。
{'。': 0.9992140113641594,
 'が': 1.792813031379192e-17,
 'だ': 3.337671866742168e-15,
 'は': 6.843918640824542e-13,
 'ラーメン': 4.346423325570966e-12,
 '好き': 0.0007859886274165869,
 '寿司': 3.3897721929460857e-12,
 '彼': 1.9089723897061755e-21,
 '私': 1.2634112679770324e-21}
-----
入力: ['は']	正解: 彼
{'。': 1.0609735644032356e-10,
 'が': 0.0005148065620589125,
 'だ': 2.1652549537413823e-05,
 'は': 0.0007521753056085488,
 'ラーメン': 3.314204030492522e-05,
 '好き': 1.2783030853537752e-09,
 '寿司': 2.1799386307850823e-05,
 '彼': 0.34903517227537956,
 '私': 0.6496212504964022}
-----
入力: ['彼', 'ラーメン']	正解: は
{'。': 0.00019788001168226393,
 'が': 0.0002780338887341041,
 'だ': 7.559156783428679e-10,
 'は': 0.9994778212071774,
 'ラーメン': 1.231315247784555e-09,
 '好き': 1.0471452688529702e-09,
 '寿司': 1.0952014156579716e-09,
 '彼': 2.206344409587853e-05,
 '私': 2.4197318732700544e-05}
-----
入力: ['は', 'が']	正解: ラーメン
{'。': 6.695462930240341e-09,
 'が': 2.517612237837813e-08,
 'だ': 0.0003030921861392993,
 'は': 6.603990952301419e-07,
 'ラーメン': 0.665660793437573,
 '好き': 0.0010368999907446406,
 '寿司': 0.33231286513481473,
 '彼': 0.0002646629535473338,
 '私': 0.0004209940265004653}
-----
入力: ['ラーメン', '好き']	正解: が
{'。': 6.666590824923218e-07,
 'が': 0.9997484882015913,
 'だ': 8.677312162209899e-05,
 'は': 0.00012453918538858817,
 'ラーメン': 1.7253303891964961e-10,
 '好き': 1.0228938051243059e-10,
 '寿司': 1.6177153059318543e-10,
 '彼': 1.6972152424687814e-05,
 '私': 2.2560243296882435e-05}
-----
入力: ['が', 'だ']	正解: 好き
{'。': 0.0007981490846108894,
 'が': 5.771128414025364e-15,
 'だ': 4.622410091849169e-09,
 'は': 2.4469538972070834e-11,
 'ラーメン': 0.0002961118604773813,
 '好き': 0.9987447674593978,
 '寿司': 0.00016096694858177426,
 '彼': 2.404278737146331e-14,
 '私': 2.2805764162340577e-14}
-----
入力: ['好き', '。']	正解: だ
{'。': 4.523072097641837e-12,
 'が': 0.00020279548379928985,
 'だ': 0.9997925761310679,
 'は': 1.4116428170736628e-11,
 'ラーメン': 1.544836591094253e-06,
 '好き': 1.9502427789887074e-08,
 '寿司': 9.952116906697878e-07,
 '彼': 7.57800405177982e-07,
 '私': 1.3110153785717838e-06}
-----
入力: ['だ']	正解: 。
{'。': 0.9992140113641594,
 'が': 1.792813031379192e-17,
 'だ': 3.337671866742168e-15,
 'は': 6.843918640824542e-13,
 'ラーメン': 4.346423325570966e-12,
 '好き': 0.0007859886274165869,
 '寿司': 3.3897721929460857e-12,
 '彼': 1.9089723897061755e-21,
 '私': 1.2634112679770324e-21}
-----
```
</div>しっかりマスクされているはずの単語に対する予測結果が大きくなっていることが確認できますね。また、マスク部分に入る候補が複数あるような「は」などの入力に対しては「私」や「彼」の確率が高くなっています。</details>

単語同士の類似度は次のようになります。

```python
sim1 = cosine_similarity(u[:, :6]@np.diag(s[:6]))
sim2 = cosine_similarity(cbow_weight)
for i in range(n_vocab):
    for j in range(i+1, n_vocab):
        print(f"|{id2w[i]}|{id2w[j]}|{sim1[i, j]}|{sim2[i, j]}|")
```

|単語1|単語2|圧縮後の類似度|CBoWの類似度|
|:-:|:-:|:-:|:-:|
|。|が|0.0|0.609909267404925|
|。|だ|0.0|0.009153085757579602|
|。|は|0.0|-0.0720924140530391|
|。|ラーメン|-9.358134352140677e-16|-0.8274765324776887|
|。|好き|0.7071067811865472|0.41772277379276707|
|。|寿司|-8.309365609351454e-16|-0.8332996025489453|
|。|彼|-3.320011377892241e-16|-0.7784692094789452|
|。|私|-3.320011377892241e-16|-0.8116561341637417|
|が|だ|0.5669467095138412|0.5669983227509734|
|が|は|0.4225771273642582|-0.3629679436604853|
|が|ラーメン|0.0|-0.92665413413469|
|が|好き|0.0|-0.3416646040237181|
|が|寿司|0.0|-0.9299612266464419|
|が|彼|0.0|-0.409256386940455|
|が|私|0.0|-0.5320033309714258|
|だ|は|1.2604230854720464e-16|-0.8700796580043243|
|だ|ラーメン|0.0|-0.4736742686622143|
|だ|好き|0.0|-0.4302351410010142|
|だ|寿司|0.0|-0.4446869259943875|
|だ|彼|0.0|-0.1676312696537884|
|だ|私|0.0|-0.25452483221675776|
|は|ラーメン|0.0|0.3340135182598704|
|は|好き|0.0|0.10352885536139433|
|は|寿司|0.0|0.2978448177749703|
|は|彼|0.0|0.26516784588968123|
|は|私|0.0|0.3205773407239325|
|ラーメン|好き|0.49999999999999967|0.02933748077676704|
|ラーメン|寿司|1.0|0.9968913675149247|
|ラーメン|彼|0.7071067811865475|0.6711319610805653|
|ラーメン|私|0.7071067811865475|0.7623964769346193|
|好き|寿司|0.4999999999999998|0.06470027335208178|
|好き|彼|-1.73340870411744e-16|-0.6781678528890698|
|好き|私|-1.73340870411744e-16|-0.576792783437659|
|寿司|彼|0.7071067811865475|0.6273489513852494|
|寿司|私|0.7071067811865475|0.7216938353221249|
|彼|私|1.0000000000000002|0.9900462932199341|

これを見てみると、「彼」と「私」や「ラーメン」と「寿司」といった、BoWの分散表現でも類似度が高かった単語のペアについては同様に高い類似度となっていることがわかります。また、「好き」と「彼」などの意味の異なった単語のペアについては負の類似度となっており、より遠い分散表現が得られていることもわかります。

これまでと決定的に異なるのが、類似度が負になっているものが存在することです。
(「。」と「ラーメン」の圧縮後類似度$-9.358134352140677e-16$などは数値誤差です)
これはBoWで作成された共起行列が全ての要素が正であったのに対して、CBoWによって生成された分散表現は最初から負の値を取り得ることに由来します。だからどうということはありませんが、強いて言えばこれまで$0 \le \cos \theta \le 1 \Leftrightarrow 0° \le \theta \le 90°$だった表現範囲が$-1 \le \cos \theta \le 1 \Leftrightarrow 0° \le \theta \le 180°$に広がることで、使われる文脈や意味が全く異なる単語同士をより遠ざける形で表現することができるようになります。~~とはいえBoWの共起行列を例えば標準化するなどして負の値を取り得るようにスケーリングすれば大差ないと思われます。~~標準化ではデータ同士の位置関係が変わらないので類似度は変わらないですね…

## skip-gram
続いてskip-gramの説明です。skip-gramはCBoWとは逆に「ある文章(コンテキスト)の特定の1単語(ターゲット)に注目し、ターゲットからコンテキスト内の周辺語を推測する」というタスクを解くニューラルネットワークから作成されるWord2Vec分散表現です。

![skip-gram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/640911/38bab558-9cb3-934d-a9bd-bdc697fcb039.png)

基本のアーキテクチャはCBoWと同じです。異なる点としては、ターゲット周辺の何単語を推測するかというウィンドウサイズについて、skip-gramではこのウィンドウサイズ$C$はあくまで最大単語数であり、学習時には単語ごとに$1 \le R \le C$である$R$をランダムに決定、ウィンドウサイズとするようです。
結局周辺$2R$語を推測するように学習を進めるので、逆伝播時は複数の正解ラベルそれぞれについて逆伝播が行われ学習されます。

<details><summary>skip-gramのPythonコード</summary><div>

```python
from sklearn.feature_extraction.text import CountVectorizer

corpus = ["私　は　ラーメン　が　好き　だ　。",
          "私　は　寿司　が　好き　だ　。",
          "彼　は　ラーメン　が　好き　だ　。"]

def tokenizer(text):
    return text.split("　")

cv = CountVectorizer(tokenizer=tokenizer)
weights = cv.fit_transform(corpus)
print(cv.get_feature_names())
print(weights.toarray())
```

```python
import numpy as np


def one_hot(word):
    one_hot_vec = np.zeros(len(cv.vocabulary_))
    one_hot_vec[cv.vocabulary_[word]] = 1
    return one_hot_vec


def get_surround(words, pointer, window_size):
    input = words[max(pointer-window_size, 0)
                : min(pointer+window_size+1, len(words))]
    if pointer-window_size < 0:
        del input[pointer]
    elif pointer+window_size+1 > len(words):
        del input[pointer+window_size+1-len(words)]
    else:
        del input[window_size]
    return input
```

```python
# Skip-gram
import matplotlib.pyplot as plt
from tqdm.notebook import trange


np.random.seed(42)

n_vocab = len(cv.vocabulary_)
epoch = 10000
window_size = 1
n_hidden = 6
lr = 1e-2
threshold = 1e-8
interval = 100

words_list = [text.split("\u3000") for text in corpus]
sg_weight = 1e-2*np.random.randn(n_vocab, n_hidden)
sg_bias = 1e-2*np.random.randn(1, n_hidden)
out_weight = 1e-2*np.random.randn(n_hidden, n_vocab)
out_bias = 1e-2*np.random.randn(1, n_vocab)

prev_loss = 0.
losses = []
for t in trange(epoch):
    loss_per_epoch = 0.
    for words in words_list:
        indices = list(range(len(words)))
        np.random.shuffle(indices)
        for i in indices:
            # Ready inputs
            R = np.random.randint(1, window_size, 1)[0] \
                if window_size > 1 else 1
            input = words[i]
            ans = get_surround(words, i, R)
            x = np.array(one_hot(input)).reshape(1, -1)
            y = np.array([one_hot(a) for a in ans])
            
            for j in range(len(y)):
                # Forward
                hidden = (x@sg_weight + sg_bias).mean(axis=0, keepdims=True)
                output = hidden@out_weight + out_bias
                predict = np.exp(output)/np.sum(np.exp(output), keepdims=True)

                # Compute loss
                loss = np.sum(-y[j]*np.log(predict+1e-8))
                loss_per_epoch += loss/len(y)

                # Backward
                delta = predict - y[j]
                d_out_weight = hidden.T@delta
                d_out_bias = delta.copy()
                d_hidden = delta@out_weight.T
                d_hidden = np.tile(d_hidden, x.shape[0])\
                            .reshape(x.shape[0], -1)*x.shape[0]
                d_hidden_weight = x.T@d_hidden
                d_hidden_bias = np.sum(d_hidden, axis=0, keepdims=True)

                # Update
                sg_weight -= lr*d_hidden_weight
                sg_bias -= lr*d_hidden_bias
                out_weight -= lr*d_out_weight
                out_bias -= lr*d_out_bias
    if t%interval == 0:
        losses.append(loss_per_epoch)
    if abs(prev_loss-loss_per_epoch) < threshold:
        break
    else:
        prev_loss = loss_per_epoch
print(f"epoch {t}: final loss = {loss_per_epoch}")

plt.title("Loss")
plt.xlabel(f"epoch [*{interval}]")
plt.ylabel("error")
plt.yscale("log")
plt.grid()
plt.plot(losses)
```
</div>ほとんどCBoWと同じコードですので、異なる部分に注目して概説します。<div>

```python
            # Ready inputs
            R = np.random.randint(1, window_size, 1)[0] \
                if window_size > 1 else 1
            input = words[i]
            ans = get_surround(words, i, R)
            x = np.array(one_hot(input)).reshape(1, -1)
            y = np.array([one_hot(a) for a in ans])
```
</div>

ここでは、[論文](https://arxiv.org/pdf/1301.3781.pdf)に基づいて$R$(コードでも`R`)を区間$\[ 1, C \]$からランダムに選択しています。また、CBoWとは逆に入力が1単語、正解ラベルが複数単語になっています。そのため、正解ラベルの数だけ予測と学習を繰り返しています。<div>

```python
                # Compute loss
                loss = np.sum(-y[j]*np.log(predict+1e-8))
                loss_per_epoch += loss/len(y)
```
</div>

また、誤差の和を取る部分については周辺語の平均となるように`len(y)`で割っています。ここは以降の逆伝播などには何の影響もありませんので注意してください。
</details>

こちらもCBoWと同じく`window_size = 1`で計算しています。

```python
print(sg_weight)
```

```
[[ 0.22918108  3.27339991  1.01933343  1.62012079  0.90708405 -1.87913403]
 [ 1.92863191 -0.18998641  0.63026778  0.31104666 -0.96804493 -1.30171052]
 [ 0.91455334 -1.60046135 -1.44311189 -0.11350698  0.1882469  -1.22089379]
 [ 0.03130425  0.15936981  2.28005579 -0.24015382 -0.30449253 -0.30488771]
 [-0.65139756  0.07268524 -0.8830847  -0.64017036  0.29955563  0.90378271]
 [-0.01706015  1.73207229 -0.53333987 -0.02213297  0.87569538 -0.73323909]
 [-0.69017767  0.08911927 -0.91040053 -0.5718911   0.31271512  0.94028928]
 [-0.9331444  -1.7166072  -0.1407946  -0.09515354 -0.66507651  1.86743211]
 [-1.04681312 -1.89507196 -0.14929907 -0.12282586 -0.71885323  2.06789077]]
```

学習した結果を見てみましょう。

```python
from pprint import pprint


vocab = sorted(cv.vocabulary_, key=lambda x: x[0])
for w in vocab:
    print(f"入力: {w}")
    x = np.array(one_hot(w)).reshape(1, -1)
    
    hidden = (x@sg_weight + sg_bias).mean(axis=0, keepdims=True)
    output = hidden@out_weight + out_bias
    predict = np.exp(output)/np.sum(np.exp(output), keepdims=True)
    pprint(dict(zip(vocab, predict[0])))
    print("-----")
```

<details><summary>推論結果</summary><div>

```python
入力: 。
{'。': 1.3239144469353089e-11,
 'が': 4.997960641851731e-06,
 'だ': 0.9953541139461264,
 'は': 2.7764377943304236e-10,
 'ラーメン': 0.0029334363806121826,
 '好き': 4.156457738226211e-06,
 '寿司': 0.0016107709974398344,
 '彼': 3.296126979500019e-05,
 '私': 5.9562696763635615e-05}
-----
入力: が
{'。': 0.003750567158263297,
 'が': 4.6082159270331885e-06,
 'だ': 0.00040077422095905195,
 'は': 0.0022721541309448307,
 'ラーメン': 0.46913935984891403,
 '好き': 0.24522084134677866,
 '寿司': 0.2787180947026821,
 '彼': 0.0002211656663196101,
 '私': 0.00027243470921139003}
-----
入力: だ
{'。': 0.999683681192532,
 'が': 2.0781415981557574e-07,
 'だ': 1.575452959656423e-08,
 'は': 3.323019550369056e-05,
 'ラーメン': 5.0407529230776816e-08,
 '好き': 0.0002827713094001281,
 '寿司': 4.236466694912832e-08,
 '彼': 4.83308265628484e-10,
 '私': 4.783703110315912e-10}
-----
入力: は
{'。': 1.9926673722233226e-07,
 'が': 3.944848935196329e-05,
 'だ': 0.001273750362798691,
 'は': 0.0008035261033390624,
 'ラーメン': 0.19043722995177081,
 '好き': 0.00013786538197888663,
 '寿司': 0.11138844469334282,
 '彼': 0.25197135513294683,
 '私': 0.4439481806177338}
-----
入力: ラーメン
{'。': 0.030621933625714613,
 'が': 0.6272492949309988,
 'だ': 0.0007105296052037572,
 'は': 0.3232780232004582,
 'ラーメン': 2.0850260411694235e-05,
 '好き': 3.628245440637999e-05,
 '寿司': 1.7564913242835294e-05,
 '彼': 0.007804837976783651,
 '私': 0.010260683032780079}
-----
入力: 好き
{'。': 0.00034132550315504136,
 'が': 0.13567178050756493,
 'だ': 0.8540807940472728,
 'は': 0.00015254636533529342,
 'ラーメン': 0.002195227027657093,
 '好き': 0.0012317746614613349,
 '寿司': 0.0015773831322773084,
 '彼': 0.0019359594395280007,
 '私': 0.0028132093157481604}
-----
入力: 寿司
{'。': 0.02596015296223942,
 'が': 0.6471093892239984,
 'だ': 0.0006869182528065833,
 'は': 0.30928869850403423,
 'ラーメン': 1.6296588507389384e-05,
 '好き': 2.7582244911429927e-05,
 '寿司': 1.3776447797611234e-05,
 '彼': 0.0073017608716218286,
 '私': 0.00959542490408305}
-----
入力: 彼
{'。': 0.0006886901127857259,
 'が': 0.0002394993503714032,
 'だ': 1.0601767462960285e-08,
 'は': 0.9979556747590752,
 'ラーメン': 1.0357454864861101e-07,
 '好き': 4.780942057025719e-08,
 '寿司': 8.769333122796121e-08,
 '彼': 0.0004940507565143427,
 '私': 0.0006218353421854127}
-----
入力: 私
{'。': 0.0003960805040578411,
 'が': 0.0001245294100118911,
 'だ': 2.201065392078026e-09,
 'は': 0.9988409627917361,
 'ラーメン': 2.6262479602017902e-08,
 '好き': 1.194485661361012e-08,
 '寿司': 2.2613010871820793e-08,
 '彼': 0.00028344042859080237,
 '私': 0.00035492384419081745}
-----
```
</div>入力単語の周辺語に対応する確率がしっかり高くなっています。周辺の候補単語が複数ある場合はそれらにも高めの確率が割り振られていますね。</details>

単語同士の類似度は次のようになります。

```python
sim1 = cosine_similarity(u[:, :6]@np.diag(s[:6]))
sim2 = cosine_similarity(sg_weight)
for i in range(n_vocab):
    for j in range(i+1, n_vocab):
        print(f"|{id2w[i]}|{id2w[j]}|{sim1[i, j]}|{sim2[i, j]}|")
```

|単語1|単語2|圧縮後の類似度|skip-gramの類似度|
|:-:|:-:|:-:|:-:|
|。|が|0.0|0.2229174802585368|
|。|だ|0.0|-0.3674335855441274|
|。|は|0.0|0.2723764188044039|
|。|ラーメン|-9.358134352140677e-16|-0.4754847189450626|
|。|好き|0.7071067811865472|0.7817025671798838|
|。|寿司|-8.309365609351454e-16|-0.456552927920765|
|。|彼|-3.8116456325797087e-16|-0.8475062385217488|
|。|私|-3.8116456325797087e-16|-0.8475173155982927|
|が|だ|0.5669467095138412|0.3640402874596618|
|が|は|0.42257712736425806|0.33974348880014726|
|が|ラーメン|0.0|-0.8376685971974773|
|が|好き|0.0|-0.1064519129339985|
|が|寿司|0.0|-0.8537253881767827|
|が|彼|0.0|-0.46191969042634284|
|が|私|0.0|-0.467964176110694|
|だ|は|5.891059824436164e-17|-0.5124080998028411|
|だ|ラーメン|0.0|-0.0978580102078866|
|だ|好き|0.0|-0.16833614115052817|
|だ|寿司|0.0|-0.11287169955655381|
|だ|彼|0.0|-0.0402272194888962|
|だ|私|0.0|-0.04343022317483239|
|は|ラーメン|0.0|-0.6014626995143739|
|は|好き|0.0|-0.19527698014885694|
|は|寿司|0.0|-0.614719779635031|
|は|彼|0.0|-0.14841524375627319|
|は|私|0.0|-0.14655646280689358|
|ラーメン|好き|0.4999999999999997|0.06514039650248336|
|ラーメン|寿司|1.0|0.9984870144582699|
|ラーメン|彼|0.7071067811865474|0.4866769208968102|
|ラーメン|私|0.7071067811865474|0.4911721963613695|
|好き|寿司|0.49999999999999983|0.07169858308098982|
|好き|彼|-1.6799257269068767e-16|-0.808795571847555|
|好き|私|-1.6799257269068767e-16|-0.806128868561986|
|寿司|彼|0.7071067811865474|0.49199579367023183|
|寿司|私|0.7071067811865474|0.4963165645318926|
|彼|私|1.0|0.999954515885256|

CBoWと同じくいい感じに単語同士の類似度が取れています。比較もしておきましょう。

```python
sim1 = cosine_similarity(u[:, :6]@np.diag(s[:6]))
sim2 = cosine_similarity(cbow_weight)
sim3 = cosine_similarity(sg_weight)
for i in range(n_vocab):
    for j in range(i+1, n_vocab):
        print(f"|{id2w[i]}|{id2w[j]}|{sim1[i, j]}|{sim2[i, j]}|{sim3[i, j]}|")
```

|単語1|単語2|圧縮後の類似度|CBoWの類似度|skip-gramの類似度|
|:-:|:-:|:-:|:-:|:-:|
|。|が|0.0|0.609909267404925|0.2229174802585368|
|。|だ|0.0|0.009153085757579602|-0.3674335855441274|
|。|は|0.0|-0.0720924140530391|0.2723764188044039|
|。|ラーメン|-9.358134352140677e-16|-0.8274765324776887|-0.4754847189450626|
|。|好き|0.7071067811865472|0.41772277379276707|0.7817025671798838|
|。|寿司|-8.309365609351454e-16|-0.8332996025489453|-0.456552927920765|
|。|彼|-3.8116456325797087e-16|-0.7784692094789452|-0.8475062385217488|
|。|私|-3.8116456325797087e-16|-0.8116561341637417|-0.8475173155982927|
|が|だ|0.5669467095138412|0.5669983227509734|0.3640402874596618|
|が|は|0.42257712736425806|-0.3629679436604853|0.33974348880014726|
|が|ラーメン|0.0|-0.92665413413469|-0.8376685971974773|
|が|好き|0.0|-0.3416646040237181|-0.1064519129339985|
|が|寿司|0.0|-0.9299612266464419|-0.8537253881767827|
|が|彼|0.0|-0.409256386940455|-0.46191969042634284|
|が|私|0.0|-0.5320033309714258|-0.467964176110694|
|だ|は|5.891059824436164e-17|-0.8700796580043243|-0.5124080998028411|
|だ|ラーメン|0.0|-0.4736742686622143|-0.0978580102078866|
|だ|好き|0.0|-0.4302351410010142|-0.16833614115052817|
|だ|寿司|0.0|-0.4446869259943875|-0.11287169955655381|
|だ|彼|0.0|-0.1676312696537884|-0.0402272194888962|
|だ|私|0.0|-0.25452483221675776|-0.04343022317483239|
|は|ラーメン|0.0|0.3340135182598704|-0.6014626995143739|
|は|好き|0.0|0.10352885536139433|-0.19527698014885694|
|は|寿司|0.0|0.2978448177749703|-0.614719779635031|
|は|彼|0.0|0.26516784588968123|-0.14841524375627319|
|は|私|0.0|0.3205773407239325|-0.14655646280689358|
|ラーメン|好き|0.4999999999999997|0.02933748077676704|0.06514039650248336|
|ラーメン|寿司|1.0|0.9968913675149247|0.9984870144582699|
|ラーメン|彼|0.7071067811865474|0.6711319610805653|0.4866769208968102|
|ラーメン|私|0.7071067811865474|0.7623964769346193|0.4911721963613695|
|好き|寿司|0.49999999999999983|0.06470027335208178|0.07169858308098982|
|好き|彼|-1.6799257269068767e-16|-0.6781678528890698|-0.808795571847555|
|好き|私|-1.6799257269068767e-16|-0.576792783437659|-0.806128868561986|
|寿司|彼|0.7071067811865474|0.6273489513852494|0.49199579367023183|
|寿司|私|0.7071067811865474|0.7216938353221249|0.4963165645318926|
|彼|私|1.0|0.9900462932199341|0.999954515885256|

こうしてみると傾向は同じような感じになっていますが、よくよくみるとかなり異なっていることがわかります。
「私」と「彼」や「ラーメン」と「寿司」などの、同様の文脈で登場する単語同士の類似度はともに高いですが、名詞同士である「私」と「ラーメン」などについては、CBoWではかなり類似度が高く見積もられていますが、skip-gramでは少し抑えられています。さらに、品詞が異なる「は」と「私」などの類似度はskip-gramでは負の値を取るほど離れていることがわかります。
これにも見られる通り、CBoWよりもskip-gramで生成された分散表現の方が単語の概念的により的確なものである場合が多いようです。ただし学習コストはskip-gramの方が高くなります。

## 分散表現の図示
ここまで色々と単語の分散表現を見てきましたが、数字ばっかりコードばっかりで直感的ではありません。今回はただ数個の単語を分析するだけでしたのでまだ大丈夫ですが、もし数万単語もあれば飽き飽きするというか、嫌になりますよね。ということでより直感的に見られるように図示してみます。

```python
!pip install japanize-matplotlib
import japanize_matplotlib
from sklearn.decomposition import PCA


# CBoW
pca = PCA(n_components=2)
pca.fit(cbow_weight)
data_pca = pca.transform(cbow_weight)
for i in range(n_vocab):
    plt.plot(data_pca[i][0], data_pca[i][1], ms=5.0, zorder=2, marker="x")
    plt.annotate(id2w[i], (data_pca[i][0], data_pca[i][1]))
plt.title("CBoW")
plt.show()

# skip-gram
pca = PCA(n_components=2)
pca.fit(sg_weight)
data_pca = pca.transform(sg_weight)
for i in range(n_vocab):
    plt.plot(data_pca[i][0], data_pca[i][1], ms=5.0, zorder=2, marker="x")
    plt.annotate(id2w[i], (data_pca[i][0], data_pca[i][1]))
plt.title("skip-gram")
plt.show()
```

<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/640911/d7f782b8-80c9-2413-e783-3c7d7c5b5c7c.png width=50%><img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/640911/359cb4b8-4fb3-46ac-ea61-ab3f9536ce4c.png width=50%>

図示する際はPCAによって分散表現を2次元に落とし込んでいます。こうして見比べるとそれぞれの位置関係は割と似ていますが、skip-gramでは似ているものはより近くに、似ていないものはより遠くにありますね。
今回の超小規模なデータセットでも比較的綺麗に分かれていますし、もっと大規模なコーパスに対してしっかり学習した結果も図示してみたいものです。

## Word2Vecの参考
- [https://www.anarchive-beta.com/entry/2020/10/23/190000](https://www.anarchive-beta.com/entry/2020/10/23/190000)
- [Word2Vecを理解する](https://qiita.com/g-k/items/69afa87c73654af49d36)
- [Word2vecとは｜モデルの種類やその仕組み、活用事例まで紹介！](https://ainow.ai/2021/04/08/254071/)
- [Word2Vec：発明した本人も驚く単語ベクトルの驚異的な力](https://deepage.net/bigdata/machine_learning/2016/09/02/word2vec_power_of_word_vector.html)
- [単語と図で理解する自然言語処理（word2vec, RNN, LSTM）前編](https://www.takapy.work/entry/2019/01/06/203042)
- [word2vec（Skip-Gram Model）の仕組みを恐らく日本一簡潔にまとめてみたつもり](https://www.randpy.tokyo/entry/word2vec_skip_gram_model)
- [元論文: Efficient Estimation of Word Representations in
Vector Space](https://arxiv.org/pdf/1301.3781.pdf)
- [Is word2vec basically an autoencoder?](https://www.quora.com/Is-word2vec-basically-an-autoencoder)
- [【pythonで自然言語処理】word2vecで分散表現した単語を２次元に可視化する](https://propen.dream-target.jp/blog/python_word2vec)
- [グラフに日本語フォントで出力するには](https://colab.research.google.com/github/aquapathos/Semi3B/blob/master/HowToUseJapaneseOnColab.ipynb)


# おわりに
自然言語処理を腰を据えて勉強始めました。難しいですね〜


[^1]: ある二つの単語が文章内で同時に出現していること
[^2]: 複数の文章の集合