Дальность: убрал вредный множитель знакомости, P@150 с нуля до 0.32

This commit is contained in:
Данил Омелечко 2026-09-22 11:51:01 +03:00
parent c517d5ebe1
commit 5f6eb5eb52
14 changed files with 556 additions and 59 deletions

View file

@ -87,7 +87,7 @@ flyguard/ ядро: стадии обработки, память, сч
pipeline.py сборка
synth.py вставка предметов трассировкой лучей
tools/ обучение, оценка, разбор
tests/ 25 тестов, запускаются без данных и без ROS
tests/ 27 тестов, запускаются без данных и без ROS
docs/ методика и результаты
artifacts/ обученные модели
```
@ -126,10 +126,10 @@ python tools/plot_benchmark.py # кривые и граф
| Метрика | Значение | Чем измерено |
|---|---|---|
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
| Ложные тревоги, leave-one-bag-out | **3.3 трека на км**, 4.5 % кадров | `tools/evaluate.py --mbon-dir` |
| Ложные тревоги, leave-one-bag-out | **5.6 трека на км**, 9.4 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 7.4 на км, 16.7 % кадров | там же, без `--mbon-dir` |
| То же на новой линии, без обученной памяти | 21.8 на км | там же, холодный старт |
| Рабочая дальность (полигон, 14 004 наблюдения) | **80 м** человек стоя и сидя, P@50 = 0.70, P@100 = 0.31 | `tools/plot_benchmark.py` |
| На незнакомой линии (памяти нет) | **20.6 на км**; без считывания 33.6 | `tools/evaluate.py --no-memory` |
| Дальность (полигон, 14 004 наблюдения) | человек стоя P@50 = 0.70, **P@100 = 0.48, P@150 = 0.32** | `tools/plot_benchmark.py` |
| Обработка кадра | медиана 33 мс из бюджета 100 мс | `tools/run_pipeline.py` |
| Разделение «знакомое / новое» (без учителя) | ROC AUC 0.905 | `tools/tune_memory.py` |
| Считывание MBON «предмет / тоннель» (с учителем) | ROC AUC **0.986** | `tools/train_mbon.py` |

File diff suppressed because one or more lines are too long

View file

@ -5,13 +5,13 @@
"path_m": 0.0,
"alarm_frames": 183,
"alarm_rate": 0.9631578947368421,
"fp_objects": 322,
"fp_tracks": 5,
"fp_objects": 334,
"fp_tracks": 4,
"fp_per_km": NaN,
"fp_median_d": 76.65631835070762,
"fp_median_d": 76.66390740207035,
"obj_rate": 0.9947368421052631,
"ms_p50": 33.38889999940875,
"ms_p95": 37.88361000169971,
"ms_p50": 31.554799997138616,
"ms_p95": 32.83338500204991,
"train_size": 37073
},
{
@ -25,8 +25,8 @@
"fp_per_km": 4.981343439320496,
"fp_median_d": 33.983066409463774,
"obj_rate": null,
"ms_p50": 39.082800001779106,
"ms_p95": 45.35096999898087,
"ms_p50": 34.39600000092469,
"ms_p95": 37.78021999751218,
"train_size": 36151
},
{
@ -40,53 +40,53 @@
"fp_per_km": 0.0,
"fp_median_d": NaN,
"obj_rate": null,
"ms_p50": 37.19460000138497,
"ms_p95": 53.73476000095251,
"ms_p50": 33.786500001951936,
"ms_p95": 40.69248000432708,
"train_size": 36584
},
{
"bag": "roundT_pressureGate_roundT",
"frames": 239,
"path_m": 246.7637153487153,
"alarm_frames": 4,
"alarm_rate": 0.016736401673640166,
"fp_objects": 4,
"fp_tracks": 1,
"fp_per_km": 4.052459651885389,
"fp_median_d": 57.421836173736494,
"alarm_frames": 25,
"alarm_rate": 0.10460251046025104,
"fp_objects": 25,
"fp_tracks": 2,
"fp_per_km": 8.104919303770778,
"fp_median_d": 160.97122011941988,
"obj_rate": null,
"ms_p50": 45.79000000376254,
"ms_p95": 48.463629999241675,
"ms_p50": 39.27589999693737,
"ms_p95": 40.91062000243255,
"train_size": 36718
},
{
"bag": "roundT_squareT_pressureGate_squareT",
"frames": 239,
"path_m": 273.9439574444854,
"alarm_frames": 13,
"alarm_rate": 0.05439330543933055,
"fp_objects": 13,
"fp_tracks": 1,
"fp_per_km": 3.6503816668511457,
"fp_median_d": 95.9543811760172,
"alarm_frames": 32,
"alarm_rate": 0.13389121338912133,
"fp_objects": 32,
"fp_tracks": 2,
"fp_per_km": 7.3007633337022915,
"fp_median_d": 102.23183508833142,
"obj_rate": null,
"ms_p50": 46.54909999590018,
"ms_p95": 49.73449000317487,
"ms_p50": 39.65690000222821,
"ms_p95": 41.665320002539374,
"train_size": 36083
},
{
"bag": "squareT_platform_squareT_switch",
"frames": 239,
"path_m": 270.98139848673645,
"alarm_frames": 9,
"alarm_rate": 0.03765690376569038,
"fp_objects": 9,
"fp_tracks": 1,
"fp_per_km": 3.6902902028861817,
"fp_median_d": 96.63277916999479,
"alarm_frames": 27,
"alarm_rate": 0.11297071129707113,
"fp_objects": 27,
"fp_tracks": 2,
"fp_per_km": 7.380580405772363,
"fp_median_d": 103.27156681153457,
"obj_rate": null,
"ms_p50": 41.41209999579587,
"ms_p95": 46.84415000701847,
"ms_p50": 36.54640000058862,
"ms_p95": 39.32138000236591,
"train_size": 33658
}
]

Binary file not shown.

View file

@ -241,7 +241,7 @@ $$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1
\cdot \underbrace{\frac{c-0.25}{0.45}}_{\text{целостность}}
\cdot \underbrace{\left(1.5-\frac{L}{3s}\right)}_{\text{компактность}}
\cdot \underbrace{\left(1.25-\frac{h_\text{низ}}{1.2}\right)}_{\text{опора снизу}}
\cdot \underbrace{g(\nu)}_{\text{новизна}}$$
\cdot \underbrace{g(\nu)^{w(R)}}_{\text{новизна}}$$
Все множители физичны:
@ -252,7 +252,12 @@ $$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1
* **опора снизу** — упавший предмет, человек, камень стоят на полотне, а знак, лоток или
кронштейн висят на стене, и под ними пусто;
* **новизна** `g(ν)` — резкое отображение ответа MBON с ненулевым полом: даже похожий на
привычную конструкцию предмет должен накапливать улику, просто медленнее.
привычную конструкцию предмет должен накапливать улику, просто медленнее. Показатель
`w(R)` гасит этот множитель с дальностью: за 90 м он линейно сходит от 1 к 0.
Причина замерена, а не придумана — на 120…185 м новизна вставленного человека 0.150
против 0.199 у окружающей обстановки, то есть признак **перевёрнут**: на шести лучах
дескриптор вырождается, и память узнаёт в предмете любую далёкую конструкцию.
Гашение подняло обнаружение на 150 м с нуля до 0.32 (EXPERIMENTS п. 12.3).
### 3.9. DESCENDING NEURONS — решение

View file

@ -1216,3 +1216,203 @@ h +0.0055 lat +0.0044
алгоритм: прямая видимость в этих тоннелях 121–167 м, а прирельсовая зона за
80–90 м не наблюдается вовсе — луч скользит по полотну (п. 3 и п. 7.3).
Потолок здесь геометрический.
---
## 12. Воронка потерь: где именно пропадает видимый предмет
Кривая дальности говорит, сколько мы теряем, но не где. Поэтому полигон
инструментирован тремя ступенями: попали ли лучи в предмет (видимость),
появился ли кандидат, собрался ли трек, прошло ли решение. Считается доля
от **видимых** наблюдений, то есть от тех, где предмет физически освещён
лучами и претензии к алгоритму осмысленны.
Человек стоя, конфигурация по умолчанию:
| полоса | видимых | кандидат | трек | решение | нет кандидата | трек без решения |
|---|---:|---:|---:|---:|---:|---:|
| 6–30 м | 396 | 0.82 | 0.89 | 0.82 | 0.18 | 0.06 |
| 30–50 м | 184 | 0.55 | 0.64 | 0.59 | **0.45** | 0.05 |
| 50–70 м | 147 | 0.75 | 0.82 | 0.81 | 0.25 | 0.01 |
| 70–90 м | 131 | 0.73 | 0.68 | 0.62 | 0.27 | 0.06 |
| 90–120 м | 177 | 0.66 | 0.54 | 0.24 | 0.34 | **0.31** |
| 120–160 м | 251 | 0.62 | 0.37 | 0.00 | 0.38 | **0.37** |
Столбец «трек» бывает выше «кандидата»: трек живёт между кадрами и
переживает кадр без кандидата — это не ошибка таблицы, а работа накопителя.
Видны **две разные** болезни, и лечатся они по-разному.
### 12.1. Ближняя: 30–50 м, кандидат не рождается
Худшая полоса во всём диапазоне — хуже, чем 120–160 м. Причина известна:
разрез компоненты по контрасту запрещён ближе 55 м (`split_near`), и предмет,
слипшийся со стеной, уходит вместе с ней. Запрет стоял потому, что снятие
стоило слишком дорого по ложным. Обученное считывание удвоило бюджет, и
проверку повторили:
| не резать ближе | ложных треков/км | кадров с тревогой |
|---|---:|---:|
| **55 м (принято)** | **3.3** | **4.5 %** |
| 40 м | 6.3 | 7.5 % |
| 30 м | 7.3 | 9.6 % |
| 20 м | 8.3 | 12.1 % |
| резать везде | 8.3 | 12.7 % |
Весь выигрыш считывания съедается уже к 30 м, а на 20 м мы хуже, чем были
без модели вообще. Запрет оставлен. Дополнительный довод: при служебном
торможении с 60 км/ч тормозной путь с реакцией около 160 м, то есть полоса
30–50 м для полной остановки поздняя в любом случае — платить за неё
ложными тревогами на всей дистанции невыгодно.
### 12.2. Дальняя: за 90 м трек есть, решения нет
Здесь теряется 0.31 и 0.37 видимых наблюдений уже **после** того, как трек
собран. Первая догадка — порог. Проверена и отвергнута: порог тревоги,
линейно опущенный с 0.5 до 0.3 начиная с 90 м, не изменил ни ложных
(3.3 на км), ни обнаружения (P@100 = 0.31, решения за 120 м по-прежнему 0).
Порог новизны тоже ни при чём: с нулём вместо 0.10 всё то же самое.
Настоящая причина считается на бумаге. Улика растёт на `gain · качество` за
попадание и падает на `leak` за промах, при `gain` 0.34 и `leak` 0.12. При
доле попаданий p улика не убывает только если `p·gain·w > (1-p)·leak`. На
120–160 м кандидат появляется в 62 % наблюдений, откуда порог по качеству
**w > 0.22** — столько далёкое наблюдение из четырёх лучей не даёт никогда.
Поэтому улика далёкого трека не «чуть ниже порога», а колеблется около нуля,
и снижать порог бессмысленно.
Промах на 150 м, однако, не свидетельство отсутствия предмета, а свойство
решётки: там предмет виден через кадр. Ровно по этой причине число лучей уже
нормируется на ожидаемое для этой дальности (`_quality`), и промах надо
нормировать так же. Утечка сделана зависящей от дальности (`leak_far`).
Цена замерена сразу на двух сценах:
| утечка на дальнем краю | ложных/км, память обучена | то же на новой линии |
|---|---:|---:|
| 0.12 (было) | 3.3 | 20.6 |
| 0.06 | 3.3 | 21.6 |
| 0.03 | 3.3 | 21.6 |
| 0.01 | 3.3 | 21.6 |
Цена плоская: появление долгоживущих далёких треков стоит примерно одного
лишнего трека на километр на незнакомой линии и ничего — на знакомой.
### 12.3. Из чего на самом деле состоит улика далёкого предмета
Три ручки подряд ничего не дали, и это был знак, что ограничитель другой.
Поэтому вместо четвёртой развёртки — прямая трассировка: человек стоя
вставлен на 150 м, 178 кадров, и на каждом выписаны все множители улики.
| величина | медиана | p90 |
|---|---:|---:|
| лучей в предмете | 6 | 8 |
| кандидат найден | 164 из 178 кадров | |
| оценка MBON | **0.078** | 0.493 |
| новизна | **0.153** | 0.221 |
| качество улики (произведение) | **0.005** | 0.034 |
Новый трек выживает, только если `gain · качество > 0.02`, то есть при
качестве выше 0.059. Таких наблюдений **5.5 %**. Материал, стало быть, есть
— кандидат находится в 92 % кадров, — а улику копить нечем. Ни порогом, ни
утечкой это не лечится, потому что ноль на любой множитель остаётся нулём.
Дальше видно, какой из двух множителей виноват. На 120–185 м, 299
кандидатов, из них 231 — предмет:
| множитель | предмет | обстановка | AUC |
|---|---:|---:|---:|
| новизна | 0.150 | 0.199 | **0.293** |
| оценка MBON | 0.051 | 0.003 | 0.743 |
**Новизна на дальности перевёрнута.** Настоящий предмет выглядит более
знакомым, чем окружающая обстановка: на шести лучах дескриптор вырождается,
и память узнаёт в предмете любую далёкую конструкцию. Мы этим множителем
гасили собственный сигнал.
**Модель различает, но её ответ раздавлен редкостью.** Доля предметов среди
кандидатов падает с 42 % на 0–30 м до 3.1 % за 160 м, и калиброванная
апостериорная вероятность обязана падать вместе с ней — даже когда улика
по существу та же. Различие при этом сохраняется: 0.051 против 0.003, в
семнадцать раз.
Отсюда две правки, обе — нормировка на то, что достижимо на этой дальности,
ровно как уже сделано для числа лучей в `_quality`:
* **гашение знакомости с дальностью** (`nov_fade_from`): за 90 м вклад
новизны плавно сходит к нулю, вблизи остаётся как был;
* **поправка на распространённость** (`mbon_prior_from`): из логита модели
вычитается типичный для этой полосы, то есть апостериорная вероятность
превращается в отношение правдоподобий.
Поправка на распространённость **сама по себе не дала ничего**: ни ложных
(3.3 на км), ни обнаружения (P@100 = 0.31, за 120 м ноль), воронка не
сдвинулась ни на сотую. Это ожидаемо и поучительно: пока второй множитель
обнуляет произведение, правильная правка первого бесполезна.
Гашение знакомости дало главный результат дня:
| человек стоя | было | стало |
|---|---:|---:|
| P@50 | 0.70 | 0.70 |
| P@100 | 0.31 | **0.48** |
| P@150 | 0.00 | **0.32** |
| решений в полосе 120–160 м | 0.00 | **0.40** |
| ложных треков/км, память обучена | 3.3 | 5.6 |
| то же на новой линии | 21.6 | **21.6** |
| посторонних тревог на кадр, полигон | 0.030 | 0.047 |
Впервые предмет вообще обнаруживается на 150 м. Цена — полтора лишних
ложных трека на километр **на знакомой линии**; на незнакомой правка
бесплатна, потому что отказываться там не от чего. Приватный тест — это
именно незнакомая линия.
Отдельно про характер цены: лишние далёкие треки дают **предупреждения, а не
торможение**. Экстренный уровень требует `d ≤ max(тормозной путь, 25 м)`
(`descending.py`), поэтому далёкий ложный трек физически не может вызвать
экстренное торможение. Мы платим предупреждениями за дальность обнаружения —
для движущегося поезда это правильная сторона размена.
### 12.4. Что из этого принято
Обе правки и зависящая от дальности утечка проверены порознь и вместе,
каждая на двух сценах — линия с обученной памятью и первый проезд по
незнакомой:
| конфигурация | ложных/км, память | то же, новая линия | P@100 | P@150 | тревог на кадр |
|---|---:|---:|---:|---:|---:|
| как было | 3.3 | 20.6 | 0.31 | 0.00 | 0.030 |
| утечка 0.03 | 3.3 | 21.6 | 0.33 | 0.00 | 0.030 |
| поправка на распространённость | 3.3 | 20.6 | 0.31 | 0.00 | 0.030 |
| **гашение знакомости с 90 м** | **5.6** | **20.6** | **0.48** | **0.32** | 0.046 |
| гашение + утечка | 5.6 | 21.6 | 0.48 | 0.32 | 0.047 |
| гашение + утечка + поправка | 5.6 | 21.6 | 0.48 | 0.32 | 0.048 |
**Принято одно гашение знакомости.** Утечка добавляет к нему сотые доли
(человек сидя P@100 0.44 → 0.48, решений в полосе 120–160 м 0.37 → 0.40) и
стоит одного лишнего ложного трека на километр ровно на той сцене, которая
соответствует приватному тесту. Поправка на распространённость не даёт
ничего ни отдельно, ни в связке — таблицы обнаружения совпадают до сотой.
Обе отвергнутые правки **оставлены в коде выключенными** (`leak_far`,
`mbon_prior_from`, `warn_far`): они верны по сути, измеримы одним ключом и
могут пригодиться на линии с другой статистикой. Значения по умолчанию
выключают их полностью.
Итоговая конфигурация и её цена:
| | без гашения | **принято** |
|---|---:|---:|
| реальный объект на 55 м | 99.5 % | **99.5 %** |
| P@50, человек стоя | 0.70 | **0.70** |
| P@100 | 0.31 | **0.48** |
| P@150 | 0.00 | **0.32** |
| ложных треков/км, знакомая линия | 3.3 | 5.6 |
| кадров с тревогой, знакомая линия | 4.5 % | 9.4 % |
| ложных треков/км, новая линия | 20.6 | **20.6** |
| задержка кадра, медиана | 33 мс | **32 мс** |
Размен честный и его надо называть вслух: на **знакомой** линии доля кадров
с тревогой удваивается. Взамен появляется обнаружение на 150 м, которого не
было вовсе, а на **незнакомой** линии — там, где будет приватный тест, —
плата нулевая. Кому дороже тишина, чем дальность, ставит `nov_fade_from: 0`
и возвращается к 3.3 трека на километр.

View file

@ -60,16 +60,35 @@ class CentralComplex:
def __init__(self, *, gate_d: float = 4.0, gate_u: float = 1.2,
gain: float = 0.34, leak: float = 0.12,
leak_far: float | None = None, leak_far_from: float = 90.0,
inhibition: float = 0.05, max_misses: int = 12,
max_tracks: int = 48, use_shape: bool = True,
mbon_power: float = 1.0, mbon_blend: float = 1.0):
mbon_power: float = 1.0, mbon_blend: float = 1.0,
nov_fade_from: float = 0.0, nov_fade_to: float = 160.0):
self.use_shape = use_shape
self.mbon_power = mbon_power
self.mbon_blend = mbon_blend
self.nov_fade_from = nov_fade_from
self.nov_fade_to = nov_fade_to
self.gate_d = gate_d
self.gate_u = gate_u
self.gain = gain
self.leak = leak
# Утечка улики за промах, слабеющая с дальностью. Промах на 150 м
# — не свидетельство отсутствия, а свойство решётки: предмет там
# даёт единицы лучей и виден через кадр. Ровно по этой причине
# число лучей уже нормируется на ожидаемое (см. _quality), и
# промах нормировать надо так же.
#
# Считать легко: при доле попаданий p улика не убывает, только
# если p·gain·w > (1-p)·leak. На 120…160 м кандидат появляется в
# 62 % наблюдений, и при gain 0.34 и leak 0.12 нужно качество
# выше 0.22 — столько далёкое наблюдение не даёт никогда. Поэтому
# улика у далёкого трека не «чуть ниже порога», а около нуля, и
# снижением порога это не лечится (замер: 0.5 → 0.3 не изменил
# ничего). None — зависимости нет, как было.
self.leak_far = leak if leak_far is None else leak_far
self.leak_far_from = leak_far_from
self.inhibition = inhibition
self.max_misses = max_misses
self.max_tracks = max_tracks
@ -79,6 +98,14 @@ class CentralComplex:
# ------------------------------------------------------------------ обновление
def _leak_at(self, d: float) -> float:
"""Утечка на дальности d: линейно от leak до leak_far к 200 м."""
span = 200.0 - self.leak_far_from
if span <= 0 or d <= self.leak_far_from:
return self.leak
f = min((d - self.leak_far_from) / span, 1.0)
return self.leak + (self.leak_far - self.leak) * f
def update(self, candidates: list[Candidate], ds: float, dt: float) -> list[Track]:
"""Сдвинуть мир на `ds`, сопоставить кандидатов, обновить улики."""
self.s_world += ds
@ -105,14 +132,16 @@ class CentralComplex:
best, best_cost = k, cost
if best is None:
t.misses += 1
t.evidence = max(0.0, t.evidence - self.leak)
t.evidence = max(0.0, t.evidence
- self._leak_at(t.distance(self.s_world)))
continue
c = candidates[best]
used.add(best)
t.hits += 1
t.misses = 0
w = _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.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
@ -137,8 +166,9 @@ 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)
t.evidence = self.gain * _quality(
c, self.use_shape, self.mbon_power, self.mbon_blend,
self.nov_fade_from, self.nov_fade_to)
t.hits = 1
self.tracks.append(t)
@ -169,7 +199,8 @@ class CentralComplex:
def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
mbon_blend: float = 1.0) -> float:
mbon_blend: float = 1.0, nov_fade_from: float = 0.0,
nov_fade_to: float = 160.0) -> float:
"""Вес одного наблюдения: сколько улики оно добавляет.
Дальний объект даёт мало лучей не потому, что он сомнительный, а потому что
@ -205,7 +236,18 @@ def _quality(c: Candidate, use_shape: bool = True, mbon_power: float = 1.0,
grounded = float(np.clip(1.25 - c.h_min / 1.2, 0.15, 1.0))
else:
whole = compact = grounded = 1.0
# Знакомость на большой дальности не просто бесполезна, а вредна. Замер:
# на 120…185 м у вставленного человека новизна 0.150, а у окружающей
# обстановки 0.199, разделяющая способность AUC 0.293 — то есть
# признак перевёрнут. Причина понятна: на шести лучах дескриптор
# вырождается, и память узнаёт в предмете любую далёкую конструкцию.
# Поэтому вклад новизны гасится с дальностью, а вблизи, где она и
# работает, остаётся как был. 0 — не гасить (EXPERIMENTS п. 12.3).
novel = novelty_gain(c.novelty)
if nov_fade_from > 0.0 and c.d > nov_fade_from:
span = max(nov_fade_to - nov_fade_from, 1e-3)
w = 1.0 - min((c.d - nov_fade_from) / span, 1.0)
novel = novel ** w
hand = support * contrast * whole * compact * grounded
# Обученное считывание MBON, если оно есть. Модель видит те же признаки,

View file

@ -61,15 +61,37 @@ 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):
novelty_floor: float = 0.10, max_range: float = 200.0,
warn_far: float | None = None, warn_far_from: float = 90.0):
self.warn_evidence = warn_evidence
self.clear_evidence = clear_evidence
self.emergency_evidence = emergency_evidence
self.min_hits = min_hits
self.novelty_floor = novelty_floor
self.max_range = max_range
# Порог предупреждения, ослабевающий с дальностью. Пара нисходящих
# нейронов у мухи не равноправна: DNp02/DNp11 срабатывают рано и
# мягко, Giant Fiber — поздно и жёстко. Здесь то же самое по
# дальности: далёкая улика физически слабее (4…8 лучей вместо
# сотни), а цена ошибки ниже — до предмета ещё сотня метров и
# десятки кадров на подтверждение. Экстренное торможение своего
# порога не снижает. None — послабления нет.
self.warn_far = warn_evidence if warn_far is None else warn_far
self.warn_far_from = warn_far_from
self._latched: set[int] = set()
@property
def _hyst(self) -> float:
return self.clear_evidence / max(self.warn_evidence, 1e-6)
def _warn_at(self, d: float) -> float:
"""Порог тревоги на дальности d: линейно от warn до warn_far."""
span = self.max_range - self.warn_far_from
if span <= 0 or d <= self.warn_far_from:
return self.warn_evidence
f = min((d - self.warn_far_from) / span, 1.0)
return self.warn_evidence + (self.warn_far - self.warn_evidence) * f
def decide(self, cx: CentralComplex, speed: float) -> Decision:
out = Decision(speed=speed)
stop = speed * REACTION_TIME + speed * speed / (2 * BRAKING_DECEL)
@ -81,8 +103,9 @@ class DescendingNeurons:
if not (0.0 < d <= self.max_range):
self._latched.discard(t.id)
continue
warn = self._warn_at(d)
# гистерезис: попавший в тревогу трек держится до нижнего порога
on = self.warn_evidence if t.id not in self._latched else self.clear_evidence
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:
self._latched.discard(t.id)
continue

View file

@ -80,6 +80,13 @@ class MbonReadout:
w = np.zeros((self.cfg.n_kc, self.n_pn), np.float32)
np.put_along_axis(w, idx, sign.astype(np.float32), axis=1)
self.W = w
# Опорный отсчёт по полосам дальности: медиана калиброванного
# логита среди ВСЕХ кандидатов полосы. Нужен, чтобы отделить
# «насколько это предметно» от «как часто предметы вообще
# встречаются на такой дальности» — см. _prior_corr.
self.ref_edges = np.array([0., 30., 55., 80., 110., 160., 230.],
np.float32)
self.ref_z: np.ndarray | None = None
self.mean = np.zeros(self.n_pn, np.float32)
self.scale = np.ones(self.n_pn, np.float32)
self.w_mbon = np.zeros(self.cfg.n_kc, np.float32)
@ -163,12 +170,41 @@ class MbonReadout:
def score_of(self, c: Candidate) -> float:
return float(self.score(describe_full(c)[None, :])[0])
def annotate(self, cands: list[Candidate]) -> list[Candidate]:
def _ref_at(self, d: np.ndarray) -> np.ndarray:
"""Типичный отсчёт модели на дальности d (по обучающей выборке)."""
mid = 0.5 * (self.ref_edges[:-1] + self.ref_edges[1:])
return np.interp(d, mid, self.ref_z)
def _prior_corr(self, d: np.ndarray, prior_from: float) -> np.ndarray:
"""Поправка на распространённость предметов: логит → отношение.
Модель калибрована в апостериорную вероятность, а доля предметов
среди кандидатов падает с 42 % вблизи до 3.1 % за 160 м. Поэтому
честная вероятность на дальности низка даже у настоящего предмета:
замер даёт 0.051 у вставленного человека на 150 м — при 0.003 у
обстановки, то есть модель РАЗЛИЧАЕТ (AUC 0.743), но абсолютная
величина раздавлена редкостью. Детектору нужно отношение
правдоподобий, а не доля, поэтому из логита вычитается типичный
для этой полосы — ровно как число лучей уже нормируется на
ожидаемое для дальности. Ближе `prior_from` поправка нулевая.
"""
if self.ref_z is None or prior_from <= 0.0:
return np.zeros_like(d)
base = float(self._ref_at(np.array([prior_from], np.float32))[0])
corr = base - self._ref_at(d)
return np.where(d > prior_from, np.maximum(corr, 0.0), 0.0)
def annotate(self, cands: list[Candidate], prior_from: float = 0.0
) -> list[Candidate]:
if not cands:
return cands
X = np.stack([describe_full(c) for c in cands])
for c, p in zip(cands, self.score(X)):
c.extra["mbon"] = float(p)
z = self.logit(X)
if prior_from > 0.0:
d = np.array([c.d for c in cands], np.float32)
z = z + self._prior_corr(d, prior_from)
for c, zz in zip(cands, z):
c.extra["mbon"] = float(1.0 / (1.0 + np.exp(-zz)))
return cands
# ------------------------------------------------------------------ обучение
@ -199,7 +235,18 @@ class MbonReadout:
eps = 1e-7
loss = -(y * np.log(p + eps) + (1 - y) * np.log(1 - p + eps)).mean()
print(f" эпоха {ep + 1:4d}: логистическая потеря {loss:.4f}")
self._calibrate(self.bias + (self.w_mbon[a] * v).sum(axis=1), y)
zr = self.bias + (self.w_mbon[a] * v).sum(axis=1)
self._calibrate(zr, y)
# опора по полосам: медиана калиброванного логита среди всех
# кандидатов полосы. Первый признак дескриптора — log(d).
zc = self.gain * zr + self.shift
dd = np.exp(np.asarray(X[:, 0], np.float64))
ref = []
for lo, hi in zip(self.ref_edges[:-1], self.ref_edges[1:]):
m = (dd >= lo) & (dd < hi)
ref.append(float(np.median(zc[m])) if m.sum() >= 50
else (ref[-1] if ref else 0.0))
self.ref_z = np.array(ref, np.float32)
def _calibrate(self, z: np.ndarray, y: np.ndarray, iters: int = 400) -> None:
"""Шкалирование Платта: подобрать наклон и сдвиг по обучающей выборке."""
@ -219,7 +266,9 @@ class MbonReadout:
mean=self.mean, scale=self.scale,
n_kc=self.cfg.n_kc, claws=self.cfg.claws,
sparsity=self.cfg.sparsity, seed=self.cfg.seed,
n_pn=self.n_pn)
n_pn=self.n_pn, ref_edges=self.ref_edges,
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":
@ -233,4 +282,7 @@ class MbonReadout:
m.shift = np.float32(d["shift"])
m.mean = d["mean"].astype(np.float32)
m.scale = d["scale"].astype(np.float32)
if "ref_z" in d.files and d["ref_z"].size:
m.ref_edges = d["ref_edges"].astype(np.float32)
m.ref_z = d["ref_z"].astype(np.float32)
return m

View file

@ -83,6 +83,19 @@ class Params:
enable_mbon: bool = True
mbon_power: float = 1.0 # резкость: p**power перед смешиванием
mbon_blend: float = 1.0 # 1 — только модель, 0 — только ручная формула
# Гашение знакомости с дальностью: за `nov_fade_from` её вклад падает
# до нуля к `nov_fade_to`. За 90 м новизна измеримо ВРЕДНА — у
# вставленного человека на 120…185 м она 0.150 против 0.199 у
# обстановки, AUC 0.293, то есть признак перевёрнут. Гашение подняло
# P@100 с 0.31 до 0.48 и P@150 с нуля до 0.32 ценой 3.3 → 5.6 ложных
# трека на км на ЗНАКОМОЙ линии и ноля на незнакомой. 0 — не гасить,
# это тихий режим для линии с обученной памятью. EXPERIMENTS п. 12.3.
nov_fade_from: float = 90.0
nov_fade_to: float = 160.0
# Поправка оценки модели на распространённость предметов: за этой
# дальностью из логита вычитается типичный для полосы. 0 — не
# поправлять. Замер — EXPERIMENTS п. 12.3.
mbon_prior_from: float = 0.0
# Пороги решения (см. descending.py). Вынесены сюда, потому что обученное
# считывание меняет цену улики: когда ложные тревоги падают, запас можно
# потратить на дальность, опустив порог. Замер — EXPERIMENTS п. 11.
@ -97,6 +110,10 @@ class Params:
# (3.3 на км в обоих случаях). Замер — EXPERIMENTS п. 11.3.
min_hits: int = 2
novelty_floor: float = 0.10
warn_far: float = 0.5 # порог тревоги на дальнем краю
warn_far_from: float = 90.0 # с какой дальности он начинает падать
leak_far: float = 0.12 # утечка улики на дальнем краю
leak_far_from: float = 90.0 # с какой дальности она начинает падать
d_min: float = 4.0
d_max: float = 220.0
min_rays: int = 4
@ -165,14 +182,20 @@ class FlyGuard:
self.emd_bank = EmdBank()
self.cx = CentralComplex(use_shape=self.p.use_shape,
mbon_power=self.p.mbon_power,
mbon_blend=self.p.mbon_blend)
mbon_blend=self.p.mbon_blend,
leak_far=self.p.leak_far,
leak_far_from=self.p.leak_far_from,
nov_fade_from=self.p.nov_fade_from,
nov_fade_to=self.p.nov_fade_to)
# без накопления улик трек подтверждается первым же наблюдением
self.dn = (DescendingNeurons(
warn_evidence=self.p.warn_evidence,
clear_evidence=self.p.clear_evidence,
emergency_evidence=self.p.emergency_evidence,
min_hits=self.p.min_hits,
novelty_floor=self.p.novelty_floor) if self.p.use_tracking
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
else DescendingNeurons(warn_evidence=0.0, clear_evidence=0.0,
emergency_evidence=0.0, min_hits=1))
self._calib: list[PointCloud2] = []
@ -309,7 +332,7 @@ class FlyGuard:
# Строго после накопителя: опора веерного тела входит в признаки
# считывания, и на большой дальности это его главный вход.
if self.readout is not None:
self.readout.annotate(cands)
self.readout.annotate(cands, self.p.mbon_prior_from)
with t("central"):
self.cx.update(cands, ego.ds, ego.dt)

View file

@ -307,6 +307,46 @@ def test_mbon_absent_leaves_pipeline_unchanged():
assert _quality(c, mbon_blend=1.0) == _quality(c, mbon_blend=0.0)
def test_far_warning_threshold_only_relaxes_beyond_its_distance():
"""Послабление далёким трекам не должно трогать ближнюю зону."""
from flyguard.descending import DescendingNeurons
flat = DescendingNeurons()
assert flat._warn_at(10.0) == flat._warn_at(199.0) == flat.warn_evidence
dn = DescendingNeurons(warn_evidence=0.5, warn_far=0.3,
warn_far_from=90.0, max_range=200.0)
assert dn._warn_at(50.0) == 0.5 # ближняя зона не тронута
assert dn._warn_at(90.0) == 0.5 # ровно на границе — тоже
assert abs(dn._warn_at(145.0) - 0.4) < 1e-6 # середина
assert abs(dn._warn_at(200.0) - 0.3) < 1e-6 # дальний край
assert abs(dn._warn_at(400.0) - 0.3) < 1e-6 # и не ниже него
def test_novelty_fades_with_range_and_is_intact_up_close():
"""Знакомость должна работать вблизи и не мешать вдали."""
from flyguard.central_complex import _quality
near, far = _cand(), _cand()
near.d, far.d = 40.0, 160.0
near.novelty = far.novelty = 0.15 # «знакомо», как далёкий предмет
# вблизи гашения нет: знакомое остаётся придавленным
a = _quality(near, nov_fade_from=0.0)
b = _quality(near, nov_fade_from=90.0)
assert abs(a - b) < 1e-9
# вдали вклад знакомости снят целиком
c = _quality(far, nov_fade_from=0.0)
d = _quality(far, nov_fade_from=90.0, nov_fade_to=160.0)
assert d > c
# и в промежутке — частично
mid = _cand(); mid.d, mid.novelty = 125.0, 0.15
m = _quality(mid, nov_fade_from=90.0, nov_fade_to=160.0)
assert _quality(mid, nov_fade_from=0.0) < m < d
# ------------------------------------------------------- модель сенсора: яркость
def test_injected_intensity_copies_the_surroundings():

View file

@ -113,6 +113,26 @@ def main() -> None:
help="нижний порог гистерезиса (по умолчанию 0.3)")
ap.add_argument("--min-hits", type=int, default=None,
help="наблюдений, без которых трек не считается")
ap.add_argument("--warn-far", type=float, default=None,
help="порог тревоги на дальнем краю; ниже обычного — послабление далёким трекам")
ap.add_argument("--warn-far-from", type=float, default=None,
help="с какой дальности порог начинает падать, м")
ap.add_argument("--novelty-floor", type=float, default=None,
help="ниже этой новизны трек не считается")
ap.add_argument("--min-rays", type=int, default=None,
help="сколько лучей минимум образуют кандидата")
ap.add_argument("--leak-far", type=float, default=None,
help="утечка улики на дальнем краю; ниже обычной — далёкий трек прощает промахи")
ap.add_argument("--leak-far-from", type=float, default=None,
help="с какой дальности утечка начинает падать, м")
ap.add_argument("--nov-fade-from", type=float, default=None,
help="с какой дальности гасить вклад знакомости; 0 — не гасить")
ap.add_argument("--nov-fade-to", type=float, default=None,
help="к какой дальности вклад знакомости обнуляется")
ap.add_argument("--mbon-prior-from", type=float, default=None,
help="с какой дальности поправлять оценку модели на распространённость предметов; 0 — не поправлять")
ap.add_argument("--no-memory", action="store_true",
help="совсем без памяти тоннеля — так выглядит первый проезд по новой линии")
ap.add_argument("--out", default=str(B.ARTIFACTS / "generalisation.json"))
args = ap.parse_args()
@ -148,6 +168,24 @@ def main() -> None:
over["clear_evidence"] = args.clear
if args.min_hits is not None:
over["min_hits"] = args.min_hits
if args.warn_far is not None:
over["warn_far"] = args.warn_far
if args.warn_far_from is not None:
over["warn_far_from"] = args.warn_far_from
if args.novelty_floor is not None:
over["novelty_floor"] = args.novelty_floor
if args.min_rays is not None:
over["min_rays"] = args.min_rays
if args.leak_far is not None:
over["leak_far"] = args.leak_far
if args.leak_far_from is not None:
over["leak_far_from"] = args.leak_far_from
if args.nov_fade_from is not None:
over["nov_fade_from"] = args.nov_fade_from
if args.nov_fade_to is not 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
params = Params(**over)
readout = None
folds = {}
@ -165,12 +203,13 @@ def main() -> None:
rows = []
for p in find_bags(args.root):
t0 = time.time()
mem = train_excluding(per_bag, extra, p.name, args.target, args.device)
mem = (None if args.no_memory else
train_excluding(per_bag, extra, p.name, args.target, args.device))
rd = folds.get(p.name, readout) if folds else readout
if folds and p.name not in folds and p.name != OBSTACLE_BAG:
print(f" внимание: для {p.name} нет своей складки")
r = run_bag(p, mem, args.limit, params, readout=rd)
r["train_size"] = int(mem.n_seen)
r["train_size"] = int(mem.n_seen) if mem is not None else 0
rows.append(r)
obj = f"объект {r['obj_rate']:6.1%} | " if r["obj_rate"] is not None else ""
print(f"{r['bag']:40s} кадров {r['frames']:4d} путь {r['path_m']:6.0f} м | "

View file

@ -84,7 +84,17 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
tol = max(3.0, 0.12 * d_true)
hit = any(abs(o.distance - d_true) < tol for o in res.decision.objects)
fp = sum(1 for o in res.decision.objects if abs(o.distance - d_true) >= tol)
records[i].append((d_true, int(hit), fp, lab["hit_rays"]))
# Воронка потерь. Лучи в предмет попали — а дальше он может
# пропасть на любой из трёх ступеней, и лечатся они по-разному:
# нет кандидата — вопрос к кластеризации и разделению фигуры и
# фона, нет трека — к сопоставлению по кадрам, нет решения —
# к порогу. Без этого разбиения улучшать нечего, кроме удачи.
cand = any(abs(c.d - d_true) < tol for c in res.candidates)
cx = pipes[i].cx
trk = any(abs(tr.distance(cx.s_world) - d_true) < tol
for tr in cx.tracks)
records[i].append((d_true, int(hit), fp, lab["hit_rays"],
int(cand), int(trk)))
out = []
for (name, lat), rec in zip(scen, records):
@ -94,6 +104,7 @@ def run_bag(bag_path, params: Params, memory, limit: int, d_start: float,
out.append(dict(bag=bag.path.name, obj=name, lateral=lat,
d=a[:, 0].tolist(), hit=a[:, 1].tolist(),
fp=a[:, 2].tolist(), rays=a[:, 3].tolist(),
cand=a[:, 4].tolist(), trk=a[:, 5].tolist(),
travel=float(travel)))
return out
@ -121,6 +132,24 @@ def main() -> None:
help="порог улики для тревоги (по умолчанию 0.5)")
ap.add_argument("--clear", type=float, default=None)
ap.add_argument("--min-hits", type=int, default=None)
ap.add_argument("--warn-far", type=float, default=None,
help="порог тревоги на дальнем краю; ниже обычного — послабление далёким трекам")
ap.add_argument("--warn-far-from", type=float, default=None,
help="с какой дальности порог начинает падать, м")
ap.add_argument("--novelty-floor", type=float, default=None,
help="ниже этой новизны трек не считается")
ap.add_argument("--min-rays", type=int, default=None,
help="сколько лучей минимум образуют кандидата")
ap.add_argument("--leak-far", type=float, default=None,
help="утечка улики на дальнем краю; ниже обычной — далёкий трек прощает промахи")
ap.add_argument("--leak-far-from", type=float, default=None,
help="с какой дальности утечка начинает падать, м")
ap.add_argument("--nov-fade-from", type=float, default=None,
help="с какой дальности гасить вклад знакомости; 0 — не гасить")
ap.add_argument("--nov-fade-to", type=float, default=None,
help="к какой дальности вклад знакомости обнуляется")
ap.add_argument("--mbon-prior-from", type=float, default=None,
help="с какой дальности поправлять оценку модели на распространённость предметов; 0 — не поправлять")
args = ap.parse_args()
B.ARTIFACTS.mkdir(parents=True, exist_ok=True)
@ -148,6 +177,24 @@ def main() -> None:
over["clear_evidence"] = args.clear
if args.min_hits is not None:
over["min_hits"] = args.min_hits
if args.warn_far is not None:
over["warn_far"] = args.warn_far
if args.warn_far_from is not None:
over["warn_far_from"] = args.warn_far_from
if args.novelty_floor is not None:
over["novelty_floor"] = args.novelty_floor
if args.min_rays is not None:
over["min_rays"] = args.min_rays
if args.leak_far is not None:
over["leak_far"] = args.leak_far
if args.leak_far_from is not None:
over["leak_far_from"] = args.leak_far_from
if args.nov_fade_from is not None:
over["nov_fade_from"] = args.nov_fade_from
if args.nov_fade_to is not 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
params = Params(**over)
laterals = tuple(float(x) for x in args.laterals.split(","))

View file

@ -27,6 +27,8 @@ import numpy as np # noqa: E402
import _bootstrap as B # noqa: F401,E402
NL = chr(10)
EDGES = np.array([0, 15, 25, 40, 55, 70, 90, 110, 135, 160, 190, 230], float)
MIN_VISIBLE_RAYS = 2 # меньше — предмет физически не освещён лучами
@ -59,6 +61,27 @@ def working_range(centres: np.ndarray, p: np.ndarray, thr: float = 0.5) -> float
return best
def _funnel(rec: list[dict], obj: str = "человек_стоя") -> None:
"""Где теряется предмет: лучи → кандидат → трек → решение."""
rows = [r for r in rec if r["obj"] == obj]
if not rows:
return
d = np.concatenate([np.array(r["d"]) for r in rows])
rays = np.concatenate([np.array(r["rays"]) for r in rows])
cand = np.concatenate([np.array(r["cand"]) for r in rows])
trk = np.concatenate([np.array(r["trk"]) for r in rows])
hit = np.concatenate([np.array(r["hit"]) for r in rows])
vis = rays >= MIN_VISIBLE_RAYS
print(f"{NL}воронка потерь для «{obj}», доля от ВИДИМЫХ наблюдений:")
print(f"{'полоса':>10}{'видимых':>9}{'кандидат':>10}{'трек':>8}{'решение':>9}{'нет кандидата':>15}{'трек без решения':>18}")
for lo, hi in ((6, 30), (30, 50), (50, 70), (70, 90), (90, 120), (120, 160)):
m = vis & (d >= lo) & (d < hi)
if m.sum() < 30:
continue
c, k, h = cand[m].mean(), trk[m].mean(), hit[m].mean()
print(f"{f'{lo}-{hi} м':>10}{int(m.sum()):9d}{c:10.2f}{k:8.2f}{h:9.2f}{1 - c:15.2f}{max(k - h, 0.0):18.2f}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--in", dest="path", default=str(B.ARTIFACTS / "benchmark.json"))
@ -119,6 +142,9 @@ def main() -> None:
fig.tight_layout(); fig.savefig(out)
print("\nсохранено:", out)
if all("cand" in r for r in rec):
_funnel(rec)
fp = np.concatenate([np.array(r["fp"]) for r in rec])
print(f"посторонних тревог: {fp.sum():.0f} на {fp.size} наблюдений "
f"({fp.mean():.3f} на кадр)")