Nikita Losev commited on
Commit
ce33685
·
1 Parent(s): 1bf92c2

film-camera service

Browse files
.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: Avito Camera Body Type
3
- emoji: 📊
4
- colorFrom: pink
5
- colorTo: purple
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
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