225 lines
12 KiB
Python
225 lines
12 KiB
Python
"""Обучение считывания по ТРЕКАМ и честная проверка по бэгам.
|
||
|
||
Покадровое считывание (`train_mbon.py`) отвечает на вопрос «похоже ли это
|
||
пятно на предмет». Здесь вопрос другой: «стоит ли по этому треку тормозить» —
|
||
и отвечать на него по одному кадру нечем. Воронка потерь (EXPERIMENTS п. 12)
|
||
показывает, где это болит: на 120…160 м трек заводится в 62 % наблюдений, а до
|
||
решения доходит 38 %. Улика есть, порога не набирает.
|
||
|
||
Главная таблица внизу — не AUC модели сам по себе, а **сравнение с уликой**.
|
||
Улика уже сейчас стоит в пороге, и если модель её не обгоняет, весь замысел
|
||
не стоит ни строчки кода: значит, в истории трека нет ничего сверх того, что
|
||
накопитель и так посчитал.
|
||
|
||
Проверка — leave-one-bag-out, как и везде: модель обучается на всех записях,
|
||
кроме проверяемой.
|
||
|
||
python tools/make_benchmark.py --memory artifacts/mushroom_body.npz \
|
||
--mbon-dir artifacts/mbon_folds --tracks-out data/cache/track_set.npz
|
||
python tools/train_track.py --device cuda
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
|
||
import _bootstrap as B # noqa: F401
|
||
from _metrics import auc, fpr_at_tpr
|
||
from flyguard.mbon_readout import MbonConfig
|
||
from flyguard.track_readout import TRACK_FEATURES, TrackReadout
|
||
|
||
BANDS = ((0, 30), (30, 55), (55, 80), (80, 110), (110, 160), (160, 230))
|
||
|
||
|
||
def band_table(d, y, cols: dict[str, np.ndarray]) -> None:
|
||
"""AUC по полосам дальности для нескольких оценок разом."""
|
||
head = "".join(f"{k:>16}" for k in cols)
|
||
print(f"{'полоса':>12}{'наблюдений':>12}{'предметов':>11}{head}")
|
||
for lo, hi in BANDS:
|
||
m = (d >= lo) & (d < hi)
|
||
if m.sum() < 30 or len(set(y[m].tolist())) < 2:
|
||
continue
|
||
cells = "".join(f"{auc(v[m], y[m]):16.3f}" for v in cols.values())
|
||
print(f"{f'{lo}-{hi} м':>12}{int(m.sum()):12d}{int(y[m].sum()):11d}{cells}")
|
||
|
||
|
||
def loo(names, X, Y, cfg, args):
|
||
"""Обучение на всех записях, кроме проверяемой."""
|
||
out = []
|
||
for held in names:
|
||
tr = [n for n in names if n != held]
|
||
Xtr = np.concatenate([X[n] for n in tr])
|
||
ytr = np.concatenate([Y[n] for n in tr])
|
||
m = TrackReadout(cfg, n_pn=Xtr.shape[1])
|
||
m.fit_normalizer(Xtr)
|
||
m.learn(Xtr, ytr, epochs=args.epochs, lr=args.lr, l2=args.l2,
|
||
device=args.device)
|
||
out.append((held, m.score(X[held]), m))
|
||
return out
|
||
|
||
|
||
def main() -> None:
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("--data", default=str(B.CACHE / "track_set.npz"))
|
||
ap.add_argument("--out", default=str(B.ARTIFACTS / "track_readout.npz"))
|
||
ap.add_argument("--save-folds", default=str(B.ARTIFACTS / "track_folds"))
|
||
ap.add_argument("--n-kc", type=int, default=0,
|
||
help="ёмкость итоговой модели; 0 — лучшая из развёртки")
|
||
ap.add_argument("--sweep-kc", default="500,1000,2000,4000")
|
||
ap.add_argument("--epochs", type=int, default=200)
|
||
ap.add_argument("--lr", type=float, default=2.0)
|
||
ap.add_argument("--device", default="auto",
|
||
help="устройство обучения ('auto', 'cuda', 'cpu')")
|
||
ap.add_argument("--baseline", action="store_true",
|
||
help="сравнить с GPU/CPU градиентным бустингом (LightGBM)")
|
||
ap.add_argument("--drop", default="",
|
||
help="признаки, выброшенные из обучения — это РЕЖИМ ЗАМЕРА, "
|
||
"модель при этом не сохраняется: конвейер считает "
|
||
"описание целиком и укороченную модель не примет. "
|
||
"Смысл — проверить вклад признака. Так, оба разброса "
|
||
"на полигоне выглядят "
|
||
"сильными (-s_std даёт AUC 0.869 за 160 м), а на "
|
||
"настоящем объекте ПЕРЕВЁРНУТЫ — 0.365 м у предмета "
|
||
"против 0.013 м у ложных треков. Вставленный предмет "
|
||
"стоит на дальности, посчитанной из той же оценки "
|
||
"собственного движения, что двигает мировую "
|
||
"координату, поэтому разброса у него нет по "
|
||
"построению. EXPERIMENTS п. 15.1")
|
||
ap.add_argument("--min-hits", type=int, default=2,
|
||
help="как в descending.py: трек с меньшим числом "
|
||
"подтверждений всё равно не рассматривается")
|
||
args = ap.parse_args()
|
||
|
||
d = np.load(args.data, allow_pickle=True)
|
||
names = [str(n) for n in d["names"]]
|
||
feats = [str(f) for f in d["features"]]
|
||
assert feats == list(TRACK_FEATURES), "выборка собрана другим дескриптором"
|
||
# Модель, обученная без части признаков, не подойдёт конвейеру: он
|
||
# считает описание целиком. Такая модель — инструмент замера, и в
|
||
# `descending.py` она включается только явным `--track-score model`.
|
||
i_hits = feats.index("hits")
|
||
i_ev = feats.index("evidence")
|
||
|
||
X, Y, D = {}, {}, {}
|
||
for n in names:
|
||
Xb, yb, db = d[f"X_{n}"], d[f"y_{n}"].astype(np.int8), d[f"d_{n}"]
|
||
keep = Xb[:, i_hits] >= args.min_hits
|
||
X[n], Y[n], D[n] = Xb[keep].astype(np.float32), yb[keep], db[keep]
|
||
|
||
drop = [s.strip() for s in args.drop.split(",") if s.strip()]
|
||
if drop:
|
||
bad = [s for s in drop if s not in feats]
|
||
if bad:
|
||
raise SystemExit(f"нечего выбрасывать: {bad}")
|
||
cols = [k for k, f in enumerate(feats) if f not in drop]
|
||
feats = [feats[k] for k in cols]
|
||
i_hits, i_ev = feats.index("hits"), feats.index("evidence")
|
||
for n in names:
|
||
X[n] = X[n][:, cols]
|
||
print("выброшено из обучения:", ", ".join(drop))
|
||
|
||
tot = sum(v.shape[0] for v in X.values())
|
||
pos = sum(int(v.sum()) for v in Y.values())
|
||
print(f"выборка: {tot} наблюдений трека, предметных {pos} "
|
||
f"({pos / max(tot, 1):.1%}), признаков {len(feats)}, "
|
||
f"подтверждений не меньше {args.min_hits}")
|
||
for n in names:
|
||
print(f" {n:<42}{X[n].shape[0]:7d} предметных {int(Y[n].sum()):6d} "
|
||
f"({Y[n].mean():5.1%})")
|
||
|
||
Xall = np.concatenate([X[n] for n in names])
|
||
yall = np.concatenate([Y[n] for n in names])
|
||
dall = np.concatenate([D[n] for n in names])
|
||
|
||
# ------------------------------------------- что даёт каждый признак сам
|
||
# Ловля артефактов: если наверх вылезает признак, который не может нести
|
||
# физику (возраст трека, ширина), — выборка перекошена, а не модель умна.
|
||
print("\nодин признак — какой AUC, вся выборка:")
|
||
rank = sorted(((auc(Xall[:, i], yall), f) for i, f in enumerate(feats)),
|
||
key=lambda p: -abs(p[0] - 0.5))
|
||
for a, f in rank:
|
||
print(f" {f:<12}{a:6.3f}")
|
||
|
||
# ------------------------------------------------------------- развёртка
|
||
sweep = [int(x) for x in args.sweep_kc.split(",") if x.strip()]
|
||
best, best_auc = args.n_kc or 2000, -1.0
|
||
if sweep and not args.n_kc:
|
||
print("\nразвёртка по ёмкости (leave-one-bag-out):")
|
||
for n_kc in sweep:
|
||
cfg = MbonConfig(n_kc=n_kc, sparsity=100.0 / n_kc, seed=20260922)
|
||
t0 = time.time()
|
||
res = loo(names, X, Y, cfg, args)
|
||
s = np.concatenate([r[1] for r in res])
|
||
yy = np.concatenate([Y[r[0]] for r in res])
|
||
a = auc(s, yy)
|
||
print(f" {n_kc:6d} клеток: AUC {a:.4f}, "
|
||
f"ложных при 95 % пойманных {fpr_at_tpr(s, yy):.3f}, "
|
||
f"{time.time() - t0:5.1f} с")
|
||
if a > best_auc:
|
||
best, best_auc = n_kc, a
|
||
|
||
cfg = MbonConfig(n_kc=best, sparsity=100.0 / best, seed=20260922)
|
||
print(f"\nитоговая ёмкость: {best} клеток")
|
||
res = loo(names, X, Y, cfg, args)
|
||
s_loo = np.concatenate([r[1] for r in res])
|
||
y_loo = np.concatenate([Y[r[0]] for r in res])
|
||
d_loo = np.concatenate([D[r[0]] for r in res])
|
||
ev_loo = np.concatenate([X[r[0]][:, i_ev] for r in res])
|
||
|
||
print(f"\nleave-one-bag-out: AUC {auc(s_loo, y_loo):.4f}, "
|
||
f"ложных при 95 % пойманных {fpr_at_tpr(s_loo, y_loo):.3f}")
|
||
for held, s, _ in res:
|
||
print(f" {held:<42}AUC {auc(s, Y[held]):.4f}")
|
||
|
||
table_cols = {"улика: AUC": ev_loo, "модель: AUC": s_loo}
|
||
if args.baseline:
|
||
import lightgbm as lgb
|
||
s_lgb = []
|
||
for held in names:
|
||
tr = [n for n in names if n != held]
|
||
Xtr = np.concatenate([X[n] for n in tr])
|
||
ytr = np.concatenate([Y[n] for n in tr])
|
||
dev_type = "gpu" if (args.device == "cuda" or (args.device == "auto" and B.Path("/dev/nvidia0").exists())) else "cpu"
|
||
try:
|
||
g = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.05, num_leaves=31,
|
||
device=dev_type, verbose=-1)
|
||
g.fit(Xtr, ytr)
|
||
except Exception:
|
||
g = lgb.LGBMClassifier(n_estimators=300, learning_rate=0.05, num_leaves=31,
|
||
verbose=-1)
|
||
g.fit(Xtr, ytr)
|
||
s_lgb.append(g.predict_proba(X[held])[:, 1])
|
||
s_lgb_arr = np.concatenate(s_lgb)
|
||
print(f"\nбустинг leave-one-bag-out: AUC {auc(s_lgb_arr, y_loo):.4f}, "
|
||
f"ложных при 95 % пойманных {fpr_at_tpr(s_lgb_arr, y_loo):.3f}")
|
||
table_cols["бустинг: AUC"] = s_lgb_arr
|
||
|
||
# ------------------------------- главное: обгоняет ли модель саму улику
|
||
print("\nмодель против улики, по полосам дальности:")
|
||
band_table(d_loo, y_loo, table_cols)
|
||
|
||
# --------------------------------------------------------------- хранение
|
||
if drop:
|
||
print()
|
||
print("режим замера (--drop): модель не сохраняется")
|
||
return
|
||
folds = Path(args.save_folds)
|
||
folds.mkdir(parents=True, exist_ok=True)
|
||
for held, _, m in res:
|
||
m.save(folds / f"track_{held}.npz")
|
||
print(f"\nмодели по складкам: {folds} ({len(res)} шт.)")
|
||
|
||
final = TrackReadout(cfg, n_pn=Xall.shape[1])
|
||
final.fit_normalizer(Xall)
|
||
final.learn(Xall, yall, epochs=args.epochs, lr=args.lr, l2=args.l2,
|
||
device=args.device)
|
||
B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
|
||
final.save(args.out)
|
||
print("сохранено:", args.out)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|