资讯详情

资讯详情

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.npyvocab.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.txttokenizer.py只做「字符串 → token 列表」不碰 IDvocab.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()两份——本项目走通用场景先按小写处理。接下来是词表。词表构建这一步要同时产出三样东西itosid → 词、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(keylambda 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 / ff 越大 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 outt默认1e-4f是这个词的语料占比。当一个词的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), sizeself.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], dtypetorch.long) neg torch.from_numpy(np.stack([b[2] for b in batch])) return ctx, tgt, negctx形状固定为(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], dtypenp.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)power0.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, biasFalse) 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(dim1) # (B, 2*win, D) - (B, D) return self.out_proj(x) # (B, V) logitsctx进Embedding得到(B, 2*win, D)mean(dim1)把窗口维度压掉得到上下文向量这就是 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 个负样本里做 K1 个二分类」。输入侧和输出侧拆成两张独立的 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__() # sparseTrue反向时只产生被触及行的稀疏梯度词表大时省一大截计算 self.in_embed nn.Embedding(vocab_size, dim, sparseTrue) self.out_embed nn.Embedding(vocab_size, dim, sparseTrue) 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(dim1) # (B, D) 上下文向量 v self.out_embed(tgt) # (B, D) 正样本输出向量 u self.out_embed(neg) # (B, K, D) 负样本输出向量 pos torch.sum(c * v, dim1) # (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(dim1)).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(1exp(-x))稳输入是大负数时不会溢出。sparseTrue这个开关值得单独说词表 30 万、batch 40965 个负样本一次前向只触及大约 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(), lrlr) 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_noneTrue)稀疏梯度场景下比置零更快也更省内存二是每个 epoch 都要重新shuffleDataLoader的shuffleTrue只打乱索引顺序而__getitem__里负采样是随机的所以每个 epoch 见到的负样本组合天然不同。for epoch in range(cfg.epochs): model.train() pbar tqdm(loader, descfepoch {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_noneTrue) loss.backward() opt.step() pbar.set_postfix(lossf{loss.item():.4f})batch_size我一般从 4096 起显存够就往上加。CBOW 的梯度信号是「一批上下文平均后预测中心词」batch 太小梯度噪声大loss 曲线抖得看不出趋势太大会让同一批里的重复词挤在一起反而降低样本多样性。批次内 ID 重复率高的话稀疏梯度的收益也会打折。4. 跑起来从零到一份可用的词向量文件代码写完之后真正让人卡住的往往是环境和命令行。这一节按我实际的执行顺序走一遍。4.1 环境与依赖依赖很少requirements.txt里就五行torch2.0 numpy1.24 jieba0.42 tqdm pyyamltorch装 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, encodingutf-8) as f: json.dump({itos: vocab.itos, counts: dict(vocab.counts)}, f, ensure_asciiFalse) # 纯文本格式方便别的栈直接用 with open(f{out_prefix}.txt, w, encodingutf-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, k10): if word not in stoi: return [] norm vecs / (np.linalg.norm(vecs, axis1, keepdimsTrue) 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应该接近queendef analogy(a, b, c, stoi, itos, vecs, k5): norm vecs / (np.linalg.norm(vecs, axis1, keepdimsTrue) 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_t1e-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(..., sparseTrue)优化器却用了普通Adam。 解决换成SparseAdam。顺带注意SparseAdam不支持weight_decay也不支持对稀疏参数做梯度裁剪想加这些正则得自己在反向之后手动处理。如果实在需要普通Adam那就把sparseFalse代价是大词表下每步都要为全部参数分配和清零梯度速度会明显下降。现象五负采样表在 GPU 上但__getitem__里做的是 CPU 上的np.random训练几分钟后显存莫名其妙涨满。原因如果在__getitem__里把负采样表的张量搬到 GPU每个 worker 都会持有一份拷贝num_workers4就是四份。或者更隐蔽的情况——在训练循环里对负样本做了torch.tensor(neg_list)这种动态构造碎片化显存。 解决负采样表常驻 CPU在collate里取完之后随 batch 一起.to(dev)DataLoader的pin_memoryTrue配合非阻塞拷贝能省一点开销。固定形状的 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), freezeTrue, padding_idx0) # 微调时freezeFalse并对 embed 参数单独设一组小学习率的 param_grouppadding_idx0对应词表里的pad要保证pad的向量在保存时就是零向量否则 padding 位置会带进噪声。这一条我在一个序列标注项目里漏过模型表现诡异了很久才发现。针对领域做增量训练比从头训划算得多。做法是加载旧向量到in_embedout_embed重新初始化在领域语料上用小学习率跑一两个 epochmodel.in_embed.weight.data.copy_(torch.tensor(old_vecs)) model.out_embed.weight.data.zero_() # 输出侧必须重置词表变了就对不上 opt SparseAdam(model.parameters(), lr0.002) # 大约是原始 lr 的十分之一只跑一两个 epoch 是刻意的领域语料通常只有几十兆跑多了会把通用语义覆盖掉最后在跨领域任务上反而退化。评估上要留一份通用测试集做回归别只看领域指标涨没涨。提速方面有三个我常用的小改动。一是DataLoader的num_workers设成 4~8、pin_memoryTrue、persistent_workersTrue在__getitem__里做切片和负采样的场景下能明显减少 GPU 空转。二是把prefetch_factor提到 4让 CPU 侧提前准备好几个 batch。三是别急着上float16——SparseAdam对半精度的支持很差稀疏梯度做混合精度基本跑不通省下的那点时间还不够排查问题。真要提速优先加batch_size和减少log打印频率。改造点适用场景代价冻结词向量下游数据少于万级领域适配能力弱小学习率微调十万级样本需要防过拟合得留验证集领域增量训练有几十兆领域语料通用语义可能退化需回归测试字粒度 CBOW中文未登录词多语义粒度变粗最后说个血泪经验每次改完采样策略或者词表我都会强制重跑一遍那组固定的近邻自查——「电脑」「价格」「computer」「delivery」这四个词记下 Top-10 写进实验记录。有次为了省事跳过了直接接下游训练两天后回头看才发现下采样的t被命令行参数覆盖成了 1所有向量都退化成高频词簇白跑了一轮。从那以后我每次改完超参都强制走一遍这个检查五分钟的事。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →