From 314e377bb1d0b9b08e83bb4aa5a64b03dd966199 Mon Sep 17 00:00:00 2001 From: UsatovPavel Date: Sun, 27 Sep 2026 10:02:02 +0300 Subject: [PATCH 1/4] feat(backtest): add channel x shift backtest Offline backtest of the deployed CatBoost-AFT model (HACK-174): test channels reproduced from the training split (seed 69), Apr-Jun 2026, channel x 12h shift, new episode within 24h; two baselines, two slices, HIGH/MEDIUM thresholds, calibration, bootstrap CI. Independently reviewed. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + backtest/README.md | 24 ++ backtest/__init__.py | 1 + backtest/channels.py | 72 ++++++ backtest/data.py | 65 ++++++ backtest/dispatch.py | 68 ++++++ backtest/episodes.py | 141 ++++++++++++ backtest/isalarm_check.py | 80 +++++++ backtest/metrics.py | 196 ++++++++++++++++ backtest/predict.py | 79 +++++++ backtest/prodstyle.py | 89 +++++++ backtest/run.py | 229 +++++++++++++++++++ backtest/service.py | 39 ++++ backtest/warmstate.py | 74 ++++++ backtest/weather.py | 52 +++++ docs/backtest/backtest.md | 208 +++++++++++++++++ docs/backtest/bootstrap_ci.csv | 5 + docs/backtest/calibration.csv | 11 + docs/backtest/comparison_table.csv | 13 ++ docs/backtest/episode_value_breakdown.csv | 13 ++ docs/backtest/isalarm_by_name_coverage.csv | 2 + docs/backtest/isalarm_by_name_mismatches.csv | 4 + docs/backtest/isalarm_by_name_summary.csv | 4 + docs/backtest/isalarm_strict_coverage.csv | 2 + docs/backtest/isalarm_strict_summary.csv | 4 + 25 files changed, 1477 insertions(+) create mode 100644 backtest/README.md create mode 100644 backtest/__init__.py create mode 100644 backtest/channels.py create mode 100644 backtest/data.py create mode 100644 backtest/dispatch.py create mode 100644 backtest/episodes.py create mode 100644 backtest/isalarm_check.py create mode 100644 backtest/metrics.py create mode 100644 backtest/predict.py create mode 100644 backtest/prodstyle.py create mode 100644 backtest/run.py create mode 100644 backtest/service.py create mode 100644 backtest/warmstate.py create mode 100644 backtest/weather.py create mode 100644 docs/backtest/backtest.md create mode 100644 docs/backtest/bootstrap_ci.csv create mode 100644 docs/backtest/calibration.csv create mode 100644 docs/backtest/comparison_table.csv create mode 100644 docs/backtest/episode_value_breakdown.csv create mode 100644 docs/backtest/isalarm_by_name_coverage.csv create mode 100644 docs/backtest/isalarm_by_name_mismatches.csv create mode 100644 docs/backtest/isalarm_by_name_summary.csv create mode 100644 docs/backtest/isalarm_strict_coverage.csv create mode 100644 docs/backtest/isalarm_strict_summary.csv diff --git a/.gitignore b/.gitignore index 80857fe..0ad88db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ data/ext-journal-2026.parquet __pycache__/ .pytest_cache/ runtime/ +docs/backtest/predictions.csv +docs/backtest/test_channels.csv diff --git a/backtest/README.md b/backtest/README.md new file mode 100644 index 0000000..73bca7b --- /dev/null +++ b/backtest/README.md @@ -0,0 +1,24 @@ +# Бэктест HACK-174 + +Оффлайн-проверка `data/best_model.cbm` на смены апрель-июнь 2026 по тестовым +каналам сплита, тем же кодом признаков, что и в `inference/`/`app.py`, в +двух вариантах — "прогноз ровно на границе смены" и "то, что стенд реально +показывал по последнему событию". + +```bash +HACK174_DATASET_DIR=/path/to/Sources/dataset python -m backtest.run +``` + +Путь к данным (журналы `ext-journal-*.csv` и справочники) обязателен: флагом +`--dataset-dir` или переменной окружения `HACK174_DATASET_DIR` — здесь +намеренно нет дефолта на локальный путь. Результат пишется в +`docs/backtest/` (переопределяется `--output-dir`); `--weather-csv` +указывает на CSV Open-Meteo (по умолчанию — тот, что уже в репозитории, +`retraining/data/reference/`). Остальные флаги — `--help`. + +`docs/backtest/predictions.csv` (~99 тыс. строк) не хранится в git — +генерируется каждым прогоном заново, путь в `.gitignore`. Остальные CSV в +`docs/backtest/` — агрегаты, коммитятся. + +Определения (смена, эпизод, горизонт, isAlarm, диспетчер и т.д.) — в +`docs/backtest/backtest.md`. diff --git a/backtest/__init__.py b/backtest/__init__.py new file mode 100644 index 0000000..a3c05de --- /dev/null +++ b/backtest/__init__.py @@ -0,0 +1 @@ +"""Оффлайн-бэктест модели прогноза тревог (HACK-174).""" diff --git a/backtest/channels.py b/backtest/channels.py new file mode 100644 index 0000000..90f457e --- /dev/null +++ b/backtest/channels.py @@ -0,0 +1,72 @@ +"""Точное воспроизведение device-сплита из +retraining/notebooks/2_create_train_test_datasets_V2_MAIN.ipynb (get_device_split). + +Универсум id — уникальные ид_канала_данных из журналов 2024-2026 +(retraining/notebooks/1_prepare_dataset.ipynb), затем sort + shuffle(seed=69) + +head(90%)/slice(5%)/остаток. Совпадение размеров train/val/test (10366/575/577) +с ноутбуком проверено вручную и задокументировано в docs/backtest/backtest.md. +""" +from __future__ import annotations + +from pathlib import Path + +import polars as pl + +ID_COL = "ид_канала_данных" +YEARS = (2024, 2025, 2026) +SEED = 69 +TRAIN_SIZE = 0.9 +VAL_SIZE = 0.05 + + +def _year_ids(dataset_dir: Path, year: int) -> pl.DataFrame: + # Журналы содержат вклеенные строки-заголовки (см. комментарий в + # ноутбуке 1) — они не парсятся как Int64 и отбрасываются явно. + lf = pl.scan_csv( + dataset_dir / f"ext-journal-{year}.csv", + schema_overrides={ID_COL: pl.Utf8}, + ignore_errors=True, + ) + return ( + lf.select(ID_COL) + .filter(pl.col(ID_COL) != ID_COL) + .unique() + .collect(engine="streaming") + .with_columns(pl.col(ID_COL).cast(pl.Int64)) + ) + + +def all_channel_ids(dataset_dir: Path) -> pl.DataFrame: + """Уникальные ид каналов из журналов YEARS, отсортированные по возрастанию.""" + frames = [_year_ids(dataset_dir, year) for year in YEARS] + return pl.concat(frames).unique().sort(ID_COL) + + +def device_split(dataset_dir: Path) -> pl.DataFrame: + """Полный train/val/test сплит, один в один как get_device_split.""" + ids = all_channel_ids(dataset_dir).sample( + fraction=1.0, + shuffle=True, + seed=SEED, + ) + + n = ids.height + n_train = int(n * TRAIN_SIZE) + n_val = int(n * VAL_SIZE) + + return pl.concat( + [ + ids.head(n_train).with_columns(pl.lit("train").alias("split")), + ids.slice(n_train, n_val).with_columns(pl.lit("val").alias("split")), + ids.slice(n_train + n_val).with_columns(pl.lit("test").alias("split")), + ] + ) + + +def test_channel_ids(dataset_dir: Path) -> list[int]: + split = device_split(dataset_dir) + return ( + split.filter(pl.col("split") == "test")[ID_COL] + .sort() + .to_list() + ) diff --git a/backtest/data.py b/backtest/data.py new file mode 100644 index 0000000..abeae51 --- /dev/null +++ b/backtest/data.py @@ -0,0 +1,65 @@ +"""Загрузка событий тестовых каналов за окно бэктеста из ext-journal CSV.""" +from __future__ import annotations + +from datetime import date +from pathlib import Path + +import polars as pl + +ID_COL = "ид_канала_данных" +RAW_COLUMNS = ( + "ид_события", + ID_COL, + "дата", + "время", + "тревожное", + "значение_датчика", +) + + +def load_events( + dataset_dir: Path, + channel_ids: list[int], + start: date, + end: date, + year: int = 2026, +) -> pl.DataFrame: + """События channel_ids за [start, end], отсортированные по каналу и времени.""" + lf = pl.scan_csv( + dataset_dir / f"ext-journal-{year}.csv", + schema_overrides={ID_COL: pl.Utf8, "дата": pl.Utf8}, + ignore_errors=True, + ) + + df = ( + lf.select(*RAW_COLUMNS) + .filter(pl.col(ID_COL) != ID_COL) + .with_columns(pl.col(ID_COL).cast(pl.Int64)) + .filter(pl.col(ID_COL).is_in(channel_ids)) + .filter( + pl.col("дата").is_between( + pl.lit(start.isoformat()), + pl.lit(end.isoformat()), + ) + ) + .collect(engine="streaming") + ) + + return ( + df.with_columns( + pl.col("дата").str.to_date(), + pl.col("время").str.to_time("%H:%M:%S", strict=False), + (pl.col("тревожное") == "t").alias("тревожное"), + ) + .with_columns( + pl.datetime( + pl.col("дата").dt.year(), + pl.col("дата").dt.month(), + pl.col("дата").dt.day(), + pl.col("время").dt.hour(), + pl.col("время").dt.minute(), + pl.col("время").dt.second(), + ).alias("ts") + ) + .sort([ID_COL, "ts", "ид_события"]) + ) diff --git a/backtest/dispatch.py b/backtest/dispatch.py new file mode 100644 index 0000000..faccae1 --- /dev/null +++ b/backtest/dispatch.py @@ -0,0 +1,68 @@ +"""Диспетчер канала = ближайший предок уровня 2 в справочнике объектов. + +Sources/Q&A/Q&A_2.txt (Maxim, 9/18): иерархия_уровень 1 — единственный +корневой "district", 2 — "Диспетчер района" (16 объектов controlHouse/ +guardObject), 3 — их дочерние подобъекты. Каналы висят на объектах любого +уровня; поднимаемся по "родитель" до первого уровня 2. +""" +from __future__ import annotations + +from collections import Counter +from pathlib import Path + +import polars as pl + +ID_COL = "ид_канала_данных" + + +def channel_dispatchers( + sensors_path: Path, + objects_path: Path, +) -> dict[int, int | None]: + objects = pl.read_parquet(objects_path) + parent = dict(zip(objects["ид_объект"], objects["родитель"])) + level = dict(zip(objects["ид_объект"], objects["иерархия_уровень"])) + + def dispatcher_of(object_id: int) -> int | None: + current, seen = object_id, set() + + while current is not None and current not in seen: + if level.get(current) == 2: + return int(current) + + seen.add(current) + current = parent.get(current) + + return None + + sensors = pl.read_parquet(sensors_path, columns=[ID_COL, "ид_объект"]) + + return { + int(channel_id): dispatcher_of(int(object_id)) + for channel_id, object_id in zip(sensors[ID_COL], sensors["ид_объект"]) + } + + +def dispatcher_scale( + all_dispatchers: dict[int, int | None], + sample_channel_ids: list[int], +) -> dict[int, float]: + """Множитель «весь парк диспетчера / тестовые каналы диспетчера». + + Тестовые каналы — случайные ~5% каналов (по устройству, не по + диспетчеру), поэтому экстраполяция на весь парк предполагает + однородность каналов внутри диспетчера; при малой тестовой выборке на + диспетчера множитель может быть неустойчивым (см. допущения в + backtest.md). + """ + full_counts = Counter(d for d in all_dispatchers.values() if d is not None) + sample_set = set(sample_channel_ids) + sample_counts = Counter( + d for cid, d in all_dispatchers.items() if d is not None and cid in sample_set + ) + + return { + dispatcher: full_counts[dispatcher] / count + for dispatcher, count in sample_counts.items() + if count > 0 + } diff --git a/backtest/episodes.py b/backtest/episodes.py new file mode 100644 index 0000000..0312725 --- /dev/null +++ b/backtest/episodes.py @@ -0,0 +1,141 @@ +"""Эпизоды тревог и «вчерашние» окна для бейзлайнов и исхода прогноза. + +Эпизод — переход канала из нормы в тревогу (тревожное 0 -> 1); повторные +тревожные события подряд эпизод не продлевают и не открывают заново. +Состояние канала перед первым событием окна берётся из +backtest.warmstate.last_state_before — без этого первый тревожный сигнал +в окне ошибочно читался бы как новый эпизод (см. warmstate.py). +""" +from __future__ import annotations + +import bisect +from dataclasses import dataclass +from datetime import datetime + +import polars as pl + +ID_COL = "ид_канала_данных" + + +@dataclass(slots=True) +class ChannelTimeline: + ts: list[datetime] + is_alarm: list[bool] + alarm_ts: list[datetime] + episode_starts: list[datetime] + episode_values: list[str] + seed_is_alarm: bool | None + + +def _episode_starts( + timestamps: list[datetime], + is_alarm: list[bool], + values: list[str], + seed: bool | None, +) -> tuple[list[datetime], list[str]]: + starts: list[datetime] = [] + start_values: list[str] = [] + in_alarm = bool(seed) + + for ts, alarm, value in zip(timestamps, is_alarm, values): + if alarm and not in_alarm: + starts.append(ts) + start_values.append(value) + in_alarm = alarm + + return starts, start_values + + +def build_timelines( + events_df: pl.DataFrame, + seed_states: dict[int, tuple[bool, str]] | None = None, +) -> dict[int, ChannelTimeline]: + """events_df уже отсортирован по [ID_COL, ts] (см. backtest.data.load_events).""" + seed_states = seed_states or {} + timelines: dict[int, ChannelTimeline] = {} + + for (channel_id,), group in events_df.partition_by( + ID_COL, + as_dict=True, + maintain_order=True, + ).items(): + timestamps = group["ts"].to_list() + is_alarm = group["тревожное"].to_list() + values = group["значение_датчика"].to_list() + seed = seed_states.get(int(channel_id), (None, None))[0] + + starts, start_values = _episode_starts(timestamps, is_alarm, values, seed) + + timelines[int(channel_id)] = ChannelTimeline( + ts=timestamps, + is_alarm=is_alarm, + alarm_ts=[ts for ts, alarm in zip(timestamps, is_alarm) if alarm], + episode_starts=starts, + episode_values=start_values, + seed_is_alarm=seed, + ) + + return timelines + + +def alarms_in_window( + timeline: ChannelTimeline, + start: datetime, + end: datetime, +) -> int: + """Число тревожных событий с ts в [start, end).""" + lo = bisect.bisect_left(timeline.alarm_ts, start) + hi = bisect.bisect_left(timeline.alarm_ts, end) + return hi - lo + + +def state_at(timeline: ChannelTimeline, t0: datetime) -> bool | None: + """Тревожное/норма непосредственно перед t0 (событие с ts < t0, либо seed).""" + idx = bisect.bisect_left(timeline.ts, t0) - 1 + return timeline.is_alarm[idx] if idx >= 0 else timeline.seed_is_alarm + + +def first_episode_after( + timeline: ChannelTimeline, + t0: datetime, + horizon, +) -> datetime | None: + """Первое начало нового эпизода в (t0, t0 + horizon], иначе None.""" + lo = bisect.bisect_right(timeline.episode_starts, t0) + + if lo >= len(timeline.episode_starts): + return None + + candidate = timeline.episode_starts[lo] + return candidate if candidate <= t0 + horizon else None + + +def episode_value_breakdown( + timelines: dict[int, ChannelTimeline], + window_start: datetime, + window_end: datetime, +) -> pl.DataFrame: + """Разбивка начал эпизодов в [window_start, window_end) по значению + датчика, вызвавшему тревогу — не все эпизоды "тревога" в бытовом + смысле, часть — «Неисправен» и другие сервисные состояния. + """ + rows = [ + {"value": value, "ts": ts} + for timeline in timelines.values() + for ts, value in zip(timeline.episode_starts, timeline.episode_values) + if window_start <= ts < window_end + ] + + if not rows: + return pl.DataFrame({"значение_датчика": [], "n": [], "share": []}) + + df = pl.DataFrame(rows) + total = df.height + + return ( + df.group_by("value") + .agg(pl.len().alias("n")) + .with_columns((pl.col("n") / total).alias("share")) + .sort("n", descending=True) + .rename({"value": "значение_датчика"}) + ) diff --git a/backtest/isalarm_check.py b/backtest/isalarm_check.py new file mode 100644 index 0000000..69c2e3e --- /dev/null +++ b/backtest/isalarm_check.py @@ -0,0 +1,80 @@ +"""Честная сверка isAlarm: тревожное журнала против справочник_состояний. + +Решение остаётся прежним — берём тревожное журнала (см. episodes.py), но +отчёт должен показывать реальную картину, а не только долю совпадений: +покрытие словаря крошечное, а согласие почти целиком на "не тревога". +""" +from __future__ import annotations + +from pathlib import Path + +import polars as pl + +ID_COL = "ид_канала_данных" + + +def _unambiguous(states: pl.DataFrame, group_cols: list[str]) -> pl.DataFrame: + agg = states.group_by(group_cols).agg( + pl.col("тревожное").n_unique().alias("nuniq"), + pl.col("тревожное").first().alias("тревожное_dict"), + ) + return agg.filter(pl.col("nuniq") == 1).drop("nuniq") + + +def _summary(joined: pl.DataFrame) -> pl.DataFrame: + return ( + joined.rename({"тревожное": "journal"}) + .group_by(["journal", "тревожное_dict"]) + .agg(pl.len().alias("n")) + .sort(["journal", "тревожное_dict"]) + ) + + +def _mismatches(joined: pl.DataFrame) -> pl.DataFrame: + return ( + joined.rename({"тревожное": "journal"}) + .filter(pl.col("journal") != pl.col("тревожное_dict")) + .group_by("значение_датчика") + .agg(pl.len().alias("n")) + .sort("n", descending=True) + ) + + +def check( + events_df: pl.DataFrame, + states_csv: Path, + sensors_csv: Path, +) -> dict[str, pl.DataFrame]: + states = pl.read_csv(states_csv) + sensors = pl.read_csv(sensors_csv).select(ID_COL, "тип_датчика") + events = events_df.select(ID_COL, "тревожное", "значение_датчика") + + # Строго: (тип_датчика, название_состояния) однозначны в справочнике. + strict = _unambiguous(states, ["тип_датчика", "название_состояния"]) + strict_joined = events.join(sensors, on=ID_COL, how="left").join( + strict, + left_on=["тип_датчика", "значение_датчика"], + right_on=["тип_датчика", "название_состояния"], + how="inner", + ) + + # Шире: только по названию состояния, без учёта типа датчика. + by_name = _unambiguous(states, ["название_состояния"]) + name_joined = events.join( + by_name, + left_on="значение_датчика", + right_on="название_состояния", + how="inner", + ) + + return { + "strict_summary": _summary(strict_joined), + "strict_coverage": pl.DataFrame( + {"matched": [strict_joined.height], "total": [events.height]} + ), + "by_name_summary": _summary(name_joined), + "by_name_mismatches": _mismatches(name_joined), + "by_name_coverage": pl.DataFrame( + {"matched": [name_joined.height], "total": [events.height]} + ), + } diff --git a/backtest/metrics.py b/backtest/metrics.py new file mode 100644 index 0000000..de06cb7 --- /dev/null +++ b/backtest/metrics.py @@ -0,0 +1,196 @@ +"""Метрики бэктеста: precision/recall/алерты на смену/медиана часов, +калибровка и кластерный (по каналам) бутстрэп доверительных интервалов. +""" +from __future__ import annotations + +import numpy as np +import polars as pl + +HIGH_THRESHOLD = 0.7 +MEDIUM_THRESHOLD = 0.4 +N_BOOTSTRAP = 2000 +BOOTSTRAP_SEED = 0 + + +def _binary_metrics( + df: pl.DataFrame, + predicted: pl.Series, + dispatcher_scale: dict[int, float] | None = None, +) -> dict[str, float]: + df = df.with_columns(predicted.alias("__pred")) + + tp = df.filter(pl.col("__pred") & pl.col("outcome_24h")).height + fp = df.filter(pl.col("__pred") & ~pl.col("outcome_24h")).height + fn = df.filter(~pl.col("__pred") & pl.col("outcome_24h")).height + + precision = tp / (tp + fp) if (tp + fp) else float("nan") + recall = tp / (tp + fn) if (tp + fn) else float("nan") + + by_dispatcher_shift = ( + df.filter(pl.col("dispatcher_id").is_not_null()) + .group_by(["dispatcher_id", "t0"]) + .agg(pl.col("__pred").sum().alias("alerts")) + ) + + alerts_per_shift_sample = ( + by_dispatcher_shift["alerts"].mean() if by_dispatcher_shift.height else float("nan") + ) + + alerts_per_shift_fleet = float("nan") + if dispatcher_scale and by_dispatcher_shift.height: + scaled = by_dispatcher_shift.with_columns( + pl.col("dispatcher_id") + .replace_strict(dispatcher_scale, default=1.0, return_dtype=pl.Float64) + .alias("scale") + ).with_columns((pl.col("alerts") * pl.col("scale")).alias("alerts_scaled")) + alerts_per_shift_fleet = scaled["alerts_scaled"].mean() + + hours = df.filter(pl.col("__pred") & pl.col("outcome_24h"))["hours_to_event"].drop_nulls() + median_hours = hours.median() if hours.len() else float("nan") + + return { + "precision": precision, + "recall": recall, + "tp": tp, + "fp": fp, + "fn": fn, + "n": df.height, + # На тестовой выборке каналов (~5% парка) и пересчитанное на весь + # парк диспетчера (см. backtest.dispatch.dispatcher_scale). + "alerts_per_shift_sample": alerts_per_shift_sample, + "alerts_per_shift_fleet": alerts_per_shift_fleet, + "median_hours_to_event": median_hours, + } + + +def _method_predicates(df: pl.DataFrame) -> dict[str, pl.Series]: + return { + "model_fresh_t0_high_0.7": df["probability"] >= HIGH_THRESHOLD, + "model_fresh_t0_medium_0.4": df["probability"] >= MEDIUM_THRESHOLD, + "model_prodstyle_high_0.7": df["prodstyle_valid"] & (df["prodstyle_probability"] >= HIGH_THRESHOLD), + "model_prodstyle_medium_0.4": df["prodstyle_valid"] & (df["prodstyle_probability"] >= MEDIUM_THRESHOLD), + "baseline_alarm_yesterday": df["baseline_alarm_yesterday"], + "baseline_10plus_yesterday": df["baseline_10plus_yesterday"], + } + + +def build_comparison_table( + df: pl.DataFrame, + dispatcher_scale: dict[int, float] | None = None, +) -> pl.DataFrame: + slices = { + "all_channels": df, + "early_warning_6h": df.filter(pl.col("early_warning_ok")), + } + + rows = [] + for slice_name, sdf in slices.items(): + for method_name, predicate in _method_predicates(sdf).items(): + metrics = _binary_metrics(sdf, predicate, dispatcher_scale) + rows.append({"slice": slice_name, "method": method_name, **metrics}) + + return pl.DataFrame(rows) + + +def calibration_table( + df: pl.DataFrame, + probability_col: str = "probability", + bins: int = 10, +) -> pl.DataFrame: + breaks = [i / bins for i in range(1, bins)] + labels = [f"[{i / bins:.1f}, {(i + 1) / bins:.1f})" for i in range(bins)] + + return ( + df.with_columns( + pl.col(probability_col) + .cut(breaks, labels=labels, left_closed=True) + .alias("probability_bin") + ) + .group_by("probability_bin", maintain_order=True) + .agg( + pl.len().alias("n"), + pl.col("outcome_24h").mean().alias("actual_rate"), + pl.col(probability_col).mean().alias("mean_probability"), + ) + .sort("probability_bin") + ) + + +def _per_channel_counts(df: pl.DataFrame, predicted: pl.Series) -> pl.DataFrame: + return ( + df.with_columns(predicted.alias("__pred")) + .group_by("channel_id") + .agg( + (pl.col("__pred") & pl.col("outcome_24h")).sum().alias("tp"), + (pl.col("__pred") & ~pl.col("outcome_24h")).sum().alias("fp"), + (~pl.col("__pred") & pl.col("outcome_24h")).sum().alias("fn"), + ) + .sort("channel_id") + ) + + +def bootstrap_diff_ci( + df: pl.DataFrame, + predicted_a: pl.Series, + predicted_b: pl.Series, + n_boot: int = N_BOOTSTRAP, + seed: int = BOOTSTRAP_SEED, +) -> dict[str, tuple[float, float]]: + """95% CI разницы (precision_a - precision_b) и (recall_a - recall_b). + + Кластерный бутстрэп по каналам (не по строкам): ресемплируем каналы с + возвратом и суммируем их tp/fp/fn — строки одного канала коррелированы + (одни и те же 182 смены), построчный бутстрэп занизил бы дисперсию. + """ + counts_a = _per_channel_counts(df, predicted_a) + counts_b = _per_channel_counts(df, predicted_b) + + tp_a, fp_a, fn_a = (counts_a[c].to_numpy() for c in ("tp", "fp", "fn")) + tp_b, fp_b, fn_b = (counts_b[c].to_numpy() for c in ("tp", "fp", "fn")) + + rng = np.random.default_rng(seed) + n = tp_a.shape[0] + precision_diffs = np.empty(n_boot) + recall_diffs = np.empty(n_boot) + + for i in range(n_boot): + idx = rng.integers(0, n, n) + a_tp, a_fp, a_fn = tp_a[idx].sum(), fp_a[idx].sum(), fn_a[idx].sum() + b_tp, b_fp, b_fn = tp_b[idx].sum(), fp_b[idx].sum(), fn_b[idx].sum() + + precision_a = a_tp / (a_tp + a_fp) if (a_tp + a_fp) else 0.0 + precision_b = b_tp / (b_tp + b_fp) if (b_tp + b_fp) else 0.0 + recall_a = a_tp / (a_tp + a_fn) if (a_tp + a_fn) else 0.0 + recall_b = b_tp / (b_tp + b_fn) if (b_tp + b_fn) else 0.0 + + precision_diffs[i] = precision_a - precision_b + recall_diffs[i] = recall_a - recall_b + + return { + "precision_diff": float(np.mean(precision_diffs)), + "precision_diff_ci_low": float(np.percentile(precision_diffs, 2.5)), + "precision_diff_ci_high": float(np.percentile(precision_diffs, 97.5)), + "recall_diff": float(np.mean(recall_diffs)), + "recall_diff_ci_low": float(np.percentile(recall_diffs, 2.5)), + "recall_diff_ci_high": float(np.percentile(recall_diffs, 97.5)), + } + + +def build_bootstrap_table(df: pl.DataFrame) -> pl.DataFrame: + """CI для моделей (обоих вариантов) против бейзлайна «тревога вчера».""" + predicates = _method_predicates(df) + baseline = predicates["baseline_alarm_yesterday"] + + model_methods = [ + "model_fresh_t0_high_0.7", + "model_fresh_t0_medium_0.4", + "model_prodstyle_high_0.7", + "model_prodstyle_medium_0.4", + ] + + rows = [] + for method in model_methods: + ci = bootstrap_diff_ci(df, predicates[method], baseline) + rows.append({"method": method, "vs": "baseline_alarm_yesterday", **ci}) + + return pl.DataFrame(rows) diff --git a/backtest/predict.py b/backtest/predict.py new file mode 100644 index 0000000..cebf3e3 --- /dev/null +++ b/backtest/predict.py @@ -0,0 +1,79 @@ +"""Реплей событий через app.PredictionService + батч model.predict. + +Признаки на чек-поинте t0 строятся строго по событиям с ts < t0 (см. +app.PredictionService._build_feature_row): перед каждым чек-поинтом +докручиваем store.update по всем более ранним событиям канала, затем +строим строку фичей на месте прогноза, ничего не переписывая в app.py. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +import polars as pl + +from app import PredictionService + +ID_COL = "ид_канала_данных" + + +@dataclass(slots=True) +class ShiftPrediction: + channel_id: int + t0: datetime + probability: float + history_complete: bool + + +def _channel_events( + events_df: pl.DataFrame, +) -> dict[int, list[tuple[datetime, str]]]: + parts = events_df.partition_by(ID_COL, as_dict=True, maintain_order=True) + + return { + int(channel_id): list( + zip(group["ts"].to_list(), group["значение_датчика"].to_list()) + ) + for (channel_id,), group in parts.items() + } + + +def predict_shifts( + service: PredictionService, + events_df: pl.DataFrame, + channel_ids: list[int], + shift_times: list[datetime], + horizon_hours: float, +) -> list[ShiftPrediction]: + channel_events = _channel_events(events_df) + + rows: list[list] = [] + meta: list[tuple[int, datetime, bool]] = [] + + for channel_id in channel_ids: + events = channel_events.get(channel_id, []) + ei, n = 0, len(events) + + for t0 in shift_times: + while ei < n and events[ei][0] < t0: + ts, value = events[ei] + service.store.update(channel_id, ts, value) + ei += 1 + + row = service._build_feature_row(channel_id, t0) + complete = service.store.history_complete(channel_id, t0) + + rows.append(row) + meta.append((channel_id, t0, complete)) + + raw_predictions = service.model.predict(rows) if rows else [] + + return [ + ShiftPrediction( + channel_id=channel_id, + t0=t0, + probability=service._probability_from_raw(float(raw), horizon_hours), + history_complete=complete, + ) + for (channel_id, t0, complete), raw in zip(meta, raw_predictions) + ] diff --git a/backtest/prodstyle.py b/backtest/prodstyle.py new file mode 100644 index 0000000..48c8f4b --- /dev/null +++ b/backtest/prodstyle.py @@ -0,0 +1,89 @@ +"""«Что стенд реально показывал в момент t0». + +app.PredictionService.predict строится на КАЖДОМ событии, не на границе +смены (inference/runner.py:333-360): прогноз пишется в prediction_log с +horizon_until = ts + 30ч (HORIZON_HOURS=30, inference/config.py) и +считается устаревшим после этого срока (predictions.delete_expired). Этот +модуль воспроизводит именно это: для каждой смены t0 берёт последний +прогноз ПО СОБЫТИЮ до t0 и его "возраст" на момент t0. +""" +from __future__ import annotations + +import bisect +from dataclasses import dataclass +from datetime import datetime + +import polars as pl + +from app import PredictionService + +ID_COL = "ид_канала_данных" +PROD_HORIZON_HOURS = 30.0 + + +@dataclass(slots=True) +class ProdPrediction: + channel_id: int + t0: datetime + probability: float | None + age_hours: float | None + + @property + def valid(self) -> bool: + # Стенд удаляет просроченные прогнозы (delete_expired): диспетчер + # не видит алерта старше горизонта в 30ч на момент t0. + return self.age_hours is not None and self.age_hours < PROD_HORIZON_HOURS + + +def predict_last_event( + service: PredictionService, + events_df: pl.DataFrame, + channel_ids: list[int], + shift_times: list[datetime], +) -> list[ProdPrediction]: + parts = events_df.partition_by(ID_COL, as_dict=True, maintain_order=True) + + results: list[ProdPrediction] = [] + rows: list[list] = [] + row_meta: list[tuple[int, datetime, list[datetime]]] = [] + + for channel_id in channel_ids: + group = parts.get((channel_id,)) + ts = group["ts"].to_list() if group is not None else [] + values = group["значение_датчика"].to_list() if group is not None else [] + + # Для каждой смены — индекс последнего события со ts < t0. + due: dict[int, list[datetime]] = {} + for t0 in shift_times: + idx = bisect.bisect_left(ts, t0) - 1 + due.setdefault(idx, []).append(t0) + + for t0 in due.get(-1, []): + results.append(ProdPrediction(channel_id, t0, None, None)) + + for idx, (event_ts, value) in enumerate(zip(ts, values)): + service.store.update(channel_id, event_ts, value) + t0s = due.get(idx) + + if not t0s: + continue + + # Событие с тем же ts, что следующее: это ещё не последнее + # событие тика — переносим смены на следующий индекс. + if idx + 1 < len(ts) and ts[idx + 1] == event_ts: + due.setdefault(idx + 1, []).extend(t0s) + continue + + rows.append(service._build_feature_row(channel_id, event_ts)) + row_meta.append((channel_id, event_ts, t0s)) + + raw_predictions = service.model.predict(rows) if rows else [] + + for (channel_id, event_ts, t0s), raw in zip(row_meta, raw_predictions): + probability = service._probability_from_raw(float(raw), PROD_HORIZON_HOURS) + + for t0 in t0s: + age = (t0 - event_ts).total_seconds() / 3600 + results.append(ProdPrediction(channel_id, t0, probability, age)) + + return results diff --git a/backtest/run.py b/backtest/run.py new file mode 100644 index 0000000..23bbbf7 --- /dev/null +++ b/backtest/run.py @@ -0,0 +1,229 @@ +"""Оффлайн-бэктест HACK-174: одна команда, весь конвейер. + + python -m backtest.run --dataset-dir /path/to/Sources/dataset + +Путь к данным обязателен: через --dataset-dir или переменную окружения +HACK174_DATASET_DIR (см. backtest/README.md). Читает журналы событий и +справочники, поднимает боевую app.PredictionService на data/best_model.cbm +(та же модель, что на стенде), считает вероятность тревоги на смены +апрель-июнь 2026 по тестовым каналам сплита в двух вариантах — «прогноз +ровно на границе смены» и «то, что стенд реально показывал по последнему +событию» — и сравнивает с двумя бейзлайнами. Результат — docs/backtest/*.csv +(predictions.csv не коммитится, см. .gitignore) и docs/backtest/backtest.md. +""" +from __future__ import annotations + +import argparse +import os +import sys +import tempfile +import time +from datetime import date, datetime, timedelta +from pathlib import Path + +import polars as pl + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from backtest import channels, data, episodes, isalarm_check, metrics, prodstyle # noqa: E402 +from backtest.dispatch import channel_dispatchers, dispatcher_scale # noqa: E402 +from backtest.predict import predict_shifts # noqa: E402 +from backtest.service import build_offline_service # noqa: E402 +from backtest.warmstate import last_state_before # noqa: E402 + +HORIZON_HOURS = 24.0 +SHIFT_HOURS = (8, 20) +WARMUP_PAD_DAYS = 3 +OUTCOME_PAD_DAYS = 1 + + +def _shift_times(start: date, end: date) -> list[datetime]: + times, day = [], start + while day <= end: + times.extend(datetime(day.year, day.month, day.day, h) for h in SHIFT_HOURS) + day += timedelta(days=1) + return times + + +def _valid_shift_times(start: date, end: date, horizon_hours: float) -> list[datetime]: + """Смены, у которых t0+horizon целиком внутри [start, end] (см. п.6 + сноски проверяющего): последняя валидная смена — 29.06 20:00, не 30.06. + """ + period_end = datetime(end.year, end.month, end.day) + timedelta(days=1) + horizon = timedelta(hours=horizon_hours) + return [t0 for t0 in _shift_times(start, end) if t0 + horizon <= period_end] + + +def _build_rows( + predictions: list, + prodstyle_by_key: dict[tuple[int, datetime], prodstyle.ProdPrediction], + timelines: dict[int, episodes.ChannelTimeline], + dispatchers: dict[int, int | None], +) -> pl.DataFrame: + horizon = timedelta(hours=HORIZON_HOURS) + rows = [] + + for pred in predictions: + timeline = timelines[pred.channel_id] + yesterday_start = pred.t0 - timedelta(hours=24) + yesterday_alarms = episodes.alarms_in_window(timeline, yesterday_start, pred.t0) + + # "Спокойный" канал: сейчас в норме И без тревог последние 6ч — + # без первого условия канал, который тревожит уже давно без новых + # событий, ошибочно считался бы спокойным (см. warmstate.py). + quiet_no_recent_alarm = ( + episodes.alarms_in_window(timeline, pred.t0 - timedelta(hours=6), pred.t0) == 0 + ) + currently_normal = episodes.state_at(timeline, pred.t0) is not True + early_warning_ok = currently_normal and quiet_no_recent_alarm + + episode_start = episodes.first_episode_after(timeline, pred.t0, horizon) + prod = prodstyle_by_key.get((pred.channel_id, pred.t0)) + + rows.append( + { + "channel_id": pred.channel_id, + "t0": pred.t0, + "probability": pred.probability, + "history_complete": pred.history_complete, + "prodstyle_probability": prod.probability if prod else None, + "prodstyle_age_hours": prod.age_hours if prod else None, + "prodstyle_valid": bool(prod and prod.valid), + "baseline_alarm_yesterday": yesterday_alarms > 0, + "baseline_10plus_yesterday": yesterday_alarms >= 10, + "early_warning_ok": early_warning_ok, + "outcome_24h": episode_start is not None, + "hours_to_event": ( + (episode_start - pred.t0).total_seconds() / 3600 + if episode_start is not None + else None + ), + "dispatcher_id": dispatchers.get(pred.channel_id), + } + ) + + return pl.DataFrame(rows, infer_schema_length=None) + + +def _require_dataset_dir(value: str | None) -> Path: + resolved = value or os.environ.get("HACK174_DATASET_DIR") + if not resolved: + raise SystemExit( + "--dataset-dir is required (or set HACK174_DATASET_DIR): " + "каталог с ext-journal-*.csv и справочниками, см. backtest/README.md" + ) + return Path(resolved) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dataset-dir", type=str, default=None) + parser.add_argument( + "--weather-csv", + type=Path, + default=REPO_ROOT / "retraining/data/reference/open-meteo-55.75N37.63E140m.csv", + ) + parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "docs/backtest") + # Своя temp-директория на прогон: устаревший metadata-кеш/state не + # должен подмешаться (тот же приём, что и в inference/runner.py). + parser.add_argument( + "--runtime-dir", + type=Path, + default=Path(tempfile.mkdtemp(prefix="hack174-backtest-")), + ) + parser.add_argument("--start", type=date.fromisoformat, default=date(2026, 4, 1)) + parser.add_argument("--end", type=date.fromisoformat, default=date(2026, 6, 30)) + args = parser.parse_args() + dataset_dir = _require_dataset_dir(args.dataset_dir) + + started = time.perf_counter() + args.output_dir.mkdir(parents=True, exist_ok=True) + + print("1/8 test-channel split (notebooks/2, seed=69)...") + test_ids = channels.test_channel_ids(dataset_dir) + pl.DataFrame({"ид_канала_данных": test_ids}).write_csv( + args.output_dir / "test_channels.csv" + ) + print(f" test channels: {len(test_ids)}") + + load_start = args.start - timedelta(days=WARMUP_PAD_DAYS) + load_end = args.end + timedelta(days=OUTCOME_PAD_DAYS, hours=1) + print(f"2/8 loading events [{load_start}, {load_end}] for test channels...") + events_df = data.load_events(dataset_dir, test_ids, load_start, load_end) + print(f" events: {events_df.height}") + + print("3/8 warming episode state from before the load window...") + seed_states = last_state_before(dataset_dir, test_ids, load_start) + + print("4/8 building service (app.PredictionService, offline weather)...") + service = build_offline_service(REPO_ROOT, args.weather_csv, args.runtime_dir) + + have_events = set(events_df["ид_канала_данных"].unique().to_list()) + usable_ids = [ + cid for cid in test_ids if cid in have_events and cid in service.metadata + ] + print( + f" usable channels: {len(usable_ids)} / {len(test_ids)} " + f"(no events: {len(set(test_ids) - have_events)}, " + f"no metadata: {len(set(test_ids) - set(service.metadata))})" + ) + + shift_times = _valid_shift_times(args.start, args.end, HORIZON_HOURS) + print( + f"5/8 predicting {len(usable_ids)} channels x {len(shift_times)} shifts " + f"(fresh-t0 + prodstyle)..." + ) + predictions = predict_shifts( + service, events_df, usable_ids, shift_times, HORIZON_HOURS + ) + # Второй проход по своим таймлайнам: store уже продвинут до последней + # смены предыдущим проходом, иначе события "из прошлого" будут + # выглядеть как out-of-order (app.OnlineFeatureStore.update). + service.store.states.clear() + prod_predictions = prodstyle.predict_last_event( + service, events_df, usable_ids, shift_times + ) + prodstyle_by_key = {(p.channel_id, p.t0): p for p in prod_predictions} + + print("6/8 episodes, baselines, dispatcher grouping...") + timelines = episodes.build_timelines(events_df, seed_states) + dispatchers_all = channel_dispatchers( + service.config.sensors_metadata_path, + service.config.objects_metadata_path, + ) + scale = dispatcher_scale(dispatchers_all, usable_ids) + df = _build_rows(predictions, prodstyle_by_key, timelines, dispatchers_all) + df.write_csv(args.output_dir / "predictions.csv") + + period_end = datetime(args.end.year, args.end.month, args.end.day) + timedelta(days=1) + breakdown = episodes.episode_value_breakdown( + timelines, datetime(args.start.year, args.start.month, args.start.day), period_end + ) + breakdown.write_csv(args.output_dir / "episode_value_breakdown.csv") + + print("7/8 isAlarm sanity check (тревожное vs справочник_состояний)...") + isalarm = isalarm_check.check( + events_df, dataset_dir / "справочник_состояний.csv", dataset_dir / "справочник_каналов_датчиков.csv" + ) + for name, table in isalarm.items(): + table.write_csv(args.output_dir / f"isalarm_{name}.csv") + + print("8/8 metrics, bootstrap CI, report...") + comparison = metrics.build_comparison_table(df, dispatcher_scale=scale) + comparison.write_csv(float_precision=6, file=args.output_dir / "comparison_table.csv") + calibration = metrics.calibration_table(df) + calibration.write_csv(args.output_dir / "calibration.csv") + bootstrap = metrics.build_bootstrap_table(df) + bootstrap.write_csv(args.output_dir / "bootstrap_ci.csv") + + elapsed = time.perf_counter() - started + print(f"done in {elapsed:.1f}s") + print(comparison) + print(calibration) + print(bootstrap) + print(breakdown.head(10)) + + +if __name__ == "__main__": + main() diff --git a/backtest/service.py b/backtest/service.py new file mode 100644 index 0000000..67eddbc --- /dev/null +++ b/backtest/service.py @@ -0,0 +1,39 @@ +"""Поднимает боевой app.PredictionService offline: те же parquet-справочники +и та же модель, что на стенде, но CSV-погода вместо HTTP weather-service и +без Postgres/pickle-state. +""" +from __future__ import annotations + +from pathlib import Path + +from app import PredictionService, ServiceConfig + +from backtest.weather import CsvWeatherClient + + +def build_offline_service( + repo_root: Path, + weather_csv: Path, + runtime_dir: Path, +) -> PredictionService: + runtime_dir.mkdir(parents=True, exist_ok=True) + + config = ServiceConfig( + model_path=repo_root / "data" / "best_model.cbm", + sensors_metadata_path=( + repo_root / "data" / "справочник_каналов_датчиков.parquet" + ), + objects_metadata_path=( + repo_root / "data" / "справочник_объектов_диспетчер.parquet" + ), + metadata_cache_path=runtime_dir / "metadata_cache.pkl", + # Путь не существует: чужой/устаревший rolling-state не должен + # подмешаться к бэктесту (см. app.load_state_if_exists). + state_path=runtime_dir / "no_state.pkl", + log_path=runtime_dir / "service.log", + ) + + return PredictionService( + config=config, + weather_client=CsvWeatherClient(weather_csv), + ) diff --git a/backtest/warmstate.py b/backtest/warmstate.py new file mode 100644 index 0000000..3f1d09b --- /dev/null +++ b/backtest/warmstate.py @@ -0,0 +1,74 @@ +"""Состояние канала перед окном загрузки (для прогрева episodes.py). + +Без этого первое тревожное событие в окне бэктеста ошибочно читалось бы +как начало нового эпизода, даже если канал был в тревоге уже давно +(пример из независимой проверки: канал 334183, последнее событие до +29.03.2026 — 2026-03-12 14:31:42, тревожное=true). +""" +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +import polars as pl + +ID_COL = "ид_канала_данных" +ALL_YEARS = range(2019, 2027) + + +def _year_rows(dataset_dir: Path, year: int, channel_ids: list[int]) -> pl.DataFrame: + lf = pl.scan_csv( + dataset_dir / f"ext-journal-{year}.csv", + schema_overrides={ + ID_COL: pl.Utf8, + "дата": pl.Utf8, + "значение_датчика": pl.Utf8, + }, + ignore_errors=True, + ) + + return ( + lf.select(ID_COL, "дата", "время", "тревожное", "значение_датчика") + .filter(pl.col(ID_COL) != ID_COL) + .with_columns(pl.col(ID_COL).cast(pl.Int64)) + .filter(pl.col(ID_COL).is_in(channel_ids)) + .collect(engine="streaming") + ) + + +def last_state_before( + dataset_dir: Path, + channel_ids: list[int], + before: datetime, + years: range = ALL_YEARS, +) -> dict[int, tuple[bool, str]]: + """Последнее событие каждого канала с ts < before, по всем годам.""" + frames = [_year_rows(dataset_dir, year, channel_ids) for year in years] + df = pl.concat(frames) + + df = ( + df.with_columns( + pl.col("дата").str.to_date(), + pl.col("время").str.to_time("%H:%M:%S", strict=False), + (pl.col("тревожное") == "t").alias("тревожное"), + ) + .with_columns( + pl.datetime( + pl.col("дата").dt.year(), + pl.col("дата").dt.month(), + pl.col("дата").dt.day(), + pl.col("время").dt.hour(), + pl.col("время").dt.minute(), + pl.col("время").dt.second(), + ).alias("ts") + ) + .filter(pl.col("ts") < before) + .sort([ID_COL, "ts"]) + .group_by(ID_COL, maintain_order=True) + .last() + ) + + return { + int(row[ID_COL]): (row["тревожное"], row["значение_датчика"]) + for row in df.iter_rows(named=True) + } diff --git a/backtest/weather.py b/backtest/weather.py new file mode 100644 index 0000000..d674e78 --- /dev/null +++ b/backtest/weather.py @@ -0,0 +1,52 @@ +"""Offline WeatherSource для app.PredictionService: тот же CSV, что и в +retraining (open-meteo), вместо HTTP weather-service/Postgres. +""" +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Mapping + +import polars as pl + +WEATHER_COLUMNS = ( + "temperature_2m (°C)", + "relative_humidity_2m (%)", + "precipitation_probability (%)", + "precipitation (mm)", + "rain (mm)", + "snowfall (cm)", + "snow_depth (m)", + "weather_code (wmo code)", + "cloud_cover (%)", + "pressure_msl (hPa)", +) + + +class CsvWeatherClient: + """Как app.WeatherSource: .get(hour) -> {feature_name: value}. + + Время CSV — UTC; сдвиг +3ч на МСК воспроизводит EXPECTED_WEATHER из + retraining/notebooks/1_prepare_dataset.ipynb дословно. + """ + + def __init__(self, csv_path: Path) -> None: + df = pl.read_csv(csv_path).with_columns( + (pl.col("time").str.to_datetime() + pl.duration(hours=3)).alias("time") + ) + + self._by_hour: dict[datetime, Mapping[str, float | int | None]] = { + row["time"]: row for row in df.iter_rows(named=True) + } + + def get(self, target_hour: datetime) -> Mapping[str, float | int | None]: + hour = target_hour.replace(minute=0, second=0, microsecond=0) + row = self._by_hour.get(hour) + + if row is None: + return {name: None for name in WEATHER_COLUMNS} + + return row + + def close(self) -> None: + pass diff --git a/docs/backtest/backtest.md b/docs/backtest/backtest.md new file mode 100644 index 0000000..77e362a --- /dev/null +++ b/docs/backtest/backtest.md @@ -0,0 +1,208 @@ +# Бэктест HACK-174: модель на стенде vs бейзлайны + +Модель: `data/best_model.cbm` = `catboost-aft-482a2af568e5` (sha256 совпадает +с `moscow_hack/survival_optuna/best_model.cbm` и с версией, которую +`inference/config.py:model_version` пишет в проде). Признаки — тот же код, +что и на стенде: `app.PredictionService` импортирован как есть +(`backtest/service.py`, `backtest/predict.py`, `backtest/prodstyle.py`). + +Запуск: `HACK174_DATASET_DIR=/path/to/Sources/dataset python -m backtest.run` +(путь обязателен, см. `backtest/README.md`). Время прогона: ~65 секунд. + +## Вывод (для заказчика) + +- Прогноз, который стенд показывает на начало смены (HIGH): на диспетчера ~7 алертов в смену по всему парку, сбывается ~1 из 6 (17,6%); ловим 10% тревог и неисправностей, предупреждаем в среднем (медиана) за 6 ч. +- На спокойных каналах (в норме и без тревог 6 ч): ~5 алертов, сбывается 15,8%, ловим 7%. +- Против правила «тревога была вчера» (12,5% / 12,0%): точнее (значимо), но ловим меньше (значимо). Если пересчитать прогноз заново на границе смены, модель не лучше этого правила. +- Цель ТЗ (Precision > 0,7, Recall > 0,5) не достигнута ни в одном режиме; это идёт в «Отклонения от ТЗ». +- 83% сбывшегося — состояние «Неисправен», а не тревога. Цифры — оценка сверху: модель видела апрель–июнь 2026 на других каналах. + +## Таблица метрик + +Единица — канал × смена (12 ч), исход — новый эпизод в (t0, t0+24ч]. +«Sample» — алертов/смену/диспетчер на тестовой выборке (5% парка), «Fleet» +— пересчитано на весь парк (см. «Определения»). + +| Срез | Метод | Precision | Recall | TP | FP | FN | Sample | Fleet | Медиана ч | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Все | Свежий t0, HIGH | 0,090 | 0,066 | 114 | 1149 | 1621 | 0,44 | 8,71 | 5,3 | +| Все | Свежий t0, MEDIUM | 0,064 | 0,170 | 295 | 4281 | 1440 | 1,59 | 31,62 | 7,3 | +| Все | Prodstyle, HIGH | 0,176 | 0,101 | 175 | 820 | 1560 | 0,35 | 6,92 | 6,3 | +| Все | Prodstyle, MEDIUM | 0,144 | 0,149 | 259 | 1536 | 1476 | 0,62 | 12,07 | 8,8 | +| Все | Бейзлайн «вчера» | 0,125 | 0,120 | 209 | 1462 | 1526 | 0,58 | 11,78 | 7,8 | +| Все | Бейзлайн «≥10 вчера» | 0,227 | 0,010 | 17 | 58 | 1718 | 0,03 | 0,60 | 0,4 | +| Спокойные | Свежий t0, HIGH | 0,075 | 0,053 | 87 | 1076 | 1554 | 0,40 | 8,00 | 7,6 | +| Спокойные | Свежий t0, MEDIUM | 0,056 | 0,151 | 248 | 4145 | 1393 | 1,53 | 30,36 | 7,7 | +| Спокойные | Prodstyle, HIGH | 0,158 | 0,072 | 118 | 627 | 1523 | 0,26 | 5,18 | 6,0 | +| Спокойные | Prodstyle, MEDIUM | 0,129 | 0,118 | 194 | 1308 | 1447 | 0,52 | 10,05 | 7,8 | +| Спокойные | Бейзлайн «вчера» | 0,103 | 0,085 | 139 | 1211 | 1502 | 0,47 | 9,51 | 7,1 | +| Спокойные | Бейзлайн «≥10 вчера» | 0,070 | 0,002 | 3 | 40 | 1638 | 0,01 | 0,35 | 12,3 | + +Всего строк: 99 000 (550 каналов × 180 смен, 2026-04-01 08:00 — 2026-06-29 +20:00, граница горизонта — см. «Определения»). Базовая частота исхода — +1,79%. Полные данные — `predictions.csv` (не в git), `comparison_table.csv`. + +## Бутстрэп: модель против бейзлайна «тревога вчера» + +95% CI разницы (модель − бейзлайн) по 2000 ресемплам **каналов** (не строк +— строки одного канала коррелированы), срез «все каналы»: + +| Метод | Δ Precision | CI 95% | Δ Recall | CI 95% | +| --- | --- | --- | --- | --- | +| Свежий t0, HIGH | -0,031 | [-0,071; +0,012] | -0,054 | [-0,083; -0,028] | +| Свежий t0, MEDIUM | -0,060 | [-0,087; -0,035] | +0,049 | [+0,001; +0,106] | +| Prodstyle, HIGH | +0,051 | [+0,028; +0,075] | -0,020 | [-0,033; -0,006] | +| Prodstyle, MEDIUM | +0,020 | [+0,000; +0,041] | +0,029 | [+0,007; +0,055] | + +Значимо (CI не пересекает ноль): свежий-t0 по precision(MEDIUM) и +recall(HIGH, MEDIUM); prodstyle — по precision(HIGH, MEDIUM) и +recall(HIGH, MEDIUM). Свежий-t0/HIGH по precision — **не значимо**. +Полные данные — `bootstrap_ci.csv`. + +## Эпизоды по значению датчика + +82,7% начал эпизодов в апреле-июне — состояние «Неисправен», не тревога в +привычном смысле (`episode_value_breakdown.csv`): + +| значение_датчика | N | доля | +| --- | --- | --- | +| Неисправен | 5543 | 82,7% | +| Обнаружен дым | 366 | 5,5% | +| Не замкнут | 262 | 3,9% | +| Отключено устройство | 229 | 3,4% | +| Обнаружено движение | 113 | 1,7% | +| остальные (7 значений) | 186 | 2,8% | + +## Калибровка + +Доля сбывшихся прогнозов по бинам вероятности, свежий t0, все каналы: + +| Бин | N | Факт. доля | Средняя вероятность | +| --- | --- | --- | --- | +| [0,0-0,1) | 86719 | 0,013 | 0,013 | +| [0,1-0,2) | 4308 | 0,036 | 0,139 | +| [0,2-0,3) | 1974 | 0,060 | 0,250 | +| [0,3-0,4) | 1423 | 0,055 | 0,346 | +| [0,4-0,5) | 1363 | 0,037 | 0,451 | +| [0,5-0,6) | 1053 | 0,073 | 0,550 | +| [0,6-0,7) | 897 | 0,059 | 0,647 | +| [0,7-0,8) | 534 | 0,092 | 0,746 | +| [0,8-0,9) | 284 | 0,074 | 0,846 | +| [0,9-1,0) | 445 | 0,099 | 0,976 | + +Плохая, немонотонная: в [0,9-1,0) сбывается 9,9% при заявленных ~97,6%. + +## Определения + +- **Смена** — 08:00/20:00, наивное московское время журнала, окно 12ч. + Другого расписания в `Sources/Q&A` не найдено. +- **Граница периода** — сетка t0 с 04-01 08:00, последняя смена — + **06-29 20:00**, не 06-30: t0 берётся только если весь `(t0, t0+24ч]` + внутри периода (до 07-01 00:00); иначе исход выходил бы за "апрель-июнь" + (`backtest/run.py:_valid_shift_times`). +- **Эпизод** — переход из нормы (`тревожное=false`) в тревогу (`=true`); + повторные тревожные события подряд эпизод не открывают заново. + Состояние ДО первого события в окне прогревается по последнему + реальному событию канала до 2026-03-29 из журналов 2019-2026 + (`backtest/warmstate.py`) — без этого канал, тревожащий уже давно + (пример: канал 334183, последнее событие до окна — 2026-03-12, тревога), + ошибочно читался бы как "в норме". +- **Исход (outcome_24h)** — новый эпизод в `(t0, t0+24ч]`; 82,7% таких начал + — «Неисправен», отсюда формулировка «тревога или неисправность». +- **«Спокойный» канал** — в норме непосредственно перед t0 (по прогретому + состоянию) **и** без тревог за `[t0-6ч, t0)`. Оба условия обязательны: + без первого канал без свежих событий, но всё ещё тревожный, ошибочно + считался бы спокойным. +- **Два варианта модели**: *свежий t0* — признаки и прогноз ровно на + границе смены, горизонт 24ч (`backtest/predict.py`); *prodstyle* — то, + что стенд реально показывал бы: последний прогноз по РЕАЛЬНОМУ событию + до t0 (не на границе смены, как `inference/runner.py:333-360`), горизонт + 30ч (`HORIZON_HOURS`, `inference/config.py`), учитывается только пока не + просрочен (`age_hours < 30`, как `prediction_log`/`delete_expired`, + `inference/predictions.py`); реализация — `backtest/prodstyle.py`. +- **Тестовые каналы** — 1:1 из `retraining/notebooks/ + 2_create_train_test_datasets_V2_MAIN.ipynb` (`get_device_split`): + уникальные `ид_канала_данных` из журналов 2024/2025/2026, + `sort`+`sample(shuffle=True, seed=69)`, train 90%/val 5%/test — остаток. + 11518 id, разбиение 10366/575/577 каналов И число строк на сплит + (train 115 450 010 / val 7 739 021 / test 12 072 037) — **совпадают с + ноутбуком дословно**. Список 577 id — `test_channels.csv` (не в git, + генерируется прогоном). +- **Диспетчер** — объект уровня 2 в `справочник_объектов_диспетчер.csv` + (16 объектов, `Sources/Q&A/Q&A_2.txt`: "2 — Диспетчер района"); канал → + ближайший предок уровня 2 по `родитель` (`backtest/dispatch.py`). +- **Алертов/смену (fleet)** — тестовые каналы — ~4,8% парка (550/11485), + случайная выборка по каналам, не по диспетчерам. «Sample» — среднее + число каналов-алертов на (диспетчер, t0) на тестовой выборке; «Fleet» + домножает на `все_каналы_диспетчера / тестовые_каналы_диспетчера` + (`dispatch.dispatcher_scale`) — экстраполяция, неустойчивая там, где + тестовых каналов на диспетчера мало. +- **isAlarm** — `тревожное` журнала (то же поле — `TARGET` в + `1_prepare_dataset.ipynb`). Сверка со `справочник_состояний.csv` + (`backtest/isalarm_check.py`, `isalarm_*.csv`): + - строго по (`тип_датчика`, `название_состояния`), где справочник + однозначен: покрывает 13 321 из 1 552 141 строк (0,86%) — 13 299 + false/false, 20 true/true, 2 расхождения (согласие почти целиком + тривиально, false/false); + - шире, по одному названию состояния без учёта типа: покрывает 173 831 + строку (11,2%) — **22 729 расхождений**, почти все на "Обнаружено + движение" (11 649) и "Не замкнут" (11 078), которые справочник всегда + считает тревожными, а журнал — нет для многих каналов. + - Вывод: `тревожное` остаётся источником isAlarm (на нём обучена + модель), но справочник состояний не подтверждает это независимо — + расхождение помечено ниже как отклонение от ТЗ. + +## Допущения и неопределённости + +- Тестовые каналы воспроизведены по CSV, а не по исходным `.parquet` из + `run_pipeline.py` (их нет в этом окружении). Совпадение чисел каналов И + строк на сплит с ноутбуком — сильный аргумент, но не формальное + доказательство идентичности множества id. +- Модель обучалась в т.ч. на данных 2026 по train-каналам, включая тот же + апрель-июнь и всплеск тревог 29-30.06 на тестовых каналах (135 и 207 за + день против медианы ~20/день за квартал). Тестовые каналы в train не + попадали, но период — да: временная утечка на уровне сезона/событий + дня, метрики отчёта — оценка **сверху** относительно будущего периода. +- 1 из 577 каналов отсутствует в `справочник_каналов_датчиков.parquet`, + ещё 26 не имеют событий в марте-июле 2026 — исключены. Метрики по 550 + из 577 (95,3%). +- Погода — Open-Meteo **historical-forecast API** (заголовок + `1_prepare_dataset.ipynb`): для недавних часов источник может + подмешивать краткосрочный прогноз, то есть небольшая утечка будущего + (до +24ч) возможна. Не специфика бэктеста — тот же CSV использовался и + при обучении. +- `history_complete=false` (14,7% строк) — не "канал новый", а "за 3 суток + до t0 в окне не набралось событий": доминирующая причина — редкие + каналы (медиана 22 события за 3 месяца), а не новые (у всех 577 есть + история до 2026-03-29, см. `warmstate.py`). +- Прод по умолчанию использует горизонт 30ч, не 24ч — "свежий t0" считает + 24ч (как в ТЗ), "prodstyle" — боевые 30ч. + +## Как воспроизвести + +```bash +HACK174_DATASET_DIR=/path/to/Sources/dataset python -m backtest.run +``` + +`predictions.csv` и `test_channels.csv` не в git — генерируются каждым +прогоном (см. `.gitignore`). Остальные CSV в `docs/backtest/` — агрегаты, +коммитятся. + +## Отклонения от ТЗ + +1. Ни модель (оба варианта), ни один порог не достигает одновременно + **Precision > 0,7 и Recall > 0,5** на методике "канал × смена, строго + по времени". Лучший recall — свежий-t0 MEDIUM (17,0% при precision + 6,4%); лучший precision — prodstyle HIGH (17,6% при recall 10,1%). +2. На "свежем t0" модель не отличается от бейзлайна «тревога вчера» по + precision и значимо хуже по recall; на "prodstyle" — значимо лучше по + precision, но всё ещё значимо хуже по recall (см. бутстрэп выше). +3. Заявленный ранее PR-AUC 0,44 получен на случайном сплите по каналам, + без учёта времени — методика не сопоставима с "канал × смена" напрямую, + прямое сравнение чисел не делается. +4. `справочник_состояний.csv` не подтверждает `тревожное` журнала как + isAlarm независимо: там, где сопоставление возможно без учёта типа + датчика, 22 729 строк расходятся (справочник считает тревогой + "Обнаружено движение"/"Не замкнут", журнал — нет). Метрики построены на + `тревожное` (как и обучение модели), но расхождение требует + согласования с автором тикета. diff --git a/docs/backtest/bootstrap_ci.csv b/docs/backtest/bootstrap_ci.csv new file mode 100644 index 0000000..f1867a0 --- /dev/null +++ b/docs/backtest/bootstrap_ci.csv @@ -0,0 +1,5 @@ +method,vs,precision_diff,precision_diff_ci_low,precision_diff_ci_high,recall_diff,recall_diff_ci_low,recall_diff_ci_high +model_fresh_t0_high_0.7,baseline_alarm_yesterday,-0.03138913755639792,-0.07050541664833714,0.012371952598976558,-0.05449899321474636,-0.08312325741661279,-0.028134374772072177 +model_fresh_t0_medium_0.4,baseline_alarm_yesterday,-0.05960757964951178,-0.08694890344125657,-0.035021413272330215,0.04908701353424739,0.0006112734910093891,0.10552878578603714 +model_prodstyle_high_0.7,baseline_alarm_yesterday,0.05103185500595758,0.02823745039543638,0.07505216804979566,-0.01956546001744728,-0.032832704205969995,-0.006099442426007712 +model_prodstyle_medium_0.4,baseline_alarm_yesterday,0.01974370976459373,0.00019372933244877623,0.04067700325721721,0.029015808611489988,0.006503228120516496,0.054738962341369164 diff --git a/docs/backtest/calibration.csv b/docs/backtest/calibration.csv new file mode 100644 index 0000000..1c5e367 --- /dev/null +++ b/docs/backtest/calibration.csv @@ -0,0 +1,11 @@ +probability_bin,n,actual_rate,mean_probability +"[0.0, 0.1)",86719,0.012546270136878885,0.013213212696130827 +"[0.1, 0.2)",4308,0.036211699164345405,0.13938121656866698 +"[0.2, 0.3)",1974,0.05977710233029382,0.25046106315726696 +"[0.3, 0.4)",1423,0.054813773717498245,0.34584533768545883 +"[0.4, 0.5)",1363,0.03741746148202495,0.4513625936184026 +"[0.5, 0.6)",1053,0.0731244064577398,0.5495437673289508 +"[0.6, 0.7)",897,0.059085841694537344,0.6466572509661418 +"[0.7, 0.8)",534,0.09176029962546817,0.7455367933795556 +"[0.8, 0.9)",284,0.07394366197183098,0.8464538803053107 +"[0.9, 1.0)",445,0.09887640449438202,0.9764363440869955 diff --git a/docs/backtest/comparison_table.csv b/docs/backtest/comparison_table.csv new file mode 100644 index 0000000..d8ce08f --- /dev/null +++ b/docs/backtest/comparison_table.csv @@ -0,0 +1,13 @@ +slice,method,precision,recall,tp,fp,fn,n,alerts_per_shift_sample,alerts_per_shift_fleet,median_hours_to_event +all_channels,model_fresh_t0_high_0.7,0.090261,0.065706,114,1149,1621,99000,0.438542,8.711691,5.316250 +all_channels,model_fresh_t0_medium_0.4,0.064467,0.170029,295,4281,1440,99000,1.588889,31.621273,7.250556 +all_channels,model_prodstyle_high_0.7,0.175879,0.100865,175,820,1560,99000,0.345486,6.923810,6.276667 +all_channels,model_prodstyle_medium_0.4,0.144290,0.149280,259,1536,1476,99000,0.623264,12.072714,8.818333 +all_channels,baseline_alarm_yesterday,0.125075,0.120461,209,1462,1526,99000,0.580208,11.778137,7.755278 +all_channels,baseline_10plus_yesterday,0.226667,0.009798,17,58,1718,99000,0.026042,0.603211,0.385000 +early_warning_6h,model_fresh_t0_high_0.7,0.074807,0.053016,87,1076,1554,97481,0.403819,8.003108,7.574444 +early_warning_6h,model_fresh_t0_medium_0.4,0.056453,0.151127,248,4145,1393,97481,1.525347,30.362544,7.668750 +early_warning_6h,model_prodstyle_high_0.7,0.158389,0.071907,118,627,1523,97481,0.258681,5.180913,5.996944 +early_warning_6h,model_prodstyle_medium_0.4,0.129161,0.118221,194,1308,1447,97481,0.521528,10.053264,7.805972 +early_warning_6h,baseline_alarm_yesterday,0.102963,0.084704,139,1211,1502,97481,0.468750,9.510626,7.099722 +early_warning_6h,baseline_10plus_yesterday,0.069767,0.001828,3,40,1638,97481,0.014931,0.350726,12.250278 diff --git a/docs/backtest/episode_value_breakdown.csv b/docs/backtest/episode_value_breakdown.csv new file mode 100644 index 0000000..477a138 --- /dev/null +++ b/docs/backtest/episode_value_breakdown.csv @@ -0,0 +1,13 @@ +значение_датчика,n,share +Неисправен,5543,0.8274369308852068 +Обнаружен дым,366,0.05463502015226153 +Не замкнут,262,0.03911031497238394 +Отключено устройство,229,0.0341842065979997 +Обнаружено движение,113,0.016868189281982384 +Обнаружен газ,57,0.00850873264666368 +Рычаг сдернут,56,0.008359456635318705 +Много неисправных устройств,26,0.0038811762949693984 +Питание от батарей,23,0.0034333482609344677 +Температура ниже 3ºC,13,0.0019405881474846992 +Работают все насосы в АНС,9,0.0013434841021047917 +Не определено,2,0.0002985520226899537 diff --git a/docs/backtest/isalarm_by_name_coverage.csv b/docs/backtest/isalarm_by_name_coverage.csv new file mode 100644 index 0000000..b299f69 --- /dev/null +++ b/docs/backtest/isalarm_by_name_coverage.csv @@ -0,0 +1,2 @@ +matched,total +173831,1552141 diff --git a/docs/backtest/isalarm_by_name_mismatches.csv b/docs/backtest/isalarm_by_name_mismatches.csv new file mode 100644 index 0000000..b8500bb --- /dev/null +++ b/docs/backtest/isalarm_by_name_mismatches.csv @@ -0,0 +1,4 @@ +значение_датчика,n +Обнаружено движение,11649 +Не замкнут,11078 +Отключено устройство,2 diff --git a/docs/backtest/isalarm_by_name_summary.csv b/docs/backtest/isalarm_by_name_summary.csv new file mode 100644 index 0000000..f4c110b --- /dev/null +++ b/docs/backtest/isalarm_by_name_summary.csv @@ -0,0 +1,4 @@ +journal,тревожное_dict,n +false,false,144296 +false,true,22729 +true,true,6806 diff --git a/docs/backtest/isalarm_strict_coverage.csv b/docs/backtest/isalarm_strict_coverage.csv new file mode 100644 index 0000000..e6f02b4 --- /dev/null +++ b/docs/backtest/isalarm_strict_coverage.csv @@ -0,0 +1,2 @@ +matched,total +13321,1552141 diff --git a/docs/backtest/isalarm_strict_summary.csv b/docs/backtest/isalarm_strict_summary.csv new file mode 100644 index 0000000..fb5c6b9 --- /dev/null +++ b/docs/backtest/isalarm_strict_summary.csv @@ -0,0 +1,4 @@ +journal,тревожное_dict,n +false,false,13299 +false,true,2 +true,true,20 From 08e0a5120c17a39e83589908044ca0ffa80e0c7c Mon Sep 17 00:00:00 2001 From: UsatovPavel Date: Sun, 27 Sep 2026 10:13:17 +0300 Subject: [PATCH 2/4] refactor(backtest): move to tests/backtest, short report Report rewritten to one screen: why, what, one table, what follows. isAlarm cross-check dropped from the PR; its conclusion is one line. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 4 +- backtest/isalarm_check.py | 80 ------- docs/backtest/backtest.md | 208 ------------------ docs/backtest/isalarm_by_name_coverage.csv | 2 - docs/backtest/isalarm_by_name_mismatches.csv | 4 - docs/backtest/isalarm_by_name_summary.csv | 4 - docs/backtest/isalarm_strict_coverage.csv | 2 - docs/backtest/isalarm_strict_summary.csv | 4 - {backtest => tests/backtest}/README.md | 10 +- {backtest => tests/backtest}/__init__.py | 0 tests/backtest/backtest.md | 74 +++++++ {backtest => tests/backtest}/channels.py | 2 +- {backtest => tests/backtest}/data.py | 0 {backtest => tests/backtest}/dispatch.py | 0 {backtest => tests/backtest}/episodes.py | 0 {backtest => tests/backtest}/metrics.py | 0 {backtest => tests/backtest}/predict.py | 0 {backtest => tests/backtest}/prodstyle.py | 0 .../backtest/results}/bootstrap_ci.csv | 0 .../backtest/results}/calibration.csv | 0 .../backtest/results}/comparison_table.csv | 0 .../results}/episode_value_breakdown.csv | 0 {backtest => tests/backtest}/run.py | 41 ++-- {backtest => tests/backtest}/service.py | 2 +- {backtest => tests/backtest}/warmstate.py | 0 {backtest => tests/backtest}/weather.py | 0 26 files changed, 100 insertions(+), 337 deletions(-) delete mode 100644 backtest/isalarm_check.py delete mode 100644 docs/backtest/backtest.md delete mode 100644 docs/backtest/isalarm_by_name_coverage.csv delete mode 100644 docs/backtest/isalarm_by_name_mismatches.csv delete mode 100644 docs/backtest/isalarm_by_name_summary.csv delete mode 100644 docs/backtest/isalarm_strict_coverage.csv delete mode 100644 docs/backtest/isalarm_strict_summary.csv rename {backtest => tests/backtest}/README.md (77%) rename {backtest => tests/backtest}/__init__.py (100%) create mode 100644 tests/backtest/backtest.md rename {backtest => tests/backtest}/channels.py (97%) rename {backtest => tests/backtest}/data.py (100%) rename {backtest => tests/backtest}/dispatch.py (100%) rename {backtest => tests/backtest}/episodes.py (100%) rename {backtest => tests/backtest}/metrics.py (100%) rename {backtest => tests/backtest}/predict.py (100%) rename {backtest => tests/backtest}/prodstyle.py (100%) rename {docs/backtest => tests/backtest/results}/bootstrap_ci.csv (100%) rename {docs/backtest => tests/backtest/results}/calibration.csv (100%) rename {docs/backtest => tests/backtest/results}/comparison_table.csv (100%) rename {docs/backtest => tests/backtest/results}/episode_value_breakdown.csv (100%) rename {backtest => tests/backtest}/run.py (85%) rename {backtest => tests/backtest}/service.py (96%) rename {backtest => tests/backtest}/warmstate.py (100%) rename {backtest => tests/backtest}/weather.py (100%) diff --git a/.gitignore b/.gitignore index 0ad88db..7b53cf2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,5 @@ data/ext-journal-2026.parquet __pycache__/ .pytest_cache/ runtime/ -docs/backtest/predictions.csv -docs/backtest/test_channels.csv +tests/backtest/results/predictions.csv +tests/backtest/results/test_channels.csv diff --git a/backtest/isalarm_check.py b/backtest/isalarm_check.py deleted file mode 100644 index 69c2e3e..0000000 --- a/backtest/isalarm_check.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Честная сверка isAlarm: тревожное журнала против справочник_состояний. - -Решение остаётся прежним — берём тревожное журнала (см. episodes.py), но -отчёт должен показывать реальную картину, а не только долю совпадений: -покрытие словаря крошечное, а согласие почти целиком на "не тревога". -""" -from __future__ import annotations - -from pathlib import Path - -import polars as pl - -ID_COL = "ид_канала_данных" - - -def _unambiguous(states: pl.DataFrame, group_cols: list[str]) -> pl.DataFrame: - agg = states.group_by(group_cols).agg( - pl.col("тревожное").n_unique().alias("nuniq"), - pl.col("тревожное").first().alias("тревожное_dict"), - ) - return agg.filter(pl.col("nuniq") == 1).drop("nuniq") - - -def _summary(joined: pl.DataFrame) -> pl.DataFrame: - return ( - joined.rename({"тревожное": "journal"}) - .group_by(["journal", "тревожное_dict"]) - .agg(pl.len().alias("n")) - .sort(["journal", "тревожное_dict"]) - ) - - -def _mismatches(joined: pl.DataFrame) -> pl.DataFrame: - return ( - joined.rename({"тревожное": "journal"}) - .filter(pl.col("journal") != pl.col("тревожное_dict")) - .group_by("значение_датчика") - .agg(pl.len().alias("n")) - .sort("n", descending=True) - ) - - -def check( - events_df: pl.DataFrame, - states_csv: Path, - sensors_csv: Path, -) -> dict[str, pl.DataFrame]: - states = pl.read_csv(states_csv) - sensors = pl.read_csv(sensors_csv).select(ID_COL, "тип_датчика") - events = events_df.select(ID_COL, "тревожное", "значение_датчика") - - # Строго: (тип_датчика, название_состояния) однозначны в справочнике. - strict = _unambiguous(states, ["тип_датчика", "название_состояния"]) - strict_joined = events.join(sensors, on=ID_COL, how="left").join( - strict, - left_on=["тип_датчика", "значение_датчика"], - right_on=["тип_датчика", "название_состояния"], - how="inner", - ) - - # Шире: только по названию состояния, без учёта типа датчика. - by_name = _unambiguous(states, ["название_состояния"]) - name_joined = events.join( - by_name, - left_on="значение_датчика", - right_on="название_состояния", - how="inner", - ) - - return { - "strict_summary": _summary(strict_joined), - "strict_coverage": pl.DataFrame( - {"matched": [strict_joined.height], "total": [events.height]} - ), - "by_name_summary": _summary(name_joined), - "by_name_mismatches": _mismatches(name_joined), - "by_name_coverage": pl.DataFrame( - {"matched": [name_joined.height], "total": [events.height]} - ), - } diff --git a/docs/backtest/backtest.md b/docs/backtest/backtest.md deleted file mode 100644 index 77e362a..0000000 --- a/docs/backtest/backtest.md +++ /dev/null @@ -1,208 +0,0 @@ -# Бэктест HACK-174: модель на стенде vs бейзлайны - -Модель: `data/best_model.cbm` = `catboost-aft-482a2af568e5` (sha256 совпадает -с `moscow_hack/survival_optuna/best_model.cbm` и с версией, которую -`inference/config.py:model_version` пишет в проде). Признаки — тот же код, -что и на стенде: `app.PredictionService` импортирован как есть -(`backtest/service.py`, `backtest/predict.py`, `backtest/prodstyle.py`). - -Запуск: `HACK174_DATASET_DIR=/path/to/Sources/dataset python -m backtest.run` -(путь обязателен, см. `backtest/README.md`). Время прогона: ~65 секунд. - -## Вывод (для заказчика) - -- Прогноз, который стенд показывает на начало смены (HIGH): на диспетчера ~7 алертов в смену по всему парку, сбывается ~1 из 6 (17,6%); ловим 10% тревог и неисправностей, предупреждаем в среднем (медиана) за 6 ч. -- На спокойных каналах (в норме и без тревог 6 ч): ~5 алертов, сбывается 15,8%, ловим 7%. -- Против правила «тревога была вчера» (12,5% / 12,0%): точнее (значимо), но ловим меньше (значимо). Если пересчитать прогноз заново на границе смены, модель не лучше этого правила. -- Цель ТЗ (Precision > 0,7, Recall > 0,5) не достигнута ни в одном режиме; это идёт в «Отклонения от ТЗ». -- 83% сбывшегося — состояние «Неисправен», а не тревога. Цифры — оценка сверху: модель видела апрель–июнь 2026 на других каналах. - -## Таблица метрик - -Единица — канал × смена (12 ч), исход — новый эпизод в (t0, t0+24ч]. -«Sample» — алертов/смену/диспетчер на тестовой выборке (5% парка), «Fleet» -— пересчитано на весь парк (см. «Определения»). - -| Срез | Метод | Precision | Recall | TP | FP | FN | Sample | Fleet | Медиана ч | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| Все | Свежий t0, HIGH | 0,090 | 0,066 | 114 | 1149 | 1621 | 0,44 | 8,71 | 5,3 | -| Все | Свежий t0, MEDIUM | 0,064 | 0,170 | 295 | 4281 | 1440 | 1,59 | 31,62 | 7,3 | -| Все | Prodstyle, HIGH | 0,176 | 0,101 | 175 | 820 | 1560 | 0,35 | 6,92 | 6,3 | -| Все | Prodstyle, MEDIUM | 0,144 | 0,149 | 259 | 1536 | 1476 | 0,62 | 12,07 | 8,8 | -| Все | Бейзлайн «вчера» | 0,125 | 0,120 | 209 | 1462 | 1526 | 0,58 | 11,78 | 7,8 | -| Все | Бейзлайн «≥10 вчера» | 0,227 | 0,010 | 17 | 58 | 1718 | 0,03 | 0,60 | 0,4 | -| Спокойные | Свежий t0, HIGH | 0,075 | 0,053 | 87 | 1076 | 1554 | 0,40 | 8,00 | 7,6 | -| Спокойные | Свежий t0, MEDIUM | 0,056 | 0,151 | 248 | 4145 | 1393 | 1,53 | 30,36 | 7,7 | -| Спокойные | Prodstyle, HIGH | 0,158 | 0,072 | 118 | 627 | 1523 | 0,26 | 5,18 | 6,0 | -| Спокойные | Prodstyle, MEDIUM | 0,129 | 0,118 | 194 | 1308 | 1447 | 0,52 | 10,05 | 7,8 | -| Спокойные | Бейзлайн «вчера» | 0,103 | 0,085 | 139 | 1211 | 1502 | 0,47 | 9,51 | 7,1 | -| Спокойные | Бейзлайн «≥10 вчера» | 0,070 | 0,002 | 3 | 40 | 1638 | 0,01 | 0,35 | 12,3 | - -Всего строк: 99 000 (550 каналов × 180 смен, 2026-04-01 08:00 — 2026-06-29 -20:00, граница горизонта — см. «Определения»). Базовая частота исхода — -1,79%. Полные данные — `predictions.csv` (не в git), `comparison_table.csv`. - -## Бутстрэп: модель против бейзлайна «тревога вчера» - -95% CI разницы (модель − бейзлайн) по 2000 ресемплам **каналов** (не строк -— строки одного канала коррелированы), срез «все каналы»: - -| Метод | Δ Precision | CI 95% | Δ Recall | CI 95% | -| --- | --- | --- | --- | --- | -| Свежий t0, HIGH | -0,031 | [-0,071; +0,012] | -0,054 | [-0,083; -0,028] | -| Свежий t0, MEDIUM | -0,060 | [-0,087; -0,035] | +0,049 | [+0,001; +0,106] | -| Prodstyle, HIGH | +0,051 | [+0,028; +0,075] | -0,020 | [-0,033; -0,006] | -| Prodstyle, MEDIUM | +0,020 | [+0,000; +0,041] | +0,029 | [+0,007; +0,055] | - -Значимо (CI не пересекает ноль): свежий-t0 по precision(MEDIUM) и -recall(HIGH, MEDIUM); prodstyle — по precision(HIGH, MEDIUM) и -recall(HIGH, MEDIUM). Свежий-t0/HIGH по precision — **не значимо**. -Полные данные — `bootstrap_ci.csv`. - -## Эпизоды по значению датчика - -82,7% начал эпизодов в апреле-июне — состояние «Неисправен», не тревога в -привычном смысле (`episode_value_breakdown.csv`): - -| значение_датчика | N | доля | -| --- | --- | --- | -| Неисправен | 5543 | 82,7% | -| Обнаружен дым | 366 | 5,5% | -| Не замкнут | 262 | 3,9% | -| Отключено устройство | 229 | 3,4% | -| Обнаружено движение | 113 | 1,7% | -| остальные (7 значений) | 186 | 2,8% | - -## Калибровка - -Доля сбывшихся прогнозов по бинам вероятности, свежий t0, все каналы: - -| Бин | N | Факт. доля | Средняя вероятность | -| --- | --- | --- | --- | -| [0,0-0,1) | 86719 | 0,013 | 0,013 | -| [0,1-0,2) | 4308 | 0,036 | 0,139 | -| [0,2-0,3) | 1974 | 0,060 | 0,250 | -| [0,3-0,4) | 1423 | 0,055 | 0,346 | -| [0,4-0,5) | 1363 | 0,037 | 0,451 | -| [0,5-0,6) | 1053 | 0,073 | 0,550 | -| [0,6-0,7) | 897 | 0,059 | 0,647 | -| [0,7-0,8) | 534 | 0,092 | 0,746 | -| [0,8-0,9) | 284 | 0,074 | 0,846 | -| [0,9-1,0) | 445 | 0,099 | 0,976 | - -Плохая, немонотонная: в [0,9-1,0) сбывается 9,9% при заявленных ~97,6%. - -## Определения - -- **Смена** — 08:00/20:00, наивное московское время журнала, окно 12ч. - Другого расписания в `Sources/Q&A` не найдено. -- **Граница периода** — сетка t0 с 04-01 08:00, последняя смена — - **06-29 20:00**, не 06-30: t0 берётся только если весь `(t0, t0+24ч]` - внутри периода (до 07-01 00:00); иначе исход выходил бы за "апрель-июнь" - (`backtest/run.py:_valid_shift_times`). -- **Эпизод** — переход из нормы (`тревожное=false`) в тревогу (`=true`); - повторные тревожные события подряд эпизод не открывают заново. - Состояние ДО первого события в окне прогревается по последнему - реальному событию канала до 2026-03-29 из журналов 2019-2026 - (`backtest/warmstate.py`) — без этого канал, тревожащий уже давно - (пример: канал 334183, последнее событие до окна — 2026-03-12, тревога), - ошибочно читался бы как "в норме". -- **Исход (outcome_24h)** — новый эпизод в `(t0, t0+24ч]`; 82,7% таких начал - — «Неисправен», отсюда формулировка «тревога или неисправность». -- **«Спокойный» канал** — в норме непосредственно перед t0 (по прогретому - состоянию) **и** без тревог за `[t0-6ч, t0)`. Оба условия обязательны: - без первого канал без свежих событий, но всё ещё тревожный, ошибочно - считался бы спокойным. -- **Два варианта модели**: *свежий t0* — признаки и прогноз ровно на - границе смены, горизонт 24ч (`backtest/predict.py`); *prodstyle* — то, - что стенд реально показывал бы: последний прогноз по РЕАЛЬНОМУ событию - до t0 (не на границе смены, как `inference/runner.py:333-360`), горизонт - 30ч (`HORIZON_HOURS`, `inference/config.py`), учитывается только пока не - просрочен (`age_hours < 30`, как `prediction_log`/`delete_expired`, - `inference/predictions.py`); реализация — `backtest/prodstyle.py`. -- **Тестовые каналы** — 1:1 из `retraining/notebooks/ - 2_create_train_test_datasets_V2_MAIN.ipynb` (`get_device_split`): - уникальные `ид_канала_данных` из журналов 2024/2025/2026, - `sort`+`sample(shuffle=True, seed=69)`, train 90%/val 5%/test — остаток. - 11518 id, разбиение 10366/575/577 каналов И число строк на сплит - (train 115 450 010 / val 7 739 021 / test 12 072 037) — **совпадают с - ноутбуком дословно**. Список 577 id — `test_channels.csv` (не в git, - генерируется прогоном). -- **Диспетчер** — объект уровня 2 в `справочник_объектов_диспетчер.csv` - (16 объектов, `Sources/Q&A/Q&A_2.txt`: "2 — Диспетчер района"); канал → - ближайший предок уровня 2 по `родитель` (`backtest/dispatch.py`). -- **Алертов/смену (fleet)** — тестовые каналы — ~4,8% парка (550/11485), - случайная выборка по каналам, не по диспетчерам. «Sample» — среднее - число каналов-алертов на (диспетчер, t0) на тестовой выборке; «Fleet» - домножает на `все_каналы_диспетчера / тестовые_каналы_диспетчера` - (`dispatch.dispatcher_scale`) — экстраполяция, неустойчивая там, где - тестовых каналов на диспетчера мало. -- **isAlarm** — `тревожное` журнала (то же поле — `TARGET` в - `1_prepare_dataset.ipynb`). Сверка со `справочник_состояний.csv` - (`backtest/isalarm_check.py`, `isalarm_*.csv`): - - строго по (`тип_датчика`, `название_состояния`), где справочник - однозначен: покрывает 13 321 из 1 552 141 строк (0,86%) — 13 299 - false/false, 20 true/true, 2 расхождения (согласие почти целиком - тривиально, false/false); - - шире, по одному названию состояния без учёта типа: покрывает 173 831 - строку (11,2%) — **22 729 расхождений**, почти все на "Обнаружено - движение" (11 649) и "Не замкнут" (11 078), которые справочник всегда - считает тревожными, а журнал — нет для многих каналов. - - Вывод: `тревожное` остаётся источником isAlarm (на нём обучена - модель), но справочник состояний не подтверждает это независимо — - расхождение помечено ниже как отклонение от ТЗ. - -## Допущения и неопределённости - -- Тестовые каналы воспроизведены по CSV, а не по исходным `.parquet` из - `run_pipeline.py` (их нет в этом окружении). Совпадение чисел каналов И - строк на сплит с ноутбуком — сильный аргумент, но не формальное - доказательство идентичности множества id. -- Модель обучалась в т.ч. на данных 2026 по train-каналам, включая тот же - апрель-июнь и всплеск тревог 29-30.06 на тестовых каналах (135 и 207 за - день против медианы ~20/день за квартал). Тестовые каналы в train не - попадали, но период — да: временная утечка на уровне сезона/событий - дня, метрики отчёта — оценка **сверху** относительно будущего периода. -- 1 из 577 каналов отсутствует в `справочник_каналов_датчиков.parquet`, - ещё 26 не имеют событий в марте-июле 2026 — исключены. Метрики по 550 - из 577 (95,3%). -- Погода — Open-Meteo **historical-forecast API** (заголовок - `1_prepare_dataset.ipynb`): для недавних часов источник может - подмешивать краткосрочный прогноз, то есть небольшая утечка будущего - (до +24ч) возможна. Не специфика бэктеста — тот же CSV использовался и - при обучении. -- `history_complete=false` (14,7% строк) — не "канал новый", а "за 3 суток - до t0 в окне не набралось событий": доминирующая причина — редкие - каналы (медиана 22 события за 3 месяца), а не новые (у всех 577 есть - история до 2026-03-29, см. `warmstate.py`). -- Прод по умолчанию использует горизонт 30ч, не 24ч — "свежий t0" считает - 24ч (как в ТЗ), "prodstyle" — боевые 30ч. - -## Как воспроизвести - -```bash -HACK174_DATASET_DIR=/path/to/Sources/dataset python -m backtest.run -``` - -`predictions.csv` и `test_channels.csv` не в git — генерируются каждым -прогоном (см. `.gitignore`). Остальные CSV в `docs/backtest/` — агрегаты, -коммитятся. - -## Отклонения от ТЗ - -1. Ни модель (оба варианта), ни один порог не достигает одновременно - **Precision > 0,7 и Recall > 0,5** на методике "канал × смена, строго - по времени". Лучший recall — свежий-t0 MEDIUM (17,0% при precision - 6,4%); лучший precision — prodstyle HIGH (17,6% при recall 10,1%). -2. На "свежем t0" модель не отличается от бейзлайна «тревога вчера» по - precision и значимо хуже по recall; на "prodstyle" — значимо лучше по - precision, но всё ещё значимо хуже по recall (см. бутстрэп выше). -3. Заявленный ранее PR-AUC 0,44 получен на случайном сплите по каналам, - без учёта времени — методика не сопоставима с "канал × смена" напрямую, - прямое сравнение чисел не делается. -4. `справочник_состояний.csv` не подтверждает `тревожное` журнала как - isAlarm независимо: там, где сопоставление возможно без учёта типа - датчика, 22 729 строк расходятся (справочник считает тревогой - "Обнаружено движение"/"Не замкнут", журнал — нет). Метрики построены на - `тревожное` (как и обучение модели), но расхождение требует - согласования с автором тикета. diff --git a/docs/backtest/isalarm_by_name_coverage.csv b/docs/backtest/isalarm_by_name_coverage.csv deleted file mode 100644 index b299f69..0000000 --- a/docs/backtest/isalarm_by_name_coverage.csv +++ /dev/null @@ -1,2 +0,0 @@ -matched,total -173831,1552141 diff --git a/docs/backtest/isalarm_by_name_mismatches.csv b/docs/backtest/isalarm_by_name_mismatches.csv deleted file mode 100644 index b8500bb..0000000 --- a/docs/backtest/isalarm_by_name_mismatches.csv +++ /dev/null @@ -1,4 +0,0 @@ -значение_датчика,n -Обнаружено движение,11649 -Не замкнут,11078 -Отключено устройство,2 diff --git a/docs/backtest/isalarm_by_name_summary.csv b/docs/backtest/isalarm_by_name_summary.csv deleted file mode 100644 index f4c110b..0000000 --- a/docs/backtest/isalarm_by_name_summary.csv +++ /dev/null @@ -1,4 +0,0 @@ -journal,тревожное_dict,n -false,false,144296 -false,true,22729 -true,true,6806 diff --git a/docs/backtest/isalarm_strict_coverage.csv b/docs/backtest/isalarm_strict_coverage.csv deleted file mode 100644 index e6f02b4..0000000 --- a/docs/backtest/isalarm_strict_coverage.csv +++ /dev/null @@ -1,2 +0,0 @@ -matched,total -13321,1552141 diff --git a/docs/backtest/isalarm_strict_summary.csv b/docs/backtest/isalarm_strict_summary.csv deleted file mode 100644 index fb5c6b9..0000000 --- a/docs/backtest/isalarm_strict_summary.csv +++ /dev/null @@ -1,4 +0,0 @@ -journal,тревожное_dict,n -false,false,13299 -false,true,2 -true,true,20 diff --git a/backtest/README.md b/tests/backtest/README.md similarity index 77% rename from backtest/README.md rename to tests/backtest/README.md index 73bca7b..173a7db 100644 --- a/backtest/README.md +++ b/tests/backtest/README.md @@ -6,19 +6,19 @@ показывал по последнему событию". ```bash -HACK174_DATASET_DIR=/path/to/Sources/dataset python -m backtest.run +HACK174_DATASET_DIR=/path/to/Sources/dataset python -m tests.backtest.run ``` Путь к данным (журналы `ext-journal-*.csv` и справочники) обязателен: флагом `--dataset-dir` или переменной окружения `HACK174_DATASET_DIR` — здесь намеренно нет дефолта на локальный путь. Результат пишется в -`docs/backtest/` (переопределяется `--output-dir`); `--weather-csv` +`tests/backtest/results/` (переопределяется `--output-dir`); `--weather-csv` указывает на CSV Open-Meteo (по умолчанию — тот, что уже в репозитории, `retraining/data/reference/`). Остальные флаги — `--help`. -`docs/backtest/predictions.csv` (~99 тыс. строк) не хранится в git — +`tests/backtest/results/predictions.csv` (~99 тыс. строк) не хранится в git — генерируется каждым прогоном заново, путь в `.gitignore`. Остальные CSV в -`docs/backtest/` — агрегаты, коммитятся. +`tests/backtest/results/` — агрегаты, коммитятся. Определения (смена, эпизод, горизонт, isAlarm, диспетчер и т.д.) — в -`docs/backtest/backtest.md`. +`tests/backtest/backtest.md`. diff --git a/backtest/__init__.py b/tests/backtest/__init__.py similarity index 100% rename from backtest/__init__.py rename to tests/backtest/__init__.py diff --git a/tests/backtest/backtest.md b/tests/backtest/backtest.md new file mode 100644 index 0000000..9b9784d --- /dev/null +++ b/tests/backtest/backtest.md @@ -0,0 +1,74 @@ +# Бэктест модели прогноза (HACK-174) + +## Зачем + +Эксперты спросят: «Насколько можно верить прогнозу?» До этого у нас была +только цифра PR-AUC 0,44, по которой на этот вопрос не ответить. Здесь +модель проверена так, как её увидит диспетчер: пришёл на смену, посмотрел +прогноз и через сутки узнал, сбылся ли он. + +## Что сделали + +Взяли 550 каналов, которых модель не видела при обучении, и период +апрель–июнь 2026. На начало каждой смены (08:00 и 20:00) смотрели, что в +этот момент показывал бы стенд по каналу и случилась ли на канале новая +тревога или неисправность в следующие 24 ч. Ту же проверку прошло простое +правило «вчера была тревога — будет снова». + +## Результат + +| | Из 100 алертов сбывается | Из 100 событий поймали | Алертов в смену на диспетчера | Предупреждаем за | +|---|---|---|---|---| +| Цель из ТЗ | 70 | 50 | — | — | +| Модель, HIGH (≥ 0,7) | **18** | **10** | ~7 | 6 ч | +| Модель, MEDIUM (≥ 0,4) | 14 | 15 | ~12 | 9 ч | +| Правило «тревога вчера» | 13 | 12 | ~12 | 8 ч | + +## Что из этого следует + +1. **Заявлять экспертам высокую достоверность нельзя.** 4 из 5 алертов HIGH + ложные, 9 из 10 событий модель пропускает. Цель ТЗ не достигнута, это + идёт в «Отклонения от ТЗ». +2. **Модель почти не лучше простого правила.** HIGH точнее правила (18 + против 13), но ловит меньше (10 против 12). Обе разницы статистически + значимы. +3. **Вероятность в карточке вводит в заблуждение.** Там, где модель пишет + 90–100%, событие случается в 10% случаев. Показывать процент как + вероятность нельзя, только уровень HIGH или MEDIUM. +4. **Предсказываем в основном поломки датчиков.** 83% сбывшихся событий — + переход в «Неисправен», а не авария или реальная тревога. Честнее + называть это «риском неисправности оборудования». +5. **Цифры завышены.** Модель обучалась на этих же месяцах, пусть и на + других каналах. На будущем периоде результат будет не лучше. + Следующий шаг — переобучение с разбиением по времени (отдельная задача, + около 1 дня). + +## Детали + +- **Каналы.** Тестовые каналы воспроизведены точно по сплиту обучения + (seed 69, 577 шт.). Число каналов и строк в каждой части совпадает с + ноутбуком `2_create_train_test_datasets_V2_MAIN`. Из 577 исключено 27: + у 26 нет событий в периоде, 1 отсутствует в справочнике. Последняя + смена — 29.06 20:00, чтобы окно 24 ч целиком помещалось в данные. +- **Событие.** Засчитывается переход канала из нормы в тревогу; повторные + тревоги подряд новым событием не считаются. Флаг тревоги взят из поля + `тревожное` журнала: из `справочник_состояний.csv` однозначно его не + вывести, потому что у одного типа датчика несколько наборов состояний. + **Отклонение от описания тикета, нужна виза автора.** +- **Прогноз «как на стенде».** Берётся последний прогноз по событию канала + до начала смены с горизонтом 30 ч, если он ещё не истёк. Так устроен + `inference/runner.py`. Если же считать прогноз заново ровно в начале + смены, модель хуже правила: 9 из 100 алертов сбывается, ловит 7 из 100 + событий. +- **Диспетчер** — объект уровня 2 в справочнике объектов (16 районов). + Число алертов пересчитано на весь парк: тестовые каналы — это около 5% + каналов. +- **Спокойные каналы** (в норме, без тревог 6 ч до смены): HIGH — 16 из 100 + сбывается, поймали 7 из 100, около 5 алертов в смену. + +Полные таблицы: `comparison_table.csv` (оба варианта модели × 2 среза), +`bootstrap_ci.csv` (95% CI разницы с правилом), `calibration.csv`, +`episode_value_breakdown.csv`. + +Запуск (около 1 мин): +`HACK174_DATASET_DIR=<каталог с ext-journal-*.csv> python -m tests.backtest.run` diff --git a/backtest/channels.py b/tests/backtest/channels.py similarity index 97% rename from backtest/channels.py rename to tests/backtest/channels.py index 90f457e..2792813 100644 --- a/backtest/channels.py +++ b/tests/backtest/channels.py @@ -4,7 +4,7 @@ Универсум id — уникальные ид_канала_данных из журналов 2024-2026 (retraining/notebooks/1_prepare_dataset.ipynb), затем sort + shuffle(seed=69) + head(90%)/slice(5%)/остаток. Совпадение размеров train/val/test (10366/575/577) -с ноутбуком проверено вручную и задокументировано в docs/backtest/backtest.md. +с ноутбуком проверено вручную и задокументировано в tests/backtest/backtest.md. """ from __future__ import annotations diff --git a/backtest/data.py b/tests/backtest/data.py similarity index 100% rename from backtest/data.py rename to tests/backtest/data.py diff --git a/backtest/dispatch.py b/tests/backtest/dispatch.py similarity index 100% rename from backtest/dispatch.py rename to tests/backtest/dispatch.py diff --git a/backtest/episodes.py b/tests/backtest/episodes.py similarity index 100% rename from backtest/episodes.py rename to tests/backtest/episodes.py diff --git a/backtest/metrics.py b/tests/backtest/metrics.py similarity index 100% rename from backtest/metrics.py rename to tests/backtest/metrics.py diff --git a/backtest/predict.py b/tests/backtest/predict.py similarity index 100% rename from backtest/predict.py rename to tests/backtest/predict.py diff --git a/backtest/prodstyle.py b/tests/backtest/prodstyle.py similarity index 100% rename from backtest/prodstyle.py rename to tests/backtest/prodstyle.py diff --git a/docs/backtest/bootstrap_ci.csv b/tests/backtest/results/bootstrap_ci.csv similarity index 100% rename from docs/backtest/bootstrap_ci.csv rename to tests/backtest/results/bootstrap_ci.csv diff --git a/docs/backtest/calibration.csv b/tests/backtest/results/calibration.csv similarity index 100% rename from docs/backtest/calibration.csv rename to tests/backtest/results/calibration.csv diff --git a/docs/backtest/comparison_table.csv b/tests/backtest/results/comparison_table.csv similarity index 100% rename from docs/backtest/comparison_table.csv rename to tests/backtest/results/comparison_table.csv diff --git a/docs/backtest/episode_value_breakdown.csv b/tests/backtest/results/episode_value_breakdown.csv similarity index 100% rename from docs/backtest/episode_value_breakdown.csv rename to tests/backtest/results/episode_value_breakdown.csv diff --git a/backtest/run.py b/tests/backtest/run.py similarity index 85% rename from backtest/run.py rename to tests/backtest/run.py index 23bbbf7..ecf0185 100644 --- a/backtest/run.py +++ b/tests/backtest/run.py @@ -1,6 +1,6 @@ """Оффлайн-бэктест HACK-174: одна команда, весь конвейер. - python -m backtest.run --dataset-dir /path/to/Sources/dataset + python -m tests.backtest.run --dataset-dir /path/to/Sources/dataset Путь к данным обязателен: через --dataset-dir или переменную окружения HACK174_DATASET_DIR (см. backtest/README.md). Читает журналы событий и @@ -8,8 +8,8 @@ (та же модель, что на стенде), считает вероятность тревоги на смены апрель-июнь 2026 по тестовым каналам сплита в двух вариантах — «прогноз ровно на границе смены» и «то, что стенд реально показывал по последнему -событию» — и сравнивает с двумя бейзлайнами. Результат — docs/backtest/*.csv -(predictions.csv не коммитится, см. .gitignore) и docs/backtest/backtest.md. +событию» — и сравнивает с двумя бейзлайнами. Результат — tests/backtest/results/*.csv +(predictions.csv не коммитится, см. .gitignore) и tests/backtest/backtest.md. """ from __future__ import annotations @@ -23,14 +23,14 @@ import polars as pl -REPO_ROOT = Path(__file__).resolve().parent.parent +REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) -from backtest import channels, data, episodes, isalarm_check, metrics, prodstyle # noqa: E402 -from backtest.dispatch import channel_dispatchers, dispatcher_scale # noqa: E402 -from backtest.predict import predict_shifts # noqa: E402 -from backtest.service import build_offline_service # noqa: E402 -from backtest.warmstate import last_state_before # noqa: E402 +from tests.backtest import channels, data, episodes, metrics, prodstyle # noqa: E402 +from tests.backtest.dispatch import channel_dispatchers, dispatcher_scale # noqa: E402 +from tests.backtest.predict import predict_shifts # noqa: E402 +from tests.backtest.service import build_offline_service # noqa: E402 +from tests.backtest.warmstate import last_state_before # noqa: E402 HORIZON_HOURS = 24.0 SHIFT_HOURS = (8, 20) @@ -124,7 +124,7 @@ def main() -> None: type=Path, default=REPO_ROOT / "retraining/data/reference/open-meteo-55.75N37.63E140m.csv", ) - parser.add_argument("--output-dir", type=Path, default=REPO_ROOT / "docs/backtest") + parser.add_argument("--output-dir", type=Path, default=Path(__file__).resolve().parent / "results") # Своя temp-директория на прогон: устаревший metadata-кеш/state не # должен подмешаться (тот же приём, что и в inference/runner.py). parser.add_argument( @@ -140,7 +140,7 @@ def main() -> None: started = time.perf_counter() args.output_dir.mkdir(parents=True, exist_ok=True) - print("1/8 test-channel split (notebooks/2, seed=69)...") + print("1/7 test-channel split (notebooks/2, seed=69)...") test_ids = channels.test_channel_ids(dataset_dir) pl.DataFrame({"ид_канала_данных": test_ids}).write_csv( args.output_dir / "test_channels.csv" @@ -149,14 +149,14 @@ def main() -> None: load_start = args.start - timedelta(days=WARMUP_PAD_DAYS) load_end = args.end + timedelta(days=OUTCOME_PAD_DAYS, hours=1) - print(f"2/8 loading events [{load_start}, {load_end}] for test channels...") + print(f"2/7 loading events [{load_start}, {load_end}] for test channels...") events_df = data.load_events(dataset_dir, test_ids, load_start, load_end) print(f" events: {events_df.height}") - print("3/8 warming episode state from before the load window...") + print("3/7 warming episode state from before the load window...") seed_states = last_state_before(dataset_dir, test_ids, load_start) - print("4/8 building service (app.PredictionService, offline weather)...") + print("4/7 building service (app.PredictionService, offline weather)...") service = build_offline_service(REPO_ROOT, args.weather_csv, args.runtime_dir) have_events = set(events_df["ид_канала_данных"].unique().to_list()) @@ -171,7 +171,7 @@ def main() -> None: shift_times = _valid_shift_times(args.start, args.end, HORIZON_HOURS) print( - f"5/8 predicting {len(usable_ids)} channels x {len(shift_times)} shifts " + f"5/7 predicting {len(usable_ids)} channels x {len(shift_times)} shifts " f"(fresh-t0 + prodstyle)..." ) predictions = predict_shifts( @@ -186,7 +186,7 @@ def main() -> None: ) prodstyle_by_key = {(p.channel_id, p.t0): p for p in prod_predictions} - print("6/8 episodes, baselines, dispatcher grouping...") + print("6/7 episodes, baselines, dispatcher grouping...") timelines = episodes.build_timelines(events_df, seed_states) dispatchers_all = channel_dispatchers( service.config.sensors_metadata_path, @@ -202,14 +202,7 @@ def main() -> None: ) breakdown.write_csv(args.output_dir / "episode_value_breakdown.csv") - print("7/8 isAlarm sanity check (тревожное vs справочник_состояний)...") - isalarm = isalarm_check.check( - events_df, dataset_dir / "справочник_состояний.csv", dataset_dir / "справочник_каналов_датчиков.csv" - ) - for name, table in isalarm.items(): - table.write_csv(args.output_dir / f"isalarm_{name}.csv") - - print("8/8 metrics, bootstrap CI, report...") + print("7/7 metrics, bootstrap CI, report...") comparison = metrics.build_comparison_table(df, dispatcher_scale=scale) comparison.write_csv(float_precision=6, file=args.output_dir / "comparison_table.csv") calibration = metrics.calibration_table(df) diff --git a/backtest/service.py b/tests/backtest/service.py similarity index 96% rename from backtest/service.py rename to tests/backtest/service.py index 67eddbc..9d4b8b2 100644 --- a/backtest/service.py +++ b/tests/backtest/service.py @@ -8,7 +8,7 @@ from app import PredictionService, ServiceConfig -from backtest.weather import CsvWeatherClient +from tests.backtest.weather import CsvWeatherClient def build_offline_service( diff --git a/backtest/warmstate.py b/tests/backtest/warmstate.py similarity index 100% rename from backtest/warmstate.py rename to tests/backtest/warmstate.py diff --git a/backtest/weather.py b/tests/backtest/weather.py similarity index 100% rename from backtest/weather.py rename to tests/backtest/weather.py From 31881515b26080577e3dce498ef953546def06e3 Mon Sep 17 00:00:00 2001 From: UsatovPavel Date: Sun, 27 Sep 2026 10:22:05 +0300 Subject: [PATCH 3/4] refactor(backtest): drop redundant code Merge 11 modules into 6, remove dead args, duplicated helpers and defensive branches; README folded into backtest.md. Numbers unchanged (checked on synthetic data against previous functions). Co-Authored-By: Claude Opus 5.5 --- tests/backtest/README.md | 24 ---- tests/backtest/channels.py | 72 ------------ tests/backtest/data.py | 148 ++++++++++++++++-------- tests/backtest/dispatch.py | 68 ----------- tests/backtest/episodes.py | 113 ++++++------------ tests/backtest/metrics.py | 165 +++++++++----------------- tests/backtest/predict.py | 114 ++++++++++-------- tests/backtest/prodstyle.py | 89 -------------- tests/backtest/run.py | 225 +++++++++++------------------------- tests/backtest/service.py | 51 ++++---- tests/backtest/warmstate.py | 74 ------------ tests/backtest/weather.py | 52 --------- 12 files changed, 351 insertions(+), 844 deletions(-) delete mode 100644 tests/backtest/README.md delete mode 100644 tests/backtest/channels.py delete mode 100644 tests/backtest/dispatch.py delete mode 100644 tests/backtest/prodstyle.py delete mode 100644 tests/backtest/warmstate.py delete mode 100644 tests/backtest/weather.py diff --git a/tests/backtest/README.md b/tests/backtest/README.md deleted file mode 100644 index 173a7db..0000000 --- a/tests/backtest/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Бэктест HACK-174 - -Оффлайн-проверка `data/best_model.cbm` на смены апрель-июнь 2026 по тестовым -каналам сплита, тем же кодом признаков, что и в `inference/`/`app.py`, в -двух вариантах — "прогноз ровно на границе смены" и "то, что стенд реально -показывал по последнему событию". - -```bash -HACK174_DATASET_DIR=/path/to/Sources/dataset python -m tests.backtest.run -``` - -Путь к данным (журналы `ext-journal-*.csv` и справочники) обязателен: флагом -`--dataset-dir` или переменной окружения `HACK174_DATASET_DIR` — здесь -намеренно нет дефолта на локальный путь. Результат пишется в -`tests/backtest/results/` (переопределяется `--output-dir`); `--weather-csv` -указывает на CSV Open-Meteo (по умолчанию — тот, что уже в репозитории, -`retraining/data/reference/`). Остальные флаги — `--help`. - -`tests/backtest/results/predictions.csv` (~99 тыс. строк) не хранится в git — -генерируется каждым прогоном заново, путь в `.gitignore`. Остальные CSV в -`tests/backtest/results/` — агрегаты, коммитятся. - -Определения (смена, эпизод, горизонт, isAlarm, диспетчер и т.д.) — в -`tests/backtest/backtest.md`. diff --git a/tests/backtest/channels.py b/tests/backtest/channels.py deleted file mode 100644 index 2792813..0000000 --- a/tests/backtest/channels.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Точное воспроизведение device-сплита из -retraining/notebooks/2_create_train_test_datasets_V2_MAIN.ipynb (get_device_split). - -Универсум id — уникальные ид_канала_данных из журналов 2024-2026 -(retraining/notebooks/1_prepare_dataset.ipynb), затем sort + shuffle(seed=69) + -head(90%)/slice(5%)/остаток. Совпадение размеров train/val/test (10366/575/577) -с ноутбуком проверено вручную и задокументировано в tests/backtest/backtest.md. -""" -from __future__ import annotations - -from pathlib import Path - -import polars as pl - -ID_COL = "ид_канала_данных" -YEARS = (2024, 2025, 2026) -SEED = 69 -TRAIN_SIZE = 0.9 -VAL_SIZE = 0.05 - - -def _year_ids(dataset_dir: Path, year: int) -> pl.DataFrame: - # Журналы содержат вклеенные строки-заголовки (см. комментарий в - # ноутбуке 1) — они не парсятся как Int64 и отбрасываются явно. - lf = pl.scan_csv( - dataset_dir / f"ext-journal-{year}.csv", - schema_overrides={ID_COL: pl.Utf8}, - ignore_errors=True, - ) - return ( - lf.select(ID_COL) - .filter(pl.col(ID_COL) != ID_COL) - .unique() - .collect(engine="streaming") - .with_columns(pl.col(ID_COL).cast(pl.Int64)) - ) - - -def all_channel_ids(dataset_dir: Path) -> pl.DataFrame: - """Уникальные ид каналов из журналов YEARS, отсортированные по возрастанию.""" - frames = [_year_ids(dataset_dir, year) for year in YEARS] - return pl.concat(frames).unique().sort(ID_COL) - - -def device_split(dataset_dir: Path) -> pl.DataFrame: - """Полный train/val/test сплит, один в один как get_device_split.""" - ids = all_channel_ids(dataset_dir).sample( - fraction=1.0, - shuffle=True, - seed=SEED, - ) - - n = ids.height - n_train = int(n * TRAIN_SIZE) - n_val = int(n * VAL_SIZE) - - return pl.concat( - [ - ids.head(n_train).with_columns(pl.lit("train").alias("split")), - ids.slice(n_train, n_val).with_columns(pl.lit("val").alias("split")), - ids.slice(n_train + n_val).with_columns(pl.lit("test").alias("split")), - ] - ) - - -def test_channel_ids(dataset_dir: Path) -> list[int]: - split = device_split(dataset_dir) - return ( - split.filter(pl.col("split") == "test")[ID_COL] - .sort() - .to_list() - ) diff --git a/tests/backtest/data.py b/tests/backtest/data.py index abeae51..bf1c519 100644 --- a/tests/backtest/data.py +++ b/tests/backtest/data.py @@ -1,65 +1,123 @@ -"""Загрузка событий тестовых каналов за окно бэктеста из ext-journal CSV.""" +"""Журналы ext-journal-*.csv и справочники: сплит каналов, события, диспетчеры.""" from __future__ import annotations -from datetime import date +from collections import Counter +from datetime import date, datetime from pathlib import Path import polars as pl ID_COL = "ид_канала_данных" -RAW_COLUMNS = ( - "ид_события", - ID_COL, - "дата", - "время", - "тревожное", - "значение_датчика", -) +SEED = 69 +TRAIN_SIZE = 0.9 +VAL_SIZE = 0.05 -def load_events( - dataset_dir: Path, - channel_ids: list[int], - start: date, - end: date, - year: int = 2026, -) -> pl.DataFrame: - """События channel_ids за [start, end], отсортированные по каналу и времени.""" - lf = pl.scan_csv( +def _scan(dataset_dir: Path, year: int) -> pl.LazyFrame: + # В журналах есть вклеенные строки-заголовки (см. ноутбук 1), отбрасываем. + return pl.scan_csv( dataset_dir / f"ext-journal-{year}.csv", - schema_overrides={ID_COL: pl.Utf8, "дата": pl.Utf8}, + schema_overrides={ID_COL: pl.Utf8, "дата": pl.Utf8, "значение_датчика": pl.Utf8}, ignore_errors=True, + ).filter(pl.col(ID_COL) != ID_COL) + + +def _with_ts(df: pl.DataFrame) -> pl.DataFrame: + return df.with_columns( + pl.col("дата").str.to_date(), + pl.col("время").str.to_time("%H:%M:%S", strict=False), + (pl.col("тревожное") == "t").alias("тревожное"), + ).with_columns(pl.col("дата").dt.combine(pl.col("время")).alias("ts")) + + +def test_channel_ids(dataset_dir: Path) -> list[int]: + """Тестовая часть get_device_split из ноутбука 2_create_train_test_datasets_V2_MAIN: + ид каналов 2024-2026, sort + shuffle(seed=69), train 90% / val 5% / test остаток. + """ + ids = ( + pl.concat( + _scan(dataset_dir, year).select(ID_COL).unique() + .collect(engine="streaming").with_columns(pl.col(ID_COL).cast(pl.Int64)) + for year in (2024, 2025, 2026) + ) + .unique() + .sort(ID_COL) + .sample(fraction=1.0, shuffle=True, seed=SEED) ) + n_head = int(ids.height * TRAIN_SIZE) + int(ids.height * VAL_SIZE) + return ids.slice(n_head)[ID_COL].sort().to_list() + +def load_events( + dataset_dir: Path, channel_ids: list[int], start: date, end: date +) -> pl.DataFrame: + """События channel_ids за [start, end] из журнала 2026, по каналу и времени.""" df = ( - lf.select(*RAW_COLUMNS) - .filter(pl.col(ID_COL) != ID_COL) + _scan(dataset_dir, 2026) + .select("ид_события", ID_COL, "дата", "время", "тревожное", "значение_датчика") .with_columns(pl.col(ID_COL).cast(pl.Int64)) .filter(pl.col(ID_COL).is_in(channel_ids)) - .filter( - pl.col("дата").is_between( - pl.lit(start.isoformat()), - pl.lit(end.isoformat()), - ) - ) + .filter(pl.col("дата").is_between(pl.lit(start.isoformat()), pl.lit(end.isoformat()))) .collect(engine="streaming") ) + return _with_ts(df).sort([ID_COL, "ts", "ид_события"]) - return ( - df.with_columns( - pl.col("дата").str.to_date(), - pl.col("время").str.to_time("%H:%M:%S", strict=False), - (pl.col("тревожное") == "t").alias("тревожное"), - ) - .with_columns( - pl.datetime( - pl.col("дата").dt.year(), - pl.col("дата").dt.month(), - pl.col("дата").dt.day(), - pl.col("время").dt.hour(), - pl.col("время").dt.minute(), - pl.col("время").dt.second(), - ).alias("ts") - ) - .sort([ID_COL, "ts", "ид_события"]) + +def last_state_before( + dataset_dir: Path, channel_ids: list[int], before: date +) -> dict[int, bool]: + """Флаг тревожное последнего события канала до before по всем годам: без него + первая тревога в окне читалась бы как новый эпизод (канал 334183 в тревоге с 12.03). + """ + df = pl.concat( + _scan(dataset_dir, year) + .select(ID_COL, "дата", "время", "тревожное") + .with_columns(pl.col(ID_COL).cast(pl.Int64)) + .filter(pl.col(ID_COL).is_in(channel_ids)) + .collect(engine="streaming") + for year in range(2019, 2027) + ) + df = ( + _with_ts(df) + .filter(pl.col("ts") < before) + .sort([ID_COL, "ts"]) + .group_by(ID_COL, maintain_order=True) + .last() + ) + return dict(zip(df[ID_COL].to_list(), df["тревожное"].to_list())) + + +def channel_dispatchers(sensors_path: Path, objects_path: Path) -> dict[int, int | None]: + """Диспетчер канала — ближайший предок уровня 2 в справочнике объектов + (Q&A_2: уровень 2 — «Диспетчер района», 16 объектов). + """ + objects = pl.read_parquet(objects_path) + parent = dict(zip(objects["ид_объект"], objects["родитель"])) + level = dict(zip(objects["ид_объект"], objects["иерархия_уровень"])) + + def dispatcher_of(current: int | None) -> int | None: + seen = set() + while current is not None and current not in seen: + if level.get(current) == 2: + return int(current) + seen.add(current) + current = parent.get(current) + return None + + sensors = pl.read_parquet(sensors_path, columns=[ID_COL, "ид_объект"]) + return { + int(channel_id): dispatcher_of(int(object_id)) + for channel_id, object_id in zip(sensors[ID_COL], sensors["ид_объект"]) + } + + +def dispatcher_scale( + dispatchers: dict[int, int | None], sample_ids: list[int] +) -> dict[int, float]: + """Множитель «все каналы диспетчера / тестовые каналы диспетчера».""" + full = Counter(d for d in dispatchers.values() if d is not None) + sample_set = set(sample_ids) + sample = Counter( + d for cid, d in dispatchers.items() if d is not None and cid in sample_set ) + return {d: full[d] / count for d, count in sample.items()} diff --git a/tests/backtest/dispatch.py b/tests/backtest/dispatch.py deleted file mode 100644 index faccae1..0000000 --- a/tests/backtest/dispatch.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Диспетчер канала = ближайший предок уровня 2 в справочнике объектов. - -Sources/Q&A/Q&A_2.txt (Maxim, 9/18): иерархия_уровень 1 — единственный -корневой "district", 2 — "Диспетчер района" (16 объектов controlHouse/ -guardObject), 3 — их дочерние подобъекты. Каналы висят на объектах любого -уровня; поднимаемся по "родитель" до первого уровня 2. -""" -from __future__ import annotations - -from collections import Counter -from pathlib import Path - -import polars as pl - -ID_COL = "ид_канала_данных" - - -def channel_dispatchers( - sensors_path: Path, - objects_path: Path, -) -> dict[int, int | None]: - objects = pl.read_parquet(objects_path) - parent = dict(zip(objects["ид_объект"], objects["родитель"])) - level = dict(zip(objects["ид_объект"], objects["иерархия_уровень"])) - - def dispatcher_of(object_id: int) -> int | None: - current, seen = object_id, set() - - while current is not None and current not in seen: - if level.get(current) == 2: - return int(current) - - seen.add(current) - current = parent.get(current) - - return None - - sensors = pl.read_parquet(sensors_path, columns=[ID_COL, "ид_объект"]) - - return { - int(channel_id): dispatcher_of(int(object_id)) - for channel_id, object_id in zip(sensors[ID_COL], sensors["ид_объект"]) - } - - -def dispatcher_scale( - all_dispatchers: dict[int, int | None], - sample_channel_ids: list[int], -) -> dict[int, float]: - """Множитель «весь парк диспетчера / тестовые каналы диспетчера». - - Тестовые каналы — случайные ~5% каналов (по устройству, не по - диспетчеру), поэтому экстраполяция на весь парк предполагает - однородность каналов внутри диспетчера; при малой тестовой выборке на - диспетчера множитель может быть неустойчивым (см. допущения в - backtest.md). - """ - full_counts = Counter(d for d in all_dispatchers.values() if d is not None) - sample_set = set(sample_channel_ids) - sample_counts = Counter( - d for cid, d in all_dispatchers.items() if d is not None and cid in sample_set - ) - - return { - dispatcher: full_counts[dispatcher] / count - for dispatcher, count in sample_counts.items() - if count > 0 - } diff --git a/tests/backtest/episodes.py b/tests/backtest/episodes.py index 0312725..9885ee1 100644 --- a/tests/backtest/episodes.py +++ b/tests/backtest/episodes.py @@ -1,20 +1,15 @@ -"""Эпизоды тревог и «вчерашние» окна для бейзлайнов и исхода прогноза. - -Эпизод — переход канала из нормы в тревогу (тревожное 0 -> 1); повторные -тревожные события подряд эпизод не продлевают и не открывают заново. -Состояние канала перед первым событием окна берётся из -backtest.warmstate.last_state_before — без этого первый тревожный сигнал -в окне ошибочно читался бы как новый эпизод (см. warmstate.py). +"""Эпизод — переход канала из нормы в тревогу; повторные тревоги подряд +эпизод не продлевают и не открывают заново. """ from __future__ import annotations import bisect from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta import polars as pl -ID_COL = "ид_канала_данных" +from tests.backtest.data import ID_COL @dataclass(slots=True) @@ -27,44 +22,24 @@ class ChannelTimeline: seed_is_alarm: bool | None -def _episode_starts( - timestamps: list[datetime], - is_alarm: list[bool], - values: list[str], - seed: bool | None, -) -> tuple[list[datetime], list[str]]: - starts: list[datetime] = [] - start_values: list[str] = [] - in_alarm = bool(seed) - - for ts, alarm, value in zip(timestamps, is_alarm, values): - if alarm and not in_alarm: - starts.append(ts) - start_values.append(value) - in_alarm = alarm - - return starts, start_values - - def build_timelines( - events_df: pl.DataFrame, - seed_states: dict[int, tuple[bool, str]] | None = None, + events_df: pl.DataFrame, seed_states: dict[int, bool] ) -> dict[int, ChannelTimeline]: - """events_df уже отсортирован по [ID_COL, ts] (см. backtest.data.load_events).""" - seed_states = seed_states or {} timelines: dict[int, ChannelTimeline] = {} for (channel_id,), group in events_df.partition_by( - ID_COL, - as_dict=True, - maintain_order=True, + ID_COL, as_dict=True, maintain_order=True ).items(): timestamps = group["ts"].to_list() is_alarm = group["тревожное"].to_list() - values = group["значение_датчика"].to_list() - seed = seed_states.get(int(channel_id), (None, None))[0] + seed = seed_states.get(int(channel_id)) - starts, start_values = _episode_starts(timestamps, is_alarm, values, seed) + starts, start_values, in_alarm = [], [], bool(seed) + for ts, alarm, value in zip(timestamps, is_alarm, group["значение_датчика"]): + if alarm and not in_alarm: + starts.append(ts) + start_values.append(value) + in_alarm = alarm timelines[int(channel_id)] = ChannelTimeline( ts=timestamps, @@ -78,64 +53,42 @@ def build_timelines( return timelines -def alarms_in_window( - timeline: ChannelTimeline, - start: datetime, - end: datetime, -) -> int: +def alarms_in_window(timeline: ChannelTimeline, start: datetime, end: datetime) -> int: """Число тревожных событий с ts в [start, end).""" - lo = bisect.bisect_left(timeline.alarm_ts, start) - hi = bisect.bisect_left(timeline.alarm_ts, end) - return hi - lo + return bisect.bisect_left(timeline.alarm_ts, end) - bisect.bisect_left(timeline.alarm_ts, start) def state_at(timeline: ChannelTimeline, t0: datetime) -> bool | None: - """Тревожное/норма непосредственно перед t0 (событие с ts < t0, либо seed).""" + """Тревожное/норма перед t0 (последнее событие с ts < t0, иначе seed).""" idx = bisect.bisect_left(timeline.ts, t0) - 1 return timeline.is_alarm[idx] if idx >= 0 else timeline.seed_is_alarm def first_episode_after( - timeline: ChannelTimeline, - t0: datetime, - horizon, + timeline: ChannelTimeline, t0: datetime, horizon: timedelta ) -> datetime | None: - """Первое начало нового эпизода в (t0, t0 + horizon], иначе None.""" + """Первое начало эпизода в (t0, t0 + horizon], иначе None.""" lo = bisect.bisect_right(timeline.episode_starts, t0) - - if lo >= len(timeline.episode_starts): - return None - - candidate = timeline.episode_starts[lo] - return candidate if candidate <= t0 + horizon else None + if lo < len(timeline.episode_starts) and timeline.episode_starts[lo] <= t0 + horizon: + return timeline.episode_starts[lo] + return None def episode_value_breakdown( - timelines: dict[int, ChannelTimeline], - window_start: datetime, - window_end: datetime, + timelines: dict[int, ChannelTimeline], start: datetime, end: datetime ) -> pl.DataFrame: - """Разбивка начал эпизодов в [window_start, window_end) по значению - датчика, вызвавшему тревогу — не все эпизоды "тревога" в бытовом - смысле, часть — «Неисправен» и другие сервисные состояния. - """ - rows = [ - {"value": value, "ts": ts} - for timeline in timelines.values() - for ts, value in zip(timeline.episode_starts, timeline.episode_values) - if window_start <= ts < window_end - ] - - if not rows: - return pl.DataFrame({"значение_датчика": [], "n": [], "share": []}) - - df = pl.DataFrame(rows) - total = df.height - + """Начала эпизодов в [start, end) по значению датчика: часть — «Неисправен».""" + df = pl.DataFrame( + [ + {"значение_датчика": value} + for timeline in timelines.values() + for ts, value in zip(timeline.episode_starts, timeline.episode_values) + if start <= ts < end + ] + ) return ( - df.group_by("value") + df.group_by("значение_датчика") .agg(pl.len().alias("n")) - .with_columns((pl.col("n") / total).alias("share")) + .with_columns((pl.col("n") / df.height).alias("share")) .sort("n", descending=True) - .rename({"value": "значение_датчика"}) ) diff --git a/tests/backtest/metrics.py b/tests/backtest/metrics.py index de06cb7..9a76d6e 100644 --- a/tests/backtest/metrics.py +++ b/tests/backtest/metrics.py @@ -1,6 +1,4 @@ -"""Метрики бэктеста: precision/recall/алерты на смену/медиана часов, -калибровка и кластерный (по каналам) бутстрэп доверительных интервалов. -""" +"""Precision/recall, алерты на смену диспетчера, калибровка, бутстрэп CI.""" from __future__ import annotations import numpy as np @@ -9,57 +7,39 @@ HIGH_THRESHOLD = 0.7 MEDIUM_THRESHOLD = 0.4 N_BOOTSTRAP = 2000 -BOOTSTRAP_SEED = 0 def _binary_metrics( - df: pl.DataFrame, - predicted: pl.Series, - dispatcher_scale: dict[int, float] | None = None, + df: pl.DataFrame, predicted: pl.Series, dispatcher_scale: dict[int, float] ) -> dict[str, float]: df = df.with_columns(predicted.alias("__pred")) - tp = df.filter(pl.col("__pred") & pl.col("outcome_24h")).height fp = df.filter(pl.col("__pred") & ~pl.col("outcome_24h")).height fn = df.filter(~pl.col("__pred") & pl.col("outcome_24h")).height - precision = tp / (tp + fp) if (tp + fp) else float("nan") - recall = tp / (tp + fn) if (tp + fn) else float("nan") - by_dispatcher_shift = ( df.filter(pl.col("dispatcher_id").is_not_null()) .group_by(["dispatcher_id", "t0"]) .agg(pl.col("__pred").sum().alias("alerts")) - ) - - alerts_per_shift_sample = ( - by_dispatcher_shift["alerts"].mean() if by_dispatcher_shift.height else float("nan") - ) - - alerts_per_shift_fleet = float("nan") - if dispatcher_scale and by_dispatcher_shift.height: - scaled = by_dispatcher_shift.with_columns( + .with_columns( pl.col("dispatcher_id") .replace_strict(dispatcher_scale, default=1.0, return_dtype=pl.Float64) .alias("scale") - ).with_columns((pl.col("alerts") * pl.col("scale")).alias("alerts_scaled")) - alerts_per_shift_fleet = scaled["alerts_scaled"].mean() - + ) + ) hours = df.filter(pl.col("__pred") & pl.col("outcome_24h"))["hours_to_event"].drop_nulls() - median_hours = hours.median() if hours.len() else float("nan") return { - "precision": precision, - "recall": recall, + "precision": tp / (tp + fp) if (tp + fp) else float("nan"), + "recall": tp / (tp + fn) if (tp + fn) else float("nan"), "tp": tp, "fp": fp, "fn": fn, "n": df.height, - # На тестовой выборке каналов (~5% парка) и пересчитанное на весь - # парк диспетчера (см. backtest.dispatch.dispatcher_scale). - "alerts_per_shift_sample": alerts_per_shift_sample, - "alerts_per_shift_fleet": alerts_per_shift_fleet, - "median_hours_to_event": median_hours, + # На тестовых каналах (~5% парка) и пересчитанное на весь парк диспетчера. + "alerts_per_shift_sample": by_dispatcher_shift["alerts"].mean(), + "alerts_per_shift_fleet": (by_dispatcher_shift["alerts"] * by_dispatcher_shift["scale"]).mean(), + "median_hours_to_event": hours.median() if hours.len() else float("nan"), } @@ -74,50 +54,36 @@ def _method_predicates(df: pl.DataFrame) -> dict[str, pl.Series]: } -def build_comparison_table( - df: pl.DataFrame, - dispatcher_scale: dict[int, float] | None = None, -) -> pl.DataFrame: - slices = { - "all_channels": df, - "early_warning_6h": df.filter(pl.col("early_warning_ok")), - } - - rows = [] - for slice_name, sdf in slices.items(): - for method_name, predicate in _method_predicates(sdf).items(): - metrics = _binary_metrics(sdf, predicate, dispatcher_scale) - rows.append({"slice": slice_name, "method": method_name, **metrics}) - - return pl.DataFrame(rows) +def build_comparison_table(df: pl.DataFrame, dispatcher_scale: dict[int, float]) -> pl.DataFrame: + slices = {"all_channels": df, "early_warning_6h": df.filter(pl.col("early_warning_ok"))} + return pl.DataFrame( + [ + {"slice": slice_name, "method": method, **_binary_metrics(sdf, predicate, dispatcher_scale)} + for slice_name, sdf in slices.items() + for method, predicate in _method_predicates(sdf).items() + ] + ) -def calibration_table( - df: pl.DataFrame, - probability_col: str = "probability", - bins: int = 10, -) -> pl.DataFrame: +def calibration_table(df: pl.DataFrame, bins: int = 10) -> pl.DataFrame: breaks = [i / bins for i in range(1, bins)] labels = [f"[{i / bins:.1f}, {(i + 1) / bins:.1f})" for i in range(bins)] - return ( df.with_columns( - pl.col(probability_col) - .cut(breaks, labels=labels, left_closed=True) - .alias("probability_bin") + pl.col("probability").cut(breaks, labels=labels, left_closed=True).alias("probability_bin") ) .group_by("probability_bin", maintain_order=True) .agg( pl.len().alias("n"), pl.col("outcome_24h").mean().alias("actual_rate"), - pl.col(probability_col).mean().alias("mean_probability"), + pl.col("probability").mean().alias("mean_probability"), ) .sort("probability_bin") ) -def _per_channel_counts(df: pl.DataFrame, predicted: pl.Series) -> pl.DataFrame: - return ( +def _per_channel_counts(df: pl.DataFrame, predicted: pl.Series) -> list[np.ndarray]: + counts = ( df.with_columns(predicted.alias("__pred")) .group_by("channel_id") .agg( @@ -127,70 +93,49 @@ def _per_channel_counts(df: pl.DataFrame, predicted: pl.Series) -> pl.DataFrame: ) .sort("channel_id") ) + return [counts[c].to_numpy() for c in ("tp", "fp", "fn")] def bootstrap_diff_ci( - df: pl.DataFrame, - predicted_a: pl.Series, - predicted_b: pl.Series, - n_boot: int = N_BOOTSTRAP, - seed: int = BOOTSTRAP_SEED, -) -> dict[str, tuple[float, float]]: - """95% CI разницы (precision_a - precision_b) и (recall_a - recall_b). - - Кластерный бутстрэп по каналам (не по строкам): ресемплируем каналы с - возвратом и суммируем их tp/fp/fn — строки одного канала коррелированы - (одни и те же 182 смены), построчный бутстрэп занизил бы дисперсию. + df: pl.DataFrame, predicted_a: pl.Series, predicted_b: pl.Series +) -> dict[str, float]: + """95% CI разниц precision и recall a - b. Бутстрэп по каналам, не по строкам: + смены одного канала коррелированы, построчный занизил бы дисперсию. """ - counts_a = _per_channel_counts(df, predicted_a) - counts_b = _per_channel_counts(df, predicted_b) + tp_a, fp_a, fn_a = _per_channel_counts(df, predicted_a) + tp_b, fp_b, fn_b = _per_channel_counts(df, predicted_b) - tp_a, fp_a, fn_a = (counts_a[c].to_numpy() for c in ("tp", "fp", "fn")) - tp_b, fp_b, fn_b = (counts_b[c].to_numpy() for c in ("tp", "fp", "fn")) - - rng = np.random.default_rng(seed) + rng = np.random.default_rng(0) n = tp_a.shape[0] - precision_diffs = np.empty(n_boot) - recall_diffs = np.empty(n_boot) + diffs = {"precision_diff": np.empty(N_BOOTSTRAP), "recall_diff": np.empty(N_BOOTSTRAP)} - for i in range(n_boot): + for i in range(N_BOOTSTRAP): idx = rng.integers(0, n, n) a_tp, a_fp, a_fn = tp_a[idx].sum(), fp_a[idx].sum(), fn_a[idx].sum() b_tp, b_fp, b_fn = tp_b[idx].sum(), fp_b[idx].sum(), fn_b[idx].sum() + diffs["precision_diff"][i] = (a_tp / (a_tp + a_fp) if (a_tp + a_fp) else 0.0) - ( + b_tp / (b_tp + b_fp) if (b_tp + b_fp) else 0.0 + ) + diffs["recall_diff"][i] = (a_tp / (a_tp + a_fn) if (a_tp + a_fn) else 0.0) - ( + b_tp / (b_tp + b_fn) if (b_tp + b_fn) else 0.0 + ) - precision_a = a_tp / (a_tp + a_fp) if (a_tp + a_fp) else 0.0 - precision_b = b_tp / (b_tp + b_fp) if (b_tp + b_fp) else 0.0 - recall_a = a_tp / (a_tp + a_fn) if (a_tp + a_fn) else 0.0 - recall_b = b_tp / (b_tp + b_fn) if (b_tp + b_fn) else 0.0 - - precision_diffs[i] = precision_a - precision_b - recall_diffs[i] = recall_a - recall_b - - return { - "precision_diff": float(np.mean(precision_diffs)), - "precision_diff_ci_low": float(np.percentile(precision_diffs, 2.5)), - "precision_diff_ci_high": float(np.percentile(precision_diffs, 97.5)), - "recall_diff": float(np.mean(recall_diffs)), - "recall_diff_ci_low": float(np.percentile(recall_diffs, 2.5)), - "recall_diff_ci_high": float(np.percentile(recall_diffs, 97.5)), - } + result = {} + for name, values in diffs.items(): + result[name] = float(np.mean(values)) + result[f"{name}_ci_low"] = float(np.percentile(values, 2.5)) + result[f"{name}_ci_high"] = float(np.percentile(values, 97.5)) + return result def build_bootstrap_table(df: pl.DataFrame) -> pl.DataFrame: - """CI для моделей (обоих вариантов) против бейзлайна «тревога вчера».""" + """CI каждого варианта модели против бейзлайна «тревога вчера».""" predicates = _method_predicates(df) baseline = predicates["baseline_alarm_yesterday"] - - model_methods = [ - "model_fresh_t0_high_0.7", - "model_fresh_t0_medium_0.4", - "model_prodstyle_high_0.7", - "model_prodstyle_medium_0.4", - ] - - rows = [] - for method in model_methods: - ci = bootstrap_diff_ci(df, predicates[method], baseline) - rows.append({"method": method, "vs": "baseline_alarm_yesterday", **ci}) - - return pl.DataFrame(rows) + return pl.DataFrame( + [ + {"method": method, "vs": "baseline_alarm_yesterday", **bootstrap_diff_ci(df, predicate, baseline)} + for method, predicate in predicates.items() + if method.startswith("model_") + ] + ) diff --git a/tests/backtest/predict.py b/tests/backtest/predict.py index cebf3e3..e3021b0 100644 --- a/tests/backtest/predict.py +++ b/tests/backtest/predict.py @@ -1,40 +1,27 @@ -"""Реплей событий через app.PredictionService + батч model.predict. - -Признаки на чек-поинте t0 строятся строго по событиям с ts < t0 (см. -app.PredictionService._build_feature_row): перед каждым чек-поинтом -докручиваем store.update по всем более ранним событиям канала, затем -строим строку фичей на месте прогноза, ничего не переписывая в app.py. +"""Реплей событий через app.PredictionService в двух вариантах: прогноз ровно +на смене t0 и «как на стенде» — последний прогноз по событию до t0. """ from __future__ import annotations -from dataclasses import dataclass +import bisect from datetime import datetime import polars as pl from app import PredictionService +from tests.backtest.data import ID_COL -ID_COL = "ид_канала_данных" - - -@dataclass(slots=True) -class ShiftPrediction: - channel_id: int - t0: datetime - probability: float - history_complete: bool +# Стенд пишет прогноз на каждом событии с horizon_until = ts + 30ч и удаляет +# просроченные (inference/runner.py, predictions.delete_expired). +PROD_HORIZON_HOURS = 30.0 -def _channel_events( - events_df: pl.DataFrame, -) -> dict[int, list[tuple[datetime, str]]]: - parts = events_df.partition_by(ID_COL, as_dict=True, maintain_order=True) - +def _channel_events(events_df: pl.DataFrame) -> dict[int, list[tuple[datetime, str]]]: return { - int(channel_id): list( - zip(group["ts"].to_list(), group["значение_датчика"].to_list()) - ) - for (channel_id,), group in parts.items() + int(channel_id): list(zip(group["ts"].to_list(), group["значение_датчика"].to_list())) + for (channel_id,), group in events_df.partition_by( + ID_COL, as_dict=True, maintain_order=True + ).items() } @@ -44,36 +31,67 @@ def predict_shifts( channel_ids: list[int], shift_times: list[datetime], horizon_hours: float, -) -> list[ShiftPrediction]: +) -> list[tuple[int, datetime, float, bool]]: + """(канал, t0, вероятность, history_complete) по событиям с ts < t0.""" channel_events = _channel_events(events_df) - - rows: list[list] = [] - meta: list[tuple[int, datetime, bool]] = [] + rows, keys = [], [] for channel_id in channel_ids: events = channel_events.get(channel_id, []) - ei, n = 0, len(events) - + ei = 0 for t0 in shift_times: - while ei < n and events[ei][0] < t0: - ts, value = events[ei] - service.store.update(channel_id, ts, value) + while ei < len(events) and events[ei][0] < t0: + service.store.update(channel_id, *events[ei]) ei += 1 + rows.append(service._build_feature_row(channel_id, t0)) + keys.append((channel_id, t0, service.store.history_complete(channel_id, t0))) + + raw = service.model.predict(rows) + return [ + (channel_id, t0, service._probability_from_raw(float(r), horizon_hours), complete) + for (channel_id, t0, complete), r in zip(keys, raw) + ] - row = service._build_feature_row(channel_id, t0) - complete = service.store.history_complete(channel_id, t0) - rows.append(row) - meta.append((channel_id, t0, complete)) +def predict_last_event( + service: PredictionService, + events_df: pl.DataFrame, + channel_ids: list[int], + shift_times: list[datetime], +) -> dict[tuple[int, datetime], tuple[float | None, float | None]]: + """{(канал, t0): (вероятность, возраст в часах)} последнего прогноза по + событию до t0; (None, None), если событий до t0 не было. + """ + channel_events = _channel_events(events_df) + result: dict[tuple[int, datetime], tuple[float | None, float | None]] = {} + rows, row_meta = [], [] - raw_predictions = service.model.predict(rows) if rows else [] + for channel_id in channel_ids: + events = channel_events.get(channel_id, []) + ts = [event_ts for event_ts, _ in events] - return [ - ShiftPrediction( - channel_id=channel_id, - t0=t0, - probability=service._probability_from_raw(float(raw), horizon_hours), - history_complete=complete, - ) - for (channel_id, t0, complete), raw in zip(meta, raw_predictions) - ] + # Смены по индексу последнего события со ts < t0. + due: dict[int, list[datetime]] = {} + for t0 in shift_times: + due.setdefault(bisect.bisect_left(ts, t0) - 1, []).append(t0) + for t0 in due.get(-1, []): + result[(channel_id, t0)] = (None, None) + + for idx, (event_ts, value) in enumerate(events): + service.store.update(channel_id, event_ts, value) + t0s = due.get(idx) + if not t0s: + continue + # Следующее событие с тем же ts: прогноз стенд даст после него. + if idx + 1 < len(ts) and ts[idx + 1] == event_ts: + due.setdefault(idx + 1, []).extend(t0s) + continue + rows.append(service._build_feature_row(channel_id, event_ts)) + row_meta.append((channel_id, event_ts, t0s)) + + for (channel_id, event_ts, t0s), raw in zip(row_meta, service.model.predict(rows)): + probability = service._probability_from_raw(float(raw), PROD_HORIZON_HOURS) + for t0 in t0s: + result[(channel_id, t0)] = (probability, (t0 - event_ts).total_seconds() / 3600) + + return result diff --git a/tests/backtest/prodstyle.py b/tests/backtest/prodstyle.py deleted file mode 100644 index 48c8f4b..0000000 --- a/tests/backtest/prodstyle.py +++ /dev/null @@ -1,89 +0,0 @@ -"""«Что стенд реально показывал в момент t0». - -app.PredictionService.predict строится на КАЖДОМ событии, не на границе -смены (inference/runner.py:333-360): прогноз пишется в prediction_log с -horizon_until = ts + 30ч (HORIZON_HOURS=30, inference/config.py) и -считается устаревшим после этого срока (predictions.delete_expired). Этот -модуль воспроизводит именно это: для каждой смены t0 берёт последний -прогноз ПО СОБЫТИЮ до t0 и его "возраст" на момент t0. -""" -from __future__ import annotations - -import bisect -from dataclasses import dataclass -from datetime import datetime - -import polars as pl - -from app import PredictionService - -ID_COL = "ид_канала_данных" -PROD_HORIZON_HOURS = 30.0 - - -@dataclass(slots=True) -class ProdPrediction: - channel_id: int - t0: datetime - probability: float | None - age_hours: float | None - - @property - def valid(self) -> bool: - # Стенд удаляет просроченные прогнозы (delete_expired): диспетчер - # не видит алерта старше горизонта в 30ч на момент t0. - return self.age_hours is not None and self.age_hours < PROD_HORIZON_HOURS - - -def predict_last_event( - service: PredictionService, - events_df: pl.DataFrame, - channel_ids: list[int], - shift_times: list[datetime], -) -> list[ProdPrediction]: - parts = events_df.partition_by(ID_COL, as_dict=True, maintain_order=True) - - results: list[ProdPrediction] = [] - rows: list[list] = [] - row_meta: list[tuple[int, datetime, list[datetime]]] = [] - - for channel_id in channel_ids: - group = parts.get((channel_id,)) - ts = group["ts"].to_list() if group is not None else [] - values = group["значение_датчика"].to_list() if group is not None else [] - - # Для каждой смены — индекс последнего события со ts < t0. - due: dict[int, list[datetime]] = {} - for t0 in shift_times: - idx = bisect.bisect_left(ts, t0) - 1 - due.setdefault(idx, []).append(t0) - - for t0 in due.get(-1, []): - results.append(ProdPrediction(channel_id, t0, None, None)) - - for idx, (event_ts, value) in enumerate(zip(ts, values)): - service.store.update(channel_id, event_ts, value) - t0s = due.get(idx) - - if not t0s: - continue - - # Событие с тем же ts, что следующее: это ещё не последнее - # событие тика — переносим смены на следующий индекс. - if idx + 1 < len(ts) and ts[idx + 1] == event_ts: - due.setdefault(idx + 1, []).extend(t0s) - continue - - rows.append(service._build_feature_row(channel_id, event_ts)) - row_meta.append((channel_id, event_ts, t0s)) - - raw_predictions = service.model.predict(rows) if rows else [] - - for (channel_id, event_ts, t0s), raw in zip(row_meta, raw_predictions): - probability = service._probability_from_raw(float(raw), PROD_HORIZON_HOURS) - - for t0 in t0s: - age = (t0 - event_ts).total_seconds() / 3600 - results.append(ProdPrediction(channel_id, t0, probability, age)) - - return results diff --git a/tests/backtest/run.py b/tests/backtest/run.py index ecf0185..564b13e 100644 --- a/tests/backtest/run.py +++ b/tests/backtest/run.py @@ -1,221 +1,128 @@ -"""Оффлайн-бэктест HACK-174: одна команда, весь конвейер. - - python -m tests.backtest.run --dataset-dir /path/to/Sources/dataset - -Путь к данным обязателен: через --dataset-dir или переменную окружения -HACK174_DATASET_DIR (см. backtest/README.md). Читает журналы событий и -справочники, поднимает боевую app.PredictionService на data/best_model.cbm -(та же модель, что на стенде), считает вероятность тревоги на смены -апрель-июнь 2026 по тестовым каналам сплита в двух вариантах — «прогноз -ровно на границе смены» и «то, что стенд реально показывал по последнему -событию» — и сравнивает с двумя бейзлайнами. Результат — tests/backtest/results/*.csv -(predictions.csv не коммитится, см. .gitignore) и tests/backtest/backtest.md. +"""Оффлайн-бэктест HACK-174, см. tests/backtest/backtest.md. + + HACK174_DATASET_DIR=/path/to/Sources/dataset python -m tests.backtest.run """ from __future__ import annotations import argparse import os -import sys -import tempfile -import time from datetime import date, datetime, timedelta from pathlib import Path import polars as pl -REPO_ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(REPO_ROOT)) - -from tests.backtest import channels, data, episodes, metrics, prodstyle # noqa: E402 -from tests.backtest.dispatch import channel_dispatchers, dispatcher_scale # noqa: E402 -from tests.backtest.predict import predict_shifts # noqa: E402 -from tests.backtest.service import build_offline_service # noqa: E402 -from tests.backtest.warmstate import last_state_before # noqa: E402 +from tests.backtest import data, episodes, metrics, predict +from tests.backtest.service import build_offline_service -HORIZON_HOURS = 24.0 +REPO_ROOT = Path(__file__).resolve().parents[2] +HORIZON = timedelta(hours=24) SHIFT_HOURS = (8, 20) WARMUP_PAD_DAYS = 3 -OUTCOME_PAD_DAYS = 1 - - -def _shift_times(start: date, end: date) -> list[datetime]: - times, day = [], start - while day <= end: - times.extend(datetime(day.year, day.month, day.day, h) for h in SHIFT_HOURS) - day += timedelta(days=1) - return times - - -def _valid_shift_times(start: date, end: date, horizon_hours: float) -> list[datetime]: - """Смены, у которых t0+horizon целиком внутри [start, end] (см. п.6 - сноски проверяющего): последняя валидная смена — 29.06 20:00, не 30.06. - """ - period_end = datetime(end.year, end.month, end.day) + timedelta(days=1) - horizon = timedelta(hours=horizon_hours) - return [t0 for t0 in _shift_times(start, end) if t0 + horizon <= period_end] def _build_rows( - predictions: list, - prodstyle_by_key: dict[tuple[int, datetime], prodstyle.ProdPrediction], + predictions: list[tuple[int, datetime, float, bool]], + prodstyle: dict[tuple[int, datetime], tuple[float | None, float | None]], timelines: dict[int, episodes.ChannelTimeline], dispatchers: dict[int, int | None], ) -> pl.DataFrame: - horizon = timedelta(hours=HORIZON_HOURS) rows = [] - - for pred in predictions: - timeline = timelines[pred.channel_id] - yesterday_start = pred.t0 - timedelta(hours=24) - yesterday_alarms = episodes.alarms_in_window(timeline, yesterday_start, pred.t0) - - # "Спокойный" канал: сейчас в норме И без тревог последние 6ч — - # без первого условия канал, который тревожит уже давно без новых - # событий, ошибочно считался бы спокойным (см. warmstate.py). - quiet_no_recent_alarm = ( - episodes.alarms_in_window(timeline, pred.t0 - timedelta(hours=6), pred.t0) == 0 + for channel_id, t0, probability, history_complete in predictions: + timeline = timelines[channel_id] + yesterday_alarms = episodes.alarms_in_window(timeline, t0 - timedelta(hours=24), t0) + # Спокойный канал: сейчас в норме и без тревог последние 6 ч. + early_warning_ok = ( + episodes.state_at(timeline, t0) is not True + and episodes.alarms_in_window(timeline, t0 - timedelta(hours=6), t0) == 0 ) - currently_normal = episodes.state_at(timeline, pred.t0) is not True - early_warning_ok = currently_normal and quiet_no_recent_alarm - - episode_start = episodes.first_episode_after(timeline, pred.t0, horizon) - prod = prodstyle_by_key.get((pred.channel_id, pred.t0)) + episode_start = episodes.first_episode_after(timeline, t0, HORIZON) + prod_probability, prod_age = prodstyle[(channel_id, t0)] rows.append( { - "channel_id": pred.channel_id, - "t0": pred.t0, - "probability": pred.probability, - "history_complete": pred.history_complete, - "prodstyle_probability": prod.probability if prod else None, - "prodstyle_age_hours": prod.age_hours if prod else None, - "prodstyle_valid": bool(prod and prod.valid), + "channel_id": channel_id, + "t0": t0, + "probability": probability, + "history_complete": history_complete, + "prodstyle_probability": prod_probability, + "prodstyle_age_hours": prod_age, + "prodstyle_valid": prod_age is not None and prod_age < predict.PROD_HORIZON_HOURS, "baseline_alarm_yesterday": yesterday_alarms > 0, "baseline_10plus_yesterday": yesterday_alarms >= 10, "early_warning_ok": early_warning_ok, "outcome_24h": episode_start is not None, "hours_to_event": ( - (episode_start - pred.t0).total_seconds() / 3600 - if episode_start is not None - else None + (episode_start - t0).total_seconds() / 3600 if episode_start is not None else None ), - "dispatcher_id": dispatchers.get(pred.channel_id), + "dispatcher_id": dispatchers.get(channel_id), } ) - return pl.DataFrame(rows, infer_schema_length=None) -def _require_dataset_dir(value: str | None) -> Path: - resolved = value or os.environ.get("HACK174_DATASET_DIR") - if not resolved: - raise SystemExit( - "--dataset-dir is required (or set HACK174_DATASET_DIR): " - "каталог с ext-journal-*.csv и справочниками, см. backtest/README.md" - ) - return Path(resolved) - - def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--dataset-dir", type=str, default=None) + parser.add_argument("--dataset-dir", type=Path, default=os.environ.get("HACK174_DATASET_DIR")) parser.add_argument( "--weather-csv", type=Path, default=REPO_ROOT / "retraining/data/reference/open-meteo-55.75N37.63E140m.csv", ) parser.add_argument("--output-dir", type=Path, default=Path(__file__).resolve().parent / "results") - # Своя temp-директория на прогон: устаревший metadata-кеш/state не - # должен подмешаться (тот же приём, что и в inference/runner.py). - parser.add_argument( - "--runtime-dir", - type=Path, - default=Path(tempfile.mkdtemp(prefix="hack174-backtest-")), - ) parser.add_argument("--start", type=date.fromisoformat, default=date(2026, 4, 1)) parser.add_argument("--end", type=date.fromisoformat, default=date(2026, 6, 30)) args = parser.parse_args() - dataset_dir = _require_dataset_dir(args.dataset_dir) + if args.dataset_dir is None: + parser.error("--dataset-dir or HACK174_DATASET_DIR is required") + out = args.output_dir + out.mkdir(parents=True, exist_ok=True) - started = time.perf_counter() - args.output_dir.mkdir(parents=True, exist_ok=True) - - print("1/7 test-channel split (notebooks/2, seed=69)...") - test_ids = channels.test_channel_ids(dataset_dir) - pl.DataFrame({"ид_канала_данных": test_ids}).write_csv( - args.output_dir / "test_channels.csv" - ) - print(f" test channels: {len(test_ids)}") + test_ids = data.test_channel_ids(args.dataset_dir) + pl.DataFrame({data.ID_COL: test_ids}).write_csv(out / "test_channels.csv") load_start = args.start - timedelta(days=WARMUP_PAD_DAYS) - load_end = args.end + timedelta(days=OUTCOME_PAD_DAYS, hours=1) - print(f"2/7 loading events [{load_start}, {load_end}] for test channels...") - events_df = data.load_events(dataset_dir, test_ids, load_start, load_end) - print(f" events: {events_df.height}") - - print("3/7 warming episode state from before the load window...") - seed_states = last_state_before(dataset_dir, test_ids, load_start) - - print("4/7 building service (app.PredictionService, offline weather)...") - service = build_offline_service(REPO_ROOT, args.weather_csv, args.runtime_dir) - - have_events = set(events_df["ид_канала_данных"].unique().to_list()) - usable_ids = [ - cid for cid in test_ids if cid in have_events and cid in service.metadata - ] - print( - f" usable channels: {len(usable_ids)} / {len(test_ids)} " - f"(no events: {len(set(test_ids) - have_events)}, " - f"no metadata: {len(set(test_ids) - set(service.metadata))})" + events_df = data.load_events( + args.dataset_dir, test_ids, load_start, args.end + timedelta(days=1, hours=1) ) + seed_states = data.last_state_before(args.dataset_dir, test_ids, load_start) + service = build_offline_service(REPO_ROOT, args.weather_csv) + + have_events = set(events_df[data.ID_COL].unique().to_list()) + usable_ids = [cid for cid in test_ids if cid in have_events and cid in service.metadata] + + # Смены, у которых окно t0 + 24ч целиком внутри периода: последняя — 29.06 20:00. + period_start = datetime.combine(args.start, datetime.min.time()) + period_end = datetime.combine(args.end, datetime.min.time()) + timedelta(days=1) + shift_times = [ + period_start + timedelta(days=d, hours=h) + for d in range((args.end - args.start).days + 1) + for h in SHIFT_HOURS + if period_start + timedelta(days=d, hours=h) + HORIZON <= period_end + ] - shift_times = _valid_shift_times(args.start, args.end, HORIZON_HOURS) - print( - f"5/7 predicting {len(usable_ids)} channels x {len(shift_times)} shifts " - f"(fresh-t0 + prodstyle)..." - ) - predictions = predict_shifts( - service, events_df, usable_ids, shift_times, HORIZON_HOURS + predictions = predict.predict_shifts( + service, events_df, usable_ids, shift_times, HORIZON.total_seconds() / 3600 ) - # Второй проход по своим таймлайнам: store уже продвинут до последней - # смены предыдущим проходом, иначе события "из прошлого" будут - # выглядеть как out-of-order (app.OnlineFeatureStore.update). + # Store уже продвинут до последней смены: без сброса события второго + # прохода app.OnlineFeatureStore.update счёл бы out-of-order. service.store.states.clear() - prod_predictions = prodstyle.predict_last_event( - service, events_df, usable_ids, shift_times - ) - prodstyle_by_key = {(p.channel_id, p.t0): p for p in prod_predictions} + prodstyle = predict.predict_last_event(service, events_df, usable_ids, shift_times) - print("6/7 episodes, baselines, dispatcher grouping...") timelines = episodes.build_timelines(events_df, seed_states) - dispatchers_all = channel_dispatchers( - service.config.sensors_metadata_path, - service.config.objects_metadata_path, + dispatchers = data.channel_dispatchers( + service.config.sensors_metadata_path, service.config.objects_metadata_path ) - scale = dispatcher_scale(dispatchers_all, usable_ids) - df = _build_rows(predictions, prodstyle_by_key, timelines, dispatchers_all) - df.write_csv(args.output_dir / "predictions.csv") + df = _build_rows(predictions, prodstyle, timelines, dispatchers) + df.write_csv(out / "predictions.csv") - period_end = datetime(args.end.year, args.end.month, args.end.day) + timedelta(days=1) - breakdown = episodes.episode_value_breakdown( - timelines, datetime(args.start.year, args.start.month, args.start.day), period_end + episodes.episode_value_breakdown(timelines, period_start, period_end).write_csv( + out / "episode_value_breakdown.csv" ) - breakdown.write_csv(args.output_dir / "episode_value_breakdown.csv") - - print("7/7 metrics, bootstrap CI, report...") - comparison = metrics.build_comparison_table(df, dispatcher_scale=scale) - comparison.write_csv(float_precision=6, file=args.output_dir / "comparison_table.csv") - calibration = metrics.calibration_table(df) - calibration.write_csv(args.output_dir / "calibration.csv") - bootstrap = metrics.build_bootstrap_table(df) - bootstrap.write_csv(args.output_dir / "bootstrap_ci.csv") - - elapsed = time.perf_counter() - started - print(f"done in {elapsed:.1f}s") + comparison = metrics.build_comparison_table(df, data.dispatcher_scale(dispatchers, usable_ids)) + comparison.write_csv(out / "comparison_table.csv", float_precision=6) + metrics.calibration_table(df).write_csv(out / "calibration.csv") + metrics.build_bootstrap_table(df).write_csv(out / "bootstrap_ci.csv") print(comparison) - print(calibration) - print(bootstrap) - print(breakdown.head(10)) if __name__ == "__main__": diff --git a/tests/backtest/service.py b/tests/backtest/service.py index 9d4b8b2..0740170 100644 --- a/tests/backtest/service.py +++ b/tests/backtest/service.py @@ -1,39 +1,44 @@ -"""Поднимает боевой app.PredictionService offline: те же parquet-справочники -и та же модель, что на стенде, но CSV-погода вместо HTTP weather-service и -без Postgres/pickle-state. +"""Боевой app.PredictionService offline: модель и справочники стенда, +CSV-погода Open-Meteo вместо weather-service, без Postgres и pickle-state. """ from __future__ import annotations +import tempfile +from datetime import datetime from pathlib import Path +import polars as pl + from app import PredictionService, ServiceConfig -from tests.backtest.weather import CsvWeatherClient +class CsvWeatherClient: + """app.WeatherSource из CSV; UTC +3ч, как EXPECTED_WEATHER в ноутбуке 1.""" + + def __init__(self, csv_path: Path) -> None: + df = pl.read_csv(csv_path).with_columns( + (pl.col("time").str.to_datetime() + pl.duration(hours=3)).alias("time") + ) + self._by_hour = {row["time"]: row for row in df.iter_rows(named=True)} + self._missing = dict.fromkeys(df.columns) + + def get(self, target_hour: datetime) -> dict: + hour = target_hour.replace(minute=0, second=0, microsecond=0) + return self._by_hour.get(hour, self._missing) -def build_offline_service( - repo_root: Path, - weather_csv: Path, - runtime_dir: Path, -) -> PredictionService: - runtime_dir.mkdir(parents=True, exist_ok=True) + def close(self) -> None: + pass + +def build_offline_service(repo_root: Path, weather_csv: Path) -> PredictionService: + # Свежий каталог: чужой metadata-кеш и rolling-state не подмешаются. + runtime_dir = Path(tempfile.mkdtemp(prefix="hack174-backtest-")) config = ServiceConfig( model_path=repo_root / "data" / "best_model.cbm", - sensors_metadata_path=( - repo_root / "data" / "справочник_каналов_датчиков.parquet" - ), - objects_metadata_path=( - repo_root / "data" / "справочник_объектов_диспетчер.parquet" - ), + sensors_metadata_path=repo_root / "data" / "справочник_каналов_датчиков.parquet", + objects_metadata_path=repo_root / "data" / "справочник_объектов_диспетчер.parquet", metadata_cache_path=runtime_dir / "metadata_cache.pkl", - # Путь не существует: чужой/устаревший rolling-state не должен - # подмешаться к бэктесту (см. app.load_state_if_exists). state_path=runtime_dir / "no_state.pkl", log_path=runtime_dir / "service.log", ) - - return PredictionService( - config=config, - weather_client=CsvWeatherClient(weather_csv), - ) + return PredictionService(config=config, weather_client=CsvWeatherClient(weather_csv)) diff --git a/tests/backtest/warmstate.py b/tests/backtest/warmstate.py deleted file mode 100644 index 3f1d09b..0000000 --- a/tests/backtest/warmstate.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Состояние канала перед окном загрузки (для прогрева episodes.py). - -Без этого первое тревожное событие в окне бэктеста ошибочно читалось бы -как начало нового эпизода, даже если канал был в тревоге уже давно -(пример из независимой проверки: канал 334183, последнее событие до -29.03.2026 — 2026-03-12 14:31:42, тревожное=true). -""" -from __future__ import annotations - -from datetime import datetime -from pathlib import Path - -import polars as pl - -ID_COL = "ид_канала_данных" -ALL_YEARS = range(2019, 2027) - - -def _year_rows(dataset_dir: Path, year: int, channel_ids: list[int]) -> pl.DataFrame: - lf = pl.scan_csv( - dataset_dir / f"ext-journal-{year}.csv", - schema_overrides={ - ID_COL: pl.Utf8, - "дата": pl.Utf8, - "значение_датчика": pl.Utf8, - }, - ignore_errors=True, - ) - - return ( - lf.select(ID_COL, "дата", "время", "тревожное", "значение_датчика") - .filter(pl.col(ID_COL) != ID_COL) - .with_columns(pl.col(ID_COL).cast(pl.Int64)) - .filter(pl.col(ID_COL).is_in(channel_ids)) - .collect(engine="streaming") - ) - - -def last_state_before( - dataset_dir: Path, - channel_ids: list[int], - before: datetime, - years: range = ALL_YEARS, -) -> dict[int, tuple[bool, str]]: - """Последнее событие каждого канала с ts < before, по всем годам.""" - frames = [_year_rows(dataset_dir, year, channel_ids) for year in years] - df = pl.concat(frames) - - df = ( - df.with_columns( - pl.col("дата").str.to_date(), - pl.col("время").str.to_time("%H:%M:%S", strict=False), - (pl.col("тревожное") == "t").alias("тревожное"), - ) - .with_columns( - pl.datetime( - pl.col("дата").dt.year(), - pl.col("дата").dt.month(), - pl.col("дата").dt.day(), - pl.col("время").dt.hour(), - pl.col("время").dt.minute(), - pl.col("время").dt.second(), - ).alias("ts") - ) - .filter(pl.col("ts") < before) - .sort([ID_COL, "ts"]) - .group_by(ID_COL, maintain_order=True) - .last() - ) - - return { - int(row[ID_COL]): (row["тревожное"], row["значение_датчика"]) - for row in df.iter_rows(named=True) - } diff --git a/tests/backtest/weather.py b/tests/backtest/weather.py deleted file mode 100644 index d674e78..0000000 --- a/tests/backtest/weather.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Offline WeatherSource для app.PredictionService: тот же CSV, что и в -retraining (open-meteo), вместо HTTP weather-service/Postgres. -""" -from __future__ import annotations - -from datetime import datetime -from pathlib import Path -from typing import Mapping - -import polars as pl - -WEATHER_COLUMNS = ( - "temperature_2m (°C)", - "relative_humidity_2m (%)", - "precipitation_probability (%)", - "precipitation (mm)", - "rain (mm)", - "snowfall (cm)", - "snow_depth (m)", - "weather_code (wmo code)", - "cloud_cover (%)", - "pressure_msl (hPa)", -) - - -class CsvWeatherClient: - """Как app.WeatherSource: .get(hour) -> {feature_name: value}. - - Время CSV — UTC; сдвиг +3ч на МСК воспроизводит EXPECTED_WEATHER из - retraining/notebooks/1_prepare_dataset.ipynb дословно. - """ - - def __init__(self, csv_path: Path) -> None: - df = pl.read_csv(csv_path).with_columns( - (pl.col("time").str.to_datetime() + pl.duration(hours=3)).alias("time") - ) - - self._by_hour: dict[datetime, Mapping[str, float | int | None]] = { - row["time"]: row for row in df.iter_rows(named=True) - } - - def get(self, target_hour: datetime) -> Mapping[str, float | int | None]: - hour = target_hour.replace(minute=0, second=0, microsecond=0) - row = self._by_hour.get(hour) - - if row is None: - return {name: None for name in WEATHER_COLUMNS} - - return row - - def close(self) -> None: - pass From 4b0b25052316b0fca131cba745050353856f0388 Mon Sep 17 00:00:00 2001 From: UsatovPavel Date: Sun, 27 Sep 2026 10:25:25 +0300 Subject: [PATCH 4/4] docs(backtest): state actual training period Co-Authored-By: Claude Opus 5.5 --- tests/backtest/backtest.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/backtest/backtest.md b/tests/backtest/backtest.md index 9b9784d..fa8153d 100644 --- a/tests/backtest/backtest.md +++ b/tests/backtest/backtest.md @@ -38,9 +38,12 @@ 4. **Предсказываем в основном поломки датчиков.** 83% сбывшихся событий — переход в «Неисправен», а не авария или реальная тревога. Честнее называть это «риском неисправности оборудования». -5. **Цифры завышены.** Модель обучалась на этих же месяцах, пусть и на - других каналах. На будущем периоде результат будет не лучше. - Следующий шаг — переобучение с разбиением по времени (отдельная задача, +5. **Цифры завышены, и честной проверки по времени сейчас нет.** Модель + обучена на журналах с 01.2024 по 06.2026 (ноутбуки 1 и 5: около 720 + train-каналов, 9,5 млн строк), фильтра по дате нет. Периода после + обучения в данных нет, поэтому здесь те же месяцы, только на каналах, + которых модель не видела. Проверка по времени возможна только после + переобучения с отсечкой, например до 31.03.2026 (отдельная задача, около 1 дня). ## Детали