Spaces:
Sleeping
Sleeping
File size: 8,413 Bytes
021e07f 200d08b 021e07f 200d08b 021e07f e2fdbf7 021e07f 200d08b 021e07f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 | """
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
|