深度学习-代码

该部分是跟着李沐老师的《动手学习深度学习》完成的,也将学习过程中的一些内容放在了仓库里,欢迎查看 https://github.com/rdcamelot/d2l--

其中的第一、二章,以及最后的计算机视觉等部分并没有记录在此

该博客只是略作整理

一些函数

初始化

用正态分布进行初始化

X = torch.normal(0, 1, (num_examples, len(w))) # 通过正态分布,生成特征矩阵

初始化为给定常数

nn.init.constant_(m.weight, 1)

调用apply函数进行初始化
# PyTorch不会隐式地调整输入的形状。因此,
# 我们在线性层前定义了展平层(flatten),来调整网络输入的形状
net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))

def init_weights(m):
    if type(m) == nn.Linear:
        nn.init.normal_(m.weight, std=0.01)

# net.apply(init_weights) 自动遍历 net(以及它的所有子模块),对每个子模块调用 init_weights。
# 因此,对于网络中所有的 nn.Linear 层,都会执行我们定义的初始化操作,从而完成整个模型(或部分模型)的参数初始化。
net.apply(init_weights)
Xavier初始化

Xavier 初始化的核心思想是保持各层输入和输出的方差一致,这样信号既不会在前向传播时逐渐消失,也不会在反向传播时逐渐消失。

为此从从均值为 0、方差为:\(\sigma^2 = \frac{2}{n_{in} + n_{out}}\)的均匀分布或正态分布中抽取权重。

具体实现有两种常见变体:

  • 均匀分布版本:从均匀分布 \(U[-a, a]\) 中采样,其中: $$a = \sqrt{\frac{6}{n_{in} + n_{out}}}$$
  • 正态分布版本:从正态分布 \(N(0, \sigma^2)\) 中采样,其中: $$\sigma = \sqrt{\frac{2}{n_{in} + n_{out}}}$$

PyTorch中的实现

  • 均匀分布版本 nn.init.xavier_uniform_(tensor)
  • 正态分布版本 nn.init.xavier_normal_(tensor)
def init_xavier(m):
    if type(m) == nn.Linear:
        nn.init.xavier_uniform_(m.weight)
def init_42(m):
    if type(m) == nn.Linear:
        nn.init.constant_(m.weight, 42)

net[0].apply(init_xavier)
net[2].apply(init_42)
print(net[0].weight.data[0])
print(net[2].weight.data)

# # 应用到整个网络
# net.apply(init_xavier)
# # 或者只应用到特定层
# nn.init.xavier_uniform_(net[0].weight)
批量规范化层

BatchNorm2d 是 PyTorch 中专门用于二维特征图(例如卷积层输出)的批量归一化层。它可以对输入数据按通道进行归一化,即针对每个输出通道统一计算均值和方差,再进行标准化、缩放和偏移操作。

主要作用:
稳定训练过程:通过降低各层输入数据在训练过程中的分布变化(内部协变量偏移),使训练更加稳定,加快收敛速度。
允许使用更大的学习率:归一化后的数据更规范,可以使用更高的学习率训练网络。
具有正则化效果:归一化过程中引入的小批量统计噪声,对一定程度上防止过拟合有帮助。

# nn.BatchNorm2d:
#     专门用于处理二维卷积特征图
#     在卷积神经网络中最常用
#     nn.BatchNorm2d(6) 中的 6 是指通道数(channels),即卷积层输出的特征图的通道数

net = nn.Sequential(
    nn.Conv2d(1, 6, kernel_size=5), nn.BatchNorm2d(6), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2),
    nn.Conv2d(6, 16, kernel_size=5), nn.BatchNorm2d(16), nn.Sigmoid(),
    nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
    nn.Linear(256, 120), nn.BatchNorm1d(120), nn.Sigmoid(),
    nn.Linear(120, 84), nn.BatchNorm1d(84), nn.Sigmoid(),
    nn.Linear(84, 10))
参数绑定

初始化时设置一个共享层,然后使用它的参数来设置另一个层的参数

# 我们需要给共享层一个名称,以便可以引用它的参数
shared = nn.Linear(8, 8)
net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(),
                    shared, nn.ReLU(),
                    shared, nn.ReLU(),
                    nn.Linear(8, 1))
net(X)
# 检查参数是否相同
print(net[2].weight.data[0] == net[4].weight.data[0])
net[2].weight.data[0, 0] = 100
# 确保它们实际上是同一个对象,而不只是有相同的值,因此在修改其中一个参数后再次判断
print(net[2].weight.data[0] == net[4].weight.data[0])
类型转换函数

true_w, features, poly_features, labels = [torch.tensor(x, dtype= torch.float32) for x in [true_w, features, poly_features, labels]]
使用了列表推导式,对包含了四个变量的列表进行遍历;
对列表中的每个变量 x(原先都是 NumPy ndarray)调用 torch.tensor(x, dtype=torch.float32)。
这里显式指定 dtype 为 torch.float32,确保生成的 Tensor 数据类型是 32 位浮点数,这也是深度学习中常用的数据类型。

文本预处理

原始文本数据需要经过几个预处理步骤。 例如,我们用空格代替不间断空格(non-breaking space),使用小写字母替换大写字母,并在单词和标点符号之间插入空格。

#@save
def preprocess_nmt(text):
    """预处理“英语-法语”数据集"""
    def no_space(char, prev_char):
        return char in set(',.!?') and prev_char != ' '

    # 使用空格替换不间断空格
    # 使用小写字母替换大写字母
    # text.replace('\u202f', ' ')
	# 作用:将字符串 text 中的所有 “\u202f” 字符(窄的不间断空格)替换为普通空格 ' '。
	# 返回值:返回一个新的字符串,新字符串中所有 \u202f 都被普通空格替换掉。注意字符串在 Python 中不可变,所以每次 replace 都会返回一个新的字符串副本。
    #.replace('\xa0', ' ')
	#作用:在上一步返回的字符串上调用,将所有的 “\xa0” 字符(常见的不间断空格符)替换为普通空格。
	#返回值:返回新的字符串,其内容中 \xa0 均被替换成普通空格。
	# 使用空格替换不间断空格
    # .lower()
	#作用:将替换后的字符串中的所有大写字母转换为小写字母。这通常使得文本标准化,便于后续文本处理(例如分词、构造词表等)。
	# 返回值:返回最终的小写化的字符串
    text = text.replace('\u202f', ' ').replace('\xa0', ' ').lower()
    # 在单词和标点符号之间插入空格
    out = [' ' + char if i > 0 and no_space(char, text[i - 1]) else char
           for i, char in enumerate(text)]
    return ''.join(out)

text = preprocess_nmt(raw_text)
print(text[:80])

部分内置函数

独热编码

在train_iter中,每个词元都表示为一个数字索引, 将这些索引直接输入神经网络可能会使学习变得困难。 我们通常将每个词元表示为更具表现力的特征向量。 最简单的表示称为独热编码(one-hot encoding)

简言之,将每个索引映射为相互不同的单位向量: 假设词表中不同词元的数目为N(即len(vocab)), 词元索引的范围为0到N-1。
如果词元的索引是整数i, 那么我们将创建一个长度为N的全0向量, 并将第i处的元素设置为1。
此向量是原始词元的一个独热向量。

from torch.nn import functional as F

F.one_hot(torch.tensor([0, 2]), len(vocab))
嵌入层

nn.Embedding 是 PyTorch 中用于将离散值(通常是词元或符号的索引)映射到连续密集向量空间的层

它相当于一个查找表,将输入的整数索引转换为相应的向量表示。
构造函数通常传入两个参数:词汇表大小(num_embeddings)和嵌入向量的维度(embedding_dim)。例如,nn.Embedding(vocab_size, embed_size) 创建一个形状为 (vocab_size, embed_size) 的权重矩阵,索引对应矩阵的行。

作用:

  • 词嵌入:在自然语言处理中,词语具有离散的符号表示,直接使用独热编码(one-hot)会非常稀疏且维度高。嵌入层将词元编码成低维且密集的向量表示,便于捕获语义关系。
  • 降维和参数共享:与独热编码相比,嵌入层能显著降低输入维度,同时通过学习得到的嵌入向量能够捕获词语之间的相似性。
  • 可训练性:嵌入层中的权重(也就是词向量)在训练过程中可以自动更新,使得词向量能够逐步适应具体任务(如机器翻译、文本分类等)。

例如在序列到序列学习的循环神经网络编码器中

class Seq2SeqEncoder(d2l.Encoder):
    """用于序列到序列学习的循环神经网络编码器"""

    # vocab_size:词汇表大小,决定嵌入层的输入维度
    # embed_size:词嵌入维度,每个词元表示为多少维的向量
    # num_hiddens:GRU隐藏单元数量,决定隐藏状态维度
    # num_layers:GRU层数,增加层数可提高模型捕获复杂模式的能力
    # dropout:防止过拟合的丢弃率,默认为0表示不使用dropout
    # **kwargs:其他可能传递给基类的参数
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 dropout=0, **kwargs):
        super(Seq2SeqEncoder, self).__init__(**kwargs)
        # 嵌入层
        # 创建嵌入层,将词元索引转换为密集向量
        # 词元是离散的符号,需要转换为连续的向量才能被神经网络处理
        # 嵌入层学习词元的分布式表示,可以捕获词元之间的语义关系
        # 降低输入维度,使用密集向量而非独热编码,减少参数数量
        self.embedding = nn.Embedding(vocab_size, embed_size)

        # 使用门控循环单元作为循环神经网络
        self.rnn = nn.GRU(embed_size, num_hiddens, num_layers,
                          dropout=dropout)

    def forward(self, X, *args):
        # 输出'X'的形状:(batch_size,num_steps,embed_size)
        X = self.embedding(X)
        # 在循环神经网络模型中,第一个轴对应于时间步
        # 将张量维度从(batch_size, num_steps, embed_size)转换为(num_steps, batch_size, embed_size)
        # 这种排列使得模型按时间步处理序列,而不是按批次处理
        X = X.permute(1, 0, 2)
        # 如果未提及状态,则默认为0
        output, state = self.rnn(X)
        # output的形状:(num_steps,batch_size,num_hiddens)
        # state的形状:(num_layers,batch_size,num_hiddens)
        return output, state
卷积层

对于高度和宽度不同的卷积核,可以填充不同的高度和宽度

conv2d = nn.Conv2d(1, 1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape

为了高效计算或者缩减采样次数,也可以控制步幅参数

将高度和宽度的步幅设置为2,从而将输入的高度和宽度减半
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
comp_conv2d(conv2d, X).shape
多通道
def corr2d_multi_in_out(X, K):
    # 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
    # 最后将所有结果都叠加在一起
    return torch.stack([corr2d_multi_in(X, k) for k in K], 0) # 遍历K的第0个维度(输出通道维度),计算该通道的输出结果
    # 该过程相当于对于每个输出通道,使用对应的卷积核与输入的所有通道进行互相关运算并求和

卷积的本质是有效提取相邻像素间的相关特征,而1x1矩阵失去了该能力
实际上,1x1卷积唯一的计算发生在通道上,如调整通道数量,整合不同通道间的信息

正如上面提到的,不同输出通道的信息本质是用不同卷积核对所有输入通道进行卷积操作
因此1x1卷积将每个位置的不同输入通道下的元素进行了整合,一定程度上类似于全连接层,以\(c_i\)个输入值转换为\(c_o\)个输出值,且保留了空间结构

def corr2d_multi_in_out_1x1(X, K):
    c_i, h, w = X.shape # 输入特征图的通道数、高度和宽度
    c_o = K.shape[0] # 输出通道数
    X = X.reshape((c_i, h * w)) # 将输入特征图展平为二维矩阵,形状为 (c_i, h * w)
    K = K.reshape((c_o, c_i)) # 将卷积核展平为二维矩阵,形状为 (c_o, c_i)
    # 于是相当于进行全连接层中的矩阵乘法
    # 每个输出通道的每个像素是不同输入通道下的线性组合
    Y = torch.matmul(K, X)
    return Y.reshape((c_o, h, w))
可视化函数

show_trace_2d 是 d2l 库提供的一个可视化函数,其主要作用是绘制二维目标函数的轮廓图,并在图上标出训练过程中优化算法(如梯度下降或带动量的梯度下降)所产生的迭代轨迹,即模型参数在二维空间中的更新路径。

作用:
绘制目标函数的等高线,使你能够直观地看到函数的“形状”以及梯度下降方向。
绘制优化算法在参数空间中的更新路径(轨迹),展示从初始点到最终收敛点(或过程中移动的路径)的动态过程。

绘制的路径代表:
图中标出的路径是由训练过程中每一次参数更新的位置组成的。通过观察这条路径,你可以了解到:
优化算法如何在目标函数的曲面上迭代;
参数更新的方向以及收敛过程;

在一些问题(例如狭谷现象)中,路径可能呈现出震荡或者弯曲的特点,从而帮助分析优化算法的行为和收敛速度。

每个点(x₁, x₂)代表一次参数更新后的具体数值。也就是说,当我们使用梯度下降(或动量法)进行优化时,每次更新都会得到一个新的参数位置,这些位置共同构成迭代轨迹。

等高线则表示目标函数在二维参数空间上取相同值的一系列点,即沿着等高线上的任意点,其目标函数值都相等。这帮助你直观地看到目标函数的形状和梯度的下降方向

例如:

%matplotlib inline
import torch
from d2l import torch as d2l

eta = 0.4
def f_2d(x1, x2):
    return 0.1 * x1 ** 2 + 2 * x2 ** 2
def gd_2d(x1, x2, s1, s2):
    return (x1 - eta * 0.2 * x1, x2 - eta * 4 * x2, 0, 0)

d2l.show_trace_2d(f_2d, d2l.train_2d(gd_2d))
循环神经网络

nn.RNN 层本身返回的是每个时间步的隐藏状态和最终的状态,而不是直接给出经过输出层映射后的预测结果。因此,我们通常需要在 nn.RNN 之上再添加一个全连接层(例如 nn.Linear)来将隐藏状态转换为最终输出,例如词汇表上的概率分布。
例如:

# 输入层接受词元表示,输出层预测下一个词元的概率分布
num_hiddens = 256
rnn_layer = nn.RNN(len(vocab), num_hiddens)

class RNNModel(nn.Module):
    """循环神经网络模型"""
    def __init__(self, rnn_layer, vocab_size, **kwargs):
        super(RNNModel, self).__init__(**kwargs) # 调用父类的初始化方法
        self.rnn = rnn_layer
        self.vocab_size = vocab_size
        self.num_hiddens = self.rnn.hidden_size
        # 如果RNN是双向的(之后将介绍),num_directions应该是2,否则应该是1
        if not self.rnn.bidirectional:
            self.num_directions = 1
            self.linear = nn.Linear(self.num_hiddens, self.vocab_size)  # 创建一个从隐藏层大小到词汇表大小的线性层
        else:
            self.num_directions = 2
            self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size) # 会包含两个方向的隐藏状态,因此输入维度翻倍

    def forward(self, inputs, state):
        X = F.one_hot(inputs.T.long(), self.vocab_size)
        X = X.to(torch.float32)
        Y, state = self.rnn(X, state)
        # 全连接层首先将Y的形状改为(时间步数*批量大小,隐藏单元数),本来是(时间步数,批量大小,隐藏单元数)
        # 它的输出形状是(时间步数*批量大小,词表大小)。
        output = self.linear(Y.reshape((-1, Y.shape[-1])))
        return output, state

    def begin_state(self, device, batch_size=1):
        if not isinstance(self.rnn, nn.LSTM):
            # nn.GRU以张量作为隐状态
            return  torch.zeros((self.num_directions * self.rnn.num_layers,
                                 batch_size, self.num_hiddens),
                                device=device)
        else:
            # nn.LSTM以元组作为隐状态
            # 返回一个包含两个全零张量的元组(h_0, c_0)作为初始隐藏状态
            # h_0: 隐藏状态,形状为(方向数×层数, 批量大小, 隐藏单元数)
            # c_0: 单元状态,形状为(方向数×层数, 批量大小, 隐藏单元数)
            return (torch.zeros((
                self.num_directions * self.rnn.num_layers,
                batch_size, self.num_hiddens), device=device),
                    torch.zeros((
                        self.num_directions * self.rnn.num_layers,
                        batch_size, self.num_hiddens), device=device))
矩阵乘法

生成标量:
y = torch.matmul(X, w) + b # 通过matmul矩阵乘法,生成标签

矩阵乘法:
H = relu(X@W1 + b1) # 这里“@”代表矩阵乘法

应用

通过类似于函数型变量的形式来编写代码
# 定义均方损失函数
def squared_loss(y_hat, y):  #@save
    """均方损失"""
    return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

# 将该函数传递给损失函数变量
loss = squared_loss  # 损失函数,这里使用均方误差

# 在训练代码中调用loss计算损失
l = loss(net(X, w, b), y)  # X和y的小批量损失,前向传播
基础框架
def load_array(data_arrays, batch_size, is_train=True):  #@save
    """构造一个PyTorch数据迭代器"""
    # 使用(*data_arrays)对元组进行解包,将其中的各个张量分别作为参数传入 TensorDataset。
    # dataset 对象封装了数据集中的所有样本,每个样本由对应位置的特征和标签组成。
    dataset = data.TensorDataset(*data_arrays)  #创建一个张量数据集

    # 使用 data.DataLoader 将刚刚构造好的数据集封装成迭代器。
    # batch_size:每次迭代返回多少样本。
    # shuffle=is_train:当 is_train 为 True 时,会在每个 epoch 开始时随机打乱数据,适合训练;若为 False,则不打乱,适合测试和验证。
    return data.DataLoader(dataset, batch_size, shuffle=is_train)

batch_size = 10
data_iter = load_array((features, labels), batch_size)

# 使用Sequential来创建模型
net = nn.Sequential(nn.Linear(2, 1))

# 初始化模型参数
net[0].weight.data.normal_(0, 0.01)
net[0].bias.data.fill_(0)

# 定义损失函数以及优化算法
loss = nn.MSELoss()
trainer = torch.optim.SGD(net.parameters(), lr=0.03)

num_epochs = 3
for epoch in range(num_epochs):
    for X, y in data_iter:
        l = loss(net(X) ,y)  #计算预测值和损失
        trainer.zero_grad()  #清空梯度
        l.backward()    #反向传播计算梯度
        trainer.step()  #更新参数
    l = loss(net(features), labels) #计算损失
    print(f'epoch {epoch + 1}, loss {l:f}')

# 通过.data获取参数的数值
w = net[0].weight.data
print('w的估计误差:', true_w - w.reshape(true_w.shape))
b = net[0].bias.data
print('b的估计误差:', true_b - b)
通过with关键字进行一些操作

在 Python 中,with 关键字用于简化资源管理和异常处理,它配合上下文管理器(context manager)使用。上下文管理器是实现了 __enter__ 和 __exit__ 方法的对象,能够在进入代码块前自动准备好资源,在代码块结束后自动释放资源或进行清理工作。
例如用于临时改变执行环境:

with torch.no_grad():
    y = model(x)

通过这样设定后,在这个上下文管理器下,不会构建计算图,从而加快执行效率;或者在评估模型训练效果时,因为不需要记录操作,因此关闭梯度计算

下载Fashion-MNIST数据集

下面的download函数用来下载数据集, 将数据集缓存在本地目录(默认情况下为../data)中, 并返回下载文件的名称。 如果缓存目录中已经存在此数据集文件,并且其sha-1与存储在DATA_HUB中的相匹配, 我们将使用缓存的文件,以避免重复的下载

# 章节 3-5

# 通过ToTensor实例将图像数据从PIL类型变换成32位浮点数格式,
# 并除以255使得所有像素的数值均在0~1之间
trans = transforms.ToTensor()
mnist_train = torchvision.datasets.FashionMNIST(
    root="../data", train=True, transform=trans, download=True)
mnist_test = torchvision.datasets.FashionMNIST(
    root="../data", train=False, transform=trans, download=True)

batch_size = 256
# 每批数据的样本数

def get_dataloader_workers():  #@save
    """使用4个进程来读取数据"""
    return 4

train_iter = data.DataLoader(mnist_train, batch_size, shuffle=True,
                             num_workers=get_dataloader_workers())
# num_workers设定用于加载数据的子进程数量。多进程加载可以加速数据准备,尤其当数据需要预处理时
下载数据集
def download(name, cache_dir=os.path.join('..', 'data')):  #@save
    """下载一个DATA_HUB中的文件,返回本地文件名"""
    assert name in DATA_HUB, f"{name} 不存在于 {DATA_HUB}"
    url, sha1_hash = DATA_HUB[name]

    # 创建缓存目录
    os.makedirs(cache_dir, exist_ok=True)

    # 获取文件路径,与缓存目录拼接成完整的文件路径
    fname = os.path.join(cache_dir, url.split('/')[-1])

    # 检查缓存是否有效

    # 检查文件是否已存在于缓存目录
    # 如果存在,计算文件的SHA-1哈希值
    # 分块读取文件(1MB一块)以处理大文件
    # 如果计算得到的哈希值与预期相符,说明缓存有效,直接返回文件名
    if os.path.exists(fname):
        sha1 = hashlib.sha1()
        with open(fname, 'rb') as f:
            while True:
                data = f.read(1048576)
                if not data:
                    break
                sha1.update(data)
        if sha1.hexdigest() == sha1_hash:
            return fname  # 命中缓存

    print(f'正在从{url}下载{fname}...')
    r = requests.get(url, stream=True, verify=True)
    with open(fname, 'wb') as f:
        f.write(r.content)
    return fname
类型等参数的控制
def accuracy(y_hat, y):  #@save
    """计算预测正确的数量"""
    # 如果有多个预测类别,那么取最大的概率
    if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
        y_hat = y_hat.argmax(axis=1)

    # 由于等式运算符“==”对数据类型很敏感,因此我们将y_hat的数据类型转换为与y的数据类型一致
    cmp = y_hat.type(y.dtype) == y
    return float(cmp.type(y.dtype).sum())
评估模式

instance函数为Python内置函数,用于检查一个对象是否是一个类或类型的实例,在这段代码中,如果是,则直接启用评估模式
通过设置成评估模式:

  • 停用Dropout
    • 在训练时,Dropout层会随机"丢弃"一部分神经元,防止过拟合
    • 在评估时,我们希望使用所有神经元进行预测,以获得最佳性能
    • eval()会使所有Dropout层停止随机丢弃操作
  • 固定Batch Normalization层
    • 训练时,BatchNorm层会基于当前批次计算并更新均值和方差统计量
    • 评估时,应该使用训练期间累积的统计量而不是当前批次的值
    • eval()会使BatchNorm层使用固定的统计值,不再更新
  • 确保结果可重复性
    • 如果不设置评估模式,多次对同一输入的预测可能会有所不同
    • 评估模式确保模型输出是确定性的,便于测试和比较
  • 提高推理效率
    • 某些层在评估模式下可以进行优化,减少计算量
def evaluate_accuracy(net, data_iter):  #@save
    """计算在指定数据集上模型的精度"""
    if isinstance(net, torch.nn.Module):
        net.eval()  # 将模型设置为评估模式
    metric = Accumulator(2)  # 正确预测数、预测总数

    # 同样禁用梯度计算
    with torch.no_grad():
        for X, y in data_iter:
            metric.add(accuracy(net(X), y), y.numel())
    return metric[0] / metric[1]
训练模式

使用训练模式的作用实际上已经在上面的评估模式的区别中提及了,现在主要是对一些其他的函数做些说明
updater.zero_grad()

作用:清除所有参数的梯度

  • 在PyTorch中,梯度是累积的。如果不清零,新计算的梯度会与之前的梯度相加
  • 每次参数更新前必须先清零梯度,确保只使用当前批次的梯度
  • updater是优化器对象(如SGD、Adam等),它管理着所有需要更新的参数

l.mean().backward()

作用:计算损失相对于模型参数的梯度

  • l是损失张量,由损失函数返回(如上文中的交叉熵损失)
  • mean()对批次中的所有样本损失取平均值,使梯度规模与批次大小无关
  • backward()通过自动微分计算梯度,这些梯度会存储在各参数的.grad属性中
  • 计算过程使用反向传播算法,从损失开始反向计算每个参数的梯度

updater.step()

作用:使用计算出的梯度更新模型参数

  • 根据优化算法(如SGD、Adam等)使用梯度更新参数
  • 调用此方法后,模型参数会沿着减小损失的方向移动
  • 更新步长由学习率和优化器的特性决定
  • 不同的优化器有不同的参数更新策略,如动量、自适应学习率等
    for X, y in train_iter:
        # 计算梯度并更新参数
        y_hat = net(X)
        l = loss(y_hat, y)
        if isinstance(updater, torch.optim.Optimizer):
            # 使用PyTorch内置的优化器和损失函数
            updater.zero_grad()
            l.mean().backward()
            updater.step()
        else:
            # 使用定制的优化器和损失函数
            l.sum().backward()
            updater(X.shape[0])
        metric.add(float(l.sum()), accuracy(y_hat, y), y.numel())

简单地进行一些更新

for i in range(10):
    Y_hat = conv2d(X)
    l = (Y_hat - Y) ** 2 # 损失函数为均方误差
    conv2d.zero_grad() # 清除上一步的梯度,准备进行这一轮的反向传播
    l.sum().backward() # 计算损失函数关于卷积核参数的梯度
    # 迭代卷积核
    conv2d.weight.data[:] -= lr * conv2d.weight.grad
绘制导数

计算变量y相对于x的梯度(即ReLU函数的导数)
torch.ones_like(x)创建一个与x形状相同、值全为1的张量,作为初始梯度

retain_graph=True参数使得计算图在反向传播后不被释放,这样后续可以继续使用
执行后,x.grad属性将存储ReLU函数在各点的导数值

  • 使用全1的张量意味着我们认为 y 中的每个元素对最终结果的贡献是相同的,我们希望计算每个 y[i] 相对于 x 的导数。
  • 根据微积分的链式法则,在计算中间变量的梯度时,我们需要将后续梯度乘以相应的局部导数。torch.ones_like(x) 作为初始梯度,代表我们关心 y 中每个元素对 x 的直接导数

使用d2l库的plot函数绘制ReLU函数的导数图像

x.detach()将张量从计算图中分离,防止梯度传播,转为不需要梯度的张量用于绘图
detach() 会创建一个与原张量共享数据的新张量,但这个新张量不再与计算图相连,也就是不会跟踪梯度信息

x.grad是刚才反向传播计算得到的ReLU导数值
'x'和'grad of relu'是图表的x轴和y轴标签

y.backward(torch.ones_like(x), retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of relu', figsize=(5, 2.5))
评估损失

metric.add(l.sum(), l.numel()) # sum()返回所有元素的总和,numel()返回损失张量中元素的总数量

使用优化器中自带的参数实现正则化
    # 创建优化器,使用随机梯度下降,并设置不同参数组的优化选项
    # 创建了两个参数组,第一组包含全连接层的权重矩阵,第二组包含全连接层的偏置向量
    # 这样在更新时
    # 第一组:weight_grad = weight.grad + wd * weight
    # 第二组:bias_grad = bias.grad
    trainer = torch.optim.SGD([
        {"params":net[0].weight,'weight_decay': wd},  # 权重参数应用权重衰减
        {"params":net[0].bias}], lr=lr)  # 偏置参数不应用权重衰减

自定义块

nn.Moudle是PyTorch中表示块的基础类,所有神经网络组件都应当继承这个类

编写 self.hidden = nn.Linear(20, 256) 时,实际发生了以下过程:

  • 创建实例:实例化一个 nn.Linear 层对象,输入维度为 20,输出维度为 256
  • 自动注册参数:由于 nn.Linear 也是 nn.Module 的子类,它内部包含权重和偏置参数
  • 子模块关联:将这个层对象赋值给 self.hidden,PyTorch 自动将其注册为当前模块的子模块

这是PyTorch nn.Moudle的一个重要特性,当你将一个 nn.Module 对象赋值给当前模块的属性时,PyTorch 会自动:

  • 将其注册为子模块(submodule)
  • 递归注册其所有参数
  • 在调用方法如 .parameters()、.to(device) 或 .train()/.eval() 时,这些操作会自动传播到所有子模块

子模块允许将复杂的神经网络分解成多个逻辑单元
每个子模块可以包含子集的参数和更小的子模块,形成层次结构

class MLP(nn.Module):
    # 用模型参数声明层。这里,我们声明两个全连接的层
    def __init__(self):
        # 这样,在类实例化时也可以指定其他函数参数,例如模型参数params(稍后将介绍)
        super().__init__() # 调用MLP的父类Module的构造函数来执行必要的初始化。

        # 可以自动注册为模型的子模块和参数
        self.hidden = nn.Linear(20, 256)  # 隐藏层
        self.out = nn.Linear(256, 10)  # 输出层

    # 定义模型的前向传播,即如何根据输入X返回所需的模型输出
    def forward(self, X):
        # 注意,这里我们使用ReLU的函数版本,其在nn.functional模块中定义。
        return self.out(F.relu(self.hidden(X)))

也可以嵌套块,例如在下面的例子中,我们以一些想到的方法嵌套块,嵌套了一个Sqquential和一个线性层

class NestMLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(nn.Linear(20, 64), nn.ReLU(),
                                 nn.Linear(64, 32), nn.ReLU())
        self.linear = nn.Linear(32, 16)

    def forward(self, X):
        return self.linear(self.net(X))

chimera = nn.Sequential(NestMLP(), nn.Linear(16, 20), FixedHiddenMLP())
chimera(X)
参数访问

例如访问第二层中的偏置参数,type返回类型,print(net[2].bias)直接返回这个参数类的实例,data返回数值

print(type(net[2].bias))
print(net[2].bias)
print(net[2].bias.data)

对于嵌套的网络,也可以使用类似于多维数组的形式来访问

def block1():
    return nn.Sequential(nn.Linear(4, 8), nn.ReLU(),
                         nn.Linear(8, 4), nn.ReLU())

def block2():
    net = nn.Sequential()
    for i in range(4):
        # 在这里嵌套
        net.add_module(f'block {i}', block1())
    return net

rgnet = nn.Sequential(block2(), nn.Linear(4, 1))

# 第一个主要的块中、第二个子块的第一层的偏置项
rgnet[0][1][0].bias.data
加载和保存模型参数

这将保存模型的参数而不是保存整个模型。
例如,如果我们有一个3层多层感知机,我们需要单独指定架构。因为模型本身可以包含任意代码,所以模型本身难以序列化。
因此,为了恢复模型,我们需要用代码生成架构,然后从磁盘加载参数,从熟悉的多层感知机为例进行说明

class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.hidden = nn.Linear(20, 256)
        self.output = nn.Linear(256, 10)

    def forward(self, x):
        return self.output(F.relu(self.hidden(x)))

net = MLP()
X = torch.randn(size=(2, 20))
Y = net(X)

# 编写程序,将模型的参数存放到一个文件中
torch.save(net.state_dict(), 'mlp.params')

# 如果要恢复模型,实例化一个之前感知机的模型,此时并不需要初始化模型参数,直接读取存储的参数即可
clone = MLP()
clone.load_state_dict(torch.load('mlp.params'))
clone.eval()
# eval表示设置成评估模式,使用clone.eval()表示模型保存后载入,加载保存的模型参数后应当设置为评估模式

读取序列数据

在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列。 在迭代过程中,来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻。 对于语言建模,目标是基于到目前为止我们看到的词元来预测下一个词元, 因此标签是移位了一个词元的原始序列。

下面的代码每次可以从数据中随机生成一个小批量。 在这里,参数batch_size指定了每个小批量中子序列样本的数目, 参数num_steps是每个子序列中预定义的时间步数。

# corpus 词元序列   batch_size 每个批次的样本数量  num_steps 每个样本包含的时间步数

def seq_data_iter_random(corpus, batch_size, num_steps):  #@save
    """使用随机抽样生成一个小批量子序列"""
    # 随机起始点在0到(num_steps-1)之间,避免总是从固定位置开始切分。
    corpus = corpus[random.randint(0, num_steps - 1):]  # 随机位置开始截取序列

    # 减去1,是因为我们需要考虑标签
    # 计算可以生成的子序列总数。
    # 减1是因为对于最后一个位置,我们需要有对应的目标词元(标签Y需要向右偏移一个位置)。
    # 使用整除确保每个子序列长度完全相同
    num_subseqs = (len(corpus) - 1) // num_steps

    # 长度为num_steps的子序列的起始索引
    initial_indices = list(range(0, num_subseqs * num_steps, num_steps))

    # 打乱这些起始索引,实现真正的随机采样。这确保了每个批次中的序列来自文本的不同部分。
    random.shuffle(initial_indices)

    # 用于从起始位置提取固定长度的子序列
    def data(pos):
        # 返回从pos位置开始的长度为num_steps的序列
        return corpus[pos: pos + num_steps]

    # 计算可以生成的批次总数
    num_batches = num_subseqs // batch_size

    # 遍历每个批次
    for i in range(0, batch_size * num_batches, batch_size):
        # 为当前批次选择batch_size个起始索引
        initial_indices_per_batch = initial_indices[i: i + batch_size]

        # 生成特征数据
        X = [data(j) for j in initial_indices_per_batch]
        # 生成对应的标签数据Y。
        # 每个Y序列是从X序列的下一个位置开始的,这实现了"根据当前词元预测下一个词元"的语言模型训练目标。
        Y = [data(j + 1) for j in initial_indices_per_batch]
        yield torch.tensor(X), torch.tensor(Y)
计算设备

查看显卡信息
!nvidia-smi

查询是否有可用GPU

def try_gpu(i=0):  #@save
    """如果存在,则返回gpu(i),否则返回cpu()"""
    if torch.cuda.device_count() >= i + 1:
        return torch.device(f'cuda:{i}')
    return torch.device('cpu')

在指定设备上存放数据、模型

X = torch.ones(2, 3, device=try_gpu())

net = nn.Sequential(nn.Linear(3, 1))
net = net.to(device=try_gpu())
编码器和解码器

除了处理变长序列之外,编码器还可以提取序列中的关键信息,并将其压缩为一个或一组状态向量,这个过程有助于捕获整个输入序列的语义和结构信息

然后解码器将基于这些状态信息来生成目标序列,实现从输入到输出的转化

同时,通过将系统分为编码器和解码器两个部分,有助于模块化设计,这样可以对每一个组件分别进行改进和优化,比如在编码器中使用更强的特征提取模型,而解码器中可以采用更先进的生成策略

并且通过设置编码器和解码器接口,可以让不同的模型继承这些基类

例如可以通过设定编码器基类,具体实现在继承这个基类的模型中实现

from torch import nn

#@save
# 定义编码器基类,继承自nn.Moudle
class Encoder(nn.Module):
    """编码器-解码器架构的基本编码器接口"""
    def __init__(self, **kwargs):
        super(Encoder, self).__init__(**kwargs)

    def forward(self, X, *args):
        raise NotImplementedError # 表明这是一个抽象方法,需要在子类中实现

同样设置解码器接口,编码器的主要职责是读取变长输入序列并提取关键信息,生成一系列隐藏状态(或称编码器输出)。解码器需要接收这些信息并将其转换为适合逐步生成输出序列的初始状态或者上下文表示。

因此转换过程是在解码器中实现的,即init_state方法

#@save
class Decoder(nn.Module):
    """编码器-解码器架构的基本解码器接口"""
    def __init__(self, **kwargs):
        super(Decoder, self).__init__(**kwargs)

    # 通过编码器的输出初始化解码器的状态
    def init_state(self, enc_outputs, *args):
        raise NotImplementedError

    # 前向传播中,需要额外的状态信息
    def forward(self, X, state):
        raise NotImplementedError
#@save
class EncoderDecoder(nn.Module):
    """编码器-解码器架构的基类"""
    def __init__(self, encoder, decoder, **kwargs):
        super(EncoderDecoder, self).__init__(**kwargs)
        self.encoder = encoder
        self.decoder = decoder

    def forward(self, enc_X, dec_X, *args):
        enc_outputs = self.encoder(enc_X, *args) # 处理序列输入,生成编码输出
        dec_state = self.decoder.init_state(enc_outputs, *args) # 生成输出序列
        return self.decoder(dec_X, dec_state)

以序列到序列的解码器为例

广播context的方式:

  • 这里提取了 state 中最后一层的隐状态(state[-1]),其形状为 (batch_size, num_hiddens)。
  • 然后使用 repeat(X.shape[0], 1, 1) 将其在时间步维度上复制 X 的时间步数(即 num_steps),从而得到形状为 (num_steps, batch_size, num_hiddens) 的 context 张量。这样,每个时间步都有相同的上下文向量。

最后一层的隐状态通常包含了整个输入序列的精华信息,是编码器对整个输入进行抽象后的全局表示。
将这个表示作为上下文向量传递给解码器,可以为解码器的每个时间步提供一致且全局的语义信息,帮助生成与输入相对应的输出序列。

保证每个解码时间步使用相同上下文:

  • 通过广播(repeat)操作,每个时间步的输入都拼接了同样的上下文向量。
  • 这种设计确保了无论解码器在生成哪个时间步输出,都能参考同一个全局的、经过编码器处理后的上下文信息,有助于保持输出序列与输入序列语义上的一致性。
class Seq2SeqDecoder(d2l.Decoder):
    """用于序列到序列学习的循环神经网络解码器"""
    # vocab_size:目标语言词汇表大小,决定嵌入层输入维度和输出层维度
    # embed_size:词嵌入维度,目标序列词元的向量表示维度
    # num_hiddens:GRU隐藏单元数量,需要与编码器保持一致
    # num_layers:GRU层数,通常与编码器层数匹配
    # dropout:防止过拟合的丢弃率
    # **kwargs:传递给基类的其他参数
    def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
                 dropout=0, **kwargs):
        super(Seq2SeqDecoder, self).__init__(**kwargs)

        # 同样使用嵌入层,将目标词元索引转换为密集向量表示
        self.embedding = nn.Embedding(vocab_size, embed_size)
        self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
                          dropout=dropout)

        # 添加全连接层,将隐藏状态映射到词汇表大小的输出
        self.dense = nn.Linear(num_hiddens, vocab_size)

    def init_state(self, enc_outputs, *args):
        return enc_outputs[1]

    def forward(self, X, state):
        # 输出'X'的形状:(batch_size,num_steps,embed_size)
        # 时间步为第一维度
        X = self.embedding(X).permute(1, 0, 2)

        # 广播context,使其具有与X相同的num_steps
        # 提取最后一层的隐状态作为上下文向量
        # 之所以要进行重复,是为了每个解码时间步都能使用相同的上下文信息
        context = state[-1].repeat(X.shape[0], 1, 1)

        # 将输入X和上下文向量连接在一起
        # 连接的目的是将编码器的上下文信息传递给解码器
        X_and_context = torch.cat((X, context), 2)

        # 返回所有时间步的输出和最终隐状态
        output, state = self.rnn(X_and_context, state)
        output = self.dense(output).permute(1, 0, 2)
        # output的形状:(batch_size,num_steps,vocab_size)
        # state的形状:(num_layers,batch_size,num_hiddens)
        return output, state
posted @ 2025-05-15 09:21  rdcamelot  阅读(35)  评论(0)    收藏  举报