1. 从"分词"到"语义":为什么第五篇要死磕上下文
做自然语言处理这一路走下来,前四篇我们基本把"字词级"的活儿干完了——分词、去停用词、词性标注、关键词提取,这些都属于把一句话拆成零件。但真正让机器"读懂"一句话,光有零件远远不够。第五篇我想聊的核心,就是上下文这件事。
举个特别朴素的例子。"苹果"这个词,在"我买了两斤苹果"里是水果,在"苹果发布了新系统"里是公司。前四篇的做法是给"苹果"打一个固定的词性标签、算一个固定的词向量,可这个标签和向量根本没法同时覆盖两种含义。这就是词级处理的天然天花板:它把词当成孤立符号,丢掉了词与词之间的相互约束。
上下文建模要解决的,就是让同一个词在不同句子里拿到不同的表示。这件事在工程上有个很直观的衡量标准:把"苹果"在两种句子里的向量拿出来算余弦相似度,如果接近1,说明模型压根没区分开;如果明显低于1,说明上下文信息真的被编码进去了。我在实际项目里经常用这个小测试来快速判断一个表示方案到底有没有用,比看论文里的指标曲线直观得多。
这一篇适合谁看?如果你已经会做分词、会调词向量,但一到"这句话到底什么意思""这两个词是不是一个意思"这类任务就发怵,那这篇就是给你写的。我会从最朴素的统计方法一路讲到稠密向量,把每一步"为什么这么设计"讲透,而不是甩一堆公式让你自己悟。全程不依赖任何特定框架的高级封装,核心逻辑用几十行代码就能复现,方便你真正吃透。
需要先说明一点:下面涉及的具体参数、维度、窗口大小,都是基于常见实践给出的经验值,不是唯一正确答案。不同语料、不同任务的最优值差别很大,我给的是"能跑通、不容易翻车"的起点,你完全应该在自己的数据上再调。
2. 上下文建模的三条技术路线与选型逻辑
2.1 为什么不能一步到位上大模型
很多人一上来就想用最复杂的方案,觉得越新越好。我踩过的坑是:在一个只有几万条短文本的分类任务上,直接上了重型上下文模型,结果训练慢、显存炸、效果还不如一个调好参数的简单方案。技术选型的第一原则永远是匹配任务规模,不是匹配论文热度。
上下文建模大致可以分成三条路线,复杂度从低到高:
- 基于统计共现的路线:靠词与词一起出现的频率来刻画关系,代表是共现矩阵、PMI、LSA。优点是无需训练、可解释性强、小语料也能跑;缺点是无法处理没见过的组合,泛化差。
- 基于预测的稠密向量路线:用一个浅层网络预测上下文词,把词映射到低维稠密空间,代表是各类词向量方案。优点是泛化好、向量可运算;缺点是一个词仍然只有一个向量,没彻底解决多义。
- 基于动态表示的路线:同一个词根据所在句子生成不同向量,代表是各类上下文相关表示。优点是真正建模了上下文;缺点是吃数据、吃算力。
选型的判断标准我总结成一张表,你可以直接对照自己的情况:
| 判断维度 | 统计共现 | 稠密向量 | 动态表示 |
|---|---|---|---|
| 语料规模 | 万级即可 | 十万级以上 | 百万级以上 |
| 算力要求 | 几乎为零 | 单卡可训 | 多卡更稳 |
| 多义词处理 | 不支持 | 不支持 | 支持 |
| 可解释性 | 强 | 弱 | 很弱 |
| 上线部署难度 | 低 | 中 | 高 |
| 适合任务 | 关键词扩展、相似词 | 分类、聚类 | 语义匹配、问答 |
我的建议是:先用统计共现把数据摸清楚,再用稠密向量做基线,只有当基线明显不够用时才上动态表示。跳过前两步直接上第三步,你连问题出在哪都定位不了。
2.2 共现矩阵:最笨但最可靠的地基
共现矩阵的思路简单到粗暴:遍历语料,统计每个词周围窗口内出现了哪些词,出现一次就加一。窗口大小是关键参数,我一般从5开始试。窗口太小,抓不到远距离依赖;窗口太大,会把不相关的词也算进来,噪声陡增。
这里有个容易被忽略的细节:共现计数要做加权。离中心词越远的词,贡献应该越小。常见做法是用距离的倒数做权重,比如距离为1权重1,距离为2权重0.5,距离为3权重0.33。不加权的话,"的""了"这种高频虚词会在矩阵里占据巨大数值,把真正有信息量的实词淹没掉。
矩阵建好之后通常要做一步变换,把原始计数转成点互信息(PMI)。原因很直接:高频词天然容易共现,光看次数会误判。PMI的公式是:
PMI(w, c) = log( P(w, c) / (P(w) * P(c)) )它衡量的是"两个词一起出现的概率,比各自独立出现时高多少"。如果两个词完全独立,PMI接近0;如果强相关,PMI为正且较大。实际用的时候一般取正值部分,负值截断为0,避免负无穷干扰。
注意:PMI对低频词非常敏感。两个只出现一次的词恰好凑在一起,PMI会异常高,这是典型的假相关。解决办法是加一个平滑项,或者设置最小出现次数阈值,把出现次数太少的词直接过滤掉。
2.3 从稀疏到稠密:降维到底降掉了什么
共现矩阵有个致命问题:维度等于词表大小,动辄几万几十万维,而且极度稀疏。这时候就要做降维,把高维稀疏向量压成低维稠密向量。最经典的做法是奇异值分解(SVD)。
SVD做的事情,用大白话讲就是:找出矩阵里最重要的几个"方向",把数据投影到这些方向上。保留前k个奇异值对应的方向,就得到了k维的稠密表示。k一般取100到300,具体看语料规模。语料大可以取大一点,语料小取大了反而过拟合。
这里必须解释一个"为什么":降维为什么能保留语义?因为共现矩阵里,语义相近的词会有相似的共现模式——"猫"和"狗"周围都容易出现"喂""可爱""宠物"。SVD在压缩时,会把这种相似模式聚到同一个方向上,所以降维后的向量天然带有语义相似性。这不是巧合,是矩阵结构决定的。
降维的代价是信息损失。保留的维度越少,损失越多。我一般会画一条"保留维度 vs 下游任务效果"的曲线,找到效果开始变平的拐点,那个点就是性价比最高的k值。盲目取一个固定值,要么浪费算力,要么丢信息。
2.4 预测式词向量:把"猜上下文"变成训练目标
SVD路线是"先统计再压缩",预测式路线则是"直接学一个能预测上下文的表示"。核心思想是:如果一个词的向量能准确预测它周围会出现什么词,那这个向量就抓住了这个词的语义。
两种常见训练方式:
- 用中心词预测周围词:给定"猫",预测窗口内可能出现"喂""可爱"。这种方式对高频词更友好。
- 用周围词预测中心词:给定"喂""可爱",预测中心词是"猫"。这种方式对低频词更友好,因为每个词都能当一次预测目标。
实际训练时有个提速技巧叫负采样:不用对整个词表做归一化(那太慢了),而是随机抽几个"错误词"作为负例,让模型学会区分"真实上下文词"和"随机词"。负采样数量一般取5到20,取太少学不充分,取太多训练变慢。
提示:负采样是按词频的3/4次方采样的,不是均匀采样。这样能降低高频词被抽中的概率,让低频词也有机会当负例,训练更均衡。这个细节很多教程不讲,但直接影响效果。
2.5 动态表示:同一个词,不同句子不同向量
前面两种路线,一个词最终还是只有一个向量。动态表示彻底打破了这个限制:它把整个句子喂进去,输出每个位置上的向量,这个向量已经融合了上下文信息。
它的底层通常是这样的结构:先给每个词一个初始向量,再叠加位置信息(因为要区分词序),然后通过多层自注意力机制,让每个词去"看"句子里其他所有词,根据相关性加权融合。最后输出的每个位置向量,都是"这个词 + 它在这个句子里被谁影响"的综合结果。
自注意力的关键在"注意力权重":它决定当前词应该多关注句子里哪些词。比如"苹果发布了新系统"里,"苹果"会更多地关注"发布""系统",从而把"公司"这个含义激活。这个权重是动态算出来的,不是预设的,所以同一个词在不同句子里会得到不同表示。
代价也很明显:自注意力的计算量随句子长度平方增长。句子长度翻倍,计算量变四倍。所以长文本通常要切段处理,或者用稀疏注意力等优化手段。我在处理长文档时,一般先按语义边界切到200到300个词一段,再分别编码,最后做段落级融合。
3. 手把手搭一个上下文相似度实验
3.1 实验设计:怎么证明"上下文真的被建模了"
光讲原理容易飘,我们设计一个能跑、能看结果的小实验。目标是验证:同一个词在不同上下文里,表示是否真的不同。
实验数据用几句构造的短句,覆盖多义词的典型场景:
sentences = [ "我买了两斤苹果", "苹果发布了新系统", "这个苹果很甜", "苹果公司的股价上涨", "他咬了一口苹果", "苹果手机卖得很好", ]"苹果"在这六句里,前三句偏水果,后三句偏公司(第二句和第六句是公司,第四句也是公司)。我们要看的是:把"苹果"的上下文表示拿出来,水果组和公司组是否自动分开了。
3.2 用共现加SVD跑第一版基线
先上最朴素的方案,不依赖任何预训练模型,纯统计加降维:
import numpy as np from collections import defaultdict # 简单分词:按字切,中文短句够用 def tokenize(s): return list(s) # 构建共现矩阵 window = 2 cooc = defaultdict(float) vocab = set() for s in sentences: tokens = tokenize(s) vocab.update(tokens) for i, w in enumerate(tokens): for j in range(max(0, i-window), min(len(tokens), i+window+1)): if i == j: continue c = tokens[j] cooc[(w, c)] += 1.0 / abs(i - j) # 距离加权 vocab = sorted(vocab) idx = {w: i for i, w in enumerate(vocab)} n = len(vocab) M = np.zeros((n, n)) for (w, c), v in cooc.items(): M[idx[w], idx[c]] = v # 转PMI total = M.sum() row_sum = M.sum(axis=1, keepdims=True) col_sum = M.sum(axis=0, keepdims=True) with np.errstate(divide='ignore', invalid='ignore'): P = M / total Pw = row_sum / total Pc = col_sum / total pmi = np.log(P / (Pw * Pc)) pmi = np.nan_to_num(pmi, nan=0.0, posinf=0.0, neginf=0.0) pmi[pmi < 0] = 0 # 负值截断 # SVD降维 U, S, Vt = np.linalg.svd(pmi, full_matrices=False) k = 5 emb = U[:, :k] * S[:k]这段代码跑完,每个字都有一个5维向量。注意这里是对"字"建模,因为中文短句按字切最省事,实际项目里应该先分词再建模。
3.3 提取"苹果"的上下文表示并对比
关键一步来了:我们要的不是"苹果"这个字本身的向量,而是它在每句话里的上下文表示。做法是把"苹果"周围窗口内所有词的向量加权平均:
def context_vector(sentence, target, emb, idx, window=2): tokens = tokenize(sentence) if target not in tokens: return None pos = tokens.index(target) vecs = [] weights = [] for j in range(max(0, pos-window), min(len(tokens), pos+window+1)): if j == pos: continue w = tokens[j] if w in idx: vecs.append(emb[idx[w]]) weights.append(1.0 / abs(j - pos)) if not vecs: return None vecs = np.array(vecs) weights = np.array(weights).reshape(-1, 1) return (vecs * weights).sum(axis=0) / weights.sum() vecs = [] for s in sentences: v = context_vector(s, "苹", emb, idx) if v is not None: vecs.append((s, v)) # 两两算余弦相似度 def cos(a, b): return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)) print("句子对相似度矩阵:") for i in range(len(vecs)): for j in range(i+1, len(vecs)): sim = cos(vecs[i][1], vecs[j][1]) print(f"{vecs[i][0]} <-> {vecs[j][0]}: {sim:.3f}")跑出来的结果,水果组内部相似度应该明显高于水果组和公司组之间的相似度。如果没分开,说明窗口太小、语料太少,或者k值选得不合适。这个实验的价值不在于结果多漂亮,而在于你能亲眼看到上下文信息是怎么被编码进向量的。
3.4 结果解读:相似度数字背后的门道
拿到相似度矩阵后,别只看"高不高",要看相对关系。我一般关注三件事:
第一,组内均值 vs 组间均值。水果组三句两两相似度的平均值,应该高于水果组和公司组交叉的平均值。差距越大,说明上下文区分能力越强。如果差距只有零点零几,基本等于没区分开。
第二,异常对。如果"这个苹果很甜"和"苹果手机卖得很好"相似度异常高,说明模型被某个共同词带偏了,比如两句都有"很"或者结构相似。这时候要回头检查是不是停用词没处理干净。
第三,绝对值的意义有限。余弦相似度受向量维度、语料规模影响很大,不同实验之间不能直接比。只在同一组实验内部做相对比较才有意义。我见过有人拿两个完全不同语料上算出的相似度对比,那是没有意义的。
注意:小语料上跑出来的相似度波动很大,别急着下结论。至少要有几百句、覆盖多种上下文,结果才稳定。上面六句只是演示流程,真实评估请用更大规模的数据。
4. 那些文档里不写、但一定会踩的坑
4.1 窗口大小选错,整个实验白做
窗口是上下文建模里最容易被忽视、又最影响结果的参数。我踩过的坑是:一开始用窗口1,结果"苹果"只能看到紧邻的一个字,水果和公司的上下文几乎没区别,相似度全在0.9以上,完全分不开。后来把窗口调到3到5,区分度立刻上来了。
但窗口也不是越大越好。窗口太大,会把句子里不相关的词也拉进来,比如"我买了两斤苹果"和"苹果发布了新系统",如果窗口覆盖整句,两句的上下文词会有大量重叠(都有"了"之类),反而拉高相似度。我的经验是:短句用3到5,长句用5到8,超过10基本是噪声。
还有一个细节:窗口应该左右对称还是偏向一侧?对于大多数任务,对称窗口更合理,因为语义依赖通常不分左右。但如果是预测下一个词这类任务,就应该只用左侧窗口,避免"偷看"未来信息。这个区别在训练语言模型时特别重要,用错了会导致评估指标虚高。
4.2 高频虚词不处理,向量全被带偏
"的""了""是""在"这些词出现频率极高,在共现矩阵里数值巨大。如果不处理,SVD降维后的主方向会被这些词主导,所有实词的向量都被拉向同一个方向,相似度普遍偏高,区分度下降。
处理办法有三种,我一般组合使用:
- 停用词过滤:建矩阵前直接去掉高频虚词。简单有效,但会丢失一些语法信息。
- 子采样:按词频的倒数概率随机丢弃高频词。这样既降低了高频词影响,又保留了少量信息。
- TF-IDF加权:给每个词乘一个逆文档频率权重,高频词权重自然降低。
我通常先做停用词过滤,再对剩下的词做子采样。两步下来,实词的区分度会明显改善。这里没有银弹,具体阈值要在自己数据上试。
4.3 低频词的向量基本是噪声
出现次数少于5次的词,学出来的向量基本不可信。原因很简单:样本太少,统计不稳定,SVD或预测模型都没法从几次出现里学到可靠模式。我见过有人拿只出现一两次的词做相似度分析,结论完全不可靠。
应对策略:
- 设置最小词频阈值,低于阈值的词统一映射到一个特殊标记,或者直接用字符级表示兜底。
- 对低频词做平滑,比如把它的向量设为它所在上下文中高频词向量的平均。
- 如果任务允许,直接忽略低频词,别让它们污染整体评估。
提示:中文里低频词特别多,因为构词灵活。实际项目中,词表覆盖率能到90%以上就不错了,剩下10%的长尾词要有兜底方案,不能假设它们不存在。
4.4 评估指标选错,优化方向全反
上下文表示好不好,不能只看训练损失。训练损失降了,下游任务效果不一定涨。我一般用三类指标交叉验证:
| 指标类型 | 具体做法 | 关注点 |
|---|---|---|
| 内在评估 | 相似度任务、类比任务 | 表示本身的质量 |
| 外在评估 | 分类、聚类、匹配的下游效果 | 实际任务收益 |
| 稳定性评估 | 不同随机种子下结果方差 | 方案是否可靠 |
内在评估快,但和实际任务相关性有限;外在评估准,但每次都要重训下游模型,慢。我的做法是:调参阶段用内在评估快速筛,定稿阶段用外在评估确认。只看一个指标,很容易过拟合到那个指标上。
还有一个坑:类比任务在中文上要谨慎用。类比任务原本是为英文设计的,中文的构词和语法差异大,直接套用可能得出误导性结论。我一般只把它当参考,不作为主要依据。
5. 从实验到落地:上下文表示怎么接进真实任务
5.1 文本分类:把句子向量喂给分类器
上下文表示最直接的落地场景是文本分类。做法是把句子里每个词的上下文向量做池化(平均池化或最大池化),得到一个句子级向量,再喂给分类器。
池化方式的选择有讲究:
- 平均池化:对所有词向量取平均。优点是稳定,缺点是会被无关词稀释。
- 最大池化:每个维度取最大值。优点是突出显著特征,缺点是对噪声敏感。
- 加权池化:给每个词一个权重(比如用TF-IDF或注意力算),再加权平均。效果通常最好,但多一层计算。
我在短文本分类上一般用平均池化起步,效果不够再换加权池化。长文本分类则倾向最大池化,因为长文本里关键信息往往集中在少数几句。
分类器本身不用太复杂,逻辑回归或浅层全连接网络通常就够。上下文表示已经把大部分语义工作做完了,分类器只是画条决策边界。上太复杂的分类器,容易过拟合,还增加部署负担。
5.2 语义匹配:判断两句话是不是一个意思
语义匹配是上下文表示的主场。做法是把两句话分别编码成句子向量,然后算相似度,或者把两个向量拼接后喂给一个二分类器判断"匹配/不匹配"。
这里有个关键设计选择:用交互式还是双塔式?
- 双塔式:两句话各自独立编码,最后算相似度。优点是快,句子向量可以预先算好存起来,线上只做向量检索。缺点是两句话之间没有交互,细粒度差异抓不住。
- 交互式:两句话的表示在编码过程中就相互影响(比如交叉注意力)。优点是准,能抓住细微差异。缺点是慢,每对句子都要重新算,没法预存。
我的经验是:召回阶段用双塔,精排阶段用交互式。先用双塔从海量候选里快速筛出几百条,再用交互式精排。这样兼顾速度和精度。直接上交互式做全量匹配,线上根本扛不住。
5.3 部署时的性能取舍
上下文表示上线,绕不开性能和精度的权衡。几个实操要点:
第一,向量维度别贪大。128维和768维在多数任务上效果差距没有想象中大,但存储和计算差好几倍。我一般从128或256起步,效果不够再加。
第二,句子向量预计算。对于双塔式方案,所有候选句子的向量可以离线算好,存进向量库。线上只算查询句的向量,然后做近似最近邻检索。这样响应时间能压到毫秒级。
第三,批处理。线上请求往往是一个一个来的,但模型推理批处理效率高得多。可以设一个很小的等待窗口(比如10毫秒),把这段时间内的请求攒成一批一起算。这个技巧能显著提升吞吐。
第四,降级方案。上下文模型挂了或者超时了,要有兜底。我一般准备一个基于关键词匹配的简单方案,虽然精度低,但保证服务不中断。
注意:向量库的索引类型要选对。数据量小用暴力检索就行,数据量大要用近似索引。近似索引会损失一点召回率,但速度提升巨大。具体选哪种,要看你的数据规模和延迟要求。
6. 我在这条路上交过的学费
回过头看,上下文建模这块我最大的教训是过早追求复杂方案。早期做一个短文本分类,数据只有几千条,我直接上了动态表示,训练了整整两天,效果还不如同事用共现加SVD半小时跑出来的基线。后来才明白,数据量不够的时候,复杂模型的参数根本学不出来,反而容易过拟合。
第二个教训是忽视数据清洗。上下文建模对噪声特别敏感,因为噪声词会进入共现统计,污染整个矩阵。我有个项目,原始数据里混了大量重复的模板文本,导致某些词共现次数虚高,学出来的向量严重偏斜。后来加了去重和模板过滤,效果立刻正常了。数据质量永远比模型复杂度重要,这句话在上下文建模上体现得淋漓尽致。
第三个体会是评估要趁早、要多样。我习惯在方案定稿前,至少用三种不同方式验证:相似度任务、下游分类、人工抽查。有一次相似度指标很漂亮,但人工一看,模型把"高兴"和"悲伤"判成了近义词,因为它们在语料里经常出现在相似的句式结构中。这种问题只看数字发现不了,必须人工看。
最后分享一个实用习惯:把每次实验的配置和结果记下来。窗口大小、维度、词频阈值、评估指标,全部记在一个表格里。上下文建模的参数空间很大,不记录的话,跑了几十组实验后根本记不清哪组是什么配置。我现在的习惯是每跑一组就记一行,包括失败的配置——失败的记录往往比成功的更有价值,能帮你避开重复踩坑。
这个方向后续还能往细粒度语义、跨语言表示、领域自适应等方向延伸,但那些都是后话。把这一篇里的共现、降维、动态表示三条路线吃透,把实验跑通,把坑踩一遍,你对"机器怎么理解上下文"这件事就有了扎实的手感。剩下的,就是在具体任务里慢慢磨了。