简介:面向自然语言处理方向的课程作业与入门实践,这份压缩包提供了一整套基于SVD矩阵分解与SGNS两种途径构建汉语子词向量的Python源码与评测实现,适合需要完成词向量对比实验或借鉴课程设计思路的读者。资源共15个文件,大小约88.66MB,涵盖Python脚本、Jupyter Notebook交互式分析、文本语料、相似度评测集、训练后的模型权重(pth)与向量矩阵(npy)等,可直接运行复现,也可按模块拆解学习。已有112人学习。代码以第一次编程作业得到的子词词表为基础,在训练与测试语料并集上展开:一侧使用K=5的高维分布表示后再做SVD降维,另一侧采用窗宽K=2的SGNS训练词嵌入;针对评测集中未登录词,两种方法均以余弦相似度置零兜底,并统一了结果输出格式,便于机器判定与横向对比。整体目录清晰、注释与README说明齐全,可作为汉语子词向量评测任务的可靠参考实现。
1. 汉语子词向量这步棋:为什么SVD和SGNS两条路都要走一遍
在NLP作业里同时实现SVD分解和SGNS两种方法来构建汉语子词向量,本质上不是交两份代码,而是把分布语义的两种范式各走一遍:一种是全局共现矩阵的谱分解,一种是局部窗口里的负采样训练。我见过不少同学只跑通一个gensim的Word2Vec就去交差,结果答辩被老师一句话问住:OOV词怎么处理?子词粒度选的字还是bigram?这两个问题恰恰是作业的核心考点。这个方向不需要超大语料,一台普通电脑加python就能跑完,但它能一次性讲清共现统计、谱降维和子词表示三件事。适合做NLP课程设计、面试前的对照实验,以及想真正看懂fastText原理的人。下面按我自己的操作顺序拆开讲,代码可直接改着用。
2. 数据与子词粒度先定型:语料清洗、分词取舍与字符n-gram参数
SVD要吃共现统计,SGNS要逐句采样,两者都依赖干净的句子边界。中文语料没有天然空格,拿到原始文本的第一步不是建模,而是先定清楚“一个token到底是什么”。很多人在这一步偷懒,把整段新闻直接丢进去,SVD共现矩阵里全是跨句噪声,后面怎么调都救不回来。这一章把数据侧定死,后面两种方法的对比才有意义。
2.1 语料清洗与句子切分:给两种方法喂同一份数据
常见做法是先用正则按中英文标点切句,再根据后续需求决定是否分词。我一般会保留一条原始句子,同时产出三份序列:jieba分词后的整词序列、按字切分的字符序列、在原始句子上滑窗得到的字符bigram序列。SVD和SGNS共用同一份清洗结果,只是各自按需取用。
import re import jieba RAW_TEXT = "自然语言处理是人工智能的重要方向。子词向量能缓解未登录词问题!" def clean_sentences(text: str) -> list[str]: # 去HTML标签、URL、多余空白,再按中英文句末标点切句 text = re.sub(r"<[^>]+>", "", text) text = re.sub(r"https?://\S+", "", text) text = re.sub(r"\s+", " ", text).strip() sents = re.split(r"[。!?!?]", text) return [s.strip() for s in sents if s.strip()] sents = clean_sentences(RAW_TEXT) for sent in sents: words = list(jieba.cut(sent)) # 整词序列 chars = [ch for ch in sent] # 字符序列 bigrams = [sent[i:i+2] for i in range(len(sent)-1)] # 字符bigram print("words:", words) print("chars:", chars) # 三种序列全部落盘,后续SVD和SGNS各取所需逻辑说明:先做轻量清洗再切句,避免URL和HTML标签被当成上下文计入统计;对同一句话同时产出词、字、bigram三份序列,是因为SVD矩阵的“行”可能只需要整词,而SGNS的子词n-gram需要字符片段,两边数据源必须一致。字符bigram直接在原始句子上滑窗生成,不做分词,这样能捕捉词内部和跨词边界的组合信息。
参数说明:bigram滑窗长度固定为2,对应双字组合;如果想把子词粒度做得更细,可以把滑窗扩展为2到4的三份n-gram,SVD侧手动构造对应计数,SGNS侧用min_n/max_n控制。这里选jieba只是为了让“整词”这条线有稳定产出,如果作业限定不能用第三方分词,直接退化为按字切分,完全不影响后续流程。
提示:SVD和SGNS必须共用同一套清洗和切分配置,否则评测结果差异无法归因于方法本身,只能归因于数据不一致。
2.2 子词粒度选择:整词、字符与字符bigram的取舍表
汉语的整词数量巨大且长尾严重,评测时必然遇到未登录词。单字作为子词虽然几乎不存在OOV,但一字多义问题严重,向量噪声大;整词语义完整但泛化弱;折中的字符n-gram能把没见过的双字词拆成见过的字组合。作业场景里,SVD用“整词做行、整词+字符做列”来控制矩阵规模,SGNS用min_n=2/max_n=3让子词覆盖字符bigram和trigram,两边都覆盖OOV但实现方式不同,对比才有论述空间。
| 子词单元 | 示例(我爱自然语言处理) | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 整词 | 我 / 爱 / 自然语言处理 | 语义完整,相似度直观 | 未登录词无向量 | 与常规词向量做对照 |
| 字符 | 我 / 爱 / 自 / 然 / 语 / 言 | 词表小,OOV率低 | 一字多义,向量噪声大 | SVD矩阵省内存 |
| 字符bigram | 我爱 / 爱自 / 自然 / 然语 | 能拼出大部分双字词 | 产生“的无”“的是”等噪声 | SGNS负采样压噪声 |
评测阶段有个关键口径问题:任务给的是整词级别的相似度集合,那模型输出必须映射回整词。SVD侧直接查整词行;SGNS侧如果整词未登录,就需要用字符和bigram向量拼出近似向量。下面这个函数就是做这件事的:
import numpy as np def compose_word_vector(word: str, char_vecs, bigram_vecs): # 优先用整词向量;没有就用字符+bigram子词向量求和 if word in char_vecs: vec = char_vecs[word].copy() else: first = next(iter(char_vecs.values())) vec = np.zeros_like(first) for i in range(len(word) - 1): bg = word[i:i+2] if bg in bigram_vecs: vec += bigram_vecs[bg] return vec / (np.linalg.norm(vec) + 1e-8)逻辑说明:先查整词,查不到就遍历词内所有相邻字符对,把命中的bigram向量逐项累加,最后L2归一化。这样“量子计算”这类训练语料里完全没有的整词,也能通过“量子”“计算”等常见子词拼出向量。参数说明:+1e-8是防止零向量除零;如果字符和bigram都没命中,返回的是接近零向量的噪声,实际评测时应跳过这种词。
此外可以估算一下子词表规模:3000句、每句约20字的小语料,字符表约3000到4000,bigram表约2万到5万,整词表分词后约2万到3万。SVD矩阵行如果能控制在1万到2万,用稀疏矩阵存储完全没问题;如果子词表超过5万,矩阵的稀疏度会超过99%,要么加大min_count,要么把列限制为整词加高频字,否则后面SVD计算也会变慢。
3. 基于SVD分解构建子词向量:共现矩阵、PPMI加权与截断奇异值分解
SVD路线一共三步:构建共现矩阵,做PPMI加权,最后做截断SVD得到稠密向量。这条路径的优点是稳定,矩阵分解一次到位,不需要调学习率;缺点是矩阵构建阶段的任何错误都会被放大到最终向量里。我最早做的时候在矩阵构建上翻了三天车,所以这一章把每一步的参数和坑一次说清。
3.1 共现矩阵的构建与稀疏化:窗口大小和加权方式怎么定
行和列的选择上,我倾向让行和列共用同一套子词表:中心词和上下文来自同一个集合,后续查询整词或子词时索引逻辑最简单。窗口大小取5,呼应第2章的约定——SGNS侧window=5,SVD侧也取5,两种方法的上下文范围才可比。计数方式上,原始计数对高频虚词太友好,所以我用距离倒数加权,距离为1、2、3时权重分别是1、1/2、1/3,让邻近词对向量的影响更大。
from collections import defaultdict import numpy as np from scipy.sparse import lil_matrix def build_cooccurrence(seqs, min_count=1, window=5): # 先统计词频,截断低频,控制矩阵维度 freq = defaultdict(int) for seq in seqs: for u in seq: freq[u] += 1 vocab = {w for w, c in freq.items() if c >= min_count} idx = {w: i for i, w in enumerate(sorted(vocab))} vocab_size = len(idx) mat = lil_matrix((vocab_size, vocab_size), dtype=np.float32) for seq in seqs: n = len(seq) for i, center in enumerate(seq): if center not in idx: continue r = idx[center] for j in range(max(0, i - window), min(n, i + window + 1)): if i == j: continue ctx = seq[j] if ctx not in idx: continue c = idx[ctx] w = 1.0 / abs(i - j) # 距离反比加权 mat[r, c] += w return mat.tocsr(), idx逻辑说明:先统计整份语料的频次,把低于min_count的子词过滤掉,再为剩余子词建立索引。遍历每个句子时,中心词找到对应行,窗口内的上下文词找到对应列,累加距离倒数权重。lil_matrix适合按坐标逐个累加,构建完成后转成csr格式供后续SVD使用。
参数说明:window=5表示中心词两侧各看5个位置,总共最多10个上下文位,不是总共5个;min_count在几万句的小语料上建议设为1到2,在大语料上设到3到5,否则低频bigram会撑爆索引。代码里行列共用idx,所以被截断的子词不会留下“有行无列”的脏数据。如果后续要做PPMI,矩阵元素不能是稀疏的dense数组,这里用float32已经够用,继续用float64会让内存翻倍。
3.2 用TruncatedSVD降维得到密集向量:维度选择与归一化
为什么不用完整SVD?稠密矩阵的SVD复杂度接近O(V^3),V为2万时根本跑不动。TruncatedSVD只求前k个奇异值对应的左右奇异向量,在稀疏矩阵上可以快速完成,这正是潜在语义分析的标准做法。但SVD直接作用于原始计数矩阵时,高频停用词的绝对共现次数会主导前几个主成分,所以必须先做PPMI加权。
from scipy.sparse import coo_matrix, csr_matrix def to_ppmi(mat: csr_matrix): mat = mat.astype(np.float64) row_sum = np.asarray(mat.sum(axis=1)).ravel() col_sum = np.asarray(mat.sum(axis=0)).ravel() total = mat.sum() rows, cols = mat.nonzero() vals = mat.data # 观测共现概率 / 独立共现期望,取对数后截断为0 joint = vals / total p_w = row_sum[rows] / total p_c = col_sum[cols] / total pmi = np.log(joint / (p_w * p_c)) pmi = np.clip(pmi, 0.0, None) return coo_matrix((pmi, (rows, cols)), shape=mat.shape).tocsr() from sklearn.decomposition import TruncatedSVD from sklearn.preprocessing import normalize ppmi = to_ppmi(mat) svd = TruncatedSVD(n_components=128, random_state=42) svd.fit(ppmi) U = svd.transform(ppmi) # 每个子词一行 S = svd.singular_values_ # 奇异值,按重要程度递减 vectors = U * S # 奇异值加权 vectors = normalize(vectors, norm="l2", axis=1)逻辑说明:PPMI把“观测到的共现概率”和“假设两个词独立时的期望共现概率”做比较,比值越大说明共现越不寻常,语义信号越强;负值截断为0,避免噪声拖累向量空间。TruncatedSVD拟合后transform返回U矩阵,乘上奇异值S相当于保留了每个潜语义方向的绝对能量;最后L2归一化,让余弦相似度可以直接用点积计算。
参数说明:n_components=128是起步值,小语料可以降到64,百万句级语料可以提到300;random_state=42固定随机种子,保证向量的可复现性。U*S的加权版本比不乘S的版本在类比任务上更稳定,但词相似度的排序变化不大;如果评测分数异常低,可以先检查是不是忘记归一化。另一个小语料特有的坑:共现矩阵若过于稀疏,TruncatedSVD会给出退化结果,这时优先加大window或降低n_components,而不是盲目增加训练数据。
4. 基于SGNS构建子词向量:负采样原理与fastText风格的子词扩展
SVD是全局统计,SGNS是局部预测,两种范式放在同一份作业里对照,训练逻辑完全不同。SGNS速度更快、实现更“工程化”,但调参玄学多一点。这一章先把负采样原理说透,再给基于gensim FastText的落地代码,最后展示一段不依赖库的自写核心循环,方便写进实验报告。
4.1 从skip-gram到SGNS:负采样为什么能省下整棵softmax
skip-gram的目标是用中心词预测上下文词。最原始的softmax版本,分母要对整个词表求和,中文整词加子词轻轻松松几万乘几万,训练慢到不可接受。负采样的思路是把“预测哪个词”改成“判断这对词是否真的共现”:窗口内的上下文词标记为正样本,再从词表随机抽k个噪声词标记为负样本,用sigmoid做二分类。这样每次只更新k+1个上下文向量,训练复杂度从O(V)降到O(k)。
import numpy as np def sigmoid(x): return 1.0 / (1.0 + np.exp(-x)) def sgns_update(center_id, ctx_id, neg_ids, W, C, lr=0.025): """自写SGNS单步更新:W是中心词向量,C是上下文向量""" h = W[center_id] grad_w = np.zeros_like(h) # 正样本:让 sigmoid(h·v_ctx) 逼近1,梯度方向为 (s-1) s = sigmoid(h @ C[ctx_id]) grad_w += (s - 1.0) * C[ctx_id] C[ctx_id] -= lr * (s - 1.0) * h # 负样本:让 sigmoid(h·v_neg) 逼近0,梯度方向为 s for neg in neg_ids: s = sigmoid(h @ C[neg]) grad_w += s * C[neg] C[neg] -= lr * s * h # 中心词向量用累积梯度一次性更新,避免顺序偏差 W[center_id] -= lr * grad_w逻辑说明:正样本的梯度方向是(s-1),当s接近1时梯度接近0,学习停止;负样本的梯度方向是s,当s接近0时梯度也接近0。代码先把正样本和负样本的梯度都算完,再更新中心词向量,避免每处理一个负样本就把中心词变一次导致的顺序偏差。上下文向量则每步即时更新,这与词向量的标准做法一致。
参数说明:negative=5是经验起点,语料噪声大时可以调到15;lr=0.025是word2vec常用的初始学习率,训练过程中要线性衰减到1e-4,否则后期震荡。子词扩展是fastText的核心贡献:每个词表示为整词向量与其内部所有字符n-gram向量之和,训练时更新这些子向量,查询时按词累加。这样即使整词没在语料中出现过,由见过的字符片段也能拼出近似向量,这就是SGNS侧处理OOV的机制。
4.2 用gensim FastText实现SGNS子词训练:参数与OOV用法
自写负采样训练器要自己处理采样表、学习率衰减、多线程和词频统计,作业允许第三方库时直接用gensim最稳。gensim的FastText本质就是SGNS加字符n-gram子词,接口成熟,坑少。前提是语料文件已经切好:每行一个句子,词与词之间用空格分隔。如果用的是第2章的整词序列,写入文件时注意用空格join。
from gensim.models import FastText from gensim.models.word2vec import LineSentence # corpus.txt: 每行一句话,词间以空格分隔 sentences = LineSentence("corpus.txt") model = FastText( sentences, vector_size=128, # 与SVD侧向量维度保持一致 window=5, # 与SVD共现矩阵的窗口保持一致 sg=1, # 1=skip-gram with negative sampling,0=CBOW negative=5, # 负样本数量 min_count=2, # 词频低于2的整词直接丢弃 min_n=2, # 子词n-gram最小长度(字符) max_n=3, # 子词n-gram最大长度(字符) epochs=10, # 小语料可以多跑几轮 sample=1e-4, # 高频词下采样阈值,降低“的”“了”干扰 seed=42, workers=4, ) model.save("subword_sgns.model") # 语料中没出现过的词也能通过子词拼出向量 oov_vec = model.wv.get_vector("量子计算") print(oov_vec.shape)逻辑说明:LineSentence逐行读取语料,FastText内部会对每个token提取长度在min_n和max_n之间的字符n-gram,并和整词向量一起参与训练。训练完成后,wv.get_vector接口对词表内词直接返回整词向量与子词向量之和,对词表外词自动回退到子词计算。sg=1明确指定用SGNS而不是CBOW,这是作业标题要求的模型结构。
参数说明:vector_size=128与第3章的SVD维度对齐,评测时才公平;window=5与SVD侧共现窗口一致;min_count=2可以砍掉只出现一次的低频bigram,控制子词表规模;min_n=2/max_n=3对应字符bigram和trigram,与第2章的子词粒度设计一致。sample=1e-4让高频虚词以一定概率被丢弃,避免它们污染训练;epochs在小语料上10轮够用,百万句级语料5轮即可收敛。另注意新版本gensim统一用model.wv访问词向量,老代码里model["词"]的写法已经废弃,直接用会报错或行为异常。
5. 避坑:SVD内存、高频词污染、SGNS不收敛这次一起说清
这条链路上我踩过的坑比写代码的时间还多。下面五个问题按“现象→原因→解决”的方式整理,几乎每个都是做这个作业的人必经的坎。
5.1 共现矩阵把内存吃爆:稀疏矩阵与词表截断
现象:构建50000个词的共现矩阵时MemoryError,或者构建到一半系统把进程杀掉。
原因:直接用np.zeros((V, V))创建稠密矩阵,V为5万时就是250亿个浮点数,必炸;即使用Python的dict存稀疏结构,每个键值对的对象开销也大到无法承受。
解决:先统计词频,用min_count把低频词过滤掉;再用scipy.sparse.lil_matrix按坐标累加计数,最后转成csr_matrix参与SVD;数据类型用float32而不是float64,内存能减半。词表仍过大时,把矩阵的列限制为“整词+高频字符”,不把所有子词都放进列索引。
5.2 相似词全是最常见词:高频词对SVD主成分的污染
现象:SVD跑完后,用余弦相似度找“自然语言处理”的近义词,排在前面的全是“的”“了”“在”。
原因:原始共现计数里,高频虚词的绝对出现次数大,几乎与所有词共现,SVD的前几个主成分会被这种频率偏差主导,真正的语义信号被压到后面的分量里。
解决:把共现矩阵从原始计数改为PPMI,利用边缘概率惩罚高频词的共现期望;如果还压不住,在预处理阶段同步剔除停用词,且SVD和SGNS两条线用同一份停用词规则。PPMI配合L2归一化后,通常能把高频虚词挤出相似度前10。
5.3 SGNS训练不收敛或loss波动大:采样、学习率与打乱问题
现象:epochs跑到第5轮,词相似度结果还是接近随机,loss曲线忽高忽低。
原因:negative设成1,负样本区分度不够;学习率固定不衰减,后期震荡;语料按原文档顺序输入,连续句子主题高度相关,模型被某个话题带偏。
解决:negative先设为5,语料杂时调到15;学习率从0.025线性衰减到1e-4,gensim默认会做;训练前把句子随机打乱。检查方式:打印前几个epoch的平均loss,如果第2轮loss还在上升,优先降低初始学习率,而不是加轮数。
5.4 FastText OOV查询时报KeyError:子词向量要这样取
现象:model.wv["语料库外的词"]抛KeyError,明明装的是FastText。
原因:不同gensim版本对词表外词的__getitem__行为不一致,有的直接抛异常,只有get_vector接口明确走子词回退计算。
解决:统一用model.wv.get_vector(word)查询,它对OOV词会自动用字符n-gram求和返回向量。注意:如果目标词的所有字符片段都没在子词表里出现过,返回的是零向量,这时要么跳过该词,要么注入随机噪声再归一化;min_n和max_n过大也会让子词表爆炸,建议保持min_n=2、max_n=3。
5.5 评测分数和预期差太远:先统一评测集和粒度
现象:SVD在词相似度上表现不错,在类比任务上准确率只有十几,甚至不如随机。
原因:要么用了不适合汉语的英文评测集,要么两种方法对OOV的处理方式不同,导致参与评分的词集不一致,分数根本没有可比性。
解决:相似度用中文wordsim-240或wordsim-297;类比任务没有现成的汉语大评测集,常见做法是自建30到50条四元组,分为语义类(城市-首都)和形态类(词性/字组合)两组。评测代码里统一规则:只在两种模型都覆盖的整词上计算分数,OOV词要么都跳过,要么都用子词拼合,不能一边查表一边拼子词。
6. 评测设计与对比:用相似度和类比任务给出“谁更好”的结论
两种向量都训练好后,评测是最后一步。我的做法是:先算相似度相关性,再跑类比准确率,最后用一张表列训练时间、内存和OOV能力。评测脚本本身不复杂,但口径不统一会让结果失真。
6.1 相似度评测:Spearman相关性的计算代码
from scipy.stats import spearmanr import numpy as np def evaluate_similarity(vec_dict, gold_path="wordsim240_ch.txt"): gold, preds = [], [] for line in open(gold_path, encoding="utf-8"): w1, w2, score = line.rstrip().split("\t") if w1 not in vec_dict or w2 not in vec_dict: continue v1, v2 = vec_dict[w1], vec_dict[w2] cos = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-8) gold.append(float(score)) preds.append(cos) rho, _ = spearmanr(gold, preds) return rho逻辑说明:gold文件每行是“词1、词2、人工相似度分数”,脚本跳过任一缺失词,只对两个模型都覆盖的整词计算余弦相似度,再与人工打分做Spearman相关。说明:wordsim类数据集的rho通常在0.3到0.7之间,中文子词模型超过0.4就可以写进报告;低于0.3时先检查向量是否归一化、词集是否大量错位。
6.2 类比任务与开销对比:一张表看清SVD与SGNS
类比任务的做法是准备四元组(a, b, c, d),计算cos(a - b + c, d)判断是否命中。汉语没有标准的公开大类比集,我自建30条,结论看相对差异比绝对数值可靠。SVD在全局语义关系上稳定但僵化,SGNS在形态相关的类比上更灵活,这符合两种模型的本质差异。
| 对比项 | SVD(共现+PPMI+TruncatedSVD) | SGNS(FastText) |
|---|---|---|
| 训练模式 | 全局共现矩阵一次性分解 | 逐句流式梯度下降 |
| OOV处理 | 用子词行组合近似 | 内置子词向量求和 |
| 训练时间 | 矩阵构建慢,SVD分解较快 | 每轮迭代快,收敛轮数多 |
| 内存占用 | 随矩阵稀疏度上升 | 随词表和子词表增长 |
| 相似度稳定性 | 稳定但易被频率偏差干扰 | 依赖negative/lr设置,方差大 |
评测之外,可视化是最好的自检手段:把两种向量用PCA降到2维,画出“中国-北京-日本-东京”四个点,“中国到北京”和“日本到东京”的方向应当近似。方向错乱说明向量空间没学好,优先检查预处理和参数,而不是急着加数据。
我最初做这个作业时把SVD放在前面,共现矩阵构建和PPMI加权就调了三天,后来发现换个顺序效率高得多:先用FastText把整条链路跑到出分,再回头跑SVD做对照,问题定位会快很多。这个顺序建议你直接抄。希望帮到你。
本文还有配套的精品资源,点击获取