Мы собираем загруженность московских парковок с марта 2025-го и в какой-то момент выложили накопленное открытым датасетом: 4,68 млн получасовых замеров по 210 муниципальным парковкам. GitHub, Kaggle, Hugging Face, лицензия CC BY 4.0.

Дальше я потратил несколько сессий на то, чтобы поднять рейтинг оформления на Kaggle: описание, обложка, ноутбук, теги, лицензия. Дошёл до 82%, зашёл на страницу полюбоваться — и увидел под колонкой occupancy_rate гистограмму, у которой левая граница подписана −1000.

Процент занятости. Минус тысяча.

Дальше — расследование, в котором каждая следующая гипотеза оказывалась неверной, а правильный ответ нашёлся только тогда, когда я перестал доверять своей же документации.

Что вообще значит −1000%

Первым делом посчитал масштаб по всему датасету:

occupancy_rate < 0 : 19 792 строки (0,423%), 27 парковок
occupancy_rate > 100: 0 строк

Потом разложил по месяцам — и картина сразу стала осмысленной:

месяц    всего     <0      худший
2025-04  240 072      0        —
2025-05  250 538    557    -25,4
2025-06  220 830  1 820    -84,7
2025-07  244 513  2 036    -50,0
2025-08  244 432  1 560  -1000,0
2025-09  213 528  3 918  -1000,0
2025-10  229 321  4 576    -50,0
2025-11  253 877  4 433    -50,0
2025-12  265 962    892    -50,0
2026-01  290 705      0        —
2026-02  273 320      0        —
...
2026-09  163 367      0        —

Отрицательные значения живут ровно в мае–декабре 2025 и исчезают навсегда с января 2026. Это не случайный шум — это след изменения в коде.

Полез в коллектор. Функция расчёта выглядит так:

declared_common_total = parking.get('spaces', {}).get('common', 0)
current_common_free = congestion.get('spaces', {}).get('overall', {}).get('free', 0)

if declared_common_total <= 0:
    return 0.0

current_common_free = max(0, current_common_free)
current_common_free = min(current_common_free, declared_common_total)   # <- вот это

occupied_common = declared_common_total - current_common_free
return (occupied_common / declared_common_total) * 100

Две строчки с max и min загоняют число свободных мест в отрезок от нуля до объявленной вместимости: сколько бы город ни прислал, в расчёт пойдёт значение из этого диапазона. В англоязычном коде такое ограничение называют clamp — «зажать в тиски».

Важна здесь вторая строчка. С ней отрицательный процент получиться не может в принципе. Без неё — легко. Парковка объявляет 30 обычных мест, город присылает «свободно 50»:

занято  = 30 − 50 = −20
процент = −20 / 30 × 100 = −66,7%

Минус шестьдесят шесть процентов занятости.

Ограничение добавили в январе 2026-го. Всё сошлось: 19 792 строки — это отпечаток восьми месяцев, когда его не было.

Гипотеза, которая едва не испортила 4,6 млн строк

Раз формула известна, значит историю можно пересчитать. Данные-то есть: free_spaces в датасете, common_spaces в паспортах парковок. Берём, применяем формулу с ограничением, перезаливаем — и датасет чистый.

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

месяц      строк   совпало
2025-04  240 072   73,450%
2025-07  244 513   80,556%
2026-01  290 705   86,312%
2026-05  299 936   93,810%
2026-08  307 353   96,287%
2026-09  163 367   98,464%

Не сходится. Причём не случайным образом: чем ближе к сегодняшнему дню, тем лучше совпадение. От 73% в апреле 2025 до 98,5% в сентябре 2026.

Это очень характерная подпись. Так выглядит не ошибка в формуле, а дрейф справочных данных. Что-то, что я беру из паспорта парковки, со временем меняется — и сегодняшнее значение подходит только для сегодняшних строк.

Если бы я не проверил, а сразу перезалил, я бы своими руками испортил 4,6 млн строк, заменив правильные исторические значения на пересчитанные по неправильному знаменателю. И выглядело бы это «чисто»: ни одного отрицательного процента, всё в диапазоне 0–100.

Восстанавливаем знаменатель из самих данных

Если знаменатель менялся, его можно вытащить обратно. Формула обратима:

rate = (D − free) / D × 100   ⟹   D = free / (1 − rate/100)

Важная тонкость: строки, где ограничение сработало, для этого не годятся. Там free урезали до D, поэтому rate ровно 0, и восстановленный знаменатель окажется равен исходному free, а не настоящей ёмкости. Такие строки надо выкинуть — берём только те, где 0 < rate < 100.

m = (rate > 0.0001) & (rate < 99.999) & (free > 0)
D = (free[m] / (1.0 - rate[m] / 100.0)).round()

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

строк для восстановления: 3 135 194
знаменатель — целое число: 100,00%

Сто процентов. Не 99,8, не «почти все» — все три с лишним миллиона строк дают на выходе целое число. Формула восстановлена абсолютно точно: если бы я ошибся хоть в одном множителе, ничего целого не получилось бы.

Дальше — что это за число:

знаменатель == common_spaces : 166 парковок из 190
знаменатель == total_spaces  :   2 парковки из 190

Знаменатель — это common_spaces, «обычные» места без инвалидных. Гипотеза про total_spaces отпадает.

И главное:

парковок с постоянной ёмкостью : 116 из 190
парковок, менявших ёмкость     :  74 из 190

У одной парковки за 19 месяцев знаменатель принимал 21 разное значение, от 26 до 300. Город переобъявляет вместимость, а в паспорте у меня лежит только последнее значение.

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

Заодно выяснилось, что документация врёт

Пока я возился со знаменателем, зацепился взглядом за строчку в собственном README:

free_spaces — Total number of free spaces at this moment (common + handicapped)

А формула то же самое поле использует как «свободные обычные места» и делит на common_spaces. Одно из двух неверно.

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

free_spaces <  free_handicapped_spaces : 320 511 строк (6,843%)

В 6,8% строк — меньше. Значит это не сумма и не часть от суммы. Это два независимых счётчика, которые приезжают из API отдельно и друг с другом не согласованы. README врал полтора года, и вслед за ним врали описание на Kaggle и карточка на Hugging Face, потому что я их с README и копировал.

Заодно посчитал, насколько вообще стоит доверять этим счётчикам:

free_handicapped > паспортной ёмкости : 94 435 строк (2,0%), 36 парковок
free_spaces > common_spaces           : 116 327 строк (2,5%), 55 парковок
максимум free_handicapped             : 9979 — при объявленных ДВУХ местах

Девять тысяч девятьсот семьдесят девять свободных мест для инвалидов на парковке, где их два. Одна парковка, один день, июнь 2026-го. Счётчик просто сошёл с ума, а мы это добросовестно записали и опубликовали.

Почему это пролежало месяцы

Самое неприятное открытие ждало в скрипте экспорта. Проверка там была. С самого начала:

bad = df["occupancy_rate"].dropna()
bad = bad[(bad < 0) | (bad > 100)]
if not bad.empty:
    LOG.warning("  %d rows have out-of-range occupancy_rate in %s", len(bad), month.label)

Она честно срабатывала. Каждый раз. И каждый раз выводила одну строчку в середине лога, между «Exporting occupancy 2025-09» и «wrote 213528 rows». Экспорт гоняет 19 месяцев подряд, лог длинный, в конце бодрое «Done. 4684084 occupancy rows exported». Предупреждение терялось в потоке, и его не видел никто — включая меня.

Схема, кстати, тоже была в курсе. В occupancy.schema.json честно стояло:

"occupancy_rate": { "minimum": 0.0, "maximum": 100.0 }

Схема, которой не соответствуют её собственные данные. Никто её не валидировал — она лежала как документация.

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

INFO  Done. 4684084 occupancy rows exported across all months.
WARN  Data quirks left in the export on purpose (see README):
WARN    free_spaces < free_handicapped_spaces       320511 rows (6.843%)
WARN    occupancy_rate below 0                       19792 rows (0.423%)
WARN    Documented in README section: Known quirks in the numbers themselves

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

Что в итоге сделал с данными

Ничего.

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

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

  • раздел Known quirks in the numbers themselves с точными цифрами и готовыми фильтрами — в README, в описание на Kaggle, в карточку на Hugging Face;

  • описание free_spaces во всех трёх местах: это common-пул, а не сумма;

  • minimum в JSON-схеме приведён в соответствие с реальностью;

  • сводка по аномалиям в конце экспорта.

Плюс в описании каждой колонки на Kaggle теперь прямо написано, чего ждать. Например, у occupancy_rate:

Normally 0-100, but 19,792 rows (0.42%, confined to 18 May - 9 Dec 2025) are negative down to -1000, because before January 2026 the collector did not clamp free spaces to declared capacity. Filter occupancy_rate >= 0 for a clean scale.

Что забрать с собой

  • Проверяйте формулу на данных, где аномалий нет, прежде чем чинить данные, где они есть. Мой пересчёт «исправил» бы 19 792 плохие строки ценой порчи четырёх с половиной миллионов хороших.

  • Монотонность ошибки — это улика. «Совпадение растёт от 73% к 98% по мере приближения к сегодня» прямо указывало на дрейф справочника. Просто «не совпадает» такой информации не несёт.

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

  • Одинокий LOG.warning посреди длинного лога — это то же самое, что его отсутствие. Если проверка не меняет того, что вы видите в конце прогона, она не работает.

  • Данные в наблюдательном архиве чинить не надо. Надо чинить документацию, которая про них врёт. Пользователь переживёт 0,42% странных строк, если знает о них. Не переживёт — если узнает от своей модели.

Датасет лежит на GitHub, на Kaggle и на Hugging Face. 4 684 084 строки, 19 месяцев, CC BY 4.0. Теперь — с честным разделом про то, что в нём не так.


Это вторая статья по следам одного проекта. Первая — про чёрную карту без единой ошибки в консоли.

Комментарии (0)