Skip to content

Commit bd4b4e9

Browse files
Jad-yehyatomMoral
andauthored
Refactoring of solvers and datasets (#31)
feat: major anomaly detection benchmark and solver refactor * Add new datasets from TSB-UAD benchmark: ECG, MGAB, MITDB, DAPHNET, Dodgers, Genesis, GHL, Occupancy, and SensorScope * Add new anomaly detection solvers: Matrix Profile, Autoencoder (univariate), and RoseCDL * Refactor dataset loading and reshaping pipeline for anomaly detection tasks * Separate `anomaly_scores` from optional binary `anomaly_predictions` using solver-side `cutoff` * Adapt solvers to new score/prediction contract * Move legacy solvers to `solvers/legacy` and remove duplicate implementations * Improve evaluation pipeline and objective handling: fix `y_test` / `y_hat` reshaping and objective returns * Add `find_period_length` helper to remove dependency on TSB-AD utilities * Improve metrics performance by vectorizing: `soft_precision`, `soft_recall`, and `extract_anomaly_ranges` * Improve tests, linting, formatting, installation handling, and CI stability * Remove deprecated `safe_import_context` utilities and cleanup plotting code * Fix Autoencoder device handling --------- Co-authored-by: tommoral <thomas.moreau.2010@gmail.com>
1 parent 9b69150 commit bd4b4e9

56 files changed

Lines changed: 4752 additions & 871 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

benchmark_utils/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33
# name `benchmark_utils`, and code defined inside will be importable using
44
# the usual import syntax
55

6-
from benchopt import safe_import_context
76
from pathlib import Path
87

9-
with safe_import_context() as import_ctx:
10-
import numpy as np
8+
import numpy as np
119

1210

1311
def mean_overlaping_pred(predictions, stride):
@@ -24,7 +22,9 @@ def mean_overlaping_pred(predictions, stride):
2422
np.ndarray: Averaged predictions for each feature.
2523
"""
2624
n_windows, H, n_features = predictions.shape
27-
total_length = (n_windows-1) * stride + H - 1
25+
# The last window starts at (n_windows-1)*stride and covers H samples, so
26+
# the reconstructed signal spans (n_windows-1)*stride + H positions.
27+
total_length = (n_windows - 1) * stride + H
2828

2929
# Array to store accumulated predictions for each feature
3030
accumulated = np.zeros((total_length, n_features))

benchmark_utils/metrics.py

Lines changed: 74 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,24 @@
1-
from benchopt import safe_import_context
1+
import numpy as np
22

3-
with safe_import_context() as import_ctx:
4-
import numpy as np
3+
4+
def _dilate(mask: np.ndarray, radius: int) -> np.ndarray:
5+
"""Binary dilation with a centered window of half-width ``radius``.
6+
7+
``out[i]`` is True iff any entry of ``mask`` in ``[i-radius, i+radius]``
8+
(clipped to the array) is truthy. Matches the half-open slice
9+
``mask[max(0, i-r):min(n, i+r+1)]`` used by the soft metrics.
10+
"""
11+
mask = np.asarray(mask)
12+
n = mask.shape[0]
13+
if n == 0:
14+
return np.zeros(0, dtype=bool)
15+
if radius <= 0:
16+
return mask.astype(bool, copy=False)
17+
cum = np.concatenate(([0], np.cumsum(mask.astype(np.int64))))
18+
idx = np.arange(n)
19+
left = np.maximum(0, idx - radius)
20+
right = np.minimum(n, idx + radius + 1)
21+
return (cum[right] - cum[left]) > 0
522

623

724
def soft_precision(y_true: np.ndarray,
@@ -35,47 +52,34 @@ def soft_precision(y_true: np.ndarray,
3552
fa : int
3653
Number of false anomalies
3754
"""
38-
# EM : Exact Match
39-
em = 0
40-
# DA : Detected Anomaly
41-
da = 0
42-
# FA : False Anomaly
43-
fa = 0
44-
45-
# TFDIR = (EM + DA) / (EM + DA + FA)
55+
y_true = np.asarray(y_true)
56+
y_pred = np.asarray(y_pred)
4657

47-
# Counting exact matches
48-
for i in range(len(y_true)):
49-
if y_true[i] == 1 and (y_true[i] == y_pred[i]):
50-
em += 1
58+
true_mask = y_true == 1
59+
pred_mask = y_pred == 1
5160

52-
# False anomaly and detected anomalies
53-
for i in range(len(y_true)):
61+
# TFDIR = (EM + DA) / (EM + DA + FA)
5462

55-
left = max(0, i-detection_range)
56-
right = min(len(y_true), i+detection_range+1)
63+
# EM : Exact Match
64+
em = int(np.sum(true_mask & pred_mask))
5765

58-
if y_pred[i] == 1 and (
59-
y_true[left:right] == 0).all():
60-
fa += 1
66+
true_dil = _dilate(true_mask, detection_range)
67+
pred_dil = _dilate(pred_mask, detection_range)
6168

62-
if y_true[i] == 1 and (
63-
y_pred[left:right] == 1).any():
64-
da += 1
69+
# DA : Detected Anomaly
70+
fa = int(np.sum(pred_mask & ~true_dil))
6571

72+
# FA : False Anomaly
6673
# Removing exact matches from detected anomalies because they are
6774
# counted twice
68-
da -= em
75+
da = int(np.sum(true_mask & pred_dil)) - em
6976

70-
if return_counts:
71-
if em + da + fa == 0:
72-
return 0, em, da, fa
73-
74-
return (em + da) / (em + da + fa), em, da, fa
77+
total = em + da + fa
78+
score = (em + da) / total if total else 0
7579

76-
if em + da + fa == 0:
77-
return 0
78-
return (em + da) / (em + da + fa)
80+
if return_counts:
81+
return score, em, da, fa
82+
return score
7983

8084

8185
def soft_recall(y_true: np.ndarray,
@@ -104,46 +108,25 @@ def soft_recall(y_true: np.ndarray,
104108
ma : int
105109
Number of missed anomalies
106110
"""
107-
# EM : Exact Match
108-
em = 0
109-
# DA : Detected Anomaly
110-
da = 0
111-
# MA : Missed Anomaly
112-
ma = 0
113-
# DAIR = (EM + DA) / (EM + DA + MA)
111+
y_true = np.asarray(y_true)
112+
y_pred = np.asarray(y_pred)
114113

115-
# Counting exact matches
116-
for i in range(len(y_true)):
117-
if y_true[i] == 1 and (y_true[i] == y_pred[i]):
118-
em += 1
114+
true_mask = y_true == 1
115+
pred_mask = y_pred == 1
119116

120-
# Missing values and detected anomalies
121-
for i in range(len(y_true)):
117+
em = int(np.sum(true_mask & pred_mask))
122118

123-
left = max(0, i-detection_range)
124-
right = min(len(y_true), i+detection_range+1)
119+
pred_dil = _dilate(pred_mask, detection_range)
125120

126-
if y_true[i] == 1 and (
127-
y_pred[left:right] == 0).all():
128-
ma += 1
121+
ma = int(np.sum(true_mask & ~pred_dil))
122+
da = int(np.sum(true_mask & pred_dil)) - em
129123

130-
if y_true[i] == 1 and (
131-
y_pred[left:right] == 1).any():
132-
da += 1
133-
134-
# Removing exact matches from detected anomalies because they are
135-
# counted twice
136-
da -= em
124+
total = em + da + ma
125+
score = (em + da) / total if total else 0
137126

138127
if return_counts:
139-
if em + da + ma == 0:
140-
return 0, em, da, ma
141-
142-
return (em + da) / (em + da + ma), em, da, ma
143-
144-
if em + da + ma == 0:
145-
return 0
146-
return (em + da) / (em + da + ma)
128+
return score, em, da, ma
129+
return score
147130

148131

149132
def ctt(y_true: np.ndarray, y_pred: np.ndarray, return_signed: bool = False):
@@ -240,22 +223,34 @@ def ttc(y_true: np.ndarray, y_pred: np.ndarray, return_signed: bool = False):
240223
return tot_dist / np.sum(y_true)
241224

242225

243-
def soft_f1(precision, recall):
226+
def soft_f1(precision, recall, detection_range=None):
244227
"""
245228
Calculate the F1 score from precision and recall.
246229
247230
Parameters
248231
----------
249-
precision : float
232+
precision : float or np.ndarray
250233
Precision score
251-
recall : float
234+
recall : float or np.ndarray
252235
Recall score
236+
detection_range : int, optional
237+
If provided, ``precision`` and ``recall`` are interpreted as the
238+
true and predicted label arrays used by ``soft_precision`` and
239+
``soft_recall``.
253240
254241
Returns
255242
-------
256243
f1 : float
257244
F1 score
258245
"""
246+
if detection_range is not None:
247+
precision_score = soft_precision(
248+
precision, recall, detection_range=detection_range
249+
)
250+
recall_score = soft_recall(
251+
precision, recall, detection_range=detection_range)
252+
precision, recall = precision_score, recall_score
253+
259254
if precision + recall == 0:
260255
return 0
261256
return 2 * (precision * recall) / (precision + recall)
@@ -280,21 +275,15 @@ def extract_anomaly_ranges(labels: list[int]):
280275
Each tuple represents a range (start_index, end_index)
281276
where anomalies are present.
282277
"""
283-
ranges = []
284-
start = None
285-
286-
for i, label in enumerate(labels):
287-
if label == 1 and start is None:
288-
start = i # Start of a new anomaly range
289-
elif label == 0 and start is not None:
290-
ranges.append((start, i - 1)) # End of the current anomaly range
291-
start = None
292-
293-
# Handle the case where the series ends with an anomaly
294-
if start is not None:
295-
ranges.append((start, len(labels) - 1))
296-
297-
return ranges
278+
arr = np.asarray(labels)
279+
if arr.size == 0:
280+
return []
281+
binary = (arr == 1).astype(np.int8)
282+
padded = np.concatenate(([0], binary, [0]))
283+
diff = np.diff(padded)
284+
starts = np.where(diff == 1)[0]
285+
ends = np.where(diff == -1)[0] - 1
286+
return list(zip(starts.tolist(), ends.tolist()))
298287

299288

300289
def existence_reward(real_range, predicted_ranges):

0 commit comments

Comments
 (0)