2. TensorFlow 模型训练基础(上):MNIST 手写数字识别

各位同学,咱们直接进入正题。今天要聊的是 TensorFlow 模型训练的基础部分,我选了 MNIST 手写数字识别这个经典案例。为什么选它?因为简单、直观,而且能完整走通从数据到模型的整个流程。你想想看,一个能识别手写数字的模型,其实已经包含了深度学习最核心的几个环节。

2.1 MNIST 数据集:深度学习的 "Hello World"

MNIST 数据集,说白了就是一堆 28x28 像素的手写数字图片,从 0 到 9 一共十类。训练集有 6 万张,测试集 1 万张。我刚开始接触深度学习时,第一个跑通的模型就是这个。嗯,这里要注意,虽然 MNIST 现在被很多人说 "太简单了",但它的价值在于帮你理解数据管道、模型结构、训练流程这些基本功。

数据集关键信息:

  • 图片尺寸:28 x 28 像素(灰度图,单通道)
  • 训练样本:60,000 张
  • 测试样本:10,000 张
  • 类别数:10(数字 0-9)
  • 像素值范围:0-255(需要归一化到 0-1)

在 Keras 中加载 MNIST 非常简单,一行代码就能搞定。我个人习惯用 tf.keras.datasets.mnist.load_data(),它会自动下载并返回训练集和测试集的元组。

import tensorflow as tf
from tensorflow import keras

# 加载 MNIST 数据集
(x_train, y_train), (x_test, y_test) = keras.datasets.mnist.load_data()

# 查看数据形状
print(f"训练集图片形状: {x_train.shape}")  # (60000, 28, 28)
print(f"训练集标签形状: {y_train.shape}")  # (60000,)
print(f"测试集图片形状: {x_test.shape}")   # (10000, 28, 28)

2.2 数据预处理:别让原始数据坑了你

数据加载完,千万别直接扔进模型。我在项目中遇到过不少新手,数据预处理没做好,模型怎么调都收敛不了。这里有两个关键步骤:

  1. 归一化:把像素值从 0-255 缩放到 0-1 之间。神经网络对输入数据的尺度很敏感,大数值会让梯度爆炸。
  2. 标签处理:分类任务中,标签需要转换成 one-hot 编码,或者直接用 sparse_categorical_crossentropy 损失函数。
# 归一化:像素值除以 255.0
x_train = x_train.astype('float32') / 255.0
x_test = x_test.astype('float32') / 255.0

# 标签处理:使用 sparse_categorical_crossentropy 时不需要 one-hot
# 如果要用 categorical_crossentropy,需要做 one-hot 编码
# y_train = keras.utils.to_categorical(y_train, 10)

我的经验:归一化时一定要用 255.0 而不是 255,否则整数除法会直接截断成 0。曾经有个学员因为这个 bug 调了一整天,模型 loss 死活不降。

2.3 Keras Sequential API:搭积木一样建模型

Keras 提供了两种构建模型的方式:Sequential API 和 Functional API。对于 MNIST 这种简单的前馈网络,Sequential API 最合适。它就像搭积木,一层一层往上堆。

我常用的网络结构是这样的:

  • 输入层:把 28x28 的二维图片展平成一维向量(784 个神经元)
  • 隐藏层:全连接层,128 个神经元,激活函数用 ReLU
  • Dropout 层:随机丢弃 20% 的神经元,防止过拟合
  • 输出层:全连接层,10 个神经元,激活函数用 Softmax
model = keras.Sequential([
    # 输入层:将 28x28 图片展平为 784 维向量
    keras.layers.Flatten(input_shape=(28, 28)),
    
    # 隐藏层:128 个神经元,ReLU 激活
    keras.layers.Dense(128, activation='relu'),
    
    # Dropout 层:随机丢弃 20% 的神经元
    keras.layers.Dropout(0.2),
    
    # 输出层:10 个神经元,Softmax 输出概率
    keras.layers.Dense(10, activation='softmax')
])

# 查看模型结构
model.summary()

你想想看,这个模型其实就做了三件事:展平、特征提取、分类。Flatten 层把二维数据变成一维,Dense 层做矩阵乘法加激活函数,Dropout 层随机屏蔽一些神经元来增强泛化能力。

避坑指南:我曾经在项目中把 input_shape 写成了 (28, 28, 1),但 MNIST 默认加载的数据形状是 (28, 28),没有通道维度。如果你用 Flatten 层,它会自动处理,但如果你用 Conv2D 层,就必须显式添加通道维度。

2.4 模型编译:配置训练参数

模型搭好了,接下来要告诉它怎么学。编译阶段需要指定三个东西:优化器、损失函数、评估指标。

参数 常用选项 说明
优化器 adam, sgd, rmsprop 控制梯度下降的更新策略
损失函数 sparse_categorical_crossentropy, categorical_crossentropy 衡量预测值与真实值的差距
评估指标 accuracy, precision, recall 监控训练过程中的性能
model.compile(
    optimizer='adam',
    loss='sparse_categorical_crossentropy',
    metrics=['accuracy']
)

这里我解释一下为什么用 sparse_categorical_crossentropy。如果你的标签是整数形式(比如 0, 1, 2...),就用这个;如果你的标签做了 one-hot 编码(比如 [0,0,1,0...]),就用 categorical_crossentropy。我个人习惯用前者,省去 one-hot 编码的步骤。

2.5 模型训练:见证 loss 下降的过程

编译完成后,调用 fit() 方法开始训练。这里有几个关键参数:

  • epochs:训练轮数,每轮遍历一次全部训练数据
  • batch_size:每次梯度更新使用的样本数
  • validation_data:验证集,用于监控过拟合
history = model.fit(
    x_train, y_train,
    epochs=10,
    batch_size=32,
    validation_data=(x_test, y_test)
)

训练过程中,你会看到 loss 逐渐下降,accuracy 逐渐上升。我记得第一次跑这个模型时,看到 loss 从 2.3 降到 0.1 左右,那种成就感真的很棒。但要注意,如果 validation loss 开始上升而 training loss 还在下降,那就是过拟合的信号。

训练输出示例:

Epoch 1/10
1875/1875 [==============================] - 3s 1ms/step - loss: 0.2956 - accuracy: 0.9145 - val_loss: 0.1387 - val_accuracy: 0.9587
Epoch 2/10
1875/1875 [==============================] - 2s 1ms/step - loss: 0.1417 - accuracy: 0.9575 - val_loss: 0.1039 - val_accuracy: 0.9682
...
Epoch 10/10
1875/1875 [==============================] - 2s 1ms/step - loss: 0.0738 - accuracy: 0.9772 - val_loss: 0.0758 - val_accuracy: 0.9765

2.6 保存模型为 .h5 格式

训练完成后,一定要保存模型。为什么?因为后续的量化部署、版本控制都需要这个文件。Keras 提供了两种保存方式:

  1. 保存整个模型:包括网络结构、权重、优化器状态
  2. 仅保存权重:只保存参数,加载时需要重新定义网络结构

对于部署场景,我建议保存整个模型,这样加载时一步到位。

# 保存整个模型为 .h5 格式
model.save('mnist_model.h5')

# 加载模型
loaded_model = keras.models.load_model('mnist_model.h5')

# 验证加载的模型
test_loss, test_acc = loaded_model.evaluate(x_test, y_test, verbose=0)
print(f'测试集准确率: {test_acc:.4f}')

我的习惯:保存模型时,文件名最好包含版本号和关键信息,比如 mnist_v1_97.6acc.h5。这样后续做版本控制时,一眼就能看出哪个模型性能更好。我在项目中吃过亏,一堆 unnamed_model.h5 文件,根本分不清哪个是哪个。

2.7 本章知识体系总览

为了让你更直观地理解本章的知识结构,我画了一张流程图。它展示了从数据加载到模型保存的完整链路,每个环节都有对应的操作和注意事项。

MNIST 手写数字识别训练流程 1. 数据加载 2. 数据预处理 3. 构建模型 4. 编译模型 5. 训练模型 6. 保存 .h5 模型 关键操作与注意事项 • load_data() 自动下载 • 返回 (x_train, y_train), (x_test, y_test) • 归一化:/255.0 • 标签:整数或 one-hot • Sequential API 搭积木 • Flatten + Dense + Dropout • optimizer='adam' • loss='sparse_categorical' • metrics=['accuracy'] • epochs=10, batch_size=32 • 监控 val_loss 防过拟合 • model.save('xxx.h5') • 文件名包含版本号 • 加载:load_model()

这张图把整个流程串起来了。你跟着这个顺序走,从数据加载到模型保存,每一步都有对应的代码实现。下一章我们会在这个基础上,深入讨论模型版本控制和量化部署的细节。

本章核心要点:

  • MNIST 是 28x28 的灰度手写数字图片,共 10 类
  • 数据预处理必须做归一化,否则模型难以收敛
  • Sequential API 适合简单的前馈网络,代码简洁
  • 编译时指定优化器、损失函数、评估指标
  • 训练时监控 validation loss 防止过拟合
  • 保存为 .h5 格式,文件名包含版本信息便于管理