""" TF-IDF + Classifier Pipeline for MAUDE Adverse Event Severity Classification. Supports: - Logistic Regression (default) - Linear SVM (SVC) - Grid search hyperparameter tuning - Model persistence (joblib) - Evaluation metrics (classification report, confusion matrix) """ import os import logging from typing import Tuple, Optional import joblib import numpy as np import pandas as pd from sklearn.pipeline import Pipeline from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression from sklearn.svm import LinearSVC from sklearn.model_selection import train_test_split, GridSearchCV, StratifiedKFold from sklearn.metrics import ( classification_report, confusion_matrix, accuracy_score, f1_score, ) from sklearn.dummy import DummyClassifier from sklearn.model_selection import cross_val_score from sklearn.utils.class_weight import compute_class_weight logger = logging.getLogger(__name__) LABEL_COL = "severity_label" TEXT_COL = "clean_text" # Short label codes: D=Death, I=Injury, M=Malfunction, O=Other/Unknown LABEL_ORDER = ["D", "I", "M", "O", "UNKNOWN"] def build_pipeline(model_type: str = "logreg") -> Pipeline: """ Build a scikit-learn Pipeline with TF-IDF vectorizer and a classifier. Args: model_type: 'logreg' for Logistic Regression, 'svm' for Linear SVM. Returns: sklearn Pipeline object (untrained). """ tfidf = TfidfVectorizer( ngram_range=(1, 2), # unigrams + bigrams max_features=50_000, sublinear_tf=True, # apply log normalization to TF min_df=3, # ignore terms appearing in fewer than 3 docs max_df=0.95, # ignore terms appearing in >95% of docs strip_accents="unicode", analyzer="word", token_pattern=r"\b[a-zA-Z][a-zA-Z]+\b", # skip single chars & numbers ) if model_type == "svm": clf = LinearSVC( C=1.0, class_weight="balanced", max_iter=2000, ) else: # default: logreg clf = LogisticRegression( C=1.0, max_iter=1000, class_weight="balanced", solver="lbfgs", ) return Pipeline([("tfidf", tfidf), ("clf", clf)]) def split_data( df: pd.DataFrame, test_size: float = 0.2, random_state: int = 42, ) -> Tuple[pd.Series, pd.Series, pd.Series, pd.Series]: """Stratified train/test split.""" X = df[TEXT_COL] y = df[LABEL_COL] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=test_size, random_state=random_state, stratify=y, ) logger.info(f"Train size: {len(X_train)}, Test size: {len(X_test)}") return X_train, X_test, y_train, y_test def tune_pipeline( pipeline: Pipeline, X_train: pd.Series, y_train: pd.Series, model_type: str = "logreg", ) -> Pipeline: """ Run grid search to find best hyperparameters. Args: pipeline: Untrained sklearn Pipeline. X_train: Training text series. y_train: Training label series. model_type: 'logreg' or 'svm'. Returns: Best estimator from GridSearchCV. """ param_grid = { "tfidf__ngram_range": [(1, 1), (1, 2)], "tfidf__max_features": [30_000, 50_000], "clf__C": [0.1, 1.0, 10.0], } cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) grid = GridSearchCV( pipeline, param_grid, cv=cv, scoring="f1_weighted", n_jobs=-1, verbose=1, ) logger.info("Running grid search...") grid.fit(X_train, y_train) logger.info(f"Best params: {grid.best_params_}") logger.info(f"Best CV F1 (weighted): {grid.best_score_:.4f}") return grid.best_estimator_ def train_pipeline( pipeline: Pipeline, X_train: pd.Series, y_train: pd.Series, ) -> Pipeline: """Train the pipeline directly (no grid search).""" logger.info("Training pipeline...") pipeline.fit(X_train, y_train) return pipeline def evaluate( pipeline: Pipeline, X_test: pd.Series, y_test: pd.Series, ) -> dict: """ Evaluate trained pipeline and return metrics. Returns: Dict with accuracy, f1_weighted, classification_report, confusion_matrix. """ y_pred = pipeline.predict(X_test) acc = accuracy_score(y_test, y_pred) f1 = f1_score(y_test, y_pred, average="weighted", zero_division=0) report = classification_report(y_test, y_pred, zero_division=0) cm = confusion_matrix(y_test, y_pred, labels=pipeline.classes_) logger.info(f"\nAccuracy: {acc:.4f} | Weighted F1: {f1:.4f}") logger.info(f"\nClassification Report:\n{report}") return { "accuracy": acc, "f1_weighted": f1, "classification_report": report, "confusion_matrix": cm, "classes": list(pipeline.classes_), } def predict_single(pipeline: Pipeline, text: str) -> dict: """ Run inference on a single narrative text string. Returns: Dict with predicted label and per-class probabilities (if available). """ prediction = pipeline.predict([text])[0] result = {"predicted_label": prediction} clf = pipeline.named_steps["clf"] if hasattr(clf, "predict_proba"): proba = pipeline.predict_proba([text])[0] result["probabilities"] = dict(zip(pipeline.classes_, proba.tolist())) elif hasattr(clf, "decision_function"): scores = pipeline.decision_function([text])[0] result["decision_scores"] = dict(zip(pipeline.classes_, scores.tolist())) return result def cross_validate_pipeline( pipeline: Pipeline, X: pd.Series, y: pd.Series, n_splits: int = 5, ) -> dict: """ Run StratifiedKFold cross-validation and return per-fold and mean scores. This is the rigorous evaluation the adviser recommended: it gives a realistic estimate of generalisation performance even on small datasets, and catches inflated accuracy caused by lucky train/test splits. Args: pipeline: Untrained (or freshly built) sklearn Pipeline. X: Full text series (before split). y: Full label series. n_splits: Number of CV folds (default 5). Returns: Dict with per-fold f1 scores, mean, and std. """ cv = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42) scores = cross_val_score( pipeline, X, y, cv=cv, scoring="f1_weighted", n_jobs=-1, ) result = { "cv_f1_per_fold": scores.tolist(), "cv_f1_mean": float(scores.mean()), "cv_f1_std": float(scores.std()), "n_splits": n_splits, } logger.info( f"StratifiedKFold ({n_splits}-fold) F1: " f"{scores.mean():.4f} ± {scores.std():.4f}" ) return result def dummy_baseline( X_train: pd.Series, y_train: pd.Series, X_test: pd.Series, y_test: pd.Series, ) -> dict: """ Fit a most-frequent DummyClassifier and return its weighted F1. Comparing against this baseline is a basic sanity check: if your model barely beats a dummy classifier that always predicts the majority class, the model has not actually learned anything useful from the text. Returns: Dict with dummy accuracy and f1_weighted. """ dummy = DummyClassifier(strategy="most_frequent", random_state=42) dummy.fit(X_train, y_train) y_pred = dummy.predict(X_test) acc = accuracy_score(y_test, y_pred) f1 = f1_score(y_test, y_pred, average="weighted", zero_division=0) logger.info(f"Dummy baseline — Accuracy: {acc:.4f} | Weighted F1: {f1:.4f}") return {"dummy_accuracy": acc, "dummy_f1_weighted": f1} def save_model(pipeline: Pipeline, path: str = "models/maude_classifier.joblib") -> None: """Persist trained pipeline to disk.""" os.makedirs(os.path.dirname(path), exist_ok=True) joblib.dump(pipeline, path) logger.info(f"Model saved to {path}") def load_model(path: str = "models/maude_classifier.joblib") -> Pipeline: """Load a persisted pipeline from disk.""" if not os.path.exists(path): raise FileNotFoundError(f"Model not found at {path}") pipeline = joblib.load(path) logger.info(f"Model loaded from {path}") return pipeline