Информационная система прогнозирования квартального пассажиропотока станций Московского метрополитена. Бэкенд на FastAPI, интерфейс на Streamlit, шесть моделей прогноза за одним интерфейсом.
Работа выполнена как ВКР, защищена в 2026 году.
Есть открытые данные по входящему пассажиропотоку станций метро: одна точка на станцию в квартал, 2021-Q1 — 2026-Q1. Нужно прогнозировать поток на несколько кварталов вперёд и сравнивать между собой разные подходы к прогнозу на одинаковых входных данных.
Сложность в самих данных. Пять лет квартальных наблюдений — это 21 точка на станцию. Классические модели временных рядов на такой длине работают плохо: сезонность занимает четыре точки из двадцати одной, а на период 2021–2022 приходится провал и восстановление после ковида, который выглядит как структурный сдвиг, а не как сезонное колебание.
Отсюда основное архитектурное решение: модели делятся на два вида. Baseline обучается на одной станции по её собственному ряду. Panel обучается один раз на всех станциях сразу, а станция передаётся признаком или эмбеддингом. Panel-модель компенсирует короткий ряд шириной выборки: 263 станции по 21 точке дают достаточно данных для рекуррентной сети, тогда как на одной станции она бы не обучилась.
backend/
api/ FastAPI: роутеры, схемы запроса и ответа, обработчики ошибок
application/ сценарии: forecasting, training
domain/ сущности, доменные исключения
infrastructure/ репозитории, SQLAlchemy ORM, сессии
ml/ модели прогноза, реестр, препроцессинг, метрики
migrations/ Alembic
config.py pydantic-settings
frontend/ Streamlit: страницы, клиент API, тема
assets/ app.css, motion.js
experiments/ исследовательская часть: прогоны моделей, результаты в json
scripts/ загрузка датасета в базу
docker/ образы и compose
deploy/ развёртывание на один сервер
tests/
Зависимости направлены внутрь: api → application → domain, infrastructure → domain. Слой domain не импортирует ни фреймворк, ни драйвер базы, ни библиотеки моделей — только стандартную библиотеку. Слой application работает с репозиториями и реестром моделей через их интерфейсы и ничего не знает о том, что под ними Postgres и PyTorch.
Модели описаны двумя протоколами в backend/ml/models/base.py: BaselineForecaster принимает ряд одной станции, PanelForecaster принимает панель со всеми станциями и идентификатор нужной. Добавление седьмой модели — это новый класс, реализующий протокол, и одна ветка в backend/ml/registry.py. Ни application, ни api при этом не меняются.
Реестр импортирует реализации лениво: процесс, которому нужна только наивная модель, не тянет PyTorch и XGBoost. Обучение и инференс уходят в asyncio.to_thread, чтобы не блокировать цикл событий FastAPI.
Baseline: наивная сезонная, SARIMA, Prophet. Panel: XGBoost, LSTM, GRU.
Тест — 2025-Q1 … 2026-Q1 (5 кварталов), обучение на всём, что раньше. Сводные метрики по всем станциям:
| модель | n | MAE | RMSE | MAPE | R² |
|---|---|---|---|---|---|
| LSTM panel | 1046 | 174 172 | 262 750 | 9.84% | 0.958 |
| GRU panel | 1046 | 181 933 | 266 325 | 10.22% | 0.957 |
| XGBoost panel | 1078 | 176 586 | 289 173 | 12.38% | 0.949 |
Baseline-модели считались отдельно, на пяти станциях с наибольшим потоком. Там средний MAPE: наивная сезонная 9.27%, Prophet 12.40%, SARIMA 22.92%.
Три вывода, которые стоит читать вместе с таблицей.
Первый. GRU практически не отличается от LSTM (10.22% против 9.84% MAPE). Разница меньше, чем разброс между прогонами, поэтому утверждать, что одна архитектура лучше другой на этих данных, нельзя. Вторая сеть добавлена, чтобы это показать, а не чтобы улучшить результат.
Второй. Наивная сезонная модель на топ-5 станциях не уступает LSTM по MAPE. На коротком ряде с сильной годовой сезонностью «взять значение год назад» — сильный ориентир, и это нормальный результат, а не ошибка эксперимента. Ценность panel-моделей проявляется на всём наборе станций, а не на самых крупных.
Третий. Высокий R² в таблице получен по всем станциям сразу и в основном отражает то, что модель различает крупные и мелкие станции. На отдельных станциях R² уходит в минус (на ВДНХ около −0.68 у LSTM) — внутри одной станции модель предсказывает хуже, чем её собственное среднее. Метрика по панели льстит, поэтому в интерфейсе метрики показываются и по станции тоже.
Воспроизведение: python experiments/run_experiment.py и python experiments/run_gru.py, результаты пишутся в experiments/last_run.json и experiments/gru_run.json.
Python 3.11, FastAPI, Streamlit, SQLAlchemy 2.0 (async), Alembic, PostgreSQL 15, Docker Compose. Модели: PyTorch, XGBoost, statsmodels, Prophet.
Нужны Python 3.11+, PostgreSQL 14+ (или Docker), uv, make.
make install
cp .env.example .env
docker compose -f docker/docker-compose.yml up -d db
make migrate
make load CSV=data/passenger_flow_full.csv
make run
API: http://localhost:8000/docs Интерфейс: http://localhost:8501
Таргеты Makefile сами выставляют PYTHONPATH, так что импорты backend.* и frontend.* работают в любом окружении.
Полный стек в контейнерах: docker compose -f docker/docker-compose.yml up --build.
| метод | путь | назначение |
|---|---|---|
| GET | /api/lines |
линии метро |
| GET | /api/stations |
станции, фильтр по линии |
| GET | /api/stations/{station_id}/flow |
исторический ряд станции |
| GET | /api/models |
обученные модели |
| GET | /api/models/{model_id} |
модель по идентификатору |
| POST | /api/models/train |
обучить модель выбранного вида |
| GET | /api/models/{model_id}/metrics |
метрики модели на тесте |
| POST | /api/predict |
прогноз на заданные периоды |
| GET | /api/predictions/{station_id} |
сохранённые прогнозы станции |
Схема OpenAPI генерируется из кода и доступна на /docs.
Версии в пути нет: у API один потребитель — собственный интерфейс, и оба выкатываются вместе. Для внешних клиентов пришлось бы вводить /api/v1 и политику совместимости.
make test pytest
make lint ruff check
make typecheck mypy backend (strict)
28 тестов на препроцессинг, метрики, реестр моделей и внутренние функции сценария прогноза. Покрыта расчётная часть: разбор панели, оконное кодирование, метрики, границы доверительного интервала, ошибки реестра. HTTP-слой и репозитории тестами не покрыты — при разборе кода стоит смотреть в первую очередь на backend/ml и backend/application.
mypy проходит в режиме strict. CI прогоняет линтер, проверку формата, типы, миграции на живом Postgres и тесты.
Источник — портал открытых данных Москвы, набор data.mos.ru/opendata/62743. Выгрузка от 14.05.2026: 6142 квартальные записи по 263 станциям 18 линий за 2021-Q1 — 2026-Q1. Файл лежит в data/passenger_flow_full.csv, описание полей — в data/README.md.
Загрузка в базу — scripts/load_dataset.py.
Что система не делает и где проходят её границы:
- горизонт прогноза ограничен несколькими кварталами. При горизонте больше 8 рекуррентные модели уходят в плато, потому что кормятся собственными предсказаниями
- доверительный интервал считается как ±1.96σ по остаткам на тесте и предполагает нормальность остатков, которая на этих данных не проверялась. Интервал стоит читать как оценку порядка разброса, а не как строгие 95%
- переобучение запускается вручную из интерфейса, автоматического расписания нет
- аутентификации нет: система рассчитана на запуск в закрытом контуре. Выставлять её в интернет как есть нельзя
- один инстанс, обученные модели лежат на диске рядом с приложением. Горизонтально не масштабируется без вынесения хранилища моделей
- данные квартальные, поэтому для оперативных задач система не пригодна: суточный и часовой профиль внутри квартала не виден