news 2026/10/1 10:53:06

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于SVD与SGNS的汉语子词向量构建与相似度评测实战

简介:这份资源面向自然语言处理课程学习者与词向量入门者,围绕汉语子词向量构建与相似度评测展开,提供基于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.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 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"))

归一化后余弦相似度就是点积,计算更快,也避免量纲差异干扰对比。从那以后我每次做向量评测,都强制先归一化再算相似度,这个习惯帮我省了很多「为什么两个方法结果差这么多」的排查时间。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 10:52:03

Spring Boot健康检查与监控实战:从Actuator到Prometheus+Grafana

1. 为什么要做健康检查与监控&#xff1a;先把"监什么"和"控什么"理清楚 在Spring Boot项目上线之前&#xff0c;很多团队对健康检查的理解就是"服务能启动就行"&#xff0c;对监控的理解就是"看一眼堆内存没爆就行"。但真正到了生产环…

作者头像 李华
网站建设 2026/10/1 10:51:59

PowerShell调Windows亮度的底层原理与实战指南

1. 这不是“调个亮度”那么简单&#xff1a;PowerShell 控制 Windows 显示亮度的本质与实操边界 你搜“windows用powershell调亮度”&#xff0c;点开一堆博客&#xff0c;复制粘贴几行代码&#xff0c;运行报错——“执行策略被阻止”、“找不到WmiObject”、“Set-Brightness…

作者头像 李华
网站建设 2026/10/1 10:51:54

肝癌影像AI诊断全链路:从DICOM到推理结果工程实践

简介&#xff1a;这份资源面向医学影像AI方向的开发者与学习者&#xff0c;提供一套基于深度学习的肝癌影像诊断完整代码方案&#xff0c;适合具备Python基础、希望上手医学图像分割与分类实践的中级读者。压缩包共7个文件&#xff0c;以4个Python脚本为核心&#xff0c;涵盖数…

作者头像 李华
网站建设 2026/10/1 10:51:04

报警延迟两小时?从事件时间到处理时间,彻底排查监控链路积压

早上刚到工位&#xff0c;水还没喝一口&#xff0c;工作群突然一片红——甲方集团的通报直接到项目组全员&#xff0c;措辞很重&#xff1a;你们的系统中午12点就已经大规模异常&#xff0c;为什么到下午两点才发报警&#xff1f;监控是不是形同虚设&#xff1f; 我盯着屏幕愣…

作者头像 李华
网站建设 2026/10/1 10:50:28

Flink Agents源码解析:ActionTask执行链设计与状态恢复机制

把 Flink Agents 的源码一路读到第 6 篇&#xff0c;我终于遇到了这个系列里第一个真正“下手干活”的类&#xff1a;ActionTask。前面几篇我们聊了 Agent 的整体骨架、规划器怎么拆解意图、上下文和记忆怎么维护&#xff0c;那些都还停留在“想”的层面。到了 ActionTask&…

作者头像 李华
网站建设 2026/10/1 10:50:23

VC++ UDP 通信示例包解析:从工程结构到 Winsock API 实战

简介&#xff1a;这是一份面向VC初学者与网络编程入门者的UDP通信演示工程&#xff0c;围绕Windows平台Winsock套接字展开&#xff0c;帮助读者理解无连接传输协议的基本用法。资源以客户端与服务器双端示例为主线&#xff0c;涵盖套接字库初始化、UDP套接字创建、sockaddr_in地…

作者头像 李华