从Word2Vec到GPT:手把手实现词嵌入模型,理解大语言模型的语言基石

📅 2026/8/17 16:28:44
从Word2Vec到GPT:手把手实现词嵌入模型,理解大语言模型的语言基石
在自然语言处理领域我们常常惊叹于大语言模型如GPT系列能够理解并生成如此流畅、富有逻辑的文本。这背后一个看似简单却至关重要的技术——词嵌入Embedding——扮演着“语言基石”的角色。你是否曾好奇一个冷冰冰的单词比如“apple”是如何在计算机眼中获得“水果”、“科技公司”甚至“诱惑”等多重含义的Embedding正是实现这一“意义注入”的核心魔法。本文将深入浅出地拆解Embedding的工作原理并带你从零开始用Python“手搓”一个简易版的词嵌入模型直观感受GPT等大模型理解语言的底层逻辑。无论你是刚入门NLP的新手还是希望巩固基础知识的开发者都能通过本文的代码实践透彻理解词向量如何从海量文本中“学习”到词语的丰富语义。1. 背景与核心概念从独热编码到语义空间在深入Embedding之前我们需要理解计算机最初是如何“看”词的。1.1 独热编码的困境最原始的方法是独热编码One-Hot Encoding。假设我们的词汇表只有三个词[“猫” “狗” “鱼”]。“猫”表示为[1, 0, 0]“狗”表示为[0, 1, 0]“鱼”表示为[0, 0, 1]这种方法简单直接但存在致命缺陷维度灾难词汇表有1万个词每个词就是一个1万维的向量其中9999个是0极度稀疏计算和存储效率低下。无法表达语义关系在独热编码中任意两个向量的点积或余弦相似度都为0。这意味着计算机认为“猫”和“狗”都是宠物的语义距离与“猫”和“宇宙”的语义距离完全相同这显然不符合我们的认知。1.2 词嵌入的救赎词嵌入Word Embedding的目标就是将高维、稀疏的独热向量映射到一个相对低维如50、100、300维、稠密的连续向量空间中。稠密向量的每个维度都是一个浮点数通常不为零承载了某种潜在的语义或语法特征。低维维度远小于词汇表大小便于计算。语义性在这个学习到的向量空间中语义相近的词如“国王”和“王后”其向量在空间中的位置也彼此接近词与词之间还能进行有趣的向量运算例如vec(“国王”) - vec(“男人”) vec(“女人”) ≈ vec(“王后”)。Embedding层就是实现这种映射的神经网络层。你可以把它看作一个查询表Look-up Table或一个可训练的权重矩阵。假设词汇表大小为V嵌入维度为d那么这个Embedding层就是一个V x d的矩阵。输入一个词的索引如0代表“猫”Embedding层就输出这个索引对应的d维行向量。2. 环境准备与版本说明我们将使用Python和PyTorch库来动手实现。PyTorch提供了灵活的张量操作和自动求导功能非常适合教学和实验。环境要求操作系统Windows 10/11, macOS, 或 Linux (本文示例在Windows 11下完成)Python 3.8 (推荐3.8-3.10)核心库torch: 深度学习框架numpy: 数值计算matplotlib(可选): 用于可视化安装命令# 使用pip安装建议在虚拟环境中进行 pip install torch numpy matplotlib版本说明本文示例代码基于torch1.13.1和numpy1.24.3编写。PyTorch的API在1.x版本中保持较好的稳定性但不同小版本间可能有细微差异。如果你的环境版本不同核心逻辑完全一致可能只需调整少量导入语句或函数参数。3. 核心原理Word2Vec与负采样我们要“手搓”的Embedding模型其思想源于经典的Word2Vec算法具体来说是Skip-gram with Negative Sampling (SGNS)模型。它的核心直觉非常巧妙一个词的语义可以由它周围经常出现的词来定义。3.1 Skip-gram模型思想对于句子 “the quick brown fox jumps over the lazy dog”如果我们以 “fox” 作为中心词窗口大小为2那么它的上下文词就是 [“quick”, “brown”, “jumps”, “over”]。 Skip-gram模型的任务是给定中心词预测其周围窗口内的每一个上下文词的概率。通过让模型学习这个预测任务模型参数即Embedding矩阵就会逐渐调整使得语义相近的词拥有相似上下文最终获得相似的向量表示。3.2 负采样技术原始的Skip-gram模型需要计算整个词汇表V可能很大的softmax概率计算成本极高。负采样Negative Sampling是一种高效的优化方法。 它把多分类问题简化为一组二分类问题正样本 (中心词, 真实上下文词) - 标签为1。负样本 (中心词, 随机采样的非上下文词) - 标签为0。 模型只需要学习区分正样本和若干个如5个负样本大大提升了训练速度。负样本通常根据词频分布进行采样高频词被采为负样本的概率更大。4. 完整实战手搓Skip-gram with Negative Sampling让我们用代码将上述理论实现出来。我们将在一个小规模文本上训练以便观察整个过程。4.1 准备数据与构建词汇表首先我们需要一些文本数据并构建词汇表。import torch import torch.nn as nn import torch.optim as optim import numpy as np from collections import Counter import random # 1. 准备一个简单的示例语料 corpus [ the quick brown fox jumps over the lazy dog, i love natural language processing, deep learning is amazing, the cat sat on the mat, dogs and cats are pets ] # 2. 文本预处理分词并转为小写 def preprocess(text): return text.lower().split() tokenized_corpus [preprocess(sentence) for sentence in corpus] print(分词后的语料, tokenized_corpus) # 3. 构建词汇表 word_freq Counter() for sentence in tokenized_corpus: word_freq.update(sentence) # 设定最小词频过滤掉出现次数太少的词 min_freq 1 vocab {word for word, freq in word_freq.items() if freq min_freq} vocab sorted(list(vocab)) # 排序以保证每次运行顺序一致 # 添加特殊标记例如未知词[UNK]和填充符[PAD]本例简单处理未使用 # vocab [[PAD], [UNK]] vocab # 创建词到索引和索引到词的映射 word_to_idx {word: idx for idx, word in enumerate(vocab)} idx_to_word {idx: word for word, idx in word_to_idx.items()} vocab_size len(vocab) print(f词汇表大小{vocab_size}) print(f词汇表示例{vocab[:10]})4.2 生成训练数据中心词-上下文词对接下来我们需要从语料中生成模型需要的训练数据对。# 生成Skip-gram训练数据中心词 上下文词 def generate_training_data(tokenized_sentences, window_size2): data [] for sentence in tokenized_sentences: sentence_indices [word_to_idx.get(word, -1) for word in sentence] # 过滤掉不在词汇表中的词本例中所有词都在所以不会过滤 sentence_indices [idx for idx in sentence_indices if idx ! -1] for center_pos, center_idx in enumerate(sentence_indices): # 确定当前中心词的上下文窗口 start max(0, center_pos - window_size) end min(len(sentence_indices), center_pos window_size 1) context_indices sentence_indices[start:end] # 移除中心词本身 context_indices.remove(center_idx) for context_idx in context_indices: data.append((center_idx, context_idx)) return data window_size 2 training_data generate_training_data(tokenized_corpus, window_size) print(f生成了 {len(training_data)} 个中心词 上下文词对) print(示例数据索引形式, training_data[:5]) print(示例数据词语形式, [(idx_to_word[c], idx_to_word[ctx]) for c, ctx in training_data[:5]])4.3 实现负采样根据词频分布来采样负样本。# 计算词频分布用于负采样 word_counts np.array([word_freq[word] for word in vocab]) # 使用3/4次幂来平滑频率使得低频词被采样的概率相对增加原论文方法 word_probs word_counts ** 0.75 word_probs / word_probs.sum() # 归一化为概率分布 def get_negative_samples(center_idx, context_idx, num_negative5): 为给定的正样本中心词上下文词生成负样本 negative_samples [] # 负样本不能是中心词本身也不能是真正的上下文词 while len(negative_samples) num_negative: # 根据分布随机采样一个词索引 sampled_idx np.random.choice(len(vocab), pword_probs) if sampled_idx ! center_idx and sampled_idx ! context_idx: negative_samples.append(sampled_idx) return negative_samples # 测试负采样 center, context training_data[0] neg_samples get_negative_samples(center, context, num_negative5) print(f中心词‘{idx_to_word[center]}’和上下文词‘{idx_to_word[context]}’的负样本) print([idx_to_word[idx] for idx in neg_samples])4.4 定义Embedding模型现在我们定义包含两个Embedding层的PyTorch模型一个用于中心词一个用于上下文词包括负样本。class SkipGramNegSampling(nn.Module): def __init__(self, vocab_size, embedding_dim): super(SkipGramNegSampling, self).__init__() # 中心词嵌入层 self.center_embeddings nn.Embedding(vocab_size, embedding_dim) # 上下文词嵌入层在实际的SGNS中通常使用两个独立的嵌入表 self.context_embeddings nn.Embedding(vocab_size, embedding_dim) # 初始化权重 self.center_embeddings.weight.data.uniform_(-0.5/embedding_dim, 0.5/embedding_dim) self.context_embeddings.weight.data.uniform_(-0.5/embedding_dim, 0.5/embedding_dim) def forward(self, center, context, negative): center: 中心词索引 [batch_size] context: 正上下文词索引 [batch_size] negative: 负样本词索引 [batch_size, num_negative] # 获取嵌入向量 v_center self.center_embeddings(center) # [batch_size, embedding_dim] v_context self.context_embeddings(context) # [batch_size, embedding_dim] v_negative self.context_embeddings(negative) # [batch_size, num_negative, embedding_dim] # 计算正样本的分数点积后sigmoid pos_score torch.sum(v_center * v_context, dim1) # [batch_size] pos_loss -torch.log(torch.sigmoid(pos_score) 1e-10) # 负对数似然 # 计算负样本的分数 # v_center扩展维度以匹配v_negative: [batch_size, 1, embedding_dim] v_center_expanded v_center.unsqueeze(1) neg_score torch.bmm(v_negative, v_center_expanded.transpose(1, 2)) # [batch_size, num_negative, 1] neg_score neg_score.squeeze(2) # [batch_size, num_negative] neg_loss -torch.sum(torch.log(torch.sigmoid(-neg_score) 1e-10), dim1) # 负对数似然求和 # 总损失 正样本损失 负样本损失 total_loss pos_loss neg_loss return total_loss.mean() # 返回批次平均损失 def get_word_embeddings(self, word_idx): 获取一个词的最终向量表示通常使用中心词嵌入层 with torch.no_grad(): return self.center_embeddings(torch.tensor([word_idx])).squeeze().numpy()4.5 训练模型我们将数据组织成批次进行训练。# 超参数设置 embedding_dim 10 # 嵌入维度为了可视化这里设得较小 num_negative 5 # 每个正样本对应的负样本数 learning_rate 0.01 num_epochs 500 batch_size 32 # 初始化模型、优化器和损失函数 model SkipGramNegSampling(vocab_size, embedding_dim) optimizer optim.SGD(model.parameters(), lrlearning_rate) # 准备数据加载器简化版不打乱 def data_loader(data, batch_size): for i in range(0, len(data), batch_size): batch_data data[i:ibatch_size] centers, contexts zip(*batch_data) centers torch.tensor(centers, dtypetorch.long) contexts torch.tensor(contexts, dtypetorch.long) # 为批次中的每个样本生成负样本 negatives [] for c, ctx in batch_data: neg get_negative_samples(c, ctx, num_negative) negatives.append(neg) negatives torch.tensor(negatives, dtypetorch.long) # [batch_size, num_negative] yield centers, contexts, negatives # 训练循环 losses [] for epoch in range(num_epochs): total_loss 0 batch_count 0 for center_batch, context_batch, negative_batch in data_loader(training_data, batch_size): optimizer.zero_grad() loss model(center_batch, context_batch, negative_batch) loss.backward() optimizer.step() total_loss loss.item() batch_count 1 avg_loss total_loss / batch_count if batch_count 0 else total_loss losses.append(avg_loss) if (epoch 1) % 50 0: print(fEpoch [{epoch1}/{num_epochs}], Average Loss: {avg_loss:.4f}) print(训练完成)4.6 验证与可视化结果训练完成后我们可以查看词向量的性质。# 1. 查看特定词的向量 test_words [cat, dog, fox, love, deep] for word in test_words: if word in word_to_idx: idx word_to_idx[word] vector model.get_word_embeddings(idx) print(f单词 {word} 的向量前5维: {vector[:5]}) # 2. 计算词与词之间的余弦相似度 def cosine_similarity(vec1, vec2): return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2)) # 获取向量 cat_idx word_to_idx[cat] dog_idx word_to_idx[dog] fox_idx word_to_idx[fox] love_idx word_to_idx[love] cat_vec model.get_word_embeddings(cat_idx) dog_vec model.get_word_embeddings(dog_idx) fox_vec model.get_word_embeddings(fox_idx) love_vec model.get_word_embeddings(love_idx) print(f\n余弦相似度) print(f cat vs dog: {cosine_similarity(cat_vec, dog_vec):.4f}) print(f cat vs fox: {cosine_similarity(cat_vec, fox_vec):.4f}) print(f cat vs love: {cosine_similarity(cat_vec, love_vec):.4f}) # 3. 使用PCA降维并可视化可选需要matplotlib try: import matplotlib.pyplot as plt from sklearn.decomposition import PCA # 获取所有词的向量 all_vectors np.array([model.get_word_embeddings(i) for i in range(vocab_size)]) # 使用PCA降维到2D pca PCA(n_components2) vectors_2d pca.fit_transform(all_vectors) # 绘制 plt.figure(figsize(10, 8)) plt.scatter(vectors_2d[:, 0], vectors_2d[:, 1], alpha0.5) # 标注一些感兴趣的词 words_to_annotate [cat, dog, fox, quick, brown, jumps, love, deep, learning, language] for word in words_to_annotate: if word in word_to_idx: idx word_to_idx[word] plt.annotate(word, xy(vectors_2d[idx, 0], vectors_2d[idx, 1]), xytext(5, 2), textcoordsoffset points, fontsize9) plt.title(Word Embeddings Visualization (PCA)) plt.xlabel(PCA Component 1) plt.ylabel(PCA Component 2) plt.grid(True, alpha0.3) plt.show() except ImportError: print(未安装matplotlib或scikit-learn跳过可视化。)运行上述代码后你会发现“cat”和“dog”的相似度远高于“cat”和“love”。在可视化图中语义或语法相关的词如“cat”, “dog”, “fox”可能会聚集在一起。这就是Embedding“注入意义”的直观体现——它将文本中的共现规律编码成了空间中的几何关系。5. 从Word2Vec到GPTEmbedding的演进我们“手搓”的模型揭示了Embedding的核心思想。在现代大语言模型如GPT中Embedding技术有了更复杂和深刻的发展子词嵌入GPT等模型使用Byte-Pair Encoding (BPE)或WordPiece等子词分词法词汇表不是完整的单词而是子词单元。这能更好地处理未登录词和稀有词。上下文感知Word2Vec为每个词生成一个静态的向量。而在GPT的Transformer架构中输入嵌入Token Embedding只是起点。经过多层自注意力机制处理后每个词位的最终表示是动态的高度依赖于它所在的完整句子上下文。这才是“一词多义”得以解决的关键。位置编码Transformer本身没有递归和卷积结构为了利用序列顺序信息必须显式地将位置编码与词嵌入相加注入“位置”意义。更大的规模与数据GPT模型在超大规模语料上训练嵌入维度高达数千学习到的语义和世界知识更加丰富。可以这样理解我们实现的Word2Vec是“离线学习”的静态词典而GPT中的Embedding是“在线计算”的动态语义表示的起点和组成部分。6. 常见问题与排查思路在实现和应用Embedding时你可能会遇到以下问题问题现象常见原因解决思路训练损失不下降或为NaN学习率过高、梯度爆炸、数据异常1. 降低学习率如从0.01调到0.001。2. 检查数据中是否有无效索引如-1。3. 对损失函数中的log计算添加极小值 1e-10防止数值溢出。词向量相似度没有意义全都很接近训练轮次不足、嵌入维度太低、数据量太小1. 增加训练轮次epochs。2. 适当增加嵌入维度如从10调到50或100。3. 使用更大、更相关的语料进行训练。生僻词向量质量差词频过低训练不充分1. 在负采样中采用平滑策略如对词频取3/4次方。2. 使用子词模型如FastText、BPE代替单词模型。内存不足OOM词汇表过大、嵌入维度太高、批次太大1. 增大min_freq过滤低频词减小词汇表。2. 降低嵌入维度或批次大小。3. 使用梯度累积来模拟大批次。无法处理新词OOV词汇表固定未包含新词1. 在预处理时将未登录词映射到统一的[UNK]标记。2. 在生产中考虑使用具有子词分词能力的模型如Sentence-BERT、OpenAI的text-embedding模型。7. 最佳实践与工程建议在实际项目中应用Embedding时应注意以下几点数据质量至上Embedding的质量几乎完全取决于训练数据。确保语料与你的下游任务相关、干净、规模足够大。“垃圾进垃圾出”在Embedding学习中尤其明显。维度选择嵌入维度不是越大越好。维度太低信息压缩严重表达能力不足维度太高容易过拟合计算和存储成本增加。通常50-300维是一个常见范围需要根据任务和数据进行实验。使用预训练模型除非有非常特殊的领域数据如医学病历、法律条文否则强烈建议使用预训练的词向量如GloVe、FastText或预训练的上下文嵌入模型如BERT、GPT的Embedding层初始化。这能节省大量计算资源并利用通用语料中的丰富知识。领域自适应可以在通用预训练Embedding的基础上使用你的领域数据继续进行微调训练使其更适应特定任务。评估嵌入质量不要盲目相信训练损失。使用内部任务如词类比king - man woman ≈ queen和下游任务如文本分类、情感分析的准确率来综合评估嵌入向量的有效性。标准化与池化在使用词向量进行句子或文档表示时常见的做法是对所有词向量取平均Mean Pooling或求和有时还会先进行L2标准化。对于基于Transformer的模型通常使用[CLS]标记的向量或所有标记向量的均值作为句子表示。版本管理Embedding是模型的核心组件之一。当更新训练数据、调整超参数或更换模型架构后Embedding会变化可能导致下游任务性能波动。对Embedding文件或模型检查点进行版本控制至关重要。通过本文的讲解和实战你已经揭开了Embedding的神秘面纱并亲手实现了一个能够学习词义关系的简易模型。理解Embedding是深入NLP和现代大语言模型的关键一步。下一步你可以探索更复杂的嵌入方法如GloVe、ELMo研究Transformer中的位置编码或者尝试使用Hugging Face Transformers库加载现成的BERT、GPT模型直接调用其强大的Embedding能力来解决实际的语义匹配、搜索和分类问题。