Как мы валидировали нейросетевые прогнозы морского льда для конференции TITDS-2025

Четыре панели валидации: эталон, прогноз, наложение кромок, карта ошибок

Введение: почему RMSE врёт Ссылка на заголовок

Представьте: вы обучили нейросеть прогнозировать морской лёд в Карском море. Метрика RMSE на тестовой выборке — 0.08. Красота. Вы пишете в статью «модель достигает RMSE 0.08 по концентрации льда», рецензент кивает, и все довольны.

А потом капитан ледокола на Северном морском пути смотрит на карту, построенную по вашему прогнозу, и видит чистую воду там, где реально стоит сплочённый лёд. Или наоборот — прокладывает маршрут в обход ледового поля, которого нет. Кромка льда — граница между «можно идти» и «нельзя идти» — уехала на 50 километров. А RMSE всё ещё 0.08.

Как такое возможно? Очень просто. RMSE — это среднее по всем пикселям. Если ваша модель идеально предсказывает открытую воду (90% площади летом) и идеально предсказывает сплошной лёд в центре Арктики, то ошибки на тонкой полоске кромки тонут в общей статистике. Вы получаете отличный RMSE и совершенно непригодный для навигации прогноз.

Это не гипотетическая проблема. Мы обнаружили, что почти 30% прогнозов, которые формально «проходят» по RMSE, дают кромку льда, непригодную для принятия навигационных решений. И это было не на какой-то экзотической модели — это стабильно воспроизводится на всех четырёх архитектурах, которые мы тестировали.

В этом посте — полный пайплайн валидации, который мы собрали для доклада на конференции TITDS-2025: от спутниковых данных до публикационных карт. Весь код — Python, numpy, matplotlib. Никаких ГИС-инструментов, никаких проприетарных библиотек. Всё, что нужно, помещается в один Colab-ноутбук.

Данные: OSI SAF и Карское море Ссылка на заголовок

Мы используем данные о концентрации морского льда (SIC) из продукта OSI SAF — это совместный проект метеослужб Норвегии, Дании, Финляндии, Швеции и Франции. Конкретно два датасета:

  • CDR (Climate Data Record) — климатический архив, прошедший полный цикл валидации. Стабильный, но выходит с задержкой.
  • ICDR (Interim CDR) — оперативный продукт, обновляется ежедневно, методологически совместим с CDR.

Оба датасета лежат на сетке EASE-Grid 2.0 с шагом 25 км. Это равноплощадная проекция — каждая ячейка покрывает ровно $625 , \text{км}^2$, что критически важно для подсчёта площадей. На широтно-долготной сетке площадь ячейки зависит от широты, и вы получаете систематическую ошибку в метриках.

Область исследования — Карское море: $65°$–$82° \text{N}$, $55°$–$100° \text{E}$. Почему именно оно? Во-первых, это ключевой участок Северного морского пути. Во-вторых, здесь одна из самых динамичных ледовых обстановок в Арктике: тёплые атлантические воды с запада, пресноводный сток Оби и Енисея, полыньи, быстрые подвижки кромки. Если ваша модель работает здесь — она, скорее всего, будет работать везде.

Временной охват: 2019–2024. Разбиение:

  • Тренировка: январь 2019 — ноябрь 2021
  • Тест: январь 2022 — декабрь 2024

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

Модели: от наивного базелайна до ConvLSTM Ссылка на заголовок

Мы намеренно не гнались за SOTA. Цель — не побить рекорд, а показать, что проблема расхождения RMSE и кромки воспроизводится на разных архитектурах. Четыре модели, от простой к сложной:

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

LSTM — попиксельная рекуррентная сеть. Каждый пиксель — отдельный временной ряд, lookback 30 дней. Никакой пространственной информации — модель не знает, что соседние пиксели связаны. Это намеренное ограничение: мы хотим увидеть, что даёт «слепой» к пространству подход.

U-Net — классический encoder-decoder для сегментации. На вход — 10 последовательных кадров SIC как каналы. U-Net видит пространственные паттерны, но не имеет явной модели временной динамики — для него 10 кадров это просто 10 каналов, как RGB + альфа, только их десять.

ConvLSTM — свёрточная LSTM, которая обрабатывает последовательность 10 кадров с учётом и пространства, и времени. Теоретически — лучшее из обоих миров. На практике — самая капризная в обучении.

Все модели тренируются с MSE-лоссом по SIC. Это стандарт в литературе, и это часть проблемы — но об этом позже.

IIEE-декомпозиция: метрика, которая видит кромку Ссылка на заголовок

Метрику IIEE (Integrated Ice Edge Error) предложили Goessling et al. в 2016 году, и она стала стандартом для валидации прогнозов ледовой кромки. Идея элегантная.

Сначала бинаризуем SIC при пороге 15% — стандартный порог WMO для определения границы ледового покрова:

  • $B_F(x, y) = 1$ если прогноз $\geq 0.15$, иначе $0$
  • $B_R(x, y) = 1$ если эталон $\geq 0.15$, иначе $0$

Теперь считаем два типа ошибок:

  • Over-prediction ($O$) — модель говорит «лёд», а там вода. False Positive.
  • Under-prediction ($U$) — модель говорит «вода», а там лёд. False Negative.

Каждая ошибка измеряется в $\text{км}^2$: количество ошибочных пикселей умножаем на площадь ячейки (625 $\text{км}^2$ для нашей сетки).

IIEE декомпозируется на две компоненты:

$$\text{IIEE} = O + U$$

$$\text{AEE} = |O - U|$$

$$\text{ME} = 2 \cdot \min(O, U)$$

где $\text{AEE}$ (Absolute Extent Error) — ошибка общей площади, а $\text{ME}$ (Misplacement Error) — чистое смещение кромки. Проверка: $\text{IIEE} = \text{AEE} + \text{ME}$.

Ключевое наблюдение: если $\text{ME}/\text{IIEE} > 0.5$, значит модель правильно оценила площадь льда, но поставила его не туда. Кромка уехала, а площадь совпала. Именно это состояние и ловит наш пайплайн.

Вот код на Python, который считает всю декомпозицию:

import numpy as np

# forecast, reference — 2D-массивы SIC (0..1)
B_F = (forecast >= 0.15).astype(np.float32)
B_R = (reference >= 0.15).astype(np.float32)

FP = B_F * (1 - B_R)  # over-prediction
FN = (1 - B_F) * B_R  # under-prediction

O = FP.sum() * 625.0   # km², cell area on 25 km grid
U = FN.sum() * 625.0

IIEE = O + U
AEE = abs(O - U)
ME = 2 * min(O, U)

Десять строк. Никаких зависимостей кроме numpy. Скопируйте, вставьте в свой пайплайн — работает.

Маленькое замечание: 625.0 — это площадь одной ячейки EASE-Grid 2.0 на 25 км ($25 \times 25 = 625$). Если у вас другая сетка — подставьте свою площадь. Если сетка неравноплощадная — вам понадобится матрица площадей ячеек, и sum() заменится на взвешенную сумму.

Визуализация: четыре панели, которые рассказывают всю историю Ссылка на заголовок

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

Четыре панели case study: эталон, прогноз, наложение кромок, карта ошибок

Разберём каждую панель.

Панель (a): Reference SIC — что видит спутник Ссылка на заголовок

import matplotlib.pyplot as plt

fig, axes = plt.subplots(1, 4, figsize=(20, 5))

ax = axes[0]
ax.imshow(reference, cmap='Blues_r', vmin=0, vmax=1, origin='lower')
ax.contour(B_R, levels=[0.5], colors='navy', linewidths=1.5)
ax.set_title('(a) Reference SIC')

Blues_r — обратная синяя палитра: белый = вода (SIC ~ 0), тёмно-синий = сплошной лёд (SIC ~ 1). Почему обратная? Потому что на картах льда лёд традиционно тёмный, а вода светлая. Попробуйте прямую Blues — будет интуитивно наоборот.

contour(B_R, levels=[0.5]) рисует кромку льда — изолинию бинарной маски при 0.5. Поскольку маска содержит только 0 и 1, контур при 0.5 проходит ровно по границе.

Важная деталь: origin='lower' — чтобы ось Y шла снизу вверх. Без этого карта будет перевёрнутой, юг окажется сверху.

Панель (b): Forecast SIC — что предсказала модель Ссылка на заголовок

ax = axes[1]
ax.imshow(forecast, cmap='Blues_r', vmin=0, vmax=1, origin='lower')
ax.contour(B_F, levels=[0.5], colors='darkred', linewidths=1.5)
ax.set_title('(b) Forecast SIC')

Абсолютно тот же код, только данные — прогноз, и контур красный. Визуальное сравнение (a) и (b) уже даёт интуицию: если синие поля похожи — модель в целом справилась. Но дьявол в деталях кромки, и для этого нужна панель (c).

Панель (c): Ice edge overlay — наложение кромок Ссылка на заголовок

Это самая информативная панель. Здесь мы строим RGB-изображение вручную, без colormap:

overlay = np.ones((*reference.shape, 3))  # white background

# Reference ice: light blue
ref_mask = B_R.astype(bool)
overlay[ref_mask] = [0.7, 0.85, 1.0]

# Forecast ice: light red
frc_mask = B_F.astype(bool)
overlay[frc_mask] = [1.0, 0.8, 0.8]

# Overlap: purple
both = ref_mask & frc_mask
overlay[both] = [0.85, 0.75, 0.9]

ax = axes[2]
ax.imshow(overlay, origin='lower')
ax.contour(B_R, levels=[0.5], colors='blue', linewidths=1.5,
           linestyles='solid')
ax.contour(B_F, levels=[0.5], colors='red', linewidths=1.5,
           linestyles='dashed')
ax.set_title('(c) Ice Edge Overlay')

Три цвета: голубой — лёд только в эталоне (модель пропустила, under-prediction), розовый — лёд только в прогнозе (модель придумала, over-prediction), фиолетовый — совпадение. Плюс два контура: сплошная синяя линия — эталонная кромка, пунктирная красная — прогнозная.

Зазор между линиями — это ME. Чем он шире, тем хуже. Цвет заливки показывает, в какую сторону ошибка. На хорошем прогнозе вы видите тонкую фиолетовую полоску с почти совпадающими контурами. На плохом — широкую розовую или голубую полосу с разнесёнными линиями.

Обратите внимание: никакого ГИС не нужно. Это просто numpy-массив формы $(H, W, 3)$, поданный в imshow. «Карта» получается автоматически, потому что данные уже лежат на равноплощадной сетке. Координатные оси — индексы пикселей. Для публикации можно добавить lat/lon-тики через ax.set_xticks с пересчётом, но для визуального анализа они не нужны.

Панель (d): Error map — бинарная карта ошибок Ссылка на заголовок

from matplotlib.colors import ListedColormap

error_map = np.zeros_like(B_R, dtype=np.int8)
error_map[(B_R == 0) & (B_F == 0)] = 0  # TN: correct water
error_map[(B_R == 1) & (B_F == 0)] = 1  # FN: missed ice
error_map[(B_R == 0) & (B_F == 1)] = 2  # FP: false ice
error_map[(B_R == 1) & (B_F == 1)] = 3  # TP: correct ice

cmap_err = ListedColormap(['white', '#2166ac', '#b2182b', '#999999'])

ax = axes[3]
ax.imshow(error_map, cmap=cmap_err, vmin=0, vmax=3, origin='lower')

# Annotation
status = 'DISAGREE' if me_ratio > 0.5 else 'AGREE'
ax.annotate(f'RMSE={rmse:.3f}\nME/IIEE={me_ratio:.2f}\n{status}',
            xy=(0.02, 0.98), xycoords='axes fraction',
            va='top', fontsize=9,
            bbox=dict(boxstyle='round', fc='wheat', alpha=0.8))
ax.set_title('(d) Error Map')

plt.tight_layout()
plt.savefig('validation_panel.png', dpi=150, bbox_inches='tight')

Четыре цвета, четыре состояния:

ЦветКодЗначение
Белый0TN — вода в обоих
Синий #2166ac1FN — пропущенный лёд
Красный #b2182b2FP — ложный лёд
Серый #9999993TP — совпавший лёд

Аннотация в углу — квинтэссенция: RMSE, отношение $\text{ME}/\text{IIEE}$ и вердикт AGREE/DISAGREE. Если RMSE низкий, а статус DISAGREE — это тот самый случай, когда средняя метрика маскирует пространственную ошибку.

Цвета #2166ac и #b2182b — из палитры RdBu diverging, они хорошо различимы и в цветной печати, и на проекторе. Серый для TP — намеренно нейтральный: правильно предсказанный лёд не должен отвлекать внимание от ошибок.

Главный результат: 30% прогнозов врут Ссылка на заголовок

Мы прогнали все четыре модели на трёхлетнем тестовом периоде (2022–2024) и для каждого дневного прогноза посчитали: (1) проходит ли по RMSE (порог 0.10), (2) проходит ли по $\text{ME}/\text{IIEE}$ (порог 0.50).

Результат: 29.6% прогнозов (95% CI: 27.6–31.7%) проходят по RMSE, но проваливаются по ME/IIEE. То есть почти каждый третий «хороший» прогноз даёт кромку, сдвинутую настолько, что пространственная информация непригодна для навигации.

Разбивка по моделям:

МодельRMSE-pass, ME-fail (%)
Persistence22.1
LSTM28.3
U-Net35.4
ConvLSTM32.7

U-Net — рекордсмен. И это логично: encoder-decoder архитектура с MSE-лоссом учится «размазывать» предсказание вокруг кромки. Вместо чёткой границы 0/1 она выдаёт плавный градиент 0.3–0.7 в зоне кромки. RMSE от этого снижается (средняя ошибка маленькая), а бинарная кромка при пороге 0.15 уезжает в сторону меньшей концентрации — в открытое море.

Деградация метрик с увеличением горизонта прогноза

Эффект усиливается с увеличением горизонта прогноза. На рисунке выше видно, как расхождение между RMSE и IIEE растёт от 1 до 14 дней. На 1-дневном горизонте метрики ещё более-менее согласованы. На 7+ днях — RMSE остаётся приемлемым, а IIEE улетает.

Сезонная разбивка расхождений

Сезонность тоже играет роль. Пик расхождений — переходные сезоны (октябрь–декабрь для замерзания, июнь–июль для таяния), когда кромка динамична. Зимой, когда Карское море покрыто льдом почти целиком, метрики согласованы — кромку просто некуда сдвигать.

Тепловая карта чувствительности

Тепловая карта чувствительности показывает, как результат зависит от порогов RMSE и $\text{ME}/\text{IIEE}$. Наш выбор (0.10 и 0.50) — не произвольный: это точка, где кривые доли расхождений стабилизируются. Сдвиньте RMSE-порог до 0.15 — процент расхождений снижается до 18%, но вы уже пропускаете откровенно плохие прогнозы.

Вывод прямой: нейросети с MSE-лоссом жертвуют кромкой ради средней ошибки. Это не баг конкретной архитектуры — это свойство функции потерь. MSE штрафует за средний квадрат отклонения по всем пикселям, а пиксели кромки — это тонкая полоска, которая тонет в массе «лёгких» пикселей открытой воды и сплошного льда.

Решение? Составные функции потерь, которые явно штрафуют за смещение кромки. Но это тема для отдельного поста (и отдельной главы диссертации).

Воспроизводимость: один клик до результата Ссылка на заголовок

Весь код, данные и ноутбуки — в открытом репозитории:

github.com/bolkhovsky/sea-ice-validation-titds2025

Что внутри:

  • notebooks/full_pipeline.ipynb — Google Colab-ready ноутбук, от загрузки данных до финальных фигур. Запускается в один клик, GPU не нужен.
  • src/metrics.py — IIEE-декомпозиция и все вспомогательные метрики.
  • src/visualization.py — четырёхпанельная фигура и все графики из статьи.
  • data/checksums.sha256 — SHA256-хеши всех входных файлов для верификации.

Данные OSI SAF — публичные, скачиваются скриптом из репозитория. Модели — предобученные веса лежат в релизах GitHub.

SHA256-верификация — не формальность. Мы столкнулись с тем, что ICDR-данные за один и тот же день могут отличаться в зависимости от версии продукта. Хеши фиксируют точный набор входных данных, на которых получены результаты в статье.

Если вы работаете со своей областью — замените bbox в конфиге, пересчитайте площадь ячейки для своей сетки, и пайплайн заработает. Код параметризован, привязка к Карскому морю — только в конфигурационном файле.


Есть вопросы по пайплайну или хотите адаптировать его для своего региона? Пишите в комментариях или открывайте issue в репозитории.