Сравнить коммиты
Ничего общего в коммитах. 'master' и 'lab_1/master' имеют совершенно разные истории.
master
...
lab_1/mast
@ -1,2 +0,0 @@
|
||||
$BACKEND_STORE_DB_PATH = "./mlflow/mlruns.sqlite"
|
||||
$BACKEND_URI = "sqlite:///$BACKEND_STORE_DB_PATH"
|
||||
@ -1,4 +0,0 @@
|
||||
set -eu
|
||||
|
||||
BACKEND_STORE_DB_PATH="${BACKEND_STORE_DB_PATH:-./mlflow/mlruns.sqlite}"
|
||||
BACKEND_URI="sqlite:///$BACKEND_STORE_DB_PATH"
|
||||
@ -0,0 +1,73 @@
|
||||
# Обоснование порядка работы с блокнотами Jupyter
|
||||
|
||||
[Архитектура](https://docs.jupyter.org/en/latest/projects/architecture/content-architecture.html) экосистемы Jupyter очень сложна, особенно если учитывать сторонние дополнения; данное обоснование может содержать неточности.
|
||||
|
||||
Концептуально, важные для темы компоненты в архитектуре Jupyter:
|
||||
|
||||
* *Ядро* ([kernel](https://docs.jupyter.org/en/latest/projects/kernels.html#kernels)) — независимый процесс, непосредственно исполняющий код на конкретном языке программирования (основываясь на конкретном экземпляре интерпретатора) и предоставляющий API для передачи исполняемого кода и получения результатов. (Самое популярное ядро — `ipykernel` — использует [IPython](https://ipython.org/), программный пакет, реализующий Python с расширенными возможностями интерактивной работы.)
|
||||
|
||||
* *Сервер* (server) и *приложение* (application) — компоненты, обеспечивающие выполнение прикладных задач. Так, стандартный `jupyter_server` и использующие его популярные [веб-приложения](https://jupyter.org/) (Notebook, JupyterLab) реализуют редактирование и исполнение блокнотов и других файлов (делегируя только непосредственное исполнение кода ядрам) и все пользовательские интерфейсы.
|
||||
|
||||
Чтобы пользователь могу выбрать ядро для использования (а значит, выбрать язык программирования и конкретный экземпляр интерпретатора), сервер Jupyter выполняет поиск доступных *спецификаций ядер* (kernelspec).
|
||||
|
||||
В случае Python, выбор конкретного экземпляра интерпретатора важен в частности потому, что это означает и выбор конкретного виртуального окружения. И здесь начинаются проблемы.
|
||||
|
||||
* Сервер Jupyter реализует только примитивный механизм поиска спецификаций ядер. "**Ядра [устанавливается](https://ipython.readthedocs.io/en/stable/install/kernel_install.html#installing-the-ipython-kernel)**" (install) глобально в систему либо для отдельного пользователя, что означает размещение спецификаций ядер под глобально уникальными именами (с учётом приоритета ядер, установленных для конкретного пользователя). Механизма локального размещения спецификаций ядер просто не предусмотрено. (Сервер Jupyter, возможно, реализует и другой механизм поиска *активных* ядер, который здесь нерелевантен.)
|
||||
|
||||
* Расширение [Jupyter](https://marketplace.visualstudio.com/items?itemName=ms-toolsai.jupyter) для Visual Studio Code самостоятельно реализует ограниченные функции сервера и приложения. Однако оно имеет [продвинутый механизм](https://code.visualstudio.com/docs/datascience/jupyter-kernel-management#_python-environments) поиска спецификаций ядер. Спецификации ядер, размещённые в виртуальном окружении (в директории данных (например, `share`), далее `jupyter/kernels`), также подбираются и могут быть использованы пользователем. Они должны иметь *локально* (в отношении виртуального окружения) уникальные имена, **однако** они делят пространство имён с глобально установленными ядрами, просто с более высоким приоритетом.
|
||||
|
||||
* Блокнот Jupyter сохраняет имя нужного для его исполнения ядра в своих метаданных (`kernelspec.name`) (даже при использовании Jupytext).
|
||||
|
||||
Таким образом, отдельный блокнот требует ядро с определённым именем, это имя ищется в системе глобально, и при использовании оригинальной системы Jupyter нет возможности даже определить это имя локально для отдельного виртуального окружения с приоритетом выше, чем у ядер, установленных глобально. Т.е. блокнот существенно привязывается к окружению конкретной системы, что мешает переносимости и контролю версий, а также захламляет систему глобально установленными ядрами.
|
||||
|
||||
Найденные варианты смягчения проблемы:
|
||||
|
||||
1. Разворачивать Jupyter целиком внутри виртуального окружения.
|
||||
|
||||
При этом подходе проблемы с выбором ядра, вероятно, нет вообще.
|
||||
|
||||
**\*** Нет полной уверенности, что Jupyter в виртуальном окружении действительно будет использовать его экземпляр интерпретатора для ядра по умолчанию.
|
||||
|
||||
**+** Простота работы, по крайней мере в пределах одного виртуального окружения.
|
||||
|
||||
**−** Повышенный расход ресурсов хранилища; пакет `notebook` версии 7.4 со всеми зависимостями занимает порядка 264 МБ на диске 12 тысячами файлов.
|
||||
|
||||
**−** Возможные проблемы из-за изоляции среды Jupyter от других проектов.
|
||||
|
||||
2. Использовать только Visual Studio Code с расширением Jupyter и пакетом `ipykernel` в каждом проекте; не использовать оригинальную систему (сервер Jupyter и веб-приложения) вообще.
|
||||
|
||||
В каждом виртуальном окружении также нужен пакет `ipykernel` для работы с ядром.
|
||||
|
||||
**+** Довольно надёжная локальная связь ядер с виртуальными окружениями.
|
||||
|
||||
**−** Большая проблема с переносимостью блокнотов из-за необходимости использовать конкретную IDE с расширением вместо оригинальной системы.
|
||||
|
||||
**−** Ограниченный функционал и нестабильность расширения Jupyter для Visual Studio Code.
|
||||
|
||||
**−** Потенциальная необходимость ручных хаков в случае использования более одного виртуального окружения в одном проекте.
|
||||
|
||||
3. Использовать единственное определённое и зафиксированное для проекта имя ядра; устанавливать ядро с указанным именем глобально либо, для использования нескольких ядер (например, для нескольких виртуальных окружений для одного проекта), править каждый блокнот локально.
|
||||
|
||||
**+** Относительно простой порядок работы в случае с единственным виртуальным окружением для каждого проекта.
|
||||
|
||||
**−** Необходимо обеспечить уникальность имени ядра для проекта; имя ядра может стать нечитаемым.
|
||||
|
||||
**−** Если появляется необходимость иметь несколько ядер для одного проекта (например, для нескольких виртуальных окружений) или переименовать единственное ядро, необходимо применять локальные правки к каждому релевантному блокноту, что существенно усложняет контроль версий.
|
||||
|
||||
**−** Установленные спецификации ядер остаются в системе глобально и захламляют систему.
|
||||
|
||||
4. Запускать ядро самостоятельно из виртуального окружения и использовать механизм поиска активных ядер.
|
||||
|
||||
Есть интерфейсы, намекающие на возможность этого варианта (см. пакет `ipykernel_launcher`), но неясно, насколько это было бы практично.
|
||||
|
||||
**\*** Непонятно, возможно ли это.
|
||||
|
||||
**\*** Непонятно, практично ли это.
|
||||
|
||||
**+** Довольно надёжная локальная связь ядер с виртуальными окружениями.
|
||||
|
||||
**−** Довольно много телодвижений для обычной работы с Jupyter.
|
||||
|
||||
**−** Очень сложно в реализации, может потребовать разработки собственного ПО.
|
||||
|
||||
Вариант **3** выбран на данный момент для данного проекта из-за экономии ресурсов, переносимости и относительной простоты порядка работы с блокнотами в тривиальном рабочем процессе.
|
||||
@ -1,143 +0,0 @@
|
||||
# Использование среды 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 в виртуальном окружении.
|
||||
@ -1,5 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. $PSScriptRoot\_mlflow_config_common.ps1
|
||||
|
||||
& mlflow gc --backend-store-uri="$BACKEND_URI" @args
|
||||
@ -1,7 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
. _mlflow_config_common.sh
|
||||
|
||||
& mlflow gc --backend-store-uri="$BACKEND_URI" "$@"
|
||||
@ -1,3 +0,0 @@
|
||||
SEQUENTIAL_FEATURE_SELECTOR_PARAMS_COMMON_INCLUDE = [
|
||||
'k_features', 'forward', 'floating', 'scoring', 'cv', 'fixed_features', 'feature_groups',
|
||||
]
|
||||
@ -0,0 +1,8 @@
|
||||
from functools import wraps
|
||||
from math import log
|
||||
|
||||
from numpy import logspace as numpy_logspace
|
||||
|
||||
@wraps(numpy_logspace, assigned=('__annotations__', '__type_params__'))
|
||||
def logspace(start, stop, *args_rest, **kwargs):
|
||||
return numpy_logspace(log(start), log(stop), *args_rest, **kwargs)
|
||||
@ -1,60 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
COLUMN_TRANSFORMER_PARAMS_COMMON_INCLUDE = [
|
||||
'remainder', 'sparse_threshold', 'transformer_weights',
|
||||
]
|
||||
@ -1 +0,0 @@
|
||||
RANDOM_FOREST_REGRESSOR_PARAMS_COMMON_EXCLUDE = ['n_jobs', 'verbose', 'warm_start']
|
||||
@ -1,5 +0,0 @@
|
||||
from pandas import DataFrame
|
||||
|
||||
|
||||
def pandas_dataframe_from_transformed_artifacts(matrix, transformer) -> DataFrame:
|
||||
return DataFrame(matrix, columns=transformer.get_feature_names_out())
|
||||
@ -1 +0,0 @@
|
||||
STANDARD_SCALER_PARAMS_COMMON_EXCLUDE = ['copy']
|
||||
@ -1,2 +0,0 @@
|
||||
mlruns.sqlite
|
||||
mlartifacts/
|
||||
@ -1,7 +1,8 @@
|
||||
bokeh >=3.7.2,<4
|
||||
ipykernel >=6.30.1,<7
|
||||
ipympl ~=0.9.6
|
||||
matplotlib >=3.10.1,<4
|
||||
numpy >=2.2.6,<3
|
||||
numpy >=2.3.1,<3
|
||||
pandas >=2.3.1,<3
|
||||
scipy >=1.15.3,<2
|
||||
scipy >=1.16.1,<2
|
||||
seaborn ~=0.13.2
|
||||
@ -1 +0,0 @@
|
||||
bokeh >=3.7.2,<4
|
||||
@ -1,2 +0,0 @@
|
||||
mlxtend ~=0.23.4
|
||||
scikit-learn >=1.7.2,<2
|
||||
@ -1,4 +0,0 @@
|
||||
mlflow >=2.16,<2.22
|
||||
mlxtend ~=0.23.4
|
||||
optuna ~=4.5
|
||||
scikit-learn >=1.7.2,<2
|
||||
@ -1,59 +0,0 @@
|
||||
# Исследование и настройка предсказательной модели
|
||||
|
||||
## Блокноты 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>
|
||||
|
До Ширина: | Высота: | Размер: 43 KiB |
|
До Ширина: | Высота: | Размер: 37 KiB |
|
До Ширина: | Высота: | Размер: 93 KiB |
|
До Ширина: | Высота: | Размер: 62 KiB |
@ -1,905 +0,0 @@
|
||||
# ---
|
||||
# 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']),),
|
||||
)
|
||||
|
||||
# %%
|
||||
@ -1,14 +0,0 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
. $PSScriptRoot\_mlflow_config_common.ps1
|
||||
|
||||
$DEFAULT_ARTIFACT_ROOT = "./mlflow/mlartifacts/"
|
||||
|
||||
$MLFLOW_PORT = if ($env:MLFLOW_PORT) { $env:MLFLOW_PORT } else { 5000 }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $DEFAULT_ARTIFACT_ROOT | Out-Null
|
||||
|
||||
& mlflow server `
|
||||
--backend-store-uri="$BACKEND_URI" `
|
||||
--default-artifact-root="$DEFAULT_ARTIFACT_ROOT" `
|
||||
-p $MLFLOW_PORT
|
||||
@ -1,16 +0,0 @@
|
||||
#!/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"
|
||||
@ -1,169 +0,0 @@
|
||||
# Сервис предсказания цен
|
||||
|
||||
Веб-сервис предсказания цен на подержанные автомобили. Мониторинг в комплекте.
|
||||
|
||||
Обзор сервисов (по `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.
|
||||
@ -1,2 +0,0 @@
|
||||
GF_SECURITY_ADMIN_USER=admin
|
||||
GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
@ -1,64 +0,0 @@
|
||||
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
|
||||
|
До Ширина: | Высота: | Размер: 164 KiB |
|
До Ширина: | Высота: | Размер: 87 KiB |
|
До Ширина: | Высота: | Размер: 78 KiB |
|
До Ширина: | Высота: | Размер: 115 KiB |
|
До Ширина: | Высота: | Размер: 99 KiB |
@ -1 +0,0 @@
|
||||
objects/*.json -text
|
||||
@ -1,543 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
### Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
### Project
|
||||
*.unused
|
||||
@ -1,13 +0,0 @@
|
||||
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 +0,0 @@
|
||||
requests >=2.32.5,<3
|
||||
@ -1,280 +0,0 @@
|
||||
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))
|
||||
@ -1,37 +0,0 @@
|
||||
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))
|
||||
@ -1,3 +0,0 @@
|
||||
### Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@ -1,19 +0,0 @@
|
||||
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
|
||||
@ -1,2 +0,0 @@
|
||||
from ._meta import PACKAGE_PATH
|
||||
from .main import app
|
||||
@ -1,4 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PACKAGE_PATH = Path(__file__).parent
|
||||
@ -1,72 +0,0 @@
|
||||
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}
|
||||
@ -1,111 +0,0 @@
|
||||
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
|
||||
@ -1,7 +0,0 @@
|
||||
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 +0,0 @@
|
||||
*.pkl
|
||||
@ -1,83 +0,0 @@
|
||||
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 +0,0 @@
|
||||
data/
|
||||
@ -1,15 +0,0 @@
|
||||
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"
|
||||