29 Коммитов

Автор SHA1 Сообщение Дата
e43c47dae6 fix: путь к README для сервиса ml_service 2025-12-08 20:25:02 +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
38 изменённых файлов: 15216 добавлений и 219 удалений

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

@@ -1,4 +1,5 @@
### Python ### Python
__pycache__/
*.pyc *.pyc
### Jupyter ### Jupyter

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

@@ -8,9 +8,15 @@
](https://www.kaggle.com/datasets/vijayaadithyanvg/car-price-predictionused-cars/data) — ](https://www.kaggle.com/datasets/vijayaadithyanvg/car-price-predictionused-cars/data) —
продажа подержанных автомобилей на рынке в Индии. продажа подержанных автомобилей на рынке в Индии.
## Установка ## Сервис предсказания цен
### Общий порядок См. `services/ml_service/README.md`.
## Исследовательская часть проекта
### Установка
#### Общий порядок
**Внимание**: Здесь описан только общий порядок установки. Определённые части проекта могут требовать установки по отдельным инструкциям. **Внимание**: Здесь описан только общий порядок установки. Определённые части проекта могут требовать установки по отдельным инструкциям.
@@ -47,20 +53,24 @@
5. **При необходимости** скачайте данные. Каноническое расположение для данных проекта: `data/`. 5. **При необходимости** скачайте данные. Каноническое расположение для данных проекта: `data/`.
### Зависимости #### Зависимости
#### Общие зависимости ##### Общие зависимости
Зависимости — пакеты Python — записаны в файле `requirements.txt` (см. **Пакеты Python**). Зависимости — пакеты Python — записаны в файле `requirements/requirements.txt` (см. **Пакеты Python**).
#### Пакеты Python ##### Пакеты Python
Установка/обновление пакетов Python в активное окружение из файла `requirements.txt`: Установка/обновление пакетов Python в активное окружение из файла `requirements/requirements.txt`:
```sh ```sh
pip install -U -r requirements.txt pip install -U -r requirements/requirements.txt
``` ```
## Разведочный анализ данных (EDA) ### Разведочный анализ данных (EDA)
См. `eda/README.md`. См. `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 необходимы общие зависимости, см. [Общие зависимости](../README.md#общие-зависимости) в `README.md`.
Для EDA используется среда [Jupyter](https://jupyter.org/). Т.к. блокноты хранятся в текстовом формате под контролем версий, нужно также дополнение [Jupytext](https://jupytext.readthedocs.io/en/latest/) (как минимум для ручной конвертации блокнотов; см. ниже). Для EDA используется среда [Jupyter](https://jupyter.org/). См. об установке и использовании Jupyter в проекте в `docs/jupyter.md`.
Опционально можно использовать дополнение [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) (директория `eda/`) &mdash; пакеты Python &mdash; на данный момент включены в общие зависимости (см. выше). Дополнительные зависимости, необходимые для EDA, &mdash; пакеты Python &mdash; записаны в файле `requirements/requirements-eda.txt` (см. **Пакеты Python**). См. об установке пакетов Python в **Пакеты Python** в `README.md`.
## Работа с блокнотами Jupyter ## Работа с блокнотами Jupyter
### Jupyter См. об установке и использовании Jupyter в проекте в `docs/jupyter.md`.
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 в виртуальном окружении.

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']

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,10 +1,7 @@
bokeh >=3.7.2,<4
ipykernel >=6.30.1,<7 ipykernel >=6.30.1,<7
ipympl ~=0.9.6 ipympl ~=0.9.6
matplotlib >=3.10.1,<4 matplotlib >=3.10.1,<4
mlflow>=2.16,<2.22
numpy >=2.2.6,<3 numpy >=2.2.6,<3
pandas >=2.3.1,<3 pandas >=2.3.1,<3
scipy >=1.15.3,<2 scipy >=1.15.3,<2
scikit-learn >=1.7.2,<2
seaborn ~=0.13.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 Обычный файл

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

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

@@ -8,42 +8,64 @@
# format_version: '1.3' # format_version: '1.3'
# jupytext_version: 1.17.3 # jupytext_version: 1.17.3
# kernelspec: # kernelspec:
# display_name: .venv # display_name: python3_venv
# language: python # language: python
# name: python3 # name: python3_venv
# --- # ---
# %% [markdown]
# # Исследование и настройка предсказательной модели для цен подержанных автомобилях
# %% [markdown]
# Блокнот использует файл аугментированных данных датасета о подержанных автомобилях, создаваемый блокнотом `eda/cars_eda.py`. См. ниже параметры блокнота для papermill.
# %%
#XXX: разделить блокнот штук на 5
# %% # %%
from typing import Optional from typing import Optional
# %%
# %% tags=["parameters"] # %% 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 data_aug_pickle_path: Optional[str] = None
# Полный путь к файлу (pickle) для сохранения очищенного датасета. Если не установлен, используется `data/<data_aug_pickle_relpath>`. # Полный путь к файлу (pickle) для сохранения очищенного датасета. Если не установлен, используется `data/<data_aug_pickle_relpath>`.
data_aug_pickle_relpath: str = 'cars.aug.pickle' data_aug_pickle_relpath: str = 'cars.aug.pickle'
# Путь к файлу (pickle) для сохранения очищенного датасета относительно директории данных `data`. Игнорируется, если установлен data_aug_pickle_path. # Путь к файлу (pickle) для сохранения очищенного датасета относительно директории данных `data`. Игнорируется, если установлен data_aug_pickle_path.
mlflow_tracking_server_uri: str = 'http://localhost:5000' #model_global_comment_path: Optional[str] = None
mlflow_registry_uri: Optional[str] = None ## Полный путь к текстовому файлу с произвольным комментарием для сохранения в MLFlow как артефакт вместе с моделью. Если не установлен, используется `research/<comment_relpath>`.
#model_comment_relpath: str = 'comment.txt'
## Путь к текстовому файлу с произвольным комментарием для сохранения в MLFlow как артефакт вместе с моделью относительно директории `research`. Игнорируется, если установлен comment_path.
mlflow_experiment_name: str = 'Current price predicion for used cars' mlflow_tracking_server_uri: str = 'http://localhost:5000'
mlflow_experiment_new: bool = False # URL tracking-сервера MLFlow.
mlflow_run_name: str = 'Baseline model' 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 os
import pathlib import pathlib
import pickle import pickle
import sys
# %% # %%
import matplotlib
import mlflow import mlflow
import mlflow.models import mlflow.models
import mlflow.sklearn import mlflow.sklearn
import mlxtend.feature_selection
import mlxtend.plotting
import optuna
import optuna.samplers
import sklearn.compose import sklearn.compose
import sklearn.ensemble import sklearn.ensemble
import sklearn.metrics import sklearn.metrics
@@ -54,31 +76,64 @@ import sklearn.preprocessing
# %% # %%
BASE_PATH = pathlib.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) mlflow.set_tracking_uri(mlflow_tracking_server_uri)
if mlflow_registry_uri is not None: if mlflow_registry_uri is not None:
mlflow.set_registry_uri(mlflow_registry_uri) 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 = ( DATA_PATH = (
pathlib.Path(os.path.dirname(data_path)) pathlib.Path(os.path.dirname(data_aug_pickle_path))
if data_path is not None if data_aug_pickle_path is not None
else (BASE_PATH / 'data') 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( with open(
( (
data_aug_pickle_path data_aug_pickle_path
if data_aug_pickle_path is not None if data_aug_pickle_path is not None
else (DATA_PATH / data_aug_pickle_relpath) else (DATA_PATH / data_aug_pickle_relpath)
), ),
'rb', 'rb',
) as input_file: ) as input_file:
df_orig = pickle.load(input_file) df_orig = pickle.load(input_file)
# %% # %% [markdown]
df_orig.head(0x10) # Обзор датасета:
# %% # %%
len(df_orig) len(df_orig)
@@ -86,6 +141,15 @@ len(df_orig)
# %% # %%
df_orig.info() df_orig.info()
# %%
df_orig.head(0x10)
# %% [markdown]
# ## Разделение датасета на выборки
# %% [markdown]
# Выделение признаков и целевых переменных:
# %% # %%
feature_columns = ( feature_columns = (
'selling_price', 'selling_price',
@@ -112,7 +176,7 @@ assert all(
for col in features_to_scale_to_standard_columns for col in features_to_scale_to_standard_columns
) )
features_to_encode_one_hot_columns = ( features_to_encode_wrt_target_columns = (
'fuel_type', 'fuel_type',
'selling_type', 'selling_type',
'transmission', 'transmission',
@@ -120,13 +184,16 @@ features_to_encode_one_hot_columns = (
) )
assert all( assert all(
(col in df_orig.select_dtypes(('category', 'object')).columns) (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_orig_features = df_orig[list(feature_columns)]
df_target = df_orig[list(target_columns)] df_target = df_orig[list(target_columns)]
# %% [markdown]
# Разделение на обучающую и тестовую выборки:
# %% # %%
DF_TEST_PORTION = 0.25 DF_TEST_PORTION = 0.25
@@ -137,94 +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))) 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( 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_one_hot',
'encode_categoricals_wrt_target', 'encode_categoricals_wrt_target',
#sklearn.preprocessing.OneHotEncoder(), #build_categorical_features_encoder_onehot(),
sklearn.preprocessing.TargetEncoder( build_categorical_features_encoder_target(random_state=0x2ED6),
target_type='continuous', smooth='auto', cv=3, shuffle=True, random_state=0x2ED6, features_to_encode_wrt_target_columns,
),
features_to_encode_one_hot_columns,
), ),
], ],
remainder='drop', remainder='drop',
) )
# %% # %%
regressor = sklearn.ensemble.RandomForestRegressor( regressor = build_regressor_baseline(random_state=0x016B)
10, criterion='squared_error', max_features='sqrt', random_state=0x016B, regressor
)
# %% [markdown]
# Составной пайплайн:
# %% # %%
pipeline = sklearn.pipeline.Pipeline([ pipeline = sklearn.pipeline.Pipeline([
('preprocess', preprocess_transformer), ('preprocess', preprocess_transformer),
('regress', regressor), ('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]) _ = pipeline.fit(df_orig_features_train, df_target_train.iloc[:, 0])
# %% [markdown]
# Оценка качества:
# %% # %%
target_test_predicted = pipeline.predict(df_orig_features_test) target_test_predicted = pipeline.predict(df_orig_features_test)
# %% # %% [markdown]
metrics = { # Метрики качества (MAPE, а также MSE, MAE):
'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 metrics
# %% # %%
MODEL_PIP_REQUIREMENTS_PATH = BASE_PATH / 'requirements.txt' mlflow_log_model(
MODEL_COMMENTS_FILE_PATH = BASE_PATH / 'comment.txt' 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]
# Пайплайн предобработки признаков:
# %% # %%
MODEL_INOUT_EXAMPLE_SIZE = 0x10 features_to_extend_as_polynomial = ('selling_price', 'driven_kms')
features_to_extend_as_spline = ('age',)
# %% # %%
model_inout_example = (df_orig_features.head(MODEL_INOUT_EXAMPLE_SIZE), df_target.head(MODEL_INOUT_EXAMPLE_SIZE)) 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',
)
# %% # %%
mlflow_model_signature = mlflow.models.infer_signature(model_input=model_inout_example[0], model_output=model_inout_example[1]) 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,
}
# %% # %%
mlflow_model_signature preprocess_transformer = build_preprocess_augmenting_transformer()
preprocess_transformer
# %% [markdown]
# Демонстрация предобработки данных:
# %% # %%
model_params = pipeline.get_params() 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 model_params
# %% # %%
if mlflow_experiment_new: _ = pipeline.fit(df_orig_features, df_target.iloc[:, 0])
experiment = mlflow.get_experiment(mlflow.create_experiment(mlflow_experiment_name))
else: # %%
experiment = mlflow.set_experiment(experiment_name=mlflow_experiment_name) 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']),),
)
# %% # %%
with mlflow.start_run(experiment_id=experiment.experiment_id, run_name=mlflow_run_name):
_ = mlflow.sklearn.log_model(
pipeline,
'model',
signature=mlflow_model_signature,
input_example=model_inout_example[0],
pip_requirements=str(MODEL_PIP_REQUIREMENTS_PATH),
)
_ = mlflow.log_params(model_params)
_ = mlflow.log_metrics({k: float(v) for k, v in metrics.items()})
if MODEL_COMMENTS_FILE_PATH.exists():
mlflow.log_artifact(str(MODEL_COMMENTS_FILE_PATH))

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

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

@@ -1,13 +1,16 @@
# Must be a relative path to be used in an option for mlflow server. #!/bin/sh
BACKEND_STORE_DB_PATH="./mlflow/mlruns.sqlite"
DEFAULT_ARTIFACTS_ROOT="./mlflow/"
if [ ! -e "$BACKEND_STORE_DB_PATH" ]; then set -eu
printf '%s\n' "Error: '$BACKEND_STORE_DB_PATH' does not exist." >&2
exit 1
fi
mlflow server \ . ./_mlflow_config_common.sh
--backend-store-uri="sqlite:///$BACKEND_STORE_DB_PATH" \
--default-artifacts-root="$DEFAULT_ARTIFACTS_ROOT" \ DEFAULT_ARTIFACT_ROOT="./mlflow/mlartifacts/"
-p 5000
: "${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"

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:1 services/ml_service/
# docker run -v "$(pwd)/services/models:/models" -p 8000:8000 ml_service:1

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

@@ -0,0 +1,71 @@
# Сервис предсказания цен
Веб-сервис предсказания цен на подержанные автомобили; только stateless API. Об используемой предсказательной модели см. `research/README.md`.
## API
**Базовый URL**: `/api`. Все указанные далее URL записаны **относительно базового URL**, если не указано иное.
* Полная интерактивная документация (Swagger UI): `/docs`.
* Предсказать цену подержанного автомобиля: `/predict`.
Пример запроса:
* 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
}
```
* Тестовый эндпоинт: `/`
Возвращает простой демонстрационный объект JSON.
Может использоваться для проверки состояния сервиса.
## Развёртывание
### Файл модели
Файл используемой предсказательной модели `model.pkl` можно извлечь из MLFlow скриптом `services/models/fetch_model_as_pickle_from_mlflow.py`. Файл модели можно разместить в директории проекта, а именно в `services/models/`.
Например, извлечь модель по имени (`<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
Сборка образа (замените `<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>
Модель может быть размещена в директории проекта; тогда, например, при запуске команды из корна проекта: `$(pwd)/services/models` (здесь `$(pwd)` используется потому, что необходим абсолютный путь).

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

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

@@ -0,0 +1,64 @@
from os import getenv
from pathlib import Path
from fastapi import FastAPI
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,
)
@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}

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

@@ -0,0 +1,81 @@
from dataclasses import dataclass
from enum import Enum
from pandas import DataFrame
from pickle import load
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
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,
}])
predictions = self._model.predict(features_df)
assert len(predictions) == 1
return float(predictions[0])

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

@@ -0,0 +1,5 @@
fastapi ~=0.120.4
mlxtend ~=0.23.4
pandas >=2.3.1,<3
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))