ДЗ2: EDA — ДТП в Белгородской области (архитектура и дашборд)
Домашка про EDA: выгрузка ДТП по Белгородской области с dtp-stat.ru (13 371 запись, регион назначен по GitHub username Mentigen), дашборд на Streamlit, три статистические гипотезы, PCA/t-SNE и карта.
Эта страница — про архитектуру и дашборд. Статистика вынесена отдельно в 03-hw2-eda-statistics-belgorod, PCA/t-SNE — в 04-hw2-eda-pca-tsne-belgorod.
Пункт ТЗ → где это в коде
| Пункт ТЗ | Где реализовано |
|---|---|
| Производные признаки (час, сезон, ночь/день, город) | solution/features.py → add_derived_features |
| Профили районов для PCA/t-SNE | solution/features.py → district_profiles |
| 12 визуализаций | solution/eda_charts.py, вкладка EDA в app.py |
| 3 гипотезы, разные тесты + CI | solution/stats_tests.py → run_all |
| PCA и t-SNE | solution/projections.py → run_pca, run_tsne |
| Точечная и агрегированная карта | solution/geo_agg.py → density_map, вкладка «Карта» |
| Фильтр периода + района/категории | сайдбар main() в app.py (было в каркасе) |
| График до/после | solution/assets/category_before.png / category_after.png |
Данные: чем эта выгрузка отличается от соседних регионов
Белгородская выгрузка оказалась заметно чище, чем у Владимирской области (см. соседний гайд по ней): 0 нераспознанных дат, 0 повторных ID, поле region не потребовало реклассификации — все 22 значения сразу оказались настоящими районами, без псевдорайона вида «область целиком». Единственная проблема — 13 записей из 13 371 (0.10%) с непригодными для карты координатами (map_valid=False), они просто не попадают на карту, но участвуют во всех остальных расчётах.
Вывод отсюда практический: не стоит переносить чек-лист проблем один-в-один между регионами одного источника — у dtp-stat.ru качество выгрузки по регионам разное, и это надо проверять заново на своих данных, а не полагаться на то, что нашли в чужом отчёте.
Последний месяц выгрузки (январь 2026) неполный — 78 записей против обычных 1000-1400 в год, поэтому на временном ряде и в погодовых сравнениях этот период не учитывается как полноценный год.
Архитектура: шесть небольших модулей вместо одного большого app.py
Каркас задания уже приносит data_loading.py (load_records, records_to_frame) — чтение JSON и первичная таблица. Всё остальное разложено по смыслу, а не свалено в app.py:
features.py—add_derived_features(час, день недели, сезон,is_night,is_city,is_fatal,is_pedestrian,winter_road_issue,participants_bucket) иdistrict_profiles(числовой профиль каждого района — доли категорий/тяжести/освещённости + средние показатели, вход для PCA/t-SNE).stats_tests.py— три функции-гипотезы плюсrun_all, каждая возвращает словарь с группами, эффектом, ДИ, тестом и p-value одним и тем же контрактом (подробности — в статье про статистику).eda_charts.py— по одной функции на график, принимают готовый датафрейм и возвращаютplotly-фигуру; ничего не знают про Streamlit.geo_agg.py— агрегированная карта отдельно от точечной.projections.py— PCA и t-SNE, тоже без единой строчки Streamlit внутри.app.py— только сборка: кэширующие обёртки и раскладка по вкладкам.
Разделение окупилось один раз явно: когда потребовалось поправить records_to_frame (добавить поле road_conditions для своего вопроса №3), правка ушла одной строкой в data_loading.py, и всё остальное — features.py, графики, гипотезы — продолжило работать без изменений, потому что зависело от контракта датафрейма, а не от деталей парсинга JSON.
Кэширование: что считается один раз, а что на каждый фильтр
Streamlit перезапускает весь app.py сверху вниз при каждом клике — без кэша это значило бы читать 13 371 запись и заново гонять 10 000 перестановок в перестановочном тесте на каждое движение фильтра.
read_data кэшируется по (path, modified_ns, size) — дешёвый ключ вместо хэширования самого файла. compute_hypotheses и compute_projections кэшируются по аргументу frame напрямую (Streamlit хэширует сам DataFrame через st.cache_data) — и это осознанный компромисс, а не автоматически лучшее решение.
dated (полная выгрузка после отбраковки нераспознанных дат) передаётся туда целиком, а не через дешёвый ключ вида _frame + хэш из имени/размера файла, как сделано в гайде по Владимирской области. Работает корректно и на 13 тысячах строк незаметно, но при выгрузке на порядок больше стоило бы перейти на тот же паттерн — честная точка для улучшения, а не то, что уже сделано идеально.
Гипотезы и PCA/t-SNE считаются по dated (вся выгрузка после чистки дат), а не по current (срез с учётом фильтров периода/района). Это прямо подписано в интерфейсе: иначе перестановочный тест и bootstrap пересчитывались бы заново на каждый клик, а сравнивать их результат между собой стало бы бессмысленно — вывод гипотезы «плясал» бы вместе с фильтром вместо того, чтобы быть одним устойчивым утверждением про весь регион.
Визуализации: 12 штук, каждая закрывает свой обязательный тип
- временной ряд — число ДТП по месяцам, area-график с разбивкой по тяжести (
monthly_timeseries); - категориальный — топ-10 типов ДТП (
category_bar) и топ-12 районов (district_bar); - распределение числового признака — ECDF числа пострадавших на ДТП (
injured_distribution); - сравнение групп — доля тяжести день/ночь и по сезонам, 100%-stacked bar (
severity_share_by_group); - heatmap — число ДТП по часу суток × дню недели (
hour_weekday_heatmap); - точечная и агрегированная карта — переключатель на вкладке «Карта» (подробнее ниже);
- PCA и два запуска t-SNE — вкладка «PCA / t-SNE» (отдельная статья).
Ещё три графика — собственные вопросы, не из обязательного списка, все на вкладке EDA в разделе «Свои вопросы»:
- Наезды на пешеходов и освещение — родился прямо из наблюдения в гипотезе H1: ночью выше доля ДТП с погибшими в среднем по всем ДТП, захотелось проверить конкретно на пешеходах и оказалось, что эффект там в разы сильнее (10.3% против 47.8%).
- Число участников и тяжесть — ожидание монотонного роста риска не подтвердилось, вместо него U-образная зависимость.
- «Недостатки зимнего содержания» по сезонам — прямое продолжение гипотезы H2 про сезонность наездов на пешеходов, возможный механизм эффекта.
Карта: точки и сглаженная плотность вместо сетки
Точечная карта (px.scatter_map, была в каркасе) красит каждую точку по тяжести; при больше чем 5000 точек в срезе берётся случайная подвыборка с random_state=42 — только для отрисовки, метрики и CSV считаются по полному срезу.
Агрегированный вид — не сетка и не гексагоны, а сглаженная плотность (plotly.express.density_map, радиус ядра 12 пикселей). Выбор осознанный: у Белгородской области нет полигонов районов в выгрузке (только точки), а превращать координаты в метровую сетку (как сделано для Владимирской области через EPSG:3857) — оправдано, когда нужна честная статистика по ячейке, например доля летальных ДТП в квадрате 4×4 км, для которой важны точные границы и минимальный порог наблюдений.
Здесь задача проще — просто увидеть, где плотность выше, поэтому готовая ядерная оценка плотности из plotly закрывает вопрос без отдельной геометрии. Вдобавок она не ограничена подвыборкой в 5000 точек, в отличие от точечного вида: плотность считается по всем 13 358 пригодным точкам сразу.
График до/после: что именно стало легче увидеть
Выбран график «Топ типов ДТП» (category_before.png / category_after.png в solution/assets/). Три конкретные проблемы версии «до» и их исправление:
# все 15 категорий, порядок "как встретилось" в данных,
# вертикальные подписи на 90°, обрезаются краями графика
ax.bar(range(len(counts)), counts.values)
ax.set_xticklabels(counts.index, rotation=90, fontsize=6)
# нет заголовка, нет подписи осей, один цвет без смысла# топ-10, отсортировано по убыванию
top10 = counts.head(10).sort_values()
# горизонтальные бары — длинные русские названия читаются
# полностью без поворота подписи
ax.barh(top10.index, top10.values, color='#3579B8')
ax.set_xlabel('Число ДТП')
ax.set_title('Топ-10 типов ДТП (Белгородская область, 2015-2026)')
# число подписано у каждого бара — не нужно прикидывать на глаз
for bar, value in zip(bars, top10.values):
ax.text(value, bar.get_y(), f'{value:,}', va='center')Стало легче увидеть: какие типы ДТП реально доминируют (Столкновение — 6 134, Наезд на пешехода — 3 543), не разбираясь в повёрнутых обрезанных подписях пяти хвостовых категорий с числом записей меньше 30.
Как проходить защиту: маршрут на примере вывода про освещение
- Вкладка EDA, раздел «Свои вопросы» — показать график наездов на пешеходов по освещению, на глаз видно резкую разницу.
- Вкладка «Гипотезы» — та же идея по всем ДТП, но строго: H1, χ²-тест, 15.89% против 9.20%, эффект +6.69 п.п., ДИ [5.46; 7.92], не захватывает ноль.
- Вкладка «PCA / t-SNE» — показать, что PC1 связан именно с
sev_С погибшимии категориями наездов/съездов, и что города по этой оси стоят отдельно от районов — согласуется с H3. - Раздел «Итог» в
analysis.md— то же самое как финальное утверждение с оговоркой про связь, а не причину.
Что меняется при выборе фильтра периода/района: метрики сверху, временной ряд, категориальные графики, карта и своя тройка вопросов на вкладке EDA — все считаются по current. Вкладки «Гипотезы» и «PCA / t-SNE» не меняются от фильтра (см. раздел про кэширование выше) — это стоит проговорить на защите заранее, чтобы не выглядело багом при демонстрации.
Вопросы, которые могут задать на защите
«Почему у вас region не пришлось чинить, а у соседа (Владимирская область) — пришлось?» Разные регионы в одном источнике заполняются с разным качеством — это не общее свойство dtp-stat.ru, а конкретно повезло с Белгородской выгрузкой; проверять на своих данных нужно заново, не наследовать чужой список проблем.
«Почему агрегированная карта — плотность, а не сетка с долей летальных ДТП по ячейке?» Осознанный выбор под задачу: сглаженная плотность отвечает на вопрос «где вообще больше ДТП», для которого не нужна метрическая сетка с порогом минимального числа наблюдений в ячейке — это было бы оправдано для более точечного вопроса про долю летальных именно в конкретной локации.
«Почему гипотезы не пересчитываются при смене фильтра — это баг?» Нет, осознанное решение: иначе p-value и эффект гипотезы менялись бы вместе с произвольным пользовательским срезом, и сравнивать один и тот же вывод стало бы бессмысленно — весь дашборд явно подписывает, что эти вкладки считаются по полной выгрузке.