PyTorch手写CBOW:中英文词向量训练与负采样实践

发布时间:2026/10/30 0:35:06
PyTorch手写CBOW:中英文词向量训练与负采样实践

简介:面向自然语言处理初学者与课程实践者的一组词向量训练材料,围绕 PyTorch 实现 CBOW 模型展开,可用于理解 word2vec 的基本流程,并完成中英文语料的对照实验。压缩包共 12 个文件,约 13.58MB,包含 5 个 txt 语料与停用词文件、4 个 xml 项目配置、1 个 py 训练脚本,以及 iml、gitignore 等工程辅助文件;脚本可在中文与英文语料间切换,运行后输出对应的词向量结果,便于比较不同语料下的训练差异。已有 2694 人学习或下载。训练脚本将预处理、参数设置、模型定义与结果保存集中在一个流程中,便于按需修改窗口大小、嵌入维度、训练轮次等参数,观察词向量质量变化。读者可据此了解语料读取、停用词过滤、上下文窗口构造、模型训练与词向量导出等环节,也能参考目录组织方式,快速搭建自己的 CBOW 实验,适合作为 NLP 课程作业、词向量入门练习与 PyTorch 项目复现的参考。

1. 从一份中英文语料说起:CBOW 到底能给你什么

数据盘里放着两百多兆中英文混排文本,中文是扒下来的商品评论,英文是几份公开语料的拼接。任务很直白:把它们变成一份下游模型能直接加载的词向量。用 gensim 一行Word2Vec确实能跑通,可真要调的时候你会发现负采样怎么采、下采样阈值定在哪儿、中文在哪个字后面切,全是一个个黑匣子。这个项目就是把壳拆掉:用 PyTorch 手写 CBOW,前面挂一套中英文通用的分词与词表构建,后面产出vectors.npy+vocab.json,下游nn.Embedding.from_pretrained直接吃。它适合两类人——想搞明白 word2vec 每一步到底在算什么的新手,和需要在损失函数、采样策略上做定制改造的熟手。单卡 8G 显存足够在千万级 token 上出一版能用的向量。

2. 项目骨架与数据管线:从原始语料到 (context, target) 样本对

CBOW 的模型本体只有十几行,工程量大头全在数据侧。一个能跑和跑得好之间的差距,八成来自这里:分词切错、高频词没处理、样本对全量展开把内存吃干,最后都会表现为「loss 降下来了但向量没法看」。所以先看骨架,再看管线。

2.1 目录结构与模块职责

工程按职责切成六块,训练入口只负责串流程,任何一块想替换都不会牵一发动全身:

cbow-zh-en/ ├── configs/ │ └── base.yaml # 超参集中管理,命令行可覆盖 ├── data/ │ ├── raw/ # 原始语料,一行一句 │ │ ├── zh_corpus.txt │ │ └── en_corpus.txt │ └── processed/ # 词表、id 流、向量产物 ├── cbow/ │ ├── tokenizer.py # 中英文分词 │ ├── vocab.py # 词表构建 + 下采样 │ ├── dataset.py # 负采样表 + Dataset │ ├── model.py # CBOW 两种实现 │ ├── train.py # 训练循环 │ └── utils.py # 保存/加载/相似度 ├── scripts/ │ ├── build_vocab.py │ ├── train.sh │ └── eval_sim.py └── requirements.txt

tokenizer.py只做「字符串 → token 列表」,不碰 ID;vocab.py只做「token 流 → 词表 + 计数」,不关心模型;dataset.py把这两者串起来,负责把一条长长的 ID 流切成训练样本。这样分层的好处是,你换中文分词器的时候只动一个文件,模型那边完全无感。我在configs/base.yaml里放了dim、window、min_count、neg_k、subsample_t、lr、epochs、batch_size这几个键,命令行参数优先级高于 yaml,方便做超参扫描时不用改文件。

2.2 中英文分词与词表构建

中文和英文的区别只在切分,切完之后都是 token 序列,后面的逻辑完全共用。英文这边我一般会先转小写再用正则切,把标点和数字单独处理掉;中文用分词库的精确模式,并且显式把停用符号过滤掉,否则标点会以极高频率霸占词表前排,负采样也全采到它们身上。

# cbow/tokenizer.py import re import jieba # 只保留字母、数字、汉字,其余符号一律当分隔符丢掉 _EN_PATTERN = re.compile(r"[a-z]+(?:'[a-z]+)?|[0-9]+") _ZH_KEEP = re.compile(r"[\u4e00-\u9fa5a-zA-Z0-9]") def tokenize_zh(line: str) -> list[str]: line = line.strip() if not line: return [] tokens = jieba.lcut(line) # 单个标点、空白 token 直接丢弃;纯符号 token 也丢 return [t for t in tokens if t.strip() and _ZH_KEEP.search(t)] def tokenize_en(line: str) -> list[str]: return _EN_PATTERN.findall(line.lower())

jieba.lcut是精确模式,返回列表而不是生成器,方便直接统计。_ZH_KEEP.search(t)这一个判断就能把「,」「。」「……」这类 token 拦掉,比维护一张标点表省事。英文小写化会损失一部分专有名词信息,如果下游是命名实体类任务,建议保留原始大小写并在词表里同时存word和word.lower()两份——本项目走通用场景,先按小写处理。

接下来是词表。词表构建这一步要同时产出三样东西:itos(id → 词)、stoi(词 → id)、counts(原始频次,负采样要用),并且要在min_count上做截断,把只出现一两次的噪声词踢出词表,用统一<unk>兜底。

# cbow/vocab.py from collections import Counter PAD, UNK, EOS = "<pad>", "<unk>", "<eos>" class Vocab: def __init__(self, min_count: int = 5, max_size: int | None = None): self.min_count = min_count self.max_size = max_size self.itos: list[str] = [] self.stoi: dict[str, int] = {} self.counts: Counter = Counter() def build(self, token_stream): for tokens in token_stream: self.counts.update(tokens) # 按频次降序,先截 min_count 再截 max_size items = [(w, c) for w, c in self.counts.items() if c >= self.min_count] items.sort(key=lambda x: -x[1]) if self.max_size: items = items[: self.max_size] self.itos = [PAD, UNK, EOS] + [w for w, _ in items] self.stoi = {w: i for i, w in enumerate(self.itos)} return self def encode(self, tokens: list[str]) -> list[int]: unk = self.stoi[UNK] return [self.stoi.get(t, unk) for t in tokens]

<pad>在这里其实用不到(上下文用截断补齐,不用零填充),但保留它是为了让 id 0 有个明确语义,后面接别的任务时不用再挪位。<eos>是关键词:把它插在每句末尾再拼成一条长 ID 流,滑动窗口取上下文时就不会跨句把「上一句的最后一个词」和「下一句的第一个词」当成语义邻居。

下采样是 word2vec 里最容易被忽略的一步,也是向量质量提升最明显的一步。高频词(「的」「是」「the」「of」)数量巨大,对训练贡献却极低,原论文用一个与频次相关的保留概率把它们随机丢掉:

# cbow/vocab.py 续 import math, random def subsample(tokens: list[str], counts: Counter, total: int, t: float = 1e-4) -> list[str]: """保留概率 p = (sqrt(f/t) + 1) * t / f,f 越大 p 越小""" out = [] for w in tokens: f = counts[w] / total p = (math.sqrt(f / t) + 1.0) * t / f if p >= 1.0 or random.random() < p: out.append(w) return out

t默认1e-4,f是这个词的语料占比。当一个词的f小于t时p会大于 1,直接保留;f远大于t时(比如「的」占比 0.05)保留概率只有百分之几,等于把它的有效出现次数压下去。这个操作会让训练语料大幅缩短,训练更快,同时让低频词的向量空间不被挤压。注意下采样之后counts不要更新,负采样表仍然用原始频次构建,这是原论文的做法。

2.3 滑动窗口采样与负采样表

样本对不预先展开。千万级 token 语料,如果老老实实生成(context, target)元组列表再塞 DataLoader,光 Python 对象就够吃十几个 G。正确做法是只存一条numpy的 ID 流,在__getitem__里按位置即时切窗口。

# cbow/dataset.py import numpy as np import torch from torch.utils.data import Dataset class CBOWDataset(Dataset): def __init__(self, ids: np.ndarray, window: int = 5, neg_table: np.ndarray = None, neg_k: int = 5): self.ids = ids.astype(np.int64) self.window = window self.neg_table = neg_table # 预先按 0.75 次方构造的大数组 self.neg_k = neg_k def __len__(self): return len(self.ids) def __getitem__(self, i): # 中心词两侧各取 window 个,超出边界就夹到首尾 idx = [i + d for d in range(-self.window, self.window + 1) if d != 0] ctx = self.ids[np.clip(idx, 0, len(self.ids) - 1)] # 负样本:在负采样表里随机取 k 个位置 pick = np.random.randint(0, len(self.neg_table), size=self.neg_k) negs = self.neg_table[pick] return ctx, self.ids[i], negs def collate(batch): ctx = torch.from_numpy(np.stack([b[0] for b in batch])) tgt = torch.tensor([b[1] for b in batch], dtype=torch.long) neg = torch.from_numpy(np.stack([b[2] for b in batch])) return ctx, tgt, neg

ctx形状固定为(B, 2*window),这是能直接 stack 成 batch 的前提,所以窗口取固定值而不是原论文那种每个样本重抽window'的动态窗口。动态窗口确实能增加多样性,但代价是要额外传长度做 mask,平均池化时还得处理 padding 项,收益和复杂度不成比例,我先用固定窗口。边界处用np.clip夹到首尾,因为句子之间已经插了<eos>,被夹到的永远是边界词,不会污染语义。

负采样表是关键数据结构。它不是每次np.random.choice(vocab_size, p=...)现场算概率,而是提前按count^0.75展开成几千万长度的一维数组,采样时直接随机取下标,O(1) 拿到一个词 ID:

# cbow/dataset.py 续 def build_neg_table(counts, itos, size: int = 10_000_000, power: float = 0.75) -> np.ndarray: freq = np.array([counts.get(w, 0) for w in itos], dtype=np.float64) freq = freq ** power freq /= freq.sum() cum = np.cumsum(freq) cum[-1] = 1.0 # 防止浮点误差导致 searchsorted 越界 idx = np.searchsorted(cum, np.random.rand(size)) return idx.astype(np.int32)

power=0.75是那个经典指数,作用是抬高低频词被抽中的概率、压低高频词,让负样本别全是「的」「the」。size取一千万时,这个 int32 数组占 40MB,常驻内存完全可接受。表里存的是词表下标,不是词本身,所以词表一旦重新构建,负采样表必须跟着重建——这是个很容易忘的耦合。

3. 模型与训练循环:PyTorch 里把 CBOW 写透

模型本体是「上下文向量求平均 → 预测中心词」。听起来简单,但实现上有两条路:全词表 softmax 和负采样。选错了,小语料能跑,大语料直接卡死。

3.1 两种实现:全词表 softmax 与负采样

全词表 softmax 版本最直观,输入是(B, 2*win)的上下文 ID,平均之后过一个Linear(dim, vocab_size),再算交叉熵:

# cbow/model.py import torch import torch.nn as nn class CBOWSoftmax(nn.Module): """小词表(<5万)时的直观实现,训练慢但不易写错""" def __init__(self, vocab_size: int, dim: int = 200): super().__init__() self.in_embed = nn.Embedding(vocab_size, dim) self.out_proj = nn.Linear(dim, vocab_size, bias=False) nn.init.uniform_(self.in_embed.weight, -0.5 / dim, 0.5 / dim) nn.init.zeros_(self.out_proj.weight) def forward(self, ctx: torch.Tensor) -> torch.Tensor: x = self.in_embed(ctx).mean(dim=1) # (B, 2*win, D) -> (B, D) return self.out_proj(x) # (B, V) logits

ctx进Embedding得到(B, 2*win, D),mean(dim=1)把窗口维度压掉得到上下文向量,这就是 CBOW 里那个 B 的「bag」。out_proj不加 bias,因为加了对结果没影响反而多一组参数。初始化的±0.5/dim是 word2vec 的原始设定,out_proj置零让初始 logits 全为 0,交叉熵起点是log(V),比较直观。

问题在于Linear(dim, vocab_size)这一步:词表 30 万、维度 200 时,这个矩阵有 6000 万个参数,每个 batch 都要算一次全词表 logits 和 softmax,反向传播还要更新它。这就是负采样存在的理由。

3.2 负采样损失的手写细节

负采样把「从 V 个词里挑一个」变成「从 1 个正样本 + K 个负样本里做 K+1 个二分类」。输入侧和输出侧拆成两张独立的 Embedding 表,这是必须的——如果共用一张表,正样本和负样本的向量会互相拉扯,训练出来效果明显变差。

# cbow/model.py 续 import torch.nn.functional as F class CBOWNegSampling(nn.Module): def __init__(self, vocab_size: int, dim: int = 200): super().__init__() # sparse=True:反向时只产生被触及行的稀疏梯度,词表大时省一大截计算 self.in_embed = nn.Embedding(vocab_size, dim, sparse=True) self.out_embed = nn.Embedding(vocab_size, dim, sparse=True) nn.init.uniform_(self.in_embed.weight, -0.5 / dim, 0.5 / dim) nn.init.zeros_(self.out_embed.weight) def forward(self, ctx: torch.Tensor, tgt: torch.Tensor, neg: torch.Tensor) -> torch.Tensor: c = self.in_embed(ctx).mean(dim=1) # (B, D) 上下文向量 v = self.out_embed(tgt) # (B, D) 正样本输出向量 u = self.out_embed(neg) # (B, K, D) 负样本输出向量 pos = torch.sum(c * v, dim=1) # (B,) 点积 neg = torch.bmm(u, c.unsqueeze(2)).squeeze(2) # (B, K) # loss = -[ log σ(pos) + Σ log σ(-neg) ] loss = -(F.logsigmoid(pos) + F.logsigmoid(-neg).sum(dim=1)).mean() return loss

三个张量的形状要盯死:ctx是(B, 2*win),tgt是(B,),neg是(B, K)。torch.bmm做批量矩阵乘,u是(B, K, D)、c.unsqueeze(2)是(B, D, 1),乘完(B, K, 1),squeeze(2)拿到(B, K)的负样本打分。F.logsigmoid在数值上比自己写log(1+exp(-x))稳,输入是大负数时不会溢出。

sparse=True这个开关值得单独说:词表 30 万、batch 4096+5 个负样本,一次前向只触及大约 3 万行(含重复),稠密梯度要为 30 万 × 200 个参数全量分配和清零,浪费 90%。开启稀疏后梯度是sparse_coo_tensor,只带回被用过的那几行。代价是优化器必须换——普通Adam一遇到稀疏梯度就报错。

3.3 训练循环、稀疏梯度与学习率衰减

优化器用SparseAdam,学习率按 word2vec 的做法线性衰减,从lr一路降到lr * 1e-4,前 70% 步数不衰减:

# cbow/train.py 片段 import torch from torch.optim import SparseAdam def make_optimizer(model, lr: float): # SparseAdam 只更新本步出现过的行,且不支持 weight_decay return SparseAdam(model.parameters(), lr=lr) def lr_at(step: int, total: int, base_lr: float) -> float: progress = step / max(1, total) if progress < 0.7: return base_lr return max(base_lr * 1e-4, base_lr * (1.0 - progress) / 0.3)

训练主循环没什么花活,但有两处细节容易翻车:一是zero_grad(set_to_none=True),稀疏梯度场景下比置零更快也更省内存;二是每个 epoch 都要重新shuffle,DataLoader的shuffle=True只打乱索引顺序,而__getitem__里负采样是随机的,所以每个 epoch 见到的负样本组合天然不同。

for epoch in range(cfg.epochs): model.train() pbar = tqdm(loader, desc=f"epoch {epoch}") for step, (ctx, tgt, neg) in enumerate(pbar): global_step = epoch * len(loader) + step for g in opt.param_groups: g["lr"] = lr_at(global_step, total_steps, cfg.lr) ctx, tgt, neg = ctx.to(dev), tgt.to(dev), neg.to(dev) loss = model(ctx, tgt, neg) opt.zero_grad(set_to_none=True) loss.backward() opt.step() pbar.set_postfix(loss=f"{loss.item():.4f}")

batch_size我一般从 4096 起,显存够就往上加。CBOW 的梯度信号是「一批上下文平均后预测中心词」,batch 太小梯度噪声大,loss 曲线抖得看不出趋势;太大会让同一批里的重复词挤在一起,反而降低样本多样性。批次内 ID 重复率高的话,稀疏梯度的收益也会打折。

4. 跑起来:从零到一份可用的词向量文件

代码写完之后,真正让人卡住的往往是环境和命令行。这一节按我实际的执行顺序走一遍。

4.1 环境与依赖

依赖很少,requirements.txt里就五行:

torch>=2.0 numpy>=1.24 jieba>=0.42 tqdm pyyaml

torch装 CPU 版也能跑,只是慢,千万级 token 大概要多花五六倍时间。有卡的话建议直接装对应 CUDA 版本的 wheel,别用conda install pytorch那种默认渠道,版本对不上排查起来很费劲。jieba第一次lcut会加载词典,有几百毫秒延迟,属于正常现象,不要以为是卡死了。

验证环境是否通的最快方式:

python -c "import torch, jieba; print(torch.__version__, torch.cuda.is_available()); print(jieba.lcut('中英文语料训练词向量'))"

能打印出版本号、True/False和切好的词,就说明依赖没问题。cuda.is_available()返回False但你确信有卡,先查驱动版本和 wheel 的 CUDA 版本是否匹配,这一步我在不同机器上踩过好几次。

4.2 训练命令与参数含义

训练入口接受两个语料路径,中英文各读各的分词器,最后合并成一条 ID 流:

python -m cbow.train \ --zh data/raw/zh_corpus.txt \ --en data/raw/en_corpus.txt \ --dim 200 \ --window 5 \ --min-count 5 \ --neg-k 5 \ --subsample-t 1e-4 \ --epochs 5 \ --batch-size 4096 \ --lr 0.025 \ --out data/processed/cbow_zh_en

参数不多,但每个都直接影响结果,值得逐个说清:

参数默认作用调大的后果
dim200向量维度表达力上升,内存和训练时间线性增长,超过 300 在中小语料上收益很小
window5单侧上下文词数偏向语义相似,但会稀释句法信息
min-count5词表截断阈值词表变小、<unk>变多,OOV 率上升
neg-k5每样本负样本数梯度信号更密,单步变慢,小语料上容易过拟合
subsample-t1e-4高频词丢弃阈值调大丢得更狠,语料大幅缩短
lr0.025初始学习率太大发散,太小 5 个 epoch 根本不够收敛

--out后面跟的是前缀,不是目录,最终会生成cbow_zh_en.vectors.npy、cbow_zh_en.vocab.json和一份纯文本的cbow_zh_en.txt。文本那份是给不通 Python 的环境用的,格式就是词 空格分隔的浮点数,跟常见词向量文件格式一致。

显存和内存的粗略估算:ID 流是int32,一千万 token 占 40MB;词表 30 万 × 200 维 × 4 字节 × 2 张表 ≈ 480MB;负采样表 40MB。加起来一个 G 出头,剩下的都是 batch 和中间激活。真正吃内存的是语料读入阶段——如果一次性把所有句子readlines()进来再分词,两百兆文本会膨胀成好几个 G,我一般改成流式读、逐行分词、逐行往 ID 流里追加。

4.3 保存、加载与相似度自查

训练完先别急着接下游,做一次近邻自查,能挡掉大部分问题。保存的时候只存输入侧那张表,输出侧是训练用的辅助参数,下游用不上:

# cbow/utils.py import json, numpy as np, torch def save_vectors(model, vocab, out_prefix: str): vecs = model.in_embed.weight.detach().cpu().numpy() np.save(f"{out_prefix}.vectors.npy", vecs) with open(f"{out_prefix}.vocab.json", "w", encoding="utf-8") as f: json.dump({"itos": vocab.itos, "counts": dict(vocab.counts)}, f, ensure_ascii=False) # 纯文本格式,方便别的栈直接用 with open(f"{out_prefix}.txt", "w", encoding="utf-8") as f: for w, v in zip(vocab.itos, vecs): f.write(w + " " + " ".join(f"{x:.5f}" for x in v) + "\n")

自查函数把矩阵先做一次 L2 归一化,之后相似度就是纯点积,省掉每次算范数:

def topk_similar(word, stoi, itos, vecs, k=10): if word not in stoi: return [] norm = vecs / (np.linalg.norm(vecs, axis=1, keepdims=True) + 1e-9) sims = norm @ norm[stoi[word]] order = np.argsort(-sims)[1 : k + 1] # 跳过自己 return [(itos[i], float(sims[i])) for i in order]

跑一跑topk_similar("电脑", ...)和topk_similar("computer", ...),正常的输出应该是同语义簇里的词。如果返回的全是「的」「是」「一个」,说明高频词没处理干净,回第 2 章看下采样。如果中文返回的是单个汉字乱码,那多半是分词阶段就已经坏了,跟模型无关。

英文语料够大的话可以顺手做个类比测试,king - man + woman应该接近queen:

def analogy(a, b, c, stoi, itos, vecs, k=5): norm = vecs / (np.linalg.norm(vecs, axis=1, keepdims=True) + 1e-9) q = norm[stoi[a]] - norm[stoi[b]] + norm[stoi[c]] sims = norm @ (q / (np.linalg.norm(q) + 1e-9)) for w in (a, b, c): sims[stoi[w]] = -1 return [(itos[i], float(sims[i])) for i in np.argsort(-sims)[:k]]

这个测试对语料规模很敏感,几百万 token 的语料上做不出来是常态,别因此怀疑代码。它的价值在于横向对比:同一个语料,你改了window或者neg_k之后类比准确率有没有变化,这比只看 loss 曲线靠谱得多。

5. 常见问题排查:训练能跑但结果不对的六种情况

训练脚本不报错,loss 也在降,但向量就是不能用——这是 CBOW 最典型的困境。下面几条是我在实际项目里反复遇到的,按「现象 → 原因 → 解决」写。

5.1 向量质量类问题

现象一:topk_similar("电脑")返回的全是「的」「了」「一个」「是」。原因基本可以锁定在下采样没开或阈值设得太松。高频词在语料里出现几百万次,即使每次的梯度贡献很小,累积起来也会把它们推到向量空间的中心,所有词的最近邻都被它们占据。另一种可能是epochs太少,低频词还没被充分训练。 解决:确认subsample_t=1e-4生效(可以在subsample()里打一次「丢了多少 token」的日志,正常情况下会丢掉 30%~50%),min_count提到 5 以上,epochs加到 5~10。如果语料本身很小(百万 token 以下),先把dim降到 100,别硬撑 200 维。

现象二:中文近邻里混着一堆英文单词,或者反过来。原因不是模型,是语料拼接顺序。如果实现里是「先读完所有中文再读所有英文」,训练时每个 batch 都是同一种语言,两张表会在训练早期被不同语言各自拉扯,最后中文词和英文词混在一个空间里但没有对齐关系。中英文混训本来就不会自动产生跨语言对齐,除非你显式做双语约束。 解决:要么接受「一个空间两种语言、互不干扰」这个事实,要么把语料交错采样后再训练(按句子轮询中英),让每个 batch 混着两种语言。真要跨语言对齐,那是另一个课题,得引入平行语料做约束,不是调参能解决的。

现象三:中文近邻看着像字级别的碎片,「人工」「智能」分开出现,语义明显不对。原因在分词。jieba.lcut对未登录词会退化成单字切分,如果你的语料是电商评论、口语短句,专有名词多,这个现象会很明显。另一个常见原因是分词前没做全半角、繁简统一,同一个词在词表里出现两份。 解决:加载自定义词典把领域词灌进去,或者在分词前先做一次简单的规则归一化。也可以退一步用字级别的 CBOW,中文里字粒度向量在某些任务上并不比词粒度差,还能天然规避 OOV,代价是语义粒度变粗。

5.2 运行时问题

现象四:跑到一半RuntimeError: Adam does not support sparse gradients。原因很明确:模型里nn.Embedding(..., sparse=True),优化器却用了普通Adam。 解决:换成SparseAdam。顺带注意SparseAdam不支持weight_decay,也不支持对稀疏参数做梯度裁剪,想加这些正则得自己在反向之后手动处理。如果实在需要普通Adam,那就把sparse=False,代价是大词表下每步都要为全部参数分配和清零梯度,速度会明显下降。

现象五:负采样表在 GPU 上,但__getitem__里做的是 CPU 上的np.random,训练几分钟后显存莫名其妙涨满。原因:如果在__getitem__里把负采样表的张量搬到 GPU,每个 worker 都会持有一份拷贝,num_workers=4就是四份。或者更隐蔽的情况——在训练循环里对负样本做了torch.tensor(neg_list)这种动态构造,碎片化显存。 解决:负采样表常驻 CPU,在collate里取完之后随 batch 一起.to(dev);DataLoader的pin_memory=True配合非阻塞拷贝能省一点开销。固定形状的 batch 才能让显存占用平稳,别用动态长度。

现象六:断点续训之后,loss 从零开始重新降,或者近邻结果和上次完全不一样。原因:只存了in_embed,没存out_embed和优化器状态。CBOW 的两张表是一起训练的,只恢复一张等于让模型带着一半的旧知识重新开始,前期会有一段明显的震荡。另一个原因是下采样和负采样都是随机的,没设随机种子,两次跑出来的结果本来就不会逐位一致。 解决:checkpoint 里老老实实存model.state_dict()、opt.state_dict()、epoch、global_step四样东西,恢复时先load_state_dict再重建优化器。评估对比时要固定种子,但别指望不同种子的两次训练给出完全相同的近邻列表,看 Top-10 的重合度更合理。

6. 二次开发:把词向量接进下游,以及三处提速改造

拿到vectors.npy之后最常见的用法是喂给一个文本分类或序列标注模型。这里有个选择:冻结还是微调。我的经验是小数据集(万级样本以下)冻结,让词向量当固定特征,避免把预训练学到的语义冲掉;数据量上到十万级,解冻后用1e-5量级的小学习率做微调,效果一般能再涨一两个点。

import numpy as np, torch, torch.nn as nn vecs = np.load("data/processed/cbow_zh_en.vectors.npy") embed = nn.Embedding.from_pretrained(torch.tensor(vecs), freeze=True, padding_idx=0) # 微调时:freeze=False,并对 embed 参数单独设一组小学习率的 param_group

padding_idx=0对应词表里的<pad>,要保证<pad>的向量在保存时就是零向量,否则 padding 位置会带进噪声。这一条我在一个序列标注项目里漏过,模型表现诡异了很久才发现。

针对领域做增量训练,比从头训划算得多。做法是加载旧向量到in_embed,out_embed重新初始化,在领域语料上用小学习率跑一两个 epoch:

model.in_embed.weight.data.copy_(torch.tensor(old_vecs)) model.out_embed.weight.data.zero_() # 输出侧必须重置,词表变了就对不上 opt = SparseAdam(model.parameters(), lr=0.002) # 大约是原始 lr 的十分之一

只跑一两个 epoch 是刻意的,领域语料通常只有几十兆,跑多了会把通用语义覆盖掉,最后在跨领域任务上反而退化。评估上要留一份通用测试集做回归,别只看领域指标涨没涨。

提速方面有三个我常用的小改动。一是DataLoader的num_workers设成 4~8、pin_memory=True、persistent_workers=True,在__getitem__里做切片和负采样的场景下能明显减少 GPU 空转。二是把prefetch_factor提到 4,让 CPU 侧提前准备好几个 batch。三是别急着上float16——SparseAdam对半精度的支持很差,稀疏梯度做混合精度基本跑不通,省下的那点时间还不够排查问题。真要提速,优先加batch_size和减少log打印频率。

改造点适用场景代价
冻结词向量下游数据少于万级领域适配能力弱
小学习率微调十万级样本需要防过拟合,得留验证集
领域增量训练有几十兆领域语料通用语义可能退化,需回归测试
字粒度 CBOW中文未登录词多语义粒度变粗

最后说个血泪经验:每次改完采样策略或者词表,我都会强制重跑一遍那组固定的近邻自查——「电脑」「价格」「computer」「delivery」这四个词,记下 Top-10 写进实验记录。有次为了省事跳过了,直接接下游训练,两天后回头看才发现下采样的t被命令行参数覆盖成了 1,所有向量都退化成高频词簇,白跑了一轮。从那以后我每次改完超参都强制走一遍这个检查,五分钟的事。希望帮到你。

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