detectkit 0.2.4__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.
- detectkit/__init__.py +17 -0
- detectkit/alerting/__init__.py +13 -0
- detectkit/alerting/channels/__init__.py +21 -0
- detectkit/alerting/channels/base.py +193 -0
- detectkit/alerting/channels/email.py +146 -0
- detectkit/alerting/channels/factory.py +193 -0
- detectkit/alerting/channels/mattermost.py +53 -0
- detectkit/alerting/channels/slack.py +55 -0
- detectkit/alerting/channels/telegram.py +110 -0
- detectkit/alerting/channels/webhook.py +139 -0
- detectkit/alerting/orchestrator.py +369 -0
- detectkit/cli/__init__.py +1 -0
- detectkit/cli/commands/__init__.py +1 -0
- detectkit/cli/commands/init.py +282 -0
- detectkit/cli/commands/run.py +486 -0
- detectkit/cli/commands/test_alert.py +184 -0
- detectkit/cli/main.py +186 -0
- detectkit/config/__init__.py +30 -0
- detectkit/config/metric_config.py +499 -0
- detectkit/config/profile.py +285 -0
- detectkit/config/project_config.py +164 -0
- detectkit/config/validator.py +124 -0
- detectkit/core/__init__.py +6 -0
- detectkit/core/interval.py +132 -0
- detectkit/core/models.py +106 -0
- detectkit/database/__init__.py +27 -0
- detectkit/database/clickhouse_manager.py +393 -0
- detectkit/database/internal_tables.py +724 -0
- detectkit/database/manager.py +324 -0
- detectkit/database/tables.py +138 -0
- detectkit/detectors/__init__.py +6 -0
- detectkit/detectors/base.py +441 -0
- detectkit/detectors/factory.py +138 -0
- detectkit/detectors/statistical/__init__.py +8 -0
- detectkit/detectors/statistical/iqr.py +508 -0
- detectkit/detectors/statistical/mad.py +478 -0
- detectkit/detectors/statistical/manual_bounds.py +206 -0
- detectkit/detectors/statistical/zscore.py +491 -0
- detectkit/loaders/__init__.py +6 -0
- detectkit/loaders/metric_loader.py +470 -0
- detectkit/loaders/query_template.py +164 -0
- detectkit/orchestration/__init__.py +9 -0
- detectkit/orchestration/task_manager.py +746 -0
- detectkit/utils/__init__.py +17 -0
- detectkit/utils/stats.py +196 -0
- detectkit-0.2.4.dist-info/METADATA +237 -0
- detectkit-0.2.4.dist-info/RECORD +51 -0
- detectkit-0.2.4.dist-info/WHEEL +5 -0
- detectkit-0.2.4.dist-info/entry_points.txt +2 -0
- detectkit-0.2.4.dist-info/licenses/LICENSE +21 -0
- detectkit-0.2.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base detector interface for anomaly detection.
|
|
3
|
+
|
|
4
|
+
All detectors must inherit from BaseDetector and implement:
|
|
5
|
+
- _validate_params() - parameter validation
|
|
6
|
+
- detect() - main detection method
|
|
7
|
+
- _get_non_default_params() - for hash generation
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any, Dict, Optional
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import orjson
|
|
19
|
+
HAS_ORJSON = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
import json
|
|
22
|
+
HAS_ORJSON = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def json_dumps_sorted(obj):
|
|
26
|
+
"""JSON dumps with sorted keys - handles both orjson and standard json."""
|
|
27
|
+
if HAS_ORJSON:
|
|
28
|
+
return orjson.dumps(obj, option=orjson.OPT_SORT_KEYS).decode('utf-8')
|
|
29
|
+
else:
|
|
30
|
+
return json.dumps(obj, sort_keys=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DetectionResult:
|
|
35
|
+
"""
|
|
36
|
+
Result of anomaly detection for a single data point.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
timestamp: Data point timestamp
|
|
40
|
+
value: Actual metric value (ALWAYS original value)
|
|
41
|
+
processed_value: Value analyzed by detector (may be smoothed/transformed)
|
|
42
|
+
is_anomaly: Whether point is anomalous
|
|
43
|
+
confidence_lower: Lower bound of confidence interval (for processed_value)
|
|
44
|
+
confidence_upper: Upper bound of confidence interval (for processed_value)
|
|
45
|
+
detection_metadata: Additional metadata (severity, direction, etc.)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
timestamp: np.datetime64
|
|
49
|
+
value: float
|
|
50
|
+
processed_value: float
|
|
51
|
+
is_anomaly: bool
|
|
52
|
+
confidence_lower: Optional[float] = None
|
|
53
|
+
confidence_upper: Optional[float] = None
|
|
54
|
+
detection_metadata: Optional[Dict[str, Any]] = None
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
57
|
+
"""Convert to dictionary for database storage."""
|
|
58
|
+
return {
|
|
59
|
+
"timestamp": self.timestamp,
|
|
60
|
+
"value": self.value,
|
|
61
|
+
"processed_value": self.processed_value,
|
|
62
|
+
"is_anomaly": self.is_anomaly,
|
|
63
|
+
"confidence_lower": self.confidence_lower,
|
|
64
|
+
"confidence_upper": self.confidence_upper,
|
|
65
|
+
"detection_metadata": json_dumps_sorted(self.detection_metadata or {}),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BaseDetector(ABC):
|
|
70
|
+
"""
|
|
71
|
+
Abstract base class for anomaly detectors.
|
|
72
|
+
|
|
73
|
+
All detectors must:
|
|
74
|
+
1. Validate parameters in _validate_params()
|
|
75
|
+
2. Implement detect() to return DetectionResult for each point
|
|
76
|
+
3. Implement _get_non_default_params() for hash generation
|
|
77
|
+
|
|
78
|
+
The detector_id (hash) is used for:
|
|
79
|
+
- Storing detections in _dtk_detections table
|
|
80
|
+
- Task locking in _dtk_tasks table
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
>>> class MyDetector(BaseDetector):
|
|
84
|
+
... def __init__(self, threshold: float = 3.0):
|
|
85
|
+
... super().__init__(threshold=threshold)
|
|
86
|
+
...
|
|
87
|
+
... def _validate_params(self):
|
|
88
|
+
... if self.params["threshold"] <= 0:
|
|
89
|
+
... raise ValueError("threshold must be positive")
|
|
90
|
+
...
|
|
91
|
+
... def detect(self, data):
|
|
92
|
+
... # Detection logic here
|
|
93
|
+
... pass
|
|
94
|
+
...
|
|
95
|
+
... def _get_non_default_params(self):
|
|
96
|
+
... defaults = {"threshold": 3.0}
|
|
97
|
+
... return {k: v for k, v in self.params.items() if v != defaults.get(k)}
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(self, **params):
|
|
101
|
+
"""
|
|
102
|
+
Initialize detector with parameters.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
**params: Detector-specific parameters
|
|
106
|
+
"""
|
|
107
|
+
self.params = params
|
|
108
|
+
self._validate_params()
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def _validate_params(self):
|
|
112
|
+
"""
|
|
113
|
+
Validate detector parameters.
|
|
114
|
+
|
|
115
|
+
Should raise ValueError if parameters are invalid.
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
>>> def _validate_params(self):
|
|
119
|
+
... if self.params.get("threshold", 0) <= 0:
|
|
120
|
+
... raise ValueError("threshold must be positive")
|
|
121
|
+
"""
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
def detect(self, data: Dict[str, np.ndarray]) -> list[DetectionResult]:
|
|
126
|
+
"""
|
|
127
|
+
Perform anomaly detection on metric data.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
data: Dictionary from MetricLoader.load() with keys:
|
|
131
|
+
- timestamp: np.array of datetime64[ms]
|
|
132
|
+
- value: np.array of float64 (may contain NaN for missing data)
|
|
133
|
+
- seasonality_data: np.array of JSON strings
|
|
134
|
+
- seasonality_columns: list of column names
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
List of DetectionResult for each data point
|
|
138
|
+
|
|
139
|
+
Notes:
|
|
140
|
+
- Handle NaN values appropriately (missing data)
|
|
141
|
+
- Use seasonality_data if detector supports it
|
|
142
|
+
- confidence_lower/upper are optional (only if detector provides them)
|
|
143
|
+
- detection_metadata can include: severity, direction, missing_ratio, etc.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
>>> results = detector.detect(data)
|
|
147
|
+
>>> for result in results:
|
|
148
|
+
... if result.is_anomaly:
|
|
149
|
+
... print(f"Anomaly at {result.timestamp}: {result.value}")
|
|
150
|
+
"""
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
def get_detector_id(self) -> str:
|
|
154
|
+
"""
|
|
155
|
+
Generate unique detector ID (hash).
|
|
156
|
+
|
|
157
|
+
Hash is based on:
|
|
158
|
+
- Detector class name
|
|
159
|
+
- Non-default parameters (sorted)
|
|
160
|
+
|
|
161
|
+
This ensures:
|
|
162
|
+
- Same detector with same params = same ID
|
|
163
|
+
- Different params = different ID (allows parallel runs)
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
16-character hex string (first 16 chars of SHA256)
|
|
167
|
+
|
|
168
|
+
Example:
|
|
169
|
+
>>> detector1 = MADDetector(threshold=3.0)
|
|
170
|
+
>>> detector2 = MADDetector(threshold=3.0)
|
|
171
|
+
>>> detector1.get_detector_id() == detector2.get_detector_id()
|
|
172
|
+
True
|
|
173
|
+
>>> detector3 = MADDetector(threshold=2.5)
|
|
174
|
+
>>> detector1.get_detector_id() != detector3.get_detector_id()
|
|
175
|
+
True
|
|
176
|
+
"""
|
|
177
|
+
non_default_params = self._get_non_default_params()
|
|
178
|
+
sorted_params = sorted(non_default_params.items())
|
|
179
|
+
hash_string = self.__class__.__name__ + str(sorted_params)
|
|
180
|
+
return hashlib.sha256(hash_string.encode()).hexdigest()[:16]
|
|
181
|
+
|
|
182
|
+
def get_detector_params(self) -> str:
|
|
183
|
+
"""
|
|
184
|
+
Get detector parameters as JSON string.
|
|
185
|
+
|
|
186
|
+
Returns JSON with sorted keys for consistency.
|
|
187
|
+
Used for storing in _dtk_detections.detector_params.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
JSON string with sorted parameters
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
>>> detector = MADDetector(threshold=3.0, min_samples=30)
|
|
194
|
+
>>> detector.get_detector_params()
|
|
195
|
+
'{"min_samples": 30, "threshold": 3.0}'
|
|
196
|
+
"""
|
|
197
|
+
non_default_params = self._get_non_default_params()
|
|
198
|
+
return json_dumps_sorted(non_default_params)
|
|
199
|
+
|
|
200
|
+
@abstractmethod
|
|
201
|
+
def _get_non_default_params(self) -> Dict[str, Any]:
|
|
202
|
+
"""
|
|
203
|
+
Get parameters that differ from defaults.
|
|
204
|
+
|
|
205
|
+
Used for hash generation and parameter storage.
|
|
206
|
+
Only non-default parameters are included to ensure
|
|
207
|
+
consistent hashing across different instantiations.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Dictionary of non-default parameters
|
|
211
|
+
|
|
212
|
+
Example:
|
|
213
|
+
>>> def _get_non_default_params(self):
|
|
214
|
+
... defaults = {"threshold": 3.0, "min_samples": 30}
|
|
215
|
+
... return {
|
|
216
|
+
... k: v for k, v in self.params.items()
|
|
217
|
+
... if v != defaults.get(k)
|
|
218
|
+
... }
|
|
219
|
+
"""
|
|
220
|
+
pass
|
|
221
|
+
|
|
222
|
+
def __repr__(self) -> str:
|
|
223
|
+
"""String representation of detector."""
|
|
224
|
+
params_str = ", ".join(f"{k}={v}" for k, v in self.params.items())
|
|
225
|
+
return f"{self.__class__.__name__}({params_str})"
|
|
226
|
+
|
|
227
|
+
def get_context_size(self) -> int:
|
|
228
|
+
"""
|
|
229
|
+
Get number of historical points needed for detection.
|
|
230
|
+
|
|
231
|
+
Used by task_manager to determine how many points to load
|
|
232
|
+
when resuming from last_processed_timestamp (idempotency).
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Number of historical points needed (0 = no context needed)
|
|
236
|
+
|
|
237
|
+
Example:
|
|
238
|
+
- Manual Bounds without input_type: 0 (each point is independent)
|
|
239
|
+
- Manual Bounds with input_type=changes: 1 (need previous point)
|
|
240
|
+
- MAD with window_size=100: 100 (need 100 points for statistics)
|
|
241
|
+
- MAD with window_size=100 and input_type=changes: 100 (already covered)
|
|
242
|
+
"""
|
|
243
|
+
context = 0
|
|
244
|
+
|
|
245
|
+
# If detector uses a window (MAD, Z-Score, IQR)
|
|
246
|
+
window_size = self.params.get("window_size")
|
|
247
|
+
if window_size is not None:
|
|
248
|
+
context = window_size
|
|
249
|
+
|
|
250
|
+
# If input_type requires previous points
|
|
251
|
+
input_type = self.params.get("input_type", "values")
|
|
252
|
+
if input_type in ["changes", "absolute_changes", "log_changes"]:
|
|
253
|
+
# Need at least 1 previous point for computing changes
|
|
254
|
+
context = max(context, 1)
|
|
255
|
+
|
|
256
|
+
return context
|
|
257
|
+
|
|
258
|
+
def _preprocess_input(self, values: np.ndarray) -> np.ndarray:
|
|
259
|
+
"""
|
|
260
|
+
Preprocess input values based on input_type parameter.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
values: Original metric values
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
Processed values (may be changes, absolute changes, etc.)
|
|
267
|
+
|
|
268
|
+
Supported input_type values:
|
|
269
|
+
- "values": No transformation (default)
|
|
270
|
+
- "changes": Relative change (v[t] - v[t-1]) / v[t-1]
|
|
271
|
+
- "absolute_changes": Absolute change v[t] - v[t-1]
|
|
272
|
+
- "log_changes": Log change log(v[t]) - log(v[t-1])
|
|
273
|
+
|
|
274
|
+
Note:
|
|
275
|
+
First value has no previous point, so it's set to NaN for changes.
|
|
276
|
+
"""
|
|
277
|
+
input_type = self.params.get("input_type", "values")
|
|
278
|
+
|
|
279
|
+
if input_type == "values":
|
|
280
|
+
return values
|
|
281
|
+
|
|
282
|
+
elif input_type == "changes":
|
|
283
|
+
# Relative change
|
|
284
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
285
|
+
changes = np.diff(values) / values[:-1]
|
|
286
|
+
# First point has no previous value
|
|
287
|
+
return np.concatenate([[np.nan], changes])
|
|
288
|
+
|
|
289
|
+
elif input_type == "absolute_changes":
|
|
290
|
+
# Absolute change
|
|
291
|
+
changes = np.diff(values)
|
|
292
|
+
return np.concatenate([[np.nan], changes])
|
|
293
|
+
|
|
294
|
+
elif input_type == "log_changes":
|
|
295
|
+
# Logarithmic change (good for exponential growth)
|
|
296
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
297
|
+
log_changes = np.diff(np.log(values + 1)) # +1 to handle zeros
|
|
298
|
+
return np.concatenate([[np.nan], log_changes])
|
|
299
|
+
|
|
300
|
+
else:
|
|
301
|
+
raise ValueError(
|
|
302
|
+
f"Unknown input_type: {input_type}. "
|
|
303
|
+
f"Supported values: values, changes, absolute_changes, log_changes"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def _apply_smoothing(self, values: np.ndarray) -> np.ndarray:
|
|
307
|
+
"""
|
|
308
|
+
Apply smoothing to values to reduce noise.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
values: Input values
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Smoothed values (same length as input)
|
|
315
|
+
|
|
316
|
+
Supported smoothing methods:
|
|
317
|
+
- None: No smoothing (default)
|
|
318
|
+
- "ema": Exponential Moving Average
|
|
319
|
+
- "sma": Simple Moving Average
|
|
320
|
+
"""
|
|
321
|
+
smoothing = self.params.get("smoothing")
|
|
322
|
+
|
|
323
|
+
if smoothing is None:
|
|
324
|
+
return values
|
|
325
|
+
|
|
326
|
+
elif smoothing == "ema":
|
|
327
|
+
alpha = self.params.get("smoothing_alpha", 0.3)
|
|
328
|
+
return self._compute_ema(values, alpha)
|
|
329
|
+
|
|
330
|
+
elif smoothing == "sma":
|
|
331
|
+
window = self.params.get("smoothing_window", 10)
|
|
332
|
+
return self._compute_sma(values, window)
|
|
333
|
+
|
|
334
|
+
else:
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"Unknown smoothing method: {smoothing}. "
|
|
337
|
+
f"Supported methods: ema, sma"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def _compute_ema(self, values: np.ndarray, alpha: float) -> np.ndarray:
|
|
341
|
+
"""
|
|
342
|
+
Compute Exponential Moving Average.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
values: Input values
|
|
346
|
+
alpha: Smoothing factor (0 < alpha <= 1)
|
|
347
|
+
- Higher alpha = more weight to recent values
|
|
348
|
+
- Lower alpha = smoother (more historical weight)
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Smoothed values
|
|
352
|
+
|
|
353
|
+
Formula:
|
|
354
|
+
ema[0] = values[0]
|
|
355
|
+
ema[t] = alpha * values[t] + (1 - alpha) * ema[t-1]
|
|
356
|
+
"""
|
|
357
|
+
if not (0 < alpha <= 1):
|
|
358
|
+
raise ValueError(f"alpha must be in (0, 1], got {alpha}")
|
|
359
|
+
|
|
360
|
+
ema = np.zeros_like(values, dtype=float)
|
|
361
|
+
ema[0] = values[0]
|
|
362
|
+
|
|
363
|
+
for i in range(1, len(values)):
|
|
364
|
+
if np.isnan(values[i]):
|
|
365
|
+
ema[i] = ema[i-1] # Carry forward if missing
|
|
366
|
+
else:
|
|
367
|
+
ema[i] = alpha * values[i] + (1 - alpha) * ema[i-1]
|
|
368
|
+
|
|
369
|
+
return ema
|
|
370
|
+
|
|
371
|
+
def _compute_sma(self, values: np.ndarray, window: int) -> np.ndarray:
|
|
372
|
+
"""
|
|
373
|
+
Compute Simple Moving Average.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
values: Input values
|
|
377
|
+
window: Window size for averaging
|
|
378
|
+
|
|
379
|
+
Returns:
|
|
380
|
+
Smoothed values
|
|
381
|
+
|
|
382
|
+
Note:
|
|
383
|
+
For first (window-1) points, uses available data.
|
|
384
|
+
"""
|
|
385
|
+
if window <= 0:
|
|
386
|
+
raise ValueError(f"window must be positive, got {window}")
|
|
387
|
+
|
|
388
|
+
sma = np.zeros_like(values, dtype=float)
|
|
389
|
+
|
|
390
|
+
for i in range(len(values)):
|
|
391
|
+
start = max(0, i - window + 1)
|
|
392
|
+
window_values = values[start:i+1]
|
|
393
|
+
# Filter out NaN values
|
|
394
|
+
valid_values = window_values[~np.isnan(window_values)]
|
|
395
|
+
if len(valid_values) > 0:
|
|
396
|
+
sma[i] = np.mean(valid_values)
|
|
397
|
+
else:
|
|
398
|
+
sma[i] = np.nan
|
|
399
|
+
|
|
400
|
+
return sma
|
|
401
|
+
|
|
402
|
+
def _compute_weights(self, window_size: int) -> np.ndarray:
|
|
403
|
+
"""
|
|
404
|
+
Compute weights for points in window.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
window_size: Size of the window
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Array of weights (normalized to sum to 1)
|
|
411
|
+
|
|
412
|
+
Supported window_weights methods:
|
|
413
|
+
- None: Uniform weights (all points equal)
|
|
414
|
+
- "exponential": Exponential decay (recent points have more weight)
|
|
415
|
+
- "linear": Linear increase (recent points have more weight)
|
|
416
|
+
"""
|
|
417
|
+
window_weights = self.params.get("window_weights")
|
|
418
|
+
|
|
419
|
+
if window_weights is None:
|
|
420
|
+
# Uniform weights
|
|
421
|
+
return np.ones(window_size) / window_size
|
|
422
|
+
|
|
423
|
+
elif window_weights == "exponential":
|
|
424
|
+
weight_decay = self.params.get("weight_decay", 0.95)
|
|
425
|
+
if not (0 < weight_decay < 1):
|
|
426
|
+
raise ValueError(f"weight_decay must be in (0, 1), got {weight_decay}")
|
|
427
|
+
|
|
428
|
+
# Older points get less weight: decay^k for k in [window_size, 1]
|
|
429
|
+
weights = np.array([weight_decay ** k for k in range(window_size, 0, -1)])
|
|
430
|
+
return weights / weights.sum()
|
|
431
|
+
|
|
432
|
+
elif window_weights == "linear":
|
|
433
|
+
# Linear increase: 1, 2, 3, ..., window_size
|
|
434
|
+
weights = np.arange(1, window_size + 1, dtype=float)
|
|
435
|
+
return weights / weights.sum()
|
|
436
|
+
|
|
437
|
+
else:
|
|
438
|
+
raise ValueError(
|
|
439
|
+
f"Unknown window_weights method: {window_weights}. "
|
|
440
|
+
f"Supported methods: exponential, linear"
|
|
441
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Detector factory for creating detector instances from configuration.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
|
|
7
|
+
from detectkit.detectors.base import BaseDetector
|
|
8
|
+
from detectkit.detectors.statistical.iqr import IQRDetector
|
|
9
|
+
from detectkit.detectors.statistical.mad import MADDetector
|
|
10
|
+
from detectkit.detectors.statistical.manual_bounds import ManualBoundsDetector
|
|
11
|
+
from detectkit.detectors.statistical.zscore import ZScoreDetector
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DetectorFactory:
|
|
15
|
+
"""
|
|
16
|
+
Factory for creating detector instances from configuration.
|
|
17
|
+
|
|
18
|
+
Supports creating detectors by type name with parameters.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
>>> factory = DetectorFactory()
|
|
22
|
+
>>> detector = factory.create("zscore", {"threshold": 3.0})
|
|
23
|
+
>>> isinstance(detector, ZScoreDetector)
|
|
24
|
+
True
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Registry of available detector types
|
|
28
|
+
DETECTOR_TYPES = {
|
|
29
|
+
"zscore": ZScoreDetector,
|
|
30
|
+
"mad": MADDetector,
|
|
31
|
+
"iqr": IQRDetector,
|
|
32
|
+
"manual_bounds": ManualBoundsDetector,
|
|
33
|
+
"manual": ManualBoundsDetector, # Alias
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def create(cls, detector_type: str, params: Dict = None) -> BaseDetector:
|
|
38
|
+
"""
|
|
39
|
+
Create detector instance from type and parameters.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
detector_type: Type of detector (e.g., "zscore", "mad")
|
|
43
|
+
params: Detector parameters (optional)
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Detector instance
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: If detector type is unknown
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
>>> detector = DetectorFactory.create("zscore", {"threshold": 3.0, "window_size": 100})
|
|
53
|
+
>>> detector = DetectorFactory.create("mad", {"threshold": 2.5})
|
|
54
|
+
"""
|
|
55
|
+
params = params or {}
|
|
56
|
+
|
|
57
|
+
detector_type = detector_type.lower()
|
|
58
|
+
|
|
59
|
+
if detector_type not in cls.DETECTOR_TYPES:
|
|
60
|
+
available = ", ".join(sorted(cls.DETECTOR_TYPES.keys()))
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"Unknown detector type: '{detector_type}'. "
|
|
63
|
+
f"Available types: {available}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
detector_class = cls.DETECTOR_TYPES[detector_type]
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
return detector_class(**params)
|
|
70
|
+
except TypeError as e:
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Invalid parameters for {detector_type} detector: {e}"
|
|
73
|
+
) from e
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def create_from_config(cls, detector_config: Dict) -> BaseDetector:
|
|
77
|
+
"""
|
|
78
|
+
Create detector from configuration dictionary.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
detector_config: Configuration with 'type' and optional 'params'
|
|
82
|
+
Example: {"type": "zscore", "params": {"threshold": 3.0}}
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Detector instance
|
|
86
|
+
|
|
87
|
+
Example:
|
|
88
|
+
>>> config = {"type": "zscore", "params": {"threshold": 3.0}}
|
|
89
|
+
>>> detector = DetectorFactory.create_from_config(config)
|
|
90
|
+
"""
|
|
91
|
+
detector_type = detector_config.get("type")
|
|
92
|
+
if not detector_type:
|
|
93
|
+
raise ValueError("Detector config must have 'type' field")
|
|
94
|
+
|
|
95
|
+
params = detector_config.get("params", {})
|
|
96
|
+
|
|
97
|
+
return cls.create(detector_type, params)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def create_multiple(cls, detector_configs: List[Dict]) -> List[BaseDetector]:
|
|
101
|
+
"""
|
|
102
|
+
Create multiple detectors from list of configurations.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
detector_configs: List of detector configurations
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of detector instances
|
|
109
|
+
|
|
110
|
+
Example:
|
|
111
|
+
>>> configs = [
|
|
112
|
+
... {"type": "zscore", "params": {"threshold": 3.0}},
|
|
113
|
+
... {"type": "mad", "params": {"threshold": 2.5}},
|
|
114
|
+
... ]
|
|
115
|
+
>>> detectors = DetectorFactory.create_multiple(configs)
|
|
116
|
+
>>> len(detectors)
|
|
117
|
+
2
|
|
118
|
+
"""
|
|
119
|
+
detectors = []
|
|
120
|
+
for config in detector_configs:
|
|
121
|
+
detector = cls.create_from_config(config)
|
|
122
|
+
detectors.append(detector)
|
|
123
|
+
return detectors
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def list_available_types(cls) -> List[str]:
|
|
127
|
+
"""
|
|
128
|
+
Get list of available detector types.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
List of detector type names
|
|
132
|
+
|
|
133
|
+
Example:
|
|
134
|
+
>>> types = DetectorFactory.list_available_types()
|
|
135
|
+
>>> "zscore" in types
|
|
136
|
+
True
|
|
137
|
+
"""
|
|
138
|
+
return sorted(cls.DETECTOR_TYPES.keys())
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Statistical anomaly detectors."""
|
|
2
|
+
|
|
3
|
+
from detectkit.detectors.statistical.iqr import IQRDetector
|
|
4
|
+
from detectkit.detectors.statistical.mad import MADDetector
|
|
5
|
+
from detectkit.detectors.statistical.manual_bounds import ManualBoundsDetector
|
|
6
|
+
from detectkit.detectors.statistical.zscore import ZScoreDetector
|
|
7
|
+
|
|
8
|
+
__all__ = ["IQRDetector", "MADDetector", "ManualBoundsDetector", "ZScoreDetector"]
|