Pytorch与视觉检测
# Pytorch
# Pytorch核心概念
- 核心概念:什么是张量 (Tensor)?
- 标量 (Scalar):一个数字(例如 5 ),这是 0 维张量。
- 向量 (Vector):一串数字排成一行或一列(例如 [1,2,3] ),这是 1 维张量。
- 矩阵 (Matrix):数字排成二维表格(例如图片中的 3×3 表格),这是 2 维张量。
- 张量 (Tensor):任意维度的数字容器。它可以是 3 维(像魔方一样的立方体数据)、4 维(比如一批彩色照片的数据:数量×高×宽×颜色通道)甚至更高。
老师的点拨:
在大厂的算法面试中,你经常需要处理图像(Image)、文本(Text)或视频(Video)。
- 一张灰度图是一个28×28 的矩阵(2D Tensor)。
- 一张 RGB 彩图是 3×28×28 的立体数据(3D Tensor)。
- 如果你一次给神经网络喂 64 张图,那就是 64×3×28×28(4D Tensor)。
所以,Tensor 就是 AI 世界里的“数据集装箱”。
- 为什么要用 Tensor 而不是 Numpy?
两者的区别就是 Tensors 可以应用到 GPU 上加快计算速度。
这一点至关重要。Numpy 是非常优秀的科学计算库,但它主要运行在 CPU 上。
- CPU:像一位博学的老教授,擅长处理复杂的逻辑判断,但一次只能算几个数。
- GPU (显卡):像几千个小学生,虽然每个人只会做简单的加减乘除,但他们可以同时做几千道题(并行计算)。
深度学习的本质是海量的矩阵乘法运算。如果用 CPU 跑,训练一个大模型可能需要几年;而用 GPU 跑 Tensor 运算,可能只需要几天。
扩展知识(面试加分项):
除了 GPU 加速,PyTorch 的 Tensor 还有一个 Numpy 没有的超级功能:自动求导 (Autograd)。
当你定义 x = torch.tensor(..., requires_grad=True) 时,PyTorch 会记录你在 x 上做过的所有运算。当你最后调用 .backward() 时,它能自动帮你算出梯度(导数)。这是训练神经网络(反向传播算法)的基础,而 Numpy 做不到这一点。
- 代码实战解析
- 第一段代码:创建 Tensor
import torch
x = torch.Tensor([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(x)
2
3
这里直接传入了一个 Python 的列表嵌套(List of Lists),创建了一个 3×3 的矩阵。
输出:右侧显示 tensor([[1., 2., 3.], ...])。注意看数字后面有个小数点 .。
torch.Tensor 是 torch.FloatTensor 的别名。这意味着即使你输入的是整数 1,它也会默认把它变成浮点数 1.。在深度学习中,我们几乎总是使用浮点数(Float32)来进行计算,因为精度足够且速度快。
- 第二段代码:初始化
x = torch.zeros(3)
print(x)
# 输出: tensor([0., 0., 0.])
2
3
创建一个包含 3 个 0 的向量。这在初始化神经网络的偏置项(Bias)或者占位符时非常常用。类似的还有 torch.ones()(全1)和 torch.empty()(未初始化,速度最快但数据是随机的垃圾值)。
- 第三段代码:与 Numpy 的互转
import numpy as np
a = np.ones(3)
x = torch.from_numpy(a)
print(x)
# 输出: tensor([1., 1., 1.], dtype=torch.float64)
2
3
4
5
这里展示了如何将 Numpy 数组转换为 PyTorch 张量。
注意输出的数据类型是 torch.float64。这是因为 Numpy 的 np.ones 默认也是双精度浮点数(float64)。
在实际的大厂工程中,为了节省显存(GPU 内存很贵!),我们通常会把数据转为 float32。你可能会经常写这样的代码:
x = torch.from_numpy(a).float() # 强制转为 float32
torch.from_numpy() 创建的 Tensor 和原来的 Numpy 数组 a 共享同一块内存。如果你修改了 x,a 也会跟着变。这避免了数据复制,提高了效率。
光懂这些 API 是不够的,你需要建立以下思维
Shape (形状) 是第一直觉:
在处理 Tensor 时,脑子里要时刻清楚它的 Shape 是什么。比如 (Batch_Size, Channels, Height, Width)。如果 Shape 对不上,程序就会报错(RuntimeError)。学会熟练使用 x.shape 和 x.view() / x.reshape() 来改变数据的形状。设备管理 (Device):
代码里写的 Tensor 默认在 CPU 上。如果你想让它去 GPU 加速,你需要显式地移动它:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
x = x.to(device)
2
这是入职后写代码的标准起手式。
- 不要害怕看文档:
PyTorch 的官方文档非常好。包括加减乘除、矩阵运算等。以后遇到不会的变换,第一时间查文档,搜索关键词如 "PyTorch tensor operations"。
# Tensor运算
- 逐元素运算 (Element-wise Operations)
x = torch.Tensor([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(x.pow(-1.0))
print(x.pow(2.0))
2
3
这里的 .pow() 是 power(幂)的缩写。
x.pow(2.0) 很好理解,就是矩阵里每个数字都平方
x.pow(-1.0) 是什么意思?数学上 x−1 等于 1/x ,也就是求倒数。你看输出结果:1 变成了 1.0 ,2 变成了 0.5 ,3 变成了 0.3333 。
这种“对矩阵里每个元素单独操作”的行为,我们叫逐元素运算。除了 .pow(),还有 .sqrt() (开根号), .exp() (求 e 的次方), .log() (求对数)。
在深度学习中,激活函数(比如 ReLU, Sigmoid)本质上就是对 Tensor 做逐元素运算。
- 维度与求和 (Dimension & Summation) —— 重点难点
这是新手最容易晕的地方,请务必集中注意力。
# 按列求和
print(x.sum(dim=0)) # 输出 tensor([12., 15., 18.])
# 按行求和
print(x.sum(dim=1)) # 输出 tensor([ 6., 15., 24.])
2
3
4
5
很多初学者记不住 dim=0 和 dim=1 谁是行谁是列。我教你一个口诀:“Dim 是谁,就消灭谁”。
dim=0 (第0维):对于二维矩阵,第0维是行(纵向的索引)。当你执行 sum(dim=0) 时,你是在沿着行的方向压缩。想象一下,你把这三行数据像千层饼一样压扁成一行。
- 第一列:1+4+7=12
- 第二列:2+5+8=15
- 第三列:3+6+9=18
- 结果:原本 3×3 的矩阵,变成了 1×3 的向量。行被“消灭”了,剩下了列的信息。
dim=1 (第1维):第1维是列(横向的索引)。执行 sum(dim=1) 时,你是把每一行里的数字加在一起。
- 第一行:1+2+3=6
- 第二行:4+5+6=15
- ...
- 结果:原本 3×3 的矩阵,变成了 3×1 的向量。列被“消灭”了,剩下了行的信息。
为什么要这么做?
在神经网络中,我们经常要计算 Loss(损失函数)。比如你预测了 100 张图片的误差,最后你需要把这 100 个误差加起来求平均值,这时候就要用到 sum 或者 mean 来进行维度的缩减。
- 链式调用与广播机制 (Chaining & Broadcasting)
temp = x.sum(dim=1).pow(-1.0)
这里展示了 PyTorch 优雅的链式调用。
- 先算 x.sum(dim=1):得到每一行的和,结果是 [6, 15, 24]。
- 紧接着对这个结果 .pow(-1.0):求倒数,变成 [1/6, 1/15, 1/24],也就是 [0.1667, 0.0667, 0.0417]。
- 对角矩阵嵌入 (Diagonal Embedding)
print(temp.diag_embed()) # 接第三步
# tensor([[0.1667, 0.0000, 0.0000],
# [0.0000, 0.0667, 0.0000],
# [0.0000, 0.0000, 0.0417]])
2
3
4
输出变成了一个 3×3 的矩阵,只有对角线上有数字 [0.1667, 0.0667, 0.0417],其他地方全是 0。
这叫对角矩阵。temp 是一个向量(1维)。diag_embed() 把这个向量“塞”进了一个单位矩阵的对角线位置。
在线性代数和高阶深度学习中,对角矩阵非常重要:
- 特征值分解:很多矩阵运算为了加速,会转换成对角矩阵处理。
- 注意力机制 (Attention Mechanism):在处理序列数据时,有时候我们需要构建一个掩码(Mask)或者权重矩阵,只关注对角线上的特定关系。
- 协方差矩阵:在统计学和高斯分布中,如果变量之间互不相关,协方差矩阵就是一个对角矩阵。
# Torch功能函数
- 核心概念:nn vs nn.functional (F)
import torch.nn as nn
import torch.nn.functional as F
# nn.xxx 和 nn.functional.xxx 的实际功能是相同的。
2
3
很多新手会困惑:既然功能一样,为什么要搞两套?
nn.Conv2d (类/Class):这是“带状态的厨师”。它不仅知道怎么做卷积,它还自己保管着调料(权重 weights 和偏置 bias)。当你实例化它时,它会自动初始化参数,并且这些参数会随着训练自动更新。
F.conv2d (函数/Function):这是“纯粹的动作”。它只负责执行卷积这个动作,但它不保管调料。你必须手动把权重矩阵传给它(如图中代码所示 weight=...)。
实战建议:
- 对于有参数的层(如卷积层 Conv、全连接层 Linear),我们通常用 nn.xxx,因为我们需要框架帮我们管理参数。
- 对于没有参数的操作(如激活函数 ReLU、池化 Pooling),我们通常直接用 F.xxx,因为它更轻量,不需要实例化对象。
- 卷积操作 (Convolution) —— AI 的眼睛
- F.conv2d(...):二维卷积。
- 场景:处理图片。图片是二维的(高x宽),所以用 2D。
- 参数详解:
- input: 输入的图片数据(Tensor)。
- weight: 卷积核(Filter)。这是最重要的参数,它是用来提取特征的(比如提取边缘、纹理)。
- stride=1: 步长。卷积核每次移动几个像素。步长越大,输出图片越小。
- padding=0: 填充。是否在图片周围补一圈 0。 Padding 可以防止图片越卷越小。
- groups=1。这是一个进阶参数。默认是 1,表示所有输入通道都参与运算。如果你把它设为和输入通道数一样,那就是著名的深度可分离卷积 (Depthwise Convolution),这是移动端 AI(比如手机人脸识别)加速的关键技术。
- 激活函数 (Activation) —— 神经网络的灵魂
F.relu(input, ...)
- 数学公式: f(x)=max(0,x) 。小于 0 的变 0,大于 0 的保持不变。
- 作用:引入非线性。如果没有 ReLU,无论你的网络有多少层,本质上只是一个巨大的线性回归模型,什么都学不会。
- nplace=True 意味着直接在原内存上修改数据(省显存,但可能破坏计算图导致报错)。新手默认用 False,安全第一。
- 池化 (Pooling) —— 数据压缩
F.max_pool2d(...)
- 作用:下采样(Downsampling)。
- 原理:在一个小窗口(比如 2×2 )里,只取最大值,扔掉其他值。
目的:
- 减小数据量,加快计算。
- 提取最显著的特征(比如只要检测到这里有只眼睛,具体哪个像素点是眼睛不重要)。
- Dropout —— 防止死记硬背
F.dropout(input, p=0.5, training=True)
在训练过程中,随机把一部分神经元“关掉”(变成 0)。图中说 p=0.5 就是关掉 50%。
为什么这么做?
这就好比老师为了防止学生作弊(过拟合),考试时 randomly 遮住书上的某些字。强迫网络不要依赖某一个特定的神经元,而是学习更鲁棒的特征。
关键点 training=True:
一点极其重要!只有在训练时才开启 Dropout。在测试(推理)时,必须把它关掉(或者在代码里通过 model.eval() 自动关掉),否则你的预测结果每次都会不一样。
# 补充:什么是卷积
卷积(Convolution)确实是深度学习里最抽象、但也是最核心的概念。别被那些复杂的数学公式吓到了,我们抛开公式,用最直观的“手电筒探照”和“滤镜”的例子来彻底搞懂它。
- 什么是卷积?——“拿着放大镜找特征”
想象你手里拿着一张很大的照片(比如一张 32×32 像素的猫脸图片),你的任务是找出照片里的“眼睛”在哪里。
你会怎么做?
你不会一眼看全图,而是会拿一个小放大镜(比如 3×3 大小),在照片上从左到右、从上到下一点点移动扫描。
- 当放大镜移到空白处(背景):放大镜下的像素都是杂乱无章的,和你的目标不匹配,结果就是“没发现”。
- 当放大镜移到眼睛位置:放大镜下的像素排列正好符合“眼睛”的特征(比如中间黑、两边白),这时候你就会大喊一声:“找到了!”
这个过程,就是卷积!
- 输入图像 (Input):那张大照片。
- 卷积核/滤波器 (Kernel/Filter):那个小放大镜。它是一个小小的矩阵(比如 3×3 ),里面存着我们要寻找的“特征图案”。
- 特征图 (Feature Map):你扫描完整张照片后,记录下所有“找到特征”的位置,拼成的一张新地图。
- 计算机是怎么“看”的?——“加权求和”
计算机不懂什么是“眼睛”,它只懂数字。 假设我们的卷积核(放大镜)长这样(这是一个专门用来检测“垂直边缘”的核):

(左边是 -1,右边是 1,中间是 0)
当这个核移动到图像的某个 3×3 区域时,计算机会做两步操作:
- 对应相乘:把核里的 9 个数字,和图像上对应的 9 个像素值,一个个乘起来。
- 全部相加:把这 9 个乘积加起来,得到一个最终数值。
结果的秘密
- 如果图像这块区域没有垂直边缘(颜色很均匀),正负抵消,加出来的结果接近 0(黑色)。
- 如果图像这块区域正好有垂直边缘(左边暗右边亮),负数乘小数,正数乘大数,加出来的结果就是一个很大的正数(白色)。
所以,卷积的本质就是:通过乘法运算,提取出图像中符合卷积核特征的信号。
# 自动求导
如果说前面的卷积和 Tensor 操作是我们在搭建一辆赛车的“零件”,那么 Autograd 就是这辆赛车的“自动驾驶导航系统”。没有它,你的神经网络就只是一堆不会学习的死代码。
# 自动求导 (Autograd):让机器自己算数学题
在传统的编程中,如果你想求一个函数的导数(斜率),你需要自己推导公式,然后写成代码。但在深度学习中,网络可能有几亿个参数,人工推导导数是不可能的。
PyTorch 的 autograd 模块就是为了解决这个问题而生的。
核心逻辑三步走
- 开启追踪 (requires_grad=True)
x = torch.tensor(2.0, requires_grad=True)
通俗解释:这就好比你在 x 身上装了一个 “GPS 定位器”。你告诉 PyTorch:“嘿,这个变量 x 很重要,它是我们要调整的参数(比如神经网络的权重),请你帮我盯着它,记录它参与的所有运算。”
默认情况:普通的输入数据(如图片像素)不需要求导,所以默认是 False;只有模型参数需要设为 True。
- 构建关系 (前向传播)
y = x ** 2
这里定义了 y 和 x 的关系( y=x2)。因为 x 带着 GPS,PyTorch 在后台悄悄画了一张图(计算图),记住了:“ y 是由 x 平方得来的”。
- 反向求解 (.backward())
y.backward()
# 此时 x.grad 会自动变成 4.0
2
- 通俗解释:这是最关键的一步!你调用 .backward(),相当于下令:“开始反向推导!”
- 数学原理:根据链式法则, dy/dx 是 x2 的导数,即 2x 。当 x=2 时,梯度就是 4 。
- 结果:PyTorch 算出结果后,不会直接返回给你,而是把它存在 x 的 .grad 属性里。你可以随时通过 print(x.grad) 看到答案 tensor(4.)。
在训练神经网络时,我们所谓的“学习”,本质上就是不断计算 Loss 对 Weight 的梯度(.backward()),然后根据这个梯度去更新 Weight(减去一点点梯度)。Autograd 帮你完成了最繁琐的微积分工作。
# 动态计算图 (Dynamic Graph):PyTorch 的灵魂
动态图 vs 静态图。这是 PyTorch 能够战胜早期 TensorFlow (1.x) 成为学术界和工业界首选的关键原因。
什么是计算图?
计算图就是把代码里的运算画成流程图。比如 y=(a+b)∗c ,就是一个先加法后乘法的流程图。静态图 (TensorFlow 1.x 的模式) —— “先画图纸,再盖楼”
流程:你必须先用代码定义好整个复杂的网络结构(画图),编译通过,确认没问题了,才能喂入数据开始跑(施工)。
缺点:如果你想在中间加个 if 判断(比如:如果图片太暗就不处理),或者写个 for 循环,在静态图里非常痛苦,因为你不能在“图纸”阶段决定数据的流向。调试也很难,报错信息往往看不懂。动态图 (PyTorch 的模式) —— “边盖楼,边设计”
流程:代码执行到哪里,图就建到哪里。
优势:
- Pythonic(符合直觉):PyTorch 的代码就是标准的 Python 代码。你想用 if 语句?直接用!想用 for 循环处理变长句子?随便写!
- 调试神器:你可以像调试普通 Python 脚本一样,在任意一行打断点(Breakpoint),查看 Tensor 的数值。这对排查 Bug 来说简直是救命稻草。
- 灵活性:每次前向传播(Forward),计算图都可以不一样。这在处理自然语言处理(NLP)中长度不一的句子时特别有用。
# 波士顿房价预测Pytorch版本
# 第一步:导入工具包
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import TensorDataset, DataLoader
import pandas as pd
import numpy as np
import random
import matplotlib.pyplot as plt
from copy import deepcopy
2
3
4
5
6
7
8
9
核心知识点讲解
torch (PyTorch):
- 这是我们的核心武器。它是一个用于深度学习的框架。
- 为什么用它? 因为它能自动计算导数(反向传播),并且能利用显卡 (GPU) 加速计算。
nn (Neural Networks):
- 这是 torch 里的一个模块,专门用来搭建神经网络。
- 比如代码后面会用到的 Linear (全连接层)、ReLU (激活函数),都住在这个模块里。
TensorDataset & DataLoader:
比喻:想象你要搬砖(训练数据)。- TensorDataset 是把散乱的砖头打包成一个个标准的“包裹”。
- DataLoader 是负责搬运的卡车。它每次只运一车(Batch,比如32个样本),而不是一次性把所有砖头都塞进模型嘴里(那样会撑死/显存溢出)。
deepcopy
作用:深拷贝。在训练时,我们需要保存“最好的模型权重”。如果不用深拷贝,只是简单的赋值,Python 可能会让两个变量指向同一个内存地址。当模型继续训练变差时,那个“备份”也会跟着变差。deepcopy 确保我们存下来的是一个完全独立的快照。
# 第二步:固定随机种子
def set_seed(seed=42):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed)
set_seed(42)
2
3
4
5
6
7
8
核心知识点讲解
什么是随机种子 (Seed)?
计算机产生的“随机数”其实是伪随机。如果你给计算机同一个起始数字(种子),它生成的后续随机数序列是一模一样的。为什么要固定它?
可复现性。神经网络的初始化权重是随机的,打乱数据的顺序也是随机的。
如果不写这几行,你今天跑出来 MAE 是 1.85,明天跑可能是 1.90。你会分不清是因为改了代码变好了,还是因为运气好随机到了好的初始值。固定种子后,只要代码不变,结果永远不变。
# 第三步:数据清洗与特征工程 —— 最关键的一步
print("正在加载 housing.csv ...")
df = pd.read_csv('housing.csv', sep='\s+', header=None)
df.dropna(inplace=True)
# 【关键优化】对偏态严重的特征进行 Log 变换
skewed_cols = [0, 1, 2, 3, 5, 7]
for col in skewed_cols:
df[col] = np.log1p(df[col])
2
3
4
5
6
7
8
核心知识点讲解
什么是偏态分布 (Skewed Distribution)?
- 看第 0 列 CRIM (犯罪率)。大部分区域犯罪率很低(接近0),但有几个区犯罪率极高(比如 80)。
- 问题:神经网络喜欢“正态分布”(钟形曲线)的数据。这种长尾巴数据会让模型为了拟合那几个极端值,而忽略了大部分普通值的规律。
np.log1p(x) 是什么魔法?
- 公式是 ln(x+1) 。
- 作用:它能压缩大数值,拉伸小数值。
- 比如:原数据是 [1, 10, 100, 1000]。取对数后变成 [0.69, 2.39, 4.61, 6.9]。
- 原本 1000 是 1 的 1000 倍,现在差距缩小了。这让数据变得更“平滑”,模型学起来更容易。这是降低 MAE 的秘诀之一。
# 第四步:数据切分与标准化
# ... 切分训练集和测试集代码 ...
# 标准化
x_mean, x_std = X_train.mean(axis=0), X_train.std(axis=0)
X_train = (X_train - x_mean) / x_std
# ... y 的标准化同理 ...
2
3
4
5
6
什么是标准化 (Standardization)?
- 公式: z=(x−μ)/σ (减去均值,除以标准差)。
- 原因:数据单位不统一。
- RM (房间数) 范围是 3~9。
- TAX (税率) 范围是 100~700。
- 如果不处理,模型会觉得 TAX 的变化比 RM 重要得多(因为数值大),导致梯度更新时被大数值特征主导。标准化后,所有特征都变成了均值为0,方差为1的标准正态分布,站在同一起跑线上。
注意细节:
- 计算均值和方差时,只能用训练集 (X_train)!
- 测试集必须使用训练集的均值和方差来转换。否则就是“作弊”(数据泄露),因为现实预测时,你不可能知道未来数据的均值。
# 第五步:定义神经网络
class HighPrecisionModel(nn.Module):
def __init__(self, input_dim):
super(HighPrecisionModel, self).__init__()
self.network = nn.Sequential(
nn.Linear(input_dim, 128), # 第一层:输入 -> 128个神经元
nn.BatchNorm1d(128), # 批归一化
nn.ReLU(), # 激活函数
nn.Dropout(0.2), # 丢弃法
nn.Linear(128, 64), # 第二层:128 -> 64
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(64, 32), # 第三层:64 -> 32
nn.ReLU(),
nn.Linear(32, 1) # 输出层:32 -> 1 (房价)
)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
nn.Linear (全连接层):
- 这是神经网络的基本积木。公式是 y=Wx+b 。
- input_dim, 128 意思是:接收原始特征(比如13个),通过矩阵乘法映射到 128 个新的特征维度。这叫做特征提取。
nn.ReLU (激活函数)
nn.BatchNorm1d (批归一化):
- 作用:在每一层输出后,强行把数据拉回均值为0、方差为1。
- 好处:防止数据在层层传递中变得太大或太小(梯度消失/爆炸),能让训练速度快几倍,且更稳定。
nn.Dropout(0.2) (丢弃法):
- 防过拟合神器。
- 训练时,随机把 20% 的神经元“关掉”(设为0)。
- 原理:强迫网络不要依赖某几个特定的神经元,而是要学习更 robust(鲁棒)的特征。就像团队合作,如果随时有人请假,其他人就必须学会补位,团队整体能力反而更强。
我们为什么要设计成 128 -> 64 -> 32 -> 1 这样的“倒金字塔”结构
而不是随便写几个数字呢?这里面包含了深度学习架构设计的三大核心原则。
- 为什么是“倒金字塔”结构(128 -> 64 -> 32 -> 1)?
这叫做特征提取与抽象的过程。
第一层(128个神经元):信息爆炸与特征扩展
- 我们的原始输入只有 13 个特征(如犯罪率、房间数)。如果一开始就用很少的神经元,模型可能连最基础的规律都抓不住。
- 我们先把这 13 个特征映射到 128 维的高维空间。这就像是把一个简单的面团,揉开后铺成一大张面皮,让模型有足够大的“画布”去捕捉各种复杂的特征组合。
中间层(64 -> 32个神经元):逐步提纯与抽象
- 随着层数加深,我们不再需要那么多神经元了。模型开始把前面学到的零散特征进行整合。
- 比如,它可能把“低犯罪率”和“高房间数”这两个特征合并,抽象出一个叫“宜居度”的高级特征。神经元数量逐渐减少,就像是一个漏斗,把海量的信息一步步过滤、浓缩成最核心的精华。
输出层(1个神经元):回归任务的终极目标
- 因为我们预测的是“房价”这一个具体的数字(回归任务),所以最后必须把所有精华浓缩成 1 个输出。
- 为什么每一层都要加上 BatchNorm1d 和 ReLU?
这是为了保证数据在层层传递中“不变质”且“有活力”。
- nn.BatchNorm1d(批归一化):数据的“稳压器”
- 作用:在数据进入下一层之前,强行把数据的分布拉回到均值为 0、方差为 1 的标准状态。
- 为什么需要? 如果没有它,数据经过几层矩阵乘法后,数值可能会变得极大或极小(梯度消失/爆炸),导致模型根本学不动。BN 层保证了每一层接收到的数据都是“健康”的,从而加速收敛。
- 为什么只在前面加 Dropout(0.2),后面不加了?
这是为了平衡“防过拟合”与“信息保留”。
nn.Dropout(0.2):防死记硬背的“教官”
- 作用:在训练时,随机把 20% 的神经元关掉(设为0)。
- 为什么需要? 波士顿数据集只有 500 多条数据,模型稍微复杂一点就容易“死记硬背”(过拟合)。Dropout 强迫模型不要过度依赖某些特定的神经元,从而提升泛化能力。
为什么最后几层(64->32, 32->1)不加 Dropout?
- 因为到了最后两层,信息已经被浓缩得非常少了。如果这时候再随机关掉一部分神经元,会导致大量关键信息丢失,模型就“学傻了”,连正常的规律都学不会了。所以,Dropout 通常只加在宽大的隐藏层,输出层前一般不加。
# 第六步:训练循环与优化器
criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-5)
2
3
MSELoss (均方误差损失):
- 公式: (ypred−ytrue)2 。
- 这是回归任务最常用的损失函数。它惩罚大误差(平方级惩罚)。如果预测偏差 2,Loss 是 4;偏差 4,Loss 是 16。这迫使模型极力避免大错。
Adam 优化器:
- 它是 SGD (随机梯度下降) 的升级版。
- 它不仅看当前的梯度方向,还记住了过去的梯度方向(动量)。就像下山,SGD 是只看脚下,Adam 是带着惯性冲下去,遇到坑能跨过去,收敛更快。
weight_decay (L2 正则化):
- 在 Loss 后面加了一项惩罚项:λ∑w2 。
- 目的:限制权重 w 不要变得太大。权重太大通常意味着模型在死记硬背噪声。这是一种温和的防过拟合手段。
CosineAnnealingLR (余弦退火学习率):
- 学习率 (LR) 是模型更新的步长。
- 策略:开始时 LR 大,快速下山;后来 LR 按余弦曲线慢慢变小,在山底精细搜索最低点。
- 如果不衰减,模型可能在最低点附近来回震荡,永远停不下来。
num_epochs = 200 # 最多练 200 轮
best_val_loss = float('inf') # 记录最好的测试集分数,初始设为正无穷大
patience = 20 # 容忍度:允许连续 20 轮不进步
counter = 0 # 计数器:记录连续多少轮没进步了
best_model_state = None # 用来存放“最强状态”的模型参数
2
3
4
5
核心循环:
这是代码最长的部分,每一轮(Epoch)都会经历以下三个步骤:
- 步骤 1:上考场(训练集训练)
for epoch in range(num_epochs): # 开始第 1 到 200 轮
model.train() # 【关键】把模型切换到“训练模式”(启用 Dropout 和 BN)
epoch_train_loss = 0.0 # 这一轮的总分清零
for batch_X, batch_y in train_loader: # 每次从卡车里拿出一批(32个)数据
outputs = model(batch_X) # 1. 模型做题(前向传播)
loss = criterion(outputs, batch_y) # 2. 打分器算分(计算 Loss)
optimizer.zero_grad() # 3. 【关键】把上一批数据的梯度(修改痕迹)清零!
loss.backward() # 4. 反向传播,计算每个参数该改多少
optimizer.step() # 5. 优化器正式修改参数
epoch_train_loss += loss.item() * batch_X.size(0) # 把这一批的分数累加到总分里
2
3
4
5
6
7
8
9
10
11
12
13
- model.train():非常重要!它告诉模型:“现在是上课时间,Dropout 可以随机丢弃神经元,BatchNorm 可以更新均值方差。”
- optimizer.zero_grad():PyTorch 默认会累积梯度。如果不手动清零,第 2 批数据的梯度会加在第 1 批上面,模型就彻底乱套了。
- 步骤 2:模拟考(验证集评估)
scheduler.step() # 一轮训练结束,调节器把下一轮的学习率调小一点
model.eval() # 【关键】切换到“考试模式”(关闭 Dropout,固定 BN)
epoch_test_loss = 0.0
with torch.no_grad(): # 【关键】告诉 PyTorch:考试时不用算梯度,节省大量内存和计算量
for batch_X, batch_y in test_loader:
predicted = model(batch_X)
loss = criterion(predicted, batch_y)
epoch_test_loss += loss.item() * batch_X.size(0)
2
3
4
5
6
7
8
9
- model.eval():考试时,Dropout 必须全部开启(不能丢神经元),BN 必须使用训练时统计好的固定均值。
- torch.no_grad():反向传播是最吃内存的。考试只为了看分数,不需要修改参数,所以关掉梯度计算能让速度快好几倍。
# 第七步:早停法 (Early Stopping)
if avg_test_loss < best_val_loss:
best_val_loss = avg_test_loss
counter = 0
best_model_state = deepcopy(model.state_dict())
else:
counter += 1
if counter >= patience:
print(f"\n>>> 触发早停...")
break
2
3
4
5
6
7
8
9
为什么要早停?
- 训练不是越久越好。
- 前期:模型在学习规律(Train Loss 降,Test Loss 降)。
- 后期:模型开始背答案(Train Loss 继续降,但 Test Loss 开始上升)。这就是过拟合。
逻辑:
- 我们盯着验证集 (Test Loss)。
- 如果连续 patience (比如20) 轮,验证集 Loss 都没有创新低,说明模型已经学不动了,再练就是死记硬背了。
- 立刻停止,并恢复到最后一次最好的状态 (load_state_dict)。
算平均分 & 决定是否喊停
# 算出这一轮训练集和测试集的平均 Loss
avg_train_loss = epoch_train_loss / len(train_dataset)
avg_test_loss = epoch_test_loss / len(test_dataset)
train_losses.append(avg_train_loss) # 存进列表,最后用来画图
test_losses.append(avg_test_loss)
# --- 早停法核心逻辑 ---
if avg_test_loss < best_val_loss: # 如果这次考出了历史最好成绩
best_val_loss = avg_test_loss # 更新历史最好成绩
counter = 0 # 计数器清零(因为进步了)
best_model_state = deepcopy(model.state_dict()) # 【关键】深拷贝保存当前最强模型
else: # 如果这次没考好
counter += 1 # 没进步的轮数 +1
if counter >= patience: # 如果连续没进步的轮数超过了容忍度(20轮)
print(f"\n>>> 触发早停...")
break # 强制跳出 for 循环,停止训练
# 每 20 轮打印一次当前的进度和学习率
if (epoch + 1) % 20 == 0:
current_lr = optimizer.param_groups[0]['lr']
print(f'Epoch [{epoch + 1}/{num_epochs}], LR: {current_lr:.6f}...')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
deepcopy(model.state_dict()):state_dict() 是模型当前所有参数的字典。我们必须用深拷贝把它独立存下来。如果不拷贝,随着后面训练变差,这个备份也会跟着变差。
- 恢复最强状态
if best_model_state:
model.load_state_dict(best_model_state)
2
- 训练停止时,模型可能正处于“过拟合变差”的状态。
- 这行代码的作用是:时光倒流。把我们在训练过程中偷偷保存的那个“历史最高分模型”的参数,重新加载回模型里。
# 第八步:反归一化与评估
y_test_real = y_test_scaled * y_std + y_mean
test_preds_real = test_preds_scaled.numpy() * y_std + y_mean
2
# 图像识别技术
# 引子:为什么看图这么难?(少女还是老妇)

经典的视错觉图
你看这张图,既像少女又像老妇。这说明“识别”不仅仅是眼睛的事,更是大脑的事。
在计算机领域,这叫语义歧义性。对机器来说,图片只是一堆数字矩阵(像素点)。机器如果只盯着局部看(比如那个黑色的钩子),它可能认为是老妇的鼻子;但如果它结合整体轮廓看,那就是少女的下巴。
传统的计算机程序是“死板”的,它很难像人一样灵活切换视角。
# 过去的方法:传统图像识别(手工特征)

2012年以前的主流方法
数据获取与预处理:就像你做题前要先把卷子铺平、把字看清楚。计算机也要把图片去噪、调亮度。
特征提取(最关键的一步):这是传统方法的瓶颈。科学家必须人工设计规则告诉电脑什么是“特征”。比如下面的SIFT特征,它是一种算法,专门用来找图片里比较独特的“关键点”(比如桌角、标志牌的边缘)。

决策分类:拿着这些人工找到的特征去比对。
这种方法太累了!你想让电脑认识猫,就得告诉它“有尖耳朵、有胡须”。那遇到折耳猫怎么办?遇到没胡须的猫怎么办?人工设计的特征泛化能力太差,无法应对复杂世界。
# 灵感来源:人脑是怎么看图的?

分层处理机制:
- 视网膜 -> V1区:只能看到线条、边缘(Pixels -> Edges)。
- V2/V4区:把线条组合成形状、器官(Edges -> Object parts)。
- 高层皮层:把器官组合成一张脸(Object models)。
核心概念:层级抽象。人脑不是把所有像素一下子全分析了,而是一层层“提炼”信息。越往后,信息量越小(数据量减少),但含金量越高(保留有用信息)。
这就是后来卷积神经网络(CNN)的生物学原型。CNN里的“卷积层”就是在模仿V1区提取边缘,“池化层”就是在模仿大脑的降维过程。
# 现在的霸主:深度学习与ImageNet竞赛

计算机视觉的“寒武纪大爆发”。
- ImageNet:你可以把它理解为图像界的“高考题库”。它是一个包含上千万张图片的巨型数据库。
- ILSVRC:基于ImageNet举办的年度比赛,是全球AI实力的竞技场。
图表解读
- 横轴是时间(年份),纵轴是错误率(越低越好)。
- 转折点(2012年):AlexNet出现,错误率断崖式下跌。这是深度学习正式登基的时刻。它不再依赖人工设计特征(如SIFT),而是让网络自己从海量数据中学习特征。
- 趋势:网络越来越深(从8层到152层),效果越来越好。ResNet(残差网络)解决了网络太深导致训练不动的问题,是目前的经典基石。
# 工业视觉检测的完整解决方案
缺陷检测”这个词比较陌生,你可以把它理解为“用机器代替人眼去挑毛病”,比如工厂里检查手机屏幕有没有划痕、钢板有没有裂纹。
# 传统方法:靠“规则”找缺陷
2012年以前的主流做法。
- 核心逻辑:工程师手动设计“规则”。比如,如果图片里某个区域颜色特别深,或者边缘特别不规则,就判定为“缺陷”。
- 常用工具:Halcon、VisionPro、OpenCV。这些是工业界常用的视觉软件库。
- 这种方法就像你考试时只背了“三角形内角和是180度”,遇到四边形就不会了。它只能处理容易量化的特征,比如颜色、面积、长度、角度等。一旦产品换批次、光照变化、或者缺陷形态复杂,规则就失效了。
- 痛点:泛化能力差。同一个产品,不同批次可能颜色略有差异,传统方法就得重新调参数,非常耗时。
# 深度学习方法:让机器自己“学”特征
现在的趋势——用深度学习(特别是CNN)来做检测。
为什么需要深度学习? 因为现实世界太复杂了!光照会变、物体表面会反光、缺陷形状千奇百怪。传统方法根本搞不定。
CNN是什么? 卷积神经网络,它能自动从图片里“学”出哪些特征是重要的,不需要人工设计。
深度学习不是万能的!它最大的问题是数据 hungry——需要大量标注好的缺陷图片来训练。但在工厂里,缺陷样本往往很少(比如一天只出几个坏品),收集成本极高。
解决办法:
- 数据增强:把一张图旋转、翻转、加噪声,变成多张图。
- 无监督学习/迁移学习:先用大量正常图片训练模型,再微调识别缺陷;或者用其他领域的模型(比如医学CT)迁移过来。
# 从“手工特征”到“端到端学习”
- 传统流程:像素 -> 边缘 -> 直方图 -> K-means聚类 -> 分类器。每一步都需要人工干预。
- 深度学习流程:像素 -> [一堆黑盒层] -> 输出结果。中间过程完全由网络自己学习。
- 关键概念:End-to-End学习。意思是输入原始图片,直接输出检测结果,中间所有步骤都由神经网络自动完成,不需要人工设计特征提取器。
这就像你以前做题要分步写公式,现在直接用计算器一键出答案。虽然你不知道计算器内部怎么算的,但它又快又准。
# 缺陷检测方案设计:不止是算法!
很多初学者忽略的关键点
数据量问题:早期新冠CT只有349张缺陷样本,怎么办?答案是迁移学习——用别人训练好的肺部CT模型,再微调一下就能用。
硬件选型:
- 相机:工业相机 vs 手机相机。工业相机传感器更大(邮票大小 vs 指甲盖大小),进光量更多,低光下也能拍清楚,而且支持高速连拍。手机像素高但传感器小,不适合工业场景。
- 黑白 vs 彩色:大多数缺陷检测用黑白相机,因为算法基于灰度值处理,黑白相机精度更高、速度更快。除非你要检测颜色差异(比如色差),才用彩色相机。
- 镜头 & 光源:这是最容易被忽视的部分!光源选不好,再好的算法也白搭。
- 环形光:适合圆形或方形物体,均匀照明。
- 条形光:适合矩形物体或需要特定角度照射的场合。
- 背光:适合测轮廓尺寸,比如零件外径。
- 线光:配合线阵相机使用,适合连续运动的产线。
在工厂里,打光比算法更重要!很多时候,换个光源角度,缺陷就从“看不见”变成“一目了然”。
# 工业缺陷检测系统是如何在工厂里跑起来的。

四个要素,这是做任何工业项目都必须考虑的:
硬件(眼睛):
相机、镜头、光源:这就是我们之前讲过的“成像系统”。它们负责把产品拍清楚。如果照片拍不清楚,后面算法再厉害也没用(Garbage In, Garbage Out)。数据(神经连接): 数据湖打通:这是一个比较新的概念。以前工厂的数据是孤岛,现在要把所有数据汇聚到一个大池子(数据湖)里。
现场PLC控制:PLC(可编程逻辑控制器)是工厂里的“老大哥”,它控制着传送带停不停、机械臂动不动。视觉系统必须能和PLC“对话”,告诉它:“嘿,我检测到这个产品坏了,你把它踢出去!”算法(大脑): 传统算法 (Halcon, OpenCV):适合处理简单的、规则的测量,比如量尺寸、读条码。
深度学习 (YOLO, Faster R-CNN):【老师补课】 这两个是目前最火的目标检测算法。
YOLO (You Only Look Once):特点是快!它能一眼看出图里哪里有缺陷,适合流水线速度很快的场景。
Faster R-CNN:特点是准!它看得更仔细,适合对精度要求极高的场景。调试(磨合期):
这是最痛苦的环节。理论很美好,现实很骨感。
是否造成停产:工厂最怕停机。如果你安装设备要拆生产线,老板会杀了你的。所以通常要求“在线检测”,不能影响生产。
位置、角度、光线:差之毫厘,谬以千里。有时候相机歪了1度,或者外界阳光照进来了,系统就瘫痪了。
图展示了信号是怎么流动的,这是一个典型的闭环控制系统:
触发环节(P.G / 光电传感器):
你看传送带旁边有个红色的东西写着P.G(或者是Photoelectric Sensor)。
作用:它是“哨兵”。当产品流过来挡住光线时,它会发一个电信号给相机:“有货来了,快拍照!”否则相机就会一直拍空传送带,浪费算力。成像环节(检验用照相机 + LED照明):
收到信号后,相机瞬间闪光拍照。注意,这里的光源通常是频闪的,为了冻结运动物体的画面。处理环节(控制盒/工控机):
照片通过数据线(网线或USB)传给“控制盒”(其实是一台高性能电脑,里面跑着算法)。
电脑快速分析图片,判断:合格?还是不合格?执行环节(Fanuc PLC + 气泵/机械装置):
决策:如果电脑发现是次品,它会立刻发信号给Fanuc PLC(工厂的主控大脑)。
动作:PLC收到信号,控制气泵喷出一股高压气体,或者控制机械装置(比如推杆),把这个坏产品从传送带上吹下去或推出去。
报警:同时,那个警报装置(红绿灯)会变红,提醒工人:“注意!刚才剔除了一個坏品!”
关于“实时性”的挑战:
你想一下,如果传送带速度是1米/秒,产品每隔0.5秒就来一个。你的算法必须在0.5秒内完成:接收图片 -> 预处理 -> 模型推理 -> 输出结果 -> 发送指令。
如果算法跑了1秒,那下一个产品都过去了,你还在算上一个,这就叫漏检。所以工业界对算法的速度要求极高(通常要求毫秒级)。
关于“误杀”与“漏放”:
在工业检测里,有两个致命错误:
过杀(False Positive):好东西被当成坏的扔了。老板会心疼成本。
漏检(False Negative):坏东西没被发现,卖给了客户。这会砸招牌,甚至引发安全事故(比如汽车刹车片有裂纹没检出)。
通常我们宁愿“过杀”也不能“漏检”,安全第一。
物理中的“光路可逆”与“反射”: 你在调试时会发现,如果产品表面是镜面的(比如不锈钢),直射光会把相机晃瞎(全反射进镜头)。这时候就要用漫反射或者偏振光(就像戴墨镜)来消除反光,这都是高中物理光学的应用。
搞人工智能不仅仅是写代码(Python/PyTorch),它更是一个系统工程。 你需要懂光学(打光)、懂电学(PLC接线)、懂机械(安装位置),最后才是算法。这就是为什么“机器视觉工程师”这个职业薪水很高的原因——因为你是多面手!
# 理解CNN
# 灵感的来源——向猫的大脑学习
AI的很多突破,其实是抄大自然的作业。
- Hubel和Wiesel的猫咪实验:
背景知识:这两位科学家在1950年代做实验,把电极插到猫的视觉皮层里,然后给猫看各种图案。他们发现了一个惊人的现象:猫脑子里的神经元不是对所有东西都有反应,而是“分工明确”的。
感受野:有些细胞只对视野中某一个小小的区域(比如左上角)有反应,这个区域就叫“感受野”。
S细胞与C细胞:
- S细胞(简单细胞):像是一个“边缘探测器”。它只喜欢特定的线条,比如一条竖线。如果给它看横线,它就不理你;稍微转动一下角度,它的反应就变了。
- C细胞(复杂细胞):像是“位置 insensitive 探测器”。它不管那条竖线是在左边还是右边,只要有竖线出现,它就兴奋。
这就解释了为什么CNN要设计成现在这个样子。
- 因为S细胞的存在,我们设计了卷积层(专门找局部特征,如边缘)。
- 因为C细胞的存在,我们设计了池化层(专门用来忽略位置变化,只要特征在就行)。
CNN不是数学家拍脑袋想出来的,它是模仿生物视觉系统进化出来的。
# CNN的核心武器——“滑动窗口”与“共享权重”


什么是卷积核?
- 图里那个黄色的小方块(kernel/filter),你可以把它想象成一个“放大镜”或者“手电筒”。
- 这个小方块里有一组数字(权重 w1,w2...)。这组数字就是我们要学习的“秘籍”。
它在干什么?(卷积操作)
- 你看图中的公式: y0=w1x0+w2x1...
- 这就是在做加权求和。简单来说,就是把“放大镜”盖在图片的一小块区域上,把对应的数字乘起来再加在一起,算出一个新值。
- 然后,把这个放大镜往右挪一格,再算一次;挪到底了再换行。就像扫描仪一样扫过整张图。
为什么要“权重共享”?(重点!)
- 这就好比你在找一张大图里的“眼睛”。你不需要为图片的左上角准备一个“找眼睛探测器”,再为右下角准备另一个。你只需要拿着同一个探测器,满世界去扫就行了。
- 好处:这大大减少了需要计算的参数数量!如果每个像素都单独学一个参数,那电脑内存早就爆了。
Feature Map(特征图)是什么?

- 右边那个粉红色的矩阵,就是扫描完之后的结果。
- 它不再是原来的照片了,而是一张“高亮地图”。哪里亮,说明哪里符合探测器的特征(比如哪里有边缘)。
# 深度学习的魔法——从“看线条”到“看狗头”

CNN最神奇的地方:层级抽象。
Layer 1(浅层):
你看那些小方块,全是乱七八糟的线条、色块。
含义:这时候网络刚入门,它只学会了看边缘、颜色、纹理。就像婴儿刚睁眼,只能看到光影。Layer 2 & 3(中层):
开始出现圆圈、网格、甚至像眼睛一样的形状。
含义:网络开始把线条组合起来了。两条线交叉是个角,一圈线是个圆。Layer 4 & 5(深层):
出现了完整的狗头、车轮、人脸。
含义:这就是语义理解了!网络已经把低级的特征拼成了高级的概念。
CNN的学习过程,就是一个从微观到宏观、从具体到抽象的过程。
# 案例:手写数字检测
先来看结果

# 宏观步骤:教机器人的“四步曲”
在深入代码之前,我们先从上帝视角俯瞰整个流程。用 PyTorch 训练 AI 永远离不开这四步:
准备食材(数据加载):把网上的图片下载下来,变成电脑能懂的数字矩阵。
设计大脑(定义模型):用代码搭建一个神经网络的结构(定义层与层之间的连接)。
制定规则(配置训练环境):定义怎么算错(损失函数),以及怎么改错(优化器)。
开始上课(训练循环):让机器不断重复“做题 -> 对答案 -> 挨打(算误差)-> 改正(更新参数)”的过程。
# 第一步:准备食材(数据加载)
# 1. 定义数据预处理规则
transform = transforms.Compose([transforms.ToTensor()])
2
transforms.Compose 就像是一个“流水线加工厂”。
ToTensor() 是流水线上的第一个工人,它的作用是把普通的图片(PIL格式,像素值0-255)转换成 PyTorch 专用的张量(Tensor),并且把像素值压缩到 0~1 之间。
举一反三:以后如果你要处理彩色照片,还可以在这个流水线里加一个 transforms.Normalize((0.5,0.5,0.5), (0.5,0.5,0.5)),让数据分布更均匀,机器学得更快。
# 2. 下载并加载数据集
train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)
2
3
- MNIST 数据集科普:这是深度学习界的“Hello World”。它包含 7万张 28x28 像素的黑白手写数字图片。其中 6万张作为 train(训练集,用来教机器),1万张作为 test(测试集,用来考试)。
- 知识点:root='./data' 意思是如果本地没有,就自动下载到当前目录的 data 文件夹里。
# 3. 打包数据
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True)
2
为什么要这么写:机器不能一次吃下6万张图(内存会爆)。batch_size=64 就是每次喂给它64张图。shuffle=True 意思是每次喂图前都要打乱顺序,防止机器“死记硬背”(比如一直看0,它就只会猜0)。
# 第二步:设计大脑(定义 CNN 模型)
class SimpleCNN(nn.Module):
- Python 知识点(类继承):nn.Module 是 PyTorch 里所有神经网络的“祖宗类”。我们写 class SimpleCNN(nn.Module),意思是“我创造了一个新类,它天生就拥有神经网络的所有基础能力(比如自动记录参数)”。
def __init__(self):
super(SimpleCNN, self).__init__()
2
- Python 知识点:init 是类的初始化方法(相当于造机器时的“组装图纸”)。super().init() 是固定写法,意思是“向祖宗打个招呼,把基础能力继承过来”。
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
- API 详解 (nn.Conv2d):这就是传说中的“卷积层(印章机)”。
- 1: 输入通道数。MNIST 是黑白图,只有1层。如果是彩色 RGB 图,这里就是 3。
- 32: 输出通道数。意思是机器准备了 32 个不同形状的“印章”去扫描图片,提取 32 种特征。
- kernel_size=3:印章的大小是 3x3 像素。
- padding=1:在图片边缘补一圈0。这样卷积后,图片的长宽尺寸保持不变(依然是 28x28)。
32个印章到底是什么印章?
在代码刚写完、还没开始训练时,这32个印章是“随机乱码”(系统随机生成的数字矩阵)。但是!当机器看了几千张手写数字后,它会自动“进化”出分工:
印章1-10:可能进化成了专门找“横线”的印章。
印章11-20:可能进化成了专门找“竖线”的印章。
印章21-32:可能进化成了专门找“圆圈”或“斜线”的印章。
例子:当机器看数字“1”时,只有找“竖线”的印章会亮(得分高);当它看数字“0”时,只有找“圆圈”的印章会亮。
self.pool = nn.MaxPool2d(2, 2)
- API 详解 (MaxPool2d):最大池化层。它的作用是把图片缩小一半(28x28 变成 14x14)。怎么缩?在 2x2 的区域内只保留最大的那个数字。这相当于“抓重点”,同时减少计算量。
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
- 数据流向:注意这里的输入变成了 32(上一层的输出)。机器用 64 个更高级的印章,去扫描上一层提取出来的特征。
self.fc1 = nn.Linear(64 * 7 * 7, 128)
self.fc2 = nn.Linear(128, 10)
2
- API 详解 (nn.Linear):全连接层(分类器)。
- 数据流向计算:图片原本是 28x28,经过两次 MaxPool(每次除以2),变成了 7x7。因为上一层有 64 个特征图,所以总共有 64 * 7 * 7 = 3136 个特征值。fc1 把这 3136 个值汇总成 128 个,最后 fc2 输出 10 个数字(对应 0-9 这10个类别的概率)。
为什么要分两层(32 -> 64)?直接一次64个不行吗?
为什么要分两层? 就像人类认字一样。第一层(32个)先认出“横、竖、撇、捺”这些基础笔画;第二层(64个)把基础笔画组合起来,认出“横折”、“圆圈”这种复杂结构。如果直接一层搞定,机器就学不会这种“从局部到整体”的逻辑。
def forward(self, x):
x = self.pool(self.relu(self.conv1(x)))
x = self.pool(self.relu(self.conv2(x)))
x = x.view(-1, 64 * 7 * 7)
x = self.relu(self.fc1(x))
x = self.fc2(x)
return x
2
3
4
5
6
7
- Python 知识点:forward 定义了数据从输入到输出的流动路径。当你调用 model(图片) 时,PyTorch 会自动执行这个函数。
- 数据形状变化(重点):
- 输入 x:[64, 1, 28, 28] (64张图,1通道,28x28大小)
- 经过 conv1 + relu + pool:[64, 32, 14, 14] (变厚了,变小了)
- 经过 conv2 + relu + pool:[64, 64, 7, 7] (更厚了,更小了)
- 经过 view (展平):[64, 3136] (把二维图片拉成了一条一维的线)
- 经过 fc1 + fc2:[64, 10] (最终输出64个结果,每个结果包含10个概率)
为什么要展平?
因为卷积层处理的是“立体”的图(长x宽x厚度),而全连接层(Linear)就像一个排队的售票员,它只接受“一维”的长条数据。所以必须把立体的图“拍扁”。
为什么输出概率而不是直接输出数字?
因为神经网络本质上是做数学题,它只能输出连续的数值(比如 0.8, 0.1, 0.05),它没法直接“吐”出一个离散的整数“7”。所以我们让它输出10个概率,哪个概率最大,我们就认为它是几。
x.view(-1, 64 * 7 * 7) 为什么第一个数是 -1?
-1 是一个“占位符”,意思是“你自己算,我不管”。
- 假设你一次喂给机器 64 张图,展平后每个图有 6477 个数。view 函数看到 -1,就会自动把它算成 64,变成 [64, 3136]。
- 如果你一次只喂 1 张图,它看到 -1,就会自动把它算成 1,变成 [1, 3136]。
- 好处:写代码时不用死板地写死数字,代码变得更灵活。
# 第三步:制定规则(配置训练环境)
criterion = nn.CrossEntropyLoss()
- API 详解:交叉熵损失函数。它是分类任务的“裁判”。它会对比机器输出的10个概率和真实的标签,算出一个“误差值”。误差越大,说明机器猜得越离谱。
交叉熵损失函数
别被“交叉熵”这个吓人的数学名词唬住。它的本质就是“对数惩罚”。
例子:假设真实答案是“7”。机器给出的10个概率里,第7个位置的概率是 0.9。交叉熵算出来的误差就非常小(机器很开心)。
但如果机器瞎猜,第7个位置的概率只有 0.01。交叉熵就会算出一个巨大的误差值(机器被狠狠惩罚)。
总结:真实答案对应的概率越低,交叉熵算出的惩罚就越重。它逼着机器把正确答案的概率无限推向 1.0。
optimizer = optim.Adam(model.parameters(), lr=0.001)
model.parameters() 里面到底是什么?
里面装的是机器脑子里所有的“旋钮”(权重和偏置)。
- 对于 Conv2d,里面装的是那 32 个、64 个“印章”里每一个小格子的数字。
- 对于 Linear,里面装的是全连接层里每一条连线的“权重”。
- 优化器(教练)修改模型,其实就是通过修改 parameters() 里的这些数字来实现的。
- API 详解:Adam 优化器。它是“教练”。model.parameters() 告诉教练去修改网络里所有的印章参数和全连接权重。lr=0.001 是学习率(步长),决定了每次改正错误的幅度。
# 第四步:开始上课(训练循环)
for epoch in range(5): # 把整个数据集看5遍
for images, labels in train_loader: # 每次拿出64张图和标签
optimizer.zero_grad() # 【关键】清零上一次的梯度(擦干净草稿纸)
outputs = model(images) # 机器做题,输出10个概率
loss = criterion(outputs, labels) # 裁判算分,得出误差
loss.backward() # 【核心黑盒】反向传播!从后往前算,找出每个参数对误差的“责任”(梯度)
optimizer.step() # 教练根据责任,修改参数(吸取教训)
2
3
4
5
6
7
8
9
这四行代码(zero_grad -> forward -> backward -> step)是 PyTorch 最核心的“四步曲”,无论以后你写多复杂的 AI,这四步永远不会变!
这是一个“双层嵌套循环”(套娃):
- 外层循环(Epoch):for epoch in range(5)。意思是把整个 6万张图的数据集,从头到尾完整地看 5 遍。
- 内层循环(Batch):for images, labels in train_loader。在看每一遍时,因为一次吃不下6万张,所以每次抓 64 张出来做题。6万张图要抓约 938 次才能抓完。
- 总结:机器总共做了 5遍 × 938次 = 4690次 的“做题 -> 算误差 -> 改参数”的循环。每做一次,机器就变聪明一点点。
CNN 并没有那么神秘。它不过就是:用卷积层(Conv2d)把图片里的线条和形状提取出来,用池化层(MaxPool)把图片缩小抓重点,最后用全连接层(Linear)根据这些特征去猜它是哪个数字。
# 完整代码
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
# ==========================================
# 【修复点】定义设备:自动检测是否有显卡(GPU)
# ==========================================
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"🖥️ 当前使用设备: {device}")
# 设置中文字体,防止标题乱码
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = False
# ==========================================
# 第一步:准备食材(数据加载)
# ==========================================
transform = transforms.Compose([transforms.ToTensor()])
train_dataset = datasets.MNIST(root='./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST(root='./data', train=False, download=True, transform=transform)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=64, shuffle=False)
# ==========================================
# 第二步:设计机器人的大脑(定义 CNN 模型)
# ==========================================
class SimpleCNN(nn.Module):
def __init__(self):
super(SimpleCNN, self).__init__()
self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1)
self.pool = nn.MaxPool2d(2, 2)
self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
self.fc1 = nn.Linear(64 * 7 * 7, 128)
self.fc2 = nn.Linear(128, 10)
self.relu = nn.ReLU()
def forward(self, x):
x = self.pool(self.relu(self.conv1(x)))
x = self.pool(self.relu(self.conv2(x)))
x = x.view(-1, 64 * 7 * 7)
x = self.relu(self.fc1(x))
x = self.fc2(x)
return x
model = SimpleCNN().to(device) # 【重要】模型也要搬到和照片一样的设备上
# ==========================================
# 第三步:开始训练
# ==========================================
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
print("🚀 开始训练模型...")
for epoch in range(5):
running_loss = 0.0
for images, labels in train_loader:
# 【重要】每次循环都要把数据搬到 device 上
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
print(f"第 {epoch + 1} 轮训练结束,平均误差: {running_loss / len(train_loader):.4f}")
print("✅ 训练完成!")
# ==========================================
# 第四步:终极可视化 (特征提取 + 20张预测结果)
# ==========================================
model.eval()
# 1. 获取一批测试数据
images, labels = next(iter(test_loader))
# 【修复点】这里现在可以正常工作了,因为 device 已经定义了
images, labels = images.to(device), labels.to(device)
# 2. 获取预测结果
outputs = model(images)
_, predicted = torch.max(outputs, 1)
# 3. 手动提取第一张图片的中间层特征
with torch.no_grad():
feat1 = model.relu(model.conv1(images[0:1]))
feat1_pool = model.pool(feat1)
feat2 = model.relu(model.conv2(feat1_pool))
# 4. 创建画布
fig = plt.figure(figsize=(20, 18))
# --- A区:展示卷积神经网络的“内部视角” ---
ax_orig = fig.add_subplot(5, 5, 1)
ax_orig.imshow(images[0].cpu().squeeze(), cmap='gray') # 画图时必须转回 cpu
ax_orig.set_title("原始输入图片", fontsize=14)
ax_orig.axis('off')
ax_conv1 = fig.add_subplot(5, 5, 2)
ax_conv1.imshow(feat1.cpu()[0, 0], cmap='viridis') # 画图时必须转回 cpu
ax_conv1.set_title("第1层卷积特征 (局部)", fontsize=14)
ax_conv1.axis('off')
ax_conv2 = fig.add_subplot(5, 5, 3)
ax_conv2.imshow(feat2.cpu()[0, 0], cmap='viridis') # 画图时必须转回 cpu
ax_conv2.set_title("第2层卷积特征 (抽象)", fontsize=14)
ax_conv2.axis('off')
ax_text = fig.add_subplot(5, 5, 4)
ax_text.text(0.5, 0.5, '上方展示\n模型如何\n提取特征\n\n下方展示\n20张随机\n预测结果',
ha='center', va='center', fontsize=16, color='gray')
ax_text.axis('off')
# --- B区:展示20张图片的预测结果 ---
start_index = 5
display_count = 20
for i in range(display_count):
current_pos = start_index + i
# 计算行列位置
row = (current_pos - 1) // 5
col = (current_pos - 1) % 5
ax = fig.add_subplot(5, 5, current_pos)
# 显示图片 (记得 .cpu())
ax.imshow(images[i].cpu().squeeze(), cmap='gray')
true_val = labels[i].item()
pred_val = predicted[i].item()
color = 'green' if true_val == pred_val else 'red'
title_str = f"真:{true_val} 猜:{pred_val}"
ax.set_title(title_str, color=color, fontsize=12)
ax.axis('off')
plt.tight_layout()
plt.show()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146