121 lines
7.2 KiB
Python
121 lines
7.2 KiB
Python
"""Считывание по ТРЕКУ — тот же контур 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)
|