detectkit 0.1.2__tar.gz → 0.2.0__tar.gz

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 (61) hide show
  1. {detectkit-0.1.2/detectkit.egg-info → detectkit-0.2.0}/PKG-INFO +1 -1
  2. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/database/internal_tables.py +3 -0
  3. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/database/tables.py +3 -1
  4. detectkit-0.2.0/detectkit/detectors/base.py +441 -0
  5. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/statistical/iqr.py +124 -34
  6. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/statistical/mad.py +79 -24
  7. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/statistical/manual_bounds.py +43 -14
  8. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/statistical/zscore.py +123 -36
  9. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/orchestration/task_manager.py +9 -7
  10. detectkit-0.2.0/detectkit/utils/__init__.py +17 -0
  11. detectkit-0.2.0/detectkit/utils/stats.py +196 -0
  12. {detectkit-0.1.2 → detectkit-0.2.0/detectkit.egg-info}/PKG-INFO +1 -1
  13. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit.egg-info/SOURCES.txt +2 -1
  14. {detectkit-0.1.2 → detectkit-0.2.0}/pyproject.toml +1 -1
  15. detectkit-0.1.2/detectkit/detectors/base.py +0 -222
  16. detectkit-0.1.2/detectkit/utils/__init__.py +0 -1
  17. {detectkit-0.1.2 → detectkit-0.2.0}/LICENSE +0 -0
  18. {detectkit-0.1.2 → detectkit-0.2.0}/MANIFEST.in +0 -0
  19. {detectkit-0.1.2 → detectkit-0.2.0}/README.md +0 -0
  20. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/__init__.py +0 -0
  21. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/__init__.py +0 -0
  22. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/__init__.py +0 -0
  23. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/base.py +0 -0
  24. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/email.py +0 -0
  25. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/factory.py +0 -0
  26. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/mattermost.py +0 -0
  27. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/slack.py +0 -0
  28. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/telegram.py +0 -0
  29. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/webhook.py +0 -0
  30. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/orchestrator.py +0 -0
  31. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/__init__.py +0 -0
  32. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/commands/__init__.py +0 -0
  33. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/commands/init.py +0 -0
  34. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/commands/run.py +0 -0
  35. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/commands/test_alert.py +0 -0
  36. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/main.py +0 -0
  37. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/config/__init__.py +0 -0
  38. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/config/metric_config.py +0 -0
  39. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/config/profile.py +0 -0
  40. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/config/project_config.py +0 -0
  41. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/config/validator.py +0 -0
  42. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/core/__init__.py +0 -0
  43. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/core/interval.py +0 -0
  44. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/core/models.py +0 -0
  45. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/database/__init__.py +0 -0
  46. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/database/clickhouse_manager.py +0 -0
  47. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/database/manager.py +0 -0
  48. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/__init__.py +0 -0
  49. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/factory.py +0 -0
  50. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/statistical/__init__.py +0 -0
  51. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/loaders/__init__.py +0 -0
  52. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/loaders/metric_loader.py +0 -0
  53. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/loaders/query_template.py +0 -0
  54. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/orchestration/__init__.py +0 -0
  55. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit.egg-info/dependency_links.txt +0 -0
  56. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit.egg-info/entry_points.txt +0 -0
  57. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit.egg-info/requires.txt +0 -0
  58. {detectkit-0.1.2 → detectkit-0.2.0}/detectkit.egg-info/top_level.txt +0 -0
  59. {detectkit-0.1.2 → detectkit-0.2.0}/requirements.txt +0 -0
  60. {detectkit-0.1.2 → detectkit-0.2.0}/setup.cfg +0 -0
  61. {detectkit-0.1.2 → detectkit-0.2.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -157,6 +157,7 @@ class InternalTablesManager:
157
157
  - confidence_lower: np.array of float64 (nullable)
158
158
  - confidence_upper: np.array of float64 (nullable)
159
159
  - value: np.array of float64 (nullable)
160
+ - processed_value: np.array of float64 (nullable)
160
161
  - detection_metadata: np.array of JSON strings
161
162
  detector_params: JSON string with sorted detector parameters
162
163
 
@@ -170,6 +171,7 @@ class InternalTablesManager:
170
171
  ... "confidence_lower": np.array([0.4, 0.5]),
171
172
  ... "confidence_upper": np.array([0.6, 0.7]),
172
173
  ... "value": np.array([0.5, 0.9]),
174
+ ... "processed_value": np.array([0.5, 0.9]),
173
175
  ... "detection_metadata": np.array(['{"severity": 0.0}', '{"severity": 0.8}']),
174
176
  ... }
175
177
  >>> rows = internal.save_detections(
@@ -187,6 +189,7 @@ class InternalTablesManager:
187
189
  "confidence_lower": data["confidence_lower"],
188
190
  "confidence_upper": data["confidence_upper"],
189
191
  "value": data["value"],
192
+ "processed_value": data["processed_value"],
190
193
  "detector_params": np.full(num_rows, detector_params, dtype=object),
191
194
  "detection_metadata": data["detection_metadata"],
192
195
  "created_at": np.full(
@@ -52,7 +52,8 @@ def get_detections_table_model() -> TableModel:
52
52
  - is_anomaly: Whether point is anomalous
53
53
  - confidence_lower: Lower confidence bound
54
54
  - confidence_upper: Upper confidence bound
55
- - value: Actual metric value
55
+ - value: Actual metric value (ALWAYS original value)
56
+ - processed_value: Value analyzed by detector (may be smoothed/transformed)
56
57
  - detector_params: JSON with sorted detector parameters
57
58
  - detection_metadata: JSON with missing_ratio, severity, direction, etc.
58
59
  - created_at: When detection was performed (UTC, millisecond precision)
@@ -68,6 +69,7 @@ def get_detections_table_model() -> TableModel:
68
69
  ColumnDefinition("confidence_lower", "Nullable(Float64)", nullable=True),
69
70
  ColumnDefinition("confidence_upper", "Nullable(Float64)", nullable=True),
70
71
  ColumnDefinition("value", "Nullable(Float64)", nullable=True),
72
+ ColumnDefinition("processed_value", "Nullable(Float64)", nullable=True),
71
73
  ColumnDefinition("detector_params", "String"),
72
74
  ColumnDefinition("detection_metadata", "String"),
73
75
  ColumnDefinition("created_at", "DateTime64(3, 'UTC')"),
@@ -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
+ )