From 1cf722ff40ea7007d1452989a5373a9bab83854c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Beno=C3=AEt=20J=2E=20T=2E=20GIRARD?=
<53222414+BenoitJT-GIRARD@users.noreply.github.com>
Date: Thu, 29 Oct 2026 21:53:18 +0100
Subject: [PATCH 01/25] fix: score detection f1 as 0 when no real bout is
matched
calculate_precision_recall_f1 returned NaN for F1 whenever TP was 0, so
a recording with real bouts and no match (nothing predicted, or only
false bouts) left the mean over recordings. F1 is now 0 when TP = 0 and
FP + FN > 0, the value of 2 TP / (2 TP + FP + FN). It stays NaN for
TP = FP = FN = 0 and every other value is unchanged. The MATLAB function
BANOScalculatePrecisionRecallF1 gets the same rule.
The goldens move to golden_python_v110.json and golden_matlab_v110.json
(the v0.2.0 files are kept). Two values change: Recording_8 mount F1
goes from null to 0 and Recording_8 overall F1 from 0.6062 to 0.4546.
---
.../BANOS/BANOScalculatePrecisionRecallF1.m | 12 +-
src/banos/core/metrics.py | 9 +-
tests/fixtures/golden_matlab_v110.json | 1 +
tests/fixtures/golden_python_v110.json | 802 ++++++++++++++++++
.../matlab/generate_matlab_fixtures.m | 10 +-
.../python/generate_golden_fixtures.py | 14 +-
tests/test_metrics.py | 27 +
tests/test_parity.py | 8 +-
tests/test_pipeline.py | 58 ++
tests/test_regression.py | 29 +-
10 files changed, 934 insertions(+), 36 deletions(-)
create mode 100644 tests/fixtures/golden_matlab_v110.json
create mode 100644 tests/fixtures/golden_python_v110.json
diff --git a/matlab/BANOS/BANOScalculatePrecisionRecallF1.m b/matlab/BANOS/BANOScalculatePrecisionRecallF1.m
index fcd2bc5..2e2e1a9 100644
--- a/matlab/BANOS/BANOScalculatePrecisionRecallF1.m
+++ b/matlab/BANOS/BANOScalculatePrecisionRecallF1.m
@@ -1,7 +1,10 @@
function [precision, recall, f1] = BANOScalculatePrecisionRecallF1(tp, fp, fn)
% Calculates Precision, Recall, and F1 score at the bout level.
%
- % Returns NaN (not 0) when the denominator is zero, matching Python behaviour.
+ % Precision and recall are NaN (not 0) when their denominator is zero,
+ % matching Python behaviour. F1 is 0 when no bout is matched but some
+ % bouts were predicted or missed (TP = 0 and FP + FN > 0), and NaN only
+ % when all three counts are zero.
% NaN values propagate correctly through BANOSaggregateMetrics (nanmean).
%
% Arguments:
@@ -12,7 +15,8 @@
% Returns:
% precision - TP / (TP + FP), or NaN if TP + FP = 0.
% recall - TP / (TP + FN), or NaN if TP + FN = 0.
- % f1 - Harmonic mean of precision and recall, or NaN if undefined.
+ % f1 - Harmonic mean of precision and recall; 0 if TP = 0 and
+ % FP + FN > 0; NaN if TP = FP = FN = 0.
if (tp + fp) > 0
precision = tp / (tp + fp);
@@ -26,7 +30,9 @@
recall = NaN;
end
- if ~isnan(precision) && ~isnan(recall) && (precision + recall) > 0
+ if tp == 0 && (fp + fn) > 0
+ f1 = 0;
+ elseif ~isnan(precision) && ~isnan(recall) && (precision + recall) > 0
f1 = 2 * precision * recall / (precision + recall);
else
f1 = NaN;
diff --git a/src/banos/core/metrics.py b/src/banos/core/metrics.py
index bdd10d5..5dc93b9 100644
--- a/src/banos/core/metrics.py
+++ b/src/banos/core/metrics.py
@@ -29,11 +29,16 @@ def identify_bouts(sequence: pd.Series | list) -> list[tuple[int, int]]:
def calculate_precision_recall_f1(n_tp: int, n_fp: int, n_fn: int) -> tuple[float, float, float]:
"""Compute Detection Accuracy: Precision, Recall, F1.
- Returns NaN for any metric where the denominator is zero.
+ Precision and recall are NaN when their denominator is zero.
+ F1 is 0 when no bout is matched but some bouts were predicted or missed
+ (``n_tp == 0`` and ``n_fp + n_fn > 0``), and NaN only when all three
+ counts are zero.
"""
precision = n_tp / (n_tp + n_fp) if (n_tp + n_fp) > 0 else float("nan")
recall = n_tp / (n_tp + n_fn) if (n_tp + n_fn) > 0 else float("nan")
- if not math.isnan(precision) and not math.isnan(recall) and (precision + recall) > 0:
+ if n_tp == 0 and n_fp + n_fn > 0:
+ f1_score = 0.0
+ elif not math.isnan(precision) and not math.isnan(recall) and (precision + recall) > 0:
f1_score = 2 * precision * recall / (precision + recall)
else:
f1_score = float("nan")
diff --git a/tests/fixtures/golden_matlab_v110.json b/tests/fixtures/golden_matlab_v110.json
new file mode 100644
index 0000000..a0936cd
--- /dev/null
+++ b/tests/fixtures/golden_matlab_v110.json
@@ -0,0 +1 @@
+{"Recording_1":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.98294071362580027},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.99086091350216854},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.99306915975344445}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.98294071362580027},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.99086091350216854},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.99306915975344445}},"overall_metrics":{"precision":0.92826086956521736,"recall":0.78129251700680269,"f1_score":0.836455036759336,"so":0.761228298078524,"tp":0.30623014745659,"ic":0.99171769672035337}},"Recording_2":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98905093606628935},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99464840013584821},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99192990230311961}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98905093606628935},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99464840013584821},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99192990230311961}},"overall_metrics":{"precision":0.9671052631578948,"recall":0.83992445266728333,"f1_score":0.897300738477209,"so":0.82594559036003834,"tp":0.30908214265027689,"ic":0.99390730962631435}},"Recording_3":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98496628121015162},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.9917129219739137},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99621162045778}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98496628121015162},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.9917129219739137},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99621162045778}},"overall_metrics":{"precision":0.95948072383949645,"recall":0.85096664987969339,"f1_score":0.89862686038270723,"so":0.723715686180092,"tp":0.26887555753525666,"ic":0.99322270591046136}},"Recording_4":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.99528949732999117},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99394902176918432}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.99528949732999117},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99394902176918432}},"overall_metrics":{"precision":0.975,"recall":0.61568627450980384,"f1_score":0.72916666666666674,"so":0.70708735677240686,"tp":0.27571526135052643,"ic":0.9965302523591395}},"Recording_5":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98991370716597},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99654108838242228},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.994024222522601}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98991370716597},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99654108838242228},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.994024222522601}},"overall_metrics":{"precision":1,"recall":0.89210526315789473,"f1_score":0.94145869368256319,"so":0.83673046008612484,"tp":0.27928892486843937,"ic":0.99511975451774837}},"Recording_6":{"file_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.99366643311730019},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.99039202618472566},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.99188832402173766}},"group_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.99366643311730019},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.99039202618472566},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.99188832402173766}},"overall_metrics":{"precision":0.91408268733850129,"recall":0.74567099567099571,"f1_score":0.79250474383301706,"so":0.624464020496978,"tp":0.26406773460888022,"ic":0.9939866958309409}},"Recording_7":{"file_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98947078465360738},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.99454387011498291},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.995759546530849}},"group_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98947078465360738},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.99454387011498291},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.995759546530849}},"overall_metrics":{"precision":1,"recall":0.58390957446808511,"f1_score":0.70652984743264124,"so":0.66449505594099667,"tp":0.27653014329623343,"ic":0.99494355032485982}},"Recording_8":{"file_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99528213381542252},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":1},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99510532135592722}},"group_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99528213381542252},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":1},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99510532135592722}},"overall_metrics":{"precision":0.89166666666666661,"recall":0.34672064777327932,"f1_score":0.45462962962962961,"so":0.53056180162368516,"tp":0.038138242319112579,"ic":0.99557663443524391}},"Recording_9":{"file_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98938636025336613},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99863004362050167},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99466920484634314}},"group_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98938636025336613},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99863004362050167},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99466920484634314}},"overall_metrics":{"precision":0.79839767616191892,"recall":0.65381944444444451,"f1_score":0.712354862119013,"so":0.68272952732383074,"tp":0.27780938219974027,"ic":0.99567140218005268}},"Recording_10":{"file_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.99141187589349233},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.99506116351626372},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.998292947043181}},"group_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.99141187589349233},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.99506116351626372},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.998292947043181}},"overall_metrics":{"precision":0.89910714285714288,"recall":0.47575971177944865,"f1_score":0.58347469607011593,"so":0.66680627484973121,"tp":0.28592277410718,"ic":0.99619149661323425}}}
diff --git a/tests/fixtures/golden_python_v110.json b/tests/fixtures/golden_python_v110.json
new file mode 100644
index 0000000..37ed23f
--- /dev/null
+++ b/tests/fixtures/golden_python_v110.json
@@ -0,0 +1,802 @@
+{
+ "Recording_1": {
+ "file_metrics": {
+ "Recording_1": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "investigation": {
+ "precision": 0.8,
+ "recall": 0.7346938775510204,
+ "f1_score": 0.7659574468085106,
+ "so": 0.5444095073699892,
+ "tp": 0.0846356832664267,
+ "ic": 0.9829407136258003
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.5333333333333333,
+ "f1_score": 0.6956521739130436,
+ "so": 0.7332766872436964,
+ "tp": 0.07847125492506643,
+ "ic": 0.9908609135021688
+ },
+ "other": {
+ "precision": 0.9130434782608695,
+ "recall": 0.8571428571428571,
+ "f1_score": 0.8842105263157894,
+ "so": 0.7672269977004106,
+ "tp": 0.061813651634866906,
+ "ic": 0.9930691597534443
+ }
+ }
+ },
+ "group_metrics": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "investigation": {
+ "precision": 0.8,
+ "recall": 0.7346938775510204,
+ "f1_score": 0.7659574468085106,
+ "so": 0.5444095073699892,
+ "tp": 0.0846356832664267,
+ "ic": 0.9829407136258003
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.5333333333333333,
+ "f1_score": 0.6956521739130436,
+ "so": 0.7332766872436964,
+ "tp": 0.07847125492506643,
+ "ic": 0.9908609135021688
+ },
+ "other": {
+ "precision": 0.9130434782608695,
+ "recall": 0.8571428571428571,
+ "f1_score": 0.8842105263157894,
+ "so": 0.7672269977004106,
+ "tp": 0.061813651634866906,
+ "ic": 0.9930691597534443
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.9282608695652174,
+ "recall": 0.7812925170068027,
+ "f1_score": 0.836455036759336,
+ "so": 0.7612282980785241,
+ "tp": 0.30623014745659,
+ "ic": 0.9917176967203534
+ }
+ },
+ "Recording_2": {
+ "file_metrics": {
+ "Recording_2": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "investigation": {
+ "precision": 0.8947368421052632,
+ "recall": 0.7391304347826086,
+ "f1_score": 0.8095238095238095,
+ "so": 0.6609424826096988,
+ "tp": 0.04615109869237036,
+ "ic": 0.9890509360662894
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.8333333333333334,
+ "f1_score": 0.9090909090909091,
+ "so": 0.931754332584827,
+ "tp": 0.1451695616211745,
+ "ic": 0.9946484001358482
+ },
+ "other": {
+ "precision": 0.9736842105263158,
+ "recall": 0.7872340425531915,
+ "f1_score": 0.8705882352941177,
+ "so": 0.7110855462456277,
+ "tp": 0.04500791028756257,
+ "ic": 0.9919299023031196
+ }
+ }
+ },
+ "group_metrics": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "investigation": {
+ "precision": 0.8947368421052632,
+ "recall": 0.7391304347826086,
+ "f1_score": 0.8095238095238095,
+ "so": 0.6609424826096988,
+ "tp": 0.04615109869237036,
+ "ic": 0.9890509360662894
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.8333333333333334,
+ "f1_score": 0.9090909090909091,
+ "so": 0.931754332584827,
+ "tp": 0.1451695616211745,
+ "ic": 0.9946484001358482
+ },
+ "other": {
+ "precision": 0.9736842105263158,
+ "recall": 0.7872340425531915,
+ "f1_score": 0.8705882352941177,
+ "so": 0.7110855462456277,
+ "tp": 0.04500791028756257,
+ "ic": 0.9919299023031196
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.9671052631578947,
+ "recall": 0.8399244526672833,
+ "f1_score": 0.897300738477209,
+ "so": 0.8259455903600383,
+ "tp": 0.30908214265027684,
+ "ic": 0.9939073096263142
+ }
+ },
+ "Recording_3": {
+ "file_metrics": {
+ "Recording_3": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "investigation": {
+ "precision": 0.9354838709677419,
+ "recall": 0.7837837837837838,
+ "f1_score": 0.8529411764705881,
+ "so": 0.4563103530678653,
+ "tp": 0.02232158655731043,
+ "ic": 0.9849662812101514
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.7391304347826086,
+ "f1_score": 0.85,
+ "so": 0.6745042152422358,
+ "tp": 0.028794827943741816,
+ "ic": 0.9917129219739135
+ },
+ "other": {
+ "precision": 0.9024390243902439,
+ "recall": 0.8809523809523809,
+ "f1_score": 0.8915662650602411,
+ "so": 0.7640481764102668,
+ "tp": 0.024385815639974426,
+ "ic": 0.9962116204577801
+ }
+ }
+ },
+ "group_metrics": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "investigation": {
+ "precision": 0.9354838709677419,
+ "recall": 0.7837837837837838,
+ "f1_score": 0.8529411764705881,
+ "so": 0.4563103530678653,
+ "tp": 0.02232158655731043,
+ "ic": 0.9849662812101514
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.7391304347826086,
+ "f1_score": 0.85,
+ "so": 0.6745042152422358,
+ "tp": 0.028794827943741816,
+ "ic": 0.9917129219739135
+ },
+ "other": {
+ "precision": 0.9024390243902439,
+ "recall": 0.8809523809523809,
+ "f1_score": 0.8915662650602411,
+ "so": 0.7640481764102668,
+ "tp": 0.024385815639974426,
+ "ic": 0.9962116204577801
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.9594807238394965,
+ "recall": 0.8509666498796933,
+ "f1_score": 0.8986268603827072,
+ "so": 0.723715686180092,
+ "tp": 0.26887555753525666,
+ "ic": 0.9932227059104612
+ }
+ },
+ "Recording_4": {
+ "file_metrics": {
+ "Recording_4": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "investigation": {
+ "precision": 0.9,
+ "recall": 0.5294117647058824,
+ "f1_score": 0.6666666666666667,
+ "so": 0.39147100794903517,
+ "tp": 0.014025218005513157,
+ "ic": 0.9968824903373824
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.3333333333333333,
+ "f1_score": 0.5,
+ "so": 0.7011185281643236,
+ "tp": 0.04008717445788384,
+ "ic": 0.9952894973299912
+ },
+ "other": {
+ "precision": 1.0,
+ "recall": 0.6,
+ "f1_score": 0.7499999999999999,
+ "so": 0.7357598909762686,
+ "tp": 0.04874865293870875,
+ "ic": 0.9939490217691843
+ }
+ }
+ },
+ "group_metrics": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "investigation": {
+ "precision": 0.9,
+ "recall": 0.5294117647058824,
+ "f1_score": 0.6666666666666667,
+ "so": 0.39147100794903517,
+ "tp": 0.014025218005513157,
+ "ic": 0.9968824903373824
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.3333333333333333,
+ "f1_score": 0.5,
+ "so": 0.7011185281643236,
+ "tp": 0.04008717445788384,
+ "ic": 0.9952894973299912
+ },
+ "other": {
+ "precision": 1.0,
+ "recall": 0.6,
+ "f1_score": 0.7499999999999999,
+ "so": 0.7357598909762686,
+ "tp": 0.04874865293870875,
+ "ic": 0.9939490217691843
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.975,
+ "recall": 0.615686274509804,
+ "f1_score": 0.7291666666666666,
+ "so": 0.7070873567724069,
+ "tp": 0.27571526135052643,
+ "ic": 0.9965302523591395
+ }
+ },
+ "Recording_5": {
+ "file_metrics": {
+ "Recording_5": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "investigation": {
+ "precision": 1.0,
+ "recall": 0.8,
+ "f1_score": 0.888888888888889,
+ "so": 0.6589591312525748,
+ "tp": 0.035333351984487595,
+ "ic": 0.98991370716597
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.9,
+ "f1_score": 0.9473684210526316,
+ "so": 0.9100237928302215,
+ "tp": 0.03596010219174562,
+ "ic": 0.9965410883824223
+ },
+ "other": {
+ "precision": 1.0,
+ "recall": 0.868421052631579,
+ "f1_score": 0.9295774647887324,
+ "so": 0.7779389162617028,
+ "tp": 0.045862245297524175,
+ "ic": 0.9940242225226008
+ }
+ }
+ },
+ "group_metrics": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "investigation": {
+ "precision": 1.0,
+ "recall": 0.8,
+ "f1_score": 0.888888888888889,
+ "so": 0.6589591312525748,
+ "tp": 0.035333351984487595,
+ "ic": 0.98991370716597
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.9,
+ "f1_score": 0.9473684210526316,
+ "so": 0.9100237928302215,
+ "tp": 0.03596010219174562,
+ "ic": 0.9965410883824223
+ },
+ "other": {
+ "precision": 1.0,
+ "recall": 0.868421052631579,
+ "f1_score": 0.9295774647887324,
+ "so": 0.7779389162617028,
+ "tp": 0.045862245297524175,
+ "ic": 0.9940242225226008
+ }
+ },
+ "overall_metrics": {
+ "precision": 1.0,
+ "recall": 0.8921052631578947,
+ "f1_score": 0.9414586936825633,
+ "so": 0.8367304600861247,
+ "tp": 0.27928892486843937,
+ "ic": 0.9951197545177483
+ }
+ },
+ "Recording_6": {
+ "file_metrics": {
+ "Recording_6": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 0.36363636363636365,
+ "f1_score": 0.5333333333333333,
+ "so": 0.3554875922085192,
+ "tp": 0.014142932226192012,
+ "ic": 0.9936664331173002
+ },
+ "investigation": {
+ "precision": 0.7674418604651163,
+ "recall": 0.7857142857142857,
+ "f1_score": 0.7764705882352941,
+ "so": 0.5395797274871901,
+ "tp": 0.020316910620777756,
+ "ic": 0.9903920261847257
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "other": {
+ "precision": 0.8888888888888888,
+ "recall": 0.8333333333333334,
+ "f1_score": 0.8602150537634408,
+ "so": 0.6027887622922026,
+ "tp": 0.021811095588551047,
+ "ic": 0.9918883240217377
+ }
+ }
+ },
+ "group_metrics": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 0.36363636363636365,
+ "f1_score": 0.5333333333333333,
+ "so": 0.3554875922085192,
+ "tp": 0.014142932226192012,
+ "ic": 0.9936664331173002
+ },
+ "investigation": {
+ "precision": 0.7674418604651163,
+ "recall": 0.7857142857142857,
+ "f1_score": 0.7764705882352941,
+ "so": 0.5395797274871901,
+ "tp": 0.020316910620777756,
+ "ic": 0.9903920261847257
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "other": {
+ "precision": 0.8888888888888888,
+ "recall": 0.8333333333333334,
+ "f1_score": 0.8602150537634408,
+ "so": 0.6027887622922026,
+ "tp": 0.021811095588551047,
+ "ic": 0.9918883240217377
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.9140826873385013,
+ "recall": 0.7456709956709957,
+ "f1_score": 0.7925047438330171,
+ "so": 0.624464020496978,
+ "tp": 0.2640677346088802,
+ "ic": 0.9939866958309409
+ }
+ },
+ "Recording_7": {
+ "file_metrics": {
+ "Recording_7": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 0.525,
+ "f1_score": 0.6885245901639345,
+ "so": 0.3906951258181009,
+ "tp": 0.030228614492615706,
+ "ic": 0.9894707846536077
+ },
+ "investigation": {
+ "precision": 1.0,
+ "recall": 0.3,
+ "f1_score": 0.4615384615384615,
+ "so": 0.536495310012478,
+ "tp": 0.031330852383483966,
+ "ic": 0.9945438701149829
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "other": {
+ "precision": 1.0,
+ "recall": 0.5106382978723404,
+ "f1_score": 0.676056338028169,
+ "so": 0.7307897879334082,
+ "tp": 0.044561106308833955,
+ "ic": 0.9957595465308487
+ }
+ }
+ },
+ "group_metrics": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 0.525,
+ "f1_score": 0.6885245901639345,
+ "so": 0.3906951258181009,
+ "tp": 0.030228614492615706,
+ "ic": 0.9894707846536077
+ },
+ "investigation": {
+ "precision": 1.0,
+ "recall": 0.3,
+ "f1_score": 0.4615384615384615,
+ "so": 0.536495310012478,
+ "tp": 0.031330852383483966,
+ "ic": 0.9945438701149829
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "other": {
+ "precision": 1.0,
+ "recall": 0.5106382978723404,
+ "f1_score": 0.676056338028169,
+ "so": 0.7307897879334082,
+ "tp": 0.044561106308833955,
+ "ic": 0.9957595465308487
+ }
+ },
+ "overall_metrics": {
+ "precision": 1.0,
+ "recall": 0.5839095744680851,
+ "f1_score": 0.7065298474326412,
+ "so": 0.6644950559409968,
+ "tp": 0.27653014329623343,
+ "ic": 0.9949435503248598
+ }
+ },
+ "Recording_8": {
+ "file_metrics": {
+ "Recording_8": {
+ "attack": {
+ "precision": 0.875,
+ "recall": 0.3684210526315789,
+ "f1_score": 0.5185185185185185,
+ "so": 0.2683638230814003,
+ "tp": 0.020422876058778405,
+ "ic": 0.9919190825696258
+ },
+ "investigation": {
+ "precision": 1.0,
+ "recall": 0.5384615384615384,
+ "f1_score": 0.7000000000000001,
+ "so": 0.5910507486515022,
+ "tp": 0.02527092835048601,
+ "ic": 0.9952821338154225
+ },
+ "mount": {
+ "precision": null,
+ "recall": 0.0,
+ "f1_score": 0.0,
+ "so": null,
+ "tp": null,
+ "ic": 1.0
+ },
+ "other": {
+ "precision": 0.8,
+ "recall": 0.48,
+ "f1_score": 0.6,
+ "so": 0.7322708331381532,
+ "tp": 0.06872092254807333,
+ "ic": 0.995105321355927
+ }
+ }
+ },
+ "group_metrics": {
+ "attack": {
+ "precision": 0.875,
+ "recall": 0.3684210526315789,
+ "f1_score": 0.5185185185185185,
+ "so": 0.2683638230814003,
+ "tp": 0.020422876058778405,
+ "ic": 0.9919190825696258
+ },
+ "investigation": {
+ "precision": 1.0,
+ "recall": 0.5384615384615384,
+ "f1_score": 0.7000000000000001,
+ "so": 0.5910507486515022,
+ "tp": 0.02527092835048601,
+ "ic": 0.9952821338154225
+ },
+ "mount": {
+ "precision": null,
+ "recall": 0.0,
+ "f1_score": 0.0,
+ "so": null,
+ "tp": null,
+ "ic": 1.0
+ },
+ "other": {
+ "precision": 0.8,
+ "recall": 0.48,
+ "f1_score": 0.6,
+ "so": 0.7322708331381532,
+ "tp": 0.06872092254807333,
+ "ic": 0.995105321355927
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.8916666666666666,
+ "recall": 0.3467206477732793,
+ "f1_score": 0.4546296296296296,
+ "so": 0.5305618016236853,
+ "tp": 0.03813824231911258,
+ "ic": 0.9955766344352438
+ }
+ },
+ "Recording_9": {
+ "file_metrics": {
+ "Recording_9": {
+ "attack": {
+ "precision": 0.9565217391304348,
+ "recall": 0.7333333333333333,
+ "f1_score": 0.8301886792452831,
+ "so": 0.4241002824392379,
+ "tp": 0.0205898063721443,
+ "ic": 0.989386360253366
+ },
+ "investigation": {
+ "precision": 0.375,
+ "recall": 0.1875,
+ "f1_score": 0.25,
+ "so": 0.631678281068525,
+ "tp": 0.03480952380952381,
+ "ic": 0.9986300436205017
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "other": {
+ "precision": 0.8620689655172413,
+ "recall": 0.6944444444444444,
+ "f1_score": 0.7692307692307692,
+ "so": 0.6751395457875596,
+ "tp": 0.05583819861729283,
+ "ic": 0.9946692048463431
+ }
+ }
+ },
+ "group_metrics": {
+ "attack": {
+ "precision": 0.9565217391304348,
+ "recall": 0.7333333333333333,
+ "f1_score": 0.8301886792452831,
+ "so": 0.4241002824392379,
+ "tp": 0.0205898063721443,
+ "ic": 0.989386360253366
+ },
+ "investigation": {
+ "precision": 0.375,
+ "recall": 0.1875,
+ "f1_score": 0.25,
+ "so": 0.631678281068525,
+ "tp": 0.03480952380952381,
+ "ic": 0.9986300436205017
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "other": {
+ "precision": 0.8620689655172413,
+ "recall": 0.6944444444444444,
+ "f1_score": 0.7692307692307692,
+ "so": 0.6751395457875596,
+ "tp": 0.05583819861729283,
+ "ic": 0.9946692048463431
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.798397676161919,
+ "recall": 0.6538194444444444,
+ "f1_score": 0.712354862119013,
+ "so": 0.6827295273238306,
+ "tp": 0.2778093821997402,
+ "ic": 0.9956714021800527
+ }
+ },
+ "Recording_10": {
+ "file_metrics": {
+ "Recording_10": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 0.2857142857142857,
+ "f1_score": 0.4444444444444445,
+ "so": 0.18490328157186495,
+ "tp": 0.011189008329645374,
+ "ic": 0.9914118758934924
+ },
+ "investigation": {
+ "precision": 0.625,
+ "recall": 0.2631578947368421,
+ "f1_score": 0.37037037037037035,
+ "so": 0.6572072189744603,
+ "tp": 0.06468253968253967,
+ "ic": 0.9950611635162638
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "other": {
+ "precision": 0.9714285714285714,
+ "recall": 0.3541666666666667,
+ "f1_score": 0.5190839694656488,
+ "so": 0.8251145988525997,
+ "tp": 0.06781954841653498,
+ "ic": 0.998292947043181
+ }
+ }
+ },
+ "group_metrics": {
+ "attack": {
+ "precision": 1.0,
+ "recall": 0.2857142857142857,
+ "f1_score": 0.4444444444444445,
+ "so": 0.18490328157186495,
+ "tp": 0.011189008329645374,
+ "ic": 0.9914118758934924
+ },
+ "investigation": {
+ "precision": 0.625,
+ "recall": 0.2631578947368421,
+ "f1_score": 0.37037037037037035,
+ "so": 0.6572072189744603,
+ "tp": 0.06468253968253967,
+ "ic": 0.9950611635162638
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 1.0,
+ "f1_score": 1.0,
+ "so": 1.0,
+ "tp": 1.0,
+ "ic": 1.0
+ },
+ "other": {
+ "precision": 0.9714285714285714,
+ "recall": 0.3541666666666667,
+ "f1_score": 0.5190839694656488,
+ "so": 0.8251145988525997,
+ "tp": 0.06781954841653498,
+ "ic": 0.998292947043181
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.8991071428571429,
+ "recall": 0.47575971177944865,
+ "f1_score": 0.5834746960701159,
+ "so": 0.6668062748497312,
+ "tp": 0.28592277410718003,
+ "ic": 0.9961914966132344
+ }
+ }
+}
diff --git a/tests/fixtures/matlab/generate_matlab_fixtures.m b/tests/fixtures/matlab/generate_matlab_fixtures.m
index ce98f95..15279f0 100644
--- a/tests/fixtures/matlab/generate_matlab_fixtures.m
+++ b/tests/fixtures/matlab/generate_matlab_fixtures.m
@@ -1,8 +1,8 @@
%% generate_matlab_fixtures.m
% Generates a single JSON reference file from the Matlab BANOS implementation,
-% containing all 10 recordings in the same structure as golden_python_current.json.
+% containing all 10 recordings in the same structure as golden_python_v110.json.
%
-% Output: tests/fixtures/golden_matlab_v020.json
+% Output: tests/fixtures/golden_matlab_v110.json
%
% Run from the project root using any of:
% matlab -batch "run('tests/fixtures/matlab/generate_matlab_fixtures.m')"
@@ -13,7 +13,7 @@
addpath(fullfile(scriptDir, '..', '..', '..', 'matlab', 'BANOS'));
datasetDir = fullfile(scriptDir, '..', '..', '..', 'data', 'dataset_human_vs_human');
-outFile = fullfile(scriptDir, '..', 'golden_matlab_v020.json');
+outFile = fullfile(scriptDir, '..', 'golden_matlab_v110.json');
fprintf('Generating Matlab BANOS fixtures...\n');
fprintf('Dataset dir: %s\n', datasetDir);
@@ -67,12 +67,12 @@
clear singleData;
end
-% Save all recordings as a single JSON (matches golden_python_current.json structure)
+% Save all recordings as a single JSON (matches golden_python_v110.json structure)
fid = fopen(outFile, 'w');
if fid == -1
error('Cannot open output file: %s', outFile);
end
-fprintf(fid, '%s', jsonencode(allResults));
+fprintf(fid, '%s\n', jsonencode(allResults));
fclose(fid);
fprintf('\nDone! Saved to: %s\n', outFile);
diff --git a/tests/fixtures/python/generate_golden_fixtures.py b/tests/fixtures/python/generate_golden_fixtures.py
index 7131db4..2c1b613 100644
--- a/tests/fixtures/python/generate_golden_fixtures.py
+++ b/tests/fixtures/python/generate_golden_fixtures.py
@@ -1,11 +1,10 @@
# tests/fixtures/python/generate_golden_fixtures.py
-"""Generate golden output fixtures from the current BANOS code (v0.2.0).
+"""Generate golden output fixtures from the current BANOS code (v1.1.0).
-These fixtures freeze the current (corrected) behaviour for regression testing.
-They reflect three improvements over archive v0.1.5:
- 1. Score 1.0 for correct absence (both GT and machine have no bouts).
- 2. Score 0.0 for false detection (machine predicts bouts when GT has none).
- 3. IC switch-counting correctly includes the last adjacent frame pair.
+These fixtures freeze the current behaviour for regression testing.
+Changes since the v0.2.0 fixtures (golden_python_v020.json, kept as an archive):
+ 1. Detection F1 is 0, not NaN, when a recording has real bouts and no
+ predicted bout matches any of them.
Run once to regenerate after a deliberate algorithm change:
python tests/fixtures/python/generate_golden_fixtures.py
@@ -24,7 +23,7 @@
from banos import aggregate_metrics, calculate_banos_for_each_file, preprocess_data
DATASET_DIR = Path(__file__).parent.parent.parent.parent / "data" / "dataset_human_vs_human"
-OUT_FILE = Path(__file__).parent.parent / "golden_python_v020.json"
+OUT_FILE = Path(__file__).parent.parent / "golden_python_v110.json"
def load_recording(n: int):
@@ -68,6 +67,7 @@ def make_json_serializable(obj):
OUT_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(OUT_FILE, "w") as f:
json.dump(results, f, indent=2)
+ f.write("\n")
print(f"\nSaved golden fixtures to {OUT_FILE}")
print(f"Total recordings: {len(results)}")
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
index 9175540..712b53e 100644
--- a/tests/test_metrics.py
+++ b/tests/test_metrics.py
@@ -17,6 +17,10 @@
correctly excludes NaN values so they do not affect recordings where the behavior
actually occurred.
+ F1 is the exception: with no matched bout (TP = 0) but some predicted or
+ missed bouts (FP + FN > 0) it is 0, the value of the count form
+ 2 TP / (2 TP + FP + FN). It is NaN only when TP = FP = FN = 0.
+
IC switch-counting range:
count_switches_within_bout uses range(gt_start, gt_end) — inclusive of gt_end-1,
checking all adjacent pairs (i, i+1) up to (gt_end-1, gt_end). This was a bug
@@ -88,6 +92,29 @@ def test_prf1_balanced():
assert abs(f1 - 2 / 3) < 1e-10
+def test_prf1_nothing_predicted_f1_is_zero():
+ """Real bouts, nothing predicted: precision is 0/0, but F1 is 0, not NaN."""
+ p, r, f1 = calculate_precision_recall_f1(0, 0, 3)
+ assert math.isnan(p)
+ assert r == 0.0
+ assert f1 == 0.0
+
+
+def test_prf1_only_false_bouts_f1_is_zero():
+ """Only false bouts and no real bout: recall is 0/0, but F1 is 0, not NaN."""
+ p, r, f1 = calculate_precision_recall_f1(0, 3, 0)
+ assert p == 0.0
+ assert math.isnan(r)
+ assert f1 == 0.0
+
+
+def test_prf1_false_and_missed_bouts_f1_is_zero():
+ """Predicted and real bouts that never overlap: P = R = 0 and F1 = 0."""
+ p, r, f1 = calculate_precision_recall_f1(0, 2, 2)
+ assert p == 0.0 and r == 0.0
+ assert f1 == 0.0
+
+
# --- calculate_tiou ---
diff --git a/tests/test_parity.py b/tests/test_parity.py
index 58ae3db..eeef04f 100644
--- a/tests/test_parity.py
+++ b/tests/test_parity.py
@@ -9,11 +9,11 @@
FIXTURE FILE
------------
-tests/fixtures/golden_matlab_v020.json — generated by:
+tests/fixtures/golden_matlab_v110.json — generated by:
matlab -batch "run('tests/fixtures/matlab/generate_matlab_fixtures.m')"
The file contains all 10 recordings in a single JSON (same structure as
-golden_python_current.json), keyed by "Recording_N".
+golden_python_v110.json), keyed by "Recording_N".
TOLERANCE
---------
@@ -40,7 +40,7 @@
from banos import aggregate_metrics, calculate_banos_for_each_file, preprocess_data
DATASET_DIR = Path(__file__).parent.parent / "data" / "dataset_human_vs_human"
-MATLAB_FIXTURE = Path(__file__).parent / "fixtures" / "golden_matlab_v020.json"
+MATLAB_FIXTURE = Path(__file__).parent / "fixtures" / "golden_matlab_v110.json"
TOLERANCE = 1e-6 # floating point tolerance for Matlab vs Python comparisons
METRICS = ["precision", "recall", "f1_score", "so", "tp", "ic"]
@@ -48,7 +48,7 @@
# Load MATLAB fixture once (skip all tests if absent)
if not MATLAB_FIXTURE.exists():
pytest.skip(
- "Matlab fixture not found: tests/fixtures/golden_matlab_v020.json — "
+ "Matlab fixture not found: tests/fixtures/golden_matlab_v110.json — "
"run: matlab -batch \"run('tests/fixtures/matlab/generate_matlab_fixtures.m')\"",
allow_module_level=True,
)
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index 6bd912c..63c30aa 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -39,6 +39,25 @@
}
)
+# Hand-made videos of 60 frames with three real bouts of one behavior ("evt").
+N_FRAMES = 60
+REAL_BOUTS = [(5, 9), (25, 29), (45, 49)]
+
+
+def _frames(bouts: list[tuple[int, int]]) -> list[int]:
+ """0/1 sequence of N_FRAMES frames, 1 on every frame of the (start, end) bouts."""
+ sequence = [0] * N_FRAMES
+ for start, end in bouts:
+ sequence[start : end + 1] = [1] * (end - start + 1)
+ return sequence
+
+
+def _video(
+ pred_bouts: list[tuple[int, int]], gt_bouts: list[tuple[int, int]] = REAL_BOUTS
+) -> tuple[pd.DataFrame, pd.DataFrame]:
+ """(pred_df, gt_df) pair of one video from its predicted and real bouts."""
+ return pd.DataFrame({"evt": _frames(pred_bouts)}), pd.DataFrame({"evt": _frames(gt_bouts)})
+
def test_score_single_file_returns_dict():
result = banos.score(PRED, GT)
@@ -128,3 +147,42 @@ def test_aggregate_metrics_excludes_nan():
def test_score_version():
assert isinstance(banos.__version__, str)
assert banos.__version__ != ""
+
+
+def test_file_with_only_false_bouts_scores_f1_zero():
+ """A file whose predicted bouts all miss the real bouts scores F1 0 and stays in the mean.
+
+ Both models predict video 1 exactly. In video 2, model C predicts three
+ bouts that overlap no real bout (TP 0, FP 3, FN 3: P 0, R 0, F1 0) and
+ model D finds two of the three real bouts (TP 2, FN 1: P 1, R 2/3, F1 0.8).
+ Per-file means: C (1 + 0) / 2 = 0.5, D (1 + 0.8) / 2 = 0.9. Before 1.1.0,
+ C's video 2 scored F1 NaN, left the mean, and C got 1.0.
+ """
+ false_bouts = [(15, 19), (35, 39), (52, 56)]
+ c_video2 = banos.score(*_video(false_bouts))["evt"]
+ assert c_video2["precision"] == 0.0
+ assert c_video2["recall"] == 0.0
+ assert c_video2["f1_score"] == 0.0
+
+ group_c, _ = banos.score({"v1": _video(REAL_BOUTS), "v2": _video(false_bouts)})
+ group_d, _ = banos.score({"v1": _video(REAL_BOUTS), "v2": _video(REAL_BOUTS[:2])})
+ assert abs(group_c["evt"]["f1_score"] - 0.5) < 1e-10
+ assert abs(group_d["evt"]["f1_score"] - 0.9) < 1e-10
+
+
+def test_silent_model_on_bundled_data(all_recordings):
+ """A model that predicts nothing scores F1 0 on every recording with the behavior.
+
+ Its only non-zero F1 cells are the correct absences (1.0). In the bundled
+ data, attack is absent from 5 of the 10 recordings and mount from 4, while
+ investigation and other are in all 10. Per-file means: attack 5/10 = 0.5,
+ mount 4/10 = 0.4, investigation 0, other 0; overall F1 = 9 cells of 40 =
+ 0.225. Before 1.1.0 the recordings with the behavior scored F1 NaN and left
+ the means: attack, mount and overall F1 were 1.0.
+ """
+ data = {f"Recording_{n}": (gt * 0, gt) for n, (_, gt) in all_recordings.items()}
+ group, overall = banos.score(data)
+ expected = {"attack": 0.5, "investigation": 0.0, "mount": 0.4, "other": 0.0}
+ for behavior, f1 in expected.items():
+ assert abs(group[behavior]["f1_score"] - f1) < 1e-10, behavior
+ assert abs(overall["f1_score"] - 0.225) < 1e-10
diff --git a/tests/test_regression.py b/tests/test_regression.py
index 51cce7f..4ea6837 100644
--- a/tests/test_regression.py
+++ b/tests/test_regression.py
@@ -7,15 +7,14 @@
If any refactoring introduces an unintended numerical change — even a tiny
floating-point difference — these tests catch it immediately.
-The golden fixtures in tests/fixtures/golden_python_v020.json represent the
-CORRECTED v0.2.0 behaviour (three improvements over v0.1.5):
- 1. Score 1.0 for correct absence (both GT and machine have no bouts).
- 2. Score 0.0 for false detection (machine predicts bouts when GT has none).
- 3. IC switch-counting covers all adjacent frame pairs within each GT bout
- (the original code missed the last pair, giving inflated IC values).
+The golden fixtures in tests/fixtures/golden_python_v110.json represent the
+v1.1.0 behaviour. Changes since v0.2.0 that move values of the bundled data:
+ 1. Detection F1 is 0, not NaN, when a recording has real bouts and no
+ predicted bout matches any of them (Recording_8 mount).
-The archive fixtures (tests/fixtures/golden_python_v015.json) preserve
-the original v0.1.5 behaviour for reference and comparison.
+The archive fixtures (tests/fixtures/golden_python_v020.json and
+golden_python_v015.json) preserve the v0.2.0 and v0.1.5 behaviour for
+reference and comparison.
JSON null = Python NaN
----------------------
@@ -27,8 +26,8 @@
-----------------
If you make a deliberate algorithm change that affects output values, update
the fixtures and document why:
- python scripts/generate_golden_fixtures.py
- # Then commit the updated tests/fixtures/golden_python_v020.json with a
+ python tests/fixtures/python/generate_golden_fixtures.py
+ # Then commit the updated tests/fixtures/golden_python_v110.json with a
# clear commit message explaining the change.
"""
@@ -42,7 +41,7 @@
from banos import aggregate_metrics, calculate_banos_for_each_file, preprocess_data
DATASET_DIR = Path(__file__).parent.parent / "data" / "dataset_human_vs_human"
-GOLDEN_FILE = Path(__file__).parent / "fixtures" / "golden_python_v020.json"
+GOLDEN_FILE = Path(__file__).parent / "fixtures" / "golden_python_v110.json"
TOLERANCE = 1e-10
with open(GOLDEN_FILE) as f:
@@ -77,7 +76,7 @@ def test_regression_overall_metrics(n):
If this fails after a code change, the change affected numerical output.
Decide whether the change is a bug fix or a regression, then either:
- Fix the regression (no fixture update needed), OR
- - Deliberately update: python scripts/generate_golden_fixtures.py
+ - Deliberately update: python tests/fixtures/python/generate_golden_fixtures.py
"""
pred, gt = load_recording(n)
data, _ = preprocess_data({f"Recording_{n}": (pred.copy(), gt.copy())})
@@ -96,9 +95,9 @@ def test_regression_overall_metrics(n):
def test_regression_group_metrics(n):
"""Per-behavior metrics for each recording must match golden fixtures (tol=1e-10).
- `null` in the JSON fixture means NaN — the behavior had no GT bouts in that
- recording and the metric is undefined. The `_close()` helper treats `null`
- and `float('nan')` as equivalent.
+ `null` in the JSON fixture means NaN — the metric is undefined for that
+ behavior in that recording (e.g. precision when nothing is predicted).
+ The `_close()` helper treats `null` and `float('nan')` as equivalent.
"""
pred, gt = load_recording(n)
data, _ = preprocess_data({f"Recording_{n}": (pred.copy(), gt.copy())})
From a250f4141fe8c594f2d29df21cf5928248aa278c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Beno=C3=AEt=20J=2E=20T=2E=20GIRARD?=
<53222414+BenoitJT-GIRARD@users.noreply.github.com>
Date: Thu, 29 Oct 2026 21:54:35 +0100
Subject: [PATCH 02/25] fix: read predictions by position in intra-bout
continuity
count_switches_within_bout read pred_sequence[i] on a pandas Series,
which is a lookup by index label, while bouts are frame positions. Data
indexed other than 0..n-1 (frames numbered from 1, reversed or float
labels, strings on pandas 3) got a wrong IC or a KeyError.
count_switches_within_bout and calculate_ic now read a Series by
position, and compute_behavior_metrics passes the column values. No
golden value changes; the MATLAB code has no index labels.
---
src/banos/core/metrics.py | 18 ++++++++++++++++--
src/banos/pipeline.py | 2 +-
tests/test_metrics.py | 24 ++++++++++++++++++++++++
tests/test_pipeline.py | 26 ++++++++++++++++++++++++++
4 files changed, 67 insertions(+), 3 deletions(-)
diff --git a/src/banos/core/metrics.py b/src/banos/core/metrics.py
index 5dc93b9..d0e2ca0 100644
--- a/src/banos/core/metrics.py
+++ b/src/banos/core/metrics.py
@@ -5,9 +5,15 @@
import math
+import numpy as np
import pandas as pd
+def _by_position(sequence: pd.Series | np.ndarray | list) -> np.ndarray | list:
+ """Return ``sequence`` indexable by frame position (a Series gives its values)."""
+ return sequence.to_numpy() if isinstance(sequence, pd.Series) else sequence
+
+
def identify_bouts(sequence: pd.Series | list) -> list[tuple[int, int]]:
"""Identify continuous runs of 1s in a binary sequence.
@@ -88,12 +94,16 @@ def calculate_tp_metric(
return sum(scores) / len(scores) if scores else float("nan")
-def count_switches_within_bout(pred_sequence: pd.Series | list, gt_start: int, gt_end: int) -> int:
+def count_switches_within_bout(
+ pred_sequence: pd.Series | np.ndarray | list, gt_start: int, gt_end: int
+) -> int:
"""Count label transitions within [gt_start, gt_end] (inclusive).
Compares pred[i] with pred[i+1] for i in [gt_start, gt_end-1].
This includes the transition at the last frame boundary (gt_end-1 → gt_end).
+ Frames are read by position: the index labels of a Series are ignored.
"""
+ pred_sequence = _by_position(pred_sequence)
switches = 0
for i in range(gt_start, gt_end): # gt_end inclusive: checks pairs up to (gt_end-1, gt_end)
if pred_sequence[i] != pred_sequence[i + 1]:
@@ -101,13 +111,17 @@ def count_switches_within_bout(pred_sequence: pd.Series | list, gt_start: int, g
return switches
-def calculate_ic(pred_sequence: pd.Series | list, gt_bouts: list[tuple[int, int]]) -> float:
+def calculate_ic(
+ pred_sequence: pd.Series | np.ndarray | list, gt_bouts: list[tuple[int, int]]
+) -> float:
"""Intra-bout Continuity: 1 - (switches / gt_length) averaged over GT bouts.
+ Frames are read by position: the index labels of a Series are ignored.
Returns NaN if there are no GT bouts.
"""
if not gt_bouts:
return float("nan")
+ pred_sequence = _by_position(pred_sequence)
ic_scores = []
for gt in gt_bouts:
gt_length = gt[1] - gt[0]
diff --git a/src/banos/pipeline.py b/src/banos/pipeline.py
index 5b61fa7..6f0e2a6 100644
--- a/src/banos/pipeline.py
+++ b/src/banos/pipeline.py
@@ -60,7 +60,7 @@ def compute_behavior_metrics(
precision, recall, f1_score = calculate_precision_recall_f1(n_tp, n_fp, n_fn)
so = calculate_so(pred_bouts, gt_bouts)
tp_m = calculate_tp_metric(pred_bouts, gt_bouts)
- ic = calculate_ic(pred_matrix[behavior], gt_bouts)
+ ic = calculate_ic(pred_matrix[behavior].to_numpy(), gt_bouts)
metrics[behavior] = {
"precision": precision,
"recall": recall,
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
index 712b53e..9459bc7 100644
--- a/tests/test_metrics.py
+++ b/tests/test_metrics.py
@@ -29,6 +29,9 @@
import math
+import pandas as pd
+import pytest
+
from banos.core.metrics import (
calculate_ic,
calculate_precision_recall_f1,
@@ -215,6 +218,27 @@ def test_switches_subset_range():
assert count_switches_within_bout([0, 1, 0, 1, 0], 1, 3) == 2
+SERIES_INDEXES = {
+ "1-based": [1, 2, 3, 4, 5],
+ "reversed": [4, 3, 2, 1, 0],
+ "float": [0.0, 0.04, 0.08, 0.12, 0.16],
+ "string": ["f0", "f1", "f2", "f3", "f4"],
+}
+
+
+@pytest.mark.parametrize("kind", SERIES_INDEXES)
+def test_switches_and_ic_read_series_by_position(kind):
+ """A Series is read by frame position; its index labels are ignored.
+
+ pred=[1,1,0,0,0], gt=(0,2): one switch (frames 1-2) in length 2 -> IC 0.5.
+ Read by label, the 1-based and float indexes raise KeyError and the
+ reversed one reads frames 4, 3, 2, which hold no switch.
+ """
+ pred = pd.Series([1, 1, 0, 0, 0], index=SERIES_INDEXES[kind])
+ assert count_switches_within_bout(pred, 0, 2) == 1
+ assert calculate_ic(pred, [(0, 2)]) == 0.5
+
+
# --- calculate_ic ---
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index 63c30aa..318cc78 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -149,6 +149,32 @@ def test_score_version():
assert banos.__version__ != ""
+FRAME_INDEXES = {
+ "1-based": range(1, N_FRAMES + 1),
+ "reversed": range(N_FRAMES - 1, -1, -1),
+ "float": [frame / 30 for frame in range(N_FRAMES)],
+ "string": [f"frame_{frame}" for frame in range(N_FRAMES)],
+}
+
+
+@pytest.mark.parametrize("kind", FRAME_INDEXES)
+def test_score_reads_frames_by_position_whatever_the_index(kind):
+ """The DataFrame index is ignored: every metric reads the frames by position.
+
+ Real bouts (5,9), (25,29), (45,49); predicted (5,9), (25,27), (45,49).
+ The second real bout has one switch (frames 27-28) in length 4, so
+ IC = (1 + 3/4 + 1) / 3 = 11/12 with the default index, and must stay so
+ with frames numbered from 1, reversed labels, times in seconds or strings.
+ Read by label, IC was 2/3 (1-based), 1.0 (reversed) or a KeyError.
+ """
+ pred, gt = _video([(5, 9), (25, 27), (45, 49)])
+ expected = banos.score(pred, gt)
+ assert abs(expected["evt"]["ic"] - 11 / 12) < 1e-10
+
+ index = pd.Index(FRAME_INDEXES[kind])
+ assert banos.score(pred.set_axis(index), gt.set_axis(index)) == expected
+
+
def test_file_with_only_false_bouts_scores_f1_zero():
"""A file whose predicted bouts all miss the real bouts scores F1 0 and stays in the mean.
From 9ca7b63564dce2994f728584fa139b4bb674ec86 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Beno=C3=AEt=20J=2E=20T=2E=20GIRARD?=
<53222414+BenoitJT-GIRARD@users.noreply.github.com>
Date: Thu, 29 Oct 2026 21:55:57 +0100
Subject: [PATCH 03/25] fix: average intra-bout continuity over found real
bouts only
calculate_ic averaged 1 - switches / (length - 1) over every real bout.
A missed real bout has no predicted frame, hence no switch, and scored
1.0: missing a bout raised IC, and a model that predicted nothing got
IC 1.0. IC now averages over the real bouts that contain at least one
predicted frame (the overlap rule of SO and TP) and is NaN when no real
bout is found. One-frame real bouts stay out of IC. The MATLAB function
BANOScalculateIC gets the same rule.
39 golden IC values change, in every recording. Recording_8 mount IC
goes from 1.0 to null: its two real bouts are both missed.
---
matlab/BANOS/BANOScalculateIC.m | 15 +-
src/banos/core/metrics.py | 11 +-
tests/fixtures/golden_matlab_v110.json | 2 +-
tests/fixtures/golden_python_v110.json | 136 +++++++++---------
.../python/generate_golden_fixtures.py | 2 +
tests/test_metrics.py | 16 +++
tests/test_pipeline.py | 5 +
tests/test_regression.py | 2 +
8 files changed, 111 insertions(+), 78 deletions(-)
diff --git a/matlab/BANOS/BANOScalculateIC.m b/matlab/BANOS/BANOScalculateIC.m
index e4cd775..3a7f5be 100644
--- a/matlab/BANOS/BANOScalculateIC.m
+++ b/matlab/BANOS/BANOScalculateIC.m
@@ -1,9 +1,11 @@
function ic = BANOScalculateIC(predSequence, gtBouts)
% Calculates the Intra-bout Continuity (IC) for each ground truth bout.
%
- % IC = 1 - (switches / boutLength) averaged over all GT bouts.
- % Zero-length bouts are excluded from the average (contribute NaN).
- % Returns NaN if gtBouts is empty or all bouts are zero-length
+ % IC = 1 - (switches / boutLength) averaged over the found GT bouts.
+ % A GT bout is found when at least one of its frames is predicted (value 1).
+ % Missed bouts and zero-length bouts are excluded from the average
+ % (contribute NaN).
+ % Returns NaN if gtBouts is empty or no found bout is longer than zero
% (matches Python behaviour — NaN propagates through nanmean).
%
% Arguments:
@@ -11,16 +13,17 @@
% gtBouts - Cell array of ground truth bouts {[start, end], ...}.
%
% Returns:
- % ic - The average IC across GT bouts, or NaN if undefined.
+ % ic - The average IC across found GT bouts, or NaN if undefined.
icScores = [];
for i = 1:length(gtBouts)
boutLength = gtBouts{i}(2) - gtBouts{i}(1);
switches = BANOScountSwitchesWithinBout(predSequence, gtBouts{i}(1), gtBouts{i}(2));
- if boutLength > 0
+ found = any(predSequence(gtBouts{i}(1):gtBouts{i}(2)) == 1);
+ if boutLength > 0 && found
icScores(end + 1) = 1 - (switches / boutLength);
else
- icScores(end + 1) = NaN; % zero-length bout: undefined, excluded from mean
+ icScores(end + 1) = NaN; % zero-length or missed bout: excluded from mean
end
end
diff --git a/src/banos/core/metrics.py b/src/banos/core/metrics.py
index d0e2ca0..3663022 100644
--- a/src/banos/core/metrics.py
+++ b/src/banos/core/metrics.py
@@ -114,10 +114,14 @@ def count_switches_within_bout(
def calculate_ic(
pred_sequence: pd.Series | np.ndarray | list, gt_bouts: list[tuple[int, int]]
) -> float:
- """Intra-bout Continuity: 1 - (switches / gt_length) averaged over GT bouts.
+ """Intra-bout Continuity: 1 - (switches / gt_length) averaged over found GT bouts.
+ A GT bout is found when at least one of its frames is predicted (value 1).
+ Missed GT bouts are left out of the average, and so are one-frame GT bouts
+ (gt_length = end - start = 0, no frame pair to compare).
Frames are read by position: the index labels of a Series are ignored.
- Returns NaN if there are no GT bouts.
+ Returns NaN if there are no GT bouts or none of them is found and longer
+ than one frame.
"""
if not gt_bouts:
return float("nan")
@@ -125,7 +129,8 @@ def calculate_ic(
ic_scores = []
for gt in gt_bouts:
gt_length = gt[1] - gt[0]
- if gt_length > 0:
+ found = any(pred_sequence[i] == 1 for i in range(gt[0], gt[1] + 1))
+ if gt_length > 0 and found:
switches = count_switches_within_bout(pred_sequence, gt[0], gt[1])
ic_scores.append(1.0 - (switches / gt_length))
else:
diff --git a/tests/fixtures/golden_matlab_v110.json b/tests/fixtures/golden_matlab_v110.json
index a0936cd..28582b7 100644
--- a/tests/fixtures/golden_matlab_v110.json
+++ b/tests/fixtures/golden_matlab_v110.json
@@ -1 +1 @@
-{"Recording_1":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.98294071362580027},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.99086091350216854},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.99306915975344445}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.98294071362580027},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.99086091350216854},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.99306915975344445}},"overall_metrics":{"precision":0.92826086956521736,"recall":0.78129251700680269,"f1_score":0.836455036759336,"so":0.761228298078524,"tp":0.30623014745659,"ic":0.99171769672035337}},"Recording_2":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98905093606628935},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99464840013584821},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99192990230311961}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98905093606628935},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99464840013584821},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99192990230311961}},"overall_metrics":{"precision":0.9671052631578948,"recall":0.83992445266728333,"f1_score":0.897300738477209,"so":0.82594559036003834,"tp":0.30908214265027689,"ic":0.99390730962631435}},"Recording_3":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98496628121015162},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.9917129219739137},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99621162045778}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98496628121015162},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.9917129219739137},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99621162045778}},"overall_metrics":{"precision":0.95948072383949645,"recall":0.85096664987969339,"f1_score":0.89862686038270723,"so":0.723715686180092,"tp":0.26887555753525666,"ic":0.99322270591046136}},"Recording_4":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.99528949732999117},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99394902176918432}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.99528949732999117},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99394902176918432}},"overall_metrics":{"precision":0.975,"recall":0.61568627450980384,"f1_score":0.72916666666666674,"so":0.70708735677240686,"tp":0.27571526135052643,"ic":0.9965302523591395}},"Recording_5":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98991370716597},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99654108838242228},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.994024222522601}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98991370716597},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99654108838242228},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.994024222522601}},"overall_metrics":{"precision":1,"recall":0.89210526315789473,"f1_score":0.94145869368256319,"so":0.83673046008612484,"tp":0.27928892486843937,"ic":0.99511975451774837}},"Recording_6":{"file_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.99366643311730019},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.99039202618472566},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.99188832402173766}},"group_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.99366643311730019},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.99039202618472566},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.99188832402173766}},"overall_metrics":{"precision":0.91408268733850129,"recall":0.74567099567099571,"f1_score":0.79250474383301706,"so":0.624464020496978,"tp":0.26406773460888022,"ic":0.9939866958309409}},"Recording_7":{"file_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98947078465360738},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.99454387011498291},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.995759546530849}},"group_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98947078465360738},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.99454387011498291},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.995759546530849}},"overall_metrics":{"precision":1,"recall":0.58390957446808511,"f1_score":0.70652984743264124,"so":0.66449505594099667,"tp":0.27653014329623343,"ic":0.99494355032485982}},"Recording_8":{"file_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99528213381542252},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":1},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99510532135592722}},"group_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99528213381542252},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":1},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99510532135592722}},"overall_metrics":{"precision":0.89166666666666661,"recall":0.34672064777327932,"f1_score":0.45462962962962961,"so":0.53056180162368516,"tp":0.038138242319112579,"ic":0.99557663443524391}},"Recording_9":{"file_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98938636025336613},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99863004362050167},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99466920484634314}},"group_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98938636025336613},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99863004362050167},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99466920484634314}},"overall_metrics":{"precision":0.79839767616191892,"recall":0.65381944444444451,"f1_score":0.712354862119013,"so":0.68272952732383074,"tp":0.27780938219974027,"ic":0.99567140218005268}},"Recording_10":{"file_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.99141187589349233},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.99506116351626372},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.998292947043181}},"group_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.99141187589349233},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.99506116351626372},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.998292947043181}},"overall_metrics":{"precision":0.89910714285714288,"recall":0.47575971177944865,"f1_score":0.58347469607011593,"so":0.66680627484973121,"tp":0.28592277410718,"ic":0.99619149661323425}}}
+{"Recording_1":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.9805603480852142},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.98286421281656633},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.992439083367394}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.9805603480852142},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.98286421281656633},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.992439083367394}},"overall_metrics":{"precision":0.92826086956521736,"recall":0.78129251700680269,"f1_score":0.836455036759336,"so":0.761228298078524,"tp":0.30623014745659,"ic":0.98896591106729359}},"Recording_2":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98771568436705637},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99357808016301785},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99074891239625884}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98771568436705637},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99357808016301785},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99074891239625884}},"overall_metrics":{"precision":0.9671052631578948,"recall":0.83992445266728333,"f1_score":0.897300738477209,"so":0.82594559036003834,"tp":0.30908214265027689,"ic":0.99301066923158332}},"Recording_3":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98261726264923754},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.98941095585555594},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99591253786234157}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98261726264923754},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.98941095585555594},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99591253786234157}},"overall_metrics":{"precision":0.95948072383949645,"recall":0.85096664987969339,"f1_score":0.89862686038270723,"so":0.723715686180092,"tp":0.26887555753525666,"ic":0.99198518909178379}},"Recording_4":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.98586849198997339},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99092353265377653}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.98586849198997339},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99092353265377653}},"overall_metrics":{"precision":0.975,"recall":0.61568627450980384,"f1_score":0.72916666666666674,"so":0.70708735677240686,"tp":0.27571526135052643,"ic":0.99341862874528308}},"Recording_5":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98823265836029839},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99615676486935811},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.993672706200401}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98823265836029839},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99615676486935811},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.993672706200401}},"overall_metrics":{"precision":1,"recall":0.89210526315789473,"f1_score":0.94145869368256319,"so":0.83673046008612484,"tp":0.27928892486843937,"ic":0.99451553235751433}},"Recording_6":{"file_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.98838846071505027},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.98965295127585839},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.99046878072554168}},"group_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.98838846071505027},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.98965295127585839},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.99046878072554168}},"overall_metrics":{"precision":0.91408268733850129,"recall":0.74567099567099571,"f1_score":0.79250474383301706,"so":0.624464020496978,"tp":0.26406773460888022,"ic":0.99212754817911253}},"Recording_7":{"file_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98891661542484988},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.98181290038327618},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.99318498549600709}},"group_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98891661542484988},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.98181290038327618},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.99318498549600709}},"overall_metrics":{"precision":1,"recall":0.58390957446808511,"f1_score":0.70652984743264124,"so":0.66449505594099667,"tp":0.27653014329623343,"ic":0.99097862532603331}},"Recording_8":{"file_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99318530440005481},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":null},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99102642248586637}},"group_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99318530440005481},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":null},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99102642248586637}},"overall_metrics":{"precision":0.89166666666666661,"recall":0.34672064777327932,"f1_score":0.45462962962962961,"so":0.53056180162368516,"tp":0.038138242319112579,"ic":0.992043603151849}},"Recording_9":{"file_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98862824312860653},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99269356597600877},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99282392960084676}},"group_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98862824312860653},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99269356597600877},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99282392960084676}},"overall_metrics":{"precision":0.79839767616191892,"recall":0.65381944444444451,"f1_score":0.712354862119013,"so":0.68272952732383074,"tp":0.27780938219974027,"ic":0.99353643467636554}},"Recording_10":{"file_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.9908056553683271},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.98123242136180233},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.99554269505719462}},"group_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.9908056553683271},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.98123242136180233},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.99554269505719462}},"overall_metrics":{"precision":0.89910714285714288,"recall":0.47575971177944865,"f1_score":0.58347469607011593,"so":0.66680627484973121,"tp":0.28592277410718,"ic":0.99189519294683093}}}
diff --git a/tests/fixtures/golden_python_v110.json b/tests/fixtures/golden_python_v110.json
index 37ed23f..d111c44 100644
--- a/tests/fixtures/golden_python_v110.json
+++ b/tests/fixtures/golden_python_v110.json
@@ -16,7 +16,7 @@
"f1_score": 0.7659574468085106,
"so": 0.5444095073699892,
"tp": 0.0846356832664267,
- "ic": 0.9829407136258003
+ "ic": 0.9805603480852142
},
"mount": {
"precision": 1.0,
@@ -24,7 +24,7 @@
"f1_score": 0.6956521739130436,
"so": 0.7332766872436964,
"tp": 0.07847125492506643,
- "ic": 0.9908609135021688
+ "ic": 0.9828642128165663
},
"other": {
"precision": 0.9130434782608695,
@@ -32,7 +32,7 @@
"f1_score": 0.8842105263157894,
"so": 0.7672269977004106,
"tp": 0.061813651634866906,
- "ic": 0.9930691597534443
+ "ic": 0.9924390833673938
}
}
},
@@ -51,7 +51,7 @@
"f1_score": 0.7659574468085106,
"so": 0.5444095073699892,
"tp": 0.0846356832664267,
- "ic": 0.9829407136258003
+ "ic": 0.9805603480852142
},
"mount": {
"precision": 1.0,
@@ -59,7 +59,7 @@
"f1_score": 0.6956521739130436,
"so": 0.7332766872436964,
"tp": 0.07847125492506643,
- "ic": 0.9908609135021688
+ "ic": 0.9828642128165663
},
"other": {
"precision": 0.9130434782608695,
@@ -67,7 +67,7 @@
"f1_score": 0.8842105263157894,
"so": 0.7672269977004106,
"tp": 0.061813651634866906,
- "ic": 0.9930691597534443
+ "ic": 0.9924390833673938
}
},
"overall_metrics": {
@@ -76,7 +76,7 @@
"f1_score": 0.836455036759336,
"so": 0.7612282980785241,
"tp": 0.30623014745659,
- "ic": 0.9917176967203534
+ "ic": 0.9889659110672936
}
},
"Recording_2": {
@@ -96,7 +96,7 @@
"f1_score": 0.8095238095238095,
"so": 0.6609424826096988,
"tp": 0.04615109869237036,
- "ic": 0.9890509360662894
+ "ic": 0.9877156843670564
},
"mount": {
"precision": 1.0,
@@ -104,7 +104,7 @@
"f1_score": 0.9090909090909091,
"so": 0.931754332584827,
"tp": 0.1451695616211745,
- "ic": 0.9946484001358482
+ "ic": 0.9935780801630179
},
"other": {
"precision": 0.9736842105263158,
@@ -112,7 +112,7 @@
"f1_score": 0.8705882352941177,
"so": 0.7110855462456277,
"tp": 0.04500791028756257,
- "ic": 0.9919299023031196
+ "ic": 0.9907489123962591
}
}
},
@@ -131,7 +131,7 @@
"f1_score": 0.8095238095238095,
"so": 0.6609424826096988,
"tp": 0.04615109869237036,
- "ic": 0.9890509360662894
+ "ic": 0.9877156843670564
},
"mount": {
"precision": 1.0,
@@ -139,7 +139,7 @@
"f1_score": 0.9090909090909091,
"so": 0.931754332584827,
"tp": 0.1451695616211745,
- "ic": 0.9946484001358482
+ "ic": 0.9935780801630179
},
"other": {
"precision": 0.9736842105263158,
@@ -147,7 +147,7 @@
"f1_score": 0.8705882352941177,
"so": 0.7110855462456277,
"tp": 0.04500791028756257,
- "ic": 0.9919299023031196
+ "ic": 0.9907489123962591
}
},
"overall_metrics": {
@@ -156,7 +156,7 @@
"f1_score": 0.897300738477209,
"so": 0.8259455903600383,
"tp": 0.30908214265027684,
- "ic": 0.9939073096263142
+ "ic": 0.9930106692315833
}
},
"Recording_3": {
@@ -176,7 +176,7 @@
"f1_score": 0.8529411764705881,
"so": 0.4563103530678653,
"tp": 0.02232158655731043,
- "ic": 0.9849662812101514
+ "ic": 0.9826172626492377
},
"mount": {
"precision": 1.0,
@@ -184,7 +184,7 @@
"f1_score": 0.85,
"so": 0.6745042152422358,
"tp": 0.028794827943741816,
- "ic": 0.9917129219739135
+ "ic": 0.9894109558555562
},
"other": {
"precision": 0.9024390243902439,
@@ -192,7 +192,7 @@
"f1_score": 0.8915662650602411,
"so": 0.7640481764102668,
"tp": 0.024385815639974426,
- "ic": 0.9962116204577801
+ "ic": 0.9959125378623417
}
}
},
@@ -211,7 +211,7 @@
"f1_score": 0.8529411764705881,
"so": 0.4563103530678653,
"tp": 0.02232158655731043,
- "ic": 0.9849662812101514
+ "ic": 0.9826172626492377
},
"mount": {
"precision": 1.0,
@@ -219,7 +219,7 @@
"f1_score": 0.85,
"so": 0.6745042152422358,
"tp": 0.028794827943741816,
- "ic": 0.9917129219739135
+ "ic": 0.9894109558555562
},
"other": {
"precision": 0.9024390243902439,
@@ -227,7 +227,7 @@
"f1_score": 0.8915662650602411,
"so": 0.7640481764102668,
"tp": 0.024385815639974426,
- "ic": 0.9962116204577801
+ "ic": 0.9959125378623417
}
},
"overall_metrics": {
@@ -236,7 +236,7 @@
"f1_score": 0.8986268603827072,
"so": 0.723715686180092,
"tp": 0.26887555753525666,
- "ic": 0.9932227059104612
+ "ic": 0.9919851890917839
}
},
"Recording_4": {
@@ -264,7 +264,7 @@
"f1_score": 0.5,
"so": 0.7011185281643236,
"tp": 0.04008717445788384,
- "ic": 0.9952894973299912
+ "ic": 0.9858684919899735
},
"other": {
"precision": 1.0,
@@ -272,7 +272,7 @@
"f1_score": 0.7499999999999999,
"so": 0.7357598909762686,
"tp": 0.04874865293870875,
- "ic": 0.9939490217691843
+ "ic": 0.9909235326537765
}
}
},
@@ -299,7 +299,7 @@
"f1_score": 0.5,
"so": 0.7011185281643236,
"tp": 0.04008717445788384,
- "ic": 0.9952894973299912
+ "ic": 0.9858684919899735
},
"other": {
"precision": 1.0,
@@ -307,7 +307,7 @@
"f1_score": 0.7499999999999999,
"so": 0.7357598909762686,
"tp": 0.04874865293870875,
- "ic": 0.9939490217691843
+ "ic": 0.9909235326537765
}
},
"overall_metrics": {
@@ -316,7 +316,7 @@
"f1_score": 0.7291666666666666,
"so": 0.7070873567724069,
"tp": 0.27571526135052643,
- "ic": 0.9965302523591395
+ "ic": 0.9934186287452831
}
},
"Recording_5": {
@@ -336,7 +336,7 @@
"f1_score": 0.888888888888889,
"so": 0.6589591312525748,
"tp": 0.035333351984487595,
- "ic": 0.98991370716597
+ "ic": 0.9882326583602984
},
"mount": {
"precision": 1.0,
@@ -344,7 +344,7 @@
"f1_score": 0.9473684210526316,
"so": 0.9100237928302215,
"tp": 0.03596010219174562,
- "ic": 0.9965410883824223
+ "ic": 0.9961567648693581
},
"other": {
"precision": 1.0,
@@ -352,7 +352,7 @@
"f1_score": 0.9295774647887324,
"so": 0.7779389162617028,
"tp": 0.045862245297524175,
- "ic": 0.9940242225226008
+ "ic": 0.9936727062004008
}
}
},
@@ -371,7 +371,7 @@
"f1_score": 0.888888888888889,
"so": 0.6589591312525748,
"tp": 0.035333351984487595,
- "ic": 0.98991370716597
+ "ic": 0.9882326583602984
},
"mount": {
"precision": 1.0,
@@ -379,7 +379,7 @@
"f1_score": 0.9473684210526316,
"so": 0.9100237928302215,
"tp": 0.03596010219174562,
- "ic": 0.9965410883824223
+ "ic": 0.9961567648693581
},
"other": {
"precision": 1.0,
@@ -387,7 +387,7 @@
"f1_score": 0.9295774647887324,
"so": 0.7779389162617028,
"tp": 0.045862245297524175,
- "ic": 0.9940242225226008
+ "ic": 0.9936727062004008
}
},
"overall_metrics": {
@@ -396,7 +396,7 @@
"f1_score": 0.9414586936825633,
"so": 0.8367304600861247,
"tp": 0.27928892486843937,
- "ic": 0.9951197545177483
+ "ic": 0.9945155323575143
}
},
"Recording_6": {
@@ -408,7 +408,7 @@
"f1_score": 0.5333333333333333,
"so": 0.3554875922085192,
"tp": 0.014142932226192012,
- "ic": 0.9936664331173002
+ "ic": 0.9883884607150503
},
"investigation": {
"precision": 0.7674418604651163,
@@ -416,7 +416,7 @@
"f1_score": 0.7764705882352941,
"so": 0.5395797274871901,
"tp": 0.020316910620777756,
- "ic": 0.9903920261847257
+ "ic": 0.9896529512758584
},
"mount": {
"precision": 1.0,
@@ -432,7 +432,7 @@
"f1_score": 0.8602150537634408,
"so": 0.6027887622922026,
"tp": 0.021811095588551047,
- "ic": 0.9918883240217377
+ "ic": 0.9904687807255417
}
}
},
@@ -443,7 +443,7 @@
"f1_score": 0.5333333333333333,
"so": 0.3554875922085192,
"tp": 0.014142932226192012,
- "ic": 0.9936664331173002
+ "ic": 0.9883884607150503
},
"investigation": {
"precision": 0.7674418604651163,
@@ -451,7 +451,7 @@
"f1_score": 0.7764705882352941,
"so": 0.5395797274871901,
"tp": 0.020316910620777756,
- "ic": 0.9903920261847257
+ "ic": 0.9896529512758584
},
"mount": {
"precision": 1.0,
@@ -467,7 +467,7 @@
"f1_score": 0.8602150537634408,
"so": 0.6027887622922026,
"tp": 0.021811095588551047,
- "ic": 0.9918883240217377
+ "ic": 0.9904687807255417
}
},
"overall_metrics": {
@@ -476,7 +476,7 @@
"f1_score": 0.7925047438330171,
"so": 0.624464020496978,
"tp": 0.2640677346088802,
- "ic": 0.9939866958309409
+ "ic": 0.9921275481791125
}
},
"Recording_7": {
@@ -488,7 +488,7 @@
"f1_score": 0.6885245901639345,
"so": 0.3906951258181009,
"tp": 0.030228614492615706,
- "ic": 0.9894707846536077
+ "ic": 0.9889166154248502
},
"investigation": {
"precision": 1.0,
@@ -496,7 +496,7 @@
"f1_score": 0.4615384615384615,
"so": 0.536495310012478,
"tp": 0.031330852383483966,
- "ic": 0.9945438701149829
+ "ic": 0.9818129003832762
},
"mount": {
"precision": 1.0,
@@ -512,7 +512,7 @@
"f1_score": 0.676056338028169,
"so": 0.7307897879334082,
"tp": 0.044561106308833955,
- "ic": 0.9957595465308487
+ "ic": 0.993184985496007
}
}
},
@@ -523,7 +523,7 @@
"f1_score": 0.6885245901639345,
"so": 0.3906951258181009,
"tp": 0.030228614492615706,
- "ic": 0.9894707846536077
+ "ic": 0.9889166154248502
},
"investigation": {
"precision": 1.0,
@@ -531,7 +531,7 @@
"f1_score": 0.4615384615384615,
"so": 0.536495310012478,
"tp": 0.031330852383483966,
- "ic": 0.9945438701149829
+ "ic": 0.9818129003832762
},
"mount": {
"precision": 1.0,
@@ -547,7 +547,7 @@
"f1_score": 0.676056338028169,
"so": 0.7307897879334082,
"tp": 0.044561106308833955,
- "ic": 0.9957595465308487
+ "ic": 0.993184985496007
}
},
"overall_metrics": {
@@ -556,7 +556,7 @@
"f1_score": 0.7065298474326412,
"so": 0.6644950559409968,
"tp": 0.27653014329623343,
- "ic": 0.9949435503248598
+ "ic": 0.9909786253260333
}
},
"Recording_8": {
@@ -576,7 +576,7 @@
"f1_score": 0.7000000000000001,
"so": 0.5910507486515022,
"tp": 0.02527092835048601,
- "ic": 0.9952821338154225
+ "ic": 0.9931853044000548
},
"mount": {
"precision": null,
@@ -584,7 +584,7 @@
"f1_score": 0.0,
"so": null,
"tp": null,
- "ic": 1.0
+ "ic": null
},
"other": {
"precision": 0.8,
@@ -592,7 +592,7 @@
"f1_score": 0.6,
"so": 0.7322708331381532,
"tp": 0.06872092254807333,
- "ic": 0.995105321355927
+ "ic": 0.9910264224858661
}
}
},
@@ -611,7 +611,7 @@
"f1_score": 0.7000000000000001,
"so": 0.5910507486515022,
"tp": 0.02527092835048601,
- "ic": 0.9952821338154225
+ "ic": 0.9931853044000548
},
"mount": {
"precision": null,
@@ -619,7 +619,7 @@
"f1_score": 0.0,
"so": null,
"tp": null,
- "ic": 1.0
+ "ic": null
},
"other": {
"precision": 0.8,
@@ -627,7 +627,7 @@
"f1_score": 0.6,
"so": 0.7322708331381532,
"tp": 0.06872092254807333,
- "ic": 0.995105321355927
+ "ic": 0.9910264224858661
}
},
"overall_metrics": {
@@ -636,7 +636,7 @@
"f1_score": 0.4546296296296296,
"so": 0.5305618016236853,
"tp": 0.03813824231911258,
- "ic": 0.9955766344352438
+ "ic": 0.9920436031518488
}
},
"Recording_9": {
@@ -648,7 +648,7 @@
"f1_score": 0.8301886792452831,
"so": 0.4241002824392379,
"tp": 0.0205898063721443,
- "ic": 0.989386360253366
+ "ic": 0.9886282431286064
},
"investigation": {
"precision": 0.375,
@@ -656,7 +656,7 @@
"f1_score": 0.25,
"so": 0.631678281068525,
"tp": 0.03480952380952381,
- "ic": 0.9986300436205017
+ "ic": 0.9926935659760088
},
"mount": {
"precision": 1.0,
@@ -672,7 +672,7 @@
"f1_score": 0.7692307692307692,
"so": 0.6751395457875596,
"tp": 0.05583819861729283,
- "ic": 0.9946692048463431
+ "ic": 0.9928239296008466
}
}
},
@@ -683,7 +683,7 @@
"f1_score": 0.8301886792452831,
"so": 0.4241002824392379,
"tp": 0.0205898063721443,
- "ic": 0.989386360253366
+ "ic": 0.9886282431286064
},
"investigation": {
"precision": 0.375,
@@ -691,7 +691,7 @@
"f1_score": 0.25,
"so": 0.631678281068525,
"tp": 0.03480952380952381,
- "ic": 0.9986300436205017
+ "ic": 0.9926935659760088
},
"mount": {
"precision": 1.0,
@@ -707,7 +707,7 @@
"f1_score": 0.7692307692307692,
"so": 0.6751395457875596,
"tp": 0.05583819861729283,
- "ic": 0.9946692048463431
+ "ic": 0.9928239296008466
}
},
"overall_metrics": {
@@ -716,7 +716,7 @@
"f1_score": 0.712354862119013,
"so": 0.6827295273238306,
"tp": 0.2778093821997402,
- "ic": 0.9956714021800527
+ "ic": 0.9935364346763654
}
},
"Recording_10": {
@@ -728,7 +728,7 @@
"f1_score": 0.4444444444444445,
"so": 0.18490328157186495,
"tp": 0.011189008329645374,
- "ic": 0.9914118758934924
+ "ic": 0.9908056553683272
},
"investigation": {
"precision": 0.625,
@@ -736,7 +736,7 @@
"f1_score": 0.37037037037037035,
"so": 0.6572072189744603,
"tp": 0.06468253968253967,
- "ic": 0.9950611635162638
+ "ic": 0.9812324213618023
},
"mount": {
"precision": 1.0,
@@ -752,7 +752,7 @@
"f1_score": 0.5190839694656488,
"so": 0.8251145988525997,
"tp": 0.06781954841653498,
- "ic": 0.998292947043181
+ "ic": 0.9955426950571944
}
}
},
@@ -763,7 +763,7 @@
"f1_score": 0.4444444444444445,
"so": 0.18490328157186495,
"tp": 0.011189008329645374,
- "ic": 0.9914118758934924
+ "ic": 0.9908056553683272
},
"investigation": {
"precision": 0.625,
@@ -771,7 +771,7 @@
"f1_score": 0.37037037037037035,
"so": 0.6572072189744603,
"tp": 0.06468253968253967,
- "ic": 0.9950611635162638
+ "ic": 0.9812324213618023
},
"mount": {
"precision": 1.0,
@@ -787,7 +787,7 @@
"f1_score": 0.5190839694656488,
"so": 0.8251145988525997,
"tp": 0.06781954841653498,
- "ic": 0.998292947043181
+ "ic": 0.9955426950571944
}
},
"overall_metrics": {
@@ -796,7 +796,7 @@
"f1_score": 0.5834746960701159,
"so": 0.6668062748497312,
"tp": 0.28592277410718003,
- "ic": 0.9961914966132344
+ "ic": 0.9918951929468309
}
}
}
diff --git a/tests/fixtures/python/generate_golden_fixtures.py b/tests/fixtures/python/generate_golden_fixtures.py
index 2c1b613..cd4f10b 100644
--- a/tests/fixtures/python/generate_golden_fixtures.py
+++ b/tests/fixtures/python/generate_golden_fixtures.py
@@ -5,6 +5,8 @@
Changes since the v0.2.0 fixtures (golden_python_v020.json, kept as an archive):
1. Detection F1 is 0, not NaN, when a recording has real bouts and no
predicted bout matches any of them.
+ 2. IC averages over the real bouts that contain a predicted frame; missed
+ bouts no longer score 1.0.
Run once to regenerate after a deliberate algorithm change:
python tests/fixtures/python/generate_golden_fixtures.py
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
index 9459bc7..0bdc319 100644
--- a/tests/test_metrics.py
+++ b/tests/test_metrics.py
@@ -11,6 +11,7 @@
- No GT bouts -> recall denominator is 0 -> recall = NaN, not 0
- No overlapping bouts -> SO/TP have no inputs -> NaN, not 0
- Single-frame GT bout -> IC denominator is 0 -> NaN, not 0
+ - No GT bout found (no predicted frame inside) -> IC has no bout to average -> NaN
WHY NaN and not 0: returning 0 would bias the group/overall averages downward
by treating "undefined" as "worst possible score". nanmean() in aggregate_metrics
@@ -267,3 +268,18 @@ def test_ic_zero_length_bout_excluded():
result = calculate_ic([1], [(0, 0)])
# gt_length = 0, so this bout is skipped -> no valid scores -> NaN
assert math.isnan(result)
+
+
+def test_ic_leaves_missed_bouts_out():
+ """IC averages over the found GT bouts only; a missed bout does not score 1.0.
+
+ pred=[1,1,0,0,0,0,0], gt=(0,2) and (4,6). Bout (0,2) is found: one switch
+ in length 2 -> 0.5. Bout (4,6) has no predicted frame and is left out, so
+ IC = 0.5 (it was (0.5 + 1.0) / 2 = 0.75 when a missed bout scored 1.0).
+ """
+ assert calculate_ic([1, 1, 0, 0, 0, 0, 0], [(0, 2), (4, 6)]) == 0.5
+
+
+def test_ic_nan_when_no_bout_is_found():
+ """Nothing predicted inside any GT bout: IC has nothing to average (NaN, not 1.0)."""
+ assert math.isnan(calculate_ic([0, 0, 0, 0, 0], [(0, 1), (3, 4)]))
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index 318cc78..2856559 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -205,6 +205,9 @@ def test_silent_model_on_bundled_data(all_recordings):
mount 4/10 = 0.4, investigation 0, other 0; overall F1 = 9 cells of 40 =
0.225. Before 1.1.0 the recordings with the behavior scored F1 NaN and left
the means: attack, mount and overall F1 were 1.0.
+
+ IC has no found bout to average in any recording with the behavior, so
+ investigation and other (present in all 10) have IC NaN, not 1.0.
"""
data = {f"Recording_{n}": (gt * 0, gt) for n, (_, gt) in all_recordings.items()}
group, overall = banos.score(data)
@@ -212,3 +215,5 @@ def test_silent_model_on_bundled_data(all_recordings):
for behavior, f1 in expected.items():
assert abs(group[behavior]["f1_score"] - f1) < 1e-10, behavior
assert abs(overall["f1_score"] - 0.225) < 1e-10
+ assert math.isnan(group["investigation"]["ic"])
+ assert math.isnan(group["other"]["ic"])
diff --git a/tests/test_regression.py b/tests/test_regression.py
index 4ea6837..c656e73 100644
--- a/tests/test_regression.py
+++ b/tests/test_regression.py
@@ -11,6 +11,8 @@
v1.1.0 behaviour. Changes since v0.2.0 that move values of the bundled data:
1. Detection F1 is 0, not NaN, when a recording has real bouts and no
predicted bout matches any of them (Recording_8 mount).
+ 2. IC averages over the real bouts that contain a predicted frame; missed
+ bouts no longer score 1.0 (39 IC values, Recording_8 mount IC is NaN).
The archive fixtures (tests/fixtures/golden_python_v020.json and
golden_python_v015.json) preserve the v0.2.0 and v0.1.5 behaviour for
From 94e36b46fcf7158023731b9cccb4e4f879427c2f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Beno=C3=AEt=20J=2E=20T=2E=20GIRARD?=
<53222414+BenoitJT-GIRARD@users.noreply.github.com>
Date: Thu, 29 Oct 2026 22:01:22 +0100
Subject: [PATCH 04/25] feat: add missed_bouts option to score missed real
bouts as zero
SO and TP average over the overlapping (pred, GT) bout pairs and IC
over the found real bouts, so a real bout with no predicted frame inside
it leaves all three averages. The new missed_bouts option keeps this as
the default ("exclude") or adds one term equal to 0 per missed real bout
to SO, TP and IC ("zero"); one-frame real bouts stay out of IC.
Precision, recall, F1 and the absent-behavior rule do not depend on it.
calculate_so_terms, calculate_tp_terms and calculate_ic_terms return
the terms behind each average; calculate_so, calculate_tp_metric and
calculate_ic return their mean. They take the option as a keyword, as
do compute_behavior_metrics, calculate_banos_for_each_file and score(),
which check it on entry: an unknown value raises even when no column
reaches the metrics.
MATLAB takes it as the 'missed_bouts' name-value pair, read and
validated by the new BANOSparseOptions: an unknown option name or value
now raises an error. BANOScalculateSO, BANOScalculateTP and
BANOScalculateIC return their terms as a second output. Default results
are unchanged; six MATLAB golden IC values move by one or two units in
the last place, IC being now the mean of its terms instead of a nanmean.
---
matlab/BANOS/BANOS_score.m | 52 +++---
matlab/BANOS/BANOScalculateBANOSForEachFile.m | 9 +-
matlab/BANOS/BANOScalculateIC.m | 40 +++--
matlab/BANOS/BANOScalculateSO.m | 29 +++-
matlab/BANOS/BANOScalculateTP.m | 30 +++-
matlab/BANOS/BANOScomputeBehaviorMetrics.m | 15 +-
matlab/BANOS/BANOSparseOptions.m | 56 ++++++
matlab/BANOS/Contents.m | 1 +
src/banos/__init__.py | 18 +-
src/banos/core/__init__.py | 6 +
src/banos/core/metrics.py | 161 +++++++++++++-----
src/banos/pipeline.py | 20 ++-
tests/fixtures/golden_matlab_v110.json | 2 +-
tests/test_metrics.py | 91 ++++++++++
tests/test_pipeline.py | 70 ++++++++
15 files changed, 484 insertions(+), 116 deletions(-)
create mode 100644 matlab/BANOS/BANOSparseOptions.m
diff --git a/matlab/BANOS/BANOS_score.m b/matlab/BANOS/BANOS_score.m
index 76341f1..b0c0e17 100644
--- a/matlab/BANOS/BANOS_score.m
+++ b/matlab/BANOS/BANOS_score.m
@@ -20,8 +20,14 @@
% overallMetrics (single average across all behaviors and recordings).
%
% NAME-VALUE OPTIONS:
-% 'matching' - 'greedy' (default). 'optimal' is not yet implemented in
-% MATLAB; use the Python package for optimal matching.
+% 'matching' - 'greedy' (default). 'optimal' is not yet implemented in
+% MATLAB; use the Python package for optimal matching.
+% 'missed_bouts' - how SO, TP and IC count a GT bout with no predicted
+% frame inside it: 'exclude' (default) leaves it out of
+% their averages, 'zero' adds a term equal to 0 to each.
+% Precision, recall and F1 do not depend on it.
+% Option names and values are case-insensitive. An unknown name or value
+% raises an error (BANOS:unknownOption, BANOS:invalidOption).
%
% NaN VALUES:
% A behavior with no ground-truth bouts returns NaN for all metrics.
@@ -44,24 +50,19 @@
% BANOSaggregateMetrics, Contents
% --- Parse name-value options ------------------------------------------
- matching = 'greedy';
- i = 1;
- while i <= length(varargin)
- if ischar(varargin{i}) || isstring(varargin{i})
- key = lower(char(varargin{i}));
- if strcmp(key, 'matching')
- if i + 1 > length(varargin)
- error('BANOS_score: ''matching'' option requires a value.');
- end
- matching = lower(char(varargin{i + 1}));
- i = i + 2;
- continue;
- end
+ % Single recording: the first cell array argument is the ground truth;
+ % the remaining arguments are name-value options.
+ gt = [];
+ if iscell(data)
+ gtIndex = find(cellfun(@iscell, varargin), 1);
+ if ~isempty(gtIndex)
+ gt = varargin{gtIndex};
+ varargin(gtIndex) = [];
end
- i = i + 1;
end
+ options = BANOSparseOptions(varargin, {'matching', 'missed_bouts'});
- if strcmp(matching, 'optimal')
+ if strcmp(options.matching, 'optimal')
error('BANOS_score: optimal matching is not yet implemented in MATLAB. Use the Python package (banos.score(..., matching=''optimal'')).');
end
@@ -69,28 +70,23 @@
if isstruct(data)
% Multi-recording: data is already a properly formatted struct
[preprocessed, ~] = BANOSpreprocessData(data);
- fileMetrics = BANOScalculateBANOSForEachFile(preprocessed);
+ fileMetrics = BANOScalculateBANOSForEachFile(preprocessed, ...
+ 'missed_bouts', options.missed_bouts);
[groupMetrics, overallMetrics] = BANOSaggregateMetrics(fileMetrics);
varargout{1} = groupMetrics;
varargout{2} = overallMetrics;
elseif iscell(data)
- % Single-recording: data = pred cell array, varargin{1} = gt cell array
- % (after matching option parsing, the first non-option arg is gt)
- gt = [];
- for j = 1:length(varargin)
- if iscell(varargin{j})
- gt = varargin{j};
- break;
- end
- end
+ % Single-recording: data = pred cell array, gt = first cell array in
+ % varargin (taken out above, before the options were parsed)
if isempty(gt)
error('BANOS_score: single-recording mode requires two cell array arguments: BANOS_score(pred, gt).');
end
tmpData.single_recording = {data, gt};
[preprocessed, ~] = BANOSpreprocessData(tmpData);
- fileMetrics = BANOScalculateBANOSForEachFile(preprocessed);
+ fileMetrics = BANOScalculateBANOSForEachFile(preprocessed, ...
+ 'missed_bouts', options.missed_bouts);
varargout{1} = fileMetrics.single_recording;
else
diff --git a/matlab/BANOS/BANOScalculateBANOSForEachFile.m b/matlab/BANOS/BANOScalculateBANOSForEachFile.m
index 35894cc..3f27055 100644
--- a/matlab/BANOS/BANOScalculateBANOSForEachFile.m
+++ b/matlab/BANOS/BANOScalculateBANOSForEachFile.m
@@ -1,12 +1,16 @@
-function banosMetrics = BANOScalculateBANOSForEachFile(preprocessedData)
+function banosMetrics = BANOScalculateBANOSForEachFile(preprocessedData, varargin)
% Calculates BANOS metrics for each behavior in each file.
%
% Arguments:
% preprocessedData - A struct with preprocessed prediction and ground truth data.
+ % 'missed_bouts' - (name-value) how SO, TP and IC count a GT bout that no
+ % predicted bout overlaps: 'exclude' (default) or 'zero'
+ % (see BANOScalculateSO).
%
% Returns:
% banosMetrics - A struct containing BANOS metrics for each behavior in each file.
+ options = BANOSparseOptions(varargin, {'missed_bouts'});
fileNames = fieldnames(preprocessedData);
banosMetrics = struct();
@@ -15,7 +19,8 @@
extractMatrix = preprocessedData.(fileName);
predMatrix = extractMatrix{1};
gtMatrix = extractMatrix{2};
- behaviorMetrics = BANOScomputeBehaviorMetrics(predMatrix, gtMatrix);
+ behaviorMetrics = BANOScomputeBehaviorMetrics(predMatrix, gtMatrix, ...
+ 'missed_bouts', options.missed_bouts);
banosMetrics.(fileName) = behaviorMetrics;
end
end
diff --git a/matlab/BANOS/BANOScalculateIC.m b/matlab/BANOS/BANOScalculateIC.m
index 3a7f5be..c6042d9 100644
--- a/matlab/BANOS/BANOScalculateIC.m
+++ b/matlab/BANOS/BANOScalculateIC.m
@@ -1,38 +1,42 @@
-function ic = BANOScalculateIC(predSequence, gtBouts)
+function [ic, icTerms] = BANOScalculateIC(predSequence, gtBouts, varargin)
% Calculates the Intra-bout Continuity (IC) for each ground truth bout.
%
% IC = 1 - (switches / boutLength) averaged over the found GT bouts.
% A GT bout is found when at least one of its frames is predicted (value 1).
- % Missed bouts and zero-length bouts are excluded from the average
- % (contribute NaN).
- % Returns NaN if gtBouts is empty or no found bout is longer than zero
- % (matches Python behaviour — NaN propagates through nanmean).
+ % Zero-length bouts (boutLength = 0) are always excluded from the average.
+ % Returns NaN if there is no term (matches Python behaviour — NaN
+ % propagates through nanmean).
%
% Arguments:
% predSequence - The sequence of predictions (1-based index vector).
% gtBouts - Cell array of ground truth bouts {[start, end], ...}.
+ % 'missed_bouts' - (name-value) how a missed GT bout is counted:
+ % 'exclude' (default) leaves it out, 'zero' adds a term
+ % equal to 0.
%
% Returns:
- % ic - The average IC across found GT bouts, or NaN if undefined.
+ % ic - The average of the terms, or NaN if undefined.
+ % icTerms - Row vector of the terms, one per found GT bout longer than
+ % zero, and 0 for each missed one with 'zero'.
- icScores = [];
+ options = BANOSparseOptions(varargin, {'missed_bouts'});
+ icTerms = [];
for i = 1:length(gtBouts)
boutLength = gtBouts{i}(2) - gtBouts{i}(1);
- switches = BANOScountSwitchesWithinBout(predSequence, gtBouts{i}(1), gtBouts{i}(2));
- found = any(predSequence(gtBouts{i}(1):gtBouts{i}(2)) == 1);
- if boutLength > 0 && found
- icScores(end + 1) = 1 - (switches / boutLength);
- else
- icScores(end + 1) = NaN; % zero-length or missed bout: excluded from mean
+ if boutLength == 0
+ continue; % zero-length bout: no frame pair, no term
+ end
+ if any(predSequence(gtBouts{i}(1):gtBouts{i}(2)) == 1)
+ switches = BANOScountSwitchesWithinBout(predSequence, gtBouts{i}(1), gtBouts{i}(2));
+ icTerms(end + 1) = 1 - (switches / boutLength);
+ elseif strcmp(options.missed_bouts, 'zero')
+ icTerms(end + 1) = 0; % missed bout
end
end
- % nanmean excludes NaN entries from both numerator and denominator,
- % matching Python's: sum(valid) / len(valid).
- % Returns NaN when icScores is empty or all values are NaN.
- if isempty(icScores)
+ if isempty(icTerms)
ic = NaN;
else
- ic = nanmean(icScores);
+ ic = mean(icTerms);
end
end
diff --git a/matlab/BANOS/BANOScalculateSO.m b/matlab/BANOS/BANOScalculateSO.m
index ac41cb5..4a93174 100644
--- a/matlab/BANOS/BANOScalculateSO.m
+++ b/matlab/BANOS/BANOScalculateSO.m
@@ -1,26 +1,39 @@
-function so = BANOScalculateSO(predBouts, gtBouts)
- % Calculates the Segment Overlap (SO) for each true positive bout.
+function [so, soTerms] = BANOScalculateSO(predBouts, gtBouts, varargin)
+ % Calculates the Segment Overlap (SO) over the overlapping bout pairs.
+ %
+ % SO is the average tIoU of every overlapping (predicted, GT) bout pair.
%
% Arguments:
% predBouts - Cell array of predicted bouts.
% gtBouts - Cell array of ground truth bouts.
+ % 'missed_bouts' - (name-value) how a GT bout that no predicted bout
+ % overlaps is counted: 'exclude' (default) leaves it
+ % out, 'zero' adds a term equal to 0.
%
% Returns:
- % so - The average SO for the true positive bouts.
+ % so - The average of the terms, or NaN if there is no term.
+ % soTerms - Row vector of the terms: the tIoU of each overlapping pair,
+ % GT bout by GT bout, and 0 for each missed GT bout with 'zero'.
- tiouScores = [];
+ options = BANOSparseOptions(varargin, {'missed_bouts'});
+ soTerms = [];
for i = 1:length(gtBouts)
+ found = false;
for j = 1:length(predBouts)
if BANOSoverlaps(predBouts{j}, gtBouts{i})
- tiouScores(end + 1) = BANOScalculateTiou(predBouts{j}, gtBouts{i});
+ soTerms(end + 1) = BANOScalculateTiou(predBouts{j}, gtBouts{i});
+ found = true;
end
end
+ if ~found && strcmp(options.missed_bouts, 'zero')
+ soTerms(end + 1) = 0;
+ end
end
- % Return NaN if no overlapping bouts were found (matches Python behaviour).
+ % Return NaN if there is no term (matches Python behaviour).
% NaN propagates correctly through BANOSaggregateMetrics (nanmean).
- if isempty(tiouScores)
+ if isempty(soTerms)
so = NaN;
else
- so = mean(tiouScores);
+ so = mean(soTerms);
end
end
diff --git a/matlab/BANOS/BANOScalculateTP.m b/matlab/BANOS/BANOScalculateTP.m
index 904827b..86ad21e 100644
--- a/matlab/BANOS/BANOScalculateTP.m
+++ b/matlab/BANOS/BANOScalculateTP.m
@@ -1,27 +1,41 @@
-function tp = BANOScalculateTP(predBouts, gtBouts)
- % Calculates the Temporal Precision (TP) for each true positive bout.
+function [tp, tpTerms] = BANOScalculateTP(predBouts, gtBouts, varargin)
+ % Calculates the Temporal Precision (TP) over the overlapping bout pairs.
+ %
+ % TP is the average of 1 / (1 + |start deviation| + |end deviation|)
+ % over every overlapping (predicted, GT) bout pair.
%
% Arguments:
% predBouts - Cell array of predicted bouts.
% gtBouts - Cell array of ground truth bouts.
+ % 'missed_bouts' - (name-value) how a GT bout that no predicted bout
+ % overlaps is counted: 'exclude' (default) leaves it
+ % out, 'zero' adds a term equal to 0.
%
% Returns:
- % tp - The average TP for the true positive bouts.
+ % tp - The average of the terms, or NaN if there is no term.
+ % tpTerms - Row vector of the terms, one per overlapping pair, GT bout
+ % by GT bout, and 0 for each missed GT bout with 'zero'.
- tpScores = [];
+ options = BANOSparseOptions(varargin, {'missed_bouts'});
+ tpTerms = [];
for i = 1:length(gtBouts)
+ found = false;
for j = 1:length(predBouts)
if BANOSoverlaps(predBouts{j}, gtBouts{i})
startDiff = abs(predBouts{j}(1) - gtBouts{i}(1));
endDiff = abs(predBouts{j}(2) - gtBouts{i}(2));
- tpScores(end + 1) = 1 / (1 + startDiff + endDiff);
+ tpTerms(end + 1) = 1 / (1 + startDiff + endDiff);
+ found = true;
end
end
+ if ~found && strcmp(options.missed_bouts, 'zero')
+ tpTerms(end + 1) = 0;
+ end
end
- % Return NaN if no overlapping bouts were found (matches Python behaviour).
- if isempty(tpScores)
+ % Return NaN if there is no term (matches Python behaviour).
+ if isempty(tpTerms)
tp = NaN;
else
- tp = mean(tpScores);
+ tp = mean(tpTerms);
end
end
diff --git a/matlab/BANOS/BANOScomputeBehaviorMetrics.m b/matlab/BANOS/BANOScomputeBehaviorMetrics.m
index a5ee719..9b741e9 100644
--- a/matlab/BANOS/BANOScomputeBehaviorMetrics.m
+++ b/matlab/BANOS/BANOScomputeBehaviorMetrics.m
@@ -1,5 +1,11 @@
-function behaviorMetrics = BANOScomputeBehaviorMetrics(predMatrix, gtMatrix)
+function behaviorMetrics = BANOScomputeBehaviorMetrics(predMatrix, gtMatrix, varargin)
% Computes metrics for each behavior in the prediction and ground truth matrices.
+ %
+ % Name-value option:
+ % 'missed_bouts' - how SO, TP and IC count a GT bout that no predicted
+ % bout overlaps: 'exclude' (default) or 'zero'
+ % (see BANOScalculateSO).
+ options = BANOSparseOptions(varargin, {'missed_bouts'});
behaviorMetrics = struct();
for behavior = 1:size(predMatrix, 2)
@@ -10,9 +16,10 @@
if ~isempty(gtBouts)
[tp, fp, fn] = BANOScalculateTpFpFn(predBouts, gtBouts);
[precision, recall, f1] = BANOScalculatePrecisionRecallF1(tp, fp, fn);
- so = BANOScalculateSO(predBouts, gtBouts);
- tp = BANOScalculateTP(predBouts, gtBouts);
- ic = BANOScalculateIC(cell2mat(predMatrix(2:end, behavior)), gtBouts);
+ so = BANOScalculateSO(predBouts, gtBouts, 'missed_bouts', options.missed_bouts);
+ tp = BANOScalculateTP(predBouts, gtBouts, 'missed_bouts', options.missed_bouts);
+ ic = BANOScalculateIC(cell2mat(predMatrix(2:end, behavior)), gtBouts, ...
+ 'missed_bouts', options.missed_bouts);
elseif isempty(predBouts)
% Correct absence: system correctly detected nothing
precision = 1.0; recall = 1.0; f1 = 1.0;
diff --git a/matlab/BANOS/BANOSparseOptions.m b/matlab/BANOS/BANOSparseOptions.m
new file mode 100644
index 0000000..81fffe5
--- /dev/null
+++ b/matlab/BANOS/BANOSparseOptions.m
@@ -0,0 +1,56 @@
+function options = BANOSparseOptions(args, names)
+ % Reads and validates BANOS name-value options.
+ %
+ % Arguments:
+ % args - Cell array of name-value pairs, e.g. {'missed_bouts', 'zero'}.
+ % Names and values are case-insensitive.
+ % names - Cell array of the option names the caller accepts.
+ %
+ % Returns:
+ % options - Struct with one field per accepted name, holding the given
+ % value in lowercase, or the default value.
+ %
+ % Options and their values (the first value is the default):
+ % 'matching' - 'greedy', 'optimal'
+ % 'missed_bouts' - 'exclude', 'zero'
+ %
+ % Errors:
+ % BANOS:unknownOption - a name the caller does not accept.
+ % BANOS:missingOptionValue - a name without a value.
+ % BANOS:invalidOption - a value that is not allowed for its name.
+
+ allowedValues = struct( ...
+ 'matching', {{'greedy', 'optimal'}}, ...
+ 'missed_bouts', {{'exclude', 'zero'}});
+
+ options = struct();
+ for k = 1:numel(names)
+ values = allowedValues.(names{k});
+ options.(names{k}) = values{1};
+ end
+ accepted = strjoin(strcat('''', names, ''''), ', ');
+
+ for i = 1:2:numel(args)
+ name = args{i};
+ if ~(ischar(name) || (isstring(name) && isscalar(name)))
+ error('BANOS:unknownOption', ...
+ 'Option names must be text. Accepted options: %s.', accepted);
+ end
+ name = lower(char(name));
+ if ~any(strcmp(name, names))
+ error('BANOS:unknownOption', ...
+ 'Unknown option ''%s''. Accepted options: %s.', name, accepted);
+ end
+ if i + 1 > numel(args)
+ error('BANOS:missingOptionValue', 'Option ''%s'' requires a value.', name);
+ end
+ value = args{i + 1};
+ allowed = allowedValues.(name);
+ if ~(ischar(value) || (isstring(value) && isscalar(value))) ...
+ || ~any(strcmpi(char(value), allowed))
+ error('BANOS:invalidOption', 'Option ''%s'' must be %s.', ...
+ name, strjoin(strcat('''', allowed, ''''), ' or '));
+ end
+ options.(name) = lower(char(value));
+ end
+end
diff --git a/matlab/BANOS/Contents.m b/matlab/BANOS/Contents.m
index 3f2cb56..f44cd2a 100644
--- a/matlab/BANOS/Contents.m
+++ b/matlab/BANOS/Contents.m
@@ -25,6 +25,7 @@
% BANOSisLogicalColumn - Test whether a column contains only 0 and 1.
% BANOSmatchHeaders - Retain only common behavior columns in pred and GT.
% BANOSoverlaps - Test whether two bout intervals overlap.
+% BANOSparseOptions - Read and validate name-value options.
%
% Setup:
% setup - Add this toolbox to the MATLAB path.
diff --git a/src/banos/__init__.py b/src/banos/__init__.py
index de9905c..eabfd42 100644
--- a/src/banos/__init__.py
+++ b/src/banos/__init__.py
@@ -18,6 +18,7 @@
import pandas as pd
+from banos.core.metrics import _check_missed_bouts
from banos.core.preprocessing import preprocess_data
from banos.pipeline import (
aggregate_metrics,
@@ -40,6 +41,7 @@ def score(
gt: pd.DataFrame | None = None,
*,
matching: str = "greedy",
+ missed_bouts: str = "exclude",
) -> dict | tuple[dict, dict]:
"""Compute BANOS metrics for one or multiple annotation pairs.
@@ -54,6 +56,11 @@ def score(
Bout matching algorithm.
'greedy': first-match wins (fast, default, backward compatible)
'optimal': Hungarian algorithm (scientifically optimal, requires scipy)
+ missed_bouts : {'exclude', 'zero'}
+ How SO, TP and IC count a GT bout with no predicted frame inside it.
+ 'exclude': left out of their averages (default)
+ 'zero': one term equal to 0 in each of them
+ Precision, recall and F1 do not depend on this option.
Returns
-------
@@ -66,16 +73,23 @@ def score(
-----
Metrics return ``NaN`` when ground truth has no bouts for a given behavior.
"""
+ _check_missed_bouts(missed_bouts)
+
if isinstance(data, pd.DataFrame):
if gt is None:
raise ValueError("When 'data' is a DataFrame, 'gt' must be provided.")
data_dict = {"single": (data, gt)}
data_dict, _ = preprocess_data(data_dict)
return compute_behavior_metrics(
- data_dict["single"][0], data_dict["single"][1], matching=matching
+ data_dict["single"][0],
+ data_dict["single"][1],
+ matching=matching,
+ missed_bouts=missed_bouts,
)
# Multi-file path
data_dict, _ = preprocess_data(dict(data))
- file_metrics = calculate_banos_for_each_file(data_dict, matching=matching)
+ file_metrics = calculate_banos_for_each_file(
+ data_dict, matching=matching, missed_bouts=missed_bouts
+ )
return aggregate_metrics(file_metrics)
diff --git a/src/banos/core/__init__.py b/src/banos/core/__init__.py
index cadc8da..d96007c 100644
--- a/src/banos/core/__init__.py
+++ b/src/banos/core/__init__.py
@@ -2,10 +2,13 @@
from banos.core.matching import calculate_tp_fp_fn
from banos.core.metrics import (
calculate_ic,
+ calculate_ic_terms,
calculate_precision_recall_f1,
calculate_so,
+ calculate_so_terms,
calculate_tiou,
calculate_tp_metric,
+ calculate_tp_terms,
count_switches_within_bout,
identify_bouts,
)
@@ -21,8 +24,11 @@
"identify_bouts",
"calculate_tiou",
"calculate_so",
+ "calculate_so_terms",
"calculate_tp_metric",
+ "calculate_tp_terms",
"calculate_ic",
+ "calculate_ic_terms",
"count_switches_within_bout",
"calculate_precision_recall_f1",
"is_logical_column",
diff --git a/src/banos/core/metrics.py b/src/banos/core/metrics.py
index 3663022..0a6898c 100644
--- a/src/banos/core/metrics.py
+++ b/src/banos/core/metrics.py
@@ -4,6 +4,7 @@
from __future__ import annotations
import math
+from collections.abc import Callable
import numpy as np
import pandas as pd
@@ -60,38 +61,98 @@ def calculate_tiou(pred_bout: tuple[int, int], gt_bout: tuple[int, int]) -> floa
return intersection / union if union != 0 else 0.0
-def calculate_so(pred_bouts: list[tuple[int, int]], gt_bouts: list[tuple[int, int]]) -> float:
+def _check_missed_bouts(missed_bouts: str) -> None:
+ if missed_bouts not in ("exclude", "zero"):
+ raise ValueError(f"Unknown missed_bouts option: {missed_bouts!r}. Use 'exclude' or 'zero'.")
+
+
+def _mean(terms: list[float]) -> float:
+ return sum(terms) / len(terms) if terms else float("nan")
+
+
+def _pair_terms(
+ pred_bouts: list[tuple[int, int]],
+ gt_bouts: list[tuple[int, int]],
+ pair_term: Callable[[tuple[int, int], tuple[int, int]], float],
+ missed_bouts: str,
+) -> list[float]:
+ """Apply ``pair_term`` to every overlapping (pred, GT) bout pair, GT bout by GT bout.
+
+ A GT bout that no predicted bout overlaps is missed: it adds one term
+ equal to 0 with ``missed_bouts="zero"`` and no term with ``"exclude"``.
+ """
+ _check_missed_bouts(missed_bouts)
+ terms: list[float] = []
+ for gt in gt_bouts:
+ gt_terms = [
+ pair_term(pred, gt) for pred in pred_bouts if pred[1] >= gt[0] and pred[0] <= gt[1]
+ ]
+ if not gt_terms and missed_bouts == "zero":
+ gt_terms = [0.0]
+ terms.extend(gt_terms)
+ return terms
+
+
+def _boundary_precision(pred_bout: tuple[int, int], gt_bout: tuple[int, int]) -> float:
+ return 1.0 / (1.0 + abs(pred_bout[0] - gt_bout[0]) + abs(pred_bout[1] - gt_bout[1]))
+
+
+def calculate_so_terms(
+ pred_bouts: list[tuple[int, int]],
+ gt_bouts: list[tuple[int, int]],
+ *,
+ missed_bouts: str = "exclude",
+) -> list[float]:
+ """Segment Overlap terms: the tIoU of every overlapping bout pair.
+
+ A GT bout that no predicted bout overlaps adds no term
+ (``missed_bouts="exclude"``, default) or one term equal to 0 (``"zero"``).
+ """
+ return _pair_terms(pred_bouts, gt_bouts, calculate_tiou, missed_bouts)
+
+
+def calculate_so(
+ pred_bouts: list[tuple[int, int]],
+ gt_bouts: list[tuple[int, int]],
+ *,
+ missed_bouts: str = "exclude",
+) -> float:
"""Segment Overlap: average tIoU over all overlapping bout pairs.
- Returns NaN if there are no GT bouts.
+ With ``missed_bouts="zero"``, each GT bout that no predicted bout overlaps
+ adds a 0 to the average (see ``calculate_so_terms``).
+ Returns NaN if there are no GT bouts, or no overlapping pair with "exclude".
"""
- if not gt_bouts:
- return float("nan")
- scores = [
- calculate_tiou(pred, gt)
- for gt in gt_bouts
- for pred in pred_bouts
- if pred[1] >= gt[0] and pred[0] <= gt[1]
- ]
- return sum(scores) / len(scores) if scores else float("nan")
+ return _mean(calculate_so_terms(pred_bouts, gt_bouts, missed_bouts=missed_bouts))
+
+
+def calculate_tp_terms(
+ pred_bouts: list[tuple[int, int]],
+ gt_bouts: list[tuple[int, int]],
+ *,
+ missed_bouts: str = "exclude",
+) -> list[float]:
+ """Temporal Precision terms: 1 / (1 + |Δstart| + |Δend|) of every overlapping bout pair.
+
+ A GT bout that no predicted bout overlaps adds no term
+ (``missed_bouts="exclude"``, default) or one term equal to 0 (``"zero"``).
+ """
+ return _pair_terms(pred_bouts, gt_bouts, _boundary_precision, missed_bouts)
def calculate_tp_metric(
- pred_bouts: list[tuple[int, int]], gt_bouts: list[tuple[int, int]]
+ pred_bouts: list[tuple[int, int]],
+ gt_bouts: list[tuple[int, int]],
+ *,
+ missed_bouts: str = "exclude",
) -> float:
"""Temporal Precision: 1 / (1 + |Δstart| + |Δend|) averaged over overlapping bouts.
- Returns NaN if there are no GT bouts.
+ With ``missed_bouts="zero"``, each GT bout that no predicted bout overlaps
+ adds a 0 to the average (see ``calculate_tp_terms``).
+ Returns NaN if there are no GT bouts, or no overlapping pair with "exclude".
"""
- if not gt_bouts:
- return float("nan")
- scores = [
- 1.0 / (1.0 + abs(pred[0] - gt[0]) + abs(pred[1] - gt[1]))
- for gt in gt_bouts
- for pred in pred_bouts
- if pred[1] >= gt[0] and pred[0] <= gt[1]
- ]
- return sum(scores) / len(scores) if scores else float("nan")
+ return _mean(calculate_tp_terms(pred_bouts, gt_bouts, missed_bouts=missed_bouts))
def count_switches_within_bout(
@@ -111,29 +172,49 @@ def count_switches_within_bout(
return switches
-def calculate_ic(
- pred_sequence: pd.Series | np.ndarray | list, gt_bouts: list[tuple[int, int]]
-) -> float:
- """Intra-bout Continuity: 1 - (switches / gt_length) averaged over found GT bouts.
+def calculate_ic_terms(
+ pred_sequence: pd.Series | np.ndarray | list,
+ gt_bouts: list[tuple[int, int]],
+ *,
+ missed_bouts: str = "exclude",
+) -> list[float]:
+ """Intra-bout Continuity terms: 1 - (switches / gt_length) for each found GT bout.
A GT bout is found when at least one of its frames is predicted (value 1).
- Missed GT bouts are left out of the average, and so are one-frame GT bouts
- (gt_length = end - start = 0, no frame pair to compare).
+ A missed GT bout adds no term (``missed_bouts="exclude"``, default) or one
+ term equal to 0 (``"zero"``). One-frame GT bouts (gt_length = end - start
+ = 0, no frame pair to compare) add no term.
Frames are read by position: the index labels of a Series are ignored.
- Returns NaN if there are no GT bouts or none of them is found and longer
- than one frame.
"""
- if not gt_bouts:
- return float("nan")
+ _check_missed_bouts(missed_bouts)
pred_sequence = _by_position(pred_sequence)
- ic_scores = []
+ terms: list[float] = []
for gt in gt_bouts:
gt_length = gt[1] - gt[0]
- found = any(pred_sequence[i] == 1 for i in range(gt[0], gt[1] + 1))
- if gt_length > 0 and found:
+ if gt_length == 0:
+ continue
+ if any(pred_sequence[i] == 1 for i in range(gt[0], gt[1] + 1)):
switches = count_switches_within_bout(pred_sequence, gt[0], gt[1])
- ic_scores.append(1.0 - (switches / gt_length))
- else:
- ic_scores.append(float("nan"))
- valid = [v for v in ic_scores if not math.isnan(v)]
- return sum(valid) / len(valid) if valid else float("nan")
+ terms.append(1.0 - (switches / gt_length))
+ elif missed_bouts == "zero":
+ terms.append(0.0)
+ return terms
+
+
+def calculate_ic(
+ pred_sequence: pd.Series | np.ndarray | list,
+ gt_bouts: list[tuple[int, int]],
+ *,
+ missed_bouts: str = "exclude",
+) -> float:
+ """Intra-bout Continuity: 1 - (switches / gt_length) averaged over found GT bouts.
+
+ A GT bout is found when at least one of its frames is predicted (value 1).
+ Missed GT bouts are left out of the average (``missed_bouts="exclude"``,
+ default) or count as 0 (``"zero"``); one-frame GT bouts are always left
+ out (see ``calculate_ic_terms``).
+ Frames are read by position: the index labels of a Series are ignored.
+ Returns NaN if there is no term (no GT bout longer than one frame, or none
+ found with "exclude").
+ """
+ return _mean(calculate_ic_terms(pred_sequence, gt_bouts, missed_bouts=missed_bouts))
diff --git a/src/banos/pipeline.py b/src/banos/pipeline.py
index 6f0e2a6..58d6398 100644
--- a/src/banos/pipeline.py
+++ b/src/banos/pipeline.py
@@ -9,6 +9,7 @@
from banos.core.matching import calculate_tp_fp_fn
from banos.core.metrics import (
+ _check_missed_bouts,
calculate_ic,
calculate_precision_recall_f1,
calculate_so,
@@ -21,13 +22,19 @@ def compute_behavior_metrics(
pred_matrix: pd.DataFrame,
gt_matrix: pd.DataFrame,
matching: str = "greedy",
+ *,
+ missed_bouts: str = "exclude",
) -> dict:
"""Compute all BANOS metrics for each behavior column.
+ ``missed_bouts`` sets how SO, TP and IC count a GT bout that no predicted
+ bout overlaps: left out ('exclude', default) or a term equal to 0 ('zero').
+
Returns
-------
{behavior: {precision, recall, f1_score, so, tp, ic}}
"""
+ _check_missed_bouts(missed_bouts)
metrics: dict = {}
for behavior in pred_matrix.columns:
pred_bouts = identify_bouts(pred_matrix[behavior])
@@ -58,9 +65,9 @@ def compute_behavior_metrics(
n_tp, n_fp, n_fn = calculate_tp_fp_fn(pred_bouts, gt_bouts, matching=matching)
precision, recall, f1_score = calculate_precision_recall_f1(n_tp, n_fp, n_fn)
- so = calculate_so(pred_bouts, gt_bouts)
- tp_m = calculate_tp_metric(pred_bouts, gt_bouts)
- ic = calculate_ic(pred_matrix[behavior].to_numpy(), gt_bouts)
+ so = calculate_so(pred_bouts, gt_bouts, missed_bouts=missed_bouts)
+ tp_m = calculate_tp_metric(pred_bouts, gt_bouts, missed_bouts=missed_bouts)
+ ic = calculate_ic(pred_matrix[behavior].to_numpy(), gt_bouts, missed_bouts=missed_bouts)
metrics[behavior] = {
"precision": precision,
"recall": recall,
@@ -72,10 +79,13 @@ def compute_behavior_metrics(
return metrics
-def calculate_banos_for_each_file(preprocessed_data: dict, matching: str = "greedy") -> dict:
+def calculate_banos_for_each_file(
+ preprocessed_data: dict, matching: str = "greedy", *, missed_bouts: str = "exclude"
+) -> dict:
"""Compute BANOS metrics for each file in the preprocessed data dict."""
+ _check_missed_bouts(missed_bouts)
return {
- file_name: compute_behavior_metrics(pred, gt, matching=matching)
+ file_name: compute_behavior_metrics(pred, gt, matching=matching, missed_bouts=missed_bouts)
for file_name, (pred, gt) in preprocessed_data.items()
}
diff --git a/tests/fixtures/golden_matlab_v110.json b/tests/fixtures/golden_matlab_v110.json
index 28582b7..3e0663f 100644
--- a/tests/fixtures/golden_matlab_v110.json
+++ b/tests/fixtures/golden_matlab_v110.json
@@ -1 +1 @@
-{"Recording_1":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.9805603480852142},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.98286421281656633},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.992439083367394}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.9805603480852142},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.98286421281656633},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.992439083367394}},"overall_metrics":{"precision":0.92826086956521736,"recall":0.78129251700680269,"f1_score":0.836455036759336,"so":0.761228298078524,"tp":0.30623014745659,"ic":0.98896591106729359}},"Recording_2":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98771568436705637},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99357808016301785},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99074891239625884}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98771568436705637},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99357808016301785},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99074891239625884}},"overall_metrics":{"precision":0.9671052631578948,"recall":0.83992445266728333,"f1_score":0.897300738477209,"so":0.82594559036003834,"tp":0.30908214265027689,"ic":0.99301066923158332}},"Recording_3":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98261726264923754},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.98941095585555594},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99591253786234157}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98261726264923754},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.98941095585555594},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99591253786234157}},"overall_metrics":{"precision":0.95948072383949645,"recall":0.85096664987969339,"f1_score":0.89862686038270723,"so":0.723715686180092,"tp":0.26887555753525666,"ic":0.99198518909178379}},"Recording_4":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.98586849198997339},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99092353265377653}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.98586849198997339},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99092353265377653}},"overall_metrics":{"precision":0.975,"recall":0.61568627450980384,"f1_score":0.72916666666666674,"so":0.70708735677240686,"tp":0.27571526135052643,"ic":0.99341862874528308}},"Recording_5":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98823265836029839},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99615676486935811},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.993672706200401}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98823265836029839},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99615676486935811},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.993672706200401}},"overall_metrics":{"precision":1,"recall":0.89210526315789473,"f1_score":0.94145869368256319,"so":0.83673046008612484,"tp":0.27928892486843937,"ic":0.99451553235751433}},"Recording_6":{"file_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.98838846071505027},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.98965295127585839},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.99046878072554168}},"group_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.98838846071505027},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.98965295127585839},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.99046878072554168}},"overall_metrics":{"precision":0.91408268733850129,"recall":0.74567099567099571,"f1_score":0.79250474383301706,"so":0.624464020496978,"tp":0.26406773460888022,"ic":0.99212754817911253}},"Recording_7":{"file_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98891661542484988},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.98181290038327618},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.99318498549600709}},"group_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98891661542484988},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.98181290038327618},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.99318498549600709}},"overall_metrics":{"precision":1,"recall":0.58390957446808511,"f1_score":0.70652984743264124,"so":0.66449505594099667,"tp":0.27653014329623343,"ic":0.99097862532603331}},"Recording_8":{"file_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99318530440005481},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":null},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99102642248586637}},"group_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99318530440005481},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":null},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99102642248586637}},"overall_metrics":{"precision":0.89166666666666661,"recall":0.34672064777327932,"f1_score":0.45462962962962961,"so":0.53056180162368516,"tp":0.038138242319112579,"ic":0.992043603151849}},"Recording_9":{"file_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98862824312860653},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99269356597600877},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99282392960084676}},"group_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98862824312860653},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99269356597600877},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99282392960084676}},"overall_metrics":{"precision":0.79839767616191892,"recall":0.65381944444444451,"f1_score":0.712354862119013,"so":0.68272952732383074,"tp":0.27780938219974027,"ic":0.99353643467636554}},"Recording_10":{"file_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.9908056553683271},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.98123242136180233},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.99554269505719462}},"group_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.9908056553683271},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.98123242136180233},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.99554269505719462}},"overall_metrics":{"precision":0.89910714285714288,"recall":0.47575971177944865,"f1_score":0.58347469607011593,"so":0.66680627484973121,"tp":0.28592277410718,"ic":0.99189519294683093}}}
+{"Recording_1":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.98056034808521408},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.98286421281656633},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.99243908336739384}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.98056034808521408},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.98286421281656633},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.99243908336739384}},"overall_metrics":{"precision":0.92826086956521736,"recall":0.78129251700680269,"f1_score":0.836455036759336,"so":0.761228298078524,"tp":0.30623014745659,"ic":0.98896591106729359}},"Recording_2":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98771568436705637},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99357808016301785},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99074891239625906}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98771568436705637},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99357808016301785},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99074891239625906}},"overall_metrics":{"precision":0.9671052631578948,"recall":0.83992445266728333,"f1_score":0.897300738477209,"so":0.82594559036003834,"tp":0.30908214265027689,"ic":0.99301066923158332}},"Recording_3":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98261726264923754},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.98941095585555594},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99591253786234157}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98261726264923754},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.98941095585555594},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99591253786234157}},"overall_metrics":{"precision":0.95948072383949645,"recall":0.85096664987969339,"f1_score":0.89862686038270723,"so":0.723715686180092,"tp":0.26887555753525666,"ic":0.99198518909178379}},"Recording_4":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.98586849198997339},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99092353265377653}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.98586849198997339},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99092353265377653}},"overall_metrics":{"precision":0.975,"recall":0.61568627450980384,"f1_score":0.72916666666666674,"so":0.70708735677240686,"tp":0.27571526135052643,"ic":0.99341862874528308}},"Recording_5":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98823265836029839},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99615676486935811},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.993672706200401}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98823265836029839},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99615676486935811},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.993672706200401}},"overall_metrics":{"precision":1,"recall":0.89210526315789473,"f1_score":0.94145869368256319,"so":0.83673046008612484,"tp":0.27928892486843937,"ic":0.99451553235751433}},"Recording_6":{"file_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.98838846071505027},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.98965295127585839},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.9904687807255419}},"group_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.98838846071505027},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.98965295127585839},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.9904687807255419}},"overall_metrics":{"precision":0.91408268733850129,"recall":0.74567099567099571,"f1_score":0.79250474383301706,"so":0.624464020496978,"tp":0.26406773460888022,"ic":0.99212754817911264}},"Recording_7":{"file_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98891661542484988},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.98181290038327618},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.99318498549600709}},"group_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98891661542484988},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.98181290038327618},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.99318498549600709}},"overall_metrics":{"precision":1,"recall":0.58390957446808511,"f1_score":0.70652984743264124,"so":0.66449505594099667,"tp":0.27653014329623343,"ic":0.99097862532603331}},"Recording_8":{"file_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99318530440005481},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":null},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99102642248586637}},"group_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99318530440005481},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":null},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99102642248586637}},"overall_metrics":{"precision":0.89166666666666661,"recall":0.34672064777327932,"f1_score":0.45462962962962961,"so":0.53056180162368516,"tp":0.038138242319112579,"ic":0.992043603151849}},"Recording_9":{"file_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98862824312860653},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99269356597600877},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99282392960084676}},"group_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98862824312860653},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99269356597600877},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99282392960084676}},"overall_metrics":{"precision":0.79839767616191892,"recall":0.65381944444444451,"f1_score":0.712354862119013,"so":0.68272952732383074,"tp":0.27780938219974027,"ic":0.99353643467636554}},"Recording_10":{"file_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.99080565536832721},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.98123242136180233},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.99554269505719462}},"group_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.99080565536832721},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.98123242136180233},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.99554269505719462}},"overall_metrics":{"precision":0.89910714285714288,"recall":0.47575971177944865,"f1_score":0.58347469607011593,"so":0.66680627484973121,"tp":0.28592277410718,"ic":0.99189519294683093}}}
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
index 0bdc319..6dd3ac7 100644
--- a/tests/test_metrics.py
+++ b/tests/test_metrics.py
@@ -35,10 +35,13 @@
from banos.core.metrics import (
calculate_ic,
+ calculate_ic_terms,
calculate_precision_recall_f1,
calculate_so,
+ calculate_so_terms,
calculate_tiou,
calculate_tp_metric,
+ calculate_tp_terms,
count_switches_within_bout,
identify_bouts,
)
@@ -283,3 +286,91 @@ def test_ic_leaves_missed_bouts_out():
def test_ic_nan_when_no_bout_is_found():
"""Nothing predicted inside any GT bout: IC has nothing to average (NaN, not 1.0)."""
assert math.isnan(calculate_ic([0, 0, 0, 0, 0], [(0, 1), (3, 4)]))
+
+
+# --- missed_bouts: 'exclude' (default) or 'zero' ---
+#
+# GT bouts (0,4), (10,14), (20,24), (30,30); predicted bouts (0,4), (11,14).
+# (0,4) is found exactly, (10,14) is found one frame late, (20,24) and the
+# one-frame bout (30,30) are missed.
+
+MISSED_PRED_BOUTS = [(0, 4), (11, 14)]
+MISSED_GT_BOUTS = [(0, 4), (10, 14), (20, 24), (30, 30)]
+MISSED_PRED_SEQUENCE = [1] * 5 + [0] * 6 + [1] * 4 + [0] * 16 # frames 0..30
+
+
+def test_so_missed_bouts():
+ """SO terms: tIoU 1 and 4/5 for the found bouts, plus one 0 per missed bout with 'zero'.
+
+ exclude: (1 + 0.8) / 2 = 0.9; zero: (1 + 0.8 + 0 + 0) / 4 = 0.45.
+ """
+ assert calculate_so_terms(MISSED_PRED_BOUTS, MISSED_GT_BOUTS) == [1.0, 0.8]
+ assert calculate_so_terms(MISSED_PRED_BOUTS, MISSED_GT_BOUTS, missed_bouts="zero") == [
+ 1.0,
+ 0.8,
+ 0.0,
+ 0.0,
+ ]
+ assert abs(calculate_so(MISSED_PRED_BOUTS, MISSED_GT_BOUTS) - 0.9) < 1e-10
+ assert abs(calculate_so(MISSED_PRED_BOUTS, MISSED_GT_BOUTS, missed_bouts="zero") - 0.45) < 1e-10
+
+
+def test_tp_metric_missed_bouts():
+ """TP terms: 1 and 1/(1 + 1 + 0) = 0.5 for the found bouts, plus one 0 per missed bout.
+
+ exclude: (1 + 0.5) / 2 = 0.75; zero: (1 + 0.5 + 0 + 0) / 4 = 0.375.
+ """
+ assert calculate_tp_terms(MISSED_PRED_BOUTS, MISSED_GT_BOUTS) == [1.0, 0.5]
+ assert calculate_tp_terms(MISSED_PRED_BOUTS, MISSED_GT_BOUTS, missed_bouts="zero") == [
+ 1.0,
+ 0.5,
+ 0.0,
+ 0.0,
+ ]
+ assert calculate_tp_metric(MISSED_PRED_BOUTS, MISSED_GT_BOUTS) == 0.75
+ assert calculate_tp_metric(MISSED_PRED_BOUTS, MISSED_GT_BOUTS, missed_bouts="zero") == 0.375
+
+
+def test_ic_missed_bouts():
+ """IC terms: 1 for (0,4), 1 - 1/4 for (10,14) (switch at frames 10-11), 0 for (20,24) with 'zero'.
+
+ The missed one-frame bout (30,30) adds no IC term in either mode.
+ exclude: (1 + 0.75) / 2 = 0.875; zero: (1 + 0.75 + 0) / 3 = 1.75 / 3.
+ """
+ assert calculate_ic_terms(MISSED_PRED_SEQUENCE, MISSED_GT_BOUTS) == [1.0, 0.75]
+ assert calculate_ic_terms(MISSED_PRED_SEQUENCE, MISSED_GT_BOUTS, missed_bouts="zero") == [
+ 1.0,
+ 0.75,
+ 0.0,
+ ]
+ assert calculate_ic(MISSED_PRED_SEQUENCE, MISSED_GT_BOUTS) == 0.875
+ zero = calculate_ic(MISSED_PRED_SEQUENCE, MISSED_GT_BOUTS, missed_bouts="zero")
+ assert abs(zero - 1.75 / 3) < 1e-10
+
+
+def test_missed_bouts_zero_when_nothing_overlaps():
+ """With 'zero', real bouts and no overlap give SO = TP = IC = 0 instead of NaN."""
+ pred_bouts, gt_bouts, pred_sequence = [(0, 1)], [(5, 8)], [1, 1, 0, 0, 0, 0, 0, 0, 0]
+ assert math.isnan(calculate_so(pred_bouts, gt_bouts))
+ assert calculate_so(pred_bouts, gt_bouts, missed_bouts="zero") == 0.0
+ assert math.isnan(calculate_tp_metric(pred_bouts, gt_bouts))
+ assert calculate_tp_metric(pred_bouts, gt_bouts, missed_bouts="zero") == 0.0
+ assert math.isnan(calculate_ic(pred_sequence, gt_bouts))
+ assert calculate_ic(pred_sequence, gt_bouts, missed_bouts="zero") == 0.0
+
+
+@pytest.mark.parametrize(
+ "function, first_argument",
+ [
+ (calculate_so, []),
+ (calculate_so_terms, []),
+ (calculate_tp_metric, []),
+ (calculate_tp_terms, []),
+ (calculate_ic, [0, 1]),
+ (calculate_ic_terms, [0, 1]),
+ ],
+)
+def test_invalid_missed_bouts_raises(function, first_argument):
+ """An unknown missed_bouts value raises ValueError, even without any bout."""
+ with pytest.raises(ValueError, match="Unknown missed_bouts option"):
+ function(first_argument, [], missed_bouts="drop")
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index 2856559..baabcb5 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -175,6 +175,76 @@ def test_score_reads_frames_by_position_whatever_the_index(kind):
assert banos.score(pred.set_axis(index), gt.set_axis(index)) == expected
+def test_score_missed_bouts_zero():
+ """missed_bouts='zero' adds a 0 term per missed real bout to SO, TP and IC only.
+
+ Real bouts (5,9), (25,29), (45,49); predicted (5,9) and (26,29): the
+ third real bout is missed. Terms of the found bouts: SO 1 and 4/5,
+ TP 1 and 1/2, IC 1 and 3/4 (one switch, frames 25-26, in length 4).
+ exclude: SO 0.9, TP 0.75, IC 0.875; zero: SO 1.8/3, TP 1.5/3, IC 1.75/3.
+ Detection is the same in both modes: TP 2, FN 1 -> P 1, R 2/3, F1 0.8.
+ """
+ video = _video([(5, 9), (26, 29)])
+ exclude = banos.score(*video)["evt"]
+ zero = banos.score(*video, missed_bouts="zero")["evt"]
+ expected_exclude = {"so": 0.9, "tp": 0.75, "ic": 0.875}
+ expected_zero = {"so": 1.8 / 3, "tp": 1.5 / 3, "ic": 1.75 / 3}
+ for key in expected_exclude:
+ assert abs(exclude[key] - expected_exclude[key]) < 1e-10, key
+ assert abs(zero[key] - expected_zero[key]) < 1e-10, key
+ for key in ("precision", "recall", "f1_score"):
+ assert exclude[key] == zero[key]
+ assert abs(zero["f1_score"] - 0.8) < 1e-10
+
+ group, _ = banos.score({"v1": video, "v2": video}, missed_bouts="zero")
+ assert abs(group["evt"]["so"] - 0.6) < 1e-10
+
+
+def test_missed_bouts_zero_keeps_absent_behavior_rule():
+ """With no real bout in a file, 'zero' keeps 1.0 (nothing predicted) or 0.0 (false bouts)."""
+ absent = banos.score(*_video([], []), missed_bouts="zero")["evt"]
+ false_detection = banos.score(*_video([(5, 9)], []), missed_bouts="zero")["evt"]
+ assert set(absent.values()) == {1.0}
+ assert set(false_detection.values()) == {0.0}
+
+
+def test_score_invalid_missed_bouts_raises():
+ with pytest.raises(ValueError, match="Unknown missed_bouts option"):
+ banos.score(PRED, GT, missed_bouts="drop")
+
+
+NO_COLUMN = pd.DataFrame(index=range(N_FRAMES))
+
+
+@pytest.mark.parametrize(
+ "call",
+ [
+ pytest.param(
+ lambda: banos.score(NO_COLUMN, NO_COLUMN, missed_bouts="drop"), id="no-column"
+ ),
+ pytest.param(lambda: banos.score(PRED * 0.5, GT, missed_bouts="drop"), id="all-dropped"),
+ pytest.param(lambda: banos.score({}, missed_bouts="drop"), id="no-file"),
+ pytest.param(
+ lambda: banos.compute_behavior_metrics(NO_COLUMN, NO_COLUMN, missed_bouts="drop"),
+ id="compute_behavior_metrics",
+ ),
+ pytest.param(
+ lambda: banos.calculate_banos_for_each_file({}, missed_bouts="drop"),
+ id="calculate_banos_for_each_file",
+ ),
+ ],
+)
+def test_invalid_missed_bouts_raises_without_any_column(call):
+ """An unknown missed_bouts raises even when no column reaches the metrics.
+
+ No behavior column, every column dropped by preprocessing (values 0 and
+ 0.5), or no file at all: the option is checked on entry, not only by the
+ metric functions.
+ """
+ with pytest.raises(ValueError, match="Unknown missed_bouts option"):
+ call()
+
+
def test_file_with_only_false_bouts_scores_f1_zero():
"""A file whose predicted bouts all miss the real bouts scores F1 0 and stays in the mean.
From 1921e13fb7a351fcc2d514c01cb7ecb16d9fd213 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Beno=C3=AEt=20J=2E=20T=2E=20GIRARD?=
<53222414+BenoitJT-GIRARD@users.noreply.github.com>
Date: Thu, 29 Oct 2026 22:49:34 +0100
Subject: [PATCH 05/25] feat: add pooled aggregation of bout counts and terms
over files
score() averages per-file metrics (aggregation="per_file", default):
each recording weighs the same whatever its number of bouts, and a
recording without the behavior scores 1.0 or 0.0 on all six metrics.
The new aggregation="pooled" sums, per behavior, the TP, FP and FN
counts of all recordings for precision, recall and F1, and averages all
their SO, TP and IC terms (under the chosen missed_bouts). A recording
without the behavior adds its false bouts to FP and no term; a behavior
with no real and no predicted bout anywhere is NaN. overall_metrics is
the mean over the behaviors. The result has the same form in both modes.
compute_behavior_components and calculate_components_for_each_file
return the per-file counts and terms, and pool_components combines
them. compute_behavior_metrics now derives its metrics from the
components, with unchanged results. score() checks matching,
missed_bouts and aggregation on entry, and the pipeline functions check
matching and missed_bouts on entry, so an unknown value raises even
when no column reaches the bout matching.
In MATLAB, BANOS_score takes the 'aggregation' option,
BANOScomputeBehaviorMetrics and BANOScalculateBANOSForEachFile return
the components as a second output, and BANOSpoolComponents pools them.
---
matlab/BANOS/BANOS_score.m | 37 ++-
matlab/BANOS/BANOScalculateBANOSForEachFile.m | 9 +-
matlab/BANOS/BANOScomputeBehaviorMetrics.m | 29 +-
matlab/BANOS/BANOSparseOptions.m | 4 +-
matlab/BANOS/BANOSpoolComponents.m | 69 +++++
matlab/BANOS/Contents.m | 1 +
src/banos/__init__.py | 49 +++-
src/banos/core/matching.py | 8 +-
src/banos/pipeline.py | 204 ++++++++++----
tests/test_pipeline.py | 258 ++++++++++++++++++
10 files changed, 586 insertions(+), 82 deletions(-)
create mode 100644 matlab/BANOS/BANOSpoolComponents.m
diff --git a/matlab/BANOS/BANOS_score.m b/matlab/BANOS/BANOS_score.m
index b0c0e17..eda0e37 100644
--- a/matlab/BANOS/BANOS_score.m
+++ b/matlab/BANOS/BANOS_score.m
@@ -26,13 +26,23 @@
% frame inside it: 'exclude' (default) leaves it out of
% their averages, 'zero' adds a term equal to 0 to each.
% Precision, recall and F1 do not depend on it.
+% 'aggregation' - how the metrics are combined: 'per_file' (default)
+% scores each recording on its own, then averages;
+% 'pooled' scores each behavior once on the bout counts
+% and terms of all recordings (see BANOSpoolComponents).
+% The outputs have the same form in both modes.
% Option names and values are case-insensitive. An unknown name or value
% raises an error (BANOS:unknownOption, BANOS:invalidOption).
%
% NaN VALUES:
-% A behavior with no ground-truth bouts returns NaN for all metrics.
-% NaN values are correctly excluded from group and overall averages
-% (via nanmean), so they do not bias results.
+% With 'per_file', a behavior with no ground-truth bouts in a recording
+% scores 1.0 on all metrics if nothing is predicted there and 0.0
+% otherwise; groupMetrics and overallMetrics average with nanmean.
+% With 'pooled', this 1.0/0.0 rule does not apply and overallMetrics
+% averages the pooled behaviors with nanmean.
+% A metric is NaN when it is undefined: precision when nothing is
+% predicted, recall when there is no GT bout (pooled), SO, TP and IC when
+% they have no term (no overlap, or no found GT bout).
%
% EXAMPLE — single recording:
% pred = [{'event1','event2'}; num2cell(predData)];
@@ -47,7 +57,7 @@
% disp(overallMet.f1_score)
%
% See also: BANOSpreprocessData, BANOScalculateBANOSForEachFile,
-% BANOSaggregateMetrics, Contents
+% BANOSaggregateMetrics, BANOSpoolComponents, Contents
% --- Parse name-value options ------------------------------------------
% Single recording: the first cell array argument is the ground truth;
@@ -60,7 +70,8 @@
varargin(gtIndex) = [];
end
end
- options = BANOSparseOptions(varargin, {'matching', 'missed_bouts'});
+ options = BANOSparseOptions(varargin, {'matching', 'missed_bouts', 'aggregation'});
+ pooled = strcmp(options.aggregation, 'pooled');
if strcmp(options.matching, 'optimal')
error('BANOS_score: optimal matching is not yet implemented in MATLAB. Use the Python package (banos.score(..., matching=''optimal'')).');
@@ -70,9 +81,13 @@
if isstruct(data)
% Multi-recording: data is already a properly formatted struct
[preprocessed, ~] = BANOSpreprocessData(data);
- fileMetrics = BANOScalculateBANOSForEachFile(preprocessed, ...
+ [fileMetrics, fileComponents] = BANOScalculateBANOSForEachFile(preprocessed, ...
'missed_bouts', options.missed_bouts);
- [groupMetrics, overallMetrics] = BANOSaggregateMetrics(fileMetrics);
+ if pooled
+ [groupMetrics, overallMetrics] = BANOSpoolComponents(fileComponents);
+ else
+ [groupMetrics, overallMetrics] = BANOSaggregateMetrics(fileMetrics);
+ end
varargout{1} = groupMetrics;
varargout{2} = overallMetrics;
@@ -85,9 +100,13 @@
tmpData.single_recording = {data, gt};
[preprocessed, ~] = BANOSpreprocessData(tmpData);
- fileMetrics = BANOScalculateBANOSForEachFile(preprocessed, ...
+ [fileMetrics, fileComponents] = BANOScalculateBANOSForEachFile(preprocessed, ...
'missed_bouts', options.missed_bouts);
- varargout{1} = fileMetrics.single_recording;
+ if pooled
+ varargout{1} = BANOSpoolComponents(fileComponents);
+ else
+ varargout{1} = fileMetrics.single_recording;
+ end
else
error('BANOS_score: first argument must be a cell array (single recording) or a struct (multiple recordings).');
diff --git a/matlab/BANOS/BANOScalculateBANOSForEachFile.m b/matlab/BANOS/BANOScalculateBANOSForEachFile.m
index 3f27055..e6fda0f 100644
--- a/matlab/BANOS/BANOScalculateBANOSForEachFile.m
+++ b/matlab/BANOS/BANOScalculateBANOSForEachFile.m
@@ -1,4 +1,4 @@
-function banosMetrics = BANOScalculateBANOSForEachFile(preprocessedData, varargin)
+function [banosMetrics, fileComponents] = BANOScalculateBANOSForEachFile(preprocessedData, varargin)
% Calculates BANOS metrics for each behavior in each file.
%
% Arguments:
@@ -9,18 +9,23 @@
%
% Returns:
% banosMetrics - A struct containing BANOS metrics for each behavior in each file.
+ % fileComponents - A struct containing the metric components (bout counts
+ % and terms) for each behavior in each file, to pool with
+ % BANOSpoolComponents (see BANOScomputeBehaviorMetrics).
options = BANOSparseOptions(varargin, {'missed_bouts'});
fileNames = fieldnames(preprocessedData);
banosMetrics = struct();
+ fileComponents = struct();
for i = 1:length(fileNames)
fileName = fileNames{i};
extractMatrix = preprocessedData.(fileName);
predMatrix = extractMatrix{1};
gtMatrix = extractMatrix{2};
- behaviorMetrics = BANOScomputeBehaviorMetrics(predMatrix, gtMatrix, ...
+ [behaviorMetrics, behaviorComponents] = BANOScomputeBehaviorMetrics(predMatrix, gtMatrix, ...
'missed_bouts', options.missed_bouts);
banosMetrics.(fileName) = behaviorMetrics;
+ fileComponents.(fileName) = behaviorComponents;
end
end
diff --git a/matlab/BANOS/BANOScomputeBehaviorMetrics.m b/matlab/BANOS/BANOScomputeBehaviorMetrics.m
index 9b741e9..57b89b3 100644
--- a/matlab/BANOS/BANOScomputeBehaviorMetrics.m
+++ b/matlab/BANOS/BANOScomputeBehaviorMetrics.m
@@ -1,25 +1,38 @@
-function behaviorMetrics = BANOScomputeBehaviorMetrics(predMatrix, gtMatrix, varargin)
+function [behaviorMetrics, behaviorComponents] = BANOScomputeBehaviorMetrics(predMatrix, gtMatrix, varargin)
% Computes metrics for each behavior in the prediction and ground truth matrices.
%
% Name-value option:
% 'missed_bouts' - how SO, TP and IC count a GT bout that no predicted
% bout overlaps: 'exclude' (default) or 'zero'
% (see BANOScalculateSO).
+ %
+ % Returns:
+ % behaviorMetrics - Struct with one field per behavior holding .precision,
+ % .recall, .f1_score, .so, .tp and .ic.
+ % behaviorComponents - Struct with one field per behavior holding what the
+ % metrics are computed from: the bout counts .n_tp,
+ % .n_fp, .n_fn and the terms .so_terms, .tp_terms,
+ % .ic_terms averaged by SO, TP and IC. Summed over
+ % files by BANOSpoolComponents.
options = BANOSparseOptions(varargin, {'missed_bouts'});
behaviorMetrics = struct();
+ behaviorComponents = struct();
for behavior = 1:size(predMatrix, 2)
behaviorName = predMatrix{1,behavior};
- predBouts = BANOSidentifyBouts(cell2mat(predMatrix(2:end, behavior)));
+ predSequence = cell2mat(predMatrix(2:end, behavior));
+ predBouts = BANOSidentifyBouts(predSequence);
gtBouts = BANOSidentifyBouts(cell2mat(gtMatrix(2:end, behavior)));
+ [nTp, nFp, nFn] = BANOScalculateTpFpFn(predBouts, gtBouts);
+ [so, soTerms] = BANOScalculateSO(predBouts, gtBouts, 'missed_bouts', options.missed_bouts);
+ [tp, tpTerms] = BANOScalculateTP(predBouts, gtBouts, 'missed_bouts', options.missed_bouts);
+ [ic, icTerms] = BANOScalculateIC(predSequence, gtBouts, 'missed_bouts', options.missed_bouts);
+ behaviorComponents.(behaviorName) = struct('n_tp', nTp, 'n_fp', nFp, 'n_fn', nFn, ...
+ 'so_terms', soTerms, 'tp_terms', tpTerms, 'ic_terms', icTerms);
+
if ~isempty(gtBouts)
- [tp, fp, fn] = BANOScalculateTpFpFn(predBouts, gtBouts);
- [precision, recall, f1] = BANOScalculatePrecisionRecallF1(tp, fp, fn);
- so = BANOScalculateSO(predBouts, gtBouts, 'missed_bouts', options.missed_bouts);
- tp = BANOScalculateTP(predBouts, gtBouts, 'missed_bouts', options.missed_bouts);
- ic = BANOScalculateIC(cell2mat(predMatrix(2:end, behavior)), gtBouts, ...
- 'missed_bouts', options.missed_bouts);
+ [precision, recall, f1] = BANOScalculatePrecisionRecallF1(nTp, nFp, nFn);
elseif isempty(predBouts)
% Correct absence: system correctly detected nothing
precision = 1.0; recall = 1.0; f1 = 1.0;
diff --git a/matlab/BANOS/BANOSparseOptions.m b/matlab/BANOS/BANOSparseOptions.m
index 81fffe5..82a8d6a 100644
--- a/matlab/BANOS/BANOSparseOptions.m
+++ b/matlab/BANOS/BANOSparseOptions.m
@@ -13,6 +13,7 @@
% Options and their values (the first value is the default):
% 'matching' - 'greedy', 'optimal'
% 'missed_bouts' - 'exclude', 'zero'
+ % 'aggregation' - 'per_file', 'pooled'
%
% Errors:
% BANOS:unknownOption - a name the caller does not accept.
@@ -21,7 +22,8 @@
allowedValues = struct( ...
'matching', {{'greedy', 'optimal'}}, ...
- 'missed_bouts', {{'exclude', 'zero'}});
+ 'missed_bouts', {{'exclude', 'zero'}}, ...
+ 'aggregation', {{'per_file', 'pooled'}});
options = struct();
for k = 1:numel(names)
diff --git a/matlab/BANOS/BANOSpoolComponents.m b/matlab/BANOS/BANOSpoolComponents.m
new file mode 100644
index 0000000..9f888b6
--- /dev/null
+++ b/matlab/BANOS/BANOSpoolComponents.m
@@ -0,0 +1,69 @@
+function [groupMetrics, overallMetrics] = BANOSpoolComponents(fileComponents)
+ % Pools the metric components of all files and scores each behavior once.
+ %
+ % For each behavior, the TP, FP and FN counts are summed over the files and
+ % give precision, recall and F1 (BANOScalculatePrecisionRecallF1); SO, TP
+ % and IC are the means of all their terms over all files. A file without
+ % the behavior adds its false bouts to FP and no SO, TP or IC term: the
+ % 1.0/0.0 absent-behavior rule of the per-file metrics does not apply.
+ % A behavior with no GT bout and no predicted bout in any file is NaN on
+ % all six metrics.
+ %
+ % Arguments:
+ % fileComponents - Struct with one field per file, each holding the
+ % behavior components returned as second output by
+ % BANOScalculateBANOSForEachFile.
+ %
+ % Returns:
+ % groupMetrics - Pooled metrics for each behavior.
+ % overallMetrics - Mean of the pooled metrics over the behaviors (nanmean).
+
+ pooled = struct();
+ fileNames = fieldnames(fileComponents);
+ for i = 1:length(fileNames)
+ components = fileComponents.(fileNames{i});
+ behaviors = fieldnames(components);
+ for j = 1:length(behaviors)
+ behavior = behaviors{j};
+ c = components.(behavior);
+ if ~isfield(pooled, behavior)
+ pooled.(behavior) = struct('n_tp', 0, 'n_fp', 0, 'n_fn', 0, ...
+ 'so_terms', [], 'tp_terms', [], 'ic_terms', []);
+ end
+ p = pooled.(behavior);
+ p.n_tp = p.n_tp + c.n_tp;
+ p.n_fp = p.n_fp + c.n_fp;
+ p.n_fn = p.n_fn + c.n_fn;
+ p.so_terms = [p.so_terms, c.so_terms];
+ p.tp_terms = [p.tp_terms, c.tp_terms];
+ p.ic_terms = [p.ic_terms, c.ic_terms];
+ pooled.(behavior) = p;
+ end
+ end
+
+ groupMetrics = struct();
+ overallMetrics = struct('precision', [], 'recall', [], 'f1_score', [], 'so', [], 'tp', [], 'ic', []);
+ metricFields = fieldnames(overallMetrics);
+ behaviors = fieldnames(pooled);
+ for j = 1:length(behaviors)
+ p = pooled.(behaviors{j});
+ [precision, recall, f1] = BANOScalculatePrecisionRecallF1(p.n_tp, p.n_fp, p.n_fn);
+ % Mean of each term list; NaN when a list is empty (no term)
+ terms = {p.so_terms, p.tp_terms, p.ic_terms};
+ termMeans = NaN(1, 3);
+ for k = 1:3
+ if ~isempty(terms{k})
+ termMeans(k) = mean(terms{k});
+ end
+ end
+ groupMetrics.(behaviors{j}) = struct('precision', precision, 'recall', recall, ...
+ 'f1_score', f1, 'so', termMeans(1), 'tp', termMeans(2), 'ic', termMeans(3));
+ for k = 1:length(metricFields)
+ overallMetrics.(metricFields{k})(end + 1) = groupMetrics.(behaviors{j}).(metricFields{k});
+ end
+ end
+
+ for k = 1:length(metricFields)
+ overallMetrics.(metricFields{k}) = nanmean(overallMetrics.(metricFields{k}));
+ end
+end
diff --git a/matlab/BANOS/Contents.m b/matlab/BANOS/Contents.m
index f44cd2a..761285e 100644
--- a/matlab/BANOS/Contents.m
+++ b/matlab/BANOS/Contents.m
@@ -8,6 +8,7 @@
% BANOSpreprocessData - Fill NaN, drop non-binary columns, align headers.
% BANOScalculateBANOSForEachFile - Compute metrics for each recording.
% BANOSaggregateMetrics - Average metrics across recordings (nanmean).
+% BANOSpoolComponents - Pool bout counts and terms across recordings.
% BANOScomputeBehaviorMetrics - Per-behavior metric computation for one recording.
%
% Metric functions:
diff --git a/src/banos/__init__.py b/src/banos/__init__.py
index eabfd42..e2d4a39 100644
--- a/src/banos/__init__.py
+++ b/src/banos/__init__.py
@@ -18,12 +18,15 @@
import pandas as pd
-from banos.core.metrics import _check_missed_bouts
from banos.core.preprocessing import preprocess_data
from banos.pipeline import (
+ _check_options,
aggregate_metrics,
calculate_banos_for_each_file,
+ calculate_components_for_each_file,
+ compute_behavior_components,
compute_behavior_metrics,
+ pool_components,
)
__all__ = [
@@ -32,6 +35,9 @@
"compute_behavior_metrics",
"calculate_banos_for_each_file",
"aggregate_metrics",
+ "compute_behavior_components",
+ "calculate_components_for_each_file",
+ "pool_components",
"__version__",
]
@@ -42,6 +48,7 @@ def score(
*,
matching: str = "greedy",
missed_bouts: str = "exclude",
+ aggregation: str = "per_file",
) -> dict | tuple[dict, dict]:
"""Compute BANOS metrics for one or multiple annotation pairs.
@@ -61,25 +68,54 @@ def score(
'exclude': left out of their averages (default)
'zero': one term equal to 0 in each of them
Precision, recall and F1 do not depend on this option.
+ aggregation : {'per_file', 'pooled'}
+ How the metrics are combined.
+ 'per_file': each file is scored on its own, then averaged (default)
+ 'pooled': each behavior is scored once on the bout counts and terms
+ of all files (see ``pool_components``)
Returns
-------
Single file : dict
``{behavior: {precision, recall, f1_score, so, tp, ic}}``
Multi file : tuple[dict, dict]
- ``(group_metrics, overall_metrics)``
+ ``(group_metrics, overall_metrics)``: ``group_metrics`` has the same
+ form as the single-file dict; ``overall_metrics`` is
+ ``{metric: value}``.
Notes
-----
- Metrics return ``NaN`` when ground truth has no bouts for a given behavior.
+ With ``aggregation='per_file'``, a behavior with no GT bout in a file scores
+ 1.0 on all six metrics if nothing is predicted there and 0.0 otherwise.
+ ``group_metrics`` averages each behavior over the files and
+ ``overall_metrics`` averages every (file, behavior) cell, NaN excluded.
+ With ``aggregation='pooled'``, this 1.0/0.0 rule does not apply and
+ ``overall_metrics`` averages the pooled behaviors, NaN excluded.
+ A metric is NaN when it is undefined: precision when nothing is predicted,
+ recall when there is no GT bout (pooled), SO, TP and IC when they have no
+ term (no overlap, or no found GT bout).
+
+ An unknown ``matching``, ``missed_bouts`` or ``aggregation`` value raises
+ ValueError before the data is read.
"""
- _check_missed_bouts(missed_bouts)
+ _check_options(matching, missed_bouts)
+ if aggregation not in ("per_file", "pooled"):
+ raise ValueError(f"Unknown aggregation: {aggregation!r}. Use 'per_file' or 'pooled'.")
if isinstance(data, pd.DataFrame):
if gt is None:
raise ValueError("When 'data' is a DataFrame, 'gt' must be provided.")
data_dict = {"single": (data, gt)}
data_dict, _ = preprocess_data(data_dict)
+ if aggregation == "pooled":
+ components = compute_behavior_components(
+ data_dict["single"][0],
+ data_dict["single"][1],
+ matching=matching,
+ missed_bouts=missed_bouts,
+ )
+ group_metrics, _ = pool_components({"single": components})
+ return group_metrics
return compute_behavior_metrics(
data_dict["single"][0],
data_dict["single"][1],
@@ -89,6 +125,11 @@ def score(
# Multi-file path
data_dict, _ = preprocess_data(dict(data))
+ if aggregation == "pooled":
+ file_components = calculate_components_for_each_file(
+ data_dict, matching=matching, missed_bouts=missed_bouts
+ )
+ return pool_components(file_components)
file_metrics = calculate_banos_for_each_file(
data_dict, matching=matching, missed_bouts=missed_bouts
)
diff --git a/src/banos/core/matching.py b/src/banos/core/matching.py
index aff9e95..5f059b7 100644
--- a/src/banos/core/matching.py
+++ b/src/banos/core/matching.py
@@ -8,6 +8,11 @@ def _overlaps(pred: tuple[int, int], gt: tuple[int, int]) -> bool:
return pred[1] >= gt[0] and pred[0] <= gt[1]
+def _check_matching(matching: str) -> None:
+ if matching not in ("greedy", "optimal"):
+ raise ValueError(f"Unknown matching algorithm: {matching!r}. Use 'greedy' or 'optimal'.")
+
+
def calculate_tp_fp_fn(
pred_bouts: list[tuple[int, int]],
gt_bouts: list[tuple[int, int]],
@@ -25,8 +30,7 @@ def calculate_tp_fp_fn(
-------
(true_positives, false_positives, false_negatives)
"""
- if matching not in ("greedy", "optimal"):
- raise ValueError(f"Unknown matching algorithm: {matching!r}. Use 'greedy' or 'optimal'.")
+ _check_matching(matching)
if not gt_bouts:
return 0, len(pred_bouts), 0
diff --git a/src/banos/pipeline.py b/src/banos/pipeline.py
index 58d6398..d01eaf0 100644
--- a/src/banos/pipeline.py
+++ b/src/banos/pipeline.py
@@ -7,83 +7,140 @@
import pandas as pd
-from banos.core.matching import calculate_tp_fp_fn
+from banos.core.matching import _check_matching, calculate_tp_fp_fn
from banos.core.metrics import (
_check_missed_bouts,
- calculate_ic,
+ _mean,
+ calculate_ic_terms,
calculate_precision_recall_f1,
- calculate_so,
- calculate_tp_metric,
+ calculate_so_terms,
+ calculate_tp_terms,
identify_bouts,
)
+METRIC_KEYS = ["precision", "recall", "f1_score", "so", "tp", "ic"]
-def compute_behavior_metrics(
+
+def _check_options(matching: str, missed_bouts: str) -> None:
+ """Raise ValueError for an unknown matching or missed_bouts value."""
+ _check_matching(matching)
+ _check_missed_bouts(missed_bouts)
+
+
+def _nanmean(values: list) -> float:
+ valid = [
+ v for v in values if v is not None and not math.isnan(v)
+ ] # None guard for JSON round-trip safety
+ return _mean(valid)
+
+
+def _metrics_from_components(components: dict) -> dict:
+ """The six metrics of one behavior from its bout counts and terms."""
+ precision, recall, f1_score = calculate_precision_recall_f1(
+ components["n_tp"], components["n_fp"], components["n_fn"]
+ )
+ return {
+ "precision": precision,
+ "recall": recall,
+ "f1_score": f1_score,
+ "so": _mean(components["so_terms"]),
+ "tp": _mean(components["tp_terms"]),
+ "ic": _mean(components["ic_terms"]),
+ }
+
+
+def compute_behavior_components(
pred_matrix: pd.DataFrame,
gt_matrix: pd.DataFrame,
matching: str = "greedy",
*,
missed_bouts: str = "exclude",
) -> dict:
- """Compute all BANOS metrics for each behavior column.
+ """Compute the components of the BANOS metrics for each behavior column.
+
+ The components are what the metrics are computed from: the bout counts
+ behind precision, recall and F1, and the terms that SO, TP and IC
+ average. Summed over files, they give the pooled metrics
+ (see ``pool_components``).
``missed_bouts`` sets how SO, TP and IC count a GT bout that no predicted
bout overlaps: left out ('exclude', default) or a term equal to 0 ('zero').
Returns
-------
- {behavior: {precision, recall, f1_score, so, tp, ic}}
+ {behavior: {n_tp, n_fp, n_fn, so_terms, tp_terms, ic_terms}}
+ n_tp, n_fp, n_fn : matched, false and missed bout counts
+ so_terms, tp_terms, ic_terms : lists of the terms of SO, TP and IC
"""
- _check_missed_bouts(missed_bouts)
- metrics: dict = {}
+ _check_options(matching, missed_bouts)
+ components: dict = {}
for behavior in pred_matrix.columns:
pred_bouts = identify_bouts(pred_matrix[behavior])
gt_bouts = identify_bouts(gt_matrix[behavior])
-
- if not gt_bouts:
- if not pred_bouts:
- # Correct absence: system correctly detected nothing
- metrics[behavior] = {
- "precision": 1.0,
- "recall": 1.0,
- "f1_score": 1.0,
- "so": 1.0,
- "tp": 1.0,
- "ic": 1.0,
- }
- else:
- # False detection: system predicted bouts that don't exist in GT
- metrics[behavior] = {
- "precision": 0.0,
- "recall": 0.0,
- "f1_score": 0.0,
- "so": 0.0,
- "tp": 0.0,
- "ic": 0.0,
- }
- continue
-
n_tp, n_fp, n_fn = calculate_tp_fp_fn(pred_bouts, gt_bouts, matching=matching)
- precision, recall, f1_score = calculate_precision_recall_f1(n_tp, n_fp, n_fn)
- so = calculate_so(pred_bouts, gt_bouts, missed_bouts=missed_bouts)
- tp_m = calculate_tp_metric(pred_bouts, gt_bouts, missed_bouts=missed_bouts)
- ic = calculate_ic(pred_matrix[behavior].to_numpy(), gt_bouts, missed_bouts=missed_bouts)
- metrics[behavior] = {
- "precision": precision,
- "recall": recall,
- "f1_score": f1_score,
- "so": so,
- "tp": tp_m,
- "ic": ic,
+ components[behavior] = {
+ "n_tp": n_tp,
+ "n_fp": n_fp,
+ "n_fn": n_fn,
+ "so_terms": calculate_so_terms(pred_bouts, gt_bouts, missed_bouts=missed_bouts),
+ "tp_terms": calculate_tp_terms(pred_bouts, gt_bouts, missed_bouts=missed_bouts),
+ "ic_terms": calculate_ic_terms(
+ pred_matrix[behavior].to_numpy(), gt_bouts, missed_bouts=missed_bouts
+ ),
}
+ return components
+
+
+def compute_behavior_metrics(
+ pred_matrix: pd.DataFrame,
+ gt_matrix: pd.DataFrame,
+ matching: str = "greedy",
+ *,
+ missed_bouts: str = "exclude",
+) -> dict:
+ """Compute all BANOS metrics for each behavior column.
+
+ ``missed_bouts`` sets how SO, TP and IC count a GT bout that no predicted
+ bout overlaps: left out ('exclude', default) or a term equal to 0 ('zero').
+
+ Returns
+ -------
+ {behavior: {precision, recall, f1_score, so, tp, ic}}
+ """
+ components = compute_behavior_components(
+ pred_matrix, gt_matrix, matching=matching, missed_bouts=missed_bouts
+ )
+ metrics: dict = {}
+ for behavior, behavior_components in components.items():
+ if behavior_components["n_tp"] + behavior_components["n_fn"] == 0:
+ # No GT bout: 1.0 for a correct absence (system correctly detected
+ # nothing), 0.0 for a false detection (system predicted bouts that
+ # don't exist in GT)
+ value = 1.0 if behavior_components["n_fp"] == 0 else 0.0
+ metrics[behavior] = dict.fromkeys(METRIC_KEYS, value)
+ else:
+ metrics[behavior] = _metrics_from_components(behavior_components)
return metrics
+def calculate_components_for_each_file(
+ preprocessed_data: dict, matching: str = "greedy", *, missed_bouts: str = "exclude"
+) -> dict:
+ """Compute the BANOS metric components for each file in the preprocessed data dict."""
+ _check_options(matching, missed_bouts)
+ return {
+ file_name: compute_behavior_components(
+ pred, gt, matching=matching, missed_bouts=missed_bouts
+ )
+ for file_name, (pred, gt) in preprocessed_data.items()
+ }
+
+
def calculate_banos_for_each_file(
preprocessed_data: dict, matching: str = "greedy", *, missed_bouts: str = "exclude"
) -> dict:
"""Compute BANOS metrics for each file in the preprocessed data dict."""
- _check_missed_bouts(missed_bouts)
+ _check_options(matching, missed_bouts)
return {
file_name: compute_behavior_metrics(pred, gt, matching=matching, missed_bouts=missed_bouts)
for file_name, (pred, gt) in preprocessed_data.items()
@@ -100,27 +157,62 @@ def aggregate_metrics(file_metrics: dict) -> tuple[dict, dict]:
group_metrics : {behavior: {metric: mean_across_files}}
overall_metrics: {metric: mean_across_behaviors_and_files}
"""
- metric_keys = ["precision", "recall", "f1_score", "so", "tp", "ic"]
group: dict = {}
- overall: dict = {k: [] for k in metric_keys}
+ overall: dict = {k: [] for k in METRIC_KEYS}
for behaviors in file_metrics.values():
for behavior, m in behaviors.items():
if behavior not in group:
- group[behavior] = {k: [] for k in metric_keys}
- for key in metric_keys:
+ group[behavior] = {k: [] for k in METRIC_KEYS}
+ for key in METRIC_KEYS:
val = m.get(key, float("nan"))
group[behavior][key].append(val)
overall[key].append(val)
- def _nanmean(values: list) -> float:
- valid = [
- v for v in values if v is not None and not math.isnan(v)
- ] # None guard for JSON round-trip safety
- return sum(valid) / len(valid) if valid else float("nan")
-
for behavior in group:
- for key in metric_keys:
+ for key in METRIC_KEYS:
group[behavior][key] = _nanmean(group[behavior][key])
return group, {key: _nanmean(vals) for key, vals in overall.items()}
+
+
+def pool_components(file_components: dict) -> tuple[dict, dict]:
+ """Pool the metric components of all files and score each behavior once.
+
+ For each behavior, the TP, FP and FN counts are summed over the files and
+ give precision, recall and F1; SO, TP and IC are the means of all their
+ terms over all files. A file without the behavior adds its false bouts to
+ FP and no SO, TP or IC term: the 1.0/0.0 absent-behavior rule of the
+ per-file metrics does not apply. A behavior with no GT bout and no
+ predicted bout in any file is NaN on all six metrics.
+
+ Parameters
+ ----------
+ file_components : {file_name: {behavior: components}}
+ As returned by ``calculate_components_for_each_file``.
+
+ Returns
+ -------
+ group_metrics : {behavior: {metric: pooled_value}}
+ overall_metrics: {metric: mean_of_pooled_values_across_behaviors} (NaN excluded)
+ """
+ pooled: dict = {}
+ for behaviors in file_components.values():
+ for behavior, components in behaviors.items():
+ if behavior not in pooled:
+ pooled[behavior] = {
+ "n_tp": 0,
+ "n_fp": 0,
+ "n_fn": 0,
+ "so_terms": [],
+ "tp_terms": [],
+ "ic_terms": [],
+ }
+ for key in ("n_tp", "n_fp", "n_fn"):
+ pooled[behavior][key] += components[key]
+ for key in ("so_terms", "tp_terms", "ic_terms"):
+ pooled[behavior][key].extend(components[key])
+
+ group = {behavior: _metrics_from_components(p) for behavior, p in pooled.items()}
+ overall = {key: _nanmean([m[key] for m in group.values()]) for key in METRIC_KEYS}
+ return group, overall
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index baabcb5..d986acb 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -10,6 +10,8 @@
Pipeline stages tested together:
preprocess_data -> calculate_banos_for_each_file -> aggregate_metrics
+ preprocess_data -> calculate_components_for_each_file -> pool_components
+ (the second chain is aggregation='pooled')
The test_aggregate_metrics_excludes_nan test is critical: it confirms that
recordings where a behavior has no GT bouts (NaN metrics) do NOT pull down
@@ -22,8 +24,12 @@
import pytest
import banos
+from banos.core.matching import calculate_tp_fp_fn
from banos.pipeline import (
aggregate_metrics,
+ calculate_components_for_each_file,
+ compute_behavior_components,
+ pool_components,
)
PRED = pd.DataFrame(
@@ -265,6 +271,17 @@ def test_file_with_only_false_bouts_scores_f1_zero():
assert abs(group_c["evt"]["f1_score"] - 0.5) < 1e-10
assert abs(group_d["evt"]["f1_score"] - 0.9) < 1e-10
+ # Pooled: C has TP 3, FP 3, FN 3 -> F1 0.5; D has TP 5, FP 0, FN 1 ->
+ # P 1, R 5/6, F1 10/11.
+ pooled_c, _ = banos.score(
+ {"v1": _video(REAL_BOUTS), "v2": _video(false_bouts)}, aggregation="pooled"
+ )
+ pooled_d, _ = banos.score(
+ {"v1": _video(REAL_BOUTS), "v2": _video(REAL_BOUTS[:2])}, aggregation="pooled"
+ )
+ assert abs(pooled_c["evt"]["f1_score"] - 0.5) < 1e-10
+ assert abs(pooled_d["evt"]["f1_score"] - 10 / 11) < 1e-10
+
def test_silent_model_on_bundled_data(all_recordings):
"""A model that predicts nothing scores F1 0 on every recording with the behavior.
@@ -287,3 +304,244 @@ def test_silent_model_on_bundled_data(all_recordings):
assert abs(overall["f1_score"] - 0.225) < 1e-10
assert math.isnan(group["investigation"]["ic"])
assert math.isnan(group["other"]["ic"])
+
+ # Pooled: TP 0, FP 0, FN > 0 for every behavior, so F1 is 0 everywhere;
+ # SO, TP and IC have no term (NaN), or only 0 terms with missed_bouts='zero'.
+ pooled, pooled_overall = banos.score(data, aggregation="pooled")
+ assert {m["f1_score"] for m in pooled.values()} == {0.0}
+ assert pooled_overall["f1_score"] == 0.0
+ assert all(math.isnan(m["so"]) for m in pooled.values())
+ pooled_zero, _ = banos.score(data, aggregation="pooled", missed_bouts="zero")
+ assert {m["so"] for m in pooled_zero.values()} == {0.0}
+
+
+# --- aggregation='pooled' ---
+
+
+def test_pooled_counts_and_terms_equal_hand_sums():
+ """Pooled metrics come from the bout counts and terms summed over the files.
+
+ Video a: predicted (5,9) and (26,29) -> TP 2, FP 0, FN 1; SO terms 1, 4/5;
+ TP terms 1, 1/2; IC terms 1, 3/4 (the third real bout is missed).
+ Video b: predicted (15,19) and (45,49) -> TP 1, FP 1, FN 2; one term of 1
+ for SO, TP and IC (bout 3 found exactly, bouts 1 and 2 missed).
+ Pooled: TP 3, FP 1, FN 3 -> P 3/4, R 1/2, F1 0.6; SO (1 + 0.8 + 1) / 3,
+ TP (1 + 0.5 + 1) / 3, IC (1 + 0.75 + 1) / 3. With missed_bouts='zero',
+ the three missed bouts add three 0 terms: SO = 2.8 / 6.
+ """
+ data = {"a": _video([(5, 9), (26, 29)]), "b": _video([(15, 19), (45, 49)])}
+ preprocessed, _ = banos.preprocess_data(data)
+ components = calculate_components_for_each_file(preprocessed)
+ assert components["a"]["evt"] == {
+ "n_tp": 2,
+ "n_fp": 0,
+ "n_fn": 1,
+ "so_terms": [1.0, 0.8],
+ "tp_terms": [1.0, 0.5],
+ "ic_terms": [1.0, 0.75],
+ }
+ assert components["b"]["evt"] == compute_behavior_components(*preprocessed["b"])["evt"]
+ assert components["b"]["evt"] == {
+ "n_tp": 1,
+ "n_fp": 1,
+ "n_fn": 2,
+ "so_terms": [1.0],
+ "tp_terms": [1.0],
+ "ic_terms": [1.0],
+ }
+
+ group, overall = pool_components(components)
+ expected = {
+ "precision": 0.75,
+ "recall": 0.5,
+ "f1_score": 0.6,
+ "so": 2.8 / 3,
+ "tp": 2.5 / 3,
+ "ic": 2.75 / 3,
+ }
+ for key, value in expected.items():
+ assert abs(group["evt"][key] - value) < 1e-10, key
+ assert overall == group["evt"]
+ assert banos.score(data, aggregation="pooled") == (group, overall)
+
+ zero, _ = banos.score(data, aggregation="pooled", missed_bouts="zero")
+ assert abs(zero["evt"]["so"] - 2.8 / 6) < 1e-10
+
+
+def test_pooled_absent_behavior():
+ """In pooled mode a file without the behavior adds its false bouts to FP, nothing else.
+
+ "evt" has the real bouts of video a only (P 1, R 2/3, F1 0.8); "rare"
+ has no real bout anywhere.
+ - Video c has neither behavior and predicts nothing: the pooled values
+ do not move (per file, its correct absences would score 1.0).
+ - "rare" with nothing predicted anywhere is NaN on all six metrics.
+ - Video d predicts one false bout of each: "evt" gets FP 1, so
+ P = R = F1 = 2/3 and SO, TP, IC are unchanged; "rare" gets P 0,
+ F1 0, and R, SO, TP, IC NaN.
+ overall_metrics is the mean over the behaviors, NaN excluded.
+ """
+
+ def video(evt_pred, evt_gt, rare_pred):
+ pred = pd.DataFrame({"evt": _frames(evt_pred), "rare": _frames(rare_pred)})
+ gt = pd.DataFrame({"evt": _frames(evt_gt), "rare": _frames([])})
+ return pred, gt
+
+ a = video([(5, 9), (26, 29)], REAL_BOUTS, [])
+ c = video([], [], [])
+ d = video([(15, 19)], [], [(40, 42)])
+
+ alone, _ = banos.score({"a": a}, aggregation="pooled")
+ group, overall = banos.score({"a": a, "c": c}, aggregation="pooled")
+ assert group["evt"] == alone["evt"]
+ assert abs(group["evt"]["f1_score"] - 0.8) < 1e-10
+ assert all(math.isnan(value) for value in group["rare"].values())
+ assert overall == group["evt"]
+
+ per_file, _ = banos.score({"a": a, "c": c})
+ assert abs(per_file["evt"]["f1_score"] - 0.9) < 1e-10 # (0.8 + 1.0) / 2
+ assert per_file["rare"]["f1_score"] == 1.0
+
+ group, overall = banos.score({"a": a, "c": c, "d": d}, aggregation="pooled")
+ evt, rare = group["evt"], group["rare"]
+ for key in ("precision", "recall", "f1_score"):
+ assert abs(evt[key] - 2 / 3) < 1e-10, key
+ for key in ("so", "tp", "ic"):
+ assert evt[key] == alone["evt"][key], key
+ assert rare["precision"] == 0.0
+ assert rare["f1_score"] == 0.0
+ assert all(math.isnan(rare[key]) for key in ("recall", "so", "tp", "ic"))
+ assert abs(overall["f1_score"] - 1 / 3) < 1e-10 # (2/3 + 0) / 2
+ assert abs(overall["recall"] - 2 / 3) < 1e-10 # rare's NaN recall is excluded
+ assert overall["so"] == evt["so"]
+
+
+def test_pooled_ranks_models_by_bouts_found():
+ """Model A finds 6 of 12 real bouts, model B 3 of 12: both modes rank A first.
+
+ Four videos with real bouts (5,9), (25,29), (45,49). A finds all three in
+ video 1 and the first one in videos 2-4; B finds all three in video 1 and
+ nothing else.
+ Per file: A (1 + 3 * 0.5) / 4 = 0.625 (F1 0.5 from P 1, R 1/3),
+ B (1 + 0 + 0 + 0) / 4 = 0.25. Pooled: A TP 6, FN 6 -> F1 2/3;
+ B TP 3, FN 9 -> F1 0.4. Before 1.1.0, B's three empty videos scored F1 NaN
+ and left the mean: B got 1.0 and ranked above A.
+ """
+ model_a = {"v1": _video(REAL_BOUTS)} | {f"v{k}": _video(REAL_BOUTS[:1]) for k in (2, 3, 4)}
+ model_b = {"v1": _video(REAL_BOUTS)} | {f"v{k}": _video([]) for k in (2, 3, 4)}
+ expected = {"per_file": (0.625, 0.25), "pooled": (2 / 3, 0.4)}
+ for aggregation, (f1_a, f1_b) in expected.items():
+ group_a, _ = banos.score(model_a, aggregation=aggregation)
+ group_b, _ = banos.score(model_b, aggregation=aggregation)
+ assert abs(group_a["evt"]["f1_score"] - f1_a) < 1e-10, aggregation
+ assert abs(group_b["evt"]["f1_score"] - f1_b) < 1e-10, aggregation
+
+
+def test_pooled_single_file_returns_dict_per_behavior():
+ """A single DataFrame pair gives a dict per behavior in both aggregation modes.
+
+ PRED and GT have real bouts of both behaviors, so pooling one file changes
+ no value. Without real bouts, per file scores 1.0 (correct absence) and
+ pooled NaN.
+ """
+ assert banos.score(PRED, GT, aggregation="pooled") == banos.score(PRED, GT)
+ absent = banos.score(*_video([], []), aggregation="pooled")["evt"]
+ assert all(math.isnan(value) for value in absent.values())
+
+
+def test_score_invalid_aggregation_raises():
+ with pytest.raises(ValueError, match="Unknown aggregation"):
+ banos.score(PRED, GT, aggregation="pool")
+ with pytest.raises(ValueError, match="Unknown aggregation"):
+ banos.score({"f1": (PRED, GT)}, aggregation="pool")
+
+
+@pytest.mark.parametrize(
+ "call, message",
+ [
+ pytest.param(
+ lambda: banos.score(NO_COLUMN, NO_COLUMN, matching="best"),
+ "Unknown matching",
+ id="score-no-column-matching",
+ ),
+ pytest.param(
+ lambda: banos.score(PRED * 0.5, GT, matching="best"),
+ "Unknown matching",
+ id="score-all-dropped-matching",
+ ),
+ pytest.param(
+ lambda: banos.score({}, matching="best"),
+ "Unknown matching",
+ id="score-no-file-matching",
+ ),
+ pytest.param(
+ lambda: banos.score({}, aggregation="pool"),
+ "Unknown aggregation",
+ id="score-no-file-aggregation",
+ ),
+ pytest.param(
+ lambda: compute_behavior_components(NO_COLUMN, NO_COLUMN, matching="best"),
+ "Unknown matching",
+ id="compute_behavior_components-matching",
+ ),
+ pytest.param(
+ lambda: compute_behavior_components(NO_COLUMN, NO_COLUMN, missed_bouts="drop"),
+ "Unknown missed_bouts",
+ id="compute_behavior_components-missed_bouts",
+ ),
+ pytest.param(
+ lambda: banos.compute_behavior_metrics(NO_COLUMN, NO_COLUMN, matching="best"),
+ "Unknown matching",
+ id="compute_behavior_metrics-matching",
+ ),
+ pytest.param(
+ lambda: calculate_components_for_each_file({}, matching="best"),
+ "Unknown matching",
+ id="calculate_components_for_each_file-matching",
+ ),
+ pytest.param(
+ lambda: calculate_components_for_each_file({}, missed_bouts="drop"),
+ "Unknown missed_bouts",
+ id="calculate_components_for_each_file-missed_bouts",
+ ),
+ pytest.param(
+ lambda: banos.calculate_banos_for_each_file({}, matching="best"),
+ "Unknown matching",
+ id="calculate_banos_for_each_file-matching",
+ ),
+ ],
+)
+def test_invalid_options_raise_without_any_column(call, message):
+ """Unknown matching, missed_bouts and aggregation values raise even when
+ no column reaches the bout matching or the metrics (no column, every
+ column dropped, no file)."""
+ with pytest.raises(ValueError, match=message):
+ call()
+
+
+@pytest.mark.parametrize("aggregation", ["per_file", "pooled"])
+@pytest.mark.parametrize("several_files", [False, True])
+def test_score_passes_matching_to_bout_matching(monkeypatch, aggregation, several_files):
+ """Every score() path hands the matching option to calculate_tp_fp_fn.
+
+ Greedy and optimal matching count the same true positives on bouts read
+ from frame sequences: such bouts never overlap one another, on either
+ side, and then taking the first free overlapping real bout is already a
+ maximum matching. No input of score() can tell the two apart by its
+ numbers, so this test records the option that reaches the bout matching.
+ """
+ seen = []
+
+ def spy(pred_bouts, gt_bouts, matching="greedy"):
+ seen.append(matching)
+ return calculate_tp_fp_fn(pred_bouts, gt_bouts, matching=matching)
+
+ monkeypatch.setattr("banos.pipeline.calculate_tp_fp_fn", spy)
+ if several_files:
+ banos.score(
+ {"f1": (PRED, GT), "f2": (PRED, GT)}, matching="optimal", aggregation=aggregation
+ )
+ else:
+ banos.score(PRED, GT, matching="optimal", aggregation=aggregation)
+ assert seen
+ assert set(seen) == {"optimal"}
From 2ce6419fe3df35f423b875fe0c8c891d4718ca8f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Beno=C3=AEt=20J=2E=20T=2E=20GIRARD?=
<53222414+BenoitJT-GIRARD@users.noreply.github.com>
Date: Thu, 29 Oct 2026 22:50:26 +0100
Subject: [PATCH 06/25] fix: check input lengths and warn when columns are
dropped
preprocess_data, and so score(), now raises ValueError when the
prediction and the ground truth of a file differ in number of frames
(a shorter prediction used to raise KeyError, a longer one was scored)
or when a file has no frame. It emits a UserWarning naming the file and
the column for each column dropped (values other than 0 and 1, such as
probabilities or inf, or a column missing or invalid on the other side)
and for each column whose NaN values are replaced by 0; score() used to
discard the drop report without a word. The warnings point at the line
that called preprocess_data() or score(). Valid input gives the same
results and no warning.
BANOSpreprocessData raises and warns the same way, with the identifiers
BANOS:lengthMismatch, BANOS:noFrame, BANOS:columnDropped and
BANOS:nanReplaced.
---
matlab/BANOS/BANOSpreprocessData.m | 56 ++++++++++++++++++--
src/banos/__init__.py | 10 ++--
src/banos/core/preprocessing.py | 53 +++++++++++++++++++
tests/test_pipeline.py | 58 +++++++++++++++++++++
tests/test_preprocessing.py | 84 +++++++++++++++++++++++++++++-
5 files changed, 253 insertions(+), 8 deletions(-)
diff --git a/matlab/BANOS/BANOSpreprocessData.m b/matlab/BANOS/BANOSpreprocessData.m
index d94a72c..23920fa 100644
--- a/matlab/BANOS/BANOSpreprocessData.m
+++ b/matlab/BANOS/BANOSpreprocessData.m
@@ -2,9 +2,19 @@
% Preprocesses prediction and ground truth data for BANOS computation.
%
% Steps performed for each file:
- % 1. Replace NaN values with 0.
- % 2. Drop columns whose values are not strictly binary (0/1).
- % 3. Retain only behavior columns whose names appear in BOTH pred and gt.
+ % 1. Check that pred and gt have the same number of frames (rows 2+),
+ % and at least one.
+ % 2. Replace NaN values with 0.
+ % 3. Drop columns whose values are not strictly binary (0/1).
+ % 4. Retain only behavior columns whose names appear in BOTH pred and gt.
+ %
+ % Errors and warnings:
+ % BANOS:lengthMismatch (error) - pred and gt differ in number of frames.
+ % BANOS:noFrame (error) - a file has no frame.
+ % BANOS:nanReplaced (warning) - NaN values of a column replaced by 0.
+ % BANOS:columnDropped (warning) - a column dropped (values other than
+ % 0 and 1, or missing or invalid on the other side).
+ % Each message names the file and the column.
%
% Arguments:
% dataDict - struct; each field is a recording name whose value is
@@ -27,6 +37,18 @@
predMatrix = extractMatrix{1};
gtMatrix = extractMatrix{2};
+ % --- Check the number of frames (rows 2+) ---
+ nPredFrames = max(size(predMatrix, 1) - 1, 0);
+ nGtFrames = max(size(gtMatrix, 1) - 1, 0);
+ if nPredFrames ~= nGtFrames
+ error('BANOS:lengthMismatch', ...
+ 'File ''%s'': the prediction has %d frames but the ground truth has %d.', ...
+ fileName, nPredFrames, nGtFrames);
+ end
+ if nPredFrames == 0
+ error('BANOS:noFrame', 'File ''%s'' has no frame.', fileName);
+ end
+
% --- Separate headers (row 1) from data (rows 2+) ---
predHeaders = predMatrix(1, :);
gtHeaders = gtMatrix(1, :);
@@ -34,12 +56,30 @@
gtData = cell2mat(gtMatrix(2:end, :));
% --- Replace NaNs with zeros ---
+ sides = {'prediction', 'ground truth'};
+ nanCounts = {sum(isnan(predData), 1), sum(isnan(gtData), 1)};
+ sideHeaders = {predHeaders, gtHeaders};
+ for s = 1:2
+ for k = find(nanCounts{s} > 0)
+ warning('BANOS:nanReplaced', ...
+ 'File ''%s'': %d NaN value(s) in %s column ''%s'' replaced by 0.', ...
+ fileName, nanCounts{s}(k), sides{s}, sideHeaders{s}{k});
+ end
+ end
predData(isnan(predData)) = 0;
gtData(isnan(gtData)) = 0;
% --- Drop non-logical columns from data, track which indices ---
[predData, predDropped] = BANOSdropNonLogicalColumns(predData);
[gtData, gtDropped] = BANOSdropNonLogicalColumns(gtData);
+ sideDropped = {predDropped, gtDropped};
+ for s = 1:2
+ for k = sideDropped{s}
+ warning('BANOS:columnDropped', ...
+ 'File ''%s'': %s column ''%s'' dropped (values other than 0 and 1).', ...
+ fileName, sides{s}, sideHeaders{s}{k});
+ end
+ end
% Update header arrays to reflect dropped columns
predKeepIdx = setdiff(1:length(predHeaders), predDropped);
@@ -47,6 +87,16 @@
predHeaders = predHeaders(predKeepIdx);
gtHeaders = gtHeaders(gtKeepIdx);
+ % --- Warn about columns present on one side only ---
+ oneSide = {setdiff(predHeaders, gtHeaders, 'stable'), setdiff(gtHeaders, predHeaders, 'stable')};
+ for s = 1:2
+ for k = 1:numel(oneSide{s})
+ warning('BANOS:columnDropped', ...
+ 'File ''%s'': %s column ''%s'' dropped (missing or invalid in the %s).', ...
+ fileName, sides{s}, oneSide{s}{k}, sides{3 - s});
+ end
+ end
+
% --- Retain only columns present in both pred and gt ---
commonHeaders = intersect(predHeaders, gtHeaders, 'stable');
[~, predIdx] = ismember(commonHeaders, predHeaders);
diff --git a/src/banos/__init__.py b/src/banos/__init__.py
index e2d4a39..e5e2c22 100644
--- a/src/banos/__init__.py
+++ b/src/banos/__init__.py
@@ -18,7 +18,7 @@
import pandas as pd
-from banos.core.preprocessing import preprocess_data
+from banos.core.preprocessing import _preprocess_data, preprocess_data
from banos.pipeline import (
_check_options,
aggregate_metrics,
@@ -97,6 +97,10 @@ def score(
An unknown ``matching``, ``missed_bouts`` or ``aggregation`` value raises
ValueError before the data is read.
+
+ The input goes through ``preprocess_data``: a ValueError is raised when the
+ prediction and the ground truth of a file differ in length or a file has no
+ frame, and a UserWarning names each column dropped and each NaN replaced by 0.
"""
_check_options(matching, missed_bouts)
if aggregation not in ("per_file", "pooled"):
@@ -106,7 +110,7 @@ def score(
if gt is None:
raise ValueError("When 'data' is a DataFrame, 'gt' must be provided.")
data_dict = {"single": (data, gt)}
- data_dict, _ = preprocess_data(data_dict)
+ data_dict, _ = _preprocess_data(data_dict)
if aggregation == "pooled":
components = compute_behavior_components(
data_dict["single"][0],
@@ -124,7 +128,7 @@ def score(
)
# Multi-file path
- data_dict, _ = preprocess_data(dict(data))
+ data_dict, _ = _preprocess_data(dict(data))
if aggregation == "pooled":
file_components = calculate_components_for_each_file(
data_dict, matching=matching, missed_bouts=missed_bouts
diff --git a/src/banos/core/preprocessing.py b/src/banos/core/preprocessing.py
index 24c47a3..bd6fba8 100644
--- a/src/banos/core/preprocessing.py
+++ b/src/banos/core/preprocessing.py
@@ -3,6 +3,8 @@
from __future__ import annotations
+import warnings
+
import pandas as pd
@@ -39,10 +41,19 @@ def match_headers(
return pred_matrix[common], gt_matrix[common], dropped
+def _warn(message: str) -> None:
+ # Level 4 is the line that called preprocess_data() or score(): the levels
+ # below are _warn, _preprocess_data, then preprocess_data or score.
+ warnings.warn(message, UserWarning, stacklevel=4)
+
+
def preprocess_data(data_dict: dict[str, tuple[pd.DataFrame, pd.DataFrame]]) -> tuple[dict, dict]:
"""Preprocess prediction and ground truth DataFrames.
Fills NaN with 0, drops non-binary columns, aligns headers.
+ Each NaN replacement and each dropped column (values other than 0 and 1,
+ or a column present on one side only) emits a UserWarning naming the file
+ and the column.
Parameters
----------
@@ -52,14 +63,56 @@ def preprocess_data(data_dict: dict[str, tuple[pd.DataFrame, pd.DataFrame]]) ->
-------
data_dict : cleaned data
dropped_info : per-file info about dropped columns
+
+ Raises
+ ------
+ ValueError
+ If the prediction and the ground truth of a file differ in length
+ (number of rows, i.e. frames), or a file has no frame.
"""
+ return _preprocess_data(data_dict)
+
+
+def _preprocess_data(data_dict: dict[str, tuple[pd.DataFrame, pd.DataFrame]]) -> tuple[dict, dict]:
+ # Body of preprocess_data(). score() calls it directly, so that the
+ # warnings point at the caller's line in both cases (see _warn).
data_dict = dict(data_dict) # avoid mutating caller's dict
dropped_info: dict = {}
for file_name, (pred, gt) in data_dict.items():
+ if len(pred) != len(gt):
+ raise ValueError(
+ f"File {file_name!r}: the prediction has {len(pred)} frames "
+ f"but the ground truth has {len(gt)}."
+ )
+ if len(pred) == 0:
+ raise ValueError(f"File {file_name!r} has no frame.")
+ for side, matrix in (("prediction", pred), ("ground truth", gt)):
+ for column, n_nan in matrix.isna().sum().items():
+ if n_nan:
+ _warn(
+ f"File {file_name!r}: {n_nan} NaN value(s) in {side} column "
+ f"{column!r} replaced by 0."
+ )
pred = pred.fillna(0)
gt = gt.fillna(0)
pred, pred_dropped = drop_non_logical_columns(pred)
gt, gt_dropped = drop_non_logical_columns(gt)
+ for side, dropped in (("prediction", pred_dropped), ("ground truth", gt_dropped)):
+ for column in dropped:
+ _warn(
+ f"File {file_name!r}: {side} column {column!r} dropped "
+ "(values other than 0 and 1)."
+ )
+ for column in pred.columns.difference(gt.columns, sort=False):
+ _warn(
+ f"File {file_name!r}: prediction column {column!r} dropped "
+ "(missing or invalid in the ground truth)."
+ )
+ for column in gt.columns.difference(pred.columns, sort=False):
+ _warn(
+ f"File {file_name!r}: ground truth column {column!r} dropped "
+ "(missing or invalid in the prediction)."
+ )
pred, gt, header_dropped = match_headers(pred, gt)
data_dict[file_name] = (pred, gt)
dropped_info[file_name] = {
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index d986acb..9e46296 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -19,6 +19,8 @@
"""
import math
+import warnings
+from pathlib import Path
import pandas as pd
import pytest
@@ -449,6 +451,62 @@ def test_pooled_single_file_returns_dict_per_behavior():
assert all(math.isnan(value) for value in absent.values())
+def test_score_reports_dropped_columns_and_rejects_length_mismatch():
+ """score() no longer drops columns silently, and checks the lengths.
+
+ A probability column (0 or 0.2) is dropped with a warning naming the file
+ and the column, in single- and multi-file calls; before 1.1.0 the second
+ file silently left the mean. A prediction shorter or longer than its
+ ground truth raises ValueError (it used to raise KeyError or be scored).
+ """
+ pred, gt = _video(REAL_BOUTS)
+ probability = pred * 0.2
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ assert banos.score(probability, gt) == {}
+ group, _ = banos.score({"v1": (pred, gt), "v2": (probability, gt)})
+ assert [str(w.message) for w in caught if w.category is UserWarning] == [
+ "File 'single': prediction column 'evt' dropped (values other than 0 and 1).",
+ "File 'single': ground truth column 'evt' dropped (missing or invalid in the prediction).",
+ "File 'v2': prediction column 'evt' dropped (values other than 0 and 1).",
+ "File 'v2': ground truth column 'evt' dropped (missing or invalid in the prediction).",
+ ]
+ assert group["evt"]["f1_score"] == 1.0 # from v1 alone
+ with pytest.raises(ValueError, match="File 'single': the prediction has 45 frames"):
+ banos.score(pred.iloc[:45], gt)
+ with pytest.raises(ValueError, match="File 'v2': the prediction has 60 frames"):
+ banos.score({"v1": (pred, gt), "v2": (pred, gt.iloc[:45])})
+
+
+@pytest.mark.parametrize(
+ "call",
+ [
+ pytest.param(lambda data: banos.preprocess_data(data), id="preprocess_data"),
+ pytest.param(lambda data: banos.score(*data["v"]), id="score-single"),
+ pytest.param(
+ lambda data: banos.score(*data["v"], aggregation="pooled"), id="score-single-pooled"
+ ),
+ pytest.param(lambda data: banos.score(data), id="score-several"),
+ pytest.param(
+ lambda data: banos.score(data, aggregation="pooled"), id="score-several-pooled"
+ ),
+ ],
+)
+def test_warnings_point_at_the_caller(call):
+ """Warnings name the caller's line, through preprocess_data() and every score() path.
+
+ Python shows a warning with the file and line it is attributed to; one
+ stack level too few would name a line of banos instead of this file.
+ """
+ pred, gt = _video(REAL_BOUTS)
+ data = {"v": (pred * 0.2, gt)}
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ call(data)
+ assert caught
+ assert {Path(w.filename).resolve() for w in caught} == {Path(__file__).resolve()}
+
+
def test_score_invalid_aggregation_raises():
with pytest.raises(ValueError, match="Unknown aggregation"):
banos.score(PRED, GT, aggregation="pool")
diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py
index 730873c..ab8a58f 100644
--- a/tests/test_preprocessing.py
+++ b/tests/test_preprocessing.py
@@ -14,9 +14,15 @@
- Column dropping: non-binary columns are removed and their names reported.
- Header alignment: only behaviors present in both pred and GT are kept.
- NaN filling: missing values are replaced with 0 before further processing.
+- Input checks: ValueError when a file's prediction and ground truth differ in
+ length or have no frame; a UserWarning for each NaN replacement and each
+ dropped column, naming the file and the column.
"""
+import warnings
+
import pandas as pd
+import pytest
from banos.core.preprocessing import (
drop_non_logical_columns,
@@ -98,7 +104,8 @@ def test_match_headers_all_common():
def test_preprocess_data_fills_nan():
pred = pd.DataFrame({"a": [float("nan"), 1.0, 0.0]})
gt = pd.DataFrame({"a": [1.0, float("nan"), 0.0]})
- result, _ = preprocess_data({"f1": (pred, gt)})
+ with pytest.warns(UserWarning, match="replaced by 0"):
+ result, _ = preprocess_data({"f1": (pred, gt)})
pred_out, gt_out = result["f1"]
assert pred_out["a"].isna().sum() == 0
assert gt_out["a"].isna().sum() == 0
@@ -122,7 +129,80 @@ def test_preprocess_data_does_not_mutate_caller_dict():
def test_preprocess_data_drops_non_logical():
pred = pd.DataFrame({"a": [0, 1], "b": [0, 2]})
gt = pd.DataFrame({"a": [1, 0], "b": [1, 1]})
- result, info = preprocess_data({"f1": (pred, gt)})
+ with pytest.warns(UserWarning, match="dropped"):
+ result, info = preprocess_data({"f1": (pred, gt)})
pred_out, _ = result["f1"]
assert "b" not in pred_out.columns
assert "b" in info["f1"]["pred_dropped"]
+
+
+# --- input checks: errors and warnings ---
+
+
+def _preprocess_with_warnings(data_dict: dict) -> tuple[dict, dict, list[str]]:
+ """Run preprocess_data and also return the messages of the UserWarnings it emits."""
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ result, info = preprocess_data(data_dict)
+ messages = [str(w.message) for w in caught if w.category is UserWarning]
+ return result, info, messages
+
+
+def test_preprocess_data_raises_on_length_mismatch():
+ """Prediction and ground truth of a file must have the same number of frames (rows)."""
+ pred = pd.DataFrame({"a": [0, 1, 0]})
+ gt = pd.DataFrame({"a": [0, 1, 0, 0]})
+ with pytest.raises(ValueError, match="File 'f1': the prediction has 3 frames but"):
+ preprocess_data({"ok": (gt, gt.copy()), "f1": (pred, gt)})
+
+
+def test_preprocess_data_raises_on_file_without_frame():
+ empty = pd.DataFrame({"a": []})
+ with pytest.raises(ValueError, match="File 'f1' has no frame"):
+ preprocess_data({"f1": (empty, empty.copy())})
+
+
+def test_preprocess_data_warns_for_each_nan_column():
+ """Each column with NaN replaced by 0 is reported with its file, side and count."""
+ pred = pd.DataFrame({"a": [float("nan"), 1.0, float("nan")], "b": [0, 1, 0]})
+ gt = pd.DataFrame({"a": [1.0, 0.0, 0.0], "b": [0.0, float("nan"), 1.0]})
+ result, _, messages = _preprocess_with_warnings({"f1": (pred, gt)})
+ assert messages == [
+ "File 'f1': 2 NaN value(s) in prediction column 'a' replaced by 0.",
+ "File 'f1': 1 NaN value(s) in ground truth column 'b' replaced by 0.",
+ ]
+ assert result["f1"][0]["a"].tolist() == [0.0, 1.0, 0.0]
+
+
+def test_preprocess_data_warns_for_each_dropped_column():
+ """Each dropped column is reported: values other than 0/1 (a probability, inf)
+ or a column missing, or dropped, on the other side."""
+ pred = pd.DataFrame(
+ {"a": [0, 1, 0], "prob": [0.2, 0.9, 0.1], "only_pred": [0, 1, 1], "inf": [0, 1, 0]}
+ )
+ gt = pd.DataFrame(
+ {"a": [0, 1, 1], "prob": [0, 1, 0], "only_gt": [1, 0, 0], "inf": [0, float("inf"), 1]}
+ )
+ result, info, messages = _preprocess_with_warnings({"f1": (pred, gt)})
+ assert list(result["f1"][0].columns) == ["a"]
+ assert list(result["f1"][1].columns) == ["a"]
+ assert info["f1"]["pred_dropped"] == ["prob"]
+ assert info["f1"]["gt_dropped"] == ["inf"]
+ assert messages == [
+ "File 'f1': prediction column 'prob' dropped (values other than 0 and 1).",
+ "File 'f1': ground truth column 'inf' dropped (values other than 0 and 1).",
+ "File 'f1': prediction column 'only_pred' dropped (missing or invalid in the ground truth).",
+ "File 'f1': prediction column 'inf' dropped (missing or invalid in the ground truth).",
+ "File 'f1': ground truth column 'prob' dropped (missing or invalid in the prediction).",
+ "File 'f1': ground truth column 'only_gt' dropped (missing or invalid in the prediction).",
+ ]
+
+
+def test_preprocess_data_valid_input_emits_no_warning():
+ """Binary columns (int, float or bool) on both sides, same length: no warning."""
+ pred = pd.DataFrame({"a": [0, 1, 1], "b": [True, False, True]})
+ gt = pd.DataFrame({"a": [0.0, 1.0, 0.0], "b": [1, 0, 0]})
+ with warnings.catch_warnings():
+ warnings.simplefilter("error")
+ _, info = preprocess_data({"f1": (pred, gt)})
+ assert info["f1"] == {"pred_dropped": [], "gt_dropped": [], "header_dropped": []}
From 4eea9182e68c356b9f519c051e5d6a4cc996973a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Beno=C3=AEt=20J=2E=20T=2E=20GIRARD?=
<53222414+BenoitJT-GIRARD@users.noreply.github.com>
Date: Thu, 29 Oct 2026 22:13:03 +0100
Subject: [PATCH 07/25] test: add golden values for the ten recordings scored
together
The per-recording goldens score each recording alone, so the means
across recordings and the pooled metrics were never pinned. The
generators now also score the 10 bundled recordings together with
score() and BANOS_score, once per combination of missed_bouts and
aggregation, under "all_recordings" in golden_python_v110.json and
golden_matlab_v110.json. test_regression checks these values at 1e-10
and test_parity checks Python against MATLAB at 1e-6.
---
tests/fixtures/golden_matlab_v110.json | 2 +-
tests/fixtures/golden_python_v110.json | 178 ++++++++++++++++++
.../matlab/generate_matlab_fixtures.m | 19 ++
.../python/generate_golden_fixtures.py | 26 ++-
tests/test_parity.py | 40 +++-
tests/test_regression.py | 42 ++++-
6 files changed, 301 insertions(+), 6 deletions(-)
diff --git a/tests/fixtures/golden_matlab_v110.json b/tests/fixtures/golden_matlab_v110.json
index 3e0663f..049172c 100644
--- a/tests/fixtures/golden_matlab_v110.json
+++ b/tests/fixtures/golden_matlab_v110.json
@@ -1 +1 @@
-{"Recording_1":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.98056034808521408},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.98286421281656633},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.99243908336739384}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.98056034808521408},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.98286421281656633},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.99243908336739384}},"overall_metrics":{"precision":0.92826086956521736,"recall":0.78129251700680269,"f1_score":0.836455036759336,"so":0.761228298078524,"tp":0.30623014745659,"ic":0.98896591106729359}},"Recording_2":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98771568436705637},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99357808016301785},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99074891239625906}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98771568436705637},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99357808016301785},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99074891239625906}},"overall_metrics":{"precision":0.9671052631578948,"recall":0.83992445266728333,"f1_score":0.897300738477209,"so":0.82594559036003834,"tp":0.30908214265027689,"ic":0.99301066923158332}},"Recording_3":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98261726264923754},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.98941095585555594},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99591253786234157}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98261726264923754},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.98941095585555594},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99591253786234157}},"overall_metrics":{"precision":0.95948072383949645,"recall":0.85096664987969339,"f1_score":0.89862686038270723,"so":0.723715686180092,"tp":0.26887555753525666,"ic":0.99198518909178379}},"Recording_4":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.98586849198997339},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99092353265377653}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.98586849198997339},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99092353265377653}},"overall_metrics":{"precision":0.975,"recall":0.61568627450980384,"f1_score":0.72916666666666674,"so":0.70708735677240686,"tp":0.27571526135052643,"ic":0.99341862874528308}},"Recording_5":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98823265836029839},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99615676486935811},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.993672706200401}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98823265836029839},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99615676486935811},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.993672706200401}},"overall_metrics":{"precision":1,"recall":0.89210526315789473,"f1_score":0.94145869368256319,"so":0.83673046008612484,"tp":0.27928892486843937,"ic":0.99451553235751433}},"Recording_6":{"file_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.98838846071505027},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.98965295127585839},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.9904687807255419}},"group_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.98838846071505027},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.98965295127585839},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.9904687807255419}},"overall_metrics":{"precision":0.91408268733850129,"recall":0.74567099567099571,"f1_score":0.79250474383301706,"so":0.624464020496978,"tp":0.26406773460888022,"ic":0.99212754817911264}},"Recording_7":{"file_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98891661542484988},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.98181290038327618},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.99318498549600709}},"group_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98891661542484988},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.98181290038327618},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.99318498549600709}},"overall_metrics":{"precision":1,"recall":0.58390957446808511,"f1_score":0.70652984743264124,"so":0.66449505594099667,"tp":0.27653014329623343,"ic":0.99097862532603331}},"Recording_8":{"file_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99318530440005481},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":null},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99102642248586637}},"group_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99318530440005481},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":null},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99102642248586637}},"overall_metrics":{"precision":0.89166666666666661,"recall":0.34672064777327932,"f1_score":0.45462962962962961,"so":0.53056180162368516,"tp":0.038138242319112579,"ic":0.992043603151849}},"Recording_9":{"file_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98862824312860653},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99269356597600877},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99282392960084676}},"group_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98862824312860653},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99269356597600877},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99282392960084676}},"overall_metrics":{"precision":0.79839767616191892,"recall":0.65381944444444451,"f1_score":0.712354862119013,"so":0.68272952732383074,"tp":0.27780938219974027,"ic":0.99353643467636554}},"Recording_10":{"file_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.99080565536832721},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.98123242136180233},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.99554269505719462}},"group_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.99080565536832721},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.98123242136180233},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.99554269505719462}},"overall_metrics":{"precision":0.89910714285714288,"recall":0.47575971177944865,"f1_score":0.58347469607011593,"so":0.66680627484973121,"tp":0.28592277410718,"ic":0.99189519294683093}}}
+{"Recording_1":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.98056034808521408},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.98286421281656633},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.99243908336739384}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.8,"recall":0.73469387755102045,"f1_score":0.76595744680851063,"so":0.54440950736998917,"tp":0.0846356832664267,"ic":0.98056034808521408},"mount":{"precision":1,"recall":0.53333333333333333,"f1_score":0.69565217391304357,"so":0.73327668724369643,"tp":0.07847125492506643,"ic":0.98286421281656633},"other":{"precision":0.91304347826086951,"recall":0.8571428571428571,"f1_score":0.88421052631578945,"so":0.76722699770041058,"tp":0.061813651634866906,"ic":0.99243908336739384}},"overall_metrics":{"precision":0.92826086956521736,"recall":0.78129251700680269,"f1_score":0.836455036759336,"so":0.761228298078524,"tp":0.30623014745659,"ic":0.98896591106729359}},"Recording_2":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98771568436705637},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99357808016301785},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99074891239625906}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.89473684210526316,"recall":0.73913043478260865,"f1_score":0.80952380952380953,"so":0.6609424826096989,"tp":0.046151098692370357,"ic":0.98771568436705637},"mount":{"precision":1,"recall":0.83333333333333337,"f1_score":0.90909090909090906,"so":0.931754332584827,"tp":0.1451695616211745,"ic":0.99357808016301785},"other":{"precision":0.97368421052631582,"recall":0.78723404255319152,"f1_score":0.87058823529411766,"so":0.7110855462456277,"tp":0.045007910287562576,"ic":0.99074891239625906}},"overall_metrics":{"precision":0.9671052631578948,"recall":0.83992445266728333,"f1_score":0.897300738477209,"so":0.82594559036003834,"tp":0.30908214265027689,"ic":0.99301066923158332}},"Recording_3":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98261726264923754},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.98941095585555594},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99591253786234157}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.93548387096774188,"recall":0.78378378378378377,"f1_score":0.85294117647058809,"so":0.45631035306786549,"tp":0.022321586557310428,"ic":0.98261726264923754},"mount":{"precision":1,"recall":0.73913043478260865,"f1_score":0.85,"so":0.67450421524223569,"tp":0.028794827943741812,"ic":0.98941095585555594},"other":{"precision":0.90243902439024393,"recall":0.88095238095238093,"f1_score":0.89156626506024106,"so":0.76404817641026657,"tp":0.024385815639974422,"ic":0.99591253786234157}},"overall_metrics":{"precision":0.95948072383949645,"recall":0.85096664987969339,"f1_score":0.89862686038270723,"so":0.723715686180092,"tp":0.26887555753525666,"ic":0.99198518909178379}},"Recording_4":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.98586849198997339},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99092353265377653}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":0.9,"recall":0.52941176470588236,"f1_score":0.66666666666666674,"so":0.39147100794903517,"tp":0.014025218005513157,"ic":0.99688249033738241},"mount":{"precision":1,"recall":0.33333333333333331,"f1_score":0.5,"so":0.70111852816432363,"tp":0.040087174457883831,"ic":0.98586849198997339},"other":{"precision":1,"recall":0.6,"f1_score":0.74999999999999989,"so":0.73575989097626859,"tp":0.048748652938708746,"ic":0.99092353265377653}},"overall_metrics":{"precision":0.975,"recall":0.61568627450980384,"f1_score":0.72916666666666674,"so":0.70708735677240686,"tp":0.27571526135052643,"ic":0.99341862874528308}},"Recording_5":{"file_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98823265836029839},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99615676486935811},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.993672706200401}},"group_metrics":{"attack":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"investigation":{"precision":1,"recall":0.8,"f1_score":0.888888888888889,"so":0.65895913125257488,"tp":0.035333351984487595,"ic":0.98823265836029839},"mount":{"precision":1,"recall":0.9,"f1_score":0.94736842105263164,"so":0.91002379283022172,"tp":0.035960102191745627,"ic":0.99615676486935811},"other":{"precision":1,"recall":0.868421052631579,"f1_score":0.92957746478873238,"so":0.77793891626170275,"tp":0.045862245297524168,"ic":0.993672706200401}},"overall_metrics":{"precision":1,"recall":0.89210526315789473,"f1_score":0.94145869368256319,"so":0.83673046008612484,"tp":0.27928892486843937,"ic":0.99451553235751433}},"Recording_6":{"file_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.98838846071505027},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.98965295127585839},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.9904687807255419}},"group_metrics":{"attack":{"precision":1,"recall":0.36363636363636365,"f1_score":0.53333333333333333,"so":0.35548759220851922,"tp":0.014142932226192012,"ic":0.98838846071505027},"investigation":{"precision":0.76744186046511631,"recall":0.7857142857142857,"f1_score":0.77647058823529413,"so":0.53957972748719008,"tp":0.02031691062077776,"ic":0.98965295127585839},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.88888888888888884,"recall":0.83333333333333337,"f1_score":0.86021505376344076,"so":0.60278876229220257,"tp":0.021811095588551047,"ic":0.9904687807255419}},"overall_metrics":{"precision":0.91408268733850129,"recall":0.74567099567099571,"f1_score":0.79250474383301706,"so":0.624464020496978,"tp":0.26406773460888022,"ic":0.99212754817911264}},"Recording_7":{"file_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98891661542484988},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.98181290038327618},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.99318498549600709}},"group_metrics":{"attack":{"precision":1,"recall":0.525,"f1_score":0.68852459016393452,"so":0.39069512581810084,"tp":0.030228614492615703,"ic":0.98891661542484988},"investigation":{"precision":1,"recall":0.3,"f1_score":0.46153846153846151,"so":0.536495310012478,"tp":0.031330852383483966,"ic":0.98181290038327618},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":1,"recall":0.51063829787234039,"f1_score":0.676056338028169,"so":0.7307897879334081,"tp":0.044561106308833941,"ic":0.99318498549600709}},"overall_metrics":{"precision":1,"recall":0.58390957446808511,"f1_score":0.70652984743264124,"so":0.66449505594099667,"tp":0.27653014329623343,"ic":0.99097862532603331}},"Recording_8":{"file_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99318530440005481},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":null},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99102642248586637}},"group_metrics":{"attack":{"precision":0.875,"recall":0.36842105263157893,"f1_score":0.51851851851851849,"so":0.26836382308140022,"tp":0.020422876058778405,"ic":0.9919190825696258},"investigation":{"precision":1,"recall":0.53846153846153844,"f1_score":0.70000000000000007,"so":0.5910507486515022,"tp":0.02527092835048601,"ic":0.99318530440005481},"mount":{"precision":null,"recall":0,"f1_score":0,"so":null,"tp":null,"ic":null},"other":{"precision":0.8,"recall":0.48,"f1_score":0.6,"so":0.73227083313815311,"tp":0.068720922548073327,"ic":0.99102642248586637}},"overall_metrics":{"precision":0.89166666666666661,"recall":0.34672064777327932,"f1_score":0.45462962962962961,"so":0.53056180162368516,"tp":0.038138242319112579,"ic":0.992043603151849}},"Recording_9":{"file_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98862824312860653},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99269356597600877},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99282392960084676}},"group_metrics":{"attack":{"precision":0.95652173913043481,"recall":0.73333333333333328,"f1_score":0.83018867924528306,"so":0.424100282439238,"tp":0.020589806372144304,"ic":0.98862824312860653},"investigation":{"precision":0.375,"recall":0.1875,"f1_score":0.25,"so":0.631678281068525,"tp":0.034809523809523811,"ic":0.99269356597600877},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.86206896551724133,"recall":0.69444444444444442,"f1_score":0.76923076923076916,"so":0.67513954578756,"tp":0.055838198617292827,"ic":0.99282392960084676}},"overall_metrics":{"precision":0.79839767616191892,"recall":0.65381944444444451,"f1_score":0.712354862119013,"so":0.68272952732383074,"tp":0.27780938219974027,"ic":0.99353643467636554}},"Recording_10":{"file_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.99080565536832721},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.98123242136180233},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.99554269505719462}},"group_metrics":{"attack":{"precision":1,"recall":0.2857142857142857,"f1_score":0.44444444444444448,"so":0.18490328157186497,"tp":0.011189008329645372,"ic":0.99080565536832721},"investigation":{"precision":0.625,"recall":0.26315789473684209,"f1_score":0.37037037037037035,"so":0.65720721897446033,"tp":0.064682539682539675,"ic":0.98123242136180233},"mount":{"precision":1,"recall":1,"f1_score":1,"so":1,"tp":1,"ic":1},"other":{"precision":0.97142857142857142,"recall":0.35416666666666669,"f1_score":0.51908396946564883,"so":0.82511459885259986,"tp":0.06781954841653498,"ic":0.99554269505719462}},"overall_metrics":{"precision":0.89910714285714288,"recall":0.47575971177944865,"f1_score":0.58347469607011593,"so":0.66680627484973121,"tp":0.28592277410718,"ic":0.99189519294683093}},"all_recordings":{"exclude_per_file":{"group_metrics":{"attack":{"precision":0.98315217391304355,"recall":0.7276105035315561,"f1_score":0.80150095657055132,"so":0.66235501051191226,"tp":0.5096573237479376,"ic":0.994865805720646},"investigation":{"precision":0.829766257353812,"recall":0.56618535797359615,"f1_score":0.654235740850259,"so":0.56681037684433189,"tp":0.037887769335291942,"ic":0.987458558719619},"mount":{"precision":1,"recall":0.73391304347826092,"f1_score":0.79021115040565848,"so":0.8834086173405894,"tp":0.48094254679329018,"ic":0.99420872285494133},"other":{"precision":0.9311553139012132,"recall":0.68663330755967933,"f1_score":0.77505286219469072,"so":0.73221630555982,"tp":0.048456914727792295,"ic":0.99267435858456277}},"overall_metrics":{"precision":0.9343778833764278,"recall":0.67858555313577318,"f1_score":0.75525017750528978,"so":0.70678190987758827,"tp":0.26380776921153409,"ic":0.99225296758827564}},"exclude_pooled":{"group_metrics":{"attack":{"precision":0.975609756097561,"recall":0.418848167539267,"f1_score":0.58608058608058611,"so":0.28221499894768604,"tp":0.017892953838825207,"ic":0.99008918314299021},"investigation":{"precision":0.84615384615384615,"recall":0.65845070422535212,"f1_score":0.74059405940594059,"so":0.56118339150599472,"tp":0.040843958498742394,"ic":0.98677021109585328},"mount":{"precision":1,"recall":0.60465116279069764,"f1_score":0.75362318840579712,"so":0.75902040206560628,"tp":0.0570522939982909,"ic":0.98863902556706129},"other":{"precision":0.93146417445482865,"recall":0.66004415011037532,"f1_score":0.77260981912144711,"so":0.72972716864506215,"tp":0.046268588052302044,"ic":0.99284303851706734}},"overall_metrics":{"precision":0.938306944176559,"recall":0.585498546166423,"f1_score":0.71322691325344278,"so":0.58303649029108739,"tp":0.040514448597040134,"ic":0.98958536458074309}},"zero_per_file":{"group_metrics":{"attack":{"precision":0.98315217391304355,"recall":0.7276105035315561,"f1_score":0.80150095657055132,"so":0.64019653023213274,"tp":0.50865228106262994,"ic":0.93187083493412037},"investigation":{"precision":0.829766257353812,"recall":0.56618535797359615,"f1_score":0.654235740850259,"so":0.378197550178644,"tp":0.024841101231380485,"ic":0.67800634715154251},"mount":{"precision":1,"recall":0.73391304347826092,"f1_score":0.79021115040565848,"so":0.67481432973885869,"tp":0.42310875368414608,"ic":0.73516615169765176},"other":{"precision":0.9311553139012132,"recall":0.68663330755967933,"f1_score":0.77505286219469072,"so":0.5312769706084941,"tp":0.033673866992742066,"ic":0.74164232059319424}},"overall_metrics":{"precision":0.9343778833764278,"recall":0.67858555313577318,"f1_score":0.75525017750528978,"so":0.55612134518953238,"tp":0.24756900074272456,"ic":0.77167141359412728}},"zero_pooled":{"group_metrics":{"attack":{"precision":0.975609756097561,"recall":0.418848167539267,"f1_score":0.58608058608058611,"so":0.26005151735493581,"tp":0.016487748039964584,"ic":0.91233348813176063},"investigation":{"precision":0.84615384615384615,"recall":0.65845070422535212,"f1_score":0.74059405940594059,"so":0.43910139054679587,"tp":0.031958606123577378,"ic":0.77134854529323749},"mount":{"precision":1,"recall":0.60465116279069764,"f1_score":0.75362318840579712,"so":0.46776838731950149,"tp":0.035160134673365331,"ic":0.60927753901225867},"other":{"precision":0.93146417445482865,"recall":0.66004415011037532,"f1_score":0.77260981912144711,"so":0.51682579597483269,"tp":0.032769507394336553,"ic":0.71240445816144926}},"overall_metrics":{"precision":0.938306944176559,"recall":0.585498546166423,"f1_score":0.71322691325344278,"so":0.42093677279901642,"tp":0.029093999057810963,"ic":0.75134100764967648}}}}
diff --git a/tests/fixtures/golden_python_v110.json b/tests/fixtures/golden_python_v110.json
index d111c44..03b91f9 100644
--- a/tests/fixtures/golden_python_v110.json
+++ b/tests/fixtures/golden_python_v110.json
@@ -798,5 +798,183 @@
"tp": 0.28592277410718003,
"ic": 0.9918951929468309
}
+ },
+ "all_recordings": {
+ "exclude_per_file": {
+ "group_metrics": {
+ "attack": {
+ "precision": 0.9831521739130435,
+ "recall": 0.7276105035315561,
+ "f1_score": 0.8015009565705513,
+ "so": 0.6623550105119123,
+ "tp": 0.5096573237479376,
+ "ic": 0.994865805720646
+ },
+ "investigation": {
+ "precision": 0.829766257353812,
+ "recall": 0.5661853579735961,
+ "f1_score": 0.654235740850259,
+ "so": 0.5668103768443319,
+ "tp": 0.03788776933529195,
+ "ic": 0.987458558719619
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.7339130434782609,
+ "f1_score": 0.7902111504056585,
+ "so": 0.8834086173405894,
+ "tp": 0.4809425467932902,
+ "ic": 0.9942087228549413
+ },
+ "other": {
+ "precision": 0.9311553139012132,
+ "recall": 0.6866333075596793,
+ "f1_score": 0.7750528621946908,
+ "so": 0.73221630555982,
+ "tp": 0.048456914727792295,
+ "ic": 0.9926743585845628
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.9343778833764279,
+ "recall": 0.6785855531357731,
+ "f1_score": 0.7552501775052899,
+ "so": 0.7067819098775884,
+ "tp": 0.2638077692115341,
+ "ic": 0.9922529675882756
+ }
+ },
+ "exclude_pooled": {
+ "group_metrics": {
+ "attack": {
+ "precision": 0.975609756097561,
+ "recall": 0.418848167539267,
+ "f1_score": 0.5860805860805861,
+ "so": 0.28221499894768604,
+ "tp": 0.017892953838825207,
+ "ic": 0.9900891831429903
+ },
+ "investigation": {
+ "precision": 0.8461538461538461,
+ "recall": 0.6584507042253521,
+ "f1_score": 0.7405940594059406,
+ "so": 0.5611833915059947,
+ "tp": 0.04084395849874241,
+ "ic": 0.9867702110958532
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.6046511627906976,
+ "f1_score": 0.7536231884057971,
+ "so": 0.7590204020656063,
+ "tp": 0.057052293998290905,
+ "ic": 0.9886390255670613
+ },
+ "other": {
+ "precision": 0.9314641744548287,
+ "recall": 0.6600441501103753,
+ "f1_score": 0.7726098191214471,
+ "so": 0.7297271686450624,
+ "tp": 0.04626858805230205,
+ "ic": 0.9928430385170676
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.9383069441765589,
+ "recall": 0.585498546166423,
+ "f1_score": 0.7132269132534428,
+ "so": 0.5830364902910874,
+ "tp": 0.04051444859704014,
+ "ic": 0.9895853645807431
+ }
+ },
+ "zero_per_file": {
+ "group_metrics": {
+ "attack": {
+ "precision": 0.9831521739130435,
+ "recall": 0.7276105035315561,
+ "f1_score": 0.8015009565705513,
+ "so": 0.6401965302321327,
+ "tp": 0.5086522810626298,
+ "ic": 0.9318708349341206
+ },
+ "investigation": {
+ "precision": 0.829766257353812,
+ "recall": 0.5661853579735961,
+ "f1_score": 0.654235740850259,
+ "so": 0.378197550178644,
+ "tp": 0.024841101231380485,
+ "ic": 0.6780063471515425
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.7339130434782609,
+ "f1_score": 0.7902111504056585,
+ "so": 0.6748143297388587,
+ "tp": 0.4231087536841461,
+ "ic": 0.7351661516976518
+ },
+ "other": {
+ "precision": 0.9311553139012132,
+ "recall": 0.6866333075596793,
+ "f1_score": 0.7750528621946908,
+ "so": 0.531276970608494,
+ "tp": 0.033673866992742066,
+ "ic": 0.7416423205931941
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.9343778833764279,
+ "recall": 0.6785855531357731,
+ "f1_score": 0.7552501775052899,
+ "so": 0.5561213451895324,
+ "tp": 0.24756900074272462,
+ "ic": 0.7716714135941273
+ }
+ },
+ "zero_pooled": {
+ "group_metrics": {
+ "attack": {
+ "precision": 0.975609756097561,
+ "recall": 0.418848167539267,
+ "f1_score": 0.5860805860805861,
+ "so": 0.2600515173549358,
+ "tp": 0.016487748039964587,
+ "ic": 0.9123334881317607
+ },
+ "investigation": {
+ "precision": 0.8461538461538461,
+ "recall": 0.6584507042253521,
+ "f1_score": 0.7405940594059406,
+ "so": 0.43910139054679587,
+ "tp": 0.03195860612357739,
+ "ic": 0.7713485452932374
+ },
+ "mount": {
+ "precision": 1.0,
+ "recall": 0.6046511627906976,
+ "f1_score": 0.7536231884057971,
+ "so": 0.46776838731950154,
+ "tp": 0.035160134673365324,
+ "ic": 0.6092775390122587
+ },
+ "other": {
+ "precision": 0.9314641744548287,
+ "recall": 0.6600441501103753,
+ "f1_score": 0.7726098191214471,
+ "so": 0.5168257959748328,
+ "tp": 0.032769507394336546,
+ "ic": 0.7124044581614494
+ }
+ },
+ "overall_metrics": {
+ "precision": 0.9383069441765589,
+ "recall": 0.585498546166423,
+ "f1_score": 0.7132269132534428,
+ "so": 0.42093677279901653,
+ "tp": 0.029093999057810963,
+ "ic": 0.7513410076496765
+ }
+ }
}
}
diff --git a/tests/fixtures/matlab/generate_matlab_fixtures.m b/tests/fixtures/matlab/generate_matlab_fixtures.m
index 15279f0..2a2cc3b 100644
--- a/tests/fixtures/matlab/generate_matlab_fixtures.m
+++ b/tests/fixtures/matlab/generate_matlab_fixtures.m
@@ -1,6 +1,10 @@
%% generate_matlab_fixtures.m
% Generates a single JSON reference file from the Matlab BANOS implementation,
% containing all 10 recordings in the same structure as golden_python_v110.json.
+% Each Recording_N entry scores one recording alone, with the default options.
+% The all_recordings entry scores the 10 recordings together with BANOS_score,
+% once per combination of 'missed_bouts' and 'aggregation', keyed
+% "
- "Green = agree "
- "Orange = expected divergence (NaN vs 0) "
- "Red = unexpected mismatch"
- " Shows Python/Matlab parity -- should be SAME Total: 300 |
SAME: 300 |
+VALUE->NULL: 0 |
NULL->VALUE: 0 |
-CHANGED: 0 |
-VALUE->NULL: 0 SAME = match NULL->VALUE = Unexpected divergence (should not occur in v0.2.0) CHANGED = Unexpected numeric mismatch VALUE->NULL = unexpected"
- f" "
- )
-
- header = (
- "{behavior} {metric} "
- f"{_fmt(py_val)} {_fmt(ml_val)} "
- f"{diff_str} {status} "
- ""
- "
"
- legend = (
- ""
- " "
- )
- footer = "Behavior Metric Python Matlab "
- "Diff Status {rec_name} — Python vs Matlab
{table_html}"))
- except ImportError:
- pass # pytest-html not installed; test still runs, just no HTML extra
-
- assert unexpected_diffs == 0, (
- f"{rec_name}: {unexpected_diffs} unexpected mismatches between Python and Matlab. "
- "See the HTML report for details."
- )
diff --git a/tests/test_matching.py b/tests/test_matching.py
index 2c38672..9c662f6 100644
--- a/tests/test_matching.py
+++ b/tests/test_matching.py
@@ -11,7 +11,7 @@
that overlaps. Fast (O(n*m)) but can make suboptimal early commitments.
Optimal matching: Hungarian algorithm (scipy.optimize.linear_sum_assignment)
-finds the globally best assignment, maximizing total overlap. Slower (O(n^3))
+finds the assignment with the most overlapping (matched) pairs. Slower (O(n^3))
but scientifically preferable when bouts are densely overlapping.
The test_optimal_beats_greedy test documents the exact scenario where greedy
diff --git a/tests/test_metrics.py b/tests/test_metrics.py
index 6dd3ac7..c2e8340 100644
--- a/tests/test_metrics.py
+++ b/tests/test_metrics.py
@@ -14,9 +14,13 @@
- No GT bout found (no predicted frame inside) -> IC has no bout to average -> NaN
WHY NaN and not 0: returning 0 would bias the group/overall averages downward
- by treating "undefined" as "worst possible score". nanmean() in aggregate_metrics
- correctly excludes NaN values so they do not affect recordings where the behavior
- actually occurred.
+ by treating "undefined" as "worst possible score"; aggregate_metrics leaves
+ NaN values out of its averages (nanmean). In the pipeline, a recording with no
+ GT bout of a behavior never gets these NaN: compute_behavior_metrics scores it
+ 1.0 (nothing predicted) or 0.0 (false bouts). The NaN cells that reach the
+ averages come from recordings that have GT bouts: precision when nothing is
+ predicted, SO/TP when no bout overlaps, IC when no GT bout longer than one
+ frame is found.
F1 is the exception: with no matched bout (TP = 0) but some predicted or
missed bouts (FP + FN > 0) it is 0, the value of the count form
@@ -155,7 +159,8 @@ def test_so_returns_nan_for_empty_gt():
"""SO is undefined (NaN) when GT has no bouts — not 0.
WHY: 'no GT bouts' means the metric is inapplicable, not that overlap is zero.
- NaN causes aggregate_metrics to exclude this recording from the SO average.
+ The pipeline does not average this NaN: a recording with no GT bout of a
+ behavior scores 1.0 or 0.0 per file, and adds no SO term when pooled.
"""
assert math.isnan(calculate_so([(0, 5)], []))
diff --git a/tests/test_parity.py b/tests/test_parity.py
index 7daca4d..4f5e722 100644
--- a/tests/test_parity.py
+++ b/tests/test_parity.py
@@ -24,12 +24,17 @@
NaN HANDLING
------------
-Both Python and Matlab v0.2.0 return 1.0 for correct absence (both have no bouts)
-and 0.0 for false detection (machine predicts bouts, GT has none). No NaN expected
-for absent behaviors in v0.2.0. The _values_match() helper still handles None/NaN
-for any residual edge cases during Matlab JSON encoding.
-
-See also: tests/test_comparison_report.py for a visual HTML comparison table.
+Both Python and Matlab return 1.0 for correct absence (both have no bouts)
+and 0.0 for false detection (machine predicts bouts, GT has none), so a
+behavior absent from a recording is never NaN with per-file aggregation.
+A metric is NaN where it is undefined, in both implementations: precision when
+nothing is predicted, SO and TP when no bout overlaps, IC when no GT bout longer
+than one frame is found (and, pooled, recall of a behavior with no GT bout).
+Matlab writes NaN as JSON null; _values_match() requires NaN on both sides or
+numbers on both sides.
+
+See also: tests/fixtures/compare_fixtures.py for side-by-side tables of the
+fixture files (text or HTML).
"""
import json
@@ -83,7 +88,7 @@ def _values_match(actual, expected) -> bool:
Matlab encodes NaN via jsonencode as JSON null; Python's json.load() reads
null as None. Both Python NaN and Matlab null/NaN are treated as equivalent
- 'undefined' values — a behavior with no GT bouts in both implementations.
+ 'undefined' values; NaN on one side only is a mismatch.
"""
expected_nan = expected is None or (isinstance(expected, float) and math.isnan(expected))
actual_nan = actual is None or (isinstance(actual, float) and math.isnan(actual))
@@ -121,7 +126,10 @@ def test_parity_overall_metrics(n):
@pytest.mark.parametrize("n", range(1, 11))
def test_parity_group_metrics(n):
- """Python per-behavior metrics must match Matlab output within 1e-6."""
+ """Python per-behavior metrics must match Matlab output within 1e-6.
+
+ Both must report the same behaviors: one missing on either side is a mismatch.
+ """
rec_name = f"Recording_{n}"
if rec_name not in _MATLAB_ALL:
pytest.skip(f"{rec_name} not in Matlab fixture")
@@ -133,9 +141,11 @@ def test_parity_group_metrics(n):
group, _ = aggregate_metrics(file_metrics)
matlab_group = matlab_result.get("group_metrics", {})
+ assert set(group) == set(matlab_group), (
+ f"{rec_name} behaviors: Python only {sorted(set(group) - set(matlab_group))}, "
+ f"Matlab only {sorted(set(matlab_group) - set(group))}"
+ )
for behavior, metrics in matlab_group.items():
- if behavior not in group:
- pytest.skip(f"Behavior '{behavior}' not in Python output for {rec_name}")
for metric in METRICS:
if metric not in metrics:
continue
diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py
index 9e46296..776140c 100644
--- a/tests/test_pipeline.py
+++ b/tests/test_pipeline.py
@@ -13,12 +13,15 @@
preprocess_data -> calculate_components_for_each_file -> pool_components
(the second chain is aggregation='pooled')
-The test_aggregate_metrics_excludes_nan test is critical: it confirms that
-recordings where a behavior has no GT bouts (NaN metrics) do NOT pull down
-the group/overall averages. Using nanmean() is the correct design choice.
+The test_aggregate_metrics_excludes_nan test confirms that NaN cells (a metric
+undefined for a behavior in a recording, e.g. precision when nothing is
+predicted there) are left out of the group/overall averages (nanmean).
+A recording with no GT bout of a behavior is not NaN with the default
+per-file aggregation: it scores 1.0 if nothing is predicted and 0.0 otherwise.
"""
import math
+import tomllib
import warnings
from pathlib import Path
@@ -101,8 +104,10 @@ def test_score_all_metrics_in_range():
"""All finite metric values must lie in [0, 1]; NaN is allowed (undefined case).
WHY: values outside [0, 1] indicate a formula error (e.g., division producing
- values > 1). NaN is explicitly allowed because behaviors with no GT bouts
- return NaN by design — that is the correct signal for 'not applicable'.
+ values > 1). NaN is explicitly allowed because a metric can be undefined for
+ a behavior that has GT bouts: precision when nothing is predicted, SO and TP
+ when no predicted bout overlaps a GT bout, IC when no GT bout longer than
+ one frame is found.
"""
result = banos.score(PRED, GT)
for behavior, metrics in result.items():
@@ -119,8 +124,8 @@ def test_score_invalid_matching_raises():
def test_aggregate_metrics_excludes_nan():
"""NaN values in file metrics must be excluded from group and overall averages.
- WHY: f2 has all-NaN metrics (behavior had no GT bouts in that recording).
- If nanmean() were replaced by plain mean(), the average would be pulled toward
+ WHY: f2 has all-NaN metrics (every metric undefined for that behavior in that
+ recording). If NaN were averaged as 0, the average would be pulled toward
0, making the system appear worse than it actually is. The result must equal
only f1's values, as if f2 were never processed for that behavior.
"""
@@ -153,8 +158,15 @@ def test_aggregate_metrics_excludes_nan():
def test_score_version():
- assert isinstance(banos.__version__, str)
- assert banos.__version__ != ""
+ """banos.__version__ is the version declared in pyproject.toml.
+
+ WHY: the version is written in both files; a release that bumps only one
+ of them would publish a package whose __version__ is wrong.
+ """
+ pyproject = Path(__file__).parent.parent / "pyproject.toml"
+ with open(pyproject, "rb") as f:
+ declared = tomllib.load(f)["project"]["version"]
+ assert banos.__version__ == declared
FRAME_INDEXES = {
diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py
index ab8a58f..55582ad 100644
--- a/tests/test_preprocessing.py
+++ b/tests/test_preprocessing.py
@@ -7,7 +7,7 @@
If non-binary columns are silently kept, or headers are not aligned, every
downstream metric will be computed on wrong data with no error raised.
These tests verify that data cleaning is both correct and non-destructive
-(the caller's dict object must not be replaced or mutated unexpectedly).
+(the caller's dict and DataFrames are left unchanged).
Key behaviours tested:
- Binary detection: 0/1 int and float are accepted; 2, -1, or NaN are not.
@@ -111,19 +111,32 @@ def test_preprocess_data_fills_nan():
assert gt_out["a"].isna().sum() == 0
-def test_preprocess_data_does_not_mutate_caller_dict():
- """The caller's dict object must not be replaced — only internal copies are modified.
+@pytest.mark.parametrize("dirty_side", ["prediction", "ground truth"])
+def test_preprocess_data_does_not_mutate_caller_dict(dirty_side):
+ """The caller's dict and DataFrames are left unchanged — only copies are cleaned.
WHY: user code often reuses the same dict across multiple calls. If preprocess_data
- replaced the dict in-place, the second call would receive already-processed data,
- causing subtle double-processing bugs.
+ modified the dict or its DataFrames in place, the second call would receive
+ already-processed data, causing subtle double-processing bugs.
+ One side needs every cleaning step (a NaN filled, a non-binary column dropped,
+ a column missing on the other side dropped) and the other side needs none. The
+ test runs with each side as the one to clean, so that a step applied in place to
+ the caller's prediction or to the caller's ground truth would show here.
"""
- pred = pd.DataFrame({"a": [0, 1]})
- gt = pd.DataFrame({"a": [1, 0]})
+ dirty = pd.DataFrame(
+ {"a": [0.0, float("nan"), 1.0], "prob": [0.2, 0.9, 0.1], "one_side": [0, 1, 1]}
+ )
+ clean = pd.DataFrame({"a": [1, 0, 0], "prob": [0, 1, 0]})
+ pred, gt = (dirty, clean) if dirty_side == "prediction" else (clean, dirty)
+ expected_pred, expected_gt = pred.copy(), gt.copy()
original = {"f1": (pred, gt)}
- original_id = id(original)
- preprocess_data(original)
- assert id(original) == original_id # same dict object, not mutated internally
+ with pytest.warns(UserWarning):
+ result, _ = preprocess_data(original)
+ assert result is not original
+ assert list(original) == ["f1"]
+ assert original["f1"][0] is pred and original["f1"][1] is gt
+ pd.testing.assert_frame_equal(pred, expected_pred)
+ pd.testing.assert_frame_equal(gt, expected_gt)
def test_preprocess_data_drops_non_logical():
diff --git a/uv.lock b/uv.lock
index dd86c66..cd3d466 100644
--- a/uv.lock
+++ b/uv.lock
@@ -174,7 +174,6 @@ dev = [
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-cov" },
- { name = "pytest-html" },
{ name = "ruff" },
]
notebook = [
@@ -188,7 +187,6 @@ dev = [
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-cov" },
- { name = "pytest-html" },
{ name = "ruff" },
]
@@ -202,7 +200,6 @@ requires-dist = [
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
- { name = "pytest-html", marker = "extra == 'dev'", specifier = ">=4.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" },
{ name = "scikit-learn", marker = "extra == 'calms21'", specifier = ">=1.3" },
{ name = "scipy", specifier = ">=1.10" },
@@ -218,7 +215,6 @@ dev = [
{ name = "pre-commit", specifier = ">=3.5" },
{ name = "pytest", specifier = ">=7.4" },
{ name = "pytest-cov", specifier = ">=4.1" },
- { name = "pytest-html", specifier = ">=4.2.0" },
{ name = "ruff", specifier = ">=0.4" },
]
@@ -2404,32 +2400,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
-[[package]]
-name = "pytest-html"
-version = "4.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "jinja2" },
- { name = "pytest" },
- { name = "pytest-metadata" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c4/08/2076aa09507e51c1119d16a84c6307354d16270558f1a44fc9a2c99fdf1d/pytest_html-4.2.0.tar.gz", hash = "sha256:b6a88cba507500d8709959201e2e757d3941e859fd17cfd4ed87b16fc0c67912", size = 108634, upload-time = "2026-01-19T11:25:26.471Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl", hash = "sha256:ff5caf3e17a974008e5816edda61168e6c3da442b078a44f8744865862a85636", size = 23801, upload-time = "2026-01-19T11:25:25.008Z" },
-]
-
-[[package]]
-name = "pytest-metadata"
-version = "3.1.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pytest" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a6/85/8c969f8bec4e559f8f2b958a15229a35495f5b4ce499f6b865eac54b878d/pytest_metadata-3.1.1.tar.gz", hash = "sha256:d2a29b0355fbc03f168aa96d41ff88b1a3b44a3b02acbe491801c98a048017c8", size = 9952, upload-time = "2024-02-12T19:38:44.887Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl", hash = "sha256:c8e0844db684ee1c798cfa38908d20d67d0463ecb6137c72e91f418558dd5f4b", size = 11428, upload-time = "2024-02-12T19:38:42.531Z" },
-]
-
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
From 5958086c684876e53f067b65faf93c990e5dcb6d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Beno=C3=AEt=20J=2E=20T=2E=20GIRARD?=
<53222414+BenoitJT-GIRARD@users.noreply.github.com>
Date: Thu, 29 Oct 2026 23:09:29 +0100
Subject: [PATCH 09/25] test: correct the tags and legends of the fixture
comparison
compare_fixtures.py tagged a value that becomes NaN as NULL->VALUE and
the reverse as VALUE->NULL. The v0.1.5 / v0.2.0 legend then explained
the four Recording_8 mount cells that go from 0 to NaN (precision, F1,
SO, TP) as correct absence now scoring 1, and every other difference as
the IC fix. Swap the tags and give each comparison a meaning for each
tag. From v0.1.5 to v0.2.0: a metric with a zero denominator is NaN
instead of 0; correct absence scores 1.0 instead of 0 on all six
metrics; IC counts the last frame pair of a bout and leaves one-frame
bouts out; the overall means move with these.
Add mode 3, Python v0.2.0 against v1.1.0: one F1 from NaN to 0, one IC
from 1.0 to NaN, 39 other values changed (IC and overall means), 259
equal. Only the Recording_N entries are compared; the all_recordings
entry of the v1.1.0 file has no counterpart in older files. Legends use
ASCII arrows, which a Windows console can print.
Regenerate reports/fixture_comparison.html with the corrected script.
The report now ends with a newline, as the committed file did before;
the script never wrote one.
tests/test_compare_fixtures.py runs the script: mode 3 must tag
exactly the 41 values changed by v1.1.0, and the committed report must
equal what the script writes (skipped where reports/ is absent, as in
the sdist). The output depends only on the fixture files, so a fixture
edited without regenerating the report, or a report edited by hand,
fails the test.
---
reports/fixture_comparison.html | 28 ++--
tests/fixtures/compare_fixtures.py | 200 +++++++++++++++++++----------
tests/test_compare_fixtures.py | 98 ++++++++++++++
3 files changed, 251 insertions(+), 75 deletions(-)
create mode 100644 tests/test_compare_fixtures.py
diff --git a/reports/fixture_comparison.html b/reports/fixture_comparison.html
index 9f6dcef..5749f35 100644
--- a/reports/fixture_comparison.html
+++ b/reports/fixture_comparison.html
@@ -3,18 +3,28 @@ Mode 1: Python v0.2.0 vs Matlab v0.2.0
SAME = match VALUE->NULL = Unexpected divergence (should not occur in v0.2.0) NULL->VALUE = Unexpected divergence (should not occur in v0.2.0) CHANGED = Unexpected numeric mismatch
| Location | Metric | Python v0.2.0 | Matlab v0.2.0 | Status |
|---|---|---|---|---|
| Recording_1/overall | precision | 0.928261 | 0.928261 | SAME |
| Recording_1/overall | recall | 0.781293 | 0.781293 | SAME |
| Recording_1/overall | f1_score | 0.836455 | 0.836455 | SAME |
| Recording_1/overall | so | 0.761228 | 0.761228 | SAME |
| Recording_1/overall | tp | 0.306230 | 0.306230 | SAME |
| Recording_1/overall | ic | 0.991718 | 0.991718 | SAME |
| Recording_1/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_1/group/attack | recall | 1.000000 | 1.000000 | SAME |
| Recording_1/group/attack | f1_score | 1.000000 | 1.000000 | SAME |
| Recording_1/group/attack | so | 1.000000 | 1.000000 | SAME |
| Recording_1/group/attack | tp | 1.000000 | 1.000000 | SAME |
| Recording_1/group/attack | ic | 1.000000 | 1.000000 | SAME |
| Recording_1/group/investigation | precision | 0.800000 | 0.800000 | SAME |
| Recording_1/group/investigation | recall | 0.734694 | 0.734694 | SAME |
| Recording_1/group/investigation | f1_score | 0.765957 | 0.765957 | SAME |
| Recording_1/group/investigation | so | 0.544410 | 0.544410 | SAME |
| Recording_1/group/investigation | tp | 0.084636 | 0.084636 | SAME |
| Recording_1/group/investigation | ic | 0.982941 | 0.982941 | SAME |
| Recording_1/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_1/group/mount | recall | 0.533333 | 0.533333 | SAME |
| Recording_1/group/mount | f1_score | 0.695652 | 0.695652 | SAME |
| Recording_1/group/mount | so | 0.733277 | 0.733277 | SAME |
| Recording_1/group/mount | tp | 0.078471 | 0.078471 | SAME |
| Recording_1/group/mount | ic | 0.990861 | 0.990861 | SAME |
| Recording_1/group/other | precision | 0.913043 | 0.913043 | SAME |
| Recording_1/group/other | recall | 0.857143 | 0.857143 | SAME |
| Recording_1/group/other | f1_score | 0.884211 | 0.884211 | SAME |
| Recording_1/group/other | so | 0.767227 | 0.767227 | SAME |
| Recording_1/group/other | tp | 0.061814 | 0.061814 | SAME |
| Recording_1/group/other | ic | 0.993069 | 0.993069 | SAME |
| Recording_2/overall | precision | 0.967105 | 0.967105 | SAME |
| Recording_2/overall | recall | 0.839924 | 0.839924 | SAME |
| Recording_2/overall | f1_score | 0.897301 | 0.897301 | SAME |
| Recording_2/overall | so | 0.825946 | 0.825946 | SAME |
| Recording_2/overall | tp | 0.309082 | 0.309082 | SAME |
| Recording_2/overall | ic | 0.993907 | 0.993907 | SAME |
| Recording_2/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_2/group/attack | recall | 1.000000 | 1.000000 | SAME |
| Recording_2/group/attack | f1_score | 1.000000 | 1.000000 | SAME |
| Recording_2/group/attack | so | 1.000000 | 1.000000 | SAME |
| Recording_2/group/attack | tp | 1.000000 | 1.000000 | SAME |
| Recording_2/group/attack | ic | 1.000000 | 1.000000 | SAME |
| Recording_2/group/investigation | precision | 0.894737 | 0.894737 | SAME |
| Recording_2/group/investigation | recall | 0.739130 | 0.739130 | SAME |
| Recording_2/group/investigation | f1_score | 0.809524 | 0.809524 | SAME |
| Recording_2/group/investigation | so | 0.660942 | 0.660942 | SAME |
| Recording_2/group/investigation | tp | 0.046151 | 0.046151 | SAME |
| Recording_2/group/investigation | ic | 0.989051 | 0.989051 | SAME |
| Recording_2/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_2/group/mount | recall | 0.833333 | 0.833333 | SAME |
| Recording_2/group/mount | f1_score | 0.909091 | 0.909091 | SAME |
| Recording_2/group/mount | so | 0.931754 | 0.931754 | SAME |
| Recording_2/group/mount | tp | 0.145170 | 0.145170 | SAME |
| Recording_2/group/mount | ic | 0.994648 | 0.994648 | SAME |
| Recording_2/group/other | precision | 0.973684 | 0.973684 | SAME |
| Recording_2/group/other | recall | 0.787234 | 0.787234 | SAME |
| Recording_2/group/other | f1_score | 0.870588 | 0.870588 | SAME |
| Recording_2/group/other | so | 0.711086 | 0.711086 | SAME |
| Recording_2/group/other | tp | 0.045008 | 0.045008 | SAME |
| Recording_2/group/other | ic | 0.991930 | 0.991930 | SAME |
| Recording_3/overall | precision | 0.959481 | 0.959481 | SAME |
| Recording_3/overall | recall | 0.850967 | 0.850967 | SAME |
| Recording_3/overall | f1_score | 0.898627 | 0.898627 | SAME |
| Recording_3/overall | so | 0.723716 | 0.723716 | SAME |
| Recording_3/overall | tp | 0.268876 | 0.268876 | SAME |
| Recording_3/overall | ic | 0.993223 | 0.993223 | SAME |
| Recording_3/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_3/group/attack | recall | 1.000000 | 1.000000 | SAME |
| Recording_3/group/attack | f1_score | 1.000000 | 1.000000 | SAME |
| Recording_3/group/attack | so | 1.000000 | 1.000000 | SAME |
| Recording_3/group/attack | tp | 1.000000 | 1.000000 | SAME |
| Recording_3/group/attack | ic | 1.000000 | 1.000000 | SAME |
| Recording_3/group/investigation | precision | 0.935484 | 0.935484 | SAME |
| Recording_3/group/investigation | recall | 0.783784 | 0.783784 | SAME |
| Recording_3/group/investigation | f1_score | 0.852941 | 0.852941 | SAME |
| Recording_3/group/investigation | so | 0.456310 | 0.456310 | SAME |
| Recording_3/group/investigation | tp | 0.022322 | 0.022322 | SAME |
| Recording_3/group/investigation | ic | 0.984966 | 0.984966 | SAME |
| Recording_3/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_3/group/mount | recall | 0.739130 | 0.739130 | SAME |
| Recording_3/group/mount | f1_score | 0.850000 | 0.850000 | SAME |
| Recording_3/group/mount | so | 0.674504 | 0.674504 | SAME |
| Recording_3/group/mount | tp | 0.028795 | 0.028795 | SAME |
| Recording_3/group/mount | ic | 0.991713 | 0.991713 | SAME |
| Recording_3/group/other | precision | 0.902439 | 0.902439 | SAME |
| Recording_3/group/other | recall | 0.880952 | 0.880952 | SAME |
| Recording_3/group/other | f1_score | 0.891566 | 0.891566 | SAME |
| Recording_3/group/other | so | 0.764048 | 0.764048 | SAME |
| Recording_3/group/other | tp | 0.024386 | 0.024386 | SAME |
| Recording_3/group/other | ic | 0.996212 | 0.996212 | SAME |
| Recording_4/overall | precision | 0.975000 | 0.975000 | SAME |
| Recording_4/overall | recall | 0.615686 | 0.615686 | SAME |
| Recording_4/overall | f1_score | 0.729167 | 0.729167 | SAME |
| Recording_4/overall | so | 0.707087 | 0.707087 | SAME |
| Recording_4/overall | tp | 0.275715 | 0.275715 | SAME |
| Recording_4/overall | ic | 0.996530 | 0.996530 | SAME |
| Recording_4/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_4/group/attack | recall | 1.000000 | 1.000000 | SAME |
| Recording_4/group/attack | f1_score | 1.000000 | 1.000000 | SAME |
| Recording_4/group/attack | so | 1.000000 | 1.000000 | SAME |
| Recording_4/group/attack | tp | 1.000000 | 1.000000 | SAME |
| Recording_4/group/attack | ic | 1.000000 | 1.000000 | SAME |
| Recording_4/group/investigation | precision | 0.900000 | 0.900000 | SAME |
| Recording_4/group/investigation | recall | 0.529412 | 0.529412 | SAME |
| Recording_4/group/investigation | f1_score | 0.666667 | 0.666667 | SAME |
| Recording_4/group/investigation | so | 0.391471 | 0.391471 | SAME |
| Recording_4/group/investigation | tp | 0.014025 | 0.014025 | SAME |
| Recording_4/group/investigation | ic | 0.996882 | 0.996882 | SAME |
| Recording_4/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_4/group/mount | recall | 0.333333 | 0.333333 | SAME |
| Recording_4/group/mount | f1_score | 0.500000 | 0.500000 | SAME |
| Recording_4/group/mount | so | 0.701119 | 0.701119 | SAME |
| Recording_4/group/mount | tp | 0.040087 | 0.040087 | SAME |
| Recording_4/group/mount | ic | 0.995289 | 0.995289 | SAME |
| Recording_4/group/other | precision | 1.000000 | 1.000000 | SAME |
| Recording_4/group/other | recall | 0.600000 | 0.600000 | SAME |
| Recording_4/group/other | f1_score | 0.750000 | 0.750000 | SAME |
| Recording_4/group/other | so | 0.735760 | 0.735760 | SAME |
| Recording_4/group/other | tp | 0.048749 | 0.048749 | SAME |
| Recording_4/group/other | ic | 0.993949 | 0.993949 | SAME |
| Recording_5/overall | precision | 1.000000 | 1.000000 | SAME |
| Recording_5/overall | recall | 0.892105 | 0.892105 | SAME |
| Recording_5/overall | f1_score | 0.941459 | 0.941459 | SAME |
| Recording_5/overall | so | 0.836730 | 0.836730 | SAME |
| Recording_5/overall | tp | 0.279289 | 0.279289 | SAME |
| Recording_5/overall | ic | 0.995120 | 0.995120 | SAME |
| Recording_5/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_5/group/attack | recall | 1.000000 | 1.000000 | SAME |
| Recording_5/group/attack | f1_score | 1.000000 | 1.000000 | SAME |
| Recording_5/group/attack | so | 1.000000 | 1.000000 | SAME |
| Recording_5/group/attack | tp | 1.000000 | 1.000000 | SAME |
| Recording_5/group/attack | ic | 1.000000 | 1.000000 | SAME |
| Recording_5/group/investigation | precision | 1.000000 | 1.000000 | SAME |
| Recording_5/group/investigation | recall | 0.800000 | 0.800000 | SAME |
| Recording_5/group/investigation | f1_score | 0.888889 | 0.888889 | SAME |
| Recording_5/group/investigation | so | 0.658959 | 0.658959 | SAME |
| Recording_5/group/investigation | tp | 0.035333 | 0.035333 | SAME |
| Recording_5/group/investigation | ic | 0.989914 | 0.989914 | SAME |
| Recording_5/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_5/group/mount | recall | 0.900000 | 0.900000 | SAME |
| Recording_5/group/mount | f1_score | 0.947368 | 0.947368 | SAME |
| Recording_5/group/mount | so | 0.910024 | 0.910024 | SAME |
| Recording_5/group/mount | tp | 0.035960 | 0.035960 | SAME |
| Recording_5/group/mount | ic | 0.996541 | 0.996541 | SAME |
| Recording_5/group/other | precision | 1.000000 | 1.000000 | SAME |
| Recording_5/group/other | recall | 0.868421 | 0.868421 | SAME |
| Recording_5/group/other | f1_score | 0.929577 | 0.929577 | SAME |
| Recording_5/group/other | so | 0.777939 | 0.777939 | SAME |
| Recording_5/group/other | tp | 0.045862 | 0.045862 | SAME |
| Recording_5/group/other | ic | 0.994024 | 0.994024 | SAME |
| Recording_6/overall | precision | 0.914083 | 0.914083 | SAME |
| Recording_6/overall | recall | 0.745671 | 0.745671 | SAME |
| Recording_6/overall | f1_score | 0.792505 | 0.792505 | SAME |
| Recording_6/overall | so | 0.624464 | 0.624464 | SAME |
| Recording_6/overall | tp | 0.264068 | 0.264068 | SAME |
| Recording_6/overall | ic | 0.993987 | 0.993987 | SAME |
| Recording_6/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_6/group/attack | recall | 0.363636 | 0.363636 | SAME |
| Recording_6/group/attack | f1_score | 0.533333 | 0.533333 | SAME |
| Recording_6/group/attack | so | 0.355488 | 0.355488 | SAME |
| Recording_6/group/attack | tp | 0.014143 | 0.014143 | SAME |
| Recording_6/group/attack | ic | 0.993666 | 0.993666 | SAME |
| Recording_6/group/investigation | precision | 0.767442 | 0.767442 | SAME |
| Recording_6/group/investigation | recall | 0.785714 | 0.785714 | SAME |
| Recording_6/group/investigation | f1_score | 0.776471 | 0.776471 | SAME |
| Recording_6/group/investigation | so | 0.539580 | 0.539580 | SAME |
| Recording_6/group/investigation | tp | 0.020317 | 0.020317 | SAME |
| Recording_6/group/investigation | ic | 0.990392 | 0.990392 | SAME |
| Recording_6/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_6/group/mount | recall | 1.000000 | 1.000000 | SAME |
| Recording_6/group/mount | f1_score | 1.000000 | 1.000000 | SAME |
| Recording_6/group/mount | so | 1.000000 | 1.000000 | SAME |
| Recording_6/group/mount | tp | 1.000000 | 1.000000 | SAME |
| Recording_6/group/mount | ic | 1.000000 | 1.000000 | SAME |
| Recording_6/group/other | precision | 0.888889 | 0.888889 | SAME |
| Recording_6/group/other | recall | 0.833333 | 0.833333 | SAME |
| Recording_6/group/other | f1_score | 0.860215 | 0.860215 | SAME |
| Recording_6/group/other | so | 0.602789 | 0.602789 | SAME |
| Recording_6/group/other | tp | 0.021811 | 0.021811 | SAME |
| Recording_6/group/other | ic | 0.991888 | 0.991888 | SAME |
| Recording_7/overall | precision | 1.000000 | 1.000000 | SAME |
| Recording_7/overall | recall | 0.583910 | 0.583910 | SAME |
| Recording_7/overall | f1_score | 0.706530 | 0.706530 | SAME |
| Recording_7/overall | so | 0.664495 | 0.664495 | SAME |
| Recording_7/overall | tp | 0.276530 | 0.276530 | SAME |
| Recording_7/overall | ic | 0.994944 | 0.994944 | SAME |
| Recording_7/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_7/group/attack | recall | 0.525000 | 0.525000 | SAME |
| Recording_7/group/attack | f1_score | 0.688525 | 0.688525 | SAME |
| Recording_7/group/attack | so | 0.390695 | 0.390695 | SAME |
| Recording_7/group/attack | tp | 0.030229 | 0.030229 | SAME |
| Recording_7/group/attack | ic | 0.989471 | 0.989471 | SAME |
| Recording_7/group/investigation | precision | 1.000000 | 1.000000 | SAME |
| Recording_7/group/investigation | recall | 0.300000 | 0.300000 | SAME |
| Recording_7/group/investigation | f1_score | 0.461538 | 0.461538 | SAME |
| Recording_7/group/investigation | so | 0.536495 | 0.536495 | SAME |
| Recording_7/group/investigation | tp | 0.031331 | 0.031331 | SAME |
| Recording_7/group/investigation | ic | 0.994544 | 0.994544 | SAME |
| Recording_7/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_7/group/mount | recall | 1.000000 | 1.000000 | SAME |
| Recording_7/group/mount | f1_score | 1.000000 | 1.000000 | SAME |
| Recording_7/group/mount | so | 1.000000 | 1.000000 | SAME |
| Recording_7/group/mount | tp | 1.000000 | 1.000000 | SAME |
| Recording_7/group/mount | ic | 1.000000 | 1.000000 | SAME |
| Recording_7/group/other | precision | 1.000000 | 1.000000 | SAME |
| Recording_7/group/other | recall | 0.510638 | 0.510638 | SAME |
| Recording_7/group/other | f1_score | 0.676056 | 0.676056 | SAME |
| Recording_7/group/other | so | 0.730790 | 0.730790 | SAME |
| Recording_7/group/other | tp | 0.044561 | 0.044561 | SAME |
| Recording_7/group/other | ic | 0.995760 | 0.995760 | SAME |
| Recording_8/overall | precision | 0.891667 | 0.891667 | SAME |
| Recording_8/overall | recall | 0.346721 | 0.346721 | SAME |
| Recording_8/overall | f1_score | 0.606173 | 0.606173 | SAME |
| Recording_8/overall | so | 0.530562 | 0.530562 | SAME |
| Recording_8/overall | tp | 0.038138 | 0.038138 | SAME |
| Recording_8/overall | ic | 0.995577 | 0.995577 | SAME |
| Recording_8/group/attack | precision | 0.875000 | 0.875000 | SAME |
| Recording_8/group/attack | recall | 0.368421 | 0.368421 | SAME |
| Recording_8/group/attack | f1_score | 0.518519 | 0.518519 | SAME |
| Recording_8/group/attack | so | 0.268364 | 0.268364 | SAME |
| Recording_8/group/attack | tp | 0.020423 | 0.020423 | SAME |
| Recording_8/group/attack | ic | 0.991919 | 0.991919 | SAME |
| Recording_8/group/investigation | precision | 1.000000 | 1.000000 | SAME |
| Recording_8/group/investigation | recall | 0.538462 | 0.538462 | SAME |
| Recording_8/group/investigation | f1_score | 0.700000 | 0.700000 | SAME |
| Recording_8/group/investigation | so | 0.591051 | 0.591051 | SAME |
| Recording_8/group/investigation | tp | 0.025271 | 0.025271 | SAME |
| Recording_8/group/investigation | ic | 0.995282 | 0.995282 | SAME |
| Recording_8/group/mount | precision | NaN | NaN | SAME |
| Recording_8/group/mount | recall | 0.000000 | 0.000000 | SAME |
| Recording_8/group/mount | f1_score | NaN | NaN | SAME |
| Recording_8/group/mount | so | NaN | NaN | SAME |
| Recording_8/group/mount | tp | NaN | NaN | SAME |
| Recording_8/group/mount | ic | 1.000000 | 1.000000 | SAME |
| Recording_8/group/other | precision | 0.800000 | 0.800000 | SAME |
| Recording_8/group/other | recall | 0.480000 | 0.480000 | SAME |
| Recording_8/group/other | f1_score | 0.600000 | 0.600000 | SAME |
| Recording_8/group/other | so | 0.732271 | 0.732271 | SAME |
| Recording_8/group/other | tp | 0.068721 | 0.068721 | SAME |
| Recording_8/group/other | ic | 0.995105 | 0.995105 | SAME |
| Recording_9/overall | precision | 0.798398 | 0.798398 | SAME |
| Recording_9/overall | recall | 0.653819 | 0.653819 | SAME |
| Recording_9/overall | f1_score | 0.712355 | 0.712355 | SAME |
| Recording_9/overall | so | 0.682730 | 0.682730 | SAME |
| Recording_9/overall | tp | 0.277809 | 0.277809 | SAME |
| Recording_9/overall | ic | 0.995671 | 0.995671 | SAME |
| Recording_9/group/attack | precision | 0.956522 | 0.956522 | SAME |
| Recording_9/group/attack | recall | 0.733333 | 0.733333 | SAME |
| Recording_9/group/attack | f1_score | 0.830189 | 0.830189 | SAME |
| Recording_9/group/attack | so | 0.424100 | 0.424100 | SAME |
| Recording_9/group/attack | tp | 0.020590 | 0.020590 | SAME |
| Recording_9/group/attack | ic | 0.989386 | 0.989386 | SAME |
| Recording_9/group/investigation | precision | 0.375000 | 0.375000 | SAME |
| Recording_9/group/investigation | recall | 0.187500 | 0.187500 | SAME |
| Recording_9/group/investigation | f1_score | 0.250000 | 0.250000 | SAME |
| Recording_9/group/investigation | so | 0.631678 | 0.631678 | SAME |
| Recording_9/group/investigation | tp | 0.034810 | 0.034810 | SAME |
| Recording_9/group/investigation | ic | 0.998630 | 0.998630 | SAME |
| Recording_9/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_9/group/mount | recall | 1.000000 | 1.000000 | SAME |
| Recording_9/group/mount | f1_score | 1.000000 | 1.000000 | SAME |
| Recording_9/group/mount | so | 1.000000 | 1.000000 | SAME |
| Recording_9/group/mount | tp | 1.000000 | 1.000000 | SAME |
| Recording_9/group/mount | ic | 1.000000 | 1.000000 | SAME |
| Recording_9/group/other | precision | 0.862069 | 0.862069 | SAME |
| Recording_9/group/other | recall | 0.694444 | 0.694444 | SAME |
| Recording_9/group/other | f1_score | 0.769231 | 0.769231 | SAME |
| Recording_9/group/other | so | 0.675140 | 0.675140 | SAME |
| Recording_9/group/other | tp | 0.055838 | 0.055838 | SAME |
| Recording_9/group/other | ic | 0.994669 | 0.994669 | SAME |
| Recording_10/overall | precision | 0.899107 | 0.899107 | SAME |
| Recording_10/overall | recall | 0.475760 | 0.475760 | SAME |
| Recording_10/overall | f1_score | 0.583475 | 0.583475 | SAME |
| Recording_10/overall | so | 0.666806 | 0.666806 | SAME |
| Recording_10/overall | tp | 0.285923 | 0.285923 | SAME |
| Recording_10/overall | ic | 0.996191 | 0.996191 | SAME |
| Recording_10/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_10/group/attack | recall | 0.285714 | 0.285714 | SAME |
| Recording_10/group/attack | f1_score | 0.444444 | 0.444444 | SAME |
| Recording_10/group/attack | so | 0.184903 | 0.184903 | SAME |
| Recording_10/group/attack | tp | 0.011189 | 0.011189 | SAME |
| Recording_10/group/attack | ic | 0.991412 | 0.991412 | SAME |
| Recording_10/group/investigation | precision | 0.625000 | 0.625000 | SAME |
| Recording_10/group/investigation | recall | 0.263158 | 0.263158 | SAME |
| Recording_10/group/investigation | f1_score | 0.370370 | 0.370370 | SAME |
| Recording_10/group/investigation | so | 0.657207 | 0.657207 | SAME |
| Recording_10/group/investigation | tp | 0.064683 | 0.064683 | SAME |
| Recording_10/group/investigation | ic | 0.995061 | 0.995061 | SAME |
| Recording_10/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_10/group/mount | recall | 1.000000 | 1.000000 | SAME |
| Recording_10/group/mount | f1_score | 1.000000 | 1.000000 | SAME |
| Recording_10/group/mount | so | 1.000000 | 1.000000 | SAME |
| Recording_10/group/mount | tp | 1.000000 | 1.000000 | SAME |
| Recording_10/group/mount | ic | 1.000000 | 1.000000 | SAME |
| Recording_10/group/other | precision | 0.971429 | 0.971429 | SAME |
| Recording_10/group/other | recall | 0.354167 | 0.354167 | SAME |
| Recording_10/group/other | f1_score | 0.519084 | 0.519084 | SAME |
| Recording_10/group/other | so | 0.825115 | 0.825115 | SAME |
| Recording_10/group/other | tp | 0.067820 | 0.067820 | SAME |
| Recording_10/group/other | ic | 0.998293 | 0.998293 | SAME |
Shows ALL changes: absent-behavior fix (0→1 correct absence) + IC bug fix
+Shows the changes of v0.2.0: undefined metrics NaN instead of 0, correct absence 1.0 instead of 0, IC fixes
Total: 300 | SAME: 169 | -NULL->VALUE: 4 | -CHANGED: 127 | -VALUE->NULL: 0
-SAME = match NULL->VALUE = v0.1.5=0 → v0.2.0=1.0 (correct absence now scores 1) CHANGED = IC bug fix VALUE->NULL = unexpected
-| Location | Metric | Python v0.1.5 | Python v0.2.0 | Status |
|---|---|---|---|---|
| Recording_1/overall | precision | 0.678261 | 0.928261 | CHANGED |
| Recording_1/overall | recall | 0.531293 | 0.781293 | CHANGED |
| Recording_1/overall | f1_score | 0.586455 | 0.836455 | CHANGED |
| Recording_1/overall | so | 0.511228 | 0.761228 | CHANGED |
| Recording_1/overall | tp | 0.056230 | 0.306230 | CHANGED |
| Recording_1/overall | ic | 0.737994 | 0.991718 | CHANGED |
| Recording_1/group/attack | precision | 0.000000 | 1.000000 | CHANGED |
| Recording_1/group/attack | recall | 0.000000 | 1.000000 | CHANGED |
| Recording_1/group/attack | f1_score | 0.000000 | 1.000000 | CHANGED |
| Recording_1/group/attack | so | 0.000000 | 1.000000 | CHANGED |
| Recording_1/group/attack | tp | 0.000000 | 1.000000 | CHANGED |
| Recording_1/group/attack | ic | 0.000000 | 1.000000 | CHANGED |
| Recording_1/group/investigation | precision | 0.800000 | 0.800000 | SAME |
| Recording_1/group/investigation | recall | 0.734694 | 0.734694 | SAME |
| Recording_1/group/investigation | f1_score | 0.765957 | 0.765957 | SAME |
| Recording_1/group/investigation | so | 0.544410 | 0.544410 | SAME |
| Recording_1/group/investigation | tp | 0.084636 | 0.084636 | SAME |
| Recording_1/group/investigation | ic | 0.987641 | 0.982941 | CHANGED |
| Recording_1/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_1/group/mount | recall | 0.533333 | 0.533333 | SAME |
| Recording_1/group/mount | f1_score | 0.695652 | 0.695652 | SAME |
| Recording_1/group/mount | so | 0.733277 | 0.733277 | SAME |
| Recording_1/group/mount | tp | 0.078471 | 0.078471 | SAME |
| Recording_1/group/mount | ic | 0.990861 | 0.990861 | SAME |
| Recording_1/group/other | precision | 0.913043 | 0.913043 | SAME |
| Recording_1/group/other | recall | 0.857143 | 0.857143 | SAME |
| Recording_1/group/other | f1_score | 0.884211 | 0.884211 | SAME |
| Recording_1/group/other | so | 0.767227 | 0.767227 | SAME |
| Recording_1/group/other | tp | 0.061814 | 0.061814 | SAME |
| Recording_1/group/other | ic | 0.973473 | 0.993069 | CHANGED |
| Recording_2/overall | precision | 0.717105 | 0.967105 | CHANGED |
| Recording_2/overall | recall | 0.589924 | 0.839924 | CHANGED |
| Recording_2/overall | f1_score | 0.647301 | 0.897301 | CHANGED |
| Recording_2/overall | so | 0.575946 | 0.825946 | CHANGED |
| Recording_2/overall | tp | 0.059082 | 0.309082 | CHANGED |
| Recording_2/overall | ic | 0.744498 | 0.993907 | CHANGED |
| Recording_2/group/attack | precision | 0.000000 | 1.000000 | CHANGED |
| Recording_2/group/attack | recall | 0.000000 | 1.000000 | CHANGED |
| Recording_2/group/attack | f1_score | 0.000000 | 1.000000 | CHANGED |
| Recording_2/group/attack | so | 0.000000 | 1.000000 | CHANGED |
| Recording_2/group/attack | tp | 0.000000 | 1.000000 | CHANGED |
| Recording_2/group/attack | ic | 0.000000 | 1.000000 | CHANGED |
| Recording_2/group/investigation | precision | 0.894737 | 0.894737 | SAME |
| Recording_2/group/investigation | recall | 0.739130 | 0.739130 | SAME |
| Recording_2/group/investigation | f1_score | 0.809524 | 0.809524 | SAME |
| Recording_2/group/investigation | so | 0.660942 | 0.660942 | SAME |
| Recording_2/group/investigation | tp | 0.046151 | 0.046151 | SAME |
| Recording_2/group/investigation | ic | 0.991219 | 0.989051 | CHANGED |
| Recording_2/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_2/group/mount | recall | 0.833333 | 0.833333 | SAME |
| Recording_2/group/mount | f1_score | 0.909091 | 0.909091 | SAME |
| Recording_2/group/mount | so | 0.931754 | 0.931754 | SAME |
| Recording_2/group/mount | tp | 0.145170 | 0.145170 | SAME |
| Recording_2/group/mount | ic | 0.994648 | 0.994648 | SAME |
| Recording_2/group/other | precision | 0.973684 | 0.973684 | SAME |
| Recording_2/group/other | recall | 0.787234 | 0.787234 | SAME |
| Recording_2/group/other | f1_score | 0.870588 | 0.870588 | SAME |
| Recording_2/group/other | so | 0.711086 | 0.711086 | SAME |
| Recording_2/group/other | tp | 0.045008 | 0.045008 | SAME |
| Recording_2/group/other | ic | 0.992125 | 0.991930 | CHANGED |
| Recording_3/overall | precision | 0.709481 | 0.959481 | CHANGED |
| Recording_3/overall | recall | 0.600967 | 0.850967 | CHANGED |
| Recording_3/overall | f1_score | 0.648627 | 0.898627 | CHANGED |
| Recording_3/overall | so | 0.473716 | 0.723716 | CHANGED |
| Recording_3/overall | tp | 0.018876 | 0.268876 | CHANGED |
| Recording_3/overall | ic | 0.737293 | 0.993223 | CHANGED |
| Recording_3/group/attack | precision | 0.000000 | 1.000000 | CHANGED |
| Recording_3/group/attack | recall | 0.000000 | 1.000000 | CHANGED |
| Recording_3/group/attack | f1_score | 0.000000 | 1.000000 | CHANGED |
| Recording_3/group/attack | so | 0.000000 | 1.000000 | CHANGED |
| Recording_3/group/attack | tp | 0.000000 | 1.000000 | CHANGED |
| Recording_3/group/attack | ic | 0.000000 | 1.000000 | CHANGED |
| Recording_3/group/investigation | precision | 0.935484 | 0.935484 | SAME |
| Recording_3/group/investigation | recall | 0.783784 | 0.783784 | SAME |
| Recording_3/group/investigation | f1_score | 0.852941 | 0.852941 | SAME |
| Recording_3/group/investigation | so | 0.456310 | 0.456310 | SAME |
| Recording_3/group/investigation | tp | 0.022322 | 0.022322 | SAME |
| Recording_3/group/investigation | ic | 0.984966 | 0.984966 | SAME |
| Recording_3/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_3/group/mount | recall | 0.739130 | 0.739130 | SAME |
| Recording_3/group/mount | f1_score | 0.850000 | 0.850000 | SAME |
| Recording_3/group/mount | so | 0.674504 | 0.674504 | SAME |
| Recording_3/group/mount | tp | 0.028795 | 0.028795 | SAME |
| Recording_3/group/mount | ic | 0.991713 | 0.991713 | SAME |
| Recording_3/group/other | precision | 0.902439 | 0.902439 | SAME |
| Recording_3/group/other | recall | 0.880952 | 0.880952 | SAME |
| Recording_3/group/other | f1_score | 0.891566 | 0.891566 | SAME |
| Recording_3/group/other | so | 0.764048 | 0.764048 | SAME |
| Recording_3/group/other | tp | 0.024386 | 0.024386 | SAME |
| Recording_3/group/other | ic | 0.972492 | 0.996212 | CHANGED |
| Recording_4/overall | precision | 0.725000 | 0.975000 | CHANGED |
| Recording_4/overall | recall | 0.365686 | 0.615686 | CHANGED |
| Recording_4/overall | f1_score | 0.479167 | 0.729167 | CHANGED |
| Recording_4/overall | so | 0.457087 | 0.707087 | CHANGED |
| Recording_4/overall | tp | 0.025715 | 0.275715 | CHANGED |
| Recording_4/overall | ic | 0.736591 | 0.996530 | CHANGED |
| Recording_4/group/attack | precision | 0.000000 | 1.000000 | CHANGED |
| Recording_4/group/attack | recall | 0.000000 | 1.000000 | CHANGED |
| Recording_4/group/attack | f1_score | 0.000000 | 1.000000 | CHANGED |
| Recording_4/group/attack | so | 0.000000 | 1.000000 | CHANGED |
| Recording_4/group/attack | tp | 0.000000 | 1.000000 | CHANGED |
| Recording_4/group/attack | ic | 0.000000 | 1.000000 | CHANGED |
| Recording_4/group/investigation | precision | 0.900000 | 0.900000 | SAME |
| Recording_4/group/investigation | recall | 0.529412 | 0.529412 | SAME |
| Recording_4/group/investigation | f1_score | 0.666667 | 0.666667 | SAME |
| Recording_4/group/investigation | so | 0.391471 | 0.391471 | SAME |
| Recording_4/group/investigation | tp | 0.014025 | 0.014025 | SAME |
| Recording_4/group/investigation | ic | 0.996882 | 0.996882 | SAME |
| Recording_4/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_4/group/mount | recall | 0.333333 | 0.333333 | SAME |
| Recording_4/group/mount | f1_score | 0.500000 | 0.500000 | SAME |
| Recording_4/group/mount | so | 0.701119 | 0.701119 | SAME |
| Recording_4/group/mount | tp | 0.040087 | 0.040087 | SAME |
| Recording_4/group/mount | ic | 0.995289 | 0.995289 | SAME |
| Recording_4/group/other | precision | 1.000000 | 1.000000 | SAME |
| Recording_4/group/other | recall | 0.600000 | 0.600000 | SAME |
| Recording_4/group/other | f1_score | 0.750000 | 0.750000 | SAME |
| Recording_4/group/other | so | 0.735760 | 0.735760 | SAME |
| Recording_4/group/other | tp | 0.048749 | 0.048749 | SAME |
| Recording_4/group/other | ic | 0.954191 | 0.993949 | CHANGED |
| Recording_5/overall | precision | 0.750000 | 1.000000 | CHANGED |
| Recording_5/overall | recall | 0.642105 | 0.892105 | CHANGED |
| Recording_5/overall | f1_score | 0.691459 | 0.941459 | CHANGED |
| Recording_5/overall | so | 0.586730 | 0.836730 | CHANGED |
| Recording_5/overall | tp | 0.029289 | 0.279289 | CHANGED |
| Recording_5/overall | ic | 0.732165 | 0.995120 | CHANGED |
| Recording_5/group/attack | precision | 0.000000 | 1.000000 | CHANGED |
| Recording_5/group/attack | recall | 0.000000 | 1.000000 | CHANGED |
| Recording_5/group/attack | f1_score | 0.000000 | 1.000000 | CHANGED |
| Recording_5/group/attack | so | 0.000000 | 1.000000 | CHANGED |
| Recording_5/group/attack | tp | 0.000000 | 1.000000 | CHANGED |
| Recording_5/group/attack | ic | 0.000000 | 1.000000 | CHANGED |
| Recording_5/group/investigation | precision | 1.000000 | 1.000000 | SAME |
| Recording_5/group/investigation | recall | 0.800000 | 0.800000 | SAME |
| Recording_5/group/investigation | f1_score | 0.888889 | 0.888889 | SAME |
| Recording_5/group/investigation | so | 0.658959 | 0.658959 | SAME |
| Recording_5/group/investigation | tp | 0.035333 | 0.035333 | SAME |
| Recording_5/group/investigation | ic | 0.990205 | 0.989914 | CHANGED |
| Recording_5/group/mount | precision | 1.000000 | 1.000000 | SAME |
| Recording_5/group/mount | recall | 0.900000 | 0.900000 | SAME |
| Recording_5/group/mount | f1_score | 0.947368 | 0.947368 | SAME |
| Recording_5/group/mount | so | 0.910024 | 0.910024 | SAME |
| Recording_5/group/mount | tp | 0.035960 | 0.035960 | SAME |
| Recording_5/group/mount | ic | 0.996541 | 0.996541 | SAME |
| Recording_5/group/other | precision | 1.000000 | 1.000000 | SAME |
| Recording_5/group/other | recall | 0.868421 | 0.868421 | SAME |
| Recording_5/group/other | f1_score | 0.929577 | 0.929577 | SAME |
| Recording_5/group/other | so | 0.777939 | 0.777939 | SAME |
| Recording_5/group/other | tp | 0.045862 | 0.045862 | SAME |
| Recording_5/group/other | ic | 0.941915 | 0.994024 | CHANGED |
| Recording_6/overall | precision | 0.664083 | 0.914083 | CHANGED |
| Recording_6/overall | recall | 0.495671 | 0.745671 | CHANGED |
| Recording_6/overall | f1_score | 0.542505 | 0.792505 | CHANGED |
| Recording_6/overall | so | 0.374464 | 0.624464 | CHANGED |
| Recording_6/overall | tp | 0.014068 | 0.264068 | CHANGED |
| Recording_6/overall | ic | 0.738821 | 0.993987 | CHANGED |
| Recording_6/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_6/group/attack | recall | 0.363636 | 0.363636 | SAME |
| Recording_6/group/attack | f1_score | 0.533333 | 0.533333 | SAME |
| Recording_6/group/attack | so | 0.355488 | 0.355488 | SAME |
| Recording_6/group/attack | tp | 0.014143 | 0.014143 | SAME |
| Recording_6/group/attack | ic | 0.993666 | 0.993666 | SAME |
| Recording_6/group/investigation | precision | 0.767442 | 0.767442 | SAME |
| Recording_6/group/investigation | recall | 0.785714 | 0.785714 | SAME |
| Recording_6/group/investigation | f1_score | 0.776471 | 0.776471 | SAME |
| Recording_6/group/investigation | so | 0.539580 | 0.539580 | SAME |
| Recording_6/group/investigation | tp | 0.020317 | 0.020317 | SAME |
| Recording_6/group/investigation | ic | 0.990392 | 0.990392 | SAME |
| Recording_6/group/mount | precision | 0.000000 | 1.000000 | CHANGED |
| Recording_6/group/mount | recall | 0.000000 | 1.000000 | CHANGED |
| Recording_6/group/mount | f1_score | 0.000000 | 1.000000 | CHANGED |
| Recording_6/group/mount | so | 0.000000 | 1.000000 | CHANGED |
| Recording_6/group/mount | tp | 0.000000 | 1.000000 | CHANGED |
| Recording_6/group/mount | ic | 0.000000 | 1.000000 | CHANGED |
| Recording_6/group/other | precision | 0.888889 | 0.888889 | SAME |
| Recording_6/group/other | recall | 0.833333 | 0.833333 | SAME |
| Recording_6/group/other | f1_score | 0.860215 | 0.860215 | SAME |
| Recording_6/group/other | so | 0.602789 | 0.602789 | SAME |
| Recording_6/group/other | tp | 0.021811 | 0.021811 | SAME |
| Recording_6/group/other | ic | 0.971224 | 0.991888 | CHANGED |
| Recording_7/overall | precision | 0.750000 | 1.000000 | CHANGED |
| Recording_7/overall | recall | 0.333910 | 0.583910 | CHANGED |
| Recording_7/overall | f1_score | 0.456530 | 0.706530 | CHANGED |
| Recording_7/overall | so | 0.414495 | 0.664495 | CHANGED |
| Recording_7/overall | tp | 0.026530 | 0.276530 | CHANGED |
| Recording_7/overall | ic | 0.734494 | 0.994944 | CHANGED |
| Recording_7/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_7/group/attack | recall | 0.525000 | 0.525000 | SAME |
| Recording_7/group/attack | f1_score | 0.688525 | 0.688525 | SAME |
| Recording_7/group/attack | so | 0.390695 | 0.390695 | SAME |
| Recording_7/group/attack | tp | 0.030229 | 0.030229 | SAME |
| Recording_7/group/attack | ic | 0.989471 | 0.989471 | SAME |
| Recording_7/group/investigation | precision | 1.000000 | 1.000000 | SAME |
| Recording_7/group/investigation | recall | 0.300000 | 0.300000 | SAME |
| Recording_7/group/investigation | f1_score | 0.461538 | 0.461538 | SAME |
| Recording_7/group/investigation | so | 0.536495 | 0.536495 | SAME |
| Recording_7/group/investigation | tp | 0.031331 | 0.031331 | SAME |
| Recording_7/group/investigation | ic | 0.994544 | 0.994544 | SAME |
| Recording_7/group/mount | precision | 0.000000 | 1.000000 | CHANGED |
| Recording_7/group/mount | recall | 0.000000 | 1.000000 | CHANGED |
| Recording_7/group/mount | f1_score | 0.000000 | 1.000000 | CHANGED |
| Recording_7/group/mount | so | 0.000000 | 1.000000 | CHANGED |
| Recording_7/group/mount | tp | 0.000000 | 1.000000 | CHANGED |
| Recording_7/group/mount | ic | 0.000000 | 1.000000 | CHANGED |
| Recording_7/group/other | precision | 1.000000 | 1.000000 | SAME |
| Recording_7/group/other | recall | 0.510638 | 0.510638 | SAME |
| Recording_7/group/other | f1_score | 0.676056 | 0.676056 | SAME |
| Recording_7/group/other | so | 0.730790 | 0.730790 | SAME |
| Recording_7/group/other | tp | 0.044561 | 0.044561 | SAME |
| Recording_7/group/other | ic | 0.953960 | 0.995760 | CHANGED |
| Recording_8/overall | precision | 0.668750 | 0.891667 | CHANGED |
| Recording_8/overall | recall | 0.346721 | 0.346721 | SAME |
| Recording_8/overall | f1_score | 0.454630 | 0.606173 | CHANGED |
| Recording_8/overall | so | 0.397921 | 0.530562 | CHANGED |
| Recording_8/overall | tp | 0.028604 | 0.038138 | CHANGED |
| Recording_8/overall | ic | 0.965943 | 0.995577 | CHANGED |
| Recording_8/group/attack | precision | 0.875000 | 0.875000 | SAME |
| Recording_8/group/attack | recall | 0.368421 | 0.368421 | SAME |
| Recording_8/group/attack | f1_score | 0.518519 | 0.518519 | SAME |
| Recording_8/group/attack | so | 0.268364 | 0.268364 | SAME |
| Recording_8/group/attack | tp | 0.020423 | 0.020423 | SAME |
| Recording_8/group/attack | ic | 0.992796 | 0.991919 | CHANGED |
| Recording_8/group/investigation | precision | 1.000000 | 1.000000 | SAME |
| Recording_8/group/investigation | recall | 0.538462 | 0.538462 | SAME |
| Recording_8/group/investigation | f1_score | 0.700000 | 0.700000 | SAME |
| Recording_8/group/investigation | so | 0.591051 | 0.591051 | SAME |
| Recording_8/group/investigation | tp | 0.025271 | 0.025271 | SAME |
| Recording_8/group/investigation | ic | 0.995282 | 0.995282 | SAME |
| Recording_8/group/mount | precision | 0.000000 | NaN | NULL->VALUE |
| Recording_8/group/mount | recall | 0.000000 | 0.000000 | SAME |
| Recording_8/group/mount | f1_score | 0.000000 | NaN | NULL->VALUE |
| Recording_8/group/mount | so | 0.000000 | NaN | NULL->VALUE |
| Recording_8/group/mount | tp | 0.000000 | NaN | NULL->VALUE |
| Recording_8/group/mount | ic | 1.000000 | 1.000000 | SAME |
| Recording_8/group/other | precision | 0.800000 | 0.800000 | SAME |
| Recording_8/group/other | recall | 0.480000 | 0.480000 | SAME |
| Recording_8/group/other | f1_score | 0.600000 | 0.600000 | SAME |
| Recording_8/group/other | so | 0.732271 | 0.732271 | SAME |
| Recording_8/group/other | tp | 0.068721 | 0.068721 | SAME |
| Recording_8/group/other | ic | 0.875693 | 0.995105 | CHANGED |
| Recording_9/overall | precision | 0.548398 | 0.798398 | CHANGED |
| Recording_9/overall | recall | 0.403819 | 0.653819 | CHANGED |
| Recording_9/overall | f1_score | 0.462355 | 0.712355 | CHANGED |
| Recording_9/overall | so | 0.432730 | 0.682730 | CHANGED |
| Recording_9/overall | tp | 0.027809 | 0.277809 | CHANGED |
| Recording_9/overall | ic | 0.738825 | 0.995671 | CHANGED |
| Recording_9/group/attack | precision | 0.956522 | 0.956522 | SAME |
| Recording_9/group/attack | recall | 0.733333 | 0.733333 | SAME |
| Recording_9/group/attack | f1_score | 0.830189 | 0.830189 | SAME |
| Recording_9/group/attack | so | 0.424100 | 0.424100 | SAME |
| Recording_9/group/attack | tp | 0.020590 | 0.020590 | SAME |
| Recording_9/group/attack | ic | 0.989386 | 0.989386 | SAME |
| Recording_9/group/investigation | precision | 0.375000 | 0.375000 | SAME |
| Recording_9/group/investigation | recall | 0.187500 | 0.187500 | SAME |
| Recording_9/group/investigation | f1_score | 0.250000 | 0.250000 | SAME |
| Recording_9/group/investigation | so | 0.631678 | 0.631678 | SAME |
| Recording_9/group/investigation | tp | 0.034810 | 0.034810 | SAME |
| Recording_9/group/investigation | ic | 0.998630 | 0.998630 | SAME |
| Recording_9/group/mount | precision | 0.000000 | 1.000000 | CHANGED |
| Recording_9/group/mount | recall | 0.000000 | 1.000000 | CHANGED |
| Recording_9/group/mount | f1_score | 0.000000 | 1.000000 | CHANGED |
| Recording_9/group/mount | so | 0.000000 | 1.000000 | CHANGED |
| Recording_9/group/mount | tp | 0.000000 | 1.000000 | CHANGED |
| Recording_9/group/mount | ic | 0.000000 | 1.000000 | CHANGED |
| Recording_9/group/other | precision | 0.862069 | 0.862069 | SAME |
| Recording_9/group/other | recall | 0.694444 | 0.694444 | SAME |
| Recording_9/group/other | f1_score | 0.769231 | 0.769231 | SAME |
| Recording_9/group/other | so | 0.675140 | 0.675140 | SAME |
| Recording_9/group/other | tp | 0.055838 | 0.055838 | SAME |
| Recording_9/group/other | ic | 0.967283 | 0.994669 | CHANGED |
| Recording_10/overall | precision | 0.649107 | 0.899107 | CHANGED |
| Recording_10/overall | recall | 0.225760 | 0.475760 | CHANGED |
| Recording_10/overall | f1_score | 0.333475 | 0.583475 | CHANGED |
| Recording_10/overall | so | 0.416806 | 0.666806 | CHANGED |
| Recording_10/overall | tp | 0.035923 | 0.285923 | CHANGED |
| Recording_10/overall | ic | 0.741064 | 0.996191 | CHANGED |
| Recording_10/group/attack | precision | 1.000000 | 1.000000 | SAME |
| Recording_10/group/attack | recall | 0.285714 | 0.285714 | SAME |
| Recording_10/group/attack | f1_score | 0.444444 | 0.444444 | SAME |
| Recording_10/group/attack | so | 0.184903 | 0.184903 | SAME |
| Recording_10/group/attack | tp | 0.011189 | 0.011189 | SAME |
| Recording_10/group/attack | ic | 0.991412 | 0.991412 | SAME |
| Recording_10/group/investigation | precision | 0.625000 | 0.625000 | SAME |
| Recording_10/group/investigation | recall | 0.263158 | 0.263158 | SAME |
| Recording_10/group/investigation | f1_score | 0.370370 | 0.370370 | SAME |
| Recording_10/group/investigation | so | 0.657207 | 0.657207 | SAME |
| Recording_10/group/investigation | tp | 0.064683 | 0.064683 | SAME |
| Recording_10/group/investigation | ic | 0.995061 | 0.995061 | SAME |
| Recording_10/group/mount | precision | 0.000000 | 1.000000 | CHANGED |
| Recording_10/group/mount | recall | 0.000000 | 1.000000 | CHANGED |
| Recording_10/group/mount | f1_score | 0.000000 | 1.000000 | CHANGED |
| Recording_10/group/mount | so | 0.000000 | 1.000000 | CHANGED |
| Recording_10/group/mount | tp | 0.000000 | 1.000000 | CHANGED |
| Recording_10/group/mount | ic | 0.000000 | 1.000000 | CHANGED |
| Recording_10/group/other | precision | 0.971429 | 0.971429 | SAME |
| Recording_10/group/other | recall | 0.354167 | 0.354167 | SAME |
| Recording_10/group/other | f1_score | 0.519084 | 0.519084 | SAME |
| Recording_10/group/other | so | 0.825115 | 0.825115 | SAME |
| Recording_10/group/other | tp | 0.067820 | 0.067820 | SAME |
| Recording_10/group/other | ic | 0.977784 | 0.998293 | CHANGED |