简介:本资源面向自然语言处理课程学习者与词向量入门研究者,围绕汉语子词向量构建与相似度评测展开,提供基于SVD分解与基于SGNS两种方法的完整Python实现。语料采用训练集与测试集并集,SVD方法取K=5获取高维分布表示后降维得到vec_sta,SGNS方法窗口K=2训练得到vec_sgns,并分别计算pku_sim_test中词对的余弦相似度,未登录词按0处理,输出格式严格对齐机器评测要求。压缩包共15个文件,以7个txt数据与结果文件、4个py源码脚本为主,另含1个ipynb预处理笔记、1个md说明文档及npy、pth模型文件,整体约88.66MB。已有112人学习下载。读者可据此复现两种经典词向量方案,理解子词建模、降维与相似度评测的完整流程,并借助现成脚本与模型文件快速验证与排错。
1. 从一份 NLP 作业源码包说起:SVD 与 SGNS 构建汉语子词向量到底怎么落地
如果你手头正缺一份能直接跑通、还能对照评测的汉语子词向量代码,这个nlp作业分别基于SVD分解以及基于SGNS两种方法构建汉语子词向量并进行评测python源码.zip值得先拆开看。它把自然语言处理里两条经典路线——基于 SVD 分解的分布表示和基于 SGNS 的跳字模型——放在同一份语料、同一套子词表、同一个评测集上做对照,输出svd_result.txt和sgns_result.txt两份结果。适合正在做课程作业、想复现词向量训练流程、或者需要一套可改参数的 baseline 的从业者。语料用的是corpus.txt,评测集是pku_sim_test.txt,词表来自 BPE 子词切分,整条链路从预处理到评测都齐了。
2. 拆包先看数据流:BPE 子词表、语料与评测集怎么对齐
2.1 文件清单与各自职责
拿到压缩包先别急着跑result.py,把目录结构过一遍能省掉后面一半的报错。根目录下大致是这些文件:
| 文件 | 作用 | 备注 |
|---|---|---|
corpus.txt | 训练语料 | 第一次作业训练集与测试集并集 |
train_BPE.txt/test_BPE.txt | BPE 切分后的训练/测试文本 | 子词以空格分隔 |
pku_sim_test.txt | 评测集 | 每行两个词,算余弦相似度 |
svd.py/sgns.py | 两种方法的核心实现 | 分别产出svd.npy/SGNS.pth |
skip_gram.py | SGNS 的跳字采样逻辑 | 被sgns.py调用 |
preprocess.ipynb | 预处理与词表构建 | 生成子词到 id 的映射 |
result.py | 统一评测入口 | 读两个结果文件算指标 |
svd_result.txt/sgns_result.txt | 输出结果 | 格式必须与评测集行对齐 |
total_result.txt | 汇总对比 | 两种方法的最终得分 |
这里最容易翻车的是pku_sim_test.txt和两个*_result.txt的行对齐。评测是机器判定的,行数、行序、每行两个相似度值必须严格对应,多一行少一行都会让分数直接归零。
2.2 子词表与语料的对应关系
BPE 子词表决定了模型能表示哪些单元。train_BPE.txt里每个词已经被切成子词片段,比如「自然语言」可能变成「自然」「语」「言」这样的组合。构建向量时,词表就是这些子词片段的集合,而不是原始汉字表。语料corpus.txt是未切分的原始文本,真正喂给模型的是切分后的train_BPE.txt。
常见做法是先用preprocess.ipynb统计子词频率,过滤掉低频片段,再建立subword -> index的字典。这个字典要同时被 SVD 和 SGNS 两条线复用,否则两边向量空间对不上,评测时同一行两个词可能一个在词表内、一个在词表外,相似度直接按 0 处理,分数会莫名其妙地低。
提示:先确认
train_BPE.txt和test_BPE.txt的切分粒度一致,再动模型代码。切分不一致是后面相似度对不上的头号原因。
2.3 评测集的读取与缺失词处理
pku_sim_test.txt每行两个词,中间用空格或制表符分隔。读取时按行 split,取前两个 token 作为待比较词对。作业要求里写得很明确:当某个词没有获得向量时,该行相似度置 0。这一步必须在评测脚本里显式判断,不能靠异常吞掉。
# 读取评测集并做缺失词兜底 def load_pairs(path): pairs = [] with open(path, encoding='utf-8') as f: for line in f: parts = line.strip().split() if len(parts) < 2: continue pairs.append((parts[0], parts[1])) return pairs def safe_cosine(vec_a, vec_b): # 任一向量缺失,按作业要求返回 0 if vec_a is None or vec_b is None: return 0.0 na = np.linalg.norm(vec_a) nb = np.linalg.norm(vec_b) if na == 0 or nb == 0: return 0.0 return float(np.dot(vec_a, vec_b) / (na * nb))load_pairs负责把评测集读成词对列表,safe_cosine在计算前先做缺失判断和零范数保护。参数上唯一要注意的是分隔符,如果评测集用的是制表符而代码按空格 split,会出现整行被当成一个 token 的情况,词对数量直接减半。
3. 基于 SVD 分解的子词向量:K=5 高维表示与降维实操
3.1 为什么先做高维分布表示再降维
SVD 这条线的思路是先把每个子词表示成一个高维的分布向量,再用奇异值分解压到低维。作业里明确 K=5,意思是统计每个子词周围窗口大小为 5 的上下文共现,形成一个「子词 × 上下文」的稀疏矩阵。这个矩阵的行是子词,列是上下文子词,值可以是共现次数或 PPMI 加权后的值。
直接用原始共现矩阵做相似度计算,维度高、稀疏、噪声大,效果通常不稳定。SVD 降维的作用是把共现信息压缩到稠密低维空间,同时保留主要的语义结构。降维后的维数作业里说「自定」,常见做法是取 100 到 300 之间,语料小的时候取 100 就够,语料大可以到 300。
3.2 共现矩阵构建与 PPMI 加权
构建共现矩阵时,窗口 K=5 表示当前子词左右各看 5 个位置。遍历train_BPE.txt的每一行,对每个位置 i,取[i-5, i+5]范围内的其他子词作为上下文,累加计数。得到计数矩阵后,通常转成 PPMI(正点互信息)再做 SVD,效果比裸计数好。
import numpy as np from collections import defaultdict def build_cooccurrence(sentences, window=5): # 先建立子词到 id 的映射 vocab = {} for sent in sentences: for w in sent: if w not in vocab: vocab[w] = len(vocab) V = len(vocab) cooc = np.zeros((V, V), dtype=np.float64) for sent in sentences: ids = [vocab[w] for w in sent] for i, center in enumerate(ids): left = max(0, i - window) right = min(len(ids), i + window + 1) for j in range(left, right): if j != i: cooc[center, ids[j]] += 1.0 return vocab, cooc def ppmi(matrix, k=1.0): # 正点互信息,负值截断为 0 total = matrix.sum() row_sum = matrix.sum(axis=1, keepdims=True) col_sum = matrix.sum(axis=0, keepdims=True) with np.errstate(divide='ignore', invalid='ignore'): pmi = np.log((matrix * total) / (row_sum * col_sum) + 1e-12) return np.maximum(pmi - np.log(k), 0.0)build_cooccurrence里window=5对应作业的 K=5,cooc是 V×V 的方阵。ppmi里k=1.0是负采样平移系数,一般保持默认。注意np.errstate那行是防止除零警告刷屏,实际计算用1e-12兜底。语料大时这个矩阵会非常占内存,V 上万时 V×V 就是上亿浮点数,常见做法是改用稀疏矩阵scipy.sparse存储。
3.3 SVD 降维与向量导出
拿到 PPMI 矩阵后做截断 SVD,取前 d 个奇异值对应的左奇异向量作为子词向量。d 就是降维后的维数,作业里自定,我一般取 100。svd.py里最终会把向量存成svd.npy,方便result.py直接加载。
from scipy.sparse.linalg import svds def train_svd(cooc, dim=100): # 对 PPMI 矩阵做截断 SVD u, s, vt = svds(cooc, k=dim) # 按奇异值从大到小排序 order = np.argsort(-s) u = u[:, order] s = s[order] # 用 sqrt(s) 缩放,等价于对称归一化 vecs = u * np.sqrt(s) return vecssvds的k=dim就是降维目标维数,返回的u是左奇异向量。乘sqrt(s)是一种常见的对称归一化,让向量内积更接近原始共现关系。导出时按vocab的顺序保存,保证svd.npy第 i 行对应第 i 个子词。如果后面评测发现相似度整体偏低,先检查这里有没有做归一化,以及vocab顺序是否和保存时一致。
3.4 相似度计算与结果输出
评测阶段加载svd.npy和vocab,对pku_sim_test.txt每行两个词查向量、算余弦、写结果。输出格式必须和评测集行对齐,每行一个相似度值。
def eval_svd(pairs, vocab, vecs, out_path): with open(out_path, 'w', encoding='utf-8') as f: for a, b in pairs: va = vecs[vocab[a]] if a in vocab else None vb = vecs[vocab[b]] if b in vocab else None sim = safe_cosine(va, vb) f.write(f"{sim:.6f}\n")out_path对应svd_result.txt,每行一个六位小数。这里用safe_cosine复用前面的缺失兜底逻辑。如果某个词不在vocab里,va为 None,相似度写 0,符合作业要求。
4. 基于 SGNS 的子词向量:窗口 K=2 的跳字模型训练与调参
4.1 SGNS 与 SVD 的本质差异
SGNS(Skip-Gram with Negative Sampling)不走共现矩阵那条路,而是把词向量当成可学习参数,用「预测上下文」的任务去训练。窗口 K=2 表示只看当前子词左右各 2 个位置,比 SVD 的 K=5 窄。窄窗口更偏向捕捉句法相似性,宽窗口更偏向语义相似性,这是两条线结果不同的主要原因之一。
SGNS 的优势是不需要显式构建 V×V 矩阵,内存友好,语料大时更实用。代价是训练轮数、负采样数量、学习率这些超参需要调,调不好会出现向量塌缩或者相似度区分度低。
4.2 训练数据构造与负采样
skip_gram.py负责把train_BPE.txt转成 (中心词, 上下文词) 对。窗口 K=2 时,每个中心词最多产生 4 个正样本对。负采样按子词频率的 3/4 次方采样,这是 word2vec 的经典做法。
import random from collections import Counter def build_pairs(sentences, window=2): pairs = [] for sent in sentences: for i, center in enumerate(sent): left = max(0, i - window) right = min(len(sent), i + window + 1) for j in range(left, right): if j != i: pairs.append((center, sent[j])) return pairs def build_neg_sampler(sentences, power=0.75): freq = Counter() for sent in sentences: freq.update(sent) words = list(freq.keys()) weights = np.array([freq[w] ** power for w in words], dtype=np.float64) weights /= weights.sum() return words, weights def sample_negatives(words, weights, k=5): idx = np.random.choice(len(words), size=k, p=weights) return [words[i] for i in idx]build_pairs里window=2对应作业的 K=2。build_neg_sampler的power=0.75是频率平滑指数,调大更偏向高频词,调小更偏向低频词。sample_negatives的k=5是每个正样本配的负样本数,常见取值 5 到 15,语料小取 5 就够。
4.3 模型定义与训练循环
SGNS 模型本质是两个 embedding 矩阵:中心词矩阵和上下文词矩阵。训练时正样本对做 sigmoid 拉近,负样本对做 sigmoid 推远。sgns.py里用 PyTorch 实现,最终把中心词矩阵存成SGNS.pth。
import torch import torch.nn as nn class SGNS(nn.Module): def __init__(self, vocab_size, dim=100): super().__init__() self.center = nn.Embedding(vocab_size, dim) self.context = nn.Embedding(vocab_size, dim) # 初始化范围参考 word2vec 常见做法 init_range = 0.5 / dim self.center.weight.data.uniform_(-init_range, init_range) self.context.weight.data.uniform_(-init_range, init_range) def forward(self, center_ids, context_ids, neg_ids): v = self.center(center_ids) u_pos = self.context(context_ids) u_neg = self.context(neg_ids) pos_score = torch.sum(v * u_pos, dim=1) neg_score = torch.bmm(u_neg, v.unsqueeze(2)).squeeze(2) loss = -torch.log(torch.sigmoid(pos_score) + 1e-10).mean() \ -torch.log(torch.sigmoid(-neg_score) + 1e-10).mean() return lossdim=100是子词向量维数,作业里自定,和 SVD 那条线保持一致方便对比。init_range用0.5/dim是 word2vec 的经典初始化。损失函数里1e-10是防止 log(0)。训练时 batch 内正样本和负样本分开算再合并,注意neg_score的维度要对齐。
4.4 训练超参与结果导出
训练轮数一般 5 到 10 轮,学习率 0.001 到 0.01 之间,优化器用 Adam。语料小的时候轮数可以多些,但要注意过拟合导致相似度区分度下降。导出时只取中心词矩阵,按vocab顺序存成SGNS.pth。
def train_sgns(pairs, vocab, dim=100, epochs=5, lr=0.005, neg_k=5): model = SGNS(len(vocab), dim) optimizer = torch.optim.Adam(model.parameters(), lr=lr) words, weights = build_neg_sampler(...) # 传入切分后的句子 for epoch in range(epochs): random.shuffle(pairs) for center, context in pairs: cid = torch.tensor([vocab[center]]) ctxid = torch.tensor([vocab[context]]) negs = sample_negatives(words, weights, neg_k) nid = torch.tensor([[vocab[w] for w in negs]]) loss = model(cid, ctxid, nid) optimizer.zero_grad() loss.backward() optimizer.step() return model.center.weight.data.numpy()epochs=5、lr=0.005、neg_k=5是一组保守起点。如果 loss 下降很慢,先把 lr 调到 0.01;如果 loss 震荡,把 lr 降到 0.001。导出后同样按pku_sim_test.txt算余弦,写sgns_result.txt,缺失词置 0。
5. 避坑与排查:评测对不齐、向量塌缩、内存爆掉怎么查
5.1 现象:评测分数异常低,接近随机
原因通常是pku_sim_test.txt里的词大量不在vocab中,相似度全被置 0。BPE 切分粒度如果和评测集里的词形式不一致,比如评测集是整词而词表是子词片段,就会出现大面积缺失。
解决:先统计评测集词对在vocab中的命中率,命中率低于 60% 就要回头检查preprocess.ipynb的切分逻辑,确认评测集的词也按同样规则切成了子词再查表。
5.2 现象:SVD 结果全为 0 或 NaN
原因多半是 PPMI 矩阵里有全零行或全零列,SVD 时出现除零或无效值。语料太小、某些子词只出现在固定上下文里,都会导致这种情况。
解决:在ppmi函数里加1e-12兜底,SVD 前检查矩阵是否有全零行列,有就删掉对应子词或加极小扰动。导出向量后再做一次np.nan_to_num清洗。
5.3 现象:SGNS 训练 loss 不降或向量几乎相同
原因可能是学习率过大导致参数发散,或者负采样数量太少导致正负样本区分不开。窗口 K=2 本身信息量有限,语料又小的时候更容易出现。
解决:先把 lr 降到 0.001 观察 loss 是否稳定下降;再把neg_k从 5 提到 10 或 15;如果还不行,检查build_pairs是否真的产生了足够的正样本对,窗口设成 2 时短句可能一对都产生不了。
5.4 现象:构建共现矩阵时内存爆掉
原因是用稠密np.zeros((V, V))存矩阵,V 上万时内存直接吃满。BPE 子词表通常比汉字表大不少,这个问题很常见。
解决:改用scipy.sparse的 COO 或 CSR 格式累加计数,只在做 SVD 前转成稀疏矩阵传入svds。如果语料实在大,先对子词做频率过滤,只保留出现次数超过阈值的片段。
5.5 现象:两个结果文件行数和评测集对不上
原因通常是读取评测集时跳过了空行或格式异常行,而写结果时没有同步跳过,导致行序错位。机器判定按行比对,错一行后面全错。
解决:读和写用同一个pairs列表,读的时候跳过多少行,写的时候就写多少行。写完用wc -l对比pku_sim_test.txt和两个结果文件的行数,三者必须一致。
6. 进阶技巧:把两条线放进同一个评测脚本做对照
跑通单条线之后,真正有价值的是把 SVD 和 SGNS 放进同一个评测流程做对照。result.py和total_result.txt就是干这个的。我一般会写一个统一入口,加载两份向量,对同一批词对分别算相似度,再算和人工标注的 Spearman 相关系数。
from scipy.stats import spearmanr def compare(svd_vecs, sgns_vecs, vocab, pairs, gold): sim_svd, sim_sgns = [], [] for a, b in pairs: va = svd_vecs[vocab[a]] if a in vocab else None vb = svd_vecs[vocab[b]] if b in vocab else None sim_svd.append(safe_cosine(va, vb)) va2 = sgns_vecs[vocab[a]] if a in vocab else None vb2 = sgns_vecs[vocab[b]] if b in vocab else None sim_sgns.append(safe_cosine(va2, vb2)) rho_svd, _ = spearmanr(sim_svd, gold) rho_sgns, _ = spearmanr(sim_sgns, gold) return rho_svd, rho_sgnsgold是评测集里的人工相似度标注,spearmanr算秩相关。这个对照能直接告诉你,在当前语料和参数下哪条线更贴合人工判断。窗口 K 的差异会在这里体现得很明显:K=2 的 SGNS 在句法相似词对上通常占优,K=5 的 SVD 在语义相关词对上往往更稳。
调参时我习惯固定降维维数 d=100,只动窗口和负采样数量,这样对照才有意义。如果两条线都跑不过一个很低的基线,先别怀疑模型,回头查词表命中率和评测集行对齐,这两个坑我踩过不止一次。从那以后我每次跑完都会先wc -l对一遍行数,再统计词表命中率,最后才看分数。希望帮到你。
本文还有配套的精品资源,点击获取