自然语言处理 (NLP) 就像是让计算机 "理解" 人类语言。但计算机只懂数字,如何将文字转化为计算机能处理的形式呢?这就要用到两个核心技术:词嵌入 (Word Embedding) 和位置编码 (Position Encoding)。本文将通过简单易懂的例子和代码,带你一步步揭开它们的神秘面纱。
一、为什么需要词嵌入?
想象一下,如果你想让计算机分析 "我喜欢吃苹果" 这句话,直接把文字丢给它是没用的。计算机需要数字!
最直接的方法是给每个词分配一个编号,比如:
- "我" → 1
- "喜欢" → 2
- "吃" → 3
- "苹果" → 4
这种方法叫独热编码 (One-Hot Encoding),但它有个大问题:每个词之间是独立的,计算机看不出 "苹果" 和 "香蕉" 都是水果,"喜欢" 和 "热爱" 意思相近。
词嵌入就是解决这个问题的魔法!它把每个词变成一个向量,比如:
- "苹果" → [0.2, 0.5, 0.1, ..., 0.3] (100 维向量)
- "香蕉" → [0.3, 0.4, 0.2, ..., 0.1]
这些向量之间的距离(比如余弦相似度)能反映词义的相似性。就像在一个多维空间里,意思相近的词会靠得很近。
二、动手实现词嵌入
下面我们用 PyTorch 来实现一个简单的词嵌入例子。别担心,代码很简单!
import torch
import torch.nn as nn
# 假设我们有一个包含8个单词的小词典
vocab_size = 8 # 词典大小
embedding_dim = 4 # 每个词向量的维度(可以理解为每个词用4个数字表示)
# 创建词嵌入层
embedding = nn.Embedding(vocab_size, embedding_dim)
# 现在,让我们看看如何使用这个词嵌入层
# 假设我们有一个句子,用单词在词典中的索引表示
sentence = torch.tensor([1, 3, 5, 2]) # 比如:[我, 喜欢, 吃, 苹果] 的索引
# 获取这些词的嵌入向量
embedded_sentence = embedding(sentence)
print("原始句子索引:", sentence)
print("词嵌入后的结果形状:", embedded_sentence.shape) # 输出: [4, 4],即4个词,每个词4维向量
print("嵌入后的向量:\n", embedded_sentence)
这个例子中:
nn.Embedding
创建了一个可训练的 "查找表"- 表的大小是
vocab_size × embedding_dim
- 输入一个词的索引,就能得到对应的向量
刚开始,这些向量的值是随机的,但在训练过程中,模型会学习到有意义的表示。比如,"苹果" 和 "香蕉" 的向量会变得相似。
三、序列处理的挑战:位置信息
词嵌入解决了词义表示的问题,但还有一个关键问题:词序。
在自然语言中,词的顺序非常重要。比如:
- "狗咬人" 和 "人咬狗" 意思完全不同
- "我今天吃了苹果" 和 "我吃了苹果今天" 表达的是同一件事,但语序不同
标准的词嵌入模型会忽略词序,就像把所有词扔进一个袋子里。为了解决这个问题,我们需要引入位置编码。
四、位置编码:给词加上 "位置标签"
位置编码的目标是让模型知道每个词在句子中的位置。最直接的方法可能是给每个位置一个编号:
- 第 1 个词 → 1
- 第 2 个词 → 2
- ...
但这种简单编号有局限性,模型可能难以学习到相对位置关系(比如 "第 3 个词" 和 "第 4 个词" 相邻)。
Transformer 模型提出了一种更聪明的方法:正弦余弦位置编码。它的核心思想是:
- 为每个位置生成一个向量
- 向量的每个维度使用不同频率的正弦和余弦函数
- 这样,相邻位置的编码向量会相似,相距较远的位置会不同
让我们看看代码实现:
import torch
import math
max_position = 10 # 最大位置数(假设句子最长10个词)
embedding_dim = 4 # 向量维度 (表征位置向量的维度)
# 创建位置编码矩阵
position_encoding = torch.zeros(max_position, embedding_dim)
# 对每个位置
for pos in range(max_position):
# 对每个维度
for i in range(0, embedding_dim, 2):
# 偶数维度使用正弦函数
position_encoding[pos, i] = math.sin(pos / (10000 ** (i / embedding_dim)))
# 奇数维度使用余弦函数
if i + 1 < embedding_dim:
position_encoding[pos, i + 1] = math.cos(pos / (10000 ** (i / embedding_dim)))
print("位置编码矩阵形状:", position_encoding.shape) # [10, 4]
print("前5个位置的编码:\n", position_encoding[:5])
这个位置编码有几个有趣的特性:
- 不同位置的编码向量不同
- 相邻位置的编码相似(可以通过向量点积验证)
- 可以表示相对位置关系(比如,位置 k 的编码可以表示为位置 k+n 的编码的线性组合)
五、结合词嵌入和位置编码
在实际应用中,我们通常会把词嵌入和位置编码结合起来使用。让我们看几个完整的例子:
1、例1
import torch
import torch.nn as nn
import torch.nn.functional as F
# 参数设置
vocab_size = 10 # 词典大小
embedding_dim = 16 # 嵌入维度
max_seq_len = 5 # 最大序列长度
# 创建词嵌入层
word_embedding = nn.Embedding(vocab_size, embedding_dim)
# 创建位置编码
position_encoding = torch.zeros(max_seq_len, embedding_dim)
for pos in range(max_seq_len):
for i in range(0, embedding_dim, 2):
position_encoding[pos, i] = math.sin(pos / (10000 ** (i / embedding_dim)))
if i + 1 < embedding_dim:
position_encoding[pos, i + 1] = math.cos(pos / (10000 ** (i / embedding_dim)))
# 将位置编码转为可训练的参数(不参与训练)
position_embedding = nn.Embedding.from_pretrained(position_encoding, freeze=True)
# 假设我们有一个句子
sentence_indices = torch.tensor([2, 4, 6, 0, 0]) # 0表示padding(无实际单词)
sentence_length = 3 # 实际句子长度(前3个词有效)
# 获取词嵌入
word_vectors = word_embedding(sentence_indices)
# 创建位置索引
positions = torch.arange(sentence_length, dtype=torch.long)
# 扩展到批次维度(如果有多个句子)
positions = positions.unsqueeze(0).expand(1, sentence_length)
# 获取位置嵌入
pos_vectors = position_embedding(positions)
# 组合词嵌入和位置嵌入
final_vectors = word_vectors[:sentence_length] + pos_vectors
print("词嵌入形状:", word_vectors.shape) # [5, 16]
print("位置嵌入形状:", pos_vectors.shape) # [1, 3, 16]
print("最终向量形状:", final_vectors.shape) # [3, 16]
在这个例子中:
- 我们创建了词嵌入层和位置编码层
- 对一个句子(用索引表示)进行处理
- 获取词嵌入和位置嵌入
- 将它们相加得到最终的输入表示
这种组合方式让模型既能理解词义,又能感知词序,是现代 NLP 模型的基础。
2、例2:
import torch
import numpy
import torch.nn as nn
import torch.nn.functional as F
# 关于word embedding, 以离散的序列建模为例子
# 考虑source sentence 和 target sentence
# 构建序列, 序列的字符以其在词表中的索引的形式表示
# 批次大小
batch_size = 2
# 单词表大小
max_num_src_words = 8
max_num_tgt_words = 8
max_num_words = 8
model_dim = 16
#序列大小
max_src_seq_len = 5
max_tgt_seq_len = 5
max_position_len = 5
# 序列长度
src_len = torch.Tensor([2, 4]).to(torch.int) # 生成源序列的长度
tgt_len = torch.Tensor([4, 3]).to(torch.int) # 生成目标序列的长度
# 单词索引构成句子
src_seq = torch.cat([torch.unsqueeze(F.pad(torch.randint(1, max_num_src_words, (L,)), (0, max_src_seq_len - L)), 0) for L in src_len]) # 生成以单词索引序列构成的源句子,padding补齐每一个序列长度
tgt_seq = torch.stack([F.pad(torch.randint(1, max_num_tgt_words, (L,)), (0, max_tgt_seq_len - L)) for L in tgt_len], 0) # 生成以单词索引序列构成的目标句子,padding补齐每一个序列长
# src_seq: [tensor([6, 5, 0, 0, 0]), tensor([1, 4, 3, 4, 0])]---> [tensor([[6, 5, 0, 0, 0]]), tensor[([1, 4, 3, 4, 0]])]--->
# tensor([[5, 5, 0, 0, 0],
# [3, 7, 6, 6, 0]]) 每一个句子最多5个单词, 用单词索引表示单词, 0来补齐
# tgt_seq: [tensor([3, 1, 4, 5, 0]), tensor([4, 7, 3, 0, 0])] ---->
# tensor([[1, 2, 1, 2, 0],
# [3, 2, 2, 0, 0]])
# 所以,通过torch.unsqueeze将序列增加一维, 再通过torch.cat拼接 === torch.stack
# 构造word embedding
src_embedding_table = nn.Embedding(max_num_src_words+1, model_dim) # 第0行是padding的0 Embedding(9, 16)
tgt_embedding_table = nn.Embedding(max_num_tgt_words+1, model_dim)
src_embedding = src_embedding_table(src_seq)
tgt_embedding = tgt_embedding_table(tgt_seq)
# 构造position embedding
pos_mat = torch.arange(max_position_len).reshape((-1, 1)) # pos按行 写成一列 , 反映行变化
i_mat =torch.pow(10000, torch.arange(0, model_dim, 2).reshape((1, -1)) / model_dim) # i 表示第几个特征,写成一行, 反映列变化
pe_embedding_table = torch.zeros(max_position_len, model_dim) # 先初始化一个位置编码表
pe_embedding_table[:, 0::2] = torch.sin(pos_mat / i_mat) # 给偶数列赋值
pe_embedding_table[:, 1::2] = torch.cos((pos_mat / i_mat)) # 给奇数列赋值
pe_embedding = nn.Embedding(max_position_len, model_dim) # 获取位置编码, 这里的词汇表大小实际上是每个句子中有几个单词, 即表征的是单词的位置,model_dim是表征向量的维度
pe_embedding.weight = nn.Parameter(pe_embedding_table, requires_grad = False) # 将计算好的位置编码赋值给位置编码表
src_pos = torch.cat([torch.unsqueeze(torch.arange(max(src_len)) , 0)for _ in src_len]).to(torch.int32) # 获取位置索引
tgt_pos = torch.cat([torch.unsqueeze(torch.arange(max(tgt_len)) , 0)for _ in src_len]).to(torch.int32)
src_pe_embedding = pe_embedding(src_pos) # 传入源序列的位置
tgt_pe_embedding = pe_embedding(tgt_pos) # 传入目标序列的位置
# print(pos_mat)
# print(i_mat)
# print(pe_embedding_table)
# print((pe_embedding.weight))
print(src_pos)
print(tgt_pos)
print(src_pe_embedding)
print(tgt_pe_embedding)
(1)、数据准备:理解序列与批次
首先创建了模拟数据:
# 批次大小
batch_size = 2
# 单词表大小
max_num_src_words = 8
max_num_tgt_words = 8
model_dim = 16
# 序列大小
max_src_seq_len = 5
max_tgt_seq_len = 5
max_position_len = 5
# 序列长度
src_len = torch.Tensor([2, 4]).to(torch.int) # 源序列长度:[2, 4]
tgt_len = torch.Tensor([4, 3]).to(torch.int) # 目标序列长度:[4, 3]
这段代码定义了:
- 批次大小:一次处理 2 个序列
- 词汇表大小:源语言和目标语言各有 8 个单词
- 模型维度:每个词向量用 16 维表示
- 序列长度:源序列分别为 2 个词和 4 个词,目标序列为 4 个词和 3 个词
(2)、生成序列数据(单词索引)
下一步是生成实际的序列数据:
# 生成源序列和目标序列
src_seq = torch.cat([torch.unsqueeze(F.pad(torch.randint(1, max_num_src_words, (L,)), (0, max_src_seq_len - L)), 0) for L in src_len])
tgt_seq = torch.stack([F.pad(torch.randint(1, max_num_tgt_words, (L,)), (0, max_tgt_seq_len - L)) for L in tgt_len], 0)
这部分代码做了三件事:
- 随机生成单词索引:使用
torch.randint
生成 1 到 8 之间的随机整数,表示单词在词汇表中的位置 - 填充序列:使用
F.pad
将短序列用 0 填充到固定长度(5) - 组合批次:使用
torch.cat
或torch.stack
将多个序列组合成批次
例如,生成的源序列可能是:
tensor([[6, 5, 0, 0, 0], # 第一个序列实际长度2,后面补3个0
[1, 4, 3, 4, 0]]) # 第二个序列实际长度4,后面补1个0
(3)、词嵌入:将索引转换为向量
现在我们有了单词索引,下一步是将它们转换为向量:
# 构造词嵌入层
src_embedding_table = nn.Embedding(max_num_src_words+1, model_dim)
tgt_embedding_table = nn.Embedding(max_num_tgt_words+1, model_dim)
src_embedding = src_embedding_table(src_seq)
tgt_embedding = tgt_embedding_table(tgt_seq)
这里的关键是nn.Embedding
:
- 参数:
(词汇表大小, 向量维度)
- 为什么 + 1:因为我们用 0 作为填充符号,所以词汇表实际需要 9 个位置(0-8)
- 工作原理:本质是一个可训练的查找表,将单词索引映射到对应的向量
例如,当我们输入索引6
时,src_embedding_table
会返回表中第 6 行的 16 维向量。
(4)、位置编码:为序列添加位置信息
词嵌入忽略了词序信息,位置编码就是来解决这个问题的:
# 构造位置编码
pos_mat = torch.arange(max_position_len).reshape((-1, 1)) # 位置矩阵
i_mat = torch.pow(10000, torch.arange(0, model_dim, 2).reshape((1, -1)) / model_dim) # 频率矩阵
pe_embedding_table = torch.zeros(max_position_len, model_dim) # 初始化位置编码表
# 偶数位置使用正弦函数,奇数位置使用余弦函数
pe_embedding_table[:, 0::2] = torch.sin(pos_mat / i_mat)
pe_embedding_table[:, 1::2] = torch.cos(pos_mat / i_mat)
# 创建位置嵌入层并加载预计算的编码表
pe_embedding = nn.Embedding(max_position_len, model_dim)
pe_embedding.weight = nn.Parameter(pe_embedding_table, requires_grad=False)
这段代码实现了 Transformer 模型中使用的正弦余弦位置编码:
- 位置矩阵:
[0, 1, 2, 3]
表示词的位置,有效的最大长度是4 - 频率矩阵:控制正弦余弦函数的周期,不同维度使用不同频率
- 编码表:偶数维度用正弦函数,奇数维度用余弦函数
- 固定参数:
requires_grad=False
表示训练时不更新位置编码
(5)、获取位置编码向量
最后,我们为每个词获取对应的位置编码:
# 获取位置索引
src_pos = torch.cat([torch.unsqueeze(torch.arange(max(src_len)), 0) for _ in src_len]).to(torch.int32)
tgt_pos = torch.cat([torch.unsqueeze(torch.arange(max(tgt_len)), 0) for _ in src_len]).to(torch.int32)
# 获取位置嵌入
src_pe_embedding = pe_embedding(src_pos)
tgt_pe_embedding = pe_embedding(tgt_pos)
这里:
src_pos
和tgt_pos
是位置索引矩阵- 对于源序列,尽管设置的max_len= 5, 但有效的长度最长为 4,则位置索引为
[0, 1, 2, 3]
- 每个位置索引对应位置编码表中的一行向量
最终,我们得到了:
src_embedding
:词嵌入向量src_pe_embedding
:位置编码向量- 将它们相加,就得到了同时包含词义和位置信息的输入表示
(6)、为什么这样设计?
代码完整实现了 Transformer 模型的两个基础组件:
- 词嵌入:将单词转换为连续向量空间的表示
- 位置编码:通过正弦余弦函数为每个位置创建唯一编码
这种设计的好处是:
- 获了单词之间的语义关系
- 保留了序列中的位置信息
- 编码是固定的,不依赖于训练数据,具有通用性
六、为什么这很重要?
词嵌入和位置编码是深度学习处理文本的基石,它们带来了以下几个重要突破:
-
语义表示:词嵌入让计算机能够 "理解" 词义,捕捉词语之间的语义关系。
-
上下文感知:位置编码让模型能够区分 "狗咬人" 和 "人咬狗" 这样的句子。
-
泛化能力:通过学习到的词向量,模型可以对未见过的句子进行推理。
这些技术是 Transformer、BERT、GPT 等强大模型的基础,推动了自然语言处理领域的进步。