Word2Vec的Skip-Gram训练实战
我们需要从原始文本到模型可用的训练语料,在深度学习和 NLP 任务中,我们不能直接把一段中文扔给 Word2Vec 模型。模型无法理解“我爱人工智能”,它只能理解经过切分、清洗后的词序列。在 Word2Vec 出现之前,计算机会觉得“苹果”和“梨”完全没关系,就像“苹果”和“汽车”一样没关系。 Word2Vec 的出现,让计算机第一次“懂了”:苹果和梨差不多,但跟汽车差很远。由此,我们引入了Word2Vec,Word2Vec 是一种把“词”变成“向量坐标”的技术,并且让意思相近的词,在数学空间里靠得很近。
关于Word2Vec
1. 为什么要用它?(解决 One-Hot 的痛点)
在 Word2Vec 之前,我们表示词通常用 One-Hot(独热编码)。
One-Hot 是这样的:
- 苹果:$[1, 0, 0, 0, 0]$
- 梨子:$[0, 1, 0, 0, 0]$
- 汽车:$[0, 0, 0, 0, 1]$
问题在哪?
- 太占地儿: 如果你有 10 万个词,每个词就是一个长达 10 万的数组(只有 1 个是 1,其他全是 0)。
- 没有感情(无语义): 计算机计算 $苹果 * 梨子$ (点积)结果是 0。计算 $苹果 * 汽车$ 结果也是 0。对计算机来说,它们之间的距离是一样的,看不出谁跟谁亲近。
Word2Vec 是这样的(分布式表示):
- 苹果:$[0.2, 0.5]$
- 梨子:$[0.2, 0.6]$
<-----你看,它俩坐标很像! - 汽车:$[0.9, -0.1]$
<-----这个离得很远。
2. 它的核心思想是什么?
Word2Vec 的核心思想来自于语言学的一个假设:
“一个词的含义,由它周围的词决定。” (You shall know a word by the company it keeps.)
举个例子:
- 句子 A:我喜欢吃 苹果,因为它很甜。
- 句子 B:我喜欢吃 梨子,因为它很甜。
虽然我没告诉你“苹果”和“梨子”是水果,但因为它们周围都出现了“吃”、“甜”、“喜欢”,Word2Vec 就会推断:这两个词大概率是同类,所以在向量空间里,要把它们拉近。
数据准备与预处理
1. 核心目标
在这个阶段,我们需要将原始语料(如维基百科导出的 XML、新闻 txt 文件等)转化为模型标准输入格式。
- 输入: 杂乱的、包含 HTML 标签、标点符号、非结构化的中文长文本。
- 输出: 纯净的、以空格分词的文本文件(每一行代表一个句子或段落)。
2. 关键步骤拆解
第一步:数据清洗 (Data Cleaning)
原始数据通常包含大量噪音。我们需要使用正则表达式(Regex)去除对学习词义没有帮助的内容。
- 去除 HTML 标签: 如果数据来自网页爬虫。
- 去除特殊符号: 如
\n,\t, 表情符号等。 - 处理全角/半角字符: 统一格式。
第二步:中文分词 (Chinese Segmentation)
Word2Vec 的单位是“词(Word)”。英语天然有空格隔开,而中文需要专门的分词工具。
- 工具推荐:
jieba(结巴分词) 是 Python中最常用的库。 - 实战技巧: 对于专有名词(如“ChatGPT”、“元宇宙”),需要加载自定义词典,否则
jieba可能会把“元宇宙”切成“元”和“宇宙”,导致词义丢失。
第三步:去停用词 (Stop Words Removal)
像“的”、“了”、“是”、“在”这种词出现频率极高,但对理解上下文语义贡献很小,反而会干扰 Word2Vec 学习关键词的向量关系。我们需要通过一个停用词表将它们过滤掉。
这里推荐一个Github仓库: https://github.com/goto456/stopwords
3. Python 代码实战
前置准备:
你需要准备一个 stopwords.txt(每行一个停用词)。
# !pip install jieba
import jieba
import re
# 1. 配置路径
input_file = 'raw_corpus.txt' # 原始数据
output_file = 'word2vec_train_data.txt' # 处理后的数据
stopwords_file = 'stopwords.txt' # 停用词表
# 2. 加载停用词
def load_stopwords(path):
stopwords = set()
with open(path, 'r', encoding='utf-8') as f:
for line in f:
stopwords.add(line.strip())
return stopwords
# 3. 定义文本清洗函数
def clean_text(text):
# 去除HTML标签(简单版)
text = re.sub(r'<[^>]+>', '', text)
# 去除由于爬虫导致的非文本字符,只保留中文、英文和数字
# 这里的正则可以根据具体数据源进行调整
text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', ' ', text)
# 去除多余的空格
text = re.sub(r'\s+', ' ', text)
return text.strip()
# 4. 主处理流程
def preprocess_corpus():
print("正在加载停用词...")
stopwords = load_stopwords(stopwords_file)
print("开始处理语料...")
# 打开输出文件准备写入
with open(output_file, 'w', encoding='utf-8') as out_f:
# 流式读取大文件,避免内存溢出(对应截图实战中可能提到的大文件处理)
with open(input_file, 'r', encoding='utf-8') as in_f:
for line in in_f:
# A. 清洗
line = clean_text(line)
if not line:
continue
# B. 分词
words = jieba.cut(line)
# C. 去停用词并重新组合
out_line = ''
for word in words:
if word not in stopwords and len(word.strip()) > 0:
out_line += word + " "
# D. 写入文件(一行一个处理好的句子)
if out_line:
out_f.write(out_line.strip() + '\n')
print(f"处理完成!文件已保存至: {output_file}")
# 执行
if __name__ == "__main__":
preprocess_corpus()
训练原理
Word2Vec 并不是一个单一的算法,而是一篇论文(Google 的 Mikolov 在 2013 年发表)中提出的两套架构的统称:
Word2Vec (架构/工具包)
│
├── 架构 A: Skip-Gram (跳字模型)
│ └─ 特点:用“中心词”预测“周围词”
│
└── 架构 B: CBOW (连续词袋模型)
└─ 特点:用“周围词”预测“中心词”
Skip-Gram 与 CBOW 的核心区别
这两种模型都在做“填空题”,但是题目方向相反。
假设我们的训练数据是:"我" "喜欢" "吃" "苹果"
A. Skip-Gram
- 逻辑:一对多。
- 输入:
吃(中心词) - 任务:猜猜我旁边是谁?
- 预测目标:
喜欢(前一个),苹果(后一个)。 - 场景:给一个具体的点,去辐射周围的环境。
- 优势:对生僻词更敏感。
- 比如《三国演义》里的“张角”,虽然出现次数不多,但Skip-Gram会拿“张角”当中心词反复去拉扯周围的词,所以能学好它的向量。
- 代价:训练时间长(因为一个中心词要算好多次 Loss)。
B. CBOW
- 逻辑:多对一。
- 输入:
喜欢,苹果(周围词/上下文) - 任务:猜猜我们在描述谁?
- 预测目标:
吃(中心词)。 - 场景:像做英语完形填空:
I like ___ apples。 - 优势:训练速度快,且对常见词的表示会更平滑。
- 缺点:对于出现很少的生僻词,CBOW 容易把它“平均”掉,学不到个性。
如何理解Word2Vec的训练呢
其实,训练Word2Vec这是一个假的任务,我们的目的不是为了赢,而是为了 —— 训练过程中的副产品。
- 游戏规则:给你一个词(比如“刘备”),请你预测它旁边会出现谁?
- 训练过程:
- 模型一开始瞎猜(随机初始化向量)。
- 模型看到“刘备”,猜是“汽车”。
- 正确答案是“张飞”。
- 惩罚(Loss):模型挨了一顿打,发现自己猜错了。
- 修正(Backpropagation):模型赶紧修改“刘备”的向量数值,把它往“张飞”的向量方向推一推;同时把“刘备”往“汽车”的方向推远一点。
- 重复几亿次。
最终结果: 当把《三国演义》全读完后,凡是经常和“刘备”一起出现的词(关羽、张飞、玄德),它们的向量在空间里就会紧紧抱在一起。
PyTorch训练实战
以《三国演义》第一回白话文版本为例(刘关张桃园结义):
东汉末年,桓帝、灵帝宠信宦官,致使朝政日益腐败,民不聊生。到汉灵帝在位的时候,终于爆发一场大乱,形成诸侯割据、烽烟四起的局面。
巨鹿郡张角、张宝、张梁兄弟三人,在民间瘟疫流行之时,借治病之机广结会众,终于演发成为一场大起义。因起义军都头裹黄巾,故史称为“黄巾起义”。起义军多达四五十万人,声势浩大,所向披靡。灵帝急忙下令各路将领出兵征讨。一时间各路诸侯纷纷招兵买马,形成豪杰并起之势。幽州太守刘焉也发出榜文,招募义兵。
榜文行到涿县,引出了一位英雄。此人不甚好读书,生性宽和,寡言少语,喜怒不形于色;胸怀大志,专好结交天下豪杰。他身长七尺五寸,双手过膝,双耳垂肩,生得仪表堂堂。此人是汉中山靖王刘胜的后代,姓刘,名备,字玄德。刘备自幼丧父,对母亲很孝顺。他家境贫寒,一直以织草席卖草鞋为生。这年,刘备已二十八岁了。
刘备当日见了榜文,长长地叹息了一声。忽然听见身后一人厉声喝道:“大丈夫不为国家出力,叹什么气!”刘备回头看说话的人,只见他身高八尺,豹头环眼,燕颔虎须,声若巨雷。刘备见他相貌奇异,便问他姓名。这人道:“我姓张,名飞,字翼德,世代居住涿郡,以杀猪卖酒为业,喜欢结交天下豪杰。刚才看见你看榜时叹气,因此相问。”刘备道:“我是汉室宗亲,姓刘名备。现在黄巾作乱,我有心为国杀贼,却恨自己力量不足,因此而叹息。”张飞道:“我家里有不少财产,我们招募乡勇,共图大事,如何?”刘备大喜,便和张飞同到村中酒店饮酒商议。
饮酒之间,看见一个大汉推着车子停在店门口,进店就喊酒保:“快拿酒来,我等着进城去投军。”刘备打量此人,见他身高九尺,须长二尺,丹凤眼、卧蚕眉,面如重枣,威风凛凛,相貌堂堂。刘备邀请他同坐,问他姓名,那人道:“我姓关,名羽,字云长。因当年杀了恶霸,流落在外已经五六年了。听说这里招军,特来投奔。”刘备便将他们的打算告诉关羽,关羽大喜,于是一同到张飞的庄上商议。张飞说:“我庄后有一个桃园,花开正盛。明天,我们应当在园中祭告天地,结为兄弟,同心协力,共成大事。”刘备、关羽齐声称好。次日,三人在桃园中设下祭品,焚香立誓:“结为兄弟,不求同年同月同日生,只愿同年同月同日死。同心协力,救困扶危,上报国家,下安百姓……”刘备年长,做了大哥,关羽第二,张飞为三弟。三人从此开始招兵买马,训练士卒。
他们招来能工巧匠打造兵器:刘备打造双股剑;关羽造青龙偃月刀,重八十二斤;张飞造丈八蛇矛。兵器盔甲准备停当,三人率五百多乡勇,投奔太守刘焉,从此开始了征战生涯。沙场上,关羽、张飞勇不可当,刘备有勇有谋。三人接连取胜,战功赫赫。
此时各路诸侯豪杰并起,经过了数月的征战,黄巾军渐渐被剿灭。此时朝廷却更加腐败,卖官鬻爵。刘备没有人情,最终只被安排了一个小官——安喜县县尉。刘备到任后,与百姓秋毫无犯,很得民心。兄弟三人食则同桌,寝则同床,情义深厚。
刘备上任不满四个月,上面就派了一名督邮来巡查。刘备毕恭毕敬地出城迎接。那督邮却趾高气扬,十分傲慢。刘备躬身施礼,督邮仅在马上轻轻挥了挥马鞭算是回答。关张二人见此,都怀着怒气。到了驿馆,督邮问了刘备出身,威胁刘备说朝廷正要淘汰像他这样“虚报战功”的官吏。
刘备回到县里,与县吏商议。县吏说:“督邮大耍威风,无非是想索要贿赂罢了。”刘备道:“我同百姓秋毫无犯,哪来财物给他?”第二天,督邮就将县吏传去,勒令县吏诬告刘备残害百姓。刘备几番前往解释,都被拦在了大门外。
一天张飞喝了几杯闷酒,骑马经过驿馆,看见五六十个老人都在门前痛哭。张飞上前询问,众老人回答道:“督邮威逼县吏,要加害刘公,我们都是来求情的。谁知不放我们进去,还派门人打我们!”张飞听了大怒,睁圆环眼,咬碎钢牙,滚鞍下马,冲入驿馆直奔后堂。见督邮正坐在厅上,将县吏绑倒在地。张飞大喝:“害民贼!认得我吗!”不等督邮开口,张飞上前揪住督邮的头发,将他扯出驿馆,一路拖到县衙前,绑在拴马桩上。
张飞折下柳条,朝督邮腿上奋力鞭打,一连打折了十几根柳条。围观百姓拍手称快。刘备听见门前喧闹,急忙出去观看,见被捆打的正是督邮。刘备惊问张飞,张飞道:“如此害民贼,不打死留着干什么!”督邮求告:“玄德公救我性命!”刘备终究是仁慈的人,喝令张飞住手。关羽从旁边过来道:“兄长建了许多大功,仅做了个县尉,现在反而被这督邮侮辱。我看此处不是久留之地,不如杀了督邮,不做这小官,另图大计。”刘备于是取来大印,挂在督邮脖子上,斥责道:“你如此害民,本该取你性命,今日暂且饶了你,我们去了!”说罢,带着关、张二人前往代州投刘恢去了。
不久,四方盗贼又起。刘恢举荐刘备领兵出征。刘备立功后,朝廷免了鞭打督邮之罪,让刘备当了平原县令。
构建词表
vocab_count = Counter(corpus_words)
top_k = 100
vocab = [w for w, c in vocab_count.most_common(top_k)]
word2idx = {w: i for i, w in enumerate(vocab)}
idx2word = {i: w for i, w in enumerate(vocab)}
vocab_size = len(vocab)
为什么需要词表? 神经网络只能处理数字,不能直接处理文字。我们需要:
word2idx:词 → 数字索引(如 “刘备” → 0)idx2word:数字索引 → 词(如 0 → “刘备”)
取 Top 100 高频词是因为这段文本较短,低频词出现次数太少,学不到好的向量表示。
构造 Skip-Gram 训练样本
def make_batch(indices, window_size=3):
data = []
for i in range(len(indices)):
center = indices[i]
start = max(0, i - window_size)
end = min(len(indices), i + window_size + 1)
for j in range(start, end):
if i == j: continue
context = indices[j]
data.append((center, context))
return torch.LongTensor(data)
这是 Skip-Gram 的核心数据构造逻辑! 以窗口大小 3 为例:
句子: [刘备] [见] [榜文] [叹气] [张飞] [问]
↑ 假设这是中心词 (i=2)
窗口范围: start = max(0, 2-3) = 0
end = min(6, 2+3+1) = 6
生成的训练对:
(榜文, 刘备) ← 中心词预测左边第2个
(榜文, 见) ← 中心词预测左边第1个
(榜文, 叹气) ← 中心词预测右边第1个
(榜文, 张飞) ← 中心词预测右边第2个
(榜文, 问) ← 中心词预测右边第3个
Skip-Gram 的本质:用中心词去"猜"它周围会出现谁。猜对了,就说明这两个词经常一起出现,应该在向量空间里靠近。
模型结构
class Word2VecModel(nn.Module):
def __init__(self, vocab_size, embedding_dim):
super(Word2VecModel, self).__init__()
self.embeddings = nn.Embedding(vocab_size, embedding_dim)
self.linear = nn.Linear(embedding_dim, vocab_size, bias=False)
def forward(self, inputs):
embeds = self.embeddings(inputs)
out = self.linear(embeds)
return out
模型结构图解:
输入层 隐藏层(Embedding) 输出层
[0,0,1,...,0] → [0.2, 0.5] → [0.1, 0.05, 0.7, ...]
(One-Hot) (词向量!) (预测概率分布)
vocab_size维 embedding_dim维 vocab_size维
关键理解:
nn.Embedding(vocab_size, embedding_dim):这是一个"查表"操作。输入词的索引,输出对应的词向量。这个 Embedding 矩阵就是我们最终要的词向量!nn.Linear(embedding_dim, vocab_size):将词向量映射到词表大小的输出,配合 CrossEntropyLoss 计算"猜对了哪个词"的损失。
训练循环
for epoch in range(epochs):
optimizer.zero_grad() # 1. 清空梯度
outputs = model(x_train) # 2. 前向传播:中心词 → 预测概率
loss = criterion(outputs, y_train) # 3. 计算损失:预测 vs 真实上下文词
loss.backward() # 4. 反向传播:计算梯度
optimizer.step() # 5. 更新参数(包括 Embedding 矩阵!)
scheduler.step() # 6. 学习率衰减
训练本质:
- 模型拿到中心词"刘备",预测周围词
- 正确答案是"关羽",但模型猜成了"汽车"
- Loss 很大,模型"挨打"
- 反向传播修正"刘备"的向量,让它更靠近"关羽",远离"汽车"
- 重复 8000 轮,向量空间逐渐收敛
余弦相似度验证
def get_most_similar(word, top_n=8):
query_vec = vectors[word2idx[word]]
sims = []
for i in range(vocab_size):
if i == word2idx[word]: continue
target_vec = vectors[i]
cosine_sim = np.dot(query_vec, target_vec) / (np.linalg.norm(query_vec) * np.linalg.norm(target_vec))
sims.append((idx2word[i], cosine_sim))
sims.sort(key=lambda x: x[1], reverse=True)
return sims[:top_n]
余弦相似度公式:
$$ CosSim(A, B) = \frac{A \cdot B}{||A|| \times ||B||} $$
- 值域:$[-1, 1]$
- 1 表示方向完全相同(最相似)
- 0 表示正交(无关)
- -1 表示方向完全相反
为什么用余弦而不是欧氏距离? 余弦相似度只关心"方向",不关心"长度"。两个词即使向量模长不同,只要方向一致,就认为它们相似。
完整代码
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import jieba
import re
from collections import Counter
# ==========================================
# 1. 刘关张桃园结义文本
# ==========================================
raw_text = """东汉末年,桓帝..."""
# ==========================================
# 2. 数据预处理
# ==========================================
print("正在进行数据预处理...")
# 停用词(针对这段文本优化)
stop_words = {'的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都', '一', '一个', '上', '也', '很', '到', '说', '为', '对', '与', '个'}
text_cleaned = re.sub(r'[^\u4e00-\u9fa5]', ' ', raw_text)
words = jieba.lcut(text_cleaned)
corpus_words = [w for w in words if w not in stop_words and len(w) > 1]
vocab_count = Counter(corpus_words)
top_k = 100 # 这段文本词汇量较小,取前100即可
vocab = [w for w, c in vocab_count.most_common(top_k)]
word2idx = {w: i for i, w in enumerate(vocab)}
idx2word = {i: w for i, w in enumerate(vocab)}
vocab_size = len(vocab)
print(f"原始词数: {len(words)} -> 过滤后词数: {len(corpus_words)}")
print(f"词表大小 (Top {top_k}): {vocab_size}")
print(f"前15个高频词: {vocab[:15]}")
# ==========================================
# 3. 准备训练数据 (Skip-Gram)
# ==========================================
indices = [word2idx[w] for w in corpus_words if w in word2idx]
def make_batch(indices, window_size=3): # 窗口稍微加大,捕捉更多关系
data = []
for i in range(len(indices)):
center = indices[i]
start = max(0, i - window_size)
end = min(len(indices), i + window_size + 1)
for j in range(start, end):
if i == j: continue
context = indices[j]
data.append((center, context))
return torch.LongTensor(data)
training_data = make_batch(indices, window_size=3)
x_train = training_data[:, 0]
y_train = training_data[:, 1]
# ==========================================
# 4. 模型定义
# ==========================================
class Word2VecModel(nn.Module):
def __init__(self, vocab_size, embedding_dim):
super(Word2VecModel, self).__init__()
self.embeddings = nn.Embedding(vocab_size, embedding_dim)
self.linear = nn.Linear(embedding_dim, vocab_size, bias=False)
def forward(self, inputs):
embeds = self.embeddings(inputs)
out = self.linear(embeds)
return out
# ==========================================
# 参数设置
# ==========================================
EMBEDDING_DIM = 2
epochs = 8000
learning_rate = 0.005
model = Word2VecModel(vocab_size, EMBEDDING_DIM)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=learning_rate)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=1500, gamma=0.5)
print(f"\n开始训练 (数据量: {len(x_train)} 对样本)...")
print(f"参数: epochs={epochs}, 初始lr={learning_rate}, 每1500轮lr衰减50%")
for epoch in range(epochs):
optimizer.zero_grad()
outputs = model(x_train)
loss = criterion(outputs, y_train)
loss.backward()
optimizer.step()
scheduler.step()
if (epoch+1) % 400 == 0:
current_lr = scheduler.get_last_lr()[0]
print(f"Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}, LR: {current_lr:.6f}")
print(f"\n训练完成!最终 Loss: {loss.item():.4f}")
# 提取训练好的词向量
vectors = model.embeddings.weight.detach().numpy()
# ==========================================
# 5. 验证:寻找最近邻
# ==========================================
def get_most_similar(word, top_n=8):
if word not in word2idx:
return "词不在词表中"
query_vec = vectors[word2idx[word]]
sims = []
for i in range(vocab_size):
if i == word2idx[word]: continue
target_vec = vectors[i]
cosine_sim = np.dot(query_vec, target_vec) / (np.linalg.norm(query_vec) * np.linalg.norm(target_vec))
sims.append((idx2word[i], cosine_sim))
sims.sort(key=lambda x: x[1], reverse=True)
return sims[:top_n]
print("\n【模型验证 - 词语相似度】")
print(f"谁最接近'刘备'? -> {get_most_similar('刘备')}")
print(f"谁最接近'关羽'? -> {get_most_similar('关羽')}")
print(f"谁最接近'兄弟'? -> {get_most_similar('兄弟')}")

从上图的运行结果可以看出,模型成功学到了:
- “刘备”、“关羽”、“张飞” 在文本中经常一起出现,所以它们的向量非常接近
- “兄弟”、“三人”、“结为” 这些描述他们关系的词,也被拉到了同一个区域
- 模型仅仅通过“谁和谁经常一起出现”,就自动发现了“桃园三结义”这个语义关系!
通过这个实战,我们完整走了一遍 Skip-Gram 的训练流程:
- 数据预处理:清洗 → 分词 → 去停用词 → 构建词表
- 构造训练数据:滑动窗口生成 (中心词, 上下文词) 对
- 模型训练:Embedding 层学习词向量,Linear 层做分类预测
- 验证效果:用余弦相似度找最近邻
Word2Vec 的精髓在于:我们不关心模型最终能不能预测对,我们只要训练过程中产生的"副产品" —— 词向量。这些向量把词的语义关系编码成了数学形式,为后续的 NLP 任务(如情感分析、文本分类、机器翻译)奠定了基础