kz110AIPI
Initial commit for Module 2 NLP project
2f74c56
Raw
History Blame Contribute Delete
7.78 kB
# Portions of this file were developed with assistance from OpenAI ChatGPT/Codex and reviewed/modified by the author.
"""Model definitions for baseline, classical ML, and transformer approaches.
Portions of this file were developed with assistance from OpenAI ChatGPT/Codex and reviewed/modified by the author.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import joblib
import numpy as np
import pandas as pd
from sklearn.dummy import DummyClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from campus_triage.config import CATEGORY_LABELS, TRANSFORMER_MODEL_DIR, URGENCY_LABELS
from campus_triage.features import combine_text_fields
@dataclass
class DualClassifier:
"""Container for separate category and urgency classifiers."""
category_model: Any
urgency_model: Any
model_name: str
def predict(self, dataframe: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]:
"""Predict category and urgency labels."""
text_features = combine_text_fields(dataframe)
return self.category_model.predict(text_features), self.urgency_model.predict(text_features)
def predict_one(self, message_text: str) -> dict[str, Any]:
"""Predict one message with confidence scores when available."""
dataframe = pd.DataFrame({"message_text": [message_text]})
text_features = combine_text_fields(dataframe)
category_prediction = self.category_model.predict(text_features)[0]
urgency_prediction = self.urgency_model.predict(text_features)[0]
category_scores = prediction_scores(self.category_model, text_features, CATEGORY_LABELS)
urgency_scores = prediction_scores(self.urgency_model, text_features, URGENCY_LABELS)
return {
"category": category_prediction,
"urgency": urgency_prediction,
"category_scores": category_scores,
"urgency_scores": urgency_scores,
"category_confidence": category_scores.get(category_prediction, 0.0),
"urgency_confidence": urgency_scores.get(urgency_prediction, 0.0),
}
class TransformerTextClassifier:
"""Hugging Face text classifier wrapper with a scikit-learn-like API."""
def __init__(self, model_dir: Path, batch_size: int = 16) -> None:
"""Load a fine-tuned transformer model and tokenizer."""
if not model_dir.exists():
raise FileNotFoundError(f"Transformer model directory not found: {model_dir}")
from transformers import AutoModelForSequenceClassification, AutoTokenizer
import torch
self.model_dir = model_dir
self.batch_size = batch_size
self.tokenizer = AutoTokenizer.from_pretrained(str(model_dir))
self.model = AutoModelForSequenceClassification.from_pretrained(str(model_dir))
self.model.eval()
self.torch = torch
self.classes_ = [self.model.config.id2label[index] for index in range(self.model.config.num_labels)]
def predict_proba(self, text_features: pd.Series) -> np.ndarray:
"""Return class probabilities for input messages."""
texts = [str(text) for text in text_features.tolist()]
probability_batches = []
with self.torch.no_grad():
for start in range(0, len(texts), self.batch_size):
batch_texts = texts[start : start + self.batch_size]
encoded = self.tokenizer(batch_texts, truncation=True, padding=True, max_length=128, return_tensors="pt")
outputs = self.model(**encoded)
probabilities = self.torch.softmax(outputs.logits, dim=-1).cpu().numpy()
probability_batches.append(probabilities)
return np.vstack(probability_batches)
def predict(self, text_features: pd.Series) -> np.ndarray:
"""Predict labels for input messages."""
probabilities = self.predict_proba(text_features)
label_indexes = probabilities.argmax(axis=1)
return np.array([self.classes_[index] for index in label_indexes])
def prediction_scores(model: Any, text_features: pd.Series, labels: list[str]) -> dict[str, float]:
"""Return probability-like scores for a classifier."""
if hasattr(model, "predict_proba"):
probabilities = model.predict_proba(text_features)[0]
classes = list(model.classes_)
return {label: float(probabilities[classes.index(label)]) if label in classes else 0.0 for label in labels}
decision_scores = model.decision_function(text_features)[0]
exp_scores = np.exp(decision_scores - np.max(decision_scores))
probabilities = exp_scores / exp_scores.sum()
classes = list(model.classes_)
return {label: float(probabilities[classes.index(label)]) if label in classes else 0.0 for label in labels}
def build_baseline_model(train_dataframe: pd.DataFrame) -> DualClassifier:
"""Train majority-class baseline classifiers for category and urgency."""
text_features = combine_text_fields(train_dataframe)
category_model = DummyClassifier(strategy="most_frequent")
urgency_model = DummyClassifier(strategy="most_frequent")
category_model.fit(text_features, train_dataframe["category"])
urgency_model.fit(text_features, train_dataframe["urgency"])
return DualClassifier(category_model=category_model, urgency_model=urgency_model, model_name="majority_baseline")
def build_text_pipeline() -> Pipeline:
"""Create the TF-IDF plus Logistic Regression pipeline."""
return Pipeline(
steps=[
("tfidf", TfidfVectorizer(ngram_range=(1, 2), min_df=2, max_features=20000, sublinear_tf=True)),
("classifier", LogisticRegression(max_iter=1000, class_weight="balanced", n_jobs=None)),
]
)
def build_classical_model(train_dataframe: pd.DataFrame) -> DualClassifier:
"""Train TF-IDF Logistic Regression classifiers for category and urgency."""
text_features = combine_text_fields(train_dataframe)
category_model = build_text_pipeline()
urgency_model = build_text_pipeline()
category_model.fit(text_features, train_dataframe["category"])
urgency_model.fit(text_features, train_dataframe["urgency"])
return DualClassifier(category_model=category_model, urgency_model=urgency_model, model_name="tfidf_logistic_regression")
def load_transformer_dual_classifier(model_dir: Path = TRANSFORMER_MODEL_DIR) -> DualClassifier:
"""Load fine-tuned transformer category and urgency classifiers."""
return DualClassifier(
category_model=TransformerTextClassifier(model_dir / "category"),
urgency_model=TransformerTextClassifier(model_dir / "urgency"),
model_name="distilbert_transformer",
)
def transformer_model_available(model_dir: Path = TRANSFORMER_MODEL_DIR) -> bool:
"""Return whether fine-tuned transformer checkpoints exist."""
category_config = model_dir / "category" / "config.json"
urgency_config = model_dir / "urgency" / "config.json"
return category_config.exists() and urgency_config.exists()
def save_dual_classifier(model: DualClassifier, path: str) -> None:
"""Persist a dual classifier with joblib."""
joblib.dump(model, path)
def load_dual_classifier(path: str) -> DualClassifier:
"""Load a persisted dual classifier."""
return joblib.load(path)
def transformer_dependencies_available() -> bool:
"""Return whether optional transformer dependencies can be imported."""
try:
import datasets # noqa: F401
import torch # noqa: F401
import transformers # noqa: F401
except ImportError:
return False
return True