简介:Java版本的Word2Vec是一份面向自然语言处理入门者与Java开发者的文本向量化工程源码,围绕CBOW与Skip-gram两种训练思路,提供从文本预处理、模型训练到词向量应用的基础实现,适合用于词义相似度计算、文本分类等学习实验。压缩包共26个文件、约38KB,以10个java源文件和12个class文件为主体,辅以prefs、classpath、project等Eclipse工程配置,结构轻量,便于导入IDE阅读、运行和二次调试。已有1835人学习下载,说明其在NLP入门与课程实践中具备一定参考价值。读者可据此梳理词向量训练流程、参数调节与模型保存加载方式,并在此基础上扩展相似度计算或分类任务,适合作为Java环境下Word2Vec的小型实践样例。
1. 一个 Java 版 word2vec 到底能干什么
线上召回服务是 Java 写的,离线词向量训练却在另一套脚本里跑,中间隔着一个导出文件和一个定时任务,出了问题只能两边翻日志对时间戳——这是我见过最耗费精力的一种模型对接方式。这份 Java 版 word2vec 把这条链子收进同一个进程:读语料、统计词频、建 Huffman 树、跑 CBOW 或 Skip-gram、写出词向量文件,全部在一套 JVM 里完成,不依赖任何 Python 环境,训练完的向量直接能被线上服务当成内存里的数组用起来。
它面向的两类人很明确:一类是要在 Java 服务里补齐文本召回能力、但不想再引一套跨语言模型服务的后端;另一类是想读懂 word2vec 到底怎么算的开发者,Java 代码没有 numpy 的隐式广播,每一步矩阵和下标都摆在明面上,反而是更好的学习样本。语料规模从几十 MB 到几个 GB 都能跑,瓶颈主要在内存和线程数上。
2. 工程骨架与数据流:一次训练从语料进到 bin 出要过几道手
先看骨架,不看代码细节。搞清目录以后,后面看到任何一段实现,你都能立刻知道它在流水线的哪一环、上下游是谁。
2.1 目录结构:六个包各管一段
工程拆得比较扁平,包名基本就是职责名,不玩抽象:
word2vec-java/ ├── src/main/java/vec/ │ ├── cli/ │ │ └── TrainOptions.java # 命令行参数解析,全部超参集中在这 │ ├── corpus/ │ │ ├── CorpusReader.java # 逐行读语料,处理 BOM/空白/超长行 │ │ └── SentenceIterator.java # 把一行切成词序列,按句迭代 │ ├── vocab/ │ │ ├── VocabWord.java # 一个词的全部元数据:频次、下标、Huffman 路径 │ │ └── VocabularyBuilder.java # 词频统计 → 过滤 → 排序 → 建树 │ ├── model/ │ │ ├── Synapse.java # syn0 / syn1 / syn1neg 三块权重 │ │ ├── CbowTrainer.java # CBOW + 层次 softmax 的线程体 │ │ ├── SkipGramTrainer.java # Skip-gram + 负采样的线程体 │ │ └── UnigramTable.java # 负采样用的 1e8 长代数表 │ ├── io/ │ │ ├── VectorWriter.java # 词向量写文本或二进制 │ │ └── VectorReader.java # 读回来,供增量训练或线上加载 │ └── Train.java # main 入口,装配参数、词表、模型、线程 └── src/test/java/vec/ # 断词表、断相似度排序的小用例三个包是关键。vocab决定模型能看见哪些词,model决定这些词怎么被训练成向量,io决定训练结果怎么落地和复用。corpus和cli属于外围,改代码时改得最多的其实是这两个——换分词器、加参数、改日志,几乎都在这里动手。
这个分层有个好处:词表和模型之间只靠List<VocabWord>和float[][] syn0两个契约传递,你完全可以把VocabularyBuilder换成从数据库读词表、把VectorWriter换成写 HBase,模型层一行不用改。我一般做二次开发时,第一刀就切在io上,因为那是最不影响训练逻辑的地方,改完立刻能验证。
2.2 数据流:语料进、bin 出,中间三次全量扫描
从入口Train.main开始,一次完整训练的数据流是这样的:
第一步,VocabularyBuilder扫全量语料,把每个词的出现次数累计进一个HashMap。这一步最慢也最吃内存,几 GB 语料下这个 map 能到百万级 key,堆给不够就直接 OOM。扫完按minCount过滤,再按频次降序排,分配下标。
第二步,对排好序的词表建 Huffman 树。小顶堆每次取两个频次最小的节点合并成新父节点,直到只剩一个根。建完后从根往下走,给每个叶子记下路径上的内部节点编号和左 0 右 1 的编码,存进point[]和code[]。高频词必然靠近根,路径短,这是层次 softmax 能省掉大部分计算量的原因。
第三步,如果开了负采样(-negative > 0),还要再扫一次词表构造 unigram 表,按频次的 0.75 次幂铺一张定长数组,训练时随机抽下标就等于按词频抽样。
第四步才是训练。每个线程取语料的一个切片,逐句滑动窗口,对每个中心词生成样本,前向算概率、反向更新syn0和syn1(或syn1neg)。学习率随处理进度线性衰减。
第五步,训练结束,VectorWriter把syn0的每一行按词表顺序写成文本或二进制。文本格式一行一个词加空格分隔的浮点数,二进制格式先写词表再写 float 数组,后者加载快一个数量级,线上用后者。
整个过程中,语料只被完整读两遍:一遍统计词频,一遍训练。Huffman 树和 unigram 表都是全内存结构,所以内存占用的粗略公式是词表大小 × (平均码长 × 8 字节 + 元数据) + 词表大小 × 维度 × 4 字节 × 2。百万词表、200 维的时候,光两块权重矩阵就是 1.6 GB,这个账在开跑之前就得算清楚。
2.3 编译与第一轮跑通
工程是标准 Maven 结构,没有额外的原生依赖,JDK 11 以上直接编:
# 打包成可执行 jar,跳过测试是因为默认用例会跑几百万词的迷你语料 mvn -q clean package -DskipTests # 准备好语料后开跑。中文语料必须先分词,空格切词只适用于已分好的文本 java -Xmx8g -jar target/word2vec-java-1.0.jar \ -train corpus/segmented.txt \ -output vec/wiki.vec \ -size 200 \ -window 5 \ -sample 1e-3 \ -hs 1 \ -negative 0 \ -cbow 1 \ -threads 8 \ -iter 5 \ -minCount 5 \ -alpha 0.025 \ -binary 1各参数的落点如下,改之前先看清楚它影响的是哪一环:
| 参数 | 默认值 | 作用位置 | 调大的后果 |
|---|---|---|---|
-size | 200 | 权重矩阵维度 | 表达力强,内存和耗时线性涨 |
-window | 5 | 滑动窗口半径 | 上下文更宽,语义偏主题、偏稀疏 |
-sample | 1e-3 | 高频词下采样阈值 | 值越小,高频词被丢弃越多 |
-hs | 1 | 走层次 softmax | 与负采样二选一 |
-negative | 0 | 负样本个数 | 大于 0 时走负采样分支 |
-cbow | 1 | 训练目标 | 0 切 Skip-gram |
-threads | 4 | 训练线程数 | 不超过物理核数才有效 |
-minCount | 5 | 词表过滤 | 调大大幅削词表、降内存 |
-iter | 5 | 语料重复轮数 | 小语料可以往上加 |
第一次跑不建议直接上全量语料。切 10 万行出来,-size 50 -iter 1跑通,看输出文件里有没有乱码词、NaN、空行,确认整条链是通的,再放量。这个习惯能省掉后面很多「跑到一半才发现词表炸了」的时间。
3. 词表与 Huffman 树:训练开始前的两次全量扫描
这一章是训练的准备工作,也是工程质量最容易打折扣的地方。词表错了,后面训得再久都是白费。
3.1 词频统计:一次全量扫描要拿到什么
VocabularyBuilder.build干的活看着简单,坑全在细节里:
public List<VocabWord> build(Path corpus, int minCount) throws IOException { Map<String, Long> freq = new HashMap<>(1 << 20); // 预留百万级容量,避免反复扩容 try (BufferedReader br = Files.newBufferedReader(corpus, StandardCharsets.UTF_8)) { String line; boolean firstLine = true; while ((line = br.readLine()) != null) { if (firstLine) { line = stripBom(line); // 去掉 UTF-8 BOM,否则首词永远带 \uFEFF firstLine = false; } line = line.trim(); if (line.isEmpty()) continue; for (String w : line.split("\\s+")) { // 中文语料请先在外部完成分词,这里只按空白切 if (w.isEmpty() || w.length() > MAX_WORD_LEN) continue; // 超长串基本是没切分的整行,直接丢 freq.merge(w, 1L, Long::sum); } } } List<VocabWord> vocab = new ArrayList<>(freq.size()); for (Map.Entry<String, Long> e : freq.entrySet()) { if (e.getValue() >= minCount) { vocab.add(new VocabWord(e.getKey(), e.getValue())); } } vocab.sort(Comparator.comparingLong(VocabWord::count).reversed()); // 频次降序,高频词拿短码 for (int i = 0; i < vocab.size(); i++) { vocab.get(i).index = i; // 下标即 syn0 的行号,后面全靠它对齐 } return vocab; }逻辑说明:整个方法只做三件事——统计、过滤、排序定下标。index的赋值是全局契约,训练时syn0[index]取到的就是这个词的输入向量,输出时也按index顺序写文件,所以词表顺序一旦确定,中途不能重排,否则向量和词就对不上了。
参数说明:minCount是硬门槛,低于它的词直接不进词表,训不到向量,遇到时按未登录词处理(常见做法是整词丢掉,不做 subword)。MAX_WORD_LEN我一般设 64,作用是挡住那些因为标点缺失导致的一整行成词的情况——这类「词」频次低、长度离谱,进词表只会浪费内存。
一处值得注意的取舍:语料里的换行符。用readLine()已经吃掉了\n,但 Windows 换行会留一个\r在行尾,trim()在这里是关键,去掉它才能保证行尾词和下一行首词不会粘成新词。
3.2 Huffman 树与词向量编码路径
建树用的是最朴素的小顶堆合并,没做任何平衡优化,因为 Huffman 本身已经保证带权路径最短:
private static final class Node { long freq; int wordIndex = -1; // >= 0 表示这是叶子,对应词表下标 int nodeId = -1; // 内部节点在 syn1 中的行号,建完树后统一分配 Node left, right; } public void buildHuffman(List<VocabWord> vocab) { PriorityQueue<Node> heap = new PriorityQueue<>((a, b) -> Long.compare(a.freq, b.freq)); // 小顶堆 for (VocabWord w : vocab) { Node leaf = new Node(); leaf.freq = w.count; leaf.wordIndex = w.index; heap.add(leaf); } while (heap.size() > 1) { Node a = heap.poll(); Node b = heap.poll(); Node parent = new Node(); parent.freq = a.freq + b.freq; parent.left = a; parent.right = b; heap.add(parent); // 合并后的父节点必须塞回堆,否则树长不出来 } Node root = heap.poll(); assignPath(root, new int[MAX_CODE_LEN], new byte[MAX_CODE_LEN], 0); } private void assignPath(Node n, int[] path, byte[] bits, int depth) { if (n.wordIndex >= 0) { // 叶子:把路径定格到这个词的 point/code 上 VocabWord w = vocab.get(n.wordIndex); w.point = Arrays.copyOf(path, depth); w.code = Arrays.copyOf(bits, depth); return; } if (n.nodeId < 0) n.nodeId = nextNodeId++; // 内部节点按首次访问顺序编号,作为 syn1 的行 path[depth] = n.nodeId; bits[depth] = 0; assignPath(n.left, path, bits, depth + 1); bits[depth] = 1; assignPath(n.right, path, bits, depth + 1); }逻辑说明:合并阶段只负责把树建出来,真正的编码在assignPath的深度优先遍历里完成。走到一个叶子,它沿途经过的内部节点编号序列就是point[],每一位的左右选择就是code[],两者长度相等,训练时按下标一一对应地取 syn1 的行、判断这步该往哪个方向走。
参数说明:MAX_CODE_LEN是深度上限,按词表规模取ceil(log2(V)) + 2足够,给 64 是绝对保险值,别省这点数组。nodeId从 0 开始全局分配,内部节点总数恰好是叶子数减一,所以syn1的行数可以按vocab.size() - 1预分配,不用动态扩。
有一点要注意:left赋 0、right赋 1 这个约定必须两边一致,训练时的梯度方向、向量输出的解释都建立在这上面。改树结构时如果把左右对调了,模型仍然能收敛,但已有的旧模型文件就不能拿来接着训了——编码语义变了。我一般把这段逻辑当成「一旦定下就别动」的部分。
3.3 层次 softmax 的计算量账
为什么非要建树?直接对全词表做 softmax,每次更新要算 V 次指数,百万词表下一次梯度就是百万次 exp,训练一天也跑不完。
Huffman 树把「从词表里挑出正确那个词」变成「在树上走一条路径,每一层做一个二分类」。走到目标词经过的节点数是它的码长,高频词码长短,可能只有 3 到 5 层,低频词码长长些,但整体上按-Σ p(w) log p(w)加权后的平均步数大约是 log₂(V) 的量级。百万词表下,平均每次更新从百万次降到二十次左右。
代价是每个内部节点都要维护一份维度为size的向量,syn1的行数是V - 1,和syn0一个量级,内存翻倍。所以这个方案的前提是词表不能爆炸——minCount设低了,syn1先把你内存吃掉。
负采样走的是另一条路:不建树,直接从按freq^0.75铺的采样表里随机抽几个非目标词当负例,每次更新只算negative + 1次点积,跟词表大小彻底解耦。词表特别大、或者要频繁增量加词的时候,负采样更省事。工程里两条路都实现了,用-hs 1 -negative 0或-hs 0 -negative 5切换,参数校验里会强制二选一,同时开两个会让syn1和syn1neg都占内存、更新逻辑也打架。
4. 训练内核:CBOW 与 Skip-gram 的正反向计算
这一章是整份代码最值钱的部分。看懂这两段,word2vec 在你这里就不再是黑匣子。
4.1 两块矩阵与内存布局
Synapse里就三个二维数组:
public final class Synapse { public final float[][] syn0; // 输入向量:词表大小 × 维度,最终输出的就是它 public final float[][] syn1; // 层次 softmax 的内部节点向量,行数 = 词表大小 - 1 public final float[][] syn1neg; // 负采样的输出向量,行数 = 词表大小 public Synapse(int vocabSize, int dim, boolean useHs, boolean useNegative) { this.syn0 = new float[vocabSize][dim]; this.syn1 = useHs ? new float[Math.max(vocabSize - 1, 1)][dim] : null; this.syn1neg = useNegative ? new float[vocabSize][dim] : null; Random rnd = new Random(1L); // 固定种子,保证实验可复现 for (float[] row : syn0) { for (int d = 0; d < dim; d++) { row[d] = (rnd.nextFloat() - 0.5f) / dim; // 初始值压小,除以维度是原实现的约定 } } // syn1 / syn1neg 全部初始化为 0,与原实现一致:输出侧从零开始学 } }逻辑说明:syn0是唯一会被写进文件的矩阵,也是唯一在推理时需要的矩阵;syn1和syn1neg只是训练期间的辅助参数,训完就可以丢。区分这一点很重要——你的线上服务只需要加载syn0,内存占用直接砍半。
参数说明:初始化用(rand - 0.5) / dim,除以维度是因为维度越大点积的方差越大,不压一下前期 sigmoid 容易饱和,梯度直接归零。输出侧全零初始化是原实现的写法,靠第一轮迭代把它拉起来,能跑,但如果你改了激活或者学习率,这里可能要重新考虑。
内存布局用的是float[][],每行独立分配。这在 Java 里意味着对象头和数组头有额外开销,但也意味着每行在堆上是独立的,多线程分别更新不同行时伪共享少一些。要再抠内存可以拍成一维float[]加手动算下标,省掉数组对象头,代价是代码可读性下降。千万级词表时这个改动值得做,普通规模没必要。
4.2 CBOW:上下文求和 + 层次 softmax 反传
CBOW 的假设是「上下文决定中心词」。训练时把窗口内的上下文词向量加起来当输入,沿 Huffman 路径逐层做二分类:
// 一个训练样本:center 是中心词下标,context 是窗口内上下文词下标数组 private void trainCbow(int center, int[] context, int contextLen, float alpha) { Arrays.fill(neu1, 0f); for (int c = 0; c < contextLen; c++) { float[] row = syn0[context[c]]; for (int d = 0; d < dim; d++) neu1[d] += row[d]; // 上下文向量累加,等价于原实现里的求和 } Arrays.fill(neu1e, 0f); // neu1e 存累加的误差,一次样本用完就清 VocabWord w = vocab.get(center); for (int lv = 0; lv < w.code.length; lv++) { int node = w.point[lv]; // 这一层的内部节点,对应 syn1 的某一行 float[] syn1Row = syn1[node]; float dot = 0f; for (int d = 0; d < dim; d++) dot += neu1[d] * syn1Row[d]; if (dot > MAX_EXP) dot = MAX_EXP; // 夹住,防止 sigmoid 溢出成 1 或 0 else if (dot < -MAX_EXP) dot = -MAX_EXP; float g = (1 - w.code[lv] - sigmoid(dot)) * alpha; // 梯度 = (真实标签 - 预测概率) × 学习率 for (int d = 0; d < dim; d++) { neu1e[d] += g * syn1Row[d]; // 误差回传给输入侧 syn1Row[d] += g * neu1[d]; // 内部节点向量就地更新 } } for (int c = 0; c < contextLen; c++) { float[] row = syn0[context[c]]; for (int d = 0; d < dim; d++) row[d] += neu1e[d]; // 误差均摊回每个上下文词(原实现不做除法,系数被学习率吸收) } }逻辑说明:code[lv]是 0 或 1,表示这条路径该往左还是往右。1 - code - sigmoid这一项的统一写法覆盖了两种情况:标签为 1 时梯度是1 - p,标签为 0 时是-p,方向都对。误差neu1e在整个路径累加完之后才回写到syn0,而不是每层回写一次,这样每个上下文词只被更新一次。
参数说明:alpha是当前学习率,由外层按进度衰减后传进来。MAX_EXP一般取 6 到 8,超过这个范围 sigmoid 已经饱和到 0.9975 以上,再算下去只是浪费精度。contextLen是窗口内实际取到的词数,句首句尾会不足2 × window,不足时按实际长度处理,别补零——补零会让边界词的向量被一个假的「空词」拉偏。
一个容易被忽略的细节:同一个词可能在窗口内出现两次,这时它在syn0里会被更新两次。原实现就是这么处理的,效果上没有明显问题,但如果你的语料重复度很高,可以加一层去重。
4.3 Skip-gram 与负采样表
Skip-gram 反过来,用中心词去预测上下文词。每对(中心词,上下文词)算一个正样本,再从采样表里抽negative个词当负样本:
// 用中心词 center 预测目标词 target,label 为 1 表示真实上下文 private void trainSkipGram(int center, int target, float alpha) { float[] centerVec = syn0[center]; float[] neu1e = new float[dim]; Arrays.fill(neu1e, 0f); updatePair(centerVec, syn1neg[target], 1, alpha, neu1e); // 正样本 for (int k = 0; k < negative; k++) { int neg = unigramTable[random.nextInt(unigramTable.length)]; if (neg == target) continue; // 抽到自己就跳过,正负标签会冲突 updatePair(centerVec, syn1neg[neg], 0, alpha, neu1e); // 负样本 } for (int d = 0; d < dim; d++) centerVec[d] += neu1e[d]; // 中心词向量最后统一更新一次 } private void updatePair(float[] centerVec, float[] outVec, int label, float alpha, float[] neu1e) { float dot = 0f; for (int d = 0; d < dim; d++) dot += centerVec[d] * outVec[d]; if (dot > MAX_EXP) dot = MAX_EXP; else if (dot < -MAX_EXP) dot = -MAX_EXP; float g = (label - sigmoid(dot)) * alpha; for (int d = 0; d < dim; d++) { neu1e[d] += g * outVec[d]; outVec[d] += g * centerVec[d]; } }逻辑说明:负采样把「多分类」拆成一堆二分类。正样本标签 1,负样本标签 0,每个输出向量只在自己那一行上更新一次,所以计算量和词表大小无关,只跟negative有关。中心词向量在所有样本累加完之后才写回syn0,避免同一轮里被反复读写。
参数说明:negative常见取值 5 到 15,小语料给 5 就够,语料大了往上加,代价是线性增长的耗时。random必须是每个线程独立的ThreadLocalRandom或各自new Random(),共用同一个实例会成为锁热点,多线程直接退化成单线程。
采样表是这么铺出来的:
public static int[] build(List<VocabWord> vocab, int tableSize) { double total = 0; for (VocabWord w : vocab) total += Math.pow(w.count, 0.75); // 0.75 次幂,压低高频词权重 int[] table = new int[tableSize]; int idx = 0; double cumulative = Math.pow(vocab.get(0).count, 0.75) / total; for (int a = 0; a < tableSize; a++) { table[a] = idx; // 每个格子填的是词表下标 if ((double) a / tableSize > cumulative && idx < vocab.size() - 1) { idx++; cumulative += Math.pow(vocab.get(idx).count, 0.75) / total; } } return table; }逻辑说明:这段按累计概率把一个长度为tableSize的整数数组填满,抽样时随机取一个下标,落到的值就是词表下标。频次高的词占据更多格子,被抽中的概率自然更高,但 0.75 次幂把高频词的优势压了一些,让低频词也有机会当负样本。
参数说明:tableSize原实现用一亿,好处是精度高,坏处是固定占 400 MB。词表只有几十万的时候,一千万也能用,误差看不出来。我一般按min(1e8, 词表大小 × 100)取,能省下不少堆。
4.4 学习率衰减与多线程切片
训练循环由主线程装配,工作线程各自切一段语料:
float alpha0 = options.alpha(); long totalWords = counter.countWords(corpus); // 提前扫一遍总词数,用于算进度 AtomicLong processed = new AtomicLong(); Runnable worker = () -> { float alpha = alpha0; for (int iter = 0; iter < options.iter(); iter++) { for (Sentence sentence : mySlice) { for (int pos = 0; pos < sentence.length(); pos++) { // ... 在该位置滑窗生成样本并训练 processed.addAndGet(1); } } double progress = (double) processed.get() / (totalWords * options.iter()); alpha = Math.max(alpha0 * (float) (1 - progress), alpha0 * 1e-4f); // 线性衰减,留一个下限 } };逻辑说明:学习率随处理进度线性下降,前期步子大快速收敛,后期步子小避免在最优点附近震荡。下限alpha0 × 1e-4是保底,防止最后阶段学习率归零导致后面语料完全不产生更新。
参数说明:mySlice是当前线程负责的句子列表,按行号取模切分即可,不要在线程里共享同一个BufferedReader——那会成为全局锁,-threads 8实际只跑出一个核的效率。processed用AtomicLong,递增的竞争在千万分之一秒的量级,不影响吞吐。
多线程更新syn0本身是无锁的,两个线程可能同时写同一个词的同一行,理论上会丢更新。实践中的补偿是:每次更新的幅度很小,丢掉的更新概率低,整体收敛不受影响。这就是无锁异步更新的思路,省掉了锁的开销,代价是结果不完全可复现。如果你需要严格可复现,就只能-threads 1,慢但确定性高。
5. 排查清单:训练不收敛和向量发飘的 5 个典型故障
这些是我在跑不同语料时真踩过的,按「现象 → 原因 → 解决」记下来,遇到时照着对。
5.1 数据与词表侧的两类故障
第一条:词表里冒出带奇怪前缀的词。现象是训练完打开向量文件,第一行是\uFEFF中国这种东西,most_similar查「中国」永远查不到。原因是语料文件带 UTF-8 BOM,readLine()把它当普通字符读进来了,跟第一个词粘在一起。解决是在第一行读进来后显式剥掉\uFEFF,上面build方法里那个stripBom就是干这个的。顺带把\r也处理掉,否则 Windows 换行会让每个行尾词都带一个不可见字符,词表里凭空多出一倍的「脏词」。
第二条:最近邻全是「的」「是」「在」。现象是查任何一个词,返回的前十里有一半是停用词或标点分词残留。原因通常是-sample设成了 0 或者 1,高频词的下采样没生效,这些词在语料里出现几百万次,向量被推到一个各方向都接近的位置,跟谁余弦相似度都不低。解决办法是把-sample设到 1e-3 到 1e-5 之间,让出现频率超过阈值的词按概率被跳过;同时-minCount别设成 1,标点符号和一些零星片段本来就该被过滤掉。
5.2 训练过程侧的三类故障
第三条:输出文件里全是 NaN。现象是训练日志看着正常,写出文件后一行行翻,浮点值全是NaN。原因有两个常见来源:一是alpha初值给太大(有人直接写 0.5),点积瞬间变大,sigmoid溢出成 1,梯度算出来是 0 或者无穷;二是语料里存在超长「词」,一个词维度累加几百次,数值直接冲出 float 范围。解决办法是把alpha压回 0.025 的量级,同时在点积后加MAX_EXP的钳位,再把超长词在统计阶段丢掉——w.length() > 64直接 continue。
第四条:开了 8 个线程,CPU 只跑满一个核。现象是-threads 8和-threads 1的耗时几乎一样,top看只有一个核在满负荷。原因基本是两个:一是所有线程共享同一个BufferedReader或同一个Random实例,读和取随机数都被锁串行化了;二是有人为了「线程安全」在更新syn0的地方加了synchronized,热点路径上的锁比单线程还慢。解决办法是训练前把语料一次性读进内存成句子列表,各线程按下标取模切片;随机数用线程本地的;权重更新不加锁。语料大到装不进内存时,改成按文件偏移分块,每个线程开自己的RandomAccessFile。
第五条:跑一半 OOM,或者 GC 时间比训练时间还长。现象是堆监控里老年代一路涨到顶,Full GC频繁。原因是几块内存叠加:HashMap统计词频时的百万级 key、float[][]两块权重、加上一亿长度的 unigram 表。排查顺序是先用-minCount把词表砍下来(这一步收益最大),再把tableSize从一亿降到千万级,最后才考虑把float[][]拍成一维数组。堆参数上,-Xmx给到预估内存的 1.5 倍,-Xmn不用设太大,这些结构基本都活到训练结束,很快会晋升到老年代。
6. 二次开发:把离线训练器改成能热更新的向量服务
真正要用起来,缺的往往不是训练本身,而是「怎么在已有向量上接着训」和「怎么让线上服务查得快」这两件事。前者靠增量训练,后者靠一个轻量的内存索引,都不用改动CbowTrainer的核心逻辑。
增量训练的关键是把旧向量当成初始化值,而不是从零开始。词表取并集,旧词保留原下标和原向量,新词追加在尾部随机初始化,然后对新语料跑少量轮次:
public void continueTraining(Path oldVecFile, Path newCorpus, int epochs) throws IOException { Model prior = VectorReader.load(oldVecFile); // 读回旧 syn0 和旧词表 List<VocabWord> merged = VocabularyBuilder.merge(prior.vocab(), newCorpus); float[][] syn0 = new float[merged.size()][prior.dim()]; System.arraycopy(prior.syn0(), 0, syn0, 0, prior.syn0().length); // 旧行原样搬过来 for (int i = prior.syn0().length; i < merged.size(); i++) { for (int d = 0; d < prior.dim(); d++) { syn0[i][d] = (ThreadLocalRandom.current().nextFloat() - 0.5f) / prior.dim(); // 新词随机初始化 } } new SkipGramTrainer(merged, syn0, options).train(newCorpus, epochs, 0.01f); // 学习率要比首训小 }这里有两个参数值得盯住:一是轮次要小,增量训练跑 1 到 3 轮就够,轮数多了新语料会把旧向量的分布整个拽偏,老词的语义空间被破坏;二是学习率要给到首训的 1/3 左右,0.01 是个稳妥起点,让新词能学到东西但不会把老词冲掉。验证方式是拿一批老词做基准,训完比较它们之间的余弦相似度和训前的差异,如果整体偏移超过一个阈值,说明学习率或轮次还是太大。
线上查询侧,我一般只加载syn0和一个Map<String, Integer>,再把所有向量归一化后存成一整块float[],查询时算点积就等于算余弦:
public List<String> mostSimilar(String word, int topK) { Integer idx = wordIndex.get(word); if (idx == null) return List.of(); // 未登录词直接返回空,不做近似 float[] query = vectors[idx]; PriorityQueue<float[]> heap = new PriorityQueue<>(topK, Comparator.comparingDouble(a -> a[1])); // 小顶堆留 topK for (int i = 0; i < vectors.length; i++) { if (i == idx) continue; float sim = dot(query, vectors[i]); // 归一化之后点积即余弦 if (heap.size() < topK) heap.offer(new float[]{i, sim}); else if (sim > heap.peek()[1]) { heap.poll(); heap.offer(new float[]{i, sim}); } } // ... 把堆里剩下的下标映射回词,按相似度降序返回 }十万词表、200 维的规模下,这种暴力扫描单次查询在几毫秒级,够用;上到百万词表再考虑加一层 LSH 或者倒排近似,但在那之前,先把手上的序列化格式确认清楚——二进制格式加载比文本快一个数量级,线上别用文本格式。
踩过一次坑之后我养成了一个习惯:任何改动过词表顺序、Huffman 编码约定或者初始化方式的提交,都必须拿一份 10 万行的小语料跑两遍,一遍改前一遍改后,比对同一批词的最近邻列表,偏移超过预期就回滚。向量这种东西,出错不报异常,只是结果悄悄变差,没有回归基线你根本发现不了。希望帮到你。
本文还有配套的精品资源,点击获取