9 Коммитов

14 изменённых файлов: 349 добавлений и 9 удалений

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

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

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

@@ -8,9 +8,15 @@
](https://www.kaggle.com/datasets/vijayaadithyanvg/car-price-predictionused-cars/data) — ](https://www.kaggle.com/datasets/vijayaadithyanvg/car-price-predictionused-cars/data) —
продажа подержанных автомобилей на рынке в Индии. продажа подержанных автомобилей на рынке в Индии.
## Установка ## Сервис предсказания цен
### Общий порядок См. `services/ml_service/README.md`.
## Исследовательская часть проекта
### Установка
#### Общий порядок
**Внимание**: Здесь описан только общий порядок установки. Определённые части проекта могут требовать установки по отдельным инструкциям. **Внимание**: Здесь описан только общий порядок установки. Определённые части проекта могут требовать установки по отдельным инструкциям.
@@ -47,13 +53,13 @@
5. **При необходимости** скачайте данные. Каноническое расположение для данных проекта: `data/`. 5. **При необходимости** скачайте данные. Каноническое расположение для данных проекта: `data/`.
### Зависимости #### Зависимости
#### Общие зависимости ##### Общие зависимости
Зависимости — пакеты Python — записаны в файле `requirements/requirements.txt` (см. **Пакеты Python**). Зависимости — пакеты Python — записаны в файле `requirements/requirements.txt` (см. **Пакеты Python**).
#### Пакеты Python ##### Пакеты Python
Установка/обновление пакетов Python в активное окружение из файла `requirements/requirements.txt`: Установка/обновление пакетов Python в активное окружение из файла `requirements/requirements.txt`:
@@ -61,10 +67,10 @@
pip install -U -r requirements/requirements.txt pip install -U -r requirements/requirements.txt
``` ```
## Разведочный анализ данных (EDA) ### Разведочный анализ данных (EDA)
См. `eda/README.md`. См. `eda/README.md`.
## Исследование и настройка предсказательной модели ### Исследование и настройка предсказательной модели
См. `research/README.md`. См. `research/README.md`.

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

@@ -6,7 +6,7 @@ $DEFAULT_ARTIFACT_ROOT = "./mlflow/mlartifacts/"
$MLFLOW_PORT = if ($env:MLFLOW_PORT) { $env:MLFLOW_PORT } else { 5000 } $MLFLOW_PORT = if ($env:MLFLOW_PORT) { $env:MLFLOW_PORT } else { 5000 }
New-Item -ItemType Directory -Force -Path $DEFAULT_ARTIFACTS_ROOT | Out-Null New-Item -ItemType Directory -Force -Path $DEFAULT_ARTIFACT_ROOT | Out-Null
& mlflow server ` & mlflow server `
--backend-store-uri="$BACKEND_URI" ` --backend-store-uri="$BACKEND_URI" `

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

@@ -2,7 +2,7 @@
set -eu set -eu
. _mlflow_config_common.sh . ./_mlflow_config_common.sh
DEFAULT_ARTIFACT_ROOT="./mlflow/mlartifacts/" DEFAULT_ARTIFACT_ROOT="./mlflow/mlartifacts/"

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

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

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

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

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

@@ -0,0 +1,71 @@
# Сервис предсказания цен
Веб-сервис предсказания цен на подержанные автомобили; только stateless API. Об используемой предсказательной модели см. `research/README.md`.
## API
**Базовый URL**: `/api`. Все указанные далее URL записаны **относительно базового URL**, если не указано иное.
* Полная интерактивная документация (Swagger UI): `/docs`.
* Предсказать цену подержанного автомобиля: `/predict`.
Пример запроса:
* requst query: `item_id=16` (параметр `item_id` необходим!);
* request body:
```json
{
"selling_price": 5.59,
"driven_kms": 27000.0,
"age": 5.0,
"fuel_type": "petrol",
"selling_type": "dealer",
"transmission_type": "manual"
}
```
* response body:
```json
{
"item_id": 16,
"price": 3.743508852258851
}
```
* Тестовый эндпоинт: `/`
Возвращает простой демонстрационный объект JSON.
Может использоваться для проверки состояния сервиса.
## Развёртывание
### Файл модели
Файл используемой предсказательной модели `model.pkl` можно извлечь из MLFlow скриптом `services/models/fetch_model_as_pickle_from_mlflow.py`. Файл модели можно разместить в директории проекта, а именно в `services/models/`.
Например, извлечь модель по имени (`<model-name>`) и версии (`<model-version>`) (например, `UsedCardPricePredictionFinal/1`) (команда запускается из корневой директории проекта &mdash; от этого зависит путь к создаваемому файлу):
python services/models/fetch_model_as_pickle_from_mlflow.py --model "models:/<model-name>/<model-version>" services/models/model.pkl
Можно указать адрес tracking сервера MLFlow, например: `--tracking-uri "http://localhost:5000"`.
Информация о других опциях доступна:
python services/models/fetch_model_as_pickle_from_mlflow.py --help
### Образ Docker
Сборка образа (замените `<version>` на номер версии) (команда запускается из корневой директории проекта &mdash; от этого зависит путь к директории):
docker build -t ml_service:<version> services/ml_service/
Запуск образа (замените `<version>` на номер версии образа, `<models-dir>` на **абсолютный** путь к директории, где размещён файл предсказательной модели `model.pkl`, `<port>` на порт для запуска веб-сервиса (например, `8000`)):
docker run -v "<models-dir>:/models" -p <port>:8000 ml_service:<version>
Модель может быть размещена в директории проекта; тогда, например, при запуске команды из корна проекта: `$(pwd)/services/models` (здесь `$(pwd)` используется потому, что необходим абсолютный путь).

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

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

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

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

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

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

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

@@ -0,0 +1,81 @@
from dataclasses import dataclass
from enum import Enum
from pandas import DataFrame
from pickle import load
def open_model_file(file, *, buffering=-1, opener=None, **kwargs_extra):
open_kwargs_extra = {}
if 'closefd' in kwargs_extra:
open_kwargs_extra['closefd'] = kwargs_extra.pop('closefd')
if len(kwargs_extra) > 0:
raise TypeError(
'Unexpected keyword arguments given: {}'
.format(', '.join(map(repr, kwargs_extra.keys())))
)
return open(file, 'rb', buffering=buffering, opener=opener)
def load_model_from_file(file):
return load(file)
def load_model_from_path(path, *, buffering=-1, opener=None, **kwargs_extra):
open_kwargs_extra = {}
for k in ('closefd',):
if k in kwargs_extra:
open_kwargs_extra[k] = kwargs_extra.pop(k)
if len(kwargs_extra) > 0:
raise TypeError(
'Unexpected keyword arguments given: {}'.format(', '.join(kwargs_extra.keys()))
)
with open_model_file(
path, buffering=buffering, opener=opener, **open_kwargs_extra,
) as model_file:
return load_model_from_file(model_file)
class FuelType(Enum):
PETROL = 'petrol'
DIESEL = 'diesel'
CNG = 'cng'
class SellingType(Enum):
DEALER = 'dealer'
INDIVIDUAL = 'individual'
class TransmissionType(Enum):
MANUAL = 'manual'
AUTOMATIC = 'automatic'
@dataclass
class PricePredictionFeatures:
selling_price: float
driven_kms: float
age: float
fuel_type: FuelType
selling_type: SellingType
transmission_type: TransmissionType
class PricePredictor:
def __init__(self, model_path):
self._model = load_model_from_path(model_path)
def predict(self, features):
# WARN: порядок столбцов вроде имеет значение
features_df = DataFrame([{
'selling_price': features.selling_price,
'driven_kms': features.driven_kms,
'fuel_type': features.fuel_type.value,
'selling_type': features.selling_type.value,
'transmission': features.transmission_type.value,
'age': features.age,
}])
predictions = self._model.predict(features_df)
assert len(predictions) == 1
return float(predictions[0])

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

@@ -0,0 +1,5 @@
fastapi ~=0.120.4
mlxtend ~=0.23.4
pandas >=2.3.1,<3
scikit-learn >=1.7.2,<2
uvicorn ~=0.38.0

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

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

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

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