MBL
Ml / ДЗ2: EDA — ДТП в Белгородской области (архитектура и дашборд)
Ml сложный

ДЗ2: EDA — ДТП в Белгородской области (архитектура и дашборд)

edastreamlitpandasitmodashboard

Домашка про 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 в разделе «Свои вопросы»:

  1. Наезды на пешеходов и освещение — родился прямо из наблюдения в гипотезе H1: ночью выше доля ДТП с погибшими в среднем по всем ДТП, захотелось проверить конкретно на пешеходах и оказалось, что эффект там в разы сильнее (10.3% против 47.8%).
  2. Число участников и тяжесть — ожидание монотонного роста риска не подтвердилось, вместо него U-образная зависимость.
  3. «Недостатки зимнего содержания» по сезонам — прямое продолжение гипотезы 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.

Как проходить защиту: маршрут на примере вывода про освещение

  1. Вкладка EDA, раздел «Свои вопросы» — показать график наездов на пешеходов по освещению, на глаз видно резкую разницу.
  2. Вкладка «Гипотезы» — та же идея по всем ДТП, но строго: H1, χ²-тест, 15.89% против 9.20%, эффект +6.69 п.п., ДИ [5.46; 7.92], не захватывает ноль.
  3. Вкладка «PCA / t-SNE» — показать, что PC1 связан именно с sev_С погибшими и категориями наездов/съездов, и что города по этой оси стоят отдельно от районов — согласуется с H3.
  4. Раздел «Итог» в analysis.md — то же самое как финальное утверждение с оговоркой про связь, а не причину.

Что меняется при выборе фильтра периода/района: метрики сверху, временной ряд, категориальные графики, карта и своя тройка вопросов на вкладке EDA — все считаются по current. Вкладки «Гипотезы» и «PCA / t-SNE» не меняются от фильтра (см. раздел про кэширование выше) — это стоит проговорить на защите заранее, чтобы не выглядело багом при демонстрации.

Вопросы, которые могут задать на защите

«Почему у вас region не пришлось чинить, а у соседа (Владимирская область) — пришлось?» Разные регионы в одном источнике заполняются с разным качеством — это не общее свойство dtp-stat.ru, а конкретно повезло с Белгородской выгрузкой; проверять на своих данных нужно заново, не наследовать чужой список проблем.

«Почему агрегированная карта — плотность, а не сетка с долей летальных ДТП по ячейке?» Осознанный выбор под задачу: сглаженная плотность отвечает на вопрос «где вообще больше ДТП», для которого не нужна метрическая сетка с порогом минимального числа наблюдений в ячейке — это было бы оправдано для более точечного вопроса про долю летальных именно в конкретной локации.

«Почему гипотезы не пересчитываются при смене фильтра — это баг?» Нет, осознанное решение: иначе p-value и эффект гипотезы менялись бы вместе с произвольным пользовательским срезом, и сравнивать один и тот же вывод стало бы бессмысленно — весь дашборд явно подписывает, что эти вкладки считаются по полной выгрузке.

Самопроверка 0 / 7
Могу объяснить, почему поле region здесь НЕ пришлось чинить (в отличие от Владимирской выгрузки)
Могу назвать все 6 модулей solution/ и сказать, за что отвечает каждый
Понимаю, почему compute_hypotheses и compute_projections кэшируются по dated, а не по current
Могу объяснить, чем агрегированная карта через density_map отличается от сетки/гексагонов
Могу пересказать все три своих вопроса и то, откуда взялся первый из них
Могу назвать три конкретные проблемы графика «до» и как каждая исправлена в «после»
Могу пройти один вывод от сырых данных до интерпретации на дашборде без шпаргалки
Как усвоено?