detectkit 0.52.1__tar.gz → 0.53.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.52.1/detectkit.egg-info → detectkit-0.53.0}/PKG-INFO +1 -1
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/__init__.py +1 -1
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/autotuner.py +100 -16
- detectkit-0.53.0/detectkit/autotune/axis_spec.py +118 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/config_emitter.py +5 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/detector_select.py +11 -6
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/grid_search.py +52 -29
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/result.py +7 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/settings.py +15 -1
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/window_select.py +15 -6
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/alerting.md +6 -3
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/autotune.md +15 -7
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/cli.md +11 -6
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/detectors.md +10 -4
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +10 -6
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +5 -4
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +14 -18
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/autoreg.py +36 -11
- detectkit-0.53.0/detectkit/tuning/assets/tune.js +187 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/tuning/config_writer.py +46 -9
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/tuning/payload.py +36 -5
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/tuning/server.py +26 -0
- {detectkit-0.52.1 → detectkit-0.53.0/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit.egg-info/SOURCES.txt +1 -0
- detectkit-0.52.1/detectkit/tuning/assets/tune.js +0 -187
- {detectkit-0.52.1 → detectkit-0.53.0}/LICENSE +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/MANIFEST.in +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/README.md +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/crossval.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/labels.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/runner.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/autotune.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/osi.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/tune.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/ui.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/metric_config.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/metric_io.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/core/models.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/reporting/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/reporting/assets/report.js +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/reporting/builder.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/reporting/html_report.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/errors.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/exporter.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/importer.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/osi_model.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/semantic/query_gen.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/tuning/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/tuning/html.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/assets/ui.js +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/html.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/jobs.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/metric_files.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/overview.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/ui/server.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/pyproject.toml +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/requirements.txt +0 -0
- {detectkit-0.52.1 → detectkit-0.53.0}/setup.cfg +0 -0
- {detectkit-0.52.1 → detectkit-0.53.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.53.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import contextlib
|
|
10
10
|
import logging
|
|
11
11
|
from collections.abc import Callable, Iterator
|
|
12
|
+
from dataclasses import dataclass
|
|
12
13
|
from datetime import datetime, timedelta
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
@@ -16,6 +17,7 @@ import numpy as np
|
|
|
16
17
|
|
|
17
18
|
from detectkit.autotune._base import AutoTuneError, _AutoTuneBase
|
|
18
19
|
from detectkit.autotune._types import CandidateEval, TuneMode
|
|
20
|
+
from detectkit.autotune.axis_spec import max_context_size
|
|
19
21
|
from detectkit.autotune.crossval import build_cv_plan, predictions_from_results
|
|
20
22
|
from detectkit.autotune.detector_select import select_detector_types
|
|
21
23
|
from detectkit.autotune.grid_search import grid_search
|
|
@@ -71,6 +73,33 @@ def _consecutive(flags: np.ndarray, k: int) -> np.ndarray:
|
|
|
71
73
|
return out
|
|
72
74
|
|
|
73
75
|
|
|
76
|
+
def _fraction_fire(flags: np.ndarray, window_points: int, share: float) -> np.ndarray:
|
|
77
|
+
"""Mark index i where the fraction alert rule fires — the pure-numpy sibling
|
|
78
|
+
of ``_decision._share_fire``: the latest point itself is anomalous (a stale
|
|
79
|
+
window never fires) AND the anomalous share over the trailing
|
|
80
|
+
``window_points`` slots (current point inclusive) reaches *share*. Slots
|
|
81
|
+
before the series start, gaps and invalid points all count in the
|
|
82
|
+
denominator only, exactly like the pipeline's missing-slot handling."""
|
|
83
|
+
n = len(flags)
|
|
84
|
+
if n == 0 or window_points <= 0:
|
|
85
|
+
return flags.copy()
|
|
86
|
+
counts = np.convolve(flags.astype(np.int64), np.ones(window_points, dtype=np.int64))[:n]
|
|
87
|
+
return flags & (counts >= share * window_points)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class _AlertRuleChoice:
|
|
92
|
+
"""Outcome of the supervised alert-rule sweep.
|
|
93
|
+
|
|
94
|
+
``window_points``/``min_anomaly_share`` are set only when the OR-ed
|
|
95
|
+
fraction rule strictly beat the consecutive-only optimum; the deployed
|
|
96
|
+
behavior (consecutive OR fraction) is exactly what was scored."""
|
|
97
|
+
|
|
98
|
+
consecutive_anomalies: int | None
|
|
99
|
+
window_points: int | None = None
|
|
100
|
+
min_anomaly_share: float | None = None
|
|
101
|
+
|
|
102
|
+
|
|
74
103
|
class AutoTuner(_AutoTuneBase):
|
|
75
104
|
"""Runs the load-free tuning pipeline and returns an :class:`AutoTuneResult`."""
|
|
76
105
|
|
|
@@ -83,12 +112,17 @@ class AutoTuner(_AutoTuneBase):
|
|
|
83
112
|
)
|
|
84
113
|
|
|
85
114
|
grid = window_grid(self)
|
|
86
|
-
|
|
87
|
-
|
|
115
|
+
# Reserve the largest context any candidate the search can build might
|
|
116
|
+
# need (window + stabilization warm-up + AR lags), not just the raw
|
|
117
|
+
# window size — otherwise folds silently score points where detect()
|
|
118
|
+
# returns insufficient_data / missing_lags (valid=False), degrading
|
|
119
|
+
# the CV signal without erroring.
|
|
120
|
+
max_context = max_context_size(self, grid)
|
|
121
|
+
self.cv_plan = build_cv_plan(n, max_context, self.settings.fold_count)
|
|
88
122
|
if not self.cv_plan.fold_bounds:
|
|
89
123
|
raise AutoTuneError(
|
|
90
124
|
f"not enough datapoints ({n}) for {self.settings.fold_count}-fold "
|
|
91
|
-
f"cross-validation with a {
|
|
125
|
+
f"cross-validation with a {max_context}-point context window"
|
|
92
126
|
)
|
|
93
127
|
|
|
94
128
|
gt = self.ground_truth
|
|
@@ -111,36 +145,80 @@ class AutoTuner(_AutoTuneBase):
|
|
|
111
145
|
if best is None:
|
|
112
146
|
raise AutoTuneError("no viable detector candidate found for this data")
|
|
113
147
|
|
|
114
|
-
|
|
148
|
+
alert_rule = self._select_alert_window(best.detector_type, best.params)
|
|
115
149
|
|
|
116
|
-
return self._build_result(seasonality, best,
|
|
150
|
+
return self._build_result(seasonality, best, alert_rule)
|
|
117
151
|
|
|
118
152
|
# ------------------------------------------------------------------
|
|
119
153
|
|
|
120
|
-
def _select_alert_window(
|
|
121
|
-
|
|
154
|
+
def _select_alert_window(
|
|
155
|
+
self, detector_type: str, params: dict[str, Any]
|
|
156
|
+
) -> _AlertRuleChoice | None:
|
|
157
|
+
"""Sweep the alert rule on labeled incidents (supervised only).
|
|
158
|
+
|
|
159
|
+
Two passes over one ``detect()`` run: the legacy 1-D
|
|
160
|
+
``consecutive_anomalies`` sweep first, then a 2-D (window × share)
|
|
161
|
+
sweep of the fraction rule **OR-ed with the chosen consecutive rule**
|
|
162
|
+
— scoring exactly the composite the pipeline would deploy. The pair is
|
|
163
|
+
adopted only on a strictly greater score, so the legacy rule wins ties
|
|
164
|
+
and existing behavior is byte-stable when the fraction rule doesn't
|
|
165
|
+
help.
|
|
166
|
+
"""
|
|
122
167
|
if self.ground_truth.mode != TuneMode.SUPERVISED:
|
|
123
168
|
return None
|
|
124
169
|
detector = DetectorFactory.create(detector_type, params)
|
|
125
170
|
y_pred, y_score, valid = predictions_from_results(detector.detect(self.data))
|
|
126
171
|
y_true = self.ground_truth.y_true
|
|
172
|
+
metric, beta = self.settings.metric, self.settings.beta
|
|
173
|
+
|
|
174
|
+
def _score(alert: np.ndarray) -> float:
|
|
175
|
+
# Same invalid-point handling seam as the CV folds (pointwise
|
|
176
|
+
# metrics mask; the segment-aware one keeps unmasked arrays).
|
|
177
|
+
yt, yp, ys = arrays_for_metric(y_true, alert, y_score, valid, metric)
|
|
178
|
+
return score_predictions(yt, yp, ys, metric, beta)
|
|
179
|
+
|
|
127
180
|
best_k = 1
|
|
128
181
|
best_score = float("-inf")
|
|
129
182
|
for k in _ALERT_WINDOW_GRID:
|
|
130
|
-
|
|
131
|
-
# Same invalid-point handling seam as the CV folds (pointwise
|
|
132
|
-
# metrics mask; the segment-aware one keeps unmasked arrays).
|
|
133
|
-
yt, yp, ys = arrays_for_metric(y_true, alert, y_score, valid, self.settings.metric)
|
|
134
|
-
score = score_predictions(yt, yp, ys, self.settings.metric, self.settings.beta)
|
|
183
|
+
score = _score(_consecutive(y_pred, k))
|
|
135
184
|
if score > best_score:
|
|
136
185
|
best_score, best_k = score, k
|
|
186
|
+
|
|
187
|
+
consecutive_fire = _consecutive(y_pred, best_k)
|
|
188
|
+
n = len(y_pred)
|
|
189
|
+
best_pair: tuple[int, float] | None = None
|
|
190
|
+
for window_points in self.settings.alert_window_points_grid:
|
|
191
|
+
# < 2 would degenerate to consecutive_anomalies=1 (MetricConfig
|
|
192
|
+
# rejects it); a window longer than the series can never be
|
|
193
|
+
# observed at its own width.
|
|
194
|
+
if window_points < 2 or window_points > n:
|
|
195
|
+
continue
|
|
196
|
+
for share in self.settings.alert_share_grid:
|
|
197
|
+
alert = consecutive_fire | _fraction_fire(y_pred, window_points, share)
|
|
198
|
+
score = _score(alert)
|
|
199
|
+
if score > best_score:
|
|
200
|
+
best_score, best_pair = score, (window_points, share)
|
|
201
|
+
|
|
202
|
+
if best_pair is not None:
|
|
203
|
+
window_points, share = best_pair
|
|
204
|
+
self.log(
|
|
205
|
+
"window",
|
|
206
|
+
f"consecutive_anomalies={best_k} OR anomaly_window={window_points}p × "
|
|
207
|
+
f"min_anomaly_share={share} "
|
|
208
|
+
f"(max {metric.value}={best_score:.3f} on labeled incidents)",
|
|
209
|
+
consecutive_anomalies=best_k,
|
|
210
|
+
anomaly_window_points=window_points,
|
|
211
|
+
min_anomaly_share=share,
|
|
212
|
+
)
|
|
213
|
+
return _AlertRuleChoice(best_k, window_points, share)
|
|
214
|
+
|
|
137
215
|
self.log(
|
|
138
216
|
"window",
|
|
139
217
|
f"consecutive_anomalies={best_k} "
|
|
140
|
-
f"(max {
|
|
218
|
+
f"(max {metric.value}={best_score:.3f} on labeled incidents)",
|
|
141
219
|
consecutive_anomalies=best_k,
|
|
142
220
|
)
|
|
143
|
-
return best_k
|
|
221
|
+
return _AlertRuleChoice(best_k)
|
|
144
222
|
|
|
145
223
|
def _clean_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
146
224
|
"""Drop None/empty values so the emitted config is tidy."""
|
|
@@ -154,7 +232,7 @@ class AutoTuner(_AutoTuneBase):
|
|
|
154
232
|
return out
|
|
155
233
|
|
|
156
234
|
def _build_result(
|
|
157
|
-
self, seasonality: list | None, best: CandidateEval,
|
|
235
|
+
self, seasonality: list | None, best: CandidateEval, alert_rule: _AlertRuleChoice | None
|
|
158
236
|
) -> AutoTuneResult:
|
|
159
237
|
timestamps = self.data["timestamp"]
|
|
160
238
|
training_start = _ts_to_dt(timestamps[0]) if len(timestamps) else None
|
|
@@ -194,7 +272,13 @@ class AutoTuner(_AutoTuneBase):
|
|
|
194
272
|
score=best.score,
|
|
195
273
|
cv_per_fold=best.fold_scores.per_fold,
|
|
196
274
|
cv_stability_penalty=best.fold_scores.stability_penalty,
|
|
197
|
-
consecutive_anomalies=
|
|
275
|
+
consecutive_anomalies=alert_rule.consecutive_anomalies if alert_rule else None,
|
|
276
|
+
anomaly_window=(
|
|
277
|
+
f"{alert_rule.window_points * self.interval_seconds}s"
|
|
278
|
+
if alert_rule and alert_rule.window_points is not None
|
|
279
|
+
else None
|
|
280
|
+
),
|
|
281
|
+
min_anomaly_share=alert_rule.min_anomaly_share if alert_rule else None,
|
|
198
282
|
candidate_detector_ids=self.evaluated_ids(),
|
|
199
283
|
candidates=candidates,
|
|
200
284
|
group_votes=group_votes,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Per-detector-type axis specification for the grid search (issue #97 Phase 2).
|
|
2
|
+
|
|
3
|
+
``grid_search`` used to be one hardcoded coordinate sweep whose every axis name
|
|
4
|
+
(``window_weights``, ``half_life``, ``detrend``, ``seasonality_components``) is
|
|
5
|
+
a ``WindowedStatDetector`` constructor kwarg. The prediction-based ``autoreg``
|
|
6
|
+
detector accepts none of those (and *raises* on truthy
|
|
7
|
+
``seasonality_components``), so the sweep dispatches on a small spec keyed by
|
|
8
|
+
detector type — kept here, NOT on the detector classes, the same
|
|
9
|
+
easy-to-remove externalized-knowledge choice as ``detector_select``'s
|
|
10
|
+
suitability spec. Unlisted (future) types get the windowed default, which is
|
|
11
|
+
behavior-identical to the pre-seam sweep.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from detectkit.detectors.factory import DetectorFactory
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from detectkit.autotune._base import _AutoTuneBase
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class AxisSpec:
|
|
27
|
+
"""Which grid-search axes apply to a detector type, plus per-type floors.
|
|
28
|
+
|
|
29
|
+
``min_samples_floor`` / ``threshold_default`` override the class-attribute
|
|
30
|
+
lookup (``MIN_SAMPLES_FLOOR`` / ``THRESHOLD_DEFAULT``) for types that don't
|
|
31
|
+
carry the windowed template's attributes; ``None`` keeps the lookup.
|
|
32
|
+
``initial`` seeds the accepted-params dict so type-specific defaults (e.g.
|
|
33
|
+
autoreg's default-on stabilization) are explicit from the first candidate.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
seasonality: bool = True # inject the chosen seasonality_components
|
|
37
|
+
weighting: bool = True # window_weights + half_life axes
|
|
38
|
+
detrend: bool = True # detrend axis (still trend-gated)
|
|
39
|
+
stabilization: bool = True # stabilization axis
|
|
40
|
+
lags: bool = False # AR-order axis (settings.lags_grid)
|
|
41
|
+
min_samples_floor: int | None = None
|
|
42
|
+
threshold_default: float | None = None
|
|
43
|
+
initial: dict[str, Any] = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_WINDOWED = AxisSpec()
|
|
47
|
+
|
|
48
|
+
_AXIS_SPECS: dict[str, AxisSpec] = {
|
|
49
|
+
# v1 autoreg has no seasonality/smoothing/weighting/detrend by design (the
|
|
50
|
+
# AR residual model adapts to level and short-range dynamics on its own);
|
|
51
|
+
# it *rejects* seasonality_components, so the seam must never inject them.
|
|
52
|
+
# min_samples additionally floors at lags + 2 — see ``min_samples_for``.
|
|
53
|
+
"autoreg": AxisSpec(
|
|
54
|
+
seasonality=False,
|
|
55
|
+
weighting=False,
|
|
56
|
+
detrend=False,
|
|
57
|
+
lags=True,
|
|
58
|
+
min_samples_floor=10,
|
|
59
|
+
threshold_default=3.0,
|
|
60
|
+
initial={"lags": 5, "stabilization": "clamp"},
|
|
61
|
+
),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def axis_spec_for(detector_type: str) -> AxisSpec:
|
|
66
|
+
"""The axis spec for *detector_type* (windowed default when unlisted)."""
|
|
67
|
+
return _AXIS_SPECS.get(detector_type, _WINDOWED)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_floor(detector_type: str) -> int:
|
|
71
|
+
"""Min-samples floor: spec override first, then the class attribute."""
|
|
72
|
+
spec = axis_spec_for(detector_type)
|
|
73
|
+
if spec.min_samples_floor is not None:
|
|
74
|
+
return spec.min_samples_floor
|
|
75
|
+
detector_cls = DetectorFactory.DETECTOR_TYPES[detector_type]
|
|
76
|
+
return int(getattr(detector_cls, "MIN_SAMPLES_FLOOR", 1))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_threshold_default(detector_type: str) -> float:
|
|
80
|
+
"""Default threshold: spec override first, then the class attribute."""
|
|
81
|
+
spec = axis_spec_for(detector_type)
|
|
82
|
+
if spec.threshold_default is not None:
|
|
83
|
+
return spec.threshold_default
|
|
84
|
+
detector_cls = DetectorFactory.DETECTOR_TYPES[detector_type]
|
|
85
|
+
return float(getattr(detector_cls, "THRESHOLD_DEFAULT", 3.0))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def max_context_size(tuner: _AutoTuneBase, grid: list[int]) -> int:
|
|
89
|
+
"""Largest ``get_context_size()`` any candidate the search can build needs.
|
|
90
|
+
|
|
91
|
+
The CV plan must reserve this much lead-in — deriving it from the raw
|
|
92
|
+
window grid alone (the pre-fix behavior) under-reserves for stabilization
|
|
93
|
+
(an extra window of warm-up) and for autoreg (``+ lags``), silently
|
|
94
|
+
scoring points where ``detect()`` returns ``insufficient_data`` /
|
|
95
|
+
``missing_lags`` (they become ``valid=False``, degrading the CV signal
|
|
96
|
+
without erroring).
|
|
97
|
+
"""
|
|
98
|
+
from detectkit.autotune.detector_select import _candidate_types
|
|
99
|
+
|
|
100
|
+
max_window = max([*grid, 100])
|
|
101
|
+
context = max_window
|
|
102
|
+
for detector_type in _candidate_types(tuner):
|
|
103
|
+
spec = axis_spec_for(detector_type)
|
|
104
|
+
params: dict[str, Any] = {**tuner.settings.fixed_params}
|
|
105
|
+
params["window_size"] = max_window
|
|
106
|
+
# Worst-case axes the sweep can adopt: stabilization adds a window of
|
|
107
|
+
# warm-up; the largest lags in the grid adds `lags` context points.
|
|
108
|
+
params["stabilization"] = "clamp"
|
|
109
|
+
if spec.lags and tuner.settings.lags_grid:
|
|
110
|
+
max_lags = max(tuner.settings.lags_grid)
|
|
111
|
+
params["lags"] = max_lags
|
|
112
|
+
params["min_samples"] = max(max_lags + 2, min(30, max_window))
|
|
113
|
+
try:
|
|
114
|
+
detector = DetectorFactory.create(detector_type, params)
|
|
115
|
+
except ValueError:
|
|
116
|
+
continue
|
|
117
|
+
context = max(context, detector.get_context_size())
|
|
118
|
+
return context
|
|
@@ -61,6 +61,11 @@ def _build_alerting(original: MetricConfig, result: AutoTuneResult) -> list[dict
|
|
|
61
61
|
first = original.alerting[0].model_dump(exclude_none=True, exclude_defaults=True)
|
|
62
62
|
if result.consecutive_anomalies is not None:
|
|
63
63
|
first["consecutive_anomalies"] = result.consecutive_anomalies
|
|
64
|
+
if result.anomaly_window is not None and result.min_anomaly_share is not None:
|
|
65
|
+
# Exact-seconds duration: round-trips losslessly through
|
|
66
|
+
# `AlertConditions.from_alert_config` back to the swept point count.
|
|
67
|
+
first["anomaly_window"] = result.anomaly_window
|
|
68
|
+
first["min_anomaly_share"] = result.min_anomaly_share
|
|
64
69
|
first["min_detectors"] = 1 # single tuned detector now
|
|
65
70
|
return [first]
|
|
66
71
|
|
|
@@ -20,12 +20,10 @@ from detectkit.autotune.distribution import compute_distribution_features
|
|
|
20
20
|
from detectkit.detectors.factory import DetectorFactory
|
|
21
21
|
from detectkit.detectors.seasonality import parse_seasonality_data
|
|
22
22
|
|
|
23
|
-
# Detector types the engine can auto-tune
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
|
|
27
|
-
# per-type grid seam before it can be tuned here.
|
|
28
|
-
_EXCLUDED_TYPES = {"manual", "manual_bounds", "autoreg"}
|
|
23
|
+
# Detector types the engine can auto-tune. Derived from the factory minus the
|
|
24
|
+
# manual/stateless ones; the prediction-based "autoreg" participates via its
|
|
25
|
+
# AxisSpec (see autotune/axis_spec.py), which gates the windowed-only axes.
|
|
26
|
+
_EXCLUDED_TYPES = {"manual", "manual_bounds"}
|
|
29
27
|
# A seasonal sub-group needs at least this many points to vote.
|
|
30
28
|
_MIN_GROUP_FOR_VOTE = 10
|
|
31
29
|
# Cap on voting sub-groups so a high-cardinality component can't explode cost.
|
|
@@ -56,6 +54,13 @@ def detector_suitability(detector_type: str, features: dict[str, float]) -> floa
|
|
|
56
54
|
if detector_type == "iqr":
|
|
57
55
|
# Best on skewed / asymmetric distributions.
|
|
58
56
|
return _clip01(0.3 + 0.5 * min(1.0, skewness / 3.0) + 0.4 * outlier_fraction)
|
|
57
|
+
if detector_type == "autoreg":
|
|
58
|
+
# Prediction-based: models short-range dynamics, not level, and the
|
|
59
|
+
# distribution shape says little about dynamics — so the vote is
|
|
60
|
+
# mildly conservative (clean data, where a per-window AR fit is
|
|
61
|
+
# stable, nudges it up; it never outranks a clearly-matched level
|
|
62
|
+
# detector). Ordering only; the grid search still evaluates it.
|
|
63
|
+
return _clip01(0.3 + 0.3 * normality * (1.0 - outlier_fraction))
|
|
59
64
|
return 0.5
|
|
60
65
|
|
|
61
66
|
|
|
@@ -18,6 +18,7 @@ import numpy as np
|
|
|
18
18
|
|
|
19
19
|
from detectkit.autotune._base import _AutoTuneBase
|
|
20
20
|
from detectkit.autotune._types import CandidateEval
|
|
21
|
+
from detectkit.autotune.axis_spec import axis_spec_for, resolve_floor, resolve_threshold_default
|
|
21
22
|
from detectkit.autotune.window_select import (
|
|
22
23
|
detect_level_shift,
|
|
23
24
|
half_life_grid,
|
|
@@ -26,7 +27,6 @@ from detectkit.autotune.window_select import (
|
|
|
26
27
|
select_window,
|
|
27
28
|
trend_present,
|
|
28
29
|
)
|
|
29
|
-
from detectkit.detectors.factory import DetectorFactory
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def _initial_window(grid: list[int], floor: int) -> int | None:
|
|
@@ -92,9 +92,9 @@ def grid_search(
|
|
|
92
92
|
best_overall: CandidateEval | None = None
|
|
93
93
|
|
|
94
94
|
for detector_type in detector_types:
|
|
95
|
-
|
|
96
|
-
floor =
|
|
97
|
-
threshold_default =
|
|
95
|
+
spec = axis_spec_for(detector_type)
|
|
96
|
+
floor = resolve_floor(detector_type)
|
|
97
|
+
threshold_default = resolve_threshold_default(detector_type)
|
|
98
98
|
window = _initial_window(grid, floor)
|
|
99
99
|
if window is None:
|
|
100
100
|
tuner.log(
|
|
@@ -104,11 +104,12 @@ def grid_search(
|
|
|
104
104
|
continue
|
|
105
105
|
|
|
106
106
|
accepted: dict[str, Any] = {
|
|
107
|
-
**base,
|
|
107
|
+
**(base if spec.seasonality else {}),
|
|
108
|
+
**spec.initial,
|
|
108
109
|
"threshold": threshold_default,
|
|
109
110
|
"window_size": window,
|
|
110
|
-
"min_samples": min_samples_for(window, floor),
|
|
111
111
|
}
|
|
112
|
+
accepted["min_samples"] = min_samples_for(window, floor, accepted.get("lags"))
|
|
112
113
|
best = tuner.safe_evaluate(detector_type, accepted)
|
|
113
114
|
if best is None:
|
|
114
115
|
continue
|
|
@@ -121,28 +122,47 @@ def grid_search(
|
|
|
121
122
|
if ev is not None and ev.score > best.score:
|
|
122
123
|
best, accepted["threshold"] = ev, threshold
|
|
123
124
|
|
|
125
|
+
# Axis 1b: AR order (autoreg only) — swept like threshold, with the
|
|
126
|
+
# min-samples floor tracking lags + 2 (the detector validates that).
|
|
127
|
+
if spec.lags:
|
|
128
|
+
for lags in tuner.settings.lags_grid:
|
|
129
|
+
if lags == accepted.get("lags"):
|
|
130
|
+
continue
|
|
131
|
+
candidate = {
|
|
132
|
+
**accepted,
|
|
133
|
+
"lags": lags,
|
|
134
|
+
"min_samples": min_samples_for(accepted["window_size"], floor, lags),
|
|
135
|
+
}
|
|
136
|
+
ev = tuner.safe_evaluate(detector_type, candidate)
|
|
137
|
+
if ev is not None and ev.score > best.score:
|
|
138
|
+
best = ev
|
|
139
|
+
accepted["lags"] = lags
|
|
140
|
+
accepted["min_samples"] = candidate["min_samples"]
|
|
141
|
+
|
|
124
142
|
# Axis 2: recency weighting (only adopt if it clears the margin).
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
ev = tuner.safe_evaluate(detector_type, {**accepted, "window_weights": weights})
|
|
129
|
-
if ev is not None and ev.score > best.score + eps:
|
|
130
|
-
best, accepted["window_weights"] = ev, weights
|
|
131
|
-
|
|
132
|
-
# Axis 2b: half-life of the recency weighting — only when exponential
|
|
133
|
-
# weighting was adopted. The detector defaults to a fixed half-life; this
|
|
134
|
-
# lets the search pick a faster-forgetting baseline that tracks the current
|
|
135
|
-
# regime (the term that matters on a metric that shifted level).
|
|
136
|
-
if accepted.get("window_weights") == "exponential":
|
|
137
|
-
for half_life in half_life_grid(accepted["window_size"], accepted["min_samples"]):
|
|
138
|
-
if half_life == accepted.get("half_life"):
|
|
143
|
+
if spec.weighting:
|
|
144
|
+
for weights in (None, "exponential"):
|
|
145
|
+
if weights == accepted.get("window_weights"):
|
|
139
146
|
continue
|
|
140
|
-
ev = tuner.safe_evaluate(detector_type, {**accepted, "
|
|
147
|
+
ev = tuner.safe_evaluate(detector_type, {**accepted, "window_weights": weights})
|
|
141
148
|
if ev is not None and ev.score > best.score + eps:
|
|
142
|
-
best, accepted["
|
|
149
|
+
best, accepted["window_weights"] = ev, weights
|
|
150
|
+
|
|
151
|
+
# Axis 2b: half-life of the recency weighting — only when exponential
|
|
152
|
+
# weighting was adopted. The detector defaults to a fixed half-life;
|
|
153
|
+
# this lets the search pick a faster-forgetting baseline that tracks
|
|
154
|
+
# the current regime (the term that matters on a metric that shifted
|
|
155
|
+
# level).
|
|
156
|
+
if accepted.get("window_weights") == "exponential":
|
|
157
|
+
for half_life in half_life_grid(accepted["window_size"], accepted["min_samples"]):
|
|
158
|
+
if half_life == accepted.get("half_life"):
|
|
159
|
+
continue
|
|
160
|
+
ev = tuner.safe_evaluate(detector_type, {**accepted, "half_life": half_life})
|
|
161
|
+
if ev is not None and ev.score > best.score + eps:
|
|
162
|
+
best, accepted["half_life"] = ev, half_life
|
|
143
163
|
|
|
144
164
|
# Axis 3: detrend (gated by the trend pre-test).
|
|
145
|
-
if has_trend:
|
|
165
|
+
if spec.detrend and has_trend:
|
|
146
166
|
for detrend in (None, "linear"):
|
|
147
167
|
if detrend == accepted.get("detrend"):
|
|
148
168
|
continue
|
|
@@ -156,12 +176,15 @@ def grid_search(
|
|
|
156
176
|
# the band and mask its own tail — usually decisive on labeled data
|
|
157
177
|
# with long incidents, near-neutral on clean series. Swept before the
|
|
158
178
|
# window axis so select_window evaluates with the adopted baseline.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
179
|
+
if spec.stabilization:
|
|
180
|
+
for stabilization in (None, "clamp"):
|
|
181
|
+
if stabilization == accepted.get("stabilization"):
|
|
182
|
+
continue
|
|
183
|
+
ev = tuner.safe_evaluate(
|
|
184
|
+
detector_type, {**accepted, "stabilization": stabilization}
|
|
185
|
+
)
|
|
186
|
+
if ev is not None and ev.score > best.score + eps:
|
|
187
|
+
best, accepted["stabilization"] = ev, stabilization
|
|
165
188
|
|
|
166
189
|
# Axis 4: window size (large-window tie-bias, trend-gated in select_window).
|
|
167
190
|
window_best = select_window(tuner, detector_type, accepted, best, grid)
|
|
@@ -35,6 +35,13 @@ class AutoTuneResult:
|
|
|
35
35
|
|
|
36
36
|
consecutive_anomalies: int | None
|
|
37
37
|
candidate_detector_ids: list[str]
|
|
38
|
+
# Fraction alert rule (issue #101): an exact-seconds duration string (e.g.
|
|
39
|
+
# "7200s", lossless through `Interval(...).seconds // interval_seconds`) +
|
|
40
|
+
# share, set only when the supervised sweep adopts the OR-ed fraction rule.
|
|
41
|
+
# Like consecutive_anomalies they are non-identity metadata — deliberately
|
|
42
|
+
# excluded from `compute_run_id`.
|
|
43
|
+
anomaly_window: str | None = None
|
|
44
|
+
min_anomaly_share: float | None = None
|
|
38
45
|
candidates: list[dict[str, Any]] = field(default_factory=list)
|
|
39
46
|
group_votes: list[dict[str, Any]] = field(default_factory=list)
|
|
40
47
|
decision_log: list[dict[str, Any]] = field(default_factory=list)
|
|
@@ -43,10 +43,20 @@ class TuneSettings:
|
|
|
43
43
|
# being trapped flagging its legitimate tail.
|
|
44
44
|
threshold_grid_sigma: tuple[float, ...] = (2.5, 3.0, 3.5, 4.0, 5.0, 6.0) # mad / zscore
|
|
45
45
|
threshold_grid_iqr: tuple[float, ...] = (1.5, 2.0, 3.0, 4.0, 6.0) # iqr (Tukey)
|
|
46
|
+
# AR-order sweep for the prediction-based autoreg detector (axis gated by
|
|
47
|
+
# its AxisSpec; the residual z is in σ-units so it reuses the sigma grid).
|
|
48
|
+
lags_grid: tuple[int, ...] = (2, 3, 5, 8)
|
|
46
49
|
|
|
47
50
|
# History / window selection
|
|
48
51
|
window_tie_margin: float = 0.01 # prefer a larger window within this score gap
|
|
49
52
|
|
|
53
|
+
# Fraction alert window (supervised 2-D sweep, OR-ed with the consecutive
|
|
54
|
+
# rule exactly as the pipeline deploys it). Window grid is in grid points
|
|
55
|
+
# of the metric interval; entries < 2 are dropped (MetricConfig rejects an
|
|
56
|
+
# anomaly_window spanning < 2 intervals). Shares are fractions in (0, 1].
|
|
57
|
+
alert_window_points_grid: tuple[int, ...] = (4, 6, 12, 24)
|
|
58
|
+
alert_share_grid: tuple[float, ...] = (0.2, 0.3, 0.5)
|
|
59
|
+
|
|
50
60
|
# Stage-1 seasonality search probe
|
|
51
61
|
probe_detector_type: str = "mad"
|
|
52
62
|
|
|
@@ -58,5 +68,9 @@ class TuneSettings:
|
|
|
58
68
|
max_history: int | None = None
|
|
59
69
|
|
|
60
70
|
def threshold_grid(self, detector_type: str) -> tuple[float, ...]:
|
|
61
|
-
"""Threshold sweep for a detector type (Tukey multipliers for IQR).
|
|
71
|
+
"""Threshold sweep for a detector type (Tukey multipliers for IQR).
|
|
72
|
+
|
|
73
|
+
``autoreg`` deliberately falls through to the sigma grid: its residual
|
|
74
|
+
z-score is in σ-units, directly comparable with mad/zscore thresholds.
|
|
75
|
+
"""
|
|
62
76
|
return self.threshold_grid_iqr if detector_type == "iqr" else self.threshold_grid_sigma
|
|
@@ -15,7 +15,7 @@ import numpy as np
|
|
|
15
15
|
|
|
16
16
|
from detectkit.autotune._base import _AutoTuneBase
|
|
17
17
|
from detectkit.autotune._types import CandidateEval
|
|
18
|
-
from detectkit.
|
|
18
|
+
from detectkit.autotune.axis_spec import resolve_floor
|
|
19
19
|
from detectkit.detectors.seasonality import parse_seasonality_data
|
|
20
20
|
from detectkit.detectors.statistical._windowed import WindowedStatDetector
|
|
21
21
|
|
|
@@ -27,9 +27,17 @@ from detectkit.detectors.statistical._windowed import WindowedStatDetector
|
|
|
27
27
|
_MSPG_REF = int(WindowedStatDetector.MIN_SAMPLES_PER_GROUP_DEFAULT)
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
def min_samples_for(window_size: int, floor: int) -> int:
|
|
31
|
-
"""Derived min_samples: a quarter of the window, clamped to ``[floor, window]``.
|
|
32
|
-
|
|
30
|
+
def min_samples_for(window_size: int, floor: int, lags: int | None = None) -> int:
|
|
31
|
+
"""Derived min_samples: a quarter of the window, clamped to ``[floor, window]``.
|
|
32
|
+
|
|
33
|
+
For the autoreg detector the floor additionally tracks the AR order: the
|
|
34
|
+
AR(p) + intercept model has ``lags + 1`` unknowns, so at least ``lags + 2``
|
|
35
|
+
fit rows are required (the detector validates exactly that).
|
|
36
|
+
"""
|
|
37
|
+
ms = min(window_size, max(floor, round(window_size / 4)))
|
|
38
|
+
if lags is not None:
|
|
39
|
+
ms = min(window_size, max(ms, lags + 2))
|
|
40
|
+
return ms
|
|
33
41
|
|
|
34
42
|
|
|
35
43
|
def max_seasonal_cardinality(tuner: _AutoTuneBase) -> int:
|
|
@@ -184,12 +192,13 @@ def select_window(
|
|
|
184
192
|
grid: list[int],
|
|
185
193
|
) -> CandidateEval:
|
|
186
194
|
"""Sweep the window grid (final axis); return the tie-biased-largest winner."""
|
|
187
|
-
floor =
|
|
195
|
+
floor = resolve_floor(detector_type)
|
|
196
|
+
lags = accepted.get("lags")
|
|
188
197
|
evals: list[tuple[int, CandidateEval]] = []
|
|
189
198
|
for w in grid:
|
|
190
199
|
if w < floor:
|
|
191
200
|
continue
|
|
192
|
-
candidate = {**accepted, "window_size": w, "min_samples": min_samples_for(w, floor)}
|
|
201
|
+
candidate = {**accepted, "window_size": w, "min_samples": min_samples_for(w, floor, lags)}
|
|
193
202
|
ev = tuner.safe_evaluate(detector_type, candidate)
|
|
194
203
|
if ev is not None:
|
|
195
204
|
evals.append((w, ev))
|
|
@@ -118,9 +118,12 @@ fire and recovery always name one rule; a scattered share-fired incident's
|
|
|
118
118
|
Reports and `dtk ui`'s overview pick up share-fired
|
|
119
119
|
alerts automatically via the shared replay seam.
|
|
120
120
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
Both paths tune this pair now: supervised `dtk autotune` runs a 2-D sweep of
|
|
122
|
+
`anomaly_window` × `min_anomaly_share` OR-ed with the chosen
|
|
123
|
+
`consecutive_anomalies` rule, adopted only on a strictly greater score (a tie
|
|
124
|
+
keeps the consecutive-only rule); `dtk tune`'s cockpit has always-visible
|
|
125
|
+
anomaly-window / min-share rail controls with live replay of the same OR-ed
|
|
126
|
+
rule, and Apply writes the pair back (or removes both when off).
|
|
124
127
|
|
|
125
128
|
## Cooldown (spam control) — **set it in production**
|
|
126
129
|
|