ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

手写数字识别系统开发实战:从KNN到CNN部署全解析

手写数字识别系统开发实战:从KNN到CNN部署全解析 简介这是一份面向高校《机器学习》课程教师与初学者的教学案例PDF以手写数字识别为主线串起从线性分类器到SVM、DNN的模型演进帮助读者理解机器学习中模型设计、样本训练与参数调整的核心流程。资源共1个PDF文件大小约887KB内容按期刊论文排版适合作为课程参考文献或自学入门材料目前已有616人学习下载。案例从“小孩认数字”的直观类比切入逐步引入线性判别函数、符号函数分类规则、参数迭代公式并延伸至感知器与单层神经网络还给出图像二值化、规范化、细化等数据预处理思路。这样做能让初学者避开抽象概念快速建立机器学习整体框架也能为教师提供一套可复用的案例驱动教学参考是一份简明实用的学习资料。1. 机器学习与手写数字识别为什么课程设计要看整个系统而不仅是模型一门机器学习课最常见的期末项目就是“手写数字识别”许多人以为重点在把MNIST准确率跑到99%于是反复调参、换网络结构最后交上去的却是一个只能在命令行里打印预测标签的脚本。这个标题真正考察的是“系统开发”从数据管道、模型训练、模型部署到用户能实际书写并得到反馈的交互界面整个链路缺一不可。课程设计评分的关注点通常在两个地方模型在真实手写输入上的泛化能力以及代码的工程组织是否清晰。这两点恰恰是很多入门者最薄弱的部分。我会按“原理—训练—系统—进阶”的顺序把这一套完整拆开代码都给出最小可运行版本参数讲清为什么这样设、失败时看什么。无论你是西电机器学习期末课的复习者还是在学机器学习应用流程时拿到了这个题目这篇文章都能直接作为开发基线。2. 识别任务的核心原理与数据管线构建2.1 手写数字识别的本质一个28×28像素的图像分类问题手写数字识别是典型的图像分类任务输入是一张灰度图输出是 0-9 十个类别中的一个标签。MNIST 是这个领域的事实标准数据集60000 张训练图、10000 张测试图每张图固定为 28×28 像素像素值范围 0-255。一张图在内存里的本质是一个 784 维向量整个数据集就是 $R^{784}$ 空间中的带标签点集。课程设计里首先要建立的不是神经网络结构而是“图像如何变成张量、张量又如何变成预测”这条主线图像被读取为 H×W×C 的张量手写数字是单通道灰度图所以是 28×28×1。传统机器学习方法把图像拉平成 1×784 的特征向量。深度学习模型则保留二维空间结构用卷积操作提取局部模式。这个区别决定了你后续的算法选型。若用 KNN特征就是原始像素若用 CNN特征由卷积核自动学习。两者在课程里都有价值但定位不同前者让你理解距离度量后者让你理解端到端表征学习。2.2 从原始像素到训练样本归一化与标签编码我见过不少人的第一个问题就是“为什么别人代码里要除以 255”。像素值从 0 到 255数值范围很大直接丢给模型会产生两个后果距离类算法KNN中某个像素的绝对大小可能主导距离计算但这不是语义上的决定性差异梯度类算法中大尺度输入会让损失函数地形变得细长需要调小学习率来补偿。所以归一化的目的不是“转换格式”而是把特征尺度统一到模型更易优化的区间。标签编码有两种常见方式。整数标签 0-9 适合交叉熵损失的“稀疏标签”输入One-hot 编码则把每个标签变成 10 维向量适合手动实现 softmax 回归的场景。课程作业里我建议直接使用整数标签PyTorch 的 CrossEntropyLoss 内部会自动做 one-hot 展开不会造成混淆。2.3 算法选型对比KNN、逻辑回归与 CNN 的课程定位课程设计不要求用最先进模型但要求说清楚“为什么选它”。下面这张表是我在设计教学案例时常用的对比覆盖了机器学习课程的核心算法谱系算法参数规模训练方式测试时推理特点易踩的坑KNN无记忆全部训练样本无训练仅存储每个样本计算与所有训练样本的距离延迟高特征不归一化时距离被大像素主导逻辑回归Softmax784×10 bias梯度下降一次矩阵乘毫秒级线性决策边界对笔画形变不鲁棒小 CNN约 5 万参数反向传播十几次卷积全连接毫秒级训练不充分时比逻辑回归还差KNN 在课程里最适合做 baseline因为它零训练、容易解释“最近邻”这一直观概念。逻辑回归适合验证预处理是否有效如果线性模型准确率都上不去说明特征或标签有问题。CNN 则是最终的精度担当但要防止过拟合——数据只有 60000 张对一个随机初始化的小网络来说很容易在 5 个 epoch 内把训练集背下来。2.4 数据加载与验证集划分的最小骨架无论后续用哪个算法第一步都是拿到标准化后的数据。用 PyTorch 加载 MNIST 的常见做法是import torch from torchvision import datasets, transforms from torch.utils.data import random_split # 只基于训练集统计量做标准化测试集复用同一套参数 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_full datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) test_set datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_set, val_set random_split(train_full, [54000, 6000]) train_loader torch.utils.data.DataLoader(train_set, batch_size64, shuffleTrue) val_loader torch.utils.data.DataLoader(val_set, batch_size64, shuffleFalse)transforms.Normalize((0.1307,), (0.3081,))中的两个数分别是 MNIST 训练集的像素均值和标准差它们必须在训练集上计算不能拿测试集统计量参与训练否则就是信息泄漏。random_split从训练集里切出 6000 张作为验证集目的是在每次 epoch 后检查模型是否过拟合。验证集和测试集的区别要区分清楚验证集用于调超参数和早停测试集只在最终评估时碰一次这样得到的精度才有说服力。3. 用 KNN 到 CNN 的顺序搭出可用模型3.1 用 KNN 在十分钟内跑通手写数字识别KNN 是理解“基于实例学习”的最佳起点。它没有训练过程只需存储特征矩阵预测时计算输入样本与全部训练样本的距离取距离最近的 K 个标签投票。用 scikit-learn 实现时只需几行from sklearn.neighbors import KNeighborsClassifier from sklearn.metrics import accuracy_score # X_train 为 (54000, 784)先做 0-1 归一化再训练 model KNeighborsClassifier(n_neighbors5, p2, metricminkowski) model.fit(X_train, y_train) y_pred model.predict(X_test) print(fKNN accuracy: {accuracy_score(y_test, y_pred):.4f})n_neighbors5是经验值K 太小单点噪声对决策影响大K 太大会把其他类别的样本纳入投票。p2表示使用欧氏距离对应的闵可夫斯基距离。第一次跑建议先用 5000 个训练样本测试流程因为 KNN 预测时每个样本都要算 5.4 万次距离全量跑 10000 个测试样本在普通笔记本上大约需要几十秒能明显感受到“无训练但有推理成本”的含义。这个 baseline 在不做任何像素工程时大约能到 96% 准确率但它足够暴露一个关键问题某些手写数字的笔画粗细和偏移对距离影响很大而人类识别时更关注拓扑结构。这就是引导到神经网络的自然动机。3.2 训练一个能到 99% 的小型 CNN课程案例里我推荐一个三层结构两个卷积块加一个全连接分类头。它能以极快的训练速度达到 99% 附近且显存占用小import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.conv_block nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 32, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2) ) self.fc nn.Sequential( nn.Linear(32 * 7 * 7, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, 10) ) def forward(self, x): x self.conv_block(x) # 28x28 - 14x14 - 7x7 x x.view(x.size(0), -1) return self.fc(x)第一层卷积输出 32 个通道保持空间尺寸不变最大池化后得到 14×14第二层卷积进一步提取特征池化后到 7×7此时张量形状为(batch, 32, 7, 7)展平后正好是 1568 维。全连接层中间的 Dropout 是唯一用力正则化的地方训练阶段随机丢弃 30% 的神经元防止全连接层把训练集标签背下来。训练循环按标准流程写。优化器选 Adam初始学习率 3e-4批量大小 64跑 10 个 epochimport torch.optim as optim model SimpleCNN() optimizer optim.Adam(model.parameters(), lr3e-4) criterion nn.CrossEntropyLoss() for epoch in range(10): model.train() total_loss 0.0 for imgs, labels in train_loader: optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() # 每个 epoch 后看验证集精度早停的依据就在这里 model.eval() val_correct 0 with torch.no_grad(): for imgs, labels in val_loader: preds model(imgs).argmax(dim1) val_correct (preds labels).sum().item() acc val_correct / len(val_set) print(fepoch {epoch1}, loss {total_loss:.3f}, val_acc {acc:.4f})CrossEntropyLoss内部先做 softmax 再算交叉熵所以模型最后一层不加 softmax 激活函数预测时argmax(dim1)直接取最大 logit 对应的类别。验证时用no_grad()禁用梯度计算这不仅能省显存更重要的是提醒自己推理阶段不需要保留计算图。3.3 三个必调的参数与过拟合判断这个结构的训练通常不会出大问题但课程报告里最常被质疑的就是“你没说清楚参数为什么这样设”。至少要能回答这三个参数batch_size64批量大小决定梯度估计的噪声水平。64 在 MNIST 上是平衡训练速度与收敛稳定性的常用选择。显存不够时降到 32但不要直接跳到 8否则训练震荡明显。lr3e-4Adam 的默认学习率是 1e-3对 MNIST 也有效但 CNN 的梯度尺度变化较大3e-4 能减少前期震荡。如果 loss 在前 3 个 epoch 内不下降先看数据预处理是否漏了 Normalize。epochs10MNIST 的小模型 10 轮通常已达到充分拟合。训练完成后画一张训练 loss 与验证准确率随 epoch 变化的曲线如果验证准确率在某个 epoch 后不再上升而训练 loss 继续下降就是过拟合信号此时应把早停点设在那之前。3.4 混淆矩阵精度之外必须看的评估指标总准确率会掩盖单类错误的问题。数字 4 和 9 在部分手写风格下极像4 的识别错误会被其他类别的正确预测平均掉。训练结束后把测试集预测结果做成混淆矩阵from sklearn.metrics import confusion_matrix y_true, y_pred [], [] model.eval() with torch.no_grad(): for imgs, labels in test_loader: y_true labels.tolist() y_pred model(imgs).argmax(dim1).tolist() cm confusion_matrix(y_true, y_pred) # 查看每个类别的召回率 recall cm.diagonal() / cm.sum(axis1) print(recall) # 数据格式每个数字被正确识别的比例对角线上的数值代表每个类别的召回率。如果某个数字的召回率明显低于平均值说明系统对特定笔画结构缺乏区分能力这是数据增强和调结构的重要依据比盲目堆层数有用得多。4. 把模型嵌入手写数字识别系统开发4.1 一个可交互系统的三块骨架模型文件本身不是系统。一个能演示的系统至少由三部分组成输入模块负责获取用户手写内容预处理模块负责把画布上的笔画转换成模型能接受的张量推理展示模块负责调用模型并给出置信度。三者之间用清晰的数据结构连接——画布坐标、像素矩阵、预测结果。课程案例最常见的错误是 GUI 和模型耦合在一起。正确做法是把推理封装成独立函数predict(preprocessed_image) - (label, confidence)GUI 只负责事件循环和绘图这样后续替换输入方式比如改成摄像头拍照时不需要动推理逻辑。4.2 画板输入规整成 28×28 的预处理流程MNIST 训练样本里的数字是居中且大小统一的但用户在画板上写出的数字可能偏左、偏小、笔画细。直接缩放会导致内容偏移出有效区域识别率显著下降。我常用的预处理流程分三步import cv2 import numpy as np def preprocess_canvas(canvas_grayscale, target_size(28, 28)): # cv2.THRESH_BINARY_INV 使数字为白色前景值为 255背景为 0 _, binary cv2.threshold(canvas_grayscale, 127, 255, cv2.THRESH_BINARY_INV) # 找到数字的边界框并裁剪去掉四周多余空白 coords cv2.findNonZero(binary) x, y, w, h cv2.boundingRect(coords) if w 5 or h 5: # 过滤掉误触产生的微小噪点 return None cropped binary[y:yh, x:xw] # 保持宽高比缩放到 20x20再贴到 28x28 画布的几何中心 scale 20.0 / max(cropped.shape) resized cv2.resize(cropped, (int(cropped.shape[1] * scale), int(cropped.shape[0] * scale))) canvas np.zeros((28, 28), dtypenp.uint8) y_offset (28 - resized.shape[0]) // 2 x_offset (28 - resized.shape[1]) // 2 canvas[y_offset:y_offset resized.shape[0], x_offset:x_offset resized.shape[1]] resized # 归一化并保持与训练时相同的 (1,1,28,28) 张量形状 canvas canvas.astype(np.float32) / 255.0 canvas (canvas - 0.1307) / 0.3081 return torch.tensor(canvas, dtypetorch.float32).unsqueeze(0).unsqueeze(0)这段代码的关键在scale 20.0 / max(cropped.shape)。MNIST 的原始数据里数字主体约占 20×20 像素四周有 4 像素左右的留白。把数字统一缩放到 20×20 后嵌入 28×28 画布能尽可能还原训练集的视觉统计特征。findNonZero配合boundingRect实现自动对齐省去了手工指定坐标。4.3 用 Tkinter 搭一个 50 行以内的手写输入界面一个最简单的桌面界面只需要三个组件画布、清空按钮、识别结果标签。Tkinter 是 Python 自带 GUI 库无需额外安装适合课程展示import tkinter as tk from PIL import Image, ImageDraw class App: def __init__(self, model): self.model model self.root tk.Tk() self.root.title(手写数字识别系统) self.canvas tk.Canvas(self.root, width200, height200, bgwhite) self.canvas.pack() self.canvas.bind(B1-Motion, self.paint) self.label tk.Label(self.root, text在这里书写, font(Arial, 16)) self.label.pack() tk.Button(self.root, text识别, commandself.predict).pack(sidetk.LEFT) tk.Button(self.root, text清空, commandself.clear).pack(sidetk.RIGHT) # 创建一张 200x200 的 RGB 画布用于导出图像 self.image Image.new(RGB, (200, 200), white) self.draw ImageDraw.Draw(self.image) def paint(self, event): r 5 self.canvas.create_oval(event.x-r, event.y-r, event.xr, event.yr, fillblack) self.draw.ellipse([event.x-r, event.y-r, event.xr, event.yr], fillblack) def predict(self): # 缩小到 28x28 灰度图交给预处理函数 img self.image.resize((28, 28)).convert(L) x preprocess_canvas(np.array(img)) with torch.no_grad(): logits self.model(x) prob torch.softmax(logits, dim1)[0] label torch.argmax(prob).item() conf prob[label].item() self.label.config(textf预测结果: {label} (置信度 {conf:.2f})) def clear(self): self.canvas.delete(all) self.draw.rectangle([0, 0, 200, 200], fillwhite) def run(self): self.root.mainloop()需要注意paint中画的是黑色线条但preprocess_canvas里用了THRESH_BINARY_INV会把黑色当作背景处理所以必须在predict中先做一次反向转换或者把self.image创建为黑底白字。比较稳的做法是画布背景设为黑色、笔画设为白色这样 Tkinter 显示和输入图像的方向一致不容易出错。4.4 用 Flask 把能力开放成 API如果是专业型课程设计或者想演示前后端分离架构可以把推理封装成 REST API。Flask 方案轻量且易读from flask import Flask, request, jsonify import base64, io from PIL import Image app Flask(__name__) app.route(/predict, methods[POST]) def predict_api(): data request.get_json() # 前端传来的 base64 图片格式为 PNG 或 JPEG img_bytes base64.b64decode(data[image_b64]) img Image.open(io.BytesIO(img_bytes)).convert(L).resize((28, 28)) x preprocess_canvas(np.array(img)) with torch.no_grad(): prob torch.softmax(model(x), dim1)[0] return jsonify({label: int(torch.argmax(prob)), confidence: float(prob.max())}) if __name__ __main__: app.run(host0.0.0.0, port5000)调用方只需构造一个{image_b64: ...}JSON。这个接口让前端可以用任意技术栈网页 Canvas、微信小程序甚至 Postman 直接测试。系统开发课程的评分点在这里就从“模型会跑”上升到了“接口契约清晰、部署维护方便”在实际工程里也是同样的拆分逻辑。5. 用自绘样本与增强技巧验证系统边界5.1 自绘测试集验证系统泛化能力的唯一可靠方法MNIST 的官方测试集虽然权威但它和用户实际操作时产生的图像有明显分布差异用户画的线更粗、数字倾斜更随意、位置偏移更大。把模型在测试集上的 99% 准确率当作系统真实水平是课程答辩时最容易被打穿的点。我建议训练结束后手写 50 个数字每个数字写 5 遍用系统实际识别并统计准确率。这组自绘样本要在开发过程中随时跑而不是答辩前最后一刻。如果自绘准确率明显低于测试集优先检查预处理数字是否居中、笔画粗细是否合理、缩放比例是否失真。很多情况下问题不在模型权重而在输入变换。5.2 数据增强延长模型边界MNIST 的 60000 张图像不足以覆盖所有真实手写风格。常见做法是随机平移 2 像素、随机旋转 ±15 度、随机缩放 0.9-1.1 倍。PyTorch 中可用torchvision.transforms.RandomAffine组合train_transform transforms.Compose([ transforms.RandomAffine(degrees15, translate(0.1, 0.1), scale(0.9, 1.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])增强的核心是让模型看到更多“变形版本”的数字。但要控制强度旋转超过 20 度后数字 6 和 9 的区分本身就困难增益会转成噪声。训练时只增强训练集测试和推理阶段永远用原始输入。5.3 置信度阈值与拒识策略当用户在画板上随意画一个圆圈时模型仍会输出一个概率最高的类别。课程设计里加上拒识策略能明显提升可用性当 softmax 最大概率低于某个阈值比如 0.7时返回“无法识别请重新书写”而不是强行给出数字。这个阈值需要根据自己的模型验证太严会拒绝正常数字太宽则失去意义。在predict函数里加入阈值判断只需两行代码但对系统的实用性和答辩观感的提升非常明显。本文还有配套的精品资源点击获取
返回列表