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
724def 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
8185def 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
149132def 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
300289def existence_reward (real_range , predicted_ranges ):
0 commit comments