做中文词向量这件事,很多人第一次跑 word2vec 的经历都差不多:装好包,把语料读进来,model.train()一跑,看着日志里 loss 往下掉,觉得成了。然后打开most_similar('电脑'),发现最相近的是"的""了""是",或者干脆冒出来一堆标点符号。问题多半不在 word2vec 这个包本身,它就是个老老实实做共现统计的工具,喂什么吃什么。真正的功夫全在把中文文本变成它能吃的那一行行 token 的过程里。这篇东西讲的就是这条完整链路:从一份乱糟糟的中文语料,到一批能拿去做检索、做特征、做聚类的词向量,中间每一步为什么要这么干、参数为什么这么调、跑完之后怎么判断它到底行不行。适合已经能写 Python、想在自己业务语料上跑出可用词向量的人,也适合那些跑过一次但结果不对劲、想搞清楚哪里出了问题的读者。
1. 中文语料喂给 word2vec 之前,先搞清楚它到底在吃什么
1.1 word2vec 的输入从来不是"文本",而是 token 序列
打开任何一个 word2vec 实现,不管是 C 写的原版还是 Python 里那个常用库,它的输入接口本质上都是一个可迭代对象,每次迭代吐出一个"句子",而这个句子是一个字符串列表。英文天然自带空格,"I love NLP".split()就完事了;中文是一整串连续的汉字,没有任何显式边界,包本身不会帮你切。你如果直接把整段中文丢进去,它拿到的就是一个长度为 1 的"句子",窗口里前后根本没有别的词,最后训出来的东西毫无意义。
所以中文词向量的第一步永远是分词,这一步的质量直接决定了后面所有事情的上限。分词错了,词表里就多了一个不该存在的词,少了一个真正有语义的词,共现统计全是噪声。这一点没有任何参数能补救,vector_size 调到 300、epochs 调到 30 都没用,垃圾进垃圾出。
我一般会在正式训练前做一件很土但很有效的事:随机抽 200 行语料,人工切一遍,再和分词器的结果对比,看看切错的都是哪类词。九成情况下问题集中在领域专有名词、新造词、中英混排、数字和单位上,找到规律之后补进自定义词典,一次性就能把错误率降下来一大截。
1.2 分词粒度直接决定你最后的向量长什么样
粒度这件事没有绝对的对错,只有和你的目标匹不匹配。举个具体的例子,假设你要做的是电商评论里的情感分析,目标词是"退款"。如果分词器把"申请退款"切成"申请/退款",那"退款"这个词能拿到足够的上下文;但如果它把"无理由退货"切成"无/理由/退货","无理由"这个在很多场景下有独立含义的短语就彻底消失了,它贡献的统计信号被拆散到了三个高频低信息量的碎片上。
粒度粗的好处是词表小、每个词分到的训练样本多、向量更稳定;坏处是词的覆盖面窄,遇到没见过的组合就抓瞎。粒度细正好反过来,词表爆炸、长尾词一大堆、每个词的样本稀薄,训出来的向量抖动很大。实践中我会按目标词表反过来定粒度:先把业务上真正关心的那几百个词列出来,看看分词器能不能把它们切出来,切不出来就加词典,而不是一上来就追求"切得越细越好"。
还有一个容易被忽略的点是单字词。中文里"猫""狗""水""火"这些单字本身就是完整的语义单位,有些分词器在上下文里会把它们和相邻字合并,导致这些基础词丢失。如果你的下游任务对这类基础概念敏感,训练前跑一遍词表检查,确认这些词在不在,比训完再后悔省事得多。
1.3 语料规模和 min_count 的账要先算清楚
word2vec 靠的是共现统计,统计学意义上,一个词的向量要靠谱,它得在语料里出现足够多次。行业里比较公认的经验是:想让 100 到 200 维的向量有基本可用的语义结构,整个语料的 token 总量至少在千万级别,且目标词的出现次数最好不低于几十次。
算笔粗账。假设你手上有十万条短文本,每条平均切出 30 个 token,总量就是 300 万 token。按中文的齐夫分布,排名第一的高频词可能占 3% 到 5%,而排在几万名开外的词可能只出现一两次。这时候如果你把 min_count 设成 5,词表大概能剩下几万个,但这几万个词里有一大半出现次数在 5 到 20 之间,训出来的向量基本是噪声,most_similar返回的结果会让你怀疑人生。
我的做法是:语料 token 量在 500 万以下时,min_count 设 2 到 3,接受词表大、部分词质量差的事实;到了千万级再往上抬到 5 到 10;上亿 token 的语料才敢用 min_count=20 甚至更高去压词表。同时配合 vector_size 一起看,语料小的时候维度别贪高,100 维往往比 300 维更稳,因为高维空间在样本稀疏时更容易过拟合到噪声上。
2. 清洗与分词流水线:把一篇篇文本压成一行行 token
2.1 编码、全半角、数字与符号的归一化
原始语料里最常见的问题不是语义问题,是格式问题。同一份数据里可能混着 UTF-8、GBK、带 BOM 的 UTF-8,读进来第一行就带个看不见的\ufeff;全角标点和半角标点混用,逗号能有三四种写法;数字有的写成1,000,有的写成1000,有的写成一千。这些如果不统一,分词器会把它们当成完全不同的东西,白占词表位置。
归一化的原则是:能合并的合并,不能合并的占位。数字我一般统一替换成一个特殊占位符<NUM>,理由是具体数值对词向量的语义贡献很小,但"这里有个数字"这个位置信息是有的;英文统一转小写,避免Apple和apple分裂成两个词;链接、邮箱这类明显是噪声的直接删掉;控制字符、零宽字符全部清掉。这套规则听起来琐碎,但它能把词表规模压下来 10% 到 30%,同时让剩下的词拿到更多样本。
有一点要注意,占位符不要用会被分词器切开的字符。<NUM>里的尖括号在有些分词器里会被单独切出来,最后变成<、NUM、>三个 token,反而更乱。可以先用一个罕见的单字符占位,或者确认分词器对尖括号的处理方式之后再决定。
2.2 按句切分,别让窗口跨过句号
这一条被很多人忽略,但影响很实在。word2vec 的窗口是在一个"句子"内部滑动的,如果某个词在窗口边缘,窗口只覆盖同一句内的词。如果你的"句子"实际上是整篇几千字的文章,那窗口就会跨过段落、跨过标题,把毫不相干的内容算成共现。
标准的处理是把长文本按句号、问号、感叹号、分号切成一句一行,每行再分词。这样窗口永远在句子边界内停止,共现信号更干净。句子的长度也要留意,太短的句子(比如只有两三个词)窗口覆盖率低,可以设个下限过滤掉;太长的句子可以按逗号再切一刀,但别切太碎,否则上下文就丢了。
顺带说一句,输出文件我习惯用"一行一句、空格分隔 token"的格式,这种格式通用性最好,很多训练工具直接就能读,不用写解析代码,也方便你用head看一眼确认处理对不对。
2.3 自定义词典与停用词的正确用法
自定义词典这件事,越早做越省事。领域语料里的专有名词、产品型号、行业术语、人名地名(虚构的、业务内的那种),都应该在分词前加进去。词典的维护是个长期活,我一般的做法是维护一个纯文本文件,一行一个词,配上词频(可选),随着业务迭代不断往里加,并且把这个文件纳入版本管理,谁加了什么词、为什么加,都有记录。
停用词这块我想说一个和常见教程不太一样的观点:训练词向量的时候,不要提前删停用词。"的""了""在"这些词确实没语义,但它们恰恰是句法结构的重要信号,保留它们能帮模型学到"动词后面常跟什么""形容词常修饰什么"。真正需要过滤停用词的地方是下游任务——比如你在做关键词提取、做句向量平均的时候,把"的""了"这些词的向量平均进来会稀释语义,那时候再过滤。
当然有个例外:如果语料规模很小,停用词占的 token 比例又特别高(比如短文本、聊天记录),保留它们会让低频实词分不到训练机会,这时候可以适当删一部分超高频虚词。判断标准是看词表里前 50 个高频词占了总 token 的多少比例,超过 40% 就该考虑处理一下了。
2.4 一个能直接跑的预处理脚本
下面这段代码是我反复用过的骨架,改改路径和词典就能跑。注意LineSentence这类读取器对格式要求很严,必须是空格分隔、一行一句,所以输出环节一定要保证格式干净。
import re import jieba import logging jieba.setLogLevel(logging.WARNING) jieba.load_userdict("user_dict.txt") CLEAN_RE_CTRL = re.compile(r"[\x00-\x1f\x7f\u200b-\u200f\ufeff]") CLEAN_RE_URL = re.compile(r"https?://\S+|www\.\S+") CLEAN_RE_NUM = re.compile(r"\d+(?:[.,]\d+)*") SPLIT_RE = re.compile(r"(?<=[。!?!?;;])") def normalize(line: str) -> str: line = line.replace("\u3000", " ") line = CLEAN_RE_CTRL.sub("", line) line = CLEAN_RE_URL.sub(" ", line) line = CLEAN_RE_NUM.sub(" <NUM> ", line) line = re.sub(r"[A-Za-z]+", lambda m: m.group(0).lower(), line) line = re.sub(r"\s+", " ", line) return line.strip() def tokenize_doc(text: str): text = normalize(text) if not text: return [] tokens = [] for seg in SPLIT_RE.split(text): seg = seg.strip() if len(seg) < 2: continue tokens.extend(w for w in jieba.lcut(seg) if w.strip()) return tokens def build_corpus(src_path: str, dst_path: str, min_len: int = 5): kept, dropped = 0, 0 with open(src_path, encoding="utf-8") as fin, \ open(dst_path, "w", encoding="utf-8") as fout: for line in fin: toks = tokenize_doc(line) if len(toks) < min_len: dropped += 1 continue fout.write(" ".join(toks) + "\n") kept += 1 print(f"kept={kept}, dropped={dropped}") if __name__ == "__main__": build_corpus("raw.txt", "seg.txt")跑完之后一定要抽查。我一般会用head -n 20 seg.txt看前二十行,再随机抽几十行看中段,确认没有大片空白、没有整行变成占位符、没有奇怪的符号混进去。这个动作三十秒,能省掉后面几个小时的排查。
3. 参数表不是抄来的:vector_size、window、min_count 的取舍逻辑
3.1 核心参数的实际影响
网上流传的参数表大多是从英文语料抄来的,直接套在中文上经常水土不服。下面这张表是我自己反复调下来觉得比较靠谱的区间,以及每个参数拧错方向会出什么事。
| 参数 | 常用区间 | 拧大了会怎样 | 拧小了会怎样 |
|---|---|---|---|
| vector_size | 100 到 300 | 小语料上过拟合噪声,近义词乱飞 | 语义容量不够,近义和反义挤在一起 |
| window | 3 到 8 | 把不相干的远距离词算成共现,主题漂移 | 只学到局部搭配,抓不住主题相关 |
| min_count | 2 到 20 | 词表被砍得太狠,目标词消失 | 长尾噪声词大量涌入,拉低整体质量 |
| negative | 5 到 15 | 训练变慢,边际收益递减 | 负样本不够,区分度上不去 |
| sample | 1e-3 到 1e-5 | 高频词被砍太狠,句法信息丢失 | 高频词主导训练,实词学不到 |
| epochs | 5 到 15 | 后期学习率极低,收益很小,浪费时间 | 收敛不足,向量还没稳定就停了 |
| sg | 0 或 1 | 小语料上 skip-gram 更容易过拟合 | CBOW 训得快,但低频词质量差一些 |
window 这个参数在中文里值得单独说一句。英文的语序相对固定,窗口开大开小影响没那么剧烈;中文的修饰关系、动宾关系跨度有时候挺大,特别是长句里"的"字结构会把核心词隔开。我实测下来,通用语料 window=5 是个稳妥的起点,做搭配类任务(比如找"动词+宾语")收到 3 效果更好,做主题相关(比如找同类商品)开到 8 到 10 更合适。
3.2 CBOW 与 skip-gram:小语料该选哪个
CBOW 是用上下文预测中心词,skip-gram 反过来用中心词预测上下文。直观上的区别是:CBOW 把上下文的向量平均起来,相当于做了平滑,训练快、高频词学得好,但低频词因为平均被稀释,质量一般;skip-gram 对每个上下文词单独预测,低频词也能拿到多次更新,质量更好,代价是训练慢好几倍。
中文语料有个特点,就是长尾特别长。同样是一千万 token,中文的词表规模往往比英文大不少,意味着更多词处在低频区间。所以在中文场景下,只要算力允许,我基本默认 sg=1。十万条短文本这个量级,skip-gram 在普通机器上跑十轮也就几十分钟,完全等得起。
如果语料真的很大(上亿 token),而且下游只是要个粗粒度的相似度检索,那 CBOW 也够用,训练时间能省一大截。这个取舍没有标准答案,看你更在意词表覆盖率还是训练成本。
3.3 负采样与下采样:算一遍就知道为什么要调
负采样(negative sampling)是把原本的多分类问题简化成二分类,每个正样本配几个负样本。negative=5 是默认值,意思是每个正样本配五个随机负样本。这个值太小(比如 2)会导致模型学不到足够的区分度,所有词向量挤在一起;太大(比如 20)训练时间线性增长,但效果提升很有限。5 到 15 之间一般就够了。
下采样(sample)是个更容易被忽略的参数,它按词频把高频词随机丢掉一部分。公式是这样的:保留概率 P = (√(f/t) + 1) × (t/f),其中 f 是词的频率,t 就是 sample 参数。拿"的"举个例子,假设它占全部 token 的 2%,也就是 f=0.02,sample 设 1e-4,那么 √(0.02/0.0001)=√200≈14.14,加 1 是 15.14,再乘以 0.0001/0.02=0.005,得到保留概率约 7.6%,也就是九成以上的"的"会被丢掉。
这个机制的价值在于:高频词本来就有海量样本,少一点不影响学习,但把它们丢掉能大幅提升训练速度,同时让低频实词有更多机会占据窗口。sample 调得太小(比如 1e-6),高频词几乎全被砍掉,句法结构信息就丢了,向量会变得很奇怪;调得太大(比如 1e-2),等于没开,训练慢而且实词学不好。1e-4 到 1e-5 是中文语料比较舒服的区间。
3.4 迭代轮数和学习率
epochs 这个参数,很多人以为越大越好,其实不是。word2vec 的学习率是从 alpha 线性降到 min_alpha 的,跑完一轮学习率就往下走一截。跑到第五轮之后,学习率已经很小了,向量基本不再有实质变化,继续跑纯属浪费电。
我的经验是:小语料(百万 token 级)跑 10 到 15 轮,中大型语料跑 5 到 8 轮就够了。判断收敛的土办法是:每跑完一轮,抽几个探针词看看most_similar的结果,如果连续两轮结果基本稳定,就可以停了。
alpha 默认 0.025,一般不用动。但如果做增量训练,一定要注意:train()会从当前的 alpha 重新开始降,这会把已经训好的旧词向量搅乱。增量训练时手动把 alpha 设成 0.001 到 0.005 这个量级,让新语料只做微调,不去破坏原有结构。
4. 训练之后的保存、加载与增量更新的正确姿势
4.1 三种保存格式的区别
训完之后第一件事是存,但存什么、怎么存,很多人没搞清楚。Python 里那个常用实现其实有两层对象:模型对象(Word2Vec)和词向量对象(KeyedVectors)。前者包含训练状态、词频、参数配置,能继续训练;后者只有词和向量,体积小、加载快。
| 保存方式 | 存了什么 | 体积 | 加载后能做什么 |
|---|---|---|---|
model.save(path) | 完整模型,含训练状态 | 大 | 继续训练、查询、增量更新 |
model.wv.save(path) | 只有词和向量 | 小 | 查询、相似度计算,不能继续训练 |
save_word2vec_format | 标准文本或二进制格式 | 中 | 跨工具、跨语言读取 |
线上服务里我一般只部署KeyedVectors,加载快、内存占用小,而且没有训练状态意味着不会被人误操作继续训练。完整的模型文件留在训练机上归档,需要重建的时候再用。
加载的时候有个老坑:早期版本保存的模型在新版本 Python 里加载会因为编码问题报错。如果拿到的是别人几年前存的模型,加载时显式指定编码,或者干脆用文本格式重新导一遍,能避开这类问题。
4.2 内存估算与分块训练
训练阶段的内存占用经常被低估。粗略估算的公式是:词表大小 V × 维度 D × 4 字节,再乘以一个 2 到 3 的系数(训练时要额外维护梯度、负采样表、词频统计等)。举个例子,V=50 万、D=200,光向量本身就有 50 万 × 200 × 4 = 400MB,加上训练开销,实际峰值可能到 1.2GB 到 1.5GB。
如果你的语料特别大,词表可能到几百万,这时候要么降维度,要么用max_vocab_size限制词表规模(超出部分的高频词保留、低频词丢弃),要么换更大内存的机器。我一般会先跑一遍纯统计,只做build_vocab不训练,看看词表到底多大,再决定参数,而不是一股脑开训然后半路 OOM。
分块训练的核心是让语料以生成器的形式流式读取,而不是一次性全部读进内存。LineSentence就是干这个的,它每次只读一行,内存占用恒定。如果你自己写读取逻辑,一定要用yield,别用readlines()。
4.3 增量训练能做什么、不能做什么
新语料来了,想不重训就更新词向量,build_vocab(update=True)加train()看起来能实现,但有几个坑必须知道。
第一,新词和老词不在同一个充分训练的空间里。新词只在新语料里学了几轮,而老词是在全量语料上学了很多轮的,两者的向量尺度可能对不上,直接算相似度会偏。第二,train()会顺带更新老词的向量,如果新语料领域和原语料差别大,老词的语义会被拽偏。第三,词表只会增不会减,如果某个词在新语料里含义变了,增量训练可能学不过来。
我的实际用法是:增量训练只用于"打补丁",比如加了一批新出现的业务词,用很小的学习率、很少的轮数跑一下,让这些新词有个初始向量可用。真正的大版本更新,还是老老实实全量重训,把新旧语料合在一起跑,结果更可控。
5. 向量质量怎么判断:相似度、离群词与各向异性
5.1 先用几个探针词做人工体检
训练完不要急着上业务,先做个人工体检。挑五到十个你业务里最关心的词,跑most_similar看返回的前十个词在不在语义上说得通。这个过程很像医生拿听诊器听几下,虽然粗略,但异常情况一眼就能看出来。
体检的检查项我一般看这么几个:近义词有没有被找出来("退款"和"退货"应该有较高相似度);同类词有没有聚在一起(几个商品品类词应该互相靠近);反义词是不是相似度也很高(这是词向量的通病,"好"和"坏"经常很像,因为它们的上下文几乎一样,这时候要接受这个事实,别指望 word2vec 能区分极性);有没有出现明显的脏词(比如一堆标点或者占位符排在前列,说明预处理漏了东西)。
doesnt_match这个接口也值得试试,给一组同类词加一个异类,看它能不能把异类挑出来。这个测试对语料质量特别敏感,屡试不爽。
5.2 余弦相似度普遍偏高不是你的错觉
很多人第一次看相似度结果会困惑:怎么随便两个词都有 0.5 以上的相似度?明明八竿子打不着。这不是模型坏了,是词向量空间本身存在各向异性——所有词向量都共享一个共同的偏移方向,导致任意两个向量的夹角都偏小,余弦值整体被抬高。
解决办法有两个。简单的是中心化:把所有词向量减去全体均值,再重新归一化,这样共同偏移被消掉,相似度的区分度会明显提升。更彻底的是主成分去除,把前一两百个主成分(它们主要承载词频信息而非语义信息)减掉。
import numpy as np vecs = model.wv.get_normed_vectors() # L2 归一化后的矩阵 mean = vecs.mean(axis=0, keepdims=True) centered = vecs - mean centered /= np.linalg.norm(centered, axis=1, keepdims=True) idx = model.wv.key_to_index["退款"] sims = centered @ centered[idx] order = np.argsort(-sims)[:10] for i in order: print(model.wv.index_to_key[i], round(float(sims[i]), 4))处理完之后,你会看到本该相似的词相似度更高,无关的词相似度明显掉下去,整个分布被拉开了。这一步在做阈值判断(比如相似度大于 0.8 才算同一类)的场景里几乎是必须的,否则阈值根本没法定。
5.3 降维图能看出什么、看不出什么
把几百个词向量降到二维画个散点图,看起来很直观,但坑不少。降维本身会扭曲距离,二维图里看着挨在一起的两个词,在高维空间里可能差得挺远;反过来也一样。所以图只能用来做定性观察,别拿它做结论。
我的用法是:挑三到五个语义簇(比如几个品类、几个人物、几组动作),每个簇选十个词,降到二维看它们是不是大致分堆。分堆了就说明向量空间有结构,可以继续往下做;如果糊成一团,说明语料或者参数有问题,得回头查。
降维方法上,PCA 快、稳定、可解释,适合快速看一眼;t-SNE 分离效果好,但参数敏感,perplexity 设得太小会把簇打散,设得太大又会糊在一起,一般取样本数的平方根附近的值为起点。不管用哪个,记得先做中心化,直接拿原始向量去降维,出来的图往往是一大坨。
5.4 做一个最小可用的定量评估
定性看完了,最好再来点定量的。最简单可行的办法是人工构造一组同义词对,比如二三十对,标注它们"应该相似",然后算模型给出的相似度和人工判断的秩相关。这个数字不需要多高,它的价值在于可比较——你调完参数重训一次,看看这个指标是升了还是降了,比凭感觉靠谱得多。
构造这组词对的时候要注意覆盖度:既要有高频词也要有中低频词,既要有领域词也要有通用词。如果只挑高频词,评估结果会虚高,因为高频词怎么训都不会太差。
更进一步的做法是拿一个下游小任务来测,比如用词向量做文本分类的特征,看准确率变化。这个链路长一点,但最能反映真实效果。我一般的流程是先跑定性体检,通过了再跑定量评估,两关都过了才往业务上接。
6. 词向量往下游走:句向量、未登录词与封装
6.1 从词向量拼句向量的三种做法
词向量最直接的用途之一就是拼成句向量,拿去做分类、聚类、检索。最简单的做法是平均:把句子里所有词的向量加起来除以词数。这个方法快、不需要训练,缺点是所有词等权,虚词会稀释语义,句子一长效果就崩。
好一点的是 TF-IDF 加权平均,让信息量大的词权重高、虚词权重低。权重可以从训练语料里统计出来,不用额外标注。再讲究一点的是 SIF 加权(词频的倒数加权)再减去第一主成分,这个方法在无监督句向量的对比里表现一直不错,代码也就十几行。
import jieba import numpy as np def sent_vector(text, kv, dim=200, drop_first_pc=True): toks = [w for w in jieba.lcut(text) if w in kv.key_to_index] if not toks: return np.zeros(dim, dtype=np.float32) vs = np.array([kv[w] for w in toks], dtype=np.float32) v = vs.mean(axis=0) v = v - kv_mean # kv_mean 是全局均值,提前算好 if drop_first_pc: v = v - (v @ pc1) * pc1 # pc1 是第一主成分单位向量 return v / (np.linalg.norm(v) + 1e-9)三种方法我都用过,实测下来在短文本(一二十个字)上平均法就够用,长文本上 TF-IDF 加权明显更好,SIF 在数据量小的时候优势不明显但也不会更差。选择的时候别过度纠结,先跑起来看效果,比空想强。
6.2 未登录词怎么兜底
word2vec 有个硬伤:它只认词表里有的词,遇到没见过的词直接查不到,没有子词机制来做兜底。业务上这个问题很现实——新词、错别字、专有名词,随时会冒出来。
兜底方案有几层。第一层是切词粒度兜底:如果一个词查不到,尝试把它拆成单字,用单字向量的平均来近似;中文单字虽然多义,但作为兜底已经够用。第二层是字符级模型兜底:单独在字符级别训一套向量,词级别查不到的时候用字符向量拼。第三层是换模型:如果 OOV 问题是核心痛点,那就别死磕 word2vec 了,换带子词机制的方案(比如 fastText 那一类),它天然能处理没见过的词。
实际工程里我一般是前两层组合使用,简单、可控、不需要换技术栈。真到了 OOV 比例超过 10% 的程度,说明你的语料和业务已经脱节了,该考虑重训而不是兜底。
6.3 训练脚本的工程化:目录、日志、可复现
一个人跑实验和团队协作是两回事。我现在的习惯是把整个流程拆成固定几个目录:raw/放原始语料,seg/放分词结果,models/放训练产物,eval/放评估脚本和结果。每次训练都把参数写进一个 json 文件一起归档,包括语料版本、分词词典版本、所有超参、随机种子。
随机种子这个事必须强调。word2vec 训练里有多进程和随机初始化,不设种子的话,同样的数据和参数跑两次结果会不一样,出了问题根本没法复现。设了种子之后至少能保证单机单次可复现,排查问题的时候这一点是救命的。
日志也要打足。语料行数、token 总数、词表大小、训练耗时、每轮的耗时,这些数字在事后复盘时特别有用。我吃过亏——某次训练跑了三个小时结果很差,回头想查是不是语料读少了,结果发现当时没打日志,只能重跑一遍。从那以后这几行日志成了标配。
7. 几个真实排查链路:内存、速度、相似度异常与词表缺失
7.1 训练到一半内存爆掉
现象是进程被系统杀掉,日志停在某一轮中间,没有任何报错。排查顺序是这样的:先确认是训练阶段挂的还是加载阶段挂的,在build_vocab前后各打一行内存占用的日志,很快就能定位。
如果挂在build_vocab,说明词表太大,原因是 min_count 设得太小或者语料里噪声词太多(比如把标点、数字占位符都算进去了)。这时候要么抬高 min_count,要么设max_vocab_size做硬限制。如果挂在训练阶段,多半是多进程的每个 worker 都复制了一份共享结构,workers 设成 CPU 核数的两倍反而更吃内存。把 workers 降到核数的一半,通常就稳了。
还有一个隐蔽的原因是语料生成器在训练过程中还在做分词。分词的中间对象会一直堆在内存里不释放,跑久了就爆。正确做法是先落盘成 token 文件,训练时只做读取,把计算和 IO 彻底分开。
7.2 语料百万行,训练跑了六个小时
速度慢的排查我有个固定套路。第一步,测一下纯读取的速度:写个脚本只遍历语料什么都不做,看跑完要多久。如果这一步就很慢,说明瓶颈在 IO 或者分词,跟训练参数无关。
最常见的原因是训练时实时分词。分词本身就比训练慢,几百万行文本边切边训,时间全耗在分词器上了。解决办法就是前面说的,先切好落盘。这一改动通常能把时间从几小时压到几十分钟。
第二个原因是 workers 设得不合适。设成 1 就是单进程,白白浪费多核;设得太大(比如 32)在核数不够的机器上会频繁上下文切换,反而更慢。一般设成物理核数就比较合适。第三个原因是 sample 没开或者设得太大,导致高频词参与了大量无意义的训练,把 sample 调到 1e-4 能提速不少,效果还更好。
7.3 所有词都"很像"
这个现象前面提过,成因有好几种,得按顺序排。先看语料量,如果只有几十万 token,那就是数据不够,向量还没形成结构,这种情况下无论怎么调参都没用,只能加数据。再看 epochs,如果只跑了一两轮,模型还没收敛,向量接近随机初始化,相似度当然都差不多。
排除了这两个,再看是不是没做中心化。原始向量的余弦值普遍偏高是常态,做一次中心化之后区分度立刻出来。最后看 vector_size,如果语料只有百万级 token 却用了 300 维,高维空间里样本稀疏,所有向量会挤在原点附近,相似度也会趋同。降到 100 维试试,经常有奇效。
7.4 目标词根本不在词表里
KeyError是最让人沮丧的报错,因为明明语料里有这个词。排查逻辑是倒推:先确认原始语料里这个词到底出现没有,用 grep 搜一遍;出现了的话,看分词之后还在不在,grep一下分词文件;分词之后还在,那就是被 min_count 过滤掉了,去model.wv.key_to_index里查一下,或者临时把 min_count 设成 1 重跑一次build_vocab看看词频。
这几个环节里最常见的是分词环节丢了词。原因可能是自定义词典没加载(路径错了、编码不对、静默失败),也可能是这个词被切成了别的组合。还有一种情况是大小写和全半角没统一,你搜的是"iPhone",语料里被转成了"iphone"。这类问题没有捷径,就是一层层往下查,查到哪一层断了就在哪一层修。
7.5 Windows 下 workers 大于 1 就卡死
这个坑我踩过不止一次。多进程在 Windows 上的启动方式和 Linux 不一样,子进程会重新导入主模块,如果你的训练代码直接写在模块顶层而没有if __name__ == "__main__":保护,子进程就会递归地重新执行训练代码,最后卡死或者疯狂开进程。
修法很简单,把训练入口包进 main 保护里。另外 Windows 上多进程的开销比 Linux 大,workers 设太高收益不明显,设成 4 到 8 通常就够。如果实在调不通,先把 workers 设成 1 确认逻辑没问题,再逐步往上加,这样能快速区分是代码问题还是并发问题。
最后分享一个我自己一直在用的小习惯:每次训练前,先用一千行语料跑一个"迷你版",把整条链路走通,确认分词、训练、保存、加载、相似度查询每个环节都正常,再上全量数据。全量跑一次可能要几十分钟,迷你版只要几十秒,这几十秒能挡掉八成以上的低级错误,性价比高得离谱。