2. 写在开头
如果你在搜索引擎里敲"TensorFlow 手写数字识别",大概率会看到无数篇教你跑通 MNIST 的教程。但这类教程有一个致命的通病:跑完官方数据集之后,你仍然不知道这个模型能不能识别你自己写的数字。我第一次做完这个项目时,模型在测试集上准确率已经冲到 99.2%,但当我拿自己用鼠标在画图板里写的数字喂给它时,识别结果惨不忍睹——7 被认成 1,3 被认成 8,连 0 都经常翻车。
这篇文章想认真聊一次完整的手写数字识别项目:从数据准备,到 CNN 模型搭建,再到最终用自己的手写数字做实测。重点不放在"怎么把 MNIST 测试集刷到更高分",而是放在**"如何让你的模型真正认识你的笔迹"**这条最容易踩坑的路上。如果你刚学完神经网络基础、想找一个完整的项目练手,或者做完官方 demo 之后不知道怎么处理自己的图片,这篇文章应该能帮上忙。
先说一个好消息:这个项目不需要昂贵显卡,家里一台普通 CPU 电脑就能跑完整个训练流程,几分钟就能看到结果。整个技术栈就是 TensorFlow + Python,模型结构我用的是经典的 CNN 套路——卷积层提取特征、池化层压缩尺寸、全连接层做分类。一步一步都能看懂,即使你的数学基础一般,也能跟着做出来。
2. 这个入门项目到底在解决什么问题
2.1 任务本身:一个十分类的图像问题
手写数字识别的本质,是给一张包含数字 0-9 的灰度图片做分类。模型干的活很简单:输入一张 28x28 像素的灰度图,输出 10 个数,分别代表这张图属于 0 到 9 的概率,取最大值作为识别结果。
你可能会问:为什么是 28x28?这是 MNIST 数据集约定的标准尺寸。LeCun 团队当年把美国邮局的手写数字扫描图统一裁剪、缩放成了 28x28 的灰度图,这个尺寸后来成了手写数字识别的事实标准。28x28 意味着输入层有 784 个像素值,中间经过若干卷积和池化操作提取特征,最后通过 softmax 输出 10 个类别的概率。
2.2 为什么用 CNN 而不是普通全连接网络
一定有人推荐你用全连接网络做这个任务——毕竟输入只有 784 个值,两层隐藏层就能跑,代码也短。但实测下来,全连接网络在 MNIST 上的表现确实还行(大概 97% 左右),但它存在一个根本性的问题:它对像素位置极其敏感。数字在图片里稍微偏一点、倾斜一点,模型可能就懵了。
CNN 解决了这个问题的核心手段是局部感受野和权值共享。卷积核只关注一个小区域内的像素组合,比如一个 3x3 的卷积核能看到某个局部是横线、竖线还是拐角;然后在图片各个位置滑动时共享同一组权重,相当于用同一套"特征探测器"去扫描整张图。这就让模型天然具备了一定程度的平移不变性——特征出现在图片左上角还是右下角,被识别到的概率是差不多的。
说白了,CNN 是把"我在找什么特征"这件事做成了模式,而不是像素点的机械匹配。手写数字之间的差异,本质上就是笔画的粗细、位置、倾斜度这些局部特征的变化,CNN 这一套组合拳很对症。
2.3 整个项目的完整流程链路
在做任何代码之前,先在脑子里把整条流程画出来。我当时的流程是:
- 准备数据:下载 MNIST 作为训练集,同时准备一批自己手写的数字图片。
- 数据预处理:把 MNIST 和自己写的图片统一成相同的格式(灰度、28x28、像素值归一化)。
- 搭建模型:用 TensorFlow(Keras 接口)搭一个输入层 + 两个卷积块 + 全连接层的 CNN。
- 训练与验证:用训练集迭代若干轮,同时监控验证集准确率,防止过拟合。
- 保存模型:把训练好的模型权重保存到本地文件。
- 实测自己手写的数字:加载模型,把自定义图片预处理后送入模型做预测。
- 可视化分析:输出每张图的预测类别和置信度,甚至用混淆矩阵看错误主要集中在哪些数字上。
这篇文章的实践部分基本上会严格按照这个顺序展开。千万别跳过数据预处理那一步直接拿着自己的图去跑模型——那是当前最大的坑。
3. 数据准备:让模型和你的手写笔迹处于同一个世界
3.1 先用合法渠道获取 MNIST 数据集
MNIST 数据集是公开的学术数据集,包含 60000 张训练图片和 10000 张测试图片。在 TensorFlow 里获取非常方便,一行代码就能加载:
import tensorflow as tf # 直接加载 MNIST (x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()加载维度是 (60000, 28, 28),也就是 6 万张 28x28 的灰度图。标签是 0-9 的整数。下载过程中可能因为网络原因失败,建议多试几次,或者找一个稳定的镜像下载后放到本地再加载。
加载完数据后,有两个细节必须处理:
- 像素值归一化:MNIST 的原始像素值范围是 0 到 255,如果不归一化,数值范围差异过大会让梯度更新不稳定。简单除以 255 即可,得到 0 到 1 之间的浮点数。
- 标签转为 one-hot 编码:分类任务里,我们把整数标签 1 变成 [0, 1, 0, 0, 0, 0, 0, 0, 0, 0] 这样的向量,方便计算交叉熵损失。
# 归一化 x_train = x_train.astype('float32') / 255.0 x_test = x_test.astype('float32') / 255.0 # 增加通道维度,从 (60000, 28, 28) 变成 (60000, 28, 28, 1) x_train = x_train[..., tf.newaxis] x_test = x_test[..., tf.newaxis] # 标签转 one-hot y_train = tf.keras.utils.to_categorical(y_train, 10) y_test = tf.keras.utils.to_categorical(y_test, 10)从训练集里再切出 5000 张作为验证集,用于训练过程中的模型评估,剩余 55000 张用于训练。这一步很多人会忽略,但它对防止过拟合、判断训练是否收敛非常有帮助。
3.2 自己制作手写数字图片:格式是第一道关卡
识别自己手写数字的第一步,是"制造"符合模型输入格式的图片。具体要满足三个条件:灰度图、28x28、数字是亮色(白字)背景是暗色(黑底)。
第三点尤其是关键。MNIST 的图片是黑底白字,像素值反过来——背景接近 0(黑色),笔画接近 255(白色)。而你在画图板里默认画出来的是白底黑字,直接拿去识别,模型会把它当成"白纸上的黑色图案",特征完全对不上。我在第一次实测时就是踩了这个坑,识别准确率直接掉到 30% 以下。
自己生成数字图片的方式有很多种:
- 用画图板手绘,保存为 PNG。
- 用 OpenCV 的
cv2.putText批量生成不同字体、不同粗细的数字,用于模型测试。 - 用平板手写截图。
不管哪种方式,最后都要经过一套统一的预处理流程,我这里写了一个比较通用的脚本:
import cv2 import numpy as np def preprocess_custom_image(img_path, target_size=28): """ 将任意尺寸、任意背景色的手写数字图片统一成 MNIST 风格 """ # 读取灰度图 img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(f"无法读取图片: {img_path}") # 反色:如果是白底黑字,变成黑底白字 # cv2 读取灰度图的背景如果是白色(255),数字笔迹接近黑色(0) # 做一次 255 - img 即可反转 img_inv = 255 - img # 自适应二值化,抵消光照不均匀或扫描污渍 _, img_bin = cv2.threshold(img_inv, 128, 255, cv2.THRESH_BINARY) # 找到数字的轮廓,裁剪到数字本身的范围 contours, _ = cv2.findContours(img_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: raise ValueError("图片中未检测到数字轮廓") x, y, w, h = cv2.boundingRect(contours[0]) # 适当向外扩边,避免数字过于贴边 margin = 5 x = max(0, x - margin) y = max(0, y - margin) w = min(img.shape[1] - x, w + margin * 2) h = min(img.shape[0] - y, h + margin * 2) cropped = img_bin[y:y+h, x:x+w] # 缩放至 20x20(留出边距) resized = cv2.resize(cropped, (20, 20), interpolation=cv2.INTER_AREA) # 放置到 28x28 的黑色画布中央 canvas = np.zeros((28, 28), dtype=np.uint8) canvas[4:24, 4:24] = resized # 归一化 normalized = canvas.astype('float32') / 255.0 # 增加通道维度 normalized = normalized[..., np.newaxis] return normalized这一步里我做了几件普通人容易忽略的事:先按轮廓裁剪,再缩放到 20x20 放进 28x28 画布中央。如果直接把整个包含大量留白的图片粗暴缩放成 28x28,数字会变得非常小,模型看到的是一大片黑中间一个小白点,很难识别。先裁剪到数字本身的范围,再缩放,就能保证数字主体在 28x28 中占据合理面积。
3.3 训练集和自写图片之间的"域差异"
做数据准备的时候,我还想专门提一个概念:域差异。训练集(MNIST)的标准书写方式和普通人手绘的书写方式是有明显差异的。比如:MNIST 的数字在标准化时经历了居中和缩放处理,而我们手上拿到的图往往歪歪扭扭、字号忽大忽小、有大量留白。
一份好的输入预处理脚本,本质上是在模仿 MNIST 制作流程中的"居中缩放"环节。很多人没有意识到,预处理这个环节的意义,不是把图片变好看,而是让图片和训练集数据服从相近的分布。这也是为什么"三分模型、七分预处理"这句话在手写数字测试这种场景里尤其适用。
4. CNN 网络设计:每一层的形状变化都要心里有数
4.1 经典 LeNet-5 架构的现代改良版
最经典的 CNN 手写数字识别模型是 LeNet-5,由 LeCun 在 1998 年提出。它的结构是:卷积-池化-卷积-池化-全连接-全连接-输出。我搭的网络基本沿用了这个骨架,但在细节上做了两处改进:一是把原来的 5x5 卷积换成了两个级联的 3x3 卷积(效果更好、参数更少),二是加入了 Dropout 用于缓解过拟合。
完整网络结构:
| 层名称 | 输出形状 | 参数量 | 备注 |
|---|---|---|---|
| 输入层 | (28, 28, 1) | 0 | 灰度图 |
| Conv2D (3x3, 32) | (28, 28, 32) | 320 | padding='same' |
| 最大池化 (2x2) | (14, 14, 32) | 0 | stride=2 |
| Conv2D (3x3, 64) | (14, 14, 64) | 18496 | padding='same' |
| 最大池化 (2x2) | (7, 7, 64) | 0 | stride=2 |
| Conv2D (3x3, 128) | (7, 7, 128) | 73856 | padding='same' |
| Flatten | 6272 | 0 | 77128=6272 |
| Dropout (0.5) | 6272 | 0 | 训练时随机丢弃一半 |
| 全连接层 (128) | 128 | 802944 | ReLU 激活 |
| 输出层 (10) | 10 | 1290 | softmax 激活 |
这个设计有几个值得你停下来想一想的地方。
4.2 为什么是 3x3 卷积叠加而不是一个 5x5
两个级联的 3x3 卷积,感受到的有效视野范围和单个 5x5 卷积是一样的:第一个卷积看到 3x3,第二个卷积在第一层输出上再看 3x3,叠加起来感受野到了 5x5,但参数量却少了一大截。单个 5x5 卷积有 25 个权重参数,而两个 3x3 卷积只有 2x9=18 个权重参数,中间还多了一次非线性激活,表达能力反而更强。这是现代卷积网络设计里的基本常识,用在手写数字这种小图上同样有效。
另外,两次池化把特征图从 28x28 逐步缩到了 7x7。**为什么要反复压缩空间尺寸?**因为高层的特征更抽象,比如"这个局部是一条弧线,那两个弧线组合起来是一个 6 的形状"。抽象特征不需要太多空间位置信息,越到高层越应该整合成"这里有什么东西"的判断,而不是"这个特征出现在第几行第几列"。
4.3 Flatten 之后为什么接 Dropout
从 7x7x128 展平之后,输出向量长度是 6272,直接接全连接层,参数数量大约是 6272x128=80 万。对于一个训练集只有 5.5 万张图片的任务来说,这个参数规模已经偏大了,特别容易过拟合——模型会记住训练集里所有数字的具体笔顺细节,而不是泛化的笔画结构。
Dropout 在训练时随机让一半的神经元不参与计算,相当于同时在训练多个网络结构,测试时再把所有结构组合起来。实测下来,加了 Dropout 之后验证集准确率从 98.8% 提升到了 99.2%,这个提升主要来自对验证集(也就是没见过的数字写法)的泛化能力。
4.4 用 Keras 的 Sequential 接口搭起来
在 TensorFlow 中,推荐直接用tf.keras.Sequential,代码非常直观:
model = tf.keras.Sequential([ tf.keras.layers.Conv2D(32, (3, 3), padding='same', activation='relu', input_shape=(28, 28, 1)), tf.keras.layers.MaxPooling2D((2, 2)), tf.keras.layers.Conv2D(64, (3, 3), padding='same', activation='relu'), tf.keras.layers.MaxPooling2D((2, 2)), tf.keras.layers.Conv2D(128, (3, 3), padding='same', activation='relu'), tf.keras.layers.Flatten(), tf.keras.layers.Dropout(0.5), tf.keras.layers.Dense(128, activation='relu'), tf.keras.layers.Dense(10, activation='softmax') ]) model.summary()padding='same'的意思是卷积不改变特征图的空间尺寸,通过补零让输入输出宽高保持一致。这样设计的好处是池化层可以精准地把尺寸除以 2,从 28->14->7,每一步都很有节奏。
5. 训练控制:不要让模型只会背答案
5.1 损失函数和优化器的选择
分类任务的选择基本不用纠结:损失函数用categorical_crossentropy(交叉熵),配合 softmax 输出使用;优化器直接选 Adam。Adam 的优势在于自带自适应学习率,普通用户不需要手动做学习率衰减,它会在梯度较陡的方向自动缩小步长、在梯度平坦的方向适当加速。
代码:
model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-4), loss='categorical_crossentropy', metrics=['accuracy'] )我建议学习率从1e-4开始,而不是默认的1e-3。MNIST 非常简单,1e-3也能收敛,但在后期出现验证集准确率波动(从 99.1% 掉回 98.6%)的概率更高。1e-4会让训练稍微慢一点,但曲线更平滑,最终准确率也更稳定。
5.2 训练中的过拟合信号:训练集准确率 100%,验证集却卡住
我在训练过程中遇到过一个非常典型的信号:训练集准确率在第 5 轮就达到了 99.9%,但验证集准确率始终在 98.5% 附近徘徊,怎么都上不去。这就是过拟合的典型表现——模型开始死记硬背训练集的各种笔迹噪声,而没有提高真正的泛化能力。
解决思路有三步:
- 加入 Dropout 层(前面已经做了)。
- 减少全连接层的宽度,把 128 改成 64,参数少了约一半。
- 引入数据增强。对于手写数字,最有效的是随机平移 2 个像素、随机旋转 10 度、随机缩放。MNIST 的数据增强有很多库支持,
tf.keras.preprocessing.image.ImageDataGenerator就能做。
增强代码:
datagen = tf.keras.preprocessing.image.ImageDataGenerator( rotation_range=10, width_shift_range=0.1, height_shift_range=0.1, zoom_range=0.1, )使用增强时注意:验证集不要增强。验证集的作用是衡量模型在接近真实数据上的表现,做增强反而会引入干扰。所以训练时用datagen对训练集生成增强数据,验证集保持原始数据。
5.3 早停和模型保存
训练中最实用的一个机制是EarlyStopping:当验证集准确率在连续若干个 epoch 内不再提升时,停止训练,防止多余的迭代浪费时间和算力。配合ModelCheckpoint可以在每个 epoch 结束保存验证集表现最好的模型权重。
callbacks = [ tf.keras.callbacks.EarlyStopping(monitor='val_accuracy', patience=3, restore_best_weights=True), tf.keras.callbacks.ModelCheckpoint( 'best_model.keras', monitor='val_accuracy', save_best_only=True, mode='max' ) ] history = model.fit( datagen.flow(x_train[5000:], y_train[5000:], batch_size=128), validation_data=(x_train[:5000], y_train[:5000]), epochs=20, callbacks=callbacks )实测下来,模型通常在第 6-8 轮就稳定在 99.2% 左右的验证集准确率。因为加了 EarlyStopping,训练大概率在第 10 轮前就结束了,整个过程在 CPU 上也就 3-5 分钟。
5.4 用测试集做最终评估
MNIST 自带的 10000 张测试集和训练集来自不同的书写者,是衡量模型泛化能力的好样本。跑一下评估:
test_loss, test_acc = model.evaluate(x_test, y_test, verbose=2) print(f'测试集准确率: {test_acc:.4f}')如果调试得当,这个数字应该落在 99% 左右。但这里有一个要提醒的点:测试集准确率高,不代表你的手写图片也能被正确识别。测试集的图片同样经过了严格的居中、缩放,而自己手写的图片就算是预处理过的,分布也未必和 MNIST 完全一致。接下来这一段才是真正考验你预处理能力的地方。
6. 识别自己的数字:三分模型,七分预处理
6.1 本地新建手写图片
在实际测试阶段,我准备了 10 张从 0 到 9 的手写数字图片。你可以在任何绘图工具里画,也可以直接拍下纸上的手写数字照片。建议用颜色反差比较大的笔迹,比如黑笔白纸,保证轮廓提取能顺利找到数字边缘。
画的时候有几个小提示:
- 尽量让数字居中,别靠边。
- 字体尽量大、笔画尽量粗。
- 别追求"完美打印体",自然书写即可——模型本来就应该能处理这些。
6.2 完整预测单张图片的流程
准备好图片之后,用前面写的preprocess_custom_image处理,然后送入模型:
def predict_digit(image_path, model): processed = preprocess_custom_image(image_path) processed = processed[np.newaxis, ...] # 增加 batch 维度 (1, 28, 28, 1) preds = model.predict(processed, verbose=0) digit = np.argmax(preds[0]) confidence = preds[0][digit] return digit, confidence # 逐一测试 import os image_dir = 'my_digits/' for fname in os.listdir(image_dir): path = os.path.join(image_dir, fname) digit, conf = predict_digit(path, model) label = fname.split('_')[1][0] # 假设文件名含真实标签 print(f"{fname}: 预测={digit}, 置信度={conf:.2f}, 真实标签={label}")6.3 实测结果与疑难样本分析
我把自己的实测结果记录下来,方便对照:
| 图片 | 真实值 | 预测值 | 置信度 | 备注 |
|---|---|---|---|---|
| my_0.png | 0 | 0 | 0.97 | 正常识别 |
| my_1.png | 1 | 1 | 0.89 | 倾斜的 1 也能识别 |
| my_2.png | 2 | 2 | 0.93 | 留白较大,预处理裁剪后正常 |
| my_3.png | 3 | 8 | 0.41 | 识别失败:3 的左半边略弯,看起来像 8 |
| my_4.png | 4 | 4 | 0.91 | 正常识别 |
| my_5.png | 5 | 5 | 0.84 | 正常识别 |
| my_6.png | 6 | 6 | 0.87 | 正常识别 |
| my_7.png | 7 | 1 | 0.53 | 识别失败:7 的横比较短,左上角像 7 又像 1 |
| my_8.png | 8 | 8 | 0.98 | 笔画粗,识别稳定 |
| my_9.png | 9 | 4 | 0.35 | 识别失败:9 的收尾太直,下部分像 4 |
10 张图里 7 张正确,3 张错误。这个准确率对第一次做项目来说算正常水平,但绝对不算满意。错误集中在 3/8、7/1、9/4 这几组容易混淆的数字上。原因也很好理解:这些数字的主要区别在笔画结构和闭合区域,一旦书写习惯偏离训练集分布(比如 3 写得过于圆润、9 的弧线收得太直),模型就会在新空间里「走样」。
要进一步提高对自写数字的识别率,我实测有效的手段是:在训练集里加入自己手写数字的增强副本。也就是说,把自己写的 10 张数字,每张做 10 种小幅旋转、平移、粗细变化,生成 100 张训练图片,和 MNIST 数据混合起来重新训练。这样模型学到的分布就真正包含了"你的笔迹"这一维度。我把这个思路扩展做了以后,自写数字的识别率从 70% 提升到了 96%。
6.4 批量扩展:用 OpenCV 批量生成格式化的手写数字测试集
为了更完整评估模型,我写过一个批量生成测试样本的脚本,用的是 OpenCV 的putText。它能生成不同字体、不同粗细、不同位置偏移的数字:
import cv2 import numpy as np import os fonts = [cv2.FONT_HERSHEY_SIMPLEX, cv2.FONT_HERSHEY_DUPLEX, cv2.FONT_HERSHEY_COMPLEX] for font_id in range(3): font = fonts[font_id] for digit in range(10): for thickness in [1, 2, 3]: img = np.zeros((100, 100), dtype=np.uint8) text = str(digit) (w, h), baseline = cv2.getTextSize(text, font, 1.4, thickness) org = ((100 - w) // 2, (100 + h) // 2) cv2.putText(img, text, org, font, 1.4, 255, thickness, cv2.LINE_AA) path = f'gen_digits/font{font_id}_d{digit}_t{thickness}.png' cv2.imwrite(path, img)这些图片不是标准的 28x28,而是 100x100。这就倒逼我们的预处理流程必须足够鲁棒——从任意尺寸的图中提取数字、统一到 28x28,否则模型测试根本无法进行。所以这一步表面上是造数据,实际上是验证预处理管线的可靠性。
7. 两种预处理路线的对比与选型建议
7.1 基于轮廓裁剪的预处理(推荐)
前面写的preprocess_custom_image属于这条路:先用findContours找到数字主体的最小外接矩形,然后裁剪、缩放、居中。它有几个明确优势:
- 对白底黑字、黑底白字都兼容:通过反色统一方向。
- 对留白不敏感:只取数字本身区域,不受外部空白区域影响。
- 自动化程度高:不需要手动标定数字位置。
要注意的是findContours在不同 OpenCV 版本中返回值有差异。OpenCV 4 返回两个值(contours, hierarchy),OpenCV 3 返回三个。上面代码按 4 处理,如果你的环境是 3,记得加一个变量接收。
7.2 直接缩放的简化方案(不推荐但省事)
也有同学图省事,把自己画的图直接缩放到 28x28 不做裁剪。结果往往因为数字占比太小,识别效果差得离谱。如果你就是不想用 OpenCV,也有一个折中方案:在画图时尽量把数字画满整个画布,四周留白不超过 5%,然后用 PIL 缩放到 28x28。在画布足够满的情况下,直接缩放的效果勉强能用。但一旦数字太小或偏移,准确率会断崖式下降。
两种方案对比:
| 对比项 | 轮廓裁剪方案 | 直接缩放方案 |
|---|---|---|
| 对留白的容忍度 | 高 | 低 |
| 对数字位置的容忍度 | 高 | 低 |
| 代码复杂度 | 中 | 低 |
| 实测准确率(自写样本) | 约 90%-96% | 约 60%-75% |
如果目标是用模型快速检测乱画的数字,轮廓裁剪几乎是必须的。
7.3 动手实践:为一条数字书写预处理效果的验证方法
有一个很实用的调试技巧:把预处理结果可视化输出为图片,再和 MNIST 原图对比,肉眼评判它像不像。你可以用 matplotlib 画出来看看:
import matplotlib.pyplot as plt def inspect_preprocessed(img_path, model=None): processed = preprocess_custom_image(img_path) plt.figure(figsize=(6, 3)) plt.subplot(1, 2, 1) plt.imshow(cv2.imread(img_path, cv2.IMREAD_GRAYSCALE), cmap='gray') plt.title('原始输入') plt.axis('off') plt.subplot(1, 2, 2) plt.imshow(processed[..., 0], cmap='gray') plt.title('预处理后') plt.axis('off') plt.show()你往往会发现:预处理后的图片,数字主体形状还是挺清楚的,但边缘会有锯齿、笔画粗细会和 MNIST 有差距。这正常,因为INTER_AREA缩放会做一些像素融合。如果觉得边缘太碎,可以试试cv2.GaussianBlur做一次轻平滑(核大小 3x3),再做二值化。
8. 识别失败时的完整排查链路
如果某张自己的手写数字识别错了,不要急着改模型结构。绝大多数情况不是模型的错,而是预处理或数据分布的问题。我建立了一套固定的排查顺序,按这个顺序走,通常两三个来回就能定位问题。
8.1 第一步:检查预处理输出图片是否正常
把preprocess_custom_image的输出画出来看一眼。如果是以下情况,按相应方式处理:
| 现象 | 原因 | 调整方式 |
|---|---|---|
| 数字太偏左/右/上/下 | 居中逻辑有偏差 | 检查轮廓坐标计算,特别是 margin 加的位置 |
| 数字太小,四周大片黑色 | 裁剪失败,缩放在画布内尺寸过小 | 检查findContours是否锁定正确的边缘 |
| 数字是白色但背景呈灰杂色 | 二值化阈值不佳 | 尝试THRESH_OTSU自动阈值 |
| 笔画断裂不完整 | 反色或二值化处理不当时吞噬细节 | 调节阈值(如 100),或改用自适应二值化 |
| 输入图片没有数字 | 轮廓检测失败 | 检查图片是否为空、对比度是否足够 |
8.2 第二步:看模型的置信度分布
预测输出的 10 个概率向量很能说明问题。比如预测 3 时,[0.1, 0.02, 0.03, 0.4, 0.05, 0.08, 0.02, 0.05, 0.2, 0.05],其中 8 的概率是 0.2。这说明模型已经捕捉到"这不是一个标准的 3",只是在模棱两可之间最终选择了 3。置信度低于 0.5 时,往往意味着图片确实存在二义性,需要增强数据的多样性来覆盖这类情况。
把概率向量打印成一段极简的可视化条状图,能非常直观地看到模型对哪类别的置信度最高、其次和最低:
def show_confidences(preds): import numpy as np p = preds[0] for i, prob in enumerate(p): bar = int(prob * 30) * '#' print(f'{i}: {bar: <30} {prob:.3f}') print(f'预测: {np.argmax(p)}, 置信度: {np.max(p):.3f}')8.3 第三步:使用数据增强扩样本重新训练
明确了是哪类数字混淆之后,用数据增强针对性补充那些容易混淆的样本类别。比如模型把"我的 9 认成 4",那就在训练集里加入更多类 9 的图片,尤其是弧线收尾比较直的 9。可以用程序生成,也可以多手写几张。
重训时有个小技巧:不要从零开始训练,而是加载上一轮的模型权重继续 finetune,把学习率调低到1e-5,只训练几轮,让自己手写数据的影响逐渐渗透进现有网络。这样既能保持 MNIST 学到的通用特征,又能快速适配个人笔迹。实测这样做 3 轮,自写数字准确率就能从 70% 提升到 90% 以上,比从零训练快很多。
8.4 第四步:评估混淆矩阵定位系统性问题
当样本数量多了之后(比如每种数字手写 10 张,共 100 张),可以通过混淆矩阵整体评估。下面的代码用 sklearn 绘制:
from sklearn.metrics import confusion_matrix import seaborn as sns y_true = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # 写 10 张 0-9 y_pred = [预测结果列表] cm = confusion_matrix(y_true, y_pred) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues') plt.xlabel('Predicted') plt.ylabel('True') plt.show()混淆矩阵能让你一眼看出系统性错误集中在哪对数字上。如果 9 和 4 的混淆成规律出现,那就不是偶然偏差,一定是你的书写风格里 9 的收尾和 4 的竖笔结构类似,需要在预处理环节专门调节。
9. 总结一下我的实操体会
整个项目做下来,我最大的感受是:手写数字识别这个任务的技术天花板早在 1998 年就被人摸到了,但这不代表今天复现这个项目没有价值。恰恰相反,它是理解 CNN 的最好起点——卷积在哪里提取特征、池化为什么能降维、Dropout 为什么能防过拟合、数据预处理为什么能决定成败,这些问题在这个小项目里都能得到具象的答案。
如果用一句话总结这套流程的教训,我会说:训练模型只是最省力的那一步,真正的硬功夫在数据准备和预处理上。MNIST 刷到 99% 准确率很容易,但要让模型认识你自己歪歪扭扭的笔迹,你得先学会把自己的图片调整到模型熟悉的样子——这个过程锻炼的其实是工程能力,而不只是"调包"能力。
如果你做完这个项目,建议接着做两件事:一是把你的模型部署成一个带图形界面的小工具,打开摄像头、拍下纸上的数字、实时输出识别结果;二是把网络加深一点或者试试不用池化层,感受一下模型容量变化对结果的影响。手写数字识别虽然是你深度学习中一个很小的里程碑,但从这个点出发,你马上能理解图像分类、验证码识别、甚至迁移学习里的一些基本套路了。