4.3 为何需要“负采样”

“负采样”(Negative Sampling)

直接用 softmax,每一步都要对∣V∣个词打分,对大词表很慢。

负采样把“多分类”换成一堆“二分类”:

  • 正样本:真实共现对 (w,c),标签 1。
  • 负样本:从某个噪声分布 Pn 随机抽 k个词

与 w 人为配对,标签 0。

  • 用 logistic 回归的交叉熵损失替代 softmax:

其中

关键设定

  • k:每个正样本采多少负样本(常见 5–20,语料大时可更小)。

  • 噪声分布 Pn:通常按词频 U(w) 的 3/4 次幂做重采样,即

    直觉:降低超高频词被采中的概率,使负样本更有信息量。

  • 训练复杂度从 O(∣V∣) 降为 O(k),速度大幅提升,且效果与 softmax 相当或更好。

(另有 Hierarchical Softmax 方案,用哈夫曼树把计算降到 O(log⁡∣V∣),在小语料或超大词表时也常用。)


4.4why隐藏层权重是词向量

为什么“隐藏层权重”就是词向量

Word2Vec 没有非线性隐藏层:查表(embedding lookup)→ 求和/平均 → 线性打分

因此,模型能表达的关系基本都压在词向量的方向与长度里。

  • 训练目标等价于:让真实共现的 (w,c) 点积变大,让采来的负样本点积变小。

    这会把语义接近(共现分布相似)的词推到相近方向

  • 更形式化地(负采样近似,有经典推导):

    在最优点,点积会近似

    其中

    这说明 Word2Vec 在隐式分解(shifted)PMI 共现矩阵,所以嵌入矩阵本身就是你要的词向量表示。

  • 实践里常用 输入嵌入 E 作为词向量;也有人把 E 与 E′ 做平均或拼接。


4.5 词向量空间的语义运算

词向量空间里的“语义运算”(类比)

经典例子:

(1)怎么算?(3CosAdd)

给定 a ⁣: ⁣b::c ⁣: ⁣?,求

实践小技巧:用余弦相似度、对向量做均值去中心 + 归一化能更稳。

(2)能泛化的关系

  • 国家–首都:Paris − France + Italy ≈ Rome

  • 现在式–过去式:walking − walk + go ≈ went

    它们都对应近似的“平移向量”,说明模型学到了线性化的关系结构

(3)注意局限

  • 并非所有关系都线性、也并非语义都能“一根向量”解释(多义词、长尾词)。
  • 语料与窗口选择会影响能学到哪些关系。
  • 现代模型(如 GloVe、fastText、上下文嵌入)在很多任务上更强。

4.6 训练中的关键工程点

训练中的关键工程点(影响效果的超参)

  • 窗口大小 m:小窗口(2–5)更偏语法,大窗口(5–10+)更偏语义。
  • 模型选择:CBOW 快、对高频词好;Skip-Gram 对稀有词友好。
  • 维度 d:100–300 是经典范围;语料越大可更高。
  • 负样本数 k:常 5–15(英文大语料 5~10 就够)。
  • 高频词下采样(t≈10^{−5}):随机丢弃极高频词(如“的”“了”“the”),提速且提质。
  • min_count:过滤低频词,减少噪声。
  • 归一化:用余弦时可把向量先 L2 归一化;做类比可再做去均值处理。

4.7 训练流程

一眼看懂的训练流程(Skip-Gram + 负采样)

  1. 扫语料,取每个中心词 wt的窗口 C。
  2. 对每个 c∈C 构造正样本 (wt,c)。
  3. 从 Pn 采 k 个负样本
  4. 计算损失 LNS,对 E[wt] 和 E′[c],E′[c~] 做梯度更新。注:这里的c~即C上面带个波浪号。
  5. 重复直到收敛;训练完把 E(或 E ⁣⊕ ⁣E′)导出当作词向量

小结

  • CBOW:用上下文预测中心词;Skip-Gram:用中心词预测上下文。
  • 负采样:把大词表 softmax 变成若干二分类,快且好用。
  • 隐藏层权重=词向量:训练把“共现统计”编码进点积,等价于分解(位移过的)PMI。
  • 语义运算可线性化:很多关系接近“平移向量”,因此能做类比。

一个极小的 Skip-Gram+NS,并把几个词的最近邻与“类比计算”结果列出来,对上面的公式来一把“可视化验收”。

import numpy as np
import random
from collections import Counter

# ===== 1. 准备极小语料 =====
corpus = "国王 喜欢 城堡 女王 喜欢 城堡 男人 喜欢 足球 女人 喜欢 足球".split()

# ===== 2. 建立词表 =====
vocab = sorted(set(corpus))
word2idx = {w:i for i,w in enumerate(vocab)}
idx2word = {i:w for w,i in word2idx.items()}
V = len(vocab)

# ===== 3. 超参数 =====
d = 10         # 词向量维度
window = 2     # 窗口大小
k_neg = 3      # 每个正样本采样负例数
lr = 0.05
epochs = 200

# ===== 4. 初始化参数 =====
rng = np.random.default_rng(42)
W_in = rng.normal(0,0.01,(V,d))   # 输入向量
W_out = rng.normal(0,0.01,(V,d))  # 输出向量

# ===== 5. 构建 Skip-Gram 样本 (中心词, 上下文) =====
pairs = []
for i, w in enumerate(corpus):
    for j in range(max(0,i-window), min(len(corpus), i+window+1)):
        if i != j:
            pairs.append((word2idx[corpus[i]], word2idx[corpus[j]]))

# ===== 6. 负采样概率(词频^0.75) =====
freq = Counter(corpus)
counts = np.array([freq[w] for w in vocab], dtype=float)
p_noise = counts**0.75 / np.sum(counts**0.75)

def sigmoid(x): return 1/(1+np.exp(-x))

# ===== 7. 训练 Skip-Gram + NS =====
for epoch in range(epochs):
    random.shuffle(pairs)
    for w,c in pairs:
        # --- 正样本 (w,c), label=1 ---
        s_pos = W_in[w] @ W_out[c]
        g_pos = sigmoid(s_pos) - 1
        W_in[w]  -= lr * g_pos * W_out[c]
        W_out[c] -= lr * g_pos * W_in[w]

        # --- 负样本 ---
        negs = np.random.choice(V, k_neg, p=p_noise)
        for neg in negs:
            s_neg = W_in[w] @ W_out[neg]
            g_neg = sigmoid(s_neg) - 0
            W_in[w]    -= lr * g_neg * W_out[neg]
            W_out[neg] -= lr * g_neg * W_in[w]

# ===== 8. 结果查看函数 =====
emb = W_in  # 输入向量作为词向量

def cosine(u,v):
    return float(u@v / ((np.linalg.norm(u)+1e-9)*(np.linalg.norm(v)+1e-9)))

def most_similar(word, topn=3):
    v = emb[word2idx[word]]
    sims = [(w, cosine(v, emb[i])) for w,i in word2idx.items() if w!=word]
    return sorted(sims, key=lambda x:-x[1])[:topn]

def analogy(a,b,c, topn=3):
    vec = emb[word2idx[b]] - emb[word2idx[a]] + emb[word2idx[c]]
    sims = [(w, cosine(vec, emb[i])) for w,i in word2idx.items() if w not in (a,b,c)]
    return sorted(sims, key=lambda x:-x[1])[:topn]

# ===== 9. 测试 =====
print("相似词(国王):", most_similar("国王", topn=5))
print("相似词(女人):", most_similar("女人", topn=5))
print("类比: 男人 : 国王 :: 女人 : ?", analogy("男人","国王","女人", topn=5))

这个符号 “::”(双冒号)在这里不是编程里的语法,而是类比表达的常见写法。

  • 男人 : 国王 读作 “男人 之于 国王”

  • 女人 : ? 读作 “女人 之于 ?”

  • 合起来:

    男人 : 国王 :: 女人 : 女王
    

    就是 “男人 之于 国王,正如 女人 之于 女王”


极小 CBOW + NS 示例

import numpy as np
import random
from collections import Counter

# ===== 1. 极小语料 =====
corpus = "国王 喜欢 城堡 女王 喜欢 城堡 男人 喜欢 足球 女人 喜欢 足球".split()

# ===== 2. 词表 =====
vocab = sorted(set(corpus))
word2idx = {w:i for i,w in enumerate(vocab)}
idx2word = {i:w for w,i in word2idx.items()}
V = len(vocab)

# ===== 3. 超参数 =====
d = 10         # 词向量维度
window = 2     # 窗口大小
k_neg = 3      # 每个正样本负例数
lr = 0.05
epochs = 200

# ===== 4. 初始化向量 =====
rng = np.random.default_rng(42)
W_in = rng.normal(0,0.01,(V,d))   # 输入向量
W_out = rng.normal(0,0.01,(V,d))  # 输出向量

# ===== 5. 构建 CBOW 样本 (上下文 -> 中心词) =====
pairs = []
for i, w in enumerate(corpus):
    context = []
    for j in range(max(0,i-window), min(len(corpus), i+window+1)):
        if i != j:
            context.append(word2idx[corpus[j]])
    if context:  # (context_idxs, center_idx)
        pairs.append((context, word2idx[w]))

# ===== 6. 负采样分布 =====
freq = Counter(corpus)
counts = np.array([freq[w] for w in vocab], dtype=float)
p_noise = counts**0.75 / np.sum(counts**0.75)

def sigmoid(x): return 1/(1+np.exp(-x))

# ===== 7. 训练 CBOW + NS =====
for epoch in range(epochs):
    random.shuffle(pairs)
    for context, center in pairs:
        # CBOW: 上下文向量 = 平均
        h = np.mean(W_in[context], axis=0)

        # --- 正样本 ---
        s_pos = h @ W_out[center]
        g_pos = sigmoid(s_pos) - 1
        grad = lr * g_pos * W_out[center]
        for c in context:
            W_in[c] -= grad
        W_out[center] -= lr * g_pos * h

        # --- 负样本 ---
        negs = np.random.choice(V, k_neg, p=p_noise)
        for neg in negs:
            s_neg = h @ W_out[neg]
            g_neg = sigmoid(s_neg) - 0
            grad = lr * g_neg * W_out[neg]
            for c in context:
                W_in[c] -= grad
            W_out[neg] -= lr * g_neg * h

emb = W_in  # 输入嵌入作为词向量

# ===== 8. 辅助函数 =====
def cosine(u,v):
    return float(u@v / ((np.linalg.norm(u)+1e-9)*(np.linalg.norm(v)+1e-9)))

def most_similar(word, topn=3):
    v = emb[word2idx[word]]
    sims = [(w, cosine(v, emb[i])) for w,i in word2idx.items() if w!=word]
    return sorted(sims, key=lambda x:-x[1])[:topn]

def analogy(a,b,c, topn=3):
    vec = emb[word2idx[b]] - emb[word2idx[a]] + emb[word2idx[c]]
    sims = [(w, cosine(vec, emb[i])) for w,i in word2idx.items() if w not in (a,b,c)]
    return sorted(sims, key=lambda x:-x[1])[:topn]

# ===== 9. 测试 =====
print("相似词(国王):", most_similar("国王", topn=5))
print("相似词(女人):", most_similar("女人", topn=5))
print("类比: 男人 : 国王 :: 女人 : ?", analogy("男人","国王","女人", topn=5))


跑了同一份极小语料,在 Skip-GramCBOW 两种模式下,得到了不同的相似度和类比结果。这里面有几个值得理解的点:

  1. Skip-Gram 结果
相似词(国王): [('女王', 0.26), ('男人', 0.07), ('女人', -0.02) ...]
相似词(女人): [('男人', 0.91), ('喜欢', 0.82), ('女王', 0.61) ...]
类比: 男人 : 国王 :: 女人 : ? [('女王', 0.16), ...]
  • “国王”和“女王”有一点点相似(0.26),但不算强,因为语料太小。
  • “女人”和“男人”的相似度很高(0.91),说明 Skip-Gram 把“女人–男人”这一对近义(对偶)学到了。
  • 类比任务中,“女王”排在第一,但分数 0.16 很低 → 模型“隐约”捕捉到类比关系,但不稳。

  1. CBOW 结果
相似词(国王): [('女王', 0.84), ('男人', 0.70), ('足球', 0.56) ...]
相似词(女人): [('男人', 0.83), ('国王', 0.54), ('女王', 0.32) ...]
类比: 男人 : 国王 :: 女人 : ? [('城堡', 0.58), ('女王', 0.26), ...]
  • “国王”和“女王”相似度很高(0.84) → CBOW 在小语料下更容易学到这种共现。

  • “女人”和“男人”的相似度也很高(0.83),合理。

  • 但类比任务时,“城堡”排第一,“女王”才第二 → CBOW 把“城堡”与“国王/女王”捆绑太紧(因为语料里国王/女王总是和城堡一起出现)。

    所以在推“男人:国王 :: 女人: ?”时,它受上下文干扰太大,结果偏了。


  1. 总结差异
  • Skip-Gram

    更擅长建模 罕见词和类比关系(但需要更多语料才能明显看出来)。在你的小语料下,已经能 faintly 抓到 “国王-男人+女人≈女王”。

  • CBOW

    更快收敛,擅长学到 频繁的上下文共现。在你的小语料里,它更快把“国王–女王”看作一对,但因为数据太少,类比任务容易被“城堡”这种高频上下文词干扰。


  1. 结果说明
  • 小语料下,Skip-Gram 的类比效果 稍微比 CBOW 好(尽管分数低)。
  • CBOW 的相似度更“强”,但类比受共现干扰。
  • 如果换更大语料(比如维基百科),两者都会表现好:
    • CBOW:快,适合大数据。
    • Skip-Gram:更精准,适合稀疏词、类比关系。

  • Skip-Gram:把每个位置的中心词与其窗口内的每个上下文词配对,形成很多 (中心词idx, 上下文词idx)
  • CBOW:把每个位置窗口内的所有上下文词收集成一个列表,再与中心词配对,形成 (上下文idx列表, 中心词idx)

窗口由 window 控制,代码用

range(max(0,i-window), min(len(corpus), i+window+1))

裁剪边界,避免越界;if i != j 用来排除中心词自己


代码逐句读

pairs = []

if mode == "skipgram":
    # Skip-Gram: (中心, 上下文)
    for i, w in enumerate(corpus):
        # j 在 [i-window, i+window] 的闭区间上滑动,但被裁剪到 [0, len(corpus)-1]
        for j in range(max(0,i-window), min(len(corpus), i+window+1)):
            if i != j:  # 不跟自己配对
                pairs.append((word2idx[corpus[i]], word2idx[corpus[j]]))
else:
    # CBOW: (上下文列表, 中心)
    for i, w in enumerate(corpus):
        context = []
        for j in range(max(0,i-window), min(len(corpus), i+window+1)):
            if i != j:
                context.append(word2idx[corpus[j]])
        if context:  # 边界极端情况防空
            pairs.append((context, word2idx[w]))
  • Skip-Gram:对每个中心位置 i,把窗口内每个 j (≠ i) 都记一条样本;因此一对多
  • CBOW:对每个中心位置 i,先把所有上下文 j (≠ i) 收集进 context 列表,再一次性加一个样本(上下文列表 → 中心);因此多对一
  • 注意:CBOW 上下文是列表(顺序在后续训练中并不重要);你的训练里是做平均,所以顺序不影响结果。
  • 如果窗口里有重复词(比如“喜欢”在窗口两侧都出现),Skip-Gram 会出现两次(位置不同),CBOW 的 context 列表里也会包含两次该词的索引。

例子 1(最直观的小玩具)

语料["A", "B", "C", "D"]window = 1

(只看相邻一个词)

Skip-Gram

对每个位置 i:

  • i=0,中心 A,上下文窗口裁剪为 [0,1] 去掉自己 → 只剩 j=1 → (A, B)
  • i=1,中心 B,窗口 [0,2] 去自己 → (B, A), (B, C)
  • i=2,中心 C,窗口 [1,3] 去自己 → (C, B), (C, D)
  • i=3,中心 D,窗口 [2,3] 去自己 → (D, C)

Skip-Gram 的 pairs(用词显示,实际存 idx)

(A,B),
(B,A), (B,C),
(C,B), (C,D),
(D,C)

CBOW

对每个位置 i(中心在 i):

  • i=0:上下文 [B]([B], A)
  • i=1:上下文 [A, C]([A, C], B)
  • i=2:上下文 [B, D]([B, D], C)
  • i=3:上下文 [C]([C], D)

CBOW 的 pairs

([B], A),
([A, C], B),
([B, D], C),
([C], D)

你会发现:Skip-Gram把一行 CBOW 的上下文拆成多行;CBOW把 Skip-Gram 的多行合成一行(上下文列表)。


例子 2(用你语料的一段,展示“重复词进窗口”的情形)

取你语料的前 6 个词,window = 2

[国王, 喜欢, 城堡, 女王, 喜欢, 城堡]

索引:0 1 2 3 4 5

看一个“中心 = 城堡(i=2)”的情况:

Skip-Gram(中心 i=2)

窗口范围:max(0,2-2)=0min(6,2+2+1)=5 → j ∈ {0,1,2,3,4} 去掉自己 2

上下文词序列(按位置):[国王(0), 喜欢(1), 女王(3), 喜欢(4)]

注意“喜欢”在 1 和 4 两个位置各出现一次,所以会生成两条样本:

样本

(城堡, 国王), (城堡, 喜欢), (城堡, 女王), (城堡, 喜欢)

“喜欢”出现了两次,训练时就相当于这两个上下文信号各被看到一次,对参数更新也会有两次贡献。

CBOW(中心 i=2)

同样的窗口里,上下文列表就是:

[国王, 喜欢, 女王, 喜欢]

因此一条样本

([国王, 喜欢, 女王, 喜欢], 城堡)

因为你后续用的是均值,这个列表里“喜欢”出现两次,会让“喜欢”的向量在均值里权重更大(出现两次就被平均两次),这与 Skip-Gram 的两次更新是同一件事的另一种表达。

你抓住的点非常关键,就是 CBOW 的“均值 + 重复”Skip-Gram 的“多次更新” 看似类似,但其实不是一模一样。下面我帮你拆开:


  1. Skip-Gram 的处理方式:

中心词=城堡,上下文窗口包含 [国王, 喜欢, 女王, 喜欢] 为例。

  • Skip-Gram 会生成 4 个样本:

    (城堡, 国王), (城堡, 喜欢), (城堡, 女王), (城堡, 喜欢)
    
  • 每个 pair 都会独立训练一次。

  • 所以 “喜欢”出现两次,就真的更新了两次

  • 更新时,梯度和学习率是逐条单独计算的。

换句话说:Skip-Gram 是 重复词 → 重复训练步骤


  1. CBOW 的处理方式:

同样的窗口 [国王, 喜欢, 女王, 喜欢]

  • CBOW 只会生成 1 个样本:

    ([国王, 喜欢, 女王, 喜欢], 城堡)
    
  • 在训练时,先取所有上下文向量的平均值

  • 注意,“喜欢”出现了两次,因此在平均里它被算了两次。

  • 但是,最终只进行一次更新(用这个平均向量来预测中心词)。

换句话说:CBOW 是 重复词 → 在均值里权重更大,但更新次数还是一次


  1. “另一种表达”的含义

我之前说 CBOW 的“重复计入均值”是 Skip-Gram 的“重复训练”的另一种表达,意思是:

  • 两者都能体现出 上下文中出现多次的词 对训练结果更重要。
  • Skip-Gram:通过“出现一次 → 更新一次”来表现;
  • CBOW:通过“均值里多算一遍”来表现。

它们的结果方向是一致的:频繁出现的上下文词影响更大

但实现机制不一样。


  1. 核心区别总结
特点 Skip-Gram CBOW
更新次数 上下文词出现几次就更新几次(逐对更新) 所有上下文合成一次平均,再更新一次
训练粒度 精细,每个 (中心, 上下文) 单独学 粗一些,一次用整个上下文来学
重复词影响 出现 2 次 → 真正更新 2 次 出现 2 次 → 平均里权重 2/总数
效果 更适合低频词(训练信号多) 更适合高频词(上下文丰富时更稳定)

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐