detectkit 0.17.0__tar.gz → 0.19.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.17.0/detectkit.egg-info → detectkit-0.19.0}/PKG-INFO +1 -1
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/__init__.py +1 -1
- detectkit-0.19.0/detectkit/autotune/__init__.py +44 -0
- detectkit-0.19.0/detectkit/autotune/_base.py +109 -0
- detectkit-0.19.0/detectkit/autotune/_types.py +87 -0
- detectkit-0.19.0/detectkit/autotune/autotuner.py +194 -0
- detectkit-0.19.0/detectkit/autotune/config_emitter.py +167 -0
- detectkit-0.19.0/detectkit/autotune/crossval.py +101 -0
- detectkit-0.19.0/detectkit/autotune/detector_select.py +157 -0
- detectkit-0.19.0/detectkit/autotune/distribution.py +81 -0
- detectkit-0.19.0/detectkit/autotune/grid_search.py +111 -0
- detectkit-0.19.0/detectkit/autotune/html_labeler.py +103 -0
- detectkit-0.19.0/detectkit/autotune/labels.py +196 -0
- detectkit-0.19.0/detectkit/autotune/result.py +40 -0
- detectkit-0.19.0/detectkit/autotune/scoring.py +177 -0
- detectkit-0.19.0/detectkit/autotune/seasonality_search.py +135 -0
- detectkit-0.19.0/detectkit/autotune/settings.py +53 -0
- detectkit-0.19.0/detectkit/autotune/window_select.py +80 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/CLAUDE.section.md +4 -0
- detectkit-0.19.0/detectkit/cli/assets/claude/rules/autotune.md +128 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/cli.md +10 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/detectors.md +6 -1
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/metrics.md +2 -1
- detectkit-0.19.0/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +192 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +7 -1
- detectkit-0.19.0/detectkit/cli/commands/autotune.py +505 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/main.py +98 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/config/metric_config.py +145 -0
- detectkit-0.19.0/detectkit/database/internal_tables/_autotune_runs.py +113 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/internal_tables/manager.py +2 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/tables.py +71 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/detectors/statistical/_windowed.py +15 -3
- {detectkit-0.17.0 → detectkit-0.19.0/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit.egg-info/SOURCES.txt +20 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/LICENSE +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/MANIFEST.in +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/README.md +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/core/models.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/pyproject.toml +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/requirements.txt +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/setup.cfg +0 -0
- {detectkit-0.17.0 → detectkit-0.19.0}/setup.py +0 -0
|
@@ -4,7 +4,7 @@ detectk - Anomaly Detection for Time-Series Metrics
|
|
|
4
4
|
A Python library for data analysts and engineers to monitor metrics with automatic anomaly detection.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "0.
|
|
7
|
+
__version__ = "0.19.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""detectkit autotune engine.
|
|
2
|
+
|
|
3
|
+
Given a metric's loaded series (+ optional labeled incidents), automatically
|
|
4
|
+
chooses the seasonality grouping, detector type, hyperparameters and history
|
|
5
|
+
window, cross-validates the choice, and returns an :class:`AutoTuneResult`.
|
|
6
|
+
|
|
7
|
+
Pure and DB-free: the engine operates on the in-memory ``data`` dict and reuses
|
|
8
|
+
the existing ``WindowedStatDetector`` / ``DetectorFactory``. The CLI command
|
|
9
|
+
(``dtk autotune``) handles loading, persistence, config emission and cleanup.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from detectkit.autotune._base import AutoTuneError, _AutoTuneBase
|
|
15
|
+
from detectkit.autotune._types import ScoringMetric, TuneMode
|
|
16
|
+
from detectkit.autotune.autotuner import AutoTuner, run_autotune_engine
|
|
17
|
+
from detectkit.autotune.config_emitter import compute_run_id, emit_tuned_config
|
|
18
|
+
from detectkit.autotune.html_labeler import render_labeler_html
|
|
19
|
+
from detectkit.autotune.labels import (
|
|
20
|
+
GroundTruth,
|
|
21
|
+
IncidentLabels,
|
|
22
|
+
parse_incident_labels,
|
|
23
|
+
parse_labels_file,
|
|
24
|
+
)
|
|
25
|
+
from detectkit.autotune.result import AutoTuneResult
|
|
26
|
+
from detectkit.autotune.settings import TuneSettings
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AutoTuner",
|
|
30
|
+
"AutoTuneError",
|
|
31
|
+
"AutoTuneResult",
|
|
32
|
+
"GroundTruth",
|
|
33
|
+
"IncidentLabels",
|
|
34
|
+
"ScoringMetric",
|
|
35
|
+
"TuneMode",
|
|
36
|
+
"TuneSettings",
|
|
37
|
+
"_AutoTuneBase",
|
|
38
|
+
"compute_run_id",
|
|
39
|
+
"emit_tuned_config",
|
|
40
|
+
"parse_incident_labels",
|
|
41
|
+
"parse_labels_file",
|
|
42
|
+
"render_labeler_html",
|
|
43
|
+
"run_autotune_engine",
|
|
44
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Shared engine state + the candidate-evaluation primitive.
|
|
2
|
+
|
|
3
|
+
The stages are plain functions (in their own modules) that take an
|
|
4
|
+
:class:`_AutoTuneBase` as their first argument and call ``evaluate`` /
|
|
5
|
+
``log`` on it. This keeps cross-stage calls explicit and type-checkable
|
|
6
|
+
(no cross-mixin attribute access) while still splitting the pipeline into
|
|
7
|
+
focused, <250-line files.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from detectkit.autotune._types import (
|
|
18
|
+
CandidateEval,
|
|
19
|
+
CVPlan,
|
|
20
|
+
DecisionEntry,
|
|
21
|
+
GroupVote,
|
|
22
|
+
)
|
|
23
|
+
from detectkit.autotune.crossval import run_cv
|
|
24
|
+
from detectkit.autotune.labels import GroundTruth
|
|
25
|
+
from detectkit.autotune.settings import TuneSettings
|
|
26
|
+
from detectkit.detectors.factory import DetectorFactory
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AutoTuneError(RuntimeError):
|
|
30
|
+
"""Raised when a metric cannot be tuned (no data, no viable candidate, …)."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _AutoTuneBase:
|
|
34
|
+
"""Holds the loaded series, labels, settings, decision log + eval cache."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
metric_name: str,
|
|
40
|
+
data: dict[str, np.ndarray],
|
|
41
|
+
ground_truth: GroundTruth,
|
|
42
|
+
interval_seconds: int,
|
|
43
|
+
settings: TuneSettings,
|
|
44
|
+
on_stage: Callable[[str, str], None] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self.metric_name = metric_name
|
|
47
|
+
self.data = data
|
|
48
|
+
self.ground_truth = ground_truth
|
|
49
|
+
self.interval_seconds = interval_seconds
|
|
50
|
+
self.settings = settings
|
|
51
|
+
self._on_stage = on_stage
|
|
52
|
+
self.decision_log: list[DecisionEntry] = []
|
|
53
|
+
self.group_votes: list[GroupVote] = []
|
|
54
|
+
self.cv_plan: CVPlan | None = None
|
|
55
|
+
# detector_id -> evaluated candidate (doubles as the dedup cache and
|
|
56
|
+
# the ledger of every candidate considered during the run)
|
|
57
|
+
self._evaluated: dict[str, CandidateEval] = {}
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Progress + decision log
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def emit(self, stage: str, line: str) -> None:
|
|
64
|
+
"""Stream one progress line to the CLI renderer (if attached)."""
|
|
65
|
+
if self._on_stage is not None:
|
|
66
|
+
self._on_stage(stage, line)
|
|
67
|
+
|
|
68
|
+
def log(self, stage: str, message: str, *, emit: bool = True, **fields: Any) -> None:
|
|
69
|
+
"""Record a decision-log entry (and optionally stream it)."""
|
|
70
|
+
self.decision_log.append(DecisionEntry(stage=stage, message=message, fields=fields))
|
|
71
|
+
if emit:
|
|
72
|
+
self.emit(stage, message)
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Candidate evaluation
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def evaluate(self, detector_type: str, params: dict[str, Any]) -> CandidateEval:
|
|
79
|
+
"""Build + cross-validate a candidate detector (memoized by detector id)."""
|
|
80
|
+
full_params = {**self.settings.fixed_params, **params}
|
|
81
|
+
detector = DetectorFactory.create(detector_type, full_params)
|
|
82
|
+
detector_id = detector.get_detector_id()
|
|
83
|
+
cached = self._evaluated.get(detector_id)
|
|
84
|
+
if cached is not None:
|
|
85
|
+
return cached
|
|
86
|
+
|
|
87
|
+
if self.cv_plan is None:
|
|
88
|
+
raise AutoTuneError("CV plan not initialized before evaluation")
|
|
89
|
+
fold_scores = run_cv(detector, self.data, self.cv_plan, self.ground_truth, self.settings)
|
|
90
|
+
ev = CandidateEval(
|
|
91
|
+
detector_type=detector_type,
|
|
92
|
+
params=full_params,
|
|
93
|
+
detector_id=detector_id,
|
|
94
|
+
fold_scores=fold_scores,
|
|
95
|
+
score=fold_scores.aggregate,
|
|
96
|
+
)
|
|
97
|
+
self._evaluated[detector_id] = ev
|
|
98
|
+
return ev
|
|
99
|
+
|
|
100
|
+
def safe_evaluate(self, detector_type: str, params: dict[str, Any]) -> CandidateEval | None:
|
|
101
|
+
"""Evaluate, returning None on an invalid parameter combination."""
|
|
102
|
+
try:
|
|
103
|
+
return self.evaluate(detector_type, params)
|
|
104
|
+
except ValueError:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def evaluated_ids(self) -> list[str]:
|
|
108
|
+
"""Every distinct detector id considered during the run (cleanup ledger)."""
|
|
109
|
+
return list(self._evaluated.keys())
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Small shared types for the autotune engine.
|
|
2
|
+
|
|
3
|
+
Kept dependency-free (no imports from other autotune modules) so every stage
|
|
4
|
+
can import these without cycles.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScoringMetric(str, Enum):
|
|
15
|
+
"""Optimization target for the grid search.
|
|
16
|
+
|
|
17
|
+
All are computed in pure numpy (see :mod:`detectkit.autotune.scoring`).
|
|
18
|
+
MCC is the default: it uses all four confusion cells and is robust to the
|
|
19
|
+
heavy class imbalance of rare anomalies.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
MCC = "mcc"
|
|
23
|
+
F1 = "f1"
|
|
24
|
+
F_BETA = "f_beta"
|
|
25
|
+
BALANCED_ACCURACY = "balanced_accuracy"
|
|
26
|
+
ROC_AUC = "roc_auc"
|
|
27
|
+
PR_AUC = "pr_auc"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TuneMode(str, Enum):
|
|
31
|
+
"""Whether the run optimizes against labels or data statistics."""
|
|
32
|
+
|
|
33
|
+
SUPERVISED = "supervised"
|
|
34
|
+
UNSUPERVISED = "unsupervised"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class CVPlan:
|
|
39
|
+
"""Walk-forward fold layout over a single loaded series.
|
|
40
|
+
|
|
41
|
+
``fold_bounds`` are ``[lo, hi)`` index ranges into the series; the first
|
|
42
|
+
``context_end`` points are reserved as pure context and never scored.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
fold_bounds: list[tuple[int, int]]
|
|
46
|
+
context_end: int
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class FoldScores:
|
|
51
|
+
"""Per-fold scores plus the stability-penalized aggregate."""
|
|
52
|
+
|
|
53
|
+
per_fold: list[float]
|
|
54
|
+
aggregate: float
|
|
55
|
+
stability_penalty: float
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class CandidateEval:
|
|
60
|
+
"""A single evaluated detector candidate."""
|
|
61
|
+
|
|
62
|
+
detector_type: str
|
|
63
|
+
params: dict[str, Any]
|
|
64
|
+
detector_id: str
|
|
65
|
+
fold_scores: FoldScores
|
|
66
|
+
score: float
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class GroupVote:
|
|
71
|
+
"""Per-seasonality-group distribution features + ranked detector suitabilities."""
|
|
72
|
+
|
|
73
|
+
group: list[str]
|
|
74
|
+
features: dict[str, float]
|
|
75
|
+
ranked_types: list[tuple[str, float]]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class DecisionEntry:
|
|
80
|
+
"""One ordered, human-readable rationale entry for the decision log."""
|
|
81
|
+
|
|
82
|
+
stage: str
|
|
83
|
+
message: str
|
|
84
|
+
fields: dict[str, Any] = field(default_factory=dict)
|
|
85
|
+
|
|
86
|
+
def to_dict(self) -> dict[str, Any]:
|
|
87
|
+
return {"stage": self.stage, "message": self.message, "fields": self.fields}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""The autotune orchestrator: runs the stages and assembles the result.
|
|
2
|
+
|
|
3
|
+
Pure and DB-free — operates entirely on the in-memory ``data`` dict. The CLI
|
|
4
|
+
command handles loading, persistence, config emission and candidate cleanup.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from detectkit.autotune._base import AutoTuneError, _AutoTuneBase
|
|
16
|
+
from detectkit.autotune._types import CandidateEval, TuneMode
|
|
17
|
+
from detectkit.autotune.crossval import build_cv_plan, predictions_from_results
|
|
18
|
+
from detectkit.autotune.detector_select import select_detector_types
|
|
19
|
+
from detectkit.autotune.grid_search import grid_search
|
|
20
|
+
from detectkit.autotune.labels import GroundTruth
|
|
21
|
+
from detectkit.autotune.result import AutoTuneResult
|
|
22
|
+
from detectkit.autotune.scoring import score_predictions
|
|
23
|
+
from detectkit.autotune.seasonality_search import search_seasonality
|
|
24
|
+
from detectkit.autotune.settings import TuneSettings
|
|
25
|
+
from detectkit.autotune.window_select import window_grid
|
|
26
|
+
from detectkit.detectors.factory import DetectorFactory
|
|
27
|
+
|
|
28
|
+
_ALERT_WINDOW_GRID = (1, 2, 3, 4, 5)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ts_to_dt(ts64: np.datetime64) -> datetime:
|
|
32
|
+
ms = int(ts64.astype("datetime64[ms]").astype(np.int64))
|
|
33
|
+
return datetime(1970, 1, 1) + timedelta(milliseconds=ms)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _consecutive(flags: np.ndarray, k: int) -> np.ndarray:
|
|
37
|
+
"""Mark index i where the last *k* grid points are all anomalous."""
|
|
38
|
+
if k <= 1:
|
|
39
|
+
return flags.copy()
|
|
40
|
+
out = flags.copy()
|
|
41
|
+
for shift in range(1, k):
|
|
42
|
+
shifted = np.concatenate([np.zeros(shift, dtype=bool), flags[:-shift]])
|
|
43
|
+
out &= shifted
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AutoTuner(_AutoTuneBase):
|
|
48
|
+
"""Runs the load-free tuning pipeline and returns an :class:`AutoTuneResult`."""
|
|
49
|
+
|
|
50
|
+
def tune(self) -> AutoTuneResult:
|
|
51
|
+
timestamps = self.data["timestamp"]
|
|
52
|
+
n = int(len(timestamps))
|
|
53
|
+
if n == 0:
|
|
54
|
+
raise AutoTuneError(
|
|
55
|
+
"no datapoints to tune on — run `dtk run --select <metric> --steps load` first"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
grid = window_grid(self)
|
|
59
|
+
max_window = max([*grid, 100])
|
|
60
|
+
self.cv_plan = build_cv_plan(n, max_window, self.settings.fold_count)
|
|
61
|
+
if not self.cv_plan.fold_bounds:
|
|
62
|
+
raise AutoTuneError(
|
|
63
|
+
f"not enough datapoints ({n}) for {self.settings.fold_count}-fold "
|
|
64
|
+
f"cross-validation with a {max_window}-point context window"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
gt = self.ground_truth
|
|
68
|
+
self.log(
|
|
69
|
+
"labels",
|
|
70
|
+
f"{gt.n_intervals} interval(s) + {gt.n_points} point(s) → {gt.mode.value} mode "
|
|
71
|
+
f"({gt.n_positive} labeled grid point(s)); scoring={self.settings.metric.value}",
|
|
72
|
+
mode=gt.mode.value,
|
|
73
|
+
n_positive=gt.n_positive,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
seasonality = search_seasonality(self)
|
|
77
|
+
detector_types = select_detector_types(self, seasonality)
|
|
78
|
+
best = grid_search(self, detector_types, seasonality, grid)
|
|
79
|
+
if best is None:
|
|
80
|
+
raise AutoTuneError("no viable detector candidate found for this data")
|
|
81
|
+
|
|
82
|
+
consecutive = self._select_alert_window(best.detector_type, best.params)
|
|
83
|
+
|
|
84
|
+
return self._build_result(seasonality, best, consecutive)
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def _select_alert_window(self, detector_type: str, params: dict[str, Any]) -> int | None:
|
|
89
|
+
"""Sweep consecutive_anomalies on labeled incidents (supervised only)."""
|
|
90
|
+
if self.ground_truth.mode != TuneMode.SUPERVISED:
|
|
91
|
+
return None
|
|
92
|
+
detector = DetectorFactory.create(detector_type, params)
|
|
93
|
+
y_pred, y_score, valid = predictions_from_results(detector.detect(self.data))
|
|
94
|
+
y_true = self.ground_truth.y_true
|
|
95
|
+
best_k = 1
|
|
96
|
+
best_score = float("-inf")
|
|
97
|
+
for k in _ALERT_WINDOW_GRID:
|
|
98
|
+
alert = _consecutive(y_pred, k)
|
|
99
|
+
score = score_predictions(
|
|
100
|
+
y_true[valid],
|
|
101
|
+
alert[valid],
|
|
102
|
+
y_score[valid],
|
|
103
|
+
self.settings.metric,
|
|
104
|
+
self.settings.beta,
|
|
105
|
+
)
|
|
106
|
+
if score > best_score:
|
|
107
|
+
best_score, best_k = score, k
|
|
108
|
+
self.log(
|
|
109
|
+
"window",
|
|
110
|
+
f"consecutive_anomalies={best_k} "
|
|
111
|
+
f"(max {self.settings.metric.value}={best_score:.3f} on labeled incidents)",
|
|
112
|
+
consecutive_anomalies=best_k,
|
|
113
|
+
)
|
|
114
|
+
return best_k
|
|
115
|
+
|
|
116
|
+
def _clean_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
"""Drop None/empty values so the emitted config is tidy."""
|
|
118
|
+
out: dict[str, Any] = {}
|
|
119
|
+
for key, value in params.items():
|
|
120
|
+
if value is None:
|
|
121
|
+
continue
|
|
122
|
+
if key == "seasonality_components" and not value:
|
|
123
|
+
continue
|
|
124
|
+
out[key] = value
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
def _build_result(
|
|
128
|
+
self, seasonality: list | None, best: CandidateEval, consecutive: int | None
|
|
129
|
+
) -> AutoTuneResult:
|
|
130
|
+
timestamps = self.data["timestamp"]
|
|
131
|
+
training_start = _ts_to_dt(timestamps[0]) if len(timestamps) else None
|
|
132
|
+
training_end = _ts_to_dt(timestamps[-1]) if len(timestamps) else None
|
|
133
|
+
gt = self.ground_truth
|
|
134
|
+
|
|
135
|
+
candidates = [
|
|
136
|
+
{
|
|
137
|
+
"detector_type": ev.detector_type,
|
|
138
|
+
"params": self._clean_params(ev.params),
|
|
139
|
+
"detector_id": ev.detector_id,
|
|
140
|
+
}
|
|
141
|
+
for ev in self._evaluated.values()
|
|
142
|
+
]
|
|
143
|
+
group_votes = [
|
|
144
|
+
{"group": gv.group, "features": gv.features, "ranked_types": gv.ranked_types}
|
|
145
|
+
for gv in self.group_votes
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
return AutoTuneResult(
|
|
149
|
+
metric_name=self.metric_name,
|
|
150
|
+
mode=gt.mode.value,
|
|
151
|
+
scoring_metric=self.settings.metric.value,
|
|
152
|
+
training_start=training_start,
|
|
153
|
+
training_end=training_end,
|
|
154
|
+
interval_seconds=self.interval_seconds,
|
|
155
|
+
n_points=int(len(timestamps)),
|
|
156
|
+
labels_summary={
|
|
157
|
+
"intervals": gt.n_intervals,
|
|
158
|
+
"points": gt.n_points,
|
|
159
|
+
"positive_grid_points": gt.n_positive,
|
|
160
|
+
},
|
|
161
|
+
chosen_seasonality=seasonality,
|
|
162
|
+
chosen_detector_type=best.detector_type,
|
|
163
|
+
chosen_detector_params=self._clean_params(best.params),
|
|
164
|
+
winning_detector_id=best.detector_id,
|
|
165
|
+
score=best.score,
|
|
166
|
+
cv_per_fold=best.fold_scores.per_fold,
|
|
167
|
+
cv_stability_penalty=best.fold_scores.stability_penalty,
|
|
168
|
+
consecutive_anomalies=consecutive,
|
|
169
|
+
candidate_detector_ids=self.evaluated_ids(),
|
|
170
|
+
candidates=candidates,
|
|
171
|
+
group_votes=group_votes,
|
|
172
|
+
decision_log=[entry.to_dict() for entry in self.decision_log],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def run_autotune_engine(
|
|
177
|
+
*,
|
|
178
|
+
metric_name: str,
|
|
179
|
+
data: dict[str, np.ndarray],
|
|
180
|
+
ground_truth: GroundTruth,
|
|
181
|
+
interval_seconds: int,
|
|
182
|
+
settings: TuneSettings,
|
|
183
|
+
on_stage: Callable[[str, str], None] | None = None,
|
|
184
|
+
) -> AutoTuneResult:
|
|
185
|
+
"""Build an :class:`AutoTuner` and run it (the command↔engine entry point)."""
|
|
186
|
+
tuner = AutoTuner(
|
|
187
|
+
metric_name=metric_name,
|
|
188
|
+
data=data,
|
|
189
|
+
ground_truth=ground_truth,
|
|
190
|
+
interval_seconds=interval_seconds,
|
|
191
|
+
settings=settings,
|
|
192
|
+
on_stage=on_stage,
|
|
193
|
+
)
|
|
194
|
+
return tuner.tune()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Emit the annotated, ready-to-run tuned metric config.
|
|
2
|
+
|
|
3
|
+
Builds a new metric YAML named ``<original>__tuned_<run_id>`` led by a
|
|
4
|
+
``#``-comment block that walks the entire decision log, followed by the real
|
|
5
|
+
config (single chosen detector + chosen seasonality + copied query/alerting).
|
|
6
|
+
The body is validated through ``MetricConfig`` before it is ever written, so a
|
|
7
|
+
broken config is never emitted. PyYAML only — no new dependency.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from detectkit.autotune.result import AutoTuneResult
|
|
19
|
+
from detectkit.config.metric_config import MetricConfig
|
|
20
|
+
from detectkit.utils.json_utils import json_dumps_sorted
|
|
21
|
+
|
|
22
|
+
_RULE = "# " + "─" * 61
|
|
23
|
+
_STAGE_LABELS = {
|
|
24
|
+
"seasonality": "SEASONALITY",
|
|
25
|
+
"detector_select": "DETECTOR",
|
|
26
|
+
"grid_search": "GRID SEARCH",
|
|
27
|
+
"window": "WINDOW",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def compute_run_id(result: AutoTuneResult) -> str:
|
|
32
|
+
"""Deterministic 6-hex id from the run's inputs + outputs (no wall-clock)."""
|
|
33
|
+
payload = {
|
|
34
|
+
"metric": result.metric_name,
|
|
35
|
+
"training_start": result.training_start.isoformat() if result.training_start else None,
|
|
36
|
+
"training_end": result.training_end.isoformat() if result.training_end else None,
|
|
37
|
+
"labels": result.labels_summary,
|
|
38
|
+
"detector_type": result.chosen_detector_type,
|
|
39
|
+
"detector_params": result.chosen_detector_params,
|
|
40
|
+
"seasonality": result.chosen_seasonality,
|
|
41
|
+
"scoring_metric": result.scoring_metric,
|
|
42
|
+
}
|
|
43
|
+
return hashlib.sha256(json_dumps_sorted(payload).encode()).hexdigest()[:6]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _flatten_scalar_seasonality(seasonality: list | None) -> list[str]:
|
|
47
|
+
if not seasonality:
|
|
48
|
+
return []
|
|
49
|
+
cols: list[str] = []
|
|
50
|
+
for comp in seasonality:
|
|
51
|
+
for c in [comp] if isinstance(comp, str) else comp:
|
|
52
|
+
if c not in cols:
|
|
53
|
+
cols.append(c)
|
|
54
|
+
return cols
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_alerting(original: MetricConfig, result: AutoTuneResult) -> list[dict] | None:
|
|
58
|
+
if not original.alerting:
|
|
59
|
+
return None
|
|
60
|
+
first = original.alerting[0].model_dump(exclude_none=True, exclude_defaults=True)
|
|
61
|
+
if result.consecutive_anomalies is not None:
|
|
62
|
+
first["consecutive_anomalies"] = result.consecutive_anomalies
|
|
63
|
+
first["min_detectors"] = 1 # single tuned detector now
|
|
64
|
+
return [first]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_body(original: MetricConfig, result: AutoTuneResult, new_name: str) -> dict[str, Any]:
|
|
68
|
+
body: dict[str, Any] = {"name": new_name}
|
|
69
|
+
if original.description:
|
|
70
|
+
body["description"] = original.description
|
|
71
|
+
if original.tags:
|
|
72
|
+
body["tags"] = original.tags
|
|
73
|
+
if original.profile:
|
|
74
|
+
body["profile"] = original.profile
|
|
75
|
+
if original.query is not None:
|
|
76
|
+
body["query"] = original.query
|
|
77
|
+
elif original.query_file is not None:
|
|
78
|
+
body["query_file"] = str(original.query_file)
|
|
79
|
+
if original.query_columns is not None:
|
|
80
|
+
body["query_columns"] = original.query_columns.model_dump(exclude_none=True)
|
|
81
|
+
body["interval"] = original.interval
|
|
82
|
+
if result.training_start is not None:
|
|
83
|
+
body["loading_start_time"] = result.training_start.strftime("%Y-%m-%d %H:%M:%S")
|
|
84
|
+
elif original.loading_start_time:
|
|
85
|
+
body["loading_start_time"] = original.loading_start_time
|
|
86
|
+
body["loading_batch_size"] = original.loading_batch_size
|
|
87
|
+
|
|
88
|
+
scalar_cols = _flatten_scalar_seasonality(result.chosen_seasonality)
|
|
89
|
+
if scalar_cols:
|
|
90
|
+
body["seasonality_columns"] = scalar_cols
|
|
91
|
+
elif original.seasonality_columns:
|
|
92
|
+
body["seasonality_columns"] = original.seasonality_columns
|
|
93
|
+
|
|
94
|
+
body["detectors"] = [
|
|
95
|
+
{"type": result.chosen_detector_type, "params": result.chosen_detector_params}
|
|
96
|
+
]
|
|
97
|
+
alerting = _build_alerting(original, result)
|
|
98
|
+
if alerting is not None:
|
|
99
|
+
body["alerting"] = alerting
|
|
100
|
+
body["enabled"] = True
|
|
101
|
+
return body
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_comments(result: AutoTuneResult, source_label: str, run_id: str) -> str:
|
|
105
|
+
lines = [
|
|
106
|
+
_RULE,
|
|
107
|
+
f"# Auto-tuned by `dtk autotune` (run_id: {run_id})",
|
|
108
|
+
f"# Generated from: {source_label}",
|
|
109
|
+
"#",
|
|
110
|
+
]
|
|
111
|
+
if result.training_start and result.training_end:
|
|
112
|
+
lines.append(
|
|
113
|
+
f"# Training period : {result.training_start:%Y-%m-%d %H:%M:%S} → "
|
|
114
|
+
f"{result.training_end:%Y-%m-%d %H:%M:%S} UTC ({result.n_points:,} points)"
|
|
115
|
+
)
|
|
116
|
+
summary = result.labels_summary
|
|
117
|
+
lines.append(
|
|
118
|
+
f"# Labels : {result.mode} — {summary.get('intervals', 0)} interval(s), "
|
|
119
|
+
f"{summary.get('points', 0)} point(s), "
|
|
120
|
+
f"{summary.get('positive_grid_points', 0)} labeled grid point(s)"
|
|
121
|
+
)
|
|
122
|
+
folds = " ".join(f"{f:.2f}" for f in result.cv_per_fold) or "—"
|
|
123
|
+
lines.append(
|
|
124
|
+
f"# Scoring metric : {result.scoring_metric} = {result.score:.3f} (CV folds: {folds})"
|
|
125
|
+
)
|
|
126
|
+
lines.append("#")
|
|
127
|
+
for entry in result.decision_log:
|
|
128
|
+
label = _STAGE_LABELS.get(entry.get("stage", ""))
|
|
129
|
+
if label:
|
|
130
|
+
lines.append(f"# {label:<12}: {entry.get('message', '')}")
|
|
131
|
+
lines.append("#")
|
|
132
|
+
lines.append(f"# Reproduce: dtk autotune --select {result.metric_name}")
|
|
133
|
+
lines.append(_RULE)
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def emit_tuned_config(
|
|
138
|
+
*,
|
|
139
|
+
original_config: MetricConfig,
|
|
140
|
+
original_path: Path,
|
|
141
|
+
result: AutoTuneResult,
|
|
142
|
+
project_root: Path,
|
|
143
|
+
run_id: str | None = None,
|
|
144
|
+
) -> tuple[Path, str, str]:
|
|
145
|
+
"""Return ``(out_path, yaml_text, run_id)`` for the tuned config.
|
|
146
|
+
|
|
147
|
+
Validates the body through ``MetricConfig`` before returning so callers
|
|
148
|
+
never write an unparseable file. Does not touch the filesystem.
|
|
149
|
+
"""
|
|
150
|
+
run_id = run_id or compute_run_id(result)
|
|
151
|
+
new_name = f"{original_config.name}__tuned_{run_id}"
|
|
152
|
+
body = _build_body(original_config, result, new_name)
|
|
153
|
+
|
|
154
|
+
# Fail fast on an invalid body rather than writing a broken config.
|
|
155
|
+
MetricConfig.model_validate(body)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
source_label = str(original_path.relative_to(project_root))
|
|
159
|
+
except ValueError:
|
|
160
|
+
source_label = original_path.name
|
|
161
|
+
|
|
162
|
+
comments = _build_comments(result, source_label, run_id)
|
|
163
|
+
yaml_body = yaml.safe_dump(body, sort_keys=False, default_flow_style=False, allow_unicode=True)
|
|
164
|
+
text = f"{comments}\n{yaml_body}"
|
|
165
|
+
|
|
166
|
+
out_path = project_root / "metrics" / f"{original_path.stem}__tuned_{run_id}.yml"
|
|
167
|
+
return out_path, text, run_id
|