считывание по трекам: накопители, дескриптор, обучение, замеры

This commit is contained in:
Данил Омелечко 2026-09-22 17:00:44 +03:00
parent 2857a216d4
commit d39038184b
13 changed files with 729 additions and 59 deletions

View file

@ -87,7 +87,7 @@ flyguard/ ядро: стадии обработки, память, сч
pipeline.py сборка
synth.py вставка предметов трассировкой лучей
tools/ обучение, оценка, разбор
tests/ 29 тестов, запускаются без данных и без ROS
tests/ 32 теста, запускаются без данных и без ROS
docs/ методика и результаты
artifacts/ обученные модели
```

View file

@ -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 м
# кольцо окружения ламины упирается в стену тоннеля, стоящую на той же

View file

@ -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)

View file

@ -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"])

View file

@ -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] = []

121
flyguard/track_readout.py Normal file
View file

@ -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)

View file

@ -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

44
tools/_metrics.py Normal file
View file

@ -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())

View file

@ -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()

View file

@ -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 мс — это

View file

@ -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)} сценариев)")

View file

@ -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 = []

200
tools/train_track.py Normal file
View file

@ -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()