quantumflow-sdk 0.2.1__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,521 @@
1
+ """
2
+ Anomaly Detection for Data Pipelines.
3
+
4
+ Detects:
5
+ - Gradient explosion (>threshold) and vanishing (<threshold)
6
+ - NaN/Inf values
7
+ - Statistical deviations (moving average, z-score)
8
+ - Domain-specific anomalies (energy spikes, RMSD divergence, VaR breach)
9
+
10
+ Example:
11
+ detector = AnomalyDetector()
12
+
13
+ # Add custom detector
14
+ detector.register_detector("my_detector", my_detector_fn)
15
+
16
+ # Detect anomalies
17
+ result = detector.detect(state, step=100)
18
+ if result.is_anomaly:
19
+ print(f"Anomaly: {result.anomaly_type}")
20
+ """
21
+
22
+ import math
23
+ import logging
24
+ from dataclasses import dataclass, field
25
+ from typing import Any, Callable, Dict, List, Optional, Tuple
26
+ from enum import Enum
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class AnomalyType(str, Enum):
32
+ """Types of anomalies that can be detected."""
33
+
34
+ GRADIENT_EXPLOSION = "gradient_explosion"
35
+ GRADIENT_VANISHING = "gradient_vanishing"
36
+ NAN_DETECTED = "nan_detected"
37
+ INF_DETECTED = "inf_detected"
38
+ ENERGY_SPIKE = "energy_spike"
39
+ RMSD_DIVERGENCE = "rmsd_divergence"
40
+ VAR_BREACH = "var_breach"
41
+ DRAWDOWN_BREACH = "drawdown_breach"
42
+ STATISTICAL_DEVIATION = "statistical_deviation"
43
+ CUSTOM = "custom"
44
+
45
+
46
+ class AnomalySeverity(str, Enum):
47
+ """Severity levels for anomalies."""
48
+
49
+ INFO = "info"
50
+ WARNING = "warning"
51
+ CRITICAL = "critical"
52
+
53
+
54
+ @dataclass
55
+ class AnomalyResult:
56
+ """Result of anomaly detection."""
57
+
58
+ is_anomaly: bool
59
+ anomaly_type: Optional[str] = None
60
+ severity: str = "warning"
61
+ message: str = ""
62
+ step: int = 0
63
+ detector_name: Optional[str] = None
64
+ threshold: Optional[float] = None
65
+ actual_value: Optional[float] = None
66
+ data: Dict[str, Any] = field(default_factory=dict)
67
+
68
+ def to_dict(self) -> Dict[str, Any]:
69
+ """Convert to dictionary."""
70
+ return {
71
+ "is_anomaly": self.is_anomaly,
72
+ "anomaly_type": self.anomaly_type,
73
+ "severity": self.severity,
74
+ "message": self.message,
75
+ "step": self.step,
76
+ "detector_name": self.detector_name,
77
+ "threshold": self.threshold,
78
+ "actual_value": self.actual_value,
79
+ "data": self.data,
80
+ }
81
+
82
+
83
+ # Type for custom detector functions
84
+ DetectorFn = Callable[["PipelineState", int, Dict[str, Any]], Optional[AnomalyResult]]
85
+
86
+
87
+ class AnomalyDetector:
88
+ """
89
+ Detects anomalies in pipeline execution.
90
+
91
+ Built-in detectors:
92
+ - Gradient explosion/vanishing
93
+ - NaN/Inf detection
94
+ - Statistical deviation (moving average, z-score)
95
+
96
+ Supports custom detector registration for domain-specific checks.
97
+ """
98
+
99
+ def __init__(
100
+ self,
101
+ window_size: int = 50,
102
+ z_score_threshold: float = 3.0,
103
+ moving_avg_deviation: float = 2.0,
104
+ ):
105
+ """
106
+ Initialize anomaly detector.
107
+
108
+ Args:
109
+ window_size: Window for moving statistics
110
+ z_score_threshold: Z-score threshold for statistical anomalies
111
+ moving_avg_deviation: Multiplier for moving average deviation
112
+ """
113
+ self.window_size = window_size
114
+ self.z_score_threshold = z_score_threshold
115
+ self.moving_avg_deviation = moving_avg_deviation
116
+
117
+ # Custom detectors
118
+ self._custom_detectors: Dict[str, DetectorFn] = {}
119
+
120
+ # History for statistical analysis
121
+ self._metric_history: Dict[str, List[float]] = {}
122
+
123
+ def register_detector(self, name: str, detector_fn: DetectorFn):
124
+ """
125
+ Register a custom anomaly detector.
126
+
127
+ Args:
128
+ name: Detector name
129
+ detector_fn: Function(state, step, config) -> Optional[AnomalyResult]
130
+ """
131
+ self._custom_detectors[name] = detector_fn
132
+ logger.info(f"Registered custom detector: {name}")
133
+
134
+ def unregister_detector(self, name: str):
135
+ """Unregister a custom detector."""
136
+ if name in self._custom_detectors:
137
+ del self._custom_detectors[name]
138
+
139
+ def detect(
140
+ self,
141
+ state: "PipelineState",
142
+ step: int,
143
+ gradient_threshold: float = 100.0,
144
+ vanishing_threshold: float = 1e-7,
145
+ config: Optional[Dict[str, Any]] = None,
146
+ ) -> Optional[AnomalyResult]:
147
+ """
148
+ Run all anomaly detectors on current state.
149
+
150
+ Args:
151
+ state: Current pipeline state
152
+ step: Current step number
153
+ gradient_threshold: Threshold for gradient explosion
154
+ vanishing_threshold: Threshold for vanishing gradients
155
+ config: Additional configuration for detectors
156
+
157
+ Returns:
158
+ AnomalyResult if anomaly detected, None otherwise
159
+ """
160
+ config = config or {}
161
+
162
+ # Check NaN/Inf first (most critical)
163
+ result = self._check_nan_inf(state, step)
164
+ if result:
165
+ return result
166
+
167
+ # Check gradients
168
+ result = self._check_gradients(
169
+ state, step, gradient_threshold, vanishing_threshold
170
+ )
171
+ if result:
172
+ return result
173
+
174
+ # Check statistical deviation
175
+ result = self._check_statistical(state, step)
176
+ if result:
177
+ return result
178
+
179
+ # Run custom detectors
180
+ for name, detector_fn in self._custom_detectors.items():
181
+ try:
182
+ result = detector_fn(state, step, config)
183
+ if result and result.is_anomaly:
184
+ result.detector_name = name
185
+ return result
186
+ except Exception as e:
187
+ logger.warning(f"Custom detector {name} failed: {e}")
188
+
189
+ return None
190
+
191
+ def _check_nan_inf(self, state: "PipelineState", step: int) -> Optional[AnomalyResult]:
192
+ """Check for NaN or Inf values in state."""
193
+ values_to_check = []
194
+
195
+ # Check metrics
196
+ for name, value in state.metrics.items():
197
+ if isinstance(value, (int, float)):
198
+ values_to_check.append((f"metrics.{name}", value))
199
+
200
+ # Check gradients
201
+ for i, grad in enumerate(state.gradient_history[-10:]):
202
+ values_to_check.append((f"gradient[{i}]", grad))
203
+
204
+ # Check weights
205
+ if state.weights:
206
+ for i, w in enumerate(state.weights[:100]): # Check first 100
207
+ values_to_check.append((f"weight[{i}]", w))
208
+
209
+ # Detect NaN
210
+ for name, value in values_to_check:
211
+ if isinstance(value, float) and math.isnan(value):
212
+ return AnomalyResult(
213
+ is_anomaly=True,
214
+ anomaly_type=AnomalyType.NAN_DETECTED.value,
215
+ severity=AnomalySeverity.CRITICAL.value,
216
+ message=f"NaN detected in {name}",
217
+ step=step,
218
+ detector_name="nan_inf_detector",
219
+ actual_value=value,
220
+ data={"location": name},
221
+ )
222
+
223
+ # Detect Inf
224
+ for name, value in values_to_check:
225
+ if isinstance(value, float) and math.isinf(value):
226
+ return AnomalyResult(
227
+ is_anomaly=True,
228
+ anomaly_type=AnomalyType.INF_DETECTED.value,
229
+ severity=AnomalySeverity.CRITICAL.value,
230
+ message=f"Inf detected in {name}",
231
+ step=step,
232
+ detector_name="nan_inf_detector",
233
+ actual_value=value,
234
+ data={"location": name},
235
+ )
236
+
237
+ return None
238
+
239
+ def _check_gradients(
240
+ self,
241
+ state: "PipelineState",
242
+ step: int,
243
+ explosion_threshold: float,
244
+ vanishing_threshold: float,
245
+ ) -> Optional[AnomalyResult]:
246
+ """Check for gradient explosion or vanishing."""
247
+ if not state.gradient_history:
248
+ return None
249
+
250
+ # Get recent gradient magnitude
251
+ recent_grads = state.gradient_history[-10:]
252
+ if not recent_grads:
253
+ return None
254
+
255
+ # Compute gradient norm (average magnitude)
256
+ grad_norm = sum(abs(g) for g in recent_grads) / len(recent_grads)
257
+
258
+ # Check explosion
259
+ if grad_norm > explosion_threshold:
260
+ return AnomalyResult(
261
+ is_anomaly=True,
262
+ anomaly_type=AnomalyType.GRADIENT_EXPLOSION.value,
263
+ severity=AnomalySeverity.CRITICAL.value,
264
+ message=f"Gradient explosion detected: {grad_norm:.2e} > {explosion_threshold}",
265
+ step=step,
266
+ detector_name="gradient_detector",
267
+ threshold=explosion_threshold,
268
+ actual_value=grad_norm,
269
+ data={"recent_gradients": recent_grads},
270
+ )
271
+
272
+ # Check vanishing
273
+ if grad_norm < vanishing_threshold and grad_norm > 0:
274
+ return AnomalyResult(
275
+ is_anomaly=True,
276
+ anomaly_type=AnomalyType.GRADIENT_VANISHING.value,
277
+ severity=AnomalySeverity.WARNING.value,
278
+ message=f"Vanishing gradient detected: {grad_norm:.2e} < {vanishing_threshold}",
279
+ step=step,
280
+ detector_name="gradient_detector",
281
+ threshold=vanishing_threshold,
282
+ actual_value=grad_norm,
283
+ data={"recent_gradients": recent_grads},
284
+ )
285
+
286
+ return None
287
+
288
+ def _check_statistical(
289
+ self, state: "PipelineState", step: int
290
+ ) -> Optional[AnomalyResult]:
291
+ """Check for statistical deviations in metrics."""
292
+ for metric_name, value in state.metrics.items():
293
+ if not isinstance(value, (int, float)):
294
+ continue
295
+
296
+ # Update history
297
+ if metric_name not in self._metric_history:
298
+ self._metric_history[metric_name] = []
299
+
300
+ history = self._metric_history[metric_name]
301
+ history.append(value)
302
+
303
+ # Keep window size
304
+ if len(history) > self.window_size:
305
+ history.pop(0)
306
+
307
+ # Need enough history for statistics
308
+ if len(history) < 10:
309
+ continue
310
+
311
+ # Compute statistics
312
+ mean = sum(history) / len(history)
313
+ variance = sum((x - mean) ** 2 for x in history) / len(history)
314
+ std = math.sqrt(variance) if variance > 0 else 0
315
+
316
+ if std == 0:
317
+ continue
318
+
319
+ # Z-score check
320
+ z_score = abs(value - mean) / std
321
+
322
+ if z_score > self.z_score_threshold:
323
+ return AnomalyResult(
324
+ is_anomaly=True,
325
+ anomaly_type=AnomalyType.STATISTICAL_DEVIATION.value,
326
+ severity=AnomalySeverity.WARNING.value,
327
+ message=f"Statistical anomaly in {metric_name}: z-score={z_score:.2f}",
328
+ step=step,
329
+ detector_name="statistical_detector",
330
+ threshold=self.z_score_threshold,
331
+ actual_value=z_score,
332
+ data={
333
+ "metric_name": metric_name,
334
+ "metric_value": value,
335
+ "mean": mean,
336
+ "std": std,
337
+ "z_score": z_score,
338
+ },
339
+ )
340
+
341
+ return None
342
+
343
+ def clear_history(self):
344
+ """Clear metric history."""
345
+ self._metric_history.clear()
346
+
347
+
348
+ # ============================================================
349
+ # Domain-Specific Detector Factories
350
+ # ============================================================
351
+
352
+
353
+ def create_energy_spike_detector(
354
+ threshold_multiplier: float = 5.0,
355
+ ) -> DetectorFn:
356
+ """
357
+ Create detector for energy spikes (healthcare/chemistry).
358
+
359
+ Args:
360
+ threshold_multiplier: Multiplier for energy spike detection
361
+
362
+ Returns:
363
+ Detector function
364
+ """
365
+ energy_history: List[float] = []
366
+
367
+ def detector(state: "PipelineState", step: int, config: Dict[str, Any]) -> Optional[AnomalyResult]:
368
+ energy = state.metrics.get("energy") or state.metrics.get("ground_energy")
369
+
370
+ if energy is None:
371
+ return None
372
+
373
+ energy_history.append(energy)
374
+ if len(energy_history) > 100:
375
+ energy_history.pop(0)
376
+
377
+ if len(energy_history) < 5:
378
+ return None
379
+
380
+ # Check for sudden spike
381
+ recent_mean = sum(energy_history[-10:-1]) / (len(energy_history[-10:-1]) or 1)
382
+ if recent_mean == 0:
383
+ return None
384
+
385
+ spike_ratio = abs(energy - recent_mean) / abs(recent_mean)
386
+
387
+ if spike_ratio > threshold_multiplier:
388
+ return AnomalyResult(
389
+ is_anomaly=True,
390
+ anomaly_type=AnomalyType.ENERGY_SPIKE.value,
391
+ severity=AnomalySeverity.CRITICAL.value,
392
+ message=f"Energy spike detected: {spike_ratio:.2f}x above recent average",
393
+ step=step,
394
+ threshold=threshold_multiplier,
395
+ actual_value=spike_ratio,
396
+ data={"energy": energy, "recent_mean": recent_mean},
397
+ )
398
+
399
+ return None
400
+
401
+ return detector
402
+
403
+
404
+ def create_rmsd_detector(max_rmsd: float = 10.0) -> DetectorFn:
405
+ """
406
+ Create detector for RMSD divergence (protein folding).
407
+
408
+ Args:
409
+ max_rmsd: Maximum allowed RMSD in Angstroms
410
+
411
+ Returns:
412
+ Detector function
413
+ """
414
+
415
+ def detector(state: "PipelineState", step: int, config: Dict[str, Any]) -> Optional[AnomalyResult]:
416
+ rmsd = state.metrics.get("rmsd")
417
+
418
+ if rmsd is None:
419
+ return None
420
+
421
+ if rmsd > max_rmsd:
422
+ return AnomalyResult(
423
+ is_anomaly=True,
424
+ anomaly_type=AnomalyType.RMSD_DIVERGENCE.value,
425
+ severity=AnomalySeverity.CRITICAL.value,
426
+ message=f"RMSD divergence: {rmsd:.2f}Å > {max_rmsd}Å threshold",
427
+ step=step,
428
+ threshold=max_rmsd,
429
+ actual_value=rmsd,
430
+ data={"rmsd": rmsd},
431
+ )
432
+
433
+ return None
434
+
435
+ return detector
436
+
437
+
438
+ def create_var_detector(max_var_breach: float = 0.05) -> DetectorFn:
439
+ """
440
+ Create detector for Value-at-Risk breach (finance).
441
+
442
+ Args:
443
+ max_var_breach: Maximum VaR breach percentage (e.g., 0.05 = 5%)
444
+
445
+ Returns:
446
+ Detector function
447
+ """
448
+
449
+ def detector(state: "PipelineState", step: int, config: Dict[str, Any]) -> Optional[AnomalyResult]:
450
+ var = state.metrics.get("var") or state.metrics.get("value_at_risk")
451
+ portfolio_value = state.metrics.get("portfolio_value", 1.0)
452
+
453
+ if var is None:
454
+ return None
455
+
456
+ var_ratio = abs(var) / portfolio_value if portfolio_value > 0 else 0
457
+
458
+ if var_ratio > max_var_breach:
459
+ return AnomalyResult(
460
+ is_anomaly=True,
461
+ anomaly_type=AnomalyType.VAR_BREACH.value,
462
+ severity=AnomalySeverity.CRITICAL.value,
463
+ message=f"VaR breach: {var_ratio:.2%} > {max_var_breach:.2%} threshold",
464
+ step=step,
465
+ threshold=max_var_breach,
466
+ actual_value=var_ratio,
467
+ data={"var": var, "portfolio_value": portfolio_value},
468
+ )
469
+
470
+ return None
471
+
472
+ return detector
473
+
474
+
475
+ def create_drawdown_detector(max_drawdown: float = 0.20) -> DetectorFn:
476
+ """
477
+ Create detector for maximum drawdown breach (finance).
478
+
479
+ Args:
480
+ max_drawdown: Maximum allowed drawdown (e.g., 0.20 = 20%)
481
+
482
+ Returns:
483
+ Detector function
484
+ """
485
+ peak_value: List[float] = [0.0]
486
+
487
+ def detector(state: "PipelineState", step: int, config: Dict[str, Any]) -> Optional[AnomalyResult]:
488
+ portfolio_value = state.metrics.get("portfolio_value")
489
+
490
+ if portfolio_value is None:
491
+ return None
492
+
493
+ # Update peak
494
+ if portfolio_value > peak_value[0]:
495
+ peak_value[0] = portfolio_value
496
+
497
+ if peak_value[0] == 0:
498
+ return None
499
+
500
+ # Calculate drawdown
501
+ drawdown = (peak_value[0] - portfolio_value) / peak_value[0]
502
+
503
+ if drawdown > max_drawdown:
504
+ return AnomalyResult(
505
+ is_anomaly=True,
506
+ anomaly_type=AnomalyType.DRAWDOWN_BREACH.value,
507
+ severity=AnomalySeverity.CRITICAL.value,
508
+ message=f"Drawdown breach: {drawdown:.2%} > {max_drawdown:.2%} threshold",
509
+ step=step,
510
+ threshold=max_drawdown,
511
+ actual_value=drawdown,
512
+ data={
513
+ "current_value": portfolio_value,
514
+ "peak_value": peak_value[0],
515
+ "drawdown": drawdown,
516
+ },
517
+ )
518
+
519
+ return None
520
+
521
+ return detector