简介:面向自然语言处理初学者与课程实践者的一份词向量训练作业资源,围绕 PyTorch 实现的 CBOW 模型展开,可用于完成中英文语料上的词向量训练、停用词处理与结果输出,适合作为课堂作业参考或 word2vec 入门练习。压缩包共 12 个文件,约 13.58MB,主要由 txt 语料与词向量文本、xml 项目配置、Python 训练脚本及少量工程文件组成,按数据、脚本、输出等模块组织,便于直接定位训练流程与实验产物。已有 2695 人学习,说明该作业案例具有一定参考价值。读者可从中获得可运行的中英文 CBOW 训练代码、较完整的数据组织方式、停用词处理思路以及训练后词向量文件的输出示例,并据此理解词向量模型的输入构造、参数设置与中英文语料切换方法,适合对照自己的实验过程查漏补缺。
1. 一份中英混排语料摆在面前,最省事的做法反而是自己用 PyTorch 写一遍 CBOW
前阵子接了个活儿:手里有一批中英混排的技术文档和问答记录,下游是个小分类任务,想配一份领域词向量。上手现成工具包后才发现词表不可控,中文分词口径、标点清洗、<unk>归并全是黑匣子,下游效果一波动就查不出原因。索性用 PyTorch 从零搭了个 CBOW 词向量训练流程,语料中英文各一份,词表、滑窗、负采样、学习率这些全部摊开在配置里。
这套东西适合两类人:想搞明白 word2vec 到底怎么算出来的学习者,以及需要在私有语料上稳定复现训练结果的开发者。它不追求跑分,追求的是每一步都能改、每一步都能看。
2. 项目骨架:目录职责、依赖版本和双语料的预处理分叉
先把整体摆开。这套实现刻意压小了模型部分,把复杂度全部压到数据和配置上,因为训词向量翻车的地方九成不在网络结构,而在语料和词表。
2.1 目录结构与每个文件的职责
目录按「配置 - 数据 - 核心逻辑 - 脚本入口」四层切开,训练脚本不写业务逻辑,只做参数解析和串联:
cbow_word2vec/ ├── configs/ │ └── default.yaml # 所有超参集中在这里,代码里不写魔数 ├── data/ │ ├── raw/ │ │ ├── zh_corpus.txt # 一行一句的中文语料 │ │ └── en_corpus.txt # 一行一句的英文语料 │ └── processed/ │ ├── vocab.json # stoi 映射 │ └── itos.json # 严格按 id 顺序存的词表,必须和模型一起落盘 ├── src/ │ ├── config.py # yaml -> dataclass,带默认值和类型校验 │ ├── tokenizer.py # 中英文两条分词线 │ ├── vocab.py # 词表构建、特殊符号、持久化 │ ├── dataset.py # 滑窗采样、高频词下采样、collate │ ├── negative_sampling.py # 0.75 次方噪声分布表 │ ├── model.py # CBOW 网络 │ └── trainer.py # 训练循环、日志、checkpoint ├── scripts/ │ ├── build_vocab.py # 只建词表,不训练 │ ├── train.py # 训练入口 │ └── evaluate.py # 近邻检索、类比推理 └── requirements.txtbuild_vocab.py单独拎出来是个关键设计。词表构建要扫全量语料,几十万句要跑几分钟,如果和训练耦在一起,每次调batch_size都得重扫一遍,血泪经验。词表落盘之后,训练阶段只读 json,改超参随便改。
itos.json单独存一份,不是为了好看。nn.Embedding的第 0 行是<pad>、第 1 行是<unk>,这个顺序就是权重矩阵的行号。哪天你重新构建了词表但用了旧权重,向量和词就整体错位了,模型不报错,相似词全乱。这是最典型的后悔药场景。
配置层用 dataclass 包一层,好处是 IDE 能补全、拼错字段名会直接 raise:
# src/config.py from dataclasses import dataclass, field import yaml @dataclass class CorpusConfig: zh_path: str = "data/raw/zh_corpus.txt" en_path: str = "data/raw/en_corpus.txt" min_count: int = 5 # 低于这个词频直接进 <unk>,中文语料别设太小 max_vocab: int = 200000 # 词表上限,防止输出层参数爆炸 share_vocab: bool = True # 中英文是否共用一张词表 lowercase: bool = True @dataclass class ModelConfig: embed_dim: int = 200 window_size: int = 5 negative: int = 5 # 每个正样本配几个负样本 subsample: float = 1e-5 # 高频词下采样阈值 t @dataclass class TrainConfig: batch_size: int = 1024 epochs: int = 5 lr: float = 1e-3 clip_grad: float = 5.0 num_workers: int = 4 seed: int = 42 device: str = "cuda" @dataclass class Config: corpus: CorpusConfig = field(default_factory=CorpusConfig) model: ModelConfig = field(default_factory=ModelConfig) train: TrainConfig = field(default_factory=TrainConfig) def load_config(path: str) -> Config: with open(path, encoding="utf-8") as f: raw = yaml.safe_load(f) or {} # 只覆盖 yaml 里出现的字段,其余走默认值 return Config( corpus=CorpusConfig(**raw.get("corpus", {})), model=ModelConfig(**raw.get("model", {})), train=TrainConfig(**raw.get("train", {})), )**raw.get("corpus", {})这种写法有个副作用:yaml 里多写一个不存在的字段会直接TypeError。这恰恰是想要的,配置文件拼错字段名比跑完之后发现参数没生效强得多。
share_vocab这个开关要说清楚。如果中英文来自同一份对照文档(同一段里既有中文术语又有英文术语),共享词表有意义,共现能把「embedding」和「嵌入」拉到相近位置。如果只是把两份独立语料拼一起,共享词表只会让两个语言空间勉强挤在一块,最后中文词的近邻全是英文高频虚词,得不偿失。我的默认做法是:语料有对齐关系才共享,否则分开训两份、各存各的权重。
2.2 中英文两条分词线,别指望一个函数通吃
中文和英文的 token 边界规则完全不同,混在一个函数里做,最后一定是一堆 if。分成两个函数,各自返回list[str],后面统一进词表。
# src/tokenizer.py import re import unicodedata import jieba # 英文:单词(含缩写撇号)、小数、整数 EN_PATTERN = re.compile(r"[a-z]+(?:'[a-z]+)?|\d+\.\d+|\d+") def normalize(text: str) -> str: """NFKC 归一化:全角字母数字转半角,消除同一字符的多种码位写法""" text = unicodedata.normalize("NFKC", text) # 零宽字符和软连字符会凭空造出重复词,直接清掉 for ch in ("\u200b", "\u200c", "\u00ad"): text = text.replace(ch, " ") return re.sub(r"\s+", " ", text).strip() def tokenize_zh(line: str) -> list[str]: line = normalize(line) tokens = [] for tok in jieba.lcut(line): tok = tok.strip() # 丢掉纯标点、纯空白;保留中文、字母和数字 if not tok: continue if all(not ch.isalnum() for ch in tok): continue tokens.append(tok.lower()) # 中文不受影响,英文统一小写 return tokens def tokenize_en(line: str) -> list[str]: return EN_PATTERN.findall(normalize(line).lower())normalize里做 NFKC 是为了解决全角数字、全角括号导致「2024」和「2024」被当成两个词的问题。有个坑要提前说:NFKC 会顺手把①变成1、㈱变成(株),中文正文里这种字符少,问题不大,但如果语料里有大量特殊符号,建议改成只做全角转半角的手工映射。
句子边界在预处理阶段就切好,一行一句,jieba.lcut不跨行。CBOW 的窗口是句内窗口,跨句滑窗会把「上一段结尾」和「下一段开头」硬凑成上下文,学出来的向量质量会明显下降。
预处理跑成一次性脚本:
python -m scripts.build_vocab \ --config configs/default.yaml \ --out data/processed2.3 依赖清单和第一次跑起来的完整命令
依赖不多,装完就能跑:
pip install torch>=2.0 jieba pyyaml numpy tqdmjieba首次加载词典要 1 秒左右,num_workers > 0时每个 worker 都会加载一次,进程一多内存会明显涨。语料在百万句以内,建议num_workers: 2,别一上来就 8。
词汇表构建 + 训练 + 评测三步走:
# 1. 建词表(扫全量语料,输出 vocab.json / itos.json) python -m scripts.build_vocab --config configs/default.yaml # 2. 训练 python -m scripts.train --config configs/default.yaml --out checkpoints/ # 3. 看近邻 python -m scripts.evaluate --ckpt checkpoints/cbow_best.pt --word 神经网络 --topk 10训练日志里我固定打三样东西:loss、lr、每秒处理的样本对数pairs/s。只有 loss 一个数字的时候,出问题根本分不清是数据管线慢还是模型慢。pairs/s这个指标很朴素,但比GPU-Util靠谱,后者在小白表上经常显示 0。
3. 数据管线拆解:从一个句子到一条 (上下文, 中心词, 负样本) 训练对
数据管线是这套实现里代码最多的部分,也是最值得抄作业的部分。CBOW 的模型只有十几行,训不好基本都能怪到这里来。
3.1 词表构建与四个特殊标记
词表构建看着简单,顺序问题能让人调半天。核心原则:排序必须完全确定。Counter.most_common()在词频相同时顺序依赖插入顺序,同一份语料两次构建可能得到不同的 id 分配,权重就白训了。
# src/vocab.py import json from collections import Counter PAD, UNK, BOS, EOS = "<pad>", "<unk>", "<bos>", "<eos>" def build_vocab(sentences, min_count=5, max_vocab=200000): counter = Counter(tok for sent in sentences for tok in sent) # 先按词频降序,词频相同按字典序升序,保证结果稳定可复现 items = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0])) itos = [PAD, UNK, BOS, EOS] for tok, cnt in items: if cnt < min_count or len(itos) >= max_vocab: break itos.append(tok) stoi = {tok: i for i, tok in enumerate(itos)} return stoi, itos def save_vocab(stoi, itos, out_dir): with open(f"{out_dir}/vocab.json", "w", encoding="utf-8") as f: json.dump(stoi, f, ensure_ascii=False) with open(f"{out_dir}/itos.json", "w", encoding="utf-8") as f: json.dump(itos, f, ensure_ascii=False)min_count=5这个值在中英文上体感完全不同。英文语料几十万句,5 够用;中文语料如果规模小(比如十万句以内),5 会把大量低频专业术语全推进<unk>,这时候调到 2~3 更合适。判断依据很简单:建完词表看一眼<unk>在验证语料里的出现比例,超过 5% 就说明阈值设高了。
<bos>和<eos>在当前实现里不参与训练,只是个占位,方便后面接 RNN 类下游任务时词表不用重建。如果纯做词向量,这两个 id 可以保留但完全可以不管。
max_vocab主要防输出层参数爆炸。词表 20 万、embed_dim=200,光输入 Embedding 就是 4000 万参数,输出矩阵再来 4000 万。如果用完整 softmax,两个矩阵都要算梯度,显存直接翻倍。
3.2 滑动窗口采样与高频词下采样
CBOW 的标准做法是:对句子里的每个位置i,取[i-w, i+w]去掉i本身作为上下文,预测i位置的词。原版 word2vec 还做了两件事——动态窗口和二次采样,这两件事对最终向量质量影响极大。
# src/dataset.py import math import random from torch.utils.data import Dataset class CBOWDataset(Dataset): def __init__(self, sentences, stoi, window=5, subsample=1e-5, seed=42): self.window = window rng = random.Random(seed) unk_id = stoi["<unk>"] # 用 id 序列跑采样,比反复查 stoi 快一个数量级 id_sents = [[stoi.get(t, unk_id) for t in s] for s in sentences] total = sum(len(s) for s in id_sents) or 1 freq = Counter(t for s in id_sents for t in s) # word2vec 的保留概率:P(keep) = (sqrt(f/t) + 1) * t / f keep = {} for wid, c in freq.items(): f = c / total p = (math.sqrt(f / subsample) + 1) * subsample / f keep[wid] = min(p, 1.0) self.pairs = [] for sent in id_sents: kept = [w for w in sent if rng.random() <= keep[w]] if len(kept) < 2: continue for i, center in enumerate(kept): # 动态窗口:实际半径在 1..window 之间随机,远距离词出现次数更少 radius = rng.randint(1, window) ctx = kept[max(0, i - radius):i] + kept[i + 1:i + radius + 1] if ctx: self.pairs.append((ctx, center)) def __len__(self): return len(self.pairs) def __getitem__(self, idx): return self.pairs[idx]动态窗口这一手很容易被忽略。固定窗口会让 5 个距离上的词被采样次数一样多,而语言里近邻词的信息量明显更高。radius = randint(1, window)效果上是距离 1 被采到的概率是 1,距离 5 只有 1/5,正好符合直觉。
二次采样处理的是「的、了、the、of」这类超高频词。它们的向量在训练好的模型里通常也不差(因为出现太多),但它们贡献了海量训练对却不提供区分信息,白白占掉训练时间。《Attention is all you need》里那句「高频词提供的信号很少」说的就是这事儿。subsample=1e-5是默认值,语料越小可以调大一点(比如 1e-3),免得句子被削得太短。
一个细节:下采样之后句子的长度变了,位置i的含义和原句对不上了。这对 CBOW 没影响,因为上下文关系仍然成立,但如果你的下游要做位置相关的建模就得重新考虑。
3.3 负采样表与批次组装
完整 softmax 在 20 万词表上是跑不动的,所以要上负采样:每个正样本配 K 个随机词作为负例,把多分类降成二分类。负样本的抽样分布是freq^0.75,这个 0.75 次方是原版论文的经验值,作用是压一压超高频词被抽中的概率,抬一抬低频词。
# src/negative_sampling.py import numpy as np class NegativeSampler: def __init__(self, counts, power=0.75, table_size=1_000_000, seed=42): p = np.asarray(counts, dtype=np.float64) ** power p /= p.sum() rng = np.random.default_rng(seed) # 预先抽好一张大表,训练时直接查表,省掉每次 multinomial 的开销 self.table = rng.choice(len(counts), size=table_size, p=p) self.table_size = table_size def sample(self, batch_size, k, rng=None): rng = rng or np.random idx = rng.randint(0, self.table_size, size=(batch_size, k)) return self.table[idx]table_size=1_000_000是内存和精度的折中,占 8MB 左右(int64)。词表很大(百万级)时可以用int32省一半内存。这张表在建DataLoader之前构建一次,训练循环里只做randint,开销可以忽略。
组批用自定义collate_fn,因为上下文长度不固定,要 pad:
# src/dataset.py 续 import numpy as np import torch def make_collate(pad_id: int): def collate(batch): ctxs, centers = zip(*batch) max_len = max(len(c) for c in ctxs) ctx_arr = np.full((len(ctxs), max_len), pad_id, dtype=np.int64) for i, c in enumerate(ctxs): ctx_arr[i, :len(c)] = c return { "ctx": torch.from_numpy(ctx_arr), "center": torch.tensor(centers, dtype=torch.long), } return collatepad_id必须是 0,而且模型里nn.Embedding要显式传padding_idx=0。光靠 mask 手动清零也能跑,但padding_idx会让 pad 行的梯度恒为 0,权重永远是初始化的全零向量,后面做相似度计算的时候这一行不会跑出来干扰结果。
num_workers > 0时注意:random.Random(seed)在__init__里已经跑完了,__getitem__只做取数,所以样本生成是确定性的。这样每次训练拿到的训练对完全一致,调参时变量才可控。
4. CBOW 模型和训练循环:一个 Embedding、一个平均、一个带权输出
模型部分短,但每个选择都有理由。写清楚这些理由,比把代码贴出来更有用。
4.1 网络结构:输入求和/平均 + 输出词表映射
CBOW 的假设很粗暴:一个窗口里所有上下文词的向量加起来,能代表中心词。所以结构就是 Embedding → 池化 → 输出层。
# src/model.py import torch import torch.nn as nn import torch.nn.functional as F class CBOW(nn.Module): def __init__(self, vocab_size: int, embed_dim: int = 200, pad_id: int = 0): super().__init__() # 输入矩阵和输出矩阵分离,这是 word2vec 原版的做法 self.in_embed = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_id) self.out_embed = nn.Embedding(vocab_size, embed_dim, padding_idx=pad_id) # 初始化为小均匀分布,范围 0.5/dim,避免一开始点积过大 nn.init.uniform_(self.in_embed.weight, -0.5 / embed_dim, 0.5 / embed_dim) nn.init.zeros_(self.out_embed.weight) def encode(self, ctx): mask = (ctx != 0).unsqueeze(-1).float() # (B, L, 1) emb = self.in_embed(ctx) * mask # pad 行归零 denom = mask.sum(dim=1).clamp(min=1.0) # (B, 1),防除零 return emb.sum(dim=1) / denom # (B, D) 平均池化 def forward(self, ctx, center, negs): ctx_vec = self.encode(ctx) # (B, D) center_vec = self.out_embed(center) # (B, D) neg_vec = self.out_embed(negs) # (B, K, D) pos_score = (ctx_vec * center_vec).sum(dim=-1) # (B,) neg_score = torch.bmm(neg_vec, ctx_vec.unsqueeze(-1)).squeeze(-1) # (B, K) loss = -F.logsigmoid(pos_score).mean() - F.logsigmoid(-neg_score).mean() return loss平均还是求和,是个需要说清楚的点。原版 word2vec 用的是求和,因为它的学习率是按 batch 大小和窗口大小一起调的,求和相当于把梯度缩放了1/L。这里用平均,好处是切换窗口大小(比如从 5 改到 10)时梯度量级不漂,调参更省心。两种写法在数学上只差一个常数因子,最终学到的向量方向基本一致,但学习率要跟着改。翻车案例里有一半是改了窗口大小忘了改 lr,loss 曲线直接起飞。
输入、输出两个矩阵分开,是另一个容易搞混的地方。word2vec 训完之后只取输入矩阵(in_embed.weight)作为词向量,输出矩阵丢掉。有人问为什么不共享——共享之后高频词的输入向量会被输出层的梯度主导,低频词反过来,向量分布会畸形。这事没有绝对答案,但对小语料,分离更稳。
4.2 两种损失实现:完整 softmax 和负采样怎么选
模型里只写了负采样版。如果要换成完整 softmax(词表小于 5 万时可行),改动很小:
class CBOWSoftmax(CBOW): def __init__(self, vocab_size, embed_dim=200, pad_id=0): super().__init__(vocab_size, embed_dim, pad_id) # 输出矩阵换成 vocab_size x vocab_size 的显式投影 self.proj = nn.Linear(embed_dim, vocab_size, bias=False) def forward(self, ctx, center): ctx_vec = self.encode(ctx) logits = self.proj(ctx_vec) # (B, V) return F.cross_entropy(logits, center)选型看词表规模和语料量:
| 方案 | 词表上限 | 每步计算量 | 适用场景 |
|---|---|---|---|
| 完整 softmax | 约 5 万 | O(B×V×D) | 小语料快速验证、教学演示 |
| 负采样 K=5 | 百万级 | O(B×K×D) | 真实语料训练 |
| 层次 softmax | 百万级 | O(B×logV×D) | 词表极大且不方便调 K |
手工算一下:B=1024、V=200000、D=200,完整 softmax 一步的矩阵乘大约是 4×10^10 次浮点运算,光是输出层就吃掉整个 GPU;换成 K=5,计算量降到约 10^9,差 40 倍。这就是为什么真正的语料训练几乎都用负采样。
负采样的 K 值不是越大越好。K=5 是原版默认,语料小(百万词以内)可以到 10~15,语料大就 2~5。K 变大不会让向量质量线性变好,但训练时间线性上升。
4.3 训练循环、学习率衰减与显存控制
训练循环里我固定加了三样东西:seed 固定、梯度裁剪、学习率线性衰减。
# src/trainer.py(节选核心循环) import random import numpy as np import torch def train(model, loader, sampler, cfg, device): random.seed(cfg.seed) np.random.seed(cfg.seed) torch.manual_seed(cfg.seed) model.to(device) optimizer = torch.optim.Adam(model.parameters(), lr=cfg.lr) total_steps = len(loader) * cfg.epochs global_step = 0 for epoch in range(cfg.epochs): model.train() for batch in loader: ctx = batch["ctx"].to(device, non_blocking=True) center = batch["center"].to(device, non_blocking=True) # 负样本 id 在 CPU 上抽,抽完再搬,比在 GPU 上做 choice 省事 negs = torch.from_numpy( sampler.sample(len(center), k=cfg.negative) ).to(device, non_blocking=True) loss = model(ctx, center, negs) optimizer.zero_grad(set_to_none=True) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.clip_grad) optimizer.step() # 学习率线性衰减到初始值的 1e-4,原版 word2vec 的做法 global_step += 1 lr = cfg.lr * max(1e-4, 1.0 - global_step / total_steps) for g in optimizer.param_groups: g["lr"] = lr if global_step % 200 == 0: print(f"epoch={epoch} step={global_step} loss={loss.item():.4f} lr={lr:.6f}") torch.save({ "model": model.state_dict(), "itos": cfg.itos, "embed_dim": cfg.embed_dim, }, f"{cfg.ckpt_dir}/cbow_epoch{epoch}.pt")zero_grad(set_to_none=True)比默认的zero_grad()省一次全参数写零,大模型上能省几个百分点的时间,小模型差别不大,但习惯养起来没坏处。
clip_grad_norm_在负采样里很有必要。正样本得分是logsigmoid,负样本是logsigmoid(-x),一旦某批样本的得分跑到 ±10 以外,梯度就会尖峰,学习率 1e-3 下可能直接把向量打飞。裁剪到 5.0 是个保险值。
衰减这块,Adam + 线性衰减和 SGD + 线性衰减效果都不错。但有个细节:Adam 自带二阶动量,衰减到1e-4倍之后基本就不动了,所以epochs不宜太少。小语料跑 5 个 epoch、每 epoch 都被DataLoader完整扫一遍就够,大语料 3 个 epoch 也常见。
checkpoint 里必须存itos。加载的时候先读itos再load_state_dict,两边行号严格对齐。少了这一步,重训一次词表或者改一次min_count,加载出来的向量就是一群随机排序的数。
5. 常见问题排查:loss 不降、近邻全是虚词、复现不了
这套流程跑起来不难,难的是结果不对的时候怎么定位。下面五条是我自己在不同语料上真实遇到过的。
5.1 现象:loss 停在 4.5 左右不动,或者降得很慢
原因:负采样版的 loss 绝对值没有可比性,-log σ(x) - log σ(-x)在随机初始化时大约是 1.38 左右(两个 0.69),不是 4.x。如果看到 loss 一直在 4~5 徘徊,基本可以判定正负样本的得分没有被拉开,而最常见的原因是学习率太大导致 embedding 直接被推到饱和区,logsigmoid在饱和区梯度接近 0。
解决:先看in_embed.weight的范数,训练几步之后如果单个向量范数超过 10,说明学习率不对。把lr从 1e-3 降到 2e-4 再跑 200 步对比,同时打开梯度裁剪。另一个可能原因是负采样表没有传对,counts用了错误的全零数组,这时负样本全是 id 0,正负样本几乎不重叠,loss 也降不下去。检查办法是打印一批negs看 id 是否分散。
5.2 现象:中文相似词全是「的、了、是、在」,英文全是 the/of
原因:高频词没有做二次采样,或者subsample设得太大导致句子被削得只剩高频词。还有一种更隐蔽的情况:中文语料里混了繁体、全角标点,分词之后产生了大量只有一个字符的碎片 token,这些碎片在高频词表里挤占了位置。
解决:先把subsample从 1e-5 调到 1e-3 试一轮,观察「的」这类词在训练对里的占比是否下降。同时检查词表前 20 个词,如果出现单独的「,」「。」,说明分词后的标点过滤有漏,回到tokenize_zh那里把所有not ch.isalnum()的 token 清掉。另外中文语料如果句长普遍很短(比如问答对),窗口 5 会覆盖整句,上下文几乎一样,这时候把window_size降到 2~3 更合理。
5.3 现象:GPU 利用率很低,一个 epoch 跑几个小时
原因:九成是数据管线卡在 CPU 上。CBOWDataset.__init__里构建全部pairs是纯 Python 循环,几十万句、每句几十个词,会产生千万级的训练对,构建阶段就要几分钟到十几分钟,而且这部分内存是常驻的:每个 pair 存一个 list,Python 对象的开销远大于实际数据。
解决:第一,把pairs改成两个 numpy 数组(ctx_flat+ctx_offset的 CSR 式存法),内存能降一个数量级。第二,num_workers设成 2~4,persistent_workers=True,避免每个 epoch 重启 worker 进程。第三,batch_size从 1024 往上加,CBOW 的模型很小,在 16G 显存上跑到 4096 完全没问题,批次大了 GPU 才不会等数据。第四,负采样sample里用np.random.randint而不是 Python 的random,后者在千万次调用下慢得明显。
5.4 现象:训练完加载 checkpoint,most_similar出来的词完全对不上
原因:词表顺序和权重行号错位。要么是保存 checkpoint 的时候没存itos,要么是加载的时候用了重新构建的词表,min_count或者语料顺序变了,id 分配就全变了。还有一种情况:save_vocab写了vocab.json但训练脚本读的是缓存里的旧stoi。
解决:把itos作为 checkpoint 的一部分强绑定保存,加载时永远从 checkpoint 读itos,不从磁盘上另外读词表文件。evaluate.py里加一句断言,len(itos) == model.in_embed.weight.shape[0],不相等直接报错,别让它静默跑出一堆乱码结果。
5.5 现象:同样的配置跑两次,结果不一样
原因:seed 没有全链路固定。torch.manual_seed只固定了 torch 的随机数,random和numpy各自有独立状态,DataLoader的 worker 进程还会继承父进程状态再各自演化。jieba的分词本身是确定的,但只要词表构建的排序不稳定,后面全乱。
解决:训练入口统一设random.seed、np.random.seed、torch.manual_seed三个,DataLoader传generator=torch.Generator().manual_seed(seed)。build_vocab的排序用key=lambda kv: (-kv[1], kv[0])保证确定性。最后在训练开始前打印一次len(stoi)和词表前 10 个词,两次跑出来不一致就能立刻发现。
提示:
num_workers > 0时,worker 里的随机状态默认由 PyTorch 按 base_seed 派生,只要主进程 seed 固定,结果是可复现的。但如果你的Dataset.__getitem__里调了random.random(),那就得用worker_init_fn单独设种子。
6. 二次开发:把这份词向量接进下游任务,以及怎么验证它真的有用
训完词向量最容易犯的错,是拿几个相似词看看觉得「还行」就直接上生产。真正该做的验证是:把这批向量冻住,灌进一个简单的下游模型,和随机初始化、和公开预训练向量各跑一遍,比较验证集指标。
具体做法我一般是这样:把in_embed.weight导成numpy,按itos建一个{word: vec}的字典,存成.npy加一份itos.json;下游分类模型里用nn.Embedding.from_pretrained(tensor, freeze=True)加载,接一个 TextCNN 或者平均池化加全连接,跑一遍小验证集。三个对照组:随机初始化、这批领域词向量、通用词向量。领域语料的量不大时,自训向量能比通用向量好 1~3 个点,如果完全没差别,说明语料量不够或者min_count太高把关键术语都推进<unk>了。
导出这一步有个小技巧:导出前把向量做一次 L2 归一化再存。下游做余弦相似度的时候不用再算范数,而且避免某些任务里向量范数过大主导了初始化。
import numpy as np import torch def export_vectors(ckpt_path, out_path): ckpt = torch.load(ckpt_path, map_location="cpu") itos = ckpt["itos"] emb = ckpt["model"]["in_embed.weight"].numpy().astype(np.float32) # 逐行 L2 归一化,pad 行保持全零 norm = np.linalg.norm(emb, axis=1, keepdims=True) emb = emb / np.clip(norm, 1e-9, None) emb[0] = 0.0 np.save(out_path, emb) with open(out_path.replace(".npy", "_itos.json"), "w", encoding="utf-8") as f: json.dump(itos, f, ensure_ascii=False)emb[0] = 0.0这行不能省。<pad>行的范数是 0,归一化之后虽然还是 0,但下游如果用argmax找最近邻,全零向量和任何向量的余弦相似度都是 0,会混进结果里。显式清零再加一个if not vec.any(): continue更稳妥。
另一个常见的改造方向是增量更新:新来一批语料,不想全量重训。可行的做法是加载旧权重,把学习率调到原来的 1/10,用新语料只跑 1 个 epoch,同时保持词表不变,新词全部映射到<unk>。这个做法在多轮迭代里很实用,但要注意新语料如果和旧语料的领域差异太大,旧向量会被拉偏,最好保留旧 checkpoint 做 A/B 对比。
从那以后我每次训词向量,都把「先落盘itos、再打印词表前 10 个词、最后跑一次近邻自检」当成固定动作,三步过不了就不往下游走。希望帮到你。
本文还有配套的精品资源,点击获取