ESPNSeptember surprises: Our unexpected all-portal teamThe Jerusalem PostFlights must be open to all or to no one, Iranian adviser says following Iraqi airport suspensionDaily MaverickFreedom from the ANC could be our real emancipationPunchLady apologises for AI-generated Itel power tank explosion imageUN NewsThe Takeaway: UN General Assembly debate Day 3RTP DesportoGP de Portugal antecipado para outubro em 2027, Argentina regressa e Hungria saiInquirerMarcos signs BSKE postponement into lawBollywood HungamaA true MASTERSTROKE: Avengers Endgame: Encore has MORE surprises beyond the leaked scenes; Marvel saves its BIGGEST twist for theatres (SPOILERS ahead)SCMP ChinaXi Jinping marks second Mid-Autumn Festival in US during state visitRadio Times10 Questions with Amol Rajan and Hannah FryBillboardMusic Venue Trust Teams Up With Drowned In Sound to Launch Live Music TitleSouth China Morning PostItaly to ban burkas in schools, with 30% cap on pupils with poor Italian
The Daily Newsstand · Free, Always
Friday, September 25, 2026

Метрика стала хуже на 20%, и вот почему так правильно

Translate

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

# --- Список аномальных парковок (замороженные > 24ч, exclude from training) ---
ANOMALOUS_PARKING_IDS = [7, 8, 11, 12, 14, 21, 24, 31, 32, 37, 47, 49, 50, 51,
                         52, 53, 54, 56, 58, 61, 66, 70, 72, 81, 82, 84, 95,
                         100, 106, 113, 121, 122, 128, 133, 134, 136, 146,
                         149, 162]

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

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

Совпало из них девять.

Откуда взялись две разные тридцатки

Когда мы готовили открытый датасет, пришлось разобраться, что делать с парковками, которые вечно показывают ноль свободных мест. Выглядит как «забита под завязку», на деле город по ним занятость просто не публикует. В базе появилась вью parking_feed_health с двумя полями: feed_silent (не отдавала свободных мест 30 дней) и last_free_at (когда отдавала в последний раз).

Сравнение списка из кода с этой вью:

захардкожено в train_models.py : 39
реально молчащих (feed_silent)  : 39
пересечение                     : 9

в списке, но фид живой (исключены зря): 30
молчат, но в списке нет (учатся зря)  : 30

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

Разъехалось по двум причинам. Часть фидов за год ожила: датчик починили или парковку наконец подключили. А в списке «молчат, но не исключены» половина id выглядит так:

1433907, 1458187, 2156769, 2156770, 2156771, 2156772, 2156773,
2156774, 2156775, 2156776, 2156777, 2156964, 2156966, 2428012,
2428018, 2453096, 2453099, 2453100, 2677193, 2677205, 3911562

Это парковки, которых в реестре не было, когда список писали. Город их добавил позже, занятость по ним не передаёт, и в обучение они попадают беспрепятственно.

Список из id — это снимок состояния на один день. Данные с тех пор менялись, а снимок нет.

Что молчащие парковки делают с метрикой

Бэктест у нас уже был, и я его переписал, потому что в старом нашлись две слабости. Он брал первые 60 парковок по id, не глядя, живой ли фид (из этих 60 молчали 10), и делал один срез на последние сутки. Один срез — это одна точка: попал на спокойную неделю, получил красивую цифру.

Новый замер идёт по всем московским парковкам и по шести срезам с шагом в сутки. На вход модели 336 получасовых точек (неделя), на выходе 48 (сутки). Результаты разделены по живости фида, и рядом считаются две наивные базы: «останется как сейчас» и «как было ровно неделю назад».

  группа    метод   окон     MAE      R2
    live      gru   1008    8.80   0.814
    live  persist   1008   25.09  -0.223
    live     week   1008   10.19   0.659
  silent      gru    234    1.18     nan
  silent  persist    234    0.00     nan
  silent     week    234    0.00     nan
     all      gru   1242    7.36   0.864
     all  persist   1242   20.36   0.107
     all     week   1242    8.27   0.751

nan в строках silent стоит по делу: R² делит на разброс истинных значений, а у константного ряда разброс нулевой, так что величина не определена.

Смотреть надо на разницу между строкой live gru и строкой all gru. MAE 8,80 против 7,36: 39 константных рядов улучшают публикуемую ошибку на 16%. Если считать в обратную сторону, честный замер хуже приукрашенного на 20%. R² при этом падает с 0,864 до 0,814.

Про качество модели эти 16% не говорят ничего. Их дают парковки, у которых occupancy_rate за сентябрь принимает ровно одно значение: 100. Не «почти всегда 100», а одно уникальное значение на 31 122 строки.

Модель на них работает хуже, чем её отсутствие

В той же таблице есть строчка, которая мне понравилась больше всех. На молчащих парковках «оставить как сейчас» даёт MAE ровно 0,00, потому что ряд константный и предсказывать там нечего. GRU на тех же парковках даёт 1,18.

Нейросеть слегка дёргается вокруг константы и портит идеальный ответ. И при этом её присутствие в выборке улучшает итоговую цифру, потому что 1,18 сильно меньше, чем 8,80 на настоящих парковках, и тянет среднее вниз. Самый бесполезный кусок работы выглядит в этом замере самым успешным.

Почему растёт именно R²

С MAE понятно: среднее по лёгким и трудным рядам ниже, чем по одним трудным. С R² механика интереснее.

R² сравнивает ошибку модели с разбросом самих данных: единица минус SSE, делённое на SST. Когда все парковки сваливаются в общий пул, SST считается вокруг общего среднего. Молчащие сидят на 100, то есть далеко от этого среднего, и добавляют к знаменателю прилично. А к числителю добавляют почти ноль, потому что ошибка там 1,18. Знаменатель растёт, числитель нет, R² растёт.

Насколько это ломает интуицию, видно по наивной базе. «Останется как сейчас» на живых парковках даёт R² = −0,223. Отрицательный R² означает, что предсказание хуже, чем просто взять среднее по выборке. Добавляем 39 константных рядов, и та же самая наивная база показывает +0,107. Метод не изменился ни на строчку, а из «хуже среднего» стал «лучше среднего».

Хорошая новость

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

На живых парковках GRU даёт MAE 8,80 и R² 0,814. Ближайшая осмысленная база, «как было ровно неделю назад», даёт 10,19 и 0,659. Персистенция на горизонте в сутки разваливается совсем: 25,09 и отрицательный R².

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

По горизонту деградация ожидаемая:

 часов вперёд     MAE      R2
          0-1    4.53   0.909
          1-3    6.01   0.878
          3-6    8.31   0.789
         6-12    9.79   0.754
        12-24    9.24   0.817

Любопытно, что на 12–24 часах вперёд точность чуть выше, чем на 6–12. Моя догадка: суточная сезонность сильная, и «завтра в это же время» попадает в ту же фазу суток, что и конец входного окна, а «сегодня вечером» приходится на переход. Отдельно я это не проверял.

Что поменял в коде

Список из id убрал совсем. Теперь исключаемые парковки берутся из базы на момент запуска:

SILENT_PARKINGS_SQL = """
    SELECT parking_id FROM parking_feed_health WHERE feed_silent
"""


def get_silent_parking_ids():
    db = SessionLocal()
    try:
        ids = [row[0] for row in db.execute(text(SILENT_PARKINGS_SQL))]
        logger.info("Исключаем из обучения %d парковок с мёртвым фидом", len(ids))
        return ids
    except Exception as exc:
        logger.error("Не удалось получить список мёртвых фидов: %s. "
                     "Обучение пойдёт по всем парковкам, метрики будут завышены.", exc)
        return []
    finally:
        db.close()

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

Бэктест тоже переписан: все парковки вместо первых шестидесяти, шесть срезов вместо одного, разделение по живости фида и наивные базы рядом с моделью.

Что из этого забрать

Захардкоженный список сущностей — это снимок данных, который живёт в коде и не обновляется вместе с ними. Год он выглядел разумно. Сравнить его с реальностью стоило раньше, и совпадение размеров (39 и 39) показывает, насколько легко такую протухлость не заметить: число сходится, а состав нет.

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

И последнее. Наивная база с отрицательным R² оказалась полезнее самой метрики: она показала, что происходит с пулом, гораздо нагляднее, чем модель. Если базы нет, непонятно, с чем сравнивать 0,864.

Данные, на которых всё это считалось, лежат открыто: 4,6 млн получасовых замеров по 210 московским парковкам, CC BY 4.0. Поля feed_silent и last_free_at там есть, так что отделить живые фиды от мёртвых можно в три строки.

Это четвёртая статья по следам одного проекта. Предыдущие: про чёрную карту без единой ошибки в консоли, про −1000% занятости в собственном датасете и про сам датасет.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.