"""Контракт инференса: бандл -> вектор 52432 -> proba -> canonical argmax -> tau -> decision Энкодеры (Qwen3/DINOv3/PE-Core) считаются отдельно (encoders.py, шаг 2) и приходят сюда готовыми векторами. Здесь только сборка фич и решение - зеркало training/src/features.py """ import joblib import numpy as np from scipy.sparse import csr_matrix, hstack from sklearn.preprocessing import normalize from app.config import ( ABSTAIN, ALL_LABELS, BUNDLE_PATH, DECISION_ABSTAIN, DECISION_AUTO, REASON_ABSTAIN, REASON_ARGMAX, ) from app.schemas import PredictResponse def build_vector(bundle, text, qwen_vec, dino_vec, pe_vec): """Разреженный вектор 52432: tfidf | L2(qwen) | L2(dino) | L2(pe)""" blocks = [bundle['vectorizer'].transform([text])] for vec in (qwen_vec, dino_vec, pe_vec): normed = normalize(np.asarray(vec, dtype='float64').reshape(1, -1)) blocks.append(csr_matrix(normed)) return hstack(blocks).tocsr() def predict_proba_row(bundle, x): """proba головы -> (pred_label, confidence, probabilities) в каноничном порядке ALL_LABELS""" clf = bundle['classifier'] proba = clf.predict_proba(x)[0] by_class = {str(cls): float(proba[i]) for i, cls in enumerate(clf.classes_)} probabilities = {label: by_class[label] for label in ALL_LABELS} values = np.array([probabilities[label] for label in ALL_LABELS]) best = int(values.argmax()) return ALL_LABELS[best], float(values[best]), probabilities def decide(pred_label, confidence, tau): """Решение и причина: ниже tau или other_unknown -> abstain, иначе auto_fill""" if confidence < tau or pred_label == ABSTAIN: return ABSTAIN, DECISION_ABSTAIN, REASON_ABSTAIN return pred_label, DECISION_AUTO, REASON_ARGMAX def abstain(reason): """Готовый ответ-отказ: нет фото, недоступна ссылка и т.п.""" return PredictResponse(value=ABSTAIN, decision=DECISION_ABSTAIN, confidence=0.0, reason=reason) def build_text(title, description): """Текст для tfidf и Qwen3: (title + ' ' + description) без краёв""" return (str(title) + ' ' + str(description)).strip() class Predictor: """Грузит бандл один раз и считает ответ; энкодеры инжектятся (torch сюда не тянем)""" def __init__(self, bundle_path=BUNDLE_PATH, encoders=None): self.bundle = joblib.load(bundle_path) self.tau = float(self.bundle['tau']) self.encoders = encoders def predict(self, title, description, image): """Заголовок/описание/фото -> PredictResponse (энкодеры считают эмбеддинги)""" text = build_text(title, description) qwen_vec, dino_vec, pe_vec = self.encoders.embed(text, image) return self.predict_from_embeddings(text, qwen_vec, dino_vec, pe_vec) def predict_from_embeddings(self, text, qwen_vec, dino_vec, pe_vec): """Текст + три готовых эмбеддинга -> PredictResponse""" x = build_vector(self.bundle, text, qwen_vec, dino_vec, pe_vec) pred_label, confidence, probabilities = predict_proba_row(self.bundle, x) value, decision, reason = decide(pred_label, confidence, self.tau) return PredictResponse( value=value, decision=decision, confidence=confidence, reason=reason, probabilities=probabilities, )