手写线性神经网络:从公式到代码,亲手训练模型实现手写数字识别

手写数字识别

前言

  • 本文以李沐老师的《动手学深度学习》(d2l)为基础,实现手写数字识别
  • 使用的框架是PyTorch,但是不使用框架中预制的自定义层,而是手动构建Softmax模型
  • 使用的手写数字数据集来自MNIST

项目简介

  • 最终成果是:训练出一个识别手写数字的模型,然后能打开一个画板,亲自写数字让这个模型识别
  • 本项目已经开源,可以试着用一下,或者查看具体的代码写法:https://github.com/artless-artist/numClassifier

项目结构

numClassifier/
├── train.py            训练脚本:下载数据 → 训练 → 保存权重 → 画训练曲线可视化训练过程
├── module.py           核心模块:模型/损失/精度/优化方法的定义 + 训练/推理过程 + 公共常量
├── drawboard.py        手写板:matplotlib 黑底画板 → 截屏降采样成 28x28 供推理使用
├── infer.py            推理入口:读取 runs/model.pt,打开画板识别数字
├── vis.py              训练过程可视化:Animator 逐轮刷新曲线(可选)
├── readme.md
├── data/               自动下载的 MNIST 数据集存放目录
└── runs/               训练产物:model.pt、softmax_mnist.png

线性回归

线性模型

  • 数学表示
    • 给定输入:\(x=\begin{bmatrix} x_1 & x_2 & … & x_n \end{bmatrix}^⊤\),其中每个\(x_i\)是这一样本的第\(i\)个特征

    • 线性模型有\(n\)维权重与一个标量偏置:\(w=\begin{bmatrix} w_1 & w_2 & … & w_n \end{bmatrix}^⊤\),\(b\)

    • 输出预测值是输入的加权和:\(\hat y=w_1x_1+w_2x_2+…+w_nx_n+b\)

    • 写作向量形式为:\(\hat y=<w,x>+b\),为了方便,可以把偏置并入权重(即增广):令\(X=[x,1]\),\(w'=\begin{bmatrix} w \\ b \end{bmatrix}\),得\(\hat y= X w'\)

    从数学上看,线性回归是对\(n\)维输入的加权再加偏差的和

    显然,线性模型可以看作是一个简单的神经网络,有一层含\(n\)个神经元的输入层与一层含一个神经元的输出层

  • 损失函数

    既然将线性模型视作神经网络,那么也就可以用训练神经网络的思路,来训练这个线性模型,找到适宜的权重与偏置;而训练神经网络的思路是:用一个损失函数来衡量当前输出与目标的偏差,让模型知道自己偏离目标有多远,再通过最小化这个损失来学习各个参数

    最自然的衡量预测误差的想法是残差:\(y-\hat y\),但是残差有正有负,直接加会抵消,所以会用残差的平方和,也就是把输出值减去应该输出的正确值、再平方、最后求和,得到的值就是训练单个样本的代价,但实际情况更复杂一些:

    • 事实上最常用的损失函数是均方误差MSE:\(L(w,b)=\frac 1 {2m} \sum_{i=1}^m(y^{(i)}-\hat y^{(i)})^2\)

      \(\frac 1 {2m}\)中的\(\frac 1 m\)是取平均,\(\frac 1 2\)是为了求导时消去平方产生的因子2,二者只是惯例,都不影响最小值点的位置

      这个函数的由来源自一个假设:真实值源自线性关系加上服从正态(高斯)分布的噪声:\(y=w^⊤x+b+ϵ,ϵ∼N(0,σ^2)\)

      从这个假设出发,对于给定的\(x\),\(y\)就服从均值为\(\hat y\)、方差为\(σ^2\)的正态分布,对全部样本做最大似然估计,最后可以得到最大化似然等价于最小化平方误差和

      这一假设被广泛使用,源自两个理论依据:中心极限定理(大量独立微小误差叠加,极限趋近高斯)以及最少额外假设原则(在只已知均值和方差的情况下,高斯是最大熵分布,应当选用这一分布)

    • 另外,取另一种假设:噪声服从拉普拉斯分布,那么得到的就是平均绝对误差MAE:\(L(w,b)=\frac 1 {m} \sum_{i=1}^m|y^{(i)}-\hat y^{(i)}|\)

  • 训练过程

    在确定损失函数后,训练过程就可以写为:\(w^*,b^*=arg \underset{w,b} {min} L(x,y,w,b)\),即在所有可能的\(w,b\)中找到让损失最小的那一组

    而作为一个线性模型,它有显式解(能被公式直接表示出来的解,又称解析解):

    1. 将损失函数对增广权重\(w'\)求导得\(\frac{\partial L(X,y,w')}{\partial w'}=\frac 1 m X^⊤(X w'-y)\),梯度是与\(w'\)同形的列向量
    2. 而损失函数是凸函数,所以最优解(损失最小点)满足梯度为\(0\):\(\frac 1 m X^⊤(X w'-y)=0\)
    3. 通过简单解方程(即正规方程 normal equation)得\(w'^*=(X^⊤ X)^{-1}X^⊤ y\),注意解出的是增广权重(含\(b\))

    线性模型是为数不多有显式解的模型,对于其他模型,就不能像这样算出一个最优解来,必须借助优化方法,比如梯度下降法(这类方法的具体介绍可见我的另一篇笔记:https://www.bilibili.com/opus/1251083166904483859 ),下面要讲的Softmax回归就属于这一类:没有解析解,只能靠梯度下降一步一步逼近最优参数


Softmax回归

Softmax模型

  • 分类问题

    前面介绍的线性模型,是一种解决回归问题的方法,所谓回归问题即需要预测一个连续值的问题

    而除此之外,还有一类问题称为分类问题,需要预测的是一个离散类别(例如本文要手写的模型做的就是手写数字识别,要判断输入是0~9之间的谁,是一种有十类的分类问题)

    简而言之,分类问题与回归问题的区别在于:分类问题有多个输出,第\(i\)个输出是将输入预测为第\(i\)类的置信度(有多确定输入是这一个类别)

  • 解决分类问题的网络架构

    分类问题应该用怎样的网络去实现,其实在前面讲线性模型时已经埋了伏笔:回归任务只需要输出层有一个神经元输出数值,分类任务的输出层则需要神经元个数等于类别数,每个神经元对应一个类别,输出的是把输入预测为这一类的置信度;同时输入层的神经元个数要等于输入数据的维数——以手写数字识别为例,图片是一张28×28像素的黑白图,共784个像素,输入层就需要784个神经元,每个神经元中的数字代表一个像素点的亮度值

    本文的模型比这还要简单:不设隐藏层,只用一层线性层把784维输入直接映射到10维输出,这10维的原始输出(即softmax之前的分数)记作\(o\),具体实现细节如下:

    • 对类别进行一位有效编码:即输出结果为向量,向量维度=类别个数,当判断为某个类别时,该类别对应维度置1,其他置0
    • 同样使用均方误差进行训练:也就是把输出值减去应该输出的正确值、再平方、最后求和,衡量预测与正确答案的差距,这与前面线性回归中用的MSE是同一个思路,只是分类任务里的正确答案要先做上一步的一位有效编码(不过实际训练中更常用交叉熵,它比均方误差更贴合"概率"这一含义,原因见下一小节)
    • 最终输出选用置信度最大的类别作为预测结果,即\(\hat y=argmax\ o_i\)
    • 对于一个正常工作的模型,选定正确类别的置信度应该远远大于其他类别(差值大于某个阈值),即\(o_y-o_i>\Delta(y,i)\)
  • Softmax和交叉熵

    \(Softmax\)函数的作用是把一组原始分数转换为一组总和为1的非负值:对于分类问题,我们希望输出值应当表示选择这一类别的概率,因此需要引入\(Softmax\)函数处理输出,即\(\hat y=softmax(o)\),使得每个值在0到1之间且总和为1,可以视作概率值(具体表达式见定义softmax操作的代码写法)

    预测概率与真实概率之间的差别即损失,衡量两个含有\(n\)个元素的离散概率分布之间的差别,需要使用交叉熵:\(H(p,q)=\underset i {\sum}-p_ilog(q_i)\)

    解释一下交叉熵:\(p\)是真实概率分布,\(q\)是模型输出的预测概率分布,根据信息论,一个概率为\(q_i\)的事情发生时,携带的信息量是\(-log(q_i)\),表示了概率越小的事件发生,这表示了信息量越大(越意外),例如\(q_i\)​\(=\)​\(1\) 时 \(−log⁡1\)​\(=\)​\(0\),必然事件没信息量;而小概率的\(q_i\)​\(=\)​\(0.01\)则有\(−log⁡0.01≈4.605\),很意外,信息量大

    因此对一个离散概率分布,计算按它自己的概率加权求信息量的期望\(H(p)=\underset i {\sum}-p_ilog(p_i)\),得到的就是这个分布本身的不确定性;而按真实概率加权求预测概率的信息量,得到的就是交叉熵,表示预测按真实概率发生的事件的意外程度,当完美预测时意外程度最小,即\(H(p)\)

    将其作为损失:\(l(y,\hat y)=-\underset i {\sum}y_ilog(\hat y_i)\),注意真实的\(y\)中仅有一个元素为1(只有一个正确类,它的概率值是1,其他为0),因此得\(-log\ \hat y\)

    这个损失函数的梯度,即预测概率减真实概率:\(\partial_{o_i} l(y,\hat y)=softmax(o)_i-y_i\)(把\(\hat y=softmax(o)\)代入上述损失对\(o_i\)求导即可得到这个简洁的结果,此处直接给出),这一形式非常简洁直观,这也是选择交叉熵来表示损失的原因之一

Softmax回归的手写实现

  1. 导入库与下载数据集

    经典的识别手写数字任务不需要多做介绍,这里使用手写数字数据集MNIST,手写实现一个为数据集中的数字分类的模型,首先导入所需的库:

    import os
    import torch
    import torchvision
    from torch.utils import data
    from torchvision import transforms
    from module import net, cross_entropy, train, DATA_DIR, WORK_DIR 
    from vis import Animator
    

    然后下载MNIST数据集(由10个类别的数字图像组成,每个类别由训练数据集中的6000张图像和测试数据集中的1000张图像组成,即60000张训练、10000张测试;测试数据集不会用于训练,只用于评估模型性能):

    #下载数据集
    trans = transforms.ToTensor() #使用ToTensor把一张PIL图片或NumPy数组转成PyTorch张量,并自动归一化到[0, 1]
    #trans现在是一个"待用"的变换对象,接下来通过torchvision下载数据集,并调用这个trans将数据集中的图片转换为张量形式
    mnist_train = torchvision.datasets.MNIST(root=DATA_DIR, train=True, transform=trans, download=True) #训练数据集
    mnist_test = torchvision.datasets.MNIST(root=DATA_DIR, train=False, transform=trans, download=True) #测试数据集
    #好消息是,torchvision会自动检查目录下是否已经存在所需文件,所以不必担心启动程序时重复下载
    
  2. 读取数据集

    在拥有数据集后,我们需要读取它,根据随机梯度下降法的思路:需要每次抽取一小批量样本,并使用它们来更新我们的模型

    这一步直接交给DataLoader即可,读取批次大小与是否打乱读取都能通过参数控制:

    #读取数据集
    batch_size = 256 #每一批次读取256张图片
    dataloader_workers=4 #使用4个进程来读取数据
    
    train_iter = data.DataLoader(mnist_train, batch_size, shuffle=True, num_workers=dataloader_workers)
    test_iter = data.DataLoader(mnist_test, batch_size, shuffle=False, num_workers=dataloader_workers)
    
  3. 初始化模型参数

    既然图片尺寸是28×28,拥有784个像素点,那么就直接转为一个长度为784的向量作为模型输入,于是模型的输入是784维的,另外,数据集中包含10个类别,因此模型的输出是10维的,由此创建权重和偏置矩阵

    #初始化参数
    num_inputs = 784 #图片是28*28,即有784个像素,输入为784
    num_outputs = 10 #分类为0~9中的一个数字,输出为10
    
    W = torch.normal(0, 0.01, size=(num_inputs, num_outputs), requires_grad=True) #权重矩阵,初始值标准正态分布服从随机生成
    b = torch.zeros(num_outputs, requires_grad=True) #偏置,初始值置0
    
  4. 定义softmax操作

    因为softmax比起线性函数复杂一些,不太好直接用一行公式写出来,所以我们不直接定义模型,而是先把softmax操作单独实现

    给定一个向量\(x=[x_1,x_2…x_n]\),用softmax处理为离散概率分布的表达式是:\(softmax(x)_{i}=\frac{exp(x_{i})}{\sum^n_{j=1} exp{x_{j}}}\),而处理矩阵则将表达式写为:\(softmax(X)_{ij}=\frac{exp(X_{ij})}{\sum_kexp{X_{ik}}}\)

    def softmax(X):
        """softmax函数"""
        #将模型输出数值进行处理,使其值为[0,1]且总和为1,作为离散概率分布,softmax(X) = exp(x_i) / Σ_j[exp(x_j)]
        X_exp = torch.exp(X) #先对向量中每个元素求自然指数,保证值非负
        partition = X_exp.sum(1, keepdim=True) #保持列维度,把每一个行包含所有元素加起来,得到所在行的元素总和作为归一化因子
        return X_exp / partition #这里应用了广播机制,把每个元素都除以所在行的总值,得到每个样本的概率分布
    
  5. 定义模型

    接下来定义softmax回归模型

    def net(X,W,b):
        """softmax回归神经网络的代码表示"""
        return softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) + b)
        #我们要把28*28的图片转为784的向量
        #输入X为一个批次的图片,例如包含256张,那么张量X为(256,1,28,28),需要转换为(256,784),每行是256张图片之一的每个像素值
        #W.shape[0]=784(权重矩阵的0维是输入维数,即784),-1为自动计算:256*1*28*28 / 784 =256,自动得到256
        #原本矩阵中的像素通道1被合并进784中了,总而言之就是原本的1*28*28被拍扁进了784中
        #需要注意,这里写成两层括号X.reshape((-1, W.shape[0])),外侧括号是reshape的调用,而内层括号是一个元组,表示目标形状是(?,784)
        #其实只用单层括号也行,这样做只是为了表示传入的是一个整体
        #然后进行torch.matmul,将reshape后的X与权重矩阵W进行矩阵乘法,把784维输入映射到了10维输出中,得到(256,10)
        #然后用广播机制把偏置b加到256行中
        #最后softmax
    
  6. 定义损失函数

    接下来用代码实现前文提到的交叉熵\(-log\ \hat y\)(这可能是深度学习中最常见的损失函数,因为目前分类问题的数量远远超过回归问题的数量)

    需要注意的是,交叉熵的定义中需要逐个计算真实与预测的加权信息量,但如果使用for​循环会很低效(计算机用矩阵运算那样可以并行的计算更加高效,for循环就不得不等待循环结束),所以更好的做法是用一个运算符一次选择计算要用的所有元素

    def cross_entropy(y_hat, y):
        """损失函数,交叉熵"""
        #衡量预测值y_hat与y的偏差程度,即模型的损失函数,H(y,y_hat)= Σ_i[-(y_i)log(y_hat_i)]
        return - torch.log(y_hat[range(len(y_hat)), y]) 
        #y_hat是预测概率分布,当批次含有256张照片,每张照片给它属于10个类别的预测概率时,形状是(256,10)
        #y是256张照片的真实标签,所以对应正确的类别概率为y_i=100%,对应错误的概率为y_i=0%,交叉熵的公式就可以化简为-log(y_hat_i)
        #负号与log对数运算都很直观,重点是怎么做到用一次找到所有对应的log(y_hat_i),而非用for循环慢慢找
        #这里使用了花式索引技巧:y_hat[range(len(y_hat)), y],其含义是取y_hat[i,y[i]]
        #比如说y_hat中样本0的预测概率为[0.1, 0.3, 0.6],而真实标签是[2,0,2,1],意思是样本0的真实类别为2,样本1的真实类别为0……
        #这时,我们要取样本0预测成功的概率,显然就是取y_hat[0,2]=0.6,也就是y_hat[i,y[i]]
        #设定i的范围在range(len(y_hat))中,就能一次取遍256行(对应256张照片)
    

    有必要解释一下这个花式索引y_hat[range(len(y_hat)), y]是怎么工作的:

    y_hat = torch.tensor([
        [0.1, 0.3, 0.6],   # 样本0 的预测概率
        [0.8, 0.1, 0.1],   # 样本1
        [0.2, 0.2, 0.6],   # 样本2
        [0.3, 0.4, 0.3],   # 样本3
    ])
    
    y = torch.tensor([2, 0, 2, 1])   # 真实类别
    
    #如果手动来来看,就是这样的:
    y_hat[0,2] = 0.6 #样本0的真实类别为2,而预测这个真实类别的概率就放在[0,2]上,是0.6
    #如果想一次取出4个样本,就是:
    selected = y_hat[range(len(y_hat)) , y]         # tensor([0.6, 0.8, 0.6, 0.4])
    #这样就一次取出了:
    y_hat[0, 2] = 0.6 #取样本为0时,对应的y[0]=2,所以要取的是[0,2],以此类推
    y_hat[1, 0] = 0.8
    y_hat[2, 2] = 0.6
    y_hat[3, 1] = 0.4
    
  7. 分类精度

    衡量神经网络在分类问题上的表现,不能只靠交叉熵,因为交叉熵在衡量实际效果时不够直观,因此需要引入另一个量:分类精度,它要比交叉熵直观得多:例如分类精度92%,就是100张图片里能分对92张

    但这不意味着分类精度能替代交叉熵,因为交叉熵在训练时发挥重要作用:分类精度都一样,不意味着模型的性能是一样的,因为其中一个模型可能给了错误的分类更高的概率,对自己的分类更“不自信”,这就只能通过交叉熵反映出来,如果缺失了这一数据就无法在训练时改进模型了

    为了计算精度,使用如下的代码:

    def accuracy(y_hat, y): 
        """计算分类精度"""
        if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
        #如果预测值y_hat同时满足:
            #len(y_hat.shape) > 1:至少有两维(排除只有一维,已经是分类标签的情况)
            #y_hat.shape[1] > 1: 第1维的维度数大于1(排除二维但一行只有一个元素(即列向量)的情况,不然接下来的argmax只能取到[0])
            y_hat = y_hat.argmax(axis=1)
        #排除后的y_hat给出的是概率分布,需要使用argmax沿着第一维(类别)找最大概率对应那个类的索引,把(256,10)的概率分布变成(256,)的分类标签
        #这个判断是为了让函数更通用,既能直接处理模型输出的概率分布,也能处理已经分类完成的预测标签
        cmp = y_hat.type(y.dtype) == y #逐元素比较,先把y与y_hat的类型统一,然后逐一比较,根据比较结果返回布尔量到cmp
        return float(cmp.type(y.dtype).sum()) #然后把布尔量转成1或0,再求和,得到的就是预测成功的个数
    

    在此基础上,对于任意按批访问数据集的数据迭代器(也就是前面DataLoader给出的train_iter、test_iter这类对象),可以做出一个工具来评估任意模型在指定数据上的精度

    首先需要一个Accumulator,是一个程序类,用于对多个变量进行累加:

    class Accumulator:
        """在n个变量上累加"""
        def __init__(self, n): #接收参数n,表示要累加的变量数量
            self.data = [0.0] * n #创建一个长度为n的列表,每个元素初始化为 0.0
        def add(self, *args): #*args可变参数列表,接收多个要累加的值
            self.data = [a + float(b) for a, b in zip(self.data, args)]
            #zip(self.data, args)将当前积累值与传入的新值一一配对
            #传入的新值b转为浮点数(保证不同格式兼容)加到已有的积累值a
            #最终生成新列表重新赋给self.data
        def reset(self):
            self.data = [0.0] * len(self.data) #重置,用于下一轮统计
        def __getitem__(self, idx): #让定义的方法可以用下标访问
            return self.data[idx]
    

    然后定义计算指定数据集上模型的精度的函数:

    def evaluate_accuracy(net, W ,b, data_iter):
        """计算在指定数据集上模型的精度"""
        metric = Accumulator(2)  #使用Accumulator同时累计2个值
        with torch.no_grad(): #因为是评估,不需要计算梯度
            for X, y in data_iter: #X与y是data_iter(到时会传入测试数据集)解包出来的局部名字,此处为遍历整个数据集
                metric.add(accuracy(net(X,W,b), y), y.numel()) #累加计算正确预测数与预测总数
        return metric[0] / metric[1] #分类正确样本数/总样本数
    
  8. 定义优化算法

    接下来,就需要通过某种优化算法来让损失函数值尽可能降低,这里使用的是随机梯度下降法SGD,这个函数的作用是对每个参数执行一步梯度下降,将参数更新,然后清空梯度,不断重复这个过程即可优化

    def sgd(params, lr, batch_size): #params是需要更新的参数列表,lr是梯度下降的学习率
        """梯度下降法"""
        with torch.no_grad(): #在下列的块中关闭梯度计算,避免更新参数的操作被保存到计算图中,干扰到反向传播时的计算过程
            for param in params: #遍历每个参数
                param -= lr * param.grad / batch_size #将参数更新,原地操作避免参数内存地址变了下一轮找不到
                param.grad.zero_() #把梯度清零,避免下一轮的梯度累加到上一轮,末尾的下划线表示原地操作
    

    这一函数的核心是更新参数的param -= lr * param.grad / batch_size,对应梯度下降法的\(w_{new}=w−η\frac{\partial loss}{\partial w}\)

    这个公式表示:新的权重值应该向着\(−\frac{\partial loss}{\partial w}\)(对应param.grad)的方向(下降最快的方向)变化一步,这一步的变化量由系数\(η\)(即学习率lr)控制

    多出了一个除以batch_size,是因为刚刚的损失函数是求和形式,反向传播算出的梯度会是整批梯度之和,需要除一下变成平均梯度

  9. 训练

    终于可以开始训练了:对于梯度下降法,训练过程就是设定初始参数后,不断重复计算梯度找下降方向/根据梯度更新参数这两步:

    #超参数
    num_epochs = 10 #迭代周期,也就是训练会把整个数据集完整遍历10遍
    lr =0.1 #学习率
    #上述两个参数属于超参数(无法计算确定,必须提前给出),需要通过反复实验确定
    

    首先实现模型的一个迭代周期(这里使用了前面定义的sgd梯度下降法函数)

    def train_epoch(net, train_iter, loss, W, b, lr):
        """训练模型的一个迭代周期"""
        metric = Accumulator(3) #累加器计算训练损失总和、训练准确度总和、样本数
        for X, y in train_iter: #X与y是train_iter解包出来的局部名字,此处为遍历整个训练集
            #计算梯度并更新参数
            y_hat = net(X, W, b) #正向传播并记录中间值
            l = loss(y_hat, y) #损失函数使用之前的cross_entropy
            l.sum().backward() #进行反向传播,更新参数
            sgd([W, b], lr, X.shape[0]) #已经对输入数据处理过了,此处X.shape[0] = batch_size
            metric.add(l.sum().item(), accuracy(y_hat, y), y.numel()) #.item()取标量,避免requires_grad张量转float时的告警
        return metric[0] / metric[2], metric[1] / metric[2] #返回训练损失和训练精度
    

    在此之后就可以实现完整的训练函数:

    def train(net, train_iter, test_iter, loss, num_epochs, W, b, lr, history=None):#将前面定义的函数传入
        """训练模型的完整过程
        逐轮记录指标传入history或者Animator记录,训练过程本身不受影响
        history/Animator 采用鸭子类型,只要有add(轮次, 训练损失, 训练精度, 测试精度) 方法即可,这样本模块不必依赖 matplotlib
        """
        for epoch in range(num_epochs): #训练num_epochs轮
            train_metrics = train_epoch(net, train_iter, loss, W, b, lr) #进行训练
            test_acc = evaluate_accuracy(net, W, b, test_iter) #评估分类精度
    
            if history is not None: 
                history.add(epoch + 1, train_metrics[0], train_metrics[1], test_acc) 
                #把这一轮的指标喂给训练历史记录器,训练结束后交给vis.py中的函数画折线图
    
        train_loss, train_acc = train_metrics #在多轮训练结束后,取出最后一轮的数据
        assert train_loss < 0.5, train_loss
        assert train_acc <= 1 and train_acc > 0.7, train_acc
        assert test_acc <= 1 and test_acc > 0.7, test_acc
        #最后的部分是断言检查:assert 条件 (条件为真则进行执行,假则抛AssertionError,并打印提示信息),这里的三个断言代表:
        #最终训练损失小于 0.5 确认模型收敛
        #训练精度在0.7到1之间 确认训练精度合理
        #测试精度在0.7到1之间 确认泛化正常
        #任意条件不满足,会导致断言失败,也就是提醒训练出现问题
    

    设定迭代10次,学习率为0.1,在train.py里一次性把前面所有零件组装起来:

    #训练时逐轮刷新曲线(动画)
    animator = Animator() #动画窗口,在train() 中每跑完一轮就会调一次 animator.add(),把当前一轮的数据传进去进行绘制
    
    #进行训练
    train(net, train_iter, test_iter, cross_entropy, num_epochs, W, b, lr, history=animator)
    

    animator就是train()里那个可选的history:走的是鸭子类型,只要有add(轮次, 训练损失, 训练精度, 测试精度)方法就能传进去,所以module.py不必依赖matplotlib;不传时train()的行为和以前完全一样,仍是只训练、不记录

  10. 训练过程的记录与可视化

    按顺序,数据是这样被处理的:

    train.py
    ├─ train(...)                                    循环 num_epochs 轮
    │   ├─ train_epoch(...)                          训练一轮
    │   │   └─ 每个 batch:
    │   │        net(X, W, b) → softmax → y_hat 前向传播计算得到预测值
    │   │        cross_entropy(y_hat, y) → l 计算损失
    │   │        l.sum().backward() → sgd([W, b], lr, X.shape[0]) 更新参数
    │   │        accuracy(y_hat, y) 计算精度
    │   ├─ evaluate_accuracy(net, W, b, test_iter)   完成迭代周期后进行一次整体评估
    │   │   └─ accuracy(net(X, W, b), y)
    │   └─ history.add(...) → Animator.add(...) → plot_training_curves(...) 将训练表现记录下来绘制曲线以可视化
    ├─ torch.save(...)                               → runs/model.pt 保存训练结果
    └─ animator.save(...)                            → runs/softmax_mnist.png 保持训练过程曲线
    

    softmax_mnist

    训练结束后把权重W和偏置b保存成runs/model.pt,供接下来推理使用:

    #保存训练好的权重与偏置,供 infer.py 推理时读取(路径与 module.load_model 保持一致)
    os.makedirs(WORK_DIR, exist_ok=True) #确保保存目录存在
    torch.save({"W": W, "b": b}, WORK_DIR / "model.pt")
    
    #训练结束后存一份 PNG(复用动画窗口,不会另开一个),并让窗口留在屏幕上
    animator.save(WORK_DIR / "softmax_mnist.png")
    animator.show() #阻塞,关掉窗口脚本才结束
    

    训练过程会弹窗显示训练过程损失与准确率变化的折线图,并将图片保存到runs/softmax_mnist.png。这一部分代码写在vis.py中,仿制了d2l中的Animator,它只做记录 + 绘图,不改动module.py和train.py里的任何代码,因此不在这里展开

推理与手写板演示

接下来,我们使用训练完成的模型,在module.py写两个函数:一个负责读回参数(即加载模型),一个负责对单张图片做一次前向计算(即推理)

def load_model(path=WORK_DIR/"model.pt"):
    """读取训练好的权重和偏置;如果文件不存在就提示一下并返回 None"""
    if not os.path.exists(path):
        print("没有找到权重文件 {},请先运行 train.py 训练模型。".format(path))
        return None
    checkpoint = torch.load(path, map_location="cpu")
    return checkpoint["W"], checkpoint["b"]

def predict(image, W, b):
    """对一张 28x28 的灰度图做预测,返回 (预测数字, 置信度)"""
    x = image.float().view(1, 1, BOARD_SIZE, BOARD_SIZE)   # (28, 28) -> (1, 1, 28, 28)
    with torch.no_grad():                      # 推理不需要梯度
        probs = net(x, W, b)[0]                # net 里已经做了 softmax,输出就是概率
    confidence, digit = probs.max(0)
    return int(digit), float(confidence)
  1. 推理入口

    在项目文件夹下运行python infer.py,会读取train.py产生的runs/model.pt,打开一个手写板,识别你用鼠标写的数字

    推理入口本身只有几行:读回参数,交给画板

    from module import load_model #直接使用训练好的模型
    from drawboard import draw_board #调用画板程序
    
    model = load_model()
    if model is None:
        exit()
    W, b = model
    draw_board(W, b)
    
  2. 手写板:把画笔变成模型的输入

    画板用matplotlib实现,具体代码如下(注意要做成黑底白字,与训练用的MNIST数据一致,而且写完后整张画布要截屏降采样成28×28的灰度图,才符合模型的输入格式)

    def canvas_to_image(fig):
        """把画布上的笔画截屏成 28x28 的灰度图(黑底白字,取值 0~1)。
    
        matplotlib 画布本身就是一张像素图,直接读它的缓冲区得到一个 (高, 宽, 4) 的张量,
        再按格子取平均降采样到 28x28,就得到了与 MNIST 同样格式的输入。
        """
        fig.canvas.draw()                                    # 确保笔画已经画到画布上
        buffer = fig.canvas.buffer_rgba()                    # 画布的 RGBA 像素,(高, 宽, 4)
        rgba = torch.frombuffer(buffer, dtype=torch.uint8).reshape(buffer.shape)
        gray = rgba[..., :3].float().mean(dim=2)             # 三通道取平均 -> 灰度,0~255
    
        h, w = gray.shape
        block_h, block_w = h // BOARD_SIZE, w // BOARD_SIZE         # 每个格子占多少像素
        gray = gray[:block_h * BOARD_SIZE, :block_w * BOARD_SIZE]   # 裁成 28 的整数倍
        return gray.reshape(BOARD_SIZE, block_h, BOARD_SIZE, block_w).mean(dim=(1, 3)) / 255.0
    

    剩下的部分是窗口与事件:鼠标按下/拖动由事件回调记录笔画并实时画出来,按回车识别,按c清空

    def draw_board(W, b):
        """打开手写板:按住鼠标左键写字,回车识别,按 c 清空,关闭窗口退出。"""
        side = BOARD_SIZE * SCALE / DPI                 # 2.8 英寸 * 100 dpi = 280 像素
        fig = plt.figure(figsize=(side, side), dpi=DPI)
        ax = fig.add_axes([0, 0, 1, 1])                 # 坐标轴铺满整张画布,方便整体截屏
    
        def reset_axes():
            ax.clear()
            ax.set_xlim(0, BOARD_SIZE)
            ax.set_ylim(0, BOARD_SIZE)
            ax.set_facecolor("black")                   # 黑底白字,与 MNIST 一致
            ax.axis("off")
    
        reset_axes()
        fig.patch.set_facecolor("black")
    
        strokes = []          # 所有笔画,每条笔画是一串 (x, y) 点
        drawing = False       # 鼠标左键当前是否按下
    
        def on_press(event):
            nonlocal drawing
            if event.button == 1 and event.xdata is not None:
                drawing = True
                strokes.append([(event.xdata, event.ydata)])
    
        def on_move(event):
            if not drawing or event.xdata is None:
                return
            x0, y0 = strokes[-1][-1]                    # 上一个点
            x1, y1 = event.xdata, event.ydata           # 当前点
            strokes[-1].append((x1, y1))
            ax.plot([x0, x1], [y0, y1], color="white", linewidth=LINE_WIDTH,
                    solid_capstyle="round")             # 把这一段连起来
            fig.canvas.draw_idle()
    
        def on_release(_event):
            nonlocal drawing
            drawing = False
    
        def clear(_event=None):#按c则清空画板
            strokes.clear()
            reset_axes()
            fig.canvas.draw_idle()
            print("画板已清空,可以再写一个。")
    
        def recognize(_event=None):#回车则开始识别
            if not strokes:
                print("画板还是空的,先写一个数字吧。")
                return
            digit, confidence = predict(canvas_to_image(fig), W, b) #进行预测
            message = "识别结果:{}(置信度 {:.1%})".format(digit, confidence) #产生预测结果
            print(message)
    
        fig.canvas.mpl_connect("button_press_event", on_press)
        fig.canvas.mpl_connect("motion_notify_event", on_move)
        fig.canvas.mpl_connect("button_release_event", on_release)
    
        def on_key(event):
            if event.key == "enter":
                recognize()
            elif event.key == "c":
                clear()
    
        fig.canvas.mpl_connect("key_press_event", on_key)
    
        print("画板已打开:按住鼠标左键写数字,回车识别,按 c 清空,关闭窗口退出。")
        plt.show()
    

    画板操作:按住鼠标左键写数字 → 回车识别 → 按 c 清空 → 关闭窗口退出,识别结果会打印在终端里

    注意:模型是单层softmax,真实手写的识别率会明显低于MNIST测试集上的92%左右,写的时候尽量把数字写在中间、写大一点,识别效果最好

posted @ 2026-09-24 17:32  无术师  阅读(3)  评论(0)    收藏  举报