From d39038184b4aa5735b1fcdde2b366f40ff9dea85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D0=BB=20=D0=9E=D0=BC=D0=B5=D0=BB?= =?UTF-8?q?=D0=B5=D1=87=D0=BA=D0=BE?= Date: Tue, 22 Sep 2026 17:00:44 +0300 Subject: [PATCH] =?UTF-8?q?=D1=81=D1=87=D0=B8=D1=82=D1=8B=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D1=82=D1=80=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC:=20=D0=BD=D0=B0=D0=BA=D0=BE=D0=BF=D0=B8=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8,=20=D0=B4=D0=B5=D1=81=D0=BA=D1=80=D0=B8?= =?UTF-8?q?=D0=BF=D1=82=D0=BE=D1=80,=20=D0=BE=D0=B1=D1=83=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- flyguard/central_complex.py | 92 ++++++++++++++++- flyguard/descending.py | 43 +++++++- flyguard/mbon_readout.py | 8 +- flyguard/pipeline.py | 17 ++- flyguard/track_readout.py | 121 ++++++++++++++++++++++ tests/test_pipeline.py | 67 ++++++++++++ tools/_metrics.py | 44 ++++++++ tools/check_obstacle.py | 28 +++++ tools/evaluate.py | 41 +++++++- tools/make_benchmark.py | 94 +++++++++++++++-- tools/train_mbon.py | 31 +----- tools/train_track.py | 200 ++++++++++++++++++++++++++++++++++++ 13 files changed, 729 insertions(+), 59 deletions(-) create mode 100644 flyguard/track_readout.py create mode 100644 tools/_metrics.py create mode 100644 tools/train_track.py diff --git a/README.md b/README.md index acb2061..64c7f42 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ flyguard/ ядро: стадии обработки, память, сч pipeline.py сборка synth.py вставка предметов трассировкой лучей tools/ обучение, оценка, разбор -tests/ 29 тестов, запускаются без данных и без ROS +tests/ 32 теста, запускаются без данных и без ROS docs/ методика и результаты artifacts/ обученные модели ``` diff --git a/flyguard/central_complex.py b/flyguard/central_complex.py index 1550a3e..1b30838 100644 --- a/flyguard/central_complex.py +++ b/flyguard/central_complex.py @@ -45,15 +45,86 @@ class Track: last_n_rays: int = 0 novelty: float = 1.0 speed_lat: float = 0.0 # поперечная скорость, м/с + p_track: float = 0.0 # отсчёт считывания по треку, если оно подключено history: list = field(default_factory=list) + # Сводка по всем наблюдениям трека, накапливаемая на лету. Считать её из + # `history` нельзя: описание трека нужно каждый кадр для всех живых + # треков, а разворачивать ради этого полсотни списков в массивы — заметная + # доля бюджета кадра. Все обновления здесь O(1). + n_obs: int = 0 + s_sum: float = 0.0 # мировая привязка, подразумеваемая наблюдением + s_sq: float = 0.0 + u_sum: float = 0.0 + u_sq: float = 0.0 + w_sum: float = 0.0 # вес наблюдения — ровно то, что идёт в улику + w_max: float = 0.0 + p_sum: float = 0.0 # отсчёт считывания по кандидату, если оно есть + p_max: float = 0.0 + w_ema: float = 0.0 # он же, но со скользящим забыванием + r_sum: float = 0.0 # лучи, нормированные на ожидаемые для дальности + r_last: float = 0.0 + ri_sum: float = 0.0 # суммы для наклона нормированных лучей по времени + i_sum: float = 0.0 + i_sq: float = 0.0 + @property def confirmed(self) -> bool: return self.evidence >= 0.5 + @property + def w_mean(self) -> float: + """Средний вес наблюдения за жизнь трека. + + Улика — это тот же вес, но просуммированный и обрезанный единицей, и + обрезание её убивает: на реальном объекте медиана улики 1.000 и у + предмета, и у ложных треков (AUC 0.624), а у среднего веса 0.998 + против 0.269 (AUC 1.000). Сумма отвечает «давно ли я на это смотрю», + среднее — «на что именно я смотрю». EXPERIMENTS п. 15. + """ + return self.w_sum / max(self.n_obs, 1) + + @property + def p_mean(self) -> float: + """Средний покадровый отсчёт считывания MBON за жизнь трека.""" + return self.p_sum / max(self.n_obs, 1) + def distance(self, s_now: float) -> float: return self.s_world - s_now + def observe(self, c: Candidate, s_obs: float, w: float) -> None: + """Записать наблюдение в сводку. + + `s_obs` — место в тоннеле, которое подразумевает это наблюдение: + пройденный путь плюс измеренная дальность. У НАСТОЯЩЕГО неподвижного + предмета оно не должно меняться от кадра к кадру, как бы поезд ни + ехал, и разброс этой величины — самая прямая проверка гипотезы, + какая у трека вообще есть. В улику она сейчас не входит никак. + """ + i = float(self.n_obs) + self.n_obs += 1 + self.s_sum += s_obs + self.s_sq += s_obs * s_obs + self.u_sum += c.u + self.u_sq += c.u * c.u + self.w_sum += w + self.w_max = max(self.w_max, w) + # Среднее за всю жизнь отстаёт у СБЛИЖАЮЩЕГОСЯ предмета: он + # появляется далеко и слабым, и первые десятки наблюдений тянут + # среднее вниз ещё долго после того, как он стал отчётливым. + # Скользящее забывание (полупериод около трёх наблюдений) отвечает + # на вопрос «каков он сейчас», а не «каким был с самого начала». + self.w_ema = w if self.n_obs == 1 else 0.8 * self.w_ema + 0.2 * w + p = float(c.extra.get("mbon", 0.0)) if c.extra else 0.0 + self.p_sum += p + self.p_max = max(self.p_max, p) + r = c.n_rays / expected_rays(c.d) + self.r_sum += r + self.r_last = r + self.ri_sum += i * r + self.i_sum += i + self.i_sq += i * i + class CentralComplex: """Накопитель улик и менеджер треков.""" @@ -142,6 +213,7 @@ class CentralComplex: t.misses = 0 w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend, self.nov_fade_from, self.nov_fade_to) + t.observe(c, self.s_world + c.d, w) t.evidence = min(1.0, t.evidence + self.gain * w) if dt > 1e-3: t.speed_lat = 0.6 * t.speed_lat + 0.4 * (c.u - t.u) / dt @@ -166,9 +238,10 @@ class CentralComplex: t = Track(id=next(self._ids), s_world=self.s_world + c.d, u=c.u, h=c.h, width=c.width, height=c.height, first_d=c.d, last_d=c.d, last_n_rays=c.n_rays, novelty=c.novelty) - t.evidence = self.gain * _quality( - c, self.use_shape, self.mbon_power, self.mbon_blend, - self.nov_fade_from, self.nov_fade_to) + w = _quality(c, self.use_shape, self.mbon_power, self.mbon_blend, + self.nov_fade_from, self.nov_fade_to) + t.observe(c, self.s_world + c.d, w) + t.evidence = self.gain * w t.hits = 1 self.tracks.append(t) @@ -198,6 +271,16 @@ class CentralComplex: return out +def expected_rays(d: float) -> float: + """Сколько лучей предмет даёт на дальности d просто по устройству решётки. + + Нужна в двух местах — в весе наблюдения и в описании трека, — и оба + раза за одним и тем же: дальний предмет даёт мало лучей не потому, что + он сомнительный, а потому, что так устроена развёртка. + """ + return max(3.0, 2500.0 / max(d, 5.0) ** 1.4) + + def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0, mbon_blend: float = 1.0, nov_fade_from: float = 0.0, nov_fade_to: float = 160.0) -> float: @@ -210,8 +293,7 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0, стена тянутся дальше и в стороны. Новизна из грибовидного тела входит сюда же множителем. """ - expected = max(3.0, 2500.0 / max(c.d, 5.0) ** 1.4) - support = float(np.clip(c.n_rays / expected, 0.25, 1.0)) + support = float(np.clip(c.n_rays / expected_rays(c.d), 0.25, 1.0)) # Контраст к фону — главная улика вблизи и недоступная вдали. На 170 м # кольцо окружения ламины упирается в стену тоннеля, стоящую на той же diff --git a/flyguard/descending.py b/flyguard/descending.py index 9172eaf..77c3ff9 100644 --- a/flyguard/descending.py +++ b/flyguard/descending.py @@ -62,7 +62,9 @@ class DescendingNeurons: def __init__(self, *, warn_evidence: float = 0.5, clear_evidence: float = 0.3, emergency_evidence: float = 0.75, min_hits: int = 2, novelty_floor: float = 0.10, max_range: float = 200.0, - warn_far: float | None = None, warn_far_from: float = 90.0): + warn_far: float | None = None, warn_far_from: float = 90.0, + track_blend: float = 0.0, track_score: str = "w_mean", + track_readout=None): self.warn_evidence = warn_evidence self.clear_evidence = clear_evidence self.emergency_evidence = emergency_evidence @@ -78,6 +80,15 @@ class DescendingNeurons: # порога не снижает. None — послабления нет. self.warn_far = warn_evidence if warn_far is None else warn_far self.warn_far_from = warn_far_from + # Считывание по ТРЕКУ (см. track_readout.py). Улика — это сумма + # покадровых весов, и она ничего не знает ни о том, держится ли + # место в тоннеле, ни о том, растёт ли число лучей по закону + # решётки. Модель знает. Смешивание геометрическое, как и для + # покадрового считывания: 0 — решает только улика, ровно как + # раньше, 1 — только модель. Так размен меряется, а не объявляется. + self.track_blend = track_blend + self.track_score = track_score + self.track_readout = track_readout self._latched: set[int] = set() @property @@ -97,16 +108,40 @@ class DescendingNeurons: stop = speed * REACTION_TIME + speed * speed / (2 * BRAKING_DECEL) out.stopping_distance = stop - live: list[tuple[Track, float]] = [] + # Дешёвые проверки сначала: модель считается только по тем трекам, + # которые их прошли, — обычно это единицы, а не полсотни. + ready: list[tuple[Track, float]] = [] for t in cx.tracks: d = t.distance(cx.s_world) - if not (0.0 < d <= self.max_range): + if (not (0.0 < d <= self.max_range) or t.hits < self.min_hits + or t.novelty < self.novelty_floor): self._latched.discard(t.id) continue + ready.append((t, d)) + + use_track = self.track_blend > 0.0 and bool(ready) + if use_track and self.track_score == "model": + if self.track_readout is None: + use_track = False + else: + self.track_readout.annotate_tracks([t for t, _ in ready], + cx.s_world) + + live: list[tuple[Track, float]] = [] + for t, d in ready: warn = self._warn_at(d) # гистерезис: попавший в тревогу трек держится до нижнего порога on = warn if t.id not in self._latched else warn * self._hyst - if t.evidence < on or t.hits < self.min_hits or t.novelty < self.novelty_floor: + score = t.evidence + if use_track: + q = (t.p_track if self.track_score == "model" + else t.p_mean if self.track_score == "p_mean" + else t.w_ema if self.track_score == "w_ema" + else t.w_mean) + b = min(max(self.track_blend, 0.0), 1.0) + score = (max(q, 1e-4) ** b + * max(t.evidence, 1e-4) ** (1.0 - b)) + if score < on: self._latched.discard(t.id) continue self._latched.add(t.id) diff --git a/flyguard/mbon_readout.py b/flyguard/mbon_readout.py index 4a06e38..fe257b0 100644 --- a/flyguard/mbon_readout.py +++ b/flyguard/mbon_readout.py @@ -270,12 +270,14 @@ class MbonReadout: ref_z=(self.ref_z if self.ref_z is not None else np.zeros(0, np.float32))) - @staticmethod - def load(path: str | Path) -> "MbonReadout": + @classmethod + def load(cls, path: str | Path) -> "MbonReadout": + """Загрузка через `cls`, а не через имя класса: считывание по трекам — + тот же контур с другим входом, и оно наследуется отсюда целиком.""" d = np.load(path, allow_pickle=False) cfg = MbonConfig(n_kc=int(d["n_kc"]), claws=int(d["claws"]), sparsity=float(d["sparsity"]), seed=int(d["seed"])) - m = MbonReadout(cfg, n_pn=int(d["n_pn"])) + m = cls(cfg, n_pn=int(d["n_pn"])) m.w_mbon = d["w_mbon"].astype(np.float32) m.bias = np.float32(d["bias"]) m.gain = np.float32(d["gain"]) diff --git a/flyguard/pipeline.py b/flyguard/pipeline.py index 34ebebb..9bce62f 100644 --- a/flyguard/pipeline.py +++ b/flyguard/pipeline.py @@ -104,6 +104,15 @@ class Params: # Пороги решения (см. descending.py). Вынесены сюда, потому что обученное # считывание меняет цену улики: когда ложные тревоги падают, запас можно # потратить на дальность, опустив порог. Замер — EXPERIMENTS п. 11. + # Считывание по треку (см. track_readout.py): геометрическое смешивание + # обученного отсчёта с уликой перед сравнением с порогом. 0 — решает + # только улика, как раньше. Замер — EXPERIMENTS п. 15. + track_blend: float = 0.0 + # Чем считать «насколько это предмет» по треку: `w_mean` — средний вес + # наблюдения за всю жизнь, `w_ema` — он же со скользящим забыванием, + # `p_mean` — средний покадровый отсчёт считывания, `model` — обученное + # считывание по треку (нужен файл модели). Замер — п. 15. + track_score: str = "w_mean" warn_evidence: float = 0.5 # улика, с которой трек поднимает тревогу clear_evidence: float = 0.3 # и до которой держится (гистерезис) emergency_evidence: float = 0.75 @@ -173,7 +182,8 @@ class FlyGuard: def __init__(self, params: Params | None = None, memory: MushroomBody | None = None, layout: ScanLayout | None = None, - readout: "MbonReadout | None" = None): + readout: "MbonReadout | None" = None, + track_readout=None): self.p = params or Params() self.memory = memory if (memory is not None and self.p.enable_memory) else NoMemory() self.readout = readout if (readout is not None and self.p.enable_mbon) else None @@ -209,7 +219,10 @@ class FlyGuard: min_hits=self.p.min_hits, novelty_floor=self.p.novelty_floor, warn_far=self.p.warn_far, - warn_far_from=self.p.warn_far_from) if self.p.use_tracking + warn_far_from=self.p.warn_far_from, + track_blend=self.p.track_blend, + track_score=self.p.track_score, + track_readout=track_readout) if self.p.use_tracking else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0, emergency_evidence=0.0, min_hits=1)) self._calib: list[PointCloud2] = [] diff --git a/flyguard/track_readout.py b/flyguard/track_readout.py new file mode 100644 index 0000000..abcc984 --- /dev/null +++ b/flyguard/track_readout.py @@ -0,0 +1,121 @@ +"""Считывание по ТРЕКУ — тот же контур MBON, но на входе накопленная история. + +Считывание в `mbon_readout.py` смотрит на один кадр: контур кандидата, его +тень, яркость, опору накопителя. Этого хватает вблизи и не хватает вдали — +за 120 м предмет даёт пять-шесть лучей, и по одному кадру он неотличим от +куска стены, попавшего в габарит. + +Но у трека есть то, чего у кандидата нет в принципе: **история**. Поезд +проехал двадцать метров, а предмет, если он настоящий, остался на том же +месте в тоннеле. Воронка потерь (EXPERIMENTS п. 12) показывает, что именно +здесь и теряется дальнее обнаружение: на 120…160 м трек заводится в 62 % +наблюдений, а до решения доходит 38 % — улика есть, порога не набирает. + +Поэтому вход здесь другой: + +* `s_std` — разброс места в тоннеле, которое подразумевают наблюдения. + У неподвижного предмета оно постоянно по построению, у ложного трека, + собранного из разных кусков стены, — гуляет. В улику этот признак + сейчас не входит вообще, ни одним множителем; +* `rays_slope` — растёт ли число лучей так, как обязано расти при + сближении. У предмета оно идёт по закону решётки, у случайного + совпадения — как придётся; +* сводки веса наблюдения и покадрового отсчёта MBON: среднее и максимум + за всю жизнь трека, а не последнее значение. + +Схема при этом не меняется: те же клетки Кеньона по шесть когтей, то же +торможение APL, тот же один выход. Это ровно та же операция, что MBON +проделывает над кандидатом, только этажом выше — над тем, что центральный +комплекс успел накопить. У мухи так же: нисходящие нейроны получают сходящийся +вход и от зрительных путей, и от центрального комплекса, а не только от сетчатки. + +Стоимость — одно умножение матрицы на трек, и только для тех треков, что уже +прошли дешёвые проверки в `descending.py`. +""" +from __future__ import annotations + +import numpy as np + +from .central_complex import Track, expected_rays +from .mbon_readout import MbonConfig, MbonReadout + +TRACK_FEATURES = ( + "log_d", # дальность сейчас + "evidence", # накопленная улика — то, чем решают сегодня + "hits", # подтверждений + "age", # возраст, кадров + "hit_rate", # подтверждений на кадр жизни + "misses", # промахов подряд + "closed", # log(первая дальность / нынешняя): насколько подъехали + "novelty", # сглаженная новизна из памяти тоннеля + "width", "height", "h", + "speed_lat", # модуль поперечной скорости + "rays_now", # лучей к ожидаемым для этой дальности + "rays_mean", # то же, в среднем за жизнь + "rays_slope", # и наклон по времени + "w_mean", "w_max", # вес наблюдения + "p_mean", "p_max", # покадровый отсчёт считывания + "s_std", # разброс места в тоннеле, м + "u_std", # разброс поперечного положения, м +) + + +def _std(n: int, s: float, sq: float) -> float: + """Разброс по накопленным суммам. Отрицательную дисперсию даёт + округление, а не данные, поэтому она просто отсекается.""" + if n < 2: + return 0.0 + var = sq / n - (s / n) ** 2 + return float(np.sqrt(var)) if var > 0.0 else 0.0 + + +def describe_track(t: Track, s_now: float) -> np.ndarray: + """Вектор трека для считывания. Порядок — как в `TRACK_FEATURES`.""" + d = max(t.distance(s_now), 1.0) + n = max(t.n_obs, 1) + den = t.n_obs * t.i_sq - t.i_sum * t.i_sum + slope = ((t.n_obs * t.ri_sum - t.i_sum * t.r_sum) / den + if t.n_obs > 1 and abs(den) > 1e-9 else 0.0) + return np.array(( + np.log(d), + t.evidence, + float(t.hits), + float(t.age), + t.hits / max(t.age, 1), + float(t.misses), + np.log(max(t.first_d, 1.0) / d), + t.novelty, + t.width, t.height, t.h, + abs(t.speed_lat), + t.last_n_rays / expected_rays(d), + t.r_sum / n, + slope, + t.w_sum / n, t.w_max, + t.p_sum / n, t.p_max, + _std(t.n_obs, t.s_sum, t.s_sq), + _std(t.n_obs, t.u_sum, t.u_sq), + ), np.float32) + + +class TrackReadout(MbonReadout): + """Тот же разрежённый код, другой вход и другой учитель. + + Ёмкость меньше, чем у покадрового считывания: примеров тут на порядок + меньше (треков, а не кандидатов), и восемь тысяч клеток на такой выборке + заучивают её наизусть. Значение выбирается развёрткой в + `tools/train_track.py`, а не на глаз. + """ + + def __init__(self, cfg: MbonConfig | None = None, + n_pn: int = len(TRACK_FEATURES)): + super().__init__(cfg or MbonConfig(n_kc=2_000, seed=20260922), + n_pn=n_pn) + + def annotate_tracks(self, tracks: list[Track], s_now: float) -> None: + """Проставить каждому треку отсчёт в `extra`-поле `p_track`.""" + if not tracks: + return + X = np.stack([describe_track(t, s_now) for t in tracks]) + p = 1.0 / (1.0 + np.exp(-self.logit(X))) + for t, pp in zip(tracks, p): + t.p_track = float(pp) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 6690f83..13665a0 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -450,3 +450,70 @@ def test_parallel_never_starts_more_processes_than_there_are_bags(): assert _P.resolve(8, 3) == 3 # просили больше, чем есть работы assert _P.resolve(1, 5) == 1 # явная последовательная отладка assert 1 <= _P.resolve(0, 5) <= 5 + + +# ------------------------------------------------------------ решение по треку + +def _pair_of_tracks(): + """Два трека с ОДИНАКОВОЙ насыщенной уликой и разным средним весом.""" + from flyguard.central_complex import CentralComplex, Track + + cx = CentralComplex() + cx.s_world = 0.0 + good = Track(id=1, s_world=60.0, u=0.0, h=0.3, width=0.5, height=1.7, + evidence=1.0, hits=9, age=9, novelty=0.6, + n_obs=9, w_sum=9 * 0.95, p_sum=9 * 0.95) + junk = Track(id=2, s_world=70.0, u=0.5, h=0.3, width=0.5, height=1.7, + evidence=1.0, hits=9, age=9, novelty=0.6, + n_obs=9, w_sum=9 * 0.27, p_sum=9 * 0.27) + cx.tracks = [good, junk] + return cx + + +def test_saturated_evidence_stops_separating_and_the_mean_weight_does_not(): + """Улика обрезана единицей — и перестаёт различать. + + На реальном объекте медиана улики 1.000 и у предмета, и у ложных треков + (AUC 0.624), потому что сумма упёрлась в потолок у обоих. Средний вес + наблюдения при этом 0.998 против 0.269. Здесь это проверяется на паре + треков, у которых улика одинакова НАРОЧНО. + """ + from flyguard.descending import DescendingNeurons + + plain = DescendingNeurons().decide(_pair_of_tracks(), speed=10.0) + assert {o.track_id for o in plain.objects} == {1, 2} + + picked = DescendingNeurons(track_blend=0.7, track_score="w_mean").decide( + _pair_of_tracks(), speed=10.0) + assert {o.track_id for o in picked.objects} == {1} + + +def test_track_score_without_a_model_falls_back_to_evidence(): + """`model` без файла модели обязан вести себя как раньше, а не как ноль. + + Отсчёт модели по умолчанию нулевой, и молчаливое его использование + погасило бы вообще все треки. + """ + from flyguard.descending import DescendingNeurons + + dn = DescendingNeurons(track_blend=0.7, track_score="model", + track_readout=None) + out = dn.decide(_pair_of_tracks(), speed=10.0) + assert {o.track_id for o in out.objects} == {1, 2} + + +def test_track_accumulators_do_not_touch_the_evidence(): + """Сводка по треку копится отдельно и в улику не вмешивается.""" + from flyguard.central_complex import CentralComplex + from flyguard.lobula import Candidate + + cx = CentralComplex() + c = Candidate(d=60.0, u=0.0, h=0.5, d_min=59.8, h_min=0.1, width=0.4, + height=1.6, depth=0.3, containment=0.9, n_rays=40, + n_rings=8, n_cols=6, gap=4.0, on=0.2, floor_deficit=0.0, + shadow=0.3, inten=12.0, az_deg=0.0, el_deg=-1.0, + bbox=(0, 0, 8, 6), novelty=0.5) + cx.update([c], ds=0.0, dt=0.1) + t = cx.tracks[0] + assert t.n_obs == 1 and t.w_mean > 0.0 + assert abs(t.evidence - cx.gain * t.w_mean) < 1e-6 diff --git a/tools/_metrics.py b/tools/_metrics.py new file mode 100644 index 0000000..546438f --- /dev/null +++ b/tools/_metrics.py @@ -0,0 +1,44 @@ +"""Метрики качества ранжирования — одна реализация на все инструменты. + +Вынесено сюда не ради красоты: ранговый AUC уже один раз был написан неверно +(совпадающие значения ранжировались по порядку в массиве), дал ложную тревогу +об утечке признака и стоил разбора. Держать такую функцию в двух копиях — +значит однажды починить одну из них. +""" +from __future__ import annotations + +import numpy as np + + +def auc(score: np.ndarray, y: np.ndarray) -> float: + """Ранговый AUC со СРЕДНИМИ рангами на совпадениях. + + Без усреднения признак, у которого все значения равны (за 160 м таков, + например, контраст к фону — там его структурно нет), получает AUC 0 или 1 + просто по порядку в массиве. + """ + score = np.asarray(score, np.float64) + y = np.asarray(y) + n_pos, n_neg = int((y == 1).sum()), int((y == 0).sum()) + if n_pos == 0 or n_neg == 0: + return float("nan") + order = np.argsort(score, kind="mergesort") + s = score[order] + start = np.flatnonzero(np.r_[True, s[1:] != s[:-1]]) + end = np.r_[start[1:], s.size] + avg = (start + end - 1) / 2.0 + 1.0 + ranks = np.empty(s.size, np.float64) + ranks[order] = np.repeat(avg, end - start) + return float((ranks[y == 1].sum() - n_pos * (n_pos + 1) / 2) + / (n_pos * n_neg)) + + +def fpr_at_tpr(score: np.ndarray, y: np.ndarray, tpr: float = 0.95) -> float: + """Доля обстановки, проходящей порог, при котором ловится `tpr` предметов.""" + score = np.asarray(score, np.float64) + y = np.asarray(y) + pos, neg = score[y == 1], score[y == 0] + if pos.size == 0 or neg.size == 0: + return float("nan") + thr = np.quantile(pos, 1.0 - tpr) + return float((neg >= thr).mean()) diff --git a/tools/check_obstacle.py b/tools/check_obstacle.py index 6f183a3..db0bc73 100644 --- a/tools/check_obstacle.py +++ b/tools/check_obstacle.py @@ -12,6 +12,7 @@ import argparse import numpy as np import _bootstrap as B # noqa: F401 +from _metrics import auc from flyguard.bag import Bag from flyguard.mushroom_body import MushroomBody from flyguard.pipeline import FlyGuard, Params @@ -28,6 +29,10 @@ def main() -> None: "не видевшую этот бэг: artifacts/mbon_folds/mbon_roundT_doubleT.npz") ap.add_argument("--limit", type=int, default=200) ap.add_argument("--verbose", action="store_true") + ap.add_argument("--tracks", action="store_true", + help="сравнить признаки трека у настоящего объекта и у " + "ложных треков: единственная проверка, где метка " + "не синтетическая") args = ap.parse_args() memory = MushroomBody.load(args.memory) if args.memory else None @@ -41,6 +46,7 @@ def main() -> None: n = cand_hit = track_hit = other = 0 novelties, evid = [], [] + trk_X, trk_y = [], [] for k, (_, pc) in enumerate(bag.frames(stop=args.limit)): res = fg.process(pc) if res is None: @@ -56,6 +62,15 @@ def main() -> None: track_hit += 1 evid.append(max(o.confidence for o in mine)) other += len(objs) - len(mine) + if args.tracks: + from flyguard.track_readout import describe_track + cx = fg.cx + for tr in cx.tracks: + dd = tr.distance(cx.s_world) + if not (0.0 < dd <= 220.0) or tr.hits < 2: + continue + trk_X.append(describe_track(tr, cx.s_world)) + trk_y.append(int(TRUE_D[0] < dd < TRUE_D[1])) if args.verbose and k % 20 == 0: print(f" кадр {k:4d}: канд. в зоне {len(cs)}, " f"треков всего {len(objs)}, в зоне {len(mine)}, " @@ -68,6 +83,19 @@ def main() -> None: + (f" уверенность медиана {np.median(evid):.3f}" if evid else "")) print(f"посторонних подтверждённых объектов: {other} ({other/max(n,1):.2f} на кадр)") + if args.tracks and trk_y: + from flyguard.track_readout import TRACK_FEATURES + X, y = np.stack(trk_X), np.array(trk_y) + idx = {f: k for k, f in enumerate(TRACK_FEATURES)} + print() + print(f"наблюдений трека: {len(y)}, настоящего объекта {int(y.sum())}") + print(f"{'признак':<12}{'объект':>9}{'ложные':>9}{'AUC':>8}") + for f in ("evidence", "w_mean", "p_mean", "p_max", "rays_mean", + "s_std", "u_std", "age"): + v = X[:, idx[f]] + print(f"{f:<12}{np.median(v[y == 1]):9.3f}" + f"{np.median(v[y == 0]):9.3f}{auc(v, y):8.3f}") + if __name__ == "__main__": main() diff --git a/tools/evaluate.py b/tools/evaluate.py index f8ef0b8..c979dc9 100644 --- a/tools/evaluate.py +++ b/tools/evaluate.py @@ -31,13 +31,18 @@ TRUE_D = (50.0, 62.0) def _work(task): """Одна задача — один бэг. Память и считывание грузятся по пути уже здесь.""" - path, params, limit, mem_path, rd_path = task + path, params, limit, mem_path, rd_path, trk_path = task memory = MushroomBody.load(mem_path) if mem_path else None readout = None if rd_path: from flyguard.mbon_readout import MbonReadout readout = MbonReadout.load(rd_path) - return run_bag(path, memory, limit, params, readout=readout) + track_rd = None + if trk_path: + from flyguard.track_readout import TrackReadout + track_rd = TrackReadout.load(trk_path) + return run_bag(path, memory, limit, params, readout=readout, + track_readout=track_rd) def train_excluding(per_bag: dict[str, np.ndarray], extra: np.ndarray | None, @@ -53,8 +58,9 @@ def train_excluding(per_bag: dict[str, np.ndarray], extra: np.ndarray | None, def run_bag(bag_path, memory, limit: int, params: Params | None = None, - readout=None) -> dict: - fg = FlyGuard(params or Params(), memory=memory, readout=readout) + readout=None, track_readout=None) -> dict: + fg = FlyGuard(params or Params(), memory=memory, readout=readout, + track_readout=track_readout) bag = Bag(bag_path) n = alarms = obj_hits = fp_objects = 0 path_m = 0.0 @@ -148,6 +154,17 @@ def main() -> None: help="к какой дальности вклад знакомости обнуляется") ap.add_argument("--mbon-prior-from", type=float, default=None, help="с какой дальности поправлять оценку модели на распространённость предметов; 0 — не поправлять") + ap.add_argument("--track", default="", + help="считывание по трекам одной моделью; она видела эти " + "записи — годится только для отладки") + ap.add_argument("--track-dir", default="", + help="каталог с моделями по складкам (track_<бэг>.npz)") + ap.add_argument("--track-blend", type=float, default=None, + help="доля считывания по треку в решении; 0 — только улика") + ap.add_argument("--track-score", default=None, + choices=("w_mean", "w_ema", "p_mean", "model"), + help="чем мерить трек: средний вес наблюдения, средний " + "отсчёт считывания или обученная модель") ap.add_argument("--no-memory", action="store_true", help="совсем без памяти тоннеля — так выглядит первый проезд по новой линии") ap.add_argument("--out", default=str(B.ARTIFACTS / "generalisation.json")) @@ -210,6 +227,10 @@ def main() -> None: over["nov_fade_to"] = args.nov_fade_to if args.mbon_prior_from is not None: over["mbon_prior_from"] = args.mbon_prior_from + if args.track_blend is not None: + over["track_blend"] = args.track_blend + if args.track_score is not None: + over["track_score"] = args.track_score params = Params(**over) fold_paths: dict[str, str] = {} if args.mbon_dir: @@ -220,6 +241,15 @@ def main() -> None: f"({len(fold_paths)} моделей)") elif args.mbon: print(f"считывание MBON: {args.mbon}") + track_paths: dict[str, str] = {} + if args.track_dir: + from pathlib import Path as _P2 + for f in _P2(args.track_dir).glob("track_*.npz"): + track_paths[f.stem[len("track_"):]] = str(f) + print(f"считывание по трекам по складкам: {args.track_dir} " + f"({len(track_paths)} моделей)") + elif args.track: + print(f"считывание по трекам: {args.track}") # Память обучается ЗДЕСЬ, а не в воркере: обучение идёт на видеокарте, и # делить одну карту на пять процессов незачем. Воркеру достаётся готовый @@ -237,7 +267,8 @@ def main() -> None: rd = fold_paths.get(p.name, args.mbon) if fold_paths else args.mbon if fold_paths and p.name not in fold_paths and p.name != OBSTACLE_BAG: print(f" внимание: для {p.name} нет своей складки") - tasks.append((p, params, args.limit, mem_path, rd)) + trk = track_paths.get(p.name, args.track) if track_paths else args.track + tasks.append((p, params, args.limit, mem_path, rd, trk)) sizes.append(n_seen) # Задержка кадра под пятью процессами вырастает с 32 до 56 мс — это diff --git a/tools/make_benchmark.py b/tools/make_benchmark.py index 08d2286..5bb341c 100644 --- a/tools/make_benchmark.py +++ b/tools/make_benchmark.py @@ -26,6 +26,7 @@ from flyguard.bag import Bag, find_bags from flyguard.mushroom_body import MushroomBody from flyguard.pipeline import FlyGuard, Params from flyguard.synth import IntensityEnv, Placement, catalogue, inject +from flyguard.track_readout import describe_track HOLDOUT = "doubleT_obstacle" # там уже есть настоящий объект @@ -36,14 +37,20 @@ def _work(task): Обученное грузится путями и уже внутри процесса: передавать модели через межпроцессную границу незачем, а свою складку каждый воркер берёт сам. """ - path, params, limit, d_start, laterals, seed, mem_path, rd_path = task + (path, params, limit, d_start, laterals, seed, mem_path, rd_path, + want_tracks, trk_path) = task memory = MushroomBody.load(mem_path) if mem_path else None readout = None if rd_path: from flyguard.mbon_readout import MbonReadout readout = MbonReadout.load(rd_path) + track_rd = None + if trk_path: + from flyguard.track_readout import TrackReadout + track_rd = TrackReadout.load(trk_path) return run_bag(path, params, memory, limit, d_start, laterals, seed, - readout=readout) + readout=readout, want_tracks=want_tracks, + track_readout=track_rd) def ego_track(bag: Bag, params: Params, limit: int | None): @@ -61,7 +68,8 @@ def ego_track(bag: Bag, params: Params, limit: int | None): def run_bag(bag_path, params: Params, memory, limit: int, d_start: float, - laterals: tuple[float, ...], seed: int, readout=None) -> list[dict]: + laterals: tuple[float, ...], seed: int, readout=None, + want_tracks: bool = False, track_readout=None): bag = Bag(bag_path) s_track, _ = ego_track(bag, params, limit) have = [x for x in s_track if x is not None] @@ -71,9 +79,16 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float, cat = catalogue() scen = [(name, lat) for name in cat for lat in laterals] - pipes = [FlyGuard(params, memory=memory, readout=readout) for _ in scen] + pipes = [FlyGuard(params, memory=memory, readout=readout, + track_readout=track_readout) for _ in scen] rng = np.random.default_rng(seed) records = [[] for _ in scen] + # Выборка для считывания по трекам: описание каждого живого трека на + # каждом кадре и метка «это вставленный предмет». Собирается здесь, а не + # отдельным инструментом, ровно затем, чтобы распределение совпадало с + # рабочим: та же память тоннеля, то же покадровое считывание, те же + # пороги. Обученное на другой обстановке считывание нечего и мерить. + trk_rows: list = [] # решётка и поза нужны для вставки — берутся из отдельного «чистого» конвейера guide = FlyGuard(params, memory=None) @@ -111,6 +126,13 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float, for tr in cx.tracks) records[i].append((d_true, int(hit), fp, lab["hit_rays"], int(cand), int(trk))) + if want_tracks: + for tr in cx.tracks: + dd = tr.distance(cx.s_world) + if not (0.0 < dd <= params.d_max): + continue + trk_rows.append((describe_track(tr, cx.s_world), + int(abs(dd - d_true) < tol), dd)) out = [] for (name, lat), rec in zip(scen, records): @@ -122,7 +144,12 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float, fp=a[:, 2].tolist(), rays=a[:, 3].tolist(), cand=a[:, 4].tolist(), trk=a[:, 5].tolist(), travel=float(travel))) - return out + tracks = None + if want_tracks and trk_rows: + tracks = (np.stack([r[0] for r in trk_rows]).astype(np.float32), + np.array([r[1] for r in trk_rows], np.int8), + np.array([r[2] for r in trk_rows], np.float32)) + return out, tracks def main() -> None: @@ -138,6 +165,19 @@ def main() -> None: "дальность завышена: считывание обучалось ровно на " "таких же вставках в этот же тоннель") ap.add_argument("--out", default=str(B.ARTIFACTS / "benchmark.json")) + ap.add_argument("--tracks-out", default="", + help="куда сложить выборку по трекам (npz); пусто — не собирать") + ap.add_argument("--track", default="", + help="считывание по трекам одной моделью; она видела эти " + "записи — годится только для отладки") + ap.add_argument("--track-dir", default="", + help="каталог с моделями по складкам (track_<бэг>.npz)") + ap.add_argument("--track-blend", type=float, default=None, + help="доля считывания по треку в решении; 0 — только улика") + ap.add_argument("--track-score", default=None, + choices=("w_mean", "w_ema", "p_mean", "model"), + help="чем мерить трек: средний вес наблюдения, средний " + "отсчёт считывания или обученная модель") ap.add_argument("--limit", type=int, default=250) ap.add_argument("--d-start", type=float, default=200.0) ap.add_argument("--laterals", default="0.0,0.9") @@ -184,6 +224,15 @@ def main() -> None: f"({len(fold_paths)} моделей)") elif args.mbon: print(f"считывание MBON: {args.mbon}") + track_paths: dict[str, str] = {} + if args.track_dir: + from pathlib import Path as _P2 + for f in _P2(args.track_dir).glob("track_*.npz"): + track_paths[f.stem[len("track_"):]] = str(f) + print(f"считывание по трекам по складкам: {args.track_dir} " + f"({len(track_paths)} моделей)") + elif args.track: + print(f"считывание по трекам: {args.track}") over = {} if args.mbon_blend is not None: over["mbon_blend"] = args.mbon_blend @@ -218,6 +267,10 @@ def main() -> None: over["nov_fade_to"] = args.nov_fade_to if args.mbon_prior_from is not None: over["mbon_prior_from"] = args.mbon_prior_from + if args.track_blend is not None: + over["track_blend"] = args.track_blend + if args.track_score is not None: + over["track_score"] = args.track_score params = Params(**over) laterals = tuple(float(x) for x in args.laterals.split(",")) @@ -231,19 +284,42 @@ def main() -> None: if not rd: print(f" внимание: для {p.name} нет своей складки — пропуск") continue + trk = track_paths.get(p.name, args.track) if track_paths else args.track tasks.append((p, params, args.limit, args.d_start, laterals, args.seed, - args.memory or "", rd)) + args.memory or "", rd, bool(args.tracks_out), trk)) # Печатается по готовности, собирается по номеру задачи: порядок сценариев # в файле не должен зависеть от того, какой бэг досчитался первым. slots: list = [None] * len(tasks) - for i, task, rec, secs in P.run(_work, tasks, args.jobs): + trk_slots: list = [None] * len(tasks) + for i, task, got, secs in P.run(_work, tasks, args.jobs): + rec, tracks = got slots[i] = rec + trk_slots[i] = (task[0].name, tracks) n = sum(len(r["d"]) for r in rec) - print(f" {task[0].name:42s} сценариев {len(rec):3d}, наблюдений {n:6d}, " - f"{secs:6.1f} с", flush=True) + extra = f", треков {tracks[0].shape[0]:7d}" if tracks is not None else "" + print(f" {task[0].name:42s} сценариев {len(rec):3d}, наблюдений {n:6d}" + f"{extra}, {secs:6.1f} с", flush=True) all_rec = [r for rec in slots if rec for r in rec] + if args.tracks_out: + from flyguard.track_readout import TRACK_FEATURES + out_arr, names = {}, [] + for got in trk_slots: + if got is None or got[1] is None: + continue + name, (Xb, yb, db) = got + names.append(name) + out_arr[f"X_{name}"] = Xb + out_arr[f"y_{name}"] = yb + out_arr[f"d_{name}"] = db + np.savez_compressed(args.tracks_out, names=np.array(names), + features=np.array(TRACK_FEATURES), **out_arr) + tot = sum(v.shape[0] for k, v in out_arr.items() if k.startswith("X_")) + pos = sum(int(v.sum()) for k, v in out_arr.items() if k.startswith("y_")) + print(f"выборка по трекам: {tot} наблюдений, предметных {pos} " + f"({pos / max(tot, 1):.1%}) -> {args.tracks_out}") + with open(args.out, "w", encoding="utf-8") as f: json.dump(all_rec, f, ensure_ascii=False) print(f"сохранено: {args.out} ({len(all_rec)} сценариев)") diff --git a/tools/train_mbon.py b/tools/train_mbon.py index 2f1d9a6..b5b3601 100644 --- a/tools/train_mbon.py +++ b/tools/train_mbon.py @@ -29,41 +29,12 @@ import time import numpy as np import _bootstrap as B # noqa: F401 +from _metrics import auc, fpr_at_tpr from flyguard.mbon_readout import MbonConfig, MbonReadout BANDS = ((0, 30), (30, 55), (55, 80), (80, 110), (110, 160), (160, 230)) -def auc(score: np.ndarray, y: np.ndarray) -> float: - """Ранговый AUC со СРЕДНИМИ рангами на совпадениях. - - Без усреднения признак, у которого все значения равны (за 160 м таков, - например, контраст к фону — там его структурно нет), получает AUC 0 или 1 - просто по порядку в массиве. Один такой ложный сигнал уже стоил разбора. - """ - n_pos, n_neg = int((y == 1).sum()), int((y == 0).sum()) - if n_pos == 0 or n_neg == 0: - return float("nan") - order = np.argsort(score, kind="mergesort") - s = score[order] - start = np.flatnonzero(np.r_[True, s[1:] != s[:-1]]) - end = np.r_[start[1:], s.size] - avg = (start + end - 1) / 2.0 + 1.0 - ranks = np.empty(s.size, np.float64) - ranks[order] = np.repeat(avg, end - start) - return float((ranks[y == 1].sum() - n_pos * (n_pos + 1) / 2) - / (n_pos * n_neg)) - - -def fpr_at_tpr(score: np.ndarray, y: np.ndarray, tpr: float = 0.95) -> float: - """Доля обстановки, проходящей порог, при котором ловится `tpr` предметов.""" - pos, neg = score[y == 1], score[y == 0] - if pos.size == 0 or neg.size == 0: - return float("nan") - thr = np.quantile(pos, 1.0 - tpr) - return float((neg >= thr).mean()) - - def loo(names, X, Y, cfg, n_pn, args, keep_models=False): """Обучение на всех бэгах кроме проверяемого. Возвращает список результатов.""" out = [] diff --git a/tools/train_track.py b/tools/train_track.py new file mode 100644 index 0000000..42cf4d2 --- /dev/null +++ b/tools/train_track.py @@ -0,0 +1,200 @@ +"""Обучение считывания по ТРЕКАМ и честная проверка по бэгам. + +Покадровое считывание (`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("--l2", type=float, default=1e-4) + ap.add_argument("--device", default="cpu") + 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}") + + # ------------------------------- главное: обгоняет ли модель саму улику + print("\nмодель против улики, по полосам дальности:") + band_table(d_loo, y_loo, {"улика: AUC": ev_loo, "модель: AUC": s_loo}) + + # --------------------------------------------------------------- хранение + 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()