Цель видна только через камеру, детектор отдает рамку, дальномера нет. В какой-то момент цель уходит из кадра на три четверти секунды, и ее надо продолжать вести вслепую. Классический фильтр Калмана в этой ситуации теряет цель в 84% эпизодов. Наш трекер теряет ее в 18% и возвращает в центр кадра в 80%.

трекер

держит цель вслепую

возврат в кадр

теряет цель

классический фильтр Калмана

−0,03

22%

84%

нейросетевой пилот

0,41

84%

8%

гибрид KalmanNet и нейросети

0,50

80%

18%

Первый столбец это косинус между носом аппарата и истинным направлением на цель за время слепоты: 1,0 идеально, 0 поперек, меньше нуля отвернулся. Фильтр Калмана на монокуляре уезжает, потому что без дальности ему нечего экстраполировать. Гибрид, у которого структура взята от фильтра, а коэффициент усиления выдает обученная сеть, обходит и классику, и чистую нейросеть.

Это конец истории. Начало выглядело так: учитель, которого мы написали, чтобы показать нейросети, как надо, за 60 попыток не попал ни разу. Он честно решал уравнение встречи, вычислял точку перехвата, разворачивался и с достоинством уходил от цели, потому что закон управления мы написали с минусом, как в учебнике, а в нашей системе координат положительная команда по рысканью доворачивает влево.

И это был не худший день кампании. Худший растянулся на три недели, все это время мы выбирали лучшие модели по числу, которое мерило качество наведения в тот момент, когда аппарат уже летел по инерции и не решал ничего.

Пять недель работы команды MoranaLabs, 12 версий, 57,5 миллиона шагов на счетчике финальной модели. Ниже полная хроника: что ломалось, как чинили, какие цифры вышли. Ошибок собственно нейросети в этом списке почти нет.

Дисклеймер!

Слово «перехватчик» включает у читателя не те ожидания, поэтому сразу: все происходит внутри симулятора. Физика в numpy, среда на Gymnasium, обучение на stable-baselines3. Ни железа, ни полетов, ни испытаний. Ни одна из моделей, о которых пойдет речь, никуда не деплоилась, и деплоить ее некуда.

Калибровка стенда публичная и намеренно грубая. Тяговая кривая аппарата взята из экспорта OpenRocket, софта для любительского ракетомоделирования: стартовая масса 1,835 кг, пиковая тяга 215 Н, полный импульс около 247 Н·с, отсечка примерно на 1,6 секунды. Аэродинамика считается по школьным соотношениям с сайта NASA Glenn. Габариты и скорости целей взяты из публичных спецификаций гражданских дронов DJI. Закрытых коэффициентов у нас нет, и стенд их не изображает.

Задача интересна не темой, а режимом. Эпизод длится около трех секунд, наблюдение частичное и шумное, награда почти вся терминальная, ошибка восприятия убивает эпизод целиком. На таком горизонте любая методическая небрежность вылезает за один прогон. На длинном горизонте с плотной наградой ее можно не замечать месяцами, и обычно так и происходит.

В MoranaLabs это была внутренняя работа под простой вопрос: умеем ли мы держать объект, когда детектор моргает. Отвечать на такое дешевле на стенде, где камеру можно выключить по команде ровно на 0,75 секунды и посмотреть, что осталось в скрытом состоянии сети. В проде такой возможности нет.

Что видит пилот

Политика получает то же, что имел бы человек в кабине: рамку от детектора, свою ориентацию, угловые скорости, недавние команды на рули и возраст последнего кадра. Отдает четыре канала PWM на рули. Дальности до цели, ее координат и скорости в наблюдении нет: эти величины живут внутри симулятора и идут только в физику, награды и диагностику.

frame:  [bbox_center_x, bbox_center_y, bbox_width, bbox_height, confidence,
         bbox_dx, bbox_dy, bbox_area_delta,
         lost_frame_age,
         roll, pitch, yaw,
         roll_rate, pitch_rate, yaw_rate,
         last_fl, last_fr, last_rl, last_rr,
         frame_age]
action: [front_left_pwm, front_right_pwm, rear_left_pwm, rear_right_pwm]

Камера работает на 80 Гц, шаг симуляции 12,5 мс. Цель появляется на дальности от 200 до 500 метров и летит в одном из пяти режимов: навстречу, от нас, параллельным курсом, поперек нашего курса или наискось. Поперечных целей примерно 30% спавнов, и они же почти всю историю проекта стояли нулем в таблице результатов.

Успех засчитывается за проход в пределах радиуса захвата, причем по непрерывной траектории, а не по расстоянию в момент последнего кадра. Радиус зависит от уровня сложности: 9 метров на легком, 1,8 метра на максимальном при видимом размере цели от 0,16 до 0,95 метра. Все цифры дальше, если не оговорено иное, сняты на легком уровне, на 60 эпизодах с фиксированными сидами. Оговорка не формальная: половина ошибок кампании связана с тем, на каком срезе мы мерили.

Стенд, который врет одинаково всем

Симулятор, который врет приятным образом, обучает политику, которая на реальных данных разваливается. Поэтому работы в физике меньше, чем в аккуратной порче данных.

Детектор роняет кадры поодиночке и пачками до 60 кадров подряд, после чего цель возвращается. Камера имеет ошибку калибровки, смещение главной точки, смаз и разброс поля зрения. Задержка тракта плавает от кадра к кадру. Рулевые машинки получают ошибку усиления, смещение, мертвую зону, ограничение скорости и задержку команды, причем иногда машинку перенастраивает прямо посреди эпизода, как будто в тракте что-то поехало. Мотор, масса, тяга, сопротивление и эффективность рулей случайны в каждом эпизоде: политика не должна знать заранее, на какой именно машине она сегодня летит. Сверху ветер и турбулентность. Цель делает боковые срывы, пикирует, уходит вверх и за камеру.

Отдельная правка пришла из практики, а не из теории. Первые версии стенда рисовали идеально стабильную рамку, привязанную к геометрии цели. Настоящий покадровый детектор так себя не ведет: он дышит размером и подрагивает центром, даже когда в кадре ничего не движется. Мы добавили в камеру коррелированный джиттер центра и размера (AR(1), коэффициент 0,85), ошибка пеленга выросла с 0,84 до 1,13 градуса, и все финальные замеры сделаны уже на этом стенде.

И последняя деталь конструкции, к которой придется вернуться дважды: аппарат разгонный. Мотор работает 2 секунды, все успешные сближения происходят в интервале от 1,8 до 2,5 секунды на скоростях 94 до 125 м/с, дальше начинается баллистика. К четвертой секунде эпизода аппарат летит по инерции и снижается.

Три первые версии, каждая хуже предыдущей

Первый пилот выучился неожиданно быстро. На 7 миллионах шагов он попадал в 27% эпизодов, ни разу не разбился и ни разу не завалил геометрию. Проигрывал он одним способом: в 73% эпизодов цель уходила из кадра и не возвращалась.

Причина лежала в конфиге. Эпизод объявлялся проигранным, если цель потеряна 16 кадров подряд. На 80 Гц это 200 миллисекунд. За 200 мс поведение «поищи, куда она делась» не выучивается никогда: эпизод обрывается раньше, чем политика успеет получить хотя бы один пример удачного возврата. Мы учили ее искать цель и наказывали за то, что она начинала искать.

Вторая версия должна была это починить, а стала учебным пособием по тому, как не надо. Логика была нормальная человеческая: дадим больше информации и больше времени. Увеличили глубину истории, удлинили эпизод. Наблюдение раздулось до сотен признаков, почти одинаковых между собой, градиент размазался, а длинный эпизод стал набирать больше пошаговых штрафов. Ветка так и осталась в репозитории с пометкой failed/diagnostic. Она доказала ровно одно: больше входов и больше времени сами по себе делают хуже.

Третья попытка провалилась изящнее. Мы добавили в награду маленький бонус за то, что политика ведет себя как простой визуальный сервопривод, тянущий рамку в центр кадра. Идея разумная: пусть PPO не переоткрывает очевидную коррекцию джойстиком через редкие терминальные события. Вес поставили 0,42.

Потом мы померили сам сервопривод на этой же среде. Он попадал в 3% эпизодов.

То есть мы с положительным коэффициентом притягивали политику к поведению, потолок которого в десять раз ниже того, что уже умела первая версия. Сверху лежала вторая ошибка, арифметическая: сумма пошаговых штрафов за длинный эпизод перевешивала терминальный бонус за успех. Политике было выгоднее аккуратно умереть пораньше, чем долго лететь и рискнуть. Третья версия вышла на плато 5% и осталась там.

Четвертую версию собирали уже с оглядкой. Приор выключен, штрафы урезаны вдвое, бонус за успех поднят до 8000, окно потери расширено до 96 кадров, физика переписана с плоской модели на нормальную: ориентация интегрируется кватернионом, тяга идет по кривой с выходом на режим, выгоранием и спадом, масса убывает, аэродинамика считается в связанной системе координат.

Из этих трех неудач у нас осталось три правила, которые дальше не нарушались. Прежде чем притягивать политику к эксперту, померь эксперта на своей среде: приор работает как потолок. Сравни интеграл пошаговых штрафов с терминальным бонусом на салфетке, до запуска. И помни, что окно, за которое эпизод считается проигранным, задает, какое поведение вообще может быть выучено.

Измеритель, который читал не тот уровень словаря

К маю у нас была отдельная ветка трекера, обученная на 15 миллионах шагов, и вела она себя странно.

Композит качества стоял на −1,02, то есть хуже наивного бейзлайна. Мы написали пробник мир-модели: выключаем детектор на 2,5 секунды и смотрим, продолжает ли политика держать нос на цели, пока цель невидима. Пробник выдал −0,02 при разбросе 0,33. Нос смотрел куда попало.

Рядом работал адаптивный куррикулум, который должен был раздвигать границы возмущений по мере успехов. За 19 500 эпизодов он не сдвинул ни одной границы и только сужал конверт.

Обе истории оказались одним багом. Среда складывала часть метрик не в корень словаря info, а во вложенный info["debug"]. Пробник и колбэк куррикулума читали их с верхнего уровня, получали None, аккуратно подставляли ноль и считали статистику по нулям. Пробник измерял не отсутствие мир-модели, а нашу невнимательность. Куррикулум не видел ни одного успеха, поэтому решал, что политике тяжело, и сжимал границы.

Второй баг того же семейства лежал рядом. Куррикулум раздавал границы всем средам разом и переписывал активную ось прямо посреди эпизода, поэтому результат приписывался не той оси, которая в этом эпизоде проверялась. Починили латчем: среда фиксирует ось на reset и сама сообщает ее в конце, а колбэк раздает оси по средам поштучно.

Цена двух багов: весь накопленный стейт куррикулума в мусор и почти принятое решение перестраивать архитектуру под вывод «политика не строит мир-модель».

Когда метрика показывает идеальный ноль или идеальное отсутствие эффекта, первая гипотеза не «модель не умеет», а «измеритель отвалился». Проверяется за десять минут: подсуньте измерителю заведомо хороший вход и убедитесь, что он выдает заведомо хорошее число. Мы этого не сделали и заплатили двумя неделями.

Критик, которому можно подглядывать

После починки измерителей выяснилось, что мир-модели у политики и правда нет, просто не в тех масштабах, которые показывал сломанный пробник. LSTM в принципе способна интегрировать движение цели в скрытом состоянии, но никакого стимула это делать у нее не появится, если награда платит только за реакцию на видимую рамку.

Седьмая версия собрана из двух чужих идей, обе к тому времени лежали в открытом доступе несколько лет.

Первая: асимметричный критик. Актор видит только камеру, критик дополнительно видит привилегированный вектор истины из симулятора (направление на цель, относительная скорость, дальность, скорость сближения, ветер, коэффициенты управляемости). Прием из Pinto et al., 2017: в симуляторе полное состояние есть бесплатно, и не использовать его для обучения оценки ценности глупо. На экспорте критик выбрасывается, так что утечки на деплое не бывает по построению.

Вторая: мир-модель учится отдельной задачей. Скрытое состояние LSTM актора обязано на каждом шаге предсказывать тот самый привилегированный вектор, за это платит обычный MSE. Во время слепоты выполнить такое предсказание можно единственным способом: интегрировать динамику цели внутри скрытого состояния. Мы перестаем ждать, что мир-модель заведется сама от редкого RL-сигнала, и учим ее плотным супервизорным градиентом на каждом шаге. Побочный эффект приятный: сеть заодно занимается идентификацией собственной динамики по истории отклика на команды, как в RMA.

Алгоритм под этим: PPO в рекуррентном варианте из sb3-contrib, у которого мы переписали train(), добавив вспомогательный лосс. Актор смотрит на 5 отсчетов истории по 20 признаков и держит LSTM на 256 юнитов.

Третья правка была скучной, но без нее ничего не поехало бы. В награде предыдущей версии терминальные бонусы измерялись тысячами, а пошаговые единицами, и функция ценности жила в диапазоне, где градиенты вели себя как американские горки. Мы умножили все величины наград на 0,01 по белому списку полей, не трогая геометрию и пороги. Ценность переехала в район ±100, обучение успокоилось.

Первый длинный прогон на 30 миллионах шагов против старой ветки:

метрика

старая ветка

v7, 26,5M шагов

слепое удержание цели

−0,02

0,59

возврат цели в кадр после 1,5 с слепоты

12,5%

84%, медиана 0,19 с

уходы цели из кадра

73 до 93%

около 0%

попадания на легком уровне

0%

25%

Политика, которая не видит цель полторы секунды, продолжает держать на нее нос и возвращает ее в центр кадра за пятую долю секунды после появления. Ровно то, ради чего вспомогательный лосс и ставился.

85% аварий, которые оказались не авариями

Прогон мы вели с остановками на диагностику, и три правки по ходу дали больше, чем все остальные тюнинги вместе.

В метриках висело: 85% эпизодов помечены как авария. Читается это как «политика врезается в землю», и первым желанием было усилить штраф. Вместо этого мы открыли 15 аварийных эпизодов подряд и прокрутили их покадрово. Столкновений на тяге там не было ни одного. Все 15 выглядели одинаково: отработали 2 секунды мотора, не свели геометрию, вышли из режима, потеряли энергию и через несколько секунд аккуратно спланировали вниз. Симулятор называл это аварией, хотя по смыслу событие было другое: не врезался, а промахнулся и умер от нехватки энергии.

Разделили на два случая. Лобовому столкновению на тяге оставили полный штраф 60, энергетической смерти после выгорания дали 5. Полный штраф учил политику бояться сближения вообще. Мелкий учит не тянуть время, потому что после выгорания решать уже нечем.

Заодно проверили, не жалуемся ли мы на физику. По тяговой кривой аппарат проходит 285 метров к третьей секунде и держит около 122 м/с, спавны от 200 до 500 метров этим конвертом покрыты с запасом. Промахи были не оттого, что не долетает, и соблазн раздуть тягу отпал сам.

Дальше нашелся терм за упреждение, то есть за то, что нос смотрит не в текущее положение цели, а в точку встречи. Его вес был завышен вчетверо относительно всего остального, и политика честно оптимизировала то, за что платят: разворачивалась в упреждение, теряла на доворотах энергию и сваливалась в погоню хвостом. Уменьшение веса до 0,022 удвоило попадания, с 13% до 25%.

У того же терма обнаружился гейт: упреждение оплачивается, только если скорость сближения выше 6 м/с. Логика понятная, гейт защищал от оплаты упреждения при расхождении. На поперечных целях скорость сближения в начале как раз около нуля, так что гейт выключал градиент ровно на том классе целей, ради которого терм и существовал. Чтобы начать сближаться, надо упредить. Чтобы получить деньги за упреждение, надо уже сближаться. Порог опустили с 6 до 2 м/с.

Следующая версия добавила разгрузку центрирования: когда политика правильно летит с упреждением, рамка неизбежно уезжает от центра кадра, и центрирующие термы за это штрафуют. Мы сделали центрирующий кредит зависимым от качества упреждения.

метрика

v7

v7.1

попадания, всего

25,0%

33,3%

навстречу / от нас / параллельно

40 / 33 / 67%

50 / 53 / 78%

слепое удержание

0,59

0,624

возврат цели в кадр

84%

89%

поперечные цели

0 из 22

0 из 22

Последняя строка не сдвинулась ни на процент, и она же съела следующие три недели.

Стена

Поперечные цели не брались ничем. Промах на них сдвинулся с 30,1 до 28,7 метра при радиусе захвата 9 метров, то есть не сдвинулся: политика подходила на три радиуса и проходила мимо.

Механика понятная. Чтобы попасть в цель, пересекающую твой курс, надо лететь в точку встречи, а не в саму цель. Но летя в точку встречи, ты выносишь рамку из центра кадра, а вся визуальная часть награды устроена вокруг центра. Политика оказывается между двумя градиентами: центрирование платит плотно и на каждом шаге, попадание платит редко и только в конце эпизода, так что выигрывает тот, кто платит чаще.

Мы перебрали веса и убедились, что дело не в них. Политика не открывает упреждение градиентным поиском в принципе: чтобы получить первый положительный пример поперечного захвата, надо уже уметь упреждать, а случайно такое не выходит, потому что нужное поведение не одношаговое, оно требует согласованной работы всей траектории. Это классическая яма разведки, и вылезти из нее изнутри задачи нельзя.

Положительный пример надо было принести снаружи.

Учитель, который уверенно улетал от цели

Скриптовый учитель читает полное состояние симулятора, решает квадратное уравнение встречи и летит в расчетную точку перехвата. На борт такой не поедет никогда, потому что берет данные, которых там нет. Его работа другая: доказать, что задача в этой среде решаема, и наштамповать примеров для подражания. Схема стандартная, Learning by Cheating.

С этого места и начинается история из первого абзаца статьи. Первая версия учителя дала 0 попаданий из 60. Не «мало», а ноль, что для скриптового наведения по полному состоянию невозможно физически.

Полчаса на импульсный тест каналов (подать единичную команду и посмотреть, куда пошел угол) сняли вопрос: yaw_cmd = +1 доворачивает влево, а закон был написан с минусом. Учитель вычислял правильную точку встречи и отворачивался от нее. Ошибка тупейшая, зато дешевая: она ломает вообще все, поэтому видна на первом прогоне. С тех пор конвенции знаков висят в докстринге модуля.

Дальше три свипа по 60 эпизодов:

конфигурация учителя

попадания

поперечные

чистый пропорциональный закон

27%

2 из 22

плюс демпфирование по угловой скорости

40%

8 из 22

плюс баллистическая поправка

85%

18 из 22

Первая прибавка ожидаемая: без демпфирования учитель раскачивал аппарат вокруг нужного направления, ловил перерегулирование и тратил энергию на болтанку.

Вторая прибавка выросла из наблюдения, которое мы сначала записали в шум. Учитель промахивался на 11 метров при радиусе захвата 9, причем всегда в одну сторону, вниз.

Он целился в точку встречи и не учитывал, что за время полета до нее аппарат просядет под собственным весом. При типичных двух секундах это примерно 20 метров. Поправка 0,5 g t_go² в целевую точку подняла попадания с 40% до 85%.

Оптимальной оказалась не полная поправка, а половинная, коэффициент 0,55. Полная давала перелет и роняла результат до 68%: часть просадки добирает подъемная сила корпуса и рулей, которой в формуле нет. Вывести правильный коэффициент из физики нельзя, его надо померить, и это отдельный маленький урок про то, зачем в проекте нужен быстрый свип.

Была и четвертая идея, которую данные завернули. Учитель считает время до встречи по текущей скорости, а аппарат в это время еще разгоняется, значит время завышено и упреждение избыточно. Сделали расчет с учетом остатка тяги, ожидая прибавки, получили падение с 40% до 33%. Лишнее упреждение по текущей скорости, оказывается, компенсировало задержку рулевого тракта: убрав одну ошибку, мы обнажили другую. Флаг остался в коде выключенным, с комментарием, почему.

Итог: 85% на легком уровне, 48% на среднем, 8% на максимальном. Последняя цифра важнее первой. Чистой кинематикой максимальная сложность не берется, там нужна политика, которая умеет работать с шумом и отказами.

Дистилляция: три захода, три разные причины провала

Дальше по учебнику полагается дистилляция: учитель умеет, студент нет, гоняем DAgger и переносим навык.

Первый заход убил политику за один раунд. Теплым стартом взяли лучший чекпоинт (33% попаданий, 36,5 миллиона шагов за спиной) и начали учить его повторять действия учителя по MSE с обычным темпом 3e-4. После первого раунда студент показывал 2%.

В логе видно, что произошло:

round 0: bc=54.93  total=0.02  {'crossing': '0/22', 'inbound': '1/10', ...}
round 1: bc=0.351  total=0.03
round 7: bc=0.157  total=0.23

Голова PPO выдает не действия, а параметры распределения, и ее средние спокойно живут далеко за пределами [-1, 1]: среда все равно клипует, поэтому политике выгодно уехать в район ±30 и получить почти детерминированное поведение на границе. Учитель командует нормированно. Лосс на первом батче 55, местами 134. Такой градиент политику не подправляет, он ее перезаписывает. За семь следующих раундов студент отполз до 23%, но это уже была другая политика, собранная почти с нуля на 300 тысячах шагов против 36 миллионов у оригинала.

Второй заход добавил якорь (huber вместо MSE, ограничение нормы градиента) и студента сохранил: 30 до 32% во всех десяти раундах. Поперечные остались нулем, лосс имитации застрял на 2,7 и не двигался с места.

Тот же диагноз с другой стороны. Мы просили политику совпасть с учителем в пространстве сырых средних, где ее рабочее значение отличается от учительского на порядки. Чтобы сдвинуть среднее с 30 до 0,5, нужен огромный шаг по параметрам, а ограничение нормы его не пропускает. Формально учили правильному, практически просили сеть переехать в другую систему единиц через игольное ушко.

Починка заняла одну строчку. Вместо жесткого клипа мы стали сравнивать учителя со студентом через протекающий: значение внутри диапазона идет как есть, хвост снаружи сжимается в 20 раз, но не обнуляется.

def lclip(x, leak=0.05):
    # внутри [-1,1] как есть, снаружи сжато, но градиент жив
    return x.clamp(-1.0, 1.0) + leak * (x - x.clamp(-1.0, 1.0))

Лосс вернулся в человеческий масштаб (1,0 до 0,32), студент остался живым на 33%, и в таблице впервые появился поперечный захват: 1 из 22. Он мерцал от раунда к раунду и держаться не умел, но это был первый не-ноль за месяц.

Радость держалась ровно до следующего прогона. Стоило продолжить обычным PPO поверх дистиллята, и навык рассосался: за 3 миллиона шагов поперечные вернулись к 1 из 22, промах вырос, общий результат просел до 28%. Награда тянет обратно в погоню хвостом быстрее, чем редкие поперечные успехи успевают усилиться. Прогон убит на третьем миллионе, папка помечена как мусор.

Kickstart: оба градиента одновременно

Из трех провалов дистилляции вылезала одна и та же мысль: имитация и награда мешают друг другу, когда идут по очереди. Работает не «сначала имитация, потом RL», а оба сразу. Это Kickstarting Deep RL от DeepMind: имитационный терм сидит внутри обновления политики и постепенно затухает до нуля, пока RL не подхватит навык собственным преимуществом.

Три места, где такая реализация обычно ломается, и как их пришлось обойти:

  • обертка кладет в info действие учителя не для текущего состояния, а для того, из которого сделан шаг: именно оно выровнено с переходом, который попадет в буфер;

  • колбэк пишет в слот buffer.pos, потому что в sb3-contrib он вызывается до того, как буфер примет переход;

  • буфер расширен параллельными массивами, и паддить их надо тем же секвенсером, что и действия, иначе рекуррентные последовательности разъезжаются.

Первый прогон дал два интересных среза. На 42,5 миллионах рекорд по общему результату, 38,3%, поперечных нет. На финале 46,5 миллионов: 5 поперечных захватов из 22 и 4 из 4 наискось, первые нейронные попадания по поперечным целям за всю кампанию. Заодно развалились параллельные цели (0 из 9) и уходящие (13%).

Разбивка расхождения студента с учителем по фазам эпизода показала, где сидит проблема: в первой секунде 0,41, дальше 0,18. Решение о том, лететь ли с упреждением, принимается на разгонном участке, и именно там студент учителя не слушал. Мы дали тройной вес имитации на первые две секунды.

Кардиограмма

Дальше три недели ушло на попытки удержать оба навыка сразу. Ни одна не сработала, зато они хорошо показывают, как выглядит конкуренция навыков изнутри.

Попытка удержать оба навыка равномерным поводком (не давать имитационному весу падать ниже 0,3) кончилась за 3 миллиона шагов: погоня восстановилась до 38,3%, поперечные вымыло в ноль, причем еще при силе поводка около 0,8. Частая награда за погоню перетягивает равномерную имитацию просто потому, что встречается чаще.

Попытка сделать поводок адресным (имитируем в полную силу только на поперечных режимах, на догонных почти отпускаем) дала рекорд погони за всю историю, 43,3%. Поперечных ноль. Из бассейна погони упреждение не вытягивается даже прицельным якорем: компромисс «близко, но мимо» собирает достаточно сформированной награды, чтобы политике не было смысла меняться.

Попытка стартовать из чекпоинта, который уже умел упреждать, дала вот это:

чекпоинт

попадания

поперечные

52,5M

21,7%

2 из 22

53,5M

36,7%

1 из 22

54,5M

45,0%

1 из 22

55,5M

25,0%

6 из 22

56,5M

35,0%

1 из 22

финал

35,0%

0 из 22

Оба рекорда кампании взяты в одном прогоне и ни разу вместе. Политика ходит по синусоиде между двумя режимами поведения и проскакивает точку баланса меньше чем за миллион шагов. Эта таблица читается как кардиограмма: сердце работает, пациент не выздоравливает.

Последняя попытка была аккуратной: снизить темп обучения в десять раз, до 3e-5, и отполировать чекпоинт с шестью поперечными захватами. Они слетели за 250 тысяч шагов, погоня отросла до 43 до 45%.

На этом мы признали, что настройками это не лечится. Одна LSTM-политика с одной головой два навыка не держит: они делят одни и те же веса, а среда просит то одно, то другое в зависимости от того, как летит цель.

Чемпион, которому нельзя доверять

Пока мы гонялись за поперечными, случилась вторая неприятность, которую едва не пропустили.

Модель с 43,3% попаданий выглядела безусловно лучшей за кампанию. Пробник мир-модели мы прогнали на ней скорее по привычке, чем по подозрению.

метрика

сбалансированная модель

чемпион по попаданиям

слепое удержание цели

0,624

0,303

возврат цели в кадр

89%

53%

уходы цели на максимальной сложности

8%

35%

Имитационная кампания перестроила скрытое состояние сети так, чтобы копировать учителя, и способность вести цель вслепую упала вдвое. Учителю мир-модель не нужна, он видит все. Студент, копируя его действия, постепенно перестает тратить емкость сети на предсказание того, чего не видит.

Противнее всего, что вспомогательный лосс при этом оставался маленьким и выглядел здоровым. Он считается по всем шагам, а подавляющее большинство шагов идут с видимой целью, где предсказание тривиально. Деградация сидела в редких слепых участках и в среднем не проявлялась.

С тех пор пробник гоняется на каждом срезе любой дистилляции, а не в конце. Правило за этим стоит более общее: если у модели есть навык, который не входит в метрику отбора, он будет потерян. Мы отбирали по попаданиям и получили рекордные попадания при мертвой памяти, то есть ровно то, что заказывали.

Починили это позже двумя движениями: имитацию выключили на слепых кадрах полностью (пусть эти шаги учат только RL и вспомогательный лосс, то есть те градиенты, которые мир-модель и построили), а сам пробник добавили в критерий отбора лучшей модели.

21 600 эпизодов молчания

Параллельно шла отдельная линия, которая уперлась в ту же стену с другой стороны.

Автоматическая доменная рандомизация в духе OpenAI ADR устроена просто: справляешься, раздвигаем границы возмущений по одной оси, перестал справляться, сдвигаем обратно. У нас таких осей 43, от длительности пропаданий детектора до мертвой зоны машинок, и на выходе должна получиться карта того, сколько безобразия политика терпит.

Первый прогон отработал 21 600 эпизодов и не сдвинул ни одной границы. Ни одной из 43, ни разу за весь прогон.

Соблазн был снова обвинить колбэк, тем более что он у нас уже один раз врал. В этот раз он оказался честным, а вранье сидело в критерии. Успехом считалось попадание, порог расширения стоял на 35%, а на границах конверта, то есть там, где критерий и применяется, попаданий 0 до 15%. Условие роста было недостижимо ровно в тех точках, ради которых его писали.

Заменили на «попадание или промах в пределах 15 метров», то есть на почти-успех, который на границе еще встречается. Второй прогон сдвинул 21 ось из 43. Пропадания детектора выросли с 80 до 100,6 кадра (1,26 секунды слепоты), маневренность цели на 14 до 17%, разброс тяги до ±47%, мертвая зона машинок на 36%.

Устойчивость выросла честно: уходы цели на максимальной сложности упали с 8% до 4%, медианный промах там же с 49,8 до 43,9 метра. Попадания на легком уровне при этом рухнули с 33,3% до 18,3%.

Дальше мы два прогона пытались вернуть номинал: сначала консолидацией внутри выросшего конверта, потом смесью 50 на 50 из номинальных и граничных эпизодов. Отползти удалось до 25 до 27%, и мир-модель при этом просела. Политика внутри широкого конверта дрейфует в поведение «робастно, но мимо», потому что номинального градиента в данных почти нет, и взяться ему неоткуда.

Счет к этому моменту стал 2:0 не в нашу пользу. Погоня против упреждения, номинал против устойчивости, а к концу статьи добавится третья пара, точность оценки против удержания носа. Три разные подзадачи, один диагноз: политика не обусловлена контекстом и усредняет поведение по всем ситуациям, в которые попадает.

Переключатель, который сам себя выключает

Структурное решение напрашивается: две головы у актора, погоня и упреждение, и переключатель, который решает, чья очередь. Напрашивается оно ровно до вопроса, на чем учить переключатель. На этом вопросе мы потеряли неделю и несколько прогонов.

Первый кандидат в целевой сигнал очевиден: ярлык режима из симулятора. Мы точно знаем, что вот эта цель заспавнена поперечной, вот эту достаточно догонять. Обучаем классификатор по кадру, получаем потолок точности 0,65 и останавливаемся. Ярлык навешивается при спавне, а дальше цель маневрирует, и реализованная геометрия у режимов перекрывается: уходящая цель в развороте выглядит поперечной, поперечная в конце маневра выглядит параллельной. Классификатор упирается не в емкость, а в то, что мы просим его угадать намерение по мгновенной картинке.

Второй кандидат кажется красивым и оказывается ловушкой. Возьмем поперечную составляющую относительной скорости: у догонной геометрии она мала, у поперечной велика, физика прозрачная. Проблема вскрывается, когда смотришь, что делает с этой величиной правильное поведение. Полет с упреждением обнуляет поперечную составляющую относительной скорости, в этом его смысл. То есть переключатель, обученный на таком признаке, учится выключать голову упреждения ровно в тот момент, когда она отработала как надо. Мы честно обучили эту версию и только на графике поняли, почему она никуда не едет.

Сработал третий вариант, внешний по отношению к нам: поперечность скорости самой цели относительно линии визирования. Что делаем мы, на эту величину не влияет, поэтому петли нет, а разделяет она режимы почти бинарно. Вот первые полсекунды эпизода из лога сбора датасета:

chase [0-0.5 с]  цель g* = 0,026
lead  [0-0.5 с]  цель g* = 0,981

Оставалась мелочь: скрытое состояние такой признак вычислить не может в принципе. В привилегированном векторе лежала только относительная скорость, а разложить ее на скорость цели и свою собственную, не имея второй компоненты, нельзя. Расширили привилегированный блок с 17 до 23 признаков, и после этого признак стал достижим.

Отдельного прогона стоило открытие, что сильная супервизия через общий ствол сети прожигает политику. При весе лосса переключателя 4,0 попадания упали с 23% до 5%: градиент классификатора идет через те же слои, что и управление, и переписывает представление под свою задачу. Переключатель вынесли на отдельный GRU-энкодер, градиент которого ствола не касается.

Заплатили за эту линию еще раз, уже привычным способом. Кампания с обучением голов и переключателем снова выела мир-модель, слепое удержание упало до 0,099, то есть ниже всего, что мы видели. Лечится, к счастью, дешево: 6 миллионов шагов чистого PPO без имитации подняли его обратно до 0,452, а возврат цели в кадр до 90%. Деградация от имитации обратима, если заметить ее вовремя.

Как заморозить движущуюся мишень

Даже на отдельном энкодере переключатель, который учится одновременно с политикой, ведет себя как движущаяся мишень. Корреляция с целевым сигналом скакала между 0,13 и 0,51 от среза к срезу: политика подстраивается под переключатель, переключатель под политику, сходиться им незачем.

Петлю разорвали грубо. Собрали 2240 эпизодов (60% текущей политикой, 40% учителем), обучили переключатель офлайн на этом датасете, заморозили намертво и только потом продолжили RL. Флаг заморозки, кстати, не сохраняется в чекпоинт, так что передавать его надо при каждом возобновлении обучения. Забудешь один раз, и следующие миллионы шагов пройдут не с тем экспериментом, который ты думаешь, что ставишь.

датасет: 1 238 215 шагов / 2000 эпизодов, доля упреждающих 0,39
corr(предсказание, цель) = 0,645 стабильно (онлайн качало 0,13 до 0,51)
после первой секунды маршрутизация чистая: поперечные 0,79, встречные 0,44
первые полсекунды: не разделяется вообще

Прогон на 15 миллионах шагов от этой точки дал лучшую модель кампании:

метрика

предыдущий mainline

новый mainline

попадания, всего

33,3%

41,7%

навстречу / от нас / параллельно

50 / 53 / 78%

80 / 67 / 78%

слепое удержание

0,624

0,636

возврат цели в кадр

89%

88%

поперечные

0 из 22, промах 28,7 м

0 из 22, промах 22,7 м

Мир-модель впервые пережила имитационную кампанию целиком: в предыдущих она падала до 0,303 и 0,099, здесь выросла. Сработали ровно те два движения, которые мы сделали после истории с чемпионом.

Заодно всплыл сюжет про отбор. Быстрый eval на 16 эпизодах, по которому сохранялась лучшая модель, оказался слишком шумным: сохраненный по нему чекпоинт на честном срезе из 60 эпизодов дал 30%, а финальный, у которого композит был вдвое хуже, дал 41,7%. Дальше все решения принимались только по 60 эпизодам с фиксированными сидами.

Что не починилось: замороженный переключатель деградирует на сдвиге распределения. Корреляция 0,645 на офлайн-датасете превращается в 0,311 после 15 миллионов шагов RL, потому что политика уехала и датасет протух. Изоляция голов начинает протекать, поперечные захваты снова вымывает.

Лечится циклом: пересобрать датасет под текущей политикой, переучить переключатель, заморозить, продолжить. Итерация около часа. Замер показал, что короткие циклы правильнее длинных: первый миллион шагов после обновления все метрики растут, дальше начинается эрозия мир-модели, 0,73 до 0,435.

Голова, которой не было

К середине июня у нас была стройная теория. Переключатель в первую секунду слеп, значит команда на упреждение приходит поздно, значит поперечные цели не берутся. Оставалось подтвердить это и приделать защелку, которая включает нужную голову раньше.

Подтверждать сели с диагностическим прогоном, который принудительно включает голову упреждения на весь эпизод, зная режим цели из симулятора. Так на деплое нельзя, зато видно потолок «если бы переключатель работал идеально».

oracle force=full, 60 эпизодов
crossing 0/22   miss 22,2 м

Ноль. И промах до последнего метра такой же, как у головы погони.

Голова упреждения оказалась ее точной копией. Никакого отдельного навыка в ней не было, защелкивать было нечего, теория предыдущей недели пошла в мусор за пятнадцать минут.

Причина обнаружилась в том, как имитационный лосс доходил до голов. Он бил по смеси их выходов, а смесь взвешена переключателем. Переключатель на разгонном участке слеп и выдает около 0,35 на голову упреждения. Значит, ей доставалось 35% градиента там, где решается вся ее работа, и она просто не выучилась.

Исправление заняло 20 строк: имитационный лосс стали направлять прямо в ту голову, которая соответствует режиму цели в этом эпизоде, минуя переключатель.

# было: huber(lclip(mixed_mean), teacher), смесь взвешена переключателем
# стало: каждая голова клонирует учителя для своего режима на полном градиенте
head_means = policy.action_net.last_head_means          # (batch, n_heads, act_dim)
own = head_means.gather(1, mode_labels.view(-1, 1, 1).expand(-1, 1, act_dim))
ks_loss = huber(lclip(own.squeeze(1)), teacher_actions)

Шесть миллионов шагов спустя тот же диагностический прогон показал 3 поперечных захвата из 22 и 3 из 4 наискось.

Полсекунды, которых нет

Ожившая голова немедленно сломала все остальное. Пока обе головы были одинаковыми, было неважно, куда переключатель отправит промежуточные случаи. Как только они разошлись, уходящие и параллельные цели (поперечность около 0,6) поехали в голову упреждения, и общий результат просел с 41,7% до 33,3%.

Лечение обошлось в час офлайн-обучения, без единого шага RL. Мы переучили переключатель на более резком целевом сигнале: вместо плавной сигмоиды по всей шкале сделали жесткий ремап, где все ниже 0,55 считается чистой погоней, все выше 0,72 чистым упреждением, между ними линейный переход. Переключатель развел поперечные цели (0,87) и параллельные (0,59) по разные стороны.

метрика

mainline

кандидат с резким переключателем

попадания, всего

41,7%

38,3%

поперечные

0 из 22

1 из 22

наискось

0 из 4

1 из 4

навстречу / параллельно

80 / 78%

80 / 67%

слепое удержание

0,636

0,710

потолок с оракулом

не мерили

45,0%, 3 из 22

Первый поперечный захват на честном визуальном контуре. Чистая маршрутизация заодно подтянула мир-модель, что было бонусом: каждая голова стала видеть более однородные данные.

Разрыв между 1 из 22 на честном контуре и 3 из 22 с оракулом это цена слепоты, и дальше начинается физика, а не обучение.

Аппарат стартует, не имея о цели ничего, кроме картинки. Первые полсекунды у него нет даже истории рамки, только текущий кадр, а по одному кадру поперечная цель не отличается от догонной ничем: это точка, у нее нет скорости. Примерно с полсекунды угловая скорость рамки становится наблюдаемой, и различить режимы теоретически можно. Но решать, лететь ли с упреждением, надо на разгонном участке, пока работает мотор, а уверенно переключатель срабатывает секундой позже.

Секунда опоздания на трехсекундном эпизоде. Никакой архитектурой это не лечится: информации в сигнале физически нет.

Строка в логе, на которую мы три недели не смотрели

Пробник мир-модели работал так: летим, на четвертой секунде эпизода выключаем детектор на полторы до двух с половиной секунд, смотрим, держит ли политика нос на цели и возвращает ли ее в кадр. Число «слепое удержание» из всех таблиц выше получено именно так. По нему выбирались лучшие модели, по нему сравнивались архитектуры, по нему решалось, какую ветку развивать.

Теперь вернитесь к описанию стенда. Мотор работает 2 секунды. Все успешные сближения происходят между 1,8 и 2,5 секунды.

На четвертой секунде аппарат уже ничего не решает. Он летит по инерции, снижается и через пару секунд закончит эпизод независимо от того, что делает политика. Три недели мы мерили качество ведения цели в режиме, где ведение цели ни на что не влияет.

Самое обидное, что доказательство лежало в том же логе, двумя строками ниже главного числа, и лежало там с самого начала:

episodes=50  blackout_triggered=49
survived_through_blackout = 0/50 (0.0%)
crashed_after_blackout = 44

Ноль выживших из пятидесяти. У всех моделей без исключения, у любой архитектуры, при любых настройках. Сорок с лишним эпизодов из полусотни заканчиваются падением после окончания слепоты, и это не провал трекинга, а энергетическая смерть, которая наступила бы и без всякой слепоты.

Хуже, что в этом режиме метрика систематически врала в пользу неправильных моделей. Модель, которая после выгорания медленно планирует по пологой траектории, держит направление на цель легко: геометрия меняется медленно, предсказывать нечего. Модель, которая на разгоне активно маневрирует, в том же тесте выглядит хуже. Три недели мы отбирали кандидатов по метрике, которая награждала вялость.

Перенос окна слепоты в активную фазу (начало на 1,5 до 2,5 секунды, длительность 0,75 секунды, то есть ровно «цель ушла из кадра в момент, когда все решается») перевернул рейтинг:

модель

мертвая фаза

активная фаза

возврат цели, активная фаза

mainline

0,636

0,41

85 до 95%

кандидат с резким переключателем

0,710

0,33 до 0,45

33 до 51%

Кандидат, который по старой метрике был лучшим трекером кампании, в активной фазе теряет цель втрое чаще. Обучение головы упреждения разменяло активный трекинг на поперечные захваты, а старая метрика этот размен не показывала вообще.

Отдельно стоит посмотреть на абсолютные числа в активной фазе: 0,33 до 0,45 у всех моделей. Не потому, что модели плохие, а потому, что на разгоне линия визирования вращается быстро, скорость сближения высокая, и предсказать положение маневрирующей цели через три четверти секунды слепоты трудно физически. Настоящий запас качества у лучшей модели измеряется этими 0,4, а не красивыми 0,7 из мертвого режима.

Вывод шире нашего стенда. Если у задачи есть фазы с разной динамикой, метрика, снятая в произвольный момент, померит самую спокойную фазу, потому что она самая длинная. Мерить надо там, где решается результат. И проверять режим измерения надо так же, как сам измеритель: подсунуть заведомо разные модели и убедиться, что метрика различает их в нужную сторону.

Прогон, в котором мы дернули все рычаги разом

Следующий заход вошел в историю проекта как образцовый пример того, чего делать нельзя.

Зная теперь, что мир-модель важна, мы решили ее усилить и подняли сразу все: вес вспомогательного лосса с 1,0 до 2,5, вес награды за слепое ведение втрое, длительность пропаданий детектора с 60 до 75 кадров, вес пробника в критерии отбора в полтора раза, имитацию выключил полностью. Шесть миллионов шагов.

Мир-модель просела с 0,605 до 0,48 до 0,53. Лучшим чекпоинтом прогона оказался стартовый, то есть модель, с которой все начиналось.

Причин было две, и обе видны только задним числом. Задранная окклюзия дестабилизировала обучение: слишком много шагов подряд без наблюдений, слишком мало сигнала. А критерий отбора все еще считал пробник в мертвом режиме, потому что параметры окна слепоты были захардкожены в колбэке отбора. Мы усиливали мир-модель и одновременно отбирали модели по метрике, которая ее не измеряет.

После этого прогона каждый рычаг получил свой флаг в командной строке, параметры окна слепоты тоже. Правило в чеклисте: рычаги крутятся по одному, и до серии проверяется, что метрика отбора вообще измеряет то, что мы собираемся улучшать.

Честный Калман и как мы чуть не собрали чучело

К середине июня стало ясно, что интересная часть работы это не попадания, а трекинг сквозь потерю цели. Возник резонный вопрос: а нейросеть тут вообще нужна? Ведение цели по угловым измерениям с экстраполяцией на время пропадания это классика, для этого есть фильтр Калмана, и он не требует 60 миллионов шагов обучения.

Первый вариант дизайна эталона предполагал, что на вход идет чистое направление на цель и дальность от дальномера. С такими входами фильтр работает прекрасно. Никакого дальномера в наблюдениях нашей политики нет: у нее 20 признаков с камеры и инерциалки, и все. Сравнение получилось бы жульническим, мы дали бы эталону сенсор, которого нет у нейросети, и потом торжественно объявили, что нейросеть проиграла.

Поймали это на этапе постановки, до кода. Эталон переписали на те же входы: пеленг из центра рамки и очень шумная дальность из ее размера, потому что настоящий размер цели неизвестен (маленький дрон вблизи и большой вдали дают одинаковую рамку). Девять состояний: относительное положение, скорость, ускорение. Управление классическим пропорциональным наведением. Пеленг отдельно сверили с истинной линией визирования покадрово, расхождение 0,84 градуса.

Во время сборки эталона вылезла ошибка, которая красиво рифмуется с историей учителя. Первая версия управления брала закон упреждения из нашего же скриптового учителя. Учитель работает с абсолютной скоростью цели, а фильтр оценивает только относительную. Подставив одно в другое, мы получили команду упреждения, которая указывала назад. Одинаковые на вид формулы в разных системах отсчета означают разное, и это второй раз за кампанию, когда нас поймала не математика, а система координат.

Готовый эталон летает и попадает в 12,5% эпизодов при постоянно видимой цели, что не рекорд, но и не соломенное чучело.

Слепой тест в активной фазе, 50 эпизодов, одинаковый вход с камеры для всех:

трекер

держит цель вслепую

возврат цели в кадр

теряет цель

классический фильтр Калмана

−0,03

22%

84%

нейросетевой пилот

0,41

84%

8%

Отрицательное число в первой строке означает, что за время слепоты нос в среднем отворачивается от цели.

Фильтр тут не плохой, у него задача плохо поставлена. Дальность по размеру рамки шумная, на слепом участке она вообще не обновляется, без дальности относительная скорость восстанавливается скверно, и предсказание уезжает. LSTM в той же ситуации держит направление, потому что ее скрытое состояние обучено на распределении реальных маневров цели, а фильтр про эти маневры не знает ничего: у него в модели заложено постоянное ускорение.

Это единственный результат кампании, который мы готовы назвать красивым. Нейросеть выигрывает не размером, а тем, что у нее есть выученный приор на поведение цели, которого у фильтра нет по построению.

Гибрид: структура от фильтра, коэффициент от сети

Победа нейросети над классикой поставила следующий вопрос. Фильтр Калмана проигрывает, потому что вынужден угадывать то, чего не знает: ковариации шумов, характер маневра цели, нелинейность модели измерения. Нейросеть это знает из данных, но взамен отдает всю интерпретируемость: ни оценки состояния посмотреть, ни уверенности в ней.

Забрать оба свойства предлагает KalmanNet (Revach et al., IEEE TSP, 2022): рекурсия фильтра остается явной, а коэффициент усиления выдает обученная сеть.

for t in range(T):
    x_pred = (F @ x.unsqueeze(-1)).squeeze(-1)              # predict, как в обычном KF
    feat   = self._features(z[t], x_pred, z_prev, dx, vis[t])
    g_out, h = self.gru(feat.unsqueeze(0), h)
    K      = self.head(g_out.squeeze(0)).view(B, 9, 3)      # gain выдает GRU
    innov  = (z[t] - x_pred[..., :3]) * vis[t]              # невидимый шаг: innov = 0
    x      = x_pred + (K @ innov.unsqueeze(-1)).squeeze(-1) # update

Ковариаций в этой схеме нет вообще, сеть выдает матрицу усиления напрямую из невязки, ее приращения, приращения состояния и флага видимости: 16 признаков на входе, матрица 9 на 3 на выходе. Слепой шаг обрабатывается сам собой, невязка умножается на флаг видимости и обнуляется, обновление вырождается в чистое предсказание. Последний слой инициализирован нулями, поэтому в начале обучения фильтр ведет себя как чистая экстраполяция и не разваливается на первых батчах.

Обучение супервизорное, на истинном относительном состоянии из симулятора, с двумя важными деталями. Данные собирает нейросетевой пилот, то есть распределение траекторий такое же, как в работе. И в данные подмешана случайная слепота, причем слепые шаги входят в лосс с тройным весом, иначе сеть отлично научится фильтровать видимое и никак не научится вести цель без измерений.

датасет: 600 обучающих / 100 валидационных эпизодов, слепых шагов 19,1%
эпоха 1:  ошибка направления вслепую 21,62°, при видимой цели 8,11°
эпоха 30: ошибка направления вслепую 14,05°, при видимой цели 5,49°

Пятисторонний слепой тест в активной фазе:

трекер

держит цель вслепую

классический фильтр Калмана

−0,03

нейросетевой пилот (mainline)

0,41

нейросетевой пилот (отбор по активной фазе)

0,44

KalmanNet

0,60

Гибрид выигрывает у обоих родителей. Радость держалась ровно до строчки ниже: как самостоятельный пилот KalmanNet оказался посредственным. Возврат цели в кадр 42% против 84% у чистой нейросети, уходы цели 60% против 8%.

Объяснение простое, если разделить две способности. KalmanNet лучше всех оценивает, где цель. Но управление у него скриптовое, пропорциональное наведение по оценке, и оно хуже держит цель в поле зрения, чем сквозная политика, которую десятками миллионов шагов учили, что цель терять нельзя. Хорошая оценка и хорошая рулежка это два разных навыка, и они опять не оказались в одном модуле бесплатно.

Хирургия на живой политике

Раз один умеет оценивать, а другой рулить, надо соединить: пусть оценка KalmanNet идет входом в нейросетевую политику.

Способ соединения выбирали по критерию «минимальный шанс сломать то, что работает». Никаких новых экстракторов и никакого обучения с нуля. Оценка (направление на цель в связанных осях, нормированная дальность, относительная скорость, семь чисел) добавляется в наблюдение дополнительной строкой, а входные слои рекуррентных блоков расширяются хирургически.

def _widen_rnn(rnn, new_in):
    new = type(rnn)(new_in, rnn.hidden_size, num_layers=rnn.num_layers, ...)
    for name, p in rnn.named_parameters():
        q = dict(new.named_parameters())[name]
        if name.startswith("weight_ih_l"):
            q.zero_()
            q[:, :rnn.input_size].copy_(p)   # старые столбцы как были
        else:
            q.copy_(p)                       # новые столбцы в ноль
    return new

Новые веса в нуле, поэтому расширенная политика на старте побитово повторяет исходную: оценка на входе присутствует, но ни на что не влияет, и дальше RL сам решает, насколько ей верить. Прием стоит полчаса и снимает страх добавлять новый вход в работающую модель, рекомендую.

Единственная неожиданность оказалась не в математике, а в библиотеке: Gymnasium в версии 1.0 убрал автоматический проброс атрибутов через обертку. Наша обертка перестала пропускать к среде управляющие вызовы вроде переключения уровня куррикулума, и делегирование пришлось прописать руками.

Шесть миллионов шагов дообучения подняли слепое удержание в активной фазе с 0,43 до 0,51. Финальный тест, 50 эпизодов, все получают один и тот же вход с камеры:

трекер

держит цель вслепую

возврат цели в кадр

теряет цель

классический фильтр Калмана

−0,03

22%

84%

нейросетевой пилот

0,41

84%

8%

KalmanNet соло

0,60

42%

60%

KalmanNet плюс нейросеть

0,50

80%

18%

Гибрид забрал мир-модель фильтра и сохранил рулежку. Классический Калман он обходит по всем трем осям.

Деплойный путь проверен на уровне графов: два ONNX-файла, хост крутит их в цикле, состояние фильтра и рекуррентной сети обнуляется на старте эпизода, расхождение с исходной моделью 1,9e-6. Дальше мы не пошли, потому что железа в этом проекте нет.

Два потолка

Финальный заход был попыткой выжать еще. Вторую версию KalmanNet обучили аккуратнее: сбор данных в режиме DAgger (частично политикой, частично самим фильтром, чтобы он видел последствия своих ошибок), направленный лосс на угол, сеть побольше. Оценка стала заметно лучше: точность направления на цель вслепую в тяжелой активной фазе выросла с 0,70 до 0,78 по косинусу, ошибка упала с 44 до 39 градусов.

Дообучение слитой политики с этим фильтром не дало ничего. Лучшей моделью остался чекпоинт, с которого начинали, а следующие 4 миллиона шагов увели политику в осторожность: удержание упало с 0,50 до 0,37, зато уходы цели сократились с 8% до 4%. Другой локальный компромисс, не лучше и не хуже.

Сели разбираться и обнаружили, что все это время путали две разные величины.

Метрика «слепое удержание» это косинус между носом аппарата и истинным направлением на цель. Она измеряет не качество мир-модели, а то, насколько точно аппарат физически довернулся в нужную сторону. Померили по отдельности: 0,78 у оценки против 0,50 у носа.

Оценка знает, где цель, вдвое точнее, чем аппарат успевает туда повернуться.

Первый потолок, около 0,78, это предел монокуляра. В слепом окне дальность не наблюдаема вообще, поперечная составляющая скорости восстанавливается только по накопленной истории, и никакая архитектура не достанет из сигнала информацию, которой там нет. Лечится вторым сенсором или параллаксом.

Второй, около 0,50, это предел аэродинамики: на разгоне линия визирования вращается быстро, рули за ней не успевают, тем более вслепую, когда нет обратной связи по ошибке наведения. Тут нужна другая машина, а не другая сеть.

Главный технический вывод кампании: улучшение оценки перестало влиять на результат задолго до того, как оценка стала хорошей. Когда две подсистемы стоят последовательно, потолок задает не та, которую приятнее улучшать, а та, которая хуже. Пять недель мы улучшали ту, которую приятнее.

Что после этого поменялось в работе

Стенд у нас специфический, а выводы к нему не привязаны, и половина из них про измерения, а не про обучение.

Первое, что изменилось, это распределение усилий. По времени кампания разложилась примерно так: четверть на обучение, три четверти на диагностику, замеры и переписывание измерителей. Задним числом это правильная пропорция, а все места, где мы нарушали ее в пользу обучения, кончались прогонами в мусор.

Перед серией прогонов теперь делается пять вещей, которые раньше делались как получится:

  • измеритель проверяется на заведомо хорошем и заведомо плохом входе, до первого содержательного запуска;

  • метрика снимается в той фазе процесса, где решается результат, а параметры этой фазы лежат в командной строке, а не в коде колбэка;

  • эксперт, к которому мы собираемся притягивать модель, сначала измеряется на нашей же среде;

  • интеграл пошаговых штрафов сравнивается с терминальным бонусом на бумаге, до запуска;

  • рычаги крутятся по одному, и в конце прогона мы обязаны уметь сказать, какой именно из них дал эффект.

Каждый пункт куплен отдельным потерянным прогоном, так что список не теоретический.

Два вывода посерьезнее. Первый: метрика отбора определяет, что вырастет, и молча убивает все, что в нее не входит. Мы просили попадания и получили рекордные попадания при мертвой памяти, и модель тут ни в чем не виновата. На любом проекте, где чекпоинт выбирают по одному числу, а в проде он отвечает еще за три вещи, будет ровно это.

Второй: конкурирующие навыки настройками не разводятся. Три раза подряд, на трех разных парах, у нас получалась одна и та же синусоида, и каждый раз лечила ее не настройка, а структура. Либо условная политика с честной маршрутизацией, либо, как вышло в финале, разделение на два модуля, где каждый занимается своим делом.

Отдельно про гибриды, потому что это оказалось самым практичным результатом кампании. Классический фильтр знает, как устроена динамика, но вынужден угадывать шумы. Нейросеть знает данные, но не знает структуру. Схема, где структура берется от фильтра, а параметры выучиваются, обошла и то и другое, осталась интерпретируемой и весит меньше трети мегабайта. Мы теперь смотрим в эту сторону каждый раз, когда задача пахнет фильтрацией.

И самое скучное, но за месяц окупившееся трижды. Вся хроника кампании писалась в один файл: что запустили, что померили, какой вывод, что дальше. Три раза этот файл остановил повторный заход на уже пройденную стену, а в истории с пустой головой упреждения позволил за час разобрать теорию, которую мы строили неделю. Отрицательные результаты надо записывать с цифрами, иначе через месяц никто не вспомнит, почему отказались от идеи, и потратит на нее еще неделю.

Почему мы остановились здесь

В симуляторе еще есть что подкрутить. Пер-головный прогон осциллировал, и там наверняка прячется чекпоинт получше того, что мы взяли. Циклы обновления переключателя гоняются за час, каждый снимает свой процент. Это работа на единицы процентов, и обе стены она не двигает: первая секунда как была слепой, так и осталась, рули за линией визирования как не успевали, так и не успевают.

По хорошему следующий шаг это sim-to-real, но он упирается в смету.

Считается смета примерно так. Политика, выученная в симуляторе, на железе сыпется в первых же вылетах, потому что приводы отвечают иначе, детектор ведет себя иначе, задержки другие. Лечится это циклами «вылет, разбор, правка модели симулятора, дообучение», и циклов нужен не один и не пять. На каждый нужен аппарат, к нему мишень, площадка, люди, а сверху почти наверняка согласования и куча бумаг. Десятки аппаратов минимум, по хорошему сотни. Это уже программа испытаний со своими сроками и своей сметой, и ML команда в ней не главный участник.

Такой сметы нам сейчас не дадут. Мы это понимали, когда начинали, поэтому и вопрос ставили посильный: умеем ли мы держать объект, когда детектор моргает. Ответ получен, цифры есть, инструменты и наработки остались. Вернемся, когда появятся деньги, разрешения и живая задача, под которую все это нужно.

Если статья была интересной, можете подписаться на нас в телеграм: morana.log. Всем удачи!