李沐课代码部分——续集
十二、权重衰退
之所以我们要利用权重衰退,本质上来说就是为了提防这样的情况出现:

解释一下的话就是——明明能非常“优雅”地只是用较为平滑但变化幅度不会太大的曲线就可以把这7个点全部串起来,但倘若我们不对w这个权重进行限制,我们的模型很容易【对一道非常简单的题玩出花来】(换句话说就是把一些数据学到根本没必要达到的程度。用较为专业的措辞就是——过拟合)
公式展示如下:

完整代码展示:
import torch
from d2l import torch as d2l
import matplotlib.pyplot as plt
# from torch import nn
def synthetic_data(w, b, num_examples):
X = torch.normal(0, 1, (num_examples, len(w)))
y = torch.matmul(X, w) + b
y += torch.normal(0, 0.01, y.shape)
return X, y.reshape((-1, 1))
"""---------------- 1. 合成数据 ----------------"""
n_train, n_test, num_inputs, batch_size = 20, 100, 200, 5# 200表示200维
true_w, true_b = torch.ones((num_inputs, 1)) * 0.01, 0.05
train_data = synthetic_data(true_w, true_b, n_train)
test_data = synthetic_data(true_w, true_b, n_test)
train_iter = d2l.load_array(train_data, batch_size)
test_iter = d2l.load_array(test_data, batch_size, is_train=False)
"""---------------- 2. 初始化参数 ----------------"""
def init_params():
w = torch.normal(0, 1, size=(num_inputs, 1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)
return [w, b]
"""---------------- 3. L2 惩罚 ----------------"""
def l2_penalty(w):
return torch.sum(w.pow(2)) / 2
"""---------------- 4. 训练函数 ----------------"""
def train(lambd):
w, b = init_params()
net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
num_epochs, lr = 100, 0.003
animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
xlim=[5, num_epochs], legend=['train', 'test'])
for epoch in range(num_epochs):
for X, y in train_iter:
# 增加了L2范数惩罚项,
# 广播机制使l2_penalty(w)成为一个长度为batch_size的向量
l = loss(net(X), y) + lambd * l2_penalty(w)
l.sum().backward()
d2l.sgd([w, b], lr, batch_size)
if (epoch + 1) % 5 == 0:
with torch.no_grad():
train_loss = d2l.evaluate_loss(net, train_iter, loss)
test_loss = d2l.evaluate_loss(net, test_iter, loss)
animator.add(epoch + 1, (train_loss, test_loss))
print('w的L2范数是:', torch.norm(w).item())
plt.show()
"""---------------- 5. 运行实验 ----------------"""
train(lambd=0) # 无正则化——过拟合
train(lambd=3) # 有权重衰退——泛化更好
具体机制解析:
1. 合成数据中的* 0.01——是对我们生成的w这个200行1列的向量中的每个1通过这个广播机制乘以0.01
2. l2_penalty函数中的算法:是L^2范数,或者说是L2范数的计算公式——之所以这样写,是因为方便后续求导(train函数)时直接消掉下面的式子里w前的系数

||w|| = √(Σwᵢ²)

3. train函数的解析:
1. w, b = init_params()
是调用我们前面定义的函数,用于初始化超参数
2. net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
这是python的一个语法糖,用于临时定义一个函数而不费地方(内存),让我们只需要一行便可以定义一个急需用的函数。它等价于如下形式——
def net(X):
return d2l.linreg(X, w, b)
loss = d2l.squared_loss
这行代码大家应该很熟悉,虽然看起来有点区别。它本质上就是这样的——
def squared_loss(y_hat, y):
return (y_hat - y.reshape(y_hat.shape)).pow(2) / 2 # 依旧是除以二以便后续求导(梯度下降)时消掉2这个系数
4. animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
xlim=[5, num_epochs], legend=['train', 'test'])
xlim = [5, num_epochs]的意思是我们只画第5轮之后的答案,前面的因为波动太大所以不画
5. for epoch in range(num_epochs):
for X, y in train_iter:
# 增加了L2范数惩罚项,
# 广播机制使l2_penalty(w)成为一个长度为batch_size的向量
l = loss(net(X), y) + lambd * l2_penalty(w) # 公式的代码形式,是整个训练的核心
l.sum().backward()
d2l.sgd([w, b], lr, batch_size)
if (epoch + 1) % 5 == 0:
with torch.no_grad():
train_loss = d2l.evaluate_loss(net, train_iter, loss)
test_loss = d2l.evaluate_loss(net, test_iter, loss)
animator.add(epoch + 1, (train_loss, test_loss))
print('w的L2范数是:', torch.norm(w).item())
这部分是真正开始训练的循环,with用法表示临时禁止在构建计算图时传梯度,换句话说就是提高我们的安全性。然后就是到animator上面,我们每跑一组数据就把损失结果画在图上,然后打印出来此时的w的L2范数是多少,最后通过plt.show()将图画出来
十三、丢弃法
它是跟权重衰退不一样的提升模型鲁棒性的方法。
完整代码展示:
import torch
from torch import nn
from d2l import torch as d2l
from matplotlib import pyplot as plt
"""1. 定义丢弃函数"""
def dropout_layer(X, dropout):
assert 0 <= dropout <= 1
"""之所以写assert是因为要提前拦截非法输入"""
# 在本情况中,所有元素都被丢弃
if dropout == 1:
return torch.zeros_like(X)
# 在本情况中,所有元素都被保留
if dropout == 0:
return X
mask = (torch.Tensor(X.shape).uniform_(0, 1) > dropout).float()
# mask是一个与X形状相同的张量,其值来自伯努利分布
return mask * X / (1.0 - dropout)
"""2. 初始化数据"""
num_inputs, num_outputs, num_hidden1, num_hidden2 = 784, 10, 256, 256
dropout1, dropout2 = 0.2, 0.5
"""3. 定义神经网络"""
class Net(nn.Module):
def init(self, num_inputs, num_outputs, num_hidden1, num_hidden2, is_training = True):
super(Net, self).init()
self.num_inputs = num_inputs
self.training = is_training
self.lin1 = nn.Linear(num_inputs, num_hidden1)
self.lin2 = nn.Linear(num_hidden1, num_hidden2)
self.lin3 = nn.Linear(num_hidden2, num_outputs)
self.relu = nn.ReLU()
def forward(self, X):
H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))
# 只有在训练模型时才使用丢弃法
if self.training: H1 = dropout_layer(H1, dropout1)
H2 = self.relu(self.lin2(H1))
if self.training: H2 = dropout_layer(H2, dropout2)
out = self.lin3(H2)
return out
"""4. 初始化网络"""
net = Net(num_inputs, num_outputs, num_hidden1, num_hidden2)
"""5. 训练网络"""
if name == 'main':
num_epochs, lr, batch_size = 10, 0.5, 256
loss = nn.CrossEntropyLoss(reduction='none')
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
trainer = torch.optim.SGD(net.parameters(), lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
plt.show()
具体机制解析:
def dropout_layer(X, dropout):
assert 0 <= dropout <= 1
"""之所以写assert是因为要提前拦截非法输入"""
# 在本情况中,所有元素都被丢弃
if dropout == 1:
return torch.zeros_like(X)
# 在本情况中,所有元素都被保留
if dropout == 0:
return X
mask = (torch.Tensor(X.shape).uniform_(0, 1) > dropout).float()
# mask是一个与X形状相同的张量,其值来自伯努利分布
return mask * X / (1.0 - dropout)
1. assert的用法,其实我们从后续紧跟着的不等式可以推断出——是保证dropout这个变量【对应数学里的p这个概率】的值在0到1之间,实际上assert的用法也是如此
2. mask的用法,不是掩码,而是一种布尔判断(一样通过后续的内容推断出来)
具体分点来说就是:
1. 先创建一个跟X形状相同的新张量(未初始化的)
2. .uniform_(0, 1):是对这个我们刚创建的新张量通过原地操作(in-place)填充[0, 1)区间内(注意!是左闭右开区间!)
3. >dropout的判断:大于则为True,反之则为False,比如dropout = 0.4,那么0.6的位置为True——每个神经元保留的概率为70%
换句话说,我们原来就是通过.uniform_(0, 1)来实现“抽奖”(创建奖池),然后每个神经元【虽然不是它自己抽,但程序会替神经元决定去留——通过如上所述的“抽奖”】通过>dropout的判断来“抽奖”,抽到比dropout大的话,这个神经元就保留,反之则丢弃。
4. .float()的转变:毕竟我们整个模型训练都涉及到梯度下降、参数更新……所以还是转成float省事,万一有隐性的数据格式兼容问题呢是不是?所以,还是转成float更省心。
def __init__(self, num_inputs, num_outputs, num_hidden1, num_hidden2, is_training = True):
super(Net, self).__init__()
self.num_inputs = num_inputs
self.training = is_training
self.lin1 = nn.Linear(num_inputs, num_hidden1)
self.lin2 = nn.Linear(num_hidden1, num_hidden2)
self.lin3 = nn.Linear(num_hidden2, num_outputs)
self.relu = nn.ReLU()
在解释整个类之前,我要先说明一点:这里的Net类,是有继承nn.Module这个父类的
这就涉及到super的用法了——super(Net, self)返回一个代理对象,代表着它继承的nn.Module父类,它后面加的.__init__()这个方法实际上就是继承自nn.Module这个父类所有的方法
之后的就是线性层——nn.Linear(num_inputs, num_hidden1)就是在定义一个全连接层
格式如下:nn.Linear(in_features, out_features)
其中的in_features必须匹配上一层的输入,out_features是输入到下一层的输出或者是最后的输出(如果没有下一层的话)
最后一行的 self.relu = nn.ReLU()就是在定义激活函数(用ReLU函数——
)
def forward(self, X):
H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))
# 只有在训练模型时才使用丢弃法
if self.training: H1 = dropout_layer(H1, dropout1)
H2 = self.relu(self.lin2(H1))
if self.training: H2 = dropout_layer(H2, dropout2)
out = self.lin3(H2)
return out
这个函数就是在复现这段表达:

loss = nn.CrossEntropyLoss(reduction='none')
这就是交叉熵(损失)的表达形式——cross-Entropy(loss)(封装为函数版)
关于reduction的讲解——
reduction 值 |
行为 | 返回形状(假设 batch_size=N) |
|---|---|---|
'mean'(默认) |
对所有样本损失求平均 | 标量([] 或 torch.Size([])) |
'sum' |
对所有样本损失求和 | 标量 |
'none' |
不汇总,保留每个样本的损失 | [N](一维张量,长度=批大小) |
net.parameters()
生成一个generator对象,是数据生成器,直接用于循环、训练中
十四、多层感知机、参数初始化:
1. nn.linear(in_features=3, out_features=5):一个可学习仿射变换模块,不执行计算,只负责检查输维度是否匹配,真正的计算在.forward(x)发生。
完整代码展示:
import torch
from torch import nn
from d2l import torch as d2l
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
#以上是2个数据迭代器
"""---------------------1. 初始化模型参数(单隐藏层版)---------------------"""
num_inputs, num_outputs, num_hidden = 784, 10, 256
W1 = nn.Parameter(torch.randn(num_inputs, num_hidden, requires_grad=True) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hidden, requires_grad=True))
W2 = nn.Parameter(torch.randn(num_hidden, num_outputs, requires_grad=True) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))
params = [W1, b1, W2, b2]
"""---------------------2. 定义激活函数---------------------"""
def relu(X):
a = torch.zeros_like(X)
return torch.max(X, a)
"""---------------------3. 定义模型---------------------"""
def net(X):
X = X.reshape((-1, num_inputs))
H = relu(torch.matmul(X, W1) + b1)
return torch.matmul(H, W2) + b2
"""---------------------4. 定义损失函数---------------------"""
loss = nn.CrossEntropyLoss(reduction='none')
"""---------------------5. 定义优化算法---------------------"""
updater = torch.optim.SGD(params, lr=0.1)
"""---------------------6. 训练模型---------------------"""
num_epochs, lr = 10, 0.1
d2l.train_ch13(net, train_iter, test_iter, loss, num_epochs, updater)
d2l.predict_ch3(net, test_iter)
"""---------------------7. 可视化---------------------"""
d2l.plt.show()
具体机制解析:
导包、数据迭代器部分就不再赘述了
num_inputs, num_outputs, num_hidden = 784, 10, 256
1. num_inputs:输入层的神经元个数为784【28*28=784,为Fashion-MNIST的尺寸大小】
2. num_outputs:输出层的神经元个数为10【人为规定的】
3. num_hidden:隐藏层(单层)的神经元个数为256【人为规定的】
"""---------------------1. 初始化模型参数(单隐藏层版)---------------------"""
num_inputs, num_outputs, num_hidden = 784, 10, 256
W1 = nn.Parameter(torch.randn(num_inputs, num_hidden, requires_grad=True) * 0.01)# 这里之所以*0.01,是为了避免数字太大导致梯度爆炸/消失。下面同理
b1 = nn.Parameter(torch.zeros(num_hidden, requires_grad=True))#b1是要加到【目标层】上的,显然应该是num_hidden。下面同理
W2 = nn.Parameter(torch.randn(num_hidden, num_outputs, requires_grad=True) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))
params = [W1, b1, W2, b2]#以列表这一数据容器来收集所有可学习参数,以供优化器使用
但是!我想问大家一个问题:
为什么这里非要搞2组权重、偏置?
让我们先回到感知机部分理论讲解时,我们知道若是只有一组权重、偏置,则只能进行多分类问题(如softmax回归)或者预测趋势(如线性回归),那是我们非常熟悉的。但实际工程中可不只有如此“线性”的东西,比如银行审核“储蓄/借记卡申请”,输入是一个excel表格,可能高达十几个“特征”,但最后是一个二分类问题——批准,还是不批准,如果不批准,理由是什么?——如果只有一组权重、偏置,那就远远不够了!
或者给出MLP这样的应用场景:手写数字识别 (MNIST)。这是单隐藏层MLP最经典的“Hello World”(编程名梗,不知道的可以自己去搜一下)。因为图片尺寸小(28×28)、背景还干净,单隐藏层(比如256个神经元)就能达到97%以上的准确率,也就是上面我们的例子里讲的!
而且还有一个原因:如果只有一组权重、偏置,连【异或问题(XOR)】都无法解决!如下面的异或问题,我们使用的是【一组权重、偏置】,得出来的情况如下:(显然不尽人意,无法将绿色跟红色完全分开)
1. 第一组权重、偏置是“提取特征”用的
2. 第二组权重、偏置是“类别适配度预测”用的

"""---------------------2. 定义激活函数---------------------"""
def relu(X):
a = torch.zeros_like(X)#之所以不用torch.zeros(X.shape),是因为上面版本会自动继承X的所有元信息(如float16,而torch.zeros(X.shape)默认float32,后续运算容易出现隐患)
return torch.max(X, a)
zeros_like的解释,我觉得若是想通俗易懂些,可以这么理解——
1. 就好像它(zeros_like)根据X的元信息“复印”出来一份完全相同的“纸”,只是后来它在这张纸上填满了0(毕竟是zeros_like而不是别的_like)
2. 必须跟X维度、尺寸匹配,否则没法比较
就好像我们不能拿着3*3的矩阵跟2*4的矩阵比较里面的元素一样——尺寸/维度都不一样,有可能前面矩阵的某个元素都无法在后面矩阵找到对应!


"""---------------------3. 定义模型(前向传播)---------------------"""
def net(X):
X = X.reshape((-1, num_inputs))
H = relu(torch.matmul(X, W1) + b1)# 之所以将变量取名为H,不是没有原因的
return torch.matmul(H, W2) + b2
1. H的含义是“hidden”
2. 我们这两行代码(H所在的、return所在的)计算的数据的解析:
a) 第一行的数据由输入层流入,先经过reshape让输入的数据得以跟“提取特征权重”【合法】进行矩阵乘法(不是线性代数里面的那种),再将计算结果作为隐藏层的输出,同时作为输出层的输入
b) 第二行的数据由隐藏层流入,与上同理,输出是【原始的类别适配度预测】(注意:此时的数据还未经softmax“概率化”,只是作为原始数据)
"""---------------------4. 定义损失函数---------------------"""
loss = nn.CrossEntropyLoss(reduction='none')
模型这里使用的损失函数实际上是【LogSoftmax + NLLLOSS】
具体解析:
假设我们的模型输出了3个类别的分数(logits):[2.0, 1.0, 0.1],我们假设“正确答案”应该是“第一个”,也就是数字2.0对应的类别。
那么我们的模型在拿到上一个小板块(net函数)的计算结果后,进行的计算过程如下:
1. Softmax回归(公式不再赘述):将原始数据“概率化”->[0.659, 0.242, 0.099]
2. LogSoftmax(取对数):将上一步数据取以e为底的自然对数(因为本身公式就是以e为底数的指数运算)->[-0.417, -1.418, -2.313]
3. NLLLOSS(Negative Log Likelihood(中文意思是:似然)):根据真实标签(训练时!!!推理时不用!)给“正确答案”对应的取值“取负号”,得到最终的损失值
大家可能会有问题:我为什么不用之前讲过的Softmax + MSE(均方误差)?
答:最关键的原因是因为“梯度消失”。模型若是非常自信地说是某个答案(比如说模型说的这个答案,模型觉得对应99%可能),但预测错了,那么此时Softmax的输出应该无限接近于1 or 0,此时Softmax函数的梯度非常趋近于0,容易导致梯度消失。这意味着:如果模型错得越离谱,居然“学得越慢”!这很不符合常理,也不应该出现这样的情况,所以我们不用这样的组合。
"""---------------------5. 定义优化算法(优化器)---------------------"""
updater = torch.optim.SGD(params, lr=0.1)
讲过很多遍了,不再赘述。
"""---------------------6. 训练模型---------------------"""
num_epochs, lr = 10, 0.1
d2l.train_ch13(net, train_iter, test_iter, loss, num_epochs, updater)
d2l.predict_ch3(net, test_iter)
调用我们写的函数,不再赘述。
"""---------------------7. 可视化---------------------"""
d2l.plt.show()
可视化的方法(当然不止这一种),如果想看图则必须加上类似这样的代码
十五、图像卷积

完整代码展示:
import torch
from torch import nn
#from d2l import torch as d2l
def corr2d(x, K):
"""计算二维互相关/交叉相关运算——卷积的“白盒”实现"""
h, w= K.shape
Y = torch.zeros( (x.shape[0] - h + 1, x.shape[1] - w + 1) )
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
Y[i, j] = ( x[i:i + h, j:j + w] * K ).sum()
#公式是:Y = (n_h - k_h + 1) × (n_w - k_w + 1),且Y其实是矩阵!
return Y
以上是手动版本实现卷积,实际上在工程中的实现版本如下(黑箱变白箱版!不是直接调包哦!):
class Conv2D(nn.Module):
def init(self, kernel_size):
super().init()# 子类对父类的复写
self.weight = nn.Parameter(torch.rand(kernel_size))
# 随机地根据卷积核尺寸大小初始化参数 Parameter:/pəˈræmɪtə(r)/ 中文意思是【可学习参数】。本质上是个张量,但并非一开始就固定的参数
self.bias = nn.Parameter(torch.zeros(1))# 初始化偏置为1
def forward(self, x):
return corr2d(x, self.weight) + self.bias
验证
X = torch.tensor(
[
[0.0, 1.0, 2.0],
[3.0, 4.0, 5.0],
[6.0, 7.0, 8.0]
]
)
K = torch.tensor(
[
[0.0, 1.0],
[2.0, 3.0]
]
)
print(corr2d(X, K))
接下来模拟检测图像中的不同颜色的边缘
X = torch.ones((6, 8))
X[:, 2:6] = 0
print("X = torch.ones((6, 8)) \n X[:, 2:6] = 0")
print(X)
"""
tensor([[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.],
[1., 1., 0., 0., 0., 0., 1., 1.]])
"""
K = torch.tensor([[1.0, -1.0]])
Y = corr2d(X, K)
Y中的 1 代表从白色到黑色的边缘, -1 代表从黑色到白色的边缘\
print("K = torch.tensor([[1.0, -1.0]]) \n Y = corr2d(X, K)")
print(Y)
"""
tensor([[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.],
[ 0., 1., 0., 0., 0., -1., 0.]])
"""
但鸡肋的是——这样的卷积核只能检测竖直边缘,如果我们把输入转置?
print("corr2d(X.t(), K)")
print(corr2d(X.t(), K))
"""
tensor([[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]])
卷积核毫无反应
"""
学习由X生成Y的卷积核
conv2d = nn.Conv2d(1, 1, kernel_size=(1, 2), bias=False)#暂时不加偏置
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
for i in range(10):
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
conv2d.zero_grad() # 梯度清零
l.sum().backward() # 反向传播
conv2d.weight.data -= 3e-2 * conv2d.weight.grad # 梯度下降
if (i + 1) % 2 == 0:
print(f'epoch {i+1}, loss {l.sum():.3f}')
print( conv2d.weight.data.reshape( (1, 2) ) )
具体机制解析:
Y = torch.zeros( (x.shape[0] - h + 1, x.shape[1] - w + 1) )
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
Y[i, j] = ( x[i:i + h, j:j + w] * K ).sum()
#公式是:Y = (n_h - k_h + 1) × (n_w - k_w + 1),且Y其实是矩阵!
return Y
1. Y = torch.zeros( (x.shape[0] - h + 1, x.shape[1] - w + 1) )
我们之所以只选择这个,是因为zeros会自动给我们分配好规定的连续存储空间,而如果我们用append的话:
1. 动态分配空间的成本还是挺高的
2. PyTorch张量本身不支持如Python列表(list)那般append,我们必须先确定一个形状的张量,才能写Y[i, j] =……若y不存在或者形状不对,代码都会报错(相信很多学者对形状报错都深有体会)
2. 两个for循环
本质上就是等价于我们经常写的两个for循环进行二维矩阵内容输入的“套路”(先行,然后进入循环——固定在该行,逐列输入数据)
3. Y[i, j] = ( x[i:i + h, j:j + w] * K ).sum()

要注意的是——这里Y其实是个矩阵
4. return Y
返回Y值
class Conv2D(nn.Module):
def __init__(self, kernel_size):
super().__init__()# 子类对父类的复写
self.weight = nn.Parameter(torch.rand(kernel_size))
# 随机地根据卷积核尺寸大小初始化参数 Parameter:/pəˈræmɪtə(r)/ 中文意思是【可学习参数】。本质上是个张量,但并非一开始就固定的参数
self.bias = nn.Parameter(torch.zeros(1))# 初始化偏置为1
def forward(self, x):
return corr2d(x, self.weight) + self.bias
1. return corr2d(x, self.weight) + self.bias
等价于我们前面写的
conv2d = nn.Conv2d(1, 1, kernel_size=(1, 2), bias=False)#暂时不加偏置
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
for i in range(10):
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
conv2d.zero_grad() # 梯度清零
l.sum().backward() # 反向传播
conv2d.weight.data -= 3e-2 * conv2d.weight.grad # 梯度下降
if (i + 1) % 2 == 0:
print(f'epoch {i+1}, loss {l.sum():.3f}')
print( conv2d.weight.data.reshape( (1, 2) ) )
1.conv2d.weight.data -= 3e-2 * conv2d.weight.grad
学习率:3e-2(等价于3e^(-2))
这行代码表面上看起来陌生,实际上就是我们经常唠叨的W_新 = W_旧 - η*
只是这里的学习率直接写成了常数而已
十六、卷积中的填充与步幅
1. 填充
为了方便讨论,我们此处取s_h=1
import torch
from torch import nn
def comp_conv2d(conv2d, X):
X = X.reshape((1, 1) + X.shape)
Y = conv2d(X)
return Y.reshape(Y.shape[2:])
# 填充
conv2d = nn.Conv2d(1, 1, 3, padding=1)
X = torch.rand(size=(8, 8))
print(comp_conv2d(conv2d, X).shape) # torch.Size([8, 8])
"""
conv2d = nn.Conv2d(1, 1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape
# torch.Size([8, 8])
"""
【李沐老师原PPT的讲解我觉得有问题】

2. 步幅
# 步幅——直接加在填充那部分代码后面就行
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
print(comp_conv2d(conv2d, X).shape) # torch.Size([4, 4])
其实公式实际上的样子应该是这样的:(注意表示的是向下取整,即如果x=3.5,那么
=3而不是4)
我们可以这样想【向下取整】:(仅列举几个数字)
4
3
2
1
向下就是方向由4~1,反之则是由1~4
(s_h > 1)
计算证明:

十七、多输入输出通道
完整代码展示:
import torch
from d2l import torch as d2l
def corr2d_multi_in(X, K): # 2个用法
return sum(d2l.corr2d(x, k) for x, k in zip(X, K)) # 实际上就相当于卷积之后再把最后的结果加在一起
# zip保证了同通道匹配,不会出现X[0]去配K[1]的情况
测试
X = torch.tensor(
[
[
[0.0, 1.0, 2.0],
[3.0, 4.0, 5.0],
[6.0, 7.0, 8.0]
],
[
[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
[7.0, 8.0, 9.0]
]
]
)
K = torch.tensor( [
[
[0.0, 1.0],
[2.0, 3.0]
],
[
[1.0, 2.0],
[3.0, 4.0]
]
] )
print("corr2d_multi_in(X, K)")
print(corr2d_multi_in(X, K))
print()# 为了输出区美观不会挤在一起而已
输出:tensor([[56., 72.],
[104., 120.]])
多输出通道
def corr2d_multi_in_out(X, K):
# 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
# 最后将所有结果都叠加在一起
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)
"""
[corr2d_multi_in(X, k) for k in K]这个警告不用管他,
纯粹是pycharm的IDE做不到预判用户的每一个输入,
我们只需要运行代码就行,输入交给python解释器自己去判断
"""
K = torch.stack((K, K + 1, K + 2), 0)
print("K.shape")
print(K.shape)
print()
print("K:")
print(K)
print()# 为了输出区美观不会挤在一起而已
print("corr2d_multi_in_out(X, K)")
print(corr2d_multi_in_out(X, K))
具体机制解析:
所谓多输出通道,本质上就是多个多输入但单输出叠在一起的结果。
def corr2d_multi_in_out(X, K):
# 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
# 最后将所有结果都叠加在一起
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)
1. torch.stack(.., 0):
将列表推导式生成的多个2D输出特征图,沿着第0维(源代码里是写第0维)堆叠成3D张量,但需要注意的是:这里的第0维可不是旧的,而是新生成的一个维度。最终返回值shape = [C_out, H_out, W_out],即多输出通道的结果——这里是在第0维插入了C_out,导致输出结果由2D[ H_out, W_out]转为3D[C_out, H_out, W_out]
2. [..., for k in K]:
这里是在遍历4D核k的第0维,shape = [C_in, H_k, W_k],对应输出通道为1,然后多个神经元叠在一起,就是多输出。
3. [corr2d_multi_in(X, k):
X旧是输入嘛,是输入张量,shape=[C_in, H_in, W_in],k就是我们用的卷积核嘛,由上知是3D的,shape=[C_in, H_k, W_k],而且我们知道这个函数内部有zip(X, k),将每个输入通道与与之对应的核通道配对,每一对做互相关之后再对全部求和,最终得到单张2D输出特征图。那么这一步也就相当于是在复用之前已经验证过的、正确的“多输入单输出”逻辑,只不过是将这样的多个“多输入单输出”堆叠成多输出罢了。(至少小编的理解是这样的)
十八、池化层
完整代码展示:
import torch
from torch import nn
def pool2d(X, pool_size, mode='max'):
p_h, p_w = pool_size
Y = torch.zeros((X.shape[0] - p_h + 1, X.shape[1] - p_w + 1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
if mode == 'max':
Y[i, j] = X[i: i + p_h, j: j + p_w].max()
elif mode == 'avg':
Y[i, j] = X[i: i + p_h, j: j + p_w].mean()
return Y
X = torch.tensor([
[0.0, 1.0, 2.0],
[3.0, 4.0, 5.0],
[6.0, 7.0, 8.0]
])
print(pool2d(X, (2, 2)))
"""
输出:
tensor([[4., 5.],
[7., 8.]])
"""
print(pool2d(X, (2, 2), 'avg'))
"""
输出:
tensor([[2., 3.],
[5., 6.]])
"""
X = torch.arange(16, dtype=torch.float32).reshape((1, 1, 4, 4))
print(X)
"""
输出:
tensor([[[[ 0., 1., 2., 3.],
[ 4., 5., 6., 7.],
[ 8., 9., 10., 11.],
[12., 13., 14., 15.]]]])
"""
pool_max = nn.MaxPool2d(3)
print(pool_max(X))
"""
输出:
tensor([[[[10.]]]])
"""
pool_avg = nn.AvgPool2d(3)
print(pool_avg(X))
"""
输出:
tensor([[[[5.0000]]]])
"""
pool_stride = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
print(pool_stride(X))
"""
输出:
tensor([[[[ 5., 7.],
[13., 15.]]]])
"""
具体机制解析:
代码本身其实没什么好讲的,主要是想讲一下其背后的计算逻辑以及计算原理。
首先是第一种情况——最大池化
[
[0.0, 1.0, 2.0],
[3.0, 4.0, 5.0],
[6.0, 7.0, 8.0]
]
我们的视角就是:

输入张量(棕色框)周围的黑、红、黄、蓝四色框,则代表最大池化层在输入上移动,并选取其所涵盖的2*2=4个元素中最大的那个元素,作为输出的其中一条记录计入(计入是四色箭头表达的意思)输出张量(紫色框)中;后续不断重复这个过程。
其次是第二种情况——平均池化
[
[0.0, 1.0, 2.0],
[3.0, 4.0, 5.0],
[6.0, 7.0, 8.0]
]

针对其他情况,只需要注意步幅就行,不要混淆了!
十九、批次归一化
完整代码展示:
import torch
from torch import nn
from d2l import torch as d2l
def batch_norm(X, gamma, beta, moving_mean, moving_var, eps, momentum):
# 通过is_grad_enabled来判断当前模式是训练模式还是预测模式
if not torch.is_grad_enabled():
# 如果是在预测模式下,直接使用传入的移动平均所得的均值和方差
X_hat = (X - moving_mean) / torch.sqrt(moving_var + eps)
else:# 进入推理模式
assert len(X.shape) in (2, 4)
# 2D的shape:(N, num_features)N就是batch size,num_features就是特征数量/神经元个数
# assert表示判断——判断X.shape是否是2或者4,如果不是,则立马抛出——AssertionError的报错
if len(X.shape) == 2:
# 使用全连接层的情况,计算特征维上的均值和方差
mean = X.mean(dim=0)# 看似没有用Σ思想,实际上是被打包好在函数内部的
# 表示沿着batch size维度进行求平均
var = ((X - mean) ** 2).mean(dim=0)
else:
# X.shape = [样本总数N(0), C_out(1), H(2), W(3)]——(0)等表示index(索引)
# 这里我们需要保持X的形状以便后面可以做广播运算
# 加了keepdim=True之后,mean的形状会是[1, C, 1, 1]
mean = X.mean(dim=(0, 2, 3), keepdim=True)
var = ((X - mean) ** 2).mean(dim=(0, 2, 3), keepdim=True)
# 训练模式下,用当前的均值和方差做标准化
X_hat = (X - mean) / torch.sqrt(var + eps)# epsilon是为了防止除零
"""
(X - mean)这里体现了广播的第一处应用
mean的形状是(1, C, 1, 1),X的形状是(N, C, H, W)
PyTorch在执行减法时,将mean沿dim=0、dim=2、dim=3逻辑扩展为 (N, C, H, W),
但mean的物理存储形状始终保持(1, C, 1, 1)不变,不会发生任何形状变更!
"""
moving_mean = momentum * moving_mean + (1.0 - momentum) * mean
moving_var = momentum * moving_var + (1.0 - momentum) * var
#
# 这里的momentum是更新移动平均的均值和方差时的动量
Y = gamma * X_hat + beta # 缩放和移位——第二处广播
return Y, moving_mean.data, moving_var.data# 剔除了冗余的信息,只返回data
class BatchNorm(nn.Module):
"""
批量归一化(Batch Normalization)层的实现
批量归一化是一种常用的 normalization 技术,用于训练深度神经网络时加速收敛并提高稳定性
"""
# num_features:完全连接层的输出数量或卷积层的输出通道数。
# num_dims:2表示完全连接层,4表示卷积层
def init(self, num_features, num_dims):
super().init() # 调用父类的初始化方法
if num_dims == 2:
shape = (1, num_features) # 对于二维数据,形状为(1, num_features)
"""
大家看到这里可能会有点疑问——
为什么这里要“手动”指定形状?广播不是已经实现了吗?是不是因为超出了作用域所以要手动?
实际上并不是。广播是自动实现的,无关作用域,一旦使用自动触发。这里之所以要看似手动广播,
实际上是为后面实现广播奠定物理存储的基础——让参数得以以正确方式被创建、存储。下面同理的。
"""
else:
shape = (1, num_features, 1, 1)
# 参与求梯度和迭代的拉伸和偏移参数,分别初始化成1和0
self.gamma = nn.Parameter(torch.ones(shape))# shape具体取值已经在上面点明
self.beta = nn.Parameter(torch.zeros(shape))
"""
将γ(gamma)、β(beta)列入可学习参数范围内,
PyTorch 会自动将其加入 model.parameters(),
优化器在反向传播时会对它计算梯度并更新。
但这里的Parameter只负责提供gamma、beta“可被学习”的属性,不参与赋值。
里面的torch.ones(shape)负责将shape形状指定给那两个可学习参数
"""
# 非模型参数的变量初始化为0和1
self.moving_mean = torch.zeros(shape)# shape具体取值已经在上面点明
self.moving_var = torch.ones(shape)
# 得让形状对齐,否则广播时会报错
def forward(self, X):
# 如果X不在内存上,将moving_mean和moving_var复制到X所在显存上
if self.moving_mean.device != X.device:
self.moving_mean = self.moving_mean.to(X.device)
self.moving_var = self.moving_var.to(X.device)
# 搬运数据到X所在的device上
"""
这部分代码的含义是:无论别的数据在哪个device,都以X所在的device为准,那两个可学习参数的数据就得到X在的device去
"""
# 保存更新过的moving_mean和moving_var
# 开始调用上面写的batch_norm函数去计算moving_mean、moving_var
Y, self.moving_mean, self.moving_var = batch_norm(
X, self.gamma, self.beta, self.moving_mean,
self.moving_var, eps=1e-5, momentum=0.9)
return Y
net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5), BatchNorm(6, num_dims=4), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), BatchNorm(16, num_dims=4), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
nn.Linear(1644, 120), BatchNorm(120, num_dims=2), nn.Sigmoid(),
nn.Linear(120, 84), BatchNorm(84, num_dims=2), nn.Sigmoid(),
nn.Linear(84, 10))
lr, num_epochs, batch_size = 1.0, 10, 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())
d2l.plt.show()
涉及的公式如下:



这里的α / momentum,一般取值为0.9。关于公式中的权重分配,小编觉得我们可以这么理解——如果它大,就说明参数更新更多依赖(以第一行为例),1 - 它的值就小,相当于μ_(t - 1)被分配到的权重就大。
是不是觉得听起来还是很疑惑——为啥非要取0.9?就不能分配更多给新的μ_t吗?怎么感觉……好像太“偏心”了?实际上还真不是故意偏心历史记录
,而是经过了前人无数次实验验证得出的规律——一般取0.9。至于具体为什么,感兴趣的可以自主去查找资料,本小编不在此赘述。再讲回来,我们之所以这样分配,实际上还有个原因——
单个小批次的统计量噪声很大(样本少+随机采样共同导致的),给这样的结果分配过多的权重会导致估计剧烈抖动。于是我们通过高权重保留历史,相当于是起到了一个“低通滤波器”的作用,使得估计值平滑收敛到真实的全局统计量。
补充:实战建议将上面的Sigmoid函数换成ReLU函数,因为Sigmoid函数很容易有梯度消失等问题。
流程图讲解:(使用vs code中的markdown语言格式、第三方插件编辑)
若是你们也想自主编辑类似这样的流程图,教程奉上——
(由于篇幅、小编能力限制,只展示最关键、有关联的部分教程)
使用的必备插件:(在扩展商场中搜索,可免费下载)

格式参考:(以下面的流程图为例,进行开源展示)
```mermaid
graph TD
Start([开始]) --> Init["初始化网络和参数<br>(gamma, beta, moving_mean, moving_var)"]
Init --> Forward["前向传播<br>(Forward Pass)"]
Forward --> CheckMode{"是否为<br>训练模式?"}
CheckMode --> |是| ComputeStats["计算当前batch的<br>均值和方差"]
CheckMode --> |否| UseMovingStats["使用移动平均的<br>均值和方差"]
ComputeStats --> Normalize["归一化操作<br>(X - mean)/sqrt(var + eps)"]
UseMovingStats --> Normalize
Normalize --> UpdateMoving["更新移动平均<br>(momentum * moving + (1-momentum) * current)"]
UpdateMoving --> ScaleShift["缩放和移位<br>(Y = gamma * X_hat + beta)"]
ScaleShift --> Return["返回结果和更新后的<br>移动平均参数"]
Return --> Loss["计算损失"]
Loss --> Backward["反向传播<br>(Backward Pass)"]
Backward --> UpdateParams["更新网络参数"]
UpdateParams --> CheckEpoch{"是否完成<br>所有训练轮次?"}
CheckEpoch --> |否| Forward
CheckEpoch --> |是| End([结束])
```
注意事项:一定在末尾加入```!否则会报错:

成果展示:

net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5), BatchNorm(6, num_dims=4), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), BatchNorm(16, num_dims=4), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
nn.Linear(16*4*4, 120), BatchNorm(120, num_dims=2), nn.Sigmoid(),
nn.Linear(120, 84), BatchNorm(84, num_dims=2), nn.Sigmoid(),
nn.Linear(84, 10))
流程图:

这部分的代码推导如下——(如果实在看不清,把图片放大看)
过程由于中间有重复模块的调用便稍有省略,若想追溯完整的变化过程可手动计算,加深印象。


超参数设置:(前期的准备工作,没什么好讲的)
最后一行是可视化loss图
lr, num_epochs, batch_size = 1.0, 10, 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())
d2l.plt.show()
讲了那么多,估计你们也听累了吧?
一起来看看究竟模型训练是怎么个事吧~
复制上面的代码到PyCharm中(默认你们已经建立好python文件,就等运行了,可通过鼠标右键运行)
等待一段时间后,输出区若是输出类似这样的内容,则代表模型已经开始正常训练——期间大家可以去休息,模型会正常自主训练,无需人工监督,结束后会跳出训练结果图弹窗,并自动结束训练。
中间会不断打印<Figure size 350x250 with 1 Axes>,不必理会,是模型在告诉你——我在正常训练!不要打断我!

等待一段时间让模型自己训练并测试,训练结束后会自动生成训练结果图(d2l.plt.show()对应的含义——生成训练结果图——调用的是matplotlib这个模块包进行可视化)

以下是本小编亲自训练的结果——虽然有点“诡异”(中间突然间test acc——test accuracy的意思——下降然后上升,最后趋于平缓),但loss确实一直在降、acc在整体上升然后趋于平缓且低于1.0(表明没有过拟合,是模型正常的训练结果)

二十、目标识别与描框
完整代码展示:
import torch
from d2l import torch as d2l
================= 边界框转换函数 =================
def box_corner_to_center(boxes):
"""从(左上,右下)转换到(中间,宽度,高度)"""
x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
cx = (x1 + x2) / 2
cy = (y1 + y2) / 2
w = x2 - x1
h = y2 - y1
return torch.stack((cx, cy, w, h), dim=-1)
def box_center_to_corner(boxes):
"""从(中间,宽度,高度)转换到(左上,右下)"""
cx, cy, w, h = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
x1 = cx - 0.5 * w
y1 = cy - 0.5 * h
x2 = cx + 0.5 * w
y2 = cy + 0.5 * h
return torch.stack((x1, y1, x2, y2), dim=-1)
================= 验证转换正确性 =================
dog_bbox = [60.0, 45.0, 378.0, 516.0]
cat_bbox = [400.0, 112.0, 655.0, 493.0]
boxes = torch.tensor((dog_bbox, cat_bbox))
print("转换验证:", box_center_to_corner(box_corner_to_center(boxes)) == boxes)
================= 绘图部分(PyCharm适配) =================
def bbox_to_rect(bbox, color):
"""将边界框转换为matplotlib Rectangle对象"""
return d2l.plt.Rectangle(# Rectangle在中文里意思是矩形
xy=(bbox[0], bbox[1]), width=bbox[2]-bbox[0], height=bbox[3]-bbox[1],
fill=False, edgecolor=color, linewidth=2)
加载图片:Windows路径必须用r""或正斜杠/
try:
img = d2l.plt.imread(r"D:\my_ai_projects\d2l_data\catdog.jpg")
"""上面的路径可换成自己习惯的路径,建议写绝对路径,避免文件找不到"""
except FileNotFoundError:
print("未找到图片,使用灰色测试图替代。请你真正将图片安装下来")
import numpy as np
img = np.zeros((600, 800, 3), dtype=np.uint8) + 200
d2l.imshow返回AxesImage,需通过.axes添加patch
fig = d2l.plt.imshow(img)
fig.axes.add_patch(bbox_to_rect(dog_bbox, 'blue'))
fig.axes.add_patch(bbox_to_rect(cat_bbox, 'red'))
d2l.plt.show()
具体机制解析:(如果想先解决下载图片的问题,跳到下面的小节)
实际上这部分并不是真的让模型自己去识别目标然后锚框,而是我们人为进行锚框,相当于是先让大家理解这个目标识别、描框的概念
首先我们要明确一点——什么是boxes?
这里作为形参被传入的boxes,实际上对应的实参长这样:
dog_bbox = [60.0, 45.0, 378.0, 516.0] cat_bbox = [400.0, 112.0, 655.0, 493.0]
这个列表的四个数据分别对应如下含义:
[x_start, y_start, x_end, y_end]
或者如果大家觉得对应box_corner_to_center比较直观的话,我给出的解释如下

但在box_corner_to_center函数中,boxes被转换成了(cx, cy, w, h),其中cx的含义,我个人觉得是centural x(如果大家不计较英文表述的话……),反正含义就是矩形(Rectangle)的中心的x坐标这个分量表示为cx。
整个代码从第一行到转换验证处,实际上就是在验证这件事——
把矩阵用四角坐标表示,是否与用中心坐标、高宽度表示结果相同。仅此而已
但实际上由于李沐老师给出的数据较为友好,故看似未出现浮点数精度危机,实际上若是将数据换成比如奇数,则会出现精度不准的现象——计算机正常存储行为,非故障。故不建议直接用 == 进行布尔判别。
下载李沐课上的图片的方式:
一、走代理——前提是看自己的科学上网工具的代理端口是什么(假设是7890)
import os os.environ['http_proxy'] = 'http://127.0.0.1:7890' # 替换为你自己的代理地址和端口 os.environ['https_proxy'] = 'http://127.0.0.1:7890'
二、走国内镜像源
https://ghfast.top/https://raw.githubusercontent.com/d2l-ai/d2l-en/master/img/catdog.jpg
配置镜像源公式:
https://ghfast.top/ + <原始GitHub文件URL>
若以上镜像失败,可将ghfast.top替换为gh-proxy.com 或者 mirror.ghproxy.com 或者 kkgithub.com等,只需要将
下载脚本示例如下:
(下载路径可视个人喜好修改——注:必须加上folder,否则下载之后解释器不会按照你指定的路径存储下载的结果)
from d2l import torch as d2l
TARGET_DIR = 'D:/my_ai_projects/d2l_data'
url = 'https://ghfast.top/https://raw.githubusercontent.com/d2l-ai/d2l-en/master/img/catdog.jpg'
d2l.set_figsize()
img_path = d2l.download(url, folder=TARGET_DIR)
print(f"实际保存路径: {img_path}")
"""用于检查实际存储路径是否符合预期"""
img = d2l.plt.imread(img_path)
d2l.plt.imshow(img)
d2l.plt.show()
下载好的示例如下:(省略输出)


完整脚本展示:
import os
from d2l import torch as d2l
import torch
import torchvision
import pandas as pd
TARGET_DIR = 'D:/my_ai_projects/d2l_data'# 还是那句话——想自定义下载路径则可自修改
os.environ['https_proxy'] = 'http://127.0.0.1:7890'# 若想走代理,则替换为你本地代理的实际端口
#===============若是想不断调参以进行测试,在保证数据集已经下载好了的前提下,可将这一段代码注释掉=======================================
d2l.DATA_HUB['banana-detection'] = (
d2l.DATA_URL + 'banana-detection.zip',
'5de26c8fce5ccdea9f91267273464dc968d20d72'# 校验码
)
#记得在下载之后手动解压zip压缩包到自己指定的文件夹,然后注意绝对路径保持一致
data_dir = d2l.download('banana-detection', folder=TARGET_DIR)
print(f"实际保存路径: {data_dir}")# 注意自己实际保存的路径
#===============若是想不断调参以进行测试,在保证数据集已经下载好了的前提下,可将这一段代码注释掉=======================================
#注意:label在csv文件里的含义是类别,0代表香蕉——相当于0是香蕉这个识别对象的索引——我们只识别这一个“类别”的物体
def read_data_bananas(is_train=True):
"""读取香蕉检测数据集中的图像和标签。"""
data_dir = os.path.join(TARGET_DIR, 'banana-detection')
# 由于前面我已经下载过,便把这里的代码进行了修改,不再进行下载操作以节省运行时间,可让运行者更快看到后续的结果
csv_fname = os.path.join(data_dir,
'bananas_train' if is_train else 'bananas_val',
'label.csv')# 这部分就是在生成训练数据集
csv_data = pd.read_csv(csv_fname)# 通过附带的csv文件获取锚框的数据并生成DataFrame对象
# 想具体了解label.csv文件的内容,可自行打开csv文件查看
csv_data = csv_data.set_index('img_name')
# set_index()函数用于设置索引,这里就是将img_name这一列设置为索引并赋值给csv_data这一DataFrame对象
images, targets = [], []# 新建列表用于存储图片、目标(香蕉)
for img_name, target in csv_data.iterrows():
# 把iterrows分开来看——iter(迭代器) + rows(按行优先读取/遍历)
# 即:先遍历第一行的所有数据(迭代器来到第一行,然后再切换到列遍历去遍历该图片的所有数据——
# 此时想彻底了解具体迭代内容的学者,可点开label.csv文件查看),再切换到下一行
# label.csv文件中包含的数据,就是每张图片中的香蕉所在的位置的“形容词”,包含5个参数
"""
提示:想真正理解这个函数,建议亲自去点开label.csv去查看、思考,空想是想不通的——数据去向、形状无法对应
"""
images.append(
torchvision.io.read_image(# 默认返回unit8
os.path.join(data_dir,
'bananas_train' if is_train else 'bananas_val',
'images', f'{img_name}')))# 将图片的文件名替换成img_name
# 这里的images表示将上面两类数据集都放在各自的'images'文件夹下
targets.append(list(target))
# target则是按列读取的结果——每张图片中的香蕉所在的位置的“形容词”,包含5个参数
# 注意——这里的csv_data并非严格意义上的二维矩阵,而是宏观上的架构是一张类似Excel表格的数据容器——DataFrame对象
return images, torch.tensor(targets).unsqueeze(1) / 256# 归一化方便模型计算
#常见的标注数据存储格式:VOC标签格式与YOLO标签格式
#这里李沐老师用的是csv的绝对坐标标记格式
class BananasDataset(torch.utils.data.Dataset):
def init(self, is_train):
self.features, self.labels = read_data_bananas(is_train)
print('read ' + str(len(self.features)) +
f' training examples' if is_train else f'validation examples')
def getitem(self, idx):# 根据传入的索引,加载并返回模型训练所需的单个样本
return self.features[idx].float(), self.labels[idx]
def len(self):
return len(self.features)# 返回数据集的大小
def load_data_banas(batch_size):
train_iter = torch.utils.data.DataLoader(
BananasDataset(is_train=True),
batch_size,
shuffle=True)
val_iter = torch.utils.data.DataLoader(
BananasDataset(is_train=False),
batch_size)
return train_iter, val_iter
batch_size, edge_size = 32, 256
train_iter, _ = load_data_banas(batch_size)
batch = next(iter(train_iter))
print(f'训练数据实例:{batch[0].shape}, 标签形状:{batch[1].shape}')
imgs = (batch[0][0:10].permute(0, 2, 3, 1)) / 255
batch[0][0:10]# 表示取出10张图——记住——在Python里面,是“左闭右开”,不要一看到0~10就条件反射说“你讲得不对!应该是11张!”
实际取的图片的索引是从0~9,共9 - 0 + 1 = 10张(第0张也算一张——算上第0张自己)
PyTorch 存储图像的格式是 CHW(通道×高×宽),
但 d2l.show_images 和 matplotlib 等可视化工具要求的是HWC(高×宽×通道)。
permute 就是用来做这个格式转换的
torchvision.io.read_image读出来的像素值是 0~255 的整数(转成了float)显示和训练都需要 0~1 范围,所以除以 255
axes = d2l.show_images(imgs, 2, 5, scale=2)
scale是表示缩放倍率的意思。
这里的axes是返回的ax对象构成的集体
(单个对象是ax——包含坐标轴、边框和图片内容的完整小画布),
用于后续的绘制
其中包含关系是——ax 包含于 axes,axes包含于figure or so-called fig(大画布)
for ax, label in zip(axes, batch[1][0:10]):
d2l.show_bboxes(ax, [label[0][1:5] * edge_size], colors='w')
d2l.plt.show()
show_bboxes函数的参数是:ax对象、坐标列表、颜色
具体机制解析:
首先来讲一下所谓的uint8:
一、理解 uint8:计算机中的“小格子”
在计算机视觉和图像处理中,我们经常会遇到 uint8 这个术语。对于非计算机专业的学者来说,这听起来可能有点技术性,但别担心,我们可以用一个简单的比喻来理解它。
1. 什么是 uint8?
uint8 是"无符号8位整数"的缩写。让我们拆开来看:
- 无符号(unsigned):表示这个数字只能是正数或零,不能是负数。
- 8位(8-bit):表示计算机用8个二进制位(0或1)来存储这个数字。
- 整数(integer):表示这是一个整数,不是小数。
那么,8位能表示多少个不同的数字呢?答案是 2⁸ = 256 个。所以 uint8 能表示的范围是 0 到 255。
uint8 的背景与来历:
追根溯源,uint8 的"血统"可以一直追溯到 C 语言的
unsigned char。在早期的 C 语言规范中,char被定义为足够存放基本字符集的最小编码单元,通常恰好就是 1 个字节(8 位);而加上unsigned修饰后,unsigned char就把这 8 个比特全部用于表示数值,范围正是 0~255。很长一段时间里,图像处理库和底层 I/O 代码中随处可见unsigned char *的身影——它是最早、最直接的内存友好型像素载体。到了 C99 标准,情况变得更加规范:C99 在
<stdint.h>中引入了uint8_t这个明确的固定宽度类型。这下开发者不用再纠结char的符号性问题(某些平台上char默认是带符号还是无符号可能不一致),只需要引用uint8_t,就能在所有平台上得到完全相同的 8 位无符号行为。今天你在torchvision或OpenCV的底层 C/C++ 代码里看到的uint8_t,正是 C99 时代的直接遗产。在 Python / NumPy 生态里,
numpy.uint8则是对上述传统的继承与封装。NumPy 在 C 层面直接使用uint8_t存储数据,对外暴露为dtype=np.uint8,让 Python 用户也能使用这一紧凑、标准的 8 位无符号类型。如今我们在深度学习预处理流水线中反复遇到的dtype=np.uint8,本质上串联起了从unsigned char→uint8_t→numpy.uint8的完整演进链条,承载着数十年来计算机对"最小、最标准的像素单元"的共识。
2. 为什么图像处理中常用 uint8?
想象一下一张黑白照片。计算机需要为每个像素点分配一个亮度值:
- 0 表示纯黑色
- 255 表示纯白色
- 中间的值(如128)表示不同程度的灰色
256个等级(0-255)对于人眼来说已经足够细腻,几乎看不出明显的色阶跳跃。而且,每个像素只需要1个字节(8位)来存储,非常节省空间。
对于彩色图片,通常使用三个 uint8 值来表示红、绿、蓝三个通道,这就是我们常说的 RGB 格式。
3. 一个简单的代码示例
让我们看看在 Python 中如何使用 uint8:
import numpy as np
创建一个 uint8 类型的数组
这就像创建一个专门存放 0-255 数字的“小盒子”
pixel_values = np.array([0, 128, 255], dtype=np.uint8)
print("像素值数组:", pixel_values)
print("数据类型:", pixel_values.dtype)
尝试存储超出范围的值会发生什么?
注意:uint8 会自动“截断”到有效范围
overflow_example = np.array([300, -10], dtype=np.uint8)
print("溢出处理后的值:", overflow_example) # 输出: [44 246]
4. 实际应用场景
在之前的目标识别代码中,我们看到这样一行:
# torchvision.io.read_image 默认返回 uint8 格式的图像数据
img = torchvision.io.read_image(image_path)
这意味着读入的图片数据每个像素值都在 0-255 范围内。这种格式有几个好处:
- 标准化:所有图像都使用相同的数值范围,便于比较和处理
- 内存友好:相比使用浮点数(如 float32),uint8 只需要1/4的内存
- 显示友好:大多数显示设备都期望 0-255 范围的像素值
5. 需要注意的地方
虽然 uint8 很常用,但在进行数学运算时要小心:
# 两个 uint8 值相加可能溢出
a = np.uint8(200)
b = np.uint8(100)
result = a + b # 300,但 uint8 最大是255
print("直接相加的结果:", result) # 输出: 44(因为 300-256=44)
正确的做法:先转换为更大的数据类型
a_float = a.astype(np.float32)
b_float = b.astype(np.float32)
safe_result = a_float + b_float
print("安全相加的结果:", safe_result) # 输出: 300.0
6. 总结
可以把 uint8 想象成一种“小格子”,每个格子只能放 0-255 之间的整数。它在图像处理中特别有用,因为:
- 正好能表示人眼能分辨的亮度/颜色等级
- 节省存储空间和计算资源
- 是行业标准,各种工具都支持
下次当你看到代码中出现 dtype=np.uint8 或类似的声明时,就知道这是在告诉计算机:“请用最经济的方式存储这些图像数据,每个像素值都在 0-255 之间”。
理解了这个概念,你就能更好地理解为什么在机器学习中,我们经常需要把图像数据从 uint8 转换为 float32 进行计算(避免溢出),然后再转换回 uint8 进行显示或保存。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐




所有评论(0)