第 5 天 编码器-解码器¶
很开心我们一起来到了第 5 天!
经过了前 4 天的实践,我们已经大幅拓展了神经网络的能力。
但不知道大家注意到没有:此前我们构建的所有网络,它的输入都是固定长度的。
我们此前输入过两类数据集:一类是图像数据,另一类是表格数据,也就是所谓独立同分布数据。
图像定长很容易理解。分辨率与通道数固定,即图像的长宽和 RGB 通道数确定了,那么像素总量就随之固定,输入的长度也就固定了。
另一类所谓独立同分布数据,也就是我们在第 2 天单层神经网络时输入的那种表格型数据。
| 总用户数 | 直播预约人数 | 直播出时间(小时) | 直播推送方式(0=无推送,1=App推送,2=短信推送) | 最高在线人数 |
|---|---|---|---|---|
| 30000 | 1800 | 14 | 1 | 4100 |
| 40000 | 2000 | 16 | 1 | 4300 |
| 50000 | 3200 | 20 | 2 | 9000 |
| 60000 | 2500 | 12 | 0 | 2800 |
| 70000 | 3000 | 15 | 0 | 3600 |
| 80000 | 4200 | 18 | 1 | 9100 |
| 100000 | 6000 | 20 | 2 | 15800 |
| 120000 | 7500 | 21 | 2 | 19200 |
| 150000 | 9000 | 19 | 2 | 22500 |
| 200000 | 15000 | 22 | 2 | 38000 |
表 5-1 独立同分布数据
「独立」是指表中的每一行都是一次独立事件,和其他行没有关联,互不发生因果;「同分布」是指所有事件都在同一个因果律下发生,服从同一个概率分布。当然,正因为输入数据都在同一片因果天空下,我们才能通过深度神经网络将那个因果规律给学习出来。不然,没有规律也没法凭空提取。
别忘了,我们的最终目标是构建一个语言大模型,就像我们用的豆包、千问一样。可豆包和千问从来也不会要求我们每次都输入固定的数字,而且它的输出长度也是灵活可变的。
这是目前我们还没掌握的能力。今天第 5 天,我们来尝试突破这层界限。
5.1 新的任务:接收变长输入¶
在我们今天的破限任务里,我们希望输入一段「变长」文字,输出也是一段「变长」文字。
除了这一点,我们今天和之前的任务还有一个差别。
之前的任务,要么是输入 n 个条件,求问根据这些条件求得的预测结果;要么是输入一张图片,求问图片里的内容。这些任务里,输入和输出的类型往往并不相同 —— 比如输入是图像,输出是文字;又比如输入是预约人数,输出是在线人数。总之,输入和输出不是一个东西。
但今天我们面对的的任务不同了。我们输入的是文本,输出的还是文本,输入与输出是同一个东西。这类任务有时会被称为「自回归任务」。
我们可以把自回归任务总结成这样一个目标:基于已经出现的序列内容,预测此序列的下一个元素,序列中的每个元素都是同构的。
式 5-1 自回归任务
解决自回归任务的模型就被叫做「自回归模型」。
5.1.1 自回归模型¶
我们想要预测下一个 $ x_t $,但又不想处理变长的 $ x_1, x_2, \dots, x_{t-1} $,希望保持固定长度的输入。怎么办呢?
第一种思路非常朴素。比如我们的网络就只能接收 8 个输入,那么就直接取当前位置往前最近的 8 个元素作为输入。这是一种相当直给也很自然的想法 —— 既然只能容纳 8 个输入,那就取和当前预测相关性最高的最近 8 个。基于这种思路构建的模型,就是最基础的自回归模型。
5.1.2 隐变量自回归模型¶
还有一种进阶思路。假设整个序列长度是 15,而我们的输入窗口只有 8,我们又不想直接丢弃更前面的 7 个内容,那要怎么办?
我们可以用某种方法把前面的内容做一次总结压缩,把长序列浓缩成固定长度的信息。比如把 $ x_1 $ 到 $ x_{15} $ 的内容,压缩成 8 个长度的表示。保证输入长度依然是 8,但这 8 个单位里承载了原本 15 个元素的信息。这个过程当然可能会损失一些信息,但核心的重要信息我们尽力保留下来。这样我们就可以用只支持 8 个输入的网络去处理 15 长度或者是任意长度的序列了。
这个压缩后得到的数据,有时被称为是「隐变量」,也有叫「隐状态」的。
基于这种思路解决自回归问题的模型,有时叫做是「隐变量自回归模型」。
图 5-1 隐变量自回归模型
用图形直观地呈现隐变量自回归模型的结构大概就是像图 5-1 这样。
图中 $ h_{t-1} $ 就是所谓的 隐状态,它是对前文所有内容的归纳总结,或者说是整段序列的信息压缩。下方的 $ x_t $ 是当前新输入的字符。
比如我们已经用隐状态总结好了前 15 个字的信息,当第 16 个字作为新输入 $ x_t $ 进入模型时,会和已有的隐状态结合运算,最终产生两个输出:一个是 $ y_t $,也就是模型预测出的下一个字,完成语句的续写;另一个是更新后的新隐状态 $ h_t $,它融入了当前输入的信息,将作为下一轮预测的基础。
不难发现,这个结构还能往后接着拼,它天然支持循环迭代。每多拼一个这个结构,模型可以就多读入一个新字符,也多输出一个字符。我们把它套在一个 while(true) 里,它就能不断重复这个过程。这样我们就能达成接收任意长度的输入文本,也持续输出任意长度的文本的目的。
顺便说一嘴,图 5-1 中下标没有用我们熟悉的 $ i $,而是换成了 $ t $。
这个 $ t $ 是 time 时间的意思,这是我从其他材料上抄来的做法。不知你之前看到类似的做法是否产生过疑惑:模型的计算明明是在同一时刻完成的,为什么总会提到「时间」的概念?其实这里的「时间」并不是指 CPU 或 GPU 的物理运行时间,也不是我们现实的时间。这里的所谓时间通常指的是序列本身的先后顺序。出于一些历史原因,一些研究者习惯于把逐个出现的字符序列看作一个时间序列:第一个字符对应第 0 个时刻,第二个字符对应第 1 个时刻…… 整个序列这样就变成了一条从过去流向未来的时间线。因此在序列预测任务中,经常会遇到有人用 time 的 $ t $ 作为下标,替代原本代表索引的 $ i $。为了将来降低我们读其他材料的障碍,这点我们一起习惯一下就好。
5.2 循环神经网络¶
好。回到正题。如果我们把图 5-1 不停地重复,就会得到图 5-2。
图 5-2 循环神经网络
这样的神经网络,名字也非常望文生义,它名唤 循环神经网络,Recurrent Neural Network,RNN。
我们可能会有点困惑 —— 网络?在哪儿呢?
其实虽然图是那么横着展开画的,其实在代码里是一个不断的循环 while(true) 。网络就藏在每个格子中间的 $ φ $ 里,它是一个多层神经网络。我们要做的就是用正确的语料训练它,让它里面的参数学会总结文字的能力。
值得注意的是,之前我们提到从第 4 天的深度网络开始,模型的可解释性大幅暴跌。我们要时刻留意提醒自己 「That it is」和 「That makes sense」的区别。
这里所谓「总结文字的能力」也是一样的,甚至更突出:这个模块的设计初衷,当然是希望它习得文本的总结能力。但我们得知道,代码里并没有写任何一段逻辑,让模型真正执行「总结文本」的操作。所谓的「总结」能力,真实可确定的仅仅是我们搭建了一套约束结构 —— 指定这个模型把 X 长度的输入序列,输出为更短、字符数小于 X 的序列。至于这个模块内部是真的提炼信息做总结,还是单纯无意义压缩字符,目前的我们是无法判定的。
我们别忘了,这所谓「文本总结」能力也不是我们硬编码实现的,而是模型自己在多轮训练迭代中,自主拟合出的一套隐性模式,它是被动完成的。「文本总结」仅仅是我们赋予它的意向,夸张点说,这是我们一厢情愿的主观命名而已。
事实上,我们需要留意,后续很多的网络模块都是这样。很多资料会给模块赋予具象功能定义:比如门控模块负责判断信息流向、Embedding 模块负责映射字符语义。我们从人类视角看,这套解读逻辑通顺合理。当然,通常也是我们最初的设计目的,于是我们给模块安上对应的功能名称。但模型内部真的是按这个逻辑运行吗?如果需要真的确认,还需要做进一步工作的。我们真正了解的只有三样:输入维度、输出维度、运算逻辑。至于一轮一轮的训练后参数最终收敛成什么样、到底衍生出了何种隐性能力,我们目前不能,也没必要在目前这个阶段给自己蒙上真知的幻觉。
这个过程其实和养育孩子有一点类似。哪怕家长反复指令、灌输行为标准,也无法强制孩子完全按我们预想的逻辑学习成长。想要塑造一个个体的行为模式,目前的教育学提倡的还是搭建对应的成长环境,而非直接下达命令。
但大模型和孩子也有本质区别:我们每个人都曾是一个孩子,即便成年后淡忘儿时感受,也能一定程度上共情孩子面对问题时的思考逻辑。我们和孩子的认知结构依然是同构的。但我们之中没有人曾是一个硅基大模型,光凭本能我们是难以共情它的。
大模型确实是代码,但不是之前我们写的那些「古法代码」;大模型当然是人类的孩子,但它不是双螺旋脱氧核糖核酸的孩子。面对它,我觉得我们还是应该保持敬畏之心。我们得知道哪些点我们是不确定知道的,是需要留待我们中的某些牛人出猜想、做实验、写论文为我们答疑解惑的。
发散得有点远,还是回到我们的主线。
接下来,和以前一样,我们一步一步实现一个循环神经网络,试试看它是否真能如此这般给我们解除定长的限制。
5.2.1 定义数据集¶
首先,和大多数神经网络问题一样。我们还是得先搞定数据集的问题。
5.2.1.1 读取序列数据¶
感谢余华的伟大创作,写出了这么好的文字作为我们优秀的中文语料。我整理了《活着》的文本,保存在本书的代码仓库中作为我们的训练数据集。我们可以在路径 notebooks/datasets/to-live-a-novel-clean.txt 找到它。
打开这个文件,我们会看到一长行文本。类似下面这样。
<SOS>余华《活着》<PARA>一我比现在年轻十岁的时候,获得了一个游手好闲的职业,去乡间收集民间歌谣。<EOS>那一年的整个夏天,我如同一只乱飞的麻雀,游荡在知了和阳光充斥的村舍田野。<EOS>我喜欢喝农民那种带有苦味的茶水,他们的茶桶就放在田埂的树下,我毫无顾忌地拿起漆满茶垢的茶碗舀水喝,还把自己的水壶灌满,与田里干活的男人说上几句废话,在姑娘因我而起的窃窃私笑里扬长而去。<EOS>
很显然,我已经提前处理了数据。现在的数据很干净,标点符号是统一的,没有乱码。并且已经把段落用
现在,我们把《活着》给读到内存里来。
input_file = "datasets/to-live-a-novel-cleaned.txt"
with open(input_file, "r", encoding="utf-8") as f:
text = f.read()
print(f"text length: {len(text)}")
读好了,问题也来了 —— 以前我们的数据集都是一个问题对应一个答案。那样,网络才能根据问题去猜答案,然后对比正确答案,然后算梯度,然后更新参数。现在这一长串文本,什么是问题,什么是答案呢?
这个问题的答案,其实就藏在图 5-1 当中。前面提过,图 5-1 中间的 $ φ $ 是一个神经网络,我们真正要训练的就是它。由图可知,它一共有两路输入:一路是「隐状态」,这是模型内部计算得到的结果,会沿着序列一直向后传递,属于不断流转的「隐变量」,并不是真正的外部输入;另一路输入就是 $ x_t $,这部分对应的,无疑就是我们输入的文本内容。
既然模型是按递归的方式逐时刻运行的,那训练数据的构造思路也就很自然了。我们把一段文本按顺序拆成逐个字符的序列,举例来说:我们取连续 5 个字符作为输入序列,那么模型对应的预测目标,就是向后错位一位的下一个字符。就像下面这样。
为什么我们不直接下面这样呢?
这是因为我们后面算 Loss 的时候要对这 5 个字都分别算 Loss。如果答案只有一个字,那前面 4 个字就没 Loss 可算了。所以,我们需要给每个输入的字都给一个「下一个字」的答案。
5.2.1.2 把文字 Token 化¶
搞定了「问题-答案」对的问题,接下来我们来把这些问题输入给神经网络。
神经网络期待用数字做输入。所以我们要把我们的每个字都转成一个数字。
这个简单,我们遍历全文,把每个字都塞到一个字典里,这样我们的就得到了去重后的所有字。然后我们递增地把每个字给一个自然数作为它的索引。
# 获取 text 中所有不重复的字符(集合会自动去重)
unique_chars_set = set(text)
# 对列表中的字符进行排序,并转成数组,这样就有索引可以用了
chars_list = sorted(list(unique_chars_set))
# 用字典存储 字符 -> 索引 的映射
char2idx = {}
for i in range(len(chars_list)):
c = chars_list[i]
# 将字符作为键,索引作为值存入字典
char2idx[c] = i
# 再来一个字典存储 索引 -> 字符 的映射
idx2char = {}
for i in range(len(chars_list)):
c = chars_list[i]
# 将索引作为键,字符作为值存入字典
idx2char[i] = c
vocab_size = len(chars_list)
print(f"词汇表大小: {vocab_size}")
print(f"字符 '我' 对应的索引是: {char2idx['我']}")
print(f"索引 '20' 对应的字符是: {idx2char[20]}")
以上代码会得到下面的输出。每个字都有一个自然数的索引了。
回忆第 2 天单层神经网络,其实我们现在还有一个和那天的未竟事宜类似的问题。我们把这些字都给了自然数作为索引。自然数是有大小,是有顺序的,也就是说它们有线性关系的。但实际上我们这些文字之间并不存在线性关系。所以如果我们直接把自然数作为这些文字的编号输入神经网络,我们可能会给网络一个错误的暗示,就是这些字之间是有顺序,有线性关系的。我们希望不要给神经网络这种错误的暗示,所以我们要基于目前的索引,搞出一种新的没有线性关系的索引。
| 汉字 | 旧索引 | 新索引 |
|---|---|---|
| 我 | 0 | [1,0,0,0,0,0] |
| 比 | 1 | [0,1,0,0,0,0] |
| 现 | 2 | [0,0,1,0,0,0] |
| 在 | 3 | [0,0,0,1,0,0] |
| 年 | 4 | [0,0,0,0,1,0] |
| 轻 | 5 | [0,0,0,0,0,1] |
表 5-2 One-Hot 编码示例
像上表那样,我们把标量的旧索引转换成多维度的新索引,并且给每个字一个单独的维度。这样,加上了维度的区隔,我们就不会暗示神经网络它们之间有什么关系了。这种编码方式被称为 One-Hot 编码。One-Hot 编码最开始应用于数字电路,意思是多个引脚中某一时刻只有一个是高电平。
def one_hot_encode(index, vocab_size):
"""
将整数索引编码为 one-hot 向量
"""
# 初始化一个长度为词汇表大小、全为 0 的列表
one_hot_vector = [0] * vocab_size
# 检查索引是否越界
if 0 <= index < vocab_size:
# 将对应索引的位置设为 1
one_hot_vector[index] = 1
else:
print(f"警告:索引 {index} 超出词汇表范围 (0 ~ {vocab_size-1})")
return one_hot_vector
def one_hot_decode(one_hot_vector):
"""
将 one-hot 向量解码为整数索引
"""
# 找到列表中值为 1 的元素的索引
if 1 in one_hot_vector:
return one_hot_vector.index(1)
else:
return -1 # 返回 -1 表示无效向量
用这套编码方式,我们尝试测试一下「我」这个字的编码和还原。
# 测试一下「我」这个字的编码和还原
test_char = "我"
print(f"=== 测试字符: '{test_char}' ===")
# 字符 -> 整数索引
char_idx = char2idx.get(test_char)
print(f"[char2idx] 字符 '{test_char}' 对应的整数索引为: {char_idx}")
# 整数索引 -> one-hot 编码
if char_idx is not None:
one_hot_vec = one_hot_encode(char_idx, vocab_size)
print(f"[one_hot_encode] 生成的 One-hot 向量长度为: {len(one_hot_vec)}")
print(f"[one_hot_encode] 向量中值为 1 的位置在: {one_hot_vec.index(1)}")
# one-hot 解码 -> 整数索引 (验证还原数字索引)
decoded_idx = one_hot_decode(one_hot_vec)
print(f"[one_hot_decode] One-hot 向量解码出的索引为: {decoded_idx}")
# 整数索引 -> 字符 (验证最终还原汉字)
decoded_char = idx2char.get(decoded_idx)
print(f"[idx2char] 索引还原出的字符为: '{decoded_char}'")
# 总结
print(f"==> 最终验证结果: {'成功' if test_char == decoded_char else '失败'}")
else:
print(f"错误:词汇表中不包含字符 '{test_char}'")
运行上面的代码,会得到下面的输出。
=== 测试字符: '我' ===
[char2idx] 字符 '我' 对应的整数索引为: 725
[one_hot_encode] 生成的 One-hot 向量长度为: 1863
[one_hot_encode] 向量中值为 1 的位置在: 725
[one_hot_decode] One-hot 向量解码出的索引为: 725
[idx2char] 索引还原出的字符为: '我'
==> 最终验证结果: 成功
OK,我们搞定了我们的 One-Hot 编码。
5.2.1.3 实例化 Dataset¶
好。现在我们就正式开始制作我们的数据集了。
就用我们昨天学到的 PyTorch 的 torch.utils.data.DataLoader 类来完成。
回忆一下,昨天我们是这样使用 DataLoader 的。
# 请不要复制这段代码,这是昨天的,仅作为示意
train_set = torchvision.datasets.CIFAR10(root='./cifar10_data', train=True, download=True)
train_loader = torch.utils.data.DataLoader(
train_set, batch_size=BATCH_SIZE, huffle=True, num_workers=0)
DataLoader 需要一个 torch.utils.data.Dataset 作为它的初始化输入。和昨天现成的数据集不同的是,这次需要我们自己来给《活着》实现一个 torch.utils.data.Dataset 类。
不难。我们只需要继承实现望文生义的 __getitem__、__len__、__init__ 这 3 个方法。我们就能得到一个可以传给 torch.utils.data.DataLoader 的torch.utils.data.Dataset 类了。
import torch
class CharDataset(torch.utils.data.Dataset):
def __init__(self, text: str, char2idx: dict, learn_char_len: int = 32, step_char_len: int = 1):
self.char2idx = char2idx
self.vocab_size = len(char2idx) # 提取词汇表大小,供 one-hot 编码使用
self.learn_char_len = learn_char_len
# 将整个原始文本转换成对应的数字索引列表
self.data = []
for c in text:
idx = char2idx.get(c)
# 如果字符在词表中,加入索引列表
if idx is not None:
self.data.append(idx)
# 初始化一个空列表,用于保存所有提取出的训练样本 (数字索引形式)
self.samples = []
# 使用滑动窗口遍历数字索引列表
for i in range(0, len(self.data) - learn_char_len, step_char_len):
# 获取输入序列 x 的索引
x_idx = self.data[i : i + learn_char_len]
# 获取目标序列 y 的索引 (x 向右偏移一位)
y_idx = self.data[i + 1 : i + learn_char_len + 1]
# 将这对索引序列保存为样本
self.samples.append((x_idx, y_idx))
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
# 取出当前样本的索引序列
x_idx, y_idx = self.samples[idx]
# 遍历 x 中的每个整数索引,将其编码为 one-hot 向量
x_one_hot = [one_hot_encode(i, self.vocab_size) for i in x_idx]
# 遍历 y 中的每个整数索引,将其编码为 one-hot 向量
y_one_hot = [one_hot_encode(i, self.vocab_size) for i in y_idx]
# 转换为 torch.tensor
x_tensor = torch.tensor(x_one_hot, dtype=torch.float32)
y_tensor = torch.tensor(y_one_hot, dtype=torch.float32)
return x_tensor, y_tensor
5.2.1.4 实例化 DataLoader¶
现在我们把它接上 torch.utils.data.DataLoader 打印几对数据试试。
# 初始化 Dataset 和 DataLoader
char_dataset = CharDataset(text, char2idx=char2idx, learn_char_len=128)
dataloader = torch.utils.data.DataLoader(char_dataset, batch_size=64, num_workers=8, shuffle=True, pin_memory=True)
# 获取一个 batch 的数据
data_iter = iter(dataloader)
for batch_idx in range(1):
x_batch, y_batch = next(data_iter)
print(f"=== Batch {batch_idx + 1} ===")
print(f"输入批次形状: {x_batch.shape} (批次大小, 学习序列长度, 词汇表大小)")
print(f"目标批次形状: {y_batch.shape}")
# 为了演示清晰,这里只挑前 3 个样本进行打印
num_samples_to_print = min(3, x_batch.size(0))
for i in range(num_samples_to_print):
# 取出单个样本的张量 (形状: [seq_len, vocab_size])
x_sample = x_batch[i]
y_sample = y_batch[i]
# 将 x 的 one-hot 向量解码为索引,再转为字符
x_indices = [one_hot_decode(vec.tolist()) for vec in x_sample]
x_str = "".join([idx2char.get(idx, '?') for idx in x_indices])
# 将 y 的 one-hot 向量解码为索引,再转为字符
y_indices = [one_hot_decode(vec.tolist()) for vec in y_sample]
y_str = "".join([idx2char.get(idx, '?') for idx in y_indices])
print(f"\n样本 {i+1}:")
print(f" 输入 x 还原的前5个索引: {x_indices[:5]}")
print(f" 目标 y 还原的前5个索引: {y_indices[:5]}")
print(f" 输入 x (文本): '{x_str}'")
print(f" 目标 y (文本): '{y_str}'")
print("-" * 50)
需要注意的是,我们在初始化 dataloader 时加入了参数 num_workers=8 ,让 CPU 多线程工作,以赶上 GPU 的运行速度。这个参数的值最好和我们机器上的逻辑 CPU 数量相等。我们还加入了参数 pin_memory=True,以复用同一块内存,节省一点点在数据加载过程中反复申请释放 CPU 内存的开销。
执行后,应该会看到类似这样的输出。
=== Batch 1 ===
输入批次形状: torch.Size([64, 128, 1863]) (批次大小, 学习序列长度, 词汇表大小)
目标批次形状: torch.Size([64, 128, 1863])
样本 1:
输入 x 还原的前5个索引: [664, 1713, 1640, 551, 13]
目标 y 还原的前5个索引: [1713, 1640, 551, 13, 2]
输入 x (文本): '心里踏实。<EOS>"她拆拆缝缝给凤霞和有庆都做了件衣服,两个孩子穿上后看起来还很新。<EOS>后来我才知道她把自己的衣服也拆了,看到我生气,她笑了笑说:"衣服不穿坏起来快。<EOS>我是不会穿它们了,可不能跟着我糟蹋了。<EOS>"家珍说也给我做一件,谁'
目标 y (文本): '里踏实。<EOS>"她拆拆缝缝给凤霞和有庆都做了件衣服,两个孩子穿上后看起来还很新。<EOS>后来我才知道她把自己的衣服也拆了,看到我生气,她笑了笑说:"衣服不穿坏起来快。<EOS>我是不会穿它们了,可不能跟着我糟蹋了。<EOS>"家珍说也给我做一件,谁知'
--------------------------------------------------
样本 2:
输入 x 还原的前5个索引: [23, 955, 13, 2, 5]
目标 y 还原的前5个索引: [955, 13, 2, 5, 6]
输入 x (文本): '下来。<EOS>可我们放心不下凤霞,她和别人不一样,她老了谁会管她?<EOS>凤霞说起来又聋又哑,她也是女人,不会不知道男婚女嫁的事。<EOS>村里每年都有嫁出去娶进来的,敲锣打鼓热闹一阵,到那时候凤霞握着锄头总要看得发呆,村里几个年轻人就对凤霞指指点点,'
目标 y (文本): '来。<EOS>可我们放心不下凤霞,她和别人不一样,她老了谁会管她?<EOS>凤霞说起来又聋又哑,她也是女人,不会不知道男婚女嫁的事。<EOS>村里每年都有嫁出去娶进来的,敲锣打鼓热闹一阵,到那时候凤霞握着锄头总要看得发呆,村里几个年轻人就对凤霞指指点点,笑'
--------------------------------------------------
样本 3:
输入 x 还原的前5个索引: [6, 9, 3, 173, 551]
目标 y 还原的前5个索引: [9, 3, 173, 551, 79]
输入 x (文本): 'OS>其实人落到那种地步也就顾不上那么多了,我算是应了人穷志短那句古话了。<EOS>那天我去找龙二时,龙二坐在我家客厅的太师椅子里,两条腿搁在凳子上,一手拿茶壶一手拿着扇子,看到我走进来,龙二咧嘴笑道:"是福贵,自己找把凳子坐吧。<EOS>"他躺在太师椅里'
目标 y (文本): 'S>其实人落到那种地步也就顾不上那么多了,我算是应了人穷志短那句古话了。<EOS>那天我去找龙二时,龙二坐在我家客厅的太师椅子里,两条腿搁在凳子上,一手拿茶壶一手拿着扇子,看到我走进来,龙二咧嘴笑道:"是福贵,自己找把凳子坐吧。<EOS>"他躺在太师椅里动'
--------------------------------------------------
……
OK。这样数据集就搞定了。
接下来我们来定义我们的循环神经网络。
5.2.2 定义模型¶
首先,实现我们最重要的小零件图 5-1。
它的数学表达式是下面这样。
式 5-2 RNN 中的隐状态模块
下标 h 是 hidden 的缩写,意思是隐状态相关的变量。
\(\mathbf{b}_h\) 照例是偏置量,求导一求就求没了,我们先不管它。然后就还剩下一个 \(\mathbf{X}_t \mathbf{W}_{xh}\) 一个 \(\mathbf{H}_{t-1} \mathbf{W}_{hh}\) 和 一个 \(\phi()\) 了。我们一个一个来看。
\({X}_t\) 是最新输入的字符,所以 \(\mathbf{X}_t \mathbf{W}_{xh}\) 其实就是新输入的字符和它配套的权重的乘积。
\(\mathbf{H}_{t-1}\) 是所谓隐状态,所以 \(\mathbf{H}_{t-1} \mathbf{W}_{hh}\) 是隐状态和它配套权重的乘积。
很显然,前面两个东西都是线性的。所以,最后的 \(\phi()\) 就是负责给我们引入非线性的激活函数了。
5.2.2.1 选择激活函数¶
我们为 RNN 选择一个什么样的激活函数呢?
或者说,如果不是无脑选择 ReLU,我们面对具体的神经网络和诸多激活函数,我们应该依据什么标准来选择激活函数呢?
我们回忆一下我们当初是为什么引入的激活函数?—— 那是因为我们需要我们的神经网络需要非线性来获取拟合任意曲线的能力。那么,它是在哪个阶段帮我们引入非线性能力的呢?无疑是前向推理阶段,最终我们拟合出来的函数就是靠前向推理阶段的神经网络性质来表征的。可是既然它在推理阶段扮演了它的角色,那么我们就需要在训练阶段去训练它。
训练阶段的激活函数会对我们的神经网络产生什么影响呢?—— 它会参与反向传播,说人话就是它的导数会被乘到权重最终的梯度上去。是连乘!回忆起之前我们昨天在选择参数初始化时的考量了吗?
一个函数如果参与连乘的话,我们会对它有什么期待呢?—— 如果它一直大于 1 很多,那么连乘下来,它会把我们的梯度乘到爆炸。如果它一直小于 1 很多,它会把我们的梯度给乘没。这两种情况都不是我们所乐见的。所以说,我们期待无数个它相乘之后依然等于一。我们希望它如果求很多次导数,这些导数的结果相乘最好是等于 1。换言之,我们希望它在前向推理时起到巨大作用的同时,在反向传播时做一个小透明,不要影响权重的更新,也就是不要对梯度计算造成什么实质性的影响。
知道了这一点,回忆 ReLU,我们也就更加能理解它为什么是激活函数界最被宠爱的孩子了 —— 它的导数永远只等于 1!
图 5-3 relu 和它的导数
ReLU 如此完美,那为啥咱们这次动了二心,开始讨论上如何选择激活函数了呢?
因为我们现在面临了一个不一样的情况。正常我们前向推导是好多个不同的权重参数相乘。在合适的初始化的情况下,它们大的大小的小,在训练过程中相互避让,互相一乘也就互相抵消了,还是可以保持一个相对正常的值。但是 RNN 是同一个参数疯狂和自己相乘。在一次又一次的 **循环**神经网络中,我们实际上会做以下的疯狂连乘。
式 5-3 隐变量中包含的疯狂连乘
神经网络都是矩阵相乘,但 循环 神经网络的特殊之处在于它是同一个权重循环乘。ReLU 是不会修改网络的值的,只要值大于零就等于自身。这就会带来一个问题,如果像 ReLU 这样不做约束的话,这个数就只能等于 1 —— 因为如果它大于 1,那么在连乘之后它会爆炸,如果它小于 1 ,那么连乘之后它会消失。
这不是在反向传播中,梯度消失或者爆炸。这连前向推理的激活值都炸了。所以,面对这种特殊情况,我们需要选择一个可以约束前向推理的输出的激活函数。
图 5-4 tanh 和它的导数
图 5-5 sigmoid 和它的导数
通过上面 2 个图,我们能看到 tanh 和 sigmoid,都不像 ReLU 那样可以获得无限大的范围,都可以把输出约束到一个固定的范围里。 tanh 是约束到 -1 到 1 之间,sigmoid 则是约束到 0 到 1 之间。
他俩都符合我们的要求,在这两个里面,我们再怎么选呢?
现在就轮到看看激活函数在反向传播阶段的影响了,也就是对梯度的影响。我们对我们的前向表达式 5-2 求隐变量的偏导,能得到它的梯度推导如下。
式 5-4 隐变量的梯度推导
最后我们看到,梯度最终是激活函数的导数和隐变量对应权重 $ \mathbf{W}_{hh} $ 的乘积。
别忘了,我们是一个 循环 神经网络,所以我们的梯度实际是会乘以它的 t 次方 $ (\mathbf{W}_{hh})^t $。
还是那个逻辑,如果大于 1 会起飞,小于 1 则会消失。
对比 tanh 和 sigmoid 的导数,我们可以看出它们导数的值都小于 1。也就是说,在连乘时,它们都有使得梯度减小的倾向。具体来说, sigmoid 的导数在 0 到 0.25 之间,tanh 的导在 0 到 1 之间。相对而言, tanh 使得梯度下降的程度更小一些。选一个相对好一点的,那我们就选 tanh 啦。
小结一下这一段对于激活函数的选择方法,我们在前几天激活函数为神经网络引入非线性这个最最重要的贡献之上,又进一步加深了对它的理解。除了给网络带来非线性,它会参与前向和反向推导,这使得我们可以去思考或者说需要去思考非线性所带来的 副作用 。神经网络是被这个激活函数的副作用坑害还是可以借用以增强网络的表达,这就是我们具体选择激活函数的核心考量。
好吧,具体来说,对于我们的 RNN,我们选出了 tanh。那么,接下来我们就来实现我们的神经网络。
5.2.2.2 实现隐变量模块¶
首先,把我们最基础的小积木 —— 图 5-1 给实现出来。
class RNNCell(torch.nn.Module):
"""
图 5-1 所示的 RNN 最基础小积木。
输入:
x : 当前时刻输入,形状 (batch, vocab_size)
h_prev : 上一时刻隐状态,形状 (batch, hidden_size)
输出:
y : 当前时刻的预测(下一个字符的概率分布),形状 (batch, vocab_size)
h : 更新后的隐状态,形状 (batch, hidden_size)
"""
def __init__(self, vocab_size: int, hidden_size: int):
super().__init__()
self.vocab_size = vocab_size
self.hidden_size = hidden_size
# X_t · W_xh:把输入字符从 vocab_size 维映射到 hidden_size 维
self.linear_x = torch.nn.Linear(vocab_size, hidden_size, bias=False)
# H_{t-1} · W_hh:把上一时刻隐状态做线性变换
self.linear_h = torch.nn.Linear(hidden_size, hidden_size, bias=False)
# b_h:偏置项,两个线性层的偏置合并到这里统一加一次即可
self.bias = torch.nn.Parameter(torch.zeros(hidden_size))
# 输出层:从 hidden_size 维隐状态投影回 vocab_size 维,用于预测下一个字符
self.linear_y = torch.nn.Linear(hidden_size, vocab_size)
def forward(self, x: torch.Tensor, h_prev: torch.Tensor):
"""
式 5-2:
H_t = tanh(X_t · W_xh + H_{t-1} · W_hh + b_h)
再由 H_t 投影出预测 Y_t。
"""
# 两路线性变换 + 偏置,然后过 tanh 激活
h = torch.tanh(self.linear_x(x) + self.linear_h(h_prev) + self.bias)
# 从隐状态投影到词汇表维度,得到下一个字符的预测分布
y = self.linear_y(h)
return y, h
我们用了第 3 天学会的 torch.nn.Module 来简化我们搭建网络的工作。可以看到,我们的前向推理函数 forward 一共就 2 行。第 1 行就是我们式 5-2 的原景重现,里面用到了我们精挑细选的 tanh。然后,第 2 行是一个线形层,它是做什么的呢?
第 3 天我们遇到 1x1 卷积核时提到它其实是和一个线性层是等价的,作用都是实现从任意维度到任意维度的转换。那么,我们为什么要在最后做一次维度转换呢?
因为我们一顿折腾,最后还是想输出一个字。怎么输出一个汉字呢?比如说我们有一个 100 字的词表,我们让我们的网络输出一个 100 项的数组,其中每一个项代表每个字的适合度。这样,我们就可以拿最大值对应的那个汉字作为我们的最终输出了。
5.2.2.3 实现循环神经网络¶
好,有了小零件,我们再把带有循环的整个 RNN 给实现出来。
class RNN(torch.nn.Module):
"""
完整的循环神经网络。
将 RNNCell 按时间步循环展开,处理一整个序列。
输入:
x : 形状 (batch, seq_len, vocab_size),one-hot 编码的输入序列
输出:
outputs : 形状 (batch, seq_len, vocab_size),每个时间步的预测分布
h : 最终隐状态,形状 (batch, hidden_size)
"""
def __init__(self, vocab_size: int, hidden_size: int):
super().__init__()
self.vocab_size = vocab_size
self.hidden_size = hidden_size
# 复用我们刚才实现的小积木
self.rnn_cell = RNNCell(vocab_size, hidden_size)
def forward(self, x: torch.Tensor, h_prev: torch.Tensor = None):
batch_size = x.size(0)
seq_len = x.size(1)
# 如果没有传入初始隐状态,就用全零初始化
if h_prev is None:
h_prev = torch.zeros(batch_size, self.hidden_size, device=x.device)
# 用一个列表收集每个时间步的输出
outputs = []
# 按时间步循环 —— 这就是 RNN 中 "Recurrent" 的体现
for t in range(seq_len):
# 取出第 t 个时间步的输入,形状 (batch, vocab_size)
x_t = x[:, t, :]
# 送进 RNNCell,得到当前时刻的预测和更新后的隐状态
y_t, h_prev = self.rnn_cell(x_t, h_prev)
# 收集输出
outputs.append(y_t)
# 把列表堆叠成张量,形状 (batch, seq_len, vocab_size)
outputs = torch.stack(outputs, dim=1)
return outputs, h_prev
网络拼好了,我们先瞎推理一下试试。虽然现在还没训练,参数都还是错的,但是逻辑应该能跑通,程序不应该会出错。
# 初始化 RNN 网络
if torch.cuda.is_available():
device = torch.device("cuda")
elif torch.backends.mps.is_available():
device = torch.device("mps")
else:
device = torch.device("cpu")
print(f"Using device: {device}")
hidden_size = 1024
model = RNN(vocab_size=vocab_size, hidden_size=hidden_size)
model = model.to(device)
def generate_text(model, char2idx, idx2char, seed_text, gen_len=100):
"""
RNN 前向推理生成文本。
参数:
model : RNN 网络
char2idx : 字符 -> 索引 映射
idx2char : 索引 -> 字符 映射
seed_text : 种子文本,模型以此为起点续写
gen_len : 要生成的字符数量
"""
model.eval()
vocab_size = len(char2idx)
device = next(model.parameters()).device
# 把种子文本转成索引序列
seed_indices = [char2idx[c] for c in seed_text if c in char2idx]
# 用种子文本初始化隐状态(让模型"读"一遍种子文本)
h = None
for idx in seed_indices:
# 构造当前字符的 one-hot 张量,形状为 (1, 1, vocab_size)
x_t = torch.tensor(one_hot_encode(idx, vocab_size), dtype=torch.float32).unsqueeze(0).unsqueeze(0).to(device)
# 送入模型,更新隐状态
_, h = model(x_t, h)
# 从种子文本的最后一个字符开始续写
generated = list(seed_text)
# 取种子最后一个字符作为起始输入
if len(seed_indices) > 0:
last_idx = seed_indices[-1]
else:
last_idx = 0
for _ in range(gen_len):
# 构造当前字符的 one-hot 张量,形状为 (1, 1, vocab_size)
x_t = torch.tensor(one_hot_encode(last_idx, vocab_size), dtype=torch.float32).unsqueeze(0).unsqueeze(0).to(device)
# 送入模型,得到预测分布和新的隐状态
y_t, h = model(x_t, h)
# 取出预测分布并计算概率
# argmax() :从数组里找出最大的值,返回它的索引
next_idx = y_t.squeeze(0).squeeze(0).argmax().item()
generated.append(idx2char[next_idx])
last_idx = next_idx
return "".join(generated)
seed = "凤霞命苦啊,"
text = generate_text(model, char2idx, idx2char, seed_text=seed, gen_len=80)
print(f"\n种子: '{seed}'")
print(f"生成: '{text}'")
print("-" * 50)
运行上面的代码,我得到了以下。
Using device: cuda
种子: '凤霞命苦啊,'
生成: '凤霞命苦啊,渣渣伙蜜哆受停客搬朽垃瓶部逃屑逃理够凑钻徒镰霸谣蚁权乌玩朽痕得哧达确党磨按湾哧懂霸按湾哧懂霸按湾哧懂霸按湾哧懂霸按湾哧懂霸按湾哧懂霸按湾哧懂霸按湾哧懂霸按湾哧懂'
-------------------------------------------------
我们的 RNN 一通胡说。但没关系,这是因为它还没有训练呢,至少现在逻辑是对的,没报啥异常。
那么,接下来,我们开始训练这些权重。
5.2.3 训练网络¶
在开始训练之前,别忘了我们前面选激活函数时的连乘问题还只是得到了缓解,并没有彻底解决。
如果梯度爆炸了的话,网络肯定是训不出来的。我们把这个问题给处理一下。
5.2.3.1 梯度裁剪¶
处理的思路很直接,在每次反向传播结束得到梯度之后,把这个梯度传递给优化器去更新权重之间,我们横插一杠子。我们在这个时刻检查一下梯度是不是爆炸了,如果已经爆炸了,我们就不要傻乎乎把炸弹传给优化器去把网络给炸掉。我们先把它除以一个值,给它缩到一个合理的范围内,再传给优化器去更新权重。
可我们的权重是一个矩阵,不是一个单独的数字,我们咋知道它是大了还是小了呢?—— 我们把矩阵里的所有数都加起来,加成一个数不就好了!比如说我们有如下一个矩阵 A。
式 5-5 用于计算范数的矩阵 A
我们可以看到它里面的数有正有负,为了防止它们正负抵消,我们在把它的各个元素相加之前先求一个绝对值。
式 5-6 L1 范数
这样算出来的矩阵结果,我们把它叫做这个矩阵的「曼哈顿距离」。它就像是在一个高楼林立的都市里开出租车送客人时,从一个点到另一个点的距离。因为中间都被大楼给挡着了,不能穿楼斜插过去,只能一直走直角,所以总距离就是各个线段的和。
图 5-6 曼哈顿距离
具体到我们的矩阵 A,我们算一下它的曼哈顿距离。
式 5-7 矩阵 A 的曼哈顿距离
好,矩阵 A 的曼哈顿距离为 14。除了曼哈顿距离,它还有一个名字叫做「L1 范数」。「范」译自英语的 norm,标准的意思。因为我们原始的问题是 2 个矩阵没办法比较大小嘛,我们需要定一个如何比较大小的「标准」,也就是 norm,也就是「范」。而「范」出来的数,自然也就叫「范数」了。L1 中的 L 是法国科学院院士亨利·勒贝格名字的首字母,他是给微积分砌上最后一块砖的数学家。我们在第 1 天莱布尼兹的积分提到过,积分嘛就是求和。到了勒贝格就开始面对矩阵这种复杂玩意儿求和了。L1 中的 1 指的是我们对矩阵中的每个元素求 1 次幂,也就是保持不变。
图 5-7 亨利·勒贝格
但这个 1 次幂在我们现在的梯度裁剪的场景中会有点问题。想象一下你身处曼哈顿,一个出租车载你从 A 点到 B 点,它的打表距离可能会因为司机绕路而不合理地变长。哪怕 A 和 B 隔得并不远,是不是只要你肯绕,就能绕出一个超长的距离来。具体到我们的矩阵中来说的话,我们的 L1 范数会随着矩阵里元素的个数增加也就是我们模型参数数量增加而增大。虽然我们当然也可以写代码去根据参数数量再去调整 L1 范数的值,但那毕竟也太麻烦了,而且会造成代码穿透抽象层的讨厌耦合。
实际我们就是想求 A 点到 B 点之间的不绕路的,直线的距离,有没有这样的数学工具呢?
有!勾股定理。
图 5-8 《周髀算经》中的勾股定理
《周髀算经》看不懂,但勾股定理我是知道的。 $ d^2 = a^2 + b^2 $ 嘛,三角形两短边的平方和等于长边的平方。两点间直线最短,所以直线距离就可以从绕路的距离中算出来 $ d = \sqrt{a^2 + b^2} $。像这样算出来的直线距离也被称为欧几里得距离,也有人叫欧氏距离,一回事。所谓欧氏距离就是算的两点之间的直线距离,如果把其中一点定在坐标轴的原点,那求出来的就是我们向量的长度了。矩阵也就是一个多维度的向量嘛,像这样求出来的矩阵范数,因为不再算的 1 次幂,而是 2 次幂,自然被称为 L2 范数。
式 5-8 L2 范数
L2 范数解决了绕路的问题,我们用它来判定我们的梯度有没有炸出一个特别大的我们不能接受的值。
def manual_clip_grad_norm_(parameters, max_norm: float):
"""
手动实现的按全局 L2 范数裁剪梯度。
"""
if isinstance(parameters, torch.Tensor):
parameters = [parameters]
# 过滤出有梯度的参数
params_with_grad = [p for p in parameters if p.grad is not None]
if len(params_with_grad) == 0:
return torch.tensor(0.)
# 计算所有参数梯度拼在一起的全局 L2 范数
total_norm = torch.norm(torch.stack([torch.norm(p.grad.detach(), 2) for p in params_with_grad]), 2)
# 计算缩放系数
clip_coef = max_norm / (total_norm + 1e-6)
# 如果 total_norm < max_norm,coef > 1,我们不应放大梯度,所以限制最大为 1.0
clip_coef_clamped = torch.clamp(clip_coef, max=1.0)
# 原地缩放所有梯度
for p in params_with_grad:
p.grad.detach().mul_(clip_coef_clamped)
return total_norm
上面的代码里我们没有用 if 去判断我们的范数是不是超限了。我们是直接把我们算出来范数和我们设定的最大的范数相除,得到一个缩放系数。然后用 torch.clamp() 把我们这个系数控制在 1.0 以下,这样就只有小于 1 的系数才会生效,也就是只会缩小不会放大了。然后统一对所有的权重做这个相乘,这样就达成了我们对梯度做「裁剪」,也就是把它压制在一个范围内的目的。
5.2.3.2 Adam 优化器¶
同一个权重连续自我相乘给我们带来的麻烦还不止是梯度爆炸。它还带来另一个方向上问题:连乘既然能无限乘大,那么也就可能会无限乘小。
那么,梯度太小了怎么办呢?
处理的思路同样也很直接。我们并不是把梯度直接应用到权重上去的,更新之前我们还要乘以一个学习率呢。那我们就把所有权重一个一个看过去,如果某个权重的梯度太小,那我们相应地调大学习率不就好啦。
更新权重是由优化器负责的,那么这个灵活调整学习率的任务咱也一并交给它了。
class manual_adam:
"""
手动实现的 Adam 优化器。
"""
def __init__(self, params, lr=1e-3, betas=(0.9, 0.999), eps=1e-8):
self.params = list(params)
self.lr = lr
self.beta1, self.beta2 = betas
self.eps = eps
# 初始化状态:一阶矩 m 和 二阶矩 v
# zeros_like 会自动继承原参数的设备和数据类型
self.m = [torch.zeros_like(p) for p in self.params]
self.v = [torch.zeros_like(p) for p in self.params]
self.t = 0 # 时间步
def zero_grad(self):
for p in self.params:
if p.grad is not None:
p.grad.detach_() # 清空计算图
p.grad.zero_() # 梯度置零
def step(self):
self.t += 1
for i, p in enumerate(self.params):
if p.grad is None:
continue
grad = p.grad.data
# 更新一阶矩估计 m (Momentum)
self.m[i].mul_(self.beta1).add_(grad, alpha=1 - self.beta1)
# 更新二阶矩估计 v (RMSProp)
self.v[i].mul_(self.beta2).addcmul_(grad, grad, value=1 - self.beta2)
# 计算偏差校正
m_hat = self.m[i] / (1 - self.beta1 ** self.t)
v_hat = self.v[i] / (1 - self.beta2 ** self.t)
# 更新参数 (原地操作)
# p = p - lr * m_hat / (sqrt(v_hat) + eps)
p.data.addcdiv_(m_hat, torch.sqrt(v_hat) + self.eps, value=-self.lr)
__init__() 是我们优化器的初始化方法。请注意我们在里面留了 3 个实例变量,分别是 m、v 和 t。前两个都是数组,数组的大小和需要更新的权重数量是一致的。最后一个 t 是用来记录优化器进来了多少次的。这说明我们的优化器是溯及过往的,是会参考历史数据来更新权重的。
step() 是我们优化器更新参数的方法。这个方法一上来我们就在所有的参数中遍历了。我们挨个权重看过去,给每一个权重定制适合它的学习率。
我们的目的是让过小的梯度能得到一个较大的学习率。我们用 Geoffrey Hinton 在 2012 年告诉我们的方法,按梯度大小自适应缩放学习率。其实很简单,Hinton 让学习率乘以梯度的倒数,这样不就梯度越小学习率越大了么。但是梯度值是有正有负的,那 Hinton 就给它平方再开方一下。如何这般,如果我们假设梯度为 g、学习率为 l,那么则有下面的公式。
式 5-9 RMSProp 之一
上式中最后加了个 $ \epsilon $,那是为了防止出现除 0 错误加的一个很小的常数。然后,为了防止梯度的值骤变,突然变得很大或者很小, Hinton 结合上一个梯度值做了一个加权平均,然后学习率的计算方法就变成了下面这样。
式 5-10 RMSProp 之二
其中 $ v_{t-1} $ 是我们保存的上一次根号内的这一堆东西的计算结果。
式 5-11 RMSProp 之三
这也就是我们代码中注释了 RMSProp 的那一行在计算的东西。然后我们就拥有了会随着梯度值自动调整的学习率,解决我们在本节开始提出的问题。顺便也解决了另一个问题 —— 训练的时候学习率到底设定为多少比较好呢?设得太小学得太慢,设得太大又会在终点处来回振荡。这总是个让人纠结的问题。用了这个 Adam 优化器能一定程度上缓解这个纠结。因为呢,就像式 5-10 所示,它的学习率会自己乘以一个值作为新的学习率的,是「自适应」的。这也是它的名字 Adam 中 Ada 的由来 —— Adaptive。
我们稍等岔出去一下,思考一个这个问题:为什么在训练神经网络的时候好像经常要害怕或者说去处理「骤变」这件事情呢?在现在这个 Adam 和前面的梯度裁剪中,我们都把过大的值往小了减。虽然防止梯度爆炸是一个理由,但是如果一个梯度或者参数或者不管什么东西发生了「骤变」,难道不是说明我们学到了真正的「特征」,得赶紧去吸收它吗?为何还要把它给去除或者减小呢?停一分钟,我们先想一想……
先把结论丢出来 —— 这是因为我们的训练是分批次的。在今天的 Adam 之前,我们用的一直都是 SGD 随机梯度下降。我们也说过,这个随机并不是随机生成一个梯度就往下降,这个随机是说我们前向推理所用的样本是从整个数据集中「随机」抽取的。其实 SGD 之前还有一种优化器叫 GD,也就是最最直接的梯度下降,我不用随机的样本,直接每次都拿全体样本算出梯度,然后在这个梯度上下降。其实这才是最好的,因为我们的权重最终是要服务于全体样本的,而不是单独去拟合某一个或几个样本。可问题是,受显存制约,全体样本没办法一下子都放进来,于是只能随机取部分样本。这样算出来的的梯度等于就是个局部梯度,而所有局部梯度的均值才是我们真正想要的那个全局的梯度,或者说全局梯度是我们算出来的梯度的数学期望。所以,我们算出来的特别大的值大概率不是值得学习的「特征」,而是异常值。均值回归嘛,所以当我们算出过大值的时候,我们就会默认它是大于了全局梯度,那个我们真正想要的梯度。于是,与其让它在后续的学习训练中再去逐步的纠正、振荡,不如我们就直接把它减小一点。这个减小的动作,本质上是在往全局梯度、真正的梯度在靠拢,是减少了我们走弯路的时间。所以,我们的 Adam 才用了和 $ t-1 $ 的值做加权的方式,去把梯度值往回拉一拉。
那么,同理的,既然梯度值往回拉一拉是个好主意,我们何不把梯度也往回拉一拉呢?我们算出一个新的 m 存储当前梯度 g 和上一个 m 加权后的值。
式 5-12 RMSProp 之四
这就是我们代码中用注释标注的 Momentum 的那行代码在算的事情。
现在我们先有了新的学习率式 5-10,又有了新的梯度式 5-11。学习率乘以梯度就是我们需要往我们的权重上更新的值。所以,我们新的更新权重 w 的方式就变成了这样。
式 5-13 Adam 优化器
这就是代码中最后一行,注释了更新参数的地方在做的事情。
再次澄清一下,式 5-13 中的 $ m_t $ 由式 5-12 算出,$ v_t $ 由式 5-11 算出。这段解释有点略长了,但是值得的。我们获得了一个新的优化器 Adam,全称 Adaptive Moment Estimation。其中 Adaptive 的部分已经说过,指的是 $ v_t $。而后面 Moment 的部分则指的就是我们的 $ m_t $。
代码中还有 2 行注释了「计算偏差校正」,它们的主要作用是解决 $ v_t $ 和 $ m_t $ 的初始值设定的问题。因为我们并没有传一个明确的初值给这两个值,它们一上来是 0,但是在我们的加权求和里又占了很大的比例。所以我们将求得的值根据「步数」缩放了一下,使得一开始它会变大,随着训练再慢慢变小,直到训练了很久之后完全没有影响。具体实现细节我们一看代码便知。
5.2.3.3 开始训练¶
好了。我们有了 2 个新的小零件 —— 梯度裁剪和 Adam 优化器。我们相信它俩可以帮助我们缓解 RNN 中的那个连乘带来的一系列问题。
生成汉字这个任务的网络的输出层是给出一个所有汉字应该出现的概念。所以我们使用交叉熵作为我们的 loss。这个零件我们在第 3 天实现过了,这里我们就直接用 torch 自带的 torch.nn.CrossEntropyLoss() 了。
import time
learning_rate = 0.001
optimizer = manual_adam(model.parameters(), lr=learning_rate)
loss_fn = torch.nn.CrossEntropyLoss()
# 获取总 batch 数
total_batches = len(dataloader)
num_epochs = 20
for epoch in range(num_epochs):
total_loss = 0
batch_count = 0
for batch_idx, (x_batch, y_batch) in enumerate(dataloader):
batch_start_time = time.time()
# 把数据搬到 GPU 上
x_batch = x_batch.to(device, non_blocking=True)
y_batch = y_batch.to(device, non_blocking=True)
# 前向传播
outputs, _ = model(x_batch)
# CrossEntropyLoss 期望的输入形状:
# predictions: (N, C) —— 把 batch 和 seq_len 拍平
# targets: (N,) —— 每个位置的正确类别索引
# 所以我们需要把 one-hot 的 y 转成类别索引
y_indices = y_batch.argmax(dim=2)
# 把预测和目标 reshape 拍平
preds = outputs.reshape(-1, vocab_size)
targets = y_indices.reshape(-1)
loss = loss_fn(preds, targets)
# 反向传播
optimizer.zero_grad()
loss.backward()
# 梯度裁剪
manual_clip_grad_norm_(model.parameters(), max_norm=5.0)
# Adam 更新参数
optimizer.step()
total_loss += loss.item()
batch_count += 1
# 计算本 batch 耗时
batch_time = time.time() - batch_start_time
# 估计剩余时间 (ETA)
remaining_batches = total_batches - (batch_idx + 1)
eta = remaining_batches * batch_time
# 用 \r 动态刷新打印同一行
print(f"\rEpoch [{epoch+1}/{num_epochs}] | Batch [{batch_idx+1}/{total_batches}] | 损失: {loss.item():.4f} | 单步耗时: {batch_time:.3f}s | 预计剩余: {eta:.1f}s", end="")
# 每个 epoch 结束后换行并打印汇总信息
print(f"\rEpoch [{epoch+1}/{num_epochs}] 完成 | 平均损失: {total_loss / batch_count:.4f}")
执行上面的训练代码,我得到了如下的结果。
Epoch [1/20] 完成 | 平均损失: 3.07360] | 损失: 1.5939 | 单步耗时: 0.088s | 预计剩余: 0.0ss
Epoch [2/20] 完成 | 平均损失: 0.94130] | 损失: 0.5105 | 单步耗时: 0.087s | 预计剩余: 0.0ss
Epoch [3/20] 完成 | 平均损失: 0.37300] | 损失: 0.3300 | 单步耗时: 0.087s | 预计剩余: 0.0ss
Epoch [4/20] 完成 | 平均损失: 0.23850] | 损失: 0.2160 | 单步耗时: 0.088s | 预计剩余: 0.0ss
Epoch [5/20] 完成 | 平均损失: 0.19880] | 损失: 0.2448 | 单步耗时: 0.087s | 预计剩余: 0.0ss
Epoch [6/20] 完成 | 平均损失: 0.17830] | 损失: 0.1497 | 单步耗时: 0.088s | 预计剩余: 0.0ss
Epoch [7/20] 完成 | 平均损失: 0.16240] | 损失: 0.1476 | 单步耗时: 0.087s | 预计剩余: 0.0ss
Epoch [8/20] 完成 | 平均损失: 0.15430] | 损失: 0.1461 | 单步耗时: 0.088s | 预计剩余: 0.0ss
Epoch [9/20] 完成 | 平均损失: 0.14610] | 损失: 0.1313 | 单步耗时: 0.087s | 预计剩余: 0.0ss
Epoch [10/20] 完成 | 平均损失: 0.13710] | 损失: 0.1545 | 单步耗时: 0.087s | 预计剩余: 0.0ss
Epoch [11/20] 完成 | 平均损失: 0.13470] | 损失: 0.1282 | 单步耗时: 0.087s | 预计剩余: 0.0ss
Epoch [12/20] 完成 | 平均损失: 0.13400] | 损失: 0.1237 | 单步耗时: 0.088s | 预计剩余: 0.0ss
Epoch [13/20] 完成 | 平均损失: 0.12180] | 损失: 0.1093 | 单步耗时: 0.088s | 预计剩余: 0.0ss
Epoch [14/20] 完成 | 平均损失: 0.12480] | 损失: 0.1096 | 单步耗时: 0.088s | 预计剩余: 0.0ss
Epoch [15/20] 完成 | 平均损失: 0.12830] | 损失: 0.1262 | 单步耗时: 0.087s | 预计剩余: 0.0ss
Epoch [16/20] 完成 | 平均损失: 0.11880] | 损失: 0.0954 | 单步耗时: 0.087s | 预计剩余: 0.0sss
Epoch [17/20] 完成 | 平均损失: 0.11130] | 损失: 0.1397 | 单步耗时: 0.088s | 预计剩余: 0.0ss
Epoch [18/20] 完成 | 平均损失: 0.11770] | 损失: 0.1016 | 单步耗时: 0.088s | 预计剩余: 0.0ss
Epoch [19/20] 完成 | 平均损失: 0.11740] | 损失: 0.3597 | 单步耗时: 0.087s | 预计剩余: 0.0ss
Epoch [20/20] 完成 | 平均损失: 0.10400] | 损失: 0.0764 | 单步耗时: 0.088s | 预计剩余: 0.0ss
收敛得还不错。这个训练在腾讯云送我的 T4 上花费了 5 小时。训练时,显卡占用如下所示。
(base) root@VM-0-80-ubuntu:/workspace# nvidia-smi
Mon Jul 13 08:53:20 2026
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 525.105.17 Driver Version: 525.105.17 CUDA Version: 12.0 |
|-------------------------------+----------------------+----------------------+
| GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|===============================+======================+======================|
| 0 Tesla T4 On | 00000000:00:09.0 Off | 0 |
| N/A 66C P0 28W / 70W | 1394MiB / 15360MiB | 0% Default |
| | | N/A |
+-------------------------------+----------------------+----------------------+
可以看到 nvidia-smi 提示我们 GPU 使用率为 0%。这显然不合理,这是因为 nvidia-smi 显示的是瞬时值。我们还应该再参考第一列中的 28W / 70W,如果真的 GPU 没工作的话,它的功率是 0W 才对。那么,这个 GPU 偷懒的瞬时,机器在干嘛呢?我们执行 top -1 可以发现真相。
top - 08:52:32 up 10:09, 1 user, load average: 7.96, 4.19, 2.45
Tasks: 39 total, 9 running, 30 sleeping, 0 stopped, 0 zombie
%Cpu0 : 94.3 us, 5.7 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 99.3 us, 0.7 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 96.3 us, 3.7 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu4 : 99.7 us, 0.3 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu5 : 96.3 us, 3.7 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu6 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu7 : 99.3 us, 0.7 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
MiB Mem : 31331.9 total, 17178.8 free, 7317.5 used, 6835.6 buff/cache
MiB Swap: 0.0 total, 0.0 free, 0.0 used. 20104.2 avail Mem
此时,机器上的 8 个 CPU 已经全部跑满了。所以说,我们这个代码是一个 CPU 「渴死」GPU 的代码。还有改进空间。
先不管代码优化,我们推理一下试试看。
seed = "凤霞命苦啊,"
text = generate_text(model, char2idx, idx2char, seed_text=seed, gen_len=80)
print(f"\n种子: '{seed}'")
print(f"生成: '{text}'")
print("-" * 50)
同样的推理代码,得到了下面的推理结果。
种子: '凤霞命苦啊,'
生成: '凤霞命苦啊,你也别怪我心狠,都是那畜生胡来才会有今天。<EOS>"说完丈人又转向我,喊道:"凤霞就留给你们徐家,家珍肚里的孩子就是我们陈家的人啦。<EOS>"我娘站在一旁呜'
--------------------------------------------------
很明显,比没被训练之前的网络说得像人话多了。
5.3 PyTorch 魔法版 RNN¶
下面,照惯例,我们把我们的小零件们替换成 PyTorch 给实现的版本。再写一版 RNN。
5.3.1 Torch 内置 One-hot 编码¶
首先,读数据集的部分还是得我们自己做。稍有不同的是,这次我们不用手工实现One-hot 编码了。PyTorch 中自带了更高效的 One-hot 方法 torch.nn.functional.one_hot。所以这次我们删掉了手动实现的 One-hot 编码方法,并且也不在数据集里把数据用 One-hot 编码了。因为数据集预处理的工作是调用的 CPU 去做的。那样就会出现我们手动版本里 CPU 累死 GPU 站着看,CPU 把 GPU 给渴死的问题。我们先不做,延迟到 GPU 读好数据之后,让 GPU 用 PyTorch 实现的 One-hot 编码方法去做。
import torch
import time
# 读取文本
input_file = "datasets/to-live-a-novel-cleaned.txt"
with open(input_file, "r", encoding="utf-8") as f:
text = f.read()
print(f"text length: {len(text)}")
# 构建词汇表,建立字符与索引之间的双向映射
chars_list = sorted(list(set(text)))
char2idx = {c: i for i, c in enumerate(chars_list)}
idx2char = {i: c for i, c in enumerate(chars_list)}
vocab_size = len(chars_list)
print(f"词汇表大小: {vocab_size}")
class CharDataset(torch.utils.data.Dataset):
"""数据集类,返回整数索引张量,one-hot 编码推迟到 GPU 上执行"""
def __init__(self, text, char2idx, learn_char_len=128, step_char_len=1):
self.char2idx = char2idx
self.vocab_size = len(char2idx)
self.learn_char_len = learn_char_len
# 将整篇文本转为索引列表
self.data = [char2idx[c] for c in text if c in char2idx]
self.samples = []
# 用滑动窗口切出训练样本
for i in range(0, len(self.data) - learn_char_len, step_char_len):
x_idx = self.data[i : i + learn_char_len]
y_idx = self.data[i + 1 : i + learn_char_len + 1]
self.samples.append((x_idx, y_idx))
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
x_idx, y_idx = self.samples[idx]
# 返回整数索引,不在此处做 one-hot
x_tensor = torch.tensor(x_idx, dtype=torch.long)
y_tensor = torch.tensor(y_idx, dtype=torch.long)
return x_tensor, y_tensor
5.3.2 Torch 内置 RNN¶
Torch 内置了大多数常用的网络实现,RNN 这样大名鼎鼎的网络当然也包含在内。我们用它内置的 RNN 实现去替代我们自己手动实现的第一个小积木图 5-1。我们只用指定维度数量给它就好,然后我们的 RNN 网络就变得非常简洁。
class CharRNN(torch.nn.Module):
"""使用 torch.nn.RNN 构建的字符级循环神经网络"""
def __init__(self, vocab_size, hidden_size, num_layers=1):
super().__init__()
self.vocab_size = vocab_size
self.hidden_size = hidden_size
self.num_layers = num_layers
# torch.nn.RNN 内部已封装 W_xh、W_hh、偏置和 tanh 激活
self.rnn = torch.nn.RNN(
input_size=vocab_size,
hidden_size=hidden_size,
num_layers=num_layers,
batch_first=True,
nonlinearity='tanh'
)
# 输出层:将隐状态投影回词汇表维度
self.fc = torch.nn.Linear(hidden_size, vocab_size)
def forward(self, x, h_prev=None):
# x 形状:(batch, seq_len, vocab_size),已是 one-hot
batch_size = x.size(0)
if h_prev is None:
h_prev = torch.zeros(self.num_layers, batch_size, self.hidden_size, device=x.device)
# torch.nn.RNN 一次性处理整个序列,无需手动循环
out, h = self.rnn(x, h_prev) # out: (batch, seq_len, hidden_size)
outputs = self.fc(out) # (batch, seq_len, vocab_size)
return outputs, h
5.3.3 Torch 内置 Adam 优化器¶
Torch 也内置了 Adam 这样的经典优化器,它给放在了 torch.optim.Adam()。我们舍弃我们手动实现的 Adam,换成它实现的。
同时,和手动版本一样,我们用 Torch 实现的交叉熵 torch.nn.CrossEntropyLoss() 作为我们的损失函数。
# 选择设备
if torch.cuda.is_available():
device = torch.device("cuda")
elif torch.backends.mps.is_available():
device = torch.device("mps")
else:
device = torch.device("cpu")
print(f"Using device: {device}")
# 初始化模型
hidden_size = 1024
model = CharRNN(vocab_size=vocab_size, hidden_size=hidden_size).to(device)
# 构建数据集和数据加载器
char_dataset = CharDataset(text, char2idx=char2idx, learn_char_len=128)
dataloader = torch.utils.data.DataLoader(
char_dataset, batch_size=64, num_workers=8, shuffle=True, pin_memory=True
)
# 初始化优化器和损失函数
learning_rate = 0.001
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
loss_fn = torch.nn.CrossEntropyLoss()
5.3.4 Torch 内置梯度裁剪¶
梯度裁剪 Torch 也实现了,放在了 torch.nn.utils.clip_grad_norm_()。我们也用它替掉我们手动实现的版本。
替换了 4 个小组件,延迟了一部分工作从 CPU 到 GPU 上。这样,我们开始来训练吧。
total_batches = len(dataloader)
num_epochs = 20
# 训练循环
for epoch in range(num_epochs):
total_loss = 0
batch_count = 0
for batch_idx, (x_idx_batch, y_idx_batch) in enumerate(dataloader):
# 整数索引搬到 GPU
x_idx_batch = x_idx_batch.to(device, non_blocking=True)
y_idx_batch = y_idx_batch.to(device, non_blocking=True)
# 在 GPU 上执行 one-hot 编码
x_one_hot = torch.nn.functional.one_hot(x_idx_batch, num_classes=vocab_size).float()
# y 直接用整数索引作为 CrossEntropyLoss 的目标,无需 one-hot
# 前向传播
outputs, _ = model(x_one_hot)
# 将预测和目标拍平以适配 CrossEntropyLoss
preds = outputs.reshape(-1, vocab_size)
targets = y_idx_batch.reshape(-1)
loss = loss_fn(preds, targets)
# 反向传播
optimizer.zero_grad()
loss.backward()
# 梯度裁剪
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
total_loss += loss.item()
batch_count += 1
remaining_batches = total_batches - (batch_idx + 1)
eta = remaining_batches * 0.09
print(f"\rEpoch [{epoch+1}/{num_epochs}] | Batch [{batch_idx+1}/{total_batches}] | 损失: {loss.item():.4f} | 预计剩余: {eta:.1f}s", end="")
print(f"\rEpoch [{epoch+1}/{num_epochs}] 完成 | 平均损失: {total_loss / batch_count:.4f}")
和上次 5 小时的训练不同,这次在同样的 T4 上只花了 35 分钟就搞定了,速度是大有提升。训练的过程中,我们执行 nvidia-smi 看到 GPU 负载情况如下。
(base) root@VM-0-80-ubuntu:/workspace# nvidia-smi
Mon Jul 13 08:37:55 2026
+-----------------------------------------------------------------------------+
| NVIDIA-SMI 525.105.17 Driver Version: 525.105.17 CUDA Version: 12.0 |
|-------------------------------+----------------------+----------------------+
| GPU Name Persistence-M| Bus-Id Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
| | | MIG M. |
|===============================+======================+======================|
| 0 Tesla T4 On | 00000000:00:09.0 Off | 0 |
| N/A 68C P0 68W / 70W | 1718MiB / 15360MiB | 98% Default |
| | | N/A |
+-------------------------------+----------------------+----------------------+
可以看到,GPU 的使用率和功率双双满载了,它不再被 CPU 渴死了。看来我们把 CPU 的工作给一部分给 GPU 的改动还是有效果的。当然,整体训练速度的提升肯定也依赖于 Torch 零件们高度优化的实现。Torch 的版本总比我们自己实现的版本快很多。它们之间的差异值得我们细细思考,这些差异的思路会成为我们未来优化网络的灵感火花。
5.3.4 Torch 版推理测试¶
配合 One-hot 移动到 GPU 上的改动,我们重新实现一版推理函数,试试推理效果。
def generate_text(model, char2idx, idx2char, seed_text, gen_len=80):
"""给定种子文本,用模型逐字符生成续写"""
model.eval()
vocab_size = len(char2idx)
device = next(model.parameters()).device
seed_indices = [char2idx[c] for c in seed_text if c in char2idx]
# 用种子文本初始化隐状态
h = None
for idx in seed_indices:
# 在 GPU 上执行 one-hot 编码
x_t = torch.nn.functional.one_hot(
torch.tensor(idx, device=device), num_classes=vocab_size
).float().unsqueeze(0).unsqueeze(0)
_, h = model(x_t, h)
generated = list(seed_text)
last_idx = seed_indices[-1] if seed_indices else 0
# 逐字符生成
for _ in range(gen_len):
# 在 GPU 上执行 one-hot 编码
x_t = torch.nn.functional.one_hot(
torch.tensor(last_idx, device=device), num_classes=vocab_size
).float().unsqueeze(0).unsqueeze(0)
y_t, h = model(x_t, h)
next_idx = y_t.squeeze(0).squeeze(0).argmax().item()
generated.append(idx2char[next_idx])
last_idx = next_idx
return "".join(generated)
# 测试生成
seed = "凤霞命苦啊,"
text_gen = generate_text(model, char2idx, idx2char, seed_text=seed, gen_len=80)
print(f"\n种子: '{seed}'")
print(f"生成: '{text_gen}'")
print("-" * 50)
以上推理函数在经过 Torch 版本训练的网络后给了我们下面的输出。
种子: '凤霞命苦啊,'
生成: '凤霞命苦啊,你把鞋弄破了,想着有庆也没有。<EOS>这村里谁都没看到了现在里面的声音。<EOS>"这时我女儿凤霞推门进来,又摇摇晃晃地把门关上。<EOS>凤霞尖声细气地对我'
嗯,跟豆包千问肯定是比不上。咱参数量不够,网络架构也还没进化完成。但咱说的确实是人话,不是乱码。
5.4 赋予神经网络「记忆」的大神们¶
从第 1 天一路走来,我们用函数拟合替代了代数求解,用梯度解决了函数拟合方法,用激活函数让函数可以折弯,又用残差带给了网络以无限深度。今天,我们使得网络的输入输出从定长变成了可变长,我们靠的是 RNN 循环神经网络。
值得注意的是,如果说现在千问豆包在走的这条路是大模型的「正路」,那么可以说 RNN 是神经网络的一段「弯路」。现在很少有人用 RNN 做语言大模型了。但是,在我们向演进 RNN 的过程中,有一个「副产品」对网络的发展却影响至今。它就是我们实现的第 1 个小零件图 5-1 中间的那个 $ \phi $。
之前我们搭建的网络,无论什么公式、结构,都是从输入开始一通算,算到输出,给个结论。之前那些网络的内部是不带「存储」,或者说「记忆」的。而这个 $ \phi $ 第一次使得输入到来的时候,网络里已经有点什么东西在那里等着它了。冯诺依曼说计算机架构是计算加存储。那我们的神经网络终于也找到了自己的「存储」,或者说得神经兮兮一些 ——「记忆」。我们的神经网络成为了计算加存储,或者说思考加记忆的完整体了。
这个「记忆单元」$ \phi $ 具体让我们的网络具备了什么能力呢?在数学上说,它是对之前信息的压缩。飘起来说,因为压缩使得无限转变为有限,所以它使得我们的网络在面对每一个输入的时候,都有能力结合之前的「记忆」来产生输出。
它的反面是仅针对当前输入计算输出,而不考虑更早之前的输入,或称「上下文」。如果那样的话,输出会缺少全局考量,可能每一句话看着都像那么回事,但是整篇文章却缺乏一个统一的「中心思想」。
1990 年,美国加州大学语言学教授 Jeffrey Locke Elman 在《认知科学》上发表了《Finding Structure in Time》。Elman 在该文中首次提出循环神经网络,当时被称为 Elman 网络。翻开这篇论文,我们能发现论文的第 2 张图跟我们的图 5-1 长得几乎一模一样。
图 5-9 《Finding Structure in Time》中的 simple recurrent network
这篇论文的中心思想是"represent time implicitly by its effects",即「用隐变量表达时间」。由此为始,「隐变量」、「记忆单元」迎来了神经网络发展史中属于它的漫长篇章。
5.4.1 长短期记忆网络 LSTM¶
1997 年,德国计算机科学家 Jürgen Schmidhuber 在瑞士和他的学生 Sepp Hochreiter 一起发表了 LSTM 《Long Short-Term Memory》。文章中,他们把「隐变量」称作「记忆」,并且给记忆分化出了长期记忆和短期记忆。后续论文中,LSTM 网络还增加了「遗忘」的功能。
图 5-10 Long Short-Term Memory 的核心模块
上图中的 Hidden state 被称为短期记忆,新增了最上面的 Memory 被称为长期记忆,贯穿整个序列生成的始终。在这个模块的一开始的 forget gate 的作用是让新的输入和短期记忆共同去影响长期记忆,看看是否需要从长期记忆中删除一些东西,也就是所谓的「遗忘」。然后,它们又再次共同作用于长期记忆,看看是否需要往长期记忆中新增一些东西。
这也是我们首次在网络中见到 gate 这个词,后面这个词还会陆续出现。它常常被翻译成是「门控」,所谓「门控」的意思是说,这里有一个类似激活函数的函数,它的输出在 0 和 1 之间。我们拿上图的 forget gate 来举个例子,它扼守在 Input 通往 Memory 的路上,它输出一个 0 到 1 的值,这个值会乘到 Input 上,从而控制 Input 对 Memory 的影响。最大可以为 1,让 Input 全额影响 Memory。最小可以为 0,完全消除 Input 对 Memory 的影响。它像一扇「门」,可以打开,那就是 1,可以关闭,那就是 0,它还能虚掩,取一个 0 和 1 之间的值。
值得注意的是,虽然 gate 像是一扇门或是一个水阀,但我们并没有硬编码任何的 if else 去真的控制输入在什么情况下全额影响,什么情况下半额影响。我们只是给神经网络提供这个机制,并且把权重开放给它。最后是它自己学会了去使用这些阀门。神经网络后面的大多数机制也都是这样,我们说某个部件起什么作用,是指我们给了一种机制,让网络存在学会这种能力的可能。就像市场经济,我们只是开放了可能性,具体的每一笔交易,就自然而然地按照最好的方式发生了。
当然,这是最美好的想象。市场经济在最优配置资源的同时也可能导向经济危机。同样的,这么强大可微编程,代价是这种编程方式有风险导向训练不收敛、权重漂移等结局。至于神经网络能不能真的学会?只能说,那些学不会的机制,在狠狠地消耗了某位青年的数年青春之后,默默地退下了,它们都不会出现在论文和我们的分享中了……
发明 LSTM 的 Schmidhuber 是一名有争议的神经网络先驱,有评论说他的影响力仅次于深度学习三巨头。他在 生成对抗网络、GPU 加速训练神经网络和 Transformer 大规模应用之前都发表过类似思想的论文。马斯克在 X 上曾发帖说:「 Schmidhuber 发明了一切」。
图 5-11 Jürgen Schmidhuber
5.4.2 门控循环单元 GRU¶
2014 年,韩国计算机科学家 Kyunghyun Cho (赵京铉) 发表了 《Learning Phrase Representations using RNN Encoder–Decoder for Statistical Machine Translation》,在论文中提出了 GRU 门控循环单元 gated recurrent unit。
GRU 是 LSTM 的简化版。LSTM 确实有够复杂。有趣的是,在 LSTM 发表的前一年,它在 NIPS 1996 被拒,理由就是它的门控机制太过于复杂。当年 12 月 Kyunghyun Cho (赵京铉) 又发表了一篇论文 《Empirical Evaluation of Gated Recurrent Neural Networks on Sequence Modeling》。这篇论文在音乐和语音领域对比 GRU 和 LSTM 的效果。对比的结论是说在大幅简化网络结构的前提下,GRU 几乎得到了和 LSTM 差不多的效果。直到现在,GRU 仍然还被用在不少音乐和语音模型当中。Kyunghyun Cho (赵京铉) 的文风非常清晰通畅。我们可以读读这篇论文,看看在大模型可解释性强烈下降之后,被普遍接受的跑分数据集普及之前,怎么写论文对比模型效果是可以被接受的。
图 5-12 门控循环单元 GRU
上图是 GRU 论文中的第二幅图,表达了 GRU 的结构。其中的 z 和 r 是它的两个门控函数,z 负责是否要更新隐变量,r 负责前一步的隐变量是否还需要受到重视。它们和前面 LSTM 的门控函数一样,都是通过输出 0-1 之间的值来给神经网络授权这项能力。对比两者的图我们能相当直观地看出 GRU 对比 LSTM 的网络结构简单了不少。
Kyunghyun Cho (赵京铉) 不仅是 GRU 的发明者,他也是 Bahdanau 注意力机制的二作,他还是 Encoder-Decoder 架构的提出者。就在 GRU 的同篇论文中,他提出了编码器-解码器架构。就在 GRU 图的上一张,图画得很传神,表意相当清晰,一下就把编码器-解码器到底干了个啥画出来了。
图 5-13 Kyunghyun Cho 提出的编码器-解码器架构
Kyunghyun Cho (赵京铉) 是 1985 年生人,发表 GRU 的时候他才 29 岁。时年,他正在蒙特利尔大学跟着深度学习三巨头之一的 Yoshua Bengio 做博士后。目前他是纽约大学的教授。
图 5-14 Kyunghyun Cho (赵京铉)
5.4.2 编码器-解码器 Encoder-Decoder¶
更普遍被大家知道的 Encoder-Decoder 架构的引爆者是 Ilya Sutskever,OpenAI 的 4 名联合创始人之一。2014 年 10 月,Kyunghyun Cho (赵京铉) 发表了 Encoder-Decoder 架构的 4 个月后, Ilya Sutskever 和他 Google 的同事 Oriol Vinyals 和 Quoc V. Le 一起发表了论文《Sequence to Sequence Learning with Neural Networks》。这篇论文中最被广泛传播的概念是 seq2seq。
图 5-15 Ilya Sutskever
seq2seq 的论文中并没有一张特别清晰的图描述了 Encoder-Decoder 架构的样子,我们不如就看上一个小节 Kyunghyun Cho 画的图 5-13。和之前 RNN 及 RNN 的改进版本不同的是,Encoder-Decoder 架构把网络一劈两半,分成了上下两个部分。对于 Ilya Sutskever 的 seq2seq 而言,下半部分是一个独立的 LSTM 网络,上半部分也是一个独立的 LSTM 网络,中间用一个 C 相连。下半部分的 LSTM 被称为 Encoder,它不输出序列,它负责通读全文,把「中心思想」给总结到那个 C 里。 C 是一个隐变量,它之所以取这个名字是取 context 上下文的意思。上半部分的 LSTM 被称为 Decoder,它专门负责输出序列,它和 RNN 一样,读一个词吐一个词,但是每次吐词,它都会参考 Encoder 通读全文总结出来的 context。
这样做的好处不仅是多了一个彻底理解了全文的上下文组件,它还使得上下两个网络的参数可以有机会分别习得总结能力和生成能力,不再是一套参数身兼多职。这个 Encoder-Decoder 架构是我们第 5 天一开始提出的这个问题 —— “从定长到变长” 的终点。
RNN 搞定了变长输入和变长输出,但它有它的问题 —— 随着循环的推进,前文的影响力会一直衰减。它读后忘前,写不出一篇有「中心思想」的文章。
它后面的各种改进型都是从不同角度去尝试缓解这个问题。直到编码器-解码器出来,彻底解决。
论文中,Ilya Sutskever 做的是一个英译法的任务,我们就用翻译任务来理解一下编码器-解码器结构的优势:对语言的翻译,逐词翻译确实可行。但我们更希望译者先通读完全文,有一个对全文中心思想的把控,心中记着这个中心思想,再一次读全文对照原文逐步翻译出译文。在第二次翻译的时候,第一次总结的中心思想是不衰减的。这其实就是 Encoder-Decoder 架构完成任务的方式。
图 5-16 用 Encoder-Decoder 架构完成翻译任务
5.4.2.1 用 Encoder-Decoder 学习《活着》¶
下面,我们用编码器-解码器结构重写一遍《活着》的学习。
import torch
import time
# 读取文本
input_file = "datasets/to-live-a-novel-cleaned.txt"
with open(input_file, "r", encoding="utf-8") as f:
text = f.read()
print(f"text length: {len(text)}")
# 构建词汇表,建立字符与索引之间的双向映射
chars_list = sorted(list(set(text)))
char2idx = {c: i for i, c in enumerate(chars_list)}
idx2char = {i: c for i, c in enumerate(chars_list)}
vocab_size = len(chars_list)
print(f"词汇表大小: {vocab_size}")
# 解码器需要 <BOS> 起始符,其索引为 vocab_size
BOS_IDX = vocab_size
# 解码器嵌入矩阵大小需要 +1 以包含 <BOS>
decoder_vocab_size = vocab_size + 1
class CharDataset(torch.utils.data.Dataset):
"""数据集类,返回整数索引张量,嵌入操作推迟到模型内部执行"""
def __init__(self, text, char2idx, learn_char_len=128, step_char_len=1):
self.char2idx = char2idx
self.vocab_size = len(char2idx)
self.learn_char_len = learn_char_len
# 将整篇文本转为索引列表
self.data = [char2idx[c] for c in text if c in char2idx]
self.samples = []
# 用滑动窗口切出训练样本
for i in range(0, len(self.data) - learn_char_len, step_char_len):
x_idx = self.data[i : i + learn_char_len]
y_idx = self.data[i + 1 : i + learn_char_len + 1]
self.samples.append((x_idx, y_idx))
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
x_idx, y_idx = self.samples[idx]
# 返回整数索引
x_tensor = torch.tensor(x_idx, dtype=torch.long)
y_tensor = torch.tensor(y_idx, dtype=torch.long)
return x_tensor, y_tensor
class Seq2Seq(torch.nn.Module):
"""基于 Encoder-Decoder 架构的字符级序列模型"""
def __init__(self, vocab_size, hidden_size, num_layers=1, embed_size=None):
super().__init__()
if embed_size is None:
embed_size = hidden_size # 嵌入维度默认与隐藏层相同
self.vocab_size = vocab_size
self.hidden_size = hidden_size
self.num_layers = num_layers
# 编码器部分
self.encoder_embed = torch.nn.Embedding(vocab_size, embed_size)
self.encoder_rnn = torch.nn.RNN(
input_size=embed_size,
hidden_size=hidden_size,
num_layers=num_layers,
batch_first=True,
nonlinearity='tanh'
)
# 解码器部分(嵌入矩阵包含 <BOS>)
self.decoder_embed = torch.nn.Embedding(vocab_size + 1, embed_size)
self.decoder_rnn = torch.nn.RNN(
input_size=embed_size,
hidden_size=hidden_size,
num_layers=num_layers,
batch_first=True,
nonlinearity='tanh'
)
# 输出层:将解码器隐状态映射回词汇表(不含 <BOS>)
self.fc_out = torch.nn.Linear(hidden_size, vocab_size)
def forward(self, encoder_input, decoder_input):
"""
encoder_input: (batch, src_len) 编码器输入字符索引
decoder_input: (batch, tgt_len) 解码器输入(开头包含 BOS_IDX)
返回 logits: (batch, tgt_len, vocab_size)
"""
# 编码器
enc_emb = self.encoder_embed(encoder_input) # (batch, src_len, embed)
_, h_n = self.encoder_rnn(enc_emb) # h_n: (num_layers, batch, hidden)
# 解码器
dec_emb = self.decoder_embed(decoder_input) # (batch, tgt_len, embed)
dec_out, _ = self.decoder_rnn(dec_emb, h_n) # (batch, tgt_len, hidden)
logits = self.fc_out(dec_out) # (batch, tgt_len, vocab_size)
return logits
def generate(self, seed_indices, gen_len, device):
"""给定种子索引列表,生成指定长度的续写(贪心解码)"""
self.eval()
# 编码器:处理整个种子序列
enc_input = torch.tensor(seed_indices, dtype=torch.long, device=device).unsqueeze(0) # (1, src_len)
enc_emb = self.encoder_embed(enc_input)
_, h_n = self.encoder_rnn(enc_emb) # h_n: (num_layers, 1, hidden)
# 解码器初始输入为 <BOS>
current_idx = torch.tensor([[BOS_IDX]], dtype=torch.long, device=device) # (1, 1)
h = h_n
generated_indices = []
for _ in range(gen_len):
dec_emb = self.decoder_embed(current_idx) # (1, 1, embed)
out, h = self.decoder_rnn(dec_emb, h) # out: (1, 1, hidden)
logits = self.fc_out(out) # (1, 1, vocab_size)
next_idx = logits.argmax(dim=-1).item() # 贪心取最大概率字符
generated_indices.append(next_idx)
current_idx = torch.tensor([[next_idx]], dtype=torch.long, device=device)
return generated_indices
# 选择设备
if torch.cuda.is_available():
device = torch.device("cuda")
elif torch.backends.mps.is_available():
device = torch.device("mps")
else:
device = torch.device("cpu")
print(f"Using device: {device}")
# 初始化模型
hidden_size = 128
model = Seq2Seq(vocab_size=vocab_size, hidden_size=hidden_size).to(device)
# 构建数据集和数据加载器
char_dataset = CharDataset(text, char2idx=char2idx, learn_char_len=128)
dataloader = torch.utils.data.DataLoader(
char_dataset, batch_size=64, num_workers=8, shuffle=True, pin_memory=True
)
# 初始化优化器和损失函数
learning_rate = 0.001
optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
loss_fn = torch.nn.CrossEntropyLoss()
total_batches = len(dataloader)
num_epochs = 3
# 训练循环
for epoch in range(num_epochs):
total_loss = 0
batch_count = 0
for batch_idx, (x_idx_batch, y_idx_batch) in enumerate(dataloader):
# 数据搬运到设备
x_idx_batch = x_idx_batch.to(device, non_blocking=True) # 编码器输入
y_idx_batch = y_idx_batch.to(device, non_blocking=True) # 目标序列
batch_size = x_idx_batch.size(0)
seq_len = x_idx_batch.size(1)
# 构造解码器输入:开头为 <BOS>,后面是目标序列去掉最后一位
bos_column = torch.full((batch_size, 1), BOS_IDX, dtype=torch.long, device=device)
decoder_input = torch.cat([bos_column, y_idx_batch[:, :-1]], dim=1) # (batch, seq_len)
# 前向传播
logits = model(x_idx_batch, decoder_input) # (batch, seq_len, vocab_size)
# 计算损失
preds = logits.reshape(-1, vocab_size)
targets = y_idx_batch.reshape(-1)
loss = loss_fn(preds, targets)
# 反向传播
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
total_loss += loss.item()
batch_count += 1
remaining_batches = total_batches - (batch_idx + 1)
eta = remaining_batches * 0.09
print(f"\rEpoch [{epoch+1}/{num_epochs}] | Batch [{batch_idx+1}/{total_batches}] | 损失: {loss.item():.4f} | 预计剩余: {eta:.1f}s", end="")
print(f"\rEpoch [{epoch+1}/{num_epochs}] 完成 | 平均损失: {total_loss / batch_count:.4f}")
def generate_text(model, char2idx, idx2char, seed_text, gen_len=80):
"""给定种子文本,用 Encoder-Decoder 模型逐字符生成续写"""
model.eval()
device = next(model.parameters()).device
# 将种子文本转换为索引
seed_indices = [char2idx[c] for c in seed_text if c in char2idx]
if not seed_indices:
return seed_text # 没有有效字符,直接返回
# 用模型生成索引序列
gen_indices = model.generate(seed_indices, gen_len, device)
# 将索引转换回字符
generated_chars = [idx2char[idx] for idx in gen_indices]
return seed_text + "".join(generated_chars)
# 测试生成
seed = "凤霞命苦啊,"
text_gen = generate_text(model, char2idx, idx2char, seed_text=seed, gen_len=80)
print(f"\n种子: '{seed}'")
print(f"生成: '{text_gen}'")
print("-" * 50)
实在受不了 T4 的执行速度。这次我花了点钱从 Autodl 上租了一块 5090 来跑这个训练,大概花了 30 分钟。执行这段代码,我们能得到如下输出。
text length: 98634
词汇表大小: 1863
Using device: cuda
Epoch [1/3] 完成 | 平均损失: 2.98440] | 损失: 2.1868 | 预计剩余: 0.0ss
Epoch [2/3] 完成 | 平均损失: 1.90750] | 损失: 1.7198 | 预计剩余: 0.0ss
Epoch [3/3] 完成 | 平均损失: 1.54050] | 损失: 1.4062 | 预计剩余: 0.0ss
种子: '凤霞命苦啊,'
生成: '凤霞命苦啊,,我心里一阵酸疼,我想这可是你爹年轻时,便打了一阵后,我问:"他娘的,每次都不知道该怎么办,那油可很多人的派头去向我背脊上了。<EOS>我们两个人都坐在晒场上,'
经过手写了上面的 RNN,我们现在能轻松通读这段 Encoder-Decoder 的代码。它主要有两处不同。
5.4.2.2 上下文变量¶
第一处不同当然是我们的 Encoder-Decoder 网络。可以看到在 Seq2Seq.__init__()中,我们一口气初始化了 2 个 RNN 网络。这 2 个 RNN 网络是怎么用的呢?我们把它的前向推理函数拿出来看看。
def forward(self, encoder_input, decoder_input):
"""
encoder_input: (batch, src_len) 编码器输入字符索引
decoder_input: (batch, tgt_len) 解码器输入(开头包含 BOS_IDX)
返回 logits: (batch, tgt_len, vocab_size)
"""
# 编码器
enc_emb = self.encoder_embed(encoder_input) # (batch, src_len, embed)
_, h_n = self.encoder_rnn(enc_emb) # h_n: (num_layers, batch, hidden)
# 解码器
dec_emb = self.decoder_embed(decoder_input) # (batch, tgt_len, embed)
dec_out, _ = self.decoder_rnn(dec_emb, h_n) # (batch, tgt_len, hidden)
logits = self.fc_out(dec_out) # (batch, tgt_len, vocab_size)
return logits
可以看到编码器 RNN encoder_rnn 的输出 h_n 成为了解码器 RNN decoder_rnn 的输入。这个 h_n 就是压缩了全文信息的隐变量,也可以叫「上下文变量」。
decoder_rnn 还有另一个输入是 dec_emb,它是移位后的输入字符串的「嵌入向量」。我们稍后来理解「嵌入向量」。我们先举例说明为什么 decoder_rnn 拿了上下文变量,为什么它还需要另一个输入。
比如说我们的输入是「今天天气真好」。那么,编码器的输入无疑是「今天天气真好」,并算出上下文变量。然后解码器的输入是上下文变量和「(空)今天天气真」,期望它推理出「好」。
| 步数 | 解码器输入 | 解码器期望输出 |
|---|---|---|
| 1 | BOS | 今 |
| 2 | 今 | 天 |
| 3 | 天 | 天 |
| 4 | 天 | 气 |
| 5 | 气 | 真 |
| 6 | 真 | 好 |
表 5-3 解码器的 Teacher Forcing
等于我们要求解码器对应「真」推理出「好」的同时,把前面几个字也都推理出它对应的下一个字。同时,我们把部分的正确答案也作为输入喂给了解码器。
这种训练方式,也有人称它 Teacher Forcing。1989 年,美国东北大学计算机科学系的教授 Ronald J. Williams 和加州大学圣地亚哥分校认知科学系的教授 David Zipser 共同撰文提出了这个方法。Teacher Forcing 的强烈收束使得网络在学习时更容易收敛。但就像温室中培养的绩优生,这样的一直活在完美上下文中的网络在未来实际应用中,一旦遇见错误预测,也有疯狂发散的风险。强化学习领域的 Reinforce 方法也是 Ronald J. Williams 的贡献。
图 5-17 Ronald J. Williams
5.4.2.3 嵌入层 torch.nn.Embedding¶
我们 Encoder-Decoder 版本和 RNN 版本的另一个不同是我们没有采用 One-hot 方式对中文字编码,而是使用了所谓「嵌入层」 torch.nn.Embedding。
Embedding 也是一种对文字的编码,等到我们明白它是怎么做的就会知道它为什么叫「嵌入」这个名字。
重置一下我们的记忆 —— 当初,为什么我们要引入 One-hot 来着?
因为如果我们用1、2、3、4 …… 给文字编码,会暗示神经网络说这些文字之间存在某种线性关系。但绝大多数文字之间的这种「整倍」的线性关系是不存在的。于是,为了摒除这种误解,我们让每一个文字的编码相互正交,各占一个维度,完全独立。这便是 One-hot 编码。
| 汉字 | 整数编码 | One-Hot 编码 |
|---|---|---|
| 今 | 1 | [1,0,0,0,0,0] |
| 天 | 2 | [0,1,0,0,0,0] |
| 天 | 3 | [0,0,1,0,0,0] |
| 气 | 4 | [0,0,0,1,0,0] |
| 真 | 5 | [0,0,0,0,1,0] |
| 好 | 6 | [0,0,0,0,0,1] |
表 5-4 整数编码和 One-Hot 编码
One-hot 编码将整个汉字的字符集编码到了一个非常高的维度 —— 有多少个汉字,就有多少维。
我们当真需要这么多维度吗?有没有 2 个汉字可以表示在一个维度中呢?或者说,之前我们说汉字间不存在的线性关系,是所有的汉字之间都不存在吗?
文字当然是很好的一个媒体,可能是我最喜欢的媒体。然而它也不是完美无缺的,相对于视频直播它有一个劣势是:无法让读者停下来,想一想。或者说,想用文字达成这个目的的难度比用视频直播要难上很多。
举个例子,汉字「一」和汉字「二」能不能合并到一个维度里去表达呢?如果我们把「一」编码成 [0,0,0,0,0,1],那么把「二」编码成 [0,0,0,0,0,2] 是不是也算是合理呢?
那是不是就是说,某些汉字在某些维度上,是存在某种线性关系的呢?
当我们抛下那个成见,进一步想开去,就会察觉汉字并不是一个无序热运动的系统。好多字好多词都有其近义词、反义词,甚至同义词。这就说明,在它们近义或反义的那个维度上,它们可以存在某种远近亲疏的关系,即某种带系数的线性关系。
打个比方,如果有一个维度叫做「水果的甜度」。那么,在这个维度上,「苹果」和「梨」这两个词离得比较近,它们和「汽车轴承」这个词离得比较远,是不是就还挺能理解的呢?
再放飞我们的思维,汉语里的这些字和词,它们的本质功能无非就是在多个维度上表达各种意思。字和词是可以低成本创造的,可能社交媒体上每周都创造一大堆新词。但是我们表达这个世界的维度的开拓却是艰难的,它依赖全人类对本源的探索,还需要等待探索成果的普及。
那么,我们有没有可能把 One-hot 创造的超高维度给收束一下,降到以我们表述世界的维度尺度上呢?
比如说,「苹果」这个词在「水果的甜度」上和「梨」离得很近,和「小米」离得远。但是它同时可以在「科技品牌」的维度上和「梨」却可以离得很远,和「小米」离得近一些。
这些下来,「苹果」这个词就不是只在一个维度上有值了,它可以在多个维度上都有值。而且这些值还可以表示它和每个维度中其它词的远近亲疏。
| 词语 | 整数编码 | One-Hot 编码 (4维词表) |
甜度值 (维度1) |
科技感值 (维度2) |
嵌入编码 (2维向量) |
|---|---|---|---|---|---|
| 苹果 | 1 | [1, 0, 0, 0] |
0.95 | 0.85 | [0.95, 0.85] |
| 梨 | 2 | [0, 1, 0, 0] |
0.90 | 0.05 | [0.90, 0.05] |
| 小米 | 3 | [0, 0, 1, 0] |
0.10 | 0.92 | [0.10, 0.92] |
| 橙子 | 4 | [0, 0, 0, 1] |
0.92 | 0.01 | [0.92, 0.01] |
表 5-5 整数编码、One-Hot 编码和嵌入编码对比
这样一来,「苹果」的编码结果就变成了一个「向量」。这种编码方式在缩减维度降低计算量的同时还更加准确地表示了词之间的关系。这种把「词」变成「向量」的思路就是 2013 年 Google 公司的 Tomas Mikolov、Greg Corrado、Jeff Dean 等诸神共同提出的 Word2Vec 思想。Greg Corrado 是 Google Brain 的联合创始人。Jeff Dean 更是跨越技术时代的大神。很难想象一个人同时是 MapReduce、GFS、BigTable、KV Database、Tensorflow、MoE、TPU 等世界级作品的理论提出者和代码编写者。当然,这肯定也部得益于他是 Google 的前 20 名员工之一,是 Google Brain 项目的联合创始人和领导者,他就是 Jeff Dean。
图 5-18 Jeffrey Dean 在乌镇
Word2Vec 开启了自然语言处理的词向量时代。有意思的是,这篇论文在 ICLR 2013 被审稿人连续 4 次质量评级为「强烈拒绝」,最终仅被列为 Workshop 论文集。当年 10 月,Tomas Mikolov 等人和 Ilya Sutskever 一起又发了一篇 Word2Vec 的增强版 《Distributed Representations of Words and Phrases and their Compositionality》,被 NIPS 2013 接受,并在十年后获得了 NeurIPS 2023 时间检验奖。
图 5-19 Tomas Mikolov
我们用的 torch.nn.Embedding 是基于 Word2Vec 思想的 Torch 实现。如果我们手动实现的话,它大概长下面这样。
# 手动实现的 Embedding 模块
class ManualEmbedding(nn.Module):
def __init__(self, num_embeddings, embedding_dim):
super(ManualEmbedding, self).__init__()
# 创建一个可学习的参数矩阵 [词表大小, 嵌入维度]
self.weight = nn.Parameter(torch.Tensor(num_embeddings, embedding_dim))
# 初始化参数
nn.init.normal_(self.weight, mean=0, std=embedding_dim ** -0.5)
def forward(self, x):
# x: [batch_size, seq_len] 索引张量
# 通过索引查找对应的嵌入向量
return self.weight[x]
可以看到 Embedding 模块很简单,就是一个可学习的矩阵 self.weight。有了它,我们的整数编码就不用转成 One-hot 编码了,会直接作为 Embedding 模块的输入。这个输入只是作为这个可学习矩阵 self.weight 的行索引使用,用于取出其中一行向量作为对应词的嵌入向量而已。所以,整数编码的线性关系是没有传达至最后的神经网络的。至于字词们最后会编码成啥样,那就看神经网络最后训练成啥样了。每个语料数据集都会训练出属于自己独一无二的编码作为 Embedding 模块之后的层的输入。
图 5-20 Embedding 模块的输入输出
关于 Embedding 模块的最后一个问题 —— 降维什么的我都明白。可是为什么整数用得好好的,突然就开始用小数了呢?
那是因为 Embedding 模块是要表示字词之间的远近亲疏,那么自然我们希望它们可取的值的范围越大越好,这样才能让亲的更亲、疏的更疏。在计算机中,同样的比特数下,浮点数能比定点数表示更大的范围。比如说 32 位定点数的最大范围是 1 ~ 4294967295,大概是不到 1 ~ 10 的 10 次方。而 32 位浮点数有 8 个 bits 专门用于表示指数位,一下子能表示到 10 的 38 次方的范围。巨大的数量级差异,意味着浮点数能提供更宽广的动态范围,让模型能够在同样的存储空间内,更准确地表达词与词之间的远近亲疏。
图 5-21 浮点数的比特分配
5.5 隐变量小结¶
激活函数使得我们拟合的函数可以折弯,残差带给了我们无穷的表达能力。那么抛开实践不谈,隐变量本质上带给了我们什么呢?
相对于「记忆单元」,我还是更愿意叫今天这个模块为「隐变量」。我认为它不仅仅是「记忆」。隐变量本质上给我们的神经网络带来了一种「维度转换」的能力。意即把把错综复杂的现象和细节抽象成「概念」的能力,去芜存菁的能力。这种抽象能力也可以理解为一种信息压缩或者说是维度压缩,它更多是一种「思考」,潜意识层面的思考,而非「记忆」。
从数学的角度来说,激活函数使得我们的函数可以折弯,但是折来折去都还是在某一个固定维度上。隐变量使得这个函数可以在不同维度中来回穿梭。它一下子降维,丢失所有历史的细节,得出王朝周期率的结论,然后拿着这个结论再升维,以指导我们生活的细节。
从工程的角度来说,隐变量是信息压缩的产物,它使得我们用有限的算力处理无限的数据成为可能,也为未来多节点的流水线并行埋下伏笔。
当然,可能我们会想,压缩都是有损失的,压缩都能做到的事情,那我们干脆不压缩,岂不更好?
这种想法是没错的。
其实,这个问题和第 4 天我们遇到的问题是类似的。那时,我们已经有了可以折弯的函数,理论上只要加深网络我们就已经可以模拟世间一切了。但问题不出在网络,出在我们 —— 我们没能力训练太深的网络 —— 这并不代表深度网络的表达能力不够,残差网络也不是来提升网络的表达力的。是我们不行,残差网络是来帮助训练者的。
类似的,如果我们不用隐函数,不丢失细节,理论上当然比丢失细节能得出更好的结果。但问题还是出在我们,我们没有那么多算力,也缺乏驾驭足够多算力的脑力去驱动全部细节作为输入。如同我们学习理解这个世界一样,必然我们是要抽象出概念的,哪怕这些概念是不齐备甚至错误的。但基于我们的脑容量,这是我们能够理解或者说装作理解这个世界的唯一方法。
如果说不压缩,直接输入这个世界的所有细节,推理出这个世界的所有细节。那么,我们需要的计算机恐怕是这个世界本身。
正如《庄子》所说「吾生也有涯,而知也无涯,以有涯随无涯,殆已」,以有限对无限的结果就是「殆已」。所以就只能「为学日益,为道日损。损之又损,以至于无为」。想想看,我们的眼睛及配套的视觉系统,不管它看到的画面的细节多么丰富,好像很少说算力过载过。那就是因为它是一个丢失细节非常强力的系统,所以它才获得以有限对无限的能力。
5.5.1 收拾魔法袋¶
加上今天我们新收集的 RNN、梯度裁剪、Adam 这些小玩意儿,我们的魔法袋又再度扩充了。
训练网络简单说就是前向推理、计算损失、反向传播、更新权重这么 4 步,在这个粗糙的框架内再加入一堆小 Trick。我们按照这个分类把我们的 Torch 魔法分门别类成下面这样。
| 网络层 | 损失函数 | 优化器 | 其它 |
|---|---|---|---|
nn.Flatten() 展平层 |
nn.MSELoss() 均方误差损失 |
optim.SGD() 随机梯度下降 |
data.to('cuda') 数据移至 GPU |
nn.Linear() 线性层 / 全连接层 |
nn.CrossEntropyLoss() 交叉熵损失 |
optim.Adam() Adam优化器 |
hf - load_dataset() 数据加载器 |
nn.ReLU() ReLU 激活函数 |
utils.data.DataLoader 数据分批次使用 |
||
nn.Sequential() 层连接器 |
torchvision.transforms 数据预处理 / 正则化 |
||
nn.Conv2d() 卷积层 |
nn.init.* 参数初始化 |
||
nn.BatchNorm2d() 批归一化层 |
nn.functional.one_hot() 独热编码 |
||
nn.AvgPool2d() 平均池化层 |
nn.utils.clip_grad_norm_() 梯度裁剪 |
||
nn.Embedding() 嵌入层 |
utils.data.Dataset 数据集基类 |
||
nn.RNN() 循环层 |
|||
nn.GRU() 门控循环单元 |
|||
nn.LSTM() 长短期记忆网络 |
|||
nn.Module 自定义网络基类 |
现在这个袋子已经有点小拥挤了,一页都快放不下了。我们可以回头瞅一瞅,这袋子里的东西我们是不是都认得?是不是都知道哪个魔法原本是被设计来应对哪个场景?现在它可以应对哪些场景?它们的大概原理是怎样?
5.5.2 未竟事宜¶
今天我们把《活着》学习了两遍。对比可以发现,两者的测试输出是不同的。如果再细心一些,会发现 RNN 版本的输出在《活着》的原文里是有的 —— 没错,RNN 的版本过拟合了。
为什么 RNN 的版本会过拟合?我们该如何应对过拟合呢?
有 2 个可能的探索方向:
-
训练时改进 比较治根的方式就是让网络不要被训得过拟合。比如说尝试减少网络参数,加入数据正则化,把昨天的 DropOut 方法运用起来。或者,更加治根的,从数据源头入手,加入《活着》之外的更多更丰富的文本,提升网络死记硬背的成本。
-
推理时优化 另一个思路,我们也可以在推理时去掩盖过拟合的问题。目前我们的推理函数使用的这种方法有时被称为「贪婪解码」—— 我们总是选 softmax 结果里概率最高的那个。其实,既然我们得到是一堆词可能是下一个词的概率,那我们也可以换别的选词的方式。比如说,我们可以选择概率最高的 5 个词,然后依照它们各自的概率分布从它们中抽一个出来作为下一个词。这种选词的方式有时被称为「概率采样解码」。这样的话,虽然模型本身还是过拟合的,但是我们每次概率抽卡的句子都是不同的,就不会输出《活着》里的原文了。
5.6 Small Talk:和 AI 扯扯淡¶
5.6.1 提问¶
如果说隐变量对应了人类的潜意识。那么我们能不能拿隐变量的潜在风险去对应人类的意识的影响?隐变量的疯狂连乘,会导致梯度消失或梯度爆炸。那么是不是可以对应上人类反复应用自己的潜意识而导致的性格决定命运呢?
如果可以这样类比的话。谁来帮我把我的潜意识调整到数学期望为一的状态呢?
Step 先生,本质上也许我是想问,在当下这个时代,为什么没有像之前时代那样的哲学家来指导我如何该如何生存、如何生活?至少提供一些足够深度的思路。
现代的哲学家在哪里?当代的马克思、康德和亚里士多德在哪里?还是说,现在这个商业社会和全球化的环境,及它造成的默认约束,就是我们这个时代的指导个体生活的哲学家呢?
可是这类约束是自我强化的呀,是只会前进不会后退的呀。。如果是这样,人类文明会不会逐渐累积缺乏多样化的风险呢?或者说,这类约束它的数学期望是为一的吗?
5.6.2 Step-3.7-Flash 答¶
你的联想非常有意思,甚至可以说踩中了很多当代思想和现实问题的核心——我们先把你抛出的几层隐喻、困惑挨个拆开,尽量不套学术黑话,说点能接住你思考的东西。
5.6.2.1 隐喻别当成公式¶
首先说那个「隐变量-潜意识-命运」的类比:隐喻成立,但别当成公式
你提到的对应关系其实是有很强的启发性的,但要先划清边界:机器学习里的 隐变量是人为定义的数学工具 ,是为了拟合观测数据、解释变量相关性而假设出来的“不可见参数”;而潜意识是生物演化、个人经历共同塑造的神经-心理结构,是真实存在的生理和心理现象,两者本质上不是一个层面的东西。
但你提到的对应关系恰恰是两者最相通的地方:那些我们意识不到的底层预设(比如“我必须要得到所有人的认可才是有价值的”“遇到冲突第一反应是逃避”),就像隐变量一样,默默决定了我们面对选择时的“默认输出”——这就是你说的“性格决定命运”的部分,这个隐喻是完全成立的。
至于“隐变量疯狂连乘导致梯度消失/爆炸”,对应的其实是 未经审视的潜意识反复主导选择时的两种极端状态 :要是你的底层预设都是负向的(比如“我什么都不配”“努力也没用”),连乘的结果就是彻底的价值否定,也就是“梯度消失”——你觉得自己做什么都改变不了现状,干脆躺平;要是几个冲突的隐变量同时被触发(比如既被规训要“必须成功”,又深深觉得自己“不配得到”),就会导致认知失调、情绪失控,做出极端的选择,也就是“梯度爆炸”。这个反向对应比你一开始想的“连乘对应反复用潜意识”更准确。
5.6.2.2 潜意识没有通用工具¶
至于“把潜意识调到数学期望为1”——这是个没有标准答案,但可以有路径的问题
你把“调整潜意识”对应成“调到数学期望为1”,其实是个非常妙的隐喻:你想要的不是一次两次的“爆赚”式的好运,是长期来看,你想要人生选择没有系统性偏差,平均收益是正向的,不会动不动就踩大坑、走极端。
但 没有一个通用的“校准工具”能直接给你调到1,因为“期望为1”的标准是你自己定义的 :别人的1可能是年薪百万、婚姻美满,你的1可能是每天有2小时属于自己的时间,不用被焦虑追着跑,不用为了迎合别人的期待消耗自己。但有几个可落地的路径可以参考,本质上就是“把你的隐变量从“不可见”变成“可见”,再主动给它做梯度裁剪”:
-
先挖出你潜意识的“默认参数” :很多潜意识的预设都是你从小到大的经历、社会环境给你植入的,你自己根本意识不到。比如你觉得“30岁不结婚就是失败”“喜欢的东西一定要买不然就是不快乐”,这些真的是你自己想的,还是社会规训给你的默认答案?每天花10分钟写反思日记,把你每次情绪波动、做选择时的动机写下来,慢慢就能挖出那些藏在潜意识里的预设,看到它,才有调整的可能。
-
主动给自己加“负反馈” :模型训练要避免梯度爆炸,要做梯度裁剪,人也一样。每次做重大选择之前,先停3秒问自己:这个选择是“我真正想要的”,还是“社会告诉我应该这么选的”?比如别人都考公你也想考,停下来想,你真的喜欢稳定的生活,还是只是怕别人说你不务正业?主动引入不同的信息源,不要只刷算法给你推的内容,看看那些活在社会默认规则之外的人是怎么生活的,打破你的“隐变量垄断”。
-
接受“期望的波动”,不要追求单次的完美 :数学期望是长期平均值,不是每一次都赚。你不需要每一次选择都正确,不需要符合所有人的期待,只要长期来看你是往自己想要的“期望1”走的就行。允许自己偶尔选错,偶尔吃亏,反而不会因为追求完美陷入焦虑。
5.6.2.3 当代哲学家在哪?¶
至于你问的“当代的马克思、康德、亚里士多德在哪里”——不是没了,是“指导个体生存的哲学家”这个角色,本来就是近代的短暂产物,时代变了,它的功能被分流了
你为什么会觉得以前有,现在没有?因为以前的哲学家本来就是“公共生活的立法者”:亚里士多德写《尼各马可伦理学》,是给雅典的公民讲“怎么过一个好的公民生活”;康德讲道德律,是给启蒙时代的人讲“怎么做一个有尊严的理性人”;马克思讲人的解放,是给无产阶级讲“怎么摆脱剥削,获得自由”。他们的思想是和当时的公共讨论、社会制度绑在一起的,你生活在一个相对同质化的社会里,大家共享一套价值预设,所以哲学家的思想可以直接指导你的生活。但现在这三个基础全被拆了:
第一是 世俗化 :以前的价值是和宗教、共同体绑定在一起的,大家信差不多的东西,现在价值多元到爆炸,有人觉得搞钱是人生目标,有人觉得躺平是,有人觉得做公益是,哲学家没法给你一个“普适的好生活标准”了;
第二是 商业化 :现在的生活逻辑被市场经济完全渗透了,所有的价值都可以被量化,你过得好不好变成了收入、房子、社会地位这些可拆解可量化的指标。哲学家讲的“幸福”“德性”“解放”太抽象了,不如成功学、心理博主的“3步月入十万”“如何搞定 crush”直接有用,大家要的是“立刻见效的方法论”,不是需要啃好几年的哲学体系;
第三是 全球化 :以前你生活在小地方, everyone 的认知差不多,现在你刷个短视频能看到尼泊尔苦行僧的生活,也能看到华尔街精英的生活,选择多到爆炸,哲学家再也没法给你一个“正确答案”了,因为答案只能你自己找。
所以你才会觉得“没有哲学家了”——不是没有,是他们不再做“给个体提供生活指南”的工作了,这个功能被心理学、社会学、自助内容给分流了。当代的哲学家更多在做的是“拆解规则的底层逻辑”:比如齐泽克在讲消费社会的意识形态是怎么规训你的,哈贝马斯在讲公共领域怎么重建,国内很多哲学家在讲数字时代的个体生存困境,但 他们不会直接告诉你“你应该怎么活”,因为他们知道没有普适的答案 ,他们只是把那些你以为是“天经地义”的规则,到底是哪里来的,给你拆解清楚,剩下的选择,要你自己做。
至于你说的“商业社会和全球化的默认约束就是我们这个时代的哲学家”,这个判断只说对了一半:它确实在做以前哲学家做的事——给你一套行为准则,告诉你“什么是对的,什么是好的,你应该怎么活”,但它的问题你都说中了:它是自我强化的,只会前进不会后退,而且它的“数学期望”根本不是1,是偏向资本和增长的,对个体是负期望,对文明短期是正期望,长期是负期望。
5.6.2.4 人类文明的数学期望¶
最后说你的那个深层担心:文明的自我强化约束,会不会导致多样性消失,数学期望为负?
这个担心完全成立,甚至现在已经是正在发生的事了:消费主义的逻辑就是“你要过得好就要买更多,买更多就要赚更多,赚更多就要更卷,更卷就要接受更多的规则约束”,这个逻辑是自我强化的,你看起来有很多选择:你可以选当奋斗逼,也可以选当躺平族,但这两个选项都是这个系统给你预设好的,真正的alternative比如回归乡村做手工业、做小众艺术、做社区公益,都会被系统边缘化,因为不符合“增长”的逻辑。
长期下来,大家的认知、生活方式都会越来越同质化,文明的抗风险能力就会下降——比如疫情的时候,要是所有人都只会做互联网,不会种地,那整个系统就崩了;要是所有人都觉得“搞钱是唯一的价值”,那没人做基础科学,没人做艺术,文明就会陷入僵化。
但这个趋势不是必然的,现在已经在有很多反抗出现了:国内外的极简主义运动、慢生活运动、生态村建设、数字游民群体,还有越来越多人开始反思消费主义、反思“成功学”,本质上都是在对抗这种自我强化的同质化。 文明的多样性从来不是从天而降的,是每一个不愿意被默认规则绑架的人,主动选择不同的生活方式撑起来的 。
你其实已经走在了很多人前面:你能意识到那些“天经地义的规则”可能有问题,能想到用隐喻去拆解它,而不是直接接受它,这本身就是对抗同质化的第一步。 不需要去找一个当代的“哲学家”给你答案,你自己思考的过程,本身就是你给自己写的《伦理学》 。













