# 神经网络从入门到实战(下)——训练优化篇 --- ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1785739828049-74276e53-6bc4-4361-b541-f719632d1b27.png) ## 二、全连接神经网络 ### 2.9 模型参数初始化 在 PyTorch 中,模型参数的初始化主要分为两种情况:一是依赖框架的**默认初始化**,二是通过 `torch.nn.init` 模块进行**自定义初始化** ```python import torch.nn as nn class MyModel(nn.Module): def __init__(self): super().__init__() # 当执行这行代码时,自动调用调用reset_parameters方法 #nn.Linear 的 __init__ 就自动初始化了 weight 和 bias self.fc = nn.Linear(10, 5) def forward(self, x): return self.fc(x) # 实例化模型时,fc 就已经被初始化好了 model = MyModel() print(model.fc.weight) # 已经是有具体数值的 Tensor print(model.fc.bias) ``` ```python import torch.nn as nn # 创建一个线性层 layer = nn.Linear(10, 5) # 最简单的用法:直接用 init 覆盖默认初始化 nn.init.xavier_uniform_(layer.weight) # 均匀分布初始化权重 nn.init.zeros_(layer.bias) # 偏置设为0 print(layer.weight) print(layer.bias) ``` 关于常见的初始化方法 1. 均匀分布初始化torch.nn.init.uniform_() 2. 正态分布初始化torch.nn.init.normal_() 3. 常量初始化torch.nn.init.constant_() 4. Xavier均匀分布 5. Xavier正态分布 6. kaiming均匀分布初始化 7. kaiming正态分布初始化 **全连接层 (**`**nn.Linear**`**) **:权重通常采用 kaiming正态分布初始化(适合ReLU 及其变体 (Leaky ReLU)),Kaiming正态分布初始化的核心是从均值为0、标准差动态计算为`√(2/fan_in)(输入数据的维度)`的正态分布中随机采样,这样设计的目的是让数据在网络中稳定传播、避免梯度消失或爆炸,让模型更容易收敛。偏置(bias)通常初始化为 0。 ![若随机变量X服从一个数学期望为μ、方差为σ^2的正态分布,记作X~N(μ ,σ2)。其中, μ是正态分布的数学期望(均值), σ2是正态分布的方差。μ = 0,σ = 1的正态分布被称为标准正态分布。-1σ到 1σ之间的概率 = 68.2%,-2σ 到 2σ之间的概率 = 95.4%。标准正态分布:当μ = 0,σ = 1时的正态分布是标准正态分布](https://cdn.nlark.com/yuque/0/2026/png/21571931/1785746766841-a3b942b3-af8a-4381-8f22-2dcbd6c0859d.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787207678875-302626ca-342a-4143-8fd6-8ba573f46605.png) | **均值(μ)** | **标准差(σ)** | | --- | --- | | 所有数据的平均值,表示数据**集中在哪个位置** | 数据偏离均值的平均距离,表示数据**有多分散** | | μ = (x₁+x₂+...+xₙ) / n | σ = √[((x₁-μ)²+...+(xₙ-μ)²) / n] | | 数据的"中心点" | 数据的"平均误差"或"波动幅度" | | 决定正态分布曲线**在哪儿** | 决定正态分布曲线**是胖是瘦(**数据越分散,曲线越矮胖.数据越集中,曲线越高瘦**)** | ### 2.10 损失函数——模型怎么知道自已错了 前向传播输出了模型的预测值。那这个预测值到底好不好?得有一个量化的打分标准——这就是损失函数。 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1784600475031-3ab68113-ff0d-489f-aafe-6bda14ebd15a.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1784600783707-1e0367b5-d60d-4e81-a00a-81d75b62d302.png) #### 均方差损失函数(MSE)——万能损失函数 思路非常朴素:预测值和真实值一一相减,把差值平方(消除正负号的影响,同时让大误差被加倍惩罚),再取平均。 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787329478117-06ab7842-fdfc-4b09-b46f-9f94b97c3dae.png) **为什么是“平方”而不是“绝对值”** 1. **消去正负号**——直接求和,正负误差会互相抵消。 2. **放大大误差**——误差 3 平方变 9,误差 0.4 平方变 0.16,差异被显著拉开,让模型对大错误更敏感。 想象你在预测奶茶店接下来三天的销量。真实销量是 100、150、80 杯,你预测的是 110、140、90 杯。MSE 的计算就是:(10² + 10² + 10²) / 3 = 300 / 3 =100。这个100 就是你当前模型的"错误程度"--**模型的损失值**。 > MSE 对离群点(异常值)特别敏感——因为误差被平方了。如果你不小心预测了一个离谱的值(比如把 100 预测成 500),这一个点的贡献就是 160000,会严重拉高整体损失。这既是优点(绝不姑息大错误)也是缺点(可能被个别噪声数据带偏整条训练方向)。 > --- ### 2.11 梯度下降——模型训练的实质 有了损失函数这个"成绩单",目标就明确了——**让损失值越来越小**。模型到底怎么知道参数w,b应该往哪个方向改,后续损失计算才会变小? --- #### 导数:函数变化的快慢 **导数的直观理解**:自变量x动一点点,函数值跟着动多少。 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787279165314-4797b5fb-3faf-45ce-b46e-a3f22315b987.png) 在函数曲线上取 A、B 两点。AB 连线(割线)的斜率 = "从 A 到 B 这段,函数平均变化了多快"。当你让 B 无限逼近 A,割线就变成了 A 点的**切线**。切线的斜率,就是 A 点处的**导数**。 > **一句话**:导数 = 切线斜率 = 函数在这一点的"瞬时变化率"。如果导数是 +3,意味着自变量往右走1小步,函数值大概会上升 3 小步。 > 导数度量的是函数曲线在某一点的陡峭程度。曲线陡峭处,导数绝对值很大;越靠近波谷(最低点),曲线越平缓;到达波谷正中心时,切线完全水平,导数等于零。 对于一元函数 f(x),梯度就是导数,此时梯度和导数是一回事。 + **导数为正:**函数在这一点向右走是上升的。 + **导数为负:**函数在这一点向右走是下降的。 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1784713493301-4b6ed893-497a-4d99-a270-3bdf28c55a81.png) **几何结论:** 负导数 −f′(w) 这个符号,就像一个精准的“方向盘”,无论你在谷底的哪一侧,它都强行把你的 w往谷底的方向推。**梯度下降的方向**,是**负导数**的方向,它告诉你 w该往左还是往右。 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1784708088353-018941e3-8714-4390-a905-3819423b0bf9.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786613571200-052f7bbd-5004-4325-b1c6-4487b018d591.png) | **迭代轮数** | **权重****W**_****_ | **预测值****y**_****_ | **损失****L**_****_ | | --- | --- | --- | --- | | 初始 | 1.0 | 2.0 | 16.0 | | 第 1 轮 | 2.6 | 5.2 | 0.64 | | 第 2 轮 | 2.92 | 5.84 | 0.0256 | | 第 3 轮 | 2.984 | 5.968 | | #### 学习率——最容易被低估的超参数 学习率控制每次参数更新的步长,是训练神经网络时最重要的超参数,没有之一。 | 学习率 | 会发生什么 | 类比 | | --- | --- | --- | | 太大 | 参数震荡甚至发散,Loss 不降反升 | 下山时步子太大,一脚踩到对面山上去了 | | 适中 | 稳定快速地收敛到最优解附近 | 步伐稳健,一步步走到谷底 | | 太小 | 收敛慢到让人想提前下班 | 穿着高跟鞋小碎步下山,太阳落山了还没走到 | ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1784712510184-53cc16de-909a-41d5-8b65-22833a674c5e.png) **实践中怎么办**:从 0.01 或 0.001 开始试。如果 Loss 忽高忽低就是不降——减半;如果 Loss 平稳下降但太慢——翻倍。现代优化器(Adam、AdamW)自带自适应学习率机制,比传统的手动调参容错率高得多,是大多数任务的首选。 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786085525101-124b5713-f822-4ee4-87ab-de127c46a72e.png) #### 偏导数 “如果一个函数有多个变量,比如损失函数 L(w,b),我们想调整 w__ 来降低 L,但调整 w 时,b 也在变,怎么办? **偏导数:一个多元函数(有多个自变量)在固定其他变量不变时,只随其中一个变量变化函数值的变化率.(把其他所有变量当成常数,然后按普通导数的规则来求就可以了)。** ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786605280328-3f4b1961-d166-461e-8c84-59911101f00b.png) **∂f/∂x 告诉你函数值在 x 方向的变化率,∂f/∂y 告诉你函数值在 y 方向的变化率。** --- ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786088295956-baaa1eb9-c7b6-4a45-9bab-28ca0cb36862.png) #### 梯度:把函数关于所有参数的偏导数打包成一个向量。梯度是一个向量,指向函数增长最快的方向。所以负梯度就是下降最快的方向。 ![梯度即代表了方向,梯度代表的其实是函数值增大最快的方向;在实际应用中,我们需要寻找损失函数的最小值,所以选择负梯度向量,这就是梯度下降法的底层。负梯度代表的是函数值减小最快的方向,并不一定直接指向函数图像的最低点。 模型是借助损失函数计算了各个偏导(反向传播),然后组合每个偏导成向量(梯度),再通过梯度下降法调整每一个偏导的值,从而进行参数 w 与 b 的更新(参数更新)。最开始的初始化 w 和 b 大概率会导致损失值很大,但经过这样的神经网络训练流程,多轮训练,不断微小调整 w 和 b 的值,直至损失值趋于平缓,从而完成任务。](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786606128122-4050d325-f2cf-4fa7-9e03-c3431b294f99.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786675588682-b1b581db-36fc-4dd8-a3e9-7caf2096ceb4.png) **重要数学结论:沿着梯度方向走,函数值增加得最快;如果你沿着梯度的反方向走,函数值下降得最快.** > 梯度就是对模型损失函数的每个参数求偏导数,然后组成的向量,梯度下降就是让参数沿梯度反方向减小一点点🤏,至于多小一点由学习率这个超参数决定 > --- #### 梯度下降算法本身 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786614670448-e6c6bf14-2552-406f-b0d6-f0036a6fd512.png) **更新参数的规则**:新参数 = 旧参数 - 学习率 × 梯度。 + 如果梯度是正数(参数增大 → 损失变大),那就**减小参数** + 如果梯度是负数(参数增大 → 损失变小),那就**增大参数** + **学习率**决定了每一步迈多大——太大容易"跳过头"来回震荡,太小训练慢到地老天荒 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786615292454-7d2fa173-36c1-4791-a430-04302e1f75e5.png) 想象你是一个盲人,站在一个起伏的山坡曲线 y=f(x)上,任务是**走到最低的那个谷底**(即找到函数的最小值)。 + 因为你是盲人,你看不到整个山的形状,只能摸脚下的坡度。 + **关键动作**:你用脚跺一下地面,感觉坡是**朝左斜**还是**朝右斜** - 如果脚下的坡**朝左低**(即向右走是上坡),那你应该**向左迈一步** - 如果脚下的坡**朝右低**(即向右走是下坡),那你应该**向右迈一步** ```python import random import matplotlib.pyplot as plt # 场景:房价预测。已知 100 套房的面积和成交价,要找出估价公式:价格 = w * 面积 + b w = random.random() # 随便猜一个"每平米单价" b = random.random() # 随便猜一个"基础价" _x = [i/100 for i in range(100)] # 100 套房,面积 0~0.99(按百平米计,即 0~99 平米) # 真实行情是 价格 = 3 * 面积 + 10(面积按百平米、价格按十万计) # random.random() 是 0~1 的正数,模拟装修、楼层带来的溢价,所以每套房都比基准价略高 _y = [3*e + 10 + random.random() for e in _x] # 接下来一遍一遍地复习这 100 套房,慢慢把估价公式修正到最准 while True: # 首先迭代x, y 因为x,y是不相关的数组 zip打包成元组 # 内部循环100次,每次拿出一套房的面积 x 和真实成交价 y for x, y in zip(_x, _y): # 用当前公式给这套房估价 # h 是估计的价格 h = w * x + b # y是真实成交价(标准答案) # 根据当前w b 值计算这套房估得有多离谱(损失函数),平方让误差永远为正 loss = (y-h)**2 # 现在有损失我们需要更新 权重 w 和 b # 更新权重使用梯度下降法 当前x 和 y是确定的 需要确定w 和 b # y = w * x + b # f(w,b) 二元函数求导:估低了(y-h为正)就把 w b 调大,估高了就调小 dw = -2 * x * (y-h) db = -2 * (y-h) # 求 dw 和 db目的是修正估价公式的 w 和 b # 0.01 是学习率,即每次修正的幅度:太大来回震荡,太小学得太慢 w -= dw * 0.01 b -= db * 0.01 print(f'loss:{loss}---w:{w}---b:{b}') # 开启交互模式 plt.ion() # 清屏 plt.cla() # _x, _y是100套真实成交的房子 plt.plot(_x, _y, '.') # 间隔0.01s # 用修正后的 w, b 画出当前的估价直线 plt.plot(_x, [w * e + b for e in _x]) plt.pause(0.01) plt.show() ``` --- ### 2.12 反向传播与链式法则 **反向传播**:比较预测结果和真实答案,算出误差,然后逐层回传,算出每个权重该怎么调(根据预测误差计算梯度,更新模型权重(训练过程))。 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786948447986-5a49e715-7a42-41ee-8914-038bc710571f.png) #### 复合函数求导(链式法则) **复合函数**,就是**把一个函数的输出,当做另一个函数的输入** 如果有两个函数 f_g_,把 _g_ 的输出放进 f 里,得到的复合函数记作:(_f__g_)(_x_)=_f_(_g_(_x_)) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786949318080-45489ba6-8ad4-4b89-9dfe-a33e62e0b267.png) #### 链式法则的核心思想:**外层函数求导,乘以内层函数的导数。** ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786949410584-1fcc7805-04d4-441d-9386-54c47f726999.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786949574183-c8f7396a-ea09-4fad-bd36-04448248cf13.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787105913186-51704840-bc02-4835-9e7a-8ac2cebfafad.png) **神经网络是一个由大量简单函数(主要是线性变换与非线性激活函数)复合而成的、可导的、带有可学习参数的巨大复合函数。** #### 反向传播的完整分步示例: 下面这组图把一个具体的数值例子从头算到尾:先做前向传播算出预测值和损失,再从损失出发逐层回传梯度,最后完成一次参数更新。跟着图把每一步的数对一遍,链式法则"从后往前算导数"的感觉就建立起来了。 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787330567007-bf074b6c-c6d8-4319-834c-70dbb4977c0c.png) ```python import torch # 1. 普通张量:默认不记录梯度 x = torch.tensor([2.0, 3.0]) print(x.requires_grad) # False #PyTorch最核心的优势就是Autograd自动微分引擎,不用手动求导,代码一行开启自动计算梯度。 # 2. 开启梯度记录(模型参数专用)只有设置requires_grad=True的张量,才会记录运算流程、支持求梯度。 w = torch.tensor([1.5], requires_grad=True) print(w.requires_grad) # True ``` ```python import torch # 权重参数,开启梯度 w = torch.tensor([2.0], requires_grad=True) # 正向运算 y = w * 3 # 模拟损失函数 loss = (y - 9) ** 2 # 反向传播,自动求导 loss.backward() # 打印w的梯度 print("w的梯度:", w.grad) ``` ### 2.13 优化器 神经网络训练流程可以简化成: ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787122730029-a0c2e149-e380-44cd-91a2-653d59bc4d32.png) **优化器(Optimizer):**它负责根据当前得到的损失和梯度,计算出下一步该**如何调整**模型内部的参数,让模型的表现越来越好。 + 损失函数:负责打分 + 反向传播:负责算梯度 + 优化器:负责真正更新参数 ```python # 这是 optimizer 的超级简化版伪代码 for 每一个参数 in 模型的所有参数: 参数.数值 = 参数.旧数值 - 学习率 × 参数的梯度 ``` **默认首选优化器:Adam** ```python optimizer = torch.optim.Adam(model.parameters(), lr=0.001) ``` 1. 极度“省心”,默认参数就能跑 2. 对“学习率”不敏感(它给每个参数都“自适应”调节步长,就算你的 lr=0.01 设得稍大,它内部会自动把“梯度大的参数”的步长缩小,防止震荡。) 3. 收敛速度快(在训练初期,Adam 的“自适应学习率”会让模型像坐火箭一样快速下降 Loss) ### 2.14 完整训练循环 #### 2.14.1数据预处理 ##### 2.14.1.1归一化 **归一化**归一化本质上是把不同尺度的数据统一到 [0, 1]。 **核心目的:让模型更容易、更稳定地学习,它是训练** 不同特征的数据差异很大,比如"面积"是几十到几百,"房间数"只是个位数,"到最近地铁站的距离"可能是几千米。如果不做归一化: + 出现计算溢出,前向传播涉及大量矩阵乘法,容易产生极大或极小的数值。如果输入数据量级过大(如 > 100),多层累乘后数值会迅速膨胀至`inf`(无穷大);如果过小,会下溢至0。归一化将数据控制在合理的浮点数运算范围内(如标准差为1),保证计算过程的稳定性。 + Sigmoid/Tanh 等激活函数会直接饱和,梯度消失 **消除数据量纲的影响**:不同特征可能有完全不同的单位和量级(如"年龄"和"收入")。如果不归一化,模型会错误地认为量级大的特征更重要,**权重更新被大尺度特征主导**,小尺度特征的信息被淹没(数量级大的特征会在训练中"碾压"数量级小的特征)。 **Min-Max 归一化****公式**:x_norm = (x - x_min) / (x_max - x_min) ```python import numpy as np # 假设这是"视频时长"和"粉丝数"两个特征,数量级差异巨大 data = np.array([[60, 500000], # 60秒,50万粉丝 [180, 20000], # 3分钟,2万粉丝 [15, 1200000]]) # 15秒,120万粉丝 # Min-Max 归一化:每个特征独立压缩到 [0, 1] data_norm = (data - data.min(axis=0)) / (data.max(axis=0) - data.min(axis=0)) print(data_norm) # [[0.273, 0.407], # [1.000, 0.000], # [0.000, 1.000]] # 现在两个特征在同一个尺度上了 ``` ##### 2.14.1.2one-hot编码:把类别变成向量 计算机不认识"猫"、"狗"、"鸟"这些文字类别,需要把它们翻译成数字。但直接标 1、2、3 会让模型误以为"狗(2)是猫(1)的两倍",所以要用 One-Hot 编码。 **绝对不要**直接把“红色=1,绿色=2,蓝色=3”喂给神经网络,理由有两条: + **强加“大小顺序”**。神经网络会认为 3 > 2 > 1,即“蓝色 > 绿色 > 红色”。但颜色根本没有大小之分,这种无中生有的排序会彻底误导模型的权重更新。 + **制造“距离假象”**。在数学计算中,(绿色 - 红色) = 1,而 (蓝色 - 红色) = 2。模型会误以为“蓝红差异”是“绿红差异”的两倍,这完全不符合事实。 **核心做法**:N 个类别就用 N 维向量,每个类别独占一位"1",其余全是"0"。 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787122816055-ee865ad7-54e7-47e6-a77c-0776697adb3d.png) **规则**:向量的长度 = 类别总数,属于哪一类,对应位置就为 1,其余为 0。 ```python import torch.nn.functional as F import torch labels = torch.tensor([0, 1,2,3]) one_hot = F.one_hot(labels, num_classes=4) print(one_hot) # tensor([[1, 0, 0, 0], # [0, 1, 0, 0], # [0, 0, 1, 0], # [0, 0, 0, 1]]) ``` #### 2.14.2 训练流程 把前面拆开讲的各个零件组装起来,神经网络的训练就是一个不断重复的循环: ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786676332642-af52be2d-3018-4336-9a12-d67d3fe8c54d.png) **早停法**就是在训练神经网络时,不把预设的轮次全部跑完,而是每训练完一轮就在**测试集**上测试一次效果;一旦发现**测试集**上的表现(如损失值)连续多个轮次不再变好,就**立即终止训练** ```python #安装tensorboard uv pip install tensorboard -i https://pypi.tuna.tsinghua.edu.cn/simple #本地启动TensorBoard tensorboard --logdir=./mlp_mse/logs ``` ```python import torch import torch.nn as nn from torch.utils.data import DataLoader from torch.utils.tensorboard import SummaryWriter from torchvision import datasets, transforms from net import FullyConnectedNet import torch.nn.functional as F import tqdm #让循环在运行时,自动在控制台显示一个动态更新的进度条 # 指定日志目录 writer = SummaryWriter(log_dir='./mlp/logs') # 1. 判断是否使用CUDA device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print(f'Using device: {device}') # 2. 准备数据,Dataset 读数据 train_set = datasets.MNIST(root='./mlp/data', train=True, download=True, transform=transforms.ToTensor()) test_set = datasets.MNIST(root='./mlp/data', train=False, download=True, transform=transforms.ToTensor()) #DataLoader 负责打包、分批、迭代、打乱数据 train_loader = DataLoader(dataset=train_set, batch_size=100, shuffle=True) test_loader = DataLoader(dataset=test_set, batch_size=100, shuffle=False) # 3. 创建模型 model = FullyConnectedNet() model = model.to(device) # 4. 确定损失函数 loss_fn = nn.MSELoss() # 5. 创建优化器 使用梯度下降算法 参数的更新 opt = torch.optim.Adam(model.parameters()) # 早停法参数:当测试集损失连续 patience 轮不再变好时,提前终止训练 patience = 5 best_test_loss = float('inf') # 记录到目前为止最好的测试损失 no_improve_count = 0 # 记录测试损失连续没有变好的轮数 max_epochs = 1000 for epoch in range(max_epochs): # 6. 训练模型 model.train() train_total_loss = 0 for images, labels in tqdm.tqdm(train_loader, desc="train", total=len(train_loader)): # 将数据移动到设备 images, labels = images.to(device), labels.to(device) labels = F.one_hot(labels, num_classes=10).float() outputs = model(images) # 前向传播 loss = loss_fn(outputs, labels) # 计算损失 opt.zero_grad() # 清空梯度 loss.backward() # 反向传播 计算梯度 opt.step() # 更新参数 train_total_loss += loss.item() train_avg_loss = train_total_loss / len(train_loader) print(f'Epoch {epoch+1}/{max_epochs}, Loss: {train_avg_loss:.4f}') # 7. 测试模型 model.eval() test_total_loss = 0 test_total_acc = 0 #禁用梯度计算 #在推理时,我们不需要反向传播,因此不需要计算损失函数对参数的梯度,节省内存、加快推理速度 with torch.inference_mode(): for images, labels in tqdm.tqdm(test_loader, desc="test", total=len(test_loader)): images, labels = images.to(device), labels.to(device) labels = F.one_hot(labels, num_classes=10).float() outputs = model(images) # 计算损失 loss = loss_fn(outputs, labels) test_total_loss += loss.item() pred = torch.argmax(outputs, dim=1) target = torch.argmax(labels, dim=1) acc = torch.eq(pred, target).float().mean() test_total_acc += acc.item() test_avg_acc = test_total_acc / len(test_loader) print(f'epoch:{epoch+1}, Test Accuracy: {test_avg_acc:.4f}') test_avg_loss = test_total_loss / len(test_loader) print(f'epoch:{epoch+1},Test Loss: {test_avg_loss:.4f}') # 记录数据 writer.add_scalars('Loss/train', {'train_avg_loss': train_avg_loss, 'test_avg_loss': test_avg_loss}, epoch) writer.add_scalar('Accuracy/test', test_avg_acc, epoch) # 8. 早停判断与模型保存 if test_avg_loss < best_test_loss: # 测试损失变好了:更新最好成绩,重置计数器,保存当前最优模型 best_test_loss = test_avg_loss no_improve_count = 0 torch.save(model.state_dict(), './mlp/model/mnist_net_best.pth') print(f'测试损失改善,保存最优模型 (best loss: {best_test_loss:.4f})') else: # 测试损失没有变好:计数器加一 no_improve_count += 1 print(f'测试损失未改善 ({no_improve_count}/{patience})') if no_improve_count >= patience: # 连续 patience 轮没有变好,提前终止训练 print(f'早停:测试损失连续 {patience} 轮未改善,在第 {epoch+1} 轮终止训练') break ``` ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787202356469-d6a6c69e-081a-4b3e-b33d-38818701a9b9.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787202376264-73e7794f-4bb9-4d79-8d0e-7c823c556f16.png) | 代码 | 对应过程 | 解决的问题 | | --- | --- | --- | | `logits = model(features)` | 前向传播 | 模型根据当前参数做出了什么预测? | | loss = loss_fn(outputs, labels) | 计算损失 | 预测结果和真实答案相差多少? | | `optimizer.zero_grad()` | 清空梯度 | 如何避免上一轮梯度影响当前计算? | | `loss.backward()` | 反向传播 | 每个参数应该承担多少错误? | | `optimizer.step()` | 参数更新 | 参数应该如何变化才能降低损失? | ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787126142349-371932e2-906a-4462-9b3d-7be83ae88090.png) | 步骤 | 做什么 | 关键点 | | --- | --- | --- | | ① 初始化 | 给所有参数赋随机初始值 | 不能让所有参数一样,否则梯度也会一样,网络就废了 | | ② 前向传播 | 输入数据从第一层流到最后一层 | 矩阵乘法 + 激活函数,逐层计算 | | ③ 算损失 | 比较预测值和真实值 | 用 MSE | | ④ 反向传播 | 从输出层往回,逐层算梯度 | 链式法则连接每一层,自动求导引擎包办 | | ⑤ 更新参数 | 沿梯度的反方向调整每个参数 | 步长 = 学习率 × 梯度 | | ⑥ 判断收敛 | Loss 不再明显下降或达到预设轮数 | 可以用"早停法"避免过度训练 | #### 2.14.3 模型预测 ```python import torch from net import FullyConnectedNet from PIL import Image import numpy as np from torchvision import transforms import torch.nn.functional as F device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = FullyConnectedNet() model_path = "./mlp/model/mnist_net_best.pth" # 加载训练好的权重到模型中: # 1. torch.load(model_path, map_location=device) 从磁盘读取权重字典(state_dict), # map_location=device 保证权重被映射到当前使用的设备上(防止在 GPU 上训练的模型在纯 CPU 机器上加载失败) # 2. model.load_state_dict(...) 把读取到的权重参数逐个复制进模型的对应层中 model.load_state_dict(torch.load(model_path, map_location=device)) model.to(device) # 把模型的所有参数和缓冲区移动到指定设备上(GPU 或 CPU),之后前向计算就在该设备上进行 model.eval() # 读取图片并进行预处理 image_path = "D:/code/xwdev/mlp/mnist_pytorch_samples/image_0003_label_0.png" image = Image.open(image_path) # 用 Pillow 打开图片文件,得到一个 PIL Image 对象 # 定义图像预处理流水线,transforms.Compose 会把多个变换按顺序串联起来依次执行 transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), # 转为灰度图:无论原图是 RGB 还是 RGBA,都强制变成单通道灰度图(MNIST 是灰度图,通道数必须为 1) transforms.Resize((28, 28)), # 调整大小为 28x28:MNIST 模型训练时输入就是 28x28,推理时尺寸必须一致 transforms.ToTensor(), # 转为 tensor:把 PIL 图片转成 PyTorch 张量,形状为 [C, H, W](这里是 [1, 28, 28]), # 同时把像素值从 0~255 的整数缩放到 0.0~1.0 的浮点数 ]) input_tensor = transform(image) # 对图片执行上面定义的预处理流水线,得到形状为 [1, 28, 28] 的张量(通道、高、宽) input_tensor = input_tensor.unsqueeze(0) # 添加 batch 维度:在第 0 维插入一个大小为 1 的维度, # 形状从 [1, 28, 28] 变为 [1, 1, 28, 28],即 NCHW 格式(批量数、通道、高、宽), # 因为模型要求输入是一“批”图片,即使只有一张也要包成 batch input_tensor = input_tensor.to(device) # 把输入张量移动到与模型相同的设备上,设备和模型必须一致,否则计算时会报错 # 使用模型进行预测 with torch.no_grad(): # 上下文管理器:在其内部所有操作都不会记录梯度信息。 # 推理阶段不需要反向传播,关闭梯度可以节省内存、加快计算 output = model(input_tensor) # 前向传播:把图片张量送入模型,得到输出。 # 由于网络最后一层是 Softmax(dim=1),output 是形状 [1, 10] 的概率分布, # 每一维表示该图片属于数字 0~9 的概率,10 个概率之和为 1 predicted_class = torch.argmax(output, dim=1).item() # 取概率最大的那一维的索引作为预测类别: # argmax(output, dim=1) 在第 1 维(类别维)上找最大值的下标,返回形状 [1] 的张量 # .item() 把只含一个元素的张量转成普通的 Python 整数 print(f"Predicted class: {predicted_class}") # 打印预测结果,例如 "Predicted class: 0" # 下面三行是把送入模型的图片显示出来,方便人工核对预处理结果 img_display = input_tensor.squeeze().cpu().numpy() # 把张量变回 NumPy 数组以便转成图片: # squeeze() 去掉所有大小为 1 的维度,[1,1,28,28] -> [28,28] # .cpu() 把张量从 GPU 移回 CPU(NumPy 只能处理 CPU 上的数据) # .numpy() 把 PyTorch 张量转成 NumPy 数组,此时像素值仍是 0.0~1.0 的浮点数 image = Image.fromarray((img_display * 255).astype(np.uint8)) # 把 NumPy 数组转回 PIL 图片: # *255 把像素值从 0~1 还原回 0~255 # astype(np.uint8) 转成 8 位无符号整数(图片标准格式) # Image.fromarray 根据数组创建灰度图对象 image.show() # 调用系统默认的图片查看器显示这张图片 ``` ### 2.15 交叉熵损失函数 **概率分布:****把一个随机事件所有可能出现的结果列出来,并告诉每个结果发生的可能性有多大。****表述随机变量取值的概率规律。 ** + **随机变量的概率取值范围[0,1]: **这意味着任何随机变量取某个特定值的概率都在0和1之间。概率 不能为负,也不能超过1 + **所有取值概率的和必须为1: **对于一个随机变量的所有可能取值,它们的概率之和必须等于1。这 表示随机变量取任何可能值的概率分配是完备的。 + **离散分布: **当随机变量的取值是有限或可数的时候,我们称其为离散随机变量。概率分布描述了这 种情况下每个可能取值的概率。 ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787215810689-0021f24c-9049-4f7b-8093-e62313040b8b.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787215998332-12befe72-e745-4650-a8bf-4a92df609d81.png) **分类任务的本质,是让模型输出的概率分布 尽量接近 真实标签的概率分布。** ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786438146354-81e7f89a-f345-4391-a304-6b0e4b9d50df.png) **交叉熵损失函数:****用来衡量模型输出的概率分布和真实世界概率分布之间的****差异性******神经网络所预测的类别分布概率与实际类别分布概率之间的差距越小越好,即交叉熵越小越好。 **公式:** ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786940525179-50023833-0baf-43dd-8172-0aefde140bed.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786940619653-51c7d9a3-efe2-427f-9dbb-e58956461128.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786940766163-1f65b2d4-f397-4377-a203-d37f7139963f.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1786940977562-1841feff-5ca7-4f32-bb83-3ecb22a4c7bd.png) ```python import torch import torch.nn as nn import torch.nn.functional as F # 假设 3 分类,batch_size = 2 # 模型原始输出(logits) logits = torch.tensor([[2.0, 1.0, 0.1], # 样本1:偏向第0类 [0.5, 2.5, 0.3]]) # 样本2:偏向第1类 # 真实标签(类别索引,不是 one-hot向量) labels = torch.tensor([0, 1]) #直接用 CrossEntropyLoss criterion = nn.CrossEntropyLoss() loss = criterion(logits, labels) print(f"交叉熵损失: {loss.item():.4f}") ``` ```python from torch import nn import torch class FullyConnectedNet(nn.Module): def __init__(self): super().__init__() # nn.Sequential 会按顺序执行每一层。 self.layer = nn.Sequential( nn.Flatten(), # [batch, 1, 28, 28] -> [batch, 784] nn.Linear(28 * 28, 512), # 784 个像素点映射到 512 个隐藏特征 nn.ReLU(), # 增加非线性表达能力 nn.Linear(512, 256), # 继续提取更紧凑的隐藏特征 nn.ReLU(), nn.Linear(256, 128), nn.ReLU(), nn.Linear(128, 10), # 输出 10 个数字类别的分数 ) def forward(self, x): # forward 定义“数据如何从输入流到输出”。 # 输入 x 是一批图片,输出是每张图片对应 10 个数字类别的 logits。 return self.layer(x) if __name__ == '__main__': data = torch.randn(1,1,28,28) net = FullyConnectedNet() output = net(data) print(output) ``` ```python import torch import torch.nn as nn from torch.utils.data import DataLoader from torch.utils.tensorboard import SummaryWriter from torchvision import datasets, transforms from net import FullyConnectedNet import torch.nn.functional as F import tqdm #让循环在运行时,自动在控制台显示一个动态更新的进度条 # 指定日志目录 writer = SummaryWriter(log_dir='D:/code/xwdev/mlp_cro/logs') # 1. 判断是否使用CUDA device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') print(f'Using device: {device}') # 2. 准备数据 train_set = datasets.MNIST(root='D:/code/xwdev/mlp_cro/data', train=True, download=True, transform=transforms.ToTensor()) test_set = datasets.MNIST(root='D:/code/xwdev/mlp_cro/data', train=False, download=True, transform=transforms.ToTensor()) train_loader = DataLoader(dataset=train_set, batch_size=100, shuffle=True) test_loader = DataLoader(dataset=test_set, batch_size=100, shuffle=False) # 3. 创建模型 model = FullyConnectedNet() model = model.to(device) # 4. 确定损失函数 loss_fn = nn.CrossEntropyLoss() # 5. 创建优化器 使用梯度下降算法 参数的更新 opt = torch.optim.Adam(model.parameters()) # 早停法参数:当测试集损失连续 patience 轮不再变好时,提前终止训练 patience = 3 best_test_loss = float('inf') # 记录到目前为止最好的测试损失 no_improve_count = 0 # 记录测试损失连续没有变好的轮数 max_epochs = 1000 for epoch in range(max_epochs): # 6. 训练模型 model.train() train_total_loss = 0 for images, labels in tqdm.tqdm(train_loader, desc="train", total=len(train_loader)): # 将数据移动到设备 images, labels = images.to(device), labels.to(device) outputs = model(images) # 前向传播 loss = loss_fn(outputs, labels) # 计算损失 opt.zero_grad() # 清空梯度 loss.backward() # 反向传播 计算梯度 opt.step() # 更新参数 train_total_loss += loss.item() train_avg_loss = train_total_loss / len(train_loader) print(f'Epoch {epoch+1}/{max_epochs}, Loss: {train_avg_loss:.4f}') # 7. 测试模型 model.eval() test_total_loss = 0 test_total_acc = 0 #禁用梯度计算 #在推理时,我们不需要反向传播,因此不需要计算损失函数对参数的梯度 with torch.inference_mode(): for images, labels in tqdm.tqdm(test_loader, desc="test", total=len(test_loader)): images, labels = images.to(device), labels.to(device) outputs = model(images) # 计算损失 loss = loss_fn(outputs, labels) test_total_loss += loss.item() pred = torch.argmax(outputs, dim=1) acc = torch.eq(pred, labels).float().mean() test_total_acc += acc.item() test_avg_acc = test_total_acc / len(test_loader) print(f'epoch:{epoch+1}, Test Accuracy: {test_avg_acc:.4f}') test_avg_loss = test_total_loss / len(test_loader) print(f'epoch:{epoch+1},Test Loss: {test_avg_loss:.4f}') # 记录数据 writer.add_scalars('Loss/train', {'train_avg_loss': train_avg_loss, 'test_avg_loss': test_avg_loss}, epoch) writer.add_scalar('Accuracy/test', test_avg_acc, epoch) # 8. 早停判断与模型保存 if test_avg_loss < best_test_loss: # 测试损失变好了:更新最好成绩,重置计数器,保存当前最优模型 best_test_loss = test_avg_loss no_improve_count = 0 torch.save(model.state_dict(), 'D:/code/xwdev/mlp_cro/model/mnist_net_best.pth') print(f'测试损失改善,保存最优模型 (best loss: {best_test_loss:.4f})') else: # 测试损失没有变好:计数器加一 no_improve_count += 1 print(f'测试损失未改善 ({no_improve_count}/{patience})') if no_improve_count >= patience: # 连续 patience 轮没有变好,提前终止训练 print(f'早停:测试损失连续 {patience} 轮未改善,在第 {epoch+1} 轮终止训练') break ``` ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787203904167-111aa07e-1421-4c80-ad09-84e5f5df8015.png) ![](https://cdn.nlark.com/yuque/0/2026/png/21571931/1787203923212-198e8b05-84a1-4700-a9ac-8c4dcfadf5b6.png) ```python import torch from net import FullyConnectedNet from PIL import Image import numpy as np from torchvision import transforms import torch.nn.functional as F device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = FullyConnectedNet() model_path = "D:/code/xwdev/mlp_cro/model/mnist_net_best.pth" # 加载训练好的权重到模型中: # 1. torch.load(model_path, map_location=device) 从磁盘读取权重字典(state_dict), # map_location=device 保证权重被映射到当前使用的设备上(防止在 GPU 上训练的模型在纯 CPU 机器上加载失败) # 2. model.load_state_dict(...) 把读取到的权重参数逐个复制进模型的对应层中 model.load_state_dict(torch.load(model_path, map_location=device)) model.to(device) # 把模型的所有参数和缓冲区移动到指定设备上(GPU 或 CPU),之后前向计算就在该设备上进行 model.eval() # 读取图片并进行预处理 image_path = "D:/code/xwdev/mlp_cro/mnist_pytorch_samples/image_0000_label_7.png" image = Image.open(image_path) # 用 Pillow 打开图片文件,得到一个 PIL Image 对象 # 定义图像预处理流水线,transforms.Compose 会把多个变换按顺序串联起来依次执行 transform = transforms.Compose([ transforms.Grayscale(num_output_channels=1), # 转为灰度图:无论原图是 RGB 还是 RGBA,都强制变成单通道灰度图(MNIST 是灰度图,通道数必须为 1) transforms.Resize((28, 28)), # 调整大小为 28x28:MNIST 模型训练时输入就是 28x28,推理时尺寸必须一致 transforms.ToTensor(), # 转为 tensor:把 PIL 图片转成 PyTorch 张量,形状为 [C, H, W](这里是 [1, 28, 28]), # 同时把像素值从 0~255 的整数缩放到 0.0~1.0 的浮点数 ]) input_tensor = transform(image) # 对图片执行上面定义的预处理流水线,得到形状为 [1, 28, 28] 的张量(通道、高、宽) input_tensor = input_tensor.unsqueeze(0) # 添加 batch 维度:在第 0 维插入一个大小为 1 的维度, # 形状从 [1, 28, 28] 变为 [1, 1, 28, 28],即 NCHW 格式(批量数、通道、高、宽), # 因为模型要求输入是一“批”图片,即使只有一张也要包成 batch input_tensor = input_tensor.to(device) # 把输入张量移动到与模型相同的设备上,设备和模型必须一致,否则计算时会报错 # 使用模型进行预测 with torch.no_grad(): # 上下文管理器:在其内部所有操作都不会记录梯度信息。 # 推理阶段不需要反向传播,关闭梯度可以节省内存、加快计算 output = model(input_tensor) # 前向传播:把图片张量送入模型,得到输出。 output = F.softmax(output, dim=1) # Softmax(dim=1),output 是形状 [1, 10] 的概率分布, # 每一维表示该图片属于数字 0~9 的概率,10 个概率之和为 1 predicted_class = torch.argmax(output, dim=1).item() # 取概率最大的那一维的索引作为预测类别: # argmax(output, dim=1) 在第 1 维(类别维)上找最大值的下标,返回形状 [1] 的张量 # .item() 把只含一个元素的张量转成普通的 Python 整数 print(f"Predicted class: {predicted_class}") # 打印预测结果,例如 "Predicted class: 0" # 下面三行是把送入模型的图片显示出来,方便人工核对预处理结果 img_display = input_tensor.squeeze().cpu().numpy() # 把张量变回 NumPy 数组以便转成图片: # squeeze() 去掉所有大小为 1 的维度,[1,1,28,28] -> [28,28] # .cpu() 把张量从 GPU 移回 CPU(NumPy 只能处理 CPU 上的数据) # .numpy() 把 PyTorch 张量转成 NumPy 数组,此时像素值仍是 0.0~1.0 的浮点数 image = Image.fromarray((img_display * 255).astype(np.uint8)) # 把 NumPy 数组转回 PIL 图片: # *255 把像素值从 0~1 还原回 0~255 # astype(np.uint8) 转成 8 位无符号整数(图片标准格式) # Image.fromarray 根据数组创建灰度图对象 image.show() # 调用系统默认的图片查看器显示这张图片 ```