2026.10.9 09:51
学习路线
rust
Python -> Python数据分析 -> LangChain -> LangGraph -> FastAPI + SQLAIchemy -> 机器学习 -> 深度学习 -> NLP -> Coze -> Dify -> 大模型应用基础 -> 大模型微调 -> 多模态 -> vibeCoding -> Hermes -> Docker
复习巩固总结
1. RNN
| 张量 | 形状 | 各维含义 |
|---|---|---|
| 输入 (x) | [5, 32, 128],即 [seq_len, batch, input_size] |
句子长度 5,一批 32 条,每个词的向量 128 维 |
| 初始隐藏状态 (h_0) | [1, 32, 256],即 [num_layers, batch, hidden_size] |
隐藏层 1 层,一批 32 条,隐藏状态 256 维。对应前 14 集的概要 |
| 最终隐藏状态 (h_n) | [1, 32, 256],和 (h_0) 相同 |
当前时刻的隐藏状态,前 15 集的概要 |
输出 output |
[5, 32, 256],即 [seq_len, batch, hidden_size] |
句子长度 5,一批 32 条,每个时间步的输出向量 256 维 |
| 结构 | 内部有什么 |
|---|---|
| 传统 RNN | 输入层,隐藏层(词嵌入层、循环网络层),输出层 |
| LSTM | 遗忘门、输入门、细胞状态、输出门 |
| Bi-LSTM | 双向 LSTM。句子从前到后走一次,从后到前再走一次,两次结果合并 |
| GRU | 重置门、更新门 |
| Bi-GRU | 双向 GRU,同样是两个方向各走一遍再合并 |
1.1 基础版RNN
python
import torch
import torch.nn as nn
# todo 1. 定义函数, 演示RNN的基础版代码.
def dm_rnn_for_base():
rnn = nn.RNN(5, 6, 1)
input = torch.randn(1, 3, 5)
h0 = torch.randn(1, 3, 6)
output, hn = rnn(input, h0)
print(f'output: {output}, output.shape: {output.shape}') # shape: (1, 3, 6)
print(f'hidden: {hn}, hidden.shape: {hn.shape}') # shape: (1, 3, 6)
print(f'rnn模型: {rnn}') # RNN(5, 6)
# todo 2. 定义函数, 修改RNN模型(句子)的长度
def dm_rnn_for_sequence_len():
rnn = nn.RNN(5, 6, 1)
input = torch.randn(20, 3, 5)
h0 = torch.randn(1, 3, 6)
output, hn = rnn(input, h0)
print(f'output: {output}, output.shape: {output.shape}') # shape: (20, 3, 6)
print(f'hidden: {hn}, hidden.shape: {hn.shape}') # shape: (1, 3, 6)
print(f'rnn模型: {rnn}') # RNN(5, 6)
# todo 3. 定义函数, 修改RNN模型 隐藏层层数
def dm_rnn_for_hidden_layers():
rnn = nn.RNN(5, 6, 2)
input = torch.randn(1, 3, 5)
h0 = torch.randn(2, 3, 6)
output, hn = rnn(input, h0)
print(f'output: {output}, output.shape: {output.shape}') # shape: (1, 3, 6)
print(f'hidden: {hn}, hidden.shape: {hn.shape}') # shape: (2, 3, 6)
print(f'rnn模型: {rnn}') # RNN(5, 6, num_layers=2)
1.2 LSTM
1.2.1 基础版
LSTM 全称 Long Short Term Memory,长短时记忆结构。它是 RNN 的一种变体,用来抓住较长序列里前后词的语义关系,缓解梯度消失和梯度爆炸,但这种现象仍然可能发生。序列更长时,它的表现优于传统 RNN,但同等算力下,训练效率比传统 RNN 低很多。
遗忘门、输入门、输出门的激活函数是 sigmoid,把数变到 0 和 1 之间。接近 1 表示通过,接近 0 表示挡住。候选记忆和最终读出记忆时用 tanh,把数压到 -1 和 1 之间。下面公式里的 (*) 都是按元素相乘。
| 符号 | 谁 | 公式 | 中文 | 激活 | 作用 |
|---|---|---|---|---|---|
| (f_t) | 遗忘门 | (\sigma(W_f h_{t-1}, x_t + b_f)) | 遗忘门门值 = 激活函数(W @ 上一隐藏状态, 当前输入 + 偏置) | sigmoid | 筛选旧记忆,丢掉不要的。乘上 (C_{t-1}),决定旧记忆留多少,用来更新长期记忆本 |
| (i_t) | 输入门 | (\sigma(W_i h_{t-1}, x_t + b_i)) | 输入门门值 = 激活函数(W @ 上一隐藏状态, 当前输入 + 偏置) | sigmoid | 筛选新信息,留下值得记的。乘上 (\tilde{C}_t),决定新信息写多少,加进长期记忆本 |
| (\tilde{C}_t) | 候选记忆 | (\tanh(W_c h_{t-1}, x_t + b_c)) | 候选记忆 = tanh(W @ 上一隐藏状态, 当前输入 + 偏置) | tanh | 这一步准备好的新内容 |
| (C_t) | 记忆细胞 | (f_t * C_{t-1} + i_t * \tilde{C}_t) | 新的细胞状态 = 遗忘门门值 * 旧记忆细胞状态 + 输入门门值 * 候选记忆 | 加法更新 | 长期保存关键信息,记在小本本上,传给下一步 |
| (o_t) | 输出门 | (\sigma(W_o h_{t-1}, x_t + b_o)) | 输出门门值 = 激活函数(W @ 上一隐藏状态, 当前输入 + 偏置) | sigmoid | 筛选这一步要传出去的内容。乘上 (\tanh(C_t)),决定说出多少 |
| (h_t) | 隐藏输出 | (o_t * \tanh(C_t)) | 本次隐藏状态 = 输出门门值 * tanh 之后的新记忆细胞状态 | tanh 后再乘 | 当前隐藏输出,交给下一个时间步 |
1.2.2 Bi-LSTM
双向 LSTM 让每个词同时用上左边和右边的上下文。每个方向用的仍是原来的 LSTM 单元,遗忘门、输入门、记忆细胞和输出门都不改。变化只在外面:同一句话走两遍,再把两个方向的隐藏状态拼在一起。
正向和反向各有一套参数,参数量和计算量都大约变成单向 LSTM 的两倍。
以「我爱中国」为例。
从左往右走一遍,顺序是「我 → 爱 → 中国」。正向 LSTM(LSTM_L)依次得到 (h_{L0})、(h_{L1})、(h_{L2})。走到「爱」时,它已经看过左边的「我」。
从右往左再走一遍,顺序是「中国 → 爱 → 我」。反向 LSTM(LSTM_R)依次得到 (h_{R0})、(h_{R1})、(h_{R2})。走到「爱」时,它已经看过右边的「中国」。
| 词 | 从左往右 | 从右往左 | 最终输出 |
|---|---|---|---|
| 我 | (h_{L0}) | (h_{R2}) | (h_0):(h_{L0}) 与 (h_{R2}) 拼接 |
| 爱 | (h_{L1}) | (h_{R1}) | (h_1):(h_{L1}) 与 (h_{R1}) 拼接 |
| 中国 | (h_{L2}) | (h_{R0}) | (h_2):(h_{L2}) 与 (h_{R0}) 拼接 |
1.2.3 代码示例
python
"""
案例:
演示LSTM入门代码
LSTM简介:
概述:
Long Short Term Memory, 长短时记忆结构, 能够解决传统RNN的弊端 → 处理长序列数据效果差的问题.
组成:
遗忘门, 输入门, 细胞状态, 输出门
优点:
1. 能够解决长时依赖.
2. 缓解梯度消失, 爆炸的问题 → 训练更稳定.
3. 能够抓取复杂的特征.
缺点:
1. 计算慢, 消耗资源大.
2. 调参相对较难.
3. 可解释性稍差.
"""
# 导包
import torch
import torch.nn as nn
# todo 1. 定义函数, 演示LSTM模型的用法.
def lstm_demo():
# 1. 创建LSTM模型对象.
# 参1: 词向量维度(输入的维度), 参2: 隐藏层的维度(输出的维度), 参3: LSTM的层数. 参4: 是否双向.
lstm = nn.LSTM(input_size=5, hidden_size=6, num_layers=1, bidirectional=False)
# 2. 构建输入张量.
# 参1: 句子的长度(seq_len), 参2: 批次大小(batch_size), 参3: 词向量维度(input_size).
input = torch.randn(4, 3, 5)
# 3. 初始化隐藏层 和 细胞状态.
# 参1: LSTM层数(隐藏层层数), 参2: 批次大小(batch_size), 参3: 隐藏层的维度(hidden_size).
h0 = torch.randn(1, 3, 6)
c0 = torch.randn(1, 3, 6)
# 4. 模型计算.
# 写法1: 手动传入 h0 和 c0
# 实参列表: 参1(input) → 输入张量, 参2 → 隐藏状态 和 细胞状态的元组形式.
# 返回值: output → 本次的输出结果, (hn,cn) → 最后1个时间步的隐藏状态和细胞状态.
output, (hn, cn) = lstm(input, (h0, c0))
# 写法2: 不传入 h0和c0 → 底层会自动创建1个 全0的 h0(上一时刻隐藏状态) 和 c0(上一时刻细胞状态)
# output, (hn, cn) = lstm(input)
# 5. 打印结果.
print(f'output: {output}, {output.shape}') # shape: (4, 3, 6)
print(f'hn: {hn}, {hn.shape}') # shape: (1, 3, 6)
print(f'cn: {cn}, {cn.shape}') # shape: (1, 3, 6)
# todo 2. 测试代码
if __name__ == '__main__':
lstm_demo()
1.3 GRU
GRU 全称 Gated Recurrent Unit,门控循环单元。它也是传统 RNN 的变体,和 LSTM 一样能抓住较长序列里前后词的语义关系,缓解梯度消失和梯度爆炸。结构比 LSTM 简单,比 RNN 复杂。
也带着 RNN 结构本身的一个弊端:不能并行计算。数据量和模型体量逐步变大之后,这是 RNN 发展的关键瓶颈。
| 符号 | 谁 | 公式 | 中文 | 激活 | 作用 |
|---|---|---|---|---|---|
| (r_t) | 重置门 | (\sigma(W_r h_{t-1}, x_t)) | 重置门门值 = 激活函数(W @ 上一隐藏状态, 当前输入) | sigmoid | 决定生成新记忆时要不要擦掉上一份隐藏状态。乘上 (h_{t-1}),决定生成候选时旧记忆留多少 |
| (\tilde{h}_t) | 候选隐藏状态 | (\tanh(W r_t \* h_{t-1}, x_t)) | 候选隐藏状态 = tanh(W @ 重置门门值 \* 上一隐藏状态, 当前输入) | tanh | 这一步准备好的新记忆。先用重置门筛过旧隐藏状态,再和当前输入拼在一起 |
| (z_t) | 更新门 | (\sigma(W_z h_{t-1}, x_t)) | 更新门门值 = 激活函数(W @ 上一隐藏状态, 当前输入) | sigmoid | 决定新旧记忆怎么融合,最终这份隐藏状态里旧的占多少、新候选占多少 |
| (h_t) | 隐藏状态 | ((1 - z_t) * h_{t-1} + z_t * \tilde{h}_t) | 本次隐藏状态 = (1 - 更新门门值) * 上一隐藏状态 + 更新门门值 * 候选隐藏状态 | 加权相加 | 融合后的隐藏状态,既是这一步的输出,也传给下一步 |
python
"""
案例:
演示GRU代码
GRU简介:
概述:
Gated Recurrent Unit, 门控循环单元结构, 可以理解为是一个 简化版的智能管家,
即能像LSTM那样处理长序列信息, 计算上也更简单, 更节省资源.
核心设计思路:
重置门, 更新门. 就是在LSTM的基础上, 对部分门做合并.
优点:
和LSTM一样, 既可以处理长序列语义关联, 能够有效缓解梯度消失和爆炸问题.
关于长序列的处理效果: 优于传统RNN
关于计算复杂度: 比LSTM要小.
缺点:
不能完全解决梯度消失, 爆炸问题, 不能并行计算.
在数据量和模型体量逐步增大的未来, 是RNN发展的关键瓶颈.
"""
# 导包
import torch
import torch.nn as nn
# todo 1. 定义函数, 演示: GRU代码实现.
def gru_demo():
# 1. 创建GRU模型对象.
# 参1: 输入特征维度(词向量维度), 参2: 隐藏层维度(输出维度), 参3: 层数
gru = nn.GRU(input_size=5, hidden_size=6, num_layers=1)
# 2. 创建输入数据.
# 参1: 句子长度(seq_len), 参2: 批次大小(batch_size), 参3: 输入特征维度(input_size)
input = torch.randn(2, 3, 5)
# 3. 创建初始隐藏状态.
# 参1: 层数(num_layers), 参2: 批次大小(batch_size), 参3: 隐藏层维度(hidden_size)
h0 = torch.zeros(1, 3, 6)
# 4. 运行GRU模型.
output, hn = gru(input, h0)
# 5. 输出结果.
print(f'output: {output}, output.shape: {output.shape}') # shape: (2, 3, 6)
print(f'hidden: {hn}, hidden.shape: {hn.shape}') # shape: (1, 3, 6)
print(f'gru模型: {gru}') # RNN(5, 6)
# todo 2. 测试代码
if __name__ == '__main__':
gru_demo()
1.4 RNN人名分类案例
python
"""
案例:
代码实现RNN 全球人名分类案例, 录入人名, 预测其国家.
回顾: 深度学习, NLP项目研发流程:
1. 导包
2. 数据的预处理.
文件加载 → 封装成Tensor → 数据集对象(TensorDataset) → 数据加载器(DataLoader)
3. 构建模型.
RNN, LSTM, GRU
4. 模型训练.
绘图 → 对比三种模型的效果.
5. 模型测试.
RNN, LSTM, GRU
细节:
1. 本案例是(课程中, NLP阶段)为数不多的 用one-hot编码来处理的案例.
2. 本案例是(课程中, NLP阶段)为数不多的 用RNN, LSTM, GRU三种模型全演示的案例.
3. 代码层次上, 优先掌握: LSTM, RNN, 因为GRU写法和RNN几乎一致, 简单改改即可.
"""
import os
os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE'
# 导包
import torch # 张量计算相关
import torch.nn as nn # 神经网络模块, 各种模型的层, 组件...
import torch.nn.functional as F # 常用的函数库...
import torch.optim as optim # 优化器模块
from torch.utils.data import Dataset, DataLoader # 数据集对象, 数据加载器
import string # 字符串处理模块.
import time # 时间模块.
import matplotlib.pyplot as plt # 绘图模块.
from tqdm import tqdm # 进度条
# 解决绘图时, 中文乱码问题.
plt.rcParams['font.sans-serif'] = ['SimHei'] # Mac本换成: 'Arial Unicode MS'
plt.rcParams['axes.unicode_minus'] = False
# todo 1. 定义遍历, 获取常用的字符数量.
# 1. 获取所有的常用字符 → 包括 字母 + 符号.
all_letters = string.ascii_letters + " .,;'" # 52个字母(大小写形式) + '空格 点 逗号 分号 单引号'
# 2. 获取常用的字符的数量
n_letters = len(all_letters)
# print('所有常用字符: ', all_letters) # abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ .,;'
# print('常用字符数量: ', n_letters) # 57
# todo 2. 定义遍历, 获取常用国家名 种类数 和 个数.
# 1. 国家名 种类数.
categories = ['Italian', 'English', 'Arabic', 'Spanish', 'Scottish', 'Irish', 'Chinese', 'Vietnamese', 'Japanese', 'French', 'Greek', 'Dutch', 'Korean', 'Polish', 'Portuguese', 'Russian', 'Czech', 'German']
# 2. 国家名 个数.
category_num = len(categories)
print('国家名: ', categories)
print('国家名种类数: ', category_num) # 18个国家名
# todo 3. 定义函数, 实现: 读取源数据到内存.
def read_data(file_path):
"""
读取源数据到内存中, 并把 特征(人名) 和 标签(国家) 分别存储到两个列表中.
:param file_path: 源数据文件的路径
:return: my_list_x: 存储的人名(特征), my_list_y: 存储的国家名(标签)
"""
# 1. 创建两个列表, 分别存储: 人名(特征), 国家名(标签)
my_list_x, my_list_y = [], []
# 2. 关联文件, 并读取其内容(逐行读取)
with open(file_path, 'r', encoding='utf-8') as f:
# 3. 遍历, 获取到每一行的数据.
for line in f.readlines():
# 4. 过滤无效数据, 假设(整行的)长度 小于等于5, 就过滤掉. 整行长度 = 人名 + '\t' + 国家名
if len(line) <= 5:
continue
# 5. 走到这里, 说明该行数据是有效数据(即: 行长度 > 5), 处理后, 添加到对应的列表中.
x, y = line.strip().split('\t')
# 6. 添加到对应的列表中.
my_list_x.append(x)
my_list_y.append(y)
# 扩展: 查看下数据集的长度, 即: 判断数据是否正确.
print(f'my_list_x: {len(my_list_x)}') # 20074
print(f'my_list_y: {len(my_list_y)}') # 20074
# 7. 返回解析后的 样本 和 标签.
return my_list_x, my_list_y
# todo 4. 创建数据集对象, 即: 原始数据 → 数据集对象TensorDataset → 数据加载器DataLoader
class NameClassDataset(Dataset):
# 1. 初始化函数, 接收: 样本和标签数据, 初始化数据集基本属性.
def __init__(self, my_list_x, my_list_y):
self.my_list_x = my_list_x # 存储样本数据列表
self.my_list_y = my_list_y # 存储标签数据列表
self.sample_len = len(my_list_x) # 计算样本总数并存储, 20074
# 2. 定义函数, 用于获取样本总数. 外界用 len(NameClassDataset对象) 的时候, 自动触发.
def __len__(self):
return self.sample_len
# 3. 定义函数, 实现根据指定索引, 获取其对应的样本.
def __getitem__(self, index):
"""
根据指定的索引, 获取其对应的样本, 并进行 one-hot编码 和 张量转换.
:param index: 样本索引
:return: tensor_x: 人名(特征)的one-hot编码, tensor_y: 国家(标签)的张量表示
"""
# 1. 索引边界校验, 确保索引在合法范围. [0, self.sample_len - 1]
index = min(max(index, 0), self.sample_len - 1)
# 2. 按照索引获取原始样本 和 标签.
x = self.my_list_x[index] # 例如: Ding → (4, 57)
y = self.my_list_y[index] # 例如: Chinese → 18个国家中的某个索引, 例如: 6
# 3. 人名数据转换为 one-hot编码.
# 3.1 生成全0张量
tensor_x = torch.zeros(len(x), n_letters) # 例如: [4, 57]
# 3.2 遍历人名, 获取每个字母, 生成one-hot张量.
for li, letter in enumerate(x):
# 3.2.1 获取字母在 全局字母表中的索引位置, 例如: 字母'D' 在 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ .,;'" 中的位置
letter_index = all_letters.find(letter)
# 3.2.2 在对应位置设置为1 → 即: one-hot编码
tensor_x[li][letter_index] = 1
# 4. 国家数据转换为 张量.
tensor_y = torch.tensor(categories.index(y), dtype=torch.long)
# 5. 返回结果
return tensor_x, tensor_y
# todo 5. 定义函数, 获取数据加载器对象 → 思路: Tensor → TensorDataset → DataLoader
def get_dataloader():
# 1. 读取数据文件, 获取: 样本(人名)列表 和 标签(国家名)列表.
my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 2. 创建数据集对象
name_class_dataset = NameClassDataset(my_list_x, my_list_y)
# 3. 创建数据加载器对象, 用于批量加载和处理数据.
# 参1: 数据集对象(20074个人名 和 国家名), 参2: 批次大小, 参3: 是否打乱数据(训练集打乱, 测试集不打乱)
my_dataloader = DataLoader(name_class_dataset, batch_size=1, shuffle=True)
# 4. 测试数据加载器, 打印第一批数据(某一个样本的) 形状 和 内容
for x, y in my_dataloader:
print(f'x.shape: {x.shape}, x: {x}') # 人名的张量形状和内容.
print(f'y.shape: {y.shape}, y: {y}') # 国家张量形状和内容.
break # 仅打印第1批次数据, 用于查看, 避免全部输出.
# 5. 优化1: 可以把上述的数据加载器给返回, 后续直接调用.
# return my_dataloader
# todo n. 测试代码
if __name__ == '__main__':
# 1. 读取数据
# my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 2. 测试: 数据加载器.
get_dataloader()
NLP 学习
56. 全球人名分类案例_搭建RNN模型(掌握)
python
# todo 6. 搭建神经网络模型.
# todo 6.1 搭建RNN网络
class My_RNN(nn.Module):
# 1. 初始化函数:输入特征维度,隐藏层维度,输出维度,层数.
def __init__(self, input_size, hidden_size, output_size, n_layers=1):
# 1.1 初始化父类成员.
super().__init__()
# 1.2 输出特征维度(对应字母表大小,即:57个字符)
self.input_size = input_size
# 1.3 隐藏层维度,决定模型的表示能力.
self.hidden_size = hidden_size
# 1.4 输出维度(对应国家名数量,即:18个国家名)
self.output_size = output_size
# 1.5 层数,默认为1.
self.n_layers = n_layers
# 1.6 定义RNN层,接收输入特征 和 输出隐藏状态.
self.rnn = nn.RNN(self.input_size, self.hidden_size, self.n_layers)
# 1.7 定义全连接层,将RNN的隐藏状态转换成输出.
self.linear = nn.Linear(self.hidden_size, self.output_size)
# 1.8 定义激活函数,将输出类别 → 类别的概率分布.
# 大白话解释:多分类交叉熵损失函数CrossEntropyLoss(新版写法) = NLLLoss损失函数 + LogSoftmax(dim=-1) 旧版写法
self.softmax = nn.LogSoftmax(dim=-1) # 优化2: 如果用CrossEntropyLoss损失函数,这行代码可以省略不写.
# 2. 前向传播函数.
# 参1: input输入张量,形状为:[seq_len, input_size] → [seq_len, batch_size, input_size]
# 参2: hidden(隐藏状态),形状为:[n_layers, batch_size, hidden_size]
def forward(self, input, hidden):
# 2.1 调整输入张量,添加:batch_size
input = input.unsqueeze(1)
# 2.2 通过RNN计算.
# output: 所有时间步的隐藏状态 hn: 最后1个时间步的隐藏状态
output, hn = self.rnn(input, hidden)
# 2.3 提取最后1个时间步的隐藏状态.
tmp_output = output[-1] # 形状为:[batch_size, hidden_size]
# 2.4 通过全连接层,获取输出.
tmp_output = self.linear(tmp_output)
# 2.5 数据通过激活函数,映射到概率分布,并返回.
return self.softmax(tmp_output), hn
# 3. 初始化隐藏状态,创建全0的初始化隐藏状态.
def init_hidden(self):
# 参1: 隐藏层层数,参2: 批次大小,参3: 隐藏层维度.
return torch.zeros(self.n_layers, 1, self.hidden_size)
# todo 6.2 搭建LSTM网络
# todo 6.3 搭建GRU网络
# todo 7. 测试神经网络模型.
# todo 7.1 测试RNN网络模型
# todo 7.2 测试RNN, LSTM, GRU网络模型
拓展QA:
1. 第 416 行如果改用 CrossEntropyLoss,代码具体怎么改?
全连接层交出原始分数(logits),模型里删掉 LogSoftmax。CrossEntropyLoss 内部就是 LogSoftmax 加 NLLLoss,分数再过一遍 LogSoftmax 会算重。
__init__ 里删掉激活函数这三行:
python
# 1.8 定义激活函数,将输出类别 → 类别的概率分布.
# 大白话解释:多分类交叉熵损失函数CrossEntropyLoss(新版写法) = NLLLoss损失函数 + LogSoftmax(dim=-1) 旧版写法
self.softmax = nn.LogSoftmax(dim=-1)
forward 的返回改成直接交全连接的结果:
python
# 2.5 返回原始分数(logits)。训练时用 CrossEntropyLoss,它内部会做 LogSoftmax.
return tmp_output, hn
训练里损失函数写成:
python
criterion = nn.CrossEntropyLoss()
# output: [batch_size, 18] 的原始分数
# y: 国家下标,形状 [batch_size],dtype 为 long
loss = criterion(output, y)
现在这版配套的是旧写法:模型里 LogSoftmax,训练时用 NLLLoss。两种写法二选一。标签一直是国家下标,不用改成 one-hot。
2. 为什么 n_layers 默认是 1?改成别的数之后,unsqueeze(1) 还能用吗?
n_layers=1 是 nn.RNN 的默认层数,这个案例沿用它。人名很短,一层已经把整个人名收进最后一个隐藏状态。层数只在创建模型时传入,例如 My_RNN(57, 128, 18, n_layers=2)。
input.unsqueeze(1) 在层数改成 2、3 之后照常使用。它在第 1 维插入长度 1,把 [seq_len, input_size] 变成 [seq_len, 1, input_size]。这里的 1 是批次大小,和隐藏层层数无关。
层数变了,跟着变的是隐藏状态的第 0 维。init_hidden 已经写成 torch.zeros(self.n_layers, 1, self.hidden_size),所以不用再改。
以 seq_len 为句子长度、hidden_size 记为 (H):
| 张量 | n_layers=1 |
n_layers=2 |
|---|---|---|
unsqueeze 之后的 input |
[seq_len, 1, 57] |
相同 |
init_hidden() |
[1, 1, H] |
[2, 1, H] |
output |
[seq_len, 1, H] |
仍是 [seq_len, 1, H],每个时间步只保留最后一层 |
hn |
[1, 1, H] |
[2, 1, H],两层各一份最终状态 |
output[-1] |
[1, H] |
仍是 [1, H] |
self.linear |
Linear(H, 18) |
不用改 |
3. a.shape 为 [3, 3, 3] 时,a[-1] 取到的是什么?
a[-1] 只切第 0 维,取最后一片。这一维长度是 3,所以 a[-1] 就是 a[2],结果形状从 [3, 3, 3] 变成 [3, 3]。后面两维原样留下。
把 0 到 26 按顺序填进这个张量,第 0 维是叠在一起的 3 块,每块 3 行 3 列:
text
a = torch.arange(27).reshape(3, 3, 3)
a[0] a[1] a[-1] 也就是 a[2]
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 0 1 2 │ │ 9 10 11 │ │ 18 19 20 │
│ 3 4 5 │ │ 12 13 14 │ │ 21 22 23 │
│ 6 7 8 │ │ 15 16 17 │ │ 24 25 26 │
└──────────────┘ └──────────────┘ └──────────────┘
形状 [3, 3] 形状 [3, 3] 形状 [3, 3]
中括号里写几个下标,就从第 0 维开始连续切几刀。负号表示从该维的末尾数。
| 写法 | 切哪一维 | 结果形状 | 拿到的数 |
|---|---|---|---|
a[-1] |
第 0 维最后一块 | [3, 3] |
18 到 26 那一块 |
a[:, -1] |
第 1 维最后一行 | [3, 3] |
每块的最后一行:[6,7,8]、[15,16,17]、[24,25,26] |
a[:, :, -1] |
第 2 维最后一列 | [3, 3] |
每行的最后一个数:2、5、8、11、14、17、20、23、26 |
a[-1, -1, -1] |
三维都取最后一个 | 一个数 | 26 |
回到人名分类:output 的形状是 [seq_len, batch_size, hidden_size]。output[-1] 切的是第 0 维,也就是最后一个字母对应的隐藏状态,形状变成 [batch_size, hidden_size]。人名 Li、批次 1、隐藏维度 3 时,output 是两片,output[-1] 是字母 i 那一片:
text
output[0] 字母 L output[-1] 字母 i
┌─────────┐ ┌─────────┐
│ h0 h1 h2│ │ h0 h1 h2│
└─────────┘ └─────────┘
形状 [1, 3] 形状 [1, 3]
4. forward 的 input 为什么是 [seq_len, input_size]?RNN 要的不是 [seq_len, batch, input_size] 吗?
nn.RNN 收到的输入一直是三维,批次维会留到 output 和 hn。forward 的形参写成二维,是因为这次送进来的是一条人名;批次维由 unsqueeze(1) 补上,然后才交给 self.rnn。
__getitem__ 里一条人名做成 [人名长度, 57],例如 Ding 是 [4, 57]。注释里的 [seq_len, input_size] 说的就是这个形参。
text
__getitem__ forward 形参 unsqueeze(1) 之后 self.rnn 的输出
[seq_len, 57] → [seq_len, 57] → [seq_len, 1, 57] → output: [seq_len, 1, hidden_size]
一条人名,没有批次 原样接收 在第 1 维插入 batch=1 hn: [n_layers, 1, hidden_size]
nn.RNN 默认 batch_first=False,所以三维的顺序是句子长度、批次、特征。批次写在中间,unsqueeze(1) 插的就是这个位置。
数据加载器还会再包一层。batch_size=1 时,for x, y in my_dataloader 拿到的 x 是 [1, seq_len, 57],这个 1 在第 0 维,和 RNN 要的位置不同。训练时先 x = x.squeeze(0) 变回 [seq_len, 57],再送进 forward。直接把加载器的 x 送进去再 unsqueeze(1),会变成四维 [1, 1, seq_len, 57],self.rnn 接不住。
5. 为什么批次直接用 unsqueeze 插成 1?批次不能大于 1 吗?
unsqueeze 插入的新维度长度固定是 1。这里用它,表示这一条人名单独成一批。nn.RNN 的批次维可以大于 1,写成 8 就是 8 条一起算。当前插 1,是因为每个人名长度不同,默认的 DataLoader 叠不成一批。
__getitem__ 按名字本身的长度建张量。Ding 是 [4, 57],Li 是 [2, 57]。DataLoader 组批时用 torch.stack,这一批里每条的形状必须相同。batch_size 大于 1 时,4 和 2 对不齐,加载器直接报错。所以 get_dataloader 把 batch_size 写成 1,每批只有一条,形状总能对齐。
一批里要放多条人名,先把短名字补到这一批里最长的长度,补出来的位置一般填 0。补完之后输入已经是 [最大长度, batch_size, 57],forward 里就不再 unsqueeze。init_hidden 的第 2 维也改成这批的条数:
python
def init_hidden(self, batch_size):
return torch.zeros(self.n_layers, batch_size, self.hidden_size)
补出来的 0 仍会被 RNN 当成字母往下算。还要用 pack_padded_sequence 告诉它每条的真实长度,算到名字结束就停。这一步本案例还没写,所以批次先固定为 1。
6. forward 要手动调用吗?既然是手动调用,直接传入已经带批次的张量不就行了吗?
训练循环里自己写调用,写成 output, hn = model(x, hidden)。model(...) 进入 nn.Module 的 __call__,再执行这个类里的 forward。通常不写 model.forward(...),那样会跳过模块上的钩子。数据加载完之后,PyTorch 不会自己去跑 forward,循环里的这一行就是调用。
当前 forward 约定入参是一条人名,形状 [seq_len, 57],函数内部固定执行 unsqueeze(1)。调用方已经拿着 [seq_len, batch, 57] 再传进来,会再被插入一维,变成四维,self.rnn 接不住。外面自己带好批次,和里面再插一维,只能留一种。
加载器给出的批次还在第 0 维。batch_size=1 时,x 是 [1, seq_len, 57]。nn.RNN 默认要 [seq_len, batch, 57],批次在中间。把这个 x 原样传进当前的 forward,维度顺序对不上。现在的做法是先 squeeze(0),回到 [seq_len, 57],由 forward 用 unsqueeze(1) 把批次放到中间。
调用方如果负责整理成 [seq_len, batch, 57],forward 就改成直接交给 RNN,同时 init_hidden 接收真实的 batch_size:
python
def forward(self, input, hidden):
# input 已经是 [seq_len, batch_size, input_size]
output, hn = self.rnn(input, hidden)
tmp_output = output[-1] # [batch_size, hidden_size]
tmp_output = self.linear(tmp_output)
return self.softmax(tmp_output), hn
这个案例把「批次等于 1」写进模型里面,调用时只传一条人名。
57. 全球人名分类案例_测试RNN模型(理解)
python
# todo 7. 测试神经网络模型.
# todo 7.1 测试RNN网络模型
def dm_test_myrnn():
# 1. 实例化RNN对象
my_rnn = My_RNN(input_size=57, hidden_size=128, output_size=18)
# print(f'my_rnn: {my_rnn}') # my_rnn: My_RNN( (rnn): RNN(57, 128) (linear): Linear(in_features=128, out_features=18, bias=True) (softmax): LogSoftmax(dim=-1) )
# 2. 准备测试数据,创建一个随机张量,形状为:[seq_len人名长度, input_size词向量维度]
input = torch.randn(6, 57) # 例如: ouyang 欧阳
print(f'input(输入的张量维度): {input.shape}') # torch.Size([6, 57])
# 3. 初始化隐藏状态
# h0 = torch.zeros(1, 1, 128)
h0 = my_rnn.init_hidden() # 效果同上.
# 4. 测试一次性输入完整的一个样本(序列数据)
output, hn = my_rnn(input, h0)
# 5. 打印结果.
print(f'输出的形状: {output.shape}, 输出的内容: {output}') # [1, 18]
print(f'隐藏状态的形状: {hn.shape}, 隐藏状态的内容: {hn}') # [1, 1, 128]
# todo 7.2 测试RNN, LSTM, GRU网络模型
# todo n. 测试代码
if __name__ == '__main__':
# 1. 读取数据
# my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 2. 测试: 数据加载器.
# get_dataloader()
# 3. 测试RNN模型
dm_test_myrnn()
58. 全球人名分类案例_搭建LSTM和GRU模型(掌握)
python
# todo 6.2 搭建LSTM网络
class My_LSTM(nn.Module):
# 1. 初始化函数:输入特征维度,隐藏层维度,输出维度,层数.
def __init__(self, input_size, hidden_size, output_size, n_layers=1):
# 1.1 初始化父类成员.
super().__init__()
# 1.2 输出特征维度(对应字母表大小,即:57个字符)
self.input_size = input_size
# 1.3 隐藏层维度,决定模型的表示能力.
self.hidden_size = hidden_size
# 1.4 输出维度(对应国家名数量,即:18个国家名)
self.output_size = output_size
# 1.5 层数,默认为1.
self.n_layers = n_layers
# 1.6 定义LSTM层,接收输入特征 和 输出隐藏状态.
self.rnn = nn.LSTM(self.input_size, self.hidden_size, self.n_layers)
# 1.7 定义全连接层,将RNN的隐藏状态转换成输出.
self.linear = nn.Linear(self.hidden_size, self.output_size)
# 1.8 定义激活函数,将输出类别 → 类别的概率分布.
# 大白话解释:多分类交叉熵损失函数CrossEntropyLoss(新版写法) = NLLLoss损失函数 + LogSoftmax(dim=-1) 旧版写法
self.softmax = nn.LogSoftmax(dim=-1) # 优化2: 如果用CrossEntropyLoss损失函数,这行代码可以省略不写.
# 2. 前向传播函数.
# 参1: input输入张量,形状为:[seq_len, input_size] → [seq_len, batch_size, input_size]
# 参2: hidden(隐藏状态),形状为:[n_layers, batch_size, hidden_size]
def forward(self, input, hidden, c):
# 2.1 调整输入张量,添加:batch_size
input = input.unsqueeze(1)
# 2.2 通过LSTM计算.
# output: 所有时间步的隐藏状态 hidden: 最后1个时间步的隐藏状态
output, (hn, cn) = self.rnn(input, (hidden, c))
# 2.3 提取最后1个时间步的隐藏状态.
tmp_output = output[-1] # 形状为:[batch_size, hidden_size]
# 2.4 通过全连接层,获取输出.
tmp_output = self.linear(tmp_output)
# 2.5 数据通过激活函数,映射到概率分布,并返回.
# 返回值1: 预测的类别概率分布,形状:[batch_size, output_size]
# 返回值2: (最后一个时间步的)隐藏状态张量,形状:[n_layers, batch_size, hidden_size]
# 返回值3: (最后一个时间步的)细胞状态张量,形状:[n_layers, batch_size, hidden_size]
return self.softmax(tmp_output), hn, cn
# 3. 初始化隐藏状态,创建全0的初始化隐藏状态.
def init_hidden(self):
# 参1: 隐藏层层数,参2: 批次大小,参3: 隐藏层维度.
hidden = c = torch.zeros(self.n_layers, 1, self.hidden_size)
return hidden, c
# todo 6.3 搭建GRU网络
class My_GRU(nn.Module):
# 1. 初始化函数:输入特征维度,隐藏层维度,输出维度,层数.
def __init__(self, input_size, hidden_size, output_size, n_layers=1):
# 1.1 初始化父类成员.
super().__init__()
# 1.2 输出特征维度(对应字母表大小,即:57个字符)
self.input_size = input_size
# 1.3 隐藏层维度,决定模型的表示能力.
self.hidden_size = hidden_size
# 1.4 输出维度(对应国家名数量,即:18个国家名)
self.output_size = output_size
# 1.5 层数,默认为1.
self.n_layers = n_layers
# 1.6 定义GRU层,接收输入特征 和 输出隐藏状态.
self.rnn = nn.GRU(self.input_size, self.hidden_size, self.n_layers)
# 1.7 定义全连接层,将RNN的隐藏状态转换成输出.
self.linear = nn.Linear(self.hidden_size, self.output_size)
# 1.8 定义激活函数,将输出类别 → 类别的概率分布.
# 大白话解释:多分类交叉熵损失函数CrossEntropyLoss(新版写法) = NLLLoss损失函数 + LogSoftmax(dim=-1) 旧版写法
self.softmax = nn.LogSoftmax(dim=-1) # 优化2: 如果用CrossEntropyLoss损失函数,这行代码可以省略不写.
# 2. 前向传播函数.
# 参1: input输入张量,形状为:[seq_len, input_size] → [seq_len, batch_size, input_size]
# 参2: hidden(隐藏状态),形状为:[n_layers, batch_size, hidden_size]
def forward(self, input, hidden):
# 2.1 调整输入张量,添加:batch_size
input = input.unsqueeze(1)
# 2.2 通过RNN计算.
# output: 所有时间步的隐藏状态 hidden: 最后1个时间步的隐藏状态
output, hn = self.rnn(input, hidden)
# 2.3 提取最后1个时间步的隐藏状态.
tmp_output = output[-1] # 形状为:[batch_size, hidden_size]
# 2.4 通过全连接层,获取输出.
tmp_output = self.linear(tmp_output)
# 2.5 数据通过激活函数,映射到概率分布,并返回.
return self.softmax(tmp_output), hn
# 3. 初始化隐藏状态,创建全0的初始化隐藏状态.
def init_hidden(self):
# 参1: 隐藏层层数,参2: 批次大小,参3: 隐藏层维度.
return torch.zeros(self.n_layers, 1, self.hidden_size)
2026.10.09 13:14
59. 全球人名分类案例_测试三种模型(理解)
python
# todo 7. 测试神经网络模型(了解)
# todo 7.2 测试RNN, LSTM, GRU网络模型
def dm_test_rnn_lstm_gru():
# 1. 定义变量,记录: 输入维度(词向量维度: 57),隐藏层维度(128),输出维度(18,国家数量)
input_size, n_hidden, output_size = n_letters, 128, category_num
# input_size, n_hidden, output_size = 57, 128, 18 # 效果同上.
# 2. 加载数据
my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 3. 创建数据集对象
name_class_dataset = NameClassDataset(my_list_x, my_list_y)
# 4. 创建数据加载器.
my_dataloader = DataLoader(name_class_dataset, batch_size=1, shuffle=True)
# 5. 模型初始化.
my_rnn = My_RNN(input_size, n_hidden, output_size)
my_lstm = My_LSTM(input_size, n_hidden, output_size)
my_gru = My_GRU(input_size, n_hidden, output_size)
# 6. 模型结构可视化.
print(f'RNN模型结构: {my_rnn}')
print(f'LSTM模型结构: {my_lstm}')
print(f'GRU模型结构: {my_gru}')
# 7. 测试上述的3个模型
# 7.1 测试RNN模型
for i, (x, y) in enumerate(my_dataloader):
print(f'i: {i}') # 编号,第i条数据
print(f'x: {x}, x.shape: {x.shape}') # 输入数据的词向量形式,例如: x.shape: torch.Size([1, 10, 57])
print(f'y: {y}, y.shape: {y.shape}') # 输出数据(国家的编号),例如: y: tensor([15]), y.shape: torch.Size([1])
# 7.2 初始化隐藏状态.
hidden = my_rnn.init_hidden() # 形状:[1, 1, 128]
# 7.3 前向传播.
output, hidden = my_rnn(x[0], hidden) # x[0] 等价于:[10, 57]
print(f'RNN输出形状: {output.shape}, 预测结果: {output}')
# 扩展: 只训练1个样本,不然太多了,这里看看即可.
if i == 0:
break
# 7.2 测试LSTM模型
for i, (x, y) in enumerate(my_dataloader):
print(f'i: {i}') # 编号,第i条数据
print(f'x: {x}, x.shape: {x.shape}') # 输入数据的词向量形式,例如: x.shape: torch.Size([1, 10, 57])
print(f'y: {y}, y.shape: {y.shape}') # 输出数据(国家的编号),例如: y: tensor([15]), y.shape: torch.Size([1])
# 7.2 初始化隐藏状态.
hidden, c = my_lstm.init_hidden() # 形状:[1, 1, 128]
# 7.3 前向传播.
output, hidden, c = my_lstm(x[0], hidden, c) # x[0] 等价于:[10, 57]
print(f'LSTM输出形状: {output.shape}, 预测结果: {output}')
# 扩展: 只训练1个样本,不然太多了,这里看看即可.
if i == 0:
break
# 7.3 测试GRU模型
for i, (x, y) in enumerate(my_dataloader):
# print(f'i: {i}') # 编号,第i条数据
# print(f'x: {x}, x.shape: {x.shape}') # 输入数据的词向量形式,例如: x.shape: torch.Size([1, 10, 57])
# print(f'y: {y}, y.shape: {y.shape}') # 输出数据(国家的编号),例如: y: tensor([15]), y.shape: torch.Size([1])
# 7.2 初始化隐藏状态.
hidden = my_gru.init_hidden() # 形状:[1, 1, 128]
# 7.3 前向传播.
output, hidden = my_gru(x[0], hidden) # x[0] 等价于:[10, 57]
print(f'RNN输出形状: {output.shape}, 预测结果: {output}')
# 扩展: 只训练1个样本,不然太多了,这里看看即可.
if i == 0:
break
if __name__ == '__main__':
# 1. 读取数据
# my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 2. 测试: 数据加载器.
# get_dataloader()
# 3. 测试RNN模型
# dm_test_myrnn()
# 4. 测试RNN, LSTM, GRU模型
dm_test_rnn_lstm_gru()
60. 全球人名分类案例_RNN模型训练(掌握)
python
# todo 8. 模型训练.
# 定义变量,记录:学习率,训练的轮数.
my_lr, epochs = 1e-3, 1
# todo 8.1 RNN模型训练.
def train_rnn():
# 1. 数据准备动作.
# 1.1 读取数据
my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 1.2 构建数据集对象.
name_class_dataset = NameClassDataset(my_list_x, my_list_y)
# 2. 模型与优化器初始化.
# 2.1 定义模型参数.
# 参1: 输入维度(字符表大小),参2: 隐藏层维度,参3: 输出维度(国家数量)
input_size, n_hidden, output_size = n_letters, 128, category_num # 等价于: 57, 128, 18
# 2.2 创建模型对象.
my_rnn = My_RNN(input_size, n_hidden, output_size)
# 2.3 定义损失函数和优化器.
criterion = nn.NLLLoss() # 如果你用了CrossEntropyLoss(),则它 = NLLLoss() + LogSoftmax()
optimizer = optim.Adam(my_rnn.parameters(), lr=my_lr)
# 3. 训练过程 → 参数初始化
start_time = time.time() # 模型开始训练时间.
total_iter_num = 0 # 已训练的样本数.
total_loss = 0.0 # 已训练的损失和
total_loss_list = [] # 每100个样本求一次平均损失,形成:损失列表.
total_acc_num = 0 # 已训练的样本,预测正确的总数
total_acc_list = [] # 每100个样本求一次平均准确率,形成:准确率列表.
# 4. 具体的训练过程,按轮数遍历数据集.
for epoch in range(epochs): # epoch: 第几轮
print(f'\n开始第{epoch + 1}/{epochs} 轮训练...')
# 4.1 创建数据集加载器对象,随机打乱数据集.
train_dataloader = DataLoader(name_class_dataset, batch_size=1, shuffle=True)
# 4.2 样本迭代训练,即: 本轮具体的每批次训练
for i, (x, y) in enumerate(tqdm(train_dataloader)): # 优化点3: 这里加入进度条.
# 4.3 前向传播,计算结果.
output, hidden = my_rnn(x[0], my_rnn.init_hidden())
# 4.4 计算损失.
my_loss = criterion(output, y)
# 4.5 三剑客 → 梯度清零,反向传播,优化器更新参数.
optimizer.zero_grad()
my_loss.backward()
optimizer.step()
# 4.6 统计训练结果(指标统计)
total_iter_num += 1 # 已训练的样本数 + 1
total_loss += my_loss.item() # 累计损失值
# 4.7 计算当前样本预测准确率
pred_tag = torch.argmax(output).item()
total_acc_num += (1 if pred_tag == y else 0) # 统计: 预测正确的样本数
# 4.8 统计: 每100个样本求一次平均损失,准确率 形成:损失列表,准确率列表.
if total_iter_num % 100 == 0:
# 走这里,说明100步了,计算:平均损失.
avg_loss = total_loss / total_iter_num # 总损失 / 总样本数
# 把上述的平均损失,添加到:损失列表.
total_loss_list.append(avg_loss)
# 计算准确率,即: 预测正确的 / 总样本数,并添加到:准确率列表.
avg_acc = total_acc_num / total_iter_num
total_acc_list.append(avg_acc)
# 4.9 每2000步(个样本),打印训练日志.
if total_iter_num % 2000 == 0:
# 计算平均损失.
avg_loss = total_loss / total_iter_num
# 计算模型训练耗时
end_time = int(time.time() - start_time)
# 输出训练日志.
print(f'轮次: {epoch + 1}, 训练的样本数: {total_iter_num}, 平均损失: {avg_loss:.4f}, 耗时: {end_time}s, 准确率: {avg_acc:.4f}')
# 4.10 走到这里,说明一轮训练完毕 → 保存模型.
os.makedirs('./model', exist_ok=True)
torch.save(my_rnn.state_dict(), f'./model/my_rnn_wh02_{epoch + 1}.bin')
# 5. 走到这里,训练结束,返回统计结果.
total_time = int(time.time() - start_time)
print(f'训练完成,总耗时: {total_time}s, 总训练了 {total_iter_num}个样本!!')
# 6. 优化4: 你可以把下述返回的三个值(损失列表,训练总耗时,准确率列表),存储到文件中.
# 因为一会儿我们会 可视化3个模型的训练结果,如果没有存储的话,会把 训练动作从新跑一次.
# 7. 返回结果: 损失列表,训练总耗时,准确率列表.
return total_loss_list, total_time, total_acc_list
# todo 8.2 LSTM模型训练.
# todo 8.3 GRU模型训练.
if __name__ == '__main__':
# 1. 读取数据
# my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 2. 测试: 数据加载器.
# get_dataloader()
# 3. 测试RNN模型
# dm_test_myrnn()
# 4. 测试RNN, LSTM, GRU模型
# dm_test_rnn_lstm_gru()
# 5. 测试: 模型训练.
train_rnn()
61. 全球人名分类案例_LSTM模型训练(掌握)
python
# todo 8.2 LSTM模型训练.
def train_lstm():
# 1. 数据准备动作.
# 1.1 读取数据
my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 1.2 构建数据集对象.
name_class_dataset = NameClassDataset(my_list_x, my_list_y)
# 2. 模型与优化器初始化.
# 2.1 定义模型参数.
# 参1: 输入维度(字符表大小),参2: 隐藏层维度,参3: 输出维度(国家数量)
input_size, n_hidden, output_size = n_letters, 128, category_num # 等价于: 57, 128, 18
# 2.2 创建模型对象.
my_rnn = My_LSTM(input_size, n_hidden, output_size)
# 2.3 定义损失函数和优化器.
criterion = nn.NLLLoss() # 如果你用了CrossEntropyLoss(),则它 = NLLLoss() + LogSoftmax()
optimizer = optim.Adam(my_rnn.parameters(), lr=my_lr)
# 3. 训练过程 → 参数初始化
start_time = time.time() # 模型开始训练时间.
total_iter_num = 0 # 已训练的样本数.
total_loss = 0.0 # 已训练的损失和
total_loss_list = [] # 每100个样本求一次平均损失,形成:损失列表.
total_acc_num = 0 # 已训练的样本,预测正确的总数
total_acc_list = [] # 每100个样本求一次平均准确率,形成:准确率列表.
# 4. 具体的训练过程,按轮数遍历数据集.
for epoch in range(epochs): # epoch: 第几轮
print(f'\n开始第{epoch + 1}/{epochs} 轮训练...')
# 4.1 创建数据集加载器对象,随机打乱数据集.
train_dataloader = DataLoader(name_class_dataset, batch_size=1, shuffle=True)
# 4.2 样本迭代训练,即: 本轮具体的每批次训练
for i, (x, y) in enumerate(tqdm(train_dataloader)): # 优化点3: 这里加入进度条.
# 4.3 前向传播,计算结果.
hidden, c = my_rnn.init_hidden()
output, hidden, c = my_rnn(x[0], hidden, c)
# 4.4 计算损失.
my_loss = criterion(output, y)
# 4.5 三剑客 → 梯度清零,反向传播,优化器更新参数.
optimizer.zero_grad()
my_loss.backward()
optimizer.step()
# 4.6 统计训练结果(指标统计)
total_iter_num += 1 # 已训练的样本数 + 1
total_loss += my_loss.item() # 累计损失值
# 4.7 计算当前样本预测准确率
pred_tag = torch.argmax(output).item()
total_acc_num += (1 if pred_tag == y else 0) # 统计: 预测正确的样本数
# 4.8 统计: 每100个样本求一次平均损失,准确率 形成:损失列表,准确率列表.
if total_iter_num % 100 == 0:
# 走这里,说明100步了,计算:平均损失.
avg_loss = total_loss / total_iter_num # 总损失 / 总样本数
# 把上述的平均损失,添加到:损失列表.
total_loss_list.append(avg_loss)
# 计算准确率,即: 预测正确的 / 总样本数,并添加到:准确率列表.
avg_acc = total_acc_num / total_iter_num
total_acc_list.append(avg_acc)
# 4.9 每2000步(个样本),打印训练日志.
if total_iter_num % 2000 == 0:
# 计算平均损失.
avg_loss = total_loss / total_iter_num
# 计算模型训练耗时
end_time = int(time.time() - start_time)
# 输出训练日志.
print(f'轮次: {epoch + 1}, 训练的样本数: {total_iter_num}, 平均损失: {avg_loss:.4f}, 耗时: {end_time}s, 准确率: {avg_acc:.4f}')
# 4.10 走到这里,说明一轮训练完毕 → 保存模型.
model_path = os.path.join(os.path.dirname(__file__), 'model', f'my_lstm_wh02_{epoch + 1}.bin')
os.makedirs(os.path.dirname(model_path), exist_ok=True)
torch.save(my_rnn.state_dict(), model_path)
# 5. 走到这里,训练结束,返回统计结果.
total_time = int(time.time() - start_time)
print(f'训练完成,总耗时: {total_time}s, 总训练了 {total_iter_num}个样本!!')
# 6. 优化4: 你可以把下述返回的三个值(损失列表,训练总耗时,准确率列表),存储到文件中.
# 因为一会儿我们会 可视化3个模型的训练结果,如果没有存储的话,会把 训练动作从新跑一次.
# 7. 返回结果: 损失列表,训练总耗时,准确率列表.
return total_loss_list, total_time, total_acc_list
# todo 8.3 GRU模型训练.
if __name__ == '__main__':
# 1. 读取数据
# my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 2. 测试: 数据加载器.
# get_dataloader()
# 3. 测试RNN模型
# dm_test_myrnn()
# 4. 测试RNN, LSTM, GRU模型
# dm_test_rnn_lstm_gru()
# 5. 测试: 模型训练.
# train_rnn() # 51s
train_lstm() # 训练完成,总耗时: 52s, 总训练了 20074个样本!!
62. 全球人名分类案例_GRU模型训练(掌握)
python
# todo 8.3 GRU模型训练.
def train_gru():
# 1. 数据准备动作.
# 1.1 读取数据
my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 1.2 构建数据集对象.
name_class_dataset = NameClassDataset(my_list_x, my_list_y)
# 2. 模型与优化器初始化.
# 2.1 定义模型参数.
# 参1: 输入维度(字符表大小),参2: 隐藏层维度,参3: 输出维度(国家数量)
input_size, n_hidden, output_size = n_letters, 128, category_num # 等价于: 57, 128, 18
# 2.2 创建模型对象.
my_rnn = My_GRU(input_size, n_hidden, output_size)
# 2.3 定义损失函数和优化器.
criterion = nn.NLLLoss() # 如果你用了CrossEntropyLoss(),则它 = NLLLoss() + LogSoftmax()
optimizer = optim.Adam(my_rnn.parameters(), lr=my_lr)
# 3. 训练过程 → 参数初始化
start_time = time.time() # 模型开始训练时间.
total_iter_num = 0 # 已训练的样本数.
total_loss = 0.0 # 已训练的损失和
total_loss_list = [] # 每100个样本求一次平均损失,形成:损失列表.
total_acc_num = 0 # 已训练的样本,预测正确的总数
total_acc_list = [] # 每100个样本求一次平均准确率,形成:准确率列表.
# 4. 具体的训练过程,按轮数遍历数据集.
for epoch in range(epochs): # epoch: 第几轮
print(f'\n开始第{epoch + 1}/{epochs} 轮训练...')
# 4.1 创建数据集加载器对象,随机打乱数据集.
train_dataloader = DataLoader(name_class_dataset, batch_size=1, shuffle=True)
# 4.2 样本迭代训练,即: 本轮具体的每批次训练
for i, (x, y) in enumerate(tqdm(train_dataloader)): # 优化点3: 这里加入进度条.
# 4.3 前向传播,计算结果.
output, hidden = my_rnn(x[0], my_rnn.init_hidden())
# 4.4 计算损失.
my_loss = criterion(output, y)
# 4.5 三剑客 → 梯度清零,反向传播,优化器更新参数.
optimizer.zero_grad()
my_loss.backward()
optimizer.step()
# 4.6 统计训练结果(指标统计)
total_iter_num += 1 # 已训练的样本数 + 1
total_loss += my_loss.item() # 累计损失值
# 4.7 计算当前样本预测准确率
pred_tag = torch.argmax(output).item()
total_acc_num += (1 if pred_tag == y else 0) # 统计: 预测正确的样本数
# 4.8 统计: 每100个样本求一次平均损失,准确率 形成:损失列表,准确率列表.
if total_iter_num % 100 == 0:
# 走这里,说明100步了,计算:平均损失.
avg_loss = total_loss / total_iter_num # 总损失 / 总样本数
# 把上述的平均损失,添加到:损失列表.
total_loss_list.append(avg_loss)
# 计算准确率,即: 预测正确的 / 总样本数,并添加到:准确率列表.
avg_acc = total_acc_num / total_iter_num
total_acc_list.append(avg_acc)
# 4.9 每2000步(个样本),打印训练日志.
if total_iter_num % 2000 == 0:
# 计算平均损失.
avg_loss = total_loss / total_iter_num
# 计算模型训练耗时
end_time = int(time.time() - start_time)
# 输出训练日志.
print(f'轮次: {epoch + 1}, 训练的样本数: {total_iter_num}, 平均损失: {avg_loss:.4f}, 耗时: {end_time}s, 准确率: {avg_acc:.4f}')
# 4.10 走到这里,说明一轮训练完毕 → 保存模型.
model_path = os.path.join(os.path.dirname(__file__), 'model', f'my_gru_wh02_{epoch + 1}.bin')
os.makedirs(os.path.dirname(model_path), exist_ok=True)
torch.save(my_rnn.state_dict(), model_path)
# 5. 走到这里,训练结束,返回统计结果.
total_time = int(time.time() - start_time)
print(f'训练完成,总耗时: {total_time}s, 总训练了 {total_iter_num}个样本!!')
# 6. 优化4: 你可以把下述返回的三个值(损失列表,训练总耗时,准确率列表),存储到文件中.
# 因为一会儿我们会 可视化3个模型的训练结果,如果没有存储的话,会把 训练动作从新跑一次.
# 7. 返回结果: 损失列表,训练总耗时,准确率列表.
return total_loss_list, total_time, total_acc_list
if __name__ == '__main__':
# 1. 读取数据
# my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 2. 测试: 数据加载器.
# get_dataloader()
# 3. 测试RNN模型
# dm_test_myrnn()
# 4. 测试RNN, LSTM, GRU模型
# dm_test_rnn_lstm_gru()
# 5. 测试: 模型训练.
# train_rnn() # 训练完成,总耗时: 51s, 总训练了 20074个样本!!
# train_lstm() # 训练完成,总耗时: 52s, 总训练了 20074个样本!!
train_gru() # 训练完成,总耗时: 82s, 总训练了 20074个样本!!
63. 全球人名分类案例_三种模型可视化(了解)
python
# (作业)
# todo 9. 模型训练绘图 → 这个函数的可视化代码你可以不写,但是模型训练等代码要写出来,出3张图.
def dm_test_train_rnn_lstm_gru():
# 1. 训练3种模型,并获取性能指标.
# 参1: 损失列表,参2: 训练总耗时,参3: 准确率列表.
total_loss_list_rnn, total_time_rnn, total_acc_list_rnn = train_rnn()
total_loss_list_lstm, total_time_lstm, total_acc_list_lstm = train_lstm()
total_loss_list_gru, total_time_gru, total_acc_list_gru = train_gru()
# 2. 绘制 损失对比曲线(评估: 模型收敛速度)
# 2.1 创建画布. 0:图1
plt.figure(num=0, figsize=(10, 5))
# 2.2 绘制各模型损失曲线.
plt.plot(total_loss_list_rnn, label='RNN')
plt.plot(total_loss_list_lstm, label='LSTM')
plt.plot(total_loss_list_gru, label='GRU')
# 2.3 设置图表属性
plt.title('模型损失对比曲线')
plt.xlabel('训练步数(每100步)')
plt.ylabel('平均损失值')
plt.grid(visible=True, linestyle='--', alpha=0.7)
plt.legend(loc='upper left')
img_dir = os.path.join(os.path.dirname(__file__), 'img')
os.makedirs(img_dir, exist_ok=True)
plt.savefig(os.path.join(img_dir, 'loss_comparison.png'))
plt.show()
# 3. 绘制 训练耗时对比柱状图(评估: 模型计算效率)
# 3.1 创建画布. 1: 图2
plt.figure(num=1, figsize=(10, 5))
# 3.2 准备x轴 和 y轴标签内容.
x_data = ['RNN', 'LSTM', 'GRU']
y_data = [total_time_rnn, total_time_lstm, total_time_gru]
# 3.3 绘制柱状图
plt.bar(range(len(x_data)), y_data, tick_label=x_data)
# 3.4 设置图表属性
plt.title('模型耗时对比柱状图')
plt.savefig(os.path.join(img_dir, 'RNN_LSTM_GRU_time.png'))
plt.show()
# 4. 绘制 训练准确率对比曲线(评估: 模型效果)
# 4.1 创建画布. 2: 图3
plt.figure(num=2, figsize=(10, 5))
# 4.2 绘制各模型准确率曲线.
plt.plot(total_acc_list_rnn, label='RNN', color='red')
plt.plot(total_acc_list_lstm, label='LSTM', color='green')
plt.plot(total_acc_list_gru, label='GRU', color='orange')
# 4.3 绘制图表属性.
plt.title('模型准确率对比曲线')
plt.legend(loc='upper left')
plt.savefig(os.path.join(img_dir, 'RNN_LSTM_GRU_acc.png'))
plt.show()
if __name__ == '__main__':
# 1. 读取数据
# my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 2. 测试: 数据加载器.
# get_dataloader()
# 3. 测试RNN模型
# dm_test_myrnn()
# 4. 测试RNN, LSTM, GRU模型
# dm_test_rnn_lstm_gru()
# 5. 测试: 模型训练.
# train_rnn() # 训练完成,总耗时: 49s, 总训练了 20074个样本!!
# train_lstm() # 训练完成,总耗时: 48s, 总训练了 20074个样本!!
# train_gru() # 训练完成,总耗时: 78s, 总训练了 20074个样本!!
# 6. 测试: 模型训练绘图(即: 效果对比)
dm_test_train_rnn_lstm_gru()
损失每 100 条记一个点,20074 条人名一轮大约 200 个点。代码里 epochs = 1,所以自己跑出来的损失图横轴停在 200 附近,对应课件左图。课件右图和准确率图横轴到 800,是同一套代码把轮数改成 4 之后的结果。
63.1 损失对比曲线
损失降得快,说明这一段收敛快。
| 阶段 | 谁降得快 | 原因 |
|---|---|---|
| 第 1 轮 | RNN,然后是 GRU,最后是 LSTM | RNN 参数少,一开始见效快 |
| 训到 4 轮 | GRU 最低,LSTM 次之,RNN 停在最高 | 数据变多之后,门控模型把长一点的人名记得更稳 |
第 1 轮结束时,三条线都从 2 左右降到 1.1 到 1.3。训满 4 轮后,GRU、LSTM 降到 0.7 上下,RNN 还在 1.0 附近。模型怎么选,要看这次任务的曲线,不能按模型名字直接定。
63.2 训练耗时
耗时长短对应计算量。课件的柱状图里,RNN 大约是后两者的一半,GRU 次之,LSTM 最高。LSTM 每个时间步要维护隐藏状态和细胞状态,GRU 少一个细胞状态,传统 RNN 只有一套隐藏状态。
本机这一轮的注释是另一组数:RNN 49 秒,LSTM 48 秒,GRU 78 秒。排名会随机器和这一轮的数据顺序变化,和课件柱状图不必逐根对齐。
63.3 准确率对比曲线
4 轮之后,GRU 最高,大约 0.80;LSTM 紧挨着,大约 0.79;RNN 大约 0.72。三条线开头都在 0.45 附近,分开发生在后面的几百步。
| 看哪张图 | 本案例的顺序 |
|---|---|
| 第 1 轮损失 | RNN 收敛最快,其次 GRU,最后 LSTM |
| 多轮损失和准确率 | GRU 最好,其次 LSTM,RNN 最差 |
| 课件上的耗时 | RNN 最短,其次 GRU,LSTM 最长 |
综合这三张图,这个人名分类任务选 GRU。它在多轮之后损失更低、准确率更高,耗时又低于 LSTM。
QA:
以 RNN 为例,模型训练按什么顺序走?
和 train_rnn 的书写顺序一致。
- 数据三步。
read_data把人名和国家读进内存,NameClassDataset做成数据集,每一轮里的DataLoader再按batch_size=1打乱取出。 - 模型三件。
My_RNN、NLLLoss、Adam。课件把损失写成CrossEntropyLoss。本案例模型里已经有LogSoftmax,所以损失用NLLLoss。改成CrossEntropyLoss时,模型里就不再做LogSoftmax。 - 辅助量。
start_time、total_iter_num、total_loss、total_loss_list、total_acc_num、total_acc_list。 - 外层
for epoch in range(epochs)控制训几轮。 - 内层
for i, (x, y) in enumerate(...)逐条人名。一次循环里是:x[0]送进模型,criterion算损失,梯度清零,反向传播,optimizer.step()。每 100 条把平均损失和准确率放进列表,每 2000 条打印一行日志。 - 这一轮的内层循环结束后,
torch.save保存这一轮的state_dict。 - 全部轮次结束后返回
total_loss_list、total_time、total_acc_list。
这个案例选哪一个模型?
选 GRU。
第 1 轮的损失曲线上,RNN 降得最快;把轮数拉长之后,GRU 的损失更低,LSTM 次之。耗时上,课件里 RNN 最短,GRU 居中,LSTM 最长。准确率上,GRU 最高,LSTM 次之,RNN 最低。对着这三张图,GRU 兼顾效果和计算量。
64. 全球人名分类案例_RNN模型预测(掌握)
python
# todo 10. 模型预测.
# todo 10.1 RNN模型预测
# todo 10.1.1. 定义变量,记录模型的参数的路径.
my_rnn_path = os.path.join(os.path.dirname(__file__), 'model', 'my_rnn_wh02_1.bin')
my_lstm_path = os.path.join(os.path.dirname(__file__), 'model', 'my_lstm_wh02_1.bin')
my_gru_path = os.path.join(os.path.dirname(__file__), 'model', 'my_gru_wh02_1.bin')
# todo 10.1.2 定义函数,将要预测的人名 转成 one-hot编码,例如: 'zhang' → [5, 57]
def lineToTensor(line):
# 1. 初始化张量,[文本长度,字符表长度]
tensor_x = torch.zeros(len(line), n_letters)
# 2. 遍历文本,获取到每个字符及其索引.
for i, letter in enumerate(line):
# 3. 查看字符在全局字母表中的位置(索引)
letter_index = all_letters.find(letter)
# 4. 在张量的对应位置改为1,完成: one-hot编码
tensor_x[i][letter_index] = 1
# 5. 返回结果.
return tensor_x # 即: 'zhang' → [5, 57]
# todo 10.1.3 定义函数,实现: RNN预测.
def dm_predict_rnn(x):
# 1. 定义变量,记录模型相关参数.
n_letters, n_hidden, n_categories = 57, 128, 18
# 2. 把输入的文字转成 one-hot编码.
x_tensor = lineToTensor(x)
# 3. 创建模型对象.
my_rnn = My_RNN(n_letters, n_hidden, n_categories)
# 4. 加载模型参数.
my_rnn.load_state_dict(torch.load(my_rnn_path))
# 5. 进行预测,不计算梯度. → 节省内存和计算机资源.
with torch.no_grad():
# 5.1 模型预测
output, hidden = my_rnn(x_tensor, my_rnn.init_hidden())
# 5.2 从预测结果中,获取前3个最大的元素.
# 参1(k): 取前3个最大的元素.
# 参2(dim): 获取概率最大的元素所在的维度.
# 参3(largest): 获取概率最大的元素.
topv, topi = output.topk(3, 1, True)
# 5.3 打印待预测文本.
print(f'rnn(待预测文本): {x}')
# 5.4 解析预测结果.
for i in range(3):
value = topv[0][i].item() # 概率值 → Python的标量
category_idx = topi[0][i].item() # 类别索引
category = categories[category_idx] # 类别名称.
print(f'value: {value}, category: {category}')
# todo 10.2 LSTM模型预测(自己写)
def dm_predict_lstm(x):
# 1. 定义变量,记录模型相关参数.
n_letters, n_hidden, n_categories = 57, 128, 18
# 2. 把输入的文字转成 one-hot编码.
x_tensor = lineToTensor(x)
# 3. 创建模型对象.
my_lstm = My_LSTM(n_letters, n_hidden, n_categories)
# 4. 加载模型参数.
my_lstm.load_state_dict(torch.load(my_lstm_path))
# 5. 切到评估状态. 这个模型没有 Dropout、BatchNorm,数值和训练状态相同.
my_lstm.eval()
# 6. 预测,不记录梯度. LSTM 要同时传入隐藏状态和细胞状态.
with torch.no_grad():
hidden, c = my_lstm.init_hidden()
output, hidden, c = my_lstm(x_tensor, hidden, c)
# 7. 从预测结果中,获取前3个最大的元素.
topv, topi = output.topk(3, 1, True)
# 8. 打印待预测文本.
print(f'lstm(待预测文本): {x}')
# 9. 解析预测结果.
for i in range(3):
value = topv[0][i].item() # 对数概率 → Python的标量
category_idx = topi[0][i].item() # 类别索引
category = categories[category_idx] # 类别名称.
print(f'value: {value}, category: {category}')
# todo 10.3 GRU模型预测(自己写)
def dm_predict_gru(x):
# 1. 定义变量,记录模型相关参数.
n_letters, n_hidden, n_categories = 57, 128, 18
# 2. 把输入的文字转成 one-hot编码.
x_tensor = lineToTensor(x)
# 3. 创建模型对象.
my_gru = My_GRU(n_letters, n_hidden, n_categories)
# 4. 加载模型参数.
my_gru.load_state_dict(torch.load(my_gru_path))
# 5. 切到评估状态. 这个模型没有 Dropout、BatchNorm,数值和训练状态相同.
my_gru.eval()
# 6. 预测,不记录梯度. GRU 和 RNN 一样,只维护一份隐藏状态.
with torch.no_grad():
output, hidden = my_gru(x_tensor, my_gru.init_hidden())
# 7. 从预测结果中,获取前3个最大的元素.
topv, topi = output.topk(3, 1, True)
# 8. 打印待预测文本.
print(f'gru(待预测文本): {x}')
# 9. 解析预测结果.
for i in range(3):
value = topv[0][i].item() # 对数概率 → Python的标量
category_idx = topi[0][i].item() # 类别索引
category = categories[category_idx] # 类别名称.
print(f'value: {value}, category: {category}')
if __name__ == '__main__':
# 1. 读取数据
# my_list_x, my_list_y = read_data(os.path.join(os.path.dirname(__file__), 'data', 'name_classfication.txt'))
# 2. 测试: 数据加载器.
# get_dataloader()
# 3. 测试RNN模型
# dm_test_myrnn()
# 4. 测试RNN, LSTM, GRU模型
# dm_test_rnn_lstm_gru()
# 5. 测试: 模型训练.
# train_rnn() # 训练完成,总耗时: 49s, 总训练了 20074个样本!!
# train_lstm() # 训练完成,总耗时: 48s, 总训练了 20074个样本!!
# train_gru() # 训练完成,总耗时: 78s, 总训练了 20074个样本!!
# 6. 测试: 模型训练绘图(即: 效果对比)
# dm_test_train_rnn_lstm_gru()
# 7. 测试: 模型预测.
dm_predict_rnn('zhang')
以 dm_predict_rnn 为例,'zhang' 按这四步预测。
- 输入文本张量化。
lineToTensor('zhang')得到x_tensor,形状[5, 57]。5 是人名长度,57 是字母表大小,每个字符在自己那一行写成 1。 - 实例化模型,加载已训练参数。先
My_RNN(57, 128, 18)建一个结构相同的空模型,再load_state_dict(torch.load(my_rnn_path))读入my_rnn_wh02_1.bin。路径用脚本旁边的model目录。 - 在
with torch.no_grad():里做前向。output, hidden = my_rnn(x_tensor, my_rnn.init_hidden())。output是[1, 18]的对数概率,这一段不建计算图。 - 取出前 3 名并打印。
topv, topi = output.topk(3, 1, True)。topi[0][i]是国家下标,categories[category_idx]换成国家名,和topv里的对数概率一起打印。
拓展QA:
第 1333 行 with torch.no_grad(): 是干什么的?以前讲过吗?
这行打开一段「不记录梯度」的区域。里面的前向、topk、打印都在这个区域里。预测不调用 loss.backward(),也不调用 optimizer.step(),计算图留着没有用处。关掉记录之后,中间结果不必再挂在图上,内存和记账开销都少一些。离开 with 之后,梯度记录恢复原样。
前向本身照常算。output 仍是 [1, 18] 的对数概率,hidden 仍是最后的隐藏状态。差别只在这些张量没有为反向传播准备一份历史。
笔记里前面没有讲过 torch.no_grad。上一行注释只写了用途:预测时不计算梯度,省内存和计算。深度学习阶段讲过的是 model.train() 和 model.eval(),那一对管 Dropout、BatchNorm 在训练和推理时走哪套算法。这个 RNN 没有这两层,eval() 不改变这次前向的数值。这里要关的是梯度记录,所以用 no_grad。
训练循环里不能套这个区域。my_loss.backward() 要顺着前向留下来的计算图走回每个参数;图没记下来,反向传播就没有路径。
预测和训练的代码正常怎么写?
预测和训练各写一套。dm_predict_rnn 已经把前向放在 torch.no_grad() 里,这个写法对这个 RNN 是对的。习惯上再在前面加一行 eval()。
预测时先切到评估状态,再关掉梯度记录:
python
my_rnn.load_state_dict(torch.load(my_rnn_path))
my_rnn.eval()
with torch.no_grad():
output, hidden = my_rnn(x_tensor, my_rnn.init_hidden())
topv, topi = output.topk(3, 1, True)
eval() 管 Dropout、BatchNorm 走推理用的算法。这个 My_RNN 里没有这两层,加不加 eval(),算出来的 18 个对数概率一样。no_grad() 才是这里真正起作用的:前向照常算,只是不留下给 backward() 走的计算图。
训练时保持梯度记录,开头用 train(),循环里仍是现在这三步:
python
my_rnn.train()
output, hidden = my_rnn(x[0], my_rnn.init_hidden())
my_loss = criterion(output, y)
optimizer.zero_grad()
my_loss.backward()
optimizer.step()
train_rnn 里已经是后三行。外面包上 no_grad() 之后,backward() 就没有计算图可走。
跳过梯度是怎么实现的?不就是不反向传播了吗?
从结果上看,就是没有做反向传播,权重不会变。torch.no_grad() 做的是更早的一步:前向计算时不建计算图,所以后面也没有反向可跑。
PyTorch 里有一个线程上的开关,torch.is_grad_enabled() 能读到它。with torch.no_grad(): 进入时把开关拨到关,离开时拨回原来的状态。前向里每一次乘法、加法、激活都会看这个开关。开关开着,这一步的结果挂上 grad_fn,并留下反向要用的中间值。开关关着,这一步只算出数,output.grad_fn 是空的,中间值不保存。
不写 backward(),反向确实没跑,但前向时图已经记过了,用完再丢。no_grad 是前向当时就不记这张图。
| 前向建图 | 调用 backward() |
权重 | |
|---|---|---|---|
| 训练 | 建 | 调用,梯度写进参数的 .grad,再 step() |
更新 |
预测时只是不写 backward() |
仍然建,算完图被丢掉 | 不调用 | 不更新,但前向已经花了建图的内存 |
with torch.no_grad() |
不建 | 没图可走 | 不更新,前向也不记账 |
预测函数里两件事叠在一起:不建图,也不调用 backward()。只去掉 backward() 已经不会改权重。
预测时不做反向传播,正确率不就会变差吗?
预测阶段梯度正常就是不需要的,因为它不影响这次的预测结果。前向只用已经训好的权重算出 18 个对数概率,topk 再取出前 3 个国家。梯度是给训练时的 backward() 和 optimizer.step() 用的,用来改权重。预测不改权重,所以这份记录可以不建。torch.no_grad() 就是把这件事关掉。
正确率是训练阶段攒出来的。每一条人名走完前向、backward()、optimizer.step(),权重才挪一点。训完写进 my_rnn_wh02_1.bin 的就是这份权重。预测时 load_state_dict 把它读回来,再只做前向。这一步不改权重,所以有没有 backward(),打印出来的国家和对数概率都一样。
torch.no_grad() 也不改这些数。它只是前向时不建计算图,乘法和 LogSoftmax 的结果与建图时相同。
正确率会差,发生在训练阶段就关掉反向传播的时候。权重停在随机初始化,模型没有从 20074 条人名里学到东西,预测就接近乱猜。
65. 全球人名分类案例_思路总结(理解)
自己联系代码如下:
python
"""
晚上练习,在 dm01 的基础上做这几件事:
1. 先用 RNN 跑一版: 导包 → 数据预处理 → 构建模型 → 训练 → 预测
2. 再用 LSTM 跑一版
3. 再用 GRU 跑一版
4. 最终版: 三个模型合在一起,训练结果绘图
5. 优化:
1. 损失改用 CrossEntropyLoss,模型不再写 LogSoftmax
2. get_dataloader 直接返回数据加载器
3. 损失、耗时、准确率列表存到文件,绘图时直接加载
"""
import os
os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE'
import time
import string
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import matplotlib.pyplot as plt
from tqdm import tqdm
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_PATH = os.path.join(BASE_DIR, '..', 'data', 'name_classfication.txt')
MODEL_DIR = os.path.join(BASE_DIR, 'model')
RESULT_DIR = os.path.join(BASE_DIR, 'result')
IMG_DIR = os.path.join(BASE_DIR, 'img')
all_letters = string.ascii_letters + " .,;'"
n_letters = len(all_letters)
categories = ['Italian', 'English', 'Arabic', 'Spanish', 'Scottish', 'Irish', 'Chinese', 'Vietnamese', 'Japanese', 'French', 'Greek', 'Dutch', 'Korean', 'Polish', 'Portuguese', 'Russian', 'Czech', 'German']
category_num = len(categories)
my_lr, epochs = 1e-3, 1
HIDDEN_SIZE = 128
def read_data(file_path):
my_list_x, my_list_y = [], []
with open(file_path, 'r', encoding='utf-8') as f:
for line in f.readlines():
if len(line) <= 5:
continue
x, y = line.strip().split('\t')
my_list_x.append(x)
my_list_y.append(y)
print(f'my_list_x: {len(my_list_x)}')
print(f'my_list_y: {len(my_list_y)}')
return my_list_x, my_list_y
class NameClassDataset(Dataset):
def __init__(self, my_list_x, my_list_y):
self.my_list_x = my_list_x
self.my_list_y = my_list_y
self.sample_len = len(my_list_x)
def __len__(self):
return self.sample_len
def __getitem__(self, index):
index = min(max(index, 0), self.sample_len - 1)
x = self.my_list_x[index]
y = self.my_list_y[index]
tensor_x = torch.zeros(len(x), n_letters)
for li, letter in enumerate(x):
tensor_x[li][all_letters.find(letter)] = 1
tensor_y = torch.tensor(categories.index(y), dtype=torch.long)
return tensor_x, tensor_y
def get_dataloader():
"""读取数据并直接返回 DataLoader。每轮训练再用同一份数据集重新打乱。"""
my_list_x, my_list_y = read_data(DATA_PATH)
dataset = NameClassDataset(my_list_x, my_list_y)
return DataLoader(dataset, batch_size=1, shuffle=True)
class My_RNN(nn.Module):
def __init__(self, input_size, hidden_size, output_size, n_layers=1):
super().__init__()
self.n_layers = n_layers
self.hidden_size = hidden_size
self.rnn = nn.RNN(input_size, hidden_size, n_layers)
self.linear = nn.Linear(hidden_size, output_size)
def forward(self, input, hidden):
input = input.unsqueeze(1)
output, hn = self.rnn(input, hidden)
# 返回原始分数。CrossEntropyLoss 内部会做 LogSoftmax。
return self.linear(output[-1]), hn
def init_hidden(self):
return torch.zeros(self.n_layers, 1, self.hidden_size)
class My_LSTM(nn.Module):
def __init__(self, input_size, hidden_size, output_size, n_layers=1):
super().__init__()
self.n_layers = n_layers
self.hidden_size = hidden_size
self.rnn = nn.LSTM(input_size, hidden_size, n_layers)
self.linear = nn.Linear(hidden_size, output_size)
def forward(self, input, hidden, c):
input = input.unsqueeze(1)
output, (hn, cn) = self.rnn(input, (hidden, c))
return self.linear(output[-1]), hn, cn
def init_hidden(self):
hidden = torch.zeros(self.n_layers, 1, self.hidden_size)
c = torch.zeros(self.n_layers, 1, self.hidden_size)
return hidden, c
class My_GRU(nn.Module):
def __init__(self, input_size, hidden_size, output_size, n_layers=1):
super().__init__()
self.n_layers = n_layers
self.hidden_size = hidden_size
self.rnn = nn.GRU(input_size, hidden_size, n_layers)
self.linear = nn.Linear(hidden_size, output_size)
def forward(self, input, hidden):
input = input.unsqueeze(1)
output, hn = self.rnn(input, hidden)
return self.linear(output[-1]), hn
def init_hidden(self):
return torch.zeros(self.n_layers, 1, self.hidden_size)
MODEL_MAP = {
'rnn': My_RNN,
'lstm': My_LSTM,
'gru': My_GRU,
}
def build_model(model_name):
return MODEL_MAP[model_name](n_letters, HIDDEN_SIZE, category_num)
def forward_one(model, model_name, x):
if model_name == 'lstm':
hidden, c = model.init_hidden()
output, hidden, c = model(x, hidden, c)
return output
output, hidden = model(x, model.init_hidden())
return output
def record_path(model_name):
return os.path.join(RESULT_DIR, f'{model_name}_record.pt')
def weight_path(model_name):
return os.path.join(MODEL_DIR, f'my_{model_name}_1.bin')
def save_record(model_name, loss_list, total_time, acc_list):
os.makedirs(RESULT_DIR, exist_ok=True)
torch.save({
'loss': loss_list,
'time': total_time,
'acc': acc_list,
}, record_path(model_name))
print(f'记录已保存: {record_path(model_name)}')
def load_record(model_name):
path = record_path(model_name)
if not os.path.exists(path):
return None
record = torch.load(path)
print(f'记录已加载: {path}')
return record
def train_one(model_name):
"""训练一种模型,并把损失、耗时、准确率列表存到文件。"""
loader = get_dataloader()
dataset = loader.dataset
model = build_model(model_name)
model.train()
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=my_lr)
start_time = time.time()
total_iter_num = 0
total_loss = 0.0
total_loss_list = []
total_acc_num = 0
total_acc_list = []
for epoch in range(epochs):
print(f'\n开始第{epoch + 1}/{epochs} 轮 {model_name.upper()} 训练...')
train_dataloader = DataLoader(dataset, batch_size=1, shuffle=True)
for x, y in tqdm(train_dataloader):
output = forward_one(model, model_name, x[0])
my_loss = criterion(output, y)
optimizer.zero_grad()
my_loss.backward()
optimizer.step()
total_iter_num += 1
total_loss += my_loss.item()
pred_tag = torch.argmax(output).item()
total_acc_num += (1 if pred_tag == y.item() else 0)
if total_iter_num % 100 == 0:
total_loss_list.append(total_loss / total_iter_num)
total_acc_list.append(total_acc_num / total_iter_num)
if total_iter_num % 2000 == 0:
avg_loss = total_loss / total_iter_num
avg_acc = total_acc_num / total_iter_num
used = int(time.time() - start_time)
print(f'轮次: {epoch + 1}, 训练的样本数: {total_iter_num}, 平均损失: {avg_loss:.4f}, 耗时: {used}s, 准确率: {avg_acc:.4f}')
os.makedirs(MODEL_DIR, exist_ok=True)
torch.save(model.state_dict(), weight_path(model_name))
total_time = int(time.time() - start_time)
print(f'{model_name.upper()} 训练完成,总耗时: {total_time}s, 总训练了 {total_iter_num}个样本!!')
save_record(model_name, total_loss_list, total_time, total_acc_list)
return total_loss_list, total_time, total_acc_list
def line_to_tensor(line):
tensor_x = torch.zeros(len(line), n_letters)
for i, letter in enumerate(line):
tensor_x[i][all_letters.find(letter)] = 1
return tensor_x
def predict_one(model_name, text):
model = build_model(model_name)
model.load_state_dict(torch.load(weight_path(model_name)))
model.eval()
x_tensor = line_to_tensor(text)
with torch.no_grad():
output = forward_one(model, model_name, x_tensor)
topv, topi = output.topk(3, 1, True)
print(f'{model_name}(待预测文本): {text}')
for i in range(3):
value = topv[0][i].item()
category = categories[topi[0][i].item()]
print(f'value: {value}, category: {category}')
def train_and_predict(model_name, text='zhang'):
train_one(model_name)
predict_one(model_name, text)
def load_three_records():
records = {}
for name in ('rnn', 'lstm', 'gru'):
record = load_record(name)
if record is None:
return None
records[name] = record
return records
def plot_three(records):
os.makedirs(IMG_DIR, exist_ok=True)
names = ('rnn', 'lstm', 'gru')
labels = ('RNN', 'LSTM', 'GRU')
plt.figure(num=0, figsize=(10, 5))
for name, label in zip(names, labels):
plt.plot(records[name]['loss'], label=label)
plt.title('模型损失对比曲线')
plt.xlabel('训练步数(每100步)')
plt.ylabel('平均损失值')
plt.grid(visible=True, linestyle='--', alpha=0.7)
plt.legend(loc='upper left')
plt.savefig(os.path.join(IMG_DIR, 'loss_comparison.png'))
plt.show()
plt.figure(num=1, figsize=(10, 5))
x_data = list(labels)
y_data = [records[name]['time'] for name in names]
plt.bar(range(len(x_data)), y_data, tick_label=x_data)
plt.title('模型耗时对比柱状图')
plt.savefig(os.path.join(IMG_DIR, 'RNN_LSTM_GRU_time.png'))
plt.show()
colors = ('red', 'green', 'orange')
plt.figure(num=2, figsize=(10, 5))
for name, label, color in zip(names, labels, colors):
plt.plot(records[name]['acc'], label=label, color=color)
plt.title('模型准确率对比曲线')
plt.legend(loc='upper left')
plt.savefig(os.path.join(IMG_DIR, 'RNN_LSTM_GRU_acc.png'))
plt.show()
def run_final():
"""最终版: 三个记录都在就直接加载绘图,缺哪个就先训练哪个。"""
records = {}
for name in ('rnn', 'lstm', 'gru'):
record = load_record(name)
if record is None:
loss_list, total_time, acc_list = train_one(name)
record = {'loss': loss_list, 'time': total_time, 'acc': acc_list}
records[name] = record
plot_three(records)
if __name__ == '__main__':
# 1. 先用 RNN 把案例跑一版.
# train_and_predict('rnn')
# 2. 再用 LSTM 把案例跑一版.
# train_and_predict('lstm')
# 3. 再用 GRU 做一版.
# train_and_predict('gru')
# 4. 最终版: 三个模型合并,训练结果绘图。已有记录就直接加载。
run_final()
66. 注意力机制介绍(了解)
注意力机制的本质:用有限的精力,从海量信息里快速筛出高价值信息。人脑天生会抓重点、放过次要信息,这样才能处理世界上那么多信息。
66.1 从人眼看注意力
人类视觉在长期进化里形成了一套注意力机制,它是大脑的信号处理方式。看一张图时,眼睛先快速扫一遍全局,再把精力放到需要重点看的区域。
右图是热力图。颜色越亮,注意力放得越多。人的注意力资源有限,会从大量信息里挑出高价值的部分,例如人脸、文本标题、文章首句。
这套做法可以引进深度神经网络:让模型也把算力花在输入里更重要的位置。
66.2 注意力机制是什么
观察一件事物时能很快判断它是什么,是因为大脑把注意力放在最有辨识度的地方。判断来自这个重点,不必先把事物从头到尾看完。注意力机制就是按这个想法做出来的。
它是深度学习里的常用技术。处理序列时,模型可以按需要去看输入的不同部分。
| 用在哪 | 具体任务 |
|---|---|
| 自然语言处理 | 机器翻译(例如 Transformer)、文本摘要、问答 |
| 图像 | 图像描述生成、图像分类 |
| 多模态 | 图像和文本之间的对应关系 |
它帮助模型理解和利用输入里真正有用的信息。
66.3 为什么要在 RNN 之外再引入注意力
人名分类里,RNN、LSTM、GRU 都是一个时间步接一个时间步往前走,最后用最后一个时间步的隐藏状态做预测。和这种提特征的方式比,注意力解决的是下面两件事。
| RNN 怎么提特征 | 带注意力的模型怎么提特征 | |
|---|---|---|
| 计算方式 | 必须按时间步顺序处理,前一步算完才能算下一步,无法并行,效率低 | 各位置可以并行提取特征 |
| 长序列 | 前面词的特征会慢慢忘掉,序列里没有重点,抓不住全局关键信息 | 能注意到关键点,动态关注输入里重要的部分,减轻长信息遗忘 |
| 结果 | 整句压成最后一个隐藏状态再往下用 | 提特征更灵活 |
课堂上的翻译例子:英文 Welcome to WuHan! 译成「欢迎」时,注意力权重可以是 0.8, 0.1, 0.1。「欢迎」主要对齐 Welcome,其余两个词只分到很少的权重。模型不必把整句平均看完,也不必只靠最后一个词的隐藏状态。
下一问留到实现:特征张量怎么送进注意力机制,注意力又按哪几步把事物特征提出来。
67. 注意力机制_QKV简介(理解)
QKV 是注意力的输入写法。三者分工明确,一起完成「聚焦关键信息」:Query 是问题,Key 是索引,Value 是内容。模型用 Query 去和每个 Key 比相似度,再按这个权重从 Value 里取出实际内容。
| 名字 | 是什么 | 在计算里干什么 |
|---|---|---|
| Query(Q,查询向量) | 当前要解决的问题,触发注意力计算的需求向量 | 表达这次的任务需求 |
| Key(K,键向量) | 被关注内容的索引,也就是关键字 | 和 Q 算相似度,标出最相关的位置 |
| Value(V,值向量) | 被关注的实际内容 | 按相似度权重加权,得到最终结果 |
三步合在一起:
- 用 Query 写出当前要做什么。
- 用 Key 当索引,和 Query 算相似度,找到最相关的信息在哪。
- 用 Value 当实际内容,按相似度权重输出最终结果。
Q、K、V 是注意力的核心组件。它们配合起来,模型就能按需要去看输入的不同部分,表达能力也跟着变强。
67.1 档案柜里找文件
| 档案柜 | 对应谁 | |
|---|---|---|
| Q | 正在研究的课题,写在便利贴上 | 查询向量 |
| K | 文件夹上的标签,例如 key#1、key#2 |
索引 |
| V | 文件夹里的文档、书籍 | 要查的内容 |
便利贴先和各个标签比相关程度,对上的那个文件夹打开,里面的资料才是要拿的内容。Q 和 K 比的是相似度,关心的内容从 V 里取。
67.2 三个任务里的 QKV
| 任务 | Query | Key | Value | 怎么配合 |
|---|---|---|---|---|
| 新闻问答 | 这个新闻的主题是关于什么的? | 这个新闻报道了当地政府的新政策。 | 这个新闻说了政府的新政策是什么。 | Q 在问主题。Q 和 K 一对,对上「新政策」这条。V 再给出报道里的具体内容 |
| 文本生成 | 请给我生成一段关于环保的短文。 | 环保。 | 我们应该采取一系列措施来保护环境。 | Q 和 K 一对,确定要写的是环保。V 给出一条具体建议,用来生成短文 |
| 文本摘要 | 这篇文章的主要内容是什么? | 这篇文章介绍了最新的科学发现。 | 最新的科学发现证实了某种假设。 | Q 和 K 一对,确定主要内容是科学发现。V 提供发现的具体内容,用来写摘要 |
翻译例子接上一节:Welcome to WuHan! 译成「欢迎 来 武汉」,相似度权重是 0.8, 0.1, 0.1。权重最大的位置提供主要的 Value,其余位置只占很少一份。
下一问留到实现:QKV 这种输入已经清楚了,注意力具体按哪几步算出来。
68. 注意力机制_实现步骤介绍(理解)
注意力的计算就是两步:先算权重,再加权求和。attention 是关于 Q、K、V 的函数,普通的 q 经过这次运算,升级成一个融合了相关内容的、更强的 q。
attention(Q, K, V) \\rightarrow attention_weights,\\ attention_q
| 步 | 在算什么 | 得到什么 |
|---|---|---|
| 第 1 步 | 查询张量 q 和每个 key 做相似度运算。点积、余弦相似度都可以 | 注意力权重分布 attention_weights,一组权重系数 |
| 第 2 步 | 用这组权重去乘内容 v,再加起来 | 注意力的结果表示 attention_q |
第 1 步是拿当前问题(Query)和所有索引(Key)比,算出每个索引和这个问题有多相关,得到权重数组。第 2 步把上一步的重要程度系数乘到对应的 value 上,全部加起来,就是最终聚出来的向量。
it 指的是谁
句子来自机器人第二定律:
A robot must obey the orders given it by human beings except where such orders would conflict with the First Law.
机器人必须服从人类给它的命令,除非这些命令与第一定律相冲突。第一定律是:机器人不能伤害人类。
阅读理解要问的是:句中的 it 指代谁。这句话拆成 QKV:
| 这个例子里是谁 | |
|---|---|
| q | 要查的问题,就是 it 的词向量 |
| k | 每个单词的索引 |
| v | 每个单词的词向量 |
文本大约 21 个词,开头是 <s> a robot must obey the orders given it ...。q 是 it 这一个词,所以它和 21 个词各算一个相似度,权重分布的形状是 [1, 21]。课件表格里靠前的几行是:
| 词 | Q 和 K 的相似度(权重) |
|---|---|
<s> |
0.001 |
a |
0.3 |
robot |
0.5 |
must |
0.002 |
obey |
0.001 |
the |
0.0003 |
orders |
0.005 |
given |
0.002 |
it |
0.19 |
权重大的三处:robot 占 50%,a 占 30%,it 自己占 19%。其余词接近 0。
第 2 步按这个权重把词向量加起来。课件假设每个词向量是 3 维,加权之后仍然是 3 维:
attention_q = 0.001 \\cdot v_{} + 0.3 \\cdot v_{a} + 0.5 \\cdot v_{robot} + \\cdots + 0.19 \\cdot v_{it}
attention_q 已经不是孤立的 it 向量。它把整句里最相关的信息融了进去,一半以上来自 robot。模型据此判断 it 最可能指 robot,指代就消掉了。
这就是「普通的 q 升级成一个更加强大的 q」:输出仍是一个向量,里面动态聚合了和当前任务最相关的内容。
总结QA:
1. 说一说什么是注意力机制?
深度学习里提特征时用的一种技术。模型把精力放在事物最有辨识度的地方,从而更好地利用输入里的信息。近几年它在计算机视觉、自然语言处理、多模态里都在用。
2. 与 RNN 提取事物特征对比,说一说为什么要引入注意力机制?
RNN 一个时间步接一个时间步提特征,串行,效率低。序列一长,前面单词的特征会遗忘,没有重点。带注意力的模型可以并行提特征,效率高,而且能注意到关键点。提特征因此更灵活。
3. 说一说注意力机制的 QKV 代表什么?
Q 是查询张量,相当于正在研究的课题,比如写在便利贴上。K 是索引张量,相当于文件夹上贴的标签,比如 key#1、key#2。V 是内容张量,相当于正在查找的内容,比如文档、书籍资料。Q 和 K 比较相关性(相似度),得到注意力的权重分布,再用这组权重对 V 加权求和,得到新的注意力结果,也就是一个新的、更加强大的 q。
4. 说一说注意力机制的实现步骤?
第 1 步:用查询张量 q 与 key 做相似度运算,得到注意力的权重分布,也就是一组权重系数。第 2 步:用权重分布乘以内容,得到注意力的结果表示。最终目标是 attention(Q, K, V) --> attention_weights, attention_q。attention 是关于 QKV 的函数,通过这次运算,普通的 q 升级成一个更加强大的 q。
单选题:
1. 在自然语言处理中,注意力机制的作用是()。
- A. 提升计算速度
- B. 提取文本特征
- C. 帮助模型集中关注输入的不同部分
- D. 减少模型的复杂度
答案:C。帮助模型集中关注输入的不同部分。
2. 在注意力机制中,Q、K、V 分别代表什么意思?
- A. Query、Key、Value
- B. Question、Knowledge、Verification
- C. Qualify、Knowledge、Value
- D. Query、Keyword、Variable
答案:A。Query、Key、Value。
3. 注意力机制的一个重要应用是在机器翻译中,它如何帮助提升翻译质量?
- A. 增加模型的参数
- B. 使模型能够根据源语言和目标语言的不同部分动态调整关注度
- C. 缩短训练时间
- D. 增加模型的深度
答案:B。使模型能够根据源语言和目标语言的不同部分动态调整关注度。课件解析写的是:就是给模型增加注意力机制。
69. Seq2Seq架构_任务介绍(理解)
Seq2Seq(Sequence-to-Sequence)把一段输入序列先压成一个中间表示,再从这个中间表示生成目标序列。机器翻译、文本摘要、对话系统都是这种「序列进、序列出」的任务。
架构分成三部分:编码器(Encoder)、解码器(Decoder)、中间语义张量 (C)。课件里把 (C) 叫信息包:编码器把整句收成这一个包,交给解码器去生成。
| 部分 | 干什么 |
|---|---|
| Encoder | 理解输入。一个时间步接一个时间步编码,每步都有隐藏层输出,最后收成中间语义张量 (C) |
| (C) | 固定长度的向量,装着整句的语义 |
| Decoder | 生成输出。一个时间步接一个时间步解码,每步都用到 (C) |
69.1 编码器
编码器把输入序列(例如一段文本)映射成中间语义张量 (C),通常是一个固定长度的向量。常用结构是 RNN 或 LSTM,近年来也包括 Transformer。
输入文本的每个时间步都产生一个隐藏状态。这个隐藏状态抓住当前步的信息,再传给下一步。整句处理完后,最后一个时刻的隐藏状态当作解码器的初始隐藏状态。
以「欢迎 来 北京」为例,三个词先过 Embedding,得到词向量 (v_0, v_1, v_2)。编码器每步的输入是这次的词向量,再加上一时刻的隐藏状态:
| 步 | 本次输入 | 上一时刻隐藏状态 | 本步输出 |
|---|---|---|---|
| 欢迎 | (v_0) | (h_0) | (h_1) |
| 来 | (v_1) | (h_1) | (h_2) |
| 北京 | (v_2) | (h_2) | 再往下收成 (C) |
图上是三个 GRU:(h_0 \rightarrow h_1 \rightarrow h_2),最后合成信息包 (C),传给解码器。
69.2 解码器
解码器也是 RNN 系列模型。它收下编码器传来的初始隐藏状态,再一步步生成目标序列。生成过程里通常还会加注意力,让它在每个词上动态去看输入序列的不同部分。
解码按自回归进行:上一时间步的输出,当作下一时间步的输入。每个时间步的输入是当前 input 和上一隐藏状态 (h_{t-1}),输出是这一步的 output 和 (h_t)。output 再接一个全连接层加 softmax 做分类,从分类结果里取出预测词。每一步都会用到中间语义张量 (C)。
「欢迎 来 北京」译成英文时,解码从 _GO 开始,拿到 (C),结合解码器自己的 Embedding,用 GRU 生成英文。每步都送入 (C),上一时刻的隐藏状态是 (s_0, s_1, s_2)。生成顺序是:
_GO → welcome → to → BeiJing → _EOS
_EOS 表示句子生成结束。
69.3 (C) 的长度问题和注意力
原生 Seq2Seq 的 (C) 长度是固定的。输入一长,前面的信息会丢,翻译和生成的效果就下降。
改进是加上注意力。解码器每生成一个词,都能动态关注输入序列里对应的那一部分,长序列也好处理。注意力解决的是「信息利用不够细」:一个信息包把整句揉在一起,生成时对不上原词。
| 正在生成 | 多看编码器里的哪一部分 |
|---|---|
welcome |
「欢迎」 |
to |
「来」 |
BeiJing |
「北京」 |
这样比只靠一个信息包更细。生成每个词时都能回到原文里找对应信息,翻译更准,也更自然。
70. Seq2Seq架构_加入注意力机制(理解)
注意力加在解码里。解码仍是一个时间步接一个时间步,但每一步都要引入 QKV,把这一步普通的查询向量 q 升级成更强的 (q^{\wedge}),再送去解码。
70.1 每个解码步做什么
不加注意力时,把当前要生成的词的查询张量 q 送给 GRU 解码,再经全连接做分类,得到这个词的词向量表示。例如生成 welcome,送进去的就是 welcome 的 q。
加上注意力之后,这一步先把 q 升级成 (q^{\wedge})。生成 welcome 时,先看它和「欢迎」「来」「北京」的权重分布,再用这组权重对中间语义张量 (C) 加权求和,得到新的 (q^{\wedge})。(q^{\wedge}) 才送给 GRU,后面仍是全连接加分类。
图上两次生成的权重是:
| 正在生成 | 对「欢迎」「来」「北京」的权重 |
|---|---|
welcome |
0.8,0.1,0.1 |
to |
0.3,0.5,0.2 |
welcome 的大头在「欢迎」上,to 的大头在「来」上。
70.2 这里的 q、k、v
| 在这次解码里是谁 | |
|---|---|
| q | 词嵌入之后的张量。查询目标是谁,q 就是谁的查询张量。生成 welcome 时,q 就是 welcome 的查询张量。这个目标要先定下来 |
| k | 上一时间步的隐藏层输出。用来和 q 算相似度,得到对「欢迎」「来」「北京」的权重分布 |
| v | 中间语义张量 (C)。权重分布乘上 (C),加权求和,得到 (q^{\wedge}) |
70.3 权重一开始不准怎么办
生成 to 时,假如算出的权重是 [0.1, 0.8, 0.1],分布不准,不用在这一步里手工改。
注意力要放在整个网络的三件套里看:前向计算、损失函数、反向传播。它只是前向路径里的一小段。每一步先照常算出权重和 (q^{\wedge})。权重参数准不准,靠损失函数和反向传播去更新注意力层,模型从数据里把「该看哪个词」学出来。
70.4 练习
1. 添加注意力机制后,Seq2Seq 模型在生成输出时的特点是()。
- A. 模型会根据输入的不同部分动态调整关注度
- B. 模型的生成过程与没有注意力机制时无区别
- C. 模型会忽略输入的不同部分,只关注整体信息
- D. 注意力机制只会影响模型的训练速度,不会影响生成结果的准确性
答案:A。模型会根据输入的不同部分动态调整关注度。
2. 有关注意力机制的下列说法正确的是?(多选)
- A. seq2seq 架构中添加注意力机制,一般是在解码的时候添加
- B. seq2seq 架构编码的时候,可以使用 RNN 提取文本序列特征,在解码的时候可以使用 LSTM 提取事物特征
- C. 注意力机制只是前向计算中的一个小的计算路径。一开始每个时间步产生的注意力权重分布可能不准确,需要通过反向传播来学习事物特征,进而让模型能从数据中快速获取事物特征
- D. 注意力机制很重要,但是不要忘记整个神经网络三件事(前向计算、损失函数、反向传播)组合在一起,相互配合才行
答案:ABCD。要从更高、更深的角度理解注意力机制。
71. 扩展1_普通的Encoder_Decoder框架(理解)
普通 Encoder-Decoder 是由一个句子(或篇章)生成另一个句子(或篇章)的通用模型。给定输入句子 Source,经过这个框架生成目标句子 Target。Source 和 Target 可以是同一种语言,也可以是两种语言,各自都是单词序列。图上是 (X_1, X_2, X_3, X_4) 进编码器,语义编码 (c) 再进解码器,出来 (Y_1, Y_2, Y_3)。输入长度和输出长度可以不一样。
Source = \\langle X_1, X_2 \\cdots X_m \\rangle
Target = \\langle y_1, y_2 \\cdots y_n \\rangle
71.1 编码和解码各算什么
编码器对 Source 做非线性变换,得到中间语义表示 (C)。课件里这个变换写成 GRU 那一类:
C = F(X_1, X_2 \\cdots X_m)
解码器的任务是:根据 (C),再加上已经生成的历史 (y_1, y_2 \cdots y_{i-1}),生成第 (i) 个词 (y_i)。
y_i = G(C, y_1, y_2 \\cdots y_{i-1})
拆开看,目标句前三个词是:
y_1 = f(C)
y_2 = f(C, y_1)
y_3 = f(C, y_1, y_2)
(f) 是解码器的非线性变换。生成 (y_1)、(y_2)、(y_3) 时,用到的 Source 语义编码都是同一个 (C)。
71.2 为什么说它注意力不集中
上面这张图里没有注意力,可以看成注意力不集中的分心模型。(C) 是编码器对整个 Source 编出来的。目标句里任意一个词 (y_i),看到的都是这同一个 (C),所以 Source 里每个单词对 (y_i) 的影响力都相同。生成「welcome」和生成「to」时,模型对「欢迎」「来」「北京」没有区别对待。这就是图 1 没有体现注意力的原因。
71.3 注意力的三类
给模型的每一个输入项分配一个权重,输入项可以是图片的一块,也可以是句子里的一个词。权重大小就是希望模型对这一块的关注程度。用权重大小模拟人处理信息时的侧重,模型效果会好一些,计算量也会降一些。
深度学习里的注意力通常分三类:
| 类型 | 权重怎么给 | 特点 |
|---|---|---|
| 软注意(Soft / Global Attention,全局注意) | 每个输入项的权重在 0 和 1 之间。有的部分多看一点,有的少看一点 | 大部分信息都考虑了,只是程度不同,计算量相对大 |
| 硬注意(Hard / Local Attention,局部注意,了解即可) | 每个输入项的权重非 0 即 1。要看的留下,不看的直接丢掉 | 能省一些时间和计算,也可能丢掉本该注意的信息 |
| 自注意力(Self / Intra Attention,内注意) | 每个输入项的权重由输入项之间的相互作用决定,靠输入项内部的「表决」决定该看哪些项 | 处理很长的输入时,可以并行计算 |
2026.10.09 21:12
72. 扩展2_加入注意力机制的Encoder_Decoder框架(理解)
target 里任意一个词,都有一份对 source 各词的注意力分配概率。生成这个词时的中间语义按这份概率重算,所以每一步用的 (C) 各不相同。
编码器仍读入 (X_1, X_2, X_3, X_4)。解码器生成 (Y_1, Y_2, Y_3) 时,分别拿 (C_1, C_2, C_3):(C_1) 进第一步,(C_2) 进第二步,(C_3) 进第三步。
72.1 为什么每个目标词要单独分配注意力
机器翻译里,source 是 Tom chase Jerry,target 是「汤姆」「追逐」「杰瑞」。
生成「杰瑞」时,普通框架里 Tom、Chase、Jerry 对这个中文词的贡献一样大。译「杰瑞」时,英文里的 Jerry 更该被看重。
加上注意力之后,生成「杰瑞」要体现出每个英文词的影响不一样。可以给出这样一组概率:
| 英文词 | 生成「杰瑞」时的注意力 |
|---|---|
| Tom | 0.3 |
| Chase | 0.2 |
| Jerry | 0.5 |
每个数是注意力模型在翻译当前词「杰瑞」时,分给对应英文词的注意力大小。Jerry 分到 0.5,比另外两个词都大。
因此,target 中任意一个单词,都对应一套 source 单词的注意力分配概率。生成该词时的中间语义,跟着这组概率变化。
72.2 生成公式
(f_1) 根据这一步的信息包 (C_i),再加上已经生成的历史词,推出新词。
y_1 = f_1(C_1)
y_2 = f_1(C_2, y_1)
y_3 = f_1(C_3, y_1, y_2)
(y_1) 只看 (C_1)。从 (y_2) 起,还要带上前面已经生成的词。
72.3 每个 (C_i) 对应一套权重
每个 (C_i) 可以对应不同的源语单词注意力分配。上面这句英译中,三份中间语义可以是:
C_{Tom} = g(0.6 \\cdot f_2(Tom),\\ 0.2 \\cdot f_2(Chase),\\ 0.2 \\cdot f_2(Jerry))
C_{Chase} = g(0.2 \\cdot f_2(Tom),\\ 0.7 \\cdot f_2(Chase),\\ 0.1 \\cdot f_2(Jerry))
C_{Jerry} = g(0.3 \\cdot f_2(Tom),\\ 0.2 \\cdot f_2(Chase),\\ 0.5 \\cdot f_2(Jerry))
(f_2) 是编码器对每个输入词做的变换。编码器如果是 RNN,(f_2) 的结果往往是该词输入之后隐藏层的状态,也可以先理解成这个词经过基础处理后的表示。(g) 把这些结果按注意力系数加权求和,合成这一步的整句中间语义。权重就是注意力分配系数。
一般写成:
C_i = \\sum_{j=1}\^{L_x} a_{ij} h_j
| 符号 | 含义 |
|---|---|
| (L_x) | source 的长度。Tom、Chase、Jerry 就是 3 个词,(L_x = 3) |
| (a_{ij}) | 输出 target 第 (i) 个词时,source 第 (j) 个词的注意力系数。也就是上面的 0.6、0.2、0.2 这一类数,表示第 (j) 个输入词对生成第 (i) 个目标词有多重要 |
| (h_j) | source 第 (j) 个词的语义编码,即这个词经过 (f_2) 之后的结果。例如 (h_1 = f_2(Tom)),(h_2 = f_2(Chase)),(h_3 = f_2(Jerry)) |
(g) 本质上就是这个加权求和。
72.4 生成「汤姆」时 (C) 怎么拼出来
生成「汤姆」时,三个输入词先各自过 (f_2),得到 (f_2(Tom))、(f_2(Chase))、(f_2(Jerry))。注意力系数是:
| 系数 | 词 | 值 |
|---|---|---|
| (a_{11}) | Tom | 0.6 |
| (a_{12}) | Chase | 0.2 |
| (a_{13}) | Jerry | 0.2 |
(g) 把这三项加权加起来:
C_{汤姆} = g(0.6 \\cdot f_2(Tom),\\ 0.2 \\cdot f_2(Chase),\\ 0.2 \\cdot f_2(Jerry))
这份 (C) 就是解码器生成「汤姆」时用的中间语义张量,也就是这一步的总信息包。下一步生成「追逐」时换用 (C_{Chase}) 那一组权重(0.2、0.7、0.1),生成「杰瑞」时换用 0.3、0.2、0.5。