45 Коммитов

Автор SHA1 Сообщение Дата
99d81722c4 docs: добавить общее описание лабораторного проекта 2025-12-08 23:03:04 +03:00
6fe4557041 docs: добавить примеры мониторинга 2025-12-08 23:03:04 +03:00
667b6ac966 docs: базовая документация всего сервиса и развёртывания 2025-12-08 23:03:04 +03:00
434f4776b1 fix: сделать доступ из контейнера к модели read-only 2025-12-08 23:03:03 +03:00
296471990a Merge branch 'lab_3/master' into lab_4/wip 2025-12-08 23:02:47 +03:00
e232b693ca использовать именованные volume-ы для БД Prometheus и Grafana 2025-12-08 22:57:08 +03:00
9a6b531951 неудачная попытка вытащить БД из Prometheus (см. примечания в compose.yaml) 2025-12-08 22:57:08 +03:00
aade830c20 refactor: в compose запускать тестеры только с профилем 'with-testers' 2025-12-08 22:57:08 +03:00
9780f6e710 fix: предотвращать аварийный выход load-tester при недоступности prices-predictor во время его потенциально долгого запуска через compose 2025-12-08 22:57:08 +03:00
fd9a932e6c feat: добавить конфигурацию grafana и включить её в compose 2025-12-08 22:57:06 +03:00
91e7437e29 feat: файл compose (prices predictor + load testers + prometheus) 2025-12-08 22:56:25 +03:00
b880d2d699 feat: интеграция prometheus в ml_service, конфигурация prometheus 2025-12-08 22:55:59 +03:00
813f622579 feat: load tester 2025-12-08 22:55:02 +03:00
e43c47dae6 fix: путь к README для сервиса ml_service 2025-12-08 20:25:02 +03:00
322e02aa0b добавлены методы вызовов HTTP в README 2025-11-29 09:43:04 +03:00
cddf0e0b65 Merge branch 'lab_2/master' into lab_3/master 2025-11-22 15:01:46 +03:00
d417fa1d5d fix: Не удается привязать аргумент к параметру "Path", так как он имеет значение NULL. 2025-11-22 15:01:08 +03:00
8d7d2d5d7a добавить README для веб-сервиса 2025-11-22 15:00:24 +03:00
00f85d5d3c добавить номер версии к образу (1) 2025-11-22 14:25:07 +03:00
cbed2cf894 refactor: оптимизировать сборку образа Docker (сначала устанавливать зависимости, потом копировать код) 2025-11-22 13:55:07 +03:00
7bb2455d4c добавить веб-сервис предсказания цен, с dockerfile 2025-11-12 12:22:23 +03:00
7a3ba02966 fix: вызов _mflow_config_common в run_mlflow_server.sh (sh-версия) 2025-11-12 12:21:13 +03:00
970e46c9f0 добавить __pycache__ в .gitignore явно 2025-11-12 12:19:37 +03:00
00e0ce4b78 добавить ipynb-версию блокнота research по указанию преподавателя 2025-11-02 03:10:53 +03:00
25444818cc добавить README к research, обновить общий README (расположение файлов requirements), вынести документацию по использованию Jupyter в отдельный файл 2025-11-02 03:06:28 +03:00
4665a66473 добавить скрипты для вызов mlflow gc, вынести общую часть конфигурации скриптов вызова mlflow в отдельный sourceable скрипт 2025-11-02 02:41:30 +03:00
59897fbe61 в блокнот research добавить логирование списков выбранных признаков в MLFlow (через новый коллбек в mlflow_log_model), закомментировать логирование global_comment_file 2025-11-02 02:41:30 +03:00
bb1796e081 в блокнот research добавлено логирование Python requirements в MLFlow 2025-11-02 02:41:29 +03:00
d39d8f98d6 в блокнот research добавлена часть о выбранной лучшей модели 2025-11-02 02:41:29 +03:00
a7a0780f1a refactor: в блокноте research, использовать mlflow nested runs 2025-11-02 02:41:29 +03:00
c16caf2e6a сделать прогон optuna в блокноте research опциональным 2025-11-02 02:41:29 +03:00
22ef4d303c добавить в блокнот research часть про оптимизацию гиперпараметров, обобщить некоторый код (в особенности фильтры параметров моделей) 2025-11-02 02:41:29 +03:00
543a4c6571 добавить в блокнот research часть про фильтрацию признаков с помощью SequentialFeatureSelector 2025-11-02 02:41:29 +03:00
ce02a6966b изменить гиперпараметры RandomForestRegressor по умолчанию 2025-11-02 02:41:29 +03:00
8c3e3c1588 добавить в блокнот research часть о feature engineering с sklearn, выделить в блокноте некоторые общие функции, убрать 'transformer_input' из сохраняемых параметров Pipeline 2025-11-02 02:41:29 +03:00
2831ff4e81 fix: использовать правильное ядро Jupyter (локальное) в блокноте research 2025-11-02 02:41:28 +03:00
6038a1c566 рефакторинг блокнота research в части логирования в mlflow 2025-11-02 02:41:28 +03:00
070688dc68 рефакторинг блокнота research в плане использования MLFlow 2025-11-02 02:41:28 +03:00
2b2241b2ab разделение requirements.txt по частям проекта 2025-11-02 02:41:28 +03:00
3ee22c3f0e комментарии в блокнот research (до MLFlow) 2025-11-02 02:41:28 +03:00
af9340eda2 рефакторинг скрипта запуска сервера MLFlow, добавить скрипт запуска MLFlow для PowerShell 2025-11-02 02:41:28 +03:00
a3e8ebc030 убрать ненужную проверку существования файла БД в скрипте запуска MLFlow 2025-10-29 21:36:08 +03:00
2f1b884d4b fix: в блокноте research убрать лишние параметры для papermill 2025-10-29 13:53:30 +03:00
b66aed2636 добавить логирование прогонов в MLFlow 2025-10-15 12:41:17 +03:00
462ab85b18 ввести MLFlow в работу 2025-10-15 12:40:14 +03:00
55 изменённых файлов: 16363 добавлений и 184 удалений

3
.gitignore поставляемый
Просмотреть файл

@@ -1,4 +1,5 @@
### Python
__pycache__/
*.pyc
### Jupyter
@@ -9,3 +10,5 @@
# virtual environments
.venv/
.venv*/
# .env files
*.env

Просмотреть файл

@@ -2,15 +2,27 @@
**Выполняет**: **Сыропятов В.В.** (А-01м-24)
Реализация цикла разработки ML-сервиса:
* подготовка и исследование данных ([Jupyter](https://jupyter.org/), scientific Python),
* создание модели ML ([MLFlow](https://mlflow.org/), [scikit-learn](https://scikit-learn.org/stable/)),
* разработка REST-сервиса для выполнения модели ([FastAPI](https://fastapi.tiangolo.com/) (недоступна на 2025‑12‑08)),
* конфигурация инфрастуктуры сервиса с мониторингом ([Docker](https://www.docker.com/) (частично недоступна на 2025‑12‑08)), [Docker Compose](https://docs.docker.com/compose/) (частично недоступна на 2025‑12‑08)), [Prometheus](https://prometheus.io/) (недоступна на 2025‑12‑08)), [Grafana OSS](https://grafana.com/)).
## Данные
Используемый датасет: [Car price prediction(used cars)
](https://www.kaggle.com/datasets/vijayaadithyanvg/car-price-predictionused-cars/data) —
продажа подержанных автомобилей на рынке в Индии.
## Установка
## Сервис предсказания цен
### Общий порядок
См. `services/README.md`.
## Исследовательская часть проекта
### Установка
#### Общий порядок
**Внимание**: Здесь описан только общий порядок установки. Определённые части проекта могут требовать установки по отдельным инструкциям.
@@ -47,20 +59,24 @@
5. **При необходимости** скачайте данные. Каноническое расположение для данных проекта: `data/`.
### Зависимости
#### Зависимости
#### Общие зависимости
##### Общие зависимости
Зависимости — пакеты Python — записаны в файле `requirements.txt` (см. **Пакеты Python**).
Зависимости — пакеты Python — записаны в файле `requirements/requirements.txt` (см. **Пакеты Python**).
#### Пакеты Python
##### Пакеты Python
Установка/обновление пакетов Python в активное окружение из файла `requirements.txt`:
Установка/обновление пакетов Python в активное окружение из файла `requirements/requirements.txt`:
```sh
pip install -U -r requirements.txt
pip install -U -r requirements/requirements.txt
```
## Разведочный анализ данных (EDA)
### Разведочный анализ данных (EDA)
См. `eda/README.md`.
### Исследование и настройка предсказательной модели
См. `research/README.md`.

2
_mlflow_config_common.ps1 Обычный файл
Просмотреть файл

@@ -0,0 +1,2 @@
$BACKEND_STORE_DB_PATH = "./mlflow/mlruns.sqlite"
$BACKEND_URI = "sqlite:///$BACKEND_STORE_DB_PATH"

4
_mlflow_config_common.sh Обычный файл
Просмотреть файл

@@ -0,0 +1,4 @@
set -eu
BACKEND_STORE_DB_PATH="${BACKEND_STORE_DB_PATH:-./mlflow/mlruns.sqlite}"
BACKEND_URI="sqlite:///$BACKEND_STORE_DB_PATH"

143
docs/jupyter.md Обычный файл
Просмотреть файл

@@ -0,0 +1,143 @@
# Использование среды Jupyter
Для исследовательских задач в проекте используется среда [Jupyter](https://jupyter.org/). Т.к. блокноты хранятся в текстовом формате под контролем версий, нужно также дополнение [Jupytext](https://jupytext.readthedocs.io/en/latest/) (как минимум для ручной конвертации блокнотов; см. ниже).
Опционально можно использовать дополнение [papermill](https://papermill.readthedocs.io/en/latest/) для простого параметризованного исполнения блокнотов.
## Установка
### Общий порядок
**Внимание**: Оптимальный порядок установки и конфигурации Jupyter для работы с проектом неоднозначен. См. обоснование выбранного здесь порядка работы с блокнотами Jupyter и возможные альтернативные варианты в статье [Использование Jupyter с виртуальными окружениями Python](https://asrelo.hashnode.dev/using-jupyter-with-python-virtual-environments-ru).
1. Jupyter и дополнения должны быть установлены в систему, а **не** в виртуальное окружение. При необходимости деактивируйте виртуальное окружение.
```sh
deactivate
```
2. [Установите Jupyter](https://jupyter.org/install) и Jupytext в систему (**не** в виртуальное окружение).
```sh
pip install -U notebook jupytext
```
Полная инструкция по установке Jupytext: [Installation — Jupytext documentation](https://jupytext.readthedocs.io/en/latest/install.html).
3. **Опционально**, установите papermill в систему (**не** в виртуальное окружение).
```sh
pip install -U papermill
```
Полная инструкция по установке: [Installation - papermill 2.4.0 documentation](https://papermill.readthedocs.io/en/stable/installation.html).
4. Активируйте **виртуальное окружение** повторно.
5. Установите ядро Jupyter, связанное с данным виртуальным окружением, в директорию этого виртуального окружения. Укажите следующее имя ядра: `python3_venv`.
```sh
python -m ipykernel --sys-prefix --name python3_venv
```
6. **Опционально**, **заранее** сохраните в переменную окружения `JUPYTER_PATH` путь к данным Jupyter в виртуальном окружении `<path>` &mdash; см. п. 1 в инструкции по использованию.
* Windows (PowerShell):
```ps
[System.Environment]::SetEnvironmentVariable('JUPYTER_PATH', "<path>;$env:JUPYTER_PATH", 'User')
```
* Windows (cmd):
```bat
setx JUPYTER_PATH "<path>;%PATH%;JUPYTER_PATH"
```
* UNIX (sh):
```sh
echo 'export JUPYTER_PATH="<path>:$JUPYTER_PATH"' >> ~/.profile
```
**Внимание**: На данном этапе могут отсутствовать пригодные для прямого использования блокноты `.ipynb` (например, если проект развёртывается с нуля). Об использовании спаренных блокнотов и конвертации форматов см. [Использование Jupytext](#использование-jupytext).
### Зависимости
*Используемые при работе с Jupyter зависимости &mdash; пакеты Python &mdash; на данный момент включены в общие зависимости (см. выше), дополнительных действий не требуется.*
## Работа с блокнотами Jupyter
### Jupyter
1. **Если** при выполнении инструкции по установке Вы **не** сохранили в переменную окружения JUPYTER_PATH путь к данным Jupyter в виртуальном окружении, этот путь нужно добавить в переменную окружения сейчас.
Добавьте в переменную окружения `JUPYTER_PATH` абсолютный путь (далее обозначаемый `<path>`) `$VIRTUAL_ENV/share/jupyter`, где следует заменить `$VIRTUAL_ENV` на путь к директории, где развёрнуто виртуальное окружение. Для инструментов [`venv`](https://docs.python.org/3/library/venv.html), [`virtualenv`](https://virtualenv.pypa.io/en/stable/) можно просто в активном виртуальном окружении использовать подстановку переменной окружения `VIRTUAL_ENV` (активное виртуальное окружение не повлияет на дальнейшие шаги).
* Windows (PowerShell):
```ps
$env:JUPYTER_PATH = "<path>;$env:JUPYTER_PATH"
```
* Windows (cmd):
```bat
set "JUPYTER_PATH=<path>;%JUPYTER_PATH%"
```
* UNIX (sh):
```sh
export JUPYTER_PATH="<path>:$JUPYTER_PATH"
```
2. Запустите глобальный установленное приложение Jupyter (**не** из виртуального окружения).
* Например, запустите Jupyter Notebook:
```sh
jupyter notebook
```
Веб-приложение Notebook должно открыться в веб-браузере автоматически. Если этого не произошло, найдите в сообщениях сервера Jupyter строку примерно следующего содержания:
[I 08:58:24.417 NotebookApp] The Jupyter Notebook is running at: http://localhost:8888/
Откройте веб-браузер и перейдите по ссылке, выведенной в конце указанного сообщения.
См. также [документацию Jupyter](https://docs.jupyter.org/en/stable/running.html).
2. Используйте приложение для навигации по файловой системе (в частности, по каталогу `eda/`), редактирования и исполнения кода в блокнотах.
3. Если приложение Jupyter запрашивает **выбор ядра** Jupyter (**kernel**) или Вы сталкиваетесь с необъяснимыми **ошибками импортов**, выберите для текущего блокнота ядро с именем `python3_venv`.
* **Jupyter Notebook**: Может понадобиться выбор вручную; кнопка для выбора ядра для открытого блокнота находится в верхнем правом углу веб-страницы.
### Расширение Jupyter для Visual Studio Code
1. Запустите Visual Studio Code.
2. Откройте корневую директорию проекта в VS Code (*File* -> *Open Folder...*).
3. Если Вы открыли директорию проекта и VS Code запрашивает выбор автоматически обнаруженного виртуального окружения, согласитесь.
3. **Если** VS Code запрашивает выбор автоматически обнаруженного виртуального окружения, согласитесь.
**Иначе** [укажите](https://code.visualstudio.com/docs/python/environments#_working-with-python-interpreters) своё виртуальное окружение самостоятельно.
4. Используйте VS Code с расширением Jupyter для навигации по файловой системе (в частности, по каталогу `eda/`), редактирования и исполнения кода в блокнотах. **Не забывайте** при открытии любого блокнота проверять, что выбрано корректное ядро Jupyter (принадлежащее корректному виртуальному окружению). (Кнопка для выбора ядра для открытого блокнота находится в верхнем правом углу области содержимого вкладки; по умолчанию Вы увидите название выбранного виртуального окружения; если ядро не выбрано, на кнопке написано *Select Kernel*.)
### Использование Jupytext
Описанные ниже команды `jupytext` используют глобальной установленный экземпляр Jupytext (однако его можно запускать и изнутри виртуального окружения).
Для автоматической синхронизации связанных блокнотов (включая создание блокнотов отсутствующих, но ожидаемых форматов):
```sh
jupytext --sync eda/cars_eda.py
```
Jupytext довольно удобно работает в оригинальной среде Jupyter, синхронизируя изменения связанных файлов на лету при работе в Jupyter, **ориентируясь на метки времени на файлах**. См. документацию [Jupytext](https://jupytext.readthedocs.io/en/latest/index.html).
**Внимание**: С расширением Jupyter для Visual Studio Code Jupytext **не работает напрямую**. Для использования блокнотов `.ipynb` с расширением Jupyter для VS Code нужно синхронизировать текстовый файл под контролем версий и файл `.ipynb` вручную указанными выше командами. Однако заметьте, что это же расширение может исполнять блокнот в текстовом формате самостоятельно, посредством автоматизированного ведения временного блокнота; и оно даже автоматически создаёт/подхватывает локальное ядро Jupyter в виртуальном окружении.

Просмотреть файл

@@ -32,144 +32,12 @@
Для EDA необходимы общие зависимости, см. [Общие зависимости](../README.md#общие-зависимости) в `README.md`.
Для EDA используется среда [Jupyter](https://jupyter.org/). Т.к. блокноты хранятся в текстовом формате под контролем версий, нужно также дополнение [Jupytext](https://jupytext.readthedocs.io/en/latest/) (как минимум для ручной конвертации блокнотов; см. ниже).
Опционально можно использовать дополнение [papermill](https://papermill.readthedocs.io/en/latest/) для простого параметризованного исполнения блокнотов.
### Общий порядок
**Внимание**: Оптимальный порядок установки и конфигурации Jupyter для работы с проектом неоднозначен. См. обоснование выбранного здесь порядка работы с блокнотами Jupyter и возможные альтернативные варианты в статье [Использование Jupyter с виртуальными окружениями Python](https://asrelo.hashnode.dev/using-jupyter-with-python-virtual-environments-ru).
1. Выполните установку общих зависимостей, если это ещё не выполнено, см. **Общие зависимости** в `README.md`.
2. Jupyter и дополнения должны быть установлены в систему, а **не** в виртуальное окружение. При необходимости деактивируйте виртуальное окружение.
```sh
deactivate
```
3. [Установите Jupyter](https://jupyter.org/install) и Jupytext в систему (**не** в виртуальное окружение).
```sh
pip install -U notebook jupytext
```
Полная инструкция по установке Jupytext: [Installation &#8212; Jupytext documentation](https://jupytext.readthedocs.io/en/latest/install.html).
4. **Опционально**, установите papermill в систему (**не** в виртуальное окружение).
```sh
pip install -U papermill
```
Полная инструкция по установке: [Installation - papermill 2.4.0 documentation](https://papermill.readthedocs.io/en/stable/installation.html).
5. Активируйте **виртуальное окружение** повторно.
6. Установите ядро Jupyter, связанное с данным виртуальным окружением, в директорию этого виртуального окружения. Укажите следующее имя ядра: `python3_venv`.
```sh
python -m ipykernel --sys-prefix --name python3_venv
```
7. **Опционально**, **заранее** сохраните в переменную окружения `JUPYTER_PATH` путь к данным Jupyter в виртуальном окружении `<path>` &mdash; см. п. 1 в инструкции по использованию.
* Windows (PowerShell):
```ps
[System.Environment]::SetEnvironmentVariable('JUPYTER_PATH', "<path>;$env:JUPYTER_PATH", 'User')
```
* Windows (cmd):
```bat
setx JUPYTER_PATH "<path>;%PATH%;JUPYTER_PATH"
```
* UNIX (sh):
```sh
echo 'export JUPYTER_PATH="<path>:$JUPYTER_PATH"' >> ~/.profile
```
**Внимание**: На данном этапе могут отсутствовать пригодные для прямого использования блокноты `.ipynb` (например, если проект развёртывается с нуля). Об использовании спаренных блокнотов и конвертации форматов см. [Использование Jupytext](#использование-jupytext).
Для EDA используется среда [Jupyter](https://jupyter.org/). См. об установке и использовании Jupyter в проекте в `docs/jupyter.md`.
### Зависимости
Используемые непосредственно кодом проекта зависимости для разведочного анализа данных (EDA) (директория `eda/`) &mdash; пакеты Python &mdash; на данный момент включены в общие зависимости (см. выше).
Дополнительные зависимости, необходимые для EDA, &mdash; пакеты Python &mdash; записаны в файле `requirements/requirements-eda.txt` (см. **Пакеты Python**). См. об установке пакетов Python в **Пакеты Python** в `README.md`.
## Работа с блокнотами Jupyter
### Jupyter
1. **Если** при выполнении инструкции по установке Вы **не** сохранили в переменную окружения JUPYTER_PATH путь к данным Jupyter в виртуальном окружении, этот путь нужно добавить в переменную окружения сейчас.
Добавьте в переменную окружения `JUPYTER_PATH` абсолютный путь (далее обозначаемый `<path>`) `$VIRTUAL_ENV/share/jupyter`, где следует заменить `$VIRTUAL_ENV` на путь к директории, где развёрнуто виртуальное окружение. Для инструментов [`venv`](https://docs.python.org/3/library/venv.html), [`virtualenv`](https://virtualenv.pypa.io/en/stable/) можно просто в активном виртуальном окружении использовать подстановку переменной окружения `VIRTUAL_ENV` (активное виртуальное окружение не повлияет на дальнейшие шаги).
* Windows (PowerShell):
```ps
$env:JUPYTER_PATH = "<path>;$env:JUPYTER_PATH"
```
* Windows (cmd):
```bat
set "JUPYTER_PATH=<path>;%JUPYTER_PATH%"
```
* UNIX (sh):
```sh
export JUPYTER_PATH="<path>:$JUPYTER_PATH"
```
2. Запустите глобальный установленное приложение Jupyter (**не** из виртуального окружения).
* Например, запустите Jupyter Notebook:
```sh
jupyter notebook
```
Веб-приложение Notebook должно открыться в веб-браузере автоматически. Если этого не произошло, найдите в сообщениях сервера Jupyter строку примерно следующего содержания:
[I 08:58:24.417 NotebookApp] The Jupyter Notebook is running at: http://localhost:8888/
Откройте веб-браузер и перейдите по ссылке, выведенной в конце указанного сообщения.
См. также [документацию Jupyter](https://docs.jupyter.org/en/stable/running.html).
2. Используйте приложение для навигации по файловой системе (в частности, по каталогу `eda/`), редактирования и исполнения кода в блокнотах.
3. Если приложение Jupyter запрашивает **выбор ядра** Jupyter (**kernel**) или Вы сталкиваетесь с необъяснимыми **ошибками импортов**, выберите для текущего блокнота ядро с именем `python3_venv`.
* **Jupyter Notebook**: Может понадобиться выбор вручную; кнопка для выбора ядра для открытого блокнота находится в верхнем правом углу веб-страницы.
### Расширение Jupyter для Visual Studio Code
1. Запустите Visual Studio Code.
2. Откройте корневую директорию проекта в VS Code (*File* -> *Open Folder...*).
3. Если Вы открыли директорию проекта и VS Code запрашивает выбор автоматически обнаруженного виртуального окружения, согласитесь.
3. **Если** VS Code запрашивает выбор автоматически обнаруженного виртуального окружения, согласитесь.
**Иначе** [укажите](https://code.visualstudio.com/docs/python/environments#_working-with-python-interpreters) своё виртуальное окружение самостоятельно.
4. Используйте VS Code с расширением Jupyter для навигации по файловой системе (в частности, по каталогу `eda/`), редактирования и исполнения кода в блокнотах. **Не забывайте** при открытии любого блокнота проверять, что выбрано корректное ядро Jupyter (принадлежащее корректному виртуальному окружению). (Кнопка для выбора ядра для открытого блокнота находится в верхнем правом углу области содержимого вкладки; по умолчанию Вы увидите название выбранного виртуального окружения; если ядро не выбрано, на кнопке написано *Select Kernel*.)
### Использование Jupytext
Описанные ниже команды `jupytext` используют глобальной установленный экземпляр Jupytext (однако его можно запускать и изнутри виртуального окружения).
Для автоматической синхронизации связанных блокнотов (включая создание блокнотов отсутствующих, но ожидаемых форматов):
```sh
jupytext --sync eda/cars_eda.py
```
Jupytext довольно удобно работает в оригинальной среде Jupyter, синхронизируя изменения связанных файлов на лету при работе в Jupyter, **ориентируясь на метки времени на файлах**. См. документацию [Jupytext](https://jupytext.readthedocs.io/en/latest/index.html).
**Внимание**: С расширением Jupyter для Visual Studio Code Jupytext **не работает напрямую**. Для использования блокнотов `.ipynb` с расширением Jupyter для VS Code нужно синхронизировать текстовый файл под контролем версий и файл `.ipynb` вручную указанными выше командами. Однако заметьте, что это же расширение может исполнять блокнот в текстовом формате самостоятельно, посредством автоматизированного ведения временного блокнота; и оно даже автоматически создаёт/подхватывает локальное ядро Jupyter в виртуальном окружении.
См. об установке и использовании Jupyter в проекте в `docs/jupyter.md`.

5
gc_mlflow.ps1 Обычный файл
Просмотреть файл

@@ -0,0 +1,5 @@
$ErrorActionPreference = "Stop"
. $PSScriptRoot\_mlflow_config_common.ps1
& mlflow gc --backend-store-uri="$BACKEND_URI" @args

7
gc_mlflow.sh Обычный файл
Просмотреть файл

@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
. _mlflow_config_common.sh
& mlflow gc --backend-store-uri="$BACKEND_URI" "$@"

0
iis_project/mlxtend_utils/__init__.py Обычный файл
Просмотреть файл

Просмотреть файл

@@ -0,0 +1,3 @@
SEQUENTIAL_FEATURE_SELECTOR_PARAMS_COMMON_INCLUDE = [
'k_features', 'forward', 'floating', 'scoring', 'cv', 'fixed_features', 'feature_groups',
]

60
iis_project/sklearn_utils/__init__.py Обычный файл
Просмотреть файл

@@ -0,0 +1,60 @@
from collections.abc import Container, Sequence, Mapping
from typing import TypeAlias, TypeVar
ParamsFilterSpec: TypeAlias = (
bool
| Container[str]
| tuple[bool, Container[str]]
| Mapping[str, 'ParamsFilterSpec']
| tuple[bool, 'ParamsFilterSpec']
)
V = TypeVar('V')
def _split_param_key(key: str) -> tuple[str, ...]:
return tuple(key.split('__'))
def _match_key_to_filter_spec(
key: Sequence[str], spec: ParamsFilterSpec, empty_default: bool,
) -> bool:
if isinstance(spec, Sequence) and (len(spec) == 2) and isinstance(spec[0], bool):
if (len(key) == 0) and (not spec[0]):
return empty_default
spec = spec[1]
if isinstance(spec, Mapping):
if len(key) == 0:
return empty_default
spec_nested = spec.get(key[0])
if spec_nested is None:
return False
return _whether_to_include_param(key[1:], spec_nested)
elif isinstance(spec, Container):
if len(key) == 0:
return True
return (key[0] in spec)
return bool(spec)
def _whether_to_include_param(
key: Sequence[str], include: ParamsFilterSpec = True, exclude: ParamsFilterSpec = False,
) -> bool:
return (
(not _match_key_to_filter_spec(key, exclude, empty_default=False))
and _match_key_to_filter_spec(key, include, empty_default=True)
)
def filter_params(
params: Mapping[str, V],
include: ParamsFilterSpec = True,
exclude: ParamsFilterSpec = False,
) -> Mapping[str, V]:
return {
k: v
for k, v in params.items()
if _whether_to_include_param(_split_param_key(k), include, exclude)
}

3
iis_project/sklearn_utils/compose.py Обычный файл
Просмотреть файл

@@ -0,0 +1,3 @@
COLUMN_TRANSFORMER_PARAMS_COMMON_INCLUDE = [
'remainder', 'sparse_threshold', 'transformer_weights',
]

1
iis_project/sklearn_utils/ensemble.py Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
RANDOM_FOREST_REGRESSOR_PARAMS_COMMON_EXCLUDE = ['n_jobs', 'verbose', 'warm_start']

5
iis_project/sklearn_utils/pandas.py Обычный файл
Просмотреть файл

@@ -0,0 +1,5 @@
from pandas import DataFrame
def pandas_dataframe_from_transformed_artifacts(matrix, transformer) -> DataFrame:
return DataFrame(matrix, columns=transformer.get_feature_names_out())

1
iis_project/sklearn_utils/preprocessing.py Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
STANDARD_SCALER_PARAMS_COMMON_EXCLUDE = ['copy']

2
mlflow/.gitignore поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1,2 @@
mlruns.sqlite
mlartifacts/

1
requirements/requirements-eda.txt Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
bokeh >=3.7.2,<4

Просмотреть файл

@@ -0,0 +1,2 @@
mlxtend ~=0.23.4
scikit-learn >=1.7.2,<2

4
requirements/requirements-research.txt Обычный файл
Просмотреть файл

@@ -0,0 +1,4 @@
mlflow >=2.16,<2.22
mlxtend ~=0.23.4
optuna ~=4.5
scikit-learn >=1.7.2,<2

Просмотреть файл

@@ -1,9 +1,7 @@
bokeh >=3.7.2,<4
ipykernel >=6.30.1,<7
ipympl ~=0.9.6
matplotlib >=3.10.1,<4
numpy >=2.2.6,<3
pandas >=2.3.1,<3
scipy >=1.15.3,<2
scikit-learn >=1.7.2,<2
seaborn ~=0.13.2

59
research/README.md Обычный файл
Просмотреть файл

@@ -0,0 +1,59 @@
# Исследование и настройка предсказательной модели
## Блокноты Jupyter
* `research` &mdash; Создание множества разных моделей, с использованием разных создаваемых признаков и оптимизацией гиперпараметров.
Использует файл аугментированных данных датасета о подержанных автомобилях, создаваемый блокнотом `eda/cars_eda.py`. См. `eda/README.md`.
Если параметр блокнота `mlflow_do_log` установлен в `True`, блокнот логирует в MLFlow создаваемые модели в отдельные вложенные (nested) прогоны под одним (новым) общим прогоном с именем, определяемым параметром `mlflow_experiment_name`.
Точность предсказания текущей цены автомобиля оценивается в первую очередь по показателю MAPE (из-за наличия в выборке значений цены разных порядков), во вторую очередь учитывается MSE (ради отслеживания систематических ошибок на подвыборках). Исследованные модели:
1. baseline (MAPE = 0.35, MSE = 1.18);
2. с использованием добавленных признаков (feature engineering с помощью scikit-learn) &mdash; точность неоднозначна по сравнению с baseline (MAPE = 0.31, MSE = 1.50);
3. с использованием добавленных и выбранных (SFS) признаков &mdash; точность существенно лучше baseline (MAPE = 0.20, MSE = 1.02);
4. с использованием добавленных и выбранных признаков и оптимизированными гиперпараметрами (optuna) &mdash; точность немного лучше модели 3 по MAPE (MAPE = 0.20, MSE = 0.94).
Модель 4 выбрана как финальная модель для последующего развёртывания. Она использует следующие признаки (такие же, как и модель 3):
* `extend_features_as_polynomial__selling_price` (исходная цена продажи, нормализована `StandardScaler`),
* `extend_features_as_polynomial__selling_price^2`,
* `extend_features_as_spline__age_sp_1` (значение базисной функции 2/5 однородного сплайна, нормализованного к крайним значениям возраста автомобилей),
* `extend_features_as_spline__age_sp_2` (то же, но базисная функция 3/5),
* `scale_to_standard__age` (исходный возраст автомобиля, нормализован `StandardScaler`).
По указанию преподавателя, скриншоты пользовательского интерфейса MLFlow сохранены в директории `./mlflow_ui_figures`.
По указанию преподавателя, ID финального прогона: `4c7f04ad9ee94237b44f60b6eb14b41e` (вложен в прогон `4e4a9094cb3c4eed9d4a056a27cadcd9`).
## Установка
Для исследования и настройки предсказательной модели необходимы общие зависимости, см. [Общие зависимости](../README.md#общие-зависимости) в `README.md`.
Для исследования и настройки предсказательной модели используется среда [Jupyter](https://jupyter.org/). См. об установке и использовании Jupyter в проекте в `docs/jupyter.md`.
### Зависимости
Дополнительные зависимости, необходимые для исследования и настройки предсказательной модели, &mdash; пакеты Python &mdash; записаны в файле `requirements/requirements-research.txt` (см. **Пакеты Python**). См. об установке пакетов Python в **Пакеты Python** в `README.md`.
## Работа с блокнотами Jupyter
См. об установке и использовании Jupyter в проекте в `docs/jupyter.md`.
## Работа с MLFlow
Для управления жизненным циклом моделей машинного обучения используется платформа [MLFlow](https://mlflow.org/).
Запуск локального сервера MLFlow (**выполнять в корневой директории проекта**):
run_mlflow_server
Для остановки сервера MLFlow пошлите ему сигнал `SIGINT` (`Ctrl+C` в терминале).
Очистка локальной tracking БД MLFlow от удалённых прогонов (**выполнять в корневой директории проекта**):
gc_mlflow
Очистка локальной tracking БД MLFlow от конкретных удалённых экспериментов по списку их ID, разделённым запятыми, `<ids>` (**выполнять в корневой директории проекта**):
gc_mlflow --experiment-ids=<ids>

Двоичные данные
research/mlflow_ui_figures/registered_model_experimental.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 43 KiB

Двоичные данные
research/mlflow_ui_figures/registered_model_final.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 37 KiB

Двоичные данные
research/mlflow_ui_figures/run_final_model_artifacts_mlmodel.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 93 KiB

Двоичные данные
research/mlflow_ui_figures/runs_with_metrics_display.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 62 KiB

13796
research/research.ipynb Обычный файл

Различия файлов скрыты, потому что одна или несколько строк слишком длинны

Просмотреть файл

@@ -1,27 +1,71 @@
# ---
# jupyter:
# jupytext:
# formats: ipynb,py:percent
# formats: py:percent,ipynb
# text_representation:
# extension: .py
# format_name: percent
# format_version: '1.3'
# jupytext_version: 1.17.3
# kernelspec:
# display_name: .venv
# display_name: python3_venv
# language: python
# name: python3
# name: python3_venv
# ---
# %% [markdown]
# # Исследование и настройка предсказательной модели для цен подержанных автомобилях
# %% [markdown]
# Блокнот использует файл аугментированных данных датасета о подержанных автомобилях, создаваемый блокнотом `eda/cars_eda.py`. См. ниже параметры блокнота для papermill.
# %%
#XXX: разделить блокнот штук на 5
# %%
from typing import Optional
# %% tags=["parameters"]
data_aug_pickle_path: Optional[str] = None
# Полный путь к файлу (pickle) для сохранения очищенного датасета. Если не установлен, используется `data/<data_aug_pickle_relpath>`.
data_aug_pickle_relpath: str = 'cars.aug.pickle'
# Путь к файлу (pickle) для сохранения очищенного датасета относительно директории данных `data`. Игнорируется, если установлен data_aug_pickle_path.
#model_global_comment_path: Optional[str] = None
## Полный путь к текстовому файлу с произвольным комментарием для сохранения в MLFlow как артефакт вместе с моделью. Если не установлен, используется `research/<comment_relpath>`.
#model_comment_relpath: str = 'comment.txt'
## Путь к текстовому файлу с произвольным комментарием для сохранения в MLFlow как артефакт вместе с моделью относительно директории `research`. Игнорируется, если установлен comment_path.
mlflow_tracking_server_uri: str = 'http://localhost:5000'
# URL tracking-сервера MLFlow.
mlflow_registry_uri: Optional[str] = None
# URL сервера registry MLFlow (если не указан, используется `mlflow_tracking_server_uri`).
mlflow_do_log: bool = True
# Записывать ли прогоны (runs) в MLFlow.
mlflow_experiment_id: Optional[str] = None
# ID эксперимента MLFlow, имеет приоритет над `mlflow_experiment_name`.
mlflow_experiment_name: Optional[str] = 'Current price predicion for used cars'
# Имя эксперимента MLFlow (ниже приоритетом, чем `mlflow_experiment_id`).
mlflow_root_run_name: str = 'Models'
# Имя корневого прогона MLFlow (остальные прогоны будут созданы блокнотом внутри этого, как nested)
# %%
from collections.abc import Collection, Sequence
import os
import pathlib
import pickle
import sys
# %%
import matplotlib
import mlflow
import mlflow.models
import mlflow.sklearn
import mlxtend.feature_selection
import mlxtend.plotting
import optuna
import optuna.samplers
import sklearn.compose
import sklearn.ensemble
import sklearn.metrics
@@ -29,28 +73,54 @@ import sklearn.model_selection
import sklearn.pipeline
import sklearn.preprocessing
# %%
# %% tags=["parameters"]
data_path: Optional[str] = None
# Полный путь к файлу (CSV) с исходным датасетом. Если не установлен, ищется файл в `data/<data_relpath>`.
data_relpath: str = 'cars.csv'
# Путь к файлу (CSV) с исходным датасетом относительно директории данных `data`. Игнорируется, если установлен data_path.
data_aug_pickle_path: Optional[str] = None
# Полный путь к файлу (pickle) для сохранения очищенного датасета. Если не установлен, используется `data/<data_aug_pickle_relpath>`.
data_aug_pickle_relpath: str = 'cars.aug.pickle'
# Путь к файлу (pickle) для сохранения очищенного датасета относительно директории данных `data`. Игнорируется, если установлен data_aug_pickle_path.
# %%
BASE_PATH = pathlib.Path('..')
# %%
CODE_PATH = BASE_PATH
sys.path.insert(0, str(CODE_PATH.resolve()))
# %%
from iis_project.mlxtend_utils.feature_selection import SEQUENTIAL_FEATURE_SELECTOR_PARAMS_COMMON_INCLUDE
from iis_project.sklearn_utils import filter_params
from iis_project.sklearn_utils.compose import COLUMN_TRANSFORMER_PARAMS_COMMON_INCLUDE
from iis_project.sklearn_utils.ensemble import RANDOM_FOREST_REGRESSOR_PARAMS_COMMON_EXCLUDE
from iis_project.sklearn_utils.pandas import pandas_dataframe_from_transformed_artifacts
from iis_project.sklearn_utils.preprocessing import STANDARD_SCALER_PARAMS_COMMON_EXCLUDE
# %%
MODEL_INOUT_EXAMPLE_SIZE = 0x10
# %%
mlflow.set_tracking_uri(mlflow_tracking_server_uri)
if mlflow_registry_uri is not None:
mlflow.set_registry_uri(mlflow_registry_uri)
# %%
if mlflow_do_log:
mlflow_experiment = mlflow.set_experiment(experiment_name=mlflow_experiment_name, experiment_id=mlflow_experiment_id)
mlflow_root_run_id = None # изменяется позже
# %%
DATA_PATH = (
pathlib.Path(os.path.dirname(data_path))
if data_path is not None
pathlib.Path(os.path.dirname(data_aug_pickle_path))
if data_aug_pickle_path is not None
else (BASE_PATH / 'data')
)
# %%
def build_sequential_feature_selector(*args, **kwargs):
return mlxtend.feature_selection.SequentialFeatureSelector(*args, **kwargs)
def plot_sequential_feature_selection(feature_selector, *args_rest, **kwargs):
metric_dict = feature_selector.get_metric_dict()
return mlxtend.plotting.plot_sequential_feature_selection(metric_dict, *args_rest, **kwargs)
# %% [markdown]
# ## Загрузка и обзор данных
# %%
with open(
(
@@ -62,8 +132,8 @@ with open(
) as input_file:
df_orig = pickle.load(input_file)
# %%
df_orig.head(0x10)
# %% [markdown]
# Обзор датасета:
# %%
len(df_orig)
@@ -71,6 +141,15 @@ len(df_orig)
# %%
df_orig.info()
# %%
df_orig.head(0x10)
# %% [markdown]
# ## Разделение датасета на выборки
# %% [markdown]
# Выделение признаков и целевых переменных:
# %%
feature_columns = (
'selling_price',
@@ -97,7 +176,7 @@ assert all(
for col in features_to_scale_to_standard_columns
)
features_to_encode_one_hot_columns = (
features_to_encode_wrt_target_columns = (
'fuel_type',
'selling_type',
'transmission',
@@ -105,13 +184,16 @@ features_to_encode_one_hot_columns = (
)
assert all(
(col in df_orig.select_dtypes(('category', 'object')).columns)
for col in features_to_encode_one_hot_columns
for col in features_to_encode_wrt_target_columns
)
# %%
df_orig_features = df_orig[list(feature_columns)]
df_target = df_orig[list(target_columns)]
# %% [markdown]
# Разделение на обучающую и тестовую выборки:
# %%
DF_TEST_PORTION = 0.25
@@ -122,52 +204,702 @@ df_orig_features_train, df_orig_features_test, df_target_train, df_target_test =
)
)
# %% [markdown]
# Размеры обучающей и тестовой выборки соответственно:
# %%
tuple(map(len, (df_target_train, df_target_test)))
# %% [markdown]
# ## Модели
# %%
# XXX: один файл requirements для всех моделей
MODEL_PIP_REQUIREMENTS_PATH = BASE_PATH / 'requirements' / 'requirements-isolated-research-model.txt'
# %% [markdown]
# Сигнатура модели для MLFlow:
# %%
mlflow_model_signature = mlflow.models.infer_signature(model_input=df_orig_features, model_output=df_target)
mlflow_model_signature
# %% [raw] vscode={"languageId": "raw"}
# input_schema = mlflow.types.schema.Schema([
# mlflow.types.schema.ColSpec("double", "selling_price"),
# mlflow.types.schema.ColSpec("double", "driven_kms"),
# mlflow.types.schema.ColSpec("string", "fuel_type"),
# mlflow.types.schema.ColSpec("string", "selling_type"),
# mlflow.types.schema.ColSpec("string", "transmission"),
# mlflow.types.schema.ColSpec("double", "age"),
# ])
#
# output_schema = mlflow.types.schema.Schema([
# mlflow.types.schema.ColSpec("double", "present_price"),
# ])
#
# mlflow_model_signature = mlflow.models.ModelSignature(inputs=input_schema, outputs=output_schema)
# %%
def build_features_scaler_standard():
return sklearn.preprocessing.StandardScaler()
# %%
#def build_categorical_features_encoder_onehot():
# return sklearn.preprocessing.OneHotEncoder()
def build_categorical_features_encoder_target(*, random_state=None):
return sklearn.preprocessing.TargetEncoder(
target_type='continuous', smooth='auto', shuffle=True, random_state=random_state,
)
# %% [markdown]
# Регрессор &mdash; небольшой случайный лес, цель &mdash; минимизация квадрата ошибки предсказания:
# %%
def build_regressor(n_estimators, *, max_depth=None, max_features='sqrt', random_state=None):
return sklearn.ensemble.RandomForestRegressor(
n_estimators, criterion='squared_error',
max_depth=max_depth, max_features=max_features,
random_state=random_state,
)
def build_regressor_baseline(*, random_state=None):
return build_regressor(16, max_depth=8, max_features='sqrt')
# %%
def score_predictions(target_test, target_test_predicted):
return {
'mse': sklearn.metrics.mean_squared_error(target_test, target_test_predicted),
'mae': sklearn.metrics.mean_absolute_error(target_test, target_test_predicted),
'mape': sklearn.metrics.mean_absolute_percentage_error(target_test, target_test_predicted),
}
# %%
# использует глобальные переменные mlflow_do_log, mlflow_experiment, mlflow_root_run_name
def mlflow_log_model(
model,
model_params,
metrics,
*,
nested_run_name,
model_signature=None,
input_example=None,
pip_requirements=None,
#global_comment_file_path=None,
extra_logs_handler=None,
):
global mlflow_root_run_id
if not mlflow_do_log:
return
experiment_id = mlflow_experiment.experiment_id
start_run_root_kwargs_extra = {}
if mlflow_root_run_id is not None:
start_run_root_kwargs_extra['run_id'] = mlflow_root_run_id
else:
start_run_root_kwargs_extra['run_name'] = mlflow_root_run_name
with mlflow.start_run(experiment_id=experiment_id, **start_run_root_kwargs_extra) as root_run:
if root_run.info.status not in ('RUNNING',):
raise RuntimeError('Cannot get the root run to run')
if mlflow_root_run_id is None:
mlflow_root_run_id = root_run.info.run_id
# важно одновременно использовать nested=True и parent_run_id=...:
with mlflow.start_run(experiment_id=experiment_id, run_name=nested_run_name, nested=True, parent_run_id=mlflow_root_run_id):
if isinstance(pip_requirements, pathlib.PurePath):
pip_requirements = str(pip_requirements)
_ = mlflow.sklearn.log_model(
model,
'model',
signature=model_signature,
input_example=input_example,
pip_requirements=pip_requirements,
)
if model_params is not None:
_ = mlflow.log_params(model_params)
if metrics is not None:
_ = mlflow.log_metrics(metrics)
#if (global_comment_file_path is not None) and global_comment_file_path.exists():
# mlflow.log_artifact(str(global_comment_file_path))
if extra_logs_handler is not None:
if callable(extra_logs_handler) and (not isinstance(extra_logs_handler, Collection)):
extra_logs_handler = (extra_logs_handler,)
for extr_logs_handler_fn in extra_logs_handler:
extr_logs_handler_fn(mlflow)
# %% [markdown]
# ### Baseline модель
# %% [markdown]
# Пайплайн предобработки признаков:
# %%
preprocess_transformer = sklearn.compose.ColumnTransformer(
[
('scale_to_standard', sklearn.preprocessing.StandardScaler(), features_to_scale_to_standard_columns),
('scale_to_standard', build_features_scaler_standard(), features_to_scale_to_standard_columns),
(
#'encode_categoricals_one_hot',
'encode_categoricals_wrt_target',
#sklearn.preprocessing.OneHotEncoder(),
sklearn.preprocessing.TargetEncoder(
target_type='continuous', smooth='auto', cv=3, shuffle=True, random_state=0x2ED6,
),
features_to_encode_one_hot_columns,
#build_categorical_features_encoder_onehot(),
build_categorical_features_encoder_target(random_state=0x2ED6),
features_to_encode_wrt_target_columns,
),
],
remainder='drop',
)
# %%
regressor = sklearn.ensemble.RandomForestRegressor(
10, criterion='squared_error', max_features='sqrt', random_state=0x016B,
)
regressor = build_regressor_baseline(random_state=0x016B)
regressor
# %% [markdown]
# Составной пайплайн:
# %%
pipeline = sklearn.pipeline.Pipeline([
('preprocess', preprocess_transformer),
('regress', regressor),
])
pipeline
# %%
pipeline
model_params = filter_params(
pipeline.get_params(),
include={
'preprocess': (
False,
{
**{k: True for k in COLUMN_TRANSFORMER_PARAMS_COMMON_INCLUDE},
'scale_to_standard': True,
'encode_categorical_wrt_target': True,
},
),
'regress': (False, True),
},
exclude={
'preprocess': {'scale_to_standard': STANDARD_SCALER_PARAMS_COMMON_EXCLUDE},
'regress': RANDOM_FOREST_REGRESSOR_PARAMS_COMMON_EXCLUDE,
},
)
model_params
# %% [markdown]
# Обучение модели:
# %%
_ = pipeline.fit(df_orig_features_train, df_target_train.iloc[:, 0])
# %% [markdown]
# Оценка качества:
# %%
target_test_predicted = pipeline.predict(df_orig_features_test)
# %% [markdown]
# Метрики качества (MAPE, а также MSE, MAE):
# %%
metrics = {
'mse': sklearn.metrics.mean_squared_error(df_target_test, target_test_predicted),
'mae': sklearn.metrics.mean_absolute_error(df_target_test, target_test_predicted),
'mape': sklearn.metrics.mean_absolute_percentage_error(df_target_test, target_test_predicted),
metrics = score_predictions(df_target_test, target_test_predicted)
metrics
# %%
mlflow_log_model(
pipeline,
model_params=model_params,
metrics={k: float(v) for k, v in metrics.items()},
nested_run_name='Baseline model',
model_signature=mlflow_model_signature,
input_example=df_orig_features.head(MODEL_INOUT_EXAMPLE_SIZE),
pip_requirements=MODEL_PIP_REQUIREMENTS_PATH,
#global_comment_file_path=(
# model_comment_path
# if model_comment_path is not None
# else (BASE_PATH / 'research' / model_comment_relpath)
#),
)
# %% [markdown]
# ### Модель с дополнительными признаками
# %% [markdown]
# Пайплайн предобработки признаков:
# %%
features_to_extend_as_polynomial = ('selling_price', 'driven_kms')
features_to_extend_as_spline = ('age',)
# %%
def build_preprocess_augmenting_transformer():
assert set(features_to_extend_as_polynomial) <= {*features_to_scale_to_standard_columns}
assert set(features_to_extend_as_spline) <= {*features_to_scale_to_standard_columns}
return sklearn.compose.ColumnTransformer(
[
(
'extend_features_as_polynomial',
sklearn.pipeline.Pipeline([
(
'extend_features',
sklearn.preprocessing.PolynomialFeatures(2, include_bias=False),
),
('scale_to_standard', build_features_scaler_standard()),
]),
features_to_extend_as_polynomial,
),
(
'extend_features_as_spline',
sklearn.preprocessing.SplineTransformer(
4, knots='quantile', extrapolation='constant', include_bias=False,
),
features_to_extend_as_spline,
),
(
'scale_to_standard',
build_features_scaler_standard(),
tuple(filter(lambda f: f not in features_to_extend_as_polynomial, features_to_scale_to_standard_columns)),
),
(
'encode_categoricals_wrt_target',
build_categorical_features_encoder_target(random_state=0x2ED6),
features_to_encode_wrt_target_columns,
),
],
remainder='drop',
)
# %%
PREPROCESS_AUGMENTING_TRANSFORMER_PARAMS_COMMON_INCLUDE = {
**{k: True for k in COLUMN_TRANSFORMER_PARAMS_COMMON_INCLUDE},
'extend_features_as_polynomial': {
'extend_features': True,
'scale_to_standard': True,
},
'extend_features_as_spline': True,
'scale_to_standard': True,
'encode_categorical_wrt_target': True,
}
PREPROCESS_AUGMENTING_TRANSFORMER_PARAMS_COMMON_EXCLUDE = {
'extend_features_as_polynomial': {
'scale_to_standard': STANDARD_SCALER_PARAMS_COMMON_EXCLUDE,
},
'scale_to_standard': STANDARD_SCALER_PARAMS_COMMON_EXCLUDE,
}
# %%
preprocess_transformer = build_preprocess_augmenting_transformer()
preprocess_transformer
# %% [markdown]
# Демонстрация предобработки данных:
# %%
preprocess_transformer_tmp = build_preprocess_augmenting_transformer()
df_augd_features_matrix_train = preprocess_transformer_tmp.fit_transform(df_orig_features_train, df_target_train.iloc[:, 0])
df_augd_features_train = pandas_dataframe_from_transformed_artifacts(df_augd_features_matrix_train, preprocess_transformer_tmp)
del preprocess_transformer_tmp
# %% [markdown]
# Обзор предобработанного датасета:
# %%
df_augd_features_train.info()
# %%
df_augd_features_train.head(0x8)
# %%
regressor = build_regressor_baseline(random_state=0x3AEF)
regressor
# %% [markdown]
# Составной пайплайн:
# %%
pipeline = sklearn.pipeline.Pipeline([
('preprocess', preprocess_transformer),
('regress', regressor),
])
pipeline
# %%
model_params = filter_params(
pipeline.get_params(),
include={
'preprocess': (False, PREPROCESS_AUGMENTING_TRANSFORMER_PARAMS_COMMON_INCLUDE.copy()),
'regress': (False, True),
},
exclude={
'preprocess': PREPROCESS_AUGMENTING_TRANSFORMER_PARAMS_COMMON_EXCLUDE.copy(),
'regress': RANDOM_FOREST_REGRESSOR_PARAMS_COMMON_EXCLUDE,
},
)
model_params
# %% [markdown]
# Обучение модели:
# %%
_ = pipeline.fit(df_orig_features_train, df_target_train.iloc[:, 0])
# %% [markdown]
# Оценка качества:
# %%
target_test_predicted = pipeline.predict(df_orig_features_test)
# %% [markdown]
# Метрики качества (MAPE, а также MSE, MAE):
# %%
metrics = score_predictions(df_target_test, target_test_predicted)
metrics
# %%
mlflow_log_model(
pipeline,
model_params=model_params,
metrics={k: float(v) for k, v in metrics.items()},
nested_run_name='Model with engineered features',
model_signature=mlflow_model_signature,
input_example=df_orig_features.head(MODEL_INOUT_EXAMPLE_SIZE),
pip_requirements=MODEL_PIP_REQUIREMENTS_PATH,
#global_comment_file_path=(
# model_comment_path
# if model_comment_path is not None
# else (BASE_PATH / 'research' / model_comment_relpath)
#),
)
# %% [markdown]
# ### Модель с дополнительными и отфильтрованными признаками
# %%
def build_selected_columns_info_for_mlflow(names=None, indices=None):
info = {}
if names is not None:
info['names'] = names
if indices is not None:
info['indices'] = indices
return info
def build_extra_logs_handler_selected_columns(names=None, indices=None):
def extra_log(mlf):
if any((v is not None) for v in (names, indices)):
info = build_selected_columns_info_for_mlflow(names=names, indices=indices)
mlf.log_dict(info, 'selected_columns_info.json')
return extra_log
# %%
def build_selected_columns_info_for_mlflow_from_sequential_feature_selector(feature_selector, *, take_names=True, take_indices=True):
return build_selected_columns_info_for_mlflow(
names=(feature_selector.k_feature_names_ if take_names else None),
indices=(tuple(feature_selector.k_feature_idx_) if take_indices else None),
)
def build_extra_logs_handler_selected_columns_from_sequential_feature_selector(feature_selector):
def extra_log(mlf):
info = build_selected_columns_info_for_mlflow_from_sequential_feature_selector(feature_selector)
mlf.log_dict(info, 'selected_columns_info.json')
return extra_log
# %%
regressor = build_regressor_baseline(random_state=0x8EDD)
regressor
# %% [markdown]
# Выбор признаков среди дополненного набора по минимизации MAPE:
# %%
len(df_augd_features_train.columns)
# %%
FILTERED_FEATURES_NUM = (4, 8)
# %%
def build_feature_selector(*, verbose=0):
return build_sequential_feature_selector(
regressor, k_features=FILTERED_FEATURES_NUM, forward=True, floating=True, cv=4, scoring='neg_mean_absolute_percentage_error',
verbose=verbose,
)
# %%
FEATURE_SELECTOR_PARAMS_COMMON_INCLUDE = {
**{k: True for k in SEQUENTIAL_FEATURE_SELECTOR_PARAMS_COMMON_INCLUDE},
'estimator': False,
}
FEATURE_SELECTOR_PARAMS_COMMON_EXCLUDE = () # TODO: ай-яй-яй
# %%
feature_selector = build_feature_selector(verbose=1)
feature_selector
# %%
_ = feature_selector.fit(df_augd_features_train, df_target_train.iloc[:, 0])
# %% [markdown]
# Выбранные признаки (имена и индексы):
# %%
build_selected_columns_info_for_mlflow_from_sequential_feature_selector(feature_selector)
# %% [markdown]
# MAPE в зависимости от количества выбранных признаков (указан регион выбора, ограниченный `FILTERED_FEATURES_NUM`):
# %%
fig, ax = plot_sequential_feature_selection(feature_selector, kind='std_dev')
ax.grid(True)
if isinstance(FILTERED_FEATURES_NUM, Sequence):
_ = ax.axvspan(min(FILTERED_FEATURES_NUM), max(FILTERED_FEATURES_NUM), color=matplotlib.colormaps.get_cmap('tab10')(6), alpha=0.15)
# хотелось бы поставить верхнюю границу `len(df_augd_features_train.columns)`, но SequentialFeatureSelector до неё не досчитывает-то
_ = ax.set_xlim((1, (max(FILTERED_FEATURES_NUM) if isinstance(FILTERED_FEATURES_NUM, Sequence) else FILTERED_FEATURES_NUM)))
_ = ax.set_ylim((None, 0.))
# %% [markdown]
# Составной пайплайн:
# %%
pipeline = sklearn.pipeline.Pipeline([
('preprocess', build_preprocess_augmenting_transformer()),
('select_features', feature_selector),
('regress', regressor),
])
pipeline
# %%
model_params = filter_params(
pipeline.get_params(),
include={
'preprocess': (False, PREPROCESS_AUGMENTING_TRANSFORMER_PARAMS_COMMON_INCLUDE.copy()),
'select_features': (False, FEATURE_SELECTOR_PARAMS_COMMON_INCLUDE.copy()),
'regress': (False, True),
},
exclude={
'preprocess': PREPROCESS_AUGMENTING_TRANSFORMER_PARAMS_COMMON_EXCLUDE.copy(),
'select_features': FEATURE_SELECTOR_PARAMS_COMMON_EXCLUDE,
'regress': RANDOM_FOREST_REGRESSOR_PARAMS_COMMON_EXCLUDE,
},
)
model_params
# %% [markdown]
# Обучение модели:
# %%
# XXX: SequentialFeatureSelector обучается опять!?
_ = pipeline.fit(df_orig_features_train, df_target_train.iloc[:, 0])
# %% [markdown]
# Оценка качества:
# %%
target_test_predicted = pipeline.predict(df_orig_features_test)
# %% [markdown]
# Метрики качества (MAPE, а также MSE, MAE):
# %%
metrics = score_predictions(df_target_test, target_test_predicted)
metrics
# %%
mlflow_log_model(
pipeline,
model_params=model_params,
metrics={k: float(v) for k, v in metrics.items()},
nested_run_name='Model with filtered engineered features',
model_signature=mlflow_model_signature,
input_example=df_orig_features.head(MODEL_INOUT_EXAMPLE_SIZE),
pip_requirements=MODEL_PIP_REQUIREMENTS_PATH,
#global_comment_file_path=(
# model_comment_path
# if model_comment_path is not None
# else (BASE_PATH / 'research' / model_comment_relpath)
#),
extra_logs_handler=(build_extra_logs_handler_selected_columns_from_sequential_feature_selector(pipeline.named_steps['select_features']),),
)
# %% [markdown]
# ### Автоматический подбор гиперпараметров модели
# %% [markdown]
# Составной пайплайн:
# %%
def build_pipeline(regressor_n_estimators, regressor_max_depth=None, regressor_max_features='sqrt'):
return sklearn.pipeline.Pipeline([
('preprocess', build_preprocess_augmenting_transformer()),
('select_features', build_feature_selector()),
('regress', build_regressor(regressor_n_estimators, max_depth=regressor_max_depth, max_features=regressor_max_features)),
])
# %% [markdown]
# Целевая функция для оптимизатора гиперпараметров (подбирает параметры `RandomForestRegressor`: `n_estimators`, `max_depth`, `max_features`):
# %%
def regressor_hyperparams_objective(trial):
n_estimators = trial.suggest_int('n_estimators', 1, 256, log=True)
max_depth = trial.suggest_int('max_depth', 1, 16, log=True)
max_features = trial.suggest_float('max_features', 0.1, 1.)
# составной пайплайн:
pipeline = build_pipeline(n_estimators, regressor_max_depth=max_depth, regressor_max_features=max_features)
# обучение модели:
_ = pipeline.fit(df_orig_features_train, df_target_train.iloc[:, 0])
# оценка качества:
target_test_predicted = pipeline.predict(df_orig_features_test)
# метрика качества (MAPE):
mape = sklearn.metrics.mean_absolute_percentage_error(df_target_test, target_test_predicted)
return mape
# %% [markdown]
# optuna study:
# %%
optuna_sampler = optuna.samplers.TPESampler(seed=0x0A1C)
optuna_study = optuna.create_study(sampler=optuna_sampler, direction='minimize')
optuna_study.optimize(regressor_hyperparams_objective, n_trials=24)
# %% [markdown]
# Количество выполненных trials:
# %%
len(optuna_study.trials)
# %% [markdown]
# Лучшие найдённые гиперпараметры:
# %%
repr(optuna_study.best_params)
# %%
regressor_best_params = dict(optuna_study.best_params.items())
# %% [markdown]
# Составной пайплайн:
# %%
def build_pipeline_optimized_best():
return build_pipeline(
regressor_best_params['n_estimators'],
regressor_max_depth=regressor_best_params['max_depth'],
regressor_max_features=regressor_best_params['max_features'],
)
# %%
pipeline = build_pipeline_optimized_best()
pipeline
# %%
model_params = filter_params(
pipeline.get_params(),
include={
'preprocess': (False, PREPROCESS_AUGMENTING_TRANSFORMER_PARAMS_COMMON_INCLUDE.copy()),
'select_features': (False, FEATURE_SELECTOR_PARAMS_COMMON_INCLUDE.copy()),
'regress': (False, True),
},
exclude={
'preprocess': PREPROCESS_AUGMENTING_TRANSFORMER_PARAMS_COMMON_EXCLUDE.copy(),
'select_features': FEATURE_SELECTOR_PARAMS_COMMON_EXCLUDE,
'regress': RANDOM_FOREST_REGRESSOR_PARAMS_COMMON_EXCLUDE,
},
)
model_params
# %% [markdown]
# Обучение модели:
# %%
_ = pipeline.fit(df_orig_features_train, df_target_train.iloc[:, 0])
# %% [markdown]
# Оценка качества:
# %%
target_test_predicted = pipeline.predict(df_orig_features_test)
# %% [markdown]
# Метрики качества (MAPE, а также MSE, MAE):
# %%
metrics = score_predictions(df_target_test, target_test_predicted)
metrics
# %%
mlflow_log_model(
pipeline,
model_params=model_params,
metrics={k: float(v) for k, v in metrics.items()},
nested_run_name='Optimized model with filtered engineered features',
model_signature=mlflow_model_signature,
input_example=df_orig_features.head(MODEL_INOUT_EXAMPLE_SIZE),
pip_requirements=MODEL_PIP_REQUIREMENTS_PATH,
#global_comment_file_path=(
# model_comment_path
# if model_comment_path is not None
# else (BASE_PATH / 'research' / model_comment_relpath)
#),
extra_logs_handler=(build_extra_logs_handler_selected_columns_from_sequential_feature_selector(pipeline.named_steps['select_features']),),
)
# %% [markdown]
# ### И в продакшн
# %% [markdown]
# Лучшая выбранная модель &mdash; с автоматически подобранными гиперпараметрами.
# %%
pipeline = build_pipeline_optimized_best()
pipeline
# %%
model_params = filter_params(
pipeline.get_params(),
include={
'preprocess': (False, PREPROCESS_AUGMENTING_TRANSFORMER_PARAMS_COMMON_INCLUDE.copy()),
'select_features': (False, FEATURE_SELECTOR_PARAMS_COMMON_INCLUDE.copy()),
'regress': (False, True),
},
exclude={
'preprocess': PREPROCESS_AUGMENTING_TRANSFORMER_PARAMS_COMMON_EXCLUDE.copy(),
'select_features': FEATURE_SELECTOR_PARAMS_COMMON_EXCLUDE,
'regress': RANDOM_FOREST_REGRESSOR_PARAMS_COMMON_EXCLUDE,
},
)
model_params
# %%
_ = pipeline.fit(df_orig_features, df_target.iloc[:, 0])
# %%
mlflow_log_model(
pipeline,
model_params=model_params,
metrics=None,
nested_run_name='Final model',
model_signature=mlflow_model_signature,
input_example=df_orig_features.head(MODEL_INOUT_EXAMPLE_SIZE),
pip_requirements=MODEL_PIP_REQUIREMENTS_PATH,
#global_comment_file_path=(
# model_comment_path
# if model_comment_path is not None
# else (BASE_PATH / 'research' / model_comment_relpath)
#),
extra_logs_handler=(build_extra_logs_handler_selected_columns_from_sequential_feature_selector(pipeline.named_steps['select_features']),),
)
# %%

14
run_mlflow_server.ps1 Обычный файл
Просмотреть файл

@@ -0,0 +1,14 @@
$ErrorActionPreference = "Stop"
. $PSScriptRoot\_mlflow_config_common.ps1
$DEFAULT_ARTIFACT_ROOT = "./mlflow/mlartifacts/"
$MLFLOW_PORT = if ($env:MLFLOW_PORT) { $env:MLFLOW_PORT } else { 5000 }
New-Item -ItemType Directory -Force -Path $DEFAULT_ARTIFACT_ROOT | Out-Null
& mlflow server `
--backend-store-uri="$BACKEND_URI" `
--default-artifact-root="$DEFAULT_ARTIFACT_ROOT" `
-p $MLFLOW_PORT

16
run_mlflow_server.sh Исполняемый файл
Просмотреть файл

@@ -0,0 +1,16 @@
#!/bin/sh
set -eu
. ./_mlflow_config_common.sh
DEFAULT_ARTIFACT_ROOT="./mlflow/mlartifacts/"
: "${MLFLOW_PORT:=5000}"
mkdir -p "${DEFAULT_ARTIFACT_ROOT}"
exec mlflow server \
--backend-store-uri="$BACKEND_URI" \
--default-artifact-root="$DEFAULT_ARTIFACT_ROOT" \
-p "$MLFLOW_PORT"

169
services/README.md Обычный файл
Просмотреть файл

@@ -0,0 +1,169 @@
# Сервис предсказания цен
Веб-сервис предсказания цен на подержанные автомобили. Мониторинг в комплекте.
Обзор сервисов (по `compose.yaml`, см. о развёртывании ниже):
| Профили Compose | Имя | Объекты | Описание |
|-----------------|------------------|------------------|------------------|
| &mdash; | `prices-predictor` | код: `ml_service/` | Веб-сервис предсказания цен, только stateless API. Об используемой предсказательной модели см. `research/README.md`. |
| &mdash; | `prometheus` | конфигурация: `prometheus/` | Мониторинг сервиса ([Prometheus](https://prometheus.io/)). |
| &mdash; | `grafana` | сохранённая конфигурация: `grafana/` | Аналитика и визуализация данных мониторига сервиса ([Grafana](https://grafana.com/)). |
| `with-testers` | `load-tester` | код: `load-tester/` | Генератор потока случайных запросов к `prices-predictor` для тестирования. |
Дополнительно:
* `models/` &mdash; расположение файла модели `model.pkl` для использования сервисом `prices-predictor`.
* `fetch_model_as_pickle_from_mlflow.py` &mdash; скрипт для экспорта предиктивной модели scikit-learn из MLFlow в файл.
## API сервиса предсказания цен
**Базовый URL**: `/api`. Все указанные далее URL записаны **относительно базового URL**, если не указано иное.
* Полная интерактивная документация (Swagger UI): `/docs`.
* Предсказать цену подержанного автомобиля: `/predict` (POST).
Пример запроса:
* requst query: `item_id=16` (параметр `item_id` необходим!);
* request body:
```json
{
"selling_price": 5.59,
"driven_kms": 27000.0,
"age": 5.0,
"fuel_type": "petrol",
"selling_type": "dealer",
"transmission_type": "manual"
}
```
* response body:
```json
{
"item_id": 16,
"price": 3.743508852258851
}
```
* Тестовый эндпоинт: `/` (GET).
Возвращает простой демонстрационный объект JSON.
Может использоваться для проверки состояния сервиса.
## Мониторинг
### Prometheus UI
#### Примеры запросов
Гистограмма предсказанных цен `model_prediction_value_bucket` (запрос: `rate(model_prediction_value_bucket[5m]`):
![Гистограмма предсказанных цен как временные ряды](docs/screenshot-prometheus-query-model-1.png)
Гистограмма продолжительности предсказания цен моделью ML `model_prediction_seconds_bucket` (запрос: `rate(model_prediction_seconds_bucket[5m]`):
![Гистограмма продолжительности предсказания цен моделью ML как временные ряды](docs/screenshot-prometheus-query-model-2.png)
Интенсивность потока запросов к сервису предсказания цен с разными результатами (успех &mdash; коды HTTP `2xx`, ошибки со стороны клиента &mdash; коды HTTP `4xx`) `http_requests_total{handler="/predict"}` (запрос: `rate(http_requests_total{handler="/predict"}[5m]`):
![Интенсивность потока запросов к сервису предсказания цен с разными результатами](docs/screenshot-prometheus-query-http-1.png)
Интенсивность потока запросов к **веб-серверу** сервиса предсказания цен **с ошибками** `http_requests_total{handler="/predict"}` (запрос: `sum without(handler, method) (rate(http_requests_total{status=~"4..|5.."}[5m]))`):
![Интенсивность потока запросов к веб-серверу сервиса предсказания цен, заканчивающихся ошибками](docs/screenshot-prometheus-query-http-1.png)
### Дашборд в Grafana
Дашборд экспортирован в файл: `grafana/objects/dashboard-1765200932880.json`.
![Дашборд в Grafana](docs/screenshot-grafana-dashboard.png)
Элементы:
* мониторинг модели:
* гистограмма распределения предсказанных цен за период времени (10 мин);
* прикладной уровень:
* интенсивность потока запросов (всех запросов; запросов, заканчивающихся ошибкой);
* инфраструктурный уровень:
* состояние сервиса (up/down);
* выделенный процессу объём VRAM.
## Развёртывание
### Файл модели
Файл используемой предсказательной модели можно извлечь из MLFlow скриптом `models/fetch_model_as_pickle_from_mlflow.py`. Файл модели можно размещается в `models/model.pkl`.
Например, извлечь модель по имени (`<model-name>`) и версии (`<model-version>`) (например, `UsedCardPricePredictionFinal/1`) (команда запускается из корневой директории проекта &mdash; от этого зависит путь к создаваемому файлу):
python services/models/fetch_model_as_pickle_from_mlflow.py --model "models:/<model-name>/<model-version>" services/models/model.pkl
Можно указать адрес tracking сервера MLFlow, например: `--tracking-uri "http://localhost:5000"`.
Информация о других опциях доступна:
python services/models/fetch_model_as_pickle_from_mlflow.py --help
### Образы Docker
#### `ml_model` (для сервиса `prices-predictor`)
**Сборка образа** (замените `<version>` на номер версии) (команда запускается из корневой директории проекта &mdash; от этого зависит путь к директории):
docker build -t ml_service:<version> services/ml_service/
**Независимый запуск** (замените `<version>` на номер версии образа, `<models-dir>` на **абсолютный** путь к директории, где размещён файл предсказательной модели `model.pkl`, `<port>` на порт для запуска веб-сервиса (например, `8000`)):
docker run -v "<models-dir>:/models" -p <port>:8000 ml_service:<version>
Модель может быть размещена в `models/`; тогда, например, при запуске команды из корна проекта: `$(pwd)/services/models` (здесь `$(pwd)` используется потому, что необходим абсолютный путь).
#### `load-tester` (для сервиса `load-tester`)
**Сборка образа** (замените `<version>` на номер версии) (команда запускается из корневой директории проекта &mdash; от этого зависит путь к директории):
docker build -t load_tester:<version> services/load_tester/
**Независимый запуск** (замените `<version>` на номер версии образа, `<api-base-url>` на базовый URL сервиса `prices-predictor` (например, `http://prices-predictor:8000/api`)):
docker run -e "API_BASE_URL=<api-base-url>" ml_service:<version>
### Развёртывание сервиса посредством Compose
Конфигурация описана в файле `compose.yaml`. Имя системы: `mpei-iis-system`.
Рекомендуется (не обязательно) использовать env-файл `compose.env`. Используйте файл `compose.env.template` как шаблон.
**Директория `models/` используется сервисом `prices-predictor` как том** с файлом модели `model.pkl`. Поместите туда файл модели, см. [Файл модели](#файл-модели).
**Управление сервисом с мониторингом** (замените `<command>` и `[options...]`):
docker compose -f services/compose.yaml --env-file services/compose.env <command> [options...]
**Для запуска вместе с генераторами тестовых запросов** используйте опцию compose `--profile=with-tester`.
Основные команды `docker compose`:
* `up`: создать и запустить контейнеры (также тома, сети и прочее); оставляет вывод логов прикреплённым к терминалу, `SIGINT` останавливает контейнеры, **но не удаляет созданные объекты**;
* опция `-d`: то же, но открепляет процесс от терминала.
* `down`: остановить и удалить контейнеры (также сети и прочее; для удаления томов используйте опцию `-v`).
* `start`: запустить существующие контейнеры.
* `stop`: остановить контейнеры.
* `restart`: перезапустить контейнеры.
**Открытые на хосте интерфейсы**:
* `localhost:8010`: Сервис `prices-predictor`. Базовый URL: `/api`.
* `localhost:9090`: UI Prometheus.
* `localhost:3000`: Grafana.
**Доступные на хосте тома**:
* `mpei-iis-system_prometheus-storage`: БД Prometheus.
* `mpei-iis-system_grafana-storage`: БД Grafana.

2
services/compose.env.template Обычный файл
Просмотреть файл

@@ -0,0 +1,2 @@
GF_SECURITY_ADMIN_USER=admin
GF_SECURITY_ADMIN_PASSWORD=admin

64
services/compose.yaml Обычный файл
Просмотреть файл

@@ -0,0 +1,64 @@
name: mpei-iis-system
services:
prices-predictor:
image: ml_service:2
ports:
- "8010:8000"
volumes:
- './models:/models:ro'
load-tester:
image: load_tester:1
environment:
API_BASE_URL: "http://prices-predictor:8000/api"
# XXX: Предотвращает аварийный выход тестера при отсутствии ответа от prices-predictor
# во время его (потенциально долгого) запуска.
depends_on:
- prices-predictor
deploy:
replicas: 2
profiles:
- "with-testers"
prometheus:
image: prom/prometheus:v3.7.3
ports:
- "9090:9090"
user: nobody
command:
- "--config.file=/etc/prometheus/prometheus.yaml"
volumes:
- "./prometheus/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro"
- "prometheus-storage:/prometheus"
grafana:
image: grafana/grafana:12.4.0-20012734117
ports:
- "3000:3000"
#environment:
# GF_SECURITY_ADMIN_USER: "$__file{/run/secrets/grafana-admin-user}"
# GF_SECURITY_ADMIN_PASSWORD: "$__file{/run/secrets/grafana-admin-password}"
#secrets:
# - grafana-admin-user
# - grafana-admin-password
environment:
GF_SECURITY_ADMIN_USER: "${GF_SECURITY_ADMIN_USER:-admin}"
GF_SECURITY_ADMIN_PASSWORD: "${GF_SECURITY_ADMIN_PASSWORD:-admin}"
volumes:
- "grafana-storage:/var/lib/grafana"
volumes:
prometheus-storage: {}
grafana-storage: {}
#secrets:
#
# grafana-admin-user:
# environment: GF_SECURITY_ADMIN_USER
#
# grafana-admin-password:
# environment: GF_SECURITY_ADMIN_PASSWORD

Двоичные данные
services/docs/screenshot-grafana-dashboard.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 164 KiB

Двоичные данные
services/docs/screenshot-prometheus-query-http-1.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 87 KiB

Двоичные данные
services/docs/screenshot-prometheus-query-http-2.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 78 KiB

Двоичные данные
services/docs/screenshot-prometheus-query-model-1.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 115 KiB

Двоичные данные
services/docs/screenshot-prometheus-query-model-2.png Обычный файл

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 99 KiB

1
services/grafana/.gitattributes поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
objects/*.json -text

Просмотреть файл

@@ -0,0 +1,543 @@
{
"apiVersion": "dashboard.grafana.app/v2beta1",
"kind": "Dashboard",
"metadata": {
"name": "adcdfv7",
"generation": 9,
"creationTimestamp": "2025-12-08T12:51:53Z",
"labels": {},
"annotations": {}
},
"spec": {
"annotations": [
{
"kind": "AnnotationQuery",
"spec": {
"builtIn": true,
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"query": {
"group": "grafana",
"kind": "DataQuery",
"spec": {},
"version": "v0"
}
}
}
],
"cursorSync": "Off",
"editable": true,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"editorMode": "builder",
"exemplar": false,
"expr": "process_virtual_memory_bytes",
"instant": false,
"interval": "10s",
"legendFormat": "__auto",
"range": true
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 1,
"links": [],
"title": "Выделенный объём VRAM",
"vizConfig": {
"group": "timeseries",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "bars",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "stepAfter",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
},
{
"color": "red",
"value": 80
}
]
},
"unit": "bytes"
},
"overrides": []
},
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
}
},
"version": "12.4.0-20012734117"
}
}
},
"panel-2": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"editorMode": "builder",
"expr": "up",
"legendFormat": "__auto",
"range": true
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 2,
"links": [],
"title": "Состояние",
"vizConfig": {
"group": "state-timeline",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "continuous-GrYlRd"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
},
"unit": "bool_on_off"
},
"overrides": []
},
"options": {
"alignValue": "left",
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"mergeValues": true,
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
}
},
"version": "12.4.0-20012734117"
}
}
},
"panel-3": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"editorMode": "builder",
"expr": "sum without(instance, method, status) (rate(http_requests_total{handler=\"/predict\"}[$__rate_interval]))",
"interval": "1m",
"legendFormat": "__auto",
"range": true
},
"version": "v0"
},
"refId": "A"
}
},
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"editorMode": "builder",
"expr": "rate(http_requests_total{handler=\"/predict\", status=~\"4..|5..\"}[$__rate_interval])",
"instant": false,
"interval": "1m",
"legendFormat": "__auto",
"range": true
},
"version": "v0"
},
"refId": "B"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 3,
"links": [],
"title": "HTTP-запросы",
"vizConfig": {
"group": "timeseries",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"showValues": false,
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
},
"unit": "reqps"
},
"overrides": []
},
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
}
},
"version": "12.4.0-20012734117"
}
}
},
"panel-4": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"group": "prometheus",
"kind": "DataQuery",
"spec": {
"editorMode": "builder",
"exemplar": false,
"expr": "sum without(instance) (increase(model_prediction_value_bucket[10m])) / on() group_left sum(increase(model_prediction_value_count[10m]))",
"format": "heatmap",
"instant": false,
"interval": "10m",
"legendFormat": "<{{le}}",
"range": true
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "Подпись под каждым столбцом обозначает его максимальное соответствующее значение",
"id": 4,
"links": [],
"title": "Предсказанные цены за 10 минут",
"vizConfig": {
"group": "bargauge",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
},
"unit": "percentunit"
},
"overrides": []
},
"options": {
"displayMode": "gradient",
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"maxVizHeight": 300,
"minVizHeight": 16,
"minVizWidth": 8,
"namePlacement": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"showUnfilled": false,
"sizing": "auto",
"valueMode": "color"
}
},
"version": "12.4.0-20012734117"
}
}
}
},
"layout": {
"kind": "GridLayout",
"spec": {
"items": [
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-4"
},
"height": 8,
"width": 12,
"x": 0,
"y": 0
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-2"
},
"height": 8,
"width": 12,
"x": 12,
"y": 0
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-3"
},
"height": 8,
"width": 12,
"x": 0,
"y": 8
}
},
{
"kind": "GridLayoutItem",
"spec": {
"element": {
"kind": "ElementReference",
"name": "panel-1"
},
"height": 8,
"width": 12,
"x": 12,
"y": 8
}
}
]
}
},
"links": [],
"liveNow": false,
"preload": false,
"tags": [],
"timeSettings": {
"autoRefresh": "30s",
"autoRefreshIntervals": [
"5s",
"10s",
"30s",
"1m",
"5m",
"15m",
"30m",
"1h",
"2h",
"1d"
],
"fiscalYearStartMonth": 0,
"from": "now-1h",
"hideTimepicker": false,
"timezone": "browser",
"to": "now"
},
"title": "Сервис предсказания цен",
"variables": []
},
"status": {}
}

6
services/load_tester/.dockerignore Обычный файл
Просмотреть файл

@@ -0,0 +1,6 @@
### Python
__pycache__/
*.pyc
### Project
*.unused

13
services/load_tester/Dockerfile Обычный файл
Просмотреть файл

@@ -0,0 +1,13 @@
FROM python:3.11-slim
WORKDIR /load_tester
COPY ./requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "-m", "tester"]
# docker build -t load_tester:1 services/load_tester/
# docker run -e "API_BASE_URL=http://prices-predictor:8000/api" load_tester:1

1
services/load_tester/requirements.txt Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
requests >=2.32.5,<3

280
services/load_tester/tester.py Обычный файл
Просмотреть файл

@@ -0,0 +1,280 @@
from argparse import ArgumentParser
from collections.abc import Callable, MutableMapping
from dataclasses import dataclass, asdict
from enum import Enum
import logging
from os import getenv
from random import randint, uniform, expovariate, choice
from signal import SIGINT, SIGTERM, signal
import sys
from time import sleep
from types import FrameType
from typing import Any, cast
from requests import RequestException, Response, Session
def fixup_payload_enum_value(mapping: MutableMapping[str, Any], key: str) -> None:
mapping[key] = mapping[key].value
ENDPOINT_URL: str = '/predict'
class FuelType(Enum):
PETROL = 'petrol'
DIESEL = 'diesel'
CNG = 'cng'
class SellingType(Enum):
DEALER = 'dealer'
INDIVIDUAL = 'individual'
class TransmissionType(Enum):
MANUAL = 'manual'
AUTOMATIC = 'automatic'
@dataclass
class PricePredictionFeatures:
selling_price: float
driven_kms: float
age: float
fuel_type: FuelType
selling_type: SellingType
transmission_type: TransmissionType
MAX_RETRIES_DEFAULT = 3
def exp_delay_from_attempt_number(attempt_i: int) -> float:
return 0.2 * (2 ** attempt_i)
def post_item(
session: Session, url: str, item_id: int, features: PricePredictionFeatures,
*, max_retries: int = MAX_RETRIES_DEFAULT,
) -> Response:
if max_retries < 0:
raise ValueError('max_retries must be >= 0')
payload = asdict(features)
for k in ('fuel_type', 'selling_type', 'transmission_type'):
fixup_payload_enum_value(payload, k)
excs = []
for attempt_i in range(max_retries + 1):
try:
response = session.post(url, params={'item_id': item_id}, json=payload, timeout=10)
except RequestException as err:
excs.append(err)
sleep(exp_delay_from_attempt_number(attempt_i))
else:
return response
assert len(excs) > 0
# XXX: ...
raise IOError(
f'Failed to post an item in {max_retries + 1} attempts;'
' see the latest exception in __cause__'
) from excs[-1]
def generate_request_data() -> tuple[int, PricePredictionFeatures]:
item_id = randint(1, 100)
features = PricePredictionFeatures(
selling_price=round(uniform(2.0, 16.0), 2),
driven_kms=round(uniform(0.0, 100000.0), 0),
age=round(uniform(0.0, 10.0), 1),
fuel_type=choice(list(FuelType)),
selling_type=choice(list(SellingType)),
transmission_type=choice(list(TransmissionType)),
)
return (item_id, features)
INTERVAL_MEAN_DEFAULT = 4.0
INTERVAL_BOUNDS_DEFAULT: tuple[float | None, float | None] = (0.5, 10.0)
class Requester:
def __init__(
self,
base_url: str,
interval_mean: float = INTERVAL_MEAN_DEFAULT,
interval_bounds: tuple[float | None, float | None] = INTERVAL_BOUNDS_DEFAULT,
*, max_retries: int = MAX_RETRIES_DEFAULT,
):
self.base_url = base_url
self.interval_mean = interval_mean
self.interval_bounds = interval_bounds
self.max_retries = max_retries
self._session = Session()
self._stop_requested: bool = False
@property
def endpoint(self) -> str:
endpoint_url = ENDPOINT_URL
if (len(endpoint_url) > 0) and (not endpoint_url.startswith('/')):
endpoint_url = '/' + endpoint_url
return (self.base_url + endpoint_url)
@property
def session(self) -> Session:
return self._session
@property
def stop_requested(self) -> bool:
return self._stop_requested
def stop(self) -> None:
self._stop_requested = True
def _decide_delay(self) -> float:
interval_bounds = self.interval_bounds
val = expovariate(1. / self.interval_mean)
if interval_bounds[0] is not None:
val = max(val, interval_bounds[0])
if interval_bounds[1] is not None:
val = min(val, interval_bounds[1])
return val
def run(self) -> None:
while not self._stop_requested:
item_id, features = generate_request_data()
try:
response = post_item(
self._session, self.endpoint, item_id, features, max_retries=self.max_retries,
)
except IOError as err:
logging.warning('%s: %s', str(err), str(err.__cause__))
raise err
else:
logging.debug('Success: %s %s', response.status_code, response.reason)
sleep(self._decide_delay())
def _build_termination_handler(requester: Requester) -> Callable[[int, FrameType | None], None]:
def termination_handler(sig: int, frame: FrameType | None) -> None:
_ = sig
_ = frame
requester.stop()
return termination_handler
def _configure_logging(level: int, quiet: bool) -> None:
if quiet:
level = logging.CRITICAL + 1
logging.basicConfig(
level=level, format='%(asctime)s %(levelname)s %(message)s', stream=sys.stderr,
)
def _setup_signal_handlers(requester: Requester) -> None:
termination_handler = _build_termination_handler(requester)
for sig in (SIGINT, SIGTERM):
signal(sig, termination_handler)
def _validate_cli_interval_bound(string: str) -> float | None:
string = string.lower()
if string in ('', 'null', 'none'):
return None
return float(string)
def _validate_cli_interval_bounds(string: str) -> tuple[float | None, float | None]:
string = string.lower()
if string in ('', 'null', 'none'):
return (None, None)
min_string, max_string = string.split(',', 1)
return cast(
tuple[float | None, float | None],
tuple(map(_validate_cli_interval_bound, (min_string, max_string)))
)
def _validate_cli_max_retries(string: str) -> int:
val = int(string)
if val < 0:
raise ValueError(f'Max retries should be >=0, given {val}')
return val
def _validate_cli_logging_level(string: str) -> int:
return {
'debug': logging.DEBUG,
'info': logging.INFO,
'warning': logging.WARNING,
'error': logging.ERROR,
'critical': logging.CRITICAL,
}[string]
def parse_args(argv):
parser = ArgumentParser(
description=(
'Регулярная отправка POST-запросов на эндпоинт предсказания цены.'
' Остановка по SIGINT / SIGTERM.'
),
allow_abbrev=False,
exit_on_error=True,
)
parser.add_argument('base_url', type=str, nargs='?')
parser.add_argument('--interval-mean', type=float, dest='interval_mean')
parser.add_argument(
'--interval-bounds', type=_validate_cli_interval_bounds, dest='interval_bounds',
)
parser.add_argument(
'--max-retries',
type=_validate_cli_max_retries,
default=MAX_RETRIES_DEFAULT,
dest='max_retries',
)
parser.add_argument('-q', '--quiet', action='store_true', dest='quiet')
parser.add_argument(
'--log-level',
default=logging.WARNING,
type=_validate_cli_logging_level,
dest='logging_level',
)
args = parser.parse_args(argv[1:])
if args.base_url is None:
args.base_url = getenv('API_BASE_URL')
if args.base_url is None:
raise RuntimeError('No API base URL specified')
if (args.interval_mean is not None) and (args.interval_mean <= 0):
raise ValueError(f'Interval mean should be > 0, given {args.interval_mean}')
if (
(args.interval_bounds is not None)
and all((b is not None) for b in args.interval_bounds)
and (args.interval_bounds[0] > args.interval_bounds[1])
):
raise ValueError(f'Interval bounds should be b_1 <= b_2, given {args.interval_bounds!r}')
if args.interval_mean is not None:
if args.interval_bounds is None:
args.interval_bounds = ((args.interval_mean / 5), (args.interval_mean * 5))
else:
args.interval_mean = INTERVAL_MEAN_DEFAULT
args.interval_bounds = INTERVAL_BOUNDS_DEFAULT
return args
def main(argv):
args = parse_args(argv)
_configure_logging(args.logging_level, args.quiet)
logging.debug('Creating a Requester with base URL: %s', args.base_url)
requester = Requester(
args.base_url,
interval_mean=args.interval_mean,
interval_bounds=args.interval_bounds,
max_retries=args.max_retries,
)
_setup_signal_handlers(requester)
requester.run()
return 0
if __name__ == '__main__':
sys.exit(int(main(sys.argv) or 0))

Просмотреть файл

@@ -0,0 +1,37 @@
from os import (
getenv,
#kill,
)
#from signal import SIGINT, SIGTERM
from subprocess import (
run,
#Popen,
)
import sys
#_child_proc: Popen | None = None
#def _forward_signal(sig, frame):
# _ = frame
# if _child_proc is None:
# return
# if _child_proc.pid in (SIGINT, SIGTERM):
# kill(_child_proc.pid, sig)
# else:
# raise RuntimeError(f'Attempted to forward an unexpected signal: {sig}')
def main(argv):
argv = list(argv) # copy
base_url = getenv('API_BASE_URL')
if base_url is None:
raise RuntimeError('API_BASE_URL is not specified')
argv.append(base_url) # HACK: ...
result = run(['python', '-m', 'tester', *argv[1:]], check=False)
return result.returncode
if __name__ == '__main__':
sys.exit(int(main(sys.argv) or 0))

3
services/ml_service/.dockerignore Обычный файл
Просмотреть файл

@@ -0,0 +1,3 @@
### Python
__pycache__/
*.pyc

19
services/ml_service/Dockerfile Обычный файл
Просмотреть файл

@@ -0,0 +1,19 @@
FROM python:3.11-slim
WORKDIR /service
COPY ./requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
VOLUME /models
EXPOSE 8000/tcp
ENV MODELS_PATH=/models
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
# docker build -t ml_service:2 services/ml_service/
# docker run -v "$(pwd)/services/models:/models" -p 8000:8000 ml_service:2

2
services/ml_service/app/__init__.py Обычный файл
Просмотреть файл

@@ -0,0 +1,2 @@
from ._meta import PACKAGE_PATH
from .main import app

4
services/ml_service/app/_meta.py Обычный файл
Просмотреть файл

@@ -0,0 +1,4 @@
from pathlib import Path
PACKAGE_PATH = Path(__file__).parent

72
services/ml_service/app/main.py Обычный файл
Просмотреть файл

@@ -0,0 +1,72 @@
from os import getenv
from pathlib import Path
from fastapi import FastAPI
from prometheus_fastapi_instrumentator import Instrumentator
from pydantic import BaseModel, Field
from ._meta import PACKAGE_PATH
from .predictor import (
FuelType, SellingType, TransmissionType, PricePredictionFeatures, PricePredictor,
)
MODELS_PATH = getenv('MODELS_PATH', None)
if MODELS_PATH is not None:
MODELS_PATH = Path(MODELS_PATH)
else:
SERVICES_PATH = PACKAGE_PATH.parents[1]
assert SERVICES_PATH.name == 'services'
MODELS_PATH = SERVICES_PATH / 'models'
MODEL_PATH = MODELS_PATH / 'model.pkl'
predictor = PricePredictor(MODEL_PATH)
API_BASE_PATH = '/api'
app = FastAPI(
title='Сервис ML',
version='0.1.0',
root_path=API_BASE_PATH,
#redoc_url=None,
)
_ = (
Instrumentator(excluded_handlers=['/metrics'])
.instrument(app)
.expose(app, endpoint='/metrics')
)
@app.get('/', summary='Тестовый эндпоинт')
async def root():
return {'Hello': 'World'}
class PricePredictionRequest(BaseModel):
selling_price: float = Field(..., gt=0)
driven_kms: float = Field(..., ge=0)
age: float = Field(..., ge=0)
fuel_type: FuelType
selling_type: SellingType
transmission_type: TransmissionType
@app.post('/predict', summary='Предсказать цену подержанного автомобиля')
def predict_price(item_id: int, req: PricePredictionRequest):
features = PricePredictionFeatures(
selling_price=req.selling_price,
driven_kms=req.driven_kms,
age=req.age,
fuel_type=req.fuel_type,
selling_type=req.selling_type,
transmission_type=req.transmission_type,
)
pred = predictor.predict(features)
return {'item_id': item_id, 'price': pred}

111
services/ml_service/app/predictor.py Обычный файл
Просмотреть файл

@@ -0,0 +1,111 @@
from dataclasses import dataclass
from enum import Enum
from itertools import chain
from pandas import DataFrame
from pickle import load
from prometheus_client import Counter, Histogram
def open_model_file(file, *, buffering=-1, opener=None, **kwargs_extra):
open_kwargs_extra = {}
if 'closefd' in kwargs_extra:
open_kwargs_extra['closefd'] = kwargs_extra.pop('closefd')
if len(kwargs_extra) > 0:
raise TypeError(
'Unexpected keyword arguments given: {}'
.format(', '.join(map(repr, kwargs_extra.keys())))
)
return open(file, 'rb', buffering=buffering, opener=opener)
def load_model_from_file(file):
return load(file)
def load_model_from_path(path, *, buffering=-1, opener=None, **kwargs_extra):
open_kwargs_extra = {}
for k in ('closefd',):
if k in kwargs_extra:
open_kwargs_extra[k] = kwargs_extra.pop(k)
if len(kwargs_extra) > 0:
raise TypeError(
'Unexpected keyword arguments given: {}'.format(', '.join(kwargs_extra.keys()))
)
with open_model_file(
path, buffering=buffering, opener=opener, **open_kwargs_extra,
) as model_file:
return load_model_from_file(model_file)
class FuelType(Enum):
PETROL = 'petrol'
DIESEL = 'diesel'
CNG = 'cng'
class SellingType(Enum):
DEALER = 'dealer'
INDIVIDUAL = 'individual'
class TransmissionType(Enum):
MANUAL = 'manual'
AUTOMATIC = 'automatic'
@dataclass
class PricePredictionFeatures:
selling_price: float
driven_kms: float
age: float
fuel_type: FuelType
selling_type: SellingType
transmission_type: TransmissionType
metric_prediction_latency = Histogram(
'model_prediction_seconds', 'Время вычислений в модели',
buckets=(
list(chain.from_iterable((v * (10 ** p) for v in (1, 2, 5)) for p in range(-4, (1 + 1))))
+ [float('+inf')]
),
)
metric_prediction_errors = Counter(
'model_prediction_errors_total', 'Ошибки вычислений в модели по типу', ('error_type',),
)
metric_prediction_value = Histogram(
'model_prediction_value', 'Предсказанное значение цены',
buckets=(
list(chain.from_iterable((v * (10 ** p) for v in (1, 2, 5)) for p in range(-1, (2 + 1))))
+ [float('+inf')]
),
)
class PricePredictor:
def __init__(self, model_path):
self._model = load_model_from_path(model_path)
def predict(self, features):
# WARN: порядок столбцов вроде имеет значение
features_df = DataFrame([{
'selling_price': features.selling_price,
'driven_kms': features.driven_kms,
'fuel_type': features.fuel_type.value,
'selling_type': features.selling_type.value,
'transmission': features.transmission_type.value,
'age': features.age,
}])
try:
with metric_prediction_latency.time():
predictions = self._model.predict(features_df)
except Exception as err:
metric_prediction_errors.labels(error_type=type(err).__name__).inc()
raise
assert len(predictions) == 1
value = float(predictions[0])
metric_prediction_value.observe(value)
return value

7
services/ml_service/requirements.txt Обычный файл
Просмотреть файл

@@ -0,0 +1,7 @@
fastapi ~=0.120.4
mlxtend ~=0.23.4
pandas >=2.3.1,<3
prometheus_client ~=0.23.1
prometheus_fastapi_instrumentator >=7.0.2,<8
scikit-learn >=1.7.2,<2
uvicorn ~=0.38.0

1
services/models/.gitignore поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
*.pkl

Просмотреть файл

@@ -0,0 +1,83 @@
from argparse import ArgumentParser
from pathlib import Path
from pickle import dump
from sys import exit as sys_exit, argv as sys_argv
from mlflow import set_tracking_uri, set_registry_uri
from mlflow.sklearn import load_model
MLFLOW_TRACKING_URI_DEFAULT = 'http://localhost:5000'
def open_file_for_model(file, *, buffering=-1, opener=None, **kwargs_extra):
open_kwargs_extra = {}
if 'closefd' in kwargs_extra:
open_kwargs_extra['closefd'] = kwargs_extra.pop('closefd')
if len(kwargs_extra) > 0:
raise TypeError(
'Unexpected keyword arguments given: {}'
.format(', '.join(map(repr, kwargs_extra.keys())))
)
return open(file, 'wb', buffering=buffering, opener=opener)
def dump_model_to_file(model, file):
return dump(model, file)
def dump_model_to_path(model, path, *, buffering=-1, opener=None, **kwargs_extra):
open_kwargs_extra = {}
for k in ('closefd',):
if k in kwargs_extra:
open_kwargs_extra[k] = kwargs_extra.pop(k)
if len(kwargs_extra) > 0:
raise TypeError(
'Unexpected keyword arguments given: {}'
.format(', '.join(map(repr, kwargs_extra.keys())))
)
with open_file_for_model(
path, buffering=buffering, opener=opener, **open_kwargs_extra,
) as model_file:
return dump_model_to_file(model, model_file)
def parse_args(argv):
parser = ArgumentParser(
description=(
'Скачать модель с tracking server MLFlow и сохранить в локальный файл pickle'
),
allow_abbrev=False,
exit_on_error=True,
)
model_ref_parser = parser.add_mutually_exclusive_group(required=True)
model_ref_parser.add_argument('-m', '--model', type=str, dest='model_uri')
model_ref_parser.add_argument('--run', type=str, dest='run_id')
parser.add_argument(
'--tracking-uri', default=MLFLOW_TRACKING_URI_DEFAULT, type=str, dest='tracking_uri',
)
parser.add_argument('--registry-uri', type=str, dest='registry_uri')
parser.add_argument('out_path', default=Path('.'), type=Path)
args = parser.parse_args(argv)
return args
def main(argv):
args = parse_args(argv)
set_tracking_uri(args.tracking_uri)
if args.registry_uri is not None:
set_registry_uri(args.registry_uri)
if args.model_uri is not None:
model_uri = args.model_uri
elif args.run_id is not None:
model_uri = f'runs:/{args.run_id}/model'
else:
assert False
return 1
model = load_model(model_uri)
dump_model_to_path(model, args.out_path)
return 0
if __name__ == '__main__':
sys_exit(int(main(sys_argv) or 0))

1
services/prometheus/.gitignore поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
data/

15
services/prometheus/prometheus.yaml Обычный файл
Просмотреть файл

@@ -0,0 +1,15 @@
global:
scrape_interval: 15s
scrape_timeout: 5s
scrape_configs:
- job_name: "prices_predictor"
static_configs:
- targets:
- "prices-predictor:8000"
scheme: http
metrics_path: "/metrics"
#relabel_configs:
# - source_labels: ["__address__"]
# target_labels: "instance"