简介:这份资源面向自然语言处理课程学习者与词向量入门者,围绕汉语子词向量构建与相似度评测展开,提供基于SVD分解和基于SGNS两种方法的完整Python实现。压缩包共15个文件,以py脚本、txt数据与结果文件为主,另含ipynb预处理笔记、npy与pth模型文件及md说明,整体约88.66MB。内容覆盖子词词表读取、corpus语料训练、K=5高维表示与SVD降维、窗口K=2的SGNS训练,以及pku_sim_test逐行余弦相似度计算,并对未登录词统一置零处理,最终输出svd_result与sgns_result等评测结果。已有112人学习下载,适合希望对照两种经典方法理解静态词向量差异、复现实验流程并完成作业评测的读者参考。
1. 从一份 NLP 作业源码包说起:SVD 与 SGNS 构建汉语子词向量到底怎么落地
如果你正在做中文 NLP 的词向量入门作业,或者想找一个能直接跑通「子词级向量训练 + 相似度评测」全流程的 Python 源码包,这份资源值得拆开看。它把两条经典路线放在同一个工程里:一条是基于 SVD 分解的分布矩阵降维,另一条是基于 SGNS(Skip-Gram with Negative Sampling)的神经网络训练。语料用的是第一次作业产出的 BPE 子词词表,评测集是 pku_sim_test.txt,输出格式有硬性要求,机器判分,格式错一位就白跑。适合谁?刚接触 nlp 自然语言处理、需要交作业的学生,以及想快速对比「矩阵分解 vs 神经嵌入」两种范式差异的从业者。下面按「资源是什么 → 怎么用 → 坑在哪」的顺序拆。
2. 资源结构与运行环境:先看清每个文件在流程里的位置
2.1 文件清单与职责划分
拿到压缩包解压后,目录里不是一堆散乱脚本,而是按「预处理 → 训练 → 评测」三段式组织的。先看核心文件:
| 文件 | 类型 | 在流程中的职责 |
|---|---|---|
| corpus.txt | 语料 | 训练与测试集并集,分词后的纯文本 |
| train_BPE.txt / test_BPE.txt | 数据 | 第一次作业产出的 BPE 子词切分结果 |
| preprocess.ipynb | Notebook | 语料清洗、子词词表构建、共现统计 |
| svd.py | 脚本 | 构建高维分布表示并做 SVD 降维 |
| skip_gram.py / sgns.py | 脚本 | SGNS 模型定义与训练循环 |
| result.py | 脚本 | 读取两种向量,计算余弦相似度并输出 |
| svd.npy / SGNS.pth | 模型产物 | 训练好的向量矩阵与模型权重 |
| pku_sim_test.txt | 评测集 | 每行两个子词,需输出相似度 |
| total_result.txt | 输出 | 最终评测结果,格式敏感 |
这个结构的好处是职责清晰:preprocess.ipynb 负责把原始语料变成子词序列和共现矩阵,svd.py 和 sgns.py 各自独立训练,result.py 统一评测。你不需要改训练逻辑,只要保证数据路径和输出格式对。
2.2 环境依赖与安装
常见做法是用 conda 建一个干净环境,避免和系统 Python 冲突。依赖不多,主要是 numpy、torch、scikit-learn、jieba(如果语料需要重新分词)。
# 创建并激活环境 conda create -n subword_vec python=3.9 -y conda activate subword_vec # 安装核心依赖 pip install numpy scikit-learn torch jieba tqdm参数说明:python=3.9 是稳妥选择,torch 版本用 CPU 版即可,SGNS 在这个数据规模下不需要 GPU。如果你的机器有 CUDA,装 GPU 版会快一些,但作业级语料 CPU 完全够。装完后建议先跑python -c "import torch; print(torch.__version__)"确认没有报错。
2.3 数据准备与路径确认
corpus.txt 是训练和测试集的并集,评测时不区分。如果计算资源不够,可以选子集,但要注意:选子集后 pku_sim_test.txt 里未出现的词,相似度必须置 0,这是硬规则。先确认几个路径:
# 检查语料规模与子词覆盖情况 import os corpus_path = "corpus.txt" test_path = "pku_sim_test.txt" with open(corpus_path, "r", encoding="utf-8") as f: lines = f.readlines() print(f"语料行数: {len(lines)}") # 统计评测集中有多少词不在语料里 with open(test_path, "r", encoding="utf-8") as f: test_pairs = [line.strip().split() for line in f if line.strip()] corpus_text = "".join(lines) missing = 0 for pair in test_pairs: for word in pair: if word not in corpus_text: missing += 1 print(f"评测集中未登录词次: {missing}")这段代码的作用是提前暴露「未登录词」规模。如果 missing 占比很高,说明语料子集选得太小,SVD 和 SGNS 都会大量输出 0,评测分数会很难看。我一般会保证语料覆盖评测集 90% 以上的子词再开始训练。
3. 基于 SVD 分解的子词向量:K=5 共现矩阵怎么建、怎么降维
3.1 分布表示的原理与 K=5 窗口选择
SVD 路线的核心思想是「分布假设」:一个子词的含义由它周围出现的上下文决定。具体做法是滑动窗口统计共现,窗口大小 K=5,也就是每个中心词左右各取 5 个词作为上下文。为什么是 5?作业要求写死了 K=5,实际工程里 5 到 10 都常见,窗口越大语义越粗,越小句法越强。K=5 在中文子词场景下是个折中,既能捕捉搭配,又不会把矩阵撑得太大。
共现矩阵的维度是「子词表大小 × 子词表大小」。假设词表有 8000 个子词,矩阵就是 8000×8000,稀疏度很高。直接对稀疏矩阵做 SVD 会很慢,常见做法是先转成稀疏格式,再用 scikit-learn 的 TruncatedSVD,它内部用随机化算法,比 numpy 的完整 SVD 快一个量级。
3.2 共现矩阵构建与 SVD 降维代码
import numpy as np from collections import defaultdict from sklearn.decomposition import TruncatedSVD from scipy.sparse import lil_matrix # 1. 读取子词序列(这里假设 train_BPE.txt 每行是一个子词) with open("train_BPE.txt", "r", encoding="utf-8") as f: tokens = [line.strip() for line in f if line.strip()] # 2. 构建词表 vocab = {w: i for i, w in enumerate(set(tokens))} vocab_size = len(vocab) print(f"词表大小: {vocab_size}") # 3. 统计共现,窗口 K=5 K = 5 cooc = lil_matrix((vocab_size, vocab_size), dtype=np.float32) for i, center in enumerate(tokens): center_id = vocab[center] left = max(0, i - K) right = min(len(tokens), i + K + 1) for j in range(left, right): if j == i: continue context_id = vocab[tokens[j]] cooc[center_id, context_id] += 1 # 4. SVD 降维,维数自定,这里取 100 svd = TruncatedSVD(n_components=100, random_state=42) vec_sta = svd.fit_transform(cooc) print(f"降维后形状: {vec_sta.shape}") # 5. 保存 np.save("svd.npy", vec_sta)逻辑说明:第 3 步用 lil_matrix 逐行累加,适合稀疏场景;第 4 步 TruncatedSVD 的 n_components 就是降维后的维数,作业说「自定」,我一般取 100,和 SGNS 的维数对齐方便对比。random_state=42 保证可复现。vec_sta 就是每个子词的静态向量,行号对应 vocab 里的索引。
参数说明:K=5 是作业硬性要求,不要改。n_components 可以调,50 到 300 都行,太小会欠拟合,太大会过拟合且慢。cooc 矩阵如果内存吃紧,可以只保留频次大于阈值的项,但作业级数据一般不需要。
3.3 相似度计算与未登录词处理
拿到 vec_sta 后,评测逻辑是:对 pku_sim_test.txt 每一行的两个子词,查向量算余弦相似度。关键坑在于未登录词——如果某个子词没在语料里出现,它就没有向量,这时该行的 sim_svd 必须置 0,不能跳过也不能报错。
import numpy as np def cosine_sim(a, b): norm_a = np.linalg.norm(a) norm_b = np.linalg.norm(b) if norm_a == 0 or norm_b == 0: return 0.0 return float(np.dot(a, b) / (norm_a * norm_b)) vec_sta = np.load("svd.npy") with open("train_BPE.txt", "r", encoding="utf-8") as f: tokens = [line.strip() for line in f if line.strip()] vocab = {w: i for i, w in enumerate(set(tokens))} 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] if w1 in vocab and w2 in vocab: sim = cosine_sim(vec_sta[vocab[w1]], vec_sta[vocab[w2]]) else: sim = 0.0 results.append(sim) # 按格式输出,每行一个相似度 with open("svd_result.txt", "w", encoding="utf-8") as f: for sim in results: f.write(f"{sim:.6f}\n")这段代码里,cosine_sim 对零向量做了保护,避免除零。未登录词直接给 0.0,符合作业要求。输出保留 6 位小数,格式稳定。注意 pku_sim_test.txt 的列顺序要和输出行一一对应,不要打乱。
4. 基于 SGNS 的子词向量:窗口 K=2 的 Skip-Gram 与负采样实现
4.1 SGNS 原理与 K=2 窗口的取舍
SGNS 是 word2vec 的经典训练方式:用中心词预测上下文词,配合负采样降低计算量。作业要求窗口 K=2,比 SVD 的 K=5 小。为什么?SGNS 本身对窗口更敏感,K=2 偏向捕捉句法邻近关系,训练也更快。子词向量维数自定,我一般取 100,和 SVD 对齐,方便对比两种方法的评测差异。
SGNS 和 SVD 的本质区别:SVD 是对全局共现矩阵做一次性分解,得到的是静态的、确定性的向量;SGNS 是随机梯度下降逐样本更新,得到的是分布式表示,对低频词更友好,但训练有随机性,需要设随机种子。
4.2 模型定义与训练循环
import torch import torch.nn as nn import torch.optim as optim import numpy as np from collections import Counter # 1. 读取子词序列 with open("train_BPE.txt", "r", encoding="utf-8") as f: tokens = [line.strip() for line in f if line.strip()] # 2. 词表与索引 counter = Counter(tokens) vocab = {w: i for i, (w, _) in enumerate(counter.most_common())} idx2word = {i: w for w, i in vocab.items()} vocab_size = len(vocab) print(f"词表大小: {vocab_size}") # 3. 生成训练对,窗口 K=2 K = 2 pairs = [] for i, center in enumerate(tokens): center_id = vocab[center] left = max(0, i - K) right = min(len(tokens), i + K + 1) for j in range(left, right): if j == i: continue pairs.append((center_id, vocab[tokens[j]])) print(f"训练对数量: {len(pairs)}") # 4. SGNS 模型 class SGNS(nn.Module): def __init__(self, vocab_size, embed_dim): super().__init__() self.in_embed = nn.Embedding(vocab_size, embed_dim) self.out_embed = nn.Embedding(vocab_size, embed_dim) init_range = 0.5 / embed_dim self.in_embed.weight.data.uniform_(-init_range, init_range) self.out_embed.weight.data.uniform_(-init_range, init_range) def forward(self, center, context, negatives): # center: (B,), context: (B,), negatives: (B, N) v = self.in_embed(center) # (B, D) u_pos = self.out_embed(context) # (B, D) u_neg = self.out_embed(negatives) # (B, N, D) pos_score = torch.sum(v * u_pos, dim=1) pos_loss = -torch.log(torch.sigmoid(pos_score) + 1e-8) neg_score = torch.bmm(u_neg, v.unsqueeze(2)).squeeze(2) # (B, N) neg_loss = -torch.sum(torch.log(torch.sigmoid(-neg_score) + 1e-8), dim=1) return (pos_loss + neg_loss).mean() # 5. 训练 embed_dim = 100 model = SGNS(vocab_size, embed_dim) optimizer = optim.Adam(model.parameters(), lr=0.003) n_neg = 5 batch_size = 512 epochs = 5 # 负采样分布,按词频的 3/4 次方 freqs = np.array([counter[idx2word[i]] for i in range(vocab_size)], dtype=np.float64) noise_dist = freqs ** 0.75 noise_dist = noise_dist / noise_dist.sum() for epoch in range(epochs): np.random.shuffle(pairs) total_loss = 0.0 for start in range(0, len(pairs), batch_size): batch = pairs[start:start + batch_size] centers = torch.tensor([p[0] for p in batch], dtype=torch.long) contexts = torch.tensor([p[1] for p in batch], dtype=torch.long) negatives = torch.tensor( np.random.choice(vocab_size, size=(len(batch), n_neg), p=noise_dist), dtype=torch.long ) optimizer.zero_grad() loss = model(centers, contexts, negatives) loss.backward() optimizer.step() total_loss += loss.item() print(f"Epoch {epoch+1}, Loss: {total_loss:.4f}") # 6. 保存 torch.save(model.state_dict(), "SGNS.pth") vec_sgns = model.in_embed.weight.data.numpy() np.save("sgns_vec.npy", vec_sgns)逻辑说明:第 3 步生成 (中心词, 上下文词) 对,K=2 意味着每个中心词最多 4 个上下文。第 4 步定义两个嵌入矩阵,in_embed 是最终要用的子词向量,out_embed 只在训练中做上下文表示。第 5 步负采样按词频 3/4 次方分布,这是 word2vec 的标准做法,能平衡高频和低频词。batch_size=512、lr=0.003、epochs=5 是作业级语料的稳妥配置。
参数说明:embed_dim=100 可调,和 SVD 对齐。n_neg=5 是负采样数量,太小梯度噪声大,太大训练慢。epochs 看语料规模,如果 loss 还在明显下降可以加,但注意过拟合。
4.3 评测输出与格式对齐
SGNS 的评测逻辑和 SVD 一样,只是向量来源不同。注意 vec_sgns 的行号对应 vocab 的索引,未登录词同样置 0。
import numpy as np vec_sgns = np.load("sgns_vec.npy") with open("train_BPE.txt", "r", encoding="utf-8") as f: tokens = [line.strip() for line in f if line.strip()] vocab = {w: i for i, w in enumerate(set(tokens))} def cosine_sim(a, b): na, nb = np.linalg.norm(a), np.linalg.norm(b) if na == 0 or nb == 0: return 0.0 return float(np.dot(a, b) / (na * nb)) 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] if w1 in vocab and w2 in vocab: sim = cosine_sim(vec_sgns[vocab[w1]], vec_sgns[vocab[w2]]) else: sim = 0.0 results.append(sim) with open("sgns_result.txt", "w", encoding="utf-8") as f: for sim in results: f.write(f"{sim:.6f}\n")这里有个容易翻车的点:SVD 和 SGNS 的词表构建方式必须一致,否则同一个子词在两种方法里的索引不同,评测结果没法对比。我一般会把词表单独存成 json,两边共用。
5. 避坑与排查:格式、未登录词、随机性这三类问题最容易翻车
5.1 输出格式错位导致机器判分全错
现象:total_result.txt 提交后分数为 0,但本地看相似度数值都正常。原因:pku_sim_test.txt 每行两个词,输出要求每行一个相似度,且行数严格对应。如果中间跳过了空行或注释行,行数就对不上。解决:读评测集时不要做任何过滤,line.strip().split()后只要长度大于等于 2 就处理,输出时严格按读入顺序写,不额外加表头或空行。
5.2 未登录词处理不一致
现象:SVD 结果里某些行是 0,SGNS 里同样的词却有非零值。原因:两种方法的词表构建方式不同,SVD 用 set(tokens),SGNS 用 Counter.most_common(),如果语料里有重复或顺序差异,索引会错位。解决:统一词表构建逻辑,先存一份 vocab.json,两个脚本都读同一份。未登录词判断用w in vocab,不要用字符串包含判断。
5.3 SGNS 训练不收敛或 loss 震荡
现象:loss 一直在 4.0 以上不降,或者上下剧烈震荡。原因:学习率太大、负采样分布不对、或者 batch 里正样本太少。解决:先把 lr 降到 0.001 试,负采样确认用 3/4 次方分布,batch_size 不要小于 256。如果语料很小,epochs 可以加到 10,但要注意过拟合。
5.4 SVD 降维后向量全为零
现象:svd.npy 里大部分行是 0,评测相似度全是 0。原因:共现矩阵太稀疏,TruncatedSVD 的 n_components 设得比有效秩还大,或者语料子集选得太小。解决:先检查 cooc 矩阵的非零元素数量,如果非零元素少于 n_components×10,就减小 n_components 或扩大语料。另外确认 lil_matrix 累加时没有溢出。
5.5 随机种子未固定导致结果不可复现
现象:每次跑 SGNS 得到的评测分数都不一样。原因:负采样和参数初始化都用了随机数,没设种子。解决:在训练脚本开头加np.random.seed(42)和torch.manual_seed(42),DataLoader 如果用了 shuffle 也要设 generator。SVD 的 TruncatedSVD 有 random_state 参数,同样要固定。
6. 进阶技巧:把两种向量拼起来做对比评测与误差分析
跑通两条路线后,真正有价值的是对比分析。我一般会写一个汇总脚本,把 svd_result.txt 和 sgns_result.txt 按行对齐,算两者的相关系数,再挑出差异最大的样本人工看。
import numpy as np svd_sims = np.loadtxt("svd_result.txt") sgns_sims = np.loadtxt("sgns_result.txt") # 整体相关性 corr = np.corrcoef(svd_sims, sgns_sims)[0, 1] print(f"SVD 与 SGNS 相似度相关系数: {corr:.4f}") # 找差异最大的前 10 行 diff = np.abs(svd_sims - sgns_sims) top_idx = np.argsort(diff)[-10:][::-1] with open("pku_sim_test.txt", "r", encoding="utf-8") as f: lines = [line.strip() for line in f if line.strip()] print("\n差异最大的样本:") for i in top_idx: print(f"{lines[i]} | SVD={svd_sims[i]:.4f} | SGNS={sgns_sims[i]:.4f}")这段代码能帮你快速定位两种方法的系统性差异。常见规律是:高频子词两者接近,低频子词 SGNS 往往更好,因为 SVD 对稀疏共现的估计方差大。如果相关系数低于 0.5,说明两种方法的向量空间差异很大,这时候可以考虑把两种向量拼接后重新评测,有时候能涨点。
还有一个实用技巧:SVD 的 vec_sta 可以做 L2 归一化后再算相似度,SGNS 的向量本身量纲不同,归一化后对比更公平。我习惯在评测前统一做一次归一化:
def normalize(vecs): norms = np.linalg.norm(vecs, axis=1, keepdims=True) norms[norms == 0] = 1.0 return vecs / norms vec_sta = normalize(np.load("svd.npy")) vec_sgns = normalize(np.load("sgns_vec.npy"))归一化后余弦相似度就是点积,计算更快,也避免量纲差异干扰对比。从那以后我每次做向量评测,都强制先归一化再算相似度,这个习惯帮我省了很多「为什么两个方法结果差这么多」的排查时间。希望帮到你。
本文还有配套的精品资源,点击获取