feat: add detection + forecast modules with tests

This commit is contained in:
张宗平
2026-06-11 15:41:10 +08:00
parent 7d1c03b98d
commit 70c791af43
4 changed files with 264 additions and 0 deletions
+77
View File
@@ -0,0 +1,77 @@
"""异常检测模块:ANOMALY_WINDOW × 6 算法多路跑 + 容错。"""
import logging
from typing import Any
logger = logging.getLogger(__name__)
ALL_ALGOS = ["ksigma", "iqr", "grubbs", "shesd", "lof", "sample_ad_model"]
def run_anomaly_window(conn, stable: str, algo: str) -> list[tuple[int, int]]:
"""执行单算法 ANOMALY_WINDOW。
Args:
conn: TDConnection 实例
stable: supertable 名称(不含 ds_ 前缀)
algo: 算法名
Returns:
[(start_ts_ms, end_ts_ms), ...] 异常窗口列表
Raises:
ValueError: 算法不支持
Exception: SQL 执行失败(透传)
"""
if algo not in ALL_ALGOS:
raise ValueError(f"不支持的算法: {algo},可选: {ALL_ALGOS}")
sql = (
f"SELECT _WSTART, _WEND "
f"FROM (ANOMALY_WINDOW(value, \"algo={algo}\") "
f"FROM ds_{stable})"
)
rows = conn.execute(sql)
windows = []
for row in rows:
if row[0] is not None and row[1] is not None:
windows.append((int(row[0]), int(row[1])))
return windows
def detect_all_algos(conn, stable: str, algos: list[str] | None = None) -> dict[str, Any]:
"""依次执行所有指定算法,独立容错。
Args:
conn: TDConnection 实例
stable: supertable 名称
algos: 算法列表,默认 ALL_ALGOS
Returns:
{algo_name: {"windows": [...], "error": None | str}, ...}
"""
algos = algos or ALL_ALGOS
results: dict[str, Any] = {}
for algo in algos:
logger.info("running ANOMALY_WINDOW algo=%s stable=%s", algo, stable)
try:
if algo == "sample_ad_model":
# 检查模型文件
import os
model_path = os.path.join("data", "models", "sample_ad_model")
if not os.path.exists(model_path):
results[algo] = {"windows": [], "error": "model file not found"}
logger.warning("sample_ad_model skip: model file not found at %s", model_path)
continue
windows = run_anomaly_window(conn, stable, algo)
results[algo] = {"windows": windows, "error": None}
logger.info("algo=%s: %d windows found", algo, len(windows))
except Exception as e:
results[algo] = {"windows": [], "error": str(e)}
logger.warning("algo=%s failed (non-fatal): %s", algo, e)
return results
+108
View File
@@ -0,0 +1,108 @@
"""预测式异常模块:FORECAST + 区间外点判定。"""
import logging
from typing import TypedDict
logger = logging.getLogger(__name__)
class ForecastPoint(TypedDict):
ts: int
value: float
_flow: float
_fhigh: float
is_anomaly: bool
def run_forecast(
conn,
stable: str,
algo: str = "holtwinters",
rows: int = 10,
conf: int = 95,
) -> list[tuple[int, float, float]]:
"""执行 FORECAST 查询。
Args:
conn: TDConnection 实例
stable: supertable 名称
algo: 预测算法,默认 holtwinters
rows: 预测行数
conf: 置信度百分比
Returns:
[(frowts_ms, flow, fhigh), ...]
Raises:
RuntimeError: SQL 执行失败
"""
sql = (
f"SELECT _FROWTS, _FLOW, _FHIGH "
f"FROM (FORECAST(value, \"algo={algo},rows={rows},conf={conf}\") "
f"FROM ds_{stable})"
)
try:
raw = conn.execute(sql)
except Exception as e:
raise RuntimeError(f"FORECAST 执行失败: {e}") from e
results = []
for row in raw:
frowts = int(row[0]) if row[0] is not None else 0
flow = float(row[1]) if row[1] is not None else 0.0
fhigh = float(row[2]) if row[2] is not None else 0.0
results.append((frowts, flow, fhigh))
return results
def forecast_anomaly(
conn, stable: str, algo: str = "holtwinters", rows: int = 10, conf: int = 95
) -> list[ForecastPoint]:
"""FORECAST 预测并判定区间外异常。
步骤:
1. 执行 FORECAST,获取 [(_flow, _fhigh)] 区间
2. 查询预测区间对应的原始数据
3. 比较判定:value < _flow or value > _fhigh → is_anomaly
Args:
conn: TDConnection 实例
stable: supertable 名称
algo: 预测算法
rows: 预测行数
conf: 置信度百分比
Returns:
ForecastPoint 列表(含 is_anomaly 标记)
"""
forecasts = run_forecast(conn, stable, algo, rows, conf)
if not forecasts:
return []
frowtses = [f[0] for f in forecasts]
start_ts = min(frowtses)
end_ts = max(frowtses)
sql = f"SELECT ts, value FROM s_{stable} WHERE ts >= {start_ts} AND ts <= {end_ts}"
raw = conn.execute(sql)
fmap = {f[0]: (f[1], f[2]) for f in forecasts}
results: list[ForecastPoint] = []
for row in raw:
ts = int(row[0])
value = float(row[1])
if ts in fmap:
flow, fhigh = fmap[ts]
is_anom = value < flow or value > fhigh
else:
flow, fhigh = 0.0, 0.0
is_anom = False
results.append(ForecastPoint(
ts=ts, value=value, _flow=flow, _fhigh=fhigh, is_anomaly=is_anom
))
logger.info(
"forecast_anomaly: %d points, %d anomalies",
len(results), sum(1 for r in results if r["is_anomaly"]),
)
return results