现在位置: 首页 > PyTorch 教程 > 正文

PyTorch 词嵌入 (Embedding)

词嵌入是自然语言处理中最基础也是最重要的技术之一。

词嵌入将离散的词符号映射为连续的稠密向量,使机器能够理解和处理文本数据。

PyTorch 提供了 nn.Embedding 模块来实现这一功能,是构建各种 NLP 模型的基础。


1. 词嵌入基础概念

在计算机中,文本本质上是一串整数序列。每个词被分配一个唯一的索引 ID,但这种离散表示存在一个问题:相似的词在语义上可能很接近,但它们的 ID 却毫无关联。

词嵌入通过学习一个嵌入矩阵来解决这个问题:

\[ E \in \mathbb{R}^{V \times D} \]

其中 \(V\) 是词表大小,\(D\) 是嵌入维度。每个词 ID 对应嵌入矩阵中的一行,通过查表操作获取其向量表示:

\[ \text{embedding} = E[\text{word\_id}] \]

词嵌入的优势包括:

  • 将高维稀疏的 one-hot 向量转换为低维稠密向量,大幅降低计算开销
  • 语义相似的词在向量空间中距离更近,可通过余弦相似度计算词相似度
  • 嵌入向量是可学习的参数,可以通过反向传播自动调整

2. nn.Embedding 详解

nn.Embedding 是 PyTorch 提供的词嵌入层,封装了嵌入矩阵的创建和查表操作。

2.1 基本用法

实例

import torch
import torch.nn as nn

# 创建词嵌入层
# num_embeddings: 词表大小(vocab_size)
# embedding_dim: 嵌入维度(embedding_dim)
vocab_size = 10000
embedding_dim = 256

embedding = nn.Embedding(num_embeddings=vocab_size, embedding_dim=embedding_dim)

# 查看嵌入矩阵的形状
print(embedding.weight.shape)   # torch.Size([10000, 256])

# 输入词索引(LongTensor),获取嵌入向量
word_ids = torch.tensor([0, 1, 2, 9999])  # 任意词索引
embedded = embedding(word_ids)

print(embedded.shape)           # torch.Size([4, 256])
# 每个词ID对应一个256维的向量

2.2 nn.Embedding 参数详解

实例

import torch.nn as nn

embedding = nn.Embedding(
    num_embeddings=10000,    # 词表大小,必须大于等于输入的最大索引值
    embedding_dim=256,       # 嵌入向量维度,通常为 50, 100, 200, 300 等
    padding_idx=None,        # 填充词的索引,填充词的嵌入向量全为零
    max_norm=None,          # 嵌入向量的最大范数,用于归一化
    norm_type=2.0,          # 归一化类型,通常为 L2 范数
    scale_grad_by_freq=False,# 按词频缩放梯度
    sparse=False,           # 是否使用稀疏梯度(节省显存,但训练较慢)
    _weight=None,           # 预定义权重,用于加载预训练嵌入
)

# 查看参数量
total_params = embedding.num_embeddings * embedding.embedding_dim
print(f"嵌入层参数量: {total_params:,}")
# 10000 * 256 = 2,560,000

嵌入层的参数量 = 词表大小 × 嵌入维度,这是一个非常大的矩阵。通常 NLP 模型的嵌入层占模型总参数量的很大比例。

2.3 填充索引 padding_idx

在处理变长序列时,需要对短序列进行填充。使用 padding_idx 可以将填充词的嵌入向量固定为零向量,避免填充内容对模型产生影响:

实例

import torch
import torch.nn as nn

# 设置 padding_idx=0,表示索引0为填充词
embedding = nn.Embedding(num_embeddings=10000, embedding_dim=128, padding_idx=0)

# 初始化权重
nn.init.uniform_(embedding.weight, -0.1, 0.1)

# 索引0的嵌入向量全为0
word_0 = embedding(torch.tensor([0]))
print(f"填充词的嵌入: {word_0}")   # 全为0

# 其他索引正常
word_5 = embedding(torch.tensor([5]))
print(f"词5的嵌入: {word_5}")     # 非零值

2.4 最大范数归一化

使用 max_norm 可以限制嵌入向量的范数,防止训练过程中嵌入向量过大:

实例

import torch
import torch.nn as nn

# 设置最大范数为1.0
embedding = nn.Embedding(num_embeddings=1000, embedding_dim=64, max_norm=1.0)

# 输入任意词索引
ids = torch.tensor([1, 2, 3])
embedded = embedding(ids)

# 检查每个向量的L2范数
norms = torch.norm(embedded, p=2, dim=1)
print(f"各向量范数: {norms}")   # 所有值都接近1.0

3. 加载预训练词嵌入

使用预训练词嵌入可以大幅提升模型性能,尤其是当训练数据较少时。常见的预训练词嵌入包括 Word2Vec、GloVe、FastText 等。

3.1 从头训练与加载预训练的区别

方式 优点 缺点 适用场景
随机初始化训练 完全可定制,适应特定任务 需要大量训练数据 领域特定词汇多、训练数据充足
加载预训练嵌入 利用大规模语料知识,训练快、效果好 词汇覆盖受限,无法处理未登录词 通用任务、训练数据有限
冻结预训练嵌入 训练速度快,显存占用小 无法微调嵌入 训练资源有限、只关注上层模型
微调预训练嵌入 可适应特定任务 训练较慢,显存占用大 数据量中等、领域有一定差异

3.2 加载 GloVe 预训练词向量

GloVe 是 Stanford 大学发布的预训练词向量,下面展示如何加载:

实例

import torch
import torch.nn as nn
import numpy as np

# 模拟加载 GloVe 词向量(实际需要下载 GloVe 文件)
# 假设已有一个词向量文件,格式为:每行一个词 followed by its vectors

def load_glove_embeddings(path, word2idx, embedding_dim=300):
    """
    加载 GloVe 预训练词向量
    path: 词向量文件路径
    word2idx: 词到索引的映射字典
    embedding_dim: 词向量维度
    """

    embeddings = np.random.randn(len(word2idx), embedding_dim).astype(np.float32)
    word_count = 0

    with open(path, 'r', encoding='utf-8') as f:
        for line in f:
            values = line.strip().split()
            word = values[0]
            if word in word2idx:
                word_idx = word2idx[word]
                embeddings[word_idx] = np.asarray(values[1:], dtype=np.float32)
                word_count += 1

    print(f"已加载 {word_count}/{len(word2idx)} 个词向量")
    return torch.from_numpy(embeddings)


# 假设已有词表
word2idx = {'hello': 0, 'world': 1, 'runoob': 2, 'python': 3}
EMBED_DIM = 300

# 加载预训练嵌入并创建嵌入层
# pretrained_embeddings = load_glove_embeddings('glove.6B.300d.txt', word2idx, EMBED_DIM)
# embedding = nn.Embedding.from_pretrained(pretrained_embeddings, padding_idx=0)

# 简化示例:使用随机初始化的预训练矩阵
pretrained_embeddings = torch.randn(len(word2idx), EMBED_DIM)
embedding = nn.Embedding.from_pretrained(pretrained_embeddings, padding_idx=0)

print(f"嵌入层形状: {embedding.weight.shape}")

3.3 冻结与微调嵌入层

根据任务需求,可以选择冻结或微调嵌入层:

实例

import torch
import torch.nn as nn

embedding = nn.Embedding(num_embeddings=10000, embedding_dim=256)

# 方式一:冻结嵌入层(不参与训练)
embedding.weight.requires_grad = False
# 训练时只有 embedding.weight.requires_grad = False 的参数不会被更新

# 方式二:微调嵌入层(参与训练)
embedding.weight.requires_grad = True

# 方式三:冻结部分词向量(冻结"the", "is"等高频词)
# 假设高频词的索引在 0-99
embedding.weight.requires_grad = True
with torch.no_grad():
    embedding.weight[0:100] *= 0  # 或赋值为固定值

# 在优化器中过滤掉冻结的参数
optimizer = torch.optim.Adam(
    filter(lambda p: p.requires_grad, embedding.parameters()),
    lr=1e-3
)

4. 嵌入层与 RNN/LSTM 结合

词嵌入是 NLP 模型的第一层,将文本 ID 转换为密集向量后,传入 RNN、LSTM 等序列模型进行处理。

4.1 嵌入层 + LSTM 文本分类

实例

import torch
import torch.nn as nn

class TextClassifier(nn.Module):
    """
    嵌入层 + LSTM 文本分类模型
    """

    def __init__(self, vocab_size, embed_dim, hidden_size, num_classes, padding_idx=0):
        super().__init__()

        # 词嵌入层
        self.embedding = nn.Embedding(
            num_embeddings=vocab_size,
            embedding_dim=embed_dim,
            padding_idx=padding_idx
        )

        # LSTM 层
        self.lstm = nn.LSTM(
            input_size=embed_dim,
            hidden_size=hidden_size,
            num_layers=2,
            batch_first=True,
            bidirectional=True,
            dropout=0.3
        )

        # 分类器
        self.fc = nn.Linear(hidden_size * 2, num_classes)

    def forward(self, x):
        # x: (batch_size, seq_len) - 词索引
        embedded = self.embedding(x)  # (batch_size, seq_len, embed_dim)

        # LSTM 输出
        output, (h_n, c_n) = self.lstm(embedded)

        # 取最后一个时间步的隐藏状态(双向拼接)
        # 正向最后隐藏状态: h_n[-2]
        # 反向最后隐藏状态: h_n[-1]
        h_combined = torch.cat([h_n[-2], h_n[-1]], dim=-1)

        # 分类
        logits = self.fc(h_combined)
        return logits


# 模型实例化
VOCAB_SIZE = 10000
EMBED_DIM = 128
HIDDEN_SIZE = 128
NUM_CLASSES = 2

model = TextClassifier(VOCAB_SIZE, EMBED_DIM, HIDDEN_SIZE, NUM_CLASSES)

# 模拟输入:batch_size=4, 序列长度=10
batch_input = torch.randint(1, VOCAB_SIZE, (4, 10))
output = model(batch_input)

print(f"输入形状: {batch_input.shape}")      # torch.Size([4, 10])
print(f"输出形状: {output.shape}")           # torch.Size([4, 2])

4.2 使用预训练词向量

实例

import torch
import torch.nn as nn

# 假设已加载预训练词向量
pretrained_vectors = torch.randn(10000, 300)  # 模拟预训练向量

# 创建嵌入层并加载预训练权重
embedding = nn.Embedding.from_pretrained(
    pretrained_vectors,
    padding_idx=0,
    freeze=False  # True: 冻结不训练, False: 微调
)

# 使用 glove 或其他预训练词向量时,通常先冻结训练几轮,再解冻微调
# 前几轮只训练上层模型
embedding.weight.requires_grad = False

# 训练若干轮后,解冻嵌入层进行微调
# embedding.weight.requires_grad = True

5. 位置编码 (Positional Encoding)

与 RNN/LSTM 不同,Transformer 模型不包含位置信息,需要额外添加位置编码来让模型感知序列中词的顺序。

5.1 位置编码原理

位置编码使用正弦和余弦函数生成位置向量:

\[ \begin{aligned} PE_{(pos, 2i)} &= \sin\left(\frac{pos}{10000^{2i/d_{model}}}\right) \\ PE_{(pos, 2i+1)} &= \cos\left(\frac{pos}{10000^{2i/d_{model}}}\right) \end{aligned} \]

这种编码方式的特点是:不同位置的编码可以通过线性变换相互转换,便于模型学习位置关系。

5.2 实现位置编码

实例

import torch
import torch.nn as nn
import math

class PositionalEncoding(nn.Module):
    """
    位置编码层
    """

    def __init__(self, d_model, max_len=5000):
        super().__init__()

        # 创建位置编码矩阵
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)

        # 计算除数项
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))

        # 偶数索引使用 sin,奇数索引使用 cos
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)

        # 添加 batch 维度,并注册为不参与梯度计算的缓冲区
        pe = pe.unsqueeze(0)  # (1, max_len, d_model)
        self.register_buffer('pe', pe)

    def forward(self, x):
        """
        x: (batch_size, seq_len, d_model)
        """

        seq_len = x.size(1)
        # 截取对应长度的位置编码并相加
        x = x + self.pe[:, :seq_len, :]
        return x


# 使用示例
d_model = 256
max_len = 100

pos_encoding = PositionalEncoding(d_model, max_len)

# 模拟输入:batch_size=4, seq_len=20, d_model=256
x = torch.randn(4, 20, d_model)
x = pos_encoding(x)

print(f"输入形状: {x.shape}")   # torch.Size([4, 20, 256])

位置编码是 Transformer 架构中的关键组件,它使模型能够区分不同位置的词,即使它们的嵌入向量相同。

5.3 可学习的位置编码

除了固定的位置编码,也可以使用可学习的位置编码:

实例

import torch
import torch.nn as nn

class LearnablePositionalEncoding(nn.Module):
    """
    可学习的位置编码
    """

    def __init__(self, d_model, max_len=5000):
        super().__init__()
        self.pos_embedding = nn.Embedding(max_len, d_model)

    def forward(self, x):
        batch_size, seq_len, d_model = x.size(1), x.size(1), x.size(2)
        # 创建位置索引 [0, 1, 2, ..., seq_len-1]
        positions = torch.arange(seq_len, device=x.device).unsqueeze(0).expand(batch_size, -1)
        pos_encoded = self.pos_embedding(positions)
        return x + pos_encoded


# 使用示例
pos_encoding = LearnablePositionalEncoding(d_model=256, max_len=100)
x = torch.randn(4, 20, 256)
x = pos_encoding(x)
print(f"输出形状: {x.shape}")   # torch.Size([4, 20, 256])

6. 嵌入层的进阶技巧

6.1 降低显存占用

当词表非常大时,嵌入层会占用大量显存。可以使用以下技巧优化:

实例

import torch
import torch.nn as nn

# 技巧一:使用稀疏梯度(sparse=True)
embedding = nn.Embedding(
    num_embeddings=100000,
    embedding_dim=256,
    sparse=True  # 梯度以稀疏格式存储,节省显存
)

# 技巧二:使用量化( quantization)
# 将 float32 转换为 int8 或 float16
embedding_int8 = embedding.to(torch.int8)

# 技巧三:冻结不常用的词向量
# 在大规模词表中,只训练高频词,低频词保持冻结
embedding = nn.Embedding(num_embeddings=100000, embedding_dim=256)
embedding.weight.requires_grad = True

# 冻结索引大于 50000 的词向量
with torch.no_grad():
    embedding.weight[50000:] *= 0
embedding.weight.requires_grad = False

# 只训练高频词
embedding.weight[1:50000].requires_grad = True

6.2 处理未登录词 (OOV)

测试集中可能出现词表中没有的词(未登录词 / OOV),需要特殊处理:

实例

import torch
import torch.nn as nn

class EmbeddingWithOOV(nn.Module):
    """
    支持处理未登录词的嵌入层
    """

    def __init__(self, vocab_size, embed_dim, oov_idx=None):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size + 1, embed_dim, padding_idx=0)
        self.oov_idx = oov_idx if oov_idx is not None else vocab_size  # 最后一个索引作为 OOV

    def forward(self, x):
        # 将 OOV 词的索引替换为 oov_idx
        oov_mask = x >= self.vocab_size
        x = x.clone()
        x[oov_mask] = self.oov_idx
        return self.embedding(x)


# 使用哈希技术处理更大规模的词表
class HashEmbedding(nn.Module):
    """
    使用哈希技术处理任意规模词表的嵌入层
    """

    def __init__(self, num_buckets, embed_dim):
        super().__init__()
        self.num_buckets = num_buckets
        self.embedding = nn.Embedding(num_buckets, embed_dim)

    def forward(self, x):
        # 将词索引哈希到桶中
        # 使用 Python 字典的哈希方式
        hashed = torch.remainder(x, self.num_buckets)
        return self.embedding(hashed)

6.3 子词嵌入 (Subword Embedding)

对于形态丰富的语言(如德语、俄语),子词嵌入可以有效处理未登录词问题:

实例

import torch
import torch.nn as nn

class SubwordEmbedding(nn.Module):
    """
    简化的子词嵌入示例
    实际应使用 BPE、WordPiece 等算法进行分词
    """

    def __init__(self, vocab_size, embed_dim, char_dim=50):
        super().__init__()
        # 字符级嵌入
        self.char_embedding = nn.Embedding(vocab_size, char_dim)
        # 词级嵌入
        self.word_embedding = nn.Embedding(vocab_size, embed_dim - char_dim)
        # 字符级 LSTM,用于组合字符向量
        self.char_lstm = nn.LSTM(
            char_dim, char_dim,
            batch_first=True, bidirectional=True
        )

    def forward(self, word_ids, char_ids):
        """
        word_ids: 词索引 (batch_size, seq_len)
        char_ids: 字符索引 (batch_size, seq_len, max_word_len)
        """

        # 词嵌入
        word_emb = self.word_embedding(word_ids)

        # 字符嵌入 + LSTM
        batch_size, seq_len, max_word_len = char_ids.shape
        char_ids_flat = char_ids.view(-1, max_word_len)  # (batch_size * seq_len, max_word_len)
        char_emb = self.char_embedding(char_ids_flat)    # (batch_size * seq_len, max_word_len, char_dim)

        char_output, (h_n, _) = self.char_lstm(char_emb)
        # 取双向最后隐藏状态拼接
        char_rep = torch.cat([h_n[-2], h_n[-1]], dim=-1)  # (batch_size * seq_len, char_dim * 2)

        char_rep = char_rep.view(batch_size, seq_len, -1)  # (batch_size, seq_len, char_dim * 2)

        # 拼接词嵌入和字符表示
        combined = torch.cat([word_emb, char_rep], dim=-1)
        return combined

7. 完整实战:文本分类模型

下面是一个完整的文本分类模型示例,包含嵌入层、LSTM 和分类器:

实例

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# ── 配置参数 ──────────────────────────────────────
VOCAB_SIZE = 10000
EMBED_DIM = 128
HIDDEN_SIZE = 128
NUM_LAYERS = 2
NUM_CLASSES = 5
DROPOUT = 0.3
MAX_LEN = 200

# ── 模型定义 ──────────────────────────────────────
class TextClassificationModel(nn.Module):
    def __init__(self, vocab_size, embed_dim, hidden_size,
                 num_layers, num_classes, dropout=0.3, padding_idx=0):
        super().__init__()

        # 嵌入层
        self.embedding = nn.Embedding(
            vocab_size,
            embed_dim,
            padding_idx=padding_idx
        )

        # BiLSTM
        self.lstm = nn.LSTM(
            embed_dim,
            hidden_size,
            num_layers=num_layers,
            batch_first=True,
            bidirectional=True,
            dropout=dropout if num_layers > 1 else 0
        )

        # 分类器
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_size * 2, num_classes)

    def forward(self, x):
        # x: (batch_size, seq_len)
        embedded = self.embedding(x)  # (batch_size, seq_len, embed_dim)

        # LSTM
        output, (h_n, c_n) = self.lstm(embedded)

        # 双向最后隐藏状态拼接
        h_forward = h_n[-2]   # (batch_size, hidden_size)
        h_backward = h_n[-1]  # (batch_size, hidden_size)
        h_combined = torch.cat([h_forward, h_backward], dim=-1)

        # 分类
        dropped = self.dropout(h_combined)
        logits = self.fc(droped)
        return logits


# ── Dataset ──────────────────────────────────────
class TextDataset(Dataset):
    def __init__(self, texts, labels, vocab, max_len=200):
        self.texts = texts
        self.labels = labels
        self.vocab = vocab
        self.max_len = max_len

    def __len__(self):
        return len(self.texts)

    def __getitem__(self, idx):
        text = self.texts[idx][:self.max_len]
        # 将词转换为索引,未知词用 1(<UNK>)表示
        ids = [self.vocab.get(word, 1) for word in text]
        # 补零到固定长度
        if len(ids) < self.max_len:
            ids += [0] * (self.max_len - len(ids))
        return torch.tensor(ids), torch.tensor(self.labels[idx])


# ── 训练函数 ──────────────────────────────────────
def train_epoch(model, loader, optimizer, criterion, device):
    model.train()
    total_loss = 0
    correct = 0
    total = 0

    for inputs, labels in loader:
        inputs, labels = inputs.to(device), labels.to(device)

        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        total_loss += loss.item() * inputs.size(0)
        _, predicted = outputs.max(1)
        correct += predicted.eq(labels).sum().item()
        total += labels.size(0)

    return total_loss / total, correct / total


# ── 初始化与训练 ──────────────────────────────────
# 假设已有词表
word2idx = {'<PAD>': 0, '<UNK>': 1}  # 词表需根据实际语料构建

model = TextClassificationModel(
    VOCAB_SIZE, EMBED_DIM, HIDDEN_SIZE,
    NUM_LAYERS, NUM_CLASSES, DROPOUT
).to(device)

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)

# 模拟训练
print("开始训练...")
for epoch in range(10):
    train_loss, train_acc = train_epoch(model, None, optimizer, criterion, device)
    print(f"Epoch {epoch+1}: Loss={train_loss:.4f}, Acc={train_acc:.4f}")

8. API 快速参考

8.1 nn.Embedding 常用操作

操作 代码
创建嵌入层 nn.Embedding(num_embeddings, embedding_dim)
加载预训练嵌入 nn.Embedding.from_pretrained(weights)
查表获取嵌入 embedding(word_ids)
冻结嵌入 embedding.weight.requires_grad = False
获取嵌入向量 embedding.weight[idx]

8.2 预训练词向量资源

资源 维度 特点
GloVe 50, 100, 200, 300 词共现统计,训练快
Word2Vec 50-500 Google 训练,覆盖广
FastText 300 支持子词,处理 OOV 好
BERT 768+ 上下文相关,效果最好

8.3 嵌入层选型建议

数据量小(< 10K)
    -> 使用预训练词向量(GloVe/FastText)+ 冻结或微调

数据量中等(10K ~ 100K)
    -> 使用预训练词向量 + 微调

数据量大(> 100K)
    -> 可考虑从头训练,或使用大规模预训练模型

领域差异大
    -> 使用领域相关预训练模型或增量训练

资源受限
    -> 冻结嵌入层,使用较小的嵌入维度