做图像分类训练时我经常遇到一个需求用Keras的fit_generator跑完训练除了val_acc这类标量指标之外还想把验证集上每个样本的y_true和y_preds完整拿下来画混淆矩阵、算每类召回率、挑bad case。这在模型上线前几乎是必做的一步但fit_generator默认不会把这些明细返回给你官方文档也没讲清楚怎么拿。这篇文章就把我实际项目里验证过的方法拆开讲适合正在用tf.keras训练图像或文本分类模型、需要对验证集做细粒度评估的读者。1. 先弄明白fit_generator为什么“拿不到”预测很多新手看到model.evaluate(generator)能输出loss和acc就以为日志里应该藏着每个样本的预测结果翻遍History发现根本没有。这个问题的根源在于fit和evaluate的内部调用链验证阶段是在训练循环里执行的模型只在每个batch结束瞬间把batch粒度的指标累加进去样本粒度的预测值和真实标签都没有对外保留。evaluate返回的也只是一个包含损失和指标的列表不包含模型输出。换句话说fit_generator给训练过程做了一层数据流封装把“喂入生成器—算指标—更新权重”全部包在内部开发者只需要关心配置好generator和回调即可。但要做细粒度评估这类封装反而成了障碍。你需要的是model.predict(val_generator)跑一遍验证数据再把生成器中的真实标签按相同顺序取出来两者对齐才能继续画图、算指标。麻烦就麻烦在“按相同顺序”这四个字上生成器如果在每个epoch都打乱数据顺序predict出来的顺序和y_true的排列就对不上后面所有分析都是错的。我最早在这个功能上踩坑就是混淆矩阵矩阵画成了一团乱麻排查半天才发现是shuffle在捣鬼。从需求出发哪些场景最需要这组数据至少有三类第一类是混淆矩阵和按类别的precision/recall/F1报告这类需求在业务方验收时常被要求第二类是ROC曲线、PR曲线、AUC这类需要连续概率输出的评估不只关心argmax之后的类别第三类是bad case可视化挑出预测错误的样本回到原始图像上下文里分析数据标注问题或模型盲点。这三类需求共同点只有一个都要拿齐验证集的真实标签和模型完整输出。另外提醒一句关于命名的问题如果你还在用很老的Keras独立包确实是用model.fit_generator但在TensorFlow 2.x里这个接口已经被合并进model.fit直接传generator参数就行。看到老旧教程里写fit_generator不用慌本质上数据流还是同一套逻辑下面的方案两种写法都兼容。2. 方案选型别一上来就手写循环获取验证集y_true和y_preds社区里能见到的做法大致有四种各有各的适用边界。我给一个对比表方便你按自己的数据规模挑。方案做法概述适用场景复杂程度额外内存回调 model.predict在Callback的on_epoch_end里调predict验证集用generator/Sequence数据较多无法全部载入内存中只需存预测和标签数组validation_data传元组直接把(x_val, y_val)传给fit验证集不大能整个放入内存低需要x_val和y_val两份数组先转数组再评估遍历一次生成器把样本和标签拼装成numpy中等数据量想复用已有Generator逻辑中需要完整验证集数组自定义训练循环用GradientTape手写训练循环验证阶段完全自定义需要逐batch细粒度控制高中等从我的实操感受来说绝大多数项目用第一种就够配合第二种做交叉验证。第一种的优势是它不打乱fit原有的训练流程只是把验证集预测额外做了一遍对训练逻辑零侵入。第二种的好处是简单直接把y_true在回调外面就固定下来完全省掉从generator里抽取标签的麻烦。第三种适合那种“验证集是某个现有生成器不想改结构”的情况但一次性把数据全部拼出来要留意内存。第四种除非你在做研究性质的特殊任务否则工程上不太推荐改动面太大。我的推荐组合是验证数据不大——直接传元组验证数据必须走生成器/流式加载——用回调 predict同时把y_true单独持久化一份。两种方案都建议把验证生成器的shuffle关掉。验证阶段本来就不需要shuffleshuffle只对训练优化有意义保留shuffle除了徒增对齐难度之外没有任何收益。3. 核心实现回调 predict 双保险3.1 让验证生成器“顺序稳定”是第一要务不管用什么方案第一条原则都是验证阶段的样本顺序必须稳定、可控。ImageDataGenerator.flow_from_directory返回的迭代器默认shuffleTrue训练阶段没问题但验证阶段你需要在创建后手动改成val_gen.shuffle False。如果验证生成器是实现自keras.utils.Sequence的子类要么在初始化时不引入随机索引要么在预测前调用reset()并确保on_epoch_end里不会触发重排。为什么顺序稳定如此重要因为model.predict(val_gen)在内部会从头开始遍历这个生成器只要你前面的生成器是“从头按固定顺序吐数据”那model拿到的第i个样本和你单独val_gen[i]拿到的第i个样本就是同一个。一旦shuffle打开predict的遍历顺序和收集y_true时用的索引顺序就对不上结果必然错位。这里有一个细节容易忽略DirectoryIterator这类生成器在fit_generator内部跑验证时其实也会经历自身索引的重置。如果你在回调里手动调了reset()再predict然后再交给fit跑下一轮验证顺序可能已经被改动。所以我建议训练前就把验证生成器的shuffle永久设成Falsereset可以保留但不要反复动setting。放心验证指标不会因为顺序固定而变差。3.2 从生成器里抽出y_true的两种可靠方式第一种方式直接用keras.utils.Sequence做验证集因为它实现了__len__和__getitem__可以按下标取数据。import numpy as np def y_true_from_sequence(seq, stepsNone): if steps is None: steps len(seq) collected [] for i in range(steps): item seq[i] # item 可能是 (x, y) 或 (x, y, sample_weight) y_batch item[1] collected.append(np.asarray(y_batch)) return np.concatenate(collected, axis0)这种方式的坑在于如果Sequence内部实现了on_epoch_end并重排索引那你当前拿到的seq[i]可能已经不是原始文件顺序每次predict前必须重新reset。更保险的做法是把shuffleFalse写死在Sequence里验证集永远按原始顺序输出。第二种方式如果验证生成器是flow_from_directory这类迭代器遍历它比较麻烦因为iterator不能被方便地按下标访问。我的经验是干脆用类属性直接拿索引class_names [cls_name for cls_name in val_gen.class_indices] # val_gen.class_indices 是一个字典 {类名: 索引} # y_true可以由文件名列表映射得到但更简单的方式是训练前就另存一份实际上在真实项目中我很少依赖从generator里现场抽y_true。更常见的做法是创建验证集时顺手把y_true以npy形式落盘。这样回调里直接np.load(val_y_true.npy)一劳永逸。预测阶段只需要保证model.predict(val_gen)的输出顺序和这份npy的样本顺序一致只要shuffleFalse并且reset过一次顺序就是可复现的。3.3 完整回调代码单输出分类模型下面这段是我在多个项目里复用过的回调封装把它传给model.fit的callbacks参数即可。import numpy as np from tensorflow import keras class ValidationPredictionCallback(keras.callbacks.Callback): def __init__(self, val_gen, y_trueNone, capture_every1): super().__init__() self.val_gen val_gen self.y_true_ready y_true self.capture_every capture_every self.records [] # 每个 captured epoch 记录一组结果 def get_y_true(self, steps): if self.y_true_ready is not None: return self.y_true_ready collected [] for i in range(steps): item self.val_gen[i] y_batch item[1] collected.append(np.asarray(y_batch)) return np.concatenate(collected, axis0) def on_epoch_end(self, epoch, logsNone): if (epoch 1) % self.capture_every ! 0: return if hasattr(self.val_gen, shuffle): old_shuffle self.val_gen.shuffle self.val_gen.shuffle False if hasattr(self.val_gen, reset): self.val_gen.reset() steps getattr(self, validation_steps, None) if self.params.get(validation_steps): steps self.params[validation_steps] if steps is None: steps len(self.val_gen) y_pred self.model.predict(self.val_gen, stepssteps, verbose0) y_true self.get_y_true(steps) self.records.append({ epoch: epoch, y_true: y_true, y_pred: y_pred, }) if hasattr(self.val_gen, shuffle): self.val_gen.shuffle old_shuffle用起来就是下面这个样子。train_gen ... # 你的训练生成器 val_gen ... # 验证生成器确认 shuffleFalse # 如果 val_gen 是 DirectoryIterator可以提前抽一次 # y_true_cache np.concatenate( # [val_gen[i][1] for i in range(len(val_gen))] # ) callback ValidationPredictionCallback(val_gen, y_true_cache) model.fit( train_gen, epochs50, validation_dataval_gen, callbacks[callback] ) # 训练结束后取出最后一次捕获的预测 result callback.records[-1] y_true, y_pred result[y_true], result[y_pred]这里getattr(self, validation_steps, None)的写法是为了适配fit里显式设置validation_steps的情况如果你在fit时限制了验证只用前N个batch那predict也应该只跑前N个batch否则预测出的样本量会多于y_true的样本量无法配对。3.4 多输出模型怎么处理如果你的模型有多个输出头例如“分类 回归”或“多标签分类”model.predict返回的就不是单个数组而是一个Python listlist的每一项对应一个输出头的预测。y_true那边同理生成器里的y也可能是一个list或dict。我建议在回调里做一次统一的规范化判断。def _to_list_or_tuple(data): return data if isinstance(data, (list, tuple)) else [data] y_pred_list _to_list_or_tuple(y_pred) y_true_list _to_list_or_tuple(y_true) for idx, (yp, yt) in enumerate(zip(y_pred_list, y_true_list)): # 按第 idx 个输出头的真实标签和预测做后续分析 ...多标签分类还有一个额外的注意点如果每个输出节点用sigmoid激活那么在做混淆矩阵之前需要先决定一个阈值通常是0.5把概率转成0/1标签。如果所有输出头共享一个损失且只有一个主分类输出逻辑会简单不少但代码结构建议还是按list处理避免以后模型结构变了还要改回调。3.5 别让每个epoch都拖慢速度新手最容易犯的错误是让回调在每个epoch结束都跑一遍完整predict对于大验证集而言这会把训练时间拉长好几倍。我实际处理的办法是加一个capture_every参数只在指定的epoch执行预测比如每隔5个epoch或者只在最后一个epoch预测一次。还有一个细节model.predict之前建议把verbose0否则每个epoch结束终端会刷一屏进度条日志基本没法看。等到训练结束想真正打印完整进度时再单独调用一次model.predict并设置verbose1。另外预测时batch_size不需要刻意调小保持和训练的batch_size一致即可太大反而容易显存占用过高。4. 更省心的替代validation_data直接传元组4.1 直接吃内存的方案如果你的验证集整份能放进内存其实根本不需要从generator里折腾y_true。直接用下面这种形式model.fit( train_gen, epochs50, validation_data(x_val, y_val), callbacks[callback] )y_val就是y_true铁板钉钉顺序天然对齐。配合回调时把ValidationPredictionCallback里的y_true直接设为y_val剩下的预测逻辑完全不用变。验证集是元组时fit不会走generator的索引逻辑没有reset、shuffle这些破事代码最稳。唯一前提你的验证数据量别大到内存扛不住。我一般把阈值定在2到3GB内存以内超过这个量就走生成器方案。4.2 把现有生成器内容落成numpy如果你现成的验证集是一个Sequence或DirectoryIterator但你又想用元组方案可以一次性把生成器里所有数据拼出来。代码不复杂steps len(val_gen) x_all, y_all [], [] for i in range(steps): item val_gen[i] x_all.append(np.asarray(item[0])) y_all.append(np.asarray(item[1])) x_val np.concatenate(x_all, axis0) y_val np.concatenate(y_all, axis0)要注意两点一是val_gen[i]要确保返回顺序是固定的也就是shuffle要关掉二是如果样本总数量不是batch_size的整数倍最后一次索引返回的batch可能不足但np.concatenate对第0维拼接没问题这里几乎不会踩坑。落成数组之后建议顺便把原始未归一化的样本路径或样本本身也留下来一份方便后面画bad case时用原始像素。这个方案在工程上的意义是把“从generator抽y_true”和“预测对齐”这两件事彻底解耦。数组落成后你想预测几次就预测几次每次model.predict(x_val)都能拿到和y_val严格对齐的预测。预测过程变成了纯numpy输入不再依赖生成器内部状态这是最稳定的一条路。5. 实战中的坑与排查5.1 shuffle导致的错位这是最常遇见的坑。现象是混淆矩阵画出来乱糟糟对角线上的数字分布完全不符合逻辑。排查思路很简单先打印y_true前10个值和y_pred前10个argmax值肉眼看一看是否来自同一批样本。如果发现顺序明显对不上优先检查验证生成器创建时有没有设shuffleTrue。解决方案也很直接设成Falsereset一次重新预测。5.2 Sequence的reset状态自定义Sequence如果实现了on_epoch_end很常见会在里面做索引重排。哪怕你创建时没写shuffle框架可能也会在fit的epoch切换时调用它。所以回调里预测前一定要reset()并且你的__getitem__要用当前的索引数组。我踩过一次第一个epoch拿到的y_true顺序正常第二个epoch开始顺序就变了原因就是没有在on_epoch_end里屏蔽shuffle逻辑。5.3 验证预处理不能有随机性验证集的ImageDataGenerator参数里如果带了horizontal_flipTrue、brightness_range这类随机增强每次predict的结果都会不同。验证阶段必须把随机性去掉只保留标准化缩放否则预测概率会不稳定同一批样本预测两次结果都不一样。这不是bug是数据增强逻辑污染了验证流程。记住训练生成器可以增强验证生成器必须确定性输出。5.4 类别顺序映射flow_from_directory的类别索引是按子目录名的字典序自动生成的不是按你在数据集里的某个“习惯顺序”。画混淆矩阵和分类报告时必须从val_gen.class_indices里取类别名映射别自己脑补类名顺序。把class_indices打印出来看一眼和你的预期对齐再继续。5.5 validation_steps与batch不整除若验证集样本数不是batch_size的整数倍生成器最后一次会吐出一个不满batch的批次。predict和手工收集y_true时需要保持相同的steps数。如果fit里设置过validation_steps等于某个固定值N那么predict也必须只跑N个steps并且y_true只取前N个batch两边才能对齐。因为总样本量不同出现样本数不一致时基本都出在这个地方。5.6 老版本fit_generator与新版本fit的区别Keras 2.x时代用model.fit_generator而TensorFlow 2.6已经彻底移除该接口统一改为model.fit(..., validation_datagenerator)。老教程里的steps_per_epoch和validation_steps参数在新版里仍然保留。如果你看到报错“AttributeError: model has no attribute fit_generator”把fit_generator改成fit传生成器即可回调方案完全不变。另外新版fit如果传generatorvalidation_split参数不可用这是接口限制别纠结。我将这些高频坑整理成速查表排查问题时直接对照。现象可能原因处理办法混淆矩阵对角线完全对不上验证生成器shuffle为True关闭shuffle并reset不同epoch预测结果顺序不同Sequence的on_epoch_end重排索引每次预测前reset同一批数据两次预测结果不一致验证预处理包含随机增强去掉随机增强只保留标准化类别标题错位class_indices映射顺序没查打印class_indices对齐y_pred和y_true样本数不等validation_steps限制不一致统一predict和y_true收集的steps6. 收尾落地混淆矩阵、分类报告、bad case6.1 一次性输出评估报表拿到对齐的y_true和y_pred之后后面就是标准的sklearn流程了。以多分类为例from sklearn.metrics import confusion_matrix, classification_report y_pred_cls np.argmax(y_pred, axis1) report classification_report( y_true, y_pred_cls, target_namesclass_names, digits4, zero_division0, ) print(report)这段代码尽量在训练结束后单独跑不要放进on_epoch_end里因为打印一份全量报表会拖慢训练。我通常是训练完成后再跑一次callback.records[-1]生成最终报表。6.2 混淆矩阵热力图画混淆矩阵时我建议用matplotlib的imshow简单实现不额外依赖第三方库。核心代码如下import matplotlib.pyplot as plt import numpy as np cm confusion_matrix(y_true, y_pred_cls) plt.figure(figsize(8, 6)) plt.imshow(cm, interpolationnearest, cmapplt.cm.Blues) plt.colorbar() num_classes len(class_names) tick_marks np.arange(num_classes) plt.xticks(tick_marks, class_names, rotation45) plt.yticks(tick_marks, class_names) thresh cm.max() / 2 for i in range(num_classes): for j in range(num_classes): plt.text(j, i, format(cm[i, j], d), hacenter, vacenter, colorwhite if cm[i, j] thresh else black) plt.ylabel(True label) plt.xlabel(Predicted label) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150)数字颜色自动适应单元格背景深浅这个细节虽然简单但很实用不然深色背景配黑色数字根本看不清。6.3 保存错误样本bad case分析是模型迭代里非常有价值的一步。把预测错误的样本按概率差排序挑出最困惑的那几张能直接指出数据标注或类别定义的矛盾。mismatch_idx np.where(y_true ! y_pred_cls)[0] confidences np.max(y_pred, axis1)[mismatch_idx] sorted_idx mismatch_idx[np.argsort(confidences)] # 如果验证集是一份numpy数组可直接切片 for rank, idx in enumerate(sorted_idx[:16]): image x_val[idx] # 如果之前做了归一化需要还原到0-255再保存 if image.max() 1.0: image (image * 255.0).astype(np.uint8) true_label class_names[y_true[idx]] pred_label class_names[y_pred_cls[idx]] plt.subplot(4, 4, rank 1) plt.imshow(image) plt.title(ftr:{true_label} pre:{pred_label}, fontsize8) plt.axis(off) plt.savefig(bad_cases.png, dpi150)注意一个常被忽略的点如果验证数据来自flow_from_directory迭代器返回的是已经归一化的float数组直接imshow会是一片颜色失真。这时要么把标准化参数逆运算还原要么在创建生成器时用一个额外的原始目录做bad case回调。我通常更推荐后者因为还原归一化涉及均值/方差或scale参数容易出错且没有太大价值。预留一份未预处理的原始验证图像路径列表再按mismatch_idx索引回去读图是最干净的做法。6.4 基于连续概率的其他分析除了混淆矩阵如果业务需要更精细的阈值控制可以基于y_pred概率直接算每类的AUC或PR曲线。二分类场景下可以先把y_pred取到某一列概率再配合sklearn.metrics.roc_curve画ROC。得到连续概率的一个额外好处是你可以观察到模型在哪些类别上区分度差从而决定要不要合并类别或者增加样本。这比只盯着acc判断模型健壮性可靠得多。我个人在实际项目里最常用的组合是验证集小而内存够时直接用validation_data(x_val, y_val)验证集大时用自定义Sequence配合回调同时把y_true以npy落盘。这两条路都足够稳定不依赖生成器内部状态也不需要在回调里做复杂的索引对齐。后来所有需要出评估报表的项目我都是先跑训练再用这套方案在最后几个epoch抓一轮完整预测输出混淆矩阵、分类报告和bad case图直接交给业务验收省了无数来回沟通。希望这篇文章能帮你把这条链路的坑一次填平。