以前、以下記事で使用した、物体検出モデルの評価指標mAP(mean Average Precision)について、まとめてみました。pythonで実装したソースも掲載しました。
本記事に掲載したpython実装は、以下github上のソースcommon_ssd.pyに組み込んでます。
目次
- 1.mAPとは
- 2.検出矩形と正解矩形の重なり(IoU)
- 3.正検出(TP)、誤検出(FP)、未検出(FN)
- 4.Precision(適合率)、Recall(再現率)
- 5.mAPの算出
1. mAPとは
mAP(mean Average Precision)とは、以下です。
物体検出等のモデルの精度(以下がどの程度成り立つか)を数値化した評価指標
- 誤検出(背景を誤って検出)が少ない
- 未検出(検出対象を見逃し)が少ない
※値域は0~1、0=悪い、1=良い
誤検出の少なさを表す評価値として Precision(適合率)、未検出の少なさを表す評価値として Recall(再現率) がありますが、mAPは、この2つを組み合わせた評価指標となってます。名前にPrecisionだけが入っているので、誤検出の少なさ(Precision)だけを表すように見えますが、実際にはRecallも入ってます。未検出が多いとmAPは大きな値になりません。
Precision(適合率)、Recall(再現率)を計算するには、以下の数をカウントする必要があります。
- 正検出(TP(True Positive)): 検出対象を正しく検出
- 誤検出(FP(False Positive)): 背景を誤って検出
- 未検出(FN(False Negative)): 検出対象を見逃し
なお、画像分類の場合は、もう1つ「 背景を検出しなかった(TN(True Negative)) 」も加えて算出される Accuracy(正解率) という評価値もあるのですが、物体検出の場合、背景を適切にカウントする方法がないこともあって、これは使われません。
物体検出の結果(出力)は、以下2つです。
- 位置&大きさ(外接矩形(Bounding Box))
- クラス(種別)
正検出、と言うためには、上記が2つとも「正しい」必要があります。
クラス(種別)は、正解との一致/不一致で見ればいいので簡単ですが、位置&大きさ(外接矩形(Bounding Box))は、正解矩形と完全一致でないと「正しい」と判定されないのでは厳しすぎます。
そこで、 検出矩形と正解矩形の重なり(IoU) が一定以上であれば「正しい」と判定する、ということが行われます。
というわけで、mAPを算出するために必要な値が色々出てきましたが、これらを以下順に説明します。
- 2.検出矩形と正解矩形の重なり(IoU)
- 3.正検出(TP)、誤検出(FP)、未検出(FN)
- 4.Precision(適合率)、Recall(再現率)
- 5.mAPの算出
2. 検出矩形と正解矩形の重なり(IoU)
IoU(Intersection over Union)とは、以下です。
和集合に対する積集合の割合
※値域は0~1、0=重なりなし、1=完全一致
今回は、検出矩形と正解矩形の重なりがほしいので、集合が矩形の面積に置き換わります。
このIoUですが、分母が和集合になっていることもあって、図形を見た時の人の感覚よりもかなり低い値になる傾向があります。
例えば、下図左は、100 x 100の2つの矩形を、18だけずらしたときの図です。感覚的には、かなり重なりがあるように見えますが、IoU≒0.5です。
後述の5.2節で、検出結果毎に正しいかどうかの判定で、IoUの閾値処理を行うのですが、そこで、閾値=0.9などとしてしまうと、上図右のような、見た目ほぼ完全一致の状態しか許容しない、かなり厳しい判定になってしまいます。
3. 正検出(TP)、誤検出(FP)、未検出(FN)
正検出(TP)、誤検出(FP)、未検出(FN)とは以下です。
- 正検出(TP(True Positive)): 検出対象を正しく検出
- 誤検出(FP(False Positive)): 背景を誤って検出
- 未検出(FN(False Negative)): 検出対象を見逃し
一般的に、物体検出モデルの検出有無は、Positive(陽性)/Negative(陰性)という単語で表し、
- Positive: 検出あり
- Negative: 検出なし
と解釈します。一方で、
- True: 正しい
- False: 誤り
です。なので、この2つを組み合わせて、上述のようになってます。
ここでは、TPやFPに対し、正検出、誤検出といった日本語を付けましたが、以下の日本語が一般的です。ただこれは、医学用語で取っつきづらいので、本記事では一般用語にしておきました。
- TP:真陽性
- FP:偽陽性
- FN:偽陰性
TP等を、ベン図上に描いてみました。このベン図は、評価データ内訳(検出対象/背景)の集合と、モデル検出結果の集合(検出あり/検出なし)を合わせた図です。ここには、 「正しい「検出なし」(TN(True Negative))」 も加えてますが、TNは、物体検出モデルの場合、背景を適切にカウントする方法がないので、物体検出モデルの評価には使われません。
ベン図上でのTP等の解釈は以下になります。
- TP: 「検出対象(正解)」 かつ 「検出あり(Positive)」 → 正しい「検出あり」
- FP: 「背景」 かつ 「検出あり(Positive)」 → 誤った「検出あり」(背景を誤って検出)
- FN: 「検出対象(正解)」 かつ 「検出なし(Negative)」 → 誤った「検出なし」(検出対象を見逃し)
TP等は、下表の混同行列(Confusion Matrix)で表すのが一般的です。これはこれで分かりやすいのですが、TP等を使ったPrecision/Recallを理解するには、少なくとも自分は、混同行列よりもベン図のほうが理解しやすかったので、本記事では、次のPrecision/Recallも、このベン図で表現してみました。
| 検出結果 → 評価データ内訳 ↓ |
検出あり | 検出なし |
|---|---|---|
| 検出対象(正解) | TP | FN |
| 背景 | FP | TN |
4. Precision(適合率)、Recall(再現率)
物体検出における、Precision(適合率)、Recall(再現率)とは以下です。
- Precision(適合率): 「検出あり」に対する、正しい「検出あり」の割合
→ 誤検出(背景を誤って検出)の少なさを表す - Recall(再現率):「検出対象(正解)」に対する、正しい「検出あり」の割合
→ 未検出(検出対象を見逃し)の少なさを表す
※値域は0~1、0=悪い、1=良い
「適合率」「再現率」といった日本語訳が取っつきづらいのと、英語のほうも、特にRecall(リコール)は、不具合品の回収、といった意味でよく使われており、未検出を想起させる用語ではないこともあって、用語自体がかなり覚えづらいですが、仕方がありません。
ベン図で表現すると以下になります。
「検出対象(正解)」「検出あり」どちらに対する割合か、が、混同行列よりは視覚的に把握しやすい、と個人的には感じてます。確率が得意な方なら、条件付き確率を想起させるベン図表記がわかりやすく感じるのではないかと思います。
5. mAPの算出
mAP(mean Average Precision)は、日本語に訳すと「適合率(Precision)の平均(average)の平均(mean)」となります。
「平均(mean)」は、クラス毎に算出したAP(Average Precision)の平均、という意味で、分かりやすいのですが、「平均(Average)」が分からない、APとは一体何なのか、という方は多いのではないかと思います。
そこでまず、AP(Average Precision)とは何なのか、を説明したのち、算出方法を説明します。
5.1 AP(Average Precision)とは
AP(Average Precision)とは、
複数の信頼度閾値$C_{n}$から得られた複数の検出結果毎に算出したPrecisionの平均(Recall差分を重みとした加重平均)
となります。このことを、データフロー風に図示したのが下図です。
ただの平均ではなく、Recall差分の加重平均とすることで、未検出も取り入れた評価値になってます。
ここで出てきた 信頼度 とは、以下です。
- モデル内部で算出されている値で、自分の検出結果にどの程度自信を持っているかを示す
- 出力の最終段で、信頼度を閾値と比較し、検出結果を出力するかどうかを決定
※値域は0~1、0=自信なし、1=自信あり
いいモデルであれば、どのような難しい入力画像に対しても、検出したものには自信を持っており(信頼度大)、信頼度閾値がどんな値であっても検出結果の数に大差がなく、$Precision、Recall$ は大きな値をキープします。
一方、悪いモデルは、難しい入力画像に対しては自信がなく曖昧な結果(信頼度小)を返すため、信頼度閾値の大小によって検出結果の数に差が生じ、$Precision、Recall$ の値が揺らぎがちです。閾値が小さいと誤検出が多くなり、閾値が大きいと未検出が多くなる、というトレードオフが強く出てしまいます。
複数の信頼度閾値$C_{n}$から得られた検出結果の $Precision$ の「平均」を取ることで、信頼度閾値にモデルの検出結果がどの程度左右されるか(左右されないモデルがいいモデル)を数値化し、モデルの良し悪しを測ってます。
5.2 複数の信頼度閾値からのPrecision,Recall算出
以下を順を追って説明します。
- (a)「複数の信頼度閾値$C_{n}$から得られた複数の検出結果」の獲得方法
- (b) 検出結果毎に、正しいかどうかの判定
- (c)「信頼度閾値の検出結果」毎に、TP,FP,Precision,Recall算出
(a) 「複数の信頼度閾値Cnから得られた複数の検出結果」の獲得方法
まずは、「複数の信頼度閾値$C_{n}$から得られた複数の検出結果」の獲得方法です。
これを真面目にやろうとすると、信頼度閾値$C_{n}$を変えながら物体検出モデルを複数回実行することになるかと思いますが、時間がかかって非効率ということで、もう少し効率的な以下方法が取られています。
信頼度閾値=min(通常の物体検出で参照するDefault閾値)で得られた検出結果を、信頼度が大きい順にソートし、
$$信頼度閾値 C_{n} の検出結果=上位n位までの検出結果$$
信頼度閾値$C_{n}$を変えながら物体検出モデルを複数回実行する方法と比べると、閾値を任意に指定できない、同じ閾値から複数の違う検出結果が得られることがある、などがあり、完全に等価な方法ではないものの、物体検出モデルを1回実行するだけでやりたいことができる、効率的な方法です。
以降の算出方法の説明は、上表の検出結果例を使って行います。
この結果例は、以下から拝借したデータ例です(画像、クラス、外接矩形は適当値を付与)。
(b) 検出結果毎に、正しいかどうかの判定
次に、検出結果毎に、正しいかどうかの判定を行います。
これは、検出結果に含まれるクラス、外接矩形(Bounding Box)で判定します。
以下2つをともに満たす場合のみ「正しい」と判定します。
- 検出結果のクラス=正解クラス
- 検出結果の外接矩形(Bounding Box)と正解矩形のIoU(※2章参照) > IoU閾値
比較する正解データ(正解クラス、正解矩形)は、各正解データ(画像&クラスが検出結果と同じもの)の中からIoU最大のデータです。なお、一度参照された正解データは、重複参照されないようにします。
正しいかどうかの判定を実施した後の結果例が下図です(※正解データ例は省略)。
一番右の列「正しい?」が判定結果例です
(「T」:正しい、「F」:誤り、IoU閾値=0.5)。
(c)「信頼度閾値の検出結果」毎に、TP,FP,Precision,Recall算出
次に、「$信頼度閾値 C_{n} の検出結果$」毎に、TP,FPを求め、そこからPrecision、Recallを算出します。
4章で示した数式には、Recallの分母にFNが入ってますが、4章掲載のベン図から明らかなように
- Recallの分母(TP+FN)=「検出対象(正解)」の数(正解矩形数)
であり、これはFNを求めなくても、アノテーション結果から得られる値なので、FNの算出は不要です。
TP、FPの算出方法は以下です。
- TP:列「正しい?」の「T」(正しい)の数をカウント
- FP:列「正しい?」の「F」(誤り)の数をカウント
Precision、Recallは、4章で示した以下式です。
$$
Precision = \frac{TP}{「検出あり」の数(検出結果数)(=TP+FP)}
$$
$$
Recall = \frac{TP}{「検出対象(正解)」の数(正解矩形数)(=TP+FN)}
$$
TP、FP、Precision、Recall算出結果例を下表に示します。
下表例では、$「検出対象(正解)」の数(正解矩形数)=7$ としています。
下表の列「検出結果数」から右の列は、信頼度閾値Cnの検出結果集合に対する集計値(緑枠の例参照)となっているので、要注意です。個々の検出結果に対する値、と見てしまうと、意味が分からなくなります。
5.3 AP算出、mAP算出
5.2節で得られた複数のPrecision、Recallから、APを算出します。
APの数式を、5.1節で述べた引用とともに下記に示します。
複数の信頼度閾値 $C_{n}$ から得られた複数の検出結果毎に算出したPrecisionの平均(Recall差分を重みとした加重平均)
$$ AP = \int_{0}^{1}P(r)dr $$
※$P(r):Recall=rのときのPrecision値$、 $dr:Recall差分$
横軸をRecall、縦軸をPrecisionとして、5.2節で算出したPrecion、Recallの組をプロットし、それらを結んだ線が、上式の関数$P(r)$に相当します(下図例の青線)。これをPR曲線(折れ線ですが)といい、PR曲線を積分した値がAPです。
ただ、実際のAP算出には、$P(r)$そのものではなく、右肩下がりになるように補正したオレンジ線を積分した値が使われます。このあたりは理解が追いついておらず、なぜこれが使われるのかはよく分かってません(計算の簡略化?)。
上述の積分は、実際には離散値の下式で行います。
$$ AP=\sum_{r=0}^{1} P(r)補正値 * Δr $$
※$Δr: Recall差分$
上表例だと、
$$ AP≒(1.0・0.143) + (1.0・0.143) + (1.0・0.143) + (1.0・0.143) + (1.0・0.143) + (0.875・0.143) + (0.875・0.143) ≒ 0.964$$
となります。
上記までで、1つのクラスに対するAP($AP_{class}$)が算出できます。
これをクラス毎に行い、平均を取った値がmAPになります。
$$ mAP = \frac{1}{class数} \sum_{class} AP_{class}$$
例えば、
- $AP_{車(car)}=0.964$
- $AP_{ナンバープレート(number)}=0.387$
の場合、
$$ mAP = \frac{1}{2}(0.964 + 0.387) ≒ 0.675 $$
となります。
5.4 python実装
pythonで実装したソースは以下です。
以下github上のソースcommon_ssd.pyに組み込んでいるソースを抜粋しました。
# IoU(2章)
def iou(box1, box2) -> float:
"""IOU算出
Args:
box1 (ArrayLike): 矩形1[左上X, 左上Y, 右下X, 右下Y]
box2 (ArrayLike): 矩形2[左上X, 左上Y, 右下X, 右下Y]
Returns:
float: 矩形1,2のIoU
"""
# 積集合の面積算出
x1 = max(box1[0], box2[0])
y1 = max(box1[1], box2[1])
x2 = min(box1[2], box2[2])
y2 = min(box1[3], box2[3])
inter_area = max(0, x2 - x1) * max(0, y2 - y1) # 負値にならないようガード
# 和集合の面積算出
box1_area = (box1[2] - box1[0]) * (box1[3] - box1[1])
box2_area = (box2[2] - box2[0]) * (box2[3] - box2[1])
union_area = box1_area + box2_area - inter_area
# IoU=積集合の面積/和集合の面積(和集合の面積=0の場合は、IoU=0)
return inter_area / union_area if union_area != 0 else 0
# AP算出(5.1節、5.3節)
def compute_ap(recalls, precisions) -> float:
"""AP算出
Args:
recalls (ArrayLike): Recall
precisions (ArrayLike): Precision
Returns:
float: AP(Average Precision)
"""
# 両端点(0,0)(1,0)を追加
recalls = np.concatenate(([0.], recalls, [1.]))
precisions = np.concatenate(([0.], precisions, [0.]))
# P(r)補正値を算出(PR曲線が右肩下がりになるよう補正)
for i in range(len(precisions) - 1, 0, -1):
precisions[i - 1] = np.maximum(precisions[i - 1], precisions[i])
# Recall差分≠0 となるindexを抽出 ※Recall差分:隣接データとのRecallの差
# 例: recalls = [0.0, 0.3, 0.3, 0.4, 1.0]の場合、indices = [0,2,3]
indices = np.where(recalls[1:] != recalls[:-1])[0]
# AP = Σ P(r)補正値 * Δr (Δr:Recall差分、Δr≠0箇所のみをサンプリング)
ap = np.sum(precisions[indices + 1] * (recalls[indices + 1] - recalls[indices]))
return ap
# TP,FP,Precition,Recall算出(3章、4章、5.2節)、mAP算出(5.3節)
def evaluate_map(predictions:Dict[str,List], ground_truths:Dict[str,List], class_names:List[str], iou_threshold=0.5) -> Tuple[float, List[float]]:
""" mAP算出
Args:
predictions (Dict[str,List]) : 検出結果 ※str=画像ID、List=[外接矩形bbox,信頼度conf(score),クラス]
ground_truths (Dict[str,List]) : 正解 ※str=画像ID、List=[外接矩形bbox,クラス]
class_names (List[str]) : クラス名
iou_threshold (float, optional) : 検出結果=正解とみなすIoU下限閾値. Defaults to 0.5.
Returns:
Tuple[float, List[float]]: (mAP, クラス毎のAP)
"""
aps = [] # クラス毎のAP
for class_name in class_names:
# クラス毎にAP算出
true_positives = [] # TP(True Positive)
scores = [] # 信頼度conf
total_gts = 0 # 「検出対象(正解)」の数(正解矩形数)
gt_used = {} # 正解矩形の参照有無(重複参照を防止)
detections = [] # 検出結果. List[Tuple(画像ID,外接矩形bbox,信頼度conf(score))]
for image_id in predictions:
# 正解矩形数をカウント
gts = [g for g in ground_truths.get(image_id, []) if g[1] == class_name]
total_gts += len(gts)
# 正解矩形の参照有無フラグを初期化
gt_used[image_id] = [False] * len(gts)
# クラス=class_nameの検出結果を抽出
preds = [p for p in predictions[image_id] if p[2] == class_name]
for p in preds:
detections.append((image_id, p[0], p[1])) # (画像ID, 外接矩形bbox, 信頼度conf(score))
# 検出結果を信頼度が大きい順にソート
detections.sort(key=lambda x: -x[2])
for image_id, pred_box, score in detections:
# 検出結果毎に、正しいかどうかの判定
# 現在処理中のクラスclass_nameの正解矩形を取り出し、検出矩形pred_boxとのIoUを算出
gts = [g for g in ground_truths.get(image_id, []) if g[1] == class_name]
ious = [iou(pred_box, gt[0]) for gt in gts] # gt[0]:正解矩形, gt[1]:クラス
# IoU最大値を算出
max_iou = 0
max_index = -1
for idx, iou_val in enumerate(ious):
if iou_val > max_iou:
max_iou = iou_val
max_index = idx
if max_iou >= iou_threshold and not gt_used[image_id][max_index]:
# [IoU(最大)>閾値 かつ 正解矩形未参照] 判定=正しい
true_positives.append(1)
gt_used[image_id][max_index] = True
# print(f"{image_id}, {class_name}, {pred_box}, {score}, {max_iou}, true")
else:
# [IoU(最大)≦閾値 または 正解矩形参照済] 判定=誤り
true_positives.append(0)
# print(f"{image_id}, {class_name}, {pred_box}, {score}, {max_iou}, false")
scores.append(score)
if total_gts == 0:
aps.append(0)
continue
# TP,FP算出
# TP(tp_cumsum):「正しい」判定の数をカウント(true_positivesの1の数をカウント)
# FP(fp_cumsum):「誤り」判定の数をカウント(fp(true_positivesを0,1反転)の1の数をカウント)
# ※tp,fpは、個々の検出結果に対する値(正しいかどうかの判定結果)
tp = np.array(true_positives)
fp = 1 - tp
# ※TP(tp_cumsum)、FP(fp_cumsum)は、信頼度閾値Cnの検出結果集合に対する値
# 例: tp_cumsum[2]: 検出結果[0]~[2]の集合(※)のTP (※)信頼度閾値Cn=検出結果[2]の信頼度 で物体検出した検出結果集合
tp_cumsum = np.cumsum(tp)
fp_cumsum = np.cumsum(fp)
# Precision, Recall算出
# ※precisions、recallsも、信頼度閾値Cnの検出結果集合に対する値
# 例:precisions[2]: 検出結果[0]~[2]の集合のprecision
recalls = tp_cumsum / total_gts
precisions = tp_cumsum / (tp_cumsum + fp_cumsum)
# AP算出
ap = compute_ap(recalls, precisions)
aps.append(ap)
# print(f"tp={tp}, fp={fp}, total_gts={total_gts}")
# print(f"tp_cumsum={tp_cumsum}, fp_cumsum={fp_cumsum}")
# print(f"precisions={precisions}")
# print(f"recalls={recalls}")
# mAP算出
mAP = np.mean(aps)
return mAP, aps
# 参考: 検証用ソース
def unitTestEvaluateMap():
# 検出結果
arg_predictions = {"Image1":[(np.array([100.0, 50.0, 150.0, 110.0]), 0.95, "car"), #1
(np.array([350.0, 200.0, 500.0, 300.0]), 0.88, "car"), #2
(np.array([350.0, 400.0, 500.0, 550.0]), 0.61, "car"), #7
(np.array([470.0, 380.0, 650.0, 510.0]), 0.55, "car"), #10
(np.array([120.0, 140.0, 140.0, 145.0]), 0.92, "number"), #1n
],
"Image2":[(np.array([300.0, 250.0, 450.0, 310.0]), 0.77, "car"), #4
(np.array([110.0, 450.0, 320.0, 230.0]), 0.65, "car"), #6
(np.array([400.0, 350.0, 410.0, 380.0]), 0.95, "number"), #2n
(np.array([750.0, 700.0, 780.0, 710.0]), 0.84, "number"), #5n
],
"Image3":[(np.array([120.0, 150.0, 250.0, 310.0]), 0.84, "car"), #3
(np.array([450.0, 350.0, 650.0, 510.0]), 0.72, "car"), #5
(np.array([405.0, 150.0, 430.0, 160.0]), 0.70, "number"), #7n
(np.array([820.0, 630.0, 840.0, 640.0]), 0.78, "number"), #8n
],
"Image4":[(np.array([170.0, 250.0, 320.0, 330.0]), 0.60, "car"), #8
(np.array([350.0, 500.0, 550.0, 550.0]), 0.57, "car"), #9
],
}
# 正解矩形
arg_gt = {"Image1":[(np.array([105.0, 50.0, 150.0, 110.0]), "car"), #1
(np.array([360.0, 200.0, 505.0, 300.0]), "car"), #2
(np.array([355.0, 400.0, 510.0, 550.0]), "car"), #7
(np.array([121.0, 140.0, 141.0, 145.0]), "number"), #1n
],
"Image2":[(np.array([310.0, 260.0, 450.0, 305.0]), "car"), #4
(np.array([505.0, 500.0, 525.0, 520.0]), "number"), #2n
(np.array([752.0, 701.0, 780.0, 711.0]), "number"), #5n
],
"Image3":[(np.array([131.0, 150.0, 247.0, 310.0]), "car"), #3
(np.array([450.0, 355.0, 632.0, 510.0]), "car"), #5
(np.array([400.0, 150.0, 450.0, 160.0]), "number"), #7n
(np.array([810.0, 630.0, 830.0, 640.0]), "number"), #8n
],
"Image4":[(np.array([188.0, 250.0, 315.0, 330.0]), "car"), #8
],
}
arg_classes = ["car", "number"]
arg_iou_th = 0.5
(mAP, per_class_ap) = evaluate_map(arg_predictions, arg_gt, arg_classes, arg_iou_th)
per_class_ap_dict = {cls_name:ap_val for cls_name,ap_val in zip(arg_classes, per_class_ap)}
print(f"\nMean Average Precision({arg_iou_th}) = {mAP}\n")
print(f"== Average Precision({arg_iou_th}) per class ==")
for cls_name, ap_val in per_class_ap_dict.items():
print(f"{cls_name}: {ap_val}")
return
if __name__ == "__main__":
unitTestEvaluateMap() # 単体テスト: evaluate_map()
# 参考: 検証結果例
Mean Average Precision(0.5) = 0.6754761904761903
== Average Precision(0.5) per class ==
car: 0.9642857142857142
number: 0.3866666666666666










