Robust filters (standalone detectors)¶
Four classic, low-cost anomaly detectors -- no dynamic model, no state, plain arithmetic on a
centered local window (offline/batch cleanup, not the causal real-time loop
core/hybrid_engine.py uses). pressure_valve combines all four via a
minimum-variance estimator (derived with a Lagrange multiplier) instead of a vote, plus a
Jensen-Shannon-modulated dynamic threshold.
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/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/robust_filters.py
hampel_filter ¶
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/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/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.
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/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 | |