☰
PyTorch实现Skip-gram词向量:从语料到Embedding
2026/10/26 6:41:57 网站建设 项目流程

简介:这份基于PyTorch的词向量模型配套代码包,面向自然语言处理初学者与希望动手理解词嵌入的开发者,用一个小型英文语料串起从文本预处理到向量训练的核心流程。压缩包共6个文件,约10KB,以3个Python源码为主,辅以2个pyc缓存和1个txt语料,分别承担数据处理、模型定义、训练入口与词表素材等职责。已有3111人学习,适合课堂演示或入门实验。读者可借助DataSet.py完成分词、词汇表构建与索引映射,在Model.py中理解输入表示与网络结构,再由Main.py组织训练与参数调整,并结合1803词的英文数据观察CBOW、Skip-gram或简单MLP思路下的损失变化。代码量不大,便于逐行阅读和修改,也能作为文本分类、情感分析等下游任务的预处理参考。

1. 输入「退货」找邻居:一个能跑通的 PyTorch 词向量项目长什么样

拿一份中文评论语料喂进去,训练几轮之后输入「退货」,取余弦相似度最高的十个词,屏幕上跳出「换货」「客服」「包装破损」「物流慢」——这不是什么大模型,就是一个用 PyTorch 手写的词向量模型在做分布式假设:上下文相似的词,意思就相近。这个项目做的就是把「自然语言处理」最底层的那块砖自己烧一遍:从原始文本开始,分词、建词表、切出 (中心词, 上下文) 样本、跑 Skip-gram 加负采样,最后产出一份能直接给别人用的.vec词向量文件,附一条相似词检索和降维可视化的验证链路。它适合三类人:想搞明白 embedding 到底怎么被训出来的初学者;要往搜索、推荐、文本分类里塞一个轻量词表的工程同学;以及手上有一批领域黑话、通用预训练词向量根本认不全的场景。整个项目是纯 Python 加 PyTorch,没有花哨的分布式框架,一台普通机器就能跑完一版基线。

2. 工程结构与技术栈:从原始语料到 embedding 的一条链路

把词向量当成一个「数据加工厂」来看,这个项目的骨架其实很朴素:原料是纯文本,成品是一个带词表的矩阵,中间的每一道工序都该是一个能单独跑、单独验证的脚本。很多教程把这几步揉在一个train.py里,一旦 loss 不降,根本分不清是数据切错了、词表建歪了、还是模型写反了。这个项目按工序拆开,好处是每一段都能单独测。

2.1 目录结构与模块职责

我一般把这类项目拆成data / model / train / eval四块加一个配置入口,目录大致长这样:

word2vec-pytorch/ ├── configs/ │ └── base.yaml # 超参:dim / window / negative / lr / epochs ├── data/ │ ├── raw/ # 原始语料,一行一句,utf-8 │ └── processed/ # 分词后的语料 + 词表文件 ├── wv/ │ ├── vocab.py # 分词、词表构建、词频统计、负采样表 │ ├── dataset.py # 滑窗采样,(center, context) 对生成 │ ├── model.py # SkipGramNS 模型定义 │ └── io.py # 词向量导出 / 加载 ├── train.py # 训练入口,负责 epoch 循环与日志 ├── eval.py # 最近邻、类比推理、t-SNE 落图 └── requirements.txt

这个分法最关键的一条边界是:vocab.py和dataset.py不 import 任何nn.Module,它们只吃字符串、吐整数索引。这么切的实际收益是你可以单独跑一遍python -m wv.vocab --check,把词表前 100 个词打出来,肉眼确认「的、了、是」这些高频虚词排在最前面、<unk>占比在合理区间,而不是等训练两小时后才发现词表里混进了一堆标点符号。

train.py只做三件事:读配置、组装DataLoader、跑循环并写日志。评估逻辑全丢给eval.py,它加载的是保存下来的权重文件,不需要重新训练。这条「训练产出文件、评估读文件」的约定,是后面能做 A/B 对比超参的前提——不同配置各存一份 checkpoint,评估脚本一次性跑完,指标才好横向比。

2.2 技术选型:为什么手写 Skip-gram 而不是 pip 装一个现成的

现成的库确实一行代码就能出词向量,问题在于它们是黑盒:窗口怎么滑、下采样怎么算、负采样表按什么分布建的,全都藏在参数后面。词向量这个任务的玄学成分本来就高——同一份语料,min_count从 3 调到 5,相似词列表能换掉一半。项目用 PyTorch 自己实现,核心依赖只有三样:

依赖用途说明
PyTorch模型定义、自动求导、稀疏梯度优化用nn.Embedding+SparseAdam省显存
jieba中文分词只用于中文语料,英文语料换成简单切分
numpy / matplotlib / scikit-learn负采样表、t-SNE 降维与散点图只出现在评估侧,不影响训练

选 PyTorch 而不用别的框架,实在的理由是nn.Embedding天然支持sparse=True。词表五万、维度 128 的时候,每步只有 batch 里那几百行权重会更新,稠密优化器会把整个 5 万 × 128 的矩阵连同动量状态一起扫一遍,白白浪费算力。配合torch.optim.SparseAdam,训练速度能差出两三倍,这在单机跑的时候是实打实体感得到的。

另一层考虑是可改。词向量模型的变体很多——CBOW 与 Skip-gram 的取舍、负采样换成层次 Softmax、加高频词下采样、加 n-gram 特征,这些改动在封装好的库里基本无从下手,而自己写的forward里加几行就行。

2.3 配置与命令行参数:把 window、dim、negative 都摊开

超参集中在一个 YAML 里,命令行再用--override覆盖,避免为了试一组参数去改代码。配置大概是这个样子:

# configs/base.yaml corpus: data/raw/corpus.txt min_count: 5 # 词频低于此值的词进 <unk> max_vocab: 50000 # 词表上限,按词频截断 embed_dim: 128 # 词向量维度 window: 5 # 上下文窗口上限,实际每步随机取 1..window negative: 5 # 每个正样本配几个负样本 subsample: 1e-4 # 高频词下采样阈值,0 表示关闭 batch_size: 1024 lr: 2.5e-3 epochs: 5 seed: 42

每个参数的含义值得掰开说两句,因为它们直接决定训练出来的东西像不像样:

  • embed_dim取 128 到 300 是常见区间。语料只有几十万词的时候上 300 维,大部分维度学不到东西,相似度计算反而被噪声主导。
  • window是上限不是固定值。每处理一个中心词,从1..window里随机取一个实际窗口,这是原论文里的做法,效果比固定窗口稳,因为同一个词在不同距离上的共现都被覆盖到了。
  • negative取 5 到 15。太小梯度信号弱,loss 掉得慢且邻居词发虚;太大等于每个 batch 多算几倍矩阵乘法,收益递减很快。
  • subsample控制高频词丢弃概率。中文语料里「的」能占到 3% 以上,不处理的话它跟几乎所有词都共现,训出来的向量几乎是个常量,还会把有价值的共现关系稀释掉。

命令行入口用argparse或typer都行,关键是打印出最终生效的完整配置到日志第一行。我踩过一次坑:配置里写了window: 5,代码里默认值却是 3,某次忘了传配置文件,训练完才发现用的是 3——日志里没有生效配置,只能重训。

3. 核心模块拆解:Skip-gram、负采样与训练循环

这一章把项目里真正有信息量的四段代码拆开讲。顺序按数据流走:文本怎么变成索引、索引怎么变成样本对、样本对怎么进模型算损失、损失怎么反传更新权重。每一段都可以单独跑起来验证,不必等整条链路通。

3.1 语料预处理与词表构建

分词和词表是两个容易混在一起的步骤,实际应该分开:分词决定「哪些字符串是一个词」,词表决定「哪些词值得进模型」。中文语料用 jieba 精确模式,标点、数字、纯符号整段过滤掉;英文语料直接按空白和标点切,再做小写化。

# wv/vocab.py import re from collections import Counter _CLEAN = re.compile(r"[^\u4e00-\u9fa5a-zA-Z0-9]+") def tokenize(line: str, lang: str = "zh"): line = line.strip().lower() if not line: return [] if lang == "zh": import jieba # 先按标点切段,再对每段做精确模式分词,避免标点被 jieba 当成词 tokens = [] for seg in _CLEAN.sub(" ", line).split(): tokens.extend(jieba.lcut(seg)) return [t for t in tokens if t.strip()] return [t for t in _CLEAN.sub(" ", line).split() if t] class Vocab: def __init__(self, counter: Counter, min_count: int = 5, max_size: int = 50000): # 特殊符号固定占 0、1 两个位置,后面导出词向量时直接跳过 self.itos = ["<unk>", "<pad>"] self.stoi = {"<unk>": 0, "<pad>": 1} # 按词频降序,截断到 max_size;低于 min_count 的丢进 <unk> for word, freq in counter.most_common(): if freq < min_count: break if len(self.itos) >= max_size: break self.stoi[word] = len(self.itos) self.itos.append(word) def __len__(self): return len(self.itos) def encode(self, tokens): return [self.stoi.get(t, 0) for t in tokens]

这段代码有两个不显眼但很要命的点。第一,Counter.most_common()本身就是按词频降序返回的,所以if freq < min_count: break是安全的;如果换成先过滤再排序的写法,就得改成continue,否则会提前截断。第二,<unk>必须固定在下标 0。训练时它会被当成普通词参与共现统计,但导出和评估阶段要能按索引把它剔掉,索引一旦漂移,后面过滤逻辑全乱。

词频统计建议单独落一个counter.json。原因很实际:分词慢,几百万行语料跑 jieba 要几分钟到十几分钟,调参阶段反复重跑分词是纯浪费。把Counter缓存下来,改min_count或max_vocab的时候直接读缓存重建词表,秒级完成。

3.2 模型定义:双嵌入矩阵与负采样损失

Skip-gram 的核心思想是拿中心词去预测上下文。真正落地时不会真的做「五万分类」的 Softmax,而是把它拆成一堆二分类:真实的 (中心词, 上下文词) 对判为正,随机抽的 K 个词判为负。模型里需要两张嵌入表——输入表和输出表,这是很多人第一次写会漏的地方。

# wv/model.py import torch import torch.nn as nn class SkipGramNS(nn.Module): def __init__(self, vocab_size: int, embed_dim: int): super().__init__() # 两张表:in_embed 是最终要导出的词向量,out_embed 只是训练期的辅助参数 self.in_embed = nn.Embedding(vocab_size, embed_dim, sparse=True) self.out_embed = nn.Embedding(vocab_size, embed_dim, sparse=True) bound = (1.0 / embed_dim) ** 0.5 self.in_embed.weight.data.uniform_(-bound, bound) self.out_embed.weight.data.uniform_(-bound, bound) def forward(self, center, context, negatives): # center: [B] 中心词索引 # context: [B] 真实上下文词索引 # negatives:[B, K] 每个样本对应的 K 个负样本索引 v = self.in_embed(center) # [B, D] u_pos = self.out_embed(context) # [B, D] u_neg = self.out_embed(negatives) # [B, K, D] pos_score = torch.sum(v * u_pos, dim=1) # [B] neg_score = torch.bmm(u_neg, v.unsqueeze(2)).squeeze(2) # [B, K] # 正样本用 logsigmoid,负样本用 logsigmoid(-x),比手写 log(sigmoid) 数值稳 loss = -torch.nn.functional.logsigmoid(pos_score).mean() \ -torch.nn.functional.logsigmoid(-neg_score).sum(dim=1).mean() return loss

逐段看逻辑:v是中心词的输入表示,u_pos是真实上下文的输出表示,两者点积越大说明模型越确信这是真的一对。u_neg是[B, K, D]的三维张量,bmm把它和v做批量矩阵乘,得到每个样本对每个负样本的打分。损失函数把两部分相加:正样本的分数要往大了推,负样本的分数要往小了推。

有个细节值得单独说:-logsigmoid而不是-log(sigmoid(x) + eps)。后者在分数绝对值很大时会因为sigmoid下溢成 0,log(0)直接变inf,loss 一步爆掉,梯度全变 NaN。logsigmoid内部做了数值稳定处理,这是我自己写过一版之后才改过来的血泪经验。

out_embed在训练结束后就没用了。评估和导出只取in_embed.weight。这也是为什么有些实现把两张表分别叫syn0和syn1——历史上就是这么区分的。

3.3 批数据生成:把一条长句切成 (中心词, 上下文) 对

这一层是最容易出内存问题的地方。直觉写法是把整份语料滑窗展开成一个巨大的List[Tuple[int, int]],几十万句子下来轻松上千万对,Python 的元组开销能吃掉好几个 G。正确做法是在Dataset里按索引现取,或者干脆自己在train.py里手写一个生成器。

# wv/dataset.py import random import torch from torch.utils.data import Dataset class WindowDataset(Dataset): def __init__(self, corpus_ids, window: int = 5): # 记录每行句子在扁平数组里的 (start, end),不展开成词对 self.ids = corpus_ids self.window = window self.spans = [] cursor = 0 for sent in corpus_ids: self.spans.append((cursor, cursor + len(sent))) cursor += len(sent) # 平铺成一维,方便按绝对下标随机访问 self.flat = [w for sent in corpus_ids for w in sent] def __len__(self): return len(self.flat) def __getitem__(self, idx): # 找到 idx 属于哪一句,只在句内取上下文,避免跨句共现 lo, hi = 0, len(self.spans) - 1 while lo < hi: mid = (lo + hi) // 2 if self.spans[mid][1] <= idx: lo = mid + 1 else: hi = mid start, end = self.spans[lo] w = random.randint(1, self.window) # 窗口每次随机,覆盖不同距离 j = random.randint(max(start, idx - w), min(end - 1, idx + w)) if j == idx: # 撞到自己就退回句内随机一个 j = start if idx != start else end - 1 return self.flat[idx], self.flat[j]

__getitem__里那段二分查找是为了把「绝对下标」映射回「它属于哪句话」。为什么要句内限制?因为拼接语料的时候句子之间是直接换行连起来的,如果不做边界约束,句子末尾的词会和下一句开头的词被当成上下文对,产生的都是噪声共现。这条边界在短句多的语料(评论、弹幕、搜索 query)上影响特别大。

负采样表按词频的 0.75 次方构造。这个指数的作用是压低高频词被抽中的概率、抬高低频词,让负样本分布更接近均匀而不是被「的」「是」垄断。

# wv/vocab.py 续 import numpy as np def build_neg_table(counter, vocab, table_size=10_000_000): freq = np.array([counter.get(w, 0) for w in vocab.itos], dtype=np.float64) freq[0] = 0.0 # <unk> 不参与负采样 freq = np.power(freq, 0.75) prob = freq / freq.sum() return np.random.choice(len(vocab), size=table_size, p=prob)

建表时把 1000 万个下标直接抽出来存成 numpy 数组,训练时随机切片取 K 个就行,比每次现算np.random.choice快得多。表大小取 1e7 是经验值,五万词表下一亿也行,内存大概 400MB 的int64,看机器来。

3.4 训练循环:从 loss 曲线判断有没有学歪

训练主循环不长,但每一步都值得写清楚在干什么:

# train.py(节选) import torch, random from torch.utils.data import DataLoader from wv.model import SkipGramNS from wv.vocab import build_neg_table def train(model, dataset, neg_table, cfg, device="cpu"): loader = DataLoader(dataset, batch_size=cfg.batch_size, shuffle=True, num_workers=2, drop_last=True) # 稀疏梯度必须配 SparseAdam,普通 Adam 在稀疏权重上会报错或极慢 optimizer = torch.optim.SparseAdam(model.in_embed.parameters(), lr=cfg.lr) \ if False else torch.optim.SparseAdam( list(model.in_embed.parameters()) + list(model.out_embed.parameters()), lr=cfg.lr) for epoch in range(cfg.epochs): total, steps = 0.0, 0 for center, context in loader: center, context = center.to(device), context.to(device) # 负样本在 batch 内现切,表是预先建好的一维数组 picks = torch.randint(0, neg_table.shape[0], (center.size(0), cfg.negative)) negatives = torch.from_numpy(neg_table[picks.numpy()]).to(device) loss = model(center, context, negatives) optimizer.zero_grad() loss.backward() # 梯度裁剪,防止个别异常样本把权重推飞 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total += loss.item(); steps += 1 if steps % 200 == 0: print(f"epoch {epoch} step {steps} loss {total/steps:.4f}") total = 0.0

几个参数和写法的用意:drop_last=True是必须的,最后一个不满的 batch 只有一两条样本时,梯度方向会很偏,还会让 loss 曲线出现尖刺。num_workers=2在机械硬盘上够用,SSD 上可以加,但注意 Windows 下多进程 DataLoader 有时候会因为句柄问题卡死,本地调试可以设成 0。梯度裁剪的阈值 5.0 是个保守值,一般碰不到,碰到了通常说明学习率给太大了。

loss 的正常形态是从 4 到 6 附近起步(取决于negative的大小,负样本越多初始 loss 越大),在前几百步快速下降到 2 左右,然后缓慢下降并趋于平台。如果你看到曲线在 0.69 附近横平竖直,那几乎可以确定是全部预测成了 0.5,也就是说模型压根没学到东西——这个数字在排查那章还会再提。

4. 训练与验证:怎么跑起来、怎么判断词向量训好了

代码写完之后最现实的问题是:跑完一遍,怎么判断这份词向量是能用还是废的。词向量没有准确率这种硬指标,评价手段得自己搭。项目里给了三条:最近邻、类比推理、降维可视化,分别对应「语义是否聚集」「关系是否线性」「整体分布是否合理」。

4.1 环境准备与训练命令

依赖不多,装完就能跑。Python 建议 3.9 以上,PyTorch 装 CPU 版也够用——词向量训练在这个规模下瓶颈在内存带宽而不是算力,GPU 加速比大概只有一两倍,不值得为此增加环境复杂度。

# 建环境 python -m venv .venv && source .venv/bin/activate pip install torch --index-url https://download.pytorch.org/whl/cpu pip install jieba numpy scikit-learn matplotlib pyyaml # 1) 先单独跑分词,产出词表和掉分词后的语料,方便复用 python -m wv.vocab --corpus data/raw/corpus.txt --out data/processed --min-count 5 # 2) 训练,超参从 yaml 读,命令行可覆盖 python train.py --config configs/base.yaml --override epochs=5 --device cpu # 3) 评估 python eval.py --ckpt runs/base/last.pt --words 退货 客服 物流 --topk 10

第一步单独跑分词的收益前面提过了。这里补一句执行顺序上的注意:train.py要读的是data/processed里的词表和索引化语料,如果直接跑训练而没跑第一步,会报文件不存在。我一般的做法是在train.py开头加一个断言,缺文件时打印出正确的命令提示,而不是甩一个FileNotFoundError让人猜。

训练时长参考:五十万句、平均 20 个词、词表五万、epochs=5,单机 CPU 大概几十分钟到两小时,看机器。想先验证链路通不通,把语料截前一万行、epochs=1,两分钟就能出结果。

4.2 评估:最近邻、类比推理与 t-SNE 落图

最近邻检索是最直观的检查方式,代码也短:

# eval.py(节选) import torch @torch.no_grad() def nearest(model, vocab, word, topk=10): if word not in vocab.stoi: return [] w = model.in_embed.weight.data # 先做 L2 归一化,余弦相似度就退化成点积,省一次除法 w = w / w.norm(dim=1, keepdim=True).clamp(min=1e-8) idx = vocab.stoi[word] scores = torch.matmul(w, w[idx]) # 排除自己和特殊符号 scores[idx] = -1.0 scores[0] = scores[1] = -1.0 top = torch.topk(scores, topk).indices.tolist() return [(vocab.itos[i], round(scores[i].item(), 4)) for i in top]

参数上只有topk值得调,一般看 10 到 20 个,太少看不出趋势,太多会把弱相关的词也拉进来干扰判断。clamp(min=1e-8)是防止某些训练到一半的向量范数为 0 导致除零。

类比推理用来验证向量空间有没有「线性结构」,经典形式是king - man + woman ≈ queen。中文语料上更实用的是领域内的对应关系,比如「北京 - 中国 + 日本 ≈ 东京」这类。评估时准备一份几十条的小测试集,算 top-1 和 top-3 命中率,作为不同超参之间的横向对比指标。注意这个指标绝对数值通常不高(领域语料上 30% 到 50% 就很正常),它的价值在于相对比较,不在于绝对值。

t-SNE 落图适合放在汇报或自查的最后一环:挑 200 个高频词或者某个类别的词(比如一批商品品类名),降到二维画散点。图上如果同类词明显成团,说明训练是有效的;如果全部糊成一团,八成是训练不充分或者维度太低。散点图不要超过 300 个点,多了标签叠在一起完全看不清。

4.3 导出与下游复用

词向量训完必须落成通用格式,否则只在当前项目里能用。项目导出成文本格式,一行一个词,第一行是「词数 维度」:

# wv/io.py def save_vec(model, vocab, path): w = model.in_embed.weight.data.cpu() w = w / w.norm(dim=1, keepdim=True).clamp(min=1e-8) with open(path, "w", encoding="utf-8") as f: # 跳过 <unk> 和 <pad>,别让特殊符号污染下游词表 n = len(vocab) - 2 f.write(f"{n} {w.size(1)}\n") for word, idx in vocab.stoi.items(): if idx < 2: continue vec = " ".join(f"{x:.6f}" for x in w[idx].tolist()) f.write(f"{word} {vec}\n")

把向量归一化之后再导出是个好习惯,下游拿到手就能直接点积算相似度,不用每次重新算范数。代价是丢失了模长信息,如果你的下游任务需要「词的活跃度」这类信号(模长往往和词频相关),就得保留原始向量,另存一份。

下游用的方式有两种。文本分类这类任务里最简单的是冻结词向量,当成nn.Embedding的初始权重:

import torch.nn as nn emb = nn.Embedding.from_pretrained(weight_matrix, freeze=True, padding_idx=1) # weight_matrix 按你的词表顺序从 .vec 里查出来拼成 [V, D]

freeze=True表示不参与微调,适合数据量小的场景;数据量大的时候设成False,让词向量跟着任务一起调,通常能再涨一两个点,但要注意学习率得比主网络小一个量级,否则预训练学到的语义会被冲掉。padding_idx=1对应<pad>,保证补齐位置不产生梯度。

5. 训练翻车排查:五条从 loss 曲线看到的现象

词向量这活儿的玄学含量不低,很多时候代码没错、参数没错,结果就是不对。下面这五条是我在不同语料上反复撞到的,按「现象 → 原因 → 解决」排。

5.1 五条踩坑记录

第一条:loss 从第一步到第两千步都稳定在 0.69 附近,一动不动。现象很迷惑人,因为它不炸、不报错,看起来像在正常训练。原因是模型对所有样本都输出了接近 0.5 的概率,也就是权重根本没更新。最常见的触发点是学习率写错了量级——配置里写了2.5(漏了e-3),一步就把权重推到极值后梯度饱和;其次是优化器选错,给sparse=True的Embedding配了普通Adam。解决方式是先把 lr 降到1e-3到5e-3区间,确认优化器是SparseAdam或者SGD,再打印第一步的梯度范数,正常应该是1e-2量级,如果打出 0 或者nan,问题就在前面。

第二条:相似词列表里全是「的」「了」「是」「一个」,实词一个没有。现象是输入任何词,返回的邻居都是高频虚词。原因是高频词没有被压制。Skip-gram 的共现统计里,「的」跟几乎所有词都出现在同一个窗口内,如果不做下采样,它学到的向量会趋近所有词向量的平均值,于是跟谁余弦相似度都高。解决方式是打开subsample(1e-4到1e-5),或者在词表构建时直接把词频排名前 50 的虚词加进停用词表——粗暴但有效。另外确认min_count别设成 1,那等于把所有只出现一次的词也塞进模型,白白摊薄训练预算。

第三条:训练几十分钟,内存一路涨到把机器吃满。现象是 RSS 持续增长,最后被系统杀掉。原因通常是数据集被全量展开成了 Python 列表。一句话 20 个词、窗口 5,展开成词对就是 200 个元组;一百万句就是两亿个元组,光对象头就吃掉十几个 G。解决方式是按 3.3 的写法,Dataset里只存扁平数组和句子边界,__getitem__现算。另外把负采样表从 Pythonlist换成numpy.ndarray也能省一大截,五千万个 int 在 list 里是几十亿字节,在 ndarray 里是 400MB。

第四条:训完导出,下游加载报「词表对不上」。现象是nn.Embedding.from_pretrained时维度不匹配,或者查某个词得到随机初始化的向量。原因是导出顺序和下游词表顺序不一致。.vec文件里词的排列顺序是导出时的vocab.stoi顺序,下游如果按自己的词表顺序去拼weight_matrix,必须逐个词名去查、查不到就整行置零或者取平均,而不是按行号直接对齐。解决办法是在io.py里额外保存一份vocab.txt(一行一个词,行号即索引),下游严格按它建表,没覆盖到的词显式走<unk>。

第五条:类比推理命中率极低,但最近邻看起来挺正常。现象是相似词没毛病,一到A - B + C就崩。原因有两个,一是向量没归一化就做加减,模长大的词会主导结果;二是训练语料太小,词的相对关系还没被压进线性结构里。解决方式是先对参与运算的三个向量都做 L2 归一化,再算目标向量,最后用余弦相似度检索时排除掉输入的三个词。如果还是不行,别急着改模型,先看语料规模——几十万词的语料上类比推理本来就很难出效果,这不是代码的问题。

5.2 日志与中间态检查清单

排查的时候最怕没有中间态可看。我一般会在训练脚本里强制打这几样东西,出问题时直接翻日志,比重新跑一遍猜要快:

检查点打什么正常表现
词表建完总词数、<unk>占比词表 3 到 8 万,<unk>占比低于 5%
数据加载前 3 个 batch 的(center, context)原文是真实相邻词,不是跨句
首步梯度每个参数组的 grad norm1e-3到1e-1之间,非 0 非 nan
每 200 步平均 loss首 200 步明显下降
每 epoch 末三个固定词的 top-5 邻居邻居随 epoch 逐渐变得「像」

表里最后一行是最有用的。找三个你熟悉的词固定下来,每轮结束打一次邻居,肉眼盯着它们变合理的过程,比盯 loss 数字直观得多。我习惯放一个领域词、一个通用实词、一个动词,三个词的变化趋势基本能反映整体训练状态。

6. 二次开发改造:换语料、换目标、接下游

基线跑通之后,真正花时间的改造一般集中在三个方向,每个方向的改动量都不大,但验证方式要跟着变。

第一是换语料。这是收益最高也最容易翻车的改造。领域语料(比如某个垂直行业的工单文本)训出来的词向量,在领域词上的相似度会明显好于通用语料,代价是通用词变差。改的时候只动data/raw和min_count——领域语料总量小,min_count从 5 降到 3 甚至 2 往往能多捞回一批有效专有名词,但低于 3 就要注意噪声词开始进入邻居列表了。验证方式是准备一份 30 到 50 条的领域词对(人工标注「应该相似」和「应该不相似」),算一个简单的 top-10 命中率,改前改后各跑一次,别凭感觉判断。

第二是换训练目标。model.py里加一个 CBOW 分支大概二十行:把center和context的位置对调,中心词的表示改成上下文词向量的平均,其余损失结构完全不变。CBOW 在小语料上收敛更快、高频词更稳,Skip-gram 在低频词上表现更好——这个取舍用同一份语料两份 checkpoint 跑一次对比就能看出来。如果需要更强的效果,还可以加高频词下采样(在__getitem__里按概率丢弃高频词样本)和 n-gram 特征(把相邻两个 token 拼成一个"词"进词表),这两个改动各自独立,可以分开验证。

第三是接下游。词向量单独存在没有意义,得挂到一个真实任务上才能证明它有用。最常见的验证方式是文本分类:用同一份标注数据,一份用随机初始化的 embedding、一份用训练好的词向量初始化,其余超参完全一致,跑三次取平均 F1。差距在两个点以上就是有效收益,一个点以内基本可以认为是随机波动。

改造方向改动位置主要风险验证指标
换领域语料configs/*.yaml、data/raw词表缩水、<unk>占比上升领域词对命中率
加 CBOW 分支wv/model.py、train.py两个分支的 batch 结构不一致低频词邻居质量
接文本分类新建downstream/词表顺序错位分类 F1 对比
加 n-gramwv/vocab.py、dataset.py词表膨胀、内存上涨词表规模与耗时

这里有个我反复提醒自己的点:每次只改一个变量,改完必须有一次可量化对比。词向量这玩意儿特别容易产生「感觉变好了」的错觉——随便挑几个词看看邻居,总能找到一两个看起来更合理的。所以我从那以后,每次动超参或者改模型结构之前,都强制先把基线指标存成一份 JSON,改完再跑一遍同样的评估脚本做 diff。没有 diff 的改动,一律当成没改。希望这个习惯能帮到你,少走几段弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询