Инференс-сервис на FastAPI

Минимальный, но правильный инференс-сервис: модель грузится один раз, вход валидируется, есть health-check.

Инференс-сервис — это веб-приложение, которое держит модель в памяти и по HTTP-запросу с признаками возвращает предсказание.

Анатомия правильного сервиса

Хороший инференс-эндпоинт делает четыре вещи: грузит модель один раз при старте (а не на каждый запрос), валидирует вход по схеме, применяет тот же препроцессинг, что и при обучении, и отдаёт предсказание. Плюс health-check, чтобы оркестратор знал, что сервис жив.

Скелет на FastAPI

Этот код запускается на сервере (FastAPI + ML), поэтому он показан для чтения, без кнопки запуска.

from fastapi import FastAPI
from pydantic import BaseModel
import joblib

app = FastAPI()

# модель грузится ОДИН раз при старте, не на каждый запрос
model = joblib.load("model.pkl")  # это весь pipeline: scaler + estimator

class Request(BaseModel):
    age: int
    income: float
    orders_30d: int

class Response(BaseModel):
    probability: float
    label: int

@app.get("/health")
def health():
    return {"status": "ok"}

@app.post("/predict", response_model=Response)
def predict(req: Request):
    features = [[req.age, req.income, req.orders_30d]]
    proba = model.predict_proba(features)[0][1]
    return Response(probability=proba, label=int(proba >= 0.5))

Почему модель грузится при старте

Загрузка модели с диска — дорогая операция (десятки-сотни мс). Если делать её внутри обработчика, каждый запрос будет медленным. Поэтому модель загружают один раз в глобальную область при старте процесса и переиспользуют — все запросы работают с уже готовым объектом в памяти.

Зачем строгая схема запроса

Pydantic-модель Request валидирует вход: если придёт строка вместо числа или не хватит поля — сервис вернёт понятную ошибку 422, а не упадёт внутри predict с невнятным стектрейсом. Это первая линия защиты от битых данных в проде.

Логика порога: смоделируем на чистом Python

Внутри predict вероятность превращается в метку по порогу. Эту часть можно прочувствовать без фреймворка.

def to_label(proba, threshold=0.5):
    return 1 if proba >= threshold else 0

probas = [0.12, 0.49, 0.50, 0.73, 0.91]
print("Порог 0.5:")
for p in probas:
    print(f"  proba={p:.2f} -> label={to_label(p)}")

print("Порог 0.7 (строже к классу 1):")
for p in probas:
    print(f"  proba={p:.2f} -> label={to_label(p, 0.7)}")

Вывод:

Порог 0.5:
  proba=0.12 -> label=0
  proba=0.49 -> label=0
  proba=0.50 -> label=1
  proba=0.73 -> label=1
  proba=0.91 -> label=1
Порог 0.7 (строже к классу 1):
  proba=0.12 -> label=0
  proba=0.49 -> label=0
  proba=0.50 -> label=0
  proba=0.73 -> label=1
  proba=0.91 -> label=1

Порог — это бизнес-решение: повышая его, мы реже срабатываем (меньше ложных тревог, но больше пропусков). Сервис должен отдавать и вероятность, и метку, чтобы потребитель мог выбрать порог под свою задачу.

Как работает под капотом

Под FastAPI работает ASGI-сервер (uvicorn) с воркерами-процессами. Каждый воркер при старте грузит свою копию модели в память. Запросы распределяются по воркерам; внутри воркера обработка обычно синхронна для CPU-инференса. Health-check на /health опрашивает оркестратор (k8s): если эндпоинт перестал отвечать — под перезапускают. Версию модели сервис берёт из реестра или из тега образа, что связывает рантайм с lineage.

Частые ошибки

  • Грузить модель внутри обработчика. Каждый запрос станет медленным; грузите при старте.
  • Не валидировать вход. Битый запрос уронит predict невнятной ошибкой вместо 422.
  • Отдавать только метку. Без вероятности потребитель не сможет настроить порог под себя.
  • Забыть health-check. Оркестратор не сможет понять, что сервис завис.

Итог

  • Инференс-сервис грузит модель один раз при старте и переиспользует её для всех запросов.
  • Строгая схема запроса (Pydantic) отсекает битый вход понятной ошибкой до вызова модели.
  • Сервис отдаёт и вероятность, и метку; порог — настраиваемое бизнес-решение; health-check нужен оркестратору.
Проверьте себя
1. Почему модель грузят при старте сервиса, а не в обработчике запроса?
AТак требует FastAPI
BЗагрузка с диска дорогая; иначе каждый запрос станет медленным
CЧтобы скрыть модель
DИначе модель не обучится
2. Зачем инференс-эндпоинту строгая схема запроса (Pydantic)?
AДля красоты кода
BЧтобы отсечь битый вход понятной ошибкой 422 до вызова модели
CЧтобы ускорить predict
DЭто обязательно для Docker
3. Почему сервис лучше отдаёт и вероятность, и метку?
AЧтобы ответ был длиннее
BПорог — бизнес-решение; имея вероятность, потребитель сам выберет порог
CМетка не нужна вовсе
DТак требует health-check