Это история про пет-проект, который я делал ради трёх вещей одновременно: прогноз приземной температуры на неделю вперёд из данных одной метеостанции, честные интервалы неопределённости вместо голой точки, и работа на железе уровня Raspberry Pi без всякого GPU. По дороге я несколько раз ошибся, один раз откатил целый эксперимент, и в итоге понял про свою же модель больше, чем когда её проектировал.
В прошлых статьях я допустил множество ошибок, которые выявил при более глубоком исследовании возможностей модели. Их оказалось слишком много, я сам в них утонул, поэтому решил начать все с чистого листа, но с некоторыми пометками. Здесь не будут упоминаться прошлые версии, представим как будто их и не было.
Код лежит в репозитории (ссылка в конце). Данные не выкладываю, брал часовые ряды из Open-Meteo, их можно тянуть самому; в репозитории есть синтетический генератор, чтобы прогнать весь конвейер за пару минут.
Что меня не устраивало
Стандартный подход к задаче — это взять какой-нибудь GRU или трансформер, скормить в них окна истории с таргетами, и пусть учится. Я так и начал. На коротком горизонте (первые часы) работает прилично. Но дальше появляются три проблемы, которые ну вообще не годятся:
– Во-первых модель не умеет говорить «я не знаю». Она выдает точку, что на первом что на 168-м часу с одинаковой уверенностью. А неопределенность прогноза все же отличается на час и на неделю в разы (физическая особенность предметной области). Мне нужен был не «завтра в 15:00 будет 7°C», а «будет от 5 до 10, скорее всего около 7».
– Во-вторых на дальнем горизонте все разваливается. К 5-7 суткам свободная модель либо плоско залипает, либо улетает в свои фантазии, причем иногда намного хуже, чем обычная климатология для определенного дня и часа. Для прогнозной модели это позор, зачем она тогда вообще нужна, если не обыгрывает самый примитивный бейзлайн.
– В-третьих не работает на копеечном устройстве. В целом работает, но зачем лишняя нагрузка и долгое время прогноза. Лишний износ устройства, которое и так находится в диких условиях.
Из этих проблем выросла новая архитектура.
Идея: прогноз = климат + затухающее отклонение
Идея простая. Не даем модели предсказывать температуру напрямую, а заставляем раскладывать прогноз вот так:
T̂(t+h) = C(p, t+h) + σ(p, t+h) · ( o(h) + r(h) )
C(…) — климатическая норма для этой точки в этот час года. σ(…) — типичный масштаб отклонений там же. А o(h) — само отклонение от нормы, и вот оно собирается из истории через банк затухающих мод: набор экспоненциально затухающих осцилляторов с разными постоянными времени от нескольких часов до недель. Грубо говоря, «текущая аномалия температуры затухает к нулю с такой-то скоростью, и вот как она будет затухать ближайшие 168 часов». r(h) — небольшая нелинейная поправка сверху.
Это даёт «бесплатно», без обучения: чем дальше горизонт или чем меньше данных, тем сильнее o(h) подавляется математически. Нет истории — o(h) ≡ 0, и прогноз становится чистой климатологией. Далёкий горизонт — моды затухли, снова климатология. Это та самая гарантия «не хуже нормы», которой мне не хватало, оказывается зашита в саму форму, а не выучена и не обещана на честном слове.

Обучал в 2 этапа. Сначала только на нулевой истории (L=0), чтобы стабилизировать климат-поле, а потом на полном куррикулуме длин истории. Обучение происходит не на чистых данных, а на специально «испорченных» данных, дабы научить модель работать в реальных условиях и избежать переобучения. Сравнивал с климатологией, damped persistence (затухающая инерция), сезонным naive и двумя обучаемыми бейзлайнами: GRU seq2seq и DLinear. Климатология тут не слабый бейзлайн для галочки, а честный ориентир – на дальних горизонтах ее сложно побить, и это нормально (климатология исходит из знания всей истории температуры за несколько лет, а модель только по 672 часам и то не всегда).
Отладка, часть первая: поле, которое зубрило станции
Первый звоночек появился при простой проверке. Я решил измерить качество чистого поля вообще без истории, только по координатам. Отдельно на станциях, которые модель видела, отдельно которые не видела. Метрика – отношение MSE поля к MSE эмпирической климатологии (<1 – поле лучше нормы, больше – хуже).
на обученных станциях: = 0.98 (поле почти идеально)
на новых станциях: =3.0 (поле в 3 раза хуже нормы)
Такой симптом говорит не о нехватке данных, а о переобучении. Модель просто заучила климат каждой точки и перестала обобщать на новые станции.
Первая моя гипотеза оказалась неверной. Я подумал: координатный энкодер имеет слишком широкую полосу частот, поэтому поле лепит острые «бугорки» под каждую станцию. Сузил полосу частот в несколько раз – эффект нулевой.
Тогда я пошел системно. Заглушал компоненты под одному и искал «виновника», кто на самом деле тащит заучивание. Оказалось, что маленький линейный «прайор» в модуле паспорта станции при нулевой истории должен был отдавать что-то нейтральное. А по факту это был Linear(координаты → латент z), он тихо выучил климат каждой станции напрямую через координаты в обход всей честной физики разложения.
Проверил гипотезу: посчитал разброс латента z по станциям при нулевой истории. Если прайор нейтральный — разброс должен быть около нуля. Он был большим. Шорткат подтверждён: модель нашла способ минимизировать лосс не через хорошее обобщающееся поле, а через заучивание координат латентом. Градиентный спуск всегда идёт по пути наименьшего сопротивления, и я сам ему этот путь оставил.
Починка вышла дешёвой — сделал прайор глобальным. Вместо Linear(координаты → z) — один обучаемый вектор на всех:
# было: прайор зависит от координат (и заучивает их)
# self.prior = nn.Linear(LocEncoder.OUT, 2 * DZ)
# стало: один глобальный прайор, одинаковый для всех станций
self.prior_m = nn.Parameter(torch.zeros(DZ))
self.prior_s = nn.Parameter(torch.zeros(DZ))
Теперь при нулевой истории все станции получают одинаковый латент, независимо от координат. Заучивать координатами стало физически нечем — модель была вынуждена учить настоящее, грубое, обобщающееся поле. Разброс z упал почти до нуля, как и должно.
Отладка, часть вторая: как понять, что “плохо на новых станциях” – это уже предел, а не баг
Глобальный прайор помог, но не до конца: разрыв между обученными и новыми станциями сократился, но остался. Я зажал степени свободы поля — усилил регуляризацию, урезал число частот и ёмкость сети. И вот тут произошло то, чего я на самом деле добивался, хотя выглядело оно контринтуитивно:
|
|
обученные станции |
новые станции |
|
до зажатия |
0.98 |
5.25 |
|
после зажатия |
1.30 |
3.99 |
Качество на обученных станциях ухудшилось (с 0.98 до 1.30), и это правильно. Раньше 0.98 означало «поле зубрит»; теперь 1.30 — это честная работа поля, которое не запоминает, а обобщает. А на новых станциях стало лучше (5.25 → 3.99). Train растёт, unseen падает — они сходятся навстречу друг другу. Именно это и есть признак, что заучивание уходит.
Но разрыв всё ещё был, и я чуть не начал давить дальше. Остановила меня одна проверка, которую стоило сделать в самом начале: насколько новые станции вообще пространственно близки к обученным.
расстояние от новой станции до ближайшей обученной: медиана 643 км, 90-й перцентиль 741 км, максимум 801 км.
Это интерполяция, а не экстраполяция. Каждая новая станция окружена обученными на расстоянии ~640 км. А полоса частот моего поля к тому моменту была уже грубее, чем это расстояние. То есть поле физически не может разрешить, чем климат отличается между двумя точками в 640 км друг от друга — оно этот масштаб сглаживает.
И здесь фундаментальная дилемма, из-за которой давить дальше было бессмысленно: на масштабе расстояния между станциями «разрешить» и «запомнить» — это одно и то же. Сделаю поле тоньше 640 км — оно снова начнёт учить каждую станцию индивидуально (заучивание вернётся). Оставлю грубее — будет промахиваться по локальной структуре. Остаточная ошибка на новых точках на масштабе мельче 640 км несводима из одних координат. Это не баг модели — это предел того, что вообще можно узнать о климате точки, зная только её координаты, при такой плотности сети станций.
Этот вывод заставил меня переформулировать цель. Я гонялся за точным средним на новой станции без истории — а этого не бывает. Городской остров тепла, рельеф, локальные бризы дают сдвиг в несколько градусов, которого в координатах просто нет. И архитектура это на самом деле знает: она не обещает точное среднее на новой точке, она обещает честную неопределённость (широкий интервал там, где не уверена) и быстрое восстановление по истории. Тот самый паспорт станции для того и нужен — добрать локальный сдвиг из первых же суток данных, а не угадать его из координат.
Отладка, часть третья: эксперимент, который не сработал
Когда модель уже работала, я сделал диагностику вклада каждого компонента и заметил странное: нелинейная поправка r(h) (та самая «небольшая добавка сверху») несла на дальнем горизонте подозрительно много. Без неё прогноз на 168 часов проваливался с −1.6% до −31% относительно климатологии.
Я решил, что r работает костылём: якобы моды на дальнем горизонте не затухают как надо, оставляют смещение, а r его латает. По замыслу r должна быть маленькой и таять с горизонтом. И я добавил в лосс регуляризацию, которая давит r (и заодно остаточную энергию мод) тем сильнее, чем дальше горизонт.
Переобучил. Стало хуже:
|
|
Skill@168ч |
|
было (baseline) |
−1.6% (в пределах нормы, критерий пройден) |
|
после «фикса» |
−16.0% (модель теперь заметно хуже климатологии) |
|
на новых станциях |
−99% (полный обвал дальнего горизонта) |
Правка сделала ровно противоположное задуманному. Я подрезал r, но не убрал причину — и модель, лишившись компенсации, поехала ещё сильнее. Я откатил изменение и вернулся к предыдущему чекпойнту.
Диагностика, которую я использовал (выключение компонента у обученной модели), показывает, что компонент несёт сейчас, но не кто должен это нести. Я увидел большой вклад r на дальнем горизонте и решил, что это костыль. На самом деле r несла легитимную поправку к грубому полю, которую ни моды, ни голое поле дать не могут. Я принял фичу за баг и попытался её вырезать. По нормальному нужно было попробовать вырезать r и переобучить модель вовсе без него.
Что получилось
Финальная картина на тесте (Skill — снижение MSE относительно климатологии; выше лучше; климатология по определению 0):
|
лид |
МАЯК |
GRU |
DLinear |
Damped |
|
1 ч |
+91.6% |
+95.3% |
+97.1% |
+71.6% |
|
6 ч |
+81.0% |
+82.8% |
+77.4% |
+62.4% |
|
24 ч |
+48.7% |
+48.5% |
+37.0% |
+31.6% |
|
72 ч |
+10.2% |
+2.1% |
−27.4% |
+8.0% |
|
168 ч |
−1.6% |
−23.7% |
−69.4% |
+1.5% |
На первых часах простые модели чуть точнее — там работает банальная инерция, и сложная структура не нужна. С суток и дальше МАЯК уверенно лидирует среди обучаемых моделей. А главное — на дальнем горизонте МАЯК не проваливается ниже климатологии (Skill около нуля), тогда как DLinear к концу недели даёт −69%. Skill ≈ 0 на 168 часах — это не поражение, это осознанный потолок: угадать погоду на неделю вперёд из одной станции лучше многолетней нормы физически почти нельзя, и правильное поведение — сойтись к норме, а не фантазировать.

По вероятностным метрикам (CRPS, интервальная оценка Уинклера) МАЯК — лучший почти на всём горизонте. Интервалы честные: 90%-й интервал реально накрывает 87–95% случаев на всех лидах, а после конформной доводки — ровно около 90% во всех бинах.

Три вещи, которыми я доволен больше всего:
– Обобщение на новые станции. Skill@24ч: +47.8% на обученных станциях и +54.1% на новых. На новых даже чуть лучше — потому что паспорт добирает локальный сдвиг из истории, и то, что поле при нулевой истории мажет, перестаёт быть проблемой, как только накопилось несколько часов данных.
– Холодный старт. Без истории прогноз — плоская климатология с широкими интервалами. Модель честно сообщает незнание, а не ошибается уверенно. А с 24 часами истории восстанавливается до 94% полного качества.

– Работа на устройстве. Потоковое обновление стоит O(числа мод) на новый час, состояние — меньше 4 КБ, переживает перезагрузку, при отказе датчиков плавно деградирует к климатологии. Это уже не «модель на сервере», а то, что ставится на автономную станцию.

Мелкий недочёт, который видно на диагностике: модель занижает суточный размах примерно на 10% (наклон «прогнозная амплитуда против фактической» ≈ 0.90).

Ограничения – честно
Это не замена численному прогнозу погоды. Синоптику «сбоку» — приходящие фронты, циклоны — одна станция не видит, поэтому на дальних горизонтах прогноз честно сходится к климатологии. Улучшить дальний горизонт чисто архитектурно нельзя; нужны внешние данные (поля давления, соседние станции), а это уже другая постановка задачи.
Поле грубое по построению — на масштабе плотности сети станций точнее сделать нельзя без риска вернуть заучивание. Тонкий локальный климат новой точки восстанавливается из истории, а не из координат. На совсем новой точке при нулевой истории прогноз — это климатология, и не более того.
Нужны корректные координаты и время UTC — без них солнечная геометрия не определена.
Что дальше
Единственный легитимный способ опустить потолок точности на новых точках — не регуляризация, а обогащение входов поля географией, которая обобщается: расстояние до берега, изрезанность рельефа, перепад высот, тип подстилающей поверхности. Это реальные предикторы локального климата, которые переносятся на новые точки, в отличие от заучивания координат. Голые координаты имеют низкий потолок именно потому, что берег, рельеф и город в них не закодированы.
Ещё из диагностики выяснилось, что полусуточные моды почти ничего не несут — кандидат на удаление ради экономии на устройстве, но это надо подтверждать полноценной абляцией с переобучением.
Код
Репозиторий с архитектурой, обучением, оценкой и инструкцией по запуску на устройстве: [МАЯК]. Данные не выкладываю — использовал Open-Meteo, часовые ряды можно тянуть самостоятельно; синтетический генератор для проверки конвейера в репозитории есть.
Если у вас есть свои метеоданные и вы попробуете это запустить — расскажите, что вышло. Особенно если найдёте баг, который я пропустил: как показала история выше, находятся они в самых неожиданных местах.
Автор: YasherkaS


