Robust filters (standalone detectors)¶
You already have a recorded series: a joint velocity at 100 Hz, a temperature log, a sensor reading. A few samples in it are wrong, and you want to know which ones and to see the series with them replaced. That is what this page does.
The library has four classic batch filters for this job: Hampel, Tukey fences, Chauvenet and sigma clipping. Each one takes the whole series, compares every sample with its neighbours, and returns two things: the series with the flagged samples replaced by a local estimate, and the list of positions it flagged. The four differ in how they decide what "strange" means, so they often disagree on borderline samples.
pressure_valve settles the disagreement: it runs all four, weighs them by how confident each
one is, and returns one cleaned series, one list of flagged positions, and one pressure score
per sample. The sections below show each method in turn, then the combination.
These filters look at samples after the current one (the window is centred on it). For a series that is arriving now, where looking ahead is not allowed, use the causal versions on Streaming.
1. Hampel filter¶
The batch Hampel filter uses a window centred on each point (it sees the future) and replaces outliers with the window median.
import numpy as np
from dense_armor.utility.anomaly.robust_filters import hampel_filter
rng = np.random.default_rng(42)
v = 0.5 + 0.1 * rng.standard_normal(3000)
v[1500] = 2.5
clean, idx = hampel_filter(v, radius=15, n_sigmas=3.0)
print(1500 in idx, round(float(clean[1500]), 3), len(idx))
The stream is a joint velocity at 100 Hz: a level of 0.5, Gaussian noise with standard deviation 0.1, and one collision at sample 1500 that drives the reading to 2.5.
The Hampel filter flags the collision and replaces it with the local median, 0.498. It also flags 34 ordinary samples: with a 31-point window and a threshold of 3 in the Hampel rule, some noise values fall beyond it. The total is 35 flagged positions.
Every function on this page returns two values, clean and idx. clean is the input
series where every unflagged sample keeps its value and every flagged sample is replaced by
the local median. idx is the list of flagged positions.
The rule is |x_k - m_k| > t * S_k, where m_k is the median of the window centred on
sample k, S_k = 1.4826 * MAD is the robust estimate of the standard deviation of that
window, and t is the threshold (Pearson et al., 2016, eqs. 3 and 4; step by step on
Streaming).
2. The other three¶
tukey_fences, chauvenet_criterion and sigma_clip implement the other three classic
rules, with the same (clean, idx) return as hampel_filter.
import numpy as np
from dense_armor.utility.anomaly.robust_filters import (
hampel_filter, tukey_fences, chauvenet_criterion, sigma_clip)
rng = np.random.default_rng(42)
v = 0.5 + 0.1 * rng.standard_normal(3000)
v[1500] = 2.5
for f in (hampel_filter, tukey_fences, chauvenet_criterion, sigma_clip):
clean, idx = f(v, radius=15)
print(f.__name__, 1500 in idx, len(idx))
All four catch the collision; they differ in how many ordinary samples they also flag. Tukey uses
the quartiles (outside $[Q_1 - 1.5\,\mathrm{IQR},\, Q_3 + 1.5\,\mathrm{IQR}]$), Chauvenet rejects
when the expected number of samples that far out, $N \cdot P$, is below 0.5, sigma clipping
removes values beyond n_sigmas standard deviations and repeats on what remains.
3. pressure_valve: four estimates combined¶
Each of the four methods (Hampel, Tukey fences, Chauvenet, sigma clipping) returns two
numbers for every sample. The first is a centre c_k: the method's estimate of the
true value at that sample. The second is a scale s_k: the method's estimate of its own
uncertainty. Both come from the window around the sample, and the pair differs per method:
| method | centre c_k |
scale s_k |
|---|---|---|
| Hampel | median | 1.4826 * MAD |
| Tukey fences | median | IQR / 1.349 |
| Chauvenet | mean | standard deviation |
| sigma clipping | clipped mean | standard deviation |
The four scales can differ a lot: a method whose window already contains an outlier reports a large scale, a method whose window is clean reports a small one.
pressure_valve weights each centre by the inverse of its variance, so a method that is
sure of itself counts more than one that is not. The weight of method k is
$$w_k = \frac{1/s_k^2}{\sum_j 1/s_j^2},$$
the minimum-variance (inverse-variance) combination of independent estimates. No weight is chosen by hand.
import numpy as np
s = np.array([0.10, 0.12, 0.10, 0.30])
w = (1 / s**2) / np.sum(1 / s**2)
print(np.round(w, 3), round(float(1 / np.sqrt(np.sum(1 / s**2))), 4))
A method with a small scale gets a large weight; a method with a large scale gets a small one. The combined estimate is more precise than any single method, and its scale is
$$s = \frac{1}{\sqrt{\sum_j 1/s_j^2}}.$$
The pressure of a new sample x is how far it lies from the combined centre, measured in
units of the combined scale:
$$p = \frac{\left|x - \sum_k w_k c_k\right|}{s}.$$
In the code above the four scales are 0.10, 0.12, 0.10 and 0.30. The method with scale 0.30 gets weight 0.04; the two methods with scale 0.10 get weight 0.36 each. The combined scale is 0.0597, smaller than any of the four. The four weights are the solution of the calculation above, not numbers chosen by hand. This is the minimum-variance combination of independent estimates.
4. pressure_valve on the joint signal¶
import numpy as np
from dense_armor.utility.anomaly.robust_filters import pressure_valve
rng = np.random.default_rng(42)
v = 0.5 + 0.1 * rng.standard_normal(3000)
v[1500] = 2.5
clean, idx, p, thr = pressure_valve(v, radius=15)
print(idx, round(float(p[1500]), 1), round(float(np.median(thr)), 2))
Of the 3,000 samples, the filter flags three: the collision at 1500 (pressure 42.7) and two noise values at 949 and 2707.
The threshold is not the same at every sample. At each sample it depends on how different
the local window looks from a wider reference window of size radius * ref_mult. The
difference is measured by the Jensen–Shannon divergence, a number in [0, 1]: near 0 when
the two windows have the same shape (steady noise), near 1 when they have different shapes
(a real change of level is under way). The threshold is
$$\text{threshold}i = \text{soglia_pressione} \cdot (1 + k{\text{molla}} \cdot \mathrm{JSD}_i),$$
where soglia_pressione (default 8.0) and k_molla are the two parameters of the code.
When the two windows look the same, JSD is near 0 and the threshold stays at its base
value, 8.0. When they differ, the threshold rises and the filter tolerates a larger
deviation, so a genuine change of level is not flagged as an anomaly. On the 3,000 samples
above the median threshold is 8.46.
API reference¶
robust_filters ¶
robust_filters.py -- quattro rilevatori di anomalie "a basso costo", nessuna meccanica pesante (nessun modello dinamico, nessuno stato, nessuna differenziazione automatica): statistiche classiche, note e usate da decenni in telemetria/astronomia, ognuna calcolata su una finestra locale centrata (come healing_filter.py in questo stesso pacchetto -- non causale, pensate per pulizia offline/batch di una serie gia' registrata, non per il ciclo real-time di core/hybrid_engine.py).
Ogni funzione ha la stessa firma (x, radius=..., **soglia) -> (pulito, anomalie): pulito -- array 1D, stessa shape di x: valore grezzo dove non anomalo, mediana locale dove anomalo. anomalie -- lista di indici (int) segnalati come anomali.
I quattro metodi, in ordine di introduzione storica: - Chauvenet (1863): soglia di rigetto basata sulla probabilita' attesa che un campione della finestra locale (size N) si discosti tanto per puro caso -- se il numero atteso di tali eventi (N * P) e' < 0.5, il punto e' un outlier statisticamente "troppo improbabile per il campione a disposizione". - Tukey's fences / IQR (anni '70): nessuna assunzione sulla distribuzione, solo quartili -- oltre Q1-1.5IQR o Q3+1.5IQR e' outlier. - Hampel filter: mediana + MAD scalato (1.4826*MAD, costante di consistenza per una gaussiana) come soglia -- la generalizzazione robusta di "3 sigma" che non usa mai una media/std sensibile agli outlier che sta cercando. - Sigma-clipping iterativo: standard in astronomia (stacking di immagini, fotometria) -- ricalcola media/std della finestra escludendo i punti oltre N sigma, ripete finche' stabile o max_iters, poi giudica il punto centrale contro le statistiche "ripulite".
chauvenet_criterion ¶
Criterio di Chauvenet (1863) su finestra locale di raggio radius.
Per il punto i, con la finestra come campione (media mu, std sigma, size N): d = |x[i] - mu| / sigma; P = erfc(d / sqrt(2)) (probabilita' a due code di un tale scostamento sotto ipotesi gaussiana). Se N*P < 0.5 -- il numero atteso di scostamenti pari a questo, ripetendo l'esperimento, e' inferiore a mezzo campione -- il punto e' rigettato come outlier.
Nota: usa mean/std (non robuste), come nella formulazione originale -- su una finestra con PIU' di un outlier, mean/std sono gia' distorte dagli altri outlier prima ancora di giudicare il punto i (limite noto e documentato del criterio originale, non specifico di questa implementazione; per finestre con outlier multipli preferire hampel_filter o tukey_fences, che usano statistiche robuste).
Source code in dense_armor/utility/anomaly/robust_filters.py
tukey_fences ¶
Tukey's fences / IQR su finestra locale di raggio radius.
Q1/Q3 = 25/75-esimo percentile della finestra, IQR = Q3-Q1. Il punto i e' outlier se fuori da [Q1 - kIQR, Q3 + kIQR] (k=1.5 e' lo standard, k=3.0 la variante "extreme outliers"). Nessuna assunzione sulla distribuzione -- l'unico dei quattro a non presupporre normalita'.
Source code in dense_armor/utility/anomaly/robust_filters.py
hampel_filter ¶
hampel_filter(
x: ndarray, radius: int = 10, n_sigmas: float = 3.0
) -> Tuple[np.ndarray, List[int]]
Hampel filter su finestra locale di raggio radius.
mediana e MAD (Median Absolute Deviation) della finestra, scalato per 1.4826 (costante di consistenza: rende scaled_MAD uno stimatore non distorto della deviazione standard SE il dato fosse gaussiano, ma senza mai calcolare una media/std vera -- ogni singola statistica usata qui e' gia' robusta, a differenza di Chauvenet). Outlier se |x[i] - mediana| > n_sigmas * scaled_MAD.
Source code in dense_armor/utility/anomaly/robust_filters.py
sigma_clip ¶
sigma_clip(
x: ndarray,
radius: int = 10,
n_sigmas: float = 3.0,
max_iters: int = 5,
) -> Tuple[np.ndarray, List[int]]
Sigma-clipping iterativo (standard in astronomia: stacking, fotometria)
su finestra locale di raggio radius.
Sulla finestra (esclude il punto i stesso, cosi' un vero outlier al centro non puo' auto-proteggersi gonfiando la propria std): calcola media/std, scarta i punti oltre n_sigmas, ricalcola su quelli rimasti, ripete fino a stabilita' o max_iters. Il punto i e' outlier se fuori da n_sigmas dalla media/std FINALI (ripulite dagli altri outlier della finestra, a differenza di chauvenet_criterion che usa mean/std grezze).
Source code in dense_armor/utility/anomaly/robust_filters.py
pressure_valve ¶
pressure_valve(
x: ndarray,
radius: int = 10,
soglia_pressione: float = 8.0,
n_sigmas: float = 3.0,
ref_mult: int = 3,
k_molla: float = 3.0,
) -> Tuple[np.ndarray, List[int], np.ndarray, np.ndarray]
Orchestratore automatico dei quattro rilevatori sopra: non un voto (quanti metodi segnalano il punto), una combinazione a MINIMA VARIANZA vincolata -- lo stimatore BLUE (Best Linear Unbiased Estimator) classico della statistica, derivato con un moltiplicatore di Lagrange.
Ogni metodo produce una coppia (centro, scala) locale: Chauvenet usa media/std (non robuste, la formulazione originale -- la debolezza e' voluta, vedi sotto), sigma-clipping usa media/std ripulite iterativamente dagli outlier della finestra, Hampel usa mediana/MAD scalato (1.4826*MAD), Tukey usa mediana/IQR scalato (IQR/1.349, la stessa costante che rende IQR uno stimatore di sigma per una gaussiana). Quattro stime indipendenti dello stesso "vero" centro locale, ognuna con la propria incertezza (scala).
Combinarle non con una media semplice ma cercando i pesi w_k che minimizzano la VARIANZA della combinazione pesata Sum(w_k * centro_k), vincolati a Sum(w_k) = 1 (combinazione non distorta) -- un problema di ottimizzazione vincolata, risolto con un moltiplicatore di Lagrange lambda: L(w, lambda) = Sum(w_k^2 * scala_k^2) - lambda * (Sum(w_k) - 1) dL/dw_k = 0 => w_k = lambda / (2 * scala_k^2) dal vincolo => w_k = (1/scala_k^2) / Sum_j(1/scala_j^2) Il risultato (peso inversamente proporzionale al QUADRATO della propria incertezza) e' lo stimatore a minima varianza tra tutte le combinazioni lineari non distorte di stime indipendenti (Gauss-Markov) -- non e' un peso scelto a mano, e' quello che il vincolo impone. Un metodo la cui scala si gonfia (es. Chauvenet quando la finestra contiene gia' un outlier, la sua std non e' robusta) viene automaticamente pesato meno, senza bisogno di scartarlo esplicitamente.
centro_combinato = Sum(w_k * centro_k) scala_combinata = 1 / sqrt(Sum(1/scala_k^2)) (la stessa derivazione da' anche la varianza della combinazione stessa -- SEMPRE piu' stretta di qualunque scala_k singola, e' la proprieta' che rende utile combinare piu' stime indipendenti) pressione = |x[i] - centro_combinato| / scala_combinata
Una singola soglia finale (soglia_pressione) decide -- trigger binario,
come il resto di questo ecosistema (core/hybrid_engine.py,
dense_evolution/healing.py): mai un terzo stato intermedio, mai un
blend tra grezzo e pulito. Non e' piu' un valore "in sigma" nel senso
classico (scala_combinata e' piu' stretta di ogni scala_k, quindi la
stessa deviazione reale produce una pressione piu' alta che con un
singolo metodo) -- soglia_pressione=8.0 e' il default calibrato
direttamente (vedi test/test_robust_filters.py): su rumore gaussiano
puro (N=300, seed fisso) i percentili della pressione sono
50%=1.4, 95%=4.6, 99%=6.0, 99.9%=7.6 -- 8.0 lascia 1/300 falsi
positivi mantenendo comunque un vero outlier (pressione~88) e una
coppia di outlier ravvicinati (pressione~40) ben sopra soglia con
ampio margine. n_sigmas resta il parametro del sotto-passo
sigma-clipping interno (quante sigma oltre cui un vicino viene escluso
nell'iterazione, non la soglia finale).
LA MOLLA (JSD): la soglia stessa non e' fissa. Ad ogni punto si confronta
la finestra locale (raggio radius, la stessa usata dai 4 metodi) con
una finestra di riferimento piu' ampia (raggio radius * ref_mult,
default ref_mult=3 -- la stessa costante di healing_filter.py in questo
pacchetto) via la divergenza di Jensen-Shannon (_jensen_shannon, 0..1).
Se le due distribuzioni sono simili (rumore stazionario, nessuna vera
transizione in corso), JSD~0 e la soglia resta soglia_pressione. Se
sono molto diverse (una transizione di regime vera e' in corso: la
finestra locale, piu' vicina al nuovo livello, non somiglia piu' alla
finestra di riferimento, ancora dominata dal vecchio), JSD sale e la
soglia si allarga proporzionalmente:
soglia_effettiva[i] = soglia_pressione * (1 + k_molla * JSD[i])
"la molla cede" -- lo scudo diventa meno nervoso proprio nei punti dove
un cambiamento genuino e' piu' plausibile, senza mai smettere di
giudicare in binario (anomalia sopra soglia_effettiva, nient'altro).
_jensen_shannon usa gia' n_bins adattivo e smoothing di Laplace (vedi il suo docstring) apposta per questo uso: su finestre piccole, una JSD "ingenua" (bin fissi, epsilon quasi-zero) risultava PIU' alta su rumore stazionario puro che vicino a una vera transizione -- la molla si sarebbe attivata ovunque, non "solo dove serve". Con la versione corretta, verificato su rumore stazionario a bassa ampiezza vs un vero gradino 1.0->5.0 (radius=10, k_molla=3.0): soglia_effettiva media ~8.3 sul rumore stazionario, ~8.7-8.8 in prossimita' della transizione (piu' alta, direzione giusta) -- comunque un segnale rumoroso per natura (non separa mai perfettamente le due code), ma la soglia puo' solo ALLARGARSI (JSD >= 0), mai restringersi sotto soglia_pressione: anche nel caso peggiore (molla che si attiva "a sproposito" su rumore), il risultato e' uno scudo leggermente piu' permissivo, mai piu' nervoso.
Nessuna configurazione richiesta dal chiamante oltre i default: pensato
per un contesto dove chi legge il risultato non deve scegliere pesi o
soglie per metodo, solo leggere anomalie/pressione. Verificato
(test/test_robust_filters.py): pochi falsi positivi su rumore
gaussiano puro, rileva un vero outlier con pressione ben sopra soglia,
non tocca un gradino genuino sostenuto.
LIMITE NOTO, ORA RISOLTO -- mascheramento reciproco tra outlier ravvicinati su una baseline quasi piatta (test/test_pressure_valve_vs_ orca_experiment.py, scenario "impulsi alternati" +/-500 su fondo costante 120, zero rumore reale): la finestra locale usata per ogni stima ESCLUDE gia' il punto i stesso (fix applicato qui sotto), ma se altri outlier della stessa esplosione restano nella finestra (qui: raggio 10, 3 picchi entro 2 passi l'uno dall'altro), mediana/IQR e mediana/MAD restano comunque ESATTAMENTE 0 -- non e' un bug della loro implementazione, e' matematicamente corretto: con 18/20 punti della finestra identici, MAD e IQR (breakdown ~50%/~25%) non vedono niente di anomalo. Un M-estimator di Huber (IRLS, vedi Sung et al. 2019, arXiv:1912.04982, usato li' per spettroscopia a 2 qubit con outlier sperimentali) e' stato provato come alternativa e converge ANCH'ESSO a scala 0, per lo stesso motivo di fondo (la sua scala finale e' a sua volta una MAD dei residui). Letto direttamente il paper (non assunto dalla formula): la loro tecnica robusta funziona perche' la scala sigma^2=var(O_tau) viene stimata da MISURE RIPETUTE sullo STESSO punto (rumore di shot, M ripetizioni proiettive), un asse dati ortogonale alla finestra temporale -- non disponibile qui (una singola serie scalare, nessuna ripetizione per punto), quindi la loro soluzione non e' portabile cosi' com'e'.
Provata anche una scala di fallback dalla finestra di riferimento piu' ampia (radius*ref_mult): VERIFICATA NON FUNZIONARE, e per un motivo strutturale, non un errore di implementazione -- allargare la finestra riduce sempre la FRAZIONE di outlier rispetto al totale (3 punti su 21 locali = 14%, 3 su 61 di riferimento = 5%), quindi non puo' mai far superare a IQR/MAD (stimatori a breakdown frazionario fisso) la soglia di rottura che li fa collassare a zero: verificato numericamente, entrambe le scale restavano 0.0 anche sulla finestra di riferimento.
LA VERA CORREZIONE: quando IQR o MAD collassano davvero a zero (finestra locale genuinamente omogenea) ma il punto i si scosta comunque dalla mediana locale, quello NON e' "nessuna informazione" da scartare -- e' la prova piu' forte possibile (deviazione reale contro dispersione locale nulla). Il bug era scartare questa evidenza insieme allo stimatore degenere. Ora: scala-locale-nulla + deviazione reale => pressione forzata a infinito (anomalia certa), bypassando la combinazione pesata per quel punto. Verificato: sullo scenario reale "impulsi alternati" (+/-500 su fondo 120 senza rumore), prima del fix pressione ai 3 picchi = 3.16/5.19/3.16 (sotto soglia 8.0, zero anomalie, RMSE=86.6); dopo il fix, tutti e 3 i picchi rilevati, RMSE=0.0. Nessuna regressione: 223/223 test della suite passano, incluso il tasso di falsi positivi su rumore gaussiano puro (invariato) e tutti gli altri 6 scenari del confronto a tre vie.
Ritorna (pulito, anomalie, pressione, soglia_effettiva): pressione e'
la deviazione combinata continua per ogni punto, `soglia_effettiva' la
soglia realmente applicata in quel punto (>= soglia_pressione, si vede
dove/quanto la molla ha ceduto).
Source code in dense_armor/utility/anomaly/robust_filters.py
220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 | |
Details¶
-
Why the base threshold is 8.0. On pure stationary noise (N = 300, fixed seed) the pressure percentiles are 50% = 1.4, 95% = 4.6, 99% = 6.0, 99.9% = 7.6. A base threshold of 8.0 sits above the 99.9th percentile, so on average fewer than one sample in 300 crosses it. A true outlier reaches a pressure well above the base threshold: the collision in the example above, for instance, has pressure 42.7.
-
Why Chauvenet keeps its non-robust scale. The Chauvenet rule is used here in its original 1863 formulation, with the mean and the standard deviation of the window. When the window contains an outlier, that scale inflates, and the minimum-variance weights automatically lower Chauvenet's influence. Replacing Chauvenet's mean and standard deviation with a robust centre and scale would change the rule; keeping the original and letting the weighting handle the outlier is the choice made here.
-
Jensen–Shannon on small windows.
_jensen_shannonuses adaptive bins and Laplace smoothing. A naive version with fixed bins gave a higher divergence on stationary noise than on a real transition, the opposite of what the threshold formula needs: the threshold would rise on noise and would not rise near a transition. Adaptive bins fix this by sizing the histogram to the range the window actually covers. -
Sources. Pearson, R. K. et al. (2016), "Generalized Hampel filters", EURASIP J. Adv. Signal Process., eqs. 3–4. Chauvenet, W. (1863), A Manual of Spherical and Practical Astronomy, vol. 2. Tukey, J. W. (1977), Exploratory Data Analysis.