"""Считывание по ТРЕКУ — тот же контур MBON, но на входе накопленная история. Считывание в `mbon_readout.py` смотрит на один кадр: контур кандидата, его тень, яркость, опору накопителя. Этого хватает вблизи и не хватает вдали — за 120 м предмет даёт пять-шесть лучей, и по одному кадру он неотличим от куска стены, попавшего в габарит. Но у трека есть то, чего у кандидата нет в принципе: **история**. Поезд проехал двадцать метров, а предмет, если он настоящий, остался на том же месте в тоннеле. Воронка потерь (EXPERIMENTS п. 12) показывает, что именно здесь и теряется дальнее обнаружение: на 120…160 м трек заводится в 62 % наблюдений, а до решения доходит 38 % — улика есть, порога не набирает. Поэтому вход здесь другой: сводки за всю жизнь трека, а не последнее значение, — средний и максимальный вес наблюдения, средний и максимальный покадровый отсчёт MBON, наклон числа лучей по времени, разбросы положения. **ЧЕМ ЭТО КОНЧИЛОСЬ.** Обученная модель не обгоняет ни один из признаков, которые ей же и скормили: leave-one-bag-out AUC 0.873 против 0.880 у одного среднего веса наблюдения и 0.895 у среднего отсчёта считывания. Причина — не в схеме: наблюдений 9 299, но это сотни кадров подряд по одному и тому же треку, и независимых примеров тут сотни, а не тысячи. Поэтому в решении участвует **не модель, а прямо среднее** (`descending.track_score`, по умолчанию `w_mean`), а всё в этом файле — инструмент замера. Разбор — EXPERIMENTS п. 15. Отдельно: `s_std` и `u_std` в решении использовать нельзя. На полигоне разброс привязки к месту выглядит лучшим признаком дальней полосы (AUC 0.869 за 160 м), а на настоящем объекте он перевёрнут (0.223): вставка ставится на дальность, посчитанную из той же оценки собственного движения, что двигает мировую координату, и разброса у неё нет по построению. Признаки оставлены в дескрипторе нарочно — чтобы проверку можно было повторить (п. 15.4). """ 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", # покадровый отсчёт считывания # Оба разброса — ТОЛЬКО для замеров: на синтетике сильны, на реальном # объекте перевёрнуты. См. шапку файла и EXPERIMENTS п. 15.4. "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)