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.
Files changed (51) hide show
  1. detectkit/__init__.py +17 -0
  2. detectkit/alerting/__init__.py +13 -0
  3. detectkit/alerting/channels/__init__.py +21 -0
  4. detectkit/alerting/channels/base.py +193 -0
  5. detectkit/alerting/channels/email.py +146 -0
  6. detectkit/alerting/channels/factory.py +193 -0
  7. detectkit/alerting/channels/mattermost.py +53 -0
  8. detectkit/alerting/channels/slack.py +55 -0
  9. detectkit/alerting/channels/telegram.py +110 -0
  10. detectkit/alerting/channels/webhook.py +139 -0
  11. detectkit/alerting/orchestrator.py +369 -0
  12. detectkit/cli/__init__.py +1 -0
  13. detectkit/cli/commands/__init__.py +1 -0
  14. detectkit/cli/commands/init.py +282 -0
  15. detectkit/cli/commands/run.py +486 -0
  16. detectkit/cli/commands/test_alert.py +184 -0
  17. detectkit/cli/main.py +186 -0
  18. detectkit/config/__init__.py +30 -0
  19. detectkit/config/metric_config.py +499 -0
  20. detectkit/config/profile.py +285 -0
  21. detectkit/config/project_config.py +164 -0
  22. detectkit/config/validator.py +124 -0
  23. detectkit/core/__init__.py +6 -0
  24. detectkit/core/interval.py +132 -0
  25. detectkit/core/models.py +106 -0
  26. detectkit/database/__init__.py +27 -0
  27. detectkit/database/clickhouse_manager.py +393 -0
  28. detectkit/database/internal_tables.py +724 -0
  29. detectkit/database/manager.py +324 -0
  30. detectkit/database/tables.py +138 -0
  31. detectkit/detectors/__init__.py +6 -0
  32. detectkit/detectors/base.py +441 -0
  33. detectkit/detectors/factory.py +138 -0
  34. detectkit/detectors/statistical/__init__.py +8 -0
  35. detectkit/detectors/statistical/iqr.py +508 -0
  36. detectkit/detectors/statistical/mad.py +478 -0
  37. detectkit/detectors/statistical/manual_bounds.py +206 -0
  38. detectkit/detectors/statistical/zscore.py +491 -0
  39. detectkit/loaders/__init__.py +6 -0
  40. detectkit/loaders/metric_loader.py +470 -0
  41. detectkit/loaders/query_template.py +164 -0
  42. detectkit/orchestration/__init__.py +9 -0
  43. detectkit/orchestration/task_manager.py +746 -0
  44. detectkit/utils/__init__.py +17 -0
  45. detectkit/utils/stats.py +196 -0
  46. detectkit-0.2.4.dist-info/METADATA +237 -0
  47. detectkit-0.2.4.dist-info/RECORD +51 -0
  48. detectkit-0.2.4.dist-info/WHEEL +5 -0
  49. detectkit-0.2.4.dist-info/entry_points.txt +2 -0
  50. detectkit-0.2.4.dist-info/licenses/LICENSE +21 -0
  51. detectkit-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,491 @@
1
+ """
2
+ Z-Score anomaly detector.
3
+
4
+ Z-Score is a classical statistical method for outlier detection that:
5
+ - Uses mean as measure of center
6
+ - Uses standard deviation as measure of spread
7
+ - Assumes approximately normal distribution
8
+ - Supports seasonality grouping for adaptive thresholds
9
+
10
+ Formula:
11
+ - mean_val = mean(values)
12
+ - std_val = std(values)
13
+ - z_score = (value - mean_val) / std_val
14
+ - lower_bound = mean_val - threshold × std_val
15
+ - upper_bound = mean_val + threshold × std_val
16
+
17
+ With seasonality:
18
+ - Computes global statistics (entire window)
19
+ - Computes group statistics (seasonality subset)
20
+ - Applies multipliers: adjusted_stat = global_stat × group_multiplier
21
+
22
+ Note: Z-Score is more sensitive to outliers than MAD because
23
+ both mean and std are affected by extreme values.
24
+ """
25
+
26
+ import json
27
+ from typing import Any, Dict, List, Optional, Union
28
+
29
+ import numpy as np
30
+
31
+ from detectkit.detectors.base import BaseDetector, DetectionResult
32
+
33
+
34
+ class ZScoreDetector(BaseDetector):
35
+ """
36
+ Z-Score detector for anomaly detection with seasonality support.
37
+
38
+ Detects anomalies by comparing values against confidence intervals
39
+ based on mean and standard deviation (Z-Score method).
40
+
41
+ Parameters:
42
+ threshold (float): Number of standard deviations from mean (default: 3.0)
43
+ - 3.0 is standard (99.7% of normal data within ±3σ)
44
+ - Higher = less sensitive (fewer anomalies)
45
+ - Lower = more sensitive (more anomalies)
46
+
47
+ window_size (int): Historical window size in points (default: 100)
48
+ - Uses last N points to compute statistics
49
+ - Larger = more stable but less responsive
50
+ - Smaller = more responsive but less stable
51
+
52
+ min_samples (int): Minimum samples required for detection (default: 30)
53
+ - Skip detection if window has fewer valid points
54
+ - Ensures statistical reliability
55
+
56
+ seasonality_components (list, optional): List of seasonality groupings
57
+ - Single component: ["hour_of_day"]
58
+ - Multiple separate: ["hour_of_day", "day_of_week"]
59
+ - Combined group: [["hour_of_day", "day_of_week"]]
60
+ - Enables adaptive confidence intervals per seasonality pattern
61
+
62
+ min_samples_per_group (int): Minimum samples per seasonality group (default: 3)
63
+ - Groups with fewer samples use global statistics
64
+
65
+ Example:
66
+ >>> # Without seasonality
67
+ >>> detector = ZScoreDetector(threshold=3.0, window_size=100)
68
+ >>> results = detector.detect(data)
69
+
70
+ >>> # With seasonality
71
+ >>> detector = ZScoreDetector(
72
+ ... threshold=3.0,
73
+ ... window_size=2016,
74
+ ... seasonality_components=["hour_of_day", "day_of_week"]
75
+ ... )
76
+ >>> results = detector.detect(data)
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ threshold: float = 3.0,
82
+ window_size: int = 100,
83
+ min_samples: int = 30,
84
+ seasonality_components: Optional[List[Union[str, List[str]]]] = None,
85
+ min_samples_per_group: int = 3,
86
+ input_type: str = "values",
87
+ smoothing: Optional[str] = None,
88
+ smoothing_alpha: float = 0.3,
89
+ smoothing_window: int = 10,
90
+ window_weights: Optional[str] = None,
91
+ weight_decay: float = 0.95,
92
+ ):
93
+ """
94
+ Initialize Z-Score detector with parameters.
95
+
96
+ Args:
97
+ threshold: Number of standard deviations from mean
98
+ window_size: Historical window size in points
99
+ min_samples: Minimum total samples required
100
+ seasonality_components: Optional list of seasonality groups
101
+ min_samples_per_group: Minimum samples per seasonality group
102
+ input_type: Input transformation (values, changes, absolute_changes, log_changes)
103
+ smoothing: Smoothing method (None, ema, sma)
104
+ smoothing_alpha: EMA smoothing factor (0 < alpha <= 1)
105
+ smoothing_window: SMA window size
106
+ window_weights: Weighting method (None, exponential, linear)
107
+ weight_decay: Decay factor for exponential weights (0 < decay < 1)
108
+ """
109
+ super().__init__(
110
+ threshold=threshold,
111
+ window_size=window_size,
112
+ min_samples=min_samples,
113
+ seasonality_components=seasonality_components,
114
+ min_samples_per_group=min_samples_per_group,
115
+ input_type=input_type,
116
+ smoothing=smoothing,
117
+ smoothing_alpha=smoothing_alpha,
118
+ smoothing_window=smoothing_window,
119
+ window_weights=window_weights,
120
+ weight_decay=weight_decay,
121
+ )
122
+
123
+ def _validate_params(self):
124
+ """Validate detector parameters."""
125
+ threshold = self.params.get("threshold")
126
+ if threshold is None or threshold <= 0:
127
+ raise ValueError("threshold must be positive")
128
+
129
+ window_size = self.params.get("window_size")
130
+ if window_size is None or window_size < 1:
131
+ raise ValueError("window_size must be at least 1")
132
+
133
+ min_samples = self.params.get("min_samples")
134
+ if min_samples is None or min_samples < 2:
135
+ raise ValueError("min_samples must be at least 2")
136
+
137
+ if min_samples > window_size:
138
+ raise ValueError("min_samples cannot exceed window_size")
139
+
140
+ min_samples_per_group = self.params.get("min_samples_per_group", 3)
141
+ if min_samples_per_group < 1:
142
+ raise ValueError("min_samples_per_group must be at least 1")
143
+
144
+ def _parse_seasonality_data(
145
+ self, seasonality_data: np.ndarray, seasonality_columns: List[str]
146
+ ) -> Dict[str, np.ndarray]:
147
+ """
148
+ Parse seasonality JSON strings into structured data.
149
+
150
+ Args:
151
+ seasonality_data: Array of JSON strings
152
+ seasonality_columns: List of column names
153
+
154
+ Returns:
155
+ Dict with column names as keys, numpy arrays as values
156
+ """
157
+ if len(seasonality_data) == 0:
158
+ return {}
159
+
160
+ parsed_data = {col: [] for col in seasonality_columns}
161
+
162
+ for json_str in seasonality_data:
163
+ if json_str is None or json_str == "{}":
164
+ for col in seasonality_columns:
165
+ parsed_data[col].append(None)
166
+ else:
167
+ try:
168
+ data_dict = json.loads(json_str)
169
+ for col in seasonality_columns:
170
+ parsed_data[col].append(data_dict.get(col))
171
+ except (json.JSONDecodeError, TypeError):
172
+ for col in seasonality_columns:
173
+ parsed_data[col].append(None)
174
+
175
+ return {col: np.array(vals) for col, vals in parsed_data.items()}
176
+
177
+ def _create_seasonality_mask(
178
+ self,
179
+ seasonality_dict: Dict[str, np.ndarray],
180
+ window_start: int,
181
+ current_idx: int,
182
+ group_columns: List[str],
183
+ ) -> np.ndarray:
184
+ """
185
+ Create boolean mask for seasonality group.
186
+
187
+ Args:
188
+ seasonality_dict: Parsed seasonality data
189
+ window_start: Start index of window
190
+ current_idx: Current point index
191
+ group_columns: List of columns to group by
192
+
193
+ Returns:
194
+ Boolean mask for window indices matching current point's seasonality
195
+ """
196
+ if not group_columns or not seasonality_dict:
197
+ window_size = current_idx - window_start
198
+ return np.ones(window_size, dtype=bool)
199
+
200
+ current_values = {}
201
+ for col in group_columns:
202
+ if col in seasonality_dict:
203
+ current_values[col] = seasonality_dict[col][current_idx]
204
+ else:
205
+ return np.ones(current_idx - window_start, dtype=bool)
206
+
207
+ mask = np.ones(current_idx - window_start, dtype=bool)
208
+
209
+ for col in group_columns:
210
+ current_val = current_values[col]
211
+ window_vals = seasonality_dict[col][window_start:current_idx]
212
+ mask &= (window_vals == current_val)
213
+
214
+ return mask
215
+
216
+ def detect(self, data: Dict[str, np.ndarray]) -> list[DetectionResult]:
217
+ """
218
+ Perform Z-Score based anomaly detection with optional seasonality support.
219
+
220
+ For each point, uses historical window to compute:
221
+ 1. Global mean and std (entire window)
222
+ 2. If seasonality configured: group mean and std (seasonality subset)
223
+ 3. Apply multipliers: adjusted = global × (group / global)
224
+ 4. Build confidence interval: [mean - threshold×std, mean + threshold×std]
225
+ 5. Detect anomaly if value outside interval
226
+
227
+ Args:
228
+ data: Dictionary with keys:
229
+ - timestamp: np.array of datetime64[ms]
230
+ - value: np.array of float64 (may contain NaN)
231
+ - seasonality_data: np.array of JSON strings (optional)
232
+ - seasonality_columns: list of column names (optional)
233
+
234
+ Returns:
235
+ List of DetectionResult for each point
236
+
237
+ Notes:
238
+ - NaN values are skipped (marked as non-anomalous)
239
+ - First min_samples-1 points are skipped (insufficient history)
240
+ - Uses Bessel's correction (ddof=1) for std calculation
241
+ - Seasonality grouping creates adaptive confidence intervals
242
+ """
243
+ timestamps = data["timestamp"]
244
+ values = data["value"] # ORIGINAL values (always kept)
245
+ threshold = self.params["threshold"]
246
+ window_size = self.params["window_size"]
247
+ min_samples = self.params["min_samples"]
248
+
249
+ # Seasonality parameters
250
+ seasonality_components = self.params.get("seasonality_components")
251
+ min_samples_per_group = self.params.get("min_samples_per_group", 3)
252
+
253
+ # STEP 0: Preprocessing (smoothing + input_type transformation)
254
+ smoothed_values = self._apply_smoothing(values)
255
+ processed_values = self._preprocess_input(smoothed_values)
256
+
257
+ # Parse seasonality data if available
258
+ seasonality_dict = {}
259
+ seasonality_columns = data.get("seasonality_columns", [])
260
+ seasonality_data = data.get("seasonality_data", np.array([]))
261
+
262
+ if (
263
+ seasonality_components
264
+ and len(seasonality_columns) > 0
265
+ and len(seasonality_data) > 0
266
+ ):
267
+ seasonality_dict = self._parse_seasonality_data(
268
+ seasonality_data, seasonality_columns
269
+ )
270
+
271
+ results = []
272
+ n_points = len(timestamps)
273
+
274
+ for i in range(n_points):
275
+ current_val = values[i] # ORIGINAL value
276
+ current_processed = processed_values[i] # PROCESSED value
277
+ current_ts = timestamps[i]
278
+
279
+ # Skip NaN values (in processed)
280
+ if np.isnan(current_processed):
281
+ results.append(
282
+ DetectionResult(
283
+ timestamp=current_ts,
284
+ value=current_val,
285
+ processed_value=current_processed,
286
+ is_anomaly=False,
287
+ detection_metadata={"reason": "missing_data"},
288
+ )
289
+ )
290
+ continue
291
+
292
+ # Get historical window (not including current point)
293
+ window_start = max(0, i - window_size)
294
+ window_processed = processed_values[window_start:i]
295
+
296
+ # Filter out NaN values from window
297
+ valid_mask = ~np.isnan(window_processed)
298
+ window_valid = window_processed[valid_mask]
299
+
300
+ # Check if we have enough samples
301
+ if len(window_valid) < min_samples:
302
+ results.append(
303
+ DetectionResult(
304
+ timestamp=current_ts,
305
+ value=current_val,
306
+ processed_value=current_processed,
307
+ is_anomaly=False,
308
+ detection_metadata={
309
+ "reason": "insufficient_data",
310
+ "window_size": int(len(window_valid)),
311
+ "min_samples": min_samples,
312
+ },
313
+ )
314
+ )
315
+ continue
316
+
317
+ # Compute weights for window (if specified)
318
+ weights = self._compute_weights(len(window_valid))
319
+
320
+ # STEP 1: Compute GLOBAL statistics (entire window)
321
+ # Use weighted statistics if weights are not uniform
322
+ from detectkit.utils import weighted_mean, weighted_std
323
+
324
+ global_mean = weighted_mean(window_valid, weights)
325
+ global_std = weighted_std(window_valid, weights, center=global_mean, ddof=1)
326
+
327
+ # Initialize adjusted statistics
328
+ adjusted_mean = global_mean
329
+ adjusted_std = global_std
330
+
331
+ # STEP 2: Apply seasonality adjustments
332
+ multipliers_applied = []
333
+
334
+ if seasonality_components and seasonality_dict:
335
+ for group in seasonality_components:
336
+ # Convert single string to list
337
+ group_cols = [group] if isinstance(group, str) else group
338
+
339
+ # Create mask for this seasonality group
340
+ season_mask = self._create_seasonality_mask(
341
+ seasonality_dict, window_start, i, group_cols
342
+ )
343
+
344
+ # Apply mask to window (only valid values + seasonality match)
345
+ # Both valid_mask and season_mask are same size as window_processed
346
+ combined_mask = valid_mask & season_mask
347
+
348
+ group_values = window_processed[combined_mask]
349
+
350
+ # Check if enough samples in group
351
+ if len(group_values) < min_samples_per_group:
352
+ # Insufficient data - skip this group (multiplier = 1.0)
353
+ multipliers_applied.append({
354
+ "group": group_cols,
355
+ "mean_multiplier": 1.0,
356
+ "std_multiplier": 1.0,
357
+ "reason": "insufficient_group_data",
358
+ "group_size": int(len(group_values)),
359
+ })
360
+ continue
361
+
362
+ # Compute group statistics with weights
363
+ group_weights = self._compute_weights(len(group_values))
364
+ group_mean = weighted_mean(group_values, group_weights)
365
+ group_std = weighted_std(group_values, group_weights, center=group_mean, ddof=1)
366
+
367
+ # Calculate multipliers (avoid division by zero)
368
+ if global_mean != 0:
369
+ mean_multiplier = group_mean / global_mean
370
+ else:
371
+ mean_multiplier = 1.0
372
+
373
+ if global_std > 0:
374
+ std_multiplier = group_std / global_std
375
+ else:
376
+ std_multiplier = 1.0
377
+
378
+ # Apply multipliers
379
+ adjusted_mean *= mean_multiplier
380
+ adjusted_std *= std_multiplier
381
+
382
+ multipliers_applied.append({
383
+ "group": group_cols,
384
+ "mean_multiplier": float(mean_multiplier),
385
+ "std_multiplier": float(std_multiplier),
386
+ "group_size": int(len(group_values)),
387
+ })
388
+
389
+ # STEP 3: Build confidence interval with adjusted statistics
390
+ if adjusted_std == 0:
391
+ # All values identical - use small epsilon
392
+ confidence_lower = adjusted_mean - 1e-10
393
+ confidence_upper = adjusted_mean + 1e-10
394
+ else:
395
+ confidence_lower = adjusted_mean - threshold * adjusted_std
396
+ confidence_upper = adjusted_mean + threshold * adjusted_std
397
+
398
+ # STEP 4: Check if current PROCESSED value is anomalous
399
+ is_anomaly = (current_processed < confidence_lower) or (
400
+ current_processed > confidence_upper
401
+ )
402
+
403
+ # STEP 5: Compute metadata
404
+ metadata = {
405
+ "global_mean": float(global_mean),
406
+ "global_std": float(global_std),
407
+ "adjusted_mean": float(adjusted_mean),
408
+ "adjusted_std": float(adjusted_std),
409
+ "window_size": int(len(window_valid)),
410
+ }
411
+
412
+ # Add preprocessing info if used
413
+ if self.params.get("smoothing") or self.params.get("input_type") != "values":
414
+ metadata["preprocessing"] = {
415
+ "input_type": self.params.get("input_type", "values"),
416
+ "smoothing": self.params.get("smoothing"),
417
+ }
418
+ if self.params.get("smoothing"):
419
+ metadata["preprocessing"]["smoothed_value"] = float(smoothed_values[i])
420
+
421
+ if seasonality_components and multipliers_applied:
422
+ metadata["seasonality_groups"] = multipliers_applied
423
+
424
+ if is_anomaly:
425
+ if current_processed < confidence_lower:
426
+ direction = "below"
427
+ distance = confidence_lower - current_processed
428
+ else:
429
+ direction = "above"
430
+ distance = current_processed - confidence_upper
431
+
432
+ # Severity: how many adjusted std away
433
+ if adjusted_std > 0:
434
+ z_score = abs((current_processed - adjusted_mean) / adjusted_std)
435
+ else:
436
+ z_score = float("inf")
437
+
438
+ metadata.update(
439
+ {
440
+ "direction": direction,
441
+ "severity": float(z_score),
442
+ "distance": float(distance),
443
+ }
444
+ )
445
+
446
+ results.append(
447
+ DetectionResult(
448
+ timestamp=current_ts,
449
+ value=current_val, # ORIGINAL value
450
+ processed_value=current_processed, # PROCESSED value
451
+ is_anomaly=is_anomaly,
452
+ confidence_lower=float(confidence_lower),
453
+ confidence_upper=float(confidence_upper),
454
+ detection_metadata=metadata,
455
+ )
456
+ )
457
+
458
+ return results
459
+
460
+ def _get_non_default_params(self) -> Dict[str, Any]:
461
+ """
462
+ Get parameters that differ from defaults.
463
+
464
+ Excludes execution parameters (seasonality_components, min_samples_per_group)
465
+ from detector ID hash.
466
+ """
467
+ defaults = {
468
+ "threshold": 3.0,
469
+ "window_size": 100,
470
+ "min_samples": 30,
471
+ "min_samples_per_group": 3,
472
+ "input_type": "values",
473
+ "smoothing": None,
474
+ "smoothing_alpha": 0.3,
475
+ "smoothing_window": 10,
476
+ "window_weights": None,
477
+ "weight_decay": 0.95,
478
+ }
479
+ # Execution parameters that don't affect detector ID
480
+ execution_params = {
481
+ "seasonality_components",
482
+ "min_samples_per_group",
483
+ "smoothing_alpha", # Only affects smoothing, not algorithm
484
+ "smoothing_window", # Only affects smoothing, not algorithm
485
+ "weight_decay", # Only affects weighting, not algorithm
486
+ }
487
+
488
+ return {
489
+ k: v for k, v in self.params.items()
490
+ if v != defaults.get(k) and k not in execution_params
491
+ }
@@ -0,0 +1,6 @@
1
+ """Metric data loaders for detectk."""
2
+
3
+ from detectkit.loaders.query_template import QueryTemplate
4
+ from detectkit.loaders.metric_loader import MetricLoader
5
+
6
+ __all__ = ["QueryTemplate", "MetricLoader"]