第一次接触深度学习实验课的人,很多不是倒在数学公式上,而是倒在"环境装好了但模型跑不起来"和"模型跑起来了但准确率很诡异"这两堵墙上。实验四"Keras基础与简单应用"听起来简单,但真正做起来,几乎每一步都有隐藏的小坑:库装错版本、数据没归一化、标签没用one-hot、训练了好几轮损失纹丝不动。这篇文章就把这个实验从头到尾拆开讲一遍,不只是给可以直接抄的代码,更会解释每一行代码背后的原理,以及那些教材不会写、但实际做的时候几乎每个人都会踩的坑。
内容适合两类人:一类是刚学完深度学习基础理论、准备第一次动手写代码的同学;另一类是已经把模型跑通、但想回头弄明白"为什么这么写"的人。我会按一条完整做实验的路来组织:先讲为什么选Keras,再讲环境准备,然后是MNIST数据集的加载、预处理、模型训练、评估,最后把常见问题和延伸玩法一起端上来。
1. 为什么实验课选Keras而不是直接上PyTorch
1.1 实验课的真正目标:先建立"模型=数据流"的直觉
实验课选什么框架,不是看哪个框架在业界更流行,而是看这节课要教会你什么。深度学习入门阶段,核心目标是理解"数据经过一层层计算变成预测结果,然后用损失函数的梯度反向更新参数"这个过程。Keras的最大优势,就是把这套直觉直接翻译成了近乎白话的代码。你写的每一层,就是数据流图上的一个节点,模型的编译和训练过程与理论课讲的概念一一对应,基本没有中间翻译层。
PyTorch当然也是很优秀的框架,但它给了太多控制权:你需要自己写训练循环、自己管理梯度、自己处理设备调度。这些能力对做研究的人来说是必须的,但对第一次接触深度学习的人就是额外认知负担。不是说学不会,而是没有必要在第一节课同时学"深度学习概念"和"灵活框架的复杂用法"这两件事。Keras则把大部分细节封装掉了,让你把注意力集中在数据、网络结构、损失函数、训练结果这些核心概念上。
另外,实验四的场景是"简单应用",课题本身就很适合Keras这种高层API。比如最常见的分类任务,模型定义可以浓缩成几行:
from tensorflow import keras model = keras.Sequential([ keras.layers.Flatten(input_shape=(28, 28)), keras.layers.Dense(128, activation="relu"), keras.layers.Dense(10, activation="softmax") ])就算完全没看过文档,也能大概猜出来:把28×28的图像拉平,接一个128个神经元的全连接层,最后输出10个类别的概率。这种可读性是入门阶段非常难得的东西。如果换用需要手写矩阵乘法、手动定义前向传播的写法,很容易把新手绕晕在实现细节里,反而忘了自己在学什么。
1.2 几个框架在实验场景里的真实对比
我经常在实验课上遇到同学问:"到底该学Keras还是PyTorch?"我的回答一直是:先搞清楚你现在处在哪个阶段。下面这个表格是我个人体会的总结,不是标准答案,但参考价值很直观。
| 框架 | 代码风格 | 学习曲线 | 灵活度 | 最适合的场景 |
|---|---|---|---|---|
| Keras(tf.keras) | 高层API,搭积木式,代码量少 | 平缓,易上手 | 中低 | 快速验证、教学实验、中小型模型 |
| PyTorch | 显式控制,训练循环手写 | 先陡后平,需要补基础 | 高 | 研究、复杂网络、自定义训练逻辑 |
| 底层TensorFlow接口 | 直接用底层算子堆模型 | 陡峭 | 高但繁琐 | 框架开发、特殊情况性能调优 |
还有一点需要说清楚:现在使用Keras,绝大多数情况是从TensorFlow引入的,就是from tensorflow import keras。Keras本身也可以独立安装,但实验场景里我建议直接用TensorFlow自带的版本,原因很简单:少一个版本兼容维度,少很多莫名其妙的报错。这种选型思路本身就是工程经验的一部分——能用默认方案跑通,就不额外引入变量。
2. 环境搭建的隐藏难点:版本匹配与第一个能跑的模型
2.1 一套稳妥的环境准备步骤
实验的第一道坎不是模型,是环境。我见过太多人在这一步卡了一晚上,最后发现只是某个库版本不对。下面这套步骤是我反复验证过、对新手最稳妥的流程,建议按顺序操作。
- 用Conda创建独立环境。不建议直接在base环境里装,因为后面还要做其他实验,依赖很容易互相打架。
conda create -n dl-lab python=3.9 conda activate dl-lab- 安装TensorFlow。没有NVIDIA显卡的机器,直接装CPU版本就够了,MNIST这种数据集在CPU上训练,每个epoch也就几十秒到几分钟,完全不是瓶颈。即使有显卡,第一次做实验也用CPU版更省心,因为少一个CUDA版本匹配的变量。
pip install tensorflow- 验证环境是否能用。
import tensorflow as tf from tensorflow import keras print(tf.__version__) print(keras.__version__)- 跑一个最小的训练循环,确认训练流程没有硬件或底层库的问题。
import numpy as np model = keras.Sequential([ keras.layers.Dense(4, activation="relu"), keras.layers.Dense(1) ]) model.compile(optimizer="adam", loss="mse") x = np.random.rand(16, 8).astype("float32") y = np.random.rand(16, 1).astype("float32") model.fit(x, y, epochs=2)这一步如果没报错,说明环境基本通了,可以开始正式实验。很多人跳过这个最小测试,直接跑完整项目,结果环境报错和代码报错混在一起,排查起来非常痛苦。
2.2 最容易踩的版本坑与解决办法
环境装好后,最常见的几个坑集中在版本匹配上,我把它们列出来,方便你遇到了直接对照。
第一个坑:在网上找到的很多教程是老文章,第一行写着import keras。如果你只装了TensorFlow没单独装Keras,这一行会直接报ModuleNotFoundError。这不是你的问题,是教程版本太老了。正确写法是from tensorflow import keras。如果你为了迁就旧教程单独pip install keras,又会引入独立Keras版本与TensorFlow内置版本不一致的隐患,建议不要混用。
第二个坑:numpy版本冲突。TensorFlow的某些版本与较新的numpy不兼容,导入时会报类似"module compiled using NumPy 1.x, cannot run with NumPy 2.x"的错误。这时候不用慌,把它降成兼容版本就行,比如pip install "numpy<2"。具体版本以报错提示为准,核心思路是让numpy大版本和TensorFlow预期一致。
第三个坑:找不到对应版本的wheel。这通常发生在Python版本太新的时候,比如用Python 3.12去装某些旧版TensorFlow,找不到预编译包,然后报错"Could not find a version that satisfies the requirement tensorflow"。解决办法要么换Python 3.9/3.10,要么升级TensorFlow版本。
第四个坑:数据集下载卡住。keras.datasets.mnist.load_data()会自动从外网下载数据集,网络不太好的时候会一直卡住,或者报连接错误。这个问题的解决方法是手动下载数据集,放到本地缓存目录~/.keras/datasets/下,代码会自动优先读取本地文件。下载文件的命名和目录结构需要注意,建议先从出错的机器上找到文件名,再按同样名字存放。
这些坑里的绝大多数,都可以通过"看错误栈最后一行"快速定位。环境问题一般发生在import阶段,代码问题一般发生在某个具体操作调用时。把报错信息复制到搜索框里,搜英文原样,比搜中文翻译更容易找到有效答案。
3. 手写数字识别:从数据加载到模型训练的一条完整链路
3.1 MNIST数据集的加载与本地缓存处理
MNIST就是深度学习的"Hello World":28×28的灰度图,内容是0到9的手写数字,训练集6万张,测试集1万张。虽然现在看来这个数据集很简单,但它非常适合用来理解整个训练流程,而且Keras直接内置了加载接口,两行就能拿数据。
(x_train, y_train), (x_test, y_test) = keras.datasets.mnist.load_data()拿到数据后,第一件事不是马上塞进模型,而是看一眼它的形状和取值范围。这一步看似简单,但能帮你过滤掉很大一部分后续问题。
print(x_train.shape) # (60000, 28, 28) print(y_train.shape) # (60000,) print(x_train.dtype) # uint8 print(x_train.min(), x_train.max()) # 0 255x_train的dtype是uint8,像素值在0到255之间,y_train是整数标签,从0到9。这些信息都记住,预处理部分全都要用到。如果load_data()一直卡住或者报网络错误,就回到2.2说的手动下载方案,问题是完全可以绕过的。
3.2 预处理:归一化、形状调整与one-hot编码
预处理是整个实验里最容易被轻视、但对结果影响最大的环节。很多同学模型结构抄的一模一样,准确率却差一截,问题往往就出在这里。
第一个操作是归一化。把像素值从0到255变成0到1:
x_train = x_train.astype("float32") / 255.0 x_test = x_test.astype("float32") / 255.0为什么要归一化?因为神经网络本质上是数值计算系统,激活函数对输入范围非常敏感。如果输入是0到255的大数值,加权求和的结果很容易落到激活函数的饱和区,梯度变得很小,训练速度会明显变慢甚至停滞。把数据压缩到0到1附近后,数值计算更稳定,收敛也更快。
第二个操作是调整形状。全连接网络要求输入是一维向量,所以要把每张28×28的图像展平成784维向量。可以手动做,也可以在模型第一层放Flatten自动做。手动做的代码是:
x_train = x_train.reshape(-1, 784) x_test = x_test.reshape(-1, 784)这里的-1表示自动推导维度,比如训练集6万张图,每张784维,reshape结果就是(60000, 784)。如果后面要用卷积网络,则要reshape成(60000, 28, 28, 1),最后一个1是通道数,灰度图只有一个通道。
第三个操作是标签的one-hot编码。把数字7从标量变成向量[0,0,0,0,0,0,0,1,0,0]:
num_classes = 10 y_train = keras.utils.to_categorical(y_train, num_classes) y_test = keras.utils.to_categorical(y_test, num_classes)为什么需要one-hot?因为我接下来用的损失函数是categorical_crossentropy,它期望真实标签是概率分布形式。如果保持整数标签,也可以用sparse_categorical_crossentropy,这种情况下就不用one-hot。实验课一般用前者,因为多分类交叉熵的计算方式和概率分布更直观。两种方式都能跑通,但不要混用:模型输出和标签格式不匹配的时候,loss会算出来一个很大的数,然后梯度爆炸,模型直接废掉。
3.3 模型定义、编译和训练:每一步都在干什么
模型结构继续用1.1里的Sequential定义。三层网络:一个Flatten层把图像展平,一个128神经元的Dense层用relu激活,一个10神经元的输出层用softmax激活。relu在这里的作用是给网络引入非线性,让模型能学到更复杂的决策边界;输出层的softmax把10个神经元的值变成10个类别的概率,所有概率加起来等于1,最大值对应的类别就是预测结果。
编译这一步,很多新手只知道照着抄,不知道这三个参数在做什么:
model.compile( optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"] )optimizer="adam"是优化器,负责根据梯度更新网络参数。Adam自带自适应学习率机制,对学习率不敏感,入门阶段选它基本不会出大问题,省去了手动调学习率的麻烦。loss="categorical_crossentropy"是损失函数,量化模型预测和真实标签之间的差距,训练的目标就是让这个值越来越小。metrics=["accuracy"]是评估指标,不影响训练过程,只是训练时打印出来方便你看效果。
训练循环调用fit:
history = model.fit( x_train, y_train, batch_size=32, epochs=5, validation_split=0.2 )解释两个关键参数。batch_size=32表示每32个样本计算一次梯度并更新一次参数,不是把所有6万张图一起算。这样做的原因是显存和内存有限,且小批量更新能让训练更稳定。epochs=5表示把全部训练数据迭代5遍,每遍结束都会在验证集上计算一次损失和准确率。validation_split=0.2表示在训练集里拿出最后20%当验证集,不参与参数更新,只用于观察模型是否过拟合。
训练完成后,在真正没参与过训练的测试集上做最终评估:
test_loss, test_acc = model.evaluate(x_test, y_test) print("测试集准确率:", test_acc)这里有个特别容易犯的错:测试集数据也要做和训练集一样的预处理流程,归一化、reshape、one-hot一个都不能少。很多人训练集上表现不错,一换测试集准确率暴跌,然后怀疑模型结构,实际上只是忘记对测试集做归一化了。原始0到255的数据扔进去,模型看到的分布完全变了,效果不可能好。
3.4 预测与可视化:让实验结果看得见
模型训完,除了看准确率数字,还可以拿几个测试样本实际预测一下,用图直观看看效果。在课程报告里,一张"手写数字图片+预测结果+真实标签"的对比图,比写一百字都有说服力。
import numpy as np import matplotlib.pyplot as plt predictions = model.predict(x_test[:5]) predicted_classes = np.argmax(predictions, axis=1) true_classes = np.argmax(y_test[:5], axis=1) for i in range(5): plt.subplot(1, 5, i + 1) plt.imshow(x_test[i].reshape(28, 28), cmap="gray") plt.title(f"pred={predicted_classes[i]} true={true_classes[i]}") plt.axis("off") plt.show()model.predict返回每个样本每种类别的概率,np.argmax取概率最大的位置,就是模型预测的类别。这里再次印证了前面one-hot处理的重要性:因为y_test已经变成one-hot向量,所以要argmax才能还原成数字标签。
4. 训练曲线里的学问:损失不下降、过拟合与显存问题
4.1 训练损失不下降时的系统排查思路
实验里最打击人的现象之一:训练了好几个epoch,损失值纹丝不动,准确率一直低迷。很多人的第一反应是"网络不够深,加层",但根据我的经验,绝大多数时候问题根本不在网络结构,而在数据或超参数设置上。
我建议按下面这个顺序排查:
- 数据预处理。归一化做了吗?测试集和训练集的预处理一致吗?标签有没有配对错误?这是出现频率最高的问题。
- 网络输出和损失函数是否匹配。多分类用softmax输出加
categorical_crossentropy,二分类用sigmoid输出加binary_crossentropy,回归问题不加激活函数用mse。匹配错了,训练根本学不到东西。 - 标签的shape和格式。如果数据是整数标签,模型输出是10维概率,但loss用的是
sparse_categorical_crossentropy,一般没问题;但如果混用了categorical_crossentropy又不做one-hot,loss会直接算错。 - 模型参数有没有在更新。看每个epoch的loss,如果完全不变,说明梯度可能是零或者太大。可以临时把学习率调大一点测试,但不要长期依赖这个调整。
- 是不是数据量太少,模型学不到有效规律。MLP在MNIST上有几万张训练图,一般不会遇到这种情况,但如果自己造了20个样本,那模型再复杂也没用。
这个排查顺序的核心逻辑是"先数据后模型、先简单后复杂"。每一层都有对应的检查手段,逐层排除,比盲目改网络结构高效得多。
4.2 用history对象画曲线:识别过拟合的入门方法
fit函数返回的history对象,记录了训练过程中每个epoch的各项指标。不要忽略它,这是判断模型是否过拟合的最直接材料。
plt.figure(figsize=(10, 4)) plt.subplot(1, 2, 1) plt.plot(history.history["accuracy"], label="train_acc") plt.plot(history.history["val_accuracy"], label="val_acc") plt.title("Accuracy") plt.legend() plt.subplot(1, 2, 2) plt.plot(history.history["loss"], label="train_loss") plt.plot(history.history["val_loss"], label="val_loss") plt.title("Loss") plt.legend() plt.show()判断过拟合的方法也很直观:如果训练准确率一直上涨,但验证准确率在某轮后停滞甚至下降,同时验证损失开始回升,那就说明模型开始"背"训练集了,而不是在学通用的规律。应对过拟合的几个常用手段:
- 在Dense层后面加
Dropout,比如keras.layers.Dropout(0.5),训练时随机让一半神经元失活,强制网络学到更鲁棒的特征。 - 加L2正则化,在Dense层设置
kernel_regularizer=keras.regularizers.l2(0.001),让权重保持较小,抑制对单个特征的过度依赖。 - 使用
EarlyStopping回调,监控验证损失,连续几个epoch不下降就提前停,防止浪费时间继续训练。
callbacks = [ keras.callbacks.EarlyStopping( monitor="val_loss", patience=3, restore_best_weights=True ) ] model.fit(x_train, y_train, epochs=20, validation_split=0.2, callbacks=callbacks)这些手段在实验四里不一定都需要,因为MNIST分类任务相对简单,但理解它们是什么、怎么用,是后面做更复杂数据集的必要储备。
4.3 显存不足与CPU训练太慢的实用方案
实验课机器配置往往参差不齐。如果训练时报OOM(显存不足),最直接的办法是把batch_size调小,比如从32调到16甚至8。单次迭代的数据量小了,显存占用会明显降下来。有的同学担心batch_size变小会影响最终准确率。在MNIST这种简单任务上,影响通常很小,完全可以接受。
如果机器没有GPU、纯CPU训练,体验会慢一些。这时可以把epochs减少,或者从训练集里抽样一部分做快速验证,比如只取1万张图。代码逻辑完全不用改,只是数据量变了,先确认整个流程能通,再放开跑完整数据。训练日志也不用每行都盯着看,损失、准确率、验证损失、验证准确率这几个指标的变化趋势,比单个数字重要得多。
还有一个很多人忽略的点:训练过程的记录。我当时做实验的习惯是把每次修改的结构、参数、epoch对应的准确率记在一个小表格里。这样如果某次改动让结果变差了,可以很快回退到之前的配置,而不是陷入"乱调参数然后忘记原来是什么"的困境。
5. 实验之外:函数式API、模型保存与下一步延伸
5.1 从Sequential到函数式API:模型结构的表达能力升级
Sequential模型适合层的顺序堆叠,这是它最直观的优点,也是局限:无法表达分支、多输入、多输出这些更复杂的结构。以后看实际项目的代码,尤其是多任务模型或者带特征共享的结构,几乎都是函数式API写的。
函数式API的写法区别在于显式连接层的输入输出:
inputs = keras.Input(shape=(784,)) x = keras.layers.Dense(128, activation="relu")(inputs) outputs = keras.layers.Dense(10, activation="softmax")(x) model = keras.Model(inputs=inputs, outputs=outputs)看起来只是写法变了,本质是把数据流图显式化:inputs作为入口,经过Dense层处理得到outputs,最后用keras.Model组装起来。理解这个模式之后,再看多输入场景,比如同时输入文本和图像做分类,就只是定义两个输入分支再合并,逻辑上是顺着的。
5.2 模型的保存与加载:让实验成果可以复用
实验做完,模型不能关电脑就没了。保存和加载都很简单:
model.save("mnist_model.keras")之后随时可以加载回来做预测:
loaded_model = keras.models.load_model("mnist_model.keras") preds = loaded_model.predict(x_test[:10])保存模型在实验报告里也很有用:可以对比不同网络结构保存下来的模型在同一测试集上的表现。新版推荐用.keras格式,老代码里常见的.h5也能用,但要确保运行时Keras版本支持。建议训练结束后,把测试准确率和历史训练曲线一起存到文件里,方便后面复盘。这个习惯在后续更复杂的项目里会反复受益。
5.3 下一步延伸:从全连接网络到卷积网络
实验四做完还有余力的话,最自然的下一站是卷积网络。全连接网络把整张图展平成784维向量,丢掉了像素的空间位置关系;CNN用卷积核在图上滑动,天然保留局部结构,所以准确率会有明显提升。
可以把前面的模型改成CNN结构:
model = keras.Sequential([ keras.layers.Reshape((28, 28, 1), input_shape=(784,)), keras.layers.Conv2D(32, (3, 3), activation="relu"), keras.layers.MaxPooling2D((2, 2)), keras.layers.Conv2D(64, (3, 3), activation="relu"), keras.layers.MaxPooling2D((2, 2)), keras.layers.Flatten(), keras.layers.Dense(128, activation="relu"), keras.layers.Dropout(0.5), keras.layers.Dense(10, activation="softmax") ])注意这里第一层用Reshape把784维向量恢复成28×28×1的图片结构。如果用CNN,预处理阶段也不需要手动reshape成784,直接保持(60000, 28, 28, 1)就行。在MNIST基础分类上,CNN在不做任何数据增强的情况下,测试准确率普遍能到99%以上,这个提升会直观地让你感受到"特征提取层"的价值。
5.4 用回调函数让训练过程更省心
训练大模型的时候,不可能一直盯着终端看每个epoch的输出。回调函数是Keras里非常实用的机制,下面是我每次训练都会配置的基础组合:
callbacks = [ keras.callbacks.EarlyStopping( monitor="val_loss", patience=3, restore_best_weights=True ), keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.2, patience=2, min_lr=1e-5 ) ] model.fit(x_train, y_train, epochs=20, validation_split=0.2, callbacks=callbacks)EarlyStopping监控验证损失,连续3轮不下降就自动停止,省时间也避免过拟合。ReduceLROnPlateau在验证损失不再下降时自动把学习率乘上0.2,帮助训练在后期更好地收敛。训练结束打印history,看曲线确认训练过程正常,再根据结果决定下一步调整方向。
我做这个实验时最大的体会是:深度学习入门阶段,真正难的往往不是网络结构,而是"数据在进入网络之前有没有处理对"。很多看起来玄学的现象,排查到最后都是预处理或者维度问题。另一个特别深的体会是实验记录的价值。我把每次改的结构、参数、每个epoch的结果都记在表格里,写实验报告、跟同学讨论、复现自己的结果都变得非常快。实验四只是第一步,但这套"搭模型-训练-看曲线-调整-记录"的流程一旦跑顺,后面学CNN、学RNN、做实际项目都会顺畅得多。