Spaces:
Running
Running
| """ | |
| Text classifier for category, root_cause, and subcategory. | |
| Architecture (v2.1 — "tri-vote"): | |
| P = (2·P_tfidf + P_emb_lr + P_emb_knn) / 4 | |
| P_tfidf — TF-IDF (word+char) → soft vote of calibrated LR + calibrated | |
| LinearSVC + ComplementNB, trained on serve-shape text + | |
| investigation-augmented text | |
| P_emb_lr — calibrated LogisticRegression on multilingual sentence | |
| embeddings of the raw report text (models/embedder.py) | |
| P_emb_knn — cosine kNN (k=5, distance-weighted) on the same embeddings — | |
| template-heavy operational text suits retrieval | |
| The reported confidence is Platt-recalibrated on out-of-fold predictions so | |
| "confidence 0.8" empirically means ≈80% correct. If the embedding model is | |
| unavailable the classifier degrades to TF-IDF-only automatically. | |
| Honesty rules (chosen after measuring the deployed train/serve skew): | |
| 1. Train/serve parity — evaluated and served on exactly the input shape the | |
| API receives. Post-investigation columns (Root Caused, Action Taken) are | |
| training augmentation only, never serve features. | |
| 2. Dedup — duplicate (report text, label) pairs are collapsed before | |
| training/CV so template texts can't leak across folds. | |
| 3. No fabricated answers — below the confidence floor the API returns | |
| UNCERTAIN with ranked candidates instead of guessing. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from pathlib import Path | |
| from typing import Optional | |
| import joblib | |
| import numpy as np | |
| import pandas as pd | |
| from sklearn.calibration import CalibratedClassifierCV | |
| from sklearn.ensemble import VotingClassifier | |
| from sklearn.feature_extraction.text import TfidfVectorizer | |
| from sklearn.linear_model import LogisticRegression | |
| from sklearn.metrics import accuracy_score, f1_score | |
| from sklearn.model_selection import StratifiedKFold | |
| from sklearn.naive_bayes import ComplementNB | |
| from sklearn.neighbors import KNeighborsClassifier | |
| from sklearn.pipeline import Pipeline, FeatureUnion | |
| from sklearn.svm import LinearSVC | |
| from config import DATA_DIR, get_logger | |
| from models import embedder | |
| log = get_logger("classifier") | |
| CONFIDENCE_THRESHOLD = 0.35 | |
| HIGH_CONFIDENCE = 0.60 | |
| MIN_TRAIN_ROWS = 10 | |
| MIN_CLASS_SAMPLES = 3 | |
| # Candidate blend weights (tfidf, emb_lr, emb_knn); the best combo per role is | |
| # selected on out-of-fold predictions at train time. | |
| WEIGHT_GRID = [(2, 1, 1), (1, 1, 1), (1, 2, 1), (1, 1, 2), (2, 2, 1)] | |
| DEFAULT_WEIGHTS = (2.0, 1.0, 1.0) | |
| INDONESIAN_STOPWORDS = { | |
| "yang", "di", "ke", "dan", "atau", "dengan", "untuk", "dari", "ini", "itu", | |
| "pada", "oleh", "karena", "dalam", "bahwa", "tidak", "ada", "akan", "sudah", | |
| "bisa", "juga", "tersebut", "dapat", "saat", "telah", "setelah", "ketika", | |
| "seperti", "lebih", "sangat", "masih", "sesuai", "agar", "sehingga", | |
| "namun", "tetapi", "maka", "jika", "apabila", "walaupun", "meskipun", | |
| "sebelum", "antara", "terhadap", "melalui", "berdasarkan", "atas", | |
| "secara", "hal", "serta", "pihak", "selama", "kepada", "para", "suatu", | |
| "nya", "kita", "kami", "mereka", "dia", "belum", "baru", "sebuah", | |
| "demikian", "kemudian", "selanjutnya", | |
| "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", | |
| "have", "has", "had", "do", "does", "did", "will", "would", "could", | |
| "should", "may", "might", "shall", "can", "need", "to", "of", "in", | |
| "on", "at", "by", "for", "with", "about", | |
| } | |
| def _clean_text(text: str) -> str: | |
| if not text or str(text).lower() in ("none", "nan", ""): | |
| return "" | |
| text = str(text).lower() | |
| text = re.sub(r"[^\w\s]", " ", text) | |
| text = re.sub(r"\b\d{3,}\b", " NUM ", text) | |
| tokens = [t for t in text.split() if t not in INDONESIAN_STOPWORDS and len(t) > 1] | |
| return " ".join(tokens) | |
| def _ctx_token(prefix: str, value) -> str: | |
| """Context value → stable feature token, e.g. __air_garuda_indonesia__.""" | |
| if value is None or str(value).strip().lower() in ("", "none", "nan", "no delay"): | |
| return "" | |
| return f"__{prefix}_{str(value).strip().lower().replace(' ', '_')}__" | |
| def build_serve_text( | |
| text: str, | |
| category: Optional[str] = None, | |
| airline: Optional[str] = None, | |
| branch: Optional[str] = None, | |
| area: Optional[str] = None, | |
| sheet: Optional[str] = None, | |
| ) -> str: | |
| """ | |
| The one serve-shape input builder — used identically at train and predict | |
| time. Only fields known when the report is filed may appear here. | |
| """ | |
| parts = [_clean_text(text)] | |
| parts.append(_ctx_token("cat", category)) | |
| parts.append(_ctx_token("air", airline)) | |
| parts.append(_ctx_token("br", branch)) | |
| parts.append(_ctx_token("area", area)) | |
| parts.append(_ctx_token("sheet", sheet)) | |
| return " ".join(p for p in parts if p) | |
| # Backwards-compatible alias (older imports) | |
| def _build_combined_text(desc: str, category=None, airline=None, branch=None, | |
| area=None, delay_code=None, severity=None, | |
| case_classification=None) -> str: | |
| return build_serve_text(desc, category=category, airline=airline, | |
| branch=branch, area=area) | |
| def _tfidf_union() -> FeatureUnion: | |
| return FeatureUnion([ | |
| ("word", TfidfVectorizer( | |
| analyzer="word", ngram_range=(1, 2), min_df=1, max_df=0.95, | |
| max_features=20000, sublinear_tf=True, | |
| stop_words=list(INDONESIAN_STOPWORDS))), | |
| ("char", TfidfVectorizer( | |
| analyzer="char_wb", ngram_range=(2, 5), min_df=1, max_df=0.95, | |
| max_features=15000, sublinear_tf=True)), | |
| ]) | |
| def _make_pipeline() -> Pipeline: | |
| """TF-IDF → soft vote of calibrated LR + calibrated SVC + ComplementNB.""" | |
| return Pipeline([ | |
| ("tfidf", _tfidf_union()), | |
| ("clf", VotingClassifier( | |
| estimators=[ | |
| ("lr", CalibratedClassifierCV( | |
| LogisticRegression(max_iter=3000, class_weight="balanced", C=5.0), | |
| method="sigmoid", cv=3)), | |
| ("svc", CalibratedClassifierCV( | |
| LinearSVC(class_weight="balanced", C=1.0), | |
| method="sigmoid", cv=3)), | |
| ("nb", ComplementNB(alpha=0.3)), | |
| ], | |
| voting="soft", | |
| )), | |
| ]) | |
| def _make_emb_lr() -> CalibratedClassifierCV: | |
| return CalibratedClassifierCV( | |
| LogisticRegression(max_iter=3000, class_weight="balanced", C=10.0), | |
| method="sigmoid", cv=3) | |
| def _fit_emb_lr(E, y): | |
| """Calibrated LR on embeddings; degrades to plain LR when the smallest | |
| class is too small for the calibration folds.""" | |
| min_class = int(pd.Series(y).value_counts().min()) | |
| if min_class >= 3: | |
| return _make_emb_lr().fit(E, y) | |
| if min_class >= 2: | |
| return CalibratedClassifierCV( | |
| LogisticRegression(max_iter=3000, class_weight="balanced", C=10.0), | |
| method="sigmoid", cv=2).fit(E, y) | |
| return LogisticRegression(max_iter=3000, class_weight="balanced", C=10.0).fit(E, y) | |
| def _make_emb_knn() -> KNeighborsClassifier: | |
| return KNeighborsClassifier(n_neighbors=5, metric="cosine", weights="distance") | |
| def _model_path(role: str) -> Path: | |
| return DATA_DIR / f"classifier_{role}.joblib" | |
| def _align(P: np.ndarray, member_classes, target_classes) -> np.ndarray: | |
| out = np.zeros((P.shape[0], len(target_classes))) | |
| order = {c: i for i, c in enumerate(target_classes)} | |
| for j, c in enumerate(member_classes): | |
| out[:, order[c]] = P[:, j] | |
| return out | |
| class _Classifier: | |
| """Tri-vote (TF-IDF ensemble + embedding LR + embedding kNN) with | |
| Platt-recalibrated confidence and an abstention floor.""" | |
| def __init__(self, role: str): | |
| self.role = role | |
| self.pipe: Optional[Pipeline] = None | |
| self.emb_lr = None | |
| self.emb_knn = None | |
| self.platt: Optional[LogisticRegression] = None | |
| self.uses_embeddings = False | |
| self.blend_weights: tuple = DEFAULT_WEIGHTS | |
| # Inputs whose nearest training neighbour is less similar than this | |
| # are out-of-distribution: abstain instead of hallucinating a label. | |
| self.ood_sim_floor: Optional[float] = None | |
| # ── internals ───────────────────────────────────────────────────────── | |
| def _blend(self, X_serve_texts, E: Optional[np.ndarray]) -> tuple[np.ndarray, list]: | |
| wt, wl, wk = self.blend_weights | |
| P = wt * self.pipe.predict_proba(X_serve_texts) | |
| classes = list(self.pipe.classes_) | |
| total = wt | |
| if E is not None and self.emb_lr is not None and self.emb_knn is not None: | |
| P = P + wl * _align(self.emb_lr.predict_proba(E), self.emb_lr.classes_, classes) | |
| P = P + wk * _align(self.emb_knn.predict_proba(E), self.emb_knn.classes_, classes) | |
| total += wl + wk | |
| return P / total, classes | |
| def _recalibrate(self, conf: np.ndarray) -> np.ndarray: | |
| if self.platt is None: | |
| return conf | |
| return self.platt.predict_proba(conf.reshape(-1, 1))[:, 1] | |
| # ── training ────────────────────────────────────────────────────────── | |
| def fit(self, X_serve: pd.Series, y: pd.Series, | |
| X_aug: Optional[pd.Series] = None, | |
| X_raw: Optional[pd.Series] = None) -> dict: | |
| X_serve = X_serve.fillna("").astype(str).reset_index(drop=True) | |
| y = y.fillna("").astype(str).reset_index(drop=True) | |
| X_aug = X_aug.fillna("").astype(str).reset_index(drop=True) if X_aug is not None else None | |
| X_raw = X_raw.fillna("").astype(str).reset_index(drop=True) if X_raw is not None else None | |
| counts = y.value_counts() | |
| valid = counts[counts >= MIN_CLASS_SAMPLES].index | |
| mask = y.isin(valid).values | |
| X_serve, y = X_serve[mask].reset_index(drop=True), y[mask].reset_index(drop=True) | |
| if X_aug is not None: | |
| X_aug = X_aug[mask].reset_index(drop=True) | |
| if X_raw is not None: | |
| X_raw = X_raw[mask].reset_index(drop=True) | |
| if len(X_serve) < MIN_TRAIN_ROWS or y.nunique() < 2: | |
| raise ValueError(f"Insufficient data: {len(X_serve)} rows, {y.nunique()} classes") | |
| # Embeddings for the whole (deduped) training set — None if unavailable | |
| E_all = embedder.encode(X_raw.tolist()) if X_raw is not None else None | |
| self.uses_embeddings = E_all is not None | |
| # ── OOF cross-validation replicating the production recipe ──────── | |
| # Member probabilities are collected per fold so the blend weights can | |
| # be selected on out-of-fold predictions. Note: metrics are reported | |
| # for the selected weights on the same OOF — a small (~5-candidate) | |
| # selection optimism, documented and accepted. | |
| cv_m: dict = {} | |
| n_folds = int(min(5, y.value_counts().min())) | |
| if n_folds >= 2: | |
| skf = StratifiedKFold(n_splits=n_folds, shuffle=True, random_state=42) | |
| all_classes = np.unique(y) | |
| n, C = len(y), len(all_classes) | |
| OP_t, OP_l, OP_k = np.zeros((n, C)), np.zeros((n, C)), np.zeros((n, C)) | |
| for tr, te in skf.split(X_serve, y): | |
| fold = _Classifier(self.role) | |
| fold._fit_members(X_serve.iloc[tr], y.iloc[tr], | |
| X_aug.iloc[tr] if X_aug is not None else None, | |
| E_all[tr] if E_all is not None else None) | |
| Xe = X_serve.iloc[te] | |
| OP_t[te] = _align(fold.pipe.predict_proba(Xe), fold.pipe.classes_, all_classes) | |
| if E_all is not None and fold.emb_lr is not None: | |
| OP_l[te] = _align(fold.emb_lr.predict_proba(E_all[te]), | |
| fold.emb_lr.classes_, all_classes) | |
| OP_k[te] = _align(fold.emb_knn.predict_proba(E_all[te]), | |
| fold.emb_knn.classes_, all_classes) | |
| grid = WEIGHT_GRID if E_all is not None else [(1.0, 0.0, 0.0)] | |
| best_w, best_acc, best_pred, best_conf = None, -1.0, None, None | |
| for w in grid: | |
| wt, wl, wk = w | |
| P = (wt * OP_t + wl * OP_l + wk * OP_k) / (wt + wl + wk) | |
| idx = np.argmax(P, axis=1) | |
| pred_w = all_classes[idx] | |
| acc_w = float(accuracy_score(y, pred_w)) | |
| if acc_w > best_acc: | |
| best_w, best_acc = w, acc_w | |
| best_pred = pred_w | |
| best_conf = P[np.arange(n), idx] | |
| self.blend_weights = tuple(float(v) for v in best_w) | |
| pred, conf = best_pred, best_conf | |
| # Platt recalibration: raw blended confidence → P(top label correct) | |
| correct = (pred == y.values).astype(int) | |
| if 0 < correct.sum() < len(correct): | |
| self.platt = LogisticRegression(max_iter=1000) | |
| self.platt.fit(conf.reshape(-1, 1), correct) | |
| conf_cal = self._recalibrate(conf) | |
| hi = conf_cal >= HIGH_CONFIDENCE | |
| sel_acc = float(accuracy_score(y.values[hi], pred[hi])) if hi.sum() else 0.0 | |
| cv_m = { | |
| "cv_accuracy": round(float(accuracy_score(y, pred)), 4), | |
| "cv_f1_weighted": round(float(f1_score(y, pred, average="weighted", zero_division=0)), 4), | |
| "cv_folds": n_folds, | |
| "blend_weights": list(self.blend_weights), | |
| "hiconf_coverage": round(float(hi.mean()), 4), | |
| "hiconf_accuracy": round(sel_acc, 4), | |
| "effectiveness": round(float(hi.mean()) * sel_acc, 4), | |
| } | |
| # ── final members on all data ────────────────────────────────────── | |
| self._fit_members(X_serve, y, X_aug, E_all) | |
| log.info( | |
| "[%s] cv_acc=%s hiconf_cov=%s hiconf_acc=%s | %d unique rows, %d classes, embeddings=%s", | |
| self.role, cv_m.get("cv_accuracy", "?"), cv_m.get("hiconf_coverage", "?"), | |
| cv_m.get("hiconf_accuracy", "?"), len(X_serve), y.nunique(), self.uses_embeddings, | |
| ) | |
| return { | |
| "accuracy": cv_m.get("cv_accuracy"), | |
| "f1_weighted": cv_m.get("cv_f1_weighted"), | |
| "n_classes": int(y.nunique()), | |
| "n_samples": int(len(X_serve)), | |
| "embeddings": self.uses_embeddings, | |
| **cv_m, | |
| } | |
| def _fit_members(self, X_serve, y, X_aug, E): | |
| if X_aug is not None: | |
| X_fit = pd.concat([X_serve, X_aug], ignore_index=True) | |
| y_fit = pd.concat([y, y], ignore_index=True) | |
| else: | |
| X_fit, y_fit = X_serve, y | |
| self.pipe = _make_pipeline() | |
| self.pipe.fit(X_fit, y_fit) | |
| if E is not None: | |
| self.emb_lr = _fit_emb_lr(E, y) | |
| self.emb_knn = _make_emb_knn() | |
| self.emb_knn.fit(E, y) | |
| # Data-driven OOD floor: 1st percentile of each training row's | |
| # nearest-neighbour similarity (2nd neighbour = excludes self), | |
| # clipped to a sane band. | |
| dist, _ = self.emb_knn.kneighbors(E, n_neighbors=min(2, len(E))) | |
| nn_sim = 1.0 - dist[:, -1] | |
| self.ood_sim_floor = float(np.clip(np.percentile(nn_sim, 1), 0.30, 0.55)) | |
| else: | |
| self.emb_lr = self.emb_knn = None | |
| self.ood_sim_floor = None | |
| # ── inference ───────────────────────────────────────────────────────── | |
| def predict(self, serve_text: str, raw_text: Optional[str] = None) -> dict: | |
| if not serve_text or not serve_text.strip(): | |
| return {"label": None, "confidence": None, "status": "empty_input"} | |
| if self.pipe is None: | |
| return {"label": None, "confidence": None, "status": "model_not_trained"} | |
| E = None | |
| degraded = False | |
| if self.uses_embeddings: | |
| E = embedder.encode([raw_text or serve_text]) | |
| if E is None: | |
| degraded = True # trained with embeddings, serving without | |
| P, classes = self._blend([serve_text], E) | |
| proba = P[0] | |
| order = np.argsort(proba)[::-1] | |
| conf_raw = float(proba[order[0]]) | |
| conf = float(self._recalibrate(np.array([conf_raw]))[0]) | |
| top = [ | |
| {"label": classes[i], "confidence": round(float(proba[i]), 4)} | |
| for i in order[:3] | |
| ] | |
| out: dict = {"confidence": round(conf, 4), "top_candidates": top} | |
| if degraded: | |
| out["degraded"] = "embeddings_unavailable" | |
| # OOD guard: an input unlike anything in training must not get a | |
| # confident label, no matter what the calibrated blend says. | |
| if E is not None and self.emb_knn is not None and self.ood_sim_floor is not None: | |
| dist, _ = self.emb_knn.kneighbors(E, n_neighbors=1) | |
| nn_sim = float(1.0 - dist[0][0]) | |
| out["nn_similarity"] = round(nn_sim, 3) | |
| if nn_sim < self.ood_sim_floor: | |
| out["confidence"] = round(min(conf, CONFIDENCE_THRESHOLD - 0.01), 4) | |
| return {**out, "label": "UNCERTAIN", "status": "low_confidence", | |
| "reason": "input_dissimilar_to_training_data"} | |
| if conf < CONFIDENCE_THRESHOLD: | |
| return {**out, "label": "UNCERTAIN", "status": "low_confidence"} | |
| return { | |
| **out, | |
| "label": classes[order[0]], | |
| "status": "ok" if conf >= HIGH_CONFIDENCE else "medium_confidence", | |
| } | |
| def __getstate__(self): | |
| # embedder singleton is module-level; nothing torch-y lives on self | |
| return self.__dict__ | |
| # ── Public API ──────────────────────────────────────────────────────────────── | |
| # In-memory cache so /classify doesn't joblib.load on every request. | |
| _CACHE: dict[str, tuple[float, "_Classifier"]] = {} | |
| def train(X: pd.Series, y: pd.Series, role: str, | |
| X_aug: Optional[pd.Series] = None, | |
| X_raw: Optional[pd.Series] = None) -> dict: | |
| clf = _Classifier(role) | |
| metrics = clf.fit(X, y, X_aug=X_aug, X_raw=X_raw) | |
| DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| joblib.dump(clf, _model_path(role)) | |
| _CACHE.pop(role, None) | |
| return metrics | |
| def _load(role: str) -> Optional["_Classifier"]: | |
| path = _model_path(role) | |
| if not path.exists(): | |
| return None | |
| mtime = path.stat().st_mtime | |
| cached = _CACHE.get(role) | |
| if cached and cached[0] == mtime: | |
| return cached[1] | |
| clf: _Classifier = joblib.load(path) | |
| _CACHE[role] = (mtime, clf) | |
| return clf | |
| def predict(text: str, role: str, | |
| category: Optional[str] = None, | |
| airline: Optional[str] = None, | |
| branch: Optional[str] = None, | |
| area: Optional[str] = None, | |
| sheet: Optional[str] = None, | |
| category_hint: Optional[str] = None) -> dict: | |
| clf = _load(role) | |
| if clf is None: | |
| return {"label": None, "confidence": None, "status": "model_not_trained"} | |
| serve_text = build_serve_text( | |
| text, | |
| category=category or category_hint, | |
| airline=airline, branch=branch, area=area, sheet=sheet, | |
| ) | |
| return clf.predict(serve_text, raw_text=str(text).strip()) | |
| def model_exists(role: str) -> bool: | |
| return _model_path(role).exists() | |