Nikita Losev commited on
Commit ·
ce33685
1
Parent(s): 1bf92c2
film-camera service
Browse files- .dockerignore +25 -0
- Dockerfile +44 -0
- README.md +16 -6
- deployment/app/__init__.py +0 -0
- deployment/app/avito.py +57 -0
- deployment/app/config.py +53 -0
- deployment/app/encoders.py +47 -0
- deployment/app/images.py +18 -0
- deployment/app/main.py +129 -0
- deployment/app/metrics.py +19 -0
- deployment/app/model.py +86 -0
- deployment/app/schemas.py +30 -0
- deployment/requirements.txt +15 -0
- deployment/static/avito.svg +7 -0
- deployment/templates/index.html +143 -0
- training/final_model/tfidf_qwen3_dinov3_pe_logreg_reg1_0_unweighted_final_meta.yaml +53 -0
- training/final_model/tfidf_qwen3_dinov3_pe_logreg_reg1_0_unweighted_final_pipeline.joblib +3 -0
.dockerignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# build-context = корень репо, образу нужны только training/final_model и deployment
|
| 2 |
+
|
| 3 |
+
.git
|
| 4 |
+
.env
|
| 5 |
+
.venv
|
| 6 |
+
.ruff_cache
|
| 7 |
+
**/__pycache__/
|
| 8 |
+
*.pyc
|
| 9 |
+
.DS_Store
|
| 10 |
+
|
| 11 |
+
# данные и другие этапы — в образ не нужны
|
| 12 |
+
data/
|
| 13 |
+
data-pipeline/
|
| 14 |
+
eda/
|
| 15 |
+
problem-validation/
|
| 16 |
+
solution-design/
|
| 17 |
+
|
| 18 |
+
# из training нужен только final_model (бандл + meta)
|
| 19 |
+
training/*
|
| 20 |
+
!training/final_model
|
| 21 |
+
|
| 22 |
+
# журнал этапа и тяжёлые форматы
|
| 23 |
+
deployment/log.md
|
| 24 |
+
**/*.parquet
|
| 25 |
+
**/*.ipynb
|
Dockerfile
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
# Прод-образ сервиса film_camera_body_type: FastAPI + 3 энкодера + logreg-бандл
|
| 3 |
+
# torch ставим CPU-колесом, веса энкодеров запекаем на build (HF_TOKEN как BuildKit-секрет),
|
| 4 |
+
# рантайм офлайн. Build context = корень репо (см .dockerignore)
|
| 5 |
+
|
| 6 |
+
FROM python:3.13-slim
|
| 7 |
+
|
| 8 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 9 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 10 |
+
PIP_NO_CACHE_DIR=1 \
|
| 11 |
+
HF_HOME=/home/user/.cache/huggingface
|
| 12 |
+
|
| 13 |
+
# непривилегированный юзер (нужен для HF Spaces)
|
| 14 |
+
RUN useradd --create-home --uid 1000 user
|
| 15 |
+
WORKDIR /home/user/app
|
| 16 |
+
|
| 17 |
+
# зависимости: сперва torch+torchvision CPU-колесом (без CUDA), потом остальное
|
| 18 |
+
COPY deployment/requirements.txt deployment/requirements.txt
|
| 19 |
+
RUN pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu \
|
| 20 |
+
&& pip install -r deployment/requirements.txt
|
| 21 |
+
|
| 22 |
+
# код сервиса и бандл модели - только нужное в рантайме
|
| 23 |
+
COPY --chown=user:user deployment/app deployment/app
|
| 24 |
+
COPY --chown=user:user deployment/static deployment/static
|
| 25 |
+
COPY --chown=user:user deployment/templates deployment/templates
|
| 26 |
+
COPY --chown=user:user training/final_model training/final_model
|
| 27 |
+
|
| 28 |
+
USER user
|
| 29 |
+
WORKDIR /home/user/app/deployment
|
| 30 |
+
|
| 31 |
+
# запекаем веса 3 энкодеров в образ (DINOv3 gated -> токен через секрет), заодно
|
| 32 |
+
# проверяем загрузку бандла. Секрет монтируется только тут и в слои образа не попадает
|
| 33 |
+
RUN --mount=type=secret,id=hf_token,uid=1000 \
|
| 34 |
+
HF_TOKEN="$(cat /run/secrets/hf_token)" \
|
| 35 |
+
python -c "from app.model import Predictor; from app.encoders import Encoders; Predictor(encoders=Encoders())"
|
| 36 |
+
|
| 37 |
+
# веса уже в образе -> рантайм не ходит в сеть
|
| 38 |
+
ENV HF_HUB_OFFLINE=1 \
|
| 39 |
+
TRANSFORMERS_OFFLINE=1
|
| 40 |
+
|
| 41 |
+
EXPOSE 7860
|
| 42 |
+
# exec-форма: SIGTERM от docker stop идёт прямо в uvicorn -> чистое завершение
|
| 43 |
+
# порт фиксированный 7860 (в HF Spaces задаётся через app_port)
|
| 44 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,11 +1,21 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Тип корпуса плёночной камеры
|
| 3 |
+
emoji: 📷
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# Определение типа корпуса плёночной камеры
|
| 12 |
+
|
| 13 |
+
По объявлению (заголовок + описание + фото камеры или ссылка на Авито) сервис определяет тип
|
| 14 |
+
корпуса плёночной камеры: зеркальная (SLR), двухобъективная (TLR), дальномерная, компактная,
|
| 15 |
+
моментальная — или «не определено», если данных мало или на фото не камера
|
| 16 |
+
|
| 17 |
+
Модель: логистическая регрессия на признаках tfidf + Qwen3 + DINOv3 + PE-Core (вектор 52432)
|
| 18 |
+
|
| 19 |
+
- форма с загрузкой фото — на главной странице
|
| 20 |
+
- API и интерактивная документация — `/docs`
|
| 21 |
+
- метрики Prometheus — `/metrics`
|
deployment/app/__init__.py
ADDED
|
File without changes
|
deployment/app/avito.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Загрузка объявления Авито по ссылке: заголовок + главное фото
|
| 2 |
+
|
| 3 |
+
Авито отдаёт реальную страницу (SSR), но данные не в JSON-LD/og-тегах, а в html:
|
| 4 |
+
заголовок - <h1>, фото - <img> на CDN img.avito.st. Мобильная версия (m.avito.ru)
|
| 5 |
+
чище для парсинга. Описание Авито в статике надёжно не отдаёт (ленивая подгрузка) -
|
| 6 |
+
для url-входа берём title+фото, этого хватает (в заголовке есть модель камеры).
|
| 7 |
+
Любая неудача (блок, нет фото, таймаут) -> None, и сервис отвечает input_unavailable
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
from io import BytesIO
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
from PIL import Image
|
| 15 |
+
|
| 16 |
+
_USER_AGENT = (
|
| 17 |
+
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 '
|
| 18 |
+
'(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
| 19 |
+
)
|
| 20 |
+
_HEADERS = {'User-Agent': _USER_AGENT, 'Accept-Language': 'ru-RU,ru;q=0.9', 'Referer': 'https://m.avito.ru/'}
|
| 21 |
+
_TIMEOUT = 12.0
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def fetch_listing(url):
|
| 25 |
+
"""url объявления Авито -> (title, description, PIL.Image) либо None при любой неудаче"""
|
| 26 |
+
try:
|
| 27 |
+
mobile_url = re.sub(r'://(www\.)?avito\.ru', '://m.avito.ru', url, count=1)
|
| 28 |
+
with httpx.Client(headers=_HEADERS, timeout=_TIMEOUT, follow_redirects=True) as client:
|
| 29 |
+
html = client.get(mobile_url).text
|
| 30 |
+
title, image_url = _parse(html)
|
| 31 |
+
content = client.get(image_url).content
|
| 32 |
+
return title, '', Image.open(BytesIO(content)).convert('RGB')
|
| 33 |
+
except Exception:
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _parse(html):
|
| 38 |
+
"""(title, image_url) из html мобильной страницы Авито; без фото - ValueError"""
|
| 39 |
+
image_url = _main_photo(html)
|
| 40 |
+
if not image_url:
|
| 41 |
+
raise ValueError('не нашли фото объявления')
|
| 42 |
+
return _title(html), image_url
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _title(html):
|
| 46 |
+
"""Заголовок из <h1>, фолбэк - alt главного фото"""
|
| 47 |
+
h1 = re.search(r'<h1[^>]*>(.*?)</h1>', html, re.S)
|
| 48 |
+
if h1:
|
| 49 |
+
return re.sub(r'<[^>]+>', '', h1.group(1)).strip()
|
| 50 |
+
alt = re.search(r'<img[^>]+alt="([^"]+)"', html)
|
| 51 |
+
return alt.group(1).strip() if alt else ''
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _main_photo(html):
|
| 55 |
+
"""URL главного фото: первый <img> с CDN Авито img.avito.st"""
|
| 56 |
+
match = re.search(r'<img[^>]+src="(https?://\d+\.img\.avito\.st/image/[^"]+)"', html)
|
| 57 |
+
return match.group(1) if match else None
|
deployment/app/config.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Константы и пути прод-сервиса film_camera_body_type"""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 7 |
+
DEPLOY_DIR = Path(__file__).resolve().parents[1]
|
| 8 |
+
MODEL_DIR = REPO_ROOT / 'training' / 'final_model'
|
| 9 |
+
_BUNDLE_NAME = 'tfidf_qwen3_dinov3_pe_logreg_reg1_0_unweighted_final_pipeline.joblib'
|
| 10 |
+
BUNDLE_PATH = Path(os.environ.get('BUNDLE_PATH', str(MODEL_DIR / _BUNDLE_NAME)))
|
| 11 |
+
|
| 12 |
+
ATTRIBUTE = 'film_camera_body_type'
|
| 13 |
+
|
| 14 |
+
# 5 основных классов идут в автозаполнение, other_unknown это отказ
|
| 15 |
+
MAIN_SET = ('SLR', 'TLR', 'rangefinder_viewfinder', 'compact_point_and_shoot', 'instant')
|
| 16 |
+
ABSTAIN = 'other_unknown'
|
| 17 |
+
ALL_LABELS = MAIN_SET + (ABSTAIN,)
|
| 18 |
+
|
| 19 |
+
# размерности emb-блоков, порядок hstack: tfidf -> qwen3 -> dinov3 -> pe
|
| 20 |
+
QWEN_DIM = 1024
|
| 21 |
+
DINO_DIM = 384
|
| 22 |
+
PE_DIM = 1024
|
| 23 |
+
TOTAL_DIM = 52432
|
| 24 |
+
|
| 25 |
+
# решения и причины ответа
|
| 26 |
+
DECISION_AUTO = 'auto_fill'
|
| 27 |
+
DECISION_ABSTAIN = 'abstain'
|
| 28 |
+
REASON_ARGMAX = 'model_argmax'
|
| 29 |
+
REASON_ABSTAIN = 'model_abstained'
|
| 30 |
+
REASON_NO_PHOTO = 'photo_required'
|
| 31 |
+
REASON_URL_FAIL = 'input_unavailable'
|
| 32 |
+
|
| 33 |
+
# энкодеры (паритет с training/notebooks/extract_*.ipynb)
|
| 34 |
+
QWEN_MODEL = 'Qwen/Qwen3-Embedding-0.6B'
|
| 35 |
+
DINO_MODEL = 'facebook/dinov3-vits16-pretrain-lvd1689m'
|
| 36 |
+
PE_MODEL = 'hf-hub:timm/PE-Core-L-14-336'
|
| 37 |
+
MAX_SEQ_LEN = 512
|
| 38 |
+
|
| 39 |
+
# человекочитаемые названия классов и причин для веб-страницы
|
| 40 |
+
LABELS_RU = {
|
| 41 |
+
'SLR': 'Зеркальная (SLR)',
|
| 42 |
+
'TLR': 'Двухобъективная (TLR)',
|
| 43 |
+
'rangefinder_viewfinder': 'Дальномерная',
|
| 44 |
+
'compact_point_and_shoot': 'Компактная «мыльница»',
|
| 45 |
+
'instant': 'Моментальная (Polaroid/Instax)',
|
| 46 |
+
'other_unknown': 'Не определено',
|
| 47 |
+
}
|
| 48 |
+
REASONS_RU = {
|
| 49 |
+
'model_argmax': 'определено по фото и тексту',
|
| 50 |
+
'model_abstained': 'модель не уверена',
|
| 51 |
+
'photo_required': 'нужно фото',
|
| 52 |
+
'input_unavailable': 'не удалось открыть объявление',
|
| 53 |
+
}
|
deployment/app/encoders.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Энкодеры Qwen3/DINOv3/PE-Core - паритет 1-в-1 с training/notebooks/extract_*.ipynb
|
| 2 |
+
|
| 3 |
+
Текст: (title+' '+desc) -> Qwen3 (L2). Фото: DINOv3 pooler_output (raw) и PE-Core
|
| 4 |
+
encode_image(normalize=False) (raw). L2 на эмбеддинги накладывает model.build_vector.
|
| 5 |
+
Три модели грузятся один раз при создании Encoders
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import open_clip
|
| 9 |
+
import torch
|
| 10 |
+
from sentence_transformers import SentenceTransformer
|
| 11 |
+
from transformers import AutoImageProcessor, AutoModel
|
| 12 |
+
|
| 13 |
+
from app.config import DINO_MODEL, MAX_SEQ_LEN, PE_MODEL, QWEN_MODEL
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Encoders:
|
| 17 |
+
"""Три замороженных энкодера, загруженные один раз"""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.text_model = SentenceTransformer(QWEN_MODEL)
|
| 21 |
+
self.text_model.max_seq_length = MAX_SEQ_LEN
|
| 22 |
+
self.dino_processor = AutoImageProcessor.from_pretrained(DINO_MODEL)
|
| 23 |
+
self.dino_model = AutoModel.from_pretrained(DINO_MODEL).eval()
|
| 24 |
+
self.pe_model, _, self.pe_preprocess = open_clip.create_model_and_transforms(PE_MODEL)
|
| 25 |
+
self.pe_model = self.pe_model.eval()
|
| 26 |
+
|
| 27 |
+
def encode_text(self, text):
|
| 28 |
+
"""Qwen3 текстовый эмбеддинг, L2-нормированный (1024)"""
|
| 29 |
+
return self.text_model.encode([text], normalize_embeddings=True)[0]
|
| 30 |
+
|
| 31 |
+
@torch.no_grad()
|
| 32 |
+
def encode_dino(self, image):
|
| 33 |
+
"""DINOv3 pooler_output, сырой (384)"""
|
| 34 |
+
pixels = self.dino_processor(images=image, return_tensors='pt')
|
| 35 |
+
out = self.dino_model(**pixels)
|
| 36 |
+
return out.pooler_output[0].numpy()
|
| 37 |
+
|
| 38 |
+
@torch.no_grad()
|
| 39 |
+
def encode_pe(self, image):
|
| 40 |
+
"""PE-Core image embedding, сырой (1024)"""
|
| 41 |
+
pixels = self.pe_preprocess(image).unsqueeze(0)
|
| 42 |
+
feat = self.pe_model.encode_image(pixels, normalize=False)
|
| 43 |
+
return feat[0].numpy()
|
| 44 |
+
|
| 45 |
+
def embed(self, text, image):
|
| 46 |
+
"""Текст + фото -> (qwen, dino, pe) для model.build_vector"""
|
| 47 |
+
return self.encode_text(text), self.encode_dino(image), self.encode_pe(image)
|
deployment/app/images.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Хелперы для фото: открыть из файла и сериализовать в data-URI для страницы"""
|
| 2 |
+
|
| 3 |
+
from base64 import b64encode
|
| 4 |
+
from io import BytesIO
|
| 5 |
+
|
| 6 |
+
from PIL import Image
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def open_image(file):
|
| 10 |
+
"""Открыть загруженное фото как RGB"""
|
| 11 |
+
return Image.open(file).convert('RGB')
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def image_to_img_src(image):
|
| 15 |
+
"""PIL Image -> data:image/png;base64,... для <img> на странице"""
|
| 16 |
+
buffer = BytesIO()
|
| 17 |
+
image.save(buffer, format='png')
|
| 18 |
+
return f'data:image/png;base64,{b64encode(buffer.getvalue()).decode()}'
|
deployment/app/main.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI прод-сервиса: страница (GET/POST /), JSON POST /predict, GET /health, /metrics
|
| 2 |
+
|
| 3 |
+
Страница - серверный рендер (Jinja2 + Bootstrap, как aaa-frontend-app). Модель и
|
| 4 |
+
энкодеры грузятся один раз при старте (lifespan). Метрики Prometheus на /metrics
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
from fastapi import FastAPI, File, Form, Request, UploadFile
|
| 11 |
+
from fastapi.responses import HTMLResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from fastapi.templating import Jinja2Templates
|
| 14 |
+
from prometheus_fastapi_instrumentator import Instrumentator
|
| 15 |
+
|
| 16 |
+
from app.avito import fetch_listing
|
| 17 |
+
from app.config import (
|
| 18 |
+
DEPLOY_DIR,
|
| 19 |
+
LABELS_RU,
|
| 20 |
+
REASON_NO_PHOTO,
|
| 21 |
+
REASON_URL_FAIL,
|
| 22 |
+
REASONS_RU,
|
| 23 |
+
REPO_ROOT,
|
| 24 |
+
)
|
| 25 |
+
from app.encoders import Encoders
|
| 26 |
+
from app.images import image_to_img_src, open_image
|
| 27 |
+
from app.metrics import model_loaded, predict_latency, record
|
| 28 |
+
from app.model import Predictor, abstain
|
| 29 |
+
from app.schemas import PredictResponse
|
| 30 |
+
|
| 31 |
+
# подхватываем HF_TOKEN (и пр.) из .env, чтобы не держать токен в переменных шелла
|
| 32 |
+
# в Docker .env нет - load_dotenv просто no-op
|
| 33 |
+
load_dotenv(REPO_ROOT / '.env')
|
| 34 |
+
|
| 35 |
+
_DESCRIPTION = (
|
| 36 |
+
'Сервис определяет тип корпуса плёночной камеры по объявлению. На вход - заголовок, описание и '
|
| 37 |
+
'фото камеры либо ссылка на объявление Авито. На выход - один из типов (зеркальная, '
|
| 38 |
+
'двухобъективная, дальномерная, компактная, моментальная) или «не определено», если данных мало '
|
| 39 |
+
'или на фото не камера'
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@asynccontextmanager
|
| 44 |
+
async def lifespan(app):
|
| 45 |
+
"""Грузим бандл и энкодеры один раз на старте"""
|
| 46 |
+
app.state.predictor = Predictor(encoders=Encoders())
|
| 47 |
+
model_loaded.set(1)
|
| 48 |
+
yield
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
app = FastAPI(title='Определение типа корпуса плёночной камеры', version='1.0.0',
|
| 52 |
+
description=_DESCRIPTION, lifespan=lifespan)
|
| 53 |
+
Instrumentator().instrument(app).expose(app)
|
| 54 |
+
app.mount('/static', StaticFiles(directory=DEPLOY_DIR / 'static'), name='static')
|
| 55 |
+
templates = Jinja2Templates(directory=DEPLOY_DIR / 'templates')
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _run(title, description, url, photos):
|
| 59 |
+
"""Логика входа -> (PredictResponse, фото для превью или None)"""
|
| 60 |
+
image = None
|
| 61 |
+
if photos and photos[0].filename:
|
| 62 |
+
image = open_image(photos[0].file)
|
| 63 |
+
elif url:
|
| 64 |
+
listing = fetch_listing(url)
|
| 65 |
+
if listing is None:
|
| 66 |
+
return abstain(REASON_URL_FAIL), None
|
| 67 |
+
title, description, image = listing
|
| 68 |
+
if image is None:
|
| 69 |
+
return abstain(REASON_NO_PHOTO), None
|
| 70 |
+
with predict_latency.labels(stage='inference').time():
|
| 71 |
+
result = app.state.predictor.predict(title, description, image)
|
| 72 |
+
return result, image
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _predict(title, description, url, photos):
|
| 76 |
+
"""То же, что _run, плюс учёт ответа в метриках"""
|
| 77 |
+
result, image = _run(title, description, url, photos)
|
| 78 |
+
record(result)
|
| 79 |
+
return result, image
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _render(request, result, image):
|
| 83 |
+
"""Отрисовать страницу с опциональным результатом"""
|
| 84 |
+
image_src = image_to_img_src(image) if image is not None else None
|
| 85 |
+
context = {'result': result, 'image_src': image_src, 'labels_ru': LABELS_RU, 'reasons_ru': REASONS_RU}
|
| 86 |
+
return templates.TemplateResponse(request, 'index.html', context)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@app.get('/health', tags=['Сервис'], summary='Проверить, что сервис работает')
|
| 90 |
+
def health():
|
| 91 |
+
"""Возвращает ok, если сервис запущен и отвечает"""
|
| 92 |
+
return {'status': 'ok'}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@app.get('/', response_class=HTMLResponse, include_in_schema=False)
|
| 96 |
+
def index(request: Request):
|
| 97 |
+
"""Страница с формой"""
|
| 98 |
+
return _render(request, None, None)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@app.post('/', response_class=HTMLResponse, include_in_schema=False)
|
| 102 |
+
def index_predict(
|
| 103 |
+
request: Request,
|
| 104 |
+
title: str = Form(''),
|
| 105 |
+
description: str = Form(''),
|
| 106 |
+
url: str = Form(''),
|
| 107 |
+
photos: list[UploadFile] = File(default=[]),
|
| 108 |
+
):
|
| 109 |
+
"""Сабмит формы -> та же страница с карточкой результата"""
|
| 110 |
+
result, image = _predict(title, description, url, photos)
|
| 111 |
+
return _render(request, result, image)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@app.post('/predict', response_model=PredictResponse, tags=['Предсказание'],
|
| 115 |
+
summary='Определить тип корпуса камеры',
|
| 116 |
+
response_description='Тип корпуса, уверенность и вероятности по классам')
|
| 117 |
+
def predict(
|
| 118 |
+
title: str = Form('', description='Заг��ловок объявления'),
|
| 119 |
+
description: str = Form('', description='Описание объявления'),
|
| 120 |
+
url: str = Form('', description='Ссылка на объявление Авито - используется, если не загружено фото'),
|
| 121 |
+
photos: list[UploadFile] = File(default=[], description='Фото камеры (используется первое)'),
|
| 122 |
+
):
|
| 123 |
+
"""Принимает заголовок, описание и фото камеры либо ссылку на объявление Авито
|
| 124 |
+
|
| 125 |
+
Если переданы и фото, и ссылка - берётся загруженное фото. Без фото и без рабочей
|
| 126 |
+
ссылки сервис не угадывает, а возвращает `other_unknown`
|
| 127 |
+
"""
|
| 128 |
+
result, _ = _predict(title, description, url, photos)
|
| 129 |
+
return result
|
deployment/app/metrics.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prometheus-метрики сервиса (отдаются на /metrics)"""
|
| 2 |
+
|
| 3 |
+
from prometheus_client import Counter, Gauge, Histogram
|
| 4 |
+
|
| 5 |
+
# prometheus_client сам добавит суффикс _total -> метрика predict_requests_total
|
| 6 |
+
predict_requests = Counter('predict_requests', 'Ответы /predict по решению и классу', ['decision', 'value'])
|
| 7 |
+
predict_latency = Histogram('predict_latency_seconds', 'Время инференса по стадиям', ['stage'])
|
| 8 |
+
# уверенность модели - прокси дрейфа: падает, когда входы уходят от обучающих
|
| 9 |
+
predict_confidence = Histogram(
|
| 10 |
+
'predict_confidence', 'Уверенность модели, когда она отработала',
|
| 11 |
+
buckets=(0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0))
|
| 12 |
+
model_loaded = Gauge('model_loaded', 'Модель и энкодеры загружены (1/0)')
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def record(response):
|
| 16 |
+
"""Учесть ответ /predict в метриках"""
|
| 17 |
+
predict_requests.labels(decision=response.decision, value=response.value).inc()
|
| 18 |
+
if response.probabilities: # модель реально отработала, а не отказ по входу
|
| 19 |
+
predict_confidence.observe(response.confidence)
|
deployment/app/model.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Контракт инференса: бандл -> вектор 52432 -> proba -> canonical argmax -> tau -> decision
|
| 2 |
+
|
| 3 |
+
Энкодеры (Qwen3/DINOv3/PE-Core) считаются отдельно (encoders.py, шаг 2) и приходят сюда
|
| 4 |
+
готовыми векторами. Здесь только сборка фич и решение - зеркало training/src/features.py
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import joblib
|
| 8 |
+
import numpy as np
|
| 9 |
+
from scipy.sparse import csr_matrix, hstack
|
| 10 |
+
from sklearn.preprocessing import normalize
|
| 11 |
+
|
| 12 |
+
from app.config import (
|
| 13 |
+
ABSTAIN,
|
| 14 |
+
ALL_LABELS,
|
| 15 |
+
BUNDLE_PATH,
|
| 16 |
+
DECISION_ABSTAIN,
|
| 17 |
+
DECISION_AUTO,
|
| 18 |
+
REASON_ABSTAIN,
|
| 19 |
+
REASON_ARGMAX,
|
| 20 |
+
)
|
| 21 |
+
from app.schemas import PredictResponse
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def build_vector(bundle, text, qwen_vec, dino_vec, pe_vec):
|
| 25 |
+
"""Разреженный вектор 52432: tfidf | L2(qwen) | L2(dino) | L2(pe)"""
|
| 26 |
+
blocks = [bundle['vectorizer'].transform([text])]
|
| 27 |
+
for vec in (qwen_vec, dino_vec, pe_vec):
|
| 28 |
+
normed = normalize(np.asarray(vec, dtype='float64').reshape(1, -1))
|
| 29 |
+
blocks.append(csr_matrix(normed))
|
| 30 |
+
return hstack(blocks).tocsr()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def predict_proba_row(bundle, x):
|
| 34 |
+
"""proba головы -> (pred_label, confidence, probabilities) в каноничном порядке ALL_LABELS"""
|
| 35 |
+
clf = bundle['classifier']
|
| 36 |
+
proba = clf.predict_proba(x)[0]
|
| 37 |
+
by_class = {str(cls): float(proba[i]) for i, cls in enumerate(clf.classes_)}
|
| 38 |
+
probabilities = {label: by_class[label] for label in ALL_LABELS}
|
| 39 |
+
values = np.array([probabilities[label] for label in ALL_LABELS])
|
| 40 |
+
best = int(values.argmax())
|
| 41 |
+
return ALL_LABELS[best], float(values[best]), probabilities
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def decide(pred_label, confidence, tau):
|
| 45 |
+
"""Решение и причина: ниже tau или other_unknown -> abstain, иначе auto_fill"""
|
| 46 |
+
if confidence < tau or pred_label == ABSTAIN:
|
| 47 |
+
return ABSTAIN, DECISION_ABSTAIN, REASON_ABSTAIN
|
| 48 |
+
return pred_label, DECISION_AUTO, REASON_ARGMAX
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def abstain(reason):
|
| 52 |
+
"""Готовый ответ-отказ: нет фото, недоступна ссылка и т.п."""
|
| 53 |
+
return PredictResponse(value=ABSTAIN, decision=DECISION_ABSTAIN, confidence=0.0, reason=reason)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def build_text(title, description):
|
| 57 |
+
"""Текст для tfidf и Qwen3: (title + ' ' + description) без краёв"""
|
| 58 |
+
return (str(title) + ' ' + str(description)).strip()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class Predictor:
|
| 62 |
+
"""Грузит бандл один раз и считает ответ; энкодеры инжектятся (torch сюда не тянем)"""
|
| 63 |
+
|
| 64 |
+
def __init__(self, bundle_path=BUNDLE_PATH, encoders=None):
|
| 65 |
+
self.bundle = joblib.load(bundle_path)
|
| 66 |
+
self.tau = float(self.bundle['tau'])
|
| 67 |
+
self.encoders = encoders
|
| 68 |
+
|
| 69 |
+
def predict(self, title, description, image):
|
| 70 |
+
"""Заголовок/описание/фото -> PredictResponse (энкодеры считают эмбеддинги)"""
|
| 71 |
+
text = build_text(title, description)
|
| 72 |
+
qwen_vec, dino_vec, pe_vec = self.encoders.embed(text, image)
|
| 73 |
+
return self.predict_from_embeddings(text, qwen_vec, dino_vec, pe_vec)
|
| 74 |
+
|
| 75 |
+
def predict_from_embeddings(self, text, qwen_vec, dino_vec, pe_vec):
|
| 76 |
+
"""Текст + три готовых эмбеддинга -> PredictResponse"""
|
| 77 |
+
x = build_vector(self.bundle, text, qwen_vec, dino_vec, pe_vec)
|
| 78 |
+
pred_label, confidence, probabilities = predict_proba_row(self.bundle, x)
|
| 79 |
+
value, decision, reason = decide(pred_label, confidence, self.tau)
|
| 80 |
+
return PredictResponse(
|
| 81 |
+
value=value,
|
| 82 |
+
decision=decision,
|
| 83 |
+
confidence=confidence,
|
| 84 |
+
reason=reason,
|
| 85 |
+
probabilities=probabilities,
|
| 86 |
+
)
|
deployment/app/schemas.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic-схема ответа сервиса (как в solution-design + вероятности)"""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
from app.config import ATTRIBUTE
|
| 6 |
+
|
| 7 |
+
_EXAMPLE = {
|
| 8 |
+
'attribute': 'film_camera_body_type',
|
| 9 |
+
'value': 'instant',
|
| 10 |
+
'decision': 'auto_fill',
|
| 11 |
+
'confidence': 0.993,
|
| 12 |
+
'reason': 'model_argmax',
|
| 13 |
+
'probabilities': {
|
| 14 |
+
'SLR': 0.0, 'TLR': 0.0, 'rangefinder_viewfinder': 0.0,
|
| 15 |
+
'compact_point_and_shoot': 0.0, 'instant': 0.993, 'other_unknown': 0.007,
|
| 16 |
+
},
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class PredictResponse(BaseModel):
|
| 21 |
+
"""Ответ: тип корпуса плёночной камеры по объявлению"""
|
| 22 |
+
|
| 23 |
+
attribute: str = Field(ATTRIBUTE, description='Заполняемый атрибут товара')
|
| 24 |
+
value: str = Field(description='Один из 5 типов корпуса либо other_unknown (отказ)')
|
| 25 |
+
decision: str = Field(description='auto_fill (заполнить) или abstain (отказ)')
|
| 26 |
+
confidence: float = Field(description='Уверенность 0..1 (максимальная вероятность)')
|
| 27 |
+
reason: str = Field(description='Причина решения (model_argmax, photo_required, input_unavailable и т.п.)')
|
| 28 |
+
probabilities: dict[str, float] = Field(default_factory=dict, description='Вероятности по всем 6 классам')
|
| 29 |
+
|
| 30 |
+
model_config = {'json_schema_extra': {'example': _EXAMPLE}}
|
deployment/requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi[all]
|
| 2 |
+
scikit-learn==1.8.0
|
| 3 |
+
numpy
|
| 4 |
+
scipy
|
| 5 |
+
joblib
|
| 6 |
+
pyyaml
|
| 7 |
+
pillow
|
| 8 |
+
httpx
|
| 9 |
+
python-dotenv
|
| 10 |
+
torch
|
| 11 |
+
transformers
|
| 12 |
+
sentence-transformers
|
| 13 |
+
open_clip_torch
|
| 14 |
+
prometheus-client
|
| 15 |
+
prometheus-fastapi-instrumentator
|
deployment/static/avito.svg
ADDED
|
|
deployment/templates/index.html
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="ru">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>Тип корпуса плёночной камеры — Авито</title>
|
| 7 |
+
<link
|
| 8 |
+
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
|
| 9 |
+
rel="stylesheet"
|
| 10 |
+
/>
|
| 11 |
+
<style>
|
| 12 |
+
:root { --avito: #0099f8; }
|
| 13 |
+
body { background: #f3f4f6; }
|
| 14 |
+
.btn-avito { background: var(--avito); border-color: var(--avito); color: #fff; font-weight: 600; }
|
| 15 |
+
.btn-avito:hover, .btn-avito:focus { background: #0a86d6; border-color: #0a86d6; color: #fff; }
|
| 16 |
+
.card { border: 0; border-radius: 14px; }
|
| 17 |
+
.progress-bar { background: var(--avito); }
|
| 18 |
+
.result-photo { max-height: 220px; border-radius: 12px; object-fit: cover; }
|
| 19 |
+
.prob-row + .prob-row { margin-top: .35rem; }
|
| 20 |
+
</style>
|
| 21 |
+
</head>
|
| 22 |
+
<body>
|
| 23 |
+
<nav class="navbar bg-white border-bottom py-3">
|
| 24 |
+
<div class="container">
|
| 25 |
+
<a class="navbar-brand" href="/"><img src="/static/avito.svg" alt="Avito" height="26" /></a>
|
| 26 |
+
<span class="text-muted small">Плёночные фотоаппараты</span>
|
| 27 |
+
</div>
|
| 28 |
+
</nav>
|
| 29 |
+
|
| 30 |
+
<main class="container py-4" style="max-width: 1080px">
|
| 31 |
+
<div class="mb-4">
|
| 32 |
+
<h1 class="h3 fw-bold mb-1">Тип корпуса плёночной камеры</h1>
|
| 33 |
+
<p class="text-muted mb-0">
|
| 34 |
+
Загрузите фото камеры и заголовок объявления или вставьте ссылку на Авито — сервис определит
|
| 35 |
+
тип корпуса: зеркальная, дальномерная, компактная, двухобъективная или моментальная
|
| 36 |
+
</p>
|
| 37 |
+
</div>
|
| 38 |
+
|
| 39 |
+
<div class="row g-4">
|
| 40 |
+
<!-- форма -->
|
| 41 |
+
<div class="col-lg-6">
|
| 42 |
+
<div class="card shadow-sm">
|
| 43 |
+
<div class="card-body p-4">
|
| 44 |
+
<h5 class="card-title mb-4">Объявление</h5>
|
| 45 |
+
<form method="post" action="/" enctype="multipart/form-data">
|
| 46 |
+
<div class="mb-3">
|
| 47 |
+
<label class="form-label fw-semibold">Заголовок</label>
|
| 48 |
+
<input type="text" name="title" class="form-control" placeholder="Например: Зенит TTL" />
|
| 49 |
+
</div>
|
| 50 |
+
<div class="mb-3">
|
| 51 |
+
<label class="form-label fw-semibold">Описание</label>
|
| 52 |
+
<textarea name="description" rows="3" class="form-control" placeholder="Состояние, комплект, модель…"></textarea>
|
| 53 |
+
</div>
|
| 54 |
+
<div class="mb-3">
|
| 55 |
+
<label class="form-label fw-semibold">Фото камеры</label>
|
| 56 |
+
<input type="file" name="photos" accept="image/*" class="form-control" />
|
| 57 |
+
</div>
|
| 58 |
+
<div class="text-center text-muted small my-2">— или —</div>
|
| 59 |
+
<div class="mb-4">
|
| 60 |
+
<label class="form-label fw-semibold">Ссылка на объявление Авито</label>
|
| 61 |
+
<input type="url" name="url" class="form-control" placeholder="https://www.avito.ru/…" />
|
| 62 |
+
<div class="form-text">Если загружено фото камеры, ссылка не используется</div>
|
| 63 |
+
</div>
|
| 64 |
+
<button type="submit" class="btn btn-avito w-100 py-2">Определить тип</button>
|
| 65 |
+
</form>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<!-- результат -->
|
| 71 |
+
<div class="col-lg-6">
|
| 72 |
+
{% if result and result.probabilities %}
|
| 73 |
+
<div class="card shadow-sm">
|
| 74 |
+
<div class="card-body p-4">
|
| 75 |
+
<h6 class="text-uppercase text-muted small mb-3">Результат</h6>
|
| 76 |
+
{% if image_src %}
|
| 77 |
+
<div class="text-center mb-3">
|
| 78 |
+
<img src="{{ image_src }}" class="result-photo" alt="фото камеры" />
|
| 79 |
+
</div>
|
| 80 |
+
{% endif %}
|
| 81 |
+
<div class="d-flex align-items-center flex-wrap gap-2 mb-1">
|
| 82 |
+
<span class="h3 mb-0">{{ labels_ru.get(result.value, result.value) }}</span>
|
| 83 |
+
{% if result.decision == 'auto_fill' %}
|
| 84 |
+
<span class="badge text-bg-success">Можно заполнить</span>
|
| 85 |
+
{% else %}
|
| 86 |
+
<span class="badge text-bg-secondary">Лучше оставить пустым</span>
|
| 87 |
+
{% endif %}
|
| 88 |
+
</div>
|
| 89 |
+
<div class="text-muted small mb-3">
|
| 90 |
+
<code>{{ result.value }}</code>
|
| 91 |
+
· уверенность {{ ('%.1f'|format(result.confidence * 100))|replace('.', ',') }}%
|
| 92 |
+
· {{ reasons_ru.get(result.reason, result.reason) }}
|
| 93 |
+
</div>
|
| 94 |
+
{% for label, p in result.probabilities|dictsort(by='value', reverse=true) %}
|
| 95 |
+
<div class="prob-row d-flex align-items-center">
|
| 96 |
+
<div class="small {% if label == result.value %}fw-semibold{% endif %}" style="width: 210px">
|
| 97 |
+
{{ labels_ru.get(label, label) }}
|
| 98 |
+
</div>
|
| 99 |
+
<div class="progress flex-grow-1" style="height: 12px">
|
| 100 |
+
<div class="progress-bar" role="progressbar" style="width: {{ (p * 100)|round(1) }}%"></div>
|
| 101 |
+
</div>
|
| 102 |
+
<div class="small text-end ps-2" style="width: 56px">
|
| 103 |
+
{{ ('%.1f'|format(p * 100))|replace('.', ',') }}%
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
{% endfor %}
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
{% elif result %}
|
| 110 |
+
<div class="card shadow-sm">
|
| 111 |
+
<div class="card-body p-4 text-center">
|
| 112 |
+
<h6 class="text-uppercase text-muted small mb-3">Результат</h6>
|
| 113 |
+
{% if result.reason == 'photo_required' %}
|
| 114 |
+
<div class="h4 mb-2">Нужно фото</div>
|
| 115 |
+
<p class="text-muted mb-0">Добавьте фото камеры или вставьте ссылку на объявление — без фото сервис не угадывает</p>
|
| 116 |
+
{% else %}
|
| 117 |
+
<div class="h4 mb-2">Не удалось открыть объявление</div>
|
| 118 |
+
<p class="text-muted mb-0">Проверьте ссылку или загрузите фото вручную</p>
|
| 119 |
+
{% endif %}
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
{% else %}
|
| 123 |
+
<div class="card shadow-sm">
|
| 124 |
+
<div class="card-body p-4">
|
| 125 |
+
<h6 class="text-uppercase text-muted small mb-3">Типы корпусов</h6>
|
| 126 |
+
<ul class="list-unstyled mb-0 small">
|
| 127 |
+
<li class="mb-2"><span class="fw-semibold">Зеркальная (SLR)</span> — видоискатель смотрит через объектив, внутри зеркало и призма</li>
|
| 128 |
+
<li class="mb-2"><span class="fw-semibold">Двухобъективная (TLR)</span> — два объектива друг над другом</li>
|
| 129 |
+
<li class="mb-2"><span class="fw-semibold">Дальномерная</span> — наводка на резкость по дальномеру, без зеркала</li>
|
| 130 |
+
<li class="mb-2"><span class="fw-semibold">Компактная «мыльница»</span> — простая автоматическая, «навёл и снял»</li>
|
| 131 |
+
<li class="mb-2"><span class="fw-semibold">Моментальная</span> — печатает снимок сразу, Polaroid и Instax</li>
|
| 132 |
+
<li><span class="fw-semibold">Не определено</span> — на фото не камера, лот из нескольких или данных мало</li>
|
| 133 |
+
</ul>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
{% endif %}
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
</main>
|
| 140 |
+
|
| 141 |
+
<footer class="container py-4 text-center text-muted small">© 2026 Академия Аналитиков Авито</footer>
|
| 142 |
+
</body>
|
| 143 |
+
</html>
|
training/final_model/tfidf_qwen3_dinov3_pe_logreg_reg1_0_unweighted_final_meta.yaml
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: tfidf+qwen3+dinov3+pe+logreg__reg1.0_unweighted__final
|
| 2 |
+
champion_config: lrgrid__c1.0__none
|
| 3 |
+
trained_on: train+val
|
| 4 |
+
held_out: test+audit (не трогали)
|
| 5 |
+
n_train_refit: 122234
|
| 6 |
+
n_test: 22079
|
| 7 |
+
tau: 0.0
|
| 8 |
+
labels:
|
| 9 |
+
- SLR
|
| 10 |
+
- TLR
|
| 11 |
+
- rangefinder_viewfinder
|
| 12 |
+
- compact_point_and_shoot
|
| 13 |
+
- instant
|
| 14 |
+
- other_unknown
|
| 15 |
+
head:
|
| 16 |
+
kind: logreg
|
| 17 |
+
C: 1.0
|
| 18 |
+
class_weight: null
|
| 19 |
+
feature_order:
|
| 20 |
+
- block: tfidf
|
| 21 |
+
dim: 50000
|
| 22 |
+
scaling: none
|
| 23 |
+
- block: text_emb
|
| 24 |
+
model: Qwen/Qwen3-Embedding-0.6B
|
| 25 |
+
dim: 1024
|
| 26 |
+
scaling: l2
|
| 27 |
+
- block: image_emb
|
| 28 |
+
source: dinov3
|
| 29 |
+
model: facebook/dinov3-vits16-pretrain-lvd1689m
|
| 30 |
+
dim: 384
|
| 31 |
+
scaling: l2
|
| 32 |
+
- block: image_emb
|
| 33 |
+
source: pe
|
| 34 |
+
model: timm/PE-Core-L-14-336
|
| 35 |
+
dim: 1024
|
| 36 |
+
scaling: l2
|
| 37 |
+
total_dim: 52432
|
| 38 |
+
split_data_sha256: 11d9d547c3e4c888776e244181fc6cff237ded98094ff2e90eecd74353ef8978
|
| 39 |
+
held_out_metrics:
|
| 40 |
+
test_proxy:
|
| 41 |
+
CAR: 0.8350921690293944
|
| 42 |
+
AER: 0.03733096642823579
|
| 43 |
+
AER_hi: 0.04010987384058068
|
| 44 |
+
A: 19153
|
| 45 |
+
C: 18438
|
| 46 |
+
W: 715
|
| 47 |
+
test_audit:
|
| 48 |
+
CAR: 0.7476190476190476
|
| 49 |
+
AER: 0.024844720496894408
|
| 50 |
+
AER_hi: 0.06213330741266006
|
| 51 |
+
A: 161
|
| 52 |
+
C: 157
|
| 53 |
+
W: 4
|
training/final_model/tfidf_qwen3_dinov3_pe_logreg_reg1_0_unweighted_final_pipeline.joblib
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0a3af0a9784f294566da18f63886e410a9c025b81b8c102c08ba9e1b7c174dcf
|
| 3 |
+
size 4380782
|