训练深度学习模型,最怕什么?在我看来,不是准确率低,而是你根本不知道模型到底在干什么。模型每跑完一个epoch,权重就更新一次,几十个epoch跑下来,中间任何一个环节出了问题,都很难从最终结果倒推。所以我一直在强调一件事:从第一个epoch开始,就把acc和loss记录下来,并且可视化。这篇是TensorFlow系列的第13篇,专门聊acc和loss可视化,把训练过程从黑盒变成可以直观观察的曲线图。它适合正在入门深度学习、经常调参却摸不着头脑的同学,也适合那些已经能跑通模型、但想更系统地判断模型状态的开发者。看完你会发现,判断模型是否收敛、是否过拟合、学习率是否合适,其实一眼就能看出个大概。
1. 先搞清楚为什么要看acc和loss曲线
1.1 训练过程不是黑盒,但默认情况下你什么都看不见
模型训练的本质,是前向传播算出预测值,然后和真实标签计算一个loss,再通过反向传播把梯度传回去,更新每一层权重。每个epoch结束,模型参数都不同,理论上你应该知道这轮参数到底变好了还是变坏了。可现实中很多人是这样的:跑一个model.fit(),然后盯着终端滚动的进度条,看到loss一直在变,却说不清它在往哪个方向收敛,只能干等训练结束,打印一个最终测试准确率。这种做法其实浪费了训练过程中最宝贵的信息。
acc和loss是训练过程最核心的两个指标。acc告诉我们模型在训练集上猜对的概率有多高,loss则反映预测分布和真实分布之间的差距。两者结合起来,能判断模型是在正常学习、已经饱和、还是正在走向过拟合。比如loss降低但acc不涨,说明模型在“学”但没有转化为正确分类;loss不降反升,说明梯度方向已经乱了。这些信息只有把每个epoch的值画成曲线才能快速感知,光靠盯终端数字,几十个epoch看下来眼睛会花。
1.2 从曲线形态能直接读出七八种训练问题
可视化不是形式主义,它是我调参时的第一诊断工具。举个最典型的例子:训练acc一路冲到0.99,验证acc却停在0.75甚至开始往下走,这是典型的过拟合,曲线一眼就能看出来。反过来,训练acc和验证acc都涨得很慢,loss也下不去,说明模型容量不够或者学习率太小,属于欠拟合。再比如loss曲线上下剧烈振荡,像锯齿一样,那大概率是学习率太大,参数在最优解附近反复横跳。
还有一些边界情况,靠曲线可以提前发现:验证loss在某个epoch之后开始缓慢上升,但训练loss还在下降,这是过拟合的早期信号,比准确率变化更灵敏,因为准确率是离散的,变动幅度小,而loss是连续的,能捕捉到微小恶化。还有loss突然变成nan,曲线直接断掉,这通常意味着梯度爆炸或者数据里有异常值。这些场景,往下看都能找到对应的操作方案。
2. 可视化数据从哪来:History和回调机制
2.1 model.fit返回的History对象是首选的免费数据源
在TensorFlow的Keras接口里,model.fit()执行完会返回一个History对象,里面有个history字典,记录了每个epoch结束时的指标值。如果你训练时传入了验证集,那字典里通常会有loss、acc、val_loss、val_acc这四个键(准确率对应的键名取决于你编译时的metrics,比如accuracy、acc或categorical_accuracy)。这是最简单、零成本的数据来源,不需要自己写任何统计代码。
import tensorflow as tf model = tf.keras.Sequential([ tf.keras.layers.Dense(64, activation='relu', input_shape=(20,)), tf.keras.layers.Dense(10, activation='softmax') ]) model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) history = model.fit(x_train, y_train, epochs=50, batch_size=32, validation_data=(x_val, y_val), verbose=1) print(history.history.keys()) # dict_keys(['loss', 'accuracy', 'val_loss', 'val_accuracy'])拿到这个字典之后,画图就是纯Matplotlib的活了。注意一个容易踩的坑:如果你在compile里把metrics写成['acc'],那么字典键是acc;写成['accuracy'],键就是accuracy。我见过不少人在网上抄代码,一会儿用acc一会儿用accuracy,结果画图时报KeyError,其实不是画图的问题,是你和训练时的命名不一致。
2.2 想记录更细粒度数据,用Callback自定义回调
History只记录epoch级别的数据。有时候我想看batch级别的loss变化,或者想在每个epoch结束时顺手做点额外操作,比如动态画图、保存最佳模型,这时候就需要用Callback回调。Callback可以理解为挂在训练流程上的钩子,Keras在特定时机自动调用你重写的方法。
最常见的几个时机是:
on_epoch_end(epoch, logs):每个epoch结束触发,logs是当前epoch的指标字典on_batch_end(batch, logs):每个batch结束触发,可以看到更细粒度的loss抖动on_train_end(logs):整个训练结束触发,适合做收尾保存
自定义一个记录loss历史的回调,代码很短:
class LossHistory(tf.keras.callbacks.Callback): def on_train_begin(self, logs=None): self.epoch_loss = [] self.epoch_acc = [] def on_epoch_end(self, epoch, logs=None): self.epoch_loss.append(logs.get('loss')) self.epoch_acc.append(logs.get('accuracy')) # 这里也可以顺手做动态绘图把实例传给model.fit(callbacks=[LossHistory()])即可。回调方案比History更灵活,因为你可以在记录的同时做判断,比如连续5个epoch验证loss不再降低就提前终止训练,这其实就是EarlyStopping的原理。理解了回调机制,你其实已经掌握了自定义Keras训练流程的钥匙,acc和loss可视化只是其中一个应用场景。
2.3 为什么不直接用TensorBoard
提到可视化,很多人会推荐TensorBoard。它确实强大,能看计算图、直方图、嵌入向量,训练曲线也支持实时刷新。但我自己的习惯是:TensorBoard适合大规模实验管理,比如一次跑十几个对比实验,在浏览器里方便切换查看;而调参阶段、写笔记、做汇报,我更喜欢用Matplotlib直接出图,因为图可以保存成PNG,塞进文档、发到群里都很方便,而且样式完全可控。这一篇讲的是acc和loss的曲线可视化,核心代码就是Matplotlib,如果你想扩展,TensorBoard的TensorBoard(log_dir='./logs')回调加进去也就三行,但那是另一个话题了。
3. 实操:把acc和loss画成清晰可用的图
3.1 最基础的双子图方案:loss一张、acc一张
画图这事,别一上来就追求炫技,先把最标准的两张图跑通:左边画loss,右边画acc,每条曲线都同时画出训练集和验证集,这是我认为最利于判断训练状态的布局。
import matplotlib.pyplot as plt def plot_history(history): epochs = range(1, len(history.history['loss']) + 1) plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(epochs, history.history['loss'], label='train_loss') plt.plot(epochs, history.history['val_loss'], label='val_loss') plt.xlabel('Epoch') plt.ylabel('Loss') plt.title('Loss Curves') plt.legend() plt.subplot(1, 2, 2) plt.plot(epochs, history.history['accuracy'], label='train_acc') plt.plot(epochs, history.history['val_accuracy'], label='val_acc') plt.xlabel('Epoch') plt.ylabel('Accuracy') plt.title('Accuracy Curves') plt.legend() plt.tight_layout() plt.show() plot_history(history)这段代码有几个细节值得说。第一,epochs我用range(1, len(...)+1),让横轴从1开始,而不是从0开始,看图的直觉感受更好。第二,一定要把train_*和val_*画在同一张图里,否则你没法直接对比训练集和验证集的差距。第三,loss和acc分开画是因为它们的量纲和尺度完全不同,loss可以是从2降到0.1的大幅度变化,acc可能只是从0.6涨到0.9,放在同一个坐标系里,acc的变化会被loss的尺度压扁。
3.2 一张图同时看:双y轴对比loss和acc的联动
有时候我想把loss和acc放在同一张图里,观察它们的联动关系,比如loss下降是否对应acc上升。但二者量纲不同,直接画会导致一条曲线被压成直线。这时候用双y轴,左轴画loss,右轴画acc,两条曲线就可以各用各的尺度。
fig, ax1 = plt.subplots(figsize=(8, 5)) epochs = range(1, len(history.history['loss']) + 1) ax1.plot(epochs, history.history['loss'], 'b-', label='train_loss') ax1.plot(epochs, history.history['val_loss'], 'b--', label='val_loss') ax1.set_xlabel('Epoch') ax1.set_ylabel('Loss', color='b') ax1.tick_params(axis='y', labelcolor='b') ax1.legend(loc='upper left') ax2 = ax1.twinx() ax2.plot(epochs, history.history['accuracy'], 'r-', label='train_acc') ax2.plot(epochs, history.history['val_accuracy'], 'r--', label='val_acc') ax2.set_ylabel('Accuracy', color='r') ax2.tick_params(axis='y', labelcolor='r') ax2.legend(loc='lower right') plt.title('Loss and Accuracy over Epochs') plt.show()双y轴图适合快速查看整体趋势,但对新手有个陷阱:左右两条曲线的刻度范围不同,容易造成视觉误导。左边loss还在下降,右边acc看起来已经涨不动了,这不一定是模型坏了,有可能只是acc坐标范围一开始就到0.9了,后面自然视觉平缓。所以我一般把双y轴图作为辅助视图,核心判断还是看3.1的双子图。
3.3 多模型对比:把不同配置的history存下来再画
调参时免不了对比实验,比如改动学习率、换优化器、调batch size,每组都跑一遍。如果每次只画当前这组的曲线,对比全凭记忆,那就太粗放了。我的习惯是训练完之后把history里的指标导出成文件,统一保存,最后一次性画对比图。
import json import numpy as np # 训练完一个模型后,立刻保存 def save_history(history, name): with open(f'{name}_history.json', 'w', encoding='utf-8') as f: json.dump(history.history, f) print(f'{name} history saved') # 需要画对比图时,加载多个保存的文件 def load_history(name): with open(f'{name}_history.json', 'r', encoding='utf-8') as f: return json.load(f) # 示例: 对比两种学习率下验证集loss的收敛过程 hist_lr_1e3 = load_history('lr_1e-3') hist_lr_1e4 = load_history('lr_1e-4') plt.figure(figsize=(8, 5)) plt.plot(hist_lr_1e3['val_loss'], label='lr=1e-3 val_loss') plt.plot(hist_lr_1e4['val_loss'], label='lr=1e-4 val_loss') plt.xlabel('Epoch') plt.ylabel('Val Loss') plt.legend() plt.show()我建议从项目第一天就建立工况记录习惯:每组实验一个独立的名字,保存history的同时顺手把batch_size、learning_rate、epochs这些超参也写进文件名或一个文本说明里。这样过两周回来看图,还能想起当时跑了什么配置。对比图的价值在于,它能把超参的影响具象化,比单纯看最终准确率更能看出哪个配置在训练中段就领先了、哪个配置后劲不足。
3.4 训练过程中实时动态绘制:把终端进度条变成活图
model.fit跑完再画图是离线方案,适合看完结果再分析。但实际调参时,我更希望训练过程中就能看到曲线在动,发现问题马上中断,别干等100个epoch跑完才发现loss已经发散到天上。实现方式是利用回调+Matplotlib的交互模式。
import matplotlib.pyplot as plt import tensorflow as tf plt.ion() # 打开交互模式 class LivePlotCallback(tf.keras.callbacks.Callback): def __init__(self): super().__init__() self.epochs = [] self.train_loss = [] self.val_loss = [] self.train_acc = [] self.val_acc = [] self.fig, (self.ax1, self.ax2) = plt.subplots(1, 2, figsize=(12, 4)) self.line1, = self.ax1.plot([], [], label='train_loss') self.line2, = self.ax1.plot([], [], label='val_loss') self.line3, = self.ax2.plot([], [], label='train_acc') self.line4, = self.ax2.plot([], [], label='val_acc') self.ax1.set_xlabel('Epoch') self.ax1.set_ylabel('Loss') self.ax1.legend() self.ax2.set_xlabel('Epoch') self.ax2.set_ylabel('Accuracy') self.ax2.legend() def on_epoch_end(self, epoch, logs=None): self.epochs.append(epoch + 1) self.train_loss.append(logs.get('loss')) self.val_loss.append(logs.get('val_loss')) self.train_acc.append(logs.get('accuracy')) self.val_acc.append(logs.get('val_accuracy')) self.line1.set_data(self.epochs, self.train_loss) self.line2.set_data(self.epochs, self.val_loss) self.line3.set_data(self.epochs, self.train_acc) self.line4.set_data(self.epochs, self.val_acc) for ax in [self.ax1, self.ax2]: ax.relim() ax.autoscale_view() self.fig.canvas.draw() self.fig.canvas.flush_events() plt.pause(0.01) # 训练时动态出图 model.fit(x_train, y_train, validation_data=(x_val, y_val), epochs=100, callbacks=[LivePlotCallback()], verbose=0) plt.ioff() plt.show()这里有几个经验点。第一,刷新操作只在on_epoch_end里做,不要在on_batch_end里重绘图,否则每个batch都刷一次,训练速度会被拖慢不少,尤其数据集大的时候,绘图反而成为瓶颈。第二,relim()和autoscale_view()是让坐标轴随数据动态扩展的关键,别忘了调用,否则曲线会画出坐标系边界。第三,动态绘图只是为了实时监控,最终保存高质量图片还是离线用3.1的代码重新画一次,因为交互模式下为了流畅会牺牲部分渲染质量。
4. 怎么读曲线:形态判断训练状态
4.1 原始曲线太吵?先做平滑再判断趋势
训练过程中记录下来的loss曲线,尤其是batch级别的loss,噪声会很大。这种噪声来自于小批量随机抽样带来的梯度震荡,每个batch的样本分布都不完全一样,loss自然上下跳动。epoch级别的曲线虽然噪声小一些,但遇到小数据集或者大batch的时候,也可能出现锯齿。直接看原始曲线,新手容易被局部的尖峰带偏判断,误以为模型在发散。
我的做法是画图时单独计算一条平滑曲线,常用的方法是指数移动平均,它比简单滑动窗口更能保留趋势,而且实现就几行。
def smooth_curve(points, factor=0.9): smoothed = [] last = points[0] for p in points: last = last * factor + (1 - factor) * p smoothed.append(last) return smoothed # 用法 loss = history.history['loss'] plt.plot(smooth_curve(loss), label='smoothed loss') plt.plot(loss, alpha=0.4, label='raw loss')平滑曲线用于判断趋势,原始曲线则保留着真实信息。两条线可以画在一起,原始曲线降低透明度,这样既能看到噪声水平,又能看清走势。这里有个容易误解的点:平滑系数factor选得越大,曲线越平滑,但滞后也越明显,拐点会被拖后。0.9是常见选择,如果还是太抖,可以调到0.95,但别超过0.98,否则你看到的几乎是一条延迟了十几个epoch的曲线,判断收敛时机就不准了。
4.2 典型曲线形态速查表
光会画图不够,还要会看图。我把实际调参中常见的曲线形态整理成一张速查表,按“训练acc、验证acc、训练loss、验证loss”四列组合来判定模型状态。判断的时候,优先看loss曲线的相对关系,因为loss比acc敏感得多。
| 曲线现象 | 大概率结论 | 优先尝试的调整 |
|---|---|---|
| 训练loss下降,验证loss也下降,两者接近 | 训练正常 | 继续训练,观察是否后期分化 |
| 训练loss下降,验证loss先降后升 | 过拟合的早期信号 | 加正则化、Dropout,提前早停 |
| 训练loss很低,验证loss明显高于训练loss | 过拟合已经发生 | 减小模型容量,增加数据增强 |
| 训练loss和验证loss都居高不下 | 欠拟合 | 加大模型容量、调大学习率、检查特征 |
| 两条loss都在下降但acc不涨 | 分类边界没找对 | 检查数据标注、Loss函数、类别平衡 |
| loss剧烈振荡,幅度越来越大 | 训练发散 | 降低学习率、梯度裁剪 |
| loss变成nan,曲线断崖式消失 | 梯度爆炸或数据异常 | 检查数据含nan、降低lr、加BatchNorm |
这张表是我在实际项目里反复用到的判断框架。注意一点:表格里的结论不是绝对的,比如训练loss下降、验证loss先降后升,在某些正则化极强的设置下也可能不出现过拟合,但作为优先诊断方向几乎不会错。曲线的最大价值是给你一个方向,而不是替你下结论。
4.3 曲线判断和EarlyStopping、ModelCheckpoint配合使用
可视化是诊断手段,真正在训练中自动干预,靠的是EarlyStopping和ModelCheckpoint这两个回调。读完曲线你发现验证loss在上升,总不能眼盯着手动中断吧,可以让模型自己在验证loss连续N个epoch不再改善时提前终止。
callbacks = [ tf.keras.callbacks.EarlyStopping( monitor='val_loss', patience=10, restore_best_weights=True ), tf.keras.callbacks.ModelCheckpoint( 'best_model.h5', monitor='val_loss', save_best_only=True ) ] history = model.fit(x_train, y_train, validation_data=(x_val, y_val), epochs=200, callbacks=callbacks)monitor参数建议监控val_loss而不是val_acc,原因和前面说的一样,loss的变化更连续、更敏感,不容易因为acc数值卡在某个平台期而错过最佳模型。一个常见误区是patience设得太小,比如验证loss刚抖了一下就早停,白白浪费模型继续训练的机会。实际经验里,patience设成总epoch数的10%~20%比较合理,比如你计划跑200轮,patience取20到30。配合restore_best_weights=True,早停后会恢复到验证指标最好的权重,省得你手动加载。
5. 常见问题与排查实录
5.1 loss变成nan,曲线直接断崖
跑着跑着曲线突然变成nan,这个坑我踩过很多次,每次原因都不太一样。最常见的是学习率太大,尤其是在用Adam这类自适应优化器时,初始学习率设到0.1以上,前面几步就可能梯度过猛,把参数推到数值溢出区域。其次是你的输入数据里本来就带NaN,某个batch的样本里混进了缺失值,前向传播算出nan,反向传播也跟着nan,一路污染下去。第三种比较隐蔽,是Loss函数里出现了对数运算,预测值是0或1的极端情况,log(0)直接算成负无穷。
排查看起来要按顺序来。第一阶段,先把学习率降到原来的十分之一或百分之一,重跑几次,如果曲线恢复正常,基本就能确认是学习率过大。第二阶段,检查数据,用np.isnan(x_train).any()扫一遍,顺便用np.isinf()查一下有没有无穷值。第三阶段,在你的损失函数入口处加一个极小值clip,避免对数为零,比如tf.clip_by_value(pred, 1e-7, 1.0)。还可以在模型结构里加Batch Normalization层,它能在一定程度上压制梯度爆炸。我建议排查时只用一行代码确认数据类型,不要上来就改模型结构,先排除最简单的原因。
5.2 训练acc高到0.99,验证acc却只有0.75
这是过拟合的标准画面。训练acc一路高歌,验证acc涨到某个点开始横盘或者下跌,并且背后的验证loss从低点开始反弹。要注意区分两种情况:如果验证acc是缓慢横盘,可能与验证集本身就比较难有关,不一定过拟合;如果验证acc明显掉头向下,那你已经站在过拟合的右侧了。
应对过拟合有几步棋可走。最直接的是加Dropout,一般加在全连接层后面,rate从0.2开始调,观察验证集曲线的反应;其次加L2正则化,参数weight decay控制在1e-4到1e-6之间,不要一上来就用很大,否则模型会欠拟合;再者是数据增强,这个方法在图像任务里特别有效,旋转、翻转、裁剪,让模型看到更多变形样本;最后是降低模型容量,把隐藏层神经元数量减半,很多时候小模型反而在验证集上表现更好。每一步改动后都重新画曲线,看验证loss的最低点有没有下移。
5.3 曲线锯齿状剧烈振荡,loss就是不降
如果你的loss曲线不是在下降,而是在一个平台期上下疯狂摆动,说明学习率对当前模型来说太大了。参数在损失曲面的谷底附近来回跳跃,永远踩不到底。这种情况不是模型坏了,而是步长太大。可以按0.1倍来缩小学习率,直到曲线从锯齿变为斜向下。另一个可能因素是batch size太小,梯度估计噪声大,导致loss抖动。增大batch size可以有效平滑梯度,但会牺牲一点训练速度。实际操作中我会先降低学习率,曲线平滑了就说明是步长问题;如果已经很平滑但loss还是卡住不降,再看是否欠拟合,加大模型容量或者增加训练轮数。
5.4 动态绘图卡顿,训练速度被拖慢
有人按照动态绘图的代码跑,发现训练速度明显变慢,原因多半是刷新频率太高。如果数据集比较小,每个epoch才几秒钟,你在on_epoch_end里重绘一次图,绘图的耗时占比就很高。解决办法有三个方向:一是改回离线画图,训练时不画,跑完再画;二是只在每5个epoch绘制一次,用一个计数器控制;三是关闭其它无关的窗口刷新,确保只有一个动态图窗口。plt.pause(0.01)的参数也别设成0,pause时间太短反而可能让图形事件循环占用CPU。
5.5 图片模糊、坐标文字挤成一团
画图最终是要给人看的,图片清晰度也很关键。保存图片时指定dpi和figsize,会根据需要调整,但建议在导出到文档或PPT之前显式设置一次。
plt.figure(figsize=(10, 6)) # 加大画布 plt.plot(..., linewidth=1.5) # 适当加粗线条 plt.xlabel('Epoch', fontsize=12) plt.ylabel('Loss', fontsize=12) plt.tight_layout() # 自动调整留白,防止标签被切掉 plt.savefig('loss_curve.png', dpi=300)dpi=300足够打印和工作汇报,太高会导致图片文件过大。tight_layout()这个调用经常被忽略,但当你同时画好几个子图、每个子图又有legend的时候,不加它,坐标轴标签很容易互相重叠,加了之后布局会自动调整。细节上,图例位置如果和曲线重叠,可以通过loc='upper right'之类来指定方位,或者直接调用plt.legend()让Matplotlib自动找空白位置。
写在最后的小习惯
我个人在实际操作中,已经把“跑完一组训练就立刻保存history”变成肌肉记忆了。无论当前结果多差,那份训练过程的曲线都是宝贵的诊断记录,等模型改进了,拿前后两条曲线一对比,才能知道到底哪次调整起了作用。可视化acc和loss这件事本身不难,难的是养成用曲线判断训练状态的意识。每次训练结束,先看图,再下结论,这比盯着最终准确率数字要可靠得多。后面有机会,我还会分享混淆矩阵、特征可视化这些进阶的模型诊断方法,它们都是建立在今天这套曲线思维之上的。