YOLO 系列(包括 YOLOv4-CSP,YOLOv4 等)的探测器 detector,它们的损失函数由 3* 部分组成。如果这 3 部分的每一个部分都使用一个指标,将得到 3 个独立的指标。这会使得训练模型变得更困难(比如一个指标变好,另一个指标却变坏的情况)。
正如吴恩达教授在《机器学习策略》(Introduction to Machine Learning Strategy)课程中所提到的,当一个模型有多个指标时,应该尽量把它们合并成一个最重要的优化指标,才能使得模型有明确的优化方向。而 COCO 数据集的 average precision 指标,就是最合适的优化指标。
COCO 数据集的 AP(average precision) 指标,实际上是对 average precision 计算了两次平均值,主要算法有如下 3 个步骤:
COCO 数据集声明不区分 AP 和 mAP(mean average precision),统一使用 AP 这个词,由使用者根据使用场景自行区分是 AP 还是 mAP。如果想要区分的话,简单来说,对单个类别就是 AP,求了两次平均值之后就是 mAP(mean average precision)。
下面我们用 Keras/TensorFlow 2.8 来创建 COCO 的 AP 指标,并详细解释其算法原理。
Keras 中有一个专门用于指标的类: tf.keras.metrics.Metric。可以用它来创建一个类 MeanAveragePrecision,来计算 AP 指标。
指标类 tf.keras.metrics.Metric 中,有 3 个主要的方法,update_state、 result 和 reset_state。这 3 个方法的具体作用是:
在代码中的指标 MeanAveragePrecision 和 3 个方法如下图。
对于 COCO 的 AP 来说,主要用到类别置信度和 IoU 这两个数据,所以方法 update_state 将主要更新这 2 个数据,而方法 result 也将主要使用这 2 个数据来计算 AP。
下面的程序伪代码中,会经常提到 4 个词语,这里先明确一下这 4 个词语的定义:
用指标类 tf.keras.metrics.Metric 创建的是完整状态的指标 stateful metric,意思是可以用它来计算很复杂的指标。通常需要先创建相关的状态量 states。
对于 COCO 的 AP 指标,需要创建 3 个状态量 latest_positive_bboxes、 labels_quantity_per_image 和 showed_up_classes,3 个状态量类型均为 tf.Variable。
在每个 batch 计算完成之后,要用方法 update_state 对 3 个状态量进行更新。
下面是程序的伪代码,用到一些变量的名字,和程序中的变量名字相同,以方便阅读代码。
从标签中提取出现过的类别,得到张量 showed_up_categories_label,张量形状为 (x,),里面存放的是出现过的类别编号,表示有 x 个类别出现在了这批标签中。
1.1 showed_up_categories_index_label = tf.experimental.numpy.isclose(objectness_label, 1)
1.2 showed_up_categories_label = tf.argmax(y_true[…, 1: 81], axis=-1),showed_up_categories_label 形状为 (batch_size, *Feature_Map_px, 3)。在代码中会以 *Feature_Map_px 表示 P5, P4, P3 的特征图大小,一般 P5 特征图大小为(19, 19)。
1.3 showed_up_categories_label = showed_up_categories_label[showed_up_categories_index_label],showed_up_categories_label 形状为 (x, )。
注意不能仅使用 argmax,而是必须借助上面的第 1.3 步骤,使用 showed_up_categories_index_label 作为索引。因为在没有标签时,argmax 也会得出一个值 0,而这个 0 并不表示该物体框的类别为 0 。
从预测结果中提取出现过的类别,得到张量 showed_up_categories_pred,张量形状为 (y,),里面存放的是出现过的类别编号,表示有 y 个类别出现在了这批预测结果中。
将上面 2 个张量改变形状为 (1, -1),然后用 tf.sets.union 求并集,得到一个 sparse tensor, 将其转换为 tf.tensor,得到张量 showed_up_categories_batch,张量形状为 (categories_batch,)。
遍历 showed_up_categories_batch,对每一个出现过的类别 category,如果之前还没有出现过,则设置 showed_up_classes[category].assign(True)。
3 大主要操作步骤如下:
第一重循环,遍历批次结果数据中的每一张图片(方法 update_state 中接收的是单个批次的结果,所以要遍历批次中的每张图片),对每一张图片执行下面 2 步操作。
对于单张图片的标签 one_label 和预测结果 one_pred,分别构造相应的张量。
2.1 对于标签:构造张量 positives_index_label,positives_label 和 category_label。
positives_index_label 形状为 (19, 19, 3),是一个布尔张量,是标签正样本的索引张量,即只有 objectness 等于 1 的 bboxes,其对应布尔值才会为 True。
为了方便描述,这里的形状只以 YOLOv4-CSP 模型的 P5 形状为例。下面也是如此。
创建标签的正样本张量:
positives_label = tf.where(condition=positives_index_label[…, tf.newaxis], x=one_label, y=-8.0],positives_label 形状为 (19, 19, 3,85), 里面只有正样本信息,其它位置的数值为 -8。(使用 -8 而没有使用 -1,是为了便于和 axis=-1 混淆,方便搜索)
创建标签的类别张量:
category_label = tf.math.argmax(positives_label[…, 1: 81], axis=-1),category_label 形状为 (19, 19, 3),代表每一个正样本的类别。在不是正样本的位置,数值为 0。因为这个 0 会和类别编号 0 发生混淆,所以下面要用 tf.where 再次进行转换,使得在不是正样本的位置,其数值为 -8。
category_label = tf.where(condition=positives_index_label, x=category_label, y=-8)
2.2 对于预测结果:构造张量 positives_index_pred,positives_pred 和 category_pred。
positives_index_pred 形状为 (19, 19, 3),是一个布尔张量,是预测结果正样本的索引张量,即只有 objectness 和类别置信度都大于阈值的 bboxes,其对应布尔值才会为 True。
构造另外 2 个张量的方法,和构造对应的标签张量方法相同。这里不再赘述。
第二重循环,遍历 80 个类别,区分 4 种情况,更新状态值。
先创建标签类别的布尔值 category_bool_any_label,和预测结果的布尔值 category_bool_any_pred,两者均为标量型张量,如果有任何一个物体框内物体属于当前类别,则对应布尔值为 True:
category_bool_label = tf.experimental.numpy.is_close(category_label, category)
category_bool_any_label = tf.reduce_any(category_bool_label)
category_bool_pred = tf.experimental.numpy.is_close(category_pred, category)
category_bool_any_pred = tf.reduce_any(category_bool_pred)
对每一个类别,都要区分 4 种情况,计算得到两个张量 one_image_positive_bboxes 和 one_image_category_labels_quantity,分别用来更新两个状态量 latest_positive_bboxes 和 labels_quantity_per_image。
对 4 种情况构建布尔张量:
情况 a :标签和预测结果中,都没有该类别。无须更新状态。
情况 b :预测结果中没有该类别,但是标签中有该类别。布尔张量为 scenario_b = tf.logical_and(~category_bool_any_pred, category_bool_any_label)。
此时需要提取预测结果的类别置信度和 IoU,且类别置信度和 IoU 都为 0。另外还需要提取标签数量。
情况 c :预测结果中有该类别,标签没有该类别。布尔张量为 scenario_c = tf.logical_and(category_bool_any_pred, ~category_bool_any_label)。
此时需要提取预测结果的类别置信度和 IoU。而因为没有标签,IoU 为0。另外还需要提取标签数量 0。
情况 d :预测结果和标签中都有该类别,布尔张量为 scenario_d = tf.logical_and(category_bool_any_pred, category_bool_any_label)。
此时需要提取预测结果的类别置信度和 IoU。IoU 要经过计算得到。另外还需要提取标签数量。
只有在情况 b,c,d 时,才需要更新 2 个状态量,所以先要判断是否处在情况 b,c,d 下,再决定是否执行后续步骤。
under_scenarios_bc = tf.logical_or(scenario_b, scenario_c)
under_scenarios_bcd = tf.logical_or(under_scenarios_bc, scenario_d)
if under_scenarios_bcd:
提取 b,c,d 三种情况的置信度和 IoU,更新另外 2 个状态量。
对于每一个类别来说,a,b,c,d 情况不会同时发生,只可能出现其中的一种,因为这 4 种情况是互斥的。
下面是 b,c,d 情况下,详细的操作步骤。
情况 b:
one_image_positive_bboxes = tf.zeros(shape=(BBOXES_PER_IMAGE, 2)),可以直接把 one_image_positive_bboxes 作为输出张量。
情况 c,需要提取类别置信度和 IoU,有 5 个操作步骤:
先获取当前情况的正样本:
scenario_c_positives_pred = positives_pred[category_bool_pred],scenario_c_positives_pred 形状为 (scenario_c_bboxes, 85)。
再获取当前情况的类别置信度:
scenario_c_classification_confidence_pred = tf.reduce_max(scenario_c_positives_pred[:, 1: 81]),scenario_c_positives_pred 形状为 (scenario_c_bboxes,)。
比较 scenario_c_bboxes 和 bboxes_per_image 的大小,区分两种情况:
3.1 如果 scenario_c_bboxes < bboxes_per_image:
使用 tf.pad,对 scenario_c_classification_confidence_pred 尾部进行补零,得到新的张量 one_image_positive_bboxes,其形状变为 (bboxes_per_image,) 。
3.2 如果 scenario_c_bboxes ≥ bboxes_per_image:
按照置信度从大到小的顺序,对 scenario_c_classification_confidence_pred 进行排序,得到 scenario_c_sorted_pred,其形状为 (scenario_c_bboxes,)。
之所以要进行排序,是因为在最后计算 AP 时,需要按置信度从大到小进行排序后,才会计算 AP。所以当前步骤在筛选 bboxes 时,也就应该把置信度大的 bboxes 筛选出来。
保留置信度较大的 bboxes,即 one_image_positive_bboxes = scenario_c_sorted_pred[:bboxes_per_image],one_image_positive_bboxes 形状为 (bboxes_per_image,) 。
获取 IoU(情况 c 的 IoU 为 0,情况 d 的 IoU 需要经过计算得到)。
因为标签中没有这个类别,所以 IoU 为 0,可以使用 scenario_c_ious_pred = tf.zeros_like(one_image_positive_bboxes).
将置信度和 IoU 进行堆叠 stack。
one_image_positive_bboxes = tf.stack(values=[one_image_positive_bboxes, scenario_c_ious_pred], axis=1),one_image_positive_bboxes 形状为 (bboxes_per_image, 2)。
情况 d:此时需要计算 IoU,6 个步骤如下(因为只有和标签 IoU 最大的预测结果 bbox,才认为是命中了该标签,所以需要对每一个标签,同时和所有的预测结果 bboxes 计算 IoU):
对于预测结果:取出属于当前类别的 bboxes,把每个 bbox 信息填入全零数组 bboxes_iou_pred,其它多余的位置保持数值为 0。bboxes_iou_pred = tf.where(condition=category_bool_pred, x=positives_pred[…, -4:], y=0],bboxes_iou_pred 形状为 (19, 19, 3,4)。
对于标签:取出属于当前类别的 bboxes,即 bboxes_category_label = positives_label[…, -4:][category_bool_label],bboxes_category_label 形状为 (scenario_d_bboxes_label,4)。
对 bboxes_category_label,按照面积从小到大的顺序进行排序(体现着重小物体的思想,善于识别小物体的模型,其指标将越好),得到 sorted_bboxes_label,形状为 (scenario_d_bboxes_label, 4)。
建立张量 one_image_positive_bboxes = tf.zeros(shape=(BBOXES_PER_IMAGE, 2))。设置计数变量 new_bboxes_quantity = 0.
遍历 sorted_bboxes_label,对每一个标签 bbox,执行如下 3 个操作:
5.1 把其信息填入全 1 张量 bbox_iou_label(即张量最后一个维度,所有长度为 4 的向量,写的都是同一个 bbox 的信息),bboxes_iou_pred 形状为 (19, 19, 3, 4)。
5.2 用 bbox_iou_label 和 bboxes_iou_pred 计算 ious_category,ious_category 张量形状为 (19, 19, 3)。
5.3 如果最大 IoU 大于阈值 0.5,则认为预测结果中对应的 bbox 命中了标签,做 2 个操作:
5.3.1 将该 bbox 的类别置信度和 IoU 记录到张量 one_image_positive_bboxes 中(用 tf.concat)。
5.3.2 从 bboxes_iou_pred 去掉该 bbox(用 tf.where),后续计算 IoU 不需要再考虑这个 bbox。
5.3.3 new_bboxes_quantity += 1.
5.4 new_bboxes_quantity 等于 bboxes_per_image 时,停止记录新的 bboxes。
遍历 sorted_bboxes_label 完成之后,如果 bboxes_iou_pred 有剩余的 bboxes,说明这些 bboxes 没有命中任何标签。如果还满足条件 new_bboxes_quantity < BBOXES_PER_IMAGE,则需要将剩下 bboxes 的 IoU 设为 0,并记录到张量 one_image_positive_bboxes 中。
令剩余 bboxes 数量为 left_bboxes_quantity, 加到 one_image_positive_bboxes 后,总的 bboxes 数量为 scenario_d_bboxes = new_bboxes_quantity + left_bboxes_quantity。
求出剩余的 bboxes,得到 left_bboxes_pred, 形状为 (left_bboxes_quantity, 85)。
left_bboxes_confidence_pred = tf.reduce_max(left_bboxes_pred[:, 1: 81])
6.1 如果 scenario_d_bboxes > bboxes_per_image,则需要进行排序:
6.1.1 按照置信度从大到小的顺序,对 left_bboxes_confidence_pred 进行排序,得到 left_bboxes_sorted_confidence,其形状为 (left_bboxes_quantity,)。
之所以要进行排序,是因为在最后计算 AP 时,需要按置信度从大到小进行排序后,才会计算 AP。
6.1.2 vacant_seats = BBOXES_PER_IMAGE - new_bboxes_quantity
6.1.3 left_bboxes_confidence_pred = left_bboxes_sorted_confidence[:vacant_seats]。
6.2 如果 scenario_d_bboxes ≤ bboxes_per_image,则无须进行排序,可以直接使用left_bboxes_confidence_pred。
6.3 给 left_bboxes_confidence_pred 加上全为 0 的 IoU (使用 tf.stack),得到 left_positive_bboxes_pred,形状为(vacant_seats, 2)。
6.4 将 left_positive_bboxes_pred 和 one_image_positive_bboxes 进行拼接 concatenate,然后保留最后 bboxes_per_image 个 bboxes,即 one_image_positive_bboxes = one_image_positive_bboxes[-bboxes_per_image:]。
计算标签数量,得到整数 one_image_category_labels_quantity = tf.where(category_bool_label).shape[0]。
最后更新 2 个状态量,更新原则为先进先出 FIFO。
1. 用张量 one_image_positive_bboxes 更新状态量 latest_positive_bboxes。
latest_positive_bboxes 形状为 (CLASSES, latest_related_images, bboxes_per_image, 2)。
latest_positive_bboxes[category, 1:].assign(latest_positive_bboxes[category, :-1])
latest_positive_bboxes[category, 0].assign(one_image_positive_bboxes)
2. 用整数 one_image_category_labels_quantity 更新状态量 labels_quantity_per_image。
labels_quantity_per_image 形状为 (CLASSES, latest_related_images)。
labels_quantity_per_image[category, 1:].assign(labels_quantity_per_image[category, :-1])
labels_quantity_per_image[category, 0].assign(one_image_category_labels_quantity)
方法 result 的作用,是使用状态量来计算指标。
需要注意的是,YOLO-v4-CSP 是多输出模型,有 P3, P4, P5 这 3 个输出,所以在每批次数据计算完成之后,会在这 3 个输出上分别计算一次指标。可以设置跳过 P4, P5,只计算 P3 的指标。
在方法 result 中,根据自顶向下的程序结构,顶层的程序只有 2 个大步骤:
在上面的步骤 1.1 中,计算单个类别的 average_precision 时,如果 labels_quantity = 0,直接设 AP = 0。
而如果 labels_quantity 不等于 0,则需要计算 AP,有如下 2 个操作:
计算 recall_precisions。
1.1 创建空的张量 recall_precisions,形状为 (1,)。其索引为 recall,设置初始 recall = 0, recall_precisions[0] = 1。后续每一个 recall 值都对应一个 precision 值。
1.2 设置 true_positives = 0,false_positives = 0。
1.3 从 latest_positive_bboxes 中,取出当前类别的所有 bboxes,形状是 (bboxes_per_image * latest_related_images, 2)。每个 bbox 包含 2 个信息:类别置信度和 IoU。
1.4 按照类别置信度,进行由大到小的排序,得到张量 sorted_bboxes_category。
1.5 遍历 sorted_bboxes_category 中的所有 bboxes,计算得到 recall_precisions (使用类别置信度进行过滤。如果置信度为 0,说明它不是模型的预测结果,不应该参与计算 AP)。
1.5.1 如果该 bbox 的 IoU 大于当前的 IoU 阈值,则认为该预测正样本命中,更新 2 个数值, true_positives += 1, recall += 1。
1.5.2 如果 IoU 小于阈值,则更新 1 个数值 false_positives += 1。
1.5.3 计算 precision = true_positives/(false_positives + true_positives),然后更新张量 recall_precisions,即 recall_precisions[recall] = precision 。
计算 AP。
遍历 sorted_bboxes_category 完成后,使用张量 recall_precisions,计算多个小梯形面积,累加所有小梯形的面积,得到 AP。
2.1 从 labels_quantity_per_image 中,获得 latest_images 个相关图片的标签 bboxes 总数 labels_quantity,而 1/labels_quantity 则是小梯形的高度 trapezoid_height。
2.2 从 recall_precisions 的第 0 个索引位置开始,计算小梯形面积 (recall_precisions[0] + recall_precisions[1]) * trapezoid_height / 2。直到 recall_precisions 的倒数第 2 个索引位置结束。
2.3 把所有的小梯形面积累加起来,就得到该类别的 AP。
做了一个测试盒 testcase,盒子里放了 13 个单元测试。目前 AP 指标通过了盒子里全部的 13 个测试。
如果使用者需要改动这个指标,也应该用测试盒再测试一下,确保指标能正常运行。
对于企业用户,当然应该按照软件工程的要求,由软件测试团队进行专业的测试之后,才能使用。
测试盒程序的部分截图如下:
在使用这个 AP 指标文件时,注意以下 3 点:
yolo_v4_csp_model.compile(
run_eagerly=True,
metrics=average_precision,
loss=my_custom_loss,
optimizer=optimizer_adam)
代码已在 Github 开源,可以直接下载。→ 下载链接在此
一共有 2 个相关文件,指标文件 average_precision_metric.py 和测试盒文件 testcase_average_precision.py。