资讯详情

资讯详情

手写Skip-Gram词向量实战:负采样、下采样与调参避坑

手写一遍 Skip-Gram是我一直推荐给做文本方向的同行的必做功课。不是为了造轮子而是因为你在调现成词向量工具时踩过的那些玄学问题——窗口调大调小效果忽好忽坏、训完发现相似词全是的了是、向量拿去聚类一塌糊涂、语料翻倍但指标就是不涨——九成都能在一份两百行的裸代码里找到答案。Skip-Gram 是 Word2vec 的两种训练方式之一核心任务就一句话给定一个中心词预测它周围窗口里可能出现哪些词训完之后把中间那个隐层矩阵拎出来就是词向量。这篇东西我打算把代码从头到尾拆一遍先讲清楚它在干什么再讲代码怎么搭然后是参数怎么调、坑怎么排最后给出可以直接套用的模板。适合两类人看一类是刚接触词向量、想搞清楚里面到底发生了什么的新手另一类是已经会用工具、但遇到问题只能靠猜的老手。整篇的代码都是可以复制跑通的语料用中文分好词的列表不依赖任何分词库你把自己的数据按同样格式塞进去就行。1. 先把 Skip-Gram 这件事想明白1.1 一句话版本拿中心词去猜它的邻居假设语料里有一句话模型 用 中心词 预测 上下文。窗口取 2中心词取中心词的时候它的上下文就是左边两个模型用和右边两个预测上下文。Skip-Gram 干的事情就是训练一个模型输入中心词输出要在这四个位置上分别猜对模型用预测上下文。这里有个反直觉的地方语言模型通常是顺着往前预测下一个词而 Skip-Gram 是同时往两边看而且把每个上下文词当成一个独立的分类任务。这个设计看起来浪费——同一句话被拆成了四份训练信号但它带来的好处是每个词在一个句子里能贡献多次梯度更新小语料下收敛更快低频词也能被多训几次。这也是为什么 Skip-Gram 在语料规模不大的场景下通常比 CBOW 表现更稳。再往下拆一层所谓猜本质是一个V 分类问题V 是词表大小。词表一万那就是一万分类词表十万十万分类。直接做 softmax 计算量爆炸所以真正的工程实现里几乎都用负采样Negative Sampling或者层次 softmaxHierarchical Softmax来绕开。我下面给的代码走的是负采样路线因为它的实现最直白改起来也最灵活。1.2 one-hot 的死穴到底在哪理解 Skip-Gram 之前得先理解它要解决什么。词的 one-hot 表示是一个长度等于词表大小的向量只有一位是 1其余全 0。苹果和梨这两个语义相近的词在 one-hot 空间里的夹角是 90 度余弦相似度是 0跟苹果和宇宙飞船的关系一模一样——也就是说one-hot 里没有任何语义信息。还有个更实际的问题维度灾难。词表十万每个词就是十万维的稀疏向量存下来是一回事送进下游模型是另一回事特征维度直接爆掉。而 Skip-Gram 训出来的稠密向量通常是 50 到 300 维每个位置都是实数语义相近的词在这个空间里距离也近苹果和梨的余弦相似度会明显高于苹果和飞船。这就是所谓的分布式表示一个词的含义由它经常出现的上下文决定这是整个词向量流派的理论底座。从信息论角度看Skip-Gram 做的事情等价于对词-上下文共现矩阵做一次低秩分解只是它用随机梯度下降在线地做不需要先把整个共现矩阵算出来存到内存里。这也是它在超大语料上能跑得动的原因。理解了这一层你就知道为什么窗口大小、下采样这些参数会显著影响结果——它们直接决定了共现矩阵里哪些格子被强化、哪些被抹掉。1.3 Skip-Gram 和 CBOW 的取舍这两个是 Word2vec 的两条腿网上对比文章很多但多数停留在Skip-Gram 慢、CBOW 快这种层面。实际做选择的时候我会看三件事。维度Skip-GramCBOW训练目标中心词预测上下文上下文预测中心词单句训练样本量大每对词一个样本小每个位置一个样本小语料表现相对更好容易欠拟合低频词效果更好被训多次较差容易被平均掉语义类比任务通常更优通常稍弱训练耗时高低我自己的经验是语料低于千万词级别、或者数据里有很多长尾专业术语闭眼选 Skip-Gram语料上亿、追求训练速度、下游只是做个粗粒度的文本分类CBOW 够用。还有一个折中做法是先跑 CBOW 拿个基线再用 Skip-Gram 提升比较两者的验证集指标这个对比花不了多少时间但能帮你确认哪条路线更适合你的数据分布。2. 代码骨架四个模块谁先谁后2.1 模块划分与数据流一份能跑的 Skip-Gram我认为至少拆成四块语料预处理、样本生成、模型定义、训练循环。这四块的分界线要划清楚因为后面调参、排查问题的时候你需要能快速定位是数据造错了还是模型写错了。数据流是这样走的原始文本先过一遍分词得到词列表统计词频建词表同时算出高频词下采样的保留概率然后按滑动窗口遍历每句话生成中心词上下文词的配对配对再和负采样出来的噪声词一起打包成一个训练样本最后送进模型算损失、反向传播、更新两个嵌入矩阵。新手最容易犯的错是把这几步揉在一起写一个函数从上干到下中间出了 bug 完全没法查。我的建议是每一步都留一个中间产物打印出来看看比如生成完配对之后随机抽十条看看中心词上下文是不是真的在同一句话的窗口范围内。这个检查只要三行代码但能省下你两小时的调试。2.2 为什么必须先建词表再谈模型词表是词向量模型的坐标系一旦定下来向量的行号就固定了。这里有两个决策点要不要限制词表大小低频词怎么处理。限制词表大小的动机是内存和速度。词表从十万降到三万嵌入矩阵直接小三分之二训练速度提升明显。代价是低频词全变成UNK模型学不到它们。如果你的下游任务对长尾词敏感比如医疗、法律领域那我建议词表放大一点或者干脆不设上限只在内存吃紧的时候才砍。低频词的处理最粗暴的办法是设min_count出现次数低于阈值的词直接丢出词表。这个阈值在百万级语料上一般设 5 到 10在十万级以下的语料上设 1 到 2因为语料本来就小再砍就没什么词了。删词的时候要注意删掉的词在句子里会留下空洞你可以选择跳过它也可以选择用它前后相邻的词做拼接两种做法都行我倾向于直接跳过因为拼接会造出一批只出现一次的新词反而污染词表。2.3 训练样本是造出来的不是读出来的这一点很多人一开始不理解。原始语料里句子就那么几句但训练样本可以有几万条因为每滑动一次窗口就产生一批配对。一句十个词的句子窗口取 2大概能产出十几个中心上下文对。这里的实现细节是每个中心词上下文的采样顺序要打乱而且窗口大小在每一轮可以随机取 1 到 max_window 之间的值。后者是 Word2vec 原论文里的一个小技巧叫动态窗口dynamic window目的是让近的上下文词被采到的概率更高同时让远处偶尔也能被采到相当于对不同距离的共现关系做了加权。实测下来这个小改动对效果的提升比很多人想象的要明显而且实现成本几乎为零就是一个random.randint(1, max_window)。3. 逐段实现与关键参数3.1 词表构建与索引映射这段没什么花活但要写得干净。我给你一份可以直接用的import numpy as np import random from collections import Counter from itertools import chain random.seed(42) np.random.seed(42) # 已经分好词的语料每条是一个词列表 sentences [ [自然语言, 处理, 是, 人工智能, 的, 一个, 重要, 方向], [词向量, 把, 词, 映射, 到, 低维, 稠密, 向量, 空间], [这个, 模型, 用, 中心词, 去, 预测, 上下文, 词], [滑动, 窗口, 决定, 了, 上下文, 的, 范围, 大小], [负采样, 降低, 了, 每次, 更新, 的, 计算量], [训练, 完成, 之后, 取出, 输入, 矩阵, 就是, 词向量], ] MIN_COUNT 1 counter Counter(chain(*sentences)) vocab [w for w, c in counter.items() if c MIN_COUNT] word2idx {w: i for i, w in enumerate(vocab)} idx2word {i: w for w, i in word2idx.items()} V len(vocab) print(f词表大小: {V})有个细节值得提vocab的顺序我用的是 Counter 的插入顺序也就是词第一次出现的顺序。你可能见过有人按词频降序排两者对最终效果没有实质影响但如果后面要做向量切片对齐比如和预训练向量合并顺序必须固定且可复现建议要么写死排序规则要么把词表存成文件下次直接读。3.2 高频词下采样把的是的权重压下去中文语料里的了是这类词频率极高但它们携带的语义信息很少。如果不处理训练出来的的最近邻会是一堆功能词语义空间被这些词拖偏。Word2vec 论文里给了一个下采样公式保留概率是P_keep(w) sqrt(t / f(w))其中f(w)是词频占比t是阈值通常取1e-3到1e-5。当f(w)小于t时算出来的值大于 1直接截断到 1意思是低频词全部保留。total_tokens sum(counter[w] for w in vocab) freq {w: counter[w] / total_tokens for w in vocab} t 1e-4 keep_prob {} for w in vocab: p np.sqrt(t / freq[w]) keep_prob[w] min(p, 1.0) # 举个例子看看 for w in [的, 词向量]: if w in keep_prob: print(f{w}: 频率{freq[w]:.5f}, 保留概率{keep_prob[w]:.3f})假设的占全部 token 的 5%t1e-4那保留概率是sqrt(1e-4 / 0.05) sqrt(0.002) ≈ 0.045也就是下采样后只剩 4.5% 的的被保留。这个力度听起来很猛但实际训练中效果是正面的——模型能在有限的更新次数里更多地看到有信息量的词。注意这个阈值不要照抄。语料小的时候比如几十万字t1e-4会把中频词也砍掉一大截训出来的向量会偏。我的习惯是小语料用1e-3大语料用1e-4或更小而且下采样之后要重新训练前后对比一下下游指标别凭感觉。3.3 滑动窗口与配对生成def build_pairs(sentences, word2idx, keep_prob, max_window2): pairs [] for sent in sentences: idxs [word2idx[w] for w in sent if w in word2idx] for i, center in enumerate(idxs): # 动态窗口每次随机一个有效窗口 win random.randint(1, max_window) left max(0, i - win) right min(len(idxs), i win 1) for j in range(left, right): if j i: continue ctx idxs[j] # 用中心词的保留概率做下采样 cw sent[i] if random.random() keep_prob.get(cw, 1.0): continue pairs.append((center, ctx)) return pairs pairs build_pairs(sentences, word2idx, keep_prob, max_window2) print(f生成样本数: {len(pairs)}) print(pairs[:10])这段代码里有个容易忽略的点下采样判断我用的是中心词而不是上下文词。理论上应该对每个被保留的 token 分别判断但工程实现上通常会简化成只看中心词或者中心词和上下文词都判断一次。后者更严格会让样本量进一步减少。我建议先把两种都试一遍看看样本量差多少语料本来就小的话用宽松的那版。3.4 负采样表0.75 次方到底在调什么负采样的思路是每来一个正样本中心词真实上下文我随机抽 K 个假上下文从词表里按某种分布抽让模型学着把正样本打高分、负样本打低分。K 一般取 5 到 20小语料取 5 到 10 就够大语料取 15 以上。关键在于抽样的分布。如果按词频均匀抽高频词会被当成负样本抽到很多次模型学到的是高频词不是上下文这个信号没什么用。Word2vec 的做法是把词频取0.75次方再归一化power 0.75 weights np.array([counter[w] for w in vocab], dtypenp.float64) ** power weights weights / weights.sum() print(负采样概率前 5 名:) order np.argsort(-weights)[:5] for i in order: print(f {idx2word[i]}: {weights[i]:.4f})为什么是 0.75直观解释是如果按原始频率抽的这种词会被抽中太多次负样本里全是它模型学了等于没学如果按均匀分布抽低频词会被过度采样也不合理。0.75 次方是一个经验折中它的效果是提高低频词被抽中的相对概率同时不给高频词完全消失的机会。举个具体数字假设词 A 频率是 0.01词 B 频率是 0.0001两者频率差 100 倍。取 0.75 次方之后A 变成 0.0316B 变成 0.001差距缩小到 31.6 倍。你看高频词的优势被削弱了但没被抹平。这个指数我自己试过 0.5 和 1.00.5 对高频词压制太狠1.0 又回到原来的问题0.75 确实是个不错的默认值。3.5 模型本体两层矩阵就够了负采样版本的 Skip-Gram参数就两个矩阵W_in是中心词向量V × DW_out是上下文词向量V × D。注意这是两个不同的矩阵最后拿来用的是W_inW_out训完就扔了。有些实现会把两个矩阵共享效果通常会差一些因为中心词和上下文词在语言学上的角色本来就不对称。单样本的反向传播手推一遍def sigmoid(x): return 1.0 / (1.0 np.exp(-x)) dim 50 init_range 0.5 / dim W_in np.random.uniform(-init_range, init_range, (V, dim)) W_out np.zeros((V, dim)) def train_one(center, context, negatives, lr): v W_in[center] # (D,) u_pos W_out[context] # (D,) u_neg W_out[negatives] # (K, D) # 正样本标签为 1 score_pos v u_pos g_pos sigmoid(score_pos) - 1.0 # dL/dscore grad_v g_pos * u_pos grad_u_pos g_pos * v # 负样本标签为 0 scores_neg u_neg v # (K,) g_neg sigmoid(scores_neg) # dL/dscore标签 0 所以是 p - 0 grad_v g_neg u_neg # (D,) grad_u_neg np.outer(g_neg, v) # (K, D) # 更新 W_in[center] - lr * grad_v W_out[context] - lr * grad_u_pos W_out[negatives] - lr * grad_u_neg loss -np.log(sigmoid(score_pos) 1e-9) \ - np.sum(np.log(sigmoid(-scores_neg) 1e-9)) return loss我要强调一个新手常犯的错误W_out初始化成 0 是可以的但W_in绝对不能用 0 初始化。全 0 的话所有中心词的梯度都一样训完之后所有词向量完全重合相似度全是 1看起来训练成功了其实啥也没学到。初始化范围0.5/dim是 Word2vec 原论文的设定作用是让初始的点积不要太大避免 sigmoid 一开始就饱和。3.6 把训练循环跑起来K 5 epochs 5 lr 0.025 neg_sampler lambda n: np.random.choice(V, sizen, pweights) for epoch in range(epochs): random.shuffle(pairs) total_loss 0.0 for step, (c, o) in enumerate(pairs): negs neg_sampler(K) loss train_one(c, o, negs, lr) total_loss loss print(fepoch {epoch}, avg loss {total_loss / len(pairs):.4f})学习率这里我用0.025起步这是原论文的量级。语料小的时候这个值也偏高容易震荡你可以降到0.01甚至0.005。更讲究的做法是线性衰减从0.025一路降到0.0001公式是lr lr0 * (1 - progress)其中progress是已经处理的样本比例。这个改动对小语料提升明显值得加上。4. PyTorch 版本把上面的逻辑翻译成框架语言4.1 双嵌入表的标准写法手写 numpy 版的价值在于理解原理实际项目里我肯定用框架因为框架自带自动求导、GPU 加速和优化器改起来也快。同样的模型翻译成 PyTorch 长这样import torch import torch.nn as nn import torch.nn.functional as F class SkipGramNeg(nn.Module): def __init__(self, vocab_size, dim): super().__init__() self.in_emb nn.Embedding(vocab_size, dim) self.out_emb nn.Embedding(vocab_size, dim) init_range 0.5 / dim self.in_emb.weight.data.uniform_(-init_range, init_range) self.out_emb.weight.data.zero_() def forward(self, center, context, negatives): v self.in_emb(center) # (B, D) u self.out_emb(context) # (B, D) un self.out_emb(negatives) # (B, K, D) pos_score torch.sum(v * u, dim1) # (B,) neg_score torch.bmm(un, v.unsqueeze(2)).squeeze(2) # (B, K) loss -F.logsigmoid(pos_score).mean() \ - F.logsigmoid(-neg_score).mean() return losslogsigmoid这个函数比手动写log(sigmoid(x))稳因为它在 x 很负的时候不会直接下溢到log(0)。另外注意负样本那项写的是-logsigmoid(-neg_score)等价于-log(1 - sigmoid(neg_score))这就是负样本标签为 0 的交叉熵。这两项加起来才是完整的负采样损失。4.2 Dataset 和 DataLoader 的两个坑Dataset 本身很简单就是把配对和负采样包起来from torch.utils.data import Dataset, DataLoader class SGPairs(Dataset): def __init__(self, pairs, weights, K): self.pairs pairs self.weights weights self.K K def __len__(self): return len(self.pairs) def __getitem__(self, i): c, o self.pairs[i] neg np.random.choice(len(self.weights), sizeself.K, pself.weights) return c, o, neg def collate(batch): c, o, n zip(*batch) return (torch.tensor(c, dtypetorch.long), torch.tensor(o, dtypetorch.long), torch.tensor(np.stack(n), dtypetorch.long))第一个坑num_workers大于 0 的时候每个 worker 是独立的进程np.random的种子会被复制导致不同 worker 抽到的负样本序列高度重复。解决方式是在worker_init_fn里给每个 worker 重新设种子def worker_init(worker_id): np.random.seed(42 worker_id) random.seed(42 worker_id) loader DataLoader(ds, batch_size1024, shuffleTrue, num_workers4, collate_fncollate, worker_init_fnworker_init)第二个坑np.random.choice在每次__getitem__里都带一个概率向量是个相当重的操作如果你每轮抽 5 个负样本十万样本就是五十万次调用CPU 会先成为瓶颈。我的做法是预先生成一张大的负采样池比如一次性按分布抽一百万个数存成数组取样的时候直接从池里切片速度能快一个数量级。4.3 训练循环与日志观察device torch.device(cuda if torch.cuda.is_available() else cpu) model SkipGramNeg(V, 100).to(device) opt torch.optim.Adam(model.parameters(), lr1e-3) for epoch in range(10): model.train() running 0.0 for step, (c, o, n) in enumerate(loader): c, o, n c.to(device), o.to(device), n.to(device) opt.zero_grad() loss model(c, o, n) loss.backward() opt.step() running loss.item() print(fepoch {epoch} loss{running / len(loader):.4f})日志这块我要提醒一句只看 loss 数值没有意义因为负采样损失和采样数量强相关K 从 5 改到 15loss 的绝对值就会变不能跨配置比较。真正该盯的是 loss 的变化趋势以及每隔几个 epoch 抽一组词算一下最近邻看看语义上有没有变合理。我记得有一次 loss 从 2.1 平稳降到 1.3看着特别健康结果抽出来看苹果的最近邻是的以及因此一查才发现下采样阈值写错了功能词压根没被压下去。5. 参数调优默认值不是万能钥匙5.1 窗口大小决定学到的是语法还是主题窗口小2 到 3模型看到的是紧邻的词学到的是句法层面的相似性比如跑和跳都是动词它们周围的词性模式相似向量会靠近。窗口大5 到 10模型看到的是段落级别的共现学到的是主题层面的相似性苹果和香蕉因为经常出现在同一类文本里而靠近即使它们在句子里从不挨着。这个区分在实战中很有用。做句法分析、命名实体识别窗口取 2 到 3做文本分类、推荐、主题聚类窗口取 5 到 10。我见过有人做商品标题的相似度窗口取 2结果手机壳和手机膜分得很开因为标题里这俩词的位置模式不一样这就是窗口选小了的典型表现。窗口大小偏重的相似性典型适用场景1 到 2严格句法搭配词性标注、依存分析3 到 5句法与语义混合通用词向量、NER5 到 10主题与领域相关文本分类、聚类、推荐10 以上篇章级主题长文档相似度、话题发现5.2 维度不是越大越好维度决定了向量能容纳多少信息。经验值是dim ≈ 词表大小 ^ 0.25再乘个系数但这个公式参考价值有限。我的实际感受是十万词以下的语料100 维就够200 维开始收益递减百万词级别的语料200 到 300 维是常见选择再往上除非下游任务确实吃这个信息量否则没必要。维度调大的代价是显存和训练时间线性增长而且小语料上高维模型特别容易过拟合——表现出来就是训练集上的相似度很好看换一批新词就崩。判断方法很简单把语料切一半训两个模型看同一个词在两份向量里的最近邻重合度重合度低于 30% 基本可以判定维度过高或者语料不足。5.3 学习率的衰减策略Word2vec 原论文用的是线性衰减初始0.025训练过程中逐步降到0.0001。这个策略的核心逻辑是前期大步走快速把随机初始化的向量拉到合理区域后期小步走精调细节。在 PyTorch 里实现很简单total_steps len(loader) * epochs lr0, lr_min 0.025, 1e-4 def get_lr(step): ratio min(step / total_steps, 1.0) return lr0 * (1 - ratio) lr_min * ratio scheduler torch.optim.lr_scheduler.LambdaLR(opt, get_lr)注意这里的 scheduler 是按 step 更新的要在每个 batch 之后调用scheduler.step()。我踩过一次坑忘了调学习率一直停在初始值训到后面 loss 开始上下横跳怎么调 batch size 都没用最后打印学习率才发现。现在我养成的习惯是每个 epoch 结束打一行当前学习率出来。5.4 负采样数量和批大小的联动K 越大每个正样本的梯度信号越强模型区分能力越好但计算量也线性增长。我的经验区间是 5 到 15小语料 5 到 8大语料 10 到 15。K 大于 20 之后收益非常有限因为负样本之间开始高度冗余。批大小和 K 是联动的。批大小 1024、K5一个 batch 实际要算 1024 × 6 个词对的点积批大小 256、K5计算量降到四分之一但梯度噪声变大。我一般先用 512 起步显存够就往上加。这里有个反直觉的点批大小不是越大越好负采样本身就是一种噪声估计太小的批会让梯度方向抖得厉害太大的批又会让每个 epoch 的更新次数变少小语料上尤其明显。6. 踩坑记录与排查清单6.1 loss 不降或者降得诡异这是最高频的问题。我按可能性从高到低排一下第一种数据配对写错了。最常见的写法错误是窗口边界算错导致j范围包含了中心词自己或者越界。检查方法很朴素抽十条样本打印出来人工看一眼中心词和上下文词是不是真的在同一条句子里、距离是不是在窗口内。第二种词表索引错位。建词表的时候用的是word2idx生成配对的时候又用了一次Counter的新顺序两边对不上训练出来的向量全是乱的。避免方式是词表只建一次全程传同一个字典对象。第三种学习率太大。loss 在前几十步剧烈震荡甚至出现nan基本就是这个原因。nan还有一个来源是log(0)检查一下损失函数里有没有加eps。第四种梯度没清零。PyTorch 里忘了opt.zero_grad()梯度会累积训几步就爆。这个问题在新手代码里出现的频率高得离谱。6.2 相似词全是功能词训完之后查人工智能的最近邻返回的了是一个这种情况我遇到过至少三次原因各不相同。一次是下采样没生效检查发现keep_prob里的键是字符串但取值的时候用的是索引get一直返回默认值 1.0等于没做下采样。这个 bug 特别隐蔽因为不报错只是效果差。一次是负采样分布写成了均匀分布功能词被大量当作负样本反而在W_out里形成了特殊的模式。改回 0.75 次方就正常了。还有一次是语料本身的问题用的是某类很短的标题文本每句话就五六个词窗口取 5等于每句话里所有词两两配对语义结构完全丢失。这种语料只能把窗口压到 2或者换更长的文本。6.3 语料小的时候怎么救语料只有几十万字的时候训出来的向量确实不太稳。我试过几个办法按有效性排序增加迭代轮数。小语料的配对数量少多跑几轮能补一些但超过 10 轮之后基本就过拟合了表现为相似度越来越死板。减小维度。从 200 降到 50参数少了很多小语料反而训得更充分。这一点和直觉相反但实测有效。降低 min_count同时缩小词表上限。把长尾词保留下来但控制总量让每个词都能被训练到足够多次。用字符级或者子词信息辅助。中文可以考虑按字训练再把字向量拼成词向量这是一个独立的话题但确实是低资源场景下的常规手段。6.4 排查速查表现象最可能的原因快速验证方式loss 出现 nan学习率过大 / log(0)降 lr 到 1e-3损失里加 epsloss 完全不降学习率过小 / 数据配对错误抽 10 条样本肉眼检查相似词全是功能词下采样失效 / 负采样分布错打印 keep_prob 实际取值所有词向量几乎相同W_in 用 0 初始化检查初始化代码训练速度极慢负采样实时计算 / CPU 瓶颈预生成负采样池换批数据效果骤降维度过高 / 过拟合降维到 50 重训对比7. 训完之后怎么验证和用起来7.1 三个验证角度缺一不可很多人训完模型就直接看最近邻看到几个词比较像就以为成了这个验证太单薄。我一般会从三个角度看。最近邻就是给定一个词找余弦相似度最高的十个词。这个最直观但主观性也最强容易自欺欺人。多挑几个不同类型的词看专有名词、动词、形容词各来两个如果全都合理那基本没问题。类比任务经典的国王 - 男人 女人 ≈ 女王。中文上这个任务不太好做因为词形变化少但可以用北京 - 中国 日本 ≈ 东京这类地理类比。跑一组类比题算准确率这个指标比最近邻客观得多。下游任务这是最终裁判。把词向量冻结接到一个文本分类模型上看验证集 F1。这个最有说服力但成本也最高。我的习惯是前两个先跑如果最近邻明显不对劲就别浪费时间去跑下游了。def most_similar(word, topn10): if word not in word2idx: return [] idx word2idx[word] vec W_in[idx] norms np.linalg.norm(W_in, axis1) 1e-9 sims (W_in vec) / (norms * (np.linalg.norm(vec) 1e-9)) order np.argsort(-sims)[1:topn 1] return [(idx2word[i], round(float(sims[i]), 4)) for i in order]注意算余弦相似度的时候要加eps不然零向量会直接除出nan。这种情况在低频词上真的会出现因为它们的梯度更新次数太少向量模长可能接近 0。7.2 向量的几种用法训好的W_in是个 V × D 的矩阵用法无非三类。一是查表给定词拿向量做相似度计算、去重、聚类。这类用法最直接注意做相似度之前先做 L2 归一化不然模长大的词会占便宜。二是当特征送进下游模型。做法是把句子里所有词的向量取平均或者做加权平均得到一个句向量。这个做法简单粗暴在短文本分类上效果还行长文本就明显不如专门的句向量模型了。如果下游是神经网络更好的做法是把词向量矩阵当成 embedding 层的初始权重训练时允许微调这样模型能针对任务调整词表示。三是做冷启动的语义先验。比如新来了一个词词表里没有可以用它的字向量或者字符 n-gram 的组合来近似它的向量这是一种常见的兜底方案。中文场景下按字拆解特别自然词向量如果没在词表里可以拆成词向量两部分各自查表再平均。7.3 模板化把这份代码改成你自己的最后说一下怎么套用。整份代码的可变部分集中在三个地方语料输入、超参配置、模型规模。我一般会抽一个config出来config { min_count: 3, max_window: 5, dim: 100, neg_k: 8, subsample_t: 1e-4, lr0: 0.025, lr_min: 1e-4, batch_size: 512, epochs: 8, }这样做的好处是做参数对比实验的时候不用改代码跑脚本的时候传不同的 config 就行。我习惯把每次实验的 config、词表大小、最终 loss、几个抽查词的最近邻一起存到一个 json 里时间长了就能看出规律比如这个数据集上窗口超过 5 就明显变差这种结论比任何教程都可靠。还有一个实操建议第一次跑别追求效果先确认流程通。把 epoch 设成 1语料截前一万句十分钟内跑完看看 loss 是不是在降、样本数是不是合理、最近邻有没有一点点结构。流程通了再去调参不然你会在一个本身就有 bug 的代码上反复调参越调越迷茫。我个人在实际操作中的体会是Skip-Gram 这套东西代码量不大但每个环节都有一两个细节能决定成败尤其是下采样和负采样这两处写法上的小差别会让最终效果差出一大截。所以我建议你别急着上大语料先用几万词的小数据集把整条链路跑透把每个中间产物都打印出来看一遍把参数一个个单独改一遍看效果怎么变。这个慢的过程走完后面换任何语料、任何领域你都能凭感觉判断出问题大概出在哪一层。
觉得有用,分享给同行:

为您的企业打造数字门面

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

立即咨询 →