Сравнить коммиты
57 Коммитов
dc68608a1e
...
master
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
99d81722c4
|
|||
|
6fe4557041
|
|||
|
667b6ac966
|
|||
|
434f4776b1
|
|||
|
296471990a
|
|||
|
e232b693ca
|
|||
|
9a6b531951
|
|||
|
aade830c20
|
|||
|
9780f6e710
|
|||
|
fd9a932e6c
|
|||
|
91e7437e29
|
|||
|
b880d2d699
|
|||
|
813f622579
|
|||
|
e43c47dae6
|
|||
|
322e02aa0b
|
|||
|
cddf0e0b65
|
|||
|
d417fa1d5d
|
|||
|
8d7d2d5d7a
|
|||
|
00f85d5d3c
|
|||
|
cbed2cf894
|
|||
|
7bb2455d4c
|
|||
|
7a3ba02966
|
|||
|
970e46c9f0
|
|||
|
00e0ce4b78
|
|||
|
25444818cc
|
|||
|
4665a66473
|
|||
|
59897fbe61
|
|||
|
bb1796e081
|
|||
|
d39d8f98d6
|
|||
|
a7a0780f1a
|
|||
|
c16caf2e6a
|
|||
|
22ef4d303c
|
|||
|
543a4c6571
|
|||
|
ce02a6966b
|
|||
|
8c3e3c1588
|
|||
|
2831ff4e81
|
|||
|
6038a1c566
|
|||
|
070688dc68
|
|||
|
2b2241b2ab
|
|||
|
3ee22c3f0e
|
|||
|
af9340eda2
|
|||
|
a3e8ebc030
|
|||
|
2f1b884d4b
|
|||
|
b66aed2636
|
|||
|
462ab85b18
|
|||
|
41497aa039
|
|||
|
f6714c0918
|
|||
|
0d66f73f7f
|
|||
|
105e06f7b4
|
|||
|
addc173d75
|
|||
|
ca3d34795d
|
|||
|
e01bc467b0
|
|||
|
8bbd3c8da5
|
|||
|
1183fbd225
|
|||
|
1739d8fb8b
|
|||
|
9cbd83edca
|
|||
|
a4c2be2d02
|
10
.editorconfig
Обычный файл
@@ -0,0 +1,10 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.py]
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
11
.gitignore
поставляемый
@@ -1,9 +1,14 @@
|
|||||||
# Python
|
### Python
|
||||||
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
|
### Jupyter
|
||||||
|
.ipynb_checkpoints/
|
||||||
|
*.ipynb
|
||||||
|
|
||||||
### Project
|
### Project
|
||||||
# virtual environments
|
# virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
.venv*/
|
.venv*/
|
||||||
# data
|
# .env files
|
||||||
data/
|
*.env
|
||||||
|
|||||||
79
README.md
@@ -1,3 +1,82 @@
|
|||||||
# Лабораторный проект по курсу "Интеллектуальные информационные системы"
|
# Лабораторный проект по курсу "Интеллектуальные информационные системы"
|
||||||
|
|
||||||
**Выполняет**: **Сыропятов В.В.** (А-01м-24)
|
**Выполняет**: **Сыропятов В.В.** (А-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`.
|
||||||
|
|
||||||
|
## Исследовательская часть проекта
|
||||||
|
|
||||||
|
### Установка
|
||||||
|
|
||||||
|
#### Общий порядок
|
||||||
|
|
||||||
|
**Внимание**: Здесь описан только общий порядок установки. Определённые части проекта могут требовать установки по отдельным инструкциям.
|
||||||
|
|
||||||
|
1. Проект разработан для Python 3.10–3.12. Установите совместимую версию Python ([Download Python](https://www.python.org/downloads/)).
|
||||||
|
|
||||||
|
2. Скопируйте/склонируйте репозиторий в выделенную директорию и перейдите в неё.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tar -xzf iis-project.tar.xz
|
||||||
|
cd iis-project
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Создайте виртуальное окружение Python.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m venv .venv
|
||||||
|
```
|
||||||
|
|
||||||
|
Активируйте созданное виртуальное окружение Python.
|
||||||
|
|
||||||
|
* **Linux**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
* **Windows**:
|
||||||
|
|
||||||
|
```ps
|
||||||
|
.\.venv\Scripts\activate
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Установите зависимости для необходимых частей проекта. См. **Зависимости**.
|
||||||
|
|
||||||
|
5. **При необходимости** скачайте данные. Каноническое расположение для данных проекта: `data/`.
|
||||||
|
|
||||||
|
#### Зависимости
|
||||||
|
|
||||||
|
##### Общие зависимости
|
||||||
|
|
||||||
|
Зависимости — пакеты Python — записаны в файле `requirements/requirements.txt` (см. **Пакеты Python**).
|
||||||
|
|
||||||
|
##### Пакеты Python
|
||||||
|
|
||||||
|
Установка/обновление пакетов Python в активное окружение из файла `requirements/requirements.txt`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install -U -r requirements/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Разведочный анализ данных (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"
|
||||||
2
data/.gitignore
поставляемый
Обычный файл
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
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>` — см. п. 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 зависимости — пакеты Python — на данный момент включены в общие зависимости (см. выше), дополнительных действий не требуется.*
|
||||||
|
|
||||||
|
## Работа с блокнотами 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 в виртуальном окружении.
|
||||||
5
eda/.editorconfig
Обычный файл
@@ -0,0 +1,5 @@
|
|||||||
|
[*.py]
|
||||||
|
insert_final_newline = unset
|
||||||
|
trim_trailing_whitespace = unset
|
||||||
|
indent_size = unset
|
||||||
|
indent_style = unset
|
||||||
43
eda/README.md
Обычный файл
@@ -0,0 +1,43 @@
|
|||||||
|
# Разведочный анализ данных (EDA)
|
||||||
|
|
||||||
|
## Блокноты Jupyter
|
||||||
|
|
||||||
|
* `cars_eda` — Очистка и первичный анализ данных о подержанных автомобилях.
|
||||||
|
|
||||||
|
Использует CSV-файл сырых данных из [датасета](https://www.kaggle.com/datasets/vijayaadithyanvg/car-price-predictionused-cars/data). Каноническое расположение файла данных: `data/cars.csv`.
|
||||||
|
|
||||||
|
Создаёт файлы очищенных данных (по умолчанию — CSV) и аугментированных данных (по умолчанию — pickle). Канонические расположения: соответственно `data/cars.clean.<ext>` и `data/cars.aug.<ext>`, с заменой `<ext>` на расширение в зависимости от формата: `csv` для CSV, `pickle` для pickle.
|
||||||
|
|
||||||
|
**Выводы по исследованию**:
|
||||||
|
|
||||||
|
* Выполнена очистка датасета: удалены несколько аномальных объектов, переименованы некоторые ошибочно названные признаки. (Пропущенных значений в датасете нет.)
|
||||||
|
|
||||||
|
* Датасет дополнен (аугментирован) потенциально полезными синтетическими признаками: отношение цены с пробегом к изначальной цене, возраст (предполагаемый на основе года выпуска автомобиля и распределения этих годов выпуска в датасете), логарифмы количественных величин. Аугментированная версия сохраняется отдельно.
|
||||||
|
|
||||||
|
* Предварительно подтверждена возможность определения рыночной цены автомобиля с пробегом по использованным признакам, **в особенности** по следующим: исходная цена, возраст и пробег автомобиля, тип продающего лица (дилер или частное лицо), топливо (автомобили на дизельном топливе редко бывают дешёвыми).
|
||||||
|
|
||||||
|
* Цена продажи с пробегом сильно линейно коррелирует с изначальной ценой.
|
||||||
|
|
||||||
|
* Интересно, что возраст автомобиля является заметно лучшим предиктором снижения стоимости, чем пробег, при этом корреляция между возрастом и пробегом существенная, но не определяющая.
|
||||||
|
|
||||||
|
* Существует огромная разница в ценах у дилеров и частных лиц (у частных лиц дешевле в разы).
|
||||||
|
|
||||||
|
* Существует слабая, но заметная прямая корреляция между изначальной ценой автомобиля и пробегом к дате последующей продажи.
|
||||||
|
|
||||||
|
* Датасет не очень однороден (у него есть "тяжёлый центр"), и с малым количеством объектов это может создать проблемы с устойчивостью предсказания цен. Рекомендуется применение робастных методов ограниченной сложности; однако прямая линейная регрессия для предсказания цены проодажи может всё-таки оказаться не лучшим методом.
|
||||||
|
|
||||||
|
Графические артефакты исследования сохранены в директории `./cars_eda_figures/`.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Для EDA необходимы общие зависимости, см. [Общие зависимости](../README.md#общие-зависимости) в `README.md`.
|
||||||
|
|
||||||
|
Для EDA используется среда [Jupyter](https://jupyter.org/). См. об установке и использовании Jupyter в проекте в `docs/jupyter.md`.
|
||||||
|
|
||||||
|
### Зависимости
|
||||||
|
|
||||||
|
Дополнительные зависимости, необходимые для EDA, — пакеты Python — записаны в файле `requirements/requirements-eda.txt` (см. **Пакеты Python**). См. об установке пакетов Python в **Пакеты Python** в `README.md`.
|
||||||
|
|
||||||
|
## Работа с блокнотами Jupyter
|
||||||
|
|
||||||
|
См. об установке и использовании Jupyter в проекте в `docs/jupyter.md`.
|
||||||
717
eda/cars_eda.py
Обычный файл
@@ -0,0 +1,717 @@
|
|||||||
|
# ---
|
||||||
|
# jupyter:
|
||||||
|
# jupytext:
|
||||||
|
# formats: ipynb,py:percent
|
||||||
|
# text_representation:
|
||||||
|
# extension: .py
|
||||||
|
# format_name: percent
|
||||||
|
# format_version: '1.3'
|
||||||
|
# jupytext_version: 1.17.3
|
||||||
|
# kernelspec:
|
||||||
|
# display_name: python3_venv
|
||||||
|
# language: python
|
||||||
|
# name: python3_venv
|
||||||
|
# ---
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# # Очистка и первичный анализ данных о подержанных автомобилях
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# **ОСТОРОЖНО**: Исполнение этого блокнота может тихо (пере)записать (и по умолчанию (пере)записывает) файлы очищенных/аугментированных
|
||||||
|
# данных. См. ниже параметры блокнота для papermill.
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Блокнот использует файл сырых данных датасета о подержанных автомобилях ([источник](https://www.kaggle.com/datasets/vijayaadithyanvg/car-price-predictionused-cars)). Блокнот записывает очищенные и аугментированные данные. См. ниже параметры блокнота для papermill.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Параметры блокнота для papermill:
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# (Ячейка с параметрами блокнота для papermill требует осторожного обращения; её содержимое парсится как текст очень простым парсером.)
|
||||||
|
|
||||||
|
# %% tags=["parameters"]
|
||||||
|
data_path: Optional[str] = None
|
||||||
|
# Полный путь к файлу (CSV) с исходным датасетом. Если не установлен, ищется файл в `data/<data_relpath>`.
|
||||||
|
data_relpath: str = 'cars.csv'
|
||||||
|
# Путь к файлу (CSV) с исходным датасетом относительно директории данных `data`. Игнорируется, если установлен data_path.
|
||||||
|
|
||||||
|
data_clean_csv: bool = True
|
||||||
|
# Сохранить ли очищенный датасет как CSV.
|
||||||
|
data_clean_csv_path: Optional[str] = None
|
||||||
|
# Полный путь к файлу (CSV) для сохранения очищенного датасета. Если не установлен, используется `data/<data_clean_csv_relpath>`.
|
||||||
|
data_clean_csv_relpath: str = 'cars.clean.csv'
|
||||||
|
# Путь к файлу (CSV) для сохранения очищенного датасета относительно директории данных `data`. Игнорируется, если установлен data_clean_csv_path.
|
||||||
|
|
||||||
|
data_clean_pickle: bool = False
|
||||||
|
# Сохранить ли очищенный датасет как pandas.DataFrame через pickle.
|
||||||
|
data_clean_pickle_path: Optional[str] = None
|
||||||
|
# Полный путь к файлу (pickle) для сохранения очищенного датасета. Если не установлен, используется `data/<data_clean_pickle_relpath>`.
|
||||||
|
data_clean_pickle_relpath: str = 'cars.clean.pickle'
|
||||||
|
# Путь к файлу (pickle) для сохранения очищенного датасета относительно директории данных `data`. Игнорируется, если установлен data_clean_pickle_path.
|
||||||
|
|
||||||
|
data_aug_csv: bool = False
|
||||||
|
# Сохранить ли аугментированный датасет как CSV.
|
||||||
|
data_aug_csv_path: Optional[str] = None
|
||||||
|
# Полный путь к файлу (CSV) для сохранения аугментированного датасета. Если не установлен, используется `data/<data_aug_csv_relpath>`.
|
||||||
|
data_aug_csv_relpath: str = 'cars.aug.csv'
|
||||||
|
# Путь к файлу (CSV) для сохранения аугментированного датасета относительно директории данных `data`. Игнорируется, если установлен data_aug_csv_path.
|
||||||
|
|
||||||
|
data_aug_pickle: bool = True
|
||||||
|
# Сохранить ли очищенный датасет как pandas.DataFrame через pickle.
|
||||||
|
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.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
# #%matplotlib ipympl
|
||||||
|
|
||||||
|
# %%
|
||||||
|
import os.path
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# %%
|
||||||
|
import bokeh.io
|
||||||
|
import bokeh.models
|
||||||
|
import bokeh.plotting
|
||||||
|
import bokeh.transform
|
||||||
|
import matplotlib.pyplot
|
||||||
|
import matplotlib.ticker
|
||||||
|
import numpy
|
||||||
|
import pandas
|
||||||
|
import seaborn
|
||||||
|
|
||||||
|
# %%
|
||||||
|
BASE_PATH = pathlib.Path('..')
|
||||||
|
|
||||||
|
# %%
|
||||||
|
CODE_PATH = BASE_PATH
|
||||||
|
sys.path.insert(0, str(CODE_PATH.resolve()))
|
||||||
|
|
||||||
|
# %%
|
||||||
|
import iis_project.plotting_utils
|
||||||
|
|
||||||
|
# %%
|
||||||
|
bokeh.io.output_notebook()
|
||||||
|
|
||||||
|
# %%
|
||||||
|
DATA_PATH = (
|
||||||
|
pathlib.Path(os.path.dirname(data_path))
|
||||||
|
if data_path is not None
|
||||||
|
else (BASE_PATH / 'data')
|
||||||
|
)
|
||||||
|
|
||||||
|
# %% [markdown] jp-MarkdownHeadingCollapsed=true
|
||||||
|
# ## Загрузка и обзор данных
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_orig = pandas.read_csv(data_path if data_path is not None else (DATA_PATH / data_relpath))
|
||||||
|
|
||||||
|
df_orig = df_orig.rename(columns=lambda s: re.sub(r'\s', '_', s.lower().replace(' ', '_')))
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Обзор строк сырого датасета:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_orig.head(0x10)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Размер сырого датасета:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
len(df_orig)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Количество непустых значений и тип каждого столбца:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_orig.info()
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# ## Первичный поиск аномалий и очистка данных
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Подтверждение, что в датасете нет пустых значений:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
all((len(s) == len(df_orig)) for _, s in df_orig.items())
|
||||||
|
|
||||||
|
# %%
|
||||||
|
quantitative_columns_orig = ['selling_price', 'present_price', 'driven_kms', 'year']
|
||||||
|
categorical_columns_orig = ['car_name', 'fuel_type', 'selling_type', 'transmission', 'owner']
|
||||||
|
|
||||||
|
# %%
|
||||||
|
for column in filter(lambda s: s not in ('car_name',), categorical_columns_orig):
|
||||||
|
# XXX: по идее, переименования категорий стоило бы делать после преобразования к типу категории,
|
||||||
|
# Series.cat.rename_categories. Однако... оно просто не работает.
|
||||||
|
if pandas.api.types.is_object_dtype(df_orig[column].dtype):
|
||||||
|
df_orig[column] = df_orig[column].map(
|
||||||
|
lambda s: ' '.join(map(lambda s2: s2.lower(), s.split()))
|
||||||
|
)
|
||||||
|
df_orig[column] = df_orig[column].astype('category')
|
||||||
|
|
||||||
|
|
||||||
|
# %%
|
||||||
|
def normalize_car_name(s):
|
||||||
|
return ' '.join(map(lambda s: s.lower(), s.split()))
|
||||||
|
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Нормализация текстовых названий моделей автомобилей:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_orig['car_name'] = df_orig['car_name'].apply(normalize_car_name)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Первичные статистики по количественным признакам:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_orig[list(quantitative_columns_orig)].describe()
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Категориальные признаки:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
categorical_values_for_columns_orig = {
|
||||||
|
column: series.unique()
|
||||||
|
for column, series in df_orig[list(categorical_columns_orig)].items()
|
||||||
|
}
|
||||||
|
|
||||||
|
for column, values in categorical_values_for_columns_orig.items():
|
||||||
|
if len(values) <= 0x10:
|
||||||
|
values_str = ', '.join(map(repr, values))
|
||||||
|
else:
|
||||||
|
values_str = f'({len(values)} values)'
|
||||||
|
print(f'{column!r}: {values_str}')
|
||||||
|
|
||||||
|
# %% [markdown] raw_mimetype=""
|
||||||
|
# Просмотр распределений по отдельным признакам на предмет аномалий:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_fig, _axis = matplotlib.pyplot.subplots(2, 1, squeeze=True)
|
||||||
|
for i, (column, series) in enumerate(df_orig[['selling_price', 'present_price']].items()):
|
||||||
|
_ax = _axis[i]
|
||||||
|
_ax.set_title(str(column))
|
||||||
|
#_ax.set_xscale('symlog')
|
||||||
|
_ax.set_yscale('log')
|
||||||
|
_ax.grid(True)
|
||||||
|
_ = _ax.hist(series, bins=iis_project.plotting_utils.suggest_bins_num(len(series)))
|
||||||
|
_fig.tight_layout()
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Есть 1 объект, вероятно, аномальный по `present_price`, но это неоднозначно.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
for column, series in df_orig[['driven_kms']].items():
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_ax.set_title(str(column))
|
||||||
|
#_ax.set_xscale('symlog')
|
||||||
|
_ax.set_yscale('log')
|
||||||
|
_ax.grid(True)
|
||||||
|
_ = _ax.hist(series, bins=iis_project.plotting_utils.suggest_bins_num(len(series)))
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Есть 1 аномальный объект по `driven_kms` (аномально большой пробег).
|
||||||
|
|
||||||
|
# %%
|
||||||
|
for column, series in df_orig[['year']].items():
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_ax.set_title(str(column))
|
||||||
|
_ax.set_yscale('log')
|
||||||
|
_ax.grid(True)
|
||||||
|
_ = _ax.hist(series, bins=iis_project.plotting_utils.suggest_bins_num(len(series)))
|
||||||
|
|
||||||
|
# %%
|
||||||
|
for column, series in df_orig[
|
||||||
|
list(filter(lambda s: s not in ('car_name',), categorical_columns_orig))
|
||||||
|
].items():
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_ax.set_title(str(column))
|
||||||
|
_ax.set_yscale('log')
|
||||||
|
_ax.grid(True)
|
||||||
|
value_counts = series.value_counts()
|
||||||
|
_ = _ax.bar(tuple(map(str, value_counts.index)), value_counts)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Есть 2 исключительных объекта по топливу `fuel_type` (автомобиль на природном газе
|
||||||
|
# (CNG)) — и 1 исключительный объект по типу владельца `owner` (3).
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Внимательное рассмотрение аномальных объектов:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
labels_to_drop_from_orig = []
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_orig.loc[df_orig['owner'].isin((3,))]
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Объект, исключительный по `owner` (3), исключается из датасета, не в последнюю очередь
|
||||||
|
# из-за неясности смысла значений `owner`.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
labels_to_drop_from_orig.extend(df_orig.loc[df_orig['owner'].isin((3,))].index)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_orig.loc[(df_orig['present_price'] >= 60.) | (df_orig['driven_kms'] >= 400000) | (df_orig['fuel_type'].isin(('CNG',)))]
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Аномально большой пробег у автомобиля #196 ещё и является очень круглым числом. Этот объект
|
||||||
|
# исключается из датасета как вероятно недостоверный.
|
||||||
|
#
|
||||||
|
# 2 автомобиля на природном газе решено пока не исключать, т.к. с учётом и так малого размера
|
||||||
|
# датасета возможность хоть как-то предсказывать цены для автомобилей на природном газе сочтена
|
||||||
|
# сравнительно ценной.
|
||||||
|
#
|
||||||
|
# Автомобиль с исключительно высокой `present_price` не показывает других аномалий (у него и `selling_price` высокая в датасете), поэтому он оставлен.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
labels_to_drop_from_orig.extend((196,))
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_clean = df_orig.drop(labels_to_drop_from_orig)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
#bokeh_source_df_clean = bokeh.models.ColumnDataSource(df_clean)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# По названиям и распределениям признаков `selling_price` и `present_price` есть большое подозрение,
|
||||||
|
# что это изначальная цена и цена продажи с пробегом, но перепутанные местами.
|
||||||
|
|
||||||
|
# %% [raw] raw_mimetype=""
|
||||||
|
# present_price_ratio = df_clean['selling_price'] / df_clean['present_price']
|
||||||
|
#
|
||||||
|
# _fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
# _ = _ax.set_xlabel('driven_kms')
|
||||||
|
# _ = _ax.set_ylabel('selling_price / present_price')
|
||||||
|
# _ax.set_xscale('log')
|
||||||
|
# _ax.grid(True)
|
||||||
|
# _ = _ax.scatter(df_clean['driven_kms'], present_price_ratio, alpha=0.5)
|
||||||
|
# _ = _ax.set_ylim((0., None))
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_src = bokeh.models.ColumnDataSource({
|
||||||
|
**dict(df_clean[['car_name', 'driven_kms', 'year', 'selling_price', 'present_price']].items()),
|
||||||
|
'present_price_ratio': (df_clean['selling_price'] / df_clean['present_price']),
|
||||||
|
})
|
||||||
|
_fig = bokeh.plotting.figure(
|
||||||
|
x_axis_type='log',
|
||||||
|
x_axis_label='driven_kms', y_axis_label='selling_price / present_price',
|
||||||
|
)
|
||||||
|
_ = _fig.scatter(
|
||||||
|
'driven_kms', 'present_price_ratio',
|
||||||
|
source=_src,
|
||||||
|
size=12., alpha=0.5,
|
||||||
|
)
|
||||||
|
_fig.y_range.start = 0
|
||||||
|
_fig.add_tools(
|
||||||
|
bokeh.models.HoverTool(
|
||||||
|
tooltips=[
|
||||||
|
('present price', '@present_price'),
|
||||||
|
('selling price ', '@selling_price'),
|
||||||
|
('name', '@car_name'),
|
||||||
|
('year', '@year',),
|
||||||
|
('index', '$index',)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bokeh.plotting.show(_fig)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Указанное выше подозрение подтверждается, `selling_price` и `present_price` нужно поменять местами.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_clean[['selling_price', 'present_price']] = df_clean[['present_price', 'selling_price']]
|
||||||
|
|
||||||
|
# %% [markdown] jp-MarkdownHeadingCollapsed=true
|
||||||
|
# ## Сохранение очищенных данных
|
||||||
|
|
||||||
|
# %%
|
||||||
|
if data_clean_csv:
|
||||||
|
df_clean.to_csv(
|
||||||
|
(
|
||||||
|
data_clean_csv_path
|
||||||
|
if data_clean_csv_path is not None
|
||||||
|
else (DATA_PATH / data_clean_csv_relpath)
|
||||||
|
),
|
||||||
|
index=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
if data_clean_pickle:
|
||||||
|
import pickle
|
||||||
|
with open(
|
||||||
|
(
|
||||||
|
data_clean_pickle_path
|
||||||
|
if data_clean_pickle_path is not None
|
||||||
|
else (DATA_PATH / data_clean_pickle_relpath)
|
||||||
|
),
|
||||||
|
'wb',
|
||||||
|
) as out_file:
|
||||||
|
pickle.dump(df_clean, out_file)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df = df_clean.copy(deep=False)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# TODO: Разделить блокнот на два — очистка данных и анализ.
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# ## Анализ и аугментация данных
|
||||||
|
|
||||||
|
# %%
|
||||||
|
quantitative_columns = ['selling_price', 'present_price', 'driven_kms', 'year']
|
||||||
|
categorical_columns = ['car_name', 'fuel_type', 'selling_type', 'transmission', 'owner']
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Количество объектов:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
len(df)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Количество непустых значений и тип каждого столбца:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df.info()
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Добавим синтетический признак возраста. Для отсчёта предполагается актуальность датасета на 2019 год;
|
||||||
|
# учитывая, что год в датасете задан целым числом, это даёт возраст минимум 1 год для каждого объекта.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df['age'] = ((max(df['year']) + 1) - df['year']).astype(float)
|
||||||
|
quantitative_columns.append('age')
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Вообще говоря, отношение `present_price / selling_price` может быть полезно при изучении данных
|
||||||
|
# или обучении моделей. Добавим такой признак `present_price_ratio`:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df['present_price_ratio'] = df['present_price'] / df['selling_price']
|
||||||
|
quantitative_columns.append('present_price_ratio')
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Добавим некоторые потенциально полезные синтетические признаки - логарифмы некоторых количественных признаков:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
for column in ('selling_price', 'present_price', 'driven_kms'):
|
||||||
|
df[f'log_{column}'] = numpy.log10(df[column])
|
||||||
|
|
||||||
|
df['log_age'] = numpy.log10(numpy.maximum(1., df['age']))
|
||||||
|
|
||||||
|
# %%
|
||||||
|
bokeh_source_df = bokeh.models.ColumnDataSource(df)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Первичные статистики по количественным признакам:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df[list(quantitative_columns)].describe()
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Категориальные признаки:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
categorical_values_for_columns = {
|
||||||
|
column: series.unique()
|
||||||
|
for column, series in df[list(categorical_columns)].items()
|
||||||
|
}
|
||||||
|
|
||||||
|
for column, values in categorical_values_for_columns.items():
|
||||||
|
if len(values) <= 0x10:
|
||||||
|
values_str = ', '.join(map(repr, values))
|
||||||
|
else:
|
||||||
|
values_str = f'({len(values)} values)'
|
||||||
|
print(f'{column!r}: {values_str}')
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Матрица корреляций количественных признаков:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_corr = df[[
|
||||||
|
'present_price', 'log_present_price',
|
||||||
|
'selling_price', 'log_selling_price',
|
||||||
|
'present_price_ratio',
|
||||||
|
'driven_kms', 'log_driven_kms',
|
||||||
|
'age', 'log_age',
|
||||||
|
]].corr('pearson', min_periods=2)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Из неочевидных выводов по матрице корреляций, существует слабая, но заметная прямая корреляция
|
||||||
|
# между изначальной ценой автомобиля и пробегом к дате последующей продажи.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_im = _ax.imshow(df_corr.to_numpy(), cmap='RdBu', vmin=-1., vmax=+1., origin='upper')
|
||||||
|
_ax.tick_params(top=True, labeltop=True, bottom=False, labelbottom=False)
|
||||||
|
_ = _ax.set_xticks(range(len(df_corr.columns)), labels=df_corr.columns, rotation=90.)
|
||||||
|
_ = _ax.set_yticks(range(len(df_corr.index)), labels=df_corr.index)
|
||||||
|
if len(df_corr.columns) <= 10:
|
||||||
|
for i in range(len(df_corr.index)):
|
||||||
|
for j in range(len(df_corr.columns)):
|
||||||
|
if i == j:
|
||||||
|
continue
|
||||||
|
val = df_corr.iloc[(i, j)]
|
||||||
|
_ = _ax.text(
|
||||||
|
j, i, '{:+.2f}'.format(val),
|
||||||
|
ha='center', va='center',
|
||||||
|
color=('white' if abs(val) >= 0.5 else 'black'),
|
||||||
|
fontsize='small',
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_ = _fig.colorbar(_im)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Совместное распределение изначальной цены продажи и цены с пробегом:
|
||||||
|
|
||||||
|
# %% [raw] raw_mimetype=""
|
||||||
|
# _fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
# _ = _ax.set_xlabel('selling_price')
|
||||||
|
# _ = _ax.set_ylabel('present_price')
|
||||||
|
# _ax.grid(True)
|
||||||
|
# _ = seaborn.histplot(
|
||||||
|
# x=df['selling_price'], y=df['present_price'],
|
||||||
|
# bins=tuple(
|
||||||
|
# numpy.geomspace(
|
||||||
|
# min(series), max(series),
|
||||||
|
# (iis_project.plotting_utils.suggest_bins_num(len(series)) + 1),
|
||||||
|
# )
|
||||||
|
# for _, series in df[['selling_price', 'present_price']].items()
|
||||||
|
# ),
|
||||||
|
# #thresh=None, palette='Blues',
|
||||||
|
# cbar=True,
|
||||||
|
# #hue_norm=matplotlib.colors.LogNorm(0, 64),
|
||||||
|
# ax=_ax,
|
||||||
|
# )
|
||||||
|
# _ax.set_xscale('log')
|
||||||
|
# _ax.set_yscale('log')
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_ = _ax.set_xlabel('selling_price')
|
||||||
|
_ = _ax.set_ylabel('present_price')
|
||||||
|
_ax.set_xscale('log')
|
||||||
|
_ax.set_yscale('log')
|
||||||
|
_ax.grid(True)
|
||||||
|
_scatter = _ax.scatter(
|
||||||
|
df['selling_price'], df['present_price'], c=df['selling_type'].cat.codes,
|
||||||
|
alpha=0.5,
|
||||||
|
)
|
||||||
|
_ = _ax.legend(
|
||||||
|
*_scatter.legend_elements(
|
||||||
|
fmt=matplotlib.ticker.FuncFormatter(lambda i, _: df['selling_type'].cat.categories[int(i)]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Существует очень сильное разделение по ценам при продаже подержанных автомобилей дилерами
|
||||||
|
# и частными лицами; частные лица обычно продают автомобили намного дешевле, чем дилеры (хотя этот
|
||||||
|
# вывод не учитывает возможное влияние других переменных).
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Совместные распределения отношения цены с пробегом к изначальной, пробега, года выпуска:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_ = _ax.set_xlabel('driven_kms')
|
||||||
|
_ = _ax.set_ylabel('present_price_ratio')
|
||||||
|
_ax.grid(True)
|
||||||
|
_ = seaborn.histplot(
|
||||||
|
x=df['driven_kms'], y=df['present_price_ratio'],
|
||||||
|
bins=(
|
||||||
|
numpy.geomspace(
|
||||||
|
min(df['driven_kms']), max(df['driven_kms']),
|
||||||
|
(iis_project.plotting_utils.suggest_bins_num(len(df['driven_kms'])) + 1),
|
||||||
|
),
|
||||||
|
iis_project.plotting_utils.suggest_bins_num(len(df['present_price_ratio'])),
|
||||||
|
),
|
||||||
|
#thresh=None, palette='Blues',
|
||||||
|
cbar=True,
|
||||||
|
#hue_norm=matplotlib.colors.LogNorm(0, 64),
|
||||||
|
ax=_ax,
|
||||||
|
)
|
||||||
|
_ax.set_xscale('log')
|
||||||
|
_ = _ax.set_ylim((0., None))
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_ = _ax.set_xlabel('age')
|
||||||
|
_ = _ax.set_ylabel('present_price / selling_price')
|
||||||
|
_ax.grid(True)
|
||||||
|
_ = seaborn.histplot(
|
||||||
|
x=df['age'], y=df['present_price_ratio'],
|
||||||
|
bins=(
|
||||||
|
iis_project.plotting_utils.suggest_bins_num(len(df['age'])),
|
||||||
|
iis_project.plotting_utils.suggest_bins_num(len(df['present_price_ratio'])),
|
||||||
|
),
|
||||||
|
#thresh=None, palette='Blues',
|
||||||
|
cbar=True,
|
||||||
|
#hue_norm=matplotlib.colors.LogNorm(0, 64),
|
||||||
|
ax=_ax,
|
||||||
|
)
|
||||||
|
#_ax.set_xscale('log')
|
||||||
|
_ = _ax.set_ylim((0., None))
|
||||||
|
|
||||||
|
# %% [raw] raw_mimetype=""
|
||||||
|
# _fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
# _ = _ax.set_xlabel('age')
|
||||||
|
# _ = _ax.set_ylabel('driven_kms')
|
||||||
|
# _ax.grid(True)
|
||||||
|
# _ = seaborn.histplot(
|
||||||
|
# x=df['age'], y=df['driven_kms'],
|
||||||
|
# bins=(
|
||||||
|
# iis_project.plotting_utils.suggest_bins_num(len(df['age'])),
|
||||||
|
# numpy.geomspace(
|
||||||
|
# min(df['driven_kms']), max(df['driven_kms']),
|
||||||
|
# (iis_project.plotting_utils.suggest_bins_num(len(df['driven_kms'])) + 1),
|
||||||
|
# ),
|
||||||
|
# ),
|
||||||
|
# #thresh=None, palette='Blues',
|
||||||
|
# cbar=True,
|
||||||
|
# #hue_norm=matplotlib.colors.LogNorm(0, 64),
|
||||||
|
# ax=_ax,
|
||||||
|
# )
|
||||||
|
# #_ax.set_xscale('log')
|
||||||
|
# _ax.set_yscale('log')
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_fig = bokeh.plotting.figure(
|
||||||
|
x_axis_type='linear', y_axis_type='log',
|
||||||
|
x_axis_label='age', y_axis_label='driven_kms',
|
||||||
|
)
|
||||||
|
_mapper = bokeh.models.LinearColorMapper(
|
||||||
|
palette='Viridis256',
|
||||||
|
low=min(0., df['present_price_ratio'].min()), high=max(1., df['present_price_ratio'].max()),
|
||||||
|
)
|
||||||
|
_ = _fig.scatter(
|
||||||
|
'age', 'driven_kms',
|
||||||
|
color=bokeh.transform.transform('present_price_ratio', _mapper),
|
||||||
|
source=bokeh_source_df,
|
||||||
|
size=12., alpha=0.75,
|
||||||
|
)
|
||||||
|
_fig.add_tools(
|
||||||
|
bokeh.models.HoverTool(
|
||||||
|
tooltips=[
|
||||||
|
('present price', '@present_price'),
|
||||||
|
('selling price ', '@selling_price'),
|
||||||
|
('year', '@year',),
|
||||||
|
('index', '$index',),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
_fig.add_layout(bokeh.models.ColorBar(color_mapper=_mapper), 'right')
|
||||||
|
bokeh.plotting.show(_fig)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# В частности, существует сильная связь между и возрастом автомобиля и отношением цены с пробегом
|
||||||
|
# к изначальной. Довольно много объектов, но далеко не все, сосредоточены около одной точки
|
||||||
|
# в пространстве данных.
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Распределения цен с пробегом по категориям:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_ = _ax.set_yscale('log')
|
||||||
|
_ax.grid(True)
|
||||||
|
_ = df.boxplot('present_price', by='fuel_type', ax=_ax, patch_artist=False)
|
||||||
|
#_ax.set_ylim((0, None))
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Автомобили на дизеле дороже автомобилей на бензине, а также реже продаются
|
||||||
|
# за крайне низкие цены.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_ = _ax.set_yscale('log')
|
||||||
|
_ax.grid(True)
|
||||||
|
_ = df.boxplot('present_price', by='selling_type', ax=_ax, patch_artist=False)
|
||||||
|
#_ax.set_ylim((0, None))
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Частные лица обычно продают автомобили намного дешевле, чем дилеры (хотя этот вывод не учитывает
|
||||||
|
# возможное влияние других переменных). Отмечены множество автомобилей, продаваемых дилерами
|
||||||
|
# по особо высоким ценам.
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Проверка возможной систематической разницы в пробеге между категориями:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_ = _ax.set_yscale('log')
|
||||||
|
_ax.grid(True)
|
||||||
|
_ = df.boxplot('driven_kms', by='fuel_type', ax=_ax, patch_artist=False)
|
||||||
|
#_ax.set_ylim((0, None))
|
||||||
|
|
||||||
|
# %%
|
||||||
|
_fig, _ax = matplotlib.pyplot.subplots()
|
||||||
|
_ = _ax.set_yscale('log')
|
||||||
|
_ax.grid(True)
|
||||||
|
_ = df.boxplot('driven_kms', by='selling_type', ax=_ax, patch_artist=False)
|
||||||
|
#_ax.set_ylim((0, None))
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Большой систематической разницы в пробеге между категориями нет.
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# ## Сохранение аугментированных данных
|
||||||
|
|
||||||
|
# %%
|
||||||
|
if data_aug_csv:
|
||||||
|
df.to_csv(
|
||||||
|
(data_aug_csv_path if data_aug_csv_path is not None else (DATA_PATH / data_aug_csv_relpath)),
|
||||||
|
index=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
if data_aug_pickle:
|
||||||
|
import pickle
|
||||||
|
with open(
|
||||||
|
(
|
||||||
|
data_aug_pickle_path
|
||||||
|
if data_aug_pickle_path is not None
|
||||||
|
else (DATA_PATH / data_aug_pickle_relpath)
|
||||||
|
),
|
||||||
|
'wb',
|
||||||
|
) as out_file:
|
||||||
|
pickle.dump(df, out_file)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# ## Выводы по исследованию
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Выполнена очистка датасета: удалены несколько аномальных объектов, переименованы некоторые
|
||||||
|
# ошибочно названные признаки. (Пропущенных значений в датасете нет.)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Датасет дополнен (аугментирован) потенциально полезными синтетическими признаками:
|
||||||
|
# отношение цены с пробегом к изначальной цене, возраст (предполагаемый на основе года выпуска
|
||||||
|
# автомобиля и распределения этих годов выпуска в датасете), логарифмы количественных величин.
|
||||||
|
# Аугментированная версия сохраняется отдельно.
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Предварительно подтверждена возможность определения рыночной цены автомобиля с пробегом
|
||||||
|
# по использованным признакам, **в особенности** по следующим: исходная цена, возраст и пробег
|
||||||
|
# автомобиля, тип продающего лица (дилер или частное лицо), топливо (автомобили на дизельном
|
||||||
|
# топливе редко бывают дешёвыми).
|
||||||
|
#
|
||||||
|
# * Цена продажи с пробегом сильно линейно коррелирует с изначальной ценой.
|
||||||
|
#
|
||||||
|
# * Интересно, что возраст автомобиля является заметно лучшим предиктором снижения стоимости,
|
||||||
|
# чем пробег, при этом корреляция между возрастом и пробегом существенная, но не определяющая.
|
||||||
|
#
|
||||||
|
# * Существует огромная разница в ценах у дилеров и частных лиц (у частных лиц дешевле в разы).
|
||||||
|
#
|
||||||
|
# * Существует слабая, но заметная прямая корреляция между изначальной ценой автомобиля и пробегом
|
||||||
|
# к дате последующей продажи.
|
||||||
|
#
|
||||||
|
# * Датасет не очень однороден (у него есть "тяжёлый центр"), и с малым количеством объектов
|
||||||
|
# это может создать проблемы с устойчивостью предсказания цен. Рекомендуется применение
|
||||||
|
# робастных методов ограниченной сложности; однако прямая линейная регрессия для предсказания
|
||||||
|
# цены проодажи может всё-таки оказаться не лучшим методом.
|
||||||
1
eda/cars_eda_figures/README.txt
Обычный файл
@@ -0,0 +1 @@
|
|||||||
|
Эта директория существует по указанию преподавателя.
|
||||||
Двоичные данные
eda/cars_eda_figures/boxplots_mileage_by_fuel_type.png
Обычный файл
|
После Ширина: | Высота: | Размер: 16 KiB |
Двоичные данные
eda/cars_eda_figures/boxplots_mileage_by_seller_type.png
Обычный файл
|
После Ширина: | Высота: | Размер: 16 KiB |
Двоичные данные
eda/cars_eda_figures/boxplots_present_price_by_fuel_type.png
Обычный файл
|
После Ширина: | Высота: | Размер: 15 KiB |
Двоичные данные
eda/cars_eda_figures/boxplots_present_price_by_seller_type.png
Обычный файл
|
После Ширина: | Высота: | Размер: 17 KiB |
Двоичные данные
eda/cars_eda_figures/joint_hist_age_and_present_price_ratio.png
Обычный файл
|
После Ширина: | Высота: | Размер: 16 KiB |
Двоичные данные
eda/cars_eda_figures/joint_hist_mileage_and_present_price_ratio.png
Обычный файл
|
После Ширина: | Высота: | Размер: 15 KiB |
Двоичные данные
eda/cars_eda_figures/quantitative_features_corr_matrix.png
Обычный файл
|
После Ширина: | Высота: | Размер: 63 KiB |
Двоичные данные
eda/cars_eda_figures/scatter_age_vs_mileage_colored_by_present_price_ratio.png
Обычный файл
|
После Ширина: | Высота: | Размер: 77 KiB |
Двоичные данные
eda/cars_eda_figures/scatter_mileage_vs_inv_present_price_ratio_speculative.png
Обычный файл
|
После Ширина: | Высота: | Размер: 99 KiB |
Двоичные данные
eda/cars_eda_figures/scatter_selling_vs_present_price_by_seller_type.png
Обычный файл
|
После Ширина: | Высота: | Размер: 45 KiB |
1314
eda/eda.ipynb
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
Обычный файл
3
iis_project/mlxtend_utils/feature_selection.py
Обычный файл
@@ -0,0 +1,3 @@
|
|||||||
|
SEQUENTIAL_FEATURE_SELECTOR_PARAMS_COMMON_INCLUDE = [
|
||||||
|
'k_features', 'forward', 'floating', 'scoring', 'cv', 'fixed_features', 'feature_groups',
|
||||||
|
]
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import pandas
|
|
||||||
|
|
||||||
from .common import zip_n
|
|
||||||
|
|
||||||
def describe_df(df: pandas.DataFrame) -> pandas.DataFrame:
|
|
||||||
df_items = df.items()
|
|
||||||
names, series = zip_n(*df_items, n=2)
|
|
||||||
return pandas.DataFrame(({'length': len(s), 'dtype': s.dtype} for s in series), index=names)
|
|
||||||
@@ -1,4 +1,27 @@
|
|||||||
from math import ceil, log
|
from math import ceil
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
def suggest_bins_num(n: int) -> int:
|
from scipy.stats import norm
|
||||||
return max(int(ceil(log(n))), 10)
|
|
||||||
|
# XXX: может, заменить вызов scipy.stats.norm на аппроксимацию?
|
||||||
|
|
||||||
|
SND_QUARTILE: float = (
|
||||||
|
#0.674490
|
||||||
|
norm.ppf(1 - 0.25)
|
||||||
|
)
|
||||||
|
|
||||||
|
def suggest_iqr_to_range_to_suggest_bins_num(n: int) -> float:
|
||||||
|
p = 1 / (n + 1)
|
||||||
|
return (SND_QUARTILE / norm.ppf(1 - p/2))
|
||||||
|
|
||||||
|
def suggest_bins_num(n: int, iqr_to_range: Optional[float] = None, scale: float = 1.) -> int:
|
||||||
|
if n <= 0:
|
||||||
|
raise ValueError(f'n should be >= 1, got {n}')
|
||||||
|
if (iqr_to_range is not None) and (iqr_to_range <= 0.):
|
||||||
|
raise ValueError(f'iqr_to_range should be > 0 or None, got {iqr_to_range}')
|
||||||
|
if scale <= 0.:
|
||||||
|
raise ValueError(f'scale should be > 0, got {scale}')
|
||||||
|
if iqr_to_range is None:
|
||||||
|
iqr_to_range = suggest_iqr_to_range_to_suggest_bins_num(n)
|
||||||
|
t = 0.5 * scale * (1. / iqr_to_range) * (n ** (1. / 3))
|
||||||
|
return int(ceil(t))
|
||||||
|
|||||||
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,4 +0,0 @@
|
|||||||
matplotlib ~=3.10
|
|
||||||
numpy ~=2.3
|
|
||||||
pandas ~=2.3
|
|
||||||
seaborn ~=0.13.2
|
|
||||||
1
requirements/requirements-eda.txt
Обычный файл
@@ -0,0 +1 @@
|
|||||||
|
bokeh >=3.7.2,<4
|
||||||
2
requirements/requirements-isolated-research-model.txt
Обычный файл
@@ -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
|
||||||
7
requirements/requirements.txt
Обычный файл
@@ -0,0 +1,7 @@
|
|||||||
|
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
|
||||||
|
seaborn ~=0.13.2
|
||||||
59
research/README.md
Обычный файл
@@ -0,0 +1,59 @@
|
|||||||
|
# Исследование и настройка предсказательной модели
|
||||||
|
|
||||||
|
## Блокноты Jupyter
|
||||||
|
|
||||||
|
* `research` — Создание множества разных моделей, с использованием разных создаваемых признаков и оптимизацией гиперпараметров.
|
||||||
|
|
||||||
|
Использует файл аугментированных данных датасета о подержанных автомобилях, создаваемый блокнотом `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) — точность неоднозначна по сравнению с baseline (MAPE = 0.31, MSE = 1.50);
|
||||||
|
3. с использованием добавленных и выбранных (SFS) признаков — точность существенно лучше baseline (MAPE = 0.20, MSE = 1.02);
|
||||||
|
4. с использованием добавленных и выбранных признаков и оптимизированными гиперпараметрами (optuna) — точность немного лучше модели 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`.
|
||||||
|
|
||||||
|
### Зависимости
|
||||||
|
|
||||||
|
Дополнительные зависимости, необходимые для исследования и настройки предсказательной модели, — пакеты Python — записаны в файле `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
Обычный файл
905
research/research.py
Обычный файл
@@ -0,0 +1,905 @@
|
|||||||
|
# ---
|
||||||
|
# jupyter:
|
||||||
|
# jupytext:
|
||||||
|
# formats: py:percent,ipynb
|
||||||
|
# text_representation:
|
||||||
|
# extension: .py
|
||||||
|
# format_name: percent
|
||||||
|
# format_version: '1.3'
|
||||||
|
# jupytext_version: 1.17.3
|
||||||
|
# kernelspec:
|
||||||
|
# display_name: python3_venv
|
||||||
|
# language: python
|
||||||
|
# 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
|
||||||
|
import sklearn.model_selection
|
||||||
|
import sklearn.pipeline
|
||||||
|
import sklearn.preprocessing
|
||||||
|
|
||||||
|
# %%
|
||||||
|
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_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(
|
||||||
|
(
|
||||||
|
data_aug_pickle_path
|
||||||
|
if data_aug_pickle_path is not None
|
||||||
|
else (DATA_PATH / data_aug_pickle_relpath)
|
||||||
|
),
|
||||||
|
'rb',
|
||||||
|
) as input_file:
|
||||||
|
df_orig = pickle.load(input_file)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Обзор датасета:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
len(df_orig)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_orig.info()
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_orig.head(0x10)
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# ## Разделение датасета на выборки
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Выделение признаков и целевых переменных:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
feature_columns = (
|
||||||
|
'selling_price',
|
||||||
|
'driven_kms',
|
||||||
|
'fuel_type',
|
||||||
|
'selling_type',
|
||||||
|
'transmission',
|
||||||
|
#'owner',
|
||||||
|
'age',
|
||||||
|
)
|
||||||
|
|
||||||
|
target_columns = (
|
||||||
|
'present_price',
|
||||||
|
)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
features_to_scale_to_standard_columns = (
|
||||||
|
'selling_price',
|
||||||
|
'driven_kms',
|
||||||
|
'age',
|
||||||
|
)
|
||||||
|
assert all(
|
||||||
|
(col in df_orig.select_dtypes(('number',)).columns)
|
||||||
|
for col in features_to_scale_to_standard_columns
|
||||||
|
)
|
||||||
|
|
||||||
|
features_to_encode_wrt_target_columns = (
|
||||||
|
'fuel_type',
|
||||||
|
'selling_type',
|
||||||
|
'transmission',
|
||||||
|
#'owner',
|
||||||
|
)
|
||||||
|
assert all(
|
||||||
|
(col in df_orig.select_dtypes(('category', 'object')).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
|
||||||
|
|
||||||
|
# %%
|
||||||
|
df_orig_features_train, df_orig_features_test, df_target_train, df_target_test = (
|
||||||
|
sklearn.model_selection.train_test_split(
|
||||||
|
df_orig_features, df_target, test_size=DF_TEST_PORTION, random_state=0x7AE6,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# %% [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]
|
||||||
|
# Регрессор — небольшой случайный лес, цель — минимизация квадрата ошибки предсказания:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
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', build_features_scaler_standard(), features_to_scale_to_standard_columns),
|
||||||
|
(
|
||||||
|
#'encode_categoricals_one_hot',
|
||||||
|
'encode_categoricals_wrt_target',
|
||||||
|
#build_categorical_features_encoder_onehot(),
|
||||||
|
build_categorical_features_encoder_target(random_state=0x2ED6),
|
||||||
|
features_to_encode_wrt_target_columns,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
remainder='drop',
|
||||||
|
)
|
||||||
|
|
||||||
|
# %%
|
||||||
|
regressor = build_regressor_baseline(random_state=0x016B)
|
||||||
|
regressor
|
||||||
|
|
||||||
|
# %% [markdown]
|
||||||
|
# Составной пайплайн:
|
||||||
|
|
||||||
|
# %%
|
||||||
|
pipeline = sklearn.pipeline.Pipeline([
|
||||||
|
('preprocess', preprocess_transformer),
|
||||||
|
('regress', regressor),
|
||||||
|
])
|
||||||
|
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 = 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]
|
||||||
|
# Лучшая выбранная модель — с автоматически подобранными гиперпараметрами.
|
||||||
|
|
||||||
|
# %%
|
||||||
|
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 | Имя | Объекты | Описание |
|
||||||
|
|-----------------|------------------|------------------|------------------|
|
||||||
|
| — | `prices-predictor` | код: `ml_service/` | Веб-сервис предсказания цен, только stateless API. Об используемой предсказательной модели см. `research/README.md`. |
|
||||||
|
| — | `prometheus` | конфигурация: `prometheus/` | Мониторинг сервиса ([Prometheus](https://prometheus.io/)). |
|
||||||
|
| — | `grafana` | сохранённая конфигурация: `grafana/` | Аналитика и визуализация данных мониторига сервиса ([Grafana](https://grafana.com/)). |
|
||||||
|
| `with-testers` | `load-tester` | код: `load-tester/` | Генератор потока случайных запросов к `prices-predictor` для тестирования. |
|
||||||
|
|
||||||
|
Дополнительно:
|
||||||
|
|
||||||
|
* `models/` — расположение файла модели `model.pkl` для использования сервисом `prices-predictor`.
|
||||||
|
* `fetch_model_as_pickle_from_mlflow.py` — скрипт для экспорта предиктивной модели 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]`):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Гистограмма продолжительности предсказания цен моделью ML `model_prediction_seconds_bucket` (запрос: `rate(model_prediction_seconds_bucket[5m]`):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Интенсивность потока запросов к сервису предсказания цен с разными результатами (успех — коды HTTP `2xx`, ошибки со стороны клиента — коды HTTP `4xx`) `http_requests_total{handler="/predict"}` (запрос: `rate(http_requests_total{handler="/predict"}[5m]`):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Интенсивность потока запросов к **веб-серверу** сервиса предсказания цен **с ошибками** `http_requests_total{handler="/predict"}` (запрос: `sum without(handler, method) (rate(http_requests_total{status=~"4..|5.."}[5m]))`):
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Дашборд в Grafana
|
||||||
|
|
||||||
|
Дашборд экспортирован в файл: `grafana/objects/dashboard-1765200932880.json`.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Элементы:
|
||||||
|
|
||||||
|
* мониторинг модели:
|
||||||
|
* гистограмма распределения предсказанных цен за период времени (10 мин);
|
||||||
|
* прикладной уровень:
|
||||||
|
* интенсивность потока запросов (всех запросов; запросов, заканчивающихся ошибкой);
|
||||||
|
* инфраструктурный уровень:
|
||||||
|
* состояние сервиса (up/down);
|
||||||
|
* выделенный процессу объём VRAM.
|
||||||
|
|
||||||
|
## Развёртывание
|
||||||
|
|
||||||
|
### Файл модели
|
||||||
|
|
||||||
|
Файл используемой предсказательной модели можно извлечь из MLFlow скриптом `models/fetch_model_as_pickle_from_mlflow.py`. Файл модели можно размещается в `models/model.pkl`.
|
||||||
|
|
||||||
|
Например, извлечь модель по имени (`<model-name>`) и версии (`<model-version>`) (например, `UsedCardPricePredictionFinal/1`) (команда запускается из корневой директории проекта — от этого зависит путь к создаваемому файлу):
|
||||||
|
|
||||||
|
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>` на номер версии) (команда запускается из корневой директории проекта — от этого зависит путь к директории):
|
||||||
|
|
||||||
|
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>` на номер версии) (команда запускается из корневой директории проекта — от этого зависит путь к директории):
|
||||||
|
|
||||||
|
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
|
||||||
543
services/grafana/objects/dashboard-1765200932880.json
Обычный файл
@@ -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))
|
||||||
37
services/load_tester/tester_wrapper.py.unused
Обычный файл
@@ -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
|
||||||
83
services/models/fetch_model_as_pickle_from_mlflow.py
Обычный файл
@@ -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"
|
||||||