基于PyTorch的线性回归的从零开始实现

前言

这篇文章用来记录本人在学习 《动手学深度学习》这本书时对章节( 线性回归的从零开始实现)的一些困惑、理解和解答。

任务

我们将根据带有噪声的线性模型构造一个人造数据集
我们的任务是使用这个有限样本的数据集恢复这个模型的参数

1、导入相应的库函数

import torch
import random
from d2l import torch as d2l

2、生成数据集

def synthetic_data(w, b, num_examples): 
    """生成y=Xw+b+噪声"""
    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))

true_w = torch.tensor([2, -3.4]) 
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)

# 打印features中的第一个二维数据样本及其对应labels中的一维标签值(一个标量)
print('features:',features[0],'\nlabel:',labels[0])

d2l.set_figsize()
d2l.plt.scatter(features[:,(1)].detach().numpy(),labels.detach().numpy(),1)

代码运行结果如下:

features:tensor([1.4632,0.5511])
label:tensor([5.2498])
在这里插入图片描述

代码解读

true_w :用于设置 真实的权重,是一个包含两个元素的一维张量(即向量)。
true_b:用于设置 真实的偏置,是一个标量值。
num_examples:用于设置 样本的数量,是一个标量值。

features:用来接受synthetic_data函数生成的1000个样本的特征数据,每一个样本包含了2个特征。是一个二维张量(即矩阵)。
labels:用来接受synthetic_data函数生成的1000个样本所对应的标签。是一个二维张量(即矩阵)。

X = torch.normal(0, 1, (num_examples, len(w))):生成一个形状为 (num_examples, len(w)) 的二维张量 X,其中的元素是从均值为 0,标准差为 1 的正态分布中采样得到的。
一个样本有多个特征,每一个特征要有一个权重与之对应。
这里 len(w) 的目的是确定 X 的列数,也就是特征的数量。因为在后面的代码中,我们需要用 X 和权重向量 w 进行矩阵乘法,所以 X 的列数必须和 w 的长度相同。这就是为什么我们需要 len(w) 的原因。

y = torch.matmul(X, w) + b:用于计算每个输入样本的预测值。
由于torch.matmul(X, w)是一个矩阵和一个向量进行矩阵-向量乘法运算,得到的结果是一个向量,再把这个向量加上偏置 b ,偏置b这个常数会加到这个向量的每一个元素上,这样得出的预测值y也是一个一维张量(即向量)。

y += torch.normal(0, 0.01, y.shape):给预测值y添加一些噪声,用于模拟实际应用中可能出现的观测误差或者模型误差。
这里的 += 是直接对 y 进行加法操作,所以生成的向量噪声的每一个元素会直接加到对应的向量 y 的每个元素上。
举一个简单的例子:

import torch
a = torch.tensor([0,1])
b = torch.tensor([2,3])
# 两个向量进行相加
a +=b
print(a)

代码运行结果为:

tensor([2, 4])

return X, y.reshape((-1, 1)):这里返回的X是一个矩阵,y.reshape((-1, 1))是对向量y进行重塑,得到一个单列矩阵。

一个小困惑(来自 百川大模型 的解答):
在这里插入图片描述

torch.normal函数

用途:用于生成正太分布(也称高斯分布)的随机数。

所需参数:torch.normal(均值, 标准差, 指定输出张量的形状)。

举一个简单的例子:
生成一个形状为 (3, 3) 的张量,其中的元素是从均值为 0,标准差为 1 的正态分布中随机抽取的,代码实现如下:

import torch
# 生成一个形状为 (3, 3) 的张量,其中的元素是从均值为 0,标准差为 1 的正态分布中随机抽取的
tensor = torch.normal(0, 1, (3, 3))
print(tensor)

由于生成的是随机数,所以每次运行上述代码时,得到的张量都会有所不同。
可能的输出结果:

tensor([[ 0.5983, -0.0891, -1.5944],
[ 0.4141, 0.5653, -0.3561],
[-0.1034, 0.2961, -0.8660]])

矩阵乘法

定义:设 A 为 m×p 的矩阵,B 为 p×n 的矩阵,那么称 m×n 的矩阵C为矩阵AB的乘积,记作 C = A × B

注意事项:
1、当矩阵A的列数(column)等于矩阵B的行数(row)时,A与B可以相乘。
2、矩阵C的行数等于矩阵A的行数,C的列数等于B的列数。
3、矩阵相乘不满足交换律,即 AB ≠ BA ,除非 A 和 B 是特殊的矩阵,如单位矩阵或伴随矩阵。

举一个简单的例子:
将一个 2×3 的矩阵与一个 3×2 的矩阵相乘,得到一个 2×2 的矩阵。
在这里插入图片描述

矩阵-向量乘法

一个矩阵可以与一个向量相乘(矩阵的列数必须等于向量的长度),这在数学上被称为矩阵-向量乘法。

例如,如果你有一个 3×2 的矩阵和一个长度为 2 的向量,那么你可以将它们相乘,结果将是一个长度为 3 的向量。但是,如果你有一个 3×2 的矩阵和一个长度为 3 的向量,那么你不能将它们相乘,因为向量的长度 3 不等于矩阵的列数 2 。

在这种乘法中,向量被视为列数为1的特殊矩阵。相乘的结果是一个新的向量(长度为输入矩阵的行数)。

在机器学习和深度学习中,这种乘法常常用于将 输入特征矩阵 与 模型的权重向量 相乘,从而得到模型的预测输出。

torch.matmul函数和torch.mv函数

torch.matmul函数:用于执行两个张量之间的矩阵乘法。
也就是说,torch.matmul函数 可以处理 两个矩阵 的乘法,也可以处理 一个矩阵和一个向量 的乘法,甚至可以处理 两个向量 的点积,并支持广播机制。

标量,也称零维张量
向量,也称一维张量
矩阵,也称二维张量

广播机制:允许不同形状的张量(多维数组)进行数学运算。

举一个简单的例子:

import torch

# 创建一个 2x2 的矩阵和一个长度为 2 的向量
matrix = torch.tensor([[1, 2], [3, 4]])
vector = torch.tensor([1, 2])

# 使用 torch.matmul() 函数进行矩阵乘法
result = torch.matmul(matrix, vector)

print(result)

若没有指定输出的形状,PyTorch 默认返回一个行向量。
运行代码,结果如下:

tensor([ 5, 11])

由于 torch.matmul() 函数会将向量 vector 广播成一个 2x1 的矩阵,然后进行矩阵乘法。矩阵的第一行和向量的乘积是 1*1 + 2*2 = 5,矩阵的第二行和向量的乘积是 3*1 + 4*2 = 11。计算结果如下:
在这里插入图片描述
torch.mv函数:仅适用于一个矩阵和一个向量的乘法,并且要求矩阵的列数必须与向量的长度相等。
torch.mv 函数的参数列表如下:

torch.mv(matrix, vector) → Tensor
matrix:第一个参数,是一个二维张量,扮演矩阵的角色。
vector:第二个参数,是一个一维张量,扮演向量的角色。
举一个简单的例子:

import torch

# 创建一个 2x2 的矩阵和一个长度为 2 的向量
matrix = torch.tensor([[1, 2], [3, 4]])
vector = torch.tensor([1, 2])

# 使用 torch.mv() 函数进行矩阵-向量乘法
result = torch.mv(matrix, vector)

print(result)

运行代码,结果如下:

tensor([ 5, 11])

无论是 torch.mv 还是 torch.matmul,当它们用于执行一个矩阵和一个向量的乘法时,默认返回的都是行向量。

torch.reshape函数

torch.reshape函数:在不改变张量元素数目的情况下改变张量的形状。
torch.reshape 函数的参数列表如下:

torch.reshape(input, shape) → Tensor
input:要重塑的张量,其元素数量和元素本身不会因重塑而改变。
shape:这是一个元组,指定了输出张量的新形状。元组的每个元素表示输出张量在相应维度上的大小。

举一个简单的例子:

import torch  # 导入PyTorch库

# 创建一个2x2的矩阵a
a = torch.tensor([[0,1],[2,3]])
# 将矩阵a重塑为一维张量X
# -1 代表一个未知的尺寸,让库自动计算这个维度的大小,以便在不改变总元素数量的前提下,将数据重塑成所需的形状。
X = torch.reshape(a, (-1,))
# 打印一维张量X
print(X)

# 创建一个包含4个连续浮点数的张量b
b = torch.arange(4.)
# 将张量b重塑为2x2的矩阵Y
Y = torch.reshape(b, (2,2))
# 打印2x2的矩阵Y
print(Y)

# 创建一个2x2的矩阵c
c = torch.tensor([[1,2],[3,4]])
# 将矩阵c重塑为一列的张量Z
# -1 表示自动计算维度的大小,以便在不改变总元素数量的前提下,将数据重塑成所需的形状。
# 1 表示重塑为一个只有一列的矩阵
Z = torch.reshape(c, (-1,1))
# 打印一列的张量Z
print(Z)

运行代码,结果如下:
在这里插入图片描述

3、读取数据集

def data_iter(batch_size, features,labels):
    num_examples= len(features)
    indices= list(range(num_examples))
    #这些样本是随机读取的,没有特定的顺序
    random.shuffle(indices)
    for i in range(0,num_examples,batch_size):
        batch_indices= torch.tensor(
            indices[i:min(i+ batch_size,num_examples)])
        yield features[batch_indices],labels[batch_indices]

batch_size = 10
for X, y in data_iter(batch_size, features, labels):
    print(X, '\n', y.reshape((-1,1)))
    break

代码解读

data_iter函数:该函数接收批量大小、特征矩阵和标签向量作为输入,生成大小为batch_size的小批量。每个小批量包含一组特征和标签。

num_examples样本的数量

indices:创建了一个列表,包含了从 0 到num_examples - 1 的所有整数。这些整数代表了特征矩阵和标签向量中 每个样本的索引

random.shuffle(indices)对索引列表indices进行了随机打乱,这一步是为了在每次迭代时都能随机选择一批样本,而不是按照固定的顺序来选择。

引例:

import torch
import random

num_examples = 10 # 设样本数量为10

# 创建一个列表indices,列表中的元素包含 0~ num_examples - 1
indices = list(range(num_examples))

print(indices) # 打印列表indices中的元素
print(indices[0:5]) # 打印列表indices中的前5个元素

# 将列表indices中的元素进行随机打乱
random.shuffle(indices)
print(indices) # 打印打乱后的列表indices中的元素
print(indices[0:5]) # 打印打乱后的列表indices中的前5个元素

# 将打乱后的列表indices中的前5个元素 转换为 一个张量(向量)
batch_indices = torch.tensor(indices[0:5])
print("\n","batch_indices:",end="\n")
print(batch_indices)

# 生成一个形状为 (10, 2) 的张量(矩阵),其中的元素是从均值为 0,标准差为 1 的正态分布中随机抽取的
features = torch.normal(0,1,(10,2))
print("\n","features:",end="\n")
print(features)
# 打印出特征矩阵features中对应索引batch_indices的元素
print("\n","features[batch_indices]:",end="\n")
print(features[batch_indices])

代码运行结果如下:

在这里插入图片描述

通过上面的引例,我们对数据样本的批量采样有了直观的认识,下面来解读以下代码:

 for i in range(0,num_examples,batch_size):
        batch_indices= torch.tensor(
            indices[i:min(i+ batch_size,num_examples)])
        yield features[batch_indices],labels[batch_indices]

假设我们有一个包含100个样本的数据集,每个样本有2个特征,我们想要每次处理10个样本。那么,num_examples=100,batch_size=10,features为100行、每行有2个特征元素的特征矩阵。


首先,我们创建一个包含0到99的整数列表indices,然后使用random.shuffle(indices)将indices中的元素随机打乱,以确保每次迭代时的样本顺序都是随机的。


接下来,我们进入for循环。
第一次迭代时,i 的值为0,所以我们从indices中选择从索引0开始的10个元素(即从索引0到9取相应的元素),然后将它们转换为一个PyTorch张量batch_indices


接着,我们使用batch_indices从features和labels中选择对应的样本,并将它们通过yield关键字返回


在第二次迭代时,i 的值为10,所以我们从indices中选择从索引10开始的10个元素(即从索引10到19取相应的元素),然后将它们转换为一个PyTorch张量batch_indices。接着,我们使用batch_indices从features和labels中选择对应的样本,并将它们通过yield关键字返回。


这个过程会一直重复,直到我们处理完所有的样本。


总结一下,这段代码的作用是以小批量的方式迭代整个数据集,每次迭代返回一个小批量的数据和对应的标签。

random.shuffle()函数

random.shuffle()用于将一个列表中的元素打乱顺序,值得注意的是使用这个方法不会生成新的列表,只是将原列表的次序打乱。

举一个简单的例子:

import random

# 创建一个列表
my_list = [1, 2, 3, 4, 5]

# 调用 shuffle 方法来打乱列表中的元素顺序
random.shuffle(my_list)

# 打印打乱后的列表
print(my_list)
# 取出打乱后的列表的第一个元素
print(my_list[0])

在这个例子中,my_list 中的元素会被随机打乱,每次运行这段代码都可能得到不同的结果。一个可能的运行结果如下:

[3, 2, 4, 1, 5]
3

range()函数

range()函数:用于生成一个整数序列。

range()的三种创建方式:
1、只有一个参数(小括号中只给了一个数),即 range(stop) 。
例如:range(10)指的是默认从0开始,步长为1,不包括10。

2、有两个参数(小括号中给了两个数),即range(start, stop)。
其中,start表示这一系列数字中的第一个数字;stop-1表示这一系列数字中的最后一个数字。需要注意的是,产生的数字中不包括stop。

3、range(start, stop, step):创建一个在[start, stop)之间,步长为step。

举一个简单的例子:

# range(stop)
a = range(10)
print(a)

# range()函数产生的这一系列的数字并不是以列表(list)类型存在的,
# 这样做的目的是为了节省代码所占空间。
# 将range()产生的数字转换为列表
a_list = list(a)
print(a_list)

# range(start, stop)
b = range(1, 10)
b_list = list(b)
print(b_list)

# range(start, stop, step)
c = range(1, 10, 2)
c_list = list(c)
print(c_list)

代码运行结果:

range(0, 10)
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[1, 2, 3, 4, 5, 6, 7, 8, 9]
[1, 3, 5, 7, 9]

Python列表切片

具体详细内容参考以下这篇博客:

Python中的切片(Slice)操作详解

切片的索引方式
Python可切片对象的索引方式包括:正索引和负索引。
如下图所示,以a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]为例:
    ------>从左向右         从右向左<------

正索引0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 ,9
负索引-10, -9,-8,-7,-6,-5,-4,-3,-2,-1
(起点)0 , 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 ,9(终点)

列表切片
Python中符合序列的有序序列都支持切片(slice),例如列表,字符串,元组。
切片操作基本表达式:

object[start_index : end_index : step]

切片表达式包含两个":" ,用于分隔三个参数(start_index、end_index、step),当只有一个":"时,默认第三个参数step=1。

list[n:m] 切片是不包含后面那个元素的值(顾头不顾尾)

list[:m] 如果切片前面一个值缺省的话,从开头开始取

list[n:] 如果切片后面的值缺省的话,取到末尾

list[:] 如果全部缺省,取全部

list[n:m:s]
s:步长,表示隔多少个元素取一次

步长是正数,从左往右取

步长是负数,从右往左取

tips:切取方向非常重要~

举一个简单的例子:

# 定义一个列表
name = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']

# 切片不包含后面那个元素
print("切片 name[1:4]:", name[1:4])

# 从开头开始取
print("切片 name[:4]:", name[:4])

# 取到末尾
print("切片 name[3:]:", name[3:])

# 取全部
print("切片 name[:]:", name[:])

# 步长为2,从左往右取
print("切片 name[::2]:", name[::2])

# 步长为-2,从右往左取
print("切片 name[::-2]:", name[::-2])

代码运行结果如下:

切片 name[1:4]: [‘b’, ‘c’, ‘d’]
切片 name[:4]: [‘a’, ‘b’, ‘c’, ‘d’]
切片 name[3:]: [‘d’, ‘e’, ‘f’, ‘g’, ‘h’, ‘i’, ‘j’]
切片 name[:]: [‘a’, ‘b’, ‘c’, ‘d’, ‘e’, ‘f’, ‘g’, ‘h’, ‘i’, ‘j’]
切片 name[::2]: [‘a’, ‘c’, ‘e’, ‘g’, ‘i’]
切片 name[::-2]: [‘j’, ‘h’, ‘f’, ‘d’, ‘b’]

yield关键字

yield 关键字在 Python 中用于定义一个生成器(generator)。生成器是一种特殊的迭代器,它可以在需要的时候生成值,而不是一次性生成所有的值并保存在内存中。

当你在一个函数中使用 yield 关键字时,这个函数就会变成一个生成器。这个生成器每次被调用时,都会从上次离开的地方继续执行,直到遇到下一个 yield 语句或者函数结束。

举一个简单的例子:

def count_up_to(n):
    count = 1
    while count <= n:
        yield count
        count += 1

for number in count_up_to(5):
    print(number)

代码运行结果如下:

1
2
3
4
5

在这个例子中,count_up_to 函数是一个生成器,它会生成从1到n的整数。当我们使用 for 循环来迭代这个生成器时,每次迭代都会调用 count_up_to 函数,直到它遇到一个 yield 语句。然后,yield 语句后面的值会被返回给 for 循环,并且 count_up_to 函数的状态会被保存下来,以便下次调用时可以从上次离开的地方继续执行。

生成器非常适合用于处理大数据集或者无限序列,因为它们可以按需生成值,而不需要一次性将所有值保存在内存中。

4、初始化模型参数

w = torch.normal(0, 0.01, size=(2,1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)

代码解读

通过从均值为0、标准差为0.01的正态分布中采样随机数来初始化权重,并将偏置初始化为0。

.grad属性

在 PyTorch 中,.grad 属性用于获取或设置张量的梯度。当我们对一个张量进行反向传播时,PyTorch 会自动计算这个张量的梯度,并将梯度存储在 .grad 属性中。

举一个简单的例子:
假设我们有一个张量 x,我们想要计算函数 f(x) 关于 x 的梯度。我们可以这样做:

import torch
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
f = x ** 2
f.backward()
print(x.grad)

运行以上代码会报错,报错信息为:

RuntimeError: grad can be implicitly created only for scalar outputs

这个错误是因为 backward() 函数默认情况下只能为标量输出计算梯度。在上面的代码中,f 是一个向量,而不是一个标量,所以你不能直接调用 f.backward()。

如果我们想为一个非标量输出计算梯度,需要提供一个 grad_tensors 参数,这是一个包含了对应于 f 中每个元素的梯度的张量。在这个例子中,如果我们想计算 f 中每个元素的梯度,可以这样做:

import torch

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
f = x ** 2

gradients = torch.ones_like(f)  # 创建一个与 f 形状相同的全 1 张量
print(gradients)

f.backward(gradients)  # 为 f 的每个元素提供梯度
print(x.grad)  # f 相对于 x 的梯度

代码运行结果如下:

tensor([1., 1., 1.])
tensor([2., 4., 6.])

在这个例子中,gradients 是一个全 1 的张量,表示我们希望计算 f 中每个元素的梯度。然后我们调用 f.backward(gradients),这会为 f 的每个元素计算梯度,并将结果存储在 x.grad 中。

需要注意的是,.grad 属性只在 requires_grad=True 的张量上可用。如果一个张量的 requires_grad 属性为 False,那么这个张量就不会计算梯度,也就没有 .grad 属性。

如果只对 f 中的某些元素计算梯度,我们可以提供一个相应的梯度张量,其中只有元素为 1的位置表示与 f 对应位置的元素需要计算梯度,其他位置的元素是 0。
例如,如果我们只想计算 f 中第一个元素的梯度,你可以提供一个形状相同的梯度张量,其中只有第一个元素是 1,其他元素是 0。
举一个简单的例子:

import torch

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
f = x ** 2

# 创建一个与 f 形状相同的梯度张量,其中只有第一个元素是 1,其他元素是 0
gradients = torch.tensor([1.0, 0.0, 0.0])

f.backward(gradients)  # 为 f 的第一个元素提供梯度

print(x.grad)  # 这将是 f 相对于 x 的梯度

代码运行结果如下:

tensor([2., 0., 0.])

在这个例子中,gradients 是一个只有一个元素是 1 的张量,表示我们只想计算 f 中第一个元素的梯度。然后我们调用 f.backward(gradients),这会为 f 的第一个元素计算梯度,并将结果存储在 x.grad 中。

requires_grad=True

在深度学习中,我们经常需要对模型的参数进行优化,使得模型在某种度量下性能更好。这种优化通常是通过梯度下降等优化算法来实现的,这就需要计算模型参数的梯度。


在PyTorch中,requires_grad=True表示我们需要跟踪该张量的所有操作,以便之后能够自动计算其梯度。 这对于训练神经网络非常重要,因为在训练过程中,我们需要通过反向传播(backpropagation)来计算每个参数的梯度,然后根据这些梯度来更新参数。

例如,假设我们有一个简单的线性模型,其权重和偏置分别为w和b,我们希望通过最小化预测值和真实值之间的平方误差来训练这个模型。
在这个过程中,我们需要计算损失函数关于w和b的梯度,然后根据这些梯度来更新w和b。


如果我们设置了requires_grad=True,那么PyTorch会自动为我们计算这些梯度。
具体来说,当我们对某个张量进行了操作(比如加法、乘法等),PyTorch会记录下这个操作,然后在反向传播的过程中,会根据链式法则来自动计算这个操作的梯度。

值得注意的是,在PyTorch中,当一个张量被创建时,它的requires_grad属性默认是False。

举一个简单的例子:

import torch

x = torch.ones(2, 2)
print(x.requires_grad)  # 输出: False

y = torch.ones(2, 2, requires_grad=True)
print(y.requires_grad)  # 输出: True

叶节点和非叶节点

在 PyTorch 中,叶节点(leaf node)是指那些被标记为 requires_grad=True 的张量,它们是最基础的张量,不会通过其他张量的运算得到。而非叶节点(non-leaf node)则是指通过叶节点的运算得到的张量。

具体来说,如果你在创建一个张量时设置了 requires_grad=True,那么这个张量就是一个叶节点。例如:

x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)

在这个例子中,x 就是一个叶节点。

另一方面,如果你通过一些运算得到了一个新的张量,那么这个新的张量就是一个非叶节点。例如:

y = x ** 2

在这个例子中,y 就是一个非叶节点,因为它是通过 x 的运算得到的。

可以通过 is_leaf 属性来判断一个张量是否是叶节点:

print(x.is_leaf)  # 输出 True
print(y.is_leaf)  # 输出 False

此外,你还可以通过 grad_fn 属性来查看一个张量是通过什么运算得到的:

print(x.grad_fn)  # 输出 None,表示 x 是一个叶节点
print(y.grad_fn)  # 输出 <PowBackward0 object at 0x0000019D2B751EB8>,表示 y 是一个非叶节点,通过 Pow 运算得到

.backward()自动计算梯度过程

引例

import torch

x = torch.ones(2, 2, requires_grad=True)
print(x)

y = x + 2
print(y)

z = y * y * 3
print(z)

out = z.mean()
print(out)

out.backward()

print(x.grad)

代码运行结果:

在这里插入图片描述

在上面这段代码中,首先创建了一个值为 1 且大小为 2x2 的张量 x,并且开启了梯度追踪。然后我们对 x 进行了加法 y = x + 2 和乘法 z = y * y * 3 操作,并最终计算了结果的均值 out = z.mean()。当我们对最终的输出 out 调用 .backward() 后,PyTorch会自动计算出 out 相对于 x 的梯度,并将其保存在 x.grad 属性中。

值得注意的是,只有当设置了requires_grad=True的张量参与运算时,PyTorch才会追踪这些操作以计算梯度
此外,梯度计算仅针对那些设置了requires_grad=True的张量。

一个小问题:
如果我们在 引例代码 的末尾添加以下代码即
print(y.grad) # None
print(z.grad) # None
print(out.grad) # None
打印输出的结果都是None,并且编译器会发出警告,那为什么 print(x.grad) 不会呢?

在 PyTorch 中,backward() 函数只会为参与计算的叶节点的 .grad 属性赋值。如果一个变量没有标记为 requires_grad=True,或者它是一个非叶节点且没有调用 retain_grad(),那么它的梯度就不会被计算和存储。
这是因为 PyTorch 的设计理念是尽可能地减少不必要的计算和内存消耗。如果一个变量不参与最终的损失计算,那么就没有必要计算和存储它的梯度。此外,如果所有中间变量的梯度都被保存下来,那么可能会导致内存溢出,特别是在处理大规模数据或复杂模型时。


如果我们想要在反向传播时计算 y 、z、out 的梯度,我们需要显式地告诉 PyTorch 我们希望保留 y 、z、out 的梯度,这可以通过调用 y.retain_grad() 来实现。例如将 引例代码修改为下面的代码:

import torch

x = torch.ones(2, 2, requires_grad=True)

y = x + 2
# 确保 y 参与到计算图中
y.retain_grad()

z = y * y * 3
z.retain_grad()

out = z.mean()
out.retain_grad()

out.backward()

print(x.grad)
print(y.grad)
print(z.grad)
print(out.grad)

运行代码的结果如下:

在这里插入图片描述

在 PyTorch 中,反向传播是根据计算图进行的。计算图是由一系列的操作和张量组成的,这些操作和张量定义了前向传播的过程。在前向传播过程中,每一个操作都会记录其输入和输出张量,以便在反向传播时能够根据链式法则计算梯度。我们通过对 修改后的引例代码 详细说明这一过程。

代码执行过程

1、创建了一个张量x,并设置requires_grad=True,表示我们希望追踪对其的所有操作。

2、对 x 进行加法操作得到 y,再对 y 进行乘法操作得到 z。每一步操作都会被记录下来,形成所谓的“计算图”(computation graph)。

3、计算 z 的均值得到 out。此时,我们已经完成了前向传播(forward pass),得到了最终的输出。

4、接下来,我们调用 out.backward(),这会触发反向传播(backward pass)。在这个过程中,PyTorch会根据计算图自动应用链式法则,从 out 开始,依次计算每个操作输出的梯度。

具体而言
在这里插入图片描述

5、定义模型

def linreg(X, w, b): 
	"""线性回归模型"""
	return torch.matmul(X, w) + b

代码解读

定义模型,将模型的输入和参数同模型的输出关联起来。

要计算线性模型的输出,我们只需计算输入特征X和模型权重w的矩阵‐向量乘法后加上偏置b。

注意,上面的 Xw 是一个向量,而 b 是一个标量。

利用广播机制:当我们用一个向量加一个标量时,标量会被加到向量的每个分量上。

6、定义损失函数

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

代码解读

使用平方损失函数,在实现中,我们需要将真实值y的形状转换为和预测值y_hat的形状相同。

7、定义优化算法

def sgd(params, lr, batch_size):
    """小批量随机梯度下降"""
    with torch.no_grad():
        for param in params:
            param -= lr * param.grad / batch_size
            param.grad.zero_()

代码解读

params:这是一个参数列表,通常包含了模型的所有可训练参数。在这个上下文中,params 是一个包含权重 w 和偏置 b 的张量列表。

lr:学习率。学习率决定了每次参数更新的步长。

batch_size:代表每个小批量的样本数量。

with torch.no_grad():这是一个上下文管理器,用于关闭梯度计算,在这个块内的所有操作都不会记录梯度.

param.grad.zero_() 这行代码将当前参数的梯度清零。这是因为在下一次前向传播和反向传播时,我们需要重新计算梯度,所以需要先将之前的梯度信息清空。

一些小问题:
这段代码为什么要使用 with torch.no_grad(): 来关闭梯度计算?

在深度学习中,我们通常会在训练阶段计算并存储梯度,以便在反向传播过程中使用。然而,在某些情况下,我们可能不希望或不需要计算梯度。例如,在模型评估阶段,我们通常只需要得到模型的预测结果,而不需要计算梯度。此外,在更新模型参数时,我们也希望防止更新操作影响到梯度的计算。


在上述代码中,with torch.no_grad():用于关闭梯度计算,这是为了防止在更新模型参数时影响到梯度的计算。具体来说,当我们调用 param -= lr * param.grad / batch_size 来更新模型参数时,我们不希望这个更新操作被记录为梯度的一部分。因此,我们需要先关闭梯度计算,然后再进行更新操作。


如果不使用 with torch.no_grad():,那么在更新模型参数后,我们再调用 .backward() 方法来计算梯度时,更新操作也会被考虑进去,这显然是不正确的。因此,我们需要在更新模型参数之前,先关闭梯度计算。

为什么调用 param -= lr * param.grad / batch_size 来更新模型参数时,我们不希望这个更新操作被记录为梯度的一部分?

在深度学习中,我们使用梯度来指导模型参数的更新。在前向传播过程中,我们会计算模型的损失;在反向传播过程中,我们会计算损失关于模型参数的梯度。然后,我们会根据这些梯度来更新模型参数,以减小模型的损失。

当我们调用 param -= lr * param.grad / batch_size 来更新模型参数时,我们已经知道了应该如何更新参数才能减小模型的损失。因此,我们不需要再计算这个更新操作的梯度。如果我们将这个更新操作也记录为梯度的一部分,那么在后续的反向传播过程中,我们会再次计算这个更新操作的梯度,这显然是没有必要的,也是不正确的。

此外,如果我们不关闭梯度计算,那么在更新模型参数后,我们再调用 .backward() 方法来计算梯度时,更新操作也会被考虑进去,这会导致梯度计算错误。因此,我们需要在更新模型参数之前,先关闭梯度计算。

8、训练

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中的所有元素被加到一起,
        # 并以此计算关于[w,b]的梯度
        l.sum().backward()
        sgd([w, b], lr, batch_size) # 使用参数的梯度更新参数
    with torch.no_grad():
        train_l = loss(net(features, w, b), labels)
        print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')

print(f'w的估计误差: {true_w- w.reshape(true_w.shape)}')
print(f'b的估计误差: {true_b- b}'

代码解读

1、定义一些超参数,包括学习率 lr、训练轮数 num_epochs、线性回归模型 net 和损失函数 loss。

2、对于第一个for循环,表示对每一轮训练进行处理,总共训练num_epochs轮。
对于第二个for循环,表示在每一轮训练中,我们遍历数据集,每次处理一个小批量的数据。

3、进入第二个for循环后的具体操作如下:

对于每个小批量的数据,


首先,计算模型的预测结果和实际标签之间的损失 l。
这里需要注意的是,l 的形状是 (batch_size,1),不是一个标量,所以我们需要使用 l.sum() 将其转换为一个标量,然后调用 .backward() 方法计算梯度。


接着,使用随机梯度下降算法 sgd 更新模型的参数 w 和 b。


在每轮训练结束后,使用 torch.no_grad() 上下文管理器关闭梯度计算,然后计算整个数据集上的训练损失,并打印出来。

4、最后,我们打印出模型参数 w 和 b 的估计误差,即它们与真实参数 true_w 和 true_b 的差。

一个小问题:
为什么在每轮训练结束后,使用 torch.no_grad() 上下文管理器关闭梯度计算,然后计算整个数据集上的训练损失?(即解释一下这段代码存在的意义)

    with torch.no_grad():
        train_l = loss(net(features, w, b), labels)
        print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')

在这段代码中,我们在每轮训练结束后,使用 with torch.no_grad():来计算整个数据集上的训练损失。这样做的原因是,此时我们已经完成了这一轮的参数更新,不需要再进行反向传播计算梯度,只需要评估模型在这一轮训练后的性能即可。

另外,由于 loss 函数返回的是一个包含每个样本损失的Tensor,我们需要计算这些损失的平均值来得到整体的训练损失。这就是为什么我们要使用 train_l.mean()。


最后,我们使用 print 语句打印出当前的轮次(epoch)和对应的训练损失,以便能够跟踪模型的训练进度和性能。

对 l.sum().backward() 的理解

引例:

import torch
# 假设我们有一个形状为 (3, 1) 的二维张量 x
x = torch.tensor([[1.],
				  [2.],
				  [3.]],requires_grad=True)
				  
# 假设我们有一个形状为 (3, 1) 的二维张量 a
a = torch.tensor([[1.],
				  [2.],
				  [3.]],requires_grad=True)

# 我们先将 x 中的所有元素相加,得到一个标量
y = x.sum()
print(y)

# 我们先将 a 中的所有元素相加并求平均,得到一个标量
b = a.sum() / 3
print(b)

# 然后我们对标量y调用 .backward() 方法,计算出 x 的梯度
y.backward()

# 然后我们对标量b调用 .backward() 方法,计算出 a 的梯度
b.backward()

print(x.grad)
print(a.grad)

代码运行结果如下:

在这里插入图片描述

在 引例代码 中,x 是一个是一个形状为 (3, 1) 的二维张量,其中每一行代表一个样本的损失。a 同理。


当我们计算 x.sum() 时,我们实际上是在计算所有样本损失的总和。然后,当我们对这个总和调用 .backward()
方法时,我们得到的是所有样本损失的梯度的总和。


另一方面,当我们计算 a.sum() / 3 时,我们实际上是在计算所有样本损失的平均值。然后,当我们对这个平均值调用 .backward() 方法时,我们得到的是所有样本损失的梯度的平均值。


这两种方法的主要区别在于,第一种方法计算的是总梯度,而第二种方法计算的是平均梯度。在实际应用中,我们通常会选择第二种方法,因为我们更关心的是每个样本损失的梯度的平均值,而不是总和。

这就解释了为什么在 sgd函数 中,param -= lr * param.grad / batch_size 这行代码要进行 / batch_size 操作,这是因为在小批量随机梯度下降中,我们希望能够分别计算一个小批量中每个样本的梯度(这个梯度为一个小批量中所有样本损失梯度的总和的平均值,而不是一个小批量中所有样本损失的梯度的总和),具体来说,如果我们不除以批量大小,那么当批量大小变大时,我们的步长也会变大,这可能会导致我们的参数更新过于剧烈,从而无法收敛到最优解。相反,如果我们除以批量大小,那么无论批量大小是多少,我们的步长都会保持相对稳定,这样可以更好地控制我们的参数更新。

在 batch_size = 10 时,经过3轮训练(epoch = 3)后,代码运行的可能结果如下:
在这里插入图片描述

在 batch_size = 100 时,经过3轮训练(epoch = 3)后,代码运行的可能结果如下:
在这里插入图片描述

总的来说,/ batch_size 这个操作是为了让梯度更新的步长不受批量大小的影响,使得模型能够在不同的批量大小下都能稳定且有效地训练。

写在最后

相信很多人和我一样,在刚开始学 线性回归的从零开始实现 这一章节的时候,

对一些代码涉及到的库函数用法和对应的知识点不够了解,同时对一些代码上的使用有所不解,比如
反向传播的过程是怎么样的;
在某个地方为什么要使用 torch.no_grad() 上下文管理器关闭梯度计算;
为什么是 l.sum().backward() 而不是 l.backward();
为什么在 sgd函数 中,param -= lr * param.grad / batch_size 这行代码要进行 / batch_size 操作,不进行 / batch_size 操作对训练效果的影响会怎么样等等。

再比如对代码中涉及的一些参数的类型感到困惑,如
权重w是一个一维张量(向量)还是一个二维张量(矩阵);
特征矩阵X 和 向量权重w 相乘后的 Xw 是一个向量还是矩阵;
标签labels 是一个矩阵还是向量,
为什么在 synthetic_data函数 中最终返回的是 y.reshape((-1, 1)) 而不是 y 等等。

在观看了李沐在b站上关于这章节的视频和评论,及对本书的一些 Discussions 后,同时结合本人的一些困惑,通过查阅大量的博客及结合 百川大模型 的解答,前前后后花了三天的时间来学习,最终写下了这篇博客,希望对正在阅读的你有所帮助。

  • 9
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值