本文代码取自李沐的《动手学习深度学习》,里面有些代码细节让我觉得非常值得斟酌,特记录。
生成、可视化数据集
1 | from utils import d2l |
读取数据
使用迭代器生成数据集
1 | def data_iter(batch_size, features, labels): |
这里有一个问题,即如果样本数量不能整除batch_size,就导致有一些样本始终取不到,为了防止这个问题发生,可以每一次取完之后重新打乱样本序号。
从中取出一个样本数据:
1 | batch_size = 10 |
初始化模型参数
1 | w = torch.normal(0, 0.01, size=(2, 1), requires_grad=True) |
定义模型/损失函数/优化算法
1 | def linreg(X, w, b): |
这里的sgd函数有几个问题:
-
为什么这里需要加一个with torch.no_grad()?
因为w和b的requires_grad是True,所有关于他们的运算都会自动构建计算图(用于累积梯度),这里不需要构建静态图、跟踪计算日志,因为我们需要的、和w,b有关的计算图应该仅仅是正向传播的计算图,如果这里不加限制,会自动创建关于权重更新运算的计算图。可以将存储梯度的内存节省下来,这样也可以让代码执行速度更快。
-
这里如何更新权重的,问答区有两个回复非常好,需要记录一下
当某一变量var在函数外面已经声明时 (如var=v0),函数内部默认var为全局变量且可以访问该变量,除非在函数内部有修改变量var的行为(如重新赋值 var=v1 或者代数运算 var=var+v1 等)。在这种修改变量的情况下,变量var会被定义为局部变量并被重新分配内存,它在函数内部的变化不会影响到外部的全局变量var的值(即var=v0保持不变)。
特殊之处在于本节sgd中使用的运算符(-=)会执行原地操作(in-place operation),也就是运算结果会赋给同一块内存。由于params本身就是全局变量,修改后的结果仍然赋给它的内存,所以变化的也就是全局变量了。如修改为 param = param - … 结果就不对了
造成你困惑的最主要原因的核心是“可变对象与不可变对象”。
对于函数中的for循环,param得到的是列表中元素的引用,这没有问题。
但是呢,会不会就地改变(直接作用到变量)这得看具体的实现。
“-=”操作符会调用__isub__函数,而"-"操作符会调用__sub__函数,一般对于可变对象来说“-=”操作符会直接改变self自身。对于pytorch来说,应该会调用sub_函数.举个例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import torch
x1 = 1
x2 = 2
params = [x1, x2]
for p in params:
print(id(p), id(x1), id(x2))
p -= 4
print(id(p), id(x1), id(x2))
print(params)
x1 = torch.Tensor([1])
x2 = torch.Tensor([2])
params = [x1, x2]
for p in params:
print(id(p), id(x1), id(x2))
p -= 4
print(id(p), id(x1), id(x2))
print(params)你会得到(你自己运行的话,id得到的地址会不一样)
1
2
3
4
5
6
7
8
9
109784896 9784896 9784928
9784768 9784896 9784928
9784928 9784896 9784928
9784800 9784896 9784928
[1, 2]
139752445458112 139752445458112 139752445458176
139752445458112 139752445458112 139752445458176
139752445458176 139752445458112 139752445458176
139752445458176 139752445458112 139752445458176
[tensor([-3.]), tensor([-2.])]可以看到对于int类型,地址变换了,而torch类型,地址却没有变化。
p -= 4等价于p.sub_(4)。这个可变对象改变了自身。而若如vin100提到的写成p = p - 4则会调用构造函数,并返回一个新的变量,也就不可能作用到原先的“可变对象”。
int类没有发生就地变化是因为它是一个不可变对象。从两条评论来看,能让权重顺利更新的原因有两个:(1)python函数形参地址和实参地址相同;(2)传入的列表中的元素是tensor(可变对象)。
首先第(1)条保证了函数内外的列表对应的地址一致,如下测试代码:
1
2
3
4
5a = [1, 2, [3, 4]]
def f(a):
print(f"函数内:id(a[0]) = {id(a[0])}")
print(f"函数外:id(a[0]) = {id(a[0])}")
f(a)输出:
1
2函数外:id(a) = 1875349520968
函数内:id(a) = 1875349520968如果对列表中的第一个元素改变一下:
1
2
3
4
5
6a = [1, 2, [3, 4]]
def f(a):
a[0] -= 1
print(f"函数内:id(a[0]) = {id(a[0])}")
print(f"函数外:id(a[0]) = {id(a[0])}")
f(a)输出结果:
1
2函数外:id(a[0]) = 140713631654288
函数内:id(a[0]) = 140713631654256不可变对象(int)的地址发生了变化。
如果改变的是列表里的列表呢(可变对象)?
1
2
3
4
5
6a = [1, 2, [3, 4]]
def f(a):
a[2][0] -= 1
print(f"函数内:id(a[2]) = {id(a[2])}")
print(f"函数外:id(a[2]) = {id(a[2])}")
f(a)输出结果:
1
2函数外:id(a[2]) = 1875349520456
函数内:id(a[2]) = 1875349520456发现可变对象的地址没有发生变化。
将列表里的元素全部换成tensor测试一下:
1
2
3
4
5
6
7a = [1, 2, [3, 4]]
a = [torch.tensor(elem) for elem in a]
def f(a):
a[0] -= 1
print(f"函数内:id(a[0]) = {id(a[0])}")
print(f"函数外:id(a[0]) = {id(a[0])}")
f(a)输出结果:
1
2函数外:id(a[0]) = 1875366498040
函数内:id(a[0]) = 1875366498040地址没有改变,这就印证了上面的说法。
-
这里更新权重,为什么需要除以batch_size
因为所使用的squared_loss损失函数最终没有除以N,得到的并不是平均损失,用这个损失求得的梯度也不是平均梯度,而是所有(batch_size个)样本一并产生的损失所求得的梯度,而我们平常更新权重的时候,使用的是平均损失所计算而来的梯度(平均梯度),如果不除以batch_size可能导致下降的太快(设想一下
param -= lr * param.grad / batch_size
中param.grad
变为原来的batch_size
倍),导致进入局部极小值。 -
为什么需要使用grad.zero_()
当前的梯度只是上一次epoch计算出来的梯度,更新权重之后需要消掉这次梯度,防止和下一次的梯度叠加,下一次的梯度只能从下一次的epoch中计算而来。
-
如果我们使用官方的损失函数来代替我们自己实现的损失函数,而且用
nn.MSELoss(reduction=‘sum’)
替换nn.MSELoss()
为了使代码的行为相同,需要怎么更改学习速率?为什么? 参考官方文档中,这个函数的实现细节:https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html
应该把学习率除以batch_size,因为默认参数是’mean’,换成’sum’需要除以批量数,一般会采用默认,因为这样学习率可以跟batch_size解耦。我们测试一下,修改前:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17loss = 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)
# 这里和前面自己实现的不同
# 这个API没有自动清零梯度
# 需要利用传入优化算法的API来手动清零
trainer.zero_grad()
l.backward()
# 优化过程:
trainer.step()
# 优化完成之后,需要手动计算所有的features和labels之间的损失值
l = loss(net(features), labels)
print(f"epoch {epoch + 1}, loss {l:f}")输出:
1
2
3epoch 1, loss 0.000103
epoch 2, loss 0.000102
epoch 3, loss 0.000101修改后:
1
2loss = nn.MSELoss(reduction='sum')
trainer = torch.optim.SGD(net.parameters(), lr=0.03/batch_size)输出:
1
2
3epoch 1, loss 0.102505
epoch 2, loss 0.101427
epoch 3, loss 0.101685发现取sum的损失值显著大于默认的mean损失值,为什么?
mean意味着所有样本损失的平均值,即loss会除以样本数,sum没有除这个样本数,所以会放大1000倍(这里样本数为1000),可以试着将样本数改成10000,会发现两种方式确实损失值相差1w倍。
训练
1 | lr = 0.03 |
训练的基本步骤是进行多个epoch,每一个epoch都会将所有的数据遍历一遍,但是每一次遍历不是一次性将所有的数据加载入内存,而是使用迭代器的方式多个batch加载。
加载完数据需要投入模型计算,计算估计值和真实值之间的损失函数,利用损失函数进行反向传播,从而计算需要优化的参数的梯度。
使用参数的梯度更新参数,更新完成之后计算此时预测的结果和真实值之间的损失值并输出。
反复执行上述步骤,直至退出循环。
有两个问题:
-
为什么需要计算得到损失值,然后才对进行更新,而不是直接根据表达式更新,因为我觉得参数的梯度和损失值无关,而是求偏导之后和X有关。
有这个疑问其实是对于pytorch的静态图没有理解,凡是有关于grad needed变量的运算,pytorch都会记录其操作符,并构建反向传播图,如果不计算loss,那么这一步构建反向传播图就无法进行,求偏导确实和损失值本身无关。
-
为什么每一次计算一个batch的损失之后,就需要马上进行反向传播,我可否将每一个batch计算得到的损失拼接起来,最后进行更新?或者每一个batch都更新一次,然后将损失值拼接起来,后面每个batch都会将以前的batch的损失值拼接起来一起更新权重?
首先说第一种,基于这一思想,改写代码逻辑如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25lr = 0.03
num_epochs = 300
net = linreg
loss = squared_loss
for epoch in range(num_epochs):
count = 1
l = None
for X, y, in data_iter(batch_size, features, labels):
count += 1
if l is None:
l = loss(net(X, w, b), y) # X和Y的小批量损失
else:
torch.cat((l, loss(net(X, w, b), y)), dim=0)
# 退出循环之后(遍历所有样本之后)反向传播,然后更新权重
l.sum().backward()
sgd([w, b], lr, batch_size * count)
with torch.no_grad():
train_l = loss(net(features, w, b), labels)
if (epoch + 1) % 10 == 0:
print(f'epoch {epoch + 1}, loss {float(train_l.mean())}')
print(f'w的估计误差: {true_w - w.reshape(true_w.shape)}')
print(f'b的估计误差: {true_b - b}')最终效果并不好,最终更新的次数就是epoch次,首先次数大大减少,如果增加epoch数量,之前样本数是1000,batch_size是10,epoch是3,一共会更新300次,所以直接将epoch设置为300进行测试,将最后5个epoch计算得到的损失值列出来:
1
2
3
4
5
6
7epoch 260, loss 13.599431037902832
epoch 270, loss 13.525508880615234
epoch 280, loss 13.450084686279297
epoch 290, loss 13.36327838897705
epoch 300, loss 13.275373458862305
w的估计误差: tensor([ 1.7403, -2.9753], grad_fn=<SubBackward0>)
b的估计误差: tensor([3.6783], grad_fn=<RsubBackward1>)效果还是很差,损失值下降非常缓慢。
第二种思路行不通,首先根据这种思路改写代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25lr = 0.03
num_epochs = 3000
net = linreg
loss = squared_loss
for epoch in range(num_epochs):
count = 1
l = None
for X, y, in data_iter(batch_size, features, labels):
count += 1
if l is None:
l = loss(net(X, w, b), y) # X和Y的小批量损失
else:
torch.cat((l, loss(net(X, w, b), y)), dim=0)
# 将前一次的损失cat过来一起反向传播,然后更新权重
l.sum().backward()
sgd([w, b], lr, batch_size * count)
with torch.no_grad():
train_l = loss(net(features, w, b), labels)
if (epoch + 1) % 10 == 0:
print(f'epoch {epoch + 1}, loss {float(train_l.mean())}')
print(f'w的估计误差: {true_w - w.reshape(true_w.shape)}')
print(f'b的估计误差: {true_b - b}')代码执行会报错:
1
RuntimeError: Trying to backward through the graph a second time, but the saved intermediate results have already been freed. Specify retain_graph=True when calling backward the first time.
因为每一次正向传播的过程中,在requires_grad=True的情况下会构建反向梯度计算图,在反向传播之后都会释放掉,因为模型过大时一直占用很容易爆内存,释放之后下一个循环就无法计算被释放部分的变量的梯度。