尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
手写数字识别:从kNN到CNN的PyTorch实战指南
简介面向Python初学者与深度学习入门者的手写数字识别项目包聚焦利用卷积神经网络实现图像分类识别。压缩包共13个文件以Python源代码脚本、预训练hdf5模型和10张jpg测试图片为主要内容整体大小6.49MB轻量易用。已有290人学习下载适合本地快速运行体验。包内从MNIST数据加载、网络结构定义、模型训练评估到实际预测完整呈现了手写数字识别的主要流程同时提供已训练好的模型无需重新训练即可直接对图片进行识别方便对照结果。项目还充分体现了卷积层、池化层、全连接层等核心概念并涉及数据增强、Dropout、正则化等优化手段有助于初学者系统理解技术原理并为进一步尝试更复杂的网络架构奠定基础。1. 手写数字识别一个值得从零跑的 Python 入门项目手写数字识别在现在看起来好像已经不算什么难题随便一个深度学习框架跑一遍 MNIST 都能到 99%但真把它当成自己的第一个 Python 项目来做你才会发现里面藏着数据加载、归一化、模型过拟合、输入形状不一致这些坑哪一个都能让你在深夜翻车。这篇文章我按自己动手做过的路线来写先讲 MNIST 怎么拿、怎么检查再用 k 近邻作为第一个不训练模型的 baseline最后用 PyTorch 把 CNN 准确率推到 99% 左右。适合刚学完 Python 语法想练手的读者也适合准备转 CV 方向但还没有完整跑过流程的开发者。你可以直接照着做也可以把它当成一份检验自己 Python 环境是否配好的检查单。2. 先备好数据和运行环境MNIST 的获取姿势与 NumPy/PyTorch 安装注意2.1 为什么选中 MNIST 而不是自己拍照MNIST 是手写数字识别最标准的入门数据集6 万张训练图加 1 万张测试图每张都是 28x28 的灰度图内容只有一个 0 到 9 的数字。它的价值在于足够小一张普通 CPU 就能训练也足够基准研究界几十年来把它的错误率压得很低所以我们拿它做实验对比能得到一个非常可信的“模型到底行不行”的参照。为什么不建议直接自己拍照做数据集因为手写图片有各种尺寸、背景、笔画粗细你还得自己标注分类而 MNIST 已经帮我们做好了切分和标签。用 MNIST 可以让你先专注在模型本身等模型稳定之后再迁移到真实图片。反过来如果你一上来就用真实图片遇到问题你根本分不清是数据清洗问题还是模型问题。我的建议一直是先 MNIST后自建样本。如果你对比过其他公开数据集会发现Fashion-MNIST 虽然也是 28x28但它识别的是衣服而不是数字SVHN 是街景门牌号数字周围还有额外噪声复杂度明显更高。在手写数字识别这个标题下MNIST 依然是最合适的起点没有之一。2.2 用 torchvision 一行下载 MNIST以及本地目录结构我的环境依赖很简单先装好四个库命令在命令行执行即可pip install numpy torch torchvision matplotlib这里重点说 nami 的安装方法直接pip install numpy就行如果已经装了但import报错多半是当前命令行用的 python 和 pip 不是同一个环境可以用python -m pip install numpy来指定解释器。这个坑在 Windows 上特别常见先记录下来后面避坑章还会再提到。接着用 torchvision 自带的接口下载 MNISTfrom torchvision import datasets, transforms # 定义预处理先转 Tensor 再标准化 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_set datasets.MNIST(root./data, trainTrue, transformtransform, downloadTrue) test_set datasets.MNIST(root./data, trainFalse, transformtransform, downloadTrue) print(len(train_set), len(test_set)) print(train_set[0][0].shape, train_set[0][1])运行后本地会出现./data/MNIST/raw/目录里面是四个 gzip 文件包括train-images-idx3-ubyte.gz、train-labels-idx1-ubyte.gz以及对应的测试集文件。你不需要手动解压torchvision 会自动读。这段代码里最值得讲的是transform。transforms.ToTensor()会把 PIL 图像从 HxW 的 uint8 数组转成 1x28x28 的 float 张量像素值从 0-255 缩放到 0-1。Normalize((0.1307,), (0.3081,))用的是 MNIST 官方统计的全局均值和标准差标准化之后数据分布才能让卷积网络训练更快、更稳。这两个数值是所有 MNIST 教程通用的直接抄就行。downloadTrue表示本地不存在时自动下载。训练集和测试集分开加载trainTrue拿 6 万张trainFalse拿 1 万张。如果你在别的机器上已经下载过数据把root指向那个目录就能跳过下载。2.3 用小批量可视化确认数据没坏从张量到图片数据拿到手后我最先做的一件事不是着急建模而是画几张图看看。这一步能发现根目录里文件是否损坏、标签是否对齐、图像方向是否异常。可视化时我特意用一个只带ToTensor()的 transform避免标准化把像素值拉成负数。import matplotlib.pyplot as plt from torchvision import datasets, transforms raw_transform transforms.Compose([transforms.ToTensor()]) raw_set datasets.MNIST(root./data, trainTrue, transformraw_transform, downloadTrue) plt.figure(figsize(9, 4)) for i in range(10): img, label raw_set[i] plt.subplot(2, 5, i 1) plt.imshow(img.squeeze(), cmapgray) plt.title(flabel{label}) plt.axis(off) plt.tight_layout() plt.savefig(mnist_samples.png) plt.show()train_set[i]返回一个二元组第一个元素是图像张量第二个是 int 标签。img.squeeze()把(1, 28, 28)里的通道维去掉变成(28, 28)否则imshow会报错或者画成奇怪的颜色图。cmapgray必须写matplotlib 默认的 colormap 会把灰度数字渲染成诡异的彩色。如果你保存出来的图片是全黑或者全白先检查是不是raw_transform误加上了Normalize。标准化后的张量里会有负数值直接imshow会把范围整体压缩图像细节就丢了。我一般看到这里输出正常才会继续往下走。3. 从零实现 k 近邻分类器用 NumPy 跑通第一个手写数字识别3.1 kNN 为什么适合作为第一个模型k 近邻分类器kNN不是一个需要训练的模型它的原理特别直白给定一张测试图计算它和所有训练图片的像素距离取距离最近的 k 张训练图再统计这 k 张图的标签出现次数最多的就是预测结果。这种非参数方法没有反向传播不用调学习率唯一要做的是定义距离和选 k 值。用在手写数字识别上28x28 的图展开后是 784 维特征每个维度的像素值都在 0-1 之间量纲一致。两个数字是不是像用欧氏距离就能反映出来同一个数字的笔画位置相近像素差的平方和就小。所以 kNN 在 MNIST 上能做到 95% 以上的准确率这已经足够验证整个流程。kNN 的局限也很明显每次预测都要和全部 6 万张训练图计算距离存储和计算开销都大在高维空间里距离会变得不那么敏感所以它离 CNN 的 99% 还有一段距离。但作为手写数字识别任务的第一个 baseline它便宜、直观、不容易出 bug适合用来检查数据加载和评估脚本是不是正确。3.2 计算欧氏距离的矩阵化写法不用 for 循环先写一个加载函数把 torchvision 的数据转成 NumPy 数组方便我们手动实现距离计算。import numpy as np def load_data_as_numpy(root./data): 从 torchvision 加载 MNIST并转成 numpy 数组。 返回 X_train, y_train, X_test, y_test。 X 的 shape 是 (样本数, 784)y 是 (样本数,) from torchvision import datasets, transforms transform transforms.ToTensor() train_set datasets.MNIST(rootroot, trainTrue, downloadTrue, transformtransform) test_set datasets.MNIST(rootroot, trainFalse, downloadTrue, transformtransform) X_train np.stack([img.numpy().reshape(-1) for img, _ in train_set]) y_train np.array([label for _, label in train_set]) X_test np.stack([img.numpy().reshape(-1) for img, _ in test_set]) y_test np.array([label for _, label in test_set]) return X_train, y_train, X_test, y_test def knn_predict_one(x, X_train, y_train, k5): diff X_train - x # (N, 784) distances np.sqrt(np.sum(diff ** 2, axis1)) top_k np.argsort(distances)[:k] # 距离最小的 k 个索引 top_labels y_train[top_k] votes np.bincount(top_labels) # 统计每个类别出现次数 return votes.argmax()X_train - x这一步利用了 NumPy 广播测试样本 x 被自动扩展成和训练集一样的行数每一行对应一张训练图。逐行做差、平方、按列求和、开根号就得到 x 到所有训练样本的距离。np.argsort返回从小到大排序后的索引取前 k 个再通过索引去训练标签里取对应的类别。np.bincount(top_labels)会生成一个数组下标是类别号值是该类别出现的票数argmax()就是票数最多的类别也就是预测结果。这里不需要对像素再做标准化因为ToTensor()已经把所有像素限制在 0-1每个维度的贡献是等权的。如果你换成其他特征比如 HOG 或颜色直方图量纲不一致时一定要先归一化否则某个特征会主导距离这个细节很容易被忽略。k 值是 kNN 最重要的参数。k1 时只相信最近邻噪声样本会导致误判k 太大会把远处不同类的样本也拉进来。我一般会先跑 k3 和 k5 对比MNIST 上两者差别很小但 k5 对单个噪声点更稳健。3.3 在测试集上评估准确率并画混淆矩阵全量测试集是 1 万张CPU 上跑全部预测要等好久所以第一次验证我先取 200 张流程通了再全量也不迟。def knn_predict_batch(X_train, y_train, X_test_batch, k5): preds [] for x in X_test_batch: preds.append(knn_predict_one(x, X_train, y_train, k)) return np.array(preds) X_train, y_train, X_test, y_test load_data_as_numpy() sample_size 200 y_pred knn_predict_batch(X_train, y_train, X_test[:sample_size], k5) acc (y_pred y_test[:sample_size]).mean() print(fkNN accuracy on {sample_size} test images: {acc:.4f})这里取前 200 个测试样本是为了保持等待时间在几秒内。如果直接跑一万个在 6 万训练样本上逐样本算距离我的经验是容易等到怀疑人生所以先用小批验证流程再决定是否全量评估。准确率计算就是用预测结果和真实标签做比较(y_pred y_test).mean()是 NumPy 把布尔数组转成浮点数后取均值简洁又不容易错。接下来画混淆矩阵它能帮我们看清楚哪些数字之间容易互相认错。from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay cm confusion_matrix(y_test[:sample_size], y_pred) disp ConfusionMatrixDisplay(cm, display_labelsrange(10)) disp.plot(cmapBlues) plt.savefig(knn_confusion_matrix.png)混淆矩阵的对角线上是预测正确的位置非对角线上的亮块就是混淆项。MNIST 里最常见的是数字 2 和 8 互相认错因为它们都有一个横着的弧形结构在 28x28 的低分辨率下确实很像。看到这类错误不用慌这是数据本身的类间相似度造成的后面换 CNN 后会好很多但不会完全消失。我还会额外抽几个预测错误的样本把原图和真实标签打印出来看一眼wrong_idx np.where(y_pred ! y_test[:sample_size])[0] for idx in wrong_idx[:5]: plt.imshow(X_test[idx].reshape(28, 28), cmapgray) plt.title(ftrue{y_test[idx]}, pred{y_pred[idx]}) plt.savefig(fwrong_{idx}.png) plt.close()这个习惯我从 kNN 一直带到 CNN 阶段准确率只能告诉你错多少不能告诉你为什么错。只有看错误样本的原始图像你才能判断是笔画太潦草、图片有噪声还是模型真的抽风。手写数字识别的很多“玄学”问题最后都是靠这一眼定位出来的。4. 用 CNN 把准确率提到 99%PyTorch 训练循环与关键参数4.1 卷积网络的结构设计Conv2dReLUMaxPool2d 的堆叠CNN 和 kNN 的本质区别在于它不直接用原始像素做距离而是通过卷积核学习局部纹理特征横线、竖线、弧线、角点。多层的结构会把这些特征逐步组合成更高层的信息比如“左上角有弯弧”代表数字 2。ReLU 负责给网络加非线性MaxPool2d 做下采样既缩小特征图又保留最明显的响应。MNIST 的图只有 28x28不需要很深的网络我经常用的是 LeNet 风格、但加了一点 Dropout 的版本import torch.nn as nn class DigitCNN(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), # 输入1通道输出32通道 nn.ReLU(), nn.MaxPool2d(2), # 14x14 nn.Conv2d(32, 64, 3, padding1), # 输出64通道 nn.ReLU(), nn.MaxPool2d(2), # 7x7 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 7 * 7, 128), nn.ReLU(), nn.Dropout(0.5), # 训练时随机丢弃一半神经元 nn.Linear(128, 10), ) def forward(self, x): return self.classifier(self.features(x))第一层卷积输入通道是 1因为 MNIST 是灰度图如果你后面想迁移到 RGB 图片这里要改成 3。padding1让卷积后分辨率保持 28x28池化后变成 14x14第二层卷积后再池化变成 7x7。所以全连接层输入维度是64 * 7 * 7这个数字由最后一层特征图通道数和分辨率共同决定改网络结构时最容易漏。Dropout(0.5)的作用是训练时随机把一半神经元的输出置零强迫网络不依赖个别神经元减少过拟合。测试时要记得切到eval()模式Dropout 才会自动关闭否则预测结果会带随机性。通道数 32 和 64 是 MNIST 上的经验值。再大幅增加通道数对准确率帮助不大反而让训练变慢、更容易过拟合。如果你想压榨更好的精度可以在这个基础上加 BatchNorm但数据量这么小收益很有限。4.2 数据加载器batch、shuffle、num_workers 的设置误区训练不能一次把所有图片都塞进网络因为内存和显存都扛不住所以用 DataLoader 按 batch 分批喂数据。from torch.utils.data import DataLoader train_loader DataLoader(train_set, batch_size64, shuffleTrue, num_workers2) test_loader DataLoader(test_set, batch_size256, shuffleFalse, num_workers2)batch_size64表示每次计算 64 张图的梯度并更新一次参数。这个值不是越大越好太大容易把优化过程变得像“一步到位”太小又会导致梯度震荡。MNIST 上用 64 很保险如果你的显存不够可以降到 32效果差别不大。shuffleTrue只用在训练集作用是把每个 epoch 里的样本顺序打乱避免模型遇到重复顺序产生“偷懒”的依赖。测试集shuffleFalse这样每次评估看到的是同一个顺序结果可复现也好和索引对应。num_workers是子进程个数用来并行读取数据和做 transform。在 Linux 上设 2 或 4 很常见但在 Windows 上有个坑如果脚本没有用if __name__ __main__:包住训练代码多进程会在所有子进程里重新执行一遍脚本导致递归加载甚至卡死。我第一次在 Windows 上跑就是这么翻车的。如果你不确定先把num_workers设成 0确认流程跑通后再开 2 或其他数字别在一开始就给性能优化添乱。4.3 训练循环里的损失曲线与学习率调整把数据加载器和模型接在一起训练循环就是一个标准的三步走前向算损失、反向算梯度、更新参数。import torch import torch.nn as nn device torch.device(cuda if torch.cuda.is_available() else cpu) model DigitCNN().to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3) epochs 5 for epoch in range(epochs): model.train() total_loss 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() logits model(images) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) avg_loss total_loss / len(train_loader.dataset) model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in test_loader: images, labels images.to(device), labels.to(device) logits model(images) pred logits.argmax(dim1) total labels.numel() correct (pred labels).sum().item() acc correct / total print(fepoch {epoch1:02d} | loss {avg_loss:.4f} | test acc {acc:.4f})model.train()和model.eval()切换的不仅是状态变量还有 Dropout 和 BatchNorm 的行为。训练时 Dropout 随机关闭神经元eval 时它们恢复成直通如果你忘了切换测试准确率会忽高忽低这是初学最容易忽略的一行。optimizer.zero_grad()必须放在每次参数更新之前。PyTorch 默认是梯度累加不清零的话下一次backward()会把新旧梯度加在一起参数更新方向就变了。loss.item()是取出当前 batch 的标量损失乘上images.size(0)得到整个 batch 的总损失把所有 batch 加起来再除以总样本数得到的是不依赖 batch_size 的平均损失。Adam 的默认学习率1e-3在这个问题上表现稳定。如果你观察到 loss 来回震荡或者准确率不涨可以把 lr 降到3e-4或1e-4进阶一点可以加StepLR每两个 epoch 让学习率降一个量级。MNIST 训练 5 个 epoch 通常能到 98.5% 以上10 个 epoch 稳定到 99% 左右。如果训练集 acc 已经接近 100% 而测试集只有 97%别急着继续训练先看下避坑章里的过拟合处理。5. 手写数字识别避坑指南数据形状、归一化与过拟合的 3 个常见问题5.1 训练集准确率高但测试集低过拟合的信号现象训练集上准确率很快就到了 99.9%测试集却一直在 97% 附近徘徊甚至还在下降。原因模型把训练集里的噪声、背景纹理和特定笔画的偶然特征都背下来了拿到没见过的测试图反而泛化不好。解决思路有三个加 Dropout、做数据增强、在测试集准确率不再上升时早停。我在模型里已经加了Dropout(0.5)如果你还没加先加上再看效果。数据增强是更主动的手段MNIST 最常用的是在训练时对图像做轻微随机旋转和平移transform_train transforms.Compose([ transforms.RandomAffine(degrees8, translate(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])RandomAffine(degrees8)表示最多旋转正负 8 度translate(0.1, 0.1)表示最多水平和垂直平移 10%。数字手写本身就有倾斜和偏移这种增强相当于把真实世界的手写差异模拟出来。注意这个 transform 只能在训练集用测试集和推理时仍用原来的ToTensor Normalize否则评估结果会和真实场景不一致。早停的判断方法也简单每个 epoch 结束后打印测试集 acc如果连续两个 epoch 不涨就停止训练保留上一个 epoch 的模型。不要等到训练集 100% 了才停那多半已经过拟合。这个损失曲线可以画出来看但数值趋势已经足够判断。5.2 归一化范围搞错导致识别全乱像素值 0-255 vs 0-1现象训练时模型准确率很高但保存下来后推理自己画的图输出结果全是 0或者五个数字来回换。原因非常常见训练时ToTensor()把图片从 uint8 的 0-255 转成了 float 的 0-1推理时你却直接用 PIL 或 OpenCV 读出来的 numpy 数组喂给模型模型看到的是 0-255 的分布当然全乱。检查方法是在喂给模型之前打印像素范围import torch # 假设 img_np 是你从图片读出来的 numpy 数组 tensor torch.from_numpy(img_np).float() print(tensor.min().item(), tensor.max().item())如果输出接近0.0和255.0说明归一化被跳过了。正确做法是把整个预处理做成同一个transforms.Compose推理时也走一遍transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])ToTensor已经完成除以 255Normalize再把它转成标准分布。如果你在推理代码里手动先img_np / 255.0然后又调用了Normalize那等于做了两次标准化分布也会偏。所以最好的习惯是统一使用同一个 transform不要既手动归一化又叠加 transform。还有一个更隐蔽的坑ToTensor()对 PIL 图像的通道顺序是 HWC对 numpy 数组如果形状是(28, 28)会被当成单通道但如果你的数组是(28, 28, 1)一定要先squeeze()掉最后一维否则输入通道数会变成 1 但多出一个无效维度网络虽然不报错预测结果却完全不对。5.3 自己画图输入模型预测失败尺寸与通道不匹配现象你用系统画图板写了一个“7”保存成 300x300 的 PNG然后用img.resize((28, 28))缩小后输入模型预测结果却是 2 或者 9。原因不只是缩放还存在两个更前置的问题图像是 3 通道 RGB但模型训练时输入是 1 通道灰度MNIST 是黑底白字而你画板默认是白底黑字颜色语义和训练集是反的。正确的预处理路径是先转灰度、再判断底色、必要时反转、最后缩放到 28x28 并加 batch 维度。from PIL import Image, ImageOps import torchvision.transforms as transforms import torch img Image.open(my_digit.png).convert(L) # 1. 转灰度 # 2. 判断背景是不是白色取左上角像素 if img.getpixel((10, 10)) 127: # 白色背景 img ImageOps.invert(img) # 反转成黑底白字 # 3. 缩放并保持宽高比避免变形 img img.resize((28, 28), Image.BILINEAR) transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) tensor transform(img).unsqueeze(0) # 变成 (1,1,28,28)这里的ImageOps.invert会把白底黑字的“7”变成黑底白字的“7”和 MNIST 训练样本一致。getpixel((10, 10))取左上角判断背景色超过 127 认为背景是白色需要反转如果你的图片已经是黑底白字就不做反转。resize((28, 28))直接用最近邻插值会导致数字边缘出现锯齿所以我用Image.BILINEAR。更讲究一点的做法是先等比缩放到 20x20再放到 28x28 黑色画布中央这样能保持笔画的长宽比最后的代码实例会在下一章展示。如果你预测的还是不对最后一步是把这个 tensor 拿去model.eval()之后再预测同时关掉梯度。很多“模型不错但推理乱猜”的事故都是因为忘了model.eval()导致 Dropout 在推理时还在随机丢神经元。6. 把模型用起来整行手写数字的连通域分割法单数字识别练完真实需求往往变成“识别一行手写数字”比如快递单号、试卷学号。我在这类场景里最常用也最稳的分割方案是连通域法先二值化然后用 OpenCV 找每个数字的轮廓按 x 坐标排序最后把每个区域转成 MNIST 格式。import cv2 import numpy as np def extract_digits(gray): # 自动阈值并反转成黑底白字 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes sorted([cv2.boundingRect(c) for c in contours], keylambda b: b[0]) digits [] for x, y, w, h in boxes: if min(w, h) 5: # 过滤噪点 continue digit binary[y:yh, x:xw] # 单个数字区域 # 等比缩放到高或宽为 20再放到 28x28 黑画布中央 scale 20.0 / max(h, w) resized cv2.resize(digit, (int(w*scale), int(h*scale)), interpolationcv2.INTER_AREA) canvas np.zeros((28, 28), dtypenp.uint8) x0 (28 - resized.shape[1]) // 2 y0 (28 - resized.shape[0]) // 2 canvas[y0:y0resized.shape[0], x0:x0resized.shape[1]] resized digits.append(canvas) return digitscv2.threshold里的THRESH_BINARY_INV THRESH_OTSU是自动确定阈值的组合不用手动指定阈值它能根据灰度直方图分离文字和背景结果统一输出成黑底白字。findContours的RETR_EXTERNAL只找最外层轮廓避免数字内部的小洞被当成独立区域。排序时的keylambda b: b[0]是按每个框的左上角 x 坐标从左到右排这保证了输出顺序就是手写数字的阅读顺序。缩放时我没有直接把区域压成 28x28而是先按比例缩到最长边 20再贴在 28x28 画布中央。这样数字的长宽比被保留不会因为拉伸变形影响识别。interpolationINTER_AREA适合缩小图像能减少边缘混叠。拿到每个数字的裁剪图后用训练好的模型逐个预测。别忘了把DigitCNN切到eval()模式并给每个裁剪图加unsqueeze(0)变成 batch 维度GPU 上推理时 tensor 也要.to(device)。这一步我做过的血泪教训是分割脚本里看到的图很多都是黑底白字但训练 transform 里有Normalize如果推理时忘了加同样的Normalize准确率会掉好几个点比模型本身还致命。我自己的习惯是写任何图像预处理脚本都会先保存一张中间结果图确认切割框、缩放尺寸、黑白方向都对再让模型去预测。手写数字识别这类任务模型翻车的概率其实不高真正让你头大的永远是前后处理不一致。希望你按这套流程跑一遍之后能少踩我当年踩过的那些坑顺利把手写识别从 MNIST 迁移到你自己的真实图片上。本文还有配套的精品资源点击获取
RELATED

相关推荐

从无标题文档到正式发布:先定内核再取标题的创作流程

从无标题文档到正式发布:先定内核再取标题的创作流程

很多人打开文档软件时,都会看到一个小尴尬:新文档默认名不是“未命名”,就是“无标题”。我自己电脑里,这种文件常年躺了一排,里面有的是灵感碎片,有的是写到一半的草稿,还有的干脆就是空白。但…

📅 2026/10/11 21:12:09
斯纳克图书馆管理系统PHP版v6.0实战部署与优化指南

斯纳克图书馆管理系统PHP版v6.0实战部署与优化指南

简介:斯纳克图书馆管理系统PHP版v6.0是一套面向中小型图书馆、高校院系资料室及数字资源管理场景的成熟Web应用系统,专为具备PHPMySQL开发基础的IT人员或信息化管理员设计,用于快速部署图书编目、借阅流通、标签打印与多终端认证一体化管理。…

📅 2026/10/11 21:12:09
易支付运营版源码部署与支付通道轮询、投诉进件实战解析

易支付运营版源码部署与支付通道轮询、投诉进件实战解析

简介:面向需要自建聚合支付平台的开发者与站长,这份运营版易支付系统源码提供支付宝、微信、QQ钱包、银联等多渠道免签约接入能力,支持PC扫码、H5、公众号等多种支付场景。系统基于PHP 7.4与MySQL开发,内置轮询投诉、进件管理等运…

📅 2026/10/11 21:12:09
MORE NEWS

更多资讯

📰

C# WinForm触摸屏虚拟键盘:基于SendInput的无焦点输入方案

简介:这是一份面向C#初学者与WinForm开发者的轻量级模拟键盘工具项目,专为触摸屏交互场景定制,解决无物理键盘设备下的快捷输入需求。项目完整实现悬浮式圆形键盘界面、Win32底层按键注入、SendKeys指令发送及窗体图片填充等核心功能&#xf…

📰

MySQL慢查询排查与索引底层:从B+树到联合索引实战指南

慢查询排查和索引底层这两块,几乎是 MySQL 面试中逢面必问的固定节目。我这些年作为面试官也面过不少人,发现一个普遍现象:很多人能背出 B 树、能说出联合索引最左前缀,但一落到具体线上场景就发懵——慢 SQL 到底从哪发现的&…

📰

Python时间序列分析实战:从Pandas数据预处理到ARIMA与SARIMA建模预测

简介:本资源是一份面向Python数据分析初学者与进阶学习者的时间序列实战资料,以美国西雅图费利蒙桥自行车流量数据为案例,帮助读者掌握Pandas处理时间序列数据的完整流程。内容涵盖CSV数据读取、日期索引设置、列名重命名、缺失值与重复值清洗…

📰

科迅捷AI的七个功能,总有一个能救你的论文

打开科迅捷AI写作,很多人的第一反应是:功能这么多,到底哪个适合我?其实不用一次记全,你只需要记住一件事——你处在论文写作的哪个阶段,就去用对应的那个功能。这篇文章把它的七个核心功能一次讲清楚&#…

📰

基于Python的电影数据可视化分析系统:从爬虫到看板实战

简介:面向计算机相关专业毕业设计与项目实战学习者的电影数据可视化分析系统,提供从数据获取到票房预测的完整解决方案。项目采用Python爬取豆瓣TOP250及猫眼票房数据,通过pandas和MySQL分别实现CSV与关系型数据库持久化,并利用可…

📰

论文审稿状态监控系统:Python爬虫+C#桌面端实现

简介:这是一款面向科研工作者与学术编辑的论文审稿进度管理工具,解决传统审稿流程中状态更新滞后、人工跟踪低效、跨系统沟通不便等痛点。软件采用Python与C#混合开发:Python负责网络请求、状态爬取与数据解析,C#构建桌面交互界面…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬