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.
- {detectkit-0.1.2/detectkit.egg-info → detectkit-0.2.0}/PKG-INFO +1 -1
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/database/internal_tables.py +3 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/database/tables.py +3 -1
- detectkit-0.2.0/detectkit/detectors/base.py +441 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/statistical/iqr.py +124 -34
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/statistical/mad.py +79 -24
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/statistical/manual_bounds.py +43 -14
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/statistical/zscore.py +123 -36
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/orchestration/task_manager.py +9 -7
- detectkit-0.2.0/detectkit/utils/__init__.py +17 -0
- detectkit-0.2.0/detectkit/utils/stats.py +196 -0
- {detectkit-0.1.2 → detectkit-0.2.0/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit.egg-info/SOURCES.txt +2 -1
- {detectkit-0.1.2 → detectkit-0.2.0}/pyproject.toml +1 -1
- detectkit-0.1.2/detectkit/detectors/base.py +0 -222
- detectkit-0.1.2/detectkit/utils/__init__.py +0 -1
- {detectkit-0.1.2 → detectkit-0.2.0}/LICENSE +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/MANIFEST.in +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/README.md +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/alerting/orchestrator.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/core/models.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/requirements.txt +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/setup.cfg +0 -0
- {detectkit-0.1.2 → detectkit-0.2.0}/setup.py +0 -0
|
@@ -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
|
+
)
|