ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从编号到词向量:Embedding层的前向查表与反向训练

从编号到词向量:Embedding层的前向查表与反向训练

上一篇我们把一段文本切成了 token,并且给每个 token 按词表顺序编了个号。那会儿我就在评论区看到一批人卡在同一步:编号嘛,整数,喂给模型不就完事了?但实际写代码的时候你会发现,模型接受的根本不是整数本身,而是先过一个叫 Embedding 的层,出来后每个编号都成了一串浮点数——比如tensor([0.0245, -0.0193, 0.0832, ...])。这就很反直觉:一个 3,怎么突然就有"意思"了?

我第一次在调试大模型中间张量的时候也对着屏幕愣了半天。干过传统编程的人都知道,id 就是 id,数据库里主键是 3 不代表比 2 大、比 4 小。可到了深度学习里,编号查完表之后拿出来的向量竟然能用来算"苹果"和"香蕉"的距离,甚至能算词和词的类比关系。这篇文章就把这件事彻底讲透:Embedding 到底做了一步什么操作,编号为什么能变成词向量,以及这个"词向量"在大模型里又是怎么被继续改造的。

1. 先打破一个直觉:编号进 Embedding 层,本质是"查表"

1.1 前向传播:一个矩阵行抽取操作

先看代码。PyTorch 里最典型的 Embedding 层长得这样:

import torch import torch.nn as nn # 词表大小 10000,向量维度 64 emb = nn.Embedding(num_embeddings=10000, embedding_dim=64) # 假设三个 token id ids = torch.tensor([3, 17, 892]) vectors = emb(ids) print(vectors.shape) # torch.Size([3, 64]) print(vectors)

看到没?输入是[3, 17, 892]这三个整数,输出的形状是(3, 64),也就是每个编号各自拿到了一条 64 维的浮点向量。

关键点来了:这个操作的前向计算没有任何"语义理解",它就是一次查表。你可以把它完全等价成下面这行:

vectors = emb.weight[ids]

emb.weight的形状是(10000, 64),也就是一张有 10000 行的表,每行是一个 64 维向量。ids里面的编号 3、17、892 就当成行号,从表里把对应的行抽出来。

所以严格讲,编号本身并不是"变"成了词向量,而是编号作为一个索引,帮你从 Embedding 矩阵里把对应的那一行向量"取"了出来。

这个类比就像你去学校档案室:学生编号本身是 3 号、17 号,这些数字没有任何性格、成绩、兴趣信息。但管理员拿着编号去柜子里抽档案,拿到的那份档案才真正记录了这个人是什么样。Embedding 矩阵就是那个档案柜,每个 token 编号背后挂着一份"向量档案"。

1.2 真正改变向量语义的不是查表,是反向传播

如果只是查表,那你可能马上要问:表里存的那些浮点数从哪来?一开始是什么?

答案是:一开始是随机数。nn.Embedding(10000, 64)在你创建它的瞬间,权重矩阵里的所有值都是从一个随机分布里采出来的噪声。你用随机初始化的权重去查表,查出来的向量之间没有任何语义关系——你把"苹果"和"火星"的编号拿去查,查到的那两行向量跟扔骰子没有区别。

那后面为什么有语义了?因为训练过程会反复修改这张表里每一行的数值。

前向传播只是"取",反向传播才是"改"。每训练一步,模型根据损失函数算出梯度,然后沿着梯度方向微调整个 Embedding 矩阵里的参数。经过海量文本的反复更新,"苹果"那一行的数值会慢慢变得跟"香蕉""水果"等词的向量相近,跟"火星"的向量相去甚远。

这里有个初学者常犯的误区:以为 Embedding 层本身带有什么 NLP 魔法。其实它就是一个普通的可学习参数矩阵,跟全连接层的权重矩阵没有本质区别。它的"语义"完全是被训练任务逼出来的。

2. 为什么不能直接拿编号当数字,独热编码差在哪

2.1 直接喂编号的致命问题

既然编号是整数,为什么不直接把3、17、892当成数值特征喂给模型?这样连 Embedding 层都省了。

问题出在数值的"序"和"大小关系"上。编号 3 和编号 4 在词表里相邻,但它俩的语义可能毫无关系。比如一个按字母排序的词表里,3 号是 "about",4 号是 "above",这俩语义接近还说得过去;可如果 892 号是 "apple"、893 号是 "abandon" 呢?中间没有任何几何意义。

你要是直接把编号作为数值特征,模型会忍不住学到"数值越大代表某种信号越强"这种关系。这是神经网络的天性:它看到数字 100 和 200,就会天然假设 200 的"强度"是 100 的两倍。但 token id 之间根本不存在这种线性关系。喂原始编号等于给模型喂了一堆有误导性的数值信号,训练会很难收敛。

2.2 分布式表示:从工牌号到性格量表

那不用编号,用**独热编码(one-hot encoding)**呢?这也是很多人第一个想到的办法。

独热编码的做法是:词表 10000 个词,就用 10000 维的向量。每个词对应一个维度,这个词出现时那个维度置 1,其余全是 0。比如"猫"是 3 号,向量就是[0, 0, 1, 0, ..., 0]。

这个方案有两个硬伤。

第一个硬伤是维度爆炸。词表越大,向量就越长。现实中的大模型词表动辄几万甚至十几万 token,每次都要操作这么稀疏的高维向量,存储和计算都扛不住。

第二个硬伤更致命:任意两个不同的独热向量,在空间里都是完全正交的,余弦相似度永远是 0。"猫"和"狗"的向量没有一丝相似之处,"猫"和"火星"的向量也没有,模型完全没有办法从向量本身推断出任何语义亲疏关系。

稠密 Embedding 用的则是另一套思路:不搞"一个词一个位置"的开关式表示,而是用一个固定长度的稠密实数向量来表示每个词,每个维度上都有数值。你可以把每个维度理解为模型自己从数据里发现的某种抽象特征——虽然这个特征具体代表什么,人很难说清,但多个维度组合起来,就能构成一个词的多维"画像"。

打个比方:独热编码像"工牌号",每个员工一个编号,编号只回答"你是谁";分布式表示像一份"性格量表",每个员工在几个维度上打分,比如外向程度、细心程度。有了量表,就算你没见过这两个人,也能算出来他们在性格空间里靠得近不近。

2.3 独热编码和稠密向量的定量对比

我把两种表示摆一起对比,你感受会更直接:

对比项独热编码稠密 Embedding
维度等于词表大小,比如 50000自定,常见 128~1024
稀疏度极度稀疏,99.99% 是 0稠密,处处非零
词间相似度任意两词相似度恒为 0余弦相似度可比较
可学习性不可学习,固定编码端到端训练更新
存储成本词表 5 万就要 5 万维5 万 x 512 参数,远小于前者

说到底,Embedding 就是"用可学习的稠密低维实数向量,替代不可学习的稀疏高维独热向量"。它保留了每个词的独立身份,又给了模型表达词与词之间关系的手段。这个思路在深度学习里叫分布式表示(distributed representation)——语义不再由单个维度独立承担,而是分布在一组维度的组合模式上。

3. 语义是训练"打磨"出来的:梯度如何重塑向量空间

3.1 语言学假设:相似语境等于相似语义

现在到了最核心的问题:Embedding 矩阵里那一个个浮点数,是怎么从随机噪声变成能承载语义的结构的?

这背后有一个语言学层面的假设,圈内叫分布式假设(distributional hypothesis):一个词的意思,由它经常出现的上下文决定。说得再直白点,如果两个词经常出现在相同的语境里,那它们的语义大概率是相近的。

"苹果"和"香蕉"都会出现在"我吃了一个__""这个__很甜""水果店里的__"这样的上下文里,所以它们应该是近义词或同类词。"苹果"和"火星"几乎不会共享上下文,所以它们的距离应该很远。

那怎么把这种直觉变成一个可计算的训练目标?最经典的思路就是 word2vec 里的两个任务:Skip-gram 和 CBOW。

  • Skip-gram:给你一个中心词,让你预测它周围会出现哪些词。
  • CBOW:给你周围的上下文词,让你预测中间那个词是谁。

不管哪种,本质上都是让模型学会一件事:利用词之间的共现关系,预测上下文。

3.2 训练任务如何把相邻词的向量拉近

以 Skip-gram 为例。假设语料里有一句话:"苹果 是 一种 非常 好吃 的 水果"。我们取中心词"好吃",窗口大小设为 2,那么模型的任务就是:看到"好吃"的向量,去预测它周围可能出现"苹果""一种""非常""的"这些词的概率。

这个预测过程不是直接拿"好吃"的向量去比,而是把中心词向量跟上下文词的向量分别做点积,点积结果经过 softmax 变成概率。模型希望真实上下文词的概率尽可能高,于是损失函数会引导梯度更新——本质上是在做一件事:

把中心词的向量,往真实上下文词向量的方向上推;同时把那些没有出现的随机词向量,往反方向推一点。

所以如果一个词经常和另一堆词共同出现,它们的向量就会被反复互相推近。训练几十万步之后,"苹果"和"香蕉"因为上下文高度重叠,向量空间里自然靠在一起;"苹果"和"火星"因为上下文毫无交集,被推得越来越远。

这里我不想堆公式,给你讲个直觉:想象你在空旷操场上摆了 5 万张卡片,每张卡片是一个词。有一个训练信号反复告诉你:每次出现"苹果"时,周围应该站着"甜、水果、树、汁、红"这些卡片;每次看到这堆卡片时,中心位置大概率是"苹果"。你不断根据这类信号挪动卡片位置,最终所有卡片会自动聚类成"水果类聚一堆、动物类聚一堆、动词类聚一堆"的布局。

你甚至不需要别人告诉你"苹果是水果",仅凭共现统计规律,这个几何结构自己就冒出来了。这非常神奇,但背后的机制并不神秘:Embedding 向量是训练信号在参数空间里留下的痕迹。

3.3 大模型依然沿用了这套底层逻辑

说到大模型,很多人会问:GPT 这类模型又不专门训练 embedding,那它的词向量靠谱吗?

靠谱。因为大模型的训练任务是"预测下一个 token",这个任务的底层逻辑跟 Skip-gram 是互通的。每次模型看到前文、要预测下一个 token 时,它都在隐式地学习 token 之间的共现规律。所有 token 的 embedding 参数都参与了反向传播,一样会被海量语料反复打磨。

区别在于大模型的输入 Embedding 只是第一层,后面还有几十层 Transformer 会继续改写这些向量。但作为起点的 Embedding 层本身,早就在训练中形成了非常丰富的静态语义结构。你可以把它理解为:大模型的 Embedding 层是一本"静态词典",Transformer 层则在这本词典的基础上,根据当前上下文再做动态调节。

4. 大模型里的 Embedding 远比"查表"复杂:从静态向量到上下文表征

4.1 编号对应的是 token 而不是词

先把概念捋一下:在大模型里,你分配编号的对象严格来说不是"词",而是 token。token 可能是完整的词(比如 "apple"),可能是词根(比如 "un"),可能是标点,甚至可能是单个字符。所以更准确的说法是:大模型学到的叫token embedding,不是传统意义上的 word embedding。

这带来一个很实际的影响:一个单词可能被拆成好几个 token,每个 token 单独编号、单独有向量。比如 "unbelievable" 可能被切成 "un"、"believ"、"able"。那它的语义就不存在单一向量里,而是由这几个 token 组合表达。这就解释了为什么大模型的词表里经常能看到奇怪的 "##able" 这种条目——那是子词 token,不是完整词。

4.2 位置编码和 Transformer 改造

编号过了 Embedding 层拿到初始向量之后,接着还要做一件事:加位置信息。

如果没有位置编码,"苹果 吃 了" 和 "吃了 苹果" 这两句的输入向量序列是完全一样的,模型无法区分"苹果"在第一个位置还是第三个位置。所以 Transformer 架构会给每个位置的向量注入位置信息,常见的有绝对位置编码、相对位置编码,或者是旋转位置编码(RoPE)这类更现代的做法。

做完这些之后,向量序列才进入 Transformer 层。每一层里,每个 token 的向量都会经过注意力机制,去"看"序列里其他 token 的向量,然后更新自己的值。注意这里有本质变化:Embedding 层查出来的初始向量是固定的、与上下文无关的,但经过 Transformer 之后,每个 token 的最终向量是随着上下文动态变化的。

"NLP 界有个经典例子:word2vec 学到的 'bank' 只有一个向量,不管是河岸还是银行都用它;但在 BERT 里,'bank' 出现在 'river bank' 和 'central bank' 两条句子里时,最终向量是完全不同的。"这就是静态 embedding 和上下文 embedding(contextualized embedding)的区别。

4.3 想检查"理解"程度,该看哪一层

如果你要排查大模型,经常会遇到一个困惑:到底该把哪一层的输出叫"embedding"?

我的经验是分场景:

  • 要看基础的词义结构,看输入 Embedding 层的输出就够了。它反映的是静态语义。
  • 要看模型在具体语境下的理解,得看最后一层或倒数几层的输出。那里面包含了对整个句子的上下文建模结果。

很多向量检索系统、RAG 应用里说的"把文本编码成向量",通常指的不是第一层 Embedding,而是模型输出的语义向量,比如 CLS 位置或最后一层的平均池化。两者差距很大,别搞混。

还有一个高维向量经常被误解的点:768 维甚至 4096 维的向量,并不是"这个词有 768 种意思,每维对应一种"。真实情况是,模型学出来的每个维度本身很难用人能理解的方式解释,语义是散在整体方向上的。这就像你没办法单独指着红酒"果香"维度的某一个物理量说它代表什么,但两个酒的多维轮廓一比较,相似度就出来了。

5. 自己动手:三种方式验证 Embedding 里藏了什么

理论讲再多,不如自己上手看一眼。你不需要完整训练一个大模型,随便拿一个预训练好的 embedding 文件或者训练一个小模型就能做这三件事。

5.1 降维可视化看词聚类

第一步:把高维向量降维到二维平面,肉眼观察词的分布。常用工具是 t-SNE 或 UMAP。

import numpy as np from sklearn.manifold import TSNE import matplotlib.pyplot as plt # 假设 vectors 是词向量矩阵,shape=(N, dim) # 先抽一批高频词,别全量跑 indices = np.arange(0, 1000) embedding_subset = vectors[indices] tsne = TSNE(n_components=2, random_state=42, perplexity=30) coords = tsne.fit_transform(embedding_subset) for i, (x, y) in enumerate(coords): plt.scatter(x, y, s=5) plt.annotate(vocab[indices[i]], (x, y), fontsize=8) plt.show()

跑完你会发现非常直观:数字类聚在一边,颜色类聚在一边,食物类聚在一边。如果训练语料足够大、质量足够好,甚至可以观察到"男女性别词"沿着同一方向分开排列。这个几何结构就是语义相似度的可视化证明。

实操细节:t-SNE 全量跑几万词会非常慢,先随机抽 1000~2000 个词子集;perplexity 一般取 15~50,太小会显得聚成一团,太大结构又会被打散。

5.2 余弦相似度查最近邻

可视化适合宏观观察,最近邻检索则适合具体验证某个词的"朋友圈"。

import numpy as np # vecs: (N, dim) 已归一化 # target_idx: 要查的词 query = vecs[target_idx] sims = vecs @ query # 如果向量都是 L2 归一化的,点积等价余弦相似度 top_indices = np.argsort(sims)[::-1][1:11] # 排除自己 for idx in top_indices: print(vocab[idx], sims[idx])

我拿一个小语料训练过的 embedding 做过实验:查 "dog",前几位往往是 "cat, pet, puppy, leash";查 "beautiful",出现的是 "gorgeous, lovely, pretty, wonderful"。这种结果真的会让人有种"模型是不是懂点事"的错觉。

补充一个经验:小语料(几 MB 文本)训练出来的 embedding 一般不太干净,最近邻里会混入很多噪声。如果你看到 "beautiful" 的最近邻是 "page" 这种完全不搭边的词,别怀疑代码,就是训练数据太少、共现统计不够。

5.3 词类比运算测试

这是 word2vec 时期最有名的玩法:vector("king") - vector("man") + vector("woman") ≈ vector("queen")。

原理不复杂,因为训练好的向量空间里,"国王"和"王后"的差异方向跟"男人"和"女人"的差异方向基本一致,所以做一次向量加减法,得到的结果应该落在"queen"附近。

result_vec = vectors[king_idx] - vectors[man_idx] + vectors[woman_idx] # 然后拿 result_vec 和全词表做余弦相似度检索 # 如果一切正常,排名第一的应该是 queen

在大模型那种超大词表里,这个测试有时候会失效,因为 token 切分和上下文建模会改变很多细节。但在中小规模 embedding 上进行这个检验,仍然是判断语义质量的好方法。如果连这类简单的类比结构都没学出来,你基本可以断定语料量不够或者训练不充分。

6. 实操中容易踩的六个 Embedding 坑

最后说说我在实际项目里积累的一些教训。这些坑教科书上很少写,但几乎每个人都会踩一遍。

坑一:embedding_dim 宁小勿大。向量维度不是越大越好。维度越高,Embedding 层的参数量就越大(词表大小 × 维度),在小数据集上特别容易过拟合。通用经验:中小语料用 128~256,大规模预训练模型用 768 起步。你要是拿 20 万条数据去训一个维度 4096 的 embedding,大概率训出一堆噪声。

坑二:不固定随机种子复现不出来。由于 Embedding 矩阵是随机初始化的,哪怕你数据、代码完全一样,不设随机种子跑两次,最终向量也有差异。调参阶段务必设置random.seed、np.random.seed、torch.manual_seed,有 GPU 还要额外设置torch.cuda.manual_seed_all。我见过有人两天前跑出一个很好的结果,隔天换了台机器复现不出来,最后发现是 embedding 初始化不同造成的。

坑三:微调时冻结还是更新 Embedding,要想清楚。如果你用预训练 embedding 做下游任务、数据量又少,建议冻结 Embedding 层,省显存、防过拟合。如果任务领域跟预训练语料差异极大(比如预训练语料是通用文本,下游是一堆专业术语),可以考虑解冻并微调,让模型吸收领域信息。折中方案:用较低的学习率微调 Embedding。

坑四:计算相似度前先归一化。高维向量用欧氏距离容易受向量模长影响,而模型学习到的模长往往跟词频有关系——高频词模长偏大,低频词偏小。比较语义相似度,优先用余弦相似度,计算前先对向量做 L2 归一化。做向量检索系统时,同样建议统一归一化,否则召回质量波动很大。

坑五:OOV(词表外词)的处理。模型训练时词表是封闭的,推理时碰到完全没见过的新词,分词器会切成更小的 token 或子词,尽量绕开"完全无向量可用"的局面。如果你在维护自己的词表,遇到真正的新 token,常见做法是:随机初始化一个向量,或者取已有近义词向量的平均值做初始化。直接全零初始化要谨慎,零向量在空间里会变成所有词的"公共邻居",检索时容易出奇怪结果。

坑六:输入 embedding 和输出层权重共享的问题。不少 Transformer 架构里,输入 Embedding 矩阵和最后的输出 softmax 投影是同一个权重矩阵,这叫 weight tying。好处是省参数量、训练更稳。但如果你手动微调模型,改了输入 Embedding 的权重,输出投影会同步变化;反过来也一样。调试时别以为它俩是两个独立副本,否则会白排查很久。

写到这里,Embedding 从"编号"到"词向量"的最后一层窗户纸也应该捅破了。它的本质就三句话:前向是一次查表,反向是不断改写表格内容,表格里的每一行最终被训练成了语义空间里的一个坐标点。从我个人的实操体验来说,做模型调试时间长了你会发现,几乎所有跟"语义"沾边的问题——任务迁移、检索排序、模型泛化——追根溯源都会落到向量空间的结构上。所以这一步基础打牢,后面玩大模型会顺很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进