1. Seq2Seq 架构概述
Seq2Seq(Sequence to Sequence)模型是自然语言处理领域的重要架构,专门用于处理输入序列和输出序列长度不一致的任务。这种架构最早由Google在2014年提出,现已成为机器翻译、文本摘要、对话系统等任务的基础模型。
1.1 序列建模的基本类型
在深入Seq2Seq之前,我们需要了解RNN和LSTM处理序列数据的三种基本模式:
-
多对一(Many-to-One):将整个输入序列压缩成单个特征向量。典型应用包括:
- 文本分类(判断文章情感倾向)
- 情感分析(确定评论的正负面)
- 视频动作识别(根据帧序列判断动作类型)
-
多对多对齐(Many-to-Many, Aligned):为输入序列的每个时间步生成对应的输出。常见场景有:
- 词性标注(为每个单词标注词性)
- 命名实体识别(识别文本中的人名、地名等)
- 语音识别(将音频帧序列转为文字)
-
一对多(One-to-Many):从单个输入生成变长序列。典型例子:
- 图像描述生成(根据图片生成文字描述)
- 音乐生成(根据种子生成旋律)
- 故事续写(给定开头续写完整故事)
1.2 Seq2Seq的特殊性
Seq2Seq处理的是**多对多不对齐(Many-to-Many, Unaligned)**任务,其特点是:
- 输入输出序列长度可变且不固定
- 序列元素间没有严格的位置对应关系
- 需要理解整个输入语义后再生成输出
最典型的例子是机器翻译:
- 输入:"我爱人工智能"(3个词)
- 输出:"I love artificial intelligence"(4个词)
- 中英文单词间没有一一对应关系
2. Seq2Seq核心组件
2.1 编码器(Encoder)
编码器的作用是将输入序列压缩为一个固定长度的上下文向量(Context Vector)。这个过程类似于人类阅读句子后形成记忆和理解。
技术实现细节:
- 输入序列经过词嵌入层转换为稠密向量
- RNN/LSTM按时间步处理序列,更新隐藏状态
- 对于标准RNN:$h_t = f(h_{t-1}, x_t)$
- 对于LSTM:$(h_t, c_t) = \text{LSTM}((h_{t-1}, c_{t-1}), x_t)$
- 最终隐藏状态作为整个序列的语义表示
关键设计选择:
- 单向vs双向:双向LSTM能捕获前后文信息,但需要处理最终状态的合并
- 多层结构:深层网络能学习更抽象的特征表示
- 注意力机制(后续改进):动态关注输入序列的不同部分
2.2 解码器(Decoder)
解码器接收上下文向量,逐步生成输出序列。这个过程模拟人类组织语言表达思想的过程。
工作流程:
- 用编码器的最终状态初始化解码器状态
- 以开始符
作为第一个输入 - 每个时间步:
- 根据当前输入和状态生成新词
- 更新RNN状态
- 将预测词作为下一步输入(自回归)
- 遇到结束符
停止生成
关键技术:
- 教师强制(Teacher Forcing):训练时混合使用真实标签和模型预测
- 束搜索(Beam Search):推理时保留多个候选序列
- 注意力机制:动态参考输入序列的不同部分
3. PyTorch实现详解
3.1 编码器实现
python复制class Encoder(nn.Module):
def __init__(self, vocab_size, hidden_size, num_layers):
super(Encoder, self).__init__()
self.embedding = nn.Embedding(vocab_size, hidden_size)
self.rnn = nn.LSTM(
input_size=hidden_size,
hidden_size=hidden_size,
num_layers=num_layers,
batch_first=True,
bidirectional=False
)
def forward(self, x):
# x shape: (batch_size, seq_length)
embedded = self.embedding(x) # (batch_size, seq_len, hidden_size)
_, (hidden, cell) = self.rnn(embedded)
return hidden, cell
关键点解析:
- 词嵌入层将离散的词ID映射为连续向量空间
- LSTM的输入输出维度保持一致,便于状态传递
- batch_first=True使输入输出以(batch, seq, feature)形式组织
- 只返回最终状态,舍弃中间输出(传统Seq2Seq做法)
3.2 解码器实现
python复制class Decoder(nn.Module):
def __init__(self, vocab_size, hidden_size, num_layers):
super(Decoder, self).__init__()
self.embedding = nn.Embedding(vocab_size, hidden_size)
self.rnn = nn.LSTM(hidden_size, hidden_size, num_layers, batch_first=True)
self.fc = nn.Linear(hidden_size, vocab_size)
def forward(self, x, hidden, cell):
x = x.unsqueeze(1) # (batch_size, 1)
embedded = self.embedding(x) # (batch_size, 1, hidden_size)
output, (hidden, cell) = self.rnn(embedded, (hidden, cell))
prediction = self.fc(output.squeeze(1))
return prediction, hidden, cell
设计考量:
- 每个时间步只处理一个词,保持状态连续性
- 全连接层将隐藏状态映射到词表空间
- 返回预测结果和更新后的状态,用于下一步
- 输入输出形状精心设计以确保批次处理效率
3.3 Seq2Seq整合模块
python复制class Seq2Seq(nn.Module):
def __init__(self, encoder, decoder, device):
super(Seq2Seq, self).__init__()
self.encoder = encoder
self.decoder = decoder
self.device = device
def forward(self, src, trg, teacher_forcing_ratio=0.5):
batch_size = src.shape[0]
trg_len = trg.shape[1]
trg_vocab_size = self.decoder.fc.out_features
outputs = torch.zeros(batch_size, trg_len, trg_vocab_size).to(self.device)
hidden, cell = self.encoder(src)
input = trg[:, 0] # 第一个输入是<SOS>
for t in range(1, trg_len):
output, hidden, cell = self.decoder(input, hidden, cell)
outputs[:, t, :] = output
teacher_force = random.random() < teacher_forcing_ratio
top1 = output.argmax(1)
input = trg[:, t] if teacher_force else top1
return outputs
训练技巧:
- Teacher Forcing比例需要谨慎调整(通常0.5-0.8)
- 预分配输出张量提高内存效率
- 序列生成过程完全可微分,支持端到端训练
- 处理不同长度序列时需要padding和mask
3.4 推理实现
python复制def greedy_decode(self, src, max_len=12, sos_idx=1, eos_idx=2):
self.eval()
with torch.no_grad():
hidden, cell = self.encoder(src)
trg_indexes = [sos_idx]
for _ in range(max_len):
trg_tensor = torch.LongTensor([trg_indexes[-1]]).to(self.device)
output, hidden, cell = self.decoder(trg_tensor, hidden, cell)
pred_token = output.argmax(1).item()
trg_indexes.append(pred_token)
if pred_token == eos_idx:
break
return trg_indexes
优化点:
- eval()模式关闭dropout等训练专用层
- torch.no_grad()禁用梯度计算,节省内存
- 贪心解码每次选择概率最高的词
- 遇到
提前终止生成
4. 高级技巧与变体
4.1 上下文向量的替代用法
传统方法用编码器最终状态初始化解码器,另一种思路是将上下文向量作为每个解码步的额外输入:
python复制class DecoderAlt(nn.Module):
def __init__(self, vocab_size, hidden_size, num_layers):
super(DecoderAlt, self).__init__()
self.embedding = nn.Embedding(vocab_size, hidden_size)
self.rnn = nn.LSTM(hidden_size*2, hidden_size, num_layers, batch_first=True)
self.fc = nn.Linear(hidden_size, vocab_size)
def forward(self, x, hidden_ctx, hidden, cell):
x = x.unsqueeze(1)
embedded = self.embedding(x)
context = hidden_ctx[-1].unsqueeze(1).repeat(1, embedded.shape[1], 1)
rnn_input = torch.cat((embedded, context), dim=2)
outputs, (hidden, cell) = self.rnn(rnn_input, (hidden, cell))
predictions = self.fc(outputs.squeeze(1))
return predictions, hidden, cell
优势:
- 每个解码步都能访问完整的上下文信息
- 减轻了长序列信息衰减问题
- 为后续注意力机制奠定了基础
4.2 双向编码器
python复制class BiEncoder(nn.Module):
def __init__(self, vocab_size, hidden_size, num_layers):
super(BiEncoder, self).__init__()
self.embedding = nn.Embedding(vocab_size, hidden_size)
self.rnn = nn.LSTM(
hidden_size, hidden_size//2,
num_layers,
bidirectional=True,
batch_first=True
)
def forward(self, x):
embedded = self.embedding(x)
outputs, (hidden, cell) = self.rnn(embedded)
# 合并双向状态
hidden = hidden.view(self.rnn.num_layers, 2, -1, self.rnn.hidden_size//2)
hidden = torch.cat((hidden[:,0,:,:], hidden[:,1,:,:]), dim=2)
cell = cell.view(self.rnn.num_layers, 2, -1, self.rnn.hidden_size//2)
cell = torch.cat((cell[:,0,:,:], cell[:,1,:,:]), dim=2)
return hidden, cell
实现要点:
- 设置bidirectional=True启用双向LSTM
- 隐藏层维度减半以保持参数量
- 需要仔细处理最终状态的合并
- 解码器需要相应调整输入维度
5. 实战建议与常见问题
5.1 超参数选择经验
根据实际项目经验,推荐以下配置:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 隐藏层大小 | 256-1024 | 根据任务复杂度调整 |
| 词向量维度 | 256-512 | 与隐藏层大小相当 |
| LSTM层数 | 2-4 | 过深难以训练 |
| 批大小 | 32-128 | 考虑显存限制 |
| 学习率 | 0.0001-0.001 | 配合优化器选择 |
| Dropout率 | 0.2-0.5 | 防止过拟合 |
5.2 常见问题排查
问题1:模型不收敛
- 检查梯度流动:可视化各层梯度幅度
- 验证数据预处理:确保tokenization正确
- 尝试更小的学习率或预热策略
问题2:生成结果重复
- 调整temperature参数软化概率分布
- 尝试束搜索(beam search)替代贪心解码
- 增加惩罚重复的机制
问题3:长序列性能差
- 改用GRU可能减轻长程依赖问题
- 实现注意力机制(后续改进)
- 尝试截断反向传播(TBPTT)
5.3 优化技巧
- 动态Teacher Forcing:
python复制teacher_forcing_ratio = max(0.5, 1 - epoch/10) # 随训练逐渐减少
- 学习率调度:
python复制scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, 'min')
- 梯度裁剪:
python复制torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
- 标签平滑:
python复制criterion = nn.CrossEntropyLoss(label_smoothing=0.1)
6. 完整训练流程示例
python复制def train(model, iterator, optimizer, criterion, clip):
model.train()
epoch_loss = 0
for i, (src, trg) in enumerate(iterator):
optimizer.zero_grad()
output = model(src, trg)
output_dim = output.shape[-1]
output = output[:,1:,:].reshape(-1, output_dim)
trg = trg[:,1:].reshape(-1)
loss = criterion(output, trg)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
optimizer.step()
epoch_loss += loss.item()
return epoch_loss / len(iterator)
def evaluate(model, iterator, criterion):
model.eval()
epoch_loss = 0
with torch.no_grad():
for i, (src, trg) in enumerate(iterator):
output = model(src, trg, 0) # 禁用Teacher Forcing
output_dim = output.shape[-1]
output = output[:,1:,:].reshape(-1, output_dim)
trg = trg[:,1:].reshape(-1)
loss = criterion(output, trg)
epoch_loss += loss.item()
return epoch_loss / len(iterator)
# 主训练循环
for epoch in range(N_EPOCHS):
train_loss = train(model, train_iterator, optimizer, criterion, CLIP)
valid_loss = evaluate(model, valid_iterator, criterion)
if valid_loss < best_valid_loss:
best_valid_loss = valid_loss
torch.save(model.state_dict(), 'model.pt')
print(f'Epoch: {epoch+1:02} | Train Loss: {train_loss:.3f} | Val. Loss: {valid_loss:.3f}')
关键细节:
- 训练和验证使用不同的Teacher Forcing策略
- 忽略第一个token(
)的预测结果 - 定期保存最佳模型
- 使用梯度裁剪稳定训练
7. 扩展思考
虽然传统Seq2Seq模型已经能解决很多问题,但在实际应用中还存在以下改进空间:
-
注意力机制:使模型能够动态关注输入序列的不同部分,极大提升了长序列处理能力
-
Transformer架构:完全基于自注意力机制的模型,已成为当前最先进的序列建模方案
-
预训练+微调:使用大规模语料预训练语言模型,再针对特定任务微调
-
多模态扩展:将Seq2Seq思想应用于图像描述生成、视频摘要等跨模态任务
在实际项目中,建议从基础Seq2Seq模型开始,理解其核心思想和工作原理后,再逐步引入这些高级技术。