
简介面向自然语言处理方向的课程作业与入门实践这份压缩包提供了一整套基于SVD矩阵分解与SGNS两种途径构建汉语子词向量的Python源码与评测实现适合需要完成词向量对比实验或借鉴课程设计思路的读者。资源共15个文件大小约88.66MB涵盖Python脚本、Jupyter Notebook交互式分析、文本语料、相似度评测集、训练后的模型权重pth与向量矩阵npy等可直接运行复现也可按模块拆解学习。已有112人学习。代码以第一次编程作业得到的子词词表为基础在训练与测试语料并集上展开一侧使用K5的高维分布表示后再做SVD降维另一侧采用窗宽K2的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(rhttps?://\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:i2] 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-gramSVD侧手动构造对应计数SGNS侧用min_n/max_n控制。这里选jieba只是为了让“整词”这条线有稳定产出如果作业限定不能用第三方分词直接退化为按字切分完全不影响后续流程。提示SVD和SGNS必须共用同一套清洗和切分配置否则评测结果差异无法归因于方法本身只能归因于数据不一致。2.2 子词粒度选择整词、字符与字符bigram的取舍表汉语的整词数量巨大且长尾严重评测时必然遇到未登录词。单字作为子词虽然几乎不存在OOV但一字多义问题严重向量噪声大整词语义完整但泛化弱折中的字符n-gram能把没见过的双字词拆成见过的字组合。作业场景里SVD用“整词做行、整词字符做列”来控制矩阵规模SGNS用min_n2/max_n3让子词覆盖字符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:i2] 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到4000bigram表约2万到5万整词表分词后约2万到3万。SVD矩阵行如果能控制在1万到2万用稀疏矩阵存储完全没问题如果子词表超过5万矩阵的稀疏度会超过99%要么加大min_count要么把列限制为整词加高频字否则后面SVD计算也会变慢。3. 基于SVD分解构建子词向量共现矩阵、PPMI加权与截断奇异值分解SVD路线一共三步构建共现矩阵做PPMI加权最后做截断SVD得到稠密向量。这条路径的优点是稳定矩阵分解一次到位不需要调学习率缺点是矩阵构建阶段的任何错误都会被放大到最终向量里。我最早做的时候在矩阵构建上翻了三天车所以这一章把每一步的参数和坑一次说清。3.1 共现矩阵的构建与稀疏化窗口大小和加权方式怎么定行和列的选择上我倾向让行和列共用同一套子词表中心词和上下文来自同一个集合后续查询整词或子词时索引逻辑最简单。窗口大小取5呼应第2章的约定——SGNS侧window5SVD侧也取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_count1, window5): # 先统计词频截断低频控制矩阵维度 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), dtypenp.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使用。参数说明window5表示中心词两侧各看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(axis1)).ravel() col_sum np.asarray(mat.sum(axis0)).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)), shapemat.shape).tocsr() from sklearn.decomposition import TruncatedSVD from sklearn.preprocessing import normalize ppmi to_ppmi(mat) svd TruncatedSVD(n_components128, random_state42) svd.fit(ppmi) U svd.transform(ppmi) # 每个子词一行 S svd.singular_values_ # 奇异值按重要程度递减 vectors U * S # 奇异值加权 vectors normalize(vectors, norml2, axis1)逻辑说明PPMI把“观测到的共现概率”和“假设两个词独立时的期望共现概率”做比较比值越大说明共现越不寻常语义信号越强负值截断为0避免噪声拖累向量空间。TruncatedSVD拟合后transform返回U矩阵乘上奇异值S相当于保留了每个潜语义方向的绝对能量最后L2归一化让余弦相似度可以直接用点积计算。参数说明n_components128是起步值小语料可以降到64百万句级语料可以提到300random_state42固定随机种子保证向量的可复现性。U*S的加权版本比不乘S的版本在类比任务上更稳定但词相似度的排序变化不大如果评测分数异常低可以先检查是不是忘记归一化。另一个小语料特有的坑共现矩阵若过于稀疏TruncatedSVD会给出退化结果这时优先加大window或降低n_components而不是盲目增加训练数据。4. 基于SGNS构建子词向量负采样原理与fastText风格的子词扩展SVD是全局统计SGNS是局部预测两种范式放在同一份作业里对照训练逻辑完全不同。SGNS速度更快、实现更“工程化”但调参玄学多一点。这一章先把负采样原理说透再给基于gensim FastText的落地代码最后展示一段不依赖库的自写核心循环方便写进实验报告。4.1 从skip-gram到SGNS负采样为什么能省下整棵softmaxskip-gram的目标是用中心词预测上下文词。最原始的softmax版本分母要对整个词表求和中文整词加子词轻轻松松几万乘几万训练慢到不可接受。负采样的思路是把“预测哪个词”改成“判断这对词是否真的共现”窗口内的上下文词标记为正样本再从词表随机抽k个噪声词标记为负样本用sigmoid做二分类。这样每次只更新k1个上下文向量训练复杂度从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, lr0.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。代码先把正样本和负样本的梯度都算完再更新中心词向量避免每处理一个负样本就把中心词变一次导致的顺序偏差。上下文向量则每步即时更新这与词向量的标准做法一致。参数说明negative5是经验起点语料噪声大时可以调到15lr0.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_size128, # 与SVD侧向量维度保持一致 window5, # 与SVD共现矩阵的窗口保持一致 sg1, # 1skip-gram with negative sampling0CBOW negative5, # 负样本数量 min_count2, # 词频低于2的整词直接丢弃 min_n2, # 子词n-gram最小长度字符 max_n3, # 子词n-gram最大长度字符 epochs10, # 小语料可以多跑几轮 sample1e-4, # 高频词下采样阈值降低“的”“了”干扰 seed42, workers4, ) 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接口对词表内词直接返回整词向量与子词向量之和对词表外词自动回退到子词计算。sg1明确指定用SGNS而不是CBOW这是作业标题要求的模型结构。参数说明vector_size128与第3章的SVD维度对齐评测时才公平window5与SVD侧共现窗口一致min_count2可以砍掉只出现一次的低频bigram控制子词表规模min_n2/max_n3对应字符bigram和trigram与第2章的子词粒度设计一致。sample1e-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-4gensim默认会做训练前把句子随机打乱。检查方式打印前几个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_n2、max_n3。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_pathwordsim240_ch.txt): gold, preds [], [] for line in open(gold_path, encodingutf-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共现PPMITruncatedSVDSGNSFastText训练模式全局共现矩阵一次性分解逐句流式梯度下降OOV处理用子词行组合近似内置子词向量求和训练时间矩阵构建慢SVD分解较快每轮迭代快收敛轮数多内存占用随矩阵稀疏度上升随词表和子词表增长相似度稳定性稳定但易被频率偏差干扰依赖negative/lr设置方差大评测之外可视化是最好的自检手段把两种向量用PCA降到2维画出“中国-北京-日本-东京”四个点“中国到北京”和“日本到东京”的方向应当近似。方向错乱说明向量空间没学好优先检查预处理和参数而不是急着加数据。我最初做这个作业时把SVD放在前面共现矩阵构建和PPMI加权就调了三天后来发现换个顺序效率高得多先用FastText把整条链路跑到出分再回头跑SVD做对照问题定位会快很多。这个顺序建议你直接抄。希望帮到你。本文还有配套的精品资源点击获取