每天起床第一句,“你今天Deep Learning”了吗😍😍hahaha
😭😭每天一睁眼就困😪😪。。。
今天的内容比较简单,第5章深度网络计算 ~~~ 我觉得可以不用敲代码,理解就可以啦
前言
之前我们在线性神经网络和多层感知机中多次从零实现一些模型,目的是为了更好地理解模型原理,不过专业大佬们已经构建了比较完整的深度学习工具供我们学习和使用,实现高效构建、训练、调试和部署深度神经网络。避免了我们使用标准组件时的重复工作,同时仍然保持了我们进行底层修改的能力。
第5章主要探索了深度学习的关键组件,即模型构建、参数访问与初始化、自定义层和块及文件读写实现显著的加速。 将使读者从深度学习“基础用户”变为“高级用户”。
1 层和块
深度学习中的计算层和块是构建神经网络的核心组件。它们将输入数据逐步转换,以提取特征并完成特定任务(如分类或回归)。
层(Layer):神经网络的基本运算单元,一次参数化、可微的映射,数学形式如下:
其中,x→输入张量、
→该层的参数、
→输出张量
Q:在Pytorch里,什么才算层?
A:判定标准:满足以下 至少两个:
①有可学习参数②是nn.Module③输入 / 输出是 Tensor④自动参与反向传播
比如之前提到的线性层、卷积层、激活层、归一化层等等。特别的,「激活函数」没有参数,为什么算层?—— 因为:它是一次确定的函数映射,改变了计算图结构,有反向传播规则!
块(Block):是多个层的逻辑组合,它们共同实现一个更高级别的特定功能。块的设计是深度学习模型模块化的关键,它使得构建和复用复杂的网络结构变得更容易。
一个网络模型本身可以看作是一个大的块,但通常我们指的是网络中的子结构。
以卷积块(Convolutional Block)为例:
典型结构:
卷积层 →激活层或卷积层→批量归一化层→激活层功能:连续地执行特征提取和非线性激活。
下面是一个简单的神经网络结构,我们定义了3个层:全连接-ReLU-全连接:
import torch from torch import nn from torch.nn import functional as F # 没有 Block,只有 Layer,已经是完整可训练模型 net = nn.Sequential(nn.Linear(20, 256), nn.ReLU(), nn.Linear(256, 10)) X = torch.rand(2, 20) net(X)//输出示例:
在例中,我们通过nn.Sequential来构建模型。
nn.Sequential
它是 PyTorch 中一个核心容器(Container)模块,用于将多个层或块按顺序组织成一个单一的、可整体调用的模型。
它本身:是
nn.Module,❌不定义新计算,只负责顺序调用里面的 Layer为什么它“已经是完整模型”?—— 因为:
nn.Sequential继承nn.Module内部所有子模块:参数会被自动注册;能被
optimizer看到;能参与backward()
那什么是nn.Module呢?它是 PyTorch 中“可训练模型/层/块”的统一抽象基类,只要是:层(Layer)、块(Block)、整个网络(Network),👉全部继承自nn.Module
nn.Module
不仅仅是一个容器,它提供了 PyTorch 深度学习框架所需的所有核心功能:
A. 参数管理 (Parameter Management):
- 它能够自动追踪所有被定义的可学习参数 (Learnable Parameters)(例如权重 W 和偏置 B)。
- 这些参数被存储为特殊的
nn.Parameter对象,并会在模型训练期间自动通过梯度下降进行更新。B. 前向传播 (Forward Propagation):
- 它要求每个继承它的子类必须实现一个名为
forward()的方法,这个方法定义了数据如何流过模型。C. 模式管理 (Mode Management):
- 它提供
model.train()和model.eval()方法来切换模型的运行模式,这对于 Dropout 和 Batch Normalization 等层至关重要。D. 子模块管理 (Submodule Management):
- 它可以递归地管理所有包含在它内部的其他
nn.Module子模块,使得调用model.to(device)、model.parameters()等操作可以作用于整个网络。
nn.Module功能强大,在之后的学习中我们会逐渐体会到。
1.1 自定义块
下面通过定义一个简单的MLP模型,来直了解块如何工作。
⭐️⭐️nn.Module的最小使用范式有三个关键点:
① 继承 nn.Module ②super().__init__() ③实现 forward()
—— 我们的实现只需要提供我们自己的构造函数和前向传播函数。
- 我们在自己的构造函数__init__中需要通过super().__init__()完成父类的初始化,随后通过self.xxx添加名为xxx的层/块。
//eg.这里我们定义的MLP具有256个隐藏单元的隐藏层和1一个10维输出层。
- 我们再来看前向传播函数,
forward方法定义了数据在__init__中定义的模块之间流动的计算逻辑和顺序。这是模型的核心。
//eg.这里我们定义的计算是,对输入作用隐藏层,通过激活之后送到输出层处理
class MLP(nn.Module): # 用模型参数声明层。这里,我们声明两个全连接的层 def __init__(self): # 调用MLP的父类Module的构造函数来执行必要的初始化。 # 这样,在类实例化时也可以指定其他函数参数,例如模型参数params(稍后将介绍) super().__init__() 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)))//输出示例:前面的X.shape=2*20,通过网络作用2*20-20*256-256*10→2*10的张量结果
👊注意:只有挂在self.xxx上的 Module / Parameter 才会被追踪!下面的这个曾不会被训练、不会保存也不会更新!
1.2 顺序块
顺序块通常就是指我们前面提到的 nn.Sequential ,或者更广义地指代一种线性串联的计算结构。
为了构建简化的MySequential, 我们只需要定义两个关键函数:
一种将块逐个追加到列表中的函数;
一种前向传播函数,用于将输入按追加块的顺序传递给块组成的“链条”。
_modules是 PyTorch 中nn.Module基类的一个内部字典,用于存储和管理一个模块(或模型)中定义的所有子模块 (Submodules)。所以使用 self._modules时,系统知道在_modules字典中查找需要初始化参数的子块。
在前向传播中,每个添加的块就会对输入作用产生输出,按照顺序依次被执行。
class MySequential(nn.Module): def __init__(self, *args): super().__init__() for idx, module in enumerate(args): # 这里,module是Module子类的一个实例。我们把它保存在'Module'类的成员 # 变量_modules中。_module的类型是OrderedDict self._modules[str(idx)] = module def forward(self, X): # OrderedDict保证了按照成员添加的顺序遍历它们 for block in self._modules.values(): X = block(X) # 不断更新的 return X//输出示例:
在定义时给的三个层的参数即*args,将被解析依次加入到顺序块中,并在训练时被依次执行。
1.3 在前向传播函数中执行代码
有时候我们可能希望参数是没有更新的指定常量,可是设置不计算梯度,保持初始值;
Forward()中使用的神经网络层可以不是在__ini__中预定义的,可以再自行创建以供使用,也可以自主实现执行Python的控制流。
class FixedHiddenMLP(nn.Module): def __init__(self): super().__init__() # 不计算梯度的随机权重参数。因此其在训练期间保持不变 self.rand_weight = torch.rand((20, 20), requires_grad=False) self.linear = nn.Linear(20, 20) def forward(self, X): X = self.linear(X) # 使用创建的常量参数以及relu和mm函数 X = F.relu(torch.mm(X, self.rand_weight) + 1) X = self.linear(X) # 复用全连接层。这相当于两个全连接层共享参数 # 控制流 while X.abs().sum() > 1: X /= 2 return X.sum()下面一些输出就不展示了,通class的定义理解模块灵活性即可。
1.4 混合搭配各种组合块
各种层/块都继承了nn.module,它们之间可以相互嵌套使用。
//eg.根据forwad可以看出,我们定义的NestMLP的网络结构为:
net(线性层(20,64)→激活ReLU→线性层(64,32)→激活ReLU)→ linear(32,16)
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)总结
深度学习计算中层和块是两个重要概念,一个块可以有许多层/块组成,它们都继承了nn.module,具备了其提供的参数管理、前向传播、模式管理、子模块管理等核心功能;
借助于此,我们可以灵活地自定义模型,必须遵循三个步骤:① 继承nn.module ② 初始化:super().__init()+声明子模块 ③ 计算:实现forward()定义数据流。
2 参数管理
在明确架构并设置超参数之后,我们进入训练阶段,此时我们的目标是找到损失函数最小化的模型参数值。经过不断的调参训练,最终得到的模型参数用于测试。
有时候,我们希望将参数提取出来便于在其他环境中复用它们,将模型保存下来,以便它可以在其他软件中执行, 或者为了获得科学的理解而进行检查。
下面我们来看看如何访问到参数,又能做一些怎样的操作呢?
我们首先关注具有单隐藏层的多层感知机:
import torch from torch import nn net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 1)) X = torch.rand(size=(2, 4)) net(X)2.1 参数访问
state_dict()是一个 Python字典,它存储了模块中所有可持久化的状态,包括该模块的参数 (parameters)和缓冲 (buffers)。这个字典的结构是:
键 (Key):对应于参数或缓冲区的名称(字符串)。
值 (Value):对应于参数或缓冲区的张量 (
torch.Tensor)。
//eg.访问序列的第3个模块的状态字典
print(net[2].state_dict())(1) 访问目标参数
net[2].bias是一个nn.Parameter对象,功能上是一个张量,身份上是一个可训练的参数。
net[2].bias:这是一个参数,它的当前值是什么,以及它是否参与梯度追踪。
data属性:会返回参数内部的原始torch.Tensor。
print(type(net[2].bias)) print(net[2].bias) print(net[2].bias.data)由于尚未执行反向传播,因此grad==None:
net[2].weight.grad == None # True.data属性(获取原始张量)
内容:
.data属性会返回参数内部的原始torch.Tensor。不追踪梯度:最重要的区别是:通过
.data访问的张量默认不参与梯度追踪(即requires_grad通常为False,或被视作False)。使用场景:在训练循环中,如果需要修改参数的值(例如,在优化器更新步骤后手动实现权重衰减或梯度裁剪),应该通过
⚠️ 警告:在 PyTorch 中,不推荐直接修改.data来操作,以确保的修改不会被记录到计算图中,从而影响后续的梯度计算。.data。更推荐的做法是使用with torch.no_grad():上下文管理器来执行这些操作,因为这能更安全地切断梯度追踪。
(2) 一次性访问所有参数:
net[0].named_parameters():仅访问net序列中的第一个模块 (net[0]) 的参数。
net.named_parameters():访问整个net模型的所有参数。当通过容器 (nn.Sequential或自定义nn.Module) 访问参数时,PyTorch 会递归地遍历其子模块。参数的名称会加上模块索引作为前缀,以保证全局唯一性。
这里没有显示net[1]为什么呢?—— 激活函数没有参数噢
print(*[(name, param.shape) for name, param in net[0].named_parameters()]) print(*[(name, param.shape) for name, param in net.named_parameters()])下面这个不难理解,通过字典→'2.bias',指明第3个模块的偏差的张量。
net.state_dict()['2.bias'].data(3) 从嵌套块收集参数
块之间可以相互嵌套,我们在block2中定义了一个顺序块,并嵌套了4个block1(),分别命名为'block0'、'block1'、'block2'、'block3',最终作为block()的结构.
我们定义了全局变量 rgnet,它又嵌套了block2()、Linear()层,😆😆好好好玩起了套娃是吧
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(X)//模型结构如下:
2.2 参数初始化
知道了如何访问参数,现在看看如何正确地初始化参数。在 7 数值稳定性 + 模型初始化和激活函数
这篇文章中已经学习了良好初始化的必要性,深度学习框架提供默认随机初始化, 也允许我们创建自定义初始化方法, 满足我们通过其他规则实现初始化权重。
(1) 内置初始化
PyTorch 的torch.nn.init模块提供了多种标准的参数初始化方法,这些方法可以直接应用于模型的权重(weight)和偏置(bias)张量上。
这些内置初始化通常基于统计学理论,旨在确保在前向传播和反向传播过程中,信号和梯度的方差保持在合理的范围内,从而避免梯度消失或梯度爆炸问题。
常见的内置初始化方法包括:
均匀分布初始化 (
nn.init.uniform_):从一个均匀分布中随机采样。
正态分布初始化 (
nn.init.normal_):从一个正态分布中随机采样。
常数初始化 (
nn.init.constant_):将所有元素设置为一个固定常数(常用于将偏置初始化为零)。Xavier/Glorot 初始化 (
nn.init.xavier_uniform_/nn.init.xavier_normal_):
原理:适用于 Sigmoid 或 Tanh 等激活函数。它根据输入和输出神经元的数量,自动调整初始化范围,以保持输入和输出的方差一致。
Kaiming/He 初始化 (
nn.init.kaiming_uniform_/nn.init.kaiming_normal_):
原理:专为ReLU 激活函数设计。它考虑了 ReLU 的特性(一半神经元输出为零),通过更大的方差来初始化权重,以确保信息在深层网络中有效传递。
//eg1. 将所有权重参数初始化为标准差为0.01的高斯随机变量, 且将偏置参数设置为0
def init_normal(m): if type(m) == nn.Linear: nn.init.normal_(m.weight, mean=0, std=0.01) nn.init.zeros_(m.bias) net.apply(init_normal) # 对其中每层遍历初始化 net[0].weight.data[0], net[0].bias.data[0]//eg2.常数初始化:将所有权重参数初始化为1
def init_constant(m): if type(m) == nn.Linear: nn.init.constant_(m.weight, 1) # 常数1 nn.init.zeros_(m.bias) net.apply(init_constant) net[0].weight.data[0], net[0].bias.data[0]//eg3. 通过访问指定模块调用相应的1.apply(),用Xavier初始化第1个神经网络层, 然后将第3个神经网络层初始化为常数42
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)(2) 自定义初始化
有时候内置初始化可能无法满足我们的需求,可以自定义初始化函数。
nn.init.uniform_(m.weight, -10, 10):将权重张量 m.weight 均匀地初始化在 [-10, 10] 之间(nn.init.uniform_ 是原地 (in-place) 操作,直接修改 m.weight.data)
截断:权重张量中所有绝对值 < 5 的元素被设置为 0。
def my_init(m): # 类型检查:确保只对全连接层 (nn.Linear) 进行初始化 if type(m) == nn.Linear: # 打印信息:打印当前正在初始化的层的第一个参数的名称和形状 print("Init", *[(name, param.shape) for name, param in m.named_parameters()][0]) nn.init.uniform_(m.weight, -10, 10) m.weight.data *= m.weight.data.abs() >= 5 # 否则置0我们也可以直接设置参数:
net[0].weight.data[:] += 1 net[0].weight.data[0, 0] = 42 net[0].weight.data[0]2.3 参数绑定
有时我们希望在多个层间共享参数: 我们可以定义一个稠密层,然后使用它的参数来设置另一个层的参数。
# 我们需要给共享层一个名称,以便可以引用它的参数 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])可以看到操作第3模块,第5模块也发生了变化,二者是同一个对象,不仅仅只是值的复制。
问题:当参数绑定时,梯度会发生什么情况?
—— 由于模型参数包含梯度,因此在反向传播期间第二个隐藏层 (即第三个神经网络层)和第三个隐藏层(即第五个神经网络层)的梯度会加在一起。
总结
我们可以对访问模块中指定层的参数,并可以初始化其参数,可以使用内置初始化1函数也可以自定义,最后通过.aplly作用于模块即可。
16节的内容集中在一起篇幅有些太长,明天发后面的内容吧嗯,菜菜还是要多学~~~~😆😆😆