每次带新手入门深度学习,我几乎都会推荐从手写数字识别开始。原因很简单:数据集不挑机器、任务直观、反馈快,关键是可以把一套完整的“数据 → 模型 → 训练 → 评估 → 推理”流程在半小时内跑通。今天这篇就用 Keras 搭一个最朴素的全连接神经网络,30 行代码搞定核心功能,然后我们再一起把这 30 行拆开,看看里面那些容易被忽略、但非常影响结果的小细节。
这篇文章适合两种人:一种是刚接触深度学习、想跑通第一个像样项目的朋友;另一种是已经跑过一些教程,但一直没搞明白“为什么代码要这样写”的读者。我会把为什么用全连接网络、数据预处理每行在干嘛、训练参数怎么调、训练完怎么识别自己写的数字全部讲一遍。
先说明一点:这个项目不直接上 CNN,先把全连接吃透。全连接网络是理解神经网络最直观的起点,MNIST 又足够简单,完全可以在无 GPU 环境下快速跑出不错的效果。后面想优化再换结构也来得及。
1. 为什么第一炮要打手写数字:任务背景与选型逻辑
1.1 MNIST 是什么任务
MNIST 是深度学习里公认的入门数据集。它包含 60000 张训练图片和 10000 张测试图片,每张都是 28x28 像素的灰度图,内容是 0 到 9 的手写数字。你可能觉得这任务太简单了,但对一个刚接触神经网络的人来说,它几乎是完美的练手目标。
几个关键数字:
- 图片大小:28x28,共 784 个像素
- 类别数量:10 个(0-9)
- 数据总量:7 万张图片
- 训练成本:普通 CPU 笔记本上几分钟内跑完
“训练快”这一点比很多人想的更重要。我见过不少朋友一开始就想搞大规模数据集,结果环境配置和训练等待就把热情耗完了。MNIST 可以在你做出任何调整后迅速看到结果,非常适合用来验证对模型和参数的理解。
1.2 为什么用 Keras 而不是其他框架
Keras 在深度学习里的位置是高层 API,它把底层框架的很多复杂逻辑封装成了简洁的接口。默认提供的Sequential顺序模型让搭网络像叠积木一样直观,两三行就能定义一个完整的全连接网络。
更重要的是,Keras 把数据加载、预处理、训练、评估这些高频动作收敛成了非常简单的方法调用:model.fit()一行开始训练,model.evaluate()一行看效果。对新手来说,完全不需要先搞懂自动求导、计算图、梯度下降的底层实现,就能先把整体流程跑通。等有了整体概念,再往下钻底层细节就轻松多了。
1.3 为什么要用全连接网络,而不是直接上 CNN
很多人一看是图像任务,第一反应就是“得用卷积神经网络”。但我建议新手先从全连接开始。原因在于,全连接网络的结构极其直观:输入层把每个像素当一个特征,经过隐藏层的加权求和和非线性变换,最终输出 10 个类别的得分。这个结构可以用笔一步步推出来,非常适合建立直觉。
全连接网络在手写数字识别上的准确率大约在 97% 到 98% 之间,作为入门项目已经足够说明问题。CNN 确实能到 99% 以上,但那是建立在你已经理解“网络是怎么学出来的”这个基础上。先把基础模型跑通,你才知道后面换结构时,提升的精度到底是从哪里来的。
1.4 这一套流程能带走什么
这个项目虽然小,但五脏俱全。你会完整走一遍深度学习项目的基本骨架:
- 数据加载与预处理:归一化、形状变换、标签编码
- 模型构建:Sequential + Dense 组合网络结构
- 模型编译与训练:compile 配置学习过程,fit 完成训练
- 模型评估:在测试集上计算准确率
- 推理预测:用训练好的模型对新样本做预测
这套流程以后无论做图像、文本还是结构化数据都能直接迁移。所以别小看这个“Hello World”,它是后面所有复杂项目的基础框架。
2. 30行核心代码全貌与逐段拆解
2.1 完整代码先奉上
下面这份代码就是本文的核心。我故意没有过度精简,保留了必要的空行和注释,方便对照阅读。核心逻辑加起来就是 30 行左右。
# 1. 导入依赖 import numpy as np from tensorflow import keras from tensorflow.keras import layers # 2. 加载 MNIST 数据集 (x_train, y_train), (x_test, y_test) = keras.datasets.mnist.load_data() # 3. 数据预处理 x_train = x_train.reshape(-1, 784).astype("float32") / 255.0 x_test = x_test.reshape(-1, 784).astype("float32") / 255.0 y_train = keras.utils.to_categorical(y_train, 10) y_test = keras.utils.to_categorical(y_test, 10) # 4. 构建全连接神经网络 model = keras.Sequential([ layers.Dense(128, activation="relu", input_shape=(784,)), layers.Dense(10, activation="softmax"), ]) # 5. 打印模型结构 model.summary() # 6. 编译模型 model.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"]) # 7. 训练模型 history = model.fit(x_train, y_train, batch_size=128, epochs=10, validation_split=0.1) # 8. 在测试集上评估 test_loss, test_acc = model.evaluate(x_test, y_test) print(f"测试集准确率: {test_acc:.4f}") # 9. 看一下前 5 张测试图片的预测结果 preds = model.predict(x_test[:5]) print("预测标签:", np.argmax(preds, axis=1)) print("真实标签:", np.argmax(y_test[:5], axis=1))运行这段代码你会看到,训练过程每轮都会输出训练集和验证集的准确率,最后在测试集上得到一个 0.97 左右的准确率。环境正常的话,整个流程一分钟左右就能结束。
2.2 数据准备阶段每行在干嘛
前 7 行完成了数据加载和预处理。load_data()返回两个元组,每个元组都是(图像数组, 标签数组)的配对形式。图像数组的原始形状是(60000, 28, 28),也就是 6 万张 28x28 的灰度图。
这时候有两个高频疑问:为什么要astype("float32")?因为像素值原始类型是uint8,直接参与除法等浮点运算会有精度问题,某些框架甚至会直接报类型错误。为什么要reshape(-1, 784)?因为全连接层要求输入是一维向量,28x28 的二维矩阵得先展平成 784 个特征。to_categorical则是把整数标签转成 one-hot 编码,方便和后面的交叉熵损失函数匹配。这几个细节会在下一章单独展开,因为对新手来说,它们是最容易被忽略但影响极大的步骤。
2.3 模型定义只有 3 行
Sequential表示顺序堆叠的模型,第一层layers.Dense(128, activation="relu", input_shape=(784,))包含 128 个神经元,使用 ReLU 激活,输入特征是 784 维。第二层layers.Dense(10, activation="softmax")是输出层,10 个神经元对应 10 个数字类别,softmax 激活让输出变成概率分布。
这里有个常见的困惑:input_shape=(784,)为什么不带 batch 维度?因为 Keras 在定义网络时默认忽略批次大小。如果你写成(784, 1)甚至漏掉这个参数,第一层会不知道输入维度而报错,除非你提前用Flatten把输入展平。
2.4 编译、训练、评估三条主线
compile负责配置学习过程,三个核心参数分别是:优化器adam,在中小型数据集上基本无需手动调整学习率;损失函数categorical_crossentropy,要求标签是 one-hot 编码;metrics=["accuracy"]让训练过程中持续记录准确率,方便观察收敛情况。
fit是最关键的一步。batch_size=128表示每次从训练集随机抽 128 张图计算梯度并更新参数,epochs=10表示把全部训练数据过 10 遍,validation_split=0.1表示从训练集里再抽 10% 作为验证集。这三个参数对训练效果影响非常大,后面的调参章节会细讲。
evaluate是在模型从未见过的测试集上计算损失和准确率,这是衡量真实能力的关键。最后用predict看了前 5 张测试图,np.argmax(preds, axis=1)取出每个样本概率最大的类别,和真实标签对比。
2.5 “30行”的账是怎么算的
经常有人说“30行代码搞定手写数字识别”,但数一数自己的代码却有 29 行甚至 35 行。其实“30行”不是精确计数的硬指标,而是一个约数。去掉空行和纯注释后,核心逻辑大概在 20 行出头。
我更喜欢把它理解为:导入、预处理、建模、训练、评估、预测这六件事,都应该在可读范围内简洁写清楚。行数多一点少一点不重要,重要的是每一行你都能解释清楚它为什么存在。这比单纯追求“代码短”有价值得多。
3. 数据进网络前的“隐形”动作:漏一步可能就训练不动
3.1 像素归一化:从 0-255 到 0-1
原始图片的像素值是 0-255 的整数。如果不做归一化,直接把大数值喂给网络,第一个全连接层的加权和很容易变得很大,ReLU 激活函数会进入饱和区域,梯度变小,训练效率会明显变差。
归一化到 0-1 之后,所有特征尺度一致,梯度更新更平稳。打个比方,如果你要同时参考“身高”和“体重”两个特征,一个用米、一个用千克,数值范围差了几十倍,模型很容易把数值大的特征当成更重要。归一化就是为了消除这种尺度偏差。MNIST 像素值天然在 0-255 之间,所以最简单的x / 255.0就足够。
3.2 reshape(-1, 784):把二维图片拉成向量
全连接层内部为每个输入特征分配一个权重,输入必须是一维向量。28x28 的图片本质是二维矩阵,所以需要reshape(-1, 784)把它展平。这里的-1是一个偷懒但好用的写法,它表示“该维度大小由其他维度自动推断”。原始数组是(60000, 28, 28),reshape(-1, 784)会自动算出第一维是 60000,结果变成(60000, 784)。
展平的顺序是行优先,先读第一行 28 个像素,再读第二行。对全连接网络来说,顺序本身影响不大,因为它不关心像素的空间结构,只把它们当成 784 个独立特征。
3.3 one-hot 编码与损失函数的匹配关系
如果直接用 0-9 的整数作标签,损失函数会把“类别 7”当成“类别 6 的数值加 1”,这完全没有意义,因为数字类别之间没有数值大小关系。to_categorical(y_train, 10)会把每个标签变成长度 10 的向量,比如数字 7 变成[0 0 0 0 0 0 0 1 0 0]。
这种编码方式和categorical_crossentropy是配套的。如果你不想用 one-hot,Keras 也提供了SparseCategoricalCrossentropy,可以直接吃整数标签。但无论选哪种,标签格式必须和损失函数匹配,否则会报形状错误,或者训练出莫名其妙的结果。
3.4 为什么读结果前还要做一次 argmax
训练时用 one-hot 是为了方便计算损失,但人眼阅读不方便。所以打印预测结果时,要用np.argmax(preds, axis=1)把概率分布还原成单个类别数字。axis=1表示在第二个维度上找最大值位置,也就是对每个样本的 10 个概率分别做 argmax。这个操作在后面的推理环节还会反复用到,属于非常基础但高频的技巧。
4. 从 784 到 10 的直觉:全连接网络为什么能认数字
4.1 一个神经元到底在算什么
全连接网络没有想象中那么玄乎。每个神经元的操作只有两步:先对输入做加权求和,再过一个非线性激活函数。权重是模型学出来的参数,它决定这个神经元更关注哪些输入特征。当 784 个像素被第一层 128 个神经元处理后,就得到 128 维的新表示,里面包含了从原始像素中抽象出来的特征信息。
ReLU 激活函数的作用是引入非线性。如果去掉激活函数,多层线性变换叠加后仍然是线性变换,网络再深也退化成一层线性模型;有了 ReLU,网络才能拟合复杂的决策边界。ReLU 的另一个好处是计算简单、能缓解梯度消失问题,所以现在大多数隐藏层默认先用 ReLU。
4.2 参数数量是怎么手算出来的
我们经常听到“模型有多少参数”,这里可以直接算一遍。第一个 Dense 层:784 个输入特征到 128 个神经元,每个神经元都有一组权重加一个偏置,参数数量是784 * 128 + 128 = 100480。第二个 Dense 层:128 到 10,参数是128 * 10 + 10 = 1290。合计约 101770 个参数。
model.summary()打印出来的就是这些数字。你可能觉得十万参数很多,但对比一下,28x28 的输入用 CNN 处理后往往参数量更少、效果更好,因为卷积层通过参数共享大幅减少了冗余。不过全连接网络结构简单,理解成本低,先把它吃透再往卷积迁移,思路会更顺。
4.3 隐藏层 128 这个数字怎么来的
说实话,没有绝对正确的答案。128 是我在新手项目里最常用的中间值,它在表达能力和训练成本之间比较平衡。你可以改成 64 或者 256 做个对比实验:
| 隐藏层神经元数 | 大概参数量 | 训练速度 | 测试集准确率(大致) |
|---|---|---|---|
| 64 | 50890 | 较快 | 约 96.8% |
| 128 | 101770 | 中等 | 约 97.5% |
| 256 | 203530 | 较慢 | 约 97.8% |
神经元太少,模型表达能力受限;神经元太多,容易把训练集的特点过度记住,在测试集上反而下降。这就是深度学习中欠拟合与过拟合的权衡问题,以后做任何模型都会遇到。
4.4 输出层与 softmax 的含义
输出层只有 10 个神经元,对应 0-9 十个类别。softmax 激活把每个神经元的原始得分转换成一个概率值,所有概率加起来的和等于 1。比如一张图片经过网络,输出可能是数字 5 概率 0.82、数字 6 概率 0.11、其余都很小,那么模型的预测就是概率最大的那个类别。
这个“输出概率分布”非常实用。它不仅能告诉我们预测结果,还能提供一个置信度参考。如果你做识别工具,当最大概率低于某个阈值时,完全可以让程序提示“图片太模糊,无法确定”,而不是硬给一个答案。
4.5 全连接网络在数字识别上为什么够用
数字识别的特点是结构相对简单,笔画的局部模式不算多。全连接网络通过大量参数组合,已经可以记住足够多的模式,所以在标准 MNIST 上能到 97% 以上。这个网络真正薄弱的地方在于对平移、旋转、缩放不敏感。数字 7 出现在图片左上角和右下角时,全连接网络可能把它们当成完全不同的输入,因为它只盯像素位置,不共享空间特征。CNN 通过卷积核的滑动窗口机制天然具备平移等变性,这也是后续追求更高精度时换 CNN 的根本原因。
5. 训练那几步最容易翻车:batch_size、epochs 与验证集
5.1 batch_size=128 到底意味着什么
训练时不是一次把 6 万张图全算完再更新参数,那样内存顶不住,而且梯度方向虽然是全数据集平均、非常稳定,但每轮只更新一次,收敛太慢。也不是每张图都更新一次,那样梯度震荡太剧烈。batch_size=128的意思是:每次随机抽 128 张图,计算这 128 张图的平均梯度,更新一次网络参数。等到全部 60000 张图都被抽过一次,才算完成一个 epoch。
batch_size 越大,梯度方向越稳定,但单次更新计算量更大;越小,参数更新越频繁,训练曲线波动越大。128 在 MNIST 上属于比较稳妥的默认值,你也可以试试 32、64、256,观察准确率曲线的变化。这个过程能帮你建立对“梯度稳定性”的直观感受。
5.2 epochs=10 够不够用
一个 epoch 是完整过一遍训练集。10 个 epoch 对这个小网络来说通常已经足够。跑完之后,训练集准确率一般会在 99% 以上,验证集在 97% 左右。如果你发现训练集接近 100% 但验证集只有 97%,不用紧张,说明模型开始有一点过拟合倾向,但还没到失控的程度。
我建议训练完打印一下history.history里的accuracy和val_accuracy,手动观察两条曲线的走势。如果训练曲线一直涨、验证曲线停滞甚至下降,说明过拟合正在发生,这时候再加 epochs 已经没有意义,反而该考虑正则化或降低模型容量。
5.3 validation_split=0.1 是在保护你的模型
validation_split=0.1表示从训练集里拿出 10%(6000 张)作为验证集,剩下 54000 张用于训练。验证集不参与梯度更新,只用来在每轮结束后评估模型在“没见过”的数据上的表现。
为什么要单独搞一个验证集?因为最终要评估的是模型在测试集上的表现,测试集应该像正式考卷一样留到最后再看。如果训练中反复用测试集调参,测试集实际上就变成了训练的一部分,最后报出来的准确率会严重虚高。验证集就好比正式考试前的模拟卷,让你在不碰最后考卷的情况下提前知道复习效果。
5.4 三个常见翻车现场
我见过不少人在这一步卡住,这里集中列几个高频问题。
准确率一直上不去。先别急着换模型,检查归一化和数据形状是否正确。最常见的是把像素值默认当成 0-1 直接喂给模型,结果真实像素是 0-255,网络第一层加权和过大,ReLU 大量饱和,梯度传不回去。
loss 变成 NaN。一般是梯度爆炸,尤其在学习率较大时容易出现。纯全连接网络在 MNIST 上用默认 adam 很少遇到,但如果隐藏层设置过大或者手工调高了学习率,就可能碰上。解决方案是降低学习率,或者回头检查归一化步骤。
训练准确率高、验证准确率低。这是典型过拟合。小网络在 MNIST 上还不算严重,真遇到了可以加 Dropout、降低隐藏层神经元数,或者数据增强。
5.5 代码里容易看漏的两个细节
第一个是input_shape=(784,)这个元组只有一个元素,必须有逗号。如果写成(784),Python 会把它当成普通整数,导致形状定义出错。第二个是astype("float32"),这个转换不可省。虽然x_train / 255.0也能算出浮点数,但原始类型不转的话,某些框架检查到类型不一致会直接抛出异常。新手最容易在这些不起眼的地方卡住,报错后却迟迟定位不到原因。
6. 训练完怎么玩:保存模型、识别自己手写的图片
6.1 把模型保存下来
模型训练完只存在内存里,关掉进程就没了。想复用至少得加两行代码:
model.save("mnist_fc.h5") loaded_model = keras.models.load_model("mnist_fc.h5")保存的mnist_fc.h5文件里包含网络结构、权重和优化器状态。加载后可以直接对新图片做predict,不需要重新训练。如果只想保存权重,也可以用save_weights,但加载前必须用完全相同的网络结构重建模型,否则权重对不上。对新手来说,直接save整个模型更省心。
6.2 识别自己手写的数字:完整流程
保存模型之后,真正好玩的环节就是拿自己画的数字去测试。但要注意,直接把手机照片或画图工具写出来的图丢给模型,几乎必然失败,因为数据分布和 MNIST 不一致。下面是一段可用的处理流程:
from PIL import Image # 读图并转成灰度 img = Image.open("my_digit.png").convert("L") # 缩放到 28x28,新版 Pillow 写法 img = img.resize((28, 28), Image.Resampling.LANCZOS) # 转成 numpy 数组并归一化 img_arr = np.array(img, dtype="float32") / 255.0 # 还原成模型输入形状 img_arr = img_arr.reshape(1, 784) # 预测 prob = loaded_model.predict(img_arr) print("预测结果:", np.argmax(prob[0]))这段代码看着简单,但实际用起来有三个坑。
注意:MNIST 的训练图像是黑底白字,数字区域接近 255,背景接近 0。如果你画的是黑字白底图,像素分布正好反了,预测前要执行
img_arr = 1.0 - img_arr取反,否则模型会把背景当成数字。
第二个坑:实际手写图片一般比 28x28 大很多,直接缩放会丢失大量细节。比较好的做法是先裁剪出数字所在的区域,再缩放到 28x28,让数字主体尽量填满整张图。第三个坑:不要导入彩色三通道图。convert("L")之后如果还带着 RGB 三个通道,输入维度会变成(28, 28, 3),和全连接网络期望的 784 维对不上,reshape 也会报错。
6.3 让预测结果更可信:输出概率分布
predict返回的是 10 个概率值,我一般不会只看 argmax 的结果,还会把概率分布打出来:
prob = loaded_model.predict(img_arr)[0] top3 = np.argsort(prob)[::-1][:3] for i in top3: print(f"数字 {i}: {prob[i]:.3f}")如果最大概率是 0.97,基本可以放心;如果最大概率只有 0.3,说明这张图确实模糊或者预处理不到位,我不会贸然相信预测结果。这个习惯在真实项目中非常重要。生产环境里总会遇到模型没见过的情况,用一个置信度阈值来兜底,比硬着头皮输出一个答案靠谱得多。
7. 从 30 行还能往哪走:优化方向与我的一点体会
7.1 当前这套配置的基线成绩
跑完上面代码,你大概率会得到测试集准确率 97.5% 左右,模型参数量约 10 万,CPU 训练时间几十秒。这个成绩说明全连接网络已经能区分绝大多数手写数字,但离 99% 还有明显距离。下面几个方向可以稳步提升,而不是盲目堆层。
7.2 三个立竿见影的升级方向
方向一:加深网络。把单层 Dense(128) 改成两个 Dense(128),中间加 ReLU,能小幅提升表达能力。注意不要无限加深,全连接层堆太多会导致参数爆炸、训练变慢,收益反而下降。
方向二:加 Dropout 正则化。在隐藏层后加layers.Dropout(0.2),训练时随机丢弃一部分神经元,强迫网络不要过度依赖个别特征。对 MNIST 来说,Dropout 对测试集准确率的提升不一定明显,但对防止过拟合很有帮助,是复杂任务里的标配操作。
方向三:换成 CNN。用卷积加池化提取空间特征,模型结构大概是:
model = keras.Sequential([ layers.Conv2D(32, (3, 3), activation="relu", input_shape=(28, 28, 1)), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation="relu"), layers.MaxPooling2D((2, 2)), layers.Flatten(), layers.Dense(128, activation="relu"), layers.Dense(10, activation="softmax"), ])CNN 在 MNIST 上通常能到 99% 以上。但需要注意,输入要改回(28, 28, 1)的形状,不能再提前 reshape 成 784 维。
三个方向的对比参考:
| 方案 | 参数量 | 测试集准确率(大致) | 训练耗时 |
|---|---|---|---|
| 单隐藏层全连接 | 约 10 万 | 97.5% | 几十秒 |
| 双隐藏层全连接 + Dropout | 约 20 万 | 98% 左右 | 1 分钟 |
| 两层 CNN | 约 5 万 | 99% 以上 | 1-2 分钟 |
7.3 我的一点真实体会
带过几个从零开始的朋友之后,我发现最容易出成果的路径不是先看一堆理论,而是先把 30 行代码跑通,再一个问题一个问题地追问。比如练完这个项目,你会自然理解什么叫归一化、为什么用 softmax、batch_size 动了会发生什么。这些问题在理论课上看十遍,不如在亲手跑一次训练里记住一遍。
另一个建议是:别急着删代码。把这份 30 行版本保存下来,后面做 CNN 时和它对比,你能清楚看到“结构升级”到底换来了多少准确率提升。下次遇到一个新的分类任务,你也直接把这套流程搬过去,换数据集、换层数、换损失函数就行。
我的习惯是先把一个能用的版本跑出来,再决定要不要花时间优化。不少同学卡在“代码总觉得不够完美”的纠结里,结果迟迟不肯运行第一步。先把流程打通,再谈性能,这才是把项目继续做下去的正确姿势。