GloVe(Global Vectors for Word Representation)は、2014年に Stanford の Pennington らが提案した単語の分散表現学習手法です。GloVe は、コーパス全体の共起統計量(グローバル情報)を直接的に活用する点で Word2Vec と異なり、共起行列ベースの手法(LSA など)とニューラル言語モデル(Word2Vec など)の利点を統合したアプローチとして位置づけられます。
GloVe の核心的な洞察は、単語の意味の違いは「共起確率の比」に現れるという点です。この直感から出発して、洗練された目的関数を導出し、効率的に高品質な単語ベクトルを学習できることが示されました。
本記事の内容
- 共起行列ベースの手法と Word2Vec の比較
- 共起確率比が意味の差を捉えることの直感的説明
- GloVe の目的関数の導出(対称性と重み関数の設計)
- SVD との関係
- Word2Vec との理論的比較(暗黙的行列分解としての Skip-gram)
- Python で共起行列構築から GloVe スクラッチ実装・t-SNE による可視化
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
共起行列ベースの手法とWord2Vecの比較
単語の分散表現を学習する手法は、大きく2つのアプローチに分類されます。
1. カウントベース手法(LSA, HAL など)
コーパス全体の共起統計量を行列として構築し、SVD(特異値分解)などで次元削減します。
- 利点: コーパス全体のグローバルな統計情報を効率的に利用できる
- 欠点: 単語の類推(analogy)タスクでの性能が低い傾向がある
2. 予測ベース手法(Word2Vec, Neural LM)
局所的な文脈ウィンドウ内の共起パターンを予測する問題として学習します。
- 利点: 類推タスクで高い性能を発揮する
- 欠点: コーパス全体の統計情報を直接利用しない(ウィンドウ単位の局所的な学習)
GloVe は、これら両方の利点を統合することを目指しています。具体的には、グローバルな共起統計量を直接目的関数に組み込みつつ、ベクトル空間での線形構造(類推タスクの性能)を保証するモデルです。
共起行列の定義
語彙 $\mathcal{V} = \{w_1, w_2, \dots, w_V\}$ に対して、共起行列 $\bm{X} \in \mathbb{R}^{V \times V}$ を定義します。$X_{ij}$ は、ウィンドウサイズ内で単語 $w_i$ と $w_j$ が共起した回数です。
共起行列の基本的な性質として、以下が成り立ちます。
- $X_{ij} = X_{ji}$(対称性:ウィンドウ内での共起は対称的)
- $X_i = \sum_{k} X_{ik}$(単語 $w_i$ の文脈における全共起回数)
単語 $w_k$ が単語 $w_i$ の文脈に出現する共起確率を
$$ P_{ik} = P(w_k \mid w_i) = \frac{X_{ik}}{X_i} $$
と定義します。
共起確率比による意味の抽出
直感的な説明
GloVe の理論的な出発点は、「共起確率そのものではなく、共起確率の比が意味の差を捉える」という洞察です。
具体例として、”ice”(氷)と “steam”(蒸気)について考えます。プローブ単語 $w_k$ に対する共起確率比 $P(w_k \mid \text{ice}) / P(w_k \mid \text{steam})$ を見ると、以下のようなパターンが観察されます。
| プローブ単語 $w_k$ | $P(w_k \mid \text{ice})$ | $P(w_k \mid \text{steam})$ | 比 |
|---|---|---|---|
| solid | 高い | 低い | $\gg 1$ |
| gas | 低い | 高い | $\ll 1$ |
| water | 高い | 高い | $\approx 1$ |
| fashion | 低い | 低い | $\approx 1$ |
- solid: 氷に関連し蒸気に関連しないので、比は大きい
- gas: 蒸気に関連し氷に関連しないので、比は小さい
- water: 両方に等しく関連するので、比は 1 に近い
- fashion: 両方に無関係なので、比は 1 に近い
このように、共起確率の比は2つの単語の意味の差に関連する情報を鮮明に浮かび上がらせます。
数学的な定式化への動機
上記の観察から、単語ベクトルの学習で重要なのは共起確率の比
$$ \frac{P_{ik}}{P_{jk}} $$
であり、これをベクトル空間上の演算で表現できれば、意味的な関係をベクトルの線形演算として捉えられるはずです。
GloVe の目的関数の導出
ステップ 1: 比に対する関数形の要請
共起確率比がベクトルの関数として表されるべきだと考え、一般的な関数 $F$ を導入します。
$$ F(\bm{w}_i, \bm{w}_j, \tilde{\bm{w}}_k) = \frac{P_{ik}}{P_{jk}} $$
ここで $\bm{w}_i, \bm{w}_j$ は中心単語のベクトル、$\tilde{\bm{w}}_k$ は文脈単語のベクトルです。
ステップ 2: ベクトル空間での線形性
単語ベクトルの差 $\bm{w}_i – \bm{w}_j$ が意味の差を表すことを要請します。つまり $F$ は $\bm{w}_i – \bm{w}_j$ の関数であるべきです。
$$ F(\bm{w}_i – \bm{w}_j, \tilde{\bm{w}}_k) = \frac{P_{ik}}{P_{jk}} $$
ステップ 3: スカラーへの変換
左辺はベクトルの関数ですが、右辺はスカラーです。自然な変換として内積を取ります。
$$ F((\bm{w}_i – \bm{w}_j)^\top \tilde{\bm{w}}_k) = \frac{P_{ik}}{P_{jk}} $$
ステップ 4: 準同型性の要請
$F$ は加法を乗法に変換する性質(準同型性)を持つべきです。なぜなら、左辺の引数は差(加法的)であるのに対し、右辺は比(乗法的)だからです。
$$ F((\bm{w}_i – \bm{w}_j)^\top \tilde{\bm{w}}_k) = \frac{F(\bm{w}_i^\top \tilde{\bm{w}}_k)}{F(\bm{w}_j^\top \tilde{\bm{w}}_k)} $$
加法を乗法に写す連続関数は指数関数です。したがって $F = \exp$ と設定します。
$$ \exp((\bm{w}_i – \bm{w}_j)^\top \tilde{\bm{w}}_k) = \frac{P_{ik}}{P_{jk}} $$
これを分解すると
$$ \exp(\bm{w}_i^\top \tilde{\bm{w}}_k) = P_{ik} = \frac{X_{ik}}{X_i} $$
ステップ 5: 対数を取る
両辺の対数を取ります。
$$ \bm{w}_i^\top \tilde{\bm{w}}_k = \log P_{ik} = \log X_{ik} – \log X_i $$
ここで $\log X_i$ は $k$ に依存しないため、バイアス項 $b_i$ に吸収できます。同様に、$w$ と $\tilde{w}$ の対称性を保つために $\tilde{b}_k$ も導入すると
$$ \bm{w}_i^\top \tilde{\bm{w}}_k + b_i + \tilde{b}_k = \log X_{ik} $$
ステップ 6: 重み付き最小二乗の目的関数
上記の等式を全ての共起ペアについて最小二乗法で最適化します。ただし、$X_{ij} = 0$ の場合 $\log 0 = -\infty$ となるため、$X_{ij} > 0$ のペアのみを対象とし、さらに重み関数 $f(X_{ij})$ を導入して共起回数に応じた重要度を調整します。
$$ \begin{equation} J = \sum_{i,j=1}^{V} f(X_{ij}) \left(\bm{w}_i^\top \tilde{\bm{w}}_j + b_i + \tilde{b}_j – \log X_{ij}\right)^2 \end{equation} $$
重み関数 $f(x)$ の設計
重み関数 $f(x)$ は以下の性質を満たすべきです。
- $f(0) = 0$: $X_{ij} = 0$ のペアは無視する
- $f(x)$ は非減少: 共起回数が多いほど重要
- 大きな $x$ に対して $f(x)$ が飽和する: “the” のような超高頻度単語が過度に支配しないようにする
これらの条件を満たす関数として、以下が提案されました。
$$ f(x) = \begin{cases} (x / x_{\max})^\alpha & \text{if } x < x_{\max} \\ 1 & \text{otherwise} \end{cases} $$
原論文では $x_{\max} = 100$, $\alpha = 3/4$ が推奨されています。$\alpha = 3/4$ は Word2Vec の Negative Sampling で使われる $3/4$ 乗と同じ値であり、頻度の影響を適度に抑える効果があります。
SVD との関係
GloVe の目的関数は、重み付き行列分解(weighted matrix factorization)として解釈できます。
$\log X_{ij}$ を要素とする行列を $\bm{M}$ とすると、GloVe は
$$ \bm{M} \approx \bm{W} \tilde{\bm{W}}^\top + \bm{b} \bm{1}^\top + \bm{1} \tilde{\bm{b}}^\top $$
という低ランク近似を、重み関数 $f(X_{ij})$ に基づく重み付き二乗誤差で求めていることになります。
一方、古典的な LSA は、共起行列(または TF-IDF 行列)に対して truncated SVD を適用します。
$$ \bm{X} \approx \bm{U}_d \bm{\Sigma}_d \bm{V}_d^\top $$
SVD は重みなしのフロベニウスノルムで最適な低ランク近似を与えますが、全ての要素を等しく扱うため、$X_{ij} = 0$ の大量の要素に引きずられやすいという問題があります。GloVe の重み関数はこの問題を回避しつつ、$\log$ 変換により共起回数の分布の歪みも緩和しています。
Word2Vec との理論的比較
Skip-gram は暗黙的な行列分解
Levy & Goldberg (2014) は、Skip-gram with Negative Sampling(SGNS)が暗黙的に以下の行列を分解していることを示しました。
$$ \bm{w}_i^\top \tilde{\bm{w}}_j = \text{PMI}(w_i, w_j) – \log K $$
ここで PMI(自己相互情報量)は
$$ \text{PMI}(w_i, w_j) = \log \frac{P(w_i, w_j)}{P(w_i) P(w_j)} = \log \frac{X_{ij} \cdot |\mathcal{D}|}{X_i \cdot X_j} $$
$|\mathcal{D}|$ はコーパスの総トークンペア数、$K$ は負例の数です。
つまり、SGNS は PMI 行列から定数 $\log K$ を引いた行列(Shifted PMI 行列)の暗黙的な低ランク分解を行っています。
GloVe と SGNS の関係
GloVe が $\log X_{ij}$ を対象とし、SGNS が PMI を対象とすることの違いを整理します。
$$ \begin{align} \text{PMI}(w_i, w_j) &= \log \frac{X_{ij} \cdot |\mathcal{D}|}{X_i \cdot X_j} \\ &= \log X_{ij} + \log |\mathcal{D}| – \log X_i – \log X_j \end{align} $$
GloVe のバイアス項 $b_i, \tilde{b}_j$ が $\log X_i, \log X_j$ を吸収することを考えると、GloVe と SGNS は非常に類似した行列を分解していることがわかります。主な違いは以下の通りです。
| 特性 | GloVe | Skip-gram (SGNS) |
|---|---|---|
| 最適化対象 | $\log X_{ij}$(+バイアス) | Shifted PMI 行列 |
| 学習方法 | 重み付き最小二乗 | SGD(確率的勾配降下) |
| データの走査 | 共起行列を一度構築 | コーパスを繰り返し走査 |
| 計算効率 | 非ゼロ要素数に比例 | コーパスサイズに比例 |
| 重み付け | 明示的な $f(X_{ij})$ | 暗黙的(出現頻度に比例) |
Python での GloVe スクラッチ実装
共起行列の構築
import numpy as np
import matplotlib.pyplot as plt
from collections import Counter, defaultdict
# コーパス
corpus = [
"the cat sat on the mat",
"the dog sat on the rug",
"the cat chased the dog",
"the dog chased the cat",
"the mat is on the floor",
"the rug is on the floor",
"cat and dog are friends",
"the cat is small",
"the dog is big",
"the floor is flat",
"cat sat on the rug",
"dog sat on the mat",
]
# トークナイズと語彙構築
sentences = [s.split() for s in corpus]
word_counts = Counter(w for s in sentences for w in s)
vocab = sorted(word_counts.keys())
word2idx = {w: i for i, w in enumerate(vocab)}
idx2word = {i: w for w, i in word2idx.items()}
V = len(vocab)
print(f"語彙数: {V}")
# 共起行列の構築
window_size = 2
cooccurrence = defaultdict(float)
for sentence in sentences:
indices = [word2idx[w] for w in sentence]
for i, center in enumerate(indices):
start = max(0, i - window_size)
end = min(len(indices), i + window_size + 1)
for j in range(start, end):
if i == j:
continue
context = indices[j]
# 距離に反比例する重み(GloVeの原論文と同様)
distance = abs(i - j)
cooccurrence[(center, context)] += 1.0 / distance
# 共起行列を密行列に変換(可視化用)
X = np.zeros((V, V))
for (i, j), count in cooccurrence.items():
X[i, j] = count
# 共起行列の可視化
plt.figure(figsize=(10, 8))
plt.imshow(np.log1p(X), cmap='Blues', interpolation='nearest')
plt.colorbar(label='log(1 + X_ij)')
plt.xticks(range(V), vocab, rotation=90, fontsize=8)
plt.yticks(range(V), vocab, fontsize=8)
plt.title("Co-occurrence Matrix (log scale)")
plt.tight_layout()
plt.show()
print(f"非ゼロ共起ペア数: {len(cooccurrence)}")
GloVe の学習
import numpy as np
import matplotlib.pyplot as plt
# GloVe ハイパーパラメータ
embed_dim = 20
x_max = 100
alpha = 0.75
learning_rate = 0.05
num_epochs = 200
# 重み関数 f(x)
def weight_func(x, x_max=100, alpha=0.75):
if x < x_max:
return (x / x_max) ** alpha
return 1.0
# パラメータ初期化
W = np.random.randn(V, embed_dim) * 0.1 # 中心単語ベクトル
W_tilde = np.random.randn(V, embed_dim) * 0.1 # 文脈単語ベクトル
b = np.zeros(V) # 中心単語バイアス
b_tilde = np.zeros(V) # 文脈単語バイアス
# AdaGrad用の勾配二乗和
grad_sq_W = np.ones((V, embed_dim))
grad_sq_W_tilde = np.ones((V, embed_dim))
grad_sq_b = np.ones(V)
grad_sq_b_tilde = np.ones(V)
# 共起ペアのリスト化
cooc_pairs = [(i, j, count) for (i, j), count in cooccurrence.items() if count > 0]
print(f"学習対象ペア数: {len(cooc_pairs)}")
# 学習ループ
losses = []
for epoch in range(num_epochs):
epoch_loss = 0.0
# ペアをシャッフル
np.random.shuffle(cooc_pairs)
for i, j, x_ij in cooc_pairs:
# 重み
f_x = weight_func(x_ij, x_max, alpha)
# 予測と誤差
diff = np.dot(W[i], W_tilde[j]) + b[i] + b_tilde[j] - np.log(x_ij)
weighted_diff = f_x * diff
# 損失の累積
epoch_loss += f_x * diff ** 2
# 勾配計算
grad_w_i = weighted_diff * W_tilde[j]
grad_w_tilde_j = weighted_diff * W[i]
grad_b_i = weighted_diff
grad_b_tilde_j = weighted_diff
# AdaGrad による更新
grad_sq_W[i] += grad_w_i ** 2
grad_sq_W_tilde[j] += grad_w_tilde_j ** 2
grad_sq_b[i] += grad_b_i ** 2
grad_sq_b_tilde[j] += grad_b_tilde_j ** 2
W[i] -= learning_rate * grad_w_i / np.sqrt(grad_sq_W[i])
W_tilde[j] -= learning_rate * grad_w_tilde_j / np.sqrt(grad_sq_W_tilde[j])
b[i] -= learning_rate * grad_b_i / np.sqrt(grad_sq_b[i])
b_tilde[j] -= learning_rate * grad_b_tilde_j / np.sqrt(grad_sq_b_tilde[j])
losses.append(epoch_loss)
if (epoch + 1) % 40 == 0:
print(f"Epoch {epoch+1}/{num_epochs}, Loss: {epoch_loss:.4f}")
# 最終的な単語ベクトル(W + W_tilde の平均)
embeddings = W + W_tilde
# 学習曲線の可視化
plt.figure(figsize=(8, 5))
plt.plot(losses)
plt.xlabel("Epoch")
plt.ylabel("Total Loss")
plt.title("GloVe Training Loss")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
類似度の計算と t-SNE による可視化
import numpy as np
import matplotlib.pyplot as plt
from sklearn.manifold import TSNE
# コサイン類似度
def cosine_similarity(v1, v2):
return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-10)
# 類似度の確認
print("=== GloVe 単語間のコサイン類似度 ===")
pairs = [("cat", "dog"), ("cat", "mat"), ("mat", "rug"),
("mat", "floor"), ("sat", "chased")]
for w1, w2 in pairs:
sim = cosine_similarity(embeddings[word2idx[w1]], embeddings[word2idx[w2]])
print(f" sim({w1}, {w2}) = {sim:.4f}")
# 最近傍探索
def most_similar(word, embeddings, word2idx, idx2word, top_k=5):
"""指定した単語に最も類似する単語を返す"""
idx = word2idx[word]
target = embeddings[idx]
sims = []
for i in range(len(embeddings)):
if i == idx:
continue
sim = cosine_similarity(target, embeddings[i])
sims.append((idx2word[i], sim))
sims.sort(key=lambda x: x[1], reverse=True)
return sims[:top_k]
print("\n=== 'cat' に類似する単語 ===")
for word, sim in most_similar("cat", embeddings, word2idx, idx2word):
print(f" {word}: {sim:.4f}")
print("\n=== 'floor' に類似する単語 ===")
for word, sim in most_similar("floor", embeddings, word2idx, idx2word):
print(f" {word}: {sim:.4f}")
# t-SNE による可視化
tsne = TSNE(n_components=2, random_state=42, perplexity=5)
embeddings_2d = tsne.fit_transform(embeddings)
plt.figure(figsize=(10, 8))
for i, word in enumerate(vocab):
plt.scatter(embeddings_2d[i, 0], embeddings_2d[i, 1], c='steelblue', s=60)
plt.annotate(word, (embeddings_2d[i, 0] + 0.5, embeddings_2d[i, 1] + 0.5),
fontsize=11)
plt.xlabel("t-SNE Dimension 1")
plt.ylabel("t-SNE Dimension 2")
plt.title("GloVe Word Embeddings (t-SNE Visualization)")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
GloVe の勾配の導出
目的関数 $J$ のパラメータに関する勾配を導出しておきます。
$$ J = \sum_{i,j=1}^{V} f(X_{ij}) \left(\bm{w}_i^\top \tilde{\bm{w}}_j + b_i + \tilde{b}_j – \log X_{ij}\right)^2 $$
$e_{ij} = \bm{w}_i^\top \tilde{\bm{w}}_j + b_i + \tilde{b}_j – \log X_{ij}$ と置くと
$\bm{w}_i$ に関する勾配は
$$ \frac{\partial J}{\partial \bm{w}_i} = \sum_{j=1}^{V} 2 f(X_{ij}) \cdot e_{ij} \cdot \tilde{\bm{w}}_j $$
$\tilde{\bm{w}}_j$ に関する勾配は
$$ \frac{\partial J}{\partial \tilde{\bm{w}}_j} = \sum_{i=1}^{V} 2 f(X_{ij}) \cdot e_{ij} \cdot \bm{w}_i $$
バイアスに関する勾配は
$$ \frac{\partial J}{\partial b_i} = \sum_{j=1}^{V} 2 f(X_{ij}) \cdot e_{ij}, \quad \frac{\partial J}{\partial \tilde{b}_j} = \sum_{i=1}^{V} 2 f(X_{ij}) \cdot e_{ij} $$
実際の実装では、非ゼロの共起ペアのみを走査すればよいため、計算量は共起行列の非ゼロ要素数に比例します。原論文では最適化手法として AdaGrad が推奨されています。
最終的な単語ベクトル
GloVe のモデルには $\bm{w}_i$ と $\tilde{\bm{w}}_i$ の2つのベクトルが存在します。$\bm{X}$ が対称行列であることから、最適解においてはこの2つのベクトルは本来等しくなるべきですが、ランダム初期化により異なる解に収束します。
原論文では、最終的な単語ベクトルとして両者の和
$$ \bm{v}_i = \bm{w}_i + \tilde{\bm{w}}_i $$
を使用することが推奨されています。これはアンサンブル効果により性能が向上し、ノイズが軽減されるためです。
GloVe の利点と限界
利点
- 学習の効率性: 共起行列を事前に構築するため、コーパスの繰り返し走査が不要
- グローバル統計量の活用: 局所的なウィンドウだけでなく、コーパス全体の共起情報を直接利用
- 理論的な明快さ: 目的関数の導出過程が明確で、なぜ加法的な意味構造が生まれるかが説明可能
- 並列化の容易さ: 重み付き最小二乗は並列計算に適している
限界
- メモリ使用量: 大規模語彙では共起行列の格納に大量のメモリが必要(疎行列で対処可能)
- 文脈非依存: 多義語(”bank”=銀行/川岸)を1つのベクトルで表現するため、文脈による意味の違いを捉えられない
- 未知語への対応: 学習時に存在しなかった単語のベクトルは得られない
まとめ
本記事では、GloVe の理論と実装について解説しました。
- 共起確率比の直感: 単語の意味の違いは共起確率の比に現れるという洞察から出発し、ベクトル空間での線形構造を要請することで GloVe の目的関数を自然に導出しました
- 目的関数: $J = \sum f(X_{ij})(\bm{w}_i^\top \tilde{\bm{w}}_j + b_i + \tilde{b}_j – \log X_{ij})^2$ の各項の意味と、重み関数 $f(x)$ の設計思想を理解しました
- SVD・Word2Vec との関係: GloVe が重み付き行列分解であること、SGNS が Shifted PMI 行列の暗黙的分解であることを整理し、両者の類似性と相違点を明確にしました
- 実装: Python で共起行列の構築から GloVe の AdaGrad による学習、t-SNE を用いた単語ベクトルの可視化までを行いました
次のステップとして、以下の記事も参考にしてください。