PyTorch 自动微分入门:从 backward、detach 到线性回归完整训练(附原代码)
前面学习了张量的创建、运算和形状操作,接下来就要回答一个问题:模型里的权重和偏置,究竟是怎样一步一步学出来的?
这篇笔记把 Day02 的第 08、09 个文件和 Day03 的第 01~03 个文件作为同一个学习模块,沿着“求导 → 更新参数 → 分离梯度记录 → 计算多参数梯度 → 训练线性回归”的顺序展开。
| 1 | day02/08_自动微分模块入门.py | 如何求梯度,并手动更新一次权重? |
| 2 | day02/09_自动微分模块案例.py | 怎样反复更新,让损失逐步减小? |
| 3 | day03/01_自动微分_detach()函数介绍.py | 为什么带梯度的张量不能直接转 NumPy? |
| 4 | day03/02_自动微分真实应用场景.py | 怎样从预测误差求出权重、偏置的梯度? |
| 5 | day03/03_PyTorch框架_模拟线性回归.py | 怎样用数据集、模型和优化器完成训练? |
先记住这条训练流程,后面每个文件都是在补全其中的一部分:
输入数据 → 模型前向计算 → 计算损失 → 清除旧梯度 → 反向传播求梯度 → 更新参数
↑ │
└──────────────── 下一次训练 ──────────────────┘
前面的讲解代码均摘自对应原文件,片段之间可能依赖前面定义的变量;从函数体摘出的语句只去掉外层缩进。每节末尾再放该文件的完整原代码。学习记录中的注释与实际执行不一致时,会在正文中说明。
本次运行核对环境:Python 3.11.9、PyTorch 2.14.1+cu130、NumPy 2.4.6、scikit-learn 1.9.1、Matplotlib 3.11.2,代码使用 CPU。涉及随机初始化的结果以一次运行举例,具体数值可能变化。
一、自动微分入门:计算一次梯度并更新参数
对应文件:day02/08_自动微分模块入门.py。
1.1 损失、梯度和学习率分别是什么
可以先把训练理解为:找到一组合适的参数,让损失函数的值尽量小。
| 权重 w | 需要调整的数,初始值为 10 |
| 损失 loss | 衡量当前参数表现的函数,这里人为定义为 2*w**2 |
| 梯度 | 损失对参数的导数,表示损失随参数变化的方向和局部变化快慢 |
| 学习率 | 每次沿梯度反方向更新的步长系数,这里为 0.01 |
这一节还没有真实样本和预测值,而是先用一个简单函数演示训练中“求导与更新”的核心过程。
1.2 requires_grad=True:记录需要求导的运算
原代码先创建一个浮点张量,再用它计算损失:
import torch
w=torch.tensor(10,requires_grad=True,dtype=torch.float32)
loss=2*w**2 #loss求导后为4w
requires_grad=True 表示需要跟踪与 w 有关的运算,以便后面计算梯度。这里显式使用浮点类型,适合求导。
对于 loss=2*w**2,导数为 4*w。初始 w=10,所以损失是 200,梯度是 40。
PyTorch 自动微分会记录相关运算组成的计算图,再按链式法则求导。因此,它也能处理这里自定义的算式,不限于现成的损失函数对象。
1.3 backward():求梯度,结果放进 grad
原文件用下面两行求出梯度并打印:
loss.backward()
print(f'w的梯度:{w.grad}')
loss.backward() 沿着计算图反向计算导数,把这里需要的梯度记录到 w.grad 中。
这一步结束后,w.grad 是 40,w 仍是 10。反向传播负责求梯度,更新参数还需要下一步。
原文件还打印了 loss.grad_fn,可以用来观察损失与求导运算的关联。输出中类似 0x… 的地址随运行变化,不需要记忆。
本例 loss 只有一个元素,可以直接调用 .backward();对于多元素输出,通常需要先明确如何汇总,或者传入匹配的梯度参数。本文件中的 loss.sum() 只是对单个数求和。
1.4 按公式更新一次权重
更新公式是:新权重 = 旧权重 – 学习率 × 梯度。
w.data=w.data–0.01*w.grad
print(f'更新后的权重w:{w}')
代入本例:10 – 0.01×40 = 9.6。原文件的关键输出如下,省略了带对象地址的一行:
tensor(200., grad_fn=<SumBackward0>)
w的梯度:40.0
更新后的权重w:9.600000381469727
输出中的 9.600000381469727 是浮点数表示带来的精度差异,可以按约 9.6 理解。
原文件使用 .data 完成手动更新,它会绕过梯度记录。这里保留原写法帮助对照公式;继续写训练代码时,通常使用优化器更新,手动更新则可以在 torch.no_grad() 上下文中进行。第五节会用到 optimizer.step()。
参考:Tensor.backward。
1.5 本节完整原代码
对应文件:day02/08_自动微分模块入门.py。以下完整保留原文件的语句、注释、空行和执行顺序。
"""
案例:
演示自动微分,具体如何求导
回顾:
权重更新公式:
w新=w旧-学习率*梯度
梯度:损失函数的导数
关于损失函数的导数我们无需进行计算,因为非常常用,所有pytorch模块内置里有自动微分模块,专门针对于不同的损失函数求导
从而实现 结合 反向传输 更新 权重参数w和偏执参数b
"""
#导包
import torch
#1.定义变量,记录:出事的权重w(旧)
#参1:初始值,参2:是否自动微分,参3:数据类型
w=torch.tensor(10,requires_grad=True,dtype=torch.float32)
#2.定义loss变量,表示损失函数
loss=2*w**2 #loss求导后为4w
#3.打印梯度函数类型(用于了解)
print(f'loss的梯度函数类型:{loss.grad_fn}')
print(loss.sum())
#4.计算梯度,梯度=损失函数对权重的导数,计算完毕之后会记录到w.grad属性中
loss.backward()
print(f'w的梯度:{w.grad}')
#5.带入权重更新公式,更新权重w w新=w旧-学习率*梯度
w.data=w.data–0.01*w.grad
#6.打印更新后的权重w
print(f'更新后的权重w:{w}')
二、自动微分循环案例:用梯度下降寻找最小值
对应文件:day02/09_自动微分模块案例.py。
2.1 从一次更新到反复更新
上一节只更新一次,这个文件把相同的思路放进循环,尝试减小函数 loss=w**2+20 的值。
import torch
w=torch.tensor(10,requires_grad=True,dtype=torch.float32)
loss=w**2+20
从数学上看,w**2 的最小值为 0,因此这个函数在 w=0 时取得最小值 20。这里要找的是使损失最小的 w 的位置,不要把参数值、梯度和损失值混为一谈。
初始 w=10,损失为 10²+20=120。它的导数为 2*w,所以第一次更新后,w=10-0.01×20=9.8。
2.2 每轮执行四个关键动作
下面是原文件的整个循环:
for i in range(1,101):
# 3.1 正向计算部分
loss=w**2+20
# 3.2 梯度清零 w.grad.zero_(),因为默认情况下,梯度是累加的,所以需要清零
if w.grad is not None:
w.grad.zero_()
# 3.3 反向传播
loss.sum().backward()
# 3.4 梯度更新 w.data=w.data-学习率*w.grad
w.data=w.data–0.01*w.grad
#3.5打印本次梯度更新后的权重参数结果
print(f'第{i}次梯度更新后的权重参数结果:{w},(0.01*w.grad):{0.01*w.grad:.5f},loss={loss:.5f}')
按执行顺序看:
原注释多次写了“1000 次”,但实际代码是 range(1,101),所以运行的是 100 次更新,循环变量为 1~100。
2.3 为什么每轮要把梯度清零
PyTorch 默认会把新算出的梯度累加到已有 .grad 中。普通训练通常希望每轮使用当前这一轮的梯度,所以需要清零。
第一次反向传播前,w.grad 还没有被计算,值是 None。原文件先判断 if w.grad is not None,再调用 .zero_(),就避免了对 None 执行清零。
还要区分“梯度清零”和“参数归零”:这里清除的是 w.grad,并没有把 w 改成 0。
2.4 怎样读懂循环输出
原文件运行输出节选如下:
开始权重初始值:10.0,(0.01*w.grad):,loss=120.0
第1次梯度更新后的权重参数结果:9.800000190734863,(0.01*w.grad):0.20000,loss=120.00000
第2次梯度更新后的权重参数结果:9.604000091552734,(0.01*w.grad):0.19600,loss=116.04000
第3次梯度更新后的权重参数结果:9.411920547485352,(0.01*w.grad):0.19208,loss=112.23682
……(中间轮次省略)……
第100次梯度更新后的权重参数结果:1.3261957168579102,(0.01*w.grad):0.02707,loss=21.83132
最终权重参数结果:1.3261957168579102,(0.01*w.grad):0.02707,loss=21.83132
100 次后,w 约为 1.3262,逐渐靠近理论最优点 0,但还没有精确到达 0。
这个函数每次都满足 w新=w旧-0.01×2w旧=0.98w旧,因此可以用 10×0.98^100≈1.3262 核对结果。
输出中还有一个容易忽略的时间顺序:打印 w 时,参数已经更新;同一行的 loss 和 w.grad 则来自本轮更新前的计算。原文件最后打印的损失约为 21.8313,而把最终 w≈1.3262 重新代入函数,损失约为 21.7588。
因此,不能把同一行的三个数都理解成“更新后的参数重新求出的结果”。原文件并没有在最后一次更新后再次计算 loss。
参考:Tensor.backward 中的梯度累加说明。
2.5 本节完整原代码
对应文件:day02/09_自动微分模块案例.py。以下完整保留原文件的语句、注释、空行和执行顺序。
"""
案例:
演示自动微分模块,循环实现,计算梯度,更新参数
需求:
求loss=w**2+20 的极小值点,并打印loss是最小值的时候w的值(梯度)
解题步骤:
1.定义点w=10 requires_grad=True dtlosspe=torch.float32
2.定义函数loss=w**2+20
3.利用梯度下降法,循环迭代1000次,从而求最优解
3.1 正向计算部分
3.2 梯度清零 w.grad.zero_()
3.3 反向传播
3.4 梯度更新 w.data=w.data-学习率*w.grad
"""
#导包
import torch
# 1.定义点w=10 requires_grad=True dtlosspe=torch.float32
w=torch.tensor(10,requires_grad=True,dtype=torch.float32)
# 2.定义函数loss=w**2+20
loss=w**2+20
# 3.利用梯度下降法,循环迭代1000次,从而求最优解
print(f'开始权重初始值:{w},(0.01*w.grad):,loss={loss}')
for i in range(1,101):
# 3.1 正向计算部分
loss=w**2+20
# 3.2 梯度清零 w.grad.zero_(),因为默认情况下,梯度是累加的,所以需要清零
if w.grad is not None:
w.grad.zero_()
# 3.3 反向传播
loss.sum().backward()
# 3.4 梯度更新 w.data=w.data-学习率*w.grad
w.data=w.data–0.01*w.grad
#3.5打印本次梯度更新后的权重参数结果
print(f'第{i}次梯度更新后的权重参数结果:{w},(0.01*w.grad):{0.01*w.grad:.5f},loss={loss:.5f}')
# 4.打印结果
print(f'最终权重参数结果:{w},(0.01*w.grad):{0.01*w.grad:.5f},loss={loss:.5f}')
三、detach():分离梯度记录与转换 NumPy
对应文件:day03/01_自动微分_detach()函数介绍.py。
3.1 为什么需要先 detach
自动微分记录了张量之间的求导关系,但 NumPy 不会继续维护 PyTorch 的这套计算图。对本例 requires_grad=True 的张量,直接调用普通 .numpy() 会报错。
原文件把这次尝试保留为注释:
# t2=t1.numpy()
# print(f't2={t2},type:{type(t2)}')
下面的解决办法是:先得到一个与当前计算图分离的张量,再进行 NumPy 转换。
3.2 detach() 之后,结果仍然是张量
import torch
import numpy as np
t1=torch.tensor([10,20],requires_grad=True,dtype=torch.float32)
t3=t1.detach()
print(f't1.requires_grad={t1.requires_grad}')
print(f't3.requires_grad={t3.requires_grad}')
这里 t1 需要梯度,t3=t1.detach() 得到的 t3 不再跟踪梯度。t3 的类型仍然是 torch.Tensor,还没有变成 NumPy 数组。
3.3 分离计算图,不等于复制数据
原文件接着修改 t1,观察 t3 是否改变:
t1.data[0]=100
print(f't1={t1},type:{t1.type()}')
print(f't3={t3},type:{type(t3)}')
对应的原文件输出为:
t1=tensor([100., 20.], requires_grad=True),type:torch.FloatTensor
t3=tensor([100., 20.]),type:<class 'torch.Tensor'>
可以看到,两者的第一个元素都变成了 100。原因是 detach() 返回的张量与原张量共享底层存储,并不是复制出一份独立数据。
这条 .data 修改语句用于观察共享内存。实际训练中,不应把“分离后不跟踪梯度”理解为“随意修改共享数据也不会影响原张量”。
3.4 转换为 NumPy,以及链式调用
t3 不跟踪梯度后,就能在本例 CPU 环境中转换为数组:
t4=t3.numpy()
print(f't4={t4},type:{type(t4)}')
t5=t1.detach().numpy()
print(f't5={t5},type:{type(t5)}')
原文件对应输出为:
t4=[100. 20.],type:<class 'numpy.ndarray'>
t5=[100. 20.],type:<class 'numpy.ndarray'>
t1.detach().numpy() 的阅读顺序是:先 detach(),再 .numpy()。原注释把这种写法称为“链式法则”,这里准确的说法是链式调用;微积分里用于求导的链式法则是另一个概念。
本例中 t3、t4、t5 都与原 CPU 张量共享数据。后续遇到 GPU 张量时,需要先移到 CPU 再进行 NumPy 转换,不能无条件照搬这里的两步写法。
参考:Tensor.detach、Tensor.numpy。
3.5 本节完整原代码
对应文件:day03/01_自动微分_detach()函数介绍.py。以下完整保留原文件的语句、注释、空行和执行顺序。
"""
案例“
演示detach()的功能,解决自动微分的弊端
回顾:
自动微分=求导”:即基于损失函数计算梯度
结合权重更新公式:w新=w旧-学习率*梯度,来更新权重
问题:
一个张量一旦设置了自动微分,那这个张量就不能直接转换为numpy的ndarray对象了,需要通过detach()函数来解决
"""
#导包
import torch
import numpy as np
#1:定义张量,
#参1:初始值,参2:是否自动微分,参3:数据类型
t1=torch.tensor([10,20],requires_grad=True,dtype=torch.float32)
print(f't1={t1},type:{t1.type()}')
# #2尝试把上面t1这个张量转为numpy的ndarray对象—->不行,因为t1是自动微分的,不能直接转换
# t2=t1.numpy()
# print(f't2={t2},type:{type(t2)}')
#3;解决办法,可以通过detach()来解决
t3=t1.detach()
print(f't3={t3},type:{type(t3)}')
#4.测试t1与t3是否共享内存—->内存共享
t1.data[0]=100
print(f't1={t1},type:{t1.type()}')
print(f't3={t3},type:{type(t3)}')
#5.查看t1 t3是否会自动微分
print(f't1.requires_grad={t1.requires_grad}')
print(f't3.requires_grad={t3.requires_grad}')
#6.把t3转换为numpy的ndarray对象
t4=t3.numpy()
print(f't4={t4},type:{type(t4)}')
#7.最终版: 可以直接采用链式法则,即先detach()再转换为numpy的ndarray对象
t5=t1.detach().numpy()
print(f't5={t5},type:{type(t5)}')
四、自动微分应用:从预测值计算权重和偏置的梯度
对应文件:day03/02_自动微分真实应用场景.py。
4.1 从一个参数扩展到权重矩阵和偏置
前两个文件直接给出损失函数与 w 的关系。这个文件加入输入 x、标签 y 和预测值 z,更接近模型中的一次计算。
import torch
x = torch.ones(2, 5)
y = torch.zeros(2, 3)
w = torch.randn(5, 3, requires_grad=True)
b = torch.randn(3, requires_grad=True)
| x | (2,5) | 2 个样本,每个样本 5 个特征 |
| y | (2,3) | 每个样本对应 3 个目标值 |
| w | (5,3) | 把 5 个输入特征映射为 3 个输出 |
| b | (3,) | 每个输出对应一个偏置 |
这里把输入设为全 1、标签设为全 0,是为了用简单数据观察流程。w 和 b 通过 randn() 随机初始化,设置了 requires_grad=True。
4.2 前向传播:计算预测值
z=torch.matmul(x, w) + b
print(f'z={z}')
对应的形状变化为:(2,5) @ (5,3) → (2,3),再加上形状为 (3,) 的 b。
偏置通过广播加到每个样本的三个输出上,因此 z 最终是 (2,3),与标签 y 一致。这里的公式可以写成 z=xw+b。
4.3 用均方误差衡量预测和标签的差距
criterion = torch.nn.MSELoss()
loss = criterion(z, y)
print(f'loss={loss}')
MSELoss 是均方误差损失。默认情况下,它先将预测值与标签对应相减、平方,再对所有元素求平均。
本例共有 2×3=6 个输出元素,因此默认损失为:6 个误差平方之和 ÷ 6,结果是一个标量。
4.4 反向传播:分别得到 w 和 b 的梯度
loss.backward()
print(f'w.grad={w.grad}')
print(f'b.grad={b.grad}')
执行后,w.grad 的形状是 (5,3),b.grad 的形状是 (3,),分别与对应参数的形状一致。PyTorch 会根据计算图,自动把损失对预测值的变化传递到各个参数。
本文件没有设置随机种子,因此权重、偏置、预测值、损失和梯度的具体数字每次可能不同。判断代码时应先检查形状和计算流程。
这个文件完成了一次前向计算和反向传播,还没有执行参数更新。 原注释提到了更新公式,但实际代码停在打印梯度。接下来用优化器更新参数,才进入完整的训练过程。
参考:MSELoss。
4.5 本节完整原代码
对应文件:day03/02_自动微分真实应用场景.py。以下完整保留原文件的语句、注释、空行和执行顺序。
"""
案例:
演示自动微分在真实场景中的应用
结论:
1. 前向传播计算出预测值
2. 基于损失函数,结合预测值和真实值,计算梯度
3.结合梯度更新公式:w_new = w_old – learning_rate * gradient,更新权重
"""
#导包
import torch
#定义x,表示:特征((输入数据),假设:2行5列全1矩阵
x = torch.ones(2, 5)
print(f'x={x}')
#定义y,表示:标签(输出数据),假设:2行3列的全0矩阵
y = torch.zeros(2, 3)
print(f'y={y}')
#3.初始化(可自动微分)权重和偏置值
w = torch.randn(5, 3, requires_grad=True)
print(f'w={w}')
b = torch.randn(3, requires_grad=True)
print(f'b={b}')
#4.前向传播,计算预测值
z=torch.matmul(x, w) + b
print(f'z={z}')
#5.定义损失函数
criterion = torch.nn.MSELoss()
loss = criterion(z, y)
print(f'loss={loss}')
#6.反向传播,计算梯度
loss.backward()
#7.打印w b 用来更新的梯度
print(f'w.grad={w.grad}')
print(f'b.grad={b.grad}')
五、线性回归:用 PyTorch 完成数据加载与模型训练
对应文件:day03/03_PyTorch框架_模拟线性回归.py。
5.1 先看本例要学出什么
本例生成一批带噪声的线性数据,再训练一个形如 预测值=w×x+b 的模型。
可以把散点理解为有一定波动的观测结果,模型要从这些散点中学习一个合适的斜率和截距。
原文件使用的主要工具如下:
| make_regression | 创建带噪声的线性回归样本 |
| TensorDataset | 把特征与标签按样本对应起来 |
| DataLoader | 分批取出样本,并在训练时打乱顺序 |
| nn.Linear | 建立线性模型并管理权重、偏置 |
| nn.MSELoss | 计算均方误差 |
| optim.SGD | 根据梯度更新模型参数 |
| matplotlib.pyplot | 绘制损失变化与拟合结果 |
5.2 create_dataset():生成样本并转为张量
下面是原文件的数据创建函数:
def create_dataset():
x,y,coef=make_regression(
n_samples=100,#样本数量
n_features=1,#特征数量
noise=10,#噪声
coef=True,#是否返回系数
bias=14.5,#偏置
random_state=3,#随机种子
)
#2.把上述的数据封装成张量对象
#make_regression() 返回的是 NumPy 数组。这两行把它们转换成 PyTorch 张量,方便后续交给 PyTorch 模型计算。
x=torch.tensor(x,dtype=torch.float32)
y=torch.tensor(y,dtype=torch.float32)
#返回结果
return x,y,coef
主要参数可以这样理解:
| n_samples | 100 | 生成 100 个样本 |
| n_features | 1 | 每个样本只有 1 个输入特征 |
| noise | 10 | 在标签中加入标准差为 10 的高斯噪声 |
| coef | True | 同时返回生成数据时使用的系数 |
| bias | 14.5 | 生成数据时使用的偏置 |
| random_state | 3 | 固定这一步生成数据的随机状态 |
函数返回的 x 形状为 (100,1),y 为 (100,)。原文件把 NumPy 数组转成 float32 张量,以便交给 PyTorch 模型计算。
这里的 coef 是生成数据时的真实斜率,不是模型已经学到的权重;模型的权重会由后面的训练决定。
5.3 TensorDataset 与 DataLoader:按批次送入数据
dataset=TensorDataset(x,y)
dataloader=DataLoader(dataset,batch_size=16,shuffle=True)
TensorDataset(x,y) 将同一位置的特征与标签配成一个样本;DataLoader 再按批次取出这些样本。
batch_size=16 表示每批最多 16 个样本。100 个样本会被分为 7 批:前 6 批各 16 个,最后 1 批 4 个。shuffle=True 会在每轮迭代时打乱数据顺序。
普通批次中,train_x 的形状是 (16,1),train_y 是 (16,);最后一批的第一个维度相应变为 4。
5.4 模型、损失函数与优化器
model=nn.Linear(in_features=1,out_features=1)
criterion=nn.MSELoss()
optimizer=optim.SGD(model.parameters(),lr=0.01)
nn.Linear(in_features=1,out_features=1) 表示一个输入特征对应一个输出,适合这里的一元线性回归。默认会包含一个权重和一个偏置,model.parameters() 可以把它们交给优化器管理。
nn.MSELoss() 衡量预测和标签的差距;optim.SGD(…,lr=0.01) 使用学习率 0.01,依据参数的梯度执行更新。
5.5 预测、标签形状与参数更新
下面摘录的是原文件每个批次内的关键语句:
y_pred=model(train_x)
loss=criterion(y_pred,train_y.reshape(–1,1))#-1自动计算
optimizer.zero_grad()#梯度清零
loss.backward()#反向传播
optimizer.step()#更新参数
y_pred=model(train_x) 执行前向计算。对于普通批次,预测值形状为 (16,1),而原标签形状为 (16,),因此用 train_y.reshape(-1,1) 将标签也调整成 (16,1)。
-1 表示这一维由元素总数自动确定,因此最后一批也能正确得到 (4,1)。如果直接让 (B,1) 的预测值和 (B,) 的标签做运算,可能广播成 (B,B),偏离按样本一一比较的目的。
接下来三步各有分工:
| optimizer.zero_grad() | 清除上一次保留的参数梯度 |
| loss.backward() | 根据本批次损失计算参数梯度 |
| optimizer.step() | 根据梯度更新模型的权重与偏置 |
和第一节对照,原先手动写的更新公式,现在由优化器完成。创建优化器本身不会更新参数,真正的更新发生在 .step()。
5.6 Epoch 与 Batch:100 轮不等于只更新 100 次
下面是原文件的循环初始化和整个训练循环:
epochs,loss_list,total_loss,total_sample=100,[],0,0
for epoch in range(epochs):#一共100轮次:0,1….99
#6.3每轮是分批次进行计算的,所以从数据加载器中获取本批次的数据
for train_x,train_y in dataloader:#一共是7个批次,16,16,16,16,16,16,4
#6.4模型预测:
y_pred=model(train_x)
#6.5计算每批次的损失值
loss=criterion(y_pred,train_y.reshape(–1,1))#-1自动计算
#6.6计算总损失和样本批次数
total_loss+=loss.item()
total_sample+=1
#6.7 梯度清零,反向传播,更新参数
optimizer.zero_grad()#梯度清零
loss.backward()#反向传播
optimizer.step()#更新参数
#6.8 记录每轮的平均损失值
loss_list.append(total_loss/total_sample)
print(f'第{epoch+1}轮的平均损失值为:{total_loss/total_sample}')
一个 Epoch 表示把全部训练数据遍历一轮,一个 Batch 是其中取出的一小批数据。
本例每轮有 7 个批次,每个批次执行一次 optimizer.step()。总计 100 轮,因此一共更新参数 100×7=700 次。
5.7 读懂原代码的损失统计口径
上面代码中的 total_loss、total_sample 在训练开始前初始化一次,进入新一轮时没有重置。因此,loss_list 记录的是从训练开始累计到当前轮的批损失平均值,并不是打印文字所说的“本轮平均损失”。
另外,total_sample+=1 每处理一批增加 1,所以这个变量实际统计的是批次数。每批损失等权参与平均,最后一批 4 个样本与其他每批 16 个样本的权重相同,也不是严格按全部样本数加权的整体 MSE。
这不影响本例执行反向传播和参数更新,但会影响曲线的含义。第一幅图应该按“累计批损失平均值随训练进行的变化”理解;后期参数已经改善时,早期较大的损失仍会影响这个累计平均值。
如果以后要记录每轮的样本平均损失,思路是在每轮开始重新统计,用每批损失乘以该批样本数,再除以本轮总样本数。本篇末尾继续保留原来的统计代码,方便对照上述区别。
5.8 怎样理解两张图
第一张图使用原文件中这几条语句绘制:
plt.plot(range(epochs),loss_list)
plt.title('损失值变化图像')
plt.xlabel('轮数')
plt.ylabel('损失值')
plt.grid()
plt.show()
横轴是轮次,纵轴对应上一小节解释的累计批损失平均值。它可以帮助观察整体变化,但不能直接当成每轮独立统计的损失。
第二张图中,原文件先画样本散点,再计算预测值与理论值:
plt.scatter(x,y)
y_pred=torch.tensor(data=[v*model.weight+model.bias for v in x])
y_true=torch.tensor(data=[v*coef+14.5 for v in x])
plt.plot(x,y_pred,color='red',label='预测值')
plt.plot(x,y_true,color='blue',label='真实值')
plt.legend()
plt.show()
阅读图像时,注意三类数据的含义:
| 散点 | x,y | 带噪声的观测样本 |
| 红线 | v*model.weight+model.bias | 使用学到的参数计算的预测值 |
| 蓝线 | v*coef+14.5 | 生成样本时使用的无噪声理论关系 |
原图把蓝线标为“真实值”,这里应理解为无噪声的理论直线,不能把它与含噪声的标签 y 当成同一组数。
原数据 x 没有排序,plt.plot 会按样本顺序连接各点,可能沿直线来回描画。阅读时主要看拟合关系;后续整理绘图时,可以按 x 的顺序排列后再连线。
预测部分重新用 torch.tensor(…) 包装计算结果,会生成新的张量,不保留原来的求导关系;本次环境还出现了将带梯度张量转为标量的提示。这里已经进入绘图展示阶段,结合第三节的 detach() 理解即可,完整代码保留原写法。
5.9 一次运行结果,以及这五个文件的联系
本次核对的一次运行中,生成数据的系数约为 27.4781、偏置为 14.5;训练后模型权重约为 27.5717、偏置约为 13.8727。它们说明模型学到了接近原始关系的直线,但这不是每次都固定不变的输出。
random_state=3 固定了造数据这一步,模型初始化和训练数据打乱并没有设置 PyTorch 随机种子。因此每次训练的具体参数和曲线可能略有不同。样本本身有噪声,学到的参数也不必精确等于生成系数,训练损失不必降到 0。
本例用于理解训练流程,没有单独划分测试集;图像展示的是对这批训练样本的拟合,还不能据此判断模型在新数据上的表现。
把本模块前后联系起来,就能看出每个文件增加了什么:
| requires_grad=True | nn.Linear 的可训练参数默认需要梯度 |
| 自定义 loss 算式 | criterion(y_pred,train_y.reshape(-1,1)) |
| w.grad.zero_() | optimizer.zero_grad() |
| loss.backward() | 仍然使用反向传播求梯度 |
| 手写权重更新公式 | optimizer.step() |
| 循环更新一个 w | 分批、分轮更新模型的权重和偏置 |
| detach() 分离梯度记录 | 帮助理解训练结果如何用于数组转换与展示 |
运行本文件需要 PyTorch、scikit-learn、Matplotlib 等依赖。原代码指定了 SimHei 中文字体,本机有该字体时可以正常显示中文;交互式运行时,两次 plt.show() 通常会依次显示两张图,关闭第一张后继续执行。
参考:Linear、MSELoss、SGD、make_regression。
5.10 本节完整原代码
对应文件:day03/03_PyTorch框架_模拟线性回归.py。以下完整保留原文件的语句、注释、空行和执行顺序。
#导入相关模块:
import torch
from torch.utils.data import TensorDataset #构造数据集对象
from torch.utils.data import DataLoader #数据加载器
from torch import nn #神经网络模块。nn中有平方损失函数和假设函数
from torch import optim#optim中有优化器模块
from sklearn.datasets import make_regression#创建线性回归数据集
import matplotlib.pyplot as plt#绘图模块
plt.rcParams['font.sans-serif'] = ['SimHei']#解决中文显示问题
plt.rcParams['axes.unicode_minus'] = False#解决负号显示问题
#1.定义函数,创建线性回归样本数据
def create_dataset():
x,y,coef=make_regression(
n_samples=100,#样本数量
n_features=1,#特征数量
noise=10,#噪声
coef=True,#是否返回系数
bias=14.5,#偏置
random_state=3,#随机种子
)
#2.把上述的数据封装成张量对象
#make_regression() 返回的是 NumPy 数组。这两行把它们转换成 PyTorch 张量,方便后续交给 PyTorch 模型计算。
x=torch.tensor(x,dtype=torch.float32)
y=torch.tensor(y,dtype=torch.float32)
#返回结果
return x,y,coef
#2.定义函数,表示模型训练
def train(x,y,coef):
#创建数据对象,把tensor—->数据集对象—->数据加载器对象
dataset=TensorDataset(x,y)
#2.创建数据加载器对象,
# 参数一:数据集对象,参数二:批量大小,参数三:是否打乱数据
dataloader=DataLoader(dataset,batch_size=16,shuffle=True)
#3.创建初始的线性回归模型
#参数一:输入特征数量,参数二:输出特征数量
model=nn.Linear(in_features=1,out_features=1)
#4.创建损失函数对象
criterion=nn.MSELoss()
#5.创建优化器对象
#参数一:模型参数,参数二:学习率
optimizer=optim.SGD(model.parameters(),lr=0.01)
#6.具体训练过程
#6.1定义变量,分别表示:训练轮数,每轮的平均损失,训练总损失值,训练的样本数
epochs,loss_list,total_loss,total_sample=100,[],0,0
#6.2开始训练,按轮进行训练
for epoch in range(epochs):#一共100轮次:0,1….99
#6.3每轮是分批次进行计算的,所以从数据加载器中获取本批次的数据
for train_x,train_y in dataloader:#一共是7个批次,16,16,16,16,16,16,4
#6.4模型预测:
y_pred=model(train_x)
#6.5计算每批次的损失值
loss=criterion(y_pred,train_y.reshape(–1,1))#-1自动计算
#6.6计算总损失和样本批次数
total_loss+=loss.item()
total_sample+=1
#6.7 梯度清零,反向传播,更新参数
optimizer.zero_grad()#梯度清零
loss.backward()#反向传播
optimizer.step()#更新参数
#6.8 记录每轮的平均损失值
loss_list.append(total_loss/total_sample)
print(f'第{epoch+1}轮的平均损失值为:{total_loss/total_sample}')
#7.打印最终训练结果
print(f'{epochs}轮的平均损失分别为:{loss_list}')
print(f'模型的参数为:{model.weight},偏置为:{model.bias}')
#8.绘制损失值变化图像
plt.plot(range(epochs),loss_list)
plt.title('损失值变化图像')
plt.xlabel('轮数')
plt.ylabel('损失值')
plt.grid()
plt.show()
#9.绘制预测点和真实点图像
#9.1绘制样本点分布情况
plt.scatter(x,y)
#9.2绘制训练模型的预测值
#x:100个样本特征点
y_pred=torch.tensor(data=[v*model.weight+model.bias for v in x])
#9.3计算真实值
y_true=torch.tensor(data=[v*coef+14.5 for v in x])
#9.4绘制预测值和真实值的折线图
plt.plot(x,y_pred,color='red',label='预测值')
plt.plot(x,y_true,color='blue',label='真实值')
plt.legend()
plt.show()
if __name__ == '__main__':
x,y,coef=create_dataset()
# print(f'x={x},y={y},coef={coef}')
train(x,y,coef)


