forked from Dan4ick/Lidar_Muxa
считывание по трекам: накопители, дескриптор, обучение, замеры
This commit is contained in:
parent
2857a216d4
commit
d39038184b
13 changed files with 729 additions and 59 deletions
|
|
@ -87,7 +87,7 @@ flyguard/ ядро: стадии обработки, память, сч
|
|||
pipeline.py сборка
|
||||
synth.py вставка предметов трассировкой лучей
|
||||
tools/ обучение, оценка, разбор
|
||||
tests/ 29 тестов, запускаются без данных и без ROS
|
||||
tests/ 32 теста, запускаются без данных и без ROS
|
||||
docs/ методика и результаты
|
||||
artifacts/ обученные модели
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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 м
|
||||
# кольцо окружения ламины упирается в стену тоннеля, стоящую на той же
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
121
flyguard/track_readout.py
Normal 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)
|
||||
|
|
@ -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
44
tools/_metrics.py
Normal 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())
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 мс — это
|
||||
|
|
|
|||
|
|
@ -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)} сценариев)")
|
||||
|
|
|
|||
|
|
@ -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
200
tools/train_track.py
Normal 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()
|
||||
Loading…
Reference in a new issue