File size: 8,726 Bytes
2f74c56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# Portions of this file were developed with assistance from OpenAI ChatGPT/Codex and reviewed/modified by the author.
"""Evaluation and error analysis for Campus Triage models.

Portions of this file were developed with assistance from OpenAI ChatGPT/Codex and reviewed/modified by the author.
"""

from __future__ import annotations

from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, f1_score

from campus_triage.config import (
    BASELINE_MODEL_PATH,
    CATEGORY_LABELS,
    CLASSICAL_MODEL_PATH,
    OUTPUTS_DIR,
    TEST_PATH,
    TRAIN_PATH,
    URGENCY_LABELS,
)
from campus_triage.data import create_and_save_dataset
from campus_triage.models import (
    DualClassifier,
    load_dual_classifier,
    load_transformer_dual_classifier,
    transformer_model_available,
)
from campus_triage.train import train_all


def ensure_models_and_data() -> None:
    """Create data and default non-transformer models when they do not exist yet."""

    if not TRAIN_PATH.exists() or not TEST_PATH.exists():
        create_and_save_dataset()
    if not BASELINE_MODEL_PATH.exists() or not CLASSICAL_MODEL_PATH.exists():
        train_all(include_transformer=False)


def load_evaluation_models() -> list[DualClassifier]:
    """Load every trained model available for evaluation."""

    models = [
        load_dual_classifier(str(BASELINE_MODEL_PATH)),
        load_dual_classifier(str(CLASSICAL_MODEL_PATH)),
    ]
    if transformer_model_available():
        models.append(load_transformer_dual_classifier())
    return models


def label_metrics(y_true: pd.Series, y_pred: pd.Series, prefix: str) -> dict[str, float]:
    """Compute high-level metrics for one label task."""

    return {
        f"{prefix}_accuracy": accuracy_score(y_true, y_pred),
        f"{prefix}_macro_f1": f1_score(y_true, y_pred, average="macro", zero_division=0),
        f"{prefix}_weighted_f1": f1_score(y_true, y_pred, average="weighted", zero_division=0),
    }


def evaluate_dual_classifier(model: DualClassifier, test_dataframe: pd.DataFrame) -> dict[str, float | str]:
    """Evaluate category and urgency predictions for a model."""

    category_predictions, urgency_predictions = model.predict(test_dataframe)
    results: dict[str, float | str] = {"model": model.model_name}
    results.update(label_metrics(test_dataframe["category"], category_predictions, "category"))
    results.update(label_metrics(test_dataframe["urgency"], urgency_predictions, "urgency"))
    return results


def save_confusion_matrix(y_true: pd.Series, y_pred: pd.Series, labels: list[str], title: str, output_path: Path) -> None:
    """Save a confusion matrix plot."""

    matrix = confusion_matrix(y_true, y_pred, labels=labels)
    plt.figure(figsize=(9, 6))
    plt.imshow(matrix, interpolation="nearest", cmap="Blues")
    plt.colorbar()
    plt.title(title)
    tick_marks = range(len(labels))
    plt.xticks(tick_marks, labels, rotation=35, ha="right")
    plt.yticks(tick_marks, labels)
    threshold = matrix.max() / 2 if matrix.size else 0
    for row_index in range(matrix.shape[0]):
        for column_index in range(matrix.shape[1]):
            value = matrix[row_index, column_index]
            color = "white" if value > threshold else "black"
            plt.text(column_index, row_index, str(value), ha="center", va="center", color=color)
    plt.xlabel("Predicted")
    plt.ylabel("True")
    plt.tight_layout()
    plt.savefig(output_path, dpi=160)
    plt.close()


def build_reports(models: list[DualClassifier], test_dataframe: pd.DataFrame) -> str:
    """Build plain-text per-class classification reports."""

    report_sections = []
    for model in models:
        category_predictions, urgency_predictions = model.predict(test_dataframe)
        report_sections.append(f"Model: {model.model_name}\n")
        report_sections.append("Category report\n")
        report_sections.append(
            classification_report(test_dataframe["category"], category_predictions, labels=CATEGORY_LABELS, zero_division=0)
        )
        report_sections.append("\nUrgency report\n")
        report_sections.append(
            classification_report(test_dataframe["urgency"], urgency_predictions, labels=URGENCY_LABELS, zero_division=0)
        )
        report_sections.append("\n" + "=" * 80 + "\n")
    return "\n".join(report_sections)


def create_error_analysis(best_model: DualClassifier, test_dataframe: pd.DataFrame, output_path: Path) -> pd.DataFrame:
    """Save five concrete mispredictions with root-cause and mitigation notes."""

    category_predictions, urgency_predictions = best_model.predict(test_dataframe)
    analysis_dataframe = test_dataframe.copy()
    analysis_dataframe["predicted_category"] = category_predictions
    analysis_dataframe["predicted_urgency"] = urgency_predictions
    mistakes = analysis_dataframe[
        (analysis_dataframe["category"] != analysis_dataframe["predicted_category"])
        | (analysis_dataframe["urgency"] != analysis_dataframe["predicted_urgency"])
    ].head(5)
    rows = []
    for _, row in mistakes.iterrows():
        root_cause = infer_root_cause(row["message_text"], row["category"], row["predicted_category"])
        rows.append(
            {
                "message_text": row["message_text"],
                "true_category": row["category"],
                "predicted_category": row["predicted_category"],
                "true_urgency": row["urgency"],
                "predicted_urgency": row["predicted_urgency"],
                "likely_root_cause": root_cause,
                "concrete_mitigation_strategy": mitigation_for_root_cause(root_cause),
            }
        )
    error_dataframe = pd.DataFrame(rows)
    error_dataframe.to_csv(output_path, index=False)
    return error_dataframe


def infer_root_cause(message_text: str, true_category: str, predicted_category: str) -> str:
    """Infer a simple root cause for a misprediction."""

    text = str(message_text)
    if len(text.split()) <= 4:
        return "Very short message lacks enough context for reliable routing."
    if true_category != predicted_category and any(word in text.lower() for word in ["help", "question", "portal"]):
        return "Ambiguous shared vocabulary appears in multiple support categories."
    if any(symbol in text for symbol in ["???", "!!", ":/"]):
        return "Informal punctuation and noisy phrasing may distort text features."
    return "The message contains limited category-specific keywords."


def mitigation_for_root_cause(root_cause: str) -> str:
    """Map a root cause to an actionable mitigation."""

    if "short" in root_cause:
        return "Ask for one clarifying detail before auto-routing short requests."
    if "Ambiguous" in root_cause:
        return "Add a human-review threshold for low-confidence cross-category cases."
    if "punctuation" in root_cause:
        return "Expand training augmentation with noisy chat-style messages."
    return "Collect more labeled examples with advisor-reviewed category keywords."


def run_evaluation() -> pd.DataFrame:
    """Evaluate all trained strategies and save required outputs."""

    ensure_models_and_data()
    OUTPUTS_DIR.mkdir(parents=True, exist_ok=True)
    test_dataframe = pd.read_csv(TEST_PATH)
    models = load_evaluation_models()
    comparison = pd.DataFrame([evaluate_dual_classifier(model, test_dataframe) for model in models])
    comparison.to_csv(OUTPUTS_DIR / "model_comparison.csv", index=False)

    best_model_name = comparison.sort_values(["category_macro_f1", "urgency_macro_f1"], ascending=False).iloc[0]["model"]
    best_model = next(model for model in models if model.model_name == best_model_name)
    category_predictions, urgency_predictions = best_model.predict(test_dataframe)
    save_confusion_matrix(
        test_dataframe["category"],
        category_predictions,
        CATEGORY_LABELS,
        "Category Confusion Matrix",
        OUTPUTS_DIR / "category_confusion_matrix.png",
    )
    save_confusion_matrix(
        test_dataframe["urgency"],
        urgency_predictions,
        URGENCY_LABELS,
        "Urgency Confusion Matrix",
        OUTPUTS_DIR / "urgency_confusion_matrix.png",
    )
    (OUTPUTS_DIR / "classification_reports.txt").write_text(build_reports(models, test_dataframe), encoding="utf-8")
    create_error_analysis(best_model, test_dataframe, OUTPUTS_DIR / "error_analysis.csv")
    return comparison


def main() -> None:
    """Run evaluation from the command line."""

    comparison = run_evaluation()
    print(comparison.to_string(index=False))


if __name__ == "__main__":
    main()