diff --git a/README.md b/README.md index 64c7f42..d19b7c6 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ flyguard/ ядро: стадии обработки, память, сч pipeline.py сборка synth.py вставка предметов трассировкой лучей tools/ обучение, оценка, разбор -tests/ 32 теста, запускаются без данных и без ROS +tests/ 34 теста, запускаются без данных и без ROS docs/ методика и результаты artifacts/ обученные модели ``` diff --git a/docs/EXPERIMENTS.md b/docs/EXPERIMENTS.md index c6317a7..4ad607b 100644 --- a/docs/EXPERIMENTS.md +++ b/docs/EXPERIMENTS.md @@ -1747,3 +1747,197 @@ P@150 0.32), а на всём, что меньше, — заметно хуже: И отдельно: о ложных тревогах в той работе не сказано **ничего** — ни одного упоминания false positive или false alarm. У нас они измерены на двух сценах и выражены в треках на километр пути. + +--- + +## 15. Решение по треку, а не по кадру + +Воронка потерь (п. 12) разделила потери на две болезни, и вторая до сих пор не +лечилась: на 90…160 м трек **заводится, а решения не даёт**. В базовой настройке +это 0.20 и 0.24 от видимых наблюдений — больше, чем теряется на всех остальных +ступенях вместе. + +Порогом это не чинится, и это уже измерено (п. 12.4): 0.5 → 0.3 не меняет +ничего, потому что улика у далёкого трека не «чуть ниже порога», а около нуля. +Значит, дело не в пороге, а в том, **чем** мы меряем трек. + +### 15.1. Улика суммируется и упирается в потолок + +Улика — это `min(1, Σ gain·w)` с утечкой за промахи. Два её свойства работают +против нас: + +* она **суммируется**, то есть отвечает на вопрос «давно ли я на это смотрю», а + не «на что именно я смотрю»; +* она **обрезана единицей**, и у всего, что наблюдается дольше пары секунд, она + просто равна 1. + +Второе проверяется не на синтетике, а на единственной записи с настоящим +объектом — `doubleT_obstacle`, 753 наблюдения трека, из них 188 приходится на +реальный объект 0.67 × 1.35 м на 55 м +(`tools/check_obstacle.py --tracks --memory ... --mbon artifacts/mbon_folds/mbon_roundT_doubleT.npz`): + +| признак | объект | ложные треки | AUC | +|---|---|---|---| +| **улика** | **1.000** | **1.000** | **0.624** | +| средний вес наблюдения `w_mean` | 0.998 | 0.269 | 1.000 | +| средний отсчёт считывания `p_mean` | 0.998 | 0.582 | 1.000 | +| максимум отсчёта `p_max` | 1.000 | 0.782 | 1.000 | +| лучей к ожидаемым, в среднем | 6.483 | 1.384 | 1.000 | +| возраст трека, кадров | 94.5 | 52.0 | 0.679 | + +Улика различает предмет и ложные треки с AUC 0.624 — чуть лучше монетки, — и +не потому, что сигнала нет, а потому что и у того, и у другого она упёрлась в +потолок. **Тот самый вес, из которого улика складывается, различает их +идеально.** Сумма потеряла то, что было в слагаемых. + +Отсюда и решение: сравнивать с порогом не улику, а её геометрическую смесь со +средним весом наблюдения, `w_mean^b · улика^(1−b)`. При `b = 0` всё ровно как +раньше, так что размен меряется, а не объявляется. + +### 15.2. Выборка по трекам + +Полигон научили попутно выкладывать описание каждого живого трека на каждом +кадре с меткой «это вставленный предмет» (`make_benchmark.py --tracks-out`). +Собирается это внутри полигона нарочно: та же память тоннеля, то же покадровое +считывание, те же пороги — обученное на другой обстановке считывание нечего и +мерить. Вышло 10 819 наблюдений трека, после отбора «не меньше двух +подтверждений» (столько же требует `descending.py`) — 9 299, предметных 42.1 %. + +Что даёт каждый признак сам по себе, по полосам дальности: + +| полоса | набл. | улика | `p_mean` | `p_max` | `w_mean` | `−s_std` | +|---|---|---|---|---|---|---| +| 0–30 м | 2208 | 0.962 | 0.954 | 0.956 | 0.946 | 0.876 | +| 30–55 м | 1216 | 0.892 | **0.989** | 0.924 | 0.965 | 0.769 | +| 55–80 м | 1474 | 0.983 | 0.949 | **0.994** | 0.914 | 0.562 | +| 80–110 м | 1777 | 0.688 | 0.801 | **0.845** | 0.832 | 0.459 | +| 110–160 м | 1977 | 0.724 | 0.849 | 0.815 | **0.900** | 0.694 | +| 160–230 м | 647 | 0.306 | 0.218 | 0.218 | 0.218 | **0.869** | +| всё | 9299 | 0.865 | 0.895 | 0.890 | 0.880 | 0.670 | + +Главное здесь — полосы 80…160 м, ровно те, где воронка и теряет: улика 0.688 и +0.724, а история наблюдений 0.832 и 0.900. + +### 15.3. Почему за 160 м всё переворачивается + +В последней полосе AUC 0.218…0.306 — то есть признаки указывают **в обратную +сторону**. Разбор показывает, что дело не в признаках: + +| | наблюдений | возраст | подтверждений | улика | `p_mean` | +|---|---|---|---|---|---| +| настоящий предмет | 114 | 2.0 | 2.0 | 0.094 | 0.104 | +| ложные треки | 533 | 5.0 | 4.0 | 0.234 | 0.351 | + +На самом краю дальности предмет — **всегда самое молодое, что есть в сцене**: +он только что вошёл в зону видимости, а всё, что уже ведётся, ведётся дольше. +Любая величина, растущая со временем наблюдения, там обязана ранжировать +наоборот. Это не лечится ни моделью, ни порогом; это граница метода, и её надо +называть, а не прятать. + +### 15.4. Привязка к месту: признак, который врёт на синтетике + +Самым заманчивым признаком казался `s_std` — разброс места в тоннеле, которое +подразумевает наблюдение (пройденный путь плюс измеренная дальность). У +неподвижного предмета оно обязано быть постоянным. На полигоне признак и +выглядел блестяще: за 160 м AUC 0.869 при том, что все остальные там +перевёрнуты, разброс 0.01 м у предмета против 1.08 м у ложных треков. + +На настоящем объекте он **перевёрнут**: 0.365 м у предмета против 0.013 м у +ложных треков, AUC 0.223. + +Причина в устройстве вставки. Синтетический предмет ставится на дальность +`d_start − s(t)`, где `s(t)` — тот же самый проинтегрированный путь, который +двигает мировую координату треков. Ошибка оценки собственного движения входит в +обе величины и сокращается: у вставленного предмета привязка не дрожит **по +построению**. У настоящего объекта она дрожит на реальную ошибку одометрии, а +у ложных треков, прилипших к геометрии тоннеля, — не дрожит вовсе. + +Это тот же класс ошибки, что артефакт яркости (п. 11.1), и найден он тем же +способом: цифру с полигона проверили на единственной реальной метке, какая +есть. Оба разброса (`s_std`, `u_std`) выброшены из обучения; в дескрипторе они +оставлены нарочно, чтобы проверку можно было повторить. + +### 15.5. Обученное считывание по треку — отрицательный результат + +Сделали то же, что с покадровым считыванием: те же клетки Кеньона, то же +торможение APL, один выход, вход — 21 признак трека +(`flyguard/track_readout.py`, `tools/train_track.py`). Проверка — +leave-one-bag-out. + +| ёмкость | AUC | +|---|---| +| 500 клеток | 0.8713 | +| 1000 | 0.8641 | +| 2000 | 0.8651 | +| 4000 | 0.8730 | +| 4000, без обоих разбросов | 0.8801 | + +Для сравнения: улика сама по себе даёт 0.865, `w_mean` — 0.880, `p_mean` — +0.895, произведение `p_mean · улика` — 0.904. + +**Модель не обгоняет ни один из признаков, которые ей же и скормили.** По +полосам она выигрывает у улики на 30…55 и 80…110 м и проигрывает на 55…80 и +за 160 м. Причина понятна и её стоит назвать: наблюдений 9 299, но они не +независимы — это сотни кадров подряд по одному и тому же треку. Независимых +треков тут сотни, а не тысячи, и разрежённому коду на такой выборке учиться +нечему. + +Вывод: считывание по трекам остаётся в репозитории как инструмент замера, в +решении **не участвует** (`track_score` по умолчанию `w_mean`, модель +включается только явным `--track-score model`). Цена вопроса — один вечер и +28 секунд обучения; сэкономлено то, что модель не поехала в продукт. + +### 15.6. Размен: что стоит смешивание + +Полигон, `человек_стоя`, память тоннеля на месте, считывание по складкам. +«Посторонних» — тревоги, не совпавшие с вставленным предметом, на 14 004 +наблюдения. + +| настройка | P@50 | P@100 | P@150 | посторонних | +|---|---|---|---|---| +| улика одна (`track_blend = 0`) | 0.70 | 0.53 | 0.33 | 999 | +| смешивание 0.3 | 0.71 | 0.43 | 0.26 | **354** | +| смешивание 0.5 | 0.63 | 0.17 | 0.10 | 305 | +| смешивание 0.7 | 0.63 | 0.17 | 0.10 | 296 | + +Колено видно сразу: за 0.3 ложные почти не падают (354 → 305 → 296), а +обнаружение рушится. Причина арифметическая, и её стоит назвать: обе величины +меньше единицы, поэтому их произведение **всегда ниже улики**, и при +неподвижном пороге смешивание работает как его повышение. Сравнивать настройки +имеет смысл только на выровненной рабочей точке — то есть опустив порог на +столько, на сколько смешивание опустило отсчёт. + +Полезная доля смешивания, таким образом, мала, и весь вопрос в том, на что +потратить освободившиеся три четверти ложных тревог. + +### 15.7. Жёсткий порог вместо смешивания — отрицательный результат + +Смешивание `w_mean^b · улика^(1−b)` опускает отсчёт **и предмету тоже**, потому +что обе величины меньше единицы. Напрашивается вместо него порог: трек с +низким средним весом просто не рассматривать, а прошедшему не отнимать ничего. +Тем более что на реальном объекте разделение выглядит идеальным — 0.998 против +0.269. + +Замер (полигон, `человек_стоя`): + +| настройка | P@50 | P@100 | P@150 | посторонних | +|---|---|---|---|---| +| база | 0.70 | 0.53 | 0.33 | 999 | +| порог 0.40 | 0.60 | **0.03** | 0.10 | 158 | +| порог 0.55 | 0.37 | **0.00** | 0.01 | 85 | + +Ложные тревоги падают в шесть и в двенадцать раз, а обнаружение исчезает. +Воронка показывает где: в полосе 90…120 м до решения доходит **0.02** от +видимых наблюдений против 0.49 в базе. + +Причина — та же, что у всех остальных порогов в этой работе: **неподвижный +порог слеп к дальности**. Средний вес наблюдения сам падает с расстоянием, и +сильно: в него входит контраст к фону, который за 140 м структурно равен нулю +(п. 7.3), и вероятность считывания, которая падает вместе с числом лучей. +Порог 0.55 отсекает не «плохие треки», а «всё, что дальше семидесяти метров». + +Чтобы порог тут работал, его пришлось бы нормировать на ожидаемый для этой +дальности вес — то есть сделать ровно то, что уже сделано для числа лучей +(п. 12.5), для новизны (п. 12.3) и для утечки улики. Это возможно, но +смешивание даёт тот же эффект мягко и без ещё одной калибровочной кривой, +поэтому порог оставлен выключенным (`track_gate = 0`). diff --git a/flyguard/central_complex.py b/flyguard/central_complex.py index 1b30838..3c00698 100644 --- a/flyguard/central_complex.py +++ b/flyguard/central_complex.py @@ -61,6 +61,7 @@ class Track: w_max: float = 0.0 p_sum: float = 0.0 # отсчёт считывания по кандидату, если оно есть p_max: float = 0.0 + p_n: int = 0 # сколько раз он вообще был w_ema: float = 0.0 # он же, но со скользящим забыванием r_sum: float = 0.0 # лучи, нормированные на ожидаемые для дальности r_last: float = 0.0 @@ -86,8 +87,13 @@ class Track: @property def p_mean(self) -> float: - """Средний покадровый отсчёт считывания MBON за жизнь трека.""" - return self.p_sum / max(self.n_obs, 1) + """Средний покадровый отсчёт считывания MBON за жизнь трека. + + Без переданной конвейеру модели отсчёта нет вовсе, и возвращать тут + ноль значило бы тихо погасить все треки разом. Поэтому в таком случае + отдаётся средний вес наблюдения — он считается всегда. + """ + return self.p_sum / self.p_n if self.p_n else self.w_mean def distance(self, s_now: float) -> float: return self.s_world - s_now @@ -95,11 +101,15 @@ class Track: def observe(self, c: Candidate, s_obs: float, w: float) -> None: """Записать наблюдение в сводку. - `s_obs` — место в тоннеле, которое подразумевает это наблюдение: - пройденный путь плюс измеренная дальность. У НАСТОЯЩЕГО неподвижного - предмета оно не должно меняться от кадра к кадру, как бы поезд ни - ехал, и разброс этой величины — самая прямая проверка гипотезы, - какая у трека вообще есть. В улику она сейчас не входит никак. + Полезное здесь — `w_sum` и `p_sum`: улика суммируется и упирается в + потолок, а среднее того же веса различает предмет и обстановку и после + насыщения (EXPERIMENTS п. 15.1). + + `s_obs` — место в тоннеле, которое подразумевает наблюдение: путь + плюс дальность. Копится ТОЛЬКО ради замеров: на полигоне его разброс + выглядит сильным признаком, а на настоящем объекте он перевёрнут, + потому что вставка стоит на дальности, посчитанной из той же оценки + движения (п. 15.4). В решении не участвует и участвовать не должен. """ i = float(self.n_obs) self.n_obs += 1 @@ -115,9 +125,11 @@ class Track: # Скользящее забывание (полупериод около трёх наблюдений) отвечает # на вопрос «каков он сейчас», а не «каким был с самого начала». 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) + p = c.extra.get("mbon") if c.extra else None + if p is not None: + self.p_n += 1 + self.p_sum += float(p) + self.p_max = max(self.p_max, float(p)) r = c.n_rays / expected_rays(c.d) self.r_sum += r self.r_last = r diff --git a/flyguard/descending.py b/flyguard/descending.py index 77c3ff9..9f104ee 100644 --- a/flyguard/descending.py +++ b/flyguard/descending.py @@ -64,7 +64,7 @@ class DescendingNeurons: novelty_floor: float = 0.10, max_range: float = 200.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): + track_gate: float = 0.0, track_readout=None): self.warn_evidence = warn_evidence self.clear_evidence = clear_evidence self.emergency_evidence = emergency_evidence @@ -87,6 +87,13 @@ class DescendingNeurons: # покадрового считывания: 0 — решает только улика, ровно как # раньше, 1 — только модель. Так размен меряется, а не объявляется. self.track_blend = track_blend + # Жёсткий порог по тому же отсчёту — вместо смешивания или вместе с + # ним. Разница существенная: смешивание опускает отсчёт И предмету + # тоже (обе величины меньше единицы), а порог ничего не отнимает у + # того, кто его прошёл. На реальном объекте средний вес 0.998 у + # предмета против 0.269 у ложных треков — разделение, под которое + # порог и просится. 0 — не проверять. + self.track_gate = track_gate self.track_score = track_score self.track_readout = track_readout self._latched: set[int] = set() @@ -119,7 +126,8 @@ class DescendingNeurons: continue ready.append((t, d)) - use_track = self.track_blend > 0.0 and bool(ready) + use_track = ((self.track_blend > 0.0 or self.track_gate > 0.0) + and bool(ready)) if use_track and self.track_score == "model": if self.track_readout is None: use_track = False @@ -138,9 +146,13 @@ class DescendingNeurons: 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 q < self.track_gate: + self._latched.discard(t.id) + continue + if self.track_blend > 0.0: + b = min(self.track_blend, 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 diff --git a/flyguard/pipeline.py b/flyguard/pipeline.py index 9bce62f..03674fa 100644 --- a/flyguard/pipeline.py +++ b/flyguard/pipeline.py @@ -108,6 +108,7 @@ class Params: # обученного отсчёта с уликой перед сравнением с порогом. 0 — решает # только улика, как раньше. Замер — EXPERIMENTS п. 15. track_blend: float = 0.0 + track_gate: float = 0.0 # жёсткий порог по тому же отсчёту # Чем считать «насколько это предмет» по треку: `w_mean` — средний вес # наблюдения за всю жизнь, `w_ema` — он же со скользящим забыванием, # `p_mean` — средний покадровый отсчёт считывания, `model` — обученное @@ -221,6 +222,7 @@ class FlyGuard: warn_far=self.p.warn_far, warn_far_from=self.p.warn_far_from, track_blend=self.p.track_blend, + track_gate=self.p.track_gate, 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, diff --git a/flyguard/track_readout.py b/flyguard/track_readout.py index abcc984..ce0860c 100644 --- a/flyguard/track_readout.py +++ b/flyguard/track_readout.py @@ -11,26 +11,24 @@ здесь и теряется дальнее обнаружение: на 120…160 м трек заводится в 62 % наблюдений, а до решения доходит 38 % — улика есть, порога не набирает. -Поэтому вход здесь другой: +Поэтому вход здесь другой: сводки за всю жизнь трека, а не последнее +значение, — средний и максимальный вес наблюдения, средний и максимальный +покадровый отсчёт MBON, наклон числа лучей по времени, разбросы положения. -* `s_std` — разброс места в тоннеле, которое подразумевают наблюдения. - У неподвижного предмета оно постоянно по построению, у ложного трека, - собранного из разных кусков стены, — гуляет. В улику этот признак - сейчас не входит вообще, ни одним множителем; -* `rays_slope` — растёт ли число лучей так, как обязано расти при - сближении. У предмета оно идёт по закону решётки, у случайного - совпадения — как придётся; -* сводки веса наблюдения и покадрового отсчёта MBON: среднее и максимум - за всю жизнь трека, а не последнее значение. +**ЧЕМ ЭТО КОНЧИЛОСЬ.** Обученная модель не обгоняет ни один из признаков, +которые ей же и скормили: leave-one-bag-out AUC 0.873 против 0.880 у одного +среднего веса наблюдения и 0.895 у среднего отсчёта считывания. Причина — не в +схеме: наблюдений 9 299, но это сотни кадров подряд по одному и тому же треку, +и независимых примеров тут сотни, а не тысячи. Поэтому в решении участвует +**не модель, а прямо среднее** (`descending.track_score`, по умолчанию +`w_mean`), а всё в этом файле — инструмент замера. Разбор — EXPERIMENTS п. 15. -Схема при этом не меняется: те же клетки Кеньона по шесть когтей, то же -торможение APL, тот же один выход. Это ровно та же операция, что MBON -проделывает над кандидатом, только этажом выше — над тем, что центральный -комплекс успел накопить. У мухи так же: нисходящие нейроны получают сходящийся -вход и от зрительных путей, и от центрального комплекса, а не только от сетчатки. - -Стоимость — одно умножение матрицы на трек, и только для тех треков, что уже -прошли дешёвые проверки в `descending.py`. +Отдельно: `s_std` и `u_std` в решении использовать нельзя. На полигоне разброс +привязки к месту выглядит лучшим признаком дальней полосы (AUC 0.869 за 160 м), +а на настоящем объекте он перевёрнут (0.223): вставка ставится на дальность, +посчитанную из той же оценки собственного движения, что двигает мировую +координату, и разброса у неё нет по построению. Признаки оставлены в +дескрипторе нарочно — чтобы проверку можно было повторить (п. 15.4). """ from __future__ import annotations @@ -55,6 +53,8 @@ TRACK_FEATURES = ( "rays_slope", # и наклон по времени "w_mean", "w_max", # вес наблюдения "p_mean", "p_max", # покадровый отсчёт считывания + # Оба разброса — ТОЛЬКО для замеров: на синтетике сильны, на реальном + # объекте перевёрнуты. См. шапку файла и EXPERIMENTS п. 15.4. "s_std", # разброс места в тоннеле, м "u_std", # разброс поперечного положения, м ) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 13665a0..c75eb57 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -517,3 +517,26 @@ def test_track_accumulators_do_not_touch_the_evidence(): 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 + + +def test_track_p_mean_falls_back_when_there_is_no_readout(): + """Без модели покадрового считывания отсчёта нет, и ноль тут погасил бы всё.""" + from flyguard.central_complex import Track + + t = Track(id=1, s_world=60.0, u=0.0, h=0.3, width=0.5, height=1.7, + n_obs=4, w_sum=4 * 0.8) + assert t.p_n == 0 + assert abs(t.p_mean - 0.8) < 1e-9 + t.p_n, t.p_sum = 4, 4 * 0.3 + assert abs(t.p_mean - 0.3) < 1e-9 + + +def test_track_gate_removes_the_weak_track_without_touching_the_strong_one(): + """Порог ничего не отнимает у прошедшего — в отличие от смешивания.""" + from flyguard.descending import DescendingNeurons + + out = DescendingNeurons(track_gate=0.5, track_score="w_mean").decide( + _pair_of_tracks(), speed=10.0) + assert {o.track_id for o in out.objects} == {1} + assert out.objects[0].confidence == pytest.approx( + DescendingNeurons().decide(_pair_of_tracks(), speed=10.0).objects[0].confidence) diff --git a/tools/evaluate.py b/tools/evaluate.py index c979dc9..75c1678 100644 --- a/tools/evaluate.py +++ b/tools/evaluate.py @@ -161,6 +161,8 @@ def main() -> None: help="каталог с моделями по складкам (track_<бэг>.npz)") ap.add_argument("--track-blend", type=float, default=None, help="доля считывания по треку в решении; 0 — только улика") + ap.add_argument("--track-gate", type=float, default=None, + help="жёсткий порог по отсчёту трека; 0 — не проверять") ap.add_argument("--track-score", default=None, choices=("w_mean", "w_ema", "p_mean", "model"), help="чем мерить трек: средний вес наблюдения, средний " @@ -231,6 +233,8 @@ def main() -> None: over["track_blend"] = args.track_blend if args.track_score is not None: over["track_score"] = args.track_score + if args.track_gate is not None: + over["track_gate"] = args.track_gate params = Params(**over) fold_paths: dict[str, str] = {} if args.mbon_dir: diff --git a/tools/make_benchmark.py b/tools/make_benchmark.py index 5bb341c..688b1b7 100644 --- a/tools/make_benchmark.py +++ b/tools/make_benchmark.py @@ -174,6 +174,8 @@ def main() -> None: help="каталог с моделями по складкам (track_<бэг>.npz)") ap.add_argument("--track-blend", type=float, default=None, help="доля считывания по треку в решении; 0 — только улика") + ap.add_argument("--track-gate", type=float, default=None, + help="жёсткий порог по отсчёту трека; 0 — не проверять") ap.add_argument("--track-score", default=None, choices=("w_mean", "w_ema", "p_mean", "model"), help="чем мерить трек: средний вес наблюдения, средний " @@ -271,6 +273,8 @@ def main() -> None: over["track_blend"] = args.track_blend if args.track_score is not None: over["track_score"] = args.track_score + if args.track_gate is not None: + over["track_gate"] = args.track_gate params = Params(**over) laterals = tuple(float(x) for x in args.laterals.split(","))