import time from pathlib import Path import matplotlib.pyplot as plt import mlflow import numpy as np import pandas as pd import torch from sklearn.metrics import ConfusionMatrixDisplay, confusion_matrix from torch.utils.data import DataLoader from src.data.loader import load_splits from src.data.preprocessor import preprocess from src.features.tfidf import load_vectorizer, transform from src.models.classical import LogisticRegressionModel, SVMModel from src.models.neural import IntentDatasetNN, LSTMModel, TextCNN, Vocabulary from src.models.transformer import IntentDatasetHF, TransformerModel from src.utils.config import load_config from src.utils.mlflow_utils import get_or_create_experiment from src.utils.settings import settings VECTORIZER_PATH = "artifacts/vectorizers/tfidf.pkl" VOCAB_PATH = "artifacts/models/vocab.pkl" LOGREG_PATH = "artifacts/models/logreg.pkl" SVM_PATH = "artifacts/models/svm.pkl" TEXTCNN_PATH = "artifacts/models/textcnn.pt" RNN_PATH = "artifacts/models/rnn.pt" LSTM_PATH = "artifacts/models/lstm.pt" DISTILBERT_DIR = "artifacts/models/distilbert" MAX_LENGTH_NN = 32 MAX_LENGTH_HF = 128 REPORT_DIR = Path("artifacts/evaluation") def load_data(): splits = load_splits("data/raw") processed, label_map = preprocess(splits) id_to_label = {v: k for k, v in label_map.items()} label_names = [id_to_label[i] for i in range(len(label_map))] return processed, label_map, label_names def compute_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict: from sklearn.metrics import accuracy_score, f1_score return { "accuracy": round(accuracy_score(y_true, y_pred), 4), "macro_f1": round(f1_score(y_true, y_pred, average="macro", zero_division=0), 4), "weighted_f1": round(f1_score(y_true, y_pred, average="weighted", zero_division=0), 4), } def measure_latency(predict_fn, input_data, n_runs: int = 50) -> dict: latencies = [] for _ in range(n_runs): start = time.perf_counter() predict_fn(input_data) latencies.append((time.perf_counter() - start) * 1000) latencies = np.array(latencies) return { "latency_mean_ms": round(float(np.mean(latencies)), 3), "latency_p50_ms": round(float(np.percentile(latencies, 50)), 3), "latency_p95_ms": round(float(np.percentile(latencies, 95)), 3), "latency_p99_ms": round(float(np.percentile(latencies, 99)), 3), } def eval_logreg(processed: dict, label_names: list[str]) -> tuple[dict, np.ndarray]: print(" evaluating logistic regression...") vectorizer = load_vectorizer(VECTORIZER_PATH) X_test = transform(vectorizer, processed["test"]["text"].tolist()) y_test = processed["test"]["label_id"].values model = LogisticRegressionModel() model.load(LOGREG_PATH) y_pred = model.predict(X_test) metrics = compute_metrics(y_test, y_pred) latency = measure_latency(model.predict, X_test[:100]) return {**metrics, **latency}, y_pred def eval_svm(processed: dict, label_names: list[str]) -> tuple[dict, np.ndarray]: print(" evaluating svm...") vectorizer = load_vectorizer(VECTORIZER_PATH) X_test = transform(vectorizer, processed["test"]["text"].tolist()) y_test = processed["test"]["label_id"].values model = SVMModel() model.load(SVM_PATH) y_pred = model.predict(X_test) metrics = compute_metrics(y_test, y_pred) latency = measure_latency(model.predict, X_test[:100]) return {**metrics, **latency}, y_pred def eval_neural( model_type: str, model_path: str, processed: dict, label_map: dict, ) -> tuple[dict, np.ndarray]: print(f" evaluating {model_type}...") vocab = Vocabulary.load(VOCAB_PATH) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") num_classes = len(label_map) neural_config = load_config("neural") model_cfg = neural_config["model"][model_type] if model_type == "textcnn": model = TextCNN( vocab_size=len(vocab), embedding_dim=model_cfg["embedding_dim"], num_filters=model_cfg["num_filters"], kernel_sizes=model_cfg["kernel_sizes"], num_classes=num_classes, dropout=model_cfg["dropout"], ) elif model_type == "rnn": from src.models.neural import RNNModel model = RNNModel( vocab_size=len(vocab), embedding_dim=model_cfg["embedding_dim"], hidden_dim=model_cfg["hidden_dim"], num_layers=model_cfg["num_layers"], num_classes=num_classes, dropout=model_cfg["dropout"], ) elif model_type == "lstm": model = LSTMModel( vocab_size=len(vocab), embedding_dim=model_cfg["embedding_dim"], hidden_dim=model_cfg["hidden_dim"], num_layers=model_cfg["num_layers"], num_classes=num_classes, dropout=model_cfg["dropout"], ) model.load(model_path) model.to(device) model.eval() dataset = IntentDatasetNN( processed["test"]["text"].tolist(), processed["test"]["label_id"].tolist(), vocab, MAX_LENGTH_NN, ) loader = DataLoader(dataset, batch_size=64, shuffle=False) all_preds = [] y_test = processed["test"]["label_id"].values with torch.no_grad(): for inputs, labels in loader: inputs = inputs.to(device) logits = model(inputs) preds = logits.argmax(dim=1).cpu().numpy() all_preds.extend(preds) y_pred = np.array(all_preds) metrics = compute_metrics(y_test, y_pred) def predict_fn(loader): with torch.no_grad(): for inputs, _ in loader: model(inputs.to(device)) latency = measure_latency(predict_fn, loader, n_runs=20) return {**metrics, **latency}, y_pred def eval_distilbert(processed: dict, label_map: dict) -> tuple[dict, np.ndarray]: print(" evaluating distilbert...") device = torch.device("cuda" if torch.cuda.is_available() else "cpu") transformer = TransformerModel( model_name=DISTILBERT_DIR, num_labels=len(label_map), ) transformer.model.to(device) transformer.model.eval() dataset = IntentDatasetHF( processed["test"]["text"].tolist(), processed["test"]["label_id"].tolist(), transformer.tokenizer, MAX_LENGTH_HF, ) loader = DataLoader(dataset, batch_size=32, shuffle=False) all_preds = [] y_test = processed["test"]["label_id"].values with torch.no_grad(): for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) outputs = transformer.model(input_ids=input_ids, attention_mask=attention_mask) preds = outputs.logits.argmax(dim=1).cpu().numpy() all_preds.extend(preds) y_pred = np.array(all_preds) metrics = compute_metrics(y_test, y_pred) single_text = processed["test"]["text"].iloc[:1].tolist() single_dataset = IntentDatasetHF( single_text, [0], transformer.tokenizer, MAX_LENGTH_HF, ) single_loader = DataLoader(single_dataset, batch_size=1) def predict_fn(loader): with torch.no_grad(): for batch in loader: transformer.model( input_ids=batch["input_ids"].to(device), attention_mask=batch["attention_mask"].to(device), ) latency = measure_latency(predict_fn, single_loader, n_runs=50) return {**metrics, **latency}, y_pred def plot_comparison(results: dict, save_path: str) -> None: df = pd.DataFrame(results).T.reset_index() df.columns = ["model"] + list(df.columns[1:]) fig, axes = plt.subplots(1, 3, figsize=(18, 6)) metrics = ["accuracy", "macro_f1", "weighted_f1"] titles = ["Accuracy", "Macro F1", "Weighted F1"] colors = ["#4C72B0", "#DD8452", "#55A868", "#C44E52", "#8172B2", "#937860"] for ax, metric, title in zip(axes, metrics, titles): bars = ax.bar(df["model"], df[metric].astype(float), color=colors) ax.set_title(title, fontsize=14) ax.set_ylim(0, 1.1) ax.set_xticks(range(len(df["model"]))) ax.set_xticklabels(df["model"], rotation=30, ha="right") for bar, val in zip(bars, df[metric].astype(float)): ax.text( bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, f"{val:.3f}", ha="center", va="bottom", fontsize=9, ) plt.suptitle("Model Comparison — CLINC150", fontsize=16, y=1.02) plt.tight_layout() plt.savefig(save_path, dpi=100, bbox_inches="tight") plt.close() print(f" saved: {save_path}") def plot_latency(results: dict, save_path: str) -> None: models = list(results.keys()) p50 = [results[m]["latency_p50_ms"] for m in models] p95 = [results[m]["latency_p95_ms"] for m in models] x = np.arange(len(models)) width = 0.35 fig, ax = plt.subplots(figsize=(12, 6)) ax.bar(x - width / 2, p50, width, label="P50", color="#4C72B0") ax.bar(x + width / 2, p95, width, label="P95", color="#DD8452") ax.set_yscale("log") ax.set_ylabel("Latency (ms) — log scale") ax.set_title("Inference Latency Comparison") ax.set_xticks(x) ax.set_xticklabels(models, rotation=30, ha="right") ax.legend() plt.tight_layout() plt.savefig(save_path, dpi=100, bbox_inches="tight") plt.close() print(f" saved: {save_path}") def get_top_confused_pairs( y_true: np.ndarray, y_pred: np.ndarray, label_names: list[str], top_n: int = 20, ) -> pd.DataFrame: cm = confusion_matrix(y_true, y_pred) np.fill_diagonal(cm, 0) pairs = [] for i in range(cm.shape[0]): for j in range(cm.shape[1]): if cm[i, j] > 0: pairs.append( { "true_label": label_names[i], "predicted_label": label_names[j], "count": int(cm[i, j]), } ) df = pd.DataFrame(pairs).sort_values("count", ascending=False).head(top_n) return df.reset_index(drop=True) def plot_top_confused_pairs(df: pd.DataFrame, model_name: str, save_path: str) -> None: fig, ax = plt.subplots(figsize=(10, max(6, len(df) * 0.35))) labels = [f"{row.true_label} -> {row.predicted_label}" for row in df.itertuples()] ax.barh(labels, df["count"], color="#C44E52") ax.invert_yaxis() ax.set_xlabel("Misclassification Count") ax.set_title(f"Top Confused Pairs — {model_name}") plt.tight_layout() plt.savefig(save_path, dpi=100, bbox_inches="tight") plt.close() print(f" saved: {save_path}") def plot_oos_binary_confusion( y_true: np.ndarray, y_pred: np.ndarray, label_map: dict, model_name: str, save_path: str, ) -> None: oos_id = label_map.get("oos") y_true_binary = (y_true == oos_id).astype(int) y_pred_binary = (y_pred == oos_id).astype(int) cm = confusion_matrix(y_true_binary, y_pred_binary) fig, ax = plt.subplots(figsize=(5, 5)) disp = ConfusionMatrixDisplay( confusion_matrix=cm, display_labels=["in-scope", "oos"], ) disp.plot(ax=ax, colorbar=False, cmap="Blues") ax.set_title(f"OOS Detection — {model_name}") plt.tight_layout() plt.savefig(save_path, dpi=100, bbox_inches="tight") plt.close() print(f" saved: {save_path}") def main(): REPORT_DIR.mkdir(parents=True, exist_ok=True) print("loading data...") processed, label_map, label_names = load_data() y_test = processed["test"]["label_id"].values print("\nevaluating all models...") results = {} predictions = {} results["logreg"], predictions["logreg"] = eval_logreg(processed, label_names) results["svm"], predictions["svm"] = eval_svm(processed, label_names) results["textcnn"], predictions["textcnn"] = eval_neural("textcnn", TEXTCNN_PATH, processed, label_map) results["rnn"], predictions["rnn"] = eval_neural("rnn", RNN_PATH, processed, label_map) results["lstm"], predictions["lstm"] = eval_neural("lstm", LSTM_PATH, processed, label_map) results["distilbert"], predictions["distilbert"] = eval_distilbert(processed, label_map) print("\ngenerating plots...") plot_comparison(results, str(REPORT_DIR / "model_comparison.png")) plot_latency(results, str(REPORT_DIR / "latency_comparison.png")) for model_name, y_pred in predictions.items(): confused_df = get_top_confused_pairs(y_test, y_pred, label_names, top_n=20) confused_csv_path = REPORT_DIR / f"{model_name}_top_confused_pairs.csv" confused_df.to_csv(confused_csv_path, index=False) print(f" saved: {confused_csv_path}") plot_top_confused_pairs( confused_df, model_name, str(REPORT_DIR / f"{model_name}_top_confused_pairs.png"), ) plot_oos_binary_confusion( y_test, y_pred, label_map, model_name, str(REPORT_DIR / f"{model_name}_oos_binary.png"), ) print("\nlogging to MLflow...") mlflow.set_tracking_uri(settings.mlflow_tracking_uri) experiment_id = get_or_create_experiment("intent-classifier") with mlflow.start_run(experiment_id=experiment_id, run_name="unified-evaluation"): for model_name, metrics in results.items(): for metric_name, value in metrics.items(): mlflow.log_metric(f"{model_name}.{metric_name}", value) mlflow.log_artifact(str(REPORT_DIR / "model_comparison.png")) mlflow.log_artifact(str(REPORT_DIR / "latency_comparison.png")) print("\nfinal results:") print(f"{'model':<14} {'accuracy':<12} {'macro_f1':<12} {'weighted_f1':<14} {'p50_ms':<12} {'p95_ms'}") print("-" * 78) for model_name, metrics in results.items(): print( f"{model_name:<14} " f"{metrics['accuracy']:<12} " f"{metrics['macro_f1']:<12} " f"{metrics['weighted_f1']:<14} " f"{metrics['latency_p50_ms']:<12} " f"{metrics['latency_p95_ms']}" ) if __name__ == "__main__": main()