Мы собираем загруженность московских парковок с марта 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 >= 0for 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. Теперь — с честным разделом про то, что в нём не так.
Это вторая статья по следам одного проекта. Первая — про чёрную карту без единой ошибки в консоли.