第03回 词的灵魂——嵌入与 Word2vec

独热万点空寂寥,词词孤影各逍遥。
一入低维同席坐,亲疏远近自能招。

上回说到,独热能点名,词袋能计数,TF‑IDF 能称分量——可它们都像“编户齐民的名册”:写得清楚,却不懂人情。

“吃饭”与“用餐”明明是亲戚,在独热里却如陌路;“苹果”(水果)与“苹果”(公司)在词袋里也难分真假。

要让算法懂这份“词的灵魂”,就得教它一门新功夫:嵌入(embedding)


一、嵌入是何物:给每个词一把“方向”

嵌入的意思很朴素:
把每个词从“稀疏的高维灯阵”搬到“稠密的低维江湖”里。

我们不再用十万维的独热去“点名”,而是为每个词分配一个长度为 dd 的向量(比如 d=50d=50300300):

eRd\text{词} \longrightarrow \vec e \in \mathbb{R}^d

这向量不再是人手规定,而是让模型从大量文本里自己学出来。

学成之后,有三件好处立刻见效:

  • 相似可算:用余弦相似度一比,“吃饭”会靠近“用餐”
  • 类比可做:某些方向代表“性别”“时态”“国家‑首都”等属性
  • 可喂神经网:稠密向量更适合后续 RNN、注意力、Transformer 的计算

从此,词不再是一张身份证,而像一把带方向的剑:剑锋所指,便是它在语义江湖里的位置。


二、江湖祖训:同伴决定你是谁

嵌入背后有句名言,常被译作“同伴决定你是谁”:

你若常与“锅”“菜”“米”“香”结伴,旁人多半猜你与吃喝相关;
你若常与“股票”“发布会”“iPhone”“生态”一同出现,“苹果”便更像公司而非水果。

这条祖训,就是分布式语义的直觉:词义来自上下文

Word2vec 便是把这条直觉炼成算法的代表武功。


三、Word2vec 两路武功:CBOW 与 Skip‑gram

Word2vec 是一套“用预测练内功”的法门:
不靠人工标注意义,而是让模型做一个简单任务——猜词

设一句话是一串词:

w1,w2,,wTw_1,w_2,\dots,w_T

取某个位置 tt,把它周围一圈当“上下文”。窗口大小为 kk 时,上下文可写作:

wtk,,wt1,wt+1,,wt+kw_{t-k},\dots,w_{t-1},w_{t+1},\dots,w_{t+k}

然后便分两路练法。

1)CBOW:合围出招,猜中间那一位

CBOW(Continuous Bag of Words)像“群侠围攻”:
把周围上下文的向量汇在一起,去猜中心词 wtw_t

它训练快、稳健,像练基本拳架。

2)Skip‑gram:以一敌多,猜四方来者

Skip‑gram 像“一剑开路”:
拿中心词 wtw_t 去预测它周围的每个上下文词。

它更偏向把稀有词练得精细,因为每次出招都围着中心词打磨。

Word2vec 在工程里最常被提起的,是 Skip‑gram + 负采样这一组合拳。


四、难点与妙解:负采样(Negative Sampling)

若要“猜词”,最直接的做法是 softmax:
中心词向量与所有词表里的词逐个点积,算出概率,选最大者。

可词表往往有十万、百万之多,每次更新都要扫全词表,开销极大。

于是 Word2vec 出一奇招:
不再每次把天下英雄都叫来比武,而是——

  1. 拿一对真实共现的词当“正样本”(比如“吃饭‑香”)
  2. 再从词表里抽若干个“不太可能一起出现”的词当“负样本”(比如“吃饭‑火箭”)
  3. 训练一个二分类器:分清“真同伴”与“假路人”

把这事写成一句话,就是:
用少量对比,逼出向量方向。

在实现层面,我们只需要一个 sigmoid:

  • 点积大 → sigmoid 近 1 → 更像同伴
  • 点积小 → sigmoid 近 0 → 更像路人

这样一来,每次更新只算“正样本 + 几个负样本”,速度大大提升。

2024 年仍有研究在更一般的图嵌入与对比目标里讨论“负采样这件事本身的结构意义”,把它看作一种防止表示塌缩的“排斥力”。1


五、两套向量:一词两身,方成武功

很多初学者会疑惑:
“同一个词,为何要两套向量?”

在 Skip‑gram + 负采样里,常见做法是:

  • 一套当“出招者”(input / target embedding)
  • 一套当“接招者”(output / context embedding)

训练时两套都更新;训练后有的人取其中一套,有的人取两套相加或平均。

你可以把它理解为:
一个人既有“自己是谁”的内在气质,也有“别人如何看他”的江湖名声;两者合在一起,才更像完整的人。


六、从 Word2vec 到 2026:嵌入仍是底座

看官读到这里,也许会问:
“2026 年都推理模型、慢思考、Mamba、SAE 了,为何还学 Word2vec?”

原因很简单:
再大的模型,也离不开“把符号变成向量”的第一步。

今天的大模型多用子词 token;检索系统用句向量;多模态模型用图像/音频向量。嵌入早已不止“词向量”,而是“万物向量”。

以 OpenAI 在 2024‑01‑25 发布的 embedding v3 模型为例,就强调了在多语言检索与通用评测基准上的提升,并提供可缩短向量维度的参数,方便工程落地。2

所以你学 Word2vec,不是为了在 2026 年拿它去打擂台,而是为了:

  • 明白“相似度为何能算出来”
  • 明白“训练目标如何把语义压进向量”
  • 明白“后面一切复杂系统,都在这上面加招式”

七、极简代码:用 PyTorch 练一套 Skip‑gram + 负采样

下面是一段极简可运行示例:
用很小的语料训练词向量,并打印每个词的近邻(用余弦相似度)。

import math
import random

import torch
import torch.nn as nn
import torch.nn.functional as F


def build_vocab(tokens):
    vocab = {}
    for w in tokens:
        if w not in vocab:
            vocab[w] = len(vocab)
    inv = {i: w for w, i in vocab.items()}
    return vocab, inv


def make_pairs(tokens, window):
    pairs = []
    for i, w in enumerate(tokens):
        for j in range(max(0, i - window), min(len(tokens), i + window + 1)):
            if j != i:
                pairs.append((w, tokens[j]))
    return pairs


class SGNS(nn.Module):
    def __init__(self, vocab_size, dim):
        super().__init__()
        self.in_embed = nn.Embedding(vocab_size, dim)
        self.out_embed = nn.Embedding(vocab_size, dim)
        bound = 1.0 / math.sqrt(dim)
        nn.init.uniform_(self.in_embed.weight, -bound, bound)
        nn.init.uniform_(self.out_embed.weight, -bound, bound)

    def forward(self, center_ids, pos_ids, neg_ids):
        v = self.in_embed(center_ids)
        u_pos = self.out_embed(pos_ids)
        u_neg = self.out_embed(neg_ids)

        pos_score = (v * u_pos).sum(dim=1)
        neg_score = torch.bmm(u_neg, v.unsqueeze(2)).squeeze(2)

        loss_pos = -F.logsigmoid(pos_score).mean()
        loss_neg = -F.logsigmoid(-neg_score).mean()
        return loss_pos + loss_neg


def cosine_neighbors(emb, inv, topk=5):
    w = F.normalize(emb, dim=1)
    sim = w @ w.t()
    for i in range(w.size(0)):
        vals, idx = torch.topk(sim[i], k=min(topk + 1, w.size(0)))
        idx = [j.item() for j in idx if j.item() != i][:topk]
        print(inv[i], "->", [inv[j] for j in idx])


if __name__ == "__main__":
    torch.manual_seed(0)
    random.seed(0)

    text = """
    我 爱 吃饭 我 爱 米饭
    你 爱 吃饭 吗
    吃饭 真 香
    用餐 真 香
    我 喜欢 做饭
    """.strip().split()

    vocab, inv = build_vocab(text)
    pairs = make_pairs(text, window=2)

    dim = 32
    neg_k = 8
    model = SGNS(len(vocab), dim)
    opt = torch.optim.Adam(model.parameters(), lr=0.05)

    unigram = torch.zeros(len(vocab))
    for w in text:
        unigram[vocab[w]] += 1
    prob = (unigram ** 0.75)
    prob = prob / prob.sum()

    for step in range(800):
        center_w, pos_w = random.choice(pairs)
        c = torch.tensor([vocab[center_w]])
        p = torch.tensor([vocab[pos_w]])
        neg = torch.multinomial(prob, num_samples=neg_k, replacement=True).unsqueeze(0)

        loss = model(c, p, neg)
        opt.zero_grad()
        loss.backward()
        opt.step()

    emb = model.in_embed.weight.detach().cpu()
    cosine_neighbors(emb, inv, topk=4)

你可以把语料改成自己的小文本,观察近邻如何变化。
在这个玩具世界里,“吃饭”“用餐”“香”往往会互相靠近——这就是嵌入在做的事:把“同桌的人”拉到一起。


八、小结:本回解了“认亲”,下回要讲“记事”

这一回,我们解决了上回留下的悬念:
词不再是孤灯,而能在低维空间里结亲缘、分远近。

可新的问题又来了:
词有了向量,句子怎么办?
词袋不看顺序,但语言偏偏讲顺序:
“我 不 爱 你”与“我 爱 你”只差一个“不”,却天差地别。

因此下回我们要请“时序记忆”登场:
让模型不只看“有哪些词”,还要记“词是怎么排的”。

正是:同伴相随词义显,一维一念各生情。
若问长句谁来记,还看循环起潮声。

欲知后事如何,且听下回分解。


引用与溯源

Footnotes

  1. Bypassing Skip‑Gram Negative Sampling: Dimension Regularization … arXiv:2405.00172(2024-05)https://arxiv.org/abs/2405.00172

  2. OpenAI. New embedding models and API updates(2024-01-25)https://openai.com/index/new-embedding-models-and-api-updates/