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), iwindow1)): 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(axis1, keepdimsTrue) col_sum M.sum(axis0, keepdimsTrue) with np.errstate(divideignore, invalidignore): P M / total Pw row_sum / total Pc col_sum / total pmi np.log(P / (Pw * Pc)) pmi np.nan_to_num(pmi, nan0.0, posinf0.0, neginf0.0) pmi[pmi 0] 0 # 负值截断 # SVD降维 U, S, Vt np.linalg.svd(pmi, full_matricesFalse) k 5 emb U[:, :k] * S[:k]这段代码跑完每个字都有一个5维向量。注意这里是对字建模因为中文短句按字切最省事实际项目里应该先分词再建模。3.3 提取苹果的上下文表示并对比关键一步来了我们要的不是苹果这个字本身的向量而是它在每句话里的上下文表示。做法是把苹果周围窗口内所有词的向量加权平均def context_vector(sentence, target, emb, idx, window2): 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), poswindow1)): 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(axis0) / 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(i1, 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半小时跑出来的基线。后来才明白数据量不够的时候复杂模型的参数根本学不出来反而容易过拟合。第二个教训是忽视数据清洗。上下文建模对噪声特别敏感因为噪声词会进入共现统计污染整个矩阵。我有个项目原始数据里混了大量重复的模板文本导致某些词共现次数虚高学出来的向量严重偏斜。后来加了去重和模板过滤效果立刻正常了。数据质量永远比模型复杂度重要这句话在上下文建模上体现得淋漓尽致。第三个体会是评估要趁早、要多样。我习惯在方案定稿前至少用三种不同方式验证相似度任务、下游分类、人工抽查。有一次相似度指标很漂亮但人工一看模型把高兴和悲伤判成了近义词因为它们在语料里经常出现在相似的句式结构中。这种问题只看数字发现不了必须人工看。最后分享一个实用习惯把每次实验的配置和结果记下来。窗口大小、维度、词频阈值、评估指标全部记在一个表格里。上下文建模的参数空间很大不记录的话跑了几十组实验后根本记不清哪组是什么配置。我现在的习惯是每跑一组就记一行包括失败的配置——失败的记录往往比成功的更有价值能帮你避开重复踩坑。这个方向后续还能往细粒度语义、跨语言表示、领域自适应等方向延伸但那些都是后话。把这一篇里的共现、降维、动态表示三条路线吃透把实验跑通把坑踩一遍你对机器怎么理解上下文这件事就有了扎实的手感。剩下的就是在具体任务里慢慢磨了。