maude-nlp-classifier / src /model /classifier.py
Mukund
Prepare HF Space deploy: Streamlit app, slimmer requirements, Spaces frontmatter
e2fdbf7
Raw
History Blame Contribute Delete
8.41 kB
"""
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