1. 基础神经语言模型 (RNN)
我们将手动实现 RNN 的核心公式 ,以展示其循环的实际结构。
我们将从零开始构建一个 SimpleRNN 类,并训练它来完成一个记忆任务: (即:在当前时刻 ,输出 2 步之前 的输入)。
目的: 这个任务强迫 (forces) 模型必须使用它的隐藏状态 () 来跨时间传递信息,从而证明 RNN 的依赖关系和记忆能力。
# --- [ 1. RNN “从零” (From Scratch) 结构 + “依赖” (Dependency) 任务演示 ] ---
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
# --- 1. [结构] "手动" (Manually) 定义 RNN 的“实际结构” ---
class SimpleRNN(nn.Module):
"""
这是一个从零实现的 RNN 结构,用于展示其内部原理。
"""
def __init__(self, input_size, hidden_size, output_size):
super(SimpleRNN, self).__init__()
self.hidden_size = hidden_size
# --- RNN 的核心权重 ---
# (W_xh): 从"输入 x_t" 到"隐藏状态 h_t" 的权重
# (对应理论: W_xh * x_t, 并加上偏置 b, 线性变换层)
self.i2h = nn.Linear(input_size, hidden_size)
# (W_hh): 从"过去的隐藏状态 h_{t-1}" 到"隐藏状态 h_t" 的权重
# (对应理论: W_hh * h_{t-1})
self.h2h = nn.Linear(hidden_size, hidden_size)
# (W_hy): 从"隐藏状态 h_t" 到"输出 y_t" 的权重
self.h2o = nn.Linear(hidden_size, output_size)
# RNN 的标准非线性激活函数
self.tanh = nn.Tanh()
def forward(self, x_t, h_t_minus_1):
"""
此函数只执行“一步” (ONE STEP) 的计算。
(循环将在训练代码中手动编写)
它接收当前时间步的输入和上一个时间步的隐藏状态,然后计算出当前时间步的输出和新的隐藏状态。
代码中的偏置项b被封装在 nn.Linear 层内部,作为层的属性(self.bias)存在,并在进行线性变换时自动与权重一起参与计算。不需要手动添加它们。
"""
# 核心公式: h_t = tanh( (W_xh*x_t) + (W_hh*h_{t-1}) )
input_gate = self.i2h(x_t)
hidden_gate = self.h2h(h_t_minus_1)
# 计算新的隐藏状态 (h_t),即“新的记忆”
h_t = self.tanh(input_gate + hidden_gate)
# 输出公式: y_t = W_hy * h_t
output = self.h2o(h_t)
return output, h_t
def init_hidden(self, batch_size=1):
# 辅助函数:生成一个“零” (zero) 初始空白记忆 (h_0), 一个初始的隐藏状态, 全是0的张量
return torch.zeros(batch_size, self.hidden_size)
# --- 2. [训练] (Training) 函数 ---
def train_model(model, criterion, optimizer, X_train, Y_train, epochs=200):
"""
完整的训练流程
criterion表示损失函数
X_train, 训练数据的特征(输入序列)
Y_train, 训练数据的真实标签(目标序列)
"""
# shape: (sequence_length, batch_size, input_size)
seq_len = X_train.shape[0] # (序列长度)
print(f"\n--- [训练开始] (共 {epochs} 轮) ---")
for epoch in range(epochs):
# 1. 在每个 epoch 开始时,重置“记忆” (h_0)
# 重置 RNN 的隐藏状态,为新的序列处理做准备
# 清空优化器的梯度缓存,防止梯度累积
# 初始化本轮的总损失为 0,用于累加每个时间步的损失
h_t = model.init_hidden()
optimizer.zero_grad()
loss = 0.0
# [核心] 手动遍历“序列” (sequence)
for t in range(seq_len):
# (a) 提取当前的“输入” (x_t) 和“目标” (y_t)
x_t = X_train[t]
y_t = Y_train[t]
# (b) [核心] 向前传播“一步” (one step)
# 输入: "当前的词" (x_t), "过去的记忆" (h_t)
# 输出: "预测的词" (y_pred), "新的记忆" (h_{t+1})
y_pred, h_t = model(x_t, h_t)
# (c) 累积这一步的“损失” (loss)
loss += criterion(y_pred, y_t)
# 3. [BPTT] "通过时间反向传播" (Backpropagation Through Time)
# 基于“整个序列” (entire sequence) 的“总损失” (total loss) 进行反向传播, 计算总损失相对于模型所有参数的梯度
loss.backward()
# 4. 更新“共享” (shared) 的权重 (W_xh, W_hh, W_hy), 根据计算出的梯度更新模型的权重参数
optimizer.step()
# f-string(Formatted String)
if (epoch+1) % 40 == 0:
print(f"Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}")
print("--- [训练完成] ---")
# --- 3. [测试] (Testing) 函数 ---
def test_model(model, X_test, data_in_raw, data_out_raw):
"""
完整的测试 (推理) 流程
X_test: 测试数据的输入序列,形状通常与训练数据 X_train 一致
data_in_raw: 原始的输入序列(例如,由 0 和 1 组成的列表)。这通常是为了打印出来,直观地看到模型的输入是什么
data_out_raw: 原始的目标序列(真实标签)。用于与模型的预测结果进行对比,计算准确率
"""
print(f"\n--- [测试] (在 {data_in_raw} 上测试) ---")
seq_len = X_test.shape[0]
model.eval() # (将模型切换到“评估”(evaluation/inference)模式), 在进行任何测试或推理之前,都应该调用此方法
# "with torch.no_grad()" 告诉 PyTorch 在此代码块中“不要”计算梯度
# 在测试阶段,我们只需要进行前向传播来得到预测结果,不需要计算梯度(因为不需要更新权重)
# torch.no_grad() 会禁用 PyTorch 的自动求导机制,从而大大减少内存消耗和计算时间,让推理过程更快、更高效
with torch.no_grad():
# 重置“记忆” (h_0)
h_t = model.init_hidden()
predictions = []
# 再次“手动”遍历序列 (模拟“推理”过程)
for t in range(seq_len):
x_t = X_test[t]
y_pred, h_t = model(x_t, h_t)
# 我们的数据是 0 或 1
# 这是一个二分类问题(预测结果是 0 或 1),我们需要设定一个阈值来将连续的 y_pred 转换为离散的 0 或 1。这里使用了 0.5 作为阈值
pred_binary = 1 if y_pred.item() > 0.5 else 0
predictions.append(pred_binary)
print(f"输入序列 (Input): {data_in_raw}")
print(f"目标序列 (Target): {data_out_raw}")
print(f"模型预测 (Predicted): {predictions}")
# (计算一个简单的“准确率”)
# zip(predictions, data_out_raw): 将预测列表和真实标签列表配对
correct = sum(1 for p, t in zip(predictions, data_out_raw) if p == t)
accuracy = (correct / len(data_out_raw)) * 100
print(f"\n准确率 (Accuracy): {accuracy:.2f}%")
print("[结论]:")
print(f"模型“学会了” (learned) 在 t={t} 时刻,")
print(f"“回忆” (recall) 并“输出” (output) t={t-2} 时刻的输入。")
print("这证明了 h_t (隐藏状态) 成功地充当了“记忆” (Memory)。")
def run():
# --- (a) 准备“数据” (Data) ---
# (任务: y_t = x_{t-2}, 即“回声” (Echo) 游戏)
# seq_len = 10: 定义了序列的长度为 10 个时间步
# lag = 2: 定义了 “回声” 的延迟,即输出相对于输入的滞后步数。
seq_len = 10
lag = 2
# 1. 创建“输入” (Input)
# (e.g., [1, 0, 1, 0, 0, 1, 1, 0, 1, 0])
# randint(0, 2, ...) 会生成只包含 0 和 1 的整数,模拟二进制输入
data_in = np.random.randint(0, 2, size=(seq_len,))
# 2. 创建“目标” (Target)
data_out = np.zeros(seq_len) # 初始化为全0
for t in range(lag, seq_len):
data_out[t] = data_in[t - lag] # (e.g., y[2]=x[0], y[3]=x[1], ...)
# (data_out 现在是 [0, 0, x[0], x[1], x[2], ...])
# 准备 PyTorch 张量
# 形状:(Seq_Len, Batch_Size, Input_Size)
X_train_tensor = torch.tensor(data_in).float().view(seq_len, 1, 1)
Y_train_tensor = torch.tensor(data_out).float().view(seq_len, 1, 1)
print(f"--- [数据] (y_t = x_{t-2} 任务) ---")
print(f"输入 (X): {data_in}")
print(f"目标 (Y): {data_out.astype(int)}")
# --- (b) 准备“训练设置” (Training Setup) ---
input_size = 1 # 每个时间步的输入特征数
hidden_size = 10 # 隐藏层的神经元数量,决定了模型的 “记忆容量”
output_size = 1 # 每个时间步的输出特征数
learning_rate = 0.01 # 学习率,控制模型参数更新的幅度
epochs = 200 # (这个任务比 "+1" 任务“更难” (harder),需要更多 epochs)
model = SimpleRNN(input_size, hidden_size, output_size)
# (这是一个“分类” (classification) 任务 (0 or 1),
# BCEWithLogitsLoss 是“更正确” (more appropriate) 的损失函数), 二元交叉熵对数损失(Binary Cross-Entropy With Logits Loss), 适合二分类任务
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
print(f"\n--- [模型结构] (我们“手动”搭建的) ---")
print(model)
# --- (c) 执行“训练” ---
train_model(model, criterion, optimizer, X_train_tensor, Y_train_tensor, epochs)
# --- (d) 执行“测试” ---
# 这里是在训练集上进行测试。这么做的目的不是为了评估模型在新数据上的泛化能力
# 而是为了快速验证模型是否真正 “学会” 了我们教给它的任务(即 y_t = x_{t-2} 的依赖关系)
# 如果模型在它训练过的数据上都表现不好,那它肯定没有学会
test_model(model, X_train_tensor, data_in, data_out.astype(int))
run()
2. 基础神经语言模型 (LSTM)
我们将使用 PyTorch 的 nn.LSTMCell 来手动构建循环。
LSTM 循环: (2 个记忆传入, 2 个传出)
我们将使用与 RNN 完全相同 (identical) 的 记忆任务。
# --- [ 2. LSTM (长短期记忆) “从零” (From Scratch) 结构 + “完整训练” (Full Training) 演示 ] ---
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
# --- 1. [结构] "手动" (Manually) 定义 LSTM 的“实际结构” ---
class SimpleLSTM(nn.Module):
"""
这是一个“半手动” (semi-manual) 的 LSTM 结构。
我们使用“nn.LSTMCell”来处理“内部” (internal) 的“门” (gate) 逻辑...
...但我们“手动” (manually) 编写“循环” (loop),
以展示“两个” (TWO) 隐藏状态 (h_t, C_t) 是如何“传递” (passed) 的。
nn.LSTM 是「完整的 LSTM 层」(支持批量处理、自动迭代时间步)
nn.LSTMCell 是「单个时间步的单元」,需要手动循环迭代所有时间步(灵活性更高,适合自定义时序逻辑)
"""
"""
input_size:每个时间步的输入特征维度
hidden_size:LSTM 单元的隐藏层维度(即短期记忆 h_t 和细胞状态 C_t 的维度,是模型容量的核心参数)
output_size:最终输出的维度
"""
def __init__(self, input_size, hidden_size, output_size):
super(SimpleLSTM, self).__init__() # 调用父类(nn.Module)的初始化方法
self.hidden_size = hidden_size
# --- LSTM 的核心权重 (封装在 LSTMCell 中) ---
# (这一个“Cell” (单元) 包含了 Forget, Input, Output
# 三个“门” (Gates) 和 Cell State (C_t) 的所有权重)
self.lstm_cell = nn.LSTMCell(input_size, hidden_size)
# (W_hy): 从"短期记忆 h_t" 到"输出 y_t" 的权重
self.h2o = nn.Linear(hidden_size, output_size)
def forward(self, x_t, hidden_states):
"""
此函数只执行“一步” (ONE STEP) 的计算。
参数:
x_t (tensor): "当前的输入" [Batch, Input_Size]
hidden_states (tuple): "过去的记忆" (h_{t-1}, C_{t-1})
"""
# 1. (解包)
# 作用:把输入的元组 hidden_states 拆分成独立的「上一轮短期记忆 h_{t-1}」和「上一轮长期记忆 C_{t-1}」,方便后续 LSTM 单元调用
# 本质:只是格式拆解,没有任何数值计算,确保 LSTM 单元能拿到需要的两个输入(上一轮的两个记忆状态)
h_t_minus_1, C_t_minus_1 = hidden_states
# 2. [核心] LSTM 单元“向前” (forward) 传播“一步” (one step)
# 输入: (x_t, (h_{t-1}, C_{t-1}))
# 输出: (h_t, C_t) (“新的”短期和长期记忆)
"""
这是 LSTM 单步迭代的核心,self.lstm_cell(即 nn.LSTMCell)内部自动完成以下关键计算(对应 LSTM 原理):
遗忘门(Forget Gate):计算 f_t = σ(W_f·[h_{t-1}, x_t] + b_f),决定上一轮长期记忆 C_{t-1} 中哪些信息需要丢弃(σ 是 sigmoid 函数,输出 0~1 表示 “保留比例”)
输入门(Input Gate):计算 i_t = σ(W_i·[h_{t-1}, x_t] + b_i),决定当前输入 x_t 中的哪些新信息需要存入长期记忆
候选细胞状态:计算 Ã_t = tanh(W_c·[h_{t-1}, x_t] + b_c),生成待存入长期记忆的 “候选新信息”(tanh 输出 -1~1,控制数值范围)
更新长期记忆:C_t = f_t ⊙ C_{t-1} + i_t ⊙ Ã_t(⊙ 是元素相乘),即 “先遗忘旧信息,再加入新信息”
输出门(Output Gate):计算 o_t = σ(W_o·[h_{t-1}, x_t] + b_o),决定长期记忆 C_t 中哪些信息输出到短期记忆
更新短期记忆:h_t = o_t ⊙ tanh(C_t),得到当前时间步的短期记忆(也是后续输出映射的核心)
"""
h_t, C_t = self.lstm_cell(x_t, (h_t_minus_1, C_t_minus_1))
# 3. (输出) (注意:输出 y_t 只依赖于“短期记忆” h_t, 长期记忆 C_t 仅用于 “内部记忆传承”,不直接参与输出计算)
output = self.h2o(h_t)
# (返回“预测”和“新的记忆对”)
# output 用于当前时间步的损失计算(如分类任务的交叉熵损失)
# (h_t, C_t) 作为「下一个时间步的 hidden_states 输入」,实现时序记忆的传递
return output, (h_t, C_t)
def init_hidden(self, batch_size=1):
# 辅助函数:生成“两个” (TWO) “零” (zero) 初始记忆 (h_0, C_0)
h_0 = torch.zeros(batch_size, self.hidden_size)
C_0 = torch.zeros(batch_size, self.hidden_size)
return (h_0, C_0)
# --- 2. [训练] (Training) 函数 ---
def train_model(model, criterion, optimizer, X_train, Y_train, epochs=200):
"""
完整的训练流程
"""
seq_len = X_train.shape[0]
print(f"\n--- [训练开始] (共 {epochs} 轮) ---")
for epoch in range(epochs):
# 1. [关键] 重置“两个” (TWO) 记忆 (h_0, C_0)
hidden_states = model.init_hidden()
optimizer.zero_grad()
loss = 0.0
# [核心] 手动遍历“序列” (sequence)
for t in range(seq_len):
x_t = X_train[t]
y_t = Y_train[t]
# (b) [核心] 向前传播“一步” (one step),调用forward函数,PyTorch 的 nn.Module 自动将 模型实例(参数) 映射到 forward 方法
# 输入: "当前的词" (x_t), "过去的记忆对" (h_t, C_t)
# 输出: "预测的词" (y_pred), "新的记忆对" (h_{t+1}, C_{t+1})
y_pred, hidden_states = model(x_t, hidden_states)
loss += criterion(y_pred, y_t)
# 3. [BPTT] "通过时间反向传播"
loss.backward()
optimizer.step()
if (epoch+1) % 40 == 0:
print(f"Epoch [{epoch+1}/{epochs}], Loss: {loss.item():.4f}")
print("--- [训练完成] ---")
# --- 3. [测试] (Testing) 函数 ---
def test_model(model, X_test, data_in_raw, data_out_raw):
"""
完整的测试 (推理) 流程
"""
print(f"\n--- [测试] (在 {data_in_raw} 上测试) ---")
seq_len = X_test.shape[0]
model.eval()
with torch.no_grad():
# [关键] 重置“两个” (TWO) 记忆 (h_0, C_0)
hidden_states = model.init_hidden()
predictions = []
# 再次“手动”遍历序列
for t in range(seq_len):
x_t = X_test[t]
y_pred, hidden_states = model(x_t, hidden_states)
pred_binary = 1 if y_pred.item() > 0.5 else 0
predictions.append(pred_binary)
print(f"输入序列 (Input): {data_in_raw}")
print(f"目标序列 (Target): {data_out_raw}")
print(f"模型预测 (Predicted): {predictions}")
correct = sum(1 for p, t in zip(predictions, data_out_raw) if p == t)
accuracy = (correct / len(data_out_raw)) * 100
print(f"\n准确率 (Accuracy): {accuracy:.2f}%")
print("[结论]:")
print("LSTM 结构 (h_t, C_t) 成功地“学会”了“滞后 2 步”的记忆任务。")
def run():
# --- (a) 准备“数据” (Data) ---
# (我们使用与 RNN demo "完全相同" (identical) 的任务)
# (任务: y_t = x_{t-2}, 即“回声” (Echo) 游戏)
seq_len = 10
lag = 2
data_in = np.random.randint(0, 2, size=(seq_len,))
data_out = np.zeros(seq_len)
for t in range(lag, seq_len):
data_out[t] = data_in[t - lag] # (e.g., y[2]=x[0], y[3]=x[1], ...)
X_train_tensor = torch.tensor(data_in).float().view(seq_len, 1, 1)
Y_train_tensor = torch.tensor(data_out).float().view(seq_len, 1, 1)
print(f"--- [数据] (y_t = x_{t-2} 任务) ---")
print(f"输入 (X): {data_in}")
print(f"目标 (Y): {data_out.astype(int)}")
# --- (b) 准备“训练设置” (Training Setup) ---
input_size = 1
hidden_size = 10 # (记忆的“大小”)
output_size = 1
learning_rate = 0.01
epochs = 200
model = SimpleLSTM(input_size, hidden_size, output_size)
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
print(f"\n--- [模型结构] (我们“手动”搭建的) ---")
print(model)
# --- (c) 执行“训练” ---
train_model(model, criterion, optimizer, X_train_tensor, Y_train_tensor, epochs)
# --- (d) 执行“测试” ---
test_model(model, X_train_tensor, data_in, data_out.astype(int))
run()
3. Transformer 结构
我们将使用 PyTorch 的 nn.TransformerEncoder 来组装一个 Transformer。
奇偶累积求和:
- 输入: [1, 2, 3, 4, 5]
- 下标(从0开始)奇偶性: [偶, 奇, 偶, 奇, 偶]
- 输出: [1, 2, 1+3, 2+4, 1+3+5] -> [1.0, 2.0, 4.0, 6.0, 9.0]
这个任务强迫模型在 (偶) 时,必须直接回顾 (偶) 和 (偶) 的信息,这凸显了自注意力的长程依赖能力。
模型必须学会奇偶性这个抽象规则。这证明了它必须在内部理解它的位置编码。
# --- [ 3. Transformer (注意力) 完整结构 (Full PyTorch) + 完整训练 (Full Training) 演示 ] ---
import torch # 张量计算与自动求导
import torch.nn as nn # 神经网络构建
import torch.optim as optim # 模型优化
from torch.utils.data import DataLoader, TensorDataset # 数据加载,使用 DataLoader 自动“批量” (batching) 和“打乱” (shuffling)数据
import math # 数学计算
import time # 时间统计
# 固定PyTorch中所有随机操作的 “初始种子”(可以用其他数字),从而保证实验结果可复现(每次运行代码,随机生成的数值都完全一致)
torch.manual_seed(42)
# GPU (如果可用)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"--- [设备] (Device) ---")
print(f"Using device: {device}")
# --- 1. [数据] (Data) 生成 ---
def generate_data(num_samples, seq_len, max_val):
"""
根据“奇偶性” (parity) “求和” (sum) 任务生成数据。
(关键): 我们的“输入特征” (Input Feature) X 必须是“二维” (2D) 的:
[特征 1]: 原始“值” (value)
[特征 2]: 下标“奇偶性” (parity) (0=偶, 1=奇)
输入是一个长度为 seq_len 的数值序列(每个元素是 1~max_val的整数)
对序列中每个位置 j(从 0 开始),计算从序列开头(位置 0)到当前位置 j,所有与 j 下标奇偶性相同的元素之和
num_samples: 生成的样本数量(即数据集的总条数)
"""
# X_vals: 原始数值 (B, S), 样本数和序列长度,取值范围 [1, max_val+1)
X_vals = torch.randint(1, max_val + 1, (num_samples, seq_len))
# X_features: (B, S, 2) - 特征数为2 [value, parity], 初始化全0
X_features = torch.zeros(num_samples, seq_len, 2, dtype=torch.float32)
# Y_target: (B, S) - 每条样本对应一个长度为 seq_len 的一维张量,存储每个位置的目标和
Y_target = torch.zeros(num_samples, seq_len, dtype=torch.float32)
for i in range(num_samples):
for j in range(seq_len):
current_val = X_vals[i, j] # 当前位置的原始数值(如样本i、位置j的数值)
current_parity = j % 2 # 当前位置下标的奇偶性(0=偶数下标,1=奇数下标)
# (a) 填充 X_features
X_features[i, j, 0] = current_val # 第0个特征:原始数值
X_features[i, j, 1] = current_parity # 第1个特征:下标奇偶性
# (b) 计算 Y_target
current_sum = 0
for k in range(j + 1): # 遍历从序列开头(k=0)到当前位置(k=j)的所有元素
if k % 2 == current_parity: # 检查奇偶性是否相同
current_sum += X_vals[i, k] # 只累加“下标奇偶性和当前位置j相同”的元素
Y_target[i, j] = current_sum # 将计算好的和存入标签张量
# X_features 是模型的输入, Y_target 是模型的标签
return X_features, Y_target
# --- 2. [结构] (Architecture) 定义 ---
class PositionalEncoding(nn.Module):
"""
“位置编码” (Positional Encoding)
Transformer 的“自注意力” (Self-Attention) 机制“本身” (itself) ,并不关心元素的“顺序” (order)。
我们“手动” (manually) 注入一个“唯一” (unique) 的 “位置信号” (positional signal) (sin/cos) 到输入中,告诉模型 "元素 X 在位置 3"。
偶数维度(0、2、4...):PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
奇数维度(1、3、5...):PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
pos:序列中的位置索引(0、1、2...seq_len-1);
i:位置向量的维度索引(0、1、2...d_model/2-1);
d_model:模型隐藏层维度(输入向量的长度),常用512。
"""
def __init__(self, d_model, dropout=0.1, max_len=5000): # max_len=5000 预先生成的最大序列长度(防止序列过长)
super(PositionalEncoding, self).__init__() # 继承 nn.Module 的初始化
self.dropout = nn.Dropout(p=dropout) # Dropout层,防止过拟合(可选),0.1意味着随意丢弃10%的数据
# 初始化 pe 为一个全 0 张量,形状为「最大序列长度 × 模型维度」,后续会填充每个位置的位置向量
pe = torch.zeros(max_len, d_model)
# 生成位置索引:position → [max_len, 1]
# torch.arange(0, max_len):生成 0~max_len-1 的整数序列(代表每个位置),形状 [max_len]
# unsqueeze(1):在第 1 维(列维度)增加一个维度,变成 [max_len, 1](方便后续与维度向量做广播运算)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
# 生成频率衰减因子:div_term → [d_model//2],sin 和 cos 函数分别对应 d_model 个维度中的一半(各占 d_model//2 个维度);
# 因此需要生成一个长度为 d_model//2 的频率因子 div_term,才能和 sin/cos 函数一一对应,完成所有维度的位置向量计算。
# 这行是对原公式 10000^(2i/d_model) 的 “指数变换”(避免直接计算大数,更稳定)
# 原公式分母 10000^(2i/d_model) = exp( (2i/d_model) × log(10000) )
# 取负后变成 exp( - (2i/d_model) × log(10000) ),即代码中的计算逻辑
# torch.arange(0, d_model, 2):生成 0、2、4... 偶数维度索引,长度为 d_model//2;
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
# 填充 pe 的偶数维度(sin)和奇数维度(cos), 为每个位置 pos 生成了唯一的 d_model 维位置向量
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
# 调整 pe 的形状:[max_len, 1, d_model](适配模型输入格式)
# 原始 pe 形状 [max_len, d_model];
# unsqueeze(0):增加 “批量维度”,变成 [1, max_len, d_model];
# transpose(0, 1):交换第 0 维和第 1 维,最终变成 [max_len, 1, d_model];
# 为什么要这个形状?后续模型输入 x 的形状是 [seq_len, batch_size, d_model],pe 要通过广播与 x 相加(1 维适配 batch_size 维度)。
pe = pe.unsqueeze(0).transpose(0, 1)
# 注册缓冲区:将 pe 存入模型,不参与梯度更新(固定的位置编码)
self.register_buffer('pe', pe)
def forward(self, x):
# x 形状: [seq_len, batch_size, d_model] → Transformer 标准输入格式(序列长×批量×维度)
x = x + self.pe[:x.size(0), :] # 输入向量 + 位置向量(广播相加), 每个样本的每个位置都叠加了对应的位置向量
return self.dropout(x) # 通过 Dropout 层随机丢弃部分元素,防止模型过度依赖位置编码导致过拟合
class SummingTransformer(nn.Module):
"""
“核心” (Core) Transformer 结构
这是一个“仅编码器” (Encoder-Only) 的 Transformer
4 个核心组件,按 “输入特征投影→位置编码→Transformer 编码→输出” 的流程组织
input_features 输入特征维度(这里固定为 2:value + parity) 2(对应之前的 X_features)
d_model Transformer 模型的核心维度(隐藏层 / 嵌入层维度) 512(Transformer 常用值)
nhead 多头自注意力的 “头数”(拆分特征的并行注意力头) 8(512÷8=64,每个头 64 维)
num_encoder_layers Transformer 编码器的堆叠层数(深度) 6(原论文标准配置)
dim_feedforward 编码器前馈网络的隐藏层维度(通常是 d_model 的 4 倍) 2048(512×4)
dropout Dropout 概率(防止过拟合) 0.1(默认值)
"""
def __init__(self, input_features, d_model, nhead, num_encoder_layers, dim_feedforward, dropout=0.1):
super(SummingTransformer, self).__init__()
self.d_model = d_model
# --- 1. 输入投影层 (Input Projection) ---
# 将我们“2维” (2D) 的“特征” (feature) [value, parity]
# “投影” (project) 到“高维” (high-dimension) (d_model) 的“嵌入空间” (embedding space)
self.input_projection = nn.Linear(input_features, d_model)
# --- 2. 位置编码 (Positional Encoding) ---
# 输入经过投影后,会先叠加位置编码,再传入 Transformer 编码器。
self.pos_encoder = PositionalEncoding(d_model, dropout)
# --- 3. 核心: Transformer 编码器 --- 由 nn.TransformerEncoderLayer(单层编码器)和 nn.TransformerEncoder(多层堆叠)组成
# 单层编码器的内部结构(PyTorch 封装,无需手动实现):
# 多头自注意力(Multi-Head Self-Attention):并行捕捉序列中不同位置的依赖关系;
# 残差连接 + 层归一化(Residual Connection + Layer Norm):缓解梯度消失,稳定训练;
# 前馈网络(Feed-Forward Network):对每个位置的特征独立做非线性变换(提升表达能力);
# 残差连接 + 层归一化:再次归一化,增强稳定性。
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model,
nhead=nhead,
dim_feedforward=dim_feedforward,
dropout=dropout,
batch_first=False # (我们使用 PyTorch 默认的 [SeqLen, Batch, Feature])
)
# 我们将“num_encoder_layers” (例如 6) 个这样的“层” (layer) “堆叠” (stack) 起来
# 多层堆叠的意义:浅层捕捉局部依赖,深层捕捉全局 / 长距离依赖,提升模型学习复杂规律的能力。
self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_encoder_layers)
# --- 4. 输出层 (Output Layer) ---
# 将“高维” (d_model) 的“输出” (output) “投影回” (project back)
# “1维” (1D) (我们想要的“和” (sum))
self.output_layer = nn.Linear(d_model, 1)
def forward(self, src, src_mask):
"""
此函数执行“整个序列” (ENTIRE SEQUENCE) 的“前向” (forward) 传播
!!! 核心对比 (Core Contrast) !!!
- LSTM/RNN 的 forward:
- 输入: x_t (当前“一步” (step))
- Transformer 的 forward:
- 输入: src (“完整” (full) 序列)
参数:
src (tensor):
"完整的" (entire) 输入序列
形状: [SeqLen, Batch, Input_Features]
(例如 [20, 64, 2])
src_mask (tensor):
"因果掩码" (Causal Mask / Look-ahead mask)
形状: [SeqLen, SeqLen]
"""
# 1. (S, B, 2) -> (S, B, d_model) 投影:通过 input_projection 将 2 维输入特征映射到 d_model 维;
# 缩放(关键细节):乘以 math.sqrt(d_model) 是为了 “平衡输入特征的方差”—— 线性投影后特征的方差会随 d_model 增大而增大,
# 缩放后可让后续层的输入更稳定(Transformer 原论文的标准操作)。
src = self.input_projection(src) * math.sqrt(self.d_model)
# 2. (S, B, d_model) -> (S, B, d_model) (添加“位置” (position) 信息)
src = self.pos_encoder(src)
# 3. [核心] (S, B, d_model) -> (S, B, d_model)
# (PyTorch 在“内部” (internally) “一次性” (at once)
# 处理所有“层” (layers) 和“所有” (all) “时间步” (timesteps))
# 因果掩码(src_mask)的关键作用:
# 求和任务中,每个位置 j 的预测只能依赖 “0~j” 的历史元素(不能依赖 j+1 及以后的 “未来元素”);
# 因果掩码是一个 [SeqLen, SeqLen] 的布尔矩阵,下三角(包括对角线)为 False(允许访问),上三角为 True(禁止访问),确保模型看不到未来信息
output = self.transformer_encoder(src, mask=src_mask)
# 4. (S, B, d_model) -> (S, B, 1) (投影回“1维” (1D) 预测)
output = self.output_layer(output)
return output
def _generate_square_subsequent_mask(self, sz):
"""
(辅助函数): 生成“因果掩码” (Causal Mask)
这是一个“回归” (regression) 任务,我们要求“位置 i” (position i)
的“输出” (output) “只能” (only) 依赖“位置 0...i” (positions 0...i)
生成一个上三角掩码矩阵,将「未来位置」的信息遮蔽(设为负无穷),只保留「当前及过去位置」的信息(设为 0)。
"""
# 1. torch.ones(sz, sz):生成一个 sz×sz 的全 1 矩阵(数据类型为 float)
# 2. torch.triu(...):取矩阵的「上三角部分」(包括对角线),下三角部分置为 0
# 3. == 1:将矩阵转为布尔类型(True 表示 1,False 表示 0),方便后续筛选
# 4. .transpose(0, 1):矩阵转置(行和列交换), 目的是调整掩码的「遮挡逻辑」以匹配 Transformer 的注意力计算
mask = (torch.triu(torch.ones(sz, sz)) == 1).transpose(0, 1)
# 1. mask.float():将布尔矩阵转为 float 类型(True→1.0,False→0.0)
# 2. masked_fill(mask == 0, float('-inf')):将「禁止关注」的位置(0.0)设为负无穷(-inf,特殊定义的符号)
# 原因:Transformer 的注意力计算会先计算相似度矩阵,再加上掩码,最后做 softmax。softmax 中,-inf 会被映射为 0(完全不关注)
# 3. masked_fill(mask == 1, float(0.0)):将「允许关注」的位置(1.0)设为 0.0
# 原因:0 对相似度矩阵的值无影响(加 0 不改变原相似度),softmax 会正常计算这些位置的注意力权重
mask = mask.float().masked_fill(mask == 0, float('-inf')).masked_fill(mask == 1, float(0.0))
return mask
# --- 3. [训练] (Training) 函数 ---
# train_loader 训练数据加载器(DataLoader 实例),输出格式为 (src, tgt)(输入序列 + 目标序列)
# Transformer有并行性:一次性处理整个序列的所有时间步,而非 LSTM 那样逐时间步循环
def train_model(model, criterion, optimizer, train_loader, epochs, seq_len):
"""
完整的训练流程
"""
print(f"\n--- [训练开始] (共 {epochs} 轮) ---")
model.train() # (设置为“训练模式” (training mode), 启用 Dropout)
start_time = time.time() # 计算耗时用
for epoch in range(epochs):
epoch_loss = 0
# ---
# !!! 核心对比 (Core Contrast) !!!
# - LSTM/RNN:
# - 在“外部” (outside) 重置 hidden_state
# - 在“内部” (inside) 手动“循环” (loop) "for t in range(seq_len)"
# - Transformer:
# - 我们“循环” (loop) “批次” (Batches)
# - 模型“自己” (itself) “一次性” (at once) 处理“整个序列” (seq_len)
# ---
# batch_idx:批次索引,用于跟踪当前处理的是第几个批次
for batch_idx, (src, tgt) in enumerate(train_loader):
# 1. [准备数据] (Data Prep)
# (DataLoader 输出 [Batch, Seq, Feat], 即 [64, 20, 2])
# (a) 调整“形状” (shape) 以匹配 Transformer
# (B, S, F) -> (S, B, F) (例如 [20, 64, 2])
# Transformer 要求输入形状为 (Seq_len, Batch_size, Feat_dim)(时间步在前,批次在后),原因是:
# Transformer 并行处理所有时间步,按 “时间步维度” 组织数据更便于注意力计算;
# LSTM/RNN 通常要求 (Batch_size, Seq_len, Feat_dim)(批次在前),因为是逐时间步循环处理。
src = src.permute(1, 0, 2).float().to(device)
# (B, S) -> (S, B, 1) (例如 [20, 64, 1])
# unsqueeze(-1):在最后增加 “特征维度”([20,64] → [20,64,1]),使目标序列形状与模型输出([S,B,1])一致,方便计算损失
tgt = tgt.permute(1, 0).unsqueeze(-1).float().to(device)
# (b) 为“当前序列” (current sequence) 创建“掩码” (mask)
# (形状: [20, 20])
src_mask = model._generate_square_subsequent_mask(seq_len).to(device)
# 2. [核心] (Core) 向前传播
# (“一次性” (at once) 获得“整个序列” (entire sequence) 的“输出” (output))
output = model(src, src_mask) # 形状: (S, B, 1)
# 3. [计算损失] (Compute Loss)
# (“一次性” (at once) 比较“所有” (all) “时间步” (timesteps) 的“损失” (loss))
# 由于 output 和 tgt 形状完全一致([S,B,1]),损失函数会 一次性计算所有时间步、所有样本的损失平均值;
# 示例:如果用 MSELoss,会计算每个 (output[s,b,0], tgt[s,b,0]) 的平方误差,再求全局平均;
# 对比 LSTM:需要在循环中累计每个时间步的损失,或最后拼接所有时间步结果再计算损失,效率更低。
loss = criterion(output, tgt)
# 4. [反向传播] (Backpropagation)
optimizer.zero_grad() # 清空梯度,否则梯度会跨批次累积,导致参数更新异常;
loss.backward() # PyTorch 自动求导,计算损失对所有可训练参数的梯度
optimizer.step() # 根据梯度和优化器规则(如学习率)更新参数
# loss.item():将 PyTorch 张量类型的损失值转为 Python 浮点数(避免张量占用过多内存)#
epoch_loss += loss.item()
avg_loss = epoch_loss / len(train_loader)
if (epoch+1) % 5 == 0: # (每 5 轮打印一次)
print(f"Epoch [{epoch+1}/{epochs}], Loss: {avg_loss:.6f}")
end_time = time.time()
print(f"--- [训练完成] (耗时 {end_time - start_time:.2f}s) ---")
# --- 4. [测试] (Testing) 函数 ---
# test_name 测试任务名称(比如 “简单序列预测测试”),仅用于打印日志区分测试场景
# 输出「输入序列、预期目标、模型预测值、测试损失」,直观展示模型推理效果
def test_model(model, criterion, test_name, test_input_list, expected_output_list):
"""
完整的测试 (推理) 流程
"""
print(f"\n--- [测试] ({test_name}) ---")
model.eval() # (设置为“评估模式” (eval mode), 关闭 Dropout)
test_seq_len = len(test_input_list)
with torch.no_grad(): # (关闭“自动求导” (autograd) 以节省“内存” (memory))
# 1. [准备数据] (Data Prep)
# (我们必须“手动” (manually) 构建“一个” (one) “批次” (batch)
# 并且包含“两个” (two) “特征” (features))
test_vals = torch.tensor(test_input_list, dtype=torch.float)
test_parity = torch.tensor([i % 2 for i in range(test_seq_len)], dtype=torch.float)
# stack 按 dim=1 拼接:每个时间步对应 [核心输入, 奇偶性]
# (S, F) -> (S, 1, F) (S=len, B=1, F=2)
test_feat = torch.stack([test_vals, test_parity], dim=1)
test_src = test_feat.unsqueeze(1).to(device)
# (S)
expected_output = torch.tensor(expected_output_list, dtype=torch.float)
# 2. [准备掩码] (Mask Prep)
# 调用和训练时相同的掩码函数,生成形状为 (test_seq_len, test_seq_len) 的因果掩码
# 必须移到与 test_src 相同的设备(GPU/CPU),避免设备不匹配错误
test_mask = model._generate_square_subsequent_mask(test_seq_len).to(device)
# 3. [核心] (Core) 推理
# (!!! 核心对比 (Core Contrast) !!!)
# (再次“一次性” (at once) “完成” (complete) “所有” (all) “预测” (predictions))
prediction = model(test_src, test_mask) # 形状: (S, 1, 1)
# 4. [格式化输出] (Format Output)
# (S, 1, 1) -> (S)
# squeeze():去除维度为 1 的维度(批次维度 B=1、目标特征维度 1),将形状从 (4,1,1) 简化为 (4,),和 expected_output 形状一致
# .cpu():将张量从 GPU 移到 CPU(如果之前用 GPU 训练),因为 Python 打印列表时更兼容 CPU 张量
prediction_squeezed = prediction.squeeze().cpu()
print(f"输入 (Input): {test_input_list}")
print(f"目标 (Target): {expected_output.tolist()}")
# (使用 torch.round 修复 TypeError)
print(f"模型预测 (Predicted): {torch.round(prediction_squeezed, decimals=2).tolist()}")
# 5. [评估] (Evaluate)
# test_loss.item():将张量类型的损失转为 Python 浮点数,便于打印
test_loss = criterion(prediction_squeezed, expected_output)
print(f"\nTest MSE Loss: {test_loss.item():.6f}")
# --- 5. [执行] (Run) 主函数 ---
def run():
# --- (a) 准备“超参数” (Hyperparameters) --- 超参数是 “模型训练前手动设置的配置”,而非模型训练中学习的参数,这里按用途分类解释
# (数据参数)
SEQ_LEN = 20 # (序列“长度” (length))
MAX_VAL = 10 # (序列中数字的“最大值” (max value))
NUM_SAMPLES = 5000 # (训练“样本数” (samples))
BATCH_SIZE = 64 # 适配 GPU 并行能力
# (训练参数)
# EPOCHS=40 足够模型收敛,LR=0.0005 避免训练震荡
EPOCHS = 40 # (训练“轮数” (epochs) -> 增加轮数以拟合“求和” (sum) 任务)
LR = 0.0005 # (学习率 (Learning Rate))
# (模型参数 -> 增加“容量” (capacity) 以学习“求和” (sum) 而非“平均” (average))
# 因为 “求和” 是比 “平均” 更复杂的时序依赖任务(需要累计所有过去的信息),所以需要更大的 D_MODEL、更多的 NUM_LAYERS 来提升模型拟合能力
INPUT_FEATURES = 2 # (“值” (value), “奇偶性” (parity))
D_MODEL = 128 # (嵌入“维度” (dimension))
NHEAD = 8 # (注意力“头数” (heads))
NUM_LAYERS = 6 # (编码器“层数” (layers))
DIM_FEEDFORWARD = 512 # (前馈网络“维度” (dimension))
DROPOUT = 0.1
# --- (b) 准备“数据” (Data) ---
print("\n--- [1. 数据] (Data) ---")
print("Generating data...")
X_train_feat, Y_train = generate_data(NUM_SAMPLES, SEQ_LEN, MAX_VAL)
# (使用 TensorDataset 封装“特征” (X) 和“标签” (Y))
train_dataset = TensorDataset(X_train_feat, Y_train)
# (使用 DataLoader 自动“批量” (batching) 和“打乱” (shuffling))
train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, shuffle=True)
print("Data generation complete.")
# --- (c) 准备“训练设置” (Training Setup) ---
print("\n--- [2. 模型结构] (Model Architecture) ---")
model = SummingTransformer(
input_features=INPUT_FEATURES,
d_model=D_MODEL,
nhead=NHEAD,
num_encoder_layers=NUM_LAYERS,
dim_feedforward=DIM_FEEDFORWARD,
dropout=DROPOUT
).to(device)
# (这是一个“回归” (regression) 问题,所以使用 MSE 损失)
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=LR)
print(model)
# 参数量计算:p.numel() 统计单个参数张量的元素个数,requires_grad=True 筛选可训练参数(排除不可训练的层,如固定的嵌入层)
total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"模型总参数量 (Total Params): {total_params:,}")
# --- (d) 执行“训练” (Execute Training) ---
train_model(model, criterion, optimizer, train_loader, EPOCHS, SEQ_LEN)
# --- (e) 执行“测试” (Execute Testing) ---
# (测试用例 1: 您的示例)
test_model(
model, criterion,
test_name="教学示例 (1,2,3,4,5)",
test_input_list=[1, 2, 3, 4, 5],
expected_output_list=[1.0, 2.0, 4.0, 6.0, 9.0]
)
# (测试用例 2: 更长的序列)
test_model(
model, criterion,
test_name="较长序列 (1,5,2,1,5,1,3)",
test_input_list=[1, 5, 2, 1, 5, 1, 3],
expected_output_list=[1.0, 5.0, 3.0, 6.0, 8.0, 7.0, 11.0]
)
# --- [执行] ---
run()
--- [设备] (Device) ---
Using device: cuda
--- [1. 数据] (Data) ---
Generating data...
Data generation complete.
--- [2. 模型结构] (Model Architecture) ---
/home/yhs_joker/anaconda3/lib/python3.13/site-packages/torch/nn/modules/transformer.py:392: UserWarning: enable_nested_tensor is True, but self.use_nested_tensor is False because encoder_layer.self_attn.batch_first was not True(use batch_first for better inference performance)
warnings.warn(
SummingTransformer(
(input_projection): Linear(in_features=2, out_features=128, bias=True)
(pos_encoder): PositionalEncoding(
(dropout): Dropout(p=0.1, inplace=False)
)
(transformer_encoder): TransformerEncoder(
(layers): ModuleList(
(0-5): 6 x TransformerEncoderLayer(
(self_attn): MultiheadAttention(
(out_proj): NonDynamicallyQuantizableLinear(in_features=128, out_features=128, bias=True)
)
(linear1): Linear(in_features=128, out_features=512, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
(linear2): Linear(in_features=512, out_features=128, bias=True)
(norm1): LayerNorm((128,), eps=1e-05, elementwise_affine=True)
(norm2): LayerNorm((128,), eps=1e-05, elementwise_affine=True)
(dropout1): Dropout(p=0.1, inplace=False)
(dropout2): Dropout(p=0.1, inplace=False)
)
)
)
(output_layer): Linear(in_features=128, out_features=1, bias=True)
)
模型总参数量 (Total Params): 1,190,145
--- [训练开始] (共 40 轮) ---
Epoch [5/40], Loss: 291.882940
Epoch [10/40], Loss: 88.102475
Epoch [15/40], Loss: 45.549099
Epoch [20/40], Loss: 18.730293
Epoch [25/40], Loss: 10.449820
Epoch [30/40], Loss: 8.033350
Epoch [35/40], Loss: 6.880971
Epoch [40/40], Loss: 6.142219
--- [训练完成] (耗时 48.58s) ---
--- [测试] (教学示例 (1,2,3,4,5)) ---
输入 (Input): [1, 2, 3, 4, 5]
目标 (Target): [1.0, 2.0, 4.0, 6.0, 9.0]
模型预测 (Predicted): [1.0299999713897705, 1.1200000047683716, 3.6500000953674316, 5.239999771118164, 8.40999984741211]
Test MSE Loss: 0.364742
--- [测试] (较长序列 (1,5,2,1,5,1,3)) ---
输入 (Input): [1, 5, 2, 1, 5, 1, 3]
目标 (Target): [1.0, 5.0, 3.0, 6.0, 8.0, 7.0, 11.0]
模型预测 (Predicted): [1.0299999713897705, 4.380000114440918, 2.7899999618530273, 4.900000095367432, 7.510000228881836, 6.110000133514404, 9.0600004196167]
Test MSE Loss: 0.922968
MSE(均方误差)的公式是:MSE = 平均( (预测值 - 目标值)² ),核心特点是: 对 “大偏差” 更敏感(平方放大效应); 数值大小和 “目标值的量级” 强相关(比如目标值是 10 左右,MSE=1 意味着平均偏差约 1,是可接受的;但目标值是 0.1 左右,MSE=1 就是灾难级偏差)。 结合你的测试用例,目标值的量级在 1~11 之间,属于 “中等量级”,因此 MSE 的数值可以直接对应 “平均偏差”: MSE=0.36 → 平均偏差≈√0.36=0.6(每个预测值和目标值的平均差距约 0.6); MSE=0.92 → 平均偏差≈√0.92≈0.96(每个预测值和目标值的平均差距约 1)。
这两个 MSE Loss 对应的效果是 “合格且有效”:模型学到了核心规律,预测值无逻辑错误,短序列精度较高,长序列虽有偏差但可接受。如果是教学或初步验证,效果足够; 如果是实际应用,可通过简单优化进一步提升精度。