4. TensorFlow模型训练与导出:使用Keras训练一个简单分类模型,保存为.h5文件,准备转换

好,到了真正动手的环节了。前面几章我们聊了模型迭代的整体思路和STM32Cube.AI的工具链,现在该把模型跑起来了。

这一章的目标很明确:用Keras训练一个简单的分类模型,然后导出成.h5文件。别小看这一步,我见过太多人在导出环节翻车——模型在电脑上跑得飞起,一转换就报错。说白了,就是训练时埋的坑。

4.1 选一个适合MCU的模型结构

先说说模型选型。在STM32上跑模型,跟你在服务器上跑完全是两码事。MCU的资源就那么点——Flash几MB、RAM几百KB,你还想塞个ResNet-50进去?不现实。

我个人习惯,给MCU用的模型,参数控制在100K以内,层数不超过10层。你想想看,STM32F4系列的主频才168MHz,复杂模型推理一次要好几秒,谁受得了?

这里我选一个简单的全连接网络,做MNIST手写数字识别。经典中的经典,但足够说明整个流程。

模型结构速览:
  • 输入层:28×28=784个神经元(展平后的像素值)
  • 隐藏层1:128个神经元,ReLU激活
  • 隐藏层2:64个神经元,ReLU激活
  • 输出层:10个神经元,Softmax激活

总参数量:约107K,Flash占用约430KB,RAM占用约4KB(推理时)。

4.2 数据准备与预处理

MNIST数据集Keras自带,直接加载就行。但这里有个细节——数据归一化。

我曾经在项目里吃过亏:训练时没做归一化,模型收敛慢不说,转换到STM32后推理结果全乱套。为什么?因为MCU的浮点精度有限,大数值输入会让激活值溢出。

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
import numpy as np

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

# 归一化:像素值从[0,255]缩放到[0,1]
x_train = x_train.astype('float32') / 255.0
x_test = x_test.astype('float32') / 255.0

# 展平:从28x28变成784
x_train = x_train.reshape(-1, 784)
x_test = x_test.reshape(-1, 784)

# 标签转one-hot编码
y_train = keras.utils.to_categorical(y_train, 10)
y_test = keras.utils.to_categorical(y_test, 10)

print(f"训练集形状: {x_train.shape}, 测试集形状: {x_test.shape}")
我的经验:归一化方式一定要记下来。STM32Cube.AX的预处理步骤需要跟训练时完全一致,否则模型推理结果就是错的。我习惯把归一化参数(均值、标准差或缩放因子)写在代码注释里,方便后面部署时对照。

4.3 构建模型

模型构建用Keras Sequential API,简单直观。注意,这里我刻意不用BatchNormalization和Dropout——这两个层在STM32Cube.AI转换时偶尔会出问题,尤其是老版本的工具链。

model = keras.Sequential([
    layers.Dense(128, activation='relu', input_shape=(784,), name='hidden1'),
    layers.Dense(64, activation='relu', name='hidden2'),
    layers.Dense(10, activation='softmax', name='output')
], name='mnist_classifier')

model.summary()

模型摘要输出:

层名 类型 输出形状 参数量
hidden1 Dense (None, 128) 100,480
hidden2 Dense (None, 64) 8,256
output Dense (None, 10) 650
总计 109,386

4.4 编译与训练

编译时选Adam优化器,学习率用默认的0.001。损失函数用categorical_crossentropy,因为是多分类问题。

model.compile(
    optimizer=keras.optimizers.Adam(learning_rate=0.001),
    loss='categorical_crossentropy',
    metrics=['accuracy']
)

history = model.fit(
    x_train, y_train,
    batch_size=128,
    epochs=10,
    validation_data=(x_test, y_test),
    verbose=1
)

训练过程大概30秒就结束了。最终测试精度在97%左右——够用,但不是重点。重点是后面导出和转换。

注意:训练轮数不要太多。10轮够了,再多就过拟合了。MCU上的模型不需要追求极致精度,够用就行。我一般控制在95%-98%之间,再高就要牺牲模型大小或推理速度。

4.5 模型评估与验证

训练完先评估一下,确保模型没跑偏。

test_loss, test_acc = model.evaluate(x_test, y_test, verbose=0)
print(f"测试精度: {test_acc:.4f}")

# 随机抽几个样本看看预测结果
import matplotlib.pyplot as plt
predictions = model.predict(x_test[:5])
for i in range(5):
    pred_label = np.argmax(predictions[i])
    true_label = np.argmax(y_test[i])
    print(f"样本{i}: 预测={pred_label}, 真实={true_label}")

输出结果:

测试精度: 0.9734
样本0: 预测=7, 真实=7
样本1: 预测=2, 真实=2
样本2: 预测=1, 真实=1
样本3: 预测=0, 真实=0
样本4: 预测=4, 真实=4

嗯,没问题。模型在电脑上跑通了。

4.6 导出为.h5文件

关键步骤来了。导出.h5文件,STM32Cube.AI才能识别。

# 保存整个模型(结构+权重+优化器状态)
model.save('mnist_model.h5')
print("模型已保存为 mnist_model.h5")

# 也可以只保存权重(更轻量,但需要配合模型结构代码)
# model.save_weights('mnist_weights.h5')
导出要点:
  • 使用model.save()保存完整模型,包含网络结构、权重和训练配置。
  • 文件后缀必须是.h5,STM32Cube.AI只认这个格式。
  • 保存路径不要有中文和空格,否则工具链可能报错。
  • 保存前确认模型是training=False状态(即推理模式)。

导出后,用h5py库验证一下文件完整性:

import h5py
with h5py.File('mnist_model.h5', 'r') as f:
    print("文件中的顶层组:", list(f.keys()))
    # 应该看到 'model_weights', 'optimizer_weights', 'training_config' 等

输出:

文件中的顶层组: ['model_weights', 'optimizer_weights', 'training_config']

好,文件没问题。接下来就可以交给STM32Cube.AI了。

4.7 本章知识体系

下面这张图总结了从数据到.h5文件的完整流程:

模型训练与导出流程 从数据到.h5文件的完整流程 数据准备 模型构建 编译 训练 评估 导出.h5 验证文件 准备转换 关键检查点 • 归一化方式一致 • 输入形状正确 • 无BatchNorm/Dropout 导出注意事项 • 使用model.save() • 后缀必须是.h5 • 路径无中文空格

4.8 常见问题与避坑

最后,分享几个我踩过的坑:

  • 模型保存后加载报错:检查Keras版本。STM32Cube.AI对Keras 2.x支持最好,2.3.x到2.9.x都行。2.10以上可能有问题。
  • 转换时提示不支持层:我遇到过Lambda层和自定义层无法转换。解决办法:用标准层替代,或者用tf.keras.layers里的现成层。
  • 推理结果全一样:大概率是预处理没对齐。训练时归一化到[0,1],部署时也要一样。我曾经在STM32上忘了做归一化,结果模型输出全是0.1。
  • 模型太大放不进Flash:参数量超过200K就要考虑剪枝或量化了。后面章节会详细讲。
我的习惯:每次训练完,我都会把模型结构和权重单独保存一份JSON和H5。这样即使.h5文件损坏,也能用JSON重建结构,再加载权重。多一份备份,少一次事故。