WXL's blog

Talk is cheap, show me your work.

0%

线性回归从零开始实现

本文代码取自李沐的《动手学习深度学习》,里面有些代码细节让我觉得非常值得斟酌,特记录。

生成、可视化数据集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from utils import d2l
import random

def synthetic_data(w, b, num_examples):
"""生成 y = Xw + b + 噪声。"""
# 为x添加均值为0,方差为1的正态噪声
X = torch.normal(0, 1, (num_examples, len(w)))
y = torch.matmul(X, w) + b
# 为y添加均值为0,方差为0.01的正态噪声
y += torch.normal(0, 0.01, y.shape)
return X, y.reshape((-1, 1))

# true_w和true_b都是真实的w、b,现在根据两参数
# 添加噪声来构建数据集
true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)
d2l.set_figsize()
d2l.plt.scatter(features[:, (1)].detach().numpy(), labels.detach().numpy(), 1)
d2l.plt.show()

数据集可视化

读取数据

使用迭代器生成数据集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def data_iter(batch_size, features, labels):
num_examples = features.shape[0]
indices = list(range(num_examples))
# 打乱样本序号
random.shuffle(indices)

for i in range(0, num_examples, batch_size):
# 从随机数表indices中抽取batch_size个数,
# 然后将这几个数传入features中
# 在保证抽取的数量为batch_size的前提下,达到随机挑选样本的目的
batch_indices = torch.tensor(
indices[i: min(i + batch_size, num_examples)]
)
yield features[batch_indices], labels[batch_indices]

这里有一个问题,即如果样本数量不能整除batch_size,就导致有一些样本始终取不到,为了防止这个问题发生,可以每一次取完之后重新打乱样本序号。

从中取出一个样本数据:

1
2
3
4
5
batch_size = 10

for X, Y in data_iter(batch_size, features, labels):
print(X, "\n", Y)
break

初始化模型参数

1
2
3
4
5
w = torch.normal(0, 0.01, size=(2, 1), requires_grad=True)
# w = torch.zeros((2, 1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)
print(w)
print(b)

定义模型/损失函数/优化算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def linreg(X, w, b):
return torch.mm(X, w) + b

def squared_loss(y_hat, y): #@save
"""均方损失。"""
return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

def sgd(params, lr, batch_size):
with torch.no_grad():
for param in params:
param -= lr * param.grad / batch_size
# 当前的梯度只是上一次epoch计算出来的梯度
# 更新权重之后需要消掉这次梯度,防止和下一次的梯度叠加
# 下一次的梯度只能从下一次的epoch中计算而来
param.grad.zero_()

这里的sgd函数有几个问题:

  1. 为什么这里需要加一个with torch.no_grad()?

    因为w和b的requires_grad是True,所有关于他们的运算都会自动构建计算图(用于累积梯度),这里不需要构建静态图、跟踪计算日志,因为我们需要的、和w,b有关的计算图应该仅仅是正向传播的计算图,如果这里不加限制,会自动创建关于权重更新运算的计算图。可以将存储梯度的内存节省下来,这样也可以让代码执行速度更快。

  2. 这里如何更新权重的,问答区有两个回复非常好,需要记录一下

    当某一变量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
    19
    import 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
    10
    9784896 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
    5
    a = [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
    6
    a = [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
    6
    a = [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
    7
    a = [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

    地址没有改变,这就印证了上面的说法。

  3. 这里更新权重,为什么需要除以batch_size

    因为所使用的squared_loss损失函数最终没有除以N,得到的并不是平均损失,用这个损失求得的梯度也不是平均梯度,而是所有(batch_size个)样本一并产生的损失所求得的梯度,而我们平常更新权重的时候,使用的是平均损失所计算而来的梯度(平均梯度),如果不除以batch_size可能导致下降的太快(设想一下param -= lr * param.grad / batch_sizeparam.grad变为原来的batch_size倍),导致进入局部极小值。

  4. 为什么需要使用grad.zero_()

    当前的梯度只是上一次epoch计算出来的梯度,更新权重之后需要消掉这次梯度,防止和下一次的梯度叠加,下一次的梯度只能从下一次的epoch中计算而来。

  5. 如果我们使用官方的损失函数来代替我们自己实现的损失函数,而且用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
    17
    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)
    # 这里和前面自己实现的不同
    # 这个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
    3
    epoch 1, loss 0.000103
    epoch 2, loss 0.000102
    epoch 3, loss 0.000101

    修改后:

    1
    2
    loss = nn.MSELoss(reduction='sum')
    trainer = torch.optim.SGD(net.parameters(), lr=0.03/batch_size)

    输出:

    1
    2
    3
    epoch 1, loss 0.102505
    epoch 2, loss 0.101427
    epoch 3, loss 0.101685

    发现取sum的损失值显著大于默认的mean损失值,为什么?

    mean意味着所有样本损失的平均值,即loss会除以样本数,sum没有除这个样本数,所以会放大1000倍(这里样本数为1000),可以试着将样本数改成10000,会发现两种方式确实损失值相差1w倍。

训练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
for X, y, in data_iter(batch_size, features, labels):
l = loss(net(X, w, b), y) # X和Y的小批量损失
# 因为l的形状是(batch_size, 1),而不是一个标量,'l'中所有元素被加到一起来计算梯度
l.sum().backward()
sgd([w, b], lr, batch_size) # 使用参数的梯度进行更新

# 在经过一个epoch更新之后,重新计算此时的预测损失值为多少
with torch.no_grad():
train_l = loss(net(features, w, b), labels)
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都会将所有的数据遍历一遍,但是每一次遍历不是一次性将所有的数据加载入内存,而是使用迭代器的方式多个batch加载。

加载完数据需要投入模型计算,计算估计值和真实值之间的损失函数,利用损失函数进行反向传播,从而计算需要优化的参数的梯度。

使用参数的梯度更新参数,更新完成之后计算此时预测的结果和真实值之间的损失值并输出。

反复执行上述步骤,直至退出循环。

有两个问题

  1. 为什么需要计算得到损失值,然后才对进行更新,而不是直接根据表达式更新,因为我觉得参数的梯度和损失值无关,而是求偏导之后和X有关。

    有这个疑问其实是对于pytorch的静态图没有理解,凡是有关于grad needed变量的运算,pytorch都会记录其操作符,并构建反向传播图,如果不计算loss,那么这一步构建反向传播图就无法进行,求偏导确实和损失值本身无关。

  2. 为什么每一次计算一个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
    25
    lr = 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
    7
    epoch 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
    25
    lr = 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的情况下会构建反向梯度计算图,在反向传播之后都会释放掉,因为模型过大时一直占用很容易爆内存,释放之后下一个循环就无法计算被释放部分的变量的梯度。

行行好,赏一杯咖啡吧~