SVD与SGNS构建汉语子词向量:从共现矩阵到负采样
2026/9/23 12:38:20 网站建设 项目流程

简介:面向自然语言处理初学者与课程作业参考者,这是一份以汉语子词向量构建与评测为核心的Python源码包,完整覆盖基于SVD分解与基于SGNS两种主流方法。资源针对子词向量训练、语料预处理和相似度评测任务,提供可直接运行的脚本与中间结果,适合需要完成NLP编程作业或理解词向量原理的学习者。

压缩包内含15个文件,以py脚本、ipynb笔记本和txt数据文件为主,同时包含模型权重文件(.pth)与降维结果(.npy),总计约88.66MB。其中svd.py、sgns.py实现核心算法,preprocess.ipynb展示预处理流程,result.py负责结果汇总,并附带pku_sim_test.txt等评测数据。

资源已获得112人学习浏览,实用价值明确。通过阅读源码可掌握SVD降维构建distributional表示、SGNS训练子词向量的完整流程,还能直接利用README和结果文件核对输出格式,减少调试验证时间。尤其适合在汉语言语料上实践词向量构建与相似度评测的读者。

1. 一次 NLP 作业里的两条路线:SVD 与 SGNS 构建汉语子词向量

拿到这份 NLP 作业源码的时候,我第一反应是“又是词向量”,但仔细看了要求才发现它比普通的 word2vec 作业多绕了两个弯:一是对象从整词变成了 BPE 切出来的子词,二是必须同时实现 SVD 和 SGNS 两条路线,最后还要按机器判定的格式输出相似度结果。也就是说,你不仅要跑通代码,还得保证两条路线的输出能对得上同一个评测脚本。这份资源里既有 preprocess.ipynb 做语料清洗和 BPE 切分,也有 svd.py、skip_gram.py 分别走计数式矩阵分解和预测式负采样,最后用 result.py 统一算余弦相似度并落盘。对正在做 NLP 课程作业、或者想搞清楚“共现矩阵分解和神经网络训练出来的词向量到底差在哪”的人来说,这套代码是很好的参照物——它把两条路线完整串了一遍,还在评测环节把缺失词、格式规范这些容易翻车的细节都暴露出来了。

2. 先把数据收拾明白:corpus.txt 与 BPE 子词词表的预处理环节

2.1 语料构成:训练集和测试集的并集,以及计算资源不够时的取舍

作业要求里写得清楚:corpus.txt 用的是第一次编程作业中的训练集和测试集的并集。为什么不是只拿训练集?因为子词向量的质量直接受覆盖率影响,尤其是评测文件 pku_sim_test.txt 里的子词,很可能有一部分只出现在测试集里。如果只用训练集构建向量,评测时那些词就会全部落进“未登录词”分支,相似度被强行置 0,分数自然难看。所以“并集”这个设计不是为了省事,而是为了把 OOV(Out of Vocabulary)的比例压到最低。

文件清单里的 train_BPE.txt 和 test_BPE.txt 就是参与并集的两个来源,合并之后去重、清洗,才得到 corpus.txt。我一般会先用 wc -l 和 head 快速确认三个文件的规模比例,避免合并时把顺序搞乱。

wc -l train_BPE.txt test_BPE.txt corpus.txt head -5 train_BPE.txt

wc 的输出能直接告诉你两份语料各自的行数,如果 corpus.txt 的行数大约等于两者之和,说明合并这一步没丢数据;head 则是用来瞄一眼切分格式——注意这里每一行是一个已经做完 BPE 切分的子词序列,还是原始句子?两种情况的后续处理逻辑完全不同。这份资源里的 train_BPE.txt 命名已经暗示了它是切分后的产物,所以预处理脚本的重心不在“怎么切”,而在“怎么读进来、怎么建词表”。

如果机器内存不够,作业也明确允许选一个子集当语料。我的习惯是优先保“评测覆盖”,也就是先跑一遍 pku_sim_test.txt 里的子词,确认它们在你的语料子集里都能找到,再考虑语料规模。否则省下来的内存都会变成评测分数上的窟窿。

2.2 preprocess.ipynb 里做了什么:BPE 切分与词表对齐

preprocess.ipynb 承担的是语料清洗、BPE 词表加载和“子词到索引”映射。第一次作业已经产出了子词词表,所以这里并不需要重新训练 BPE,而是要把那份词表读进来,和 corpus.txt 的切分结果对齐。最容易出问题的地方是:BPE 切分时词表和语料的编码必须完全一致,比如全角/半角、空格字符、以及句子结束符 的处理。

我看了下这个 notebook 的处理思路,大致分四步:

# 1. 读入 BPE 词表,构建子词 -> id 的映射 subword_vocab = {} with open('subword_vocab.txt', 'r', encoding='utf-8') as f: for idx, line in enumerate(f): subword = line.strip() subword_vocab[subword] = idx # 2. 读取 corpus.txt,按空格切分并过滤不在词表中的子词 def load_corpus(path, vocab): all_subwords = [] with open(path, 'r', encoding='utf-8') as f: for line in f: tokens = line.strip().split() for tok in tokens: if tok in vocab: all_subwords.append(vocab[tok]) return all_subwords # 3. 统计子词频次,用于后续的共现矩阵或负采样分布 from collections import Counter subword_ids = load_corpus('corpus.txt', subword_vocab) freq_counter = Counter(subword_ids) # 4. 保存处理结果,供 svd.py 和 skip_gram.py 共用 import pickle with open('processed_data.pkl', 'wb') as f: pickle.dump({'subword_vocab': subword_vocab, 'subword_ids': subword_ids, 'freq_counter': freq_counter}, f)

这段代码的关键在第三步的 Counter。SVD 路线构建共现矩阵时需要知道每个子词出现了多少次,SGNS 路线的负采样分布也直接依赖词频,所以 freq_counter 是两条路线共用的“基础设施”。vocab 过滤有一个隐含风险:如果语料里某个子词不在第一次作业的词表中,它会被直接丢弃,这会导致后续评测时该词变成“未登录词”。实际做的时候,我更倾向于保留一份“语料词表”和“作业词表”的差集,看看被丢掉的子词是否出现在 pku_sim_test.txt 里。

2.3 子词词频过滤的默认习惯与自己的实测经验

第一次作业产出的 BPE 词表通常很大,直接全量使用会拖慢 SVD 和 SGNS 的训练。我的经验是给词频设一个下限:只保留在语料中出现不少于 2 次的子词。这个阈值不算激进,但能把那些因为 BPE merge 操作偶尔冒出来的噪声子词清掉,又不会伤到评测需要的低频词。

MIN_FREQ = 2 filtered_vocab = {subword: idx for subword, idx in subword_vocab.items() if freq_counter.get(idx, 0) >= MIN_FREQ}

注意这里过滤的依据是语料频次,不是词表序号。BPE 词表的初始词表里可能包含大量只在 merge 过程中出现、最终没有落到任何句子里的候选子词,用语料频次过滤是最可靠的做法。我踩过一个坑:最开始用词表长度当过滤条件,结果把语料里真实存在但词表排序靠后的子词全滤掉了,评测覆盖率直接掉了几个点。

3. SVD 分解路线:K=5 共现窗口、高维 distributional 表示与降维

3.1 为什么先做高维共现,再降维——不能跳步

SVD 路线的核心逻辑是“先计数、再压缩”。作业要求里写得很明确:获取高维 distributional 表示时 K=5,SVD 降维后的维数自定。K=5 意味着以目标子词为中心,前后各取 5 个位置内的子词作为共现上下文。这个窗口比 SGNS 的 K=2 大不少,背后的原因是共现矩阵需要更宽的上下文才能捕获到足够的语义信号——窗口太小,矩阵会非常稀疏,后续 SVD 分解出来的向量区分度会很差。

“高维分布表示”这一步不能跳。很多人直接拿共现矩阵做 SVD,但矩阵的维度是“词表大小 × 词表大小”,直接分解不仅慢,而且低秩近似会丢掉大量低频共现信息。常见做法是先构建一个词表大小 × 词表大小的稀疏矩阵,然后用截断 SVD 把它降成 d 维稠密向量。这里 d 就是“降维后的维数自定”里的那个自定参数,我一般取 100 到 300 之间,太小丢信息,太大则起不到降维的作用。

3.2 svd.py 的实现结构与参数设置

svd.py 的工作分三段:遍历语料构建共现矩阵、对矩阵做 SVD 分解、取前 d 个奇异向量作为子词向量。共现矩阵用 scipy.sparse 的 lil_matrix 或 coo_matrix 来存,因为词表规模上万时稠密矩阵的内存开销是灾难性的。

import numpy as np from scipy.sparse import lil_matrix, csr_matrix from scipy.sparse.linalg import svds k = 5 # 共现窗口大小,作业要求固定 embed_dim = 128 # SVD 降维后的向量维度,自定 # 构建共现矩阵 vocab_size = len(subword_vocab) cooc = lil_matrix((vocab_size, vocab_size), dtype=np.float32) for i in range(len(subword_ids)): center = subword_ids[i] left_start = max(0, i - k) right_end = min(len(subword_ids), i + k + 1) for j in range(left_start, right_end): if j == i: continue # 不把中心词自身算入上下文 context = subword_ids[j] cooc[center, context] += 1.0 # 转成 CSR 加速矩阵乘法 cooc_csr = cooc.tocsr() # 截断 SVD:只保留前 embed_dim 个奇异向量 u, s, vt = svds(cooc_csr, k=embed_dim) svd_vec = u * s # 用 u * s 作为子词向量

这段代码有四个地方值得细说。第一,cooc[center, context] += 1.0是在统计共现次数,没有做任何加权或归一化,这是最朴素的 PMI 之前的“原始计数”版本。第二,svds返回的 u、s、vt 是按奇异值大小升序排列的,而且有时符号是翻转的,所以用u * s作为最终向量时,不同维度上可能出现整体取反,但对余弦相似度没有影响。第三,embed_dim取得太大会让 svds 变慢,因为 scipy 的 svds 是迭代求解,k 越大迭代越久。第四,我没有在这里对向量做归一化,归一化留到评测脚本里做,因为余弦相似度本身会归一化,提前做反而可能丢失长度信息。

3.3 窗口方向的细节:左窗口、右窗口与“是否包含自身”

K=5 的窗口在实现时要特别小心边界条件。上面的代码里left_start = max(0, i - k)right_end = min(len(subword_ids), i + k + 1)处理了句首和句尾的截断,而if j == i: continue把中心词自身排除在上下文外。这两个处理看起来简单,但直接决定共现矩阵的对角线是否为 0。作业没有明确说要不要包含自身,我的判断是不包含,因为“一个子词和它自己总是共同出现”会引入一个很强的无关信号,SVD 之后会把所有向量往同一个方向拉。

另外,有些实现会把左窗口和右窗口分开计数,甚至给左侧上下文和右侧上下文不同的权重。这个作业没要求,所以我用了最简单的对称窗口。但要注意一点:如果语料里句子是按行存的,跨行的子词不应该出现在同一个窗口里。上面的代码是整段连续处理的,如果语料是“每行一个句子”,那在句子边界处必须重置窗口,否则会把上一句末尾的词和下一句开头的词算作共现。这是 SVD 路线里最容易出隐性 bug 的地方——程序能跑,结果全偏。

# 如果 corpus.txt 每行是一个句子,正确的做法是逐句处理 all_sentences = [] with open('corpus.txt', 'r', encoding='utf-8') as f: for line in f: sent = [subword_vocab[t] for t in line.strip().split() if t in subword_vocab] if sent: all_sentences.append(sent) cooc = lil_matrix((vocab_size, vocab_size), dtype=np.float32) for sent in all_sentences: for i, center in enumerate(sent): left_start = max(0, i - k) right_end = min(len(sent), i + k + 1) for j in range(left_start, right_end): if j == i: continue cooc[center, sent[j]] += 1.0

我实际跑的时候是用逐句版本,因为它的语义解释更干净——子词的上下文不应该跨越句子边界。如果作业的语料本来就是打散的长文本,那连续窗口也可以,但你必须自己确认过 corpus.txt 的行结构再决定。

3.4 svd.npy 的保存与加载:格式决定后续评测的接口

svd.py 最后会把算好的向量保存成 svd.npy,这个文件名的后缀暴露了它的存储格式是 numpy 的二进制数组。加载方式很简单:

import numpy as np svd_vec = np.load('svd.npy') print(svd_vec.shape) # (vocab_size, embed_dim)

但这里有个容易忽略的点:svd_vec 的行号必须是子词词表里的 id,而且 id 的映射要和 preprocess.ipynb 里生成的一致。如果你在 svd.py 里重新构建了词表,或者过滤了低频子词导致 id 编号错位,那评测脚本里vec[subword_id]就会取到错误的向量。我一般会在保存 npy 的同时把 id 到子词的映射存一份 json,两个文件配套使用。否则等评测阶段发现相似度计算结果毫无规律时,你根本分不清是向量的问题还是 id 错位的问题。

4. SGNS 路线:窗口为 2 的 skip-gram 负采样训练与向量抽取

4.1 SGNS 与 SVD 的差别:预测式模型为什么需要负采样

SVD 是计数式方法,先统计共现再分解矩阵;SGNS 是预测式方法,直接训练一个模型去预测上下文。作业把两者的窗口分别设成 K=5 和 K=2,这不是随意定的——计数式方法需要大窗口捕获主题层面的共现,而预测式方法的窗口更小,更强调近邻的语法和搭配信息。所以两条路线得到的是两种不同倾向的子词向量,评测结果也会体现出差异:SVD 在语义相似度上通常更稳,SGNS 在语法相似的子词上更好。

SGNS 的核心是负采样。正样本是“中心词 + 窗口内的上下文词”,负样本是“中心词 + 随机抽样出来的非上下文词”。模型要学的是区分正负样本,训练结束后,输入层到隐藏层的权重矩阵就是我们要的子词向量。skip_gram.py 里保存的 SGNS.pth 就是这个权重,而不是模型的预测结果——很多第一次接触的人会搞混,以为保存的是模型,其实我们要的是嵌入矩阵。

4.2 skip_gram.py 的结构:数据流、负采样、模型保存

skip_gram.py 的代码会比 svd.py 长不少,因为它包括数据流构造、训练循环和模型保存三块。窗口 K=2,所以每个中心词最多有 4 个正样本上下文。负采样数量是超参数,作业没规定,常见做法是 5,我实测在子词语料上负采样 5 是个安全值,太多会让训练变慢且低频词更容易被压垮。

import torch import torch.nn as nn import random window = 2 # SGNS 窗口大小,作业要求 K=2 embed_dim = 128 # 子词向量维度,自定 neg_count = 5 # 每个正样本对应的负样本数 epochs = 5 class SGNS(nn.Module): def __init__(self, vocab_size, embed_dim): super().__init__() self.center_embed = nn.Embedding(vocab_size, embed_dim) self.context_embed = nn.Embedding(vocab_size, embed_dim) nn.init.uniform_(self.center_embed.weight, -0.5 / embed_dim, 0.5 / embed_dim) nn.init.uniform_(self.context_embed.weight, -0.5 / embed_dim, 0.5 / embed_dim) def forward(self, center, context, neg_context): # 中心词向量 center_vec = self.center_embed(center) # (batch, embed_dim) # 正样本上下文向量 context_vec = self.context_embed(context) # (batch, embed_dim) # 负样本上下文向量 neg_vec = self.context_embed(neg_context) # (batch, neg_count, embed_dim) # 正样本得分:最大化 log sigmoid(center * context) pos_score = torch.sum(center_vec * context_vec, dim=1) pos_loss = -torch.log(torch.sigmoid(pos_score) + 1e-8) # 负样本得分:最小化 log sigmoid(-center * neg) neg_score = torch.bmm(neg_vec, center_vec.unsqueeze(2)).squeeze(2) neg_loss = -torch.sum(torch.log(torch.sigmoid(-neg_score) + 1e-8), dim=1) return torch.mean(pos_loss + neg_loss)

这段代码把 SGNS 的损失函数写得非常直白。pos_loss是让正样本的 dot product 尽量大,neg_loss是让负样本的 dot product 尽量小。注意center_embedcontext_embed是分开的两张表——这是 SGNS 的标准做法,因为“作为中心词的子词”和“作为上下文的子词”应该拥有不同的表示。但最后评测用的向量是取 center_embed 还是两者相加?作业没有明确,我的做法是只取 center_embed,因为 pku_sim_test.txt 里比较的是“子词本身”的相似度,不是“子词作为上下文”的相似度。

4.3 训练循环与样本构造:避免内存爆炸的生成器方案

子词语料规模动辄几百万 token,如果先把所有 (center, context) 对一次性生成再训练,内存会先爆炸。我习惯用生成器边遍历边出样本,配合 batch 喂给模型。

# 从语料生成 (center, context) 正样本对 def generate_samples(sentences, window): for sent in sentences: for i, center in enumerate(sent): left_start = max(0, i - window) right_end = min(len(sent), i + window + 1) for j in range(left_start, right_end): if j == i: continue yield center, sent[j] # 负采样:按词频的 0.75 次方分布抽样 unigram = torch.tensor([freq_counter.get(i, 0) ** 0.75 for i in range(vocab_size)]) neg_dist = unigram / unigram.sum() def negative_sampling(batch_size, neg_count): return torch.multinomial(neg_dist, batch_size * neg_count, replacement=True) \ .view(batch_size, neg_count)

负采样分布用词频的 0.75 次方是 word2vec 论文里的经典设置,目的是给低频词多一些被采到的机会。直接用原始词频的话,高频子词会被反复采成负样本,模型学不到低频子词的区分度。这里我把负采样概率放到了 CPU 上,因为词表规模不大时 GPU 上用 multinomial 反而有同步开销。训练时每个 epoch 重新遍历一遍语料,权重更新用 Adam 或 SGD 都可以,我习惯 Adam,学习率设 0.01,五到十个 epoch 就能收敛得差不多。

4.4 从 SGNS.pth 抽取子词向量的正确姿势

训练结束后,模型权重里只有 center_embed 是我们要的向量。保存的时候要特别注意 torch.save 的格式。推荐只存 state_dict,而不是整个模型,因为整个模型会带上训练时的图表结构和随机数生成器状态,文件大且加载慢。

# 保存 torch.save(sgns_model.state_dict(), 'SGNS.pth') # 加载 def load_sgns_vec(path, vocab_size, embed_dim): model = SGNS(vocab_size, embed_dim) state_dict = torch.load(path, map_location='cpu') model.load_state_dict(state_dict) vec_sgns = model.center_embed.weight.detach().cpu().numpy() return vec_sgns sgns_vec = load_sgns_vec('SGNS.pth', vocab_size, 128) np.save('sgns_vec.npy', sgns_vec)

这里最容易犯的错误是维度不匹配。训练时的 vocab_size 必须和加载时一致,否则 load_state_dict 直接报错。另一个细节是 map_location='cpu'——如果训练是在 GPU 上跑的,评测脚本跑在 CPU 上,不加这个参数会报 device 不匹配。我看了下资源里 sgns_result.txt 和 svd_result.txt 是分开存放的,说明评测脚本允许你只算一条路线,也可以两边都算,最后再用 total_result.txt 汇总。

5. 评测与机器判定:pku_sim_test.txt 相似度计算、输出格式与常见避坑

5.1 result.py 的工作流程:词典映射、余弦相似度、缺失词兜底

result.py 是整份资源的收口脚本,它把 svd.npy 和 SGNS.pth 里的向量读进来,对 pku_sim_test.txt 每一行的两个子词计算余弦相似度。作业要求里有一条关键规则:当某一行的词没有向量时(未出现在语料中),该行相似度直接置 0。这一条不能等到计算时才发现缺失,而是要在读词表的时候就建立“哪些词有向量”的集合。

import numpy as np def cosine_sim(vec1, vec2): if vec1 is None or vec2 is None: return 0.0 norm1 = np.linalg.norm(vec1) norm2 = np.linalg.norm(vec2) if norm1 == 0 or norm2 == 0: return 0.0 return float(np.dot(vec1, vec2) / (norm1 * norm2)) # 加载向量并构建子词 -> 向量的映射 svd_vec = np.load('svd.npy') subword_to_vec = {} for subword, idx in subword_vocab.items(): if idx < svd_vec.shape[0]: subword_to_vec[subword] = svd_vec[idx] # 读取评测文件,逐行计算 results = [] with open('pku_sim_test.txt', 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split() if len(parts) < 2: continue w1, w2 = parts[0], parts[1] sim = cosine_sim(subword_to_vec.get(w1), subword_to_vec.get(w2)) results.append(sim)

这里有一个我早期踩过的坑:直接subword_to_vec[subword] = svd_vec[idx]时没有检查 idx 是否越界。svd.py 如果做了词频过滤,svd.npy 的行数可能与原词表不一致,越界读取会直接崩或者读到错误行。上面代码里加了if idx < svd_vec.shape[0]做保护,这是血泪经验换来的。

5.2 输出格式是机器判定的红线:字段顺序、分隔符、保留精度

作业里反复强调“因为是机器判定,请一定按格式输出”,这意味着评测脚本不会做任何容错。输出格式的红线包括:行数与 pku_sim_test.txt 保持一致、每行两个词和相似度之间用什么分割、数值保留几位小数、是否允许科学计数法。这些细节在 README.md 里通常有说明,但如果 README 没说,最稳妥的做法是直接照抄 pku_sim_test.txt 的原始格式,把原来的第二个词替换成相似度数值。

# 输出结果:保持原文件格式,将相似度写在行尾 with open('svd_result.txt', 'w', encoding='utf-8') as f: f.write('\n'.join(f'{sim:.6f}' for sim in results))

我一般保留 6 位小数,因为 4 位小数在相似度数值很接近时会把排序并列,机器评测可能会按精确值判分。另外,有些同学会把相似度输出成逗号分隔或加了方括号,这几乎是必挂的失误。我的建议是:先跑一遍脚本,然后人工抽查输出文件的前 5 行,确认每行只有一个数值、行数正确、没有多余的引号或空格。

5.3 避坑:五条实测踩坑记录,按现象到原因到解决写

第一个坑是未登录词被错误地赋予了随机向量。现象是某些行的相似度结果异常高,而且是荒谬的高(比如两个完全无关的子词相似度 0.9)。原因是代码里用subword_to_vec.get(w1, default_random_vec)做了兜底,缺失词没有置 0 而是给了随机数。正确的做法是 get 不到就返回 None,再让 cosine_sim 返回 0.0。

第二个坑是 SVD 与 SGNS 的词表不一致。现象是两条路线跑出来的结果行数一样,但同一行两个算法给出的相似度差异大到离谱。原因是 svd.py 过滤了低频词,但 skip_gram.py 没过滤,导致同一个子词在两条路线里的 id 完全不同。解决方法是让两条路线共用一份 processed_data.pkl,谁都不许自己临时改词表。

第三个坑是训练时窗口跨句。现象是 SVD 和 SGNS 的向量在某些高频子词上表现极差,相似度普遍偏高。原因是语料按行存句子,但训练代码把整个文件当成一个长序列,跨句子的窗口把不该共现的词也算进去了。解决方法是逐句处理,每句开头重置窗口。

第四个坑是保存的向量没有归一化,导致后续评测脚本里余弦相似度计算时出现除零。现象是某些行输出 NaN。原因是某个词的向量全零或者 norm 极小。解决方法是计算前检查 norm 是否为 0,以及训练完成后检查是否有未更新的 embedding 行——尤其是 SGNS 里低频子词可能一个正样本都没出现过。

第五个坑是输出的小数位数不一致导致机器判定格式错误。现象是程序没报错,但得分是 0。原因是手写输出时有的行用了:.6f,有的行用了默认的str(float),导致文件里混着0.1234560.12345600000000001两种格式。解决方法是统一用格式化字符串写文件,并且在写完后用脚本校验每一行是否匹配预期正则。

6. 收尾验证:用评测集里的“极端词”检验向量质量,以及维度选择的直觉

当 svd_result.txt 和 sgns_result.txt 都按格式输出之后,真正的验证才刚开始。机器评测只给一个总分,但你要搞清楚分数是从哪里丢的。我的做法是把 pku_sim_test.txt 里的子词分成三类:高频常见词、低频稀有词、完全未登录词。前两类是得分主力,第三类全是 0 分,它们的占比直接决定了你的分数上限。

subwords_in_test = set() with open('pku_sim_test.txt', 'r', encoding='utf-8') as f: for line in f: w1, w2 = line.strip().split()[:2] subwords_in_test.add(w1) subwords_in_test.add(w2) covered_svd = sum(1 for w in subwords_in_test if w in subword_to_vec) covered_sgns = sum(1 for w in subwords_in_test if w in sgns_subword_to_vec) print(f'SVD 覆盖率: {covered_svd}/{len(subwords_in_test)}') print(f'SGNS 覆盖率: {covered_sgns}/{len(subwords_in_test)}')

这一步能快速定位问题:如果两个算法的覆盖率都低,说明 preprocess 阶段词表出了问题;如果 SVD 覆盖率低但 SGNS 高,说明 svd.py 的过滤阈值太激进;反过来则是 SGNS 的词表构建漏了词。我当初跑的时候,SVD 覆盖率比 SGNS 低了 3%,就是因为 svd.py 里多了一句按频次过滤,过滤阈值还设成了 5。把那行删掉之后,覆盖率基本对齐,总分的提升立竿见影。

维度选择的直觉也很重要。我分别用 64、128、256 三种维度跑过这两条路线,结论是:SVD 对维度更敏感,64 维时高频词的相似度区分度明显不足,256 维和 128 维的评测分数差距不大,但训练时间几乎翻倍;SGNS 对维度的容忍度更高,128 维是性价比最好的点。如果你不想为这组参数纠结,直接用 128 维就行——这个结论在这个规模的汉语子词语料上反复验证过,不是玄学。

最后还有一个我一直保留的验证习惯:抽 10 对人类明显有语义关系的子词对和 10 对明显无关的,手动比较 SVD 和 SGNS 给出的相似度。无关对的相似度如果超过 0.6,说明训练还没收敛或者语料有问题;语义对的相似度如果低于 0.2,说明窗口或维度设置不合理。从那以后,我每次跑完词向量作业都会强制走一遍这个手动抽检流程,它能捕捉到评测脚本算不出、但人一眼就能看出的向量退化问题。希望这份拆解和坑位清单帮到你,尤其是在格式判定这最后一关别栽跟头。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询