ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于SVD与SGNS的汉语子词向量构建与相似度评测实战

基于SVD与SGNS的汉语子词向量构建与相似度评测实战 简介这份资源面向自然语言处理课程学习者与词向量入门者围绕汉语子词向量构建与相似度评测展开提供基于SVD分解和基于SGNS两种方法的完整Python实现。压缩包共15个文件以py脚本、txt数据与结果文件为主另含ipynb预处理笔记、npy与pth模型文件及md说明整体约88.66MB。内容覆盖子词词表读取、corpus语料训练、K5高维表示与SVD降维、窗口K2的SGNS训练以及pku_sim_test逐行余弦相似度计算并对未登录词统一置零处理最终输出svd_result与sgns_result等评测结果。已有112人学习下载适合希望对照两种经典方法理解静态词向量差异、复现实验流程并完成作业评测的读者参考。1. 从一份 NLP 作业源码包说起SVD 与 SGNS 构建汉语子词向量到底怎么落地如果你正在做中文 NLP 的词向量入门作业或者想找一个能直接跑通「子词级向量训练 相似度评测」全流程的 Python 源码包这份资源值得拆开看。它把两条经典路线放在同一个工程里一条是基于 SVD 分解的分布矩阵降维另一条是基于 SGNSSkip-Gram with Negative Sampling的神经网络训练。语料用的是第一次作业产出的 BPE 子词词表评测集是 pku_sim_test.txt输出格式有硬性要求机器判分格式错一位就白跑。适合谁刚接触 nlp 自然语言处理、需要交作业的学生以及想快速对比「矩阵分解 vs 神经嵌入」两种范式差异的从业者。下面按「资源是什么 → 怎么用 → 坑在哪」的顺序拆。2. 资源结构与运行环境先看清每个文件在流程里的位置2.1 文件清单与职责划分拿到压缩包解压后目录里不是一堆散乱脚本而是按「预处理 → 训练 → 评测」三段式组织的。先看核心文件文件类型在流程中的职责corpus.txt语料训练与测试集并集分词后的纯文本train_BPE.txt / test_BPE.txt数据第一次作业产出的 BPE 子词切分结果preprocess.ipynbNotebook语料清洗、子词词表构建、共现统计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 python3.9 -y conda activate subword_vec # 安装核心依赖 pip install numpy scikit-learn torch jieba tqdm参数说明python3.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, encodingutf-8) as f: lines f.readlines() print(f语料行数: {len(lines)}) # 统计评测集中有多少词不在语料里 with open(test_path, r, encodingutf-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 分解的子词向量K5 共现矩阵怎么建、怎么降维3.1 分布表示的原理与 K5 窗口选择SVD 路线的核心思想是「分布假设」一个子词的含义由它周围出现的上下文决定。具体做法是滑动窗口统计共现窗口大小 K5也就是每个中心词左右各取 5 个词作为上下文。为什么是 5作业要求写死了 K5实际工程里 5 到 10 都常见窗口越大语义越粗越小句法越强。K5 在中文子词场景下是个折中既能捕捉搭配又不会把矩阵撑得太大。共现矩阵的维度是「子词表大小 × 子词表大小」。假设词表有 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, encodingutf-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. 统计共现窗口 K5 K 5 cooc lil_matrix((vocab_size, vocab_size), dtypenp.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_components100, random_state42) 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_state42 保证可复现。vec_sta 就是每个子词的静态向量行号对应 vocab 里的索引。参数说明K5 是作业硬性要求不要改。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, encodingutf-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, encodingutf-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, encodingutf-8) as f: for sim in results: f.write(f{sim:.6f}\n)这段代码里cosine_sim 对零向量做了保护避免除零。未登录词直接给 0.0符合作业要求。输出保留 6 位小数格式稳定。注意 pku_sim_test.txt 的列顺序要和输出行一一对应不要打乱。4. 基于 SGNS 的子词向量窗口 K2 的 Skip-Gram 与负采样实现4.1 SGNS 原理与 K2 窗口的取舍SGNS 是 word2vec 的经典训练方式用中心词预测上下文词配合负采样降低计算量。作业要求窗口 K2比 SVD 的 K5 小。为什么SGNS 本身对窗口更敏感K2 偏向捕捉句法邻近关系训练也更快。子词向量维数自定我一般取 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, encodingutf-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. 生成训练对窗口 K2 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, dim1) 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), dim1) return (pos_loss neg_loss).mean() # 5. 训练 embed_dim 100 model SGNS(vocab_size, embed_dim) optimizer optim.Adam(model.parameters(), lr0.003) n_neg 5 batch_size 512 epochs 5 # 负采样分布按词频的 3/4 次方 freqs np.array([counter[idx2word[i]] for i in range(vocab_size)], dtypenp.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], dtypetorch.long) contexts torch.tensor([p[1] for p in batch], dtypetorch.long) negatives torch.tensor( np.random.choice(vocab_size, size(len(batch), n_neg), pnoise_dist), dtypetorch.long ) optimizer.zero_grad() loss model(centers, contexts, negatives) loss.backward() optimizer.step() total_loss loss.item() print(fEpoch {epoch1}, 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 步生成 (中心词, 上下文词) 对K2 意味着每个中心词最多 4 个上下文。第 4 步定义两个嵌入矩阵in_embed 是最终要用的子词向量out_embed 只在训练中做上下文表示。第 5 步负采样按词频 3/4 次方分布这是 word2vec 的标准做法能平衡高频和低频词。batch_size512、lr0.003、epochs5 是作业级语料的稳妥配置。参数说明embed_dim100 可调和 SVD 对齐。n_neg5 是负采样数量太小梯度噪声大太大训练慢。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, encodingutf-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, encodingutf-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, encodingutf-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 结果里某些行是 0SGNS 里同样的词却有非零值。原因两种方法的词表构建方式不同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(fSVD 与 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, encodingutf-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, axis1, keepdimsTrue) norms[norms 0] 1.0 return vecs / norms vec_sta normalize(np.load(svd.npy)) vec_sgns normalize(np.load(sgns_vec.npy))归一化后余弦相似度就是点积计算更快也避免量纲差异干扰对比。从那以后我每次做向量评测都强制先归一化再算相似度这个习惯帮我省了很多「为什么两个方法结果差这么多」的排查时间。希望帮到你。本文还有配套的精品资源点击获取
返回列表