резкость считывания 1.5: ложных на новой линии 23.5 -> 14.7 на км, память пересобрана на новом полу, new_data читается кусками из tar

This commit is contained in:
Данил Омелечко 2026-09-24 14:31:47 +03:00
parent 6964e4cc4e
commit 28fd716a82
11 changed files with 474 additions and 55 deletions

View file

@ -143,6 +143,19 @@ python tools/plot_benchmark.py # кривые и граф
python tools/compare_benchmark.py было.json стало.json # правка парно
```
Большой бэг `new_data` (90 ГБ) не распаковывается целиком — инструменты
читают его кусками прямо из архива (`--tar`, по умолчанию
`../датасет/new_data` или `FLYGUARD_NEW_DATA`):
```bash
python tools/make_training_set.py --new-data 0:110 --out data/cache/training_set_nd.npz
python tools/train_mbon.py --device cuda --data data/cache/training_set.npz --data data/cache/training_set_nd.npz --train-only new_data_ --out nd.npz
python tools/eval_new_data.py --shards 110: --readout было=artifacts/mbon_readout.npz --readout стало=nd.npz --out nd.json
```
Проверка на второй половине честная: вставки идут только в первую, а
вторую модель не видит ни в каком виде (EXPERIMENTS п. 17.3).
Любое поле `Params` меняется без правки кода: `--set h_lo_core=0.28 --set
k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py` и
`check_obstacle.py`. Два прогона полигона сравнивайте только парно —
@ -168,11 +181,12 @@ k_sigma=0.75` у `make_benchmark.py`, `evaluate.py`, `make_training_set.py` и
| Метрика | Значение | Чем измерено |
|---|---|---|
| Реальный объект 0.67 × 1.35 м на 55 м | **99.5 %** кадров | `tools/check_obstacle.py` |
| Ложные тревоги, leave-one-bag-out | **8.0 трека на км**, 13.8 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 13.6 на км, 28.1 % кадров | там же, без `--mbon-dir` |
| На незнакомой линии (памяти нет) | **23.5 на км**; без считывания 36.1 | `tools/evaluate.py --no-memory` |
| Дальность (полигон, 15 560 наблюдений) | человек стоя: рабочая дальность 80 м, P@50 = 0.71, P@100 = 0.48, **P@150 = 0.49** | `tools/plot_benchmark.py` |
| Человек, упавший на пути | P@50 = **0.50** (было 0.27), рабочая дальность 32 м | там же |
| Ложные тревоги, leave-one-bag-out | **5.7 трека на км**, 8.8 % кадров | `tools/evaluate.py --mbon-dir` |
| То же без обученного считывания | 11.9 на км, 20.3 % кадров | там же, без `--mbon-dir` |
| На незнакомой линии (памяти нет) | **14.7 на км**; без считывания 36.1 | `tools/evaluate.py --no-memory` |
| Вторая половина `new_data`: другой день, 3.27 км, не видена при обучении | **17.4 на км**, 31.4 % кадров | `tools/eval_new_data.py` |
| Дальность (полигон, 15 560 наблюдений) | человек стоя: рабочая дальность **100 м**, P@50 = 0.71, P@100 = 0.50, P@150 = 0.46 | `tools/plot_benchmark.py` |
| Человек, упавший на пути | P@50 = **0.50** (было 0.27), рабочая дальность 20 м | там же |
| Обработка кадра | медиана 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

@ -3,31 +3,31 @@
"bag": "doubleT_obstacle",
"frames": 190,
"path_m": 0.0,
"alarm_frames": 183,
"alarm_rate": 0.9631578947368421,
"fp_objects": 522,
"fp_tracks": 4,
"alarm_frames": 153,
"alarm_rate": 0.8052631578947368,
"fp_objects": 336,
"fp_tracks": 5,
"fp_per_km": NaN,
"fp_median_d": 106.83192412328344,
"fp_median_d": 114.4002904954952,
"obj_rate": 0.9947368421052631,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 36428
"train_size": 48221
},
{
"bag": "doubleT_platform",
"frames": 239,
"path_m": 200.74905739412534,
"alarm_frames": 48,
"alarm_rate": 0.200836820083682,
"fp_objects": 66,
"alarm_frames": 31,
"alarm_rate": 0.1297071129707113,
"fp_objects": 42,
"fp_tracks": 2,
"fp_per_km": 9.962686878640993,
"fp_median_d": 32.8633359224923,
"fp_median_d": 34.00916383990402,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 35491
"train_size": 46850
},
{
"bag": "roundT_doubleT",
@ -42,51 +42,51 @@
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 35861
"train_size": 47438
},
{
"bag": "roundT_pressureGate_roundT",
"frames": 239,
"path_m": 246.7637153487153,
"alarm_frames": 25,
"alarm_rate": 0.10460251046025104,
"fp_objects": 25,
"fp_tracks": 2,
"fp_per_km": 8.104919303770778,
"fp_median_d": 160.95291512943237,
"alarm_frames": 18,
"alarm_rate": 0.07531380753138076,
"fp_objects": 18,
"fp_tracks": 1,
"fp_per_km": 4.052459651885389,
"fp_median_d": 160.20564869629789,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 36114
"train_size": 47718
},
{
"bag": "roundT_squareT_pressureGate_squareT",
"frames": 239,
"path_m": 273.9439574444854,
"alarm_frames": 6,
"alarm_rate": 0.02510460251046025,
"fp_objects": 6,
"fp_tracks": 2,
"fp_per_km": 7.3007633337022915,
"fp_median_d": 161.27067444604893,
"alarm_frames": 4,
"alarm_rate": 0.016736401673640166,
"fp_objects": 4,
"fp_tracks": 1,
"fp_per_km": 3.6503816668511457,
"fp_median_d": 162.18072351126122,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 35654
"train_size": 46546
},
{
"bag": "squareT_platform_squareT_switch",
"frames": 239,
"path_m": 270.98139848673645,
"alarm_frames": 86,
"alarm_rate": 0.3598326359832636,
"fp_objects": 91,
"fp_tracks": 4,
"fp_per_km": 14.761160811544727,
"fp_median_d": 96.62033146128638,
"alarm_frames": 52,
"alarm_rate": 0.2175732217573222,
"fp_objects": 56,
"fp_tracks": 3,
"fp_per_km": 11.070870608658545,
"fp_median_d": 90.95581811929335,
"obj_rate": null,
"ms_p50": NaN,
"ms_p95": NaN,
"train_size": 33494
"train_size": 44039
}
]

Binary file not shown.

View file

@ -299,6 +299,7 @@ $$q = \underbrace{\min\!\left(\frac{n_\text{лучей}}{n_\text{ожид}(d)},1
| `fov_deg` | шире сектор → лучше кривые и стрелки, дороже обработка |
| `gain` / `leak` (CX) | быстрее подтверждение против устойчивости к шуму |
| `warn_evidence` | выше → меньше ложных тревог, позже обнаружение |
| `mbon_power` | вес наблюдения — вероятность обученного считывания в этой степени. Выше → строже: 1.0 → 1.5 срезает ложные на незнакомой линии с 23.5 до 14.7 на км ценой хвоста дальности за 130 м; 2.0 почти ничего не добавляет к ложным и теряет вдвое больше (EXPERIMENTS п. 17.4) |
| темп депрессии MB | выше → сильнее подавление знакомого, но риск заглушить и настоящее |
---

View file

@ -2420,4 +2420,181 @@ leave-one-bag-out.
собранных 19.09 при прежнем полу, и низких конструкций в колее не знает: на
знакомой линии их гасит только считывание. Выборку памяти надо собирать
проходом по 90 ГБ `new_data` — это следующий шаг, если понадобится ещё
снизить ложные на знакомой линии.
снизить ложные на знакомой линии. *Сделано — п. 17.1.*
## 17. После переноса: память на новом полу, край габарита, вторая половина `new_data`
Три хвоста п. 16, каждый мерился так же: полигон парно, ложные треки на двух
сценах, настоящий объект.
### 17.1. Память тоннеля на новом полу
Кэш кандидатов памяти был собран 19.09, при старом полу, и низких конструкций
между рельсами память не знала (п. 16.8). Пересобран целиком: шесть записей
(`tune_memory.py --collect-only`) и весь `new_data` (`stream_new_data.py`,
39 707 кандидатов против 30 902), память переобучена.
| | было | новая память |
|---|---:|---:|
| ложных треков/км, знакомая линия | 8.0 | **6.5** |
| кадров с тревогой | 13.8 % | **9.0 %** |
| посторонних на полигоне | 1226 | 1166 |
| полигон парно | | +78 / −95 |
| настоящий объект | 99.5 % | 99.5 % |
Незнакомой линии память не касается — там её нет по построению.
При резкости считывания 1.5, принятой в п. 17.4, разница между памятями
исчезает: на знакомой линии 5.7 и 5.7 ложных трека на км, полигон парно
+90 / −86, на записи с настоящим объектом кадров с посторонней тревогой
80.5 % против 96.3 %. Принята новая: она собрана на том же полу габарита,
что и детектор, и знает низкие конструкции между рельсами, которые детектор
теперь видит.
Все ближние потери — один сценарий: на `roundT_pressureGate_roundT` поезд
стоит в 14.1 м от сидящего человека или ящика у края габарита, рядом
гермозатвор, и предмет не подтверждается: 26 кадров подряд у сидящего, 10 у
ящика. Кандидат там —
слипшееся пятно (≈950 лучей, высота 0.40 м), считывание в нём не уверено
(p = 0.23–0.30), и держится он только на новизне: у старой памяти 0.27–0.28,
у новой 0.20–0.21. При нижнем пороге новизны 0.15 вес наблюдения падает в
2.4 раза, улика 0.36–0.39 против 0.04–0.05. Случай пограничный при любой
памяти, новая переводит его через край. У настоящего объекта новизна тоже
ниже (медиана 0.63 → 0.51), но выше 0.50, где множитель новизны уже равен
единице.
### 17.2. Край габарита у платформы
Шестнадцать ближних потерь после переобучения (п. 16.3) разобраны. У края
габарита (0.9 м) рядом с платформой нижняя часть человека слипается с
кромкой, и от кадра к кадру кандидат то слипшееся пятно (p ≈ 0.0–0.2 у любых
складок), то «верх»: обрывок 0.2 × 0.2 м на высоте 1.5–1.7 м, целиком в
габарите, с нулевым дефицитом до пола — по признакам навесное оборудование.
Такой верх старые складки оценивали в 0.74–0.99, новые — в 0.43–0.84. Улика
не добирает до порога 0.5, и решение приходит на 8.8–8.9 м вместо 10.7–17 м.
В среднем новые складки на ближних предметах почти так же уверены, как
старые (отложенные записи, 0–15 м: медиана p 0.993 против 0.996, доля
p ≥ 0.9 — 0.864 против 0.891), а уверенного фона на 60–120 м у них вшестеро
меньше (0.1 % против 0.7 %). Отсюда и падение ложных тревог: новое считывание
строже в целом и к нетипичному обрывку в частности.
Попытка лечения — положение 0.9 м в обучающей выборке: полигон его проверяет,
а в выборке были только 0, ±0.6 и ±1.2 м. Выборка 466 715 кандидатов, AUC
0.9849. **Край это не вылечило**: человек стоя на 0–15 м +5 / −5, сидя
+0 / −4. Зато считывание стало строже к фону:
| | было | с ±0.9 |
|---|---:|---:|
| ложных треков/км, незнакомая линия | 23.5 | **21.9** |
| то же, знакомая (новая память) | 6.5 | 6.5 |
| посторонних на полигоне | 1226 | 1175 |
| полигон парно | | +9 / −25 |
| настоящий объект | 99.5 % | 99.5 % |
На второй половине `new_data` (п. 17.3) — ничего: 23.9 и 23.9 ложных трека
на км. Не принято.
### 17.3. Считывание, обученное на `new_data`
Пять коротких записей сняты в один день (02.09) и вместе дают чуть больше
километра пути. `new_data` — двадцать минут непрерывной езды 17.09: 11 271
кадр в 221 шарде, 90 ГБ несжатого tar. Места на диске меньше архива, поэтому
он читается кусками прямо из tar: `tools/_new_data.py` распаковывает по пять
шардов во временный каталог и удаляет за собой.
Делить пришлось по времени, а не по кадрам: вставки в первую половину
(шарды 0–109) идут в обучение, ложные тревоги меряются на второй (шарды
110–220, 3.27 км пути), которую модель не видела ни в каком виде. Памяти
тоннеля в этой проверке нет, как на незнакомой линии. Выборка — прежние
333 739 кандидатов плюс 827 314 из 22 кусков первой половины, предметов
5.4 %, AUC складок 0.981.
| считывание | вторая половина `new_data`, ложных/км | кадров с тревогой | незнакомая линия | знакомая линия |
|---|---:|---:|---:|---:|
| прежнее | 23.9 | 38.5 % | 23.5 | 6.5 |
| с ±0.9 (п. 17.2) | 23.9 | 39.0 % | 21.9 | 6.5 |
| с `new_data` | **13.2** | **20.2 %** | **13.2** | 6.2 |
Ложных тревог почти вдвое меньше, и прокси незнакомой линии совпал с
честной проверкой. Но полигон парно — +39 / −259: у стоящего человека P@50
0.71 → 0.62, у лежачего P@100 0.22 → 0.11, у ящика 0.53 → 0.40.
Воспроизводится так:
```bash
python tools/make_training_set.py --new-data 0:110 --out data/cache/training_set_nd.npz
python tools/train_mbon.py --device cuda --data data/cache/training_set.npz --data data/cache/training_set_nd.npz --train-only new_data_ --save-folds artifacts/mbon_folds_nd --out artifacts/mbon_readout_nd.npz
python tools/eval_new_data.py --shards 110: --readout было=artifacts/mbon_readout.npz --readout nd=artifacts/mbon_readout_nd.npz --out nd_eval.json
```
`--train-only new_data_` — куски `new_data` только учат и складок не
получают: проверяются по-прежнему пять записей.
### 17.4. Строже или умнее: сравнение при равной строгости
Модель, которая вдвое реже тревожится и заметно реже видит предмет, может
быть не лучше, а просто строже. Это проверяется сравнением при одинаковой
строгости, и ручка для неё есть с п. 11: `mbon_power`, вес наблюдения
p^power. Все строки — с новой памятью (п. 17.1).
| считывание, резкость | вторая половина `new_data` | незнакомая | знакомая | посторонних на полигоне | полигон парно к первой строке |
|---|---:|---:|---:|---:|---:|
| прежнее, 1.0 | 23.9 | 23.5 | 6.5 | 1166 | |
| **прежнее, 1.5** | 17.4 | 14.7 | **5.7** | **793** | **+1 / −142** |
| прежнее, 2.0 | 15.0 | 14.3 | 5.7 | 628 | +0 / −268 |
| `new_data`, 1.0 | **13.2** | **13.2** | 6.2 | 822 | +39 / −259 |
| `new_data`, 0.7 | 24.8 | 16.4 | 7.7 | 927 | +72 / −184 |
* **Больше половины выигрыша — строгость.** Прежнее считывание при
резкости 1.5 срезает ложные на незнакомой линии с 23.5 до 14.7, а на
полигоне теряет 142 наблюдения из 7 600: 39 вблизи у края габарита (тот же
пограничный случай, что в п. 17.1–17.2) и 82 за 90 м.
* **Но не весь.** При сопоставимых ложных на второй половине `new_data`
(15.0 и 13.2) модель на `new_data` теряет на полигоне меньше, чем прежняя
при 2.0 (−220 против −268 в сумме). Другой день записи действительно
научил её тому, чего в пяти записях нет. Ослаблять её бесполезно: при 0.7
ложные возвращаются к 24.8.
* **Между «прежнее, 1.5» и «`new_data`, 1.0» — размен, а не доминирование.**
Вторая на четверть реже тревожится на данных своего дня, первая держит
дальность: P@50 у стоящего 0.71 против 0.62, P@100 у лежачего 0.22 против
0.11, у ящика 0.50 против 0.40. На пяти записях без памяти, которых при
проверке не видела ни одна из двух, они почти равны (14.7 и 13.2), на
знакомой линии прежняя лучше (5.7 и 6.2).
**Принято: прежнее считывание, резкость 1.5.** ТЗ просит баланс дальности и
ложных тревог, постановщик — прежде всего не видеть того, чего нет; резкость
1.5 даёт второе почти без цены в первом. Модель на `new_data` остаётся
рецептом из п. 17.3: если появятся ещё записи других дней, учить на них
выгоднее, чем ужесточать.
### 17.5. Итог
Новая память (п. 17.1), прежнее считывание, резкость 1.5 (п. 17.4) — против
опубликованного 23.09:
| | 23.09 | сейчас |
|---|---:|---:|
| ложных треков/км, знакомая линия | 8.0 (13.8 % кадров) | **5.7** (8.8 %) |
| то же, незнакомая линия | 23.5 (34.6 %) | **14.7** (25.9 %) |
| то же, вторая половина `new_data` | 23.9 (38.5 %) | **17.4** (31.4 %) |
| посторонних тревог на полигоне | 1226 | **793** |
| настоящий объект | 99.5 % | 99.5 % |
| человек стоя: рабочая дальность; P@50 / P@100 / P@150 | 80 м; 0.71 / 0.48 / 0.49 | **100 м**; 0.71 / 0.50 / 0.46 |
| человек сидя | 148 м; 0.67 / 0.52 / 0.49 | 122 м; 0.67 / 0.54 / 0.39 |
| человек лёжа | 32 м; 0.50 / 0.22 / 0.02 | 20 м; 0.50 / 0.22 / 0.00 |
| ящик | 20 м; 0.54 / 0.50 / 0.26 | 20 м; 0.57 / 0.50 / 0.22 |
| чемодан | 80 м; 0.61 / 0.45 / 0.24 | 80 м; 0.61 / 0.46 / 0.24 |
Ложных тревог примерно на треть меньше на всех трёх сценах, ближняя и
средняя дальность на месте. Цена — хвост за 130 м у сидящего человека
(P@150 0.49 → 0.39) и рабочая дальность лежачего 32 → 20 м: доля в полосе
20–32 м у него ходит у самого порога 0.5, и сдвинула её новая память, а не
резкость (P@50 тот же, 0.50). Без обученного считывания при тех же
настройках было бы 11.9 и 36.1 ложных трека на км.
Отдельно найдено при проверке сдачи: образ Docker **не загружал**
считывание — `mbon_path` был пустым, файла модели в образе не было, и узел
молча работал на ручной формуле. Все цифры выше замерены со считыванием,
образ теперь тоже с ним (`docker/Dockerfile`, `detect.launch.py`), а
дымовой тест образа проверяет, что модель на месте.

View file

@ -103,7 +103,13 @@ class Params:
# веса улики в центральном комплексе там, где модель передана конвейеру;
# без модели всё работает по-прежнему.
enable_mbon: bool = True
mbon_power: float = 1.0 # резкость: p**power перед смешиванием
# Резкость: вес наблюдения p**power. Рабочая точка 1.5 выбрана сравнением
# при равной строгости (EXPERIMENTS п. 17.4): ложных треков на незнакомой
# линии 23.5 → 14.7 на км, на знакомой 6.5 → 5.7, на второй половине
# new_data 23.9 → 17.4, а полигон теряет 142 наблюдения из 7 600: 39
# вблизи у края габарита (пограничный случай п. 17.1–17.2) и 82 за 90 м.
# 2.0 срезает ещё 127 наблюдений ради 0.4 трека на км.
mbon_power: float = 1.5
mbon_blend: float = 1.0 # 1 — только модель, 0 — только ручная формула
# Гашение знакомости с дальностью: за `nov_fade_from` её вклад падает
# до нуля к `nov_fade_to`. За 90 м новизна измеримо ВРЕДНА — у

72
tools/_new_data.py Normal file
View file

@ -0,0 +1,72 @@
"""Большой бэг `new_data` кусками прямо из tar-архива.
Это 90 ГБ в 221 шарде, а свободного места на диске меньше, чем весь архив.
Кусок из нескольких подряд идущих шардов распаковывается во временный
каталог, читается как обычный многошардовый бэг (`flyguard.bag.Bag`) и
удаляется. Шард достаётся по смещению в архиве, без повторного разбора
заголовков, поэтому куски можно распаковывать из нескольких процессов сразу.
"""
from __future__ import annotations
import os
import re
import shutil
import tarfile
import tempfile
from contextlib import contextmanager
from pathlib import Path
# Архив лежит там, куда его положили при скачивании датасета, а не в data/:
# распаковывать его целиком некуда. Путь переопределяется FLYGUARD_NEW_DATA
# или ключом --tar у инструментов.
DEFAULT_TAR = os.environ.get(
"FLYGUARD_NEW_DATA",
str(Path(__file__).resolve().parents[1] / "датасет" / "new_data"))
SHARD_RE = re.compile(r"_(\d+)\.db3$")
def shards(tar_path: str | Path) -> list[tuple[int, str, int, int]]:
"""(номер, имя файла, смещение данных, размер) всех шардов, по номеру."""
out = []
with tarfile.open(tar_path, "r:") as t:
for m in t:
mm = SHARD_RE.search(m.name)
if mm and m.isfile():
out.append((int(mm.group(1)), Path(m.name).name,
m.offset_data, m.size))
out.sort()
return out
def pick(members: list, spec: str) -> list:
"""Шарды по срезу номеров: `110:` — со 110-го до конца, `0:110` — первые 110."""
lo, _, hi = spec.partition(":")
lo_i = int(lo) if lo else 0
hi_i = int(hi) if hi else None
return [m for m in members if m[0] >= lo_i and (hi_i is None or m[0] < hi_i)]
def split(members: list, per_chunk: int) -> list[list]:
"""Подряд идущие шарды группами: каждая группа — одна «запись»."""
return [members[i:i + per_chunk] for i in range(0, len(members), per_chunk)]
@contextmanager
def chunk(tar_path: str | Path, members: list, workdir: str | None = None):
"""Распаковать шарды во временный каталог, отдать его путь, потом удалить."""
d = Path(tempfile.mkdtemp(prefix="fg_nd_", dir=workdir))
try:
with open(tar_path, "rb") as src:
for _, name, off, size in members:
src.seek(off)
with open(d / name, "wb") as dst:
left = size
while left:
buf = src.read(min(left, 1 << 22))
if not buf:
raise OSError(f"архив обрезан на {name}")
dst.write(buf)
left -= len(buf)
yield d
finally:
shutil.rmtree(d, ignore_errors=True)

104
tools/eval_new_data.py Normal file
View file

@ -0,0 +1,104 @@
"""Ложные тревоги на участке `new_data`, которого считывание не видело.
Прокси незнакомой линии в `evaluate.py --no-memory` — пять коротких записей,
вместе чуть больше километра пути, и все они сняты в один день. `new_data` —
двадцать минут непрерывной езды другим днём. Если учить считывание на
вставках в первую половину, а ложные тревоги мерить на второй, проверка
остаётся честной при любом ответе на вопрос, та ли это линия: второй половины
модель не видела ни в каком виде.
Вторая половина идёт одним непрерывным потоком — состояние конвейера (поза,
ось, треки) переносится между кусками, как в `stream_new_data.py`. Памяти
тоннеля нет. Несколько вариантов считывания гоняются по одним и тем же
кадрам, поэтому разница между ними — только от считывания. Вариант может
нести и свои настройки конвейера через `@`: так сравниваются модели при
одинаковой строгости (`@mbon_power=2`), а не только при умолчаниях.
python tools/eval_new_data.py --shards 110: \\
--readout было=artifacts/mbon_readout.npz --readout стало=new.npz \\
--readout строже=artifacts/mbon_readout.npz@mbon_power=2 \\
--out nd_eval.json
"""
from __future__ import annotations
import argparse
import json
import time
import _bootstrap as B # noqa: F401
import _new_data as ND
import _params as PS
from flyguard.bag import Bag
from flyguard.mbon_readout import MbonReadout
from flyguard.pipeline import FlyGuard, Params
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--tar", default=ND.DEFAULT_TAR)
ap.add_argument("--shards", default="110:",
help="номера шардов срезом; по умолчанию вторая половина")
ap.add_argument("--per-chunk", type=int, default=5,
help="сколько шардов распаковывать за раз")
ap.add_argument("--readout", action="append", default=[],
metavar="ИМЯ=ПУТЬ[@ПОЛЕ=ЗНАЧЕНИЕ...]",
help="вариант считывания, после @ — свои поля Params; можно несколько")
ap.add_argument("--out", required=True)
PS.add_argument(ap)
args = ap.parse_args()
if not args.readout:
raise SystemExit("нужен хотя бы один --readout ИМЯ=ПУТЬ")
members = ND.pick(ND.shards(args.tar), args.shards)
pipes, stats = {}, {}
for item in args.readout:
name, _, rest = item.partition("=")
path, *own = rest.split("@")
params = Params(**PS.apply({}, args.set + own, Params))
pipes[name] = FlyGuard(params, memory=None, readout=MbonReadout.load(path))
stats[name] = dict(frames=0, path_m=0.0, back_m=0.0, alarm_frames=0,
fp_objects=0, tracks=set())
print(f"шарды {members[0][0]}–{members[-1][0]} ({len(members)} шт.), "
f"вариантов считывания {len(pipes)}")
t0 = time.time()
for part in ND.split(members, args.per_chunk):
with ND.chunk(args.tar, part) as d:
for _, pc in Bag(d).frames():
for name, fg in pipes.items():
res = fg.process(pc)
if res is None:
continue
s = stats[name]
s["frames"] += 1
ds = res.ego.ds if res.ego else 0.0
s["path_m"] += ds
s["back_m"] += max(-ds, 0.0)
objs = res.decision.objects
if objs:
s["alarm_frames"] += 1
s["fp_objects"] += len(objs)
s["tracks"].update(o.track_id for o in objs)
line = " | ".join(f"{n}: {len(s['tracks'])} треков на {s['path_m'] / 1000:.2f} км"
for n, s in stats.items())
print(f" шарды до {part[-1][0]:3d}: {line} | {(time.time() - t0) / 60:5.1f} мин",
flush=True)
rows = []
for name, s in stats.items():
km = max(s["path_m"] / 1000.0, 1e-6)
rows.append(dict(readout=name, frames=s["frames"], path_m=s["path_m"],
back_m=s["back_m"], alarm_frames=s["alarm_frames"],
alarm_rate=s["alarm_frames"] / max(s["frames"], 1),
fp_objects=s["fp_objects"], fp_tracks=len(s["tracks"]),
fp_per_km=len(s["tracks"]) / km))
print(f"{name:12s} кадров {s['frames']:6d}, путь {s['path_m'] / 1000:6.2f} км "
f"(назад {s['back_m']:.0f} м) | кадров с тревогой {rows[-1]['alarm_rate']:6.1%} | "
f"ложных треков {len(s['tracks']):4d} = {rows[-1]['fp_per_km']:5.1f} на км")
with open(args.out, "w", encoding="utf-8") as f:
json.dump(rows, f, ensure_ascii=False, indent=1)
print("сохранено:", args.out)
if __name__ == "__main__":
main()

View file

@ -23,6 +23,11 @@
независимой проверкой того, что модель выучила предмет, а не «синтетику».
python tools/make_training_set.py --out data/cache/training_set.npz
Выборку можно собрать и из большого бэга `new_data`, кусками прямо из архива
(`--new-data 0:110` — первые 110 шардов, по пять на «запись»): двадцать
минут езды другим днём дают считыванию обстановку, которой нет в пяти
коротких записях (EXPERIMENTS п. 17).
"""
from __future__ import annotations
@ -31,6 +36,7 @@ import argparse
import numpy as np
import _bootstrap as B # noqa: F401
import _new_data as ND
import _parallel as P
import _params as PS
from flyguard.bag import Bag, find_bags
@ -43,9 +49,14 @@ MIN_OVERLAP = 0.5 # доля лучей ядра, пришедш
def _work(task):
"""Одна задача — один бэг. Верхнего уровня: иначе не передать в процесс."""
path, params, limit, d_starts, laterals, seed = task
return collect_bag(path, params, limit, d_starts, laterals, seed)
"""Одна задача — один бэг или кусок `new_data`. Верхнего уровня: иначе не
передать в процесс. Кусок распаковывается внутри воркера и удаляется за ним."""
src, params, limit, d_starts, laterals, seed = task
if isinstance(src, tuple):
tar, members = src
with ND.chunk(tar, members) as d:
return collect_bag(d, params, limit, d_starts, laterals, seed)
return collect_bag(src, params, limit, d_starts, laterals, seed)
def ego_track(bag: Bag, params: Params, limit: int):
@ -141,6 +152,12 @@ def main() -> None:
# красивая и бессмысленная, а в тоннеле у оси полно штатных конструкций.
ap.add_argument("--laterals", default="0,-0.6,0.6,-1.2,1.2")
ap.add_argument("--seed", type=int, default=20260921)
ap.add_argument("--new-data", default="",
help="срез номеров шардов new_data, например 0:110: собирать "
"выборку из кусков большого бэга вместо записей --root")
ap.add_argument("--tar", default=ND.DEFAULT_TAR, help="архив new_data")
ap.add_argument("--per-chunk", type=int, default=5,
help="шардов в куске new_data; кусок считается одной записью")
P.add_argument(ap)
PS.add_argument(ap)
args = ap.parse_args()
@ -150,13 +167,20 @@ def main() -> None:
params = Params(**PS.apply({}, args.set, Params))
B.CACHE.mkdir(parents=True, exist_ok=True)
bags = [p for p in find_bags(args.root) if p.name != HOLDOUT]
tasks = [(p, params, args.limit, d_starts, laterals, args.seed) for p in bags]
if args.new_data:
chunks = ND.split(ND.pick(ND.shards(args.tar), args.new_data), args.per_chunk)
names = [f"new_data_{c[0][0]:03d}_{c[-1][0]:03d}" for c in chunks]
sources = [(args.tar, c) for c in chunks]
else:
bags = [p for p in find_bags(args.root) if p.name != HOLDOUT]
names = [p.name for p in bags]
sources = bags
tasks = [(s, params, args.limit, d_starts, laterals, args.seed) for s in sources]
# Печатается по готовности, складывается по номеру задачи: порядок бэгов
# в файле не должен зависеть от того, какой из них досчитался первым.
slots: list = [None] * len(tasks)
for i, task, got, secs in P.run(_work, tasks, args.jobs):
name = task[0].name
name = names[i]
if got is None:
print(f" {name:42s} пропущен")
continue

View file

@ -36,9 +36,17 @@ BANDS = ((0, 30), (30, 55), (55, 80), (80, 110), (110, 160), (160, 230))
def loo(names, X, Y, cfg, n_pn, args, keep_models=False):
"""Обучение на всех бэгах кроме проверяемого. Возвращает список результатов."""
"""Обучение на всех бэгах кроме проверяемого. Возвращает список результатов.
Проверяемыми бывают не все записи: куски `new_data` нужны только для
обучения, и отдельная складка на каждый из них — лишние полчаса
(`--train-only`).
"""
skip = tuple(p for p in args.train_only.split(",") if p)
out = []
for held in names:
if skip and held.startswith(skip):
continue
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])
@ -53,7 +61,9 @@ def loo(names, X, Y, cfg, n_pn, args, keep_models=False):
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--data", default=str(B.CACHE / "training_set.npz"))
ap.add_argument("--data", action="append", default=None,
help="выборка make_training_set; можно несколько — записи "
"объединяются (по умолчанию data/cache/training_set.npz)")
ap.add_argument("--out", default=str(B.ARTIFACTS / "mbon_readout.npz"))
ap.add_argument("--n-kc", type=int, default=0,
help="ёмкость итоговой модели; 0 — взять лучшую из развёртки")
@ -61,6 +71,9 @@ def main() -> None:
help="ёмкости для развёртки; пусто — не разворачивать")
ap.add_argument("--active", type=int, default=100,
help="активных клеток после торможения APL")
ap.add_argument("--train-only", default="",
help="префиксы записей через запятую, которые только учат и "
"своей складки не получают, например new_data_")
ap.add_argument("--epochs", type=int, default=60)
ap.add_argument("--lr", type=float, default=4.0)
ap.add_argument("--l2", type=float, default=1e-5)
@ -73,12 +86,20 @@ def main() -> None:
"оценка нечестна — считывание увидит проверяемый бэг")
args = ap.parse_args()
d = np.load(args.data, allow_pickle=True)
names = [str(n) for n in d["names"]]
X = {n: d[f"X_{n}"].astype(np.float32) for n in names}
Y = {n: d[f"y_{n}"].astype(np.int8) for n in names}
D = {n: d[f"d_{n}"].astype(np.float32) for n in names}
feats = [str(f) for f in d["features"]]
names, X, Y, D, feats = [], {}, {}, {}, None
for path in args.data or [str(B.CACHE / "training_set.npz")]:
d = np.load(path, allow_pickle=True)
f_here = [str(f) for f in d["features"]]
if feats is not None and f_here != feats:
raise SystemExit(f"{path}: другой набор признаков — выборки несовместимы")
feats = f_here
for n in (str(n) for n in d["names"]):
if n in X:
raise SystemExit(f"запись {n} встречается в двух выборках")
names.append(n)
X[n] = d[f"X_{n}"].astype(np.float32)
Y[n] = d[f"y_{n}"].astype(np.int8)
D[n] = d[f"d_{n}"].astype(np.float32)
n_pn = next(iter(X.values())).shape[1]
tot = sum(v.shape[0] for v in X.values())
pos = sum(int(v.sum()) for v in Y.values())