File size: 9,590 Bytes
6b6e83f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
evaluate.py  β€”  Evaluation Suite for Complaint Auto-Routing System
────────────────────────────────────────────────────────────────

Computes and reports:
    T1 Officer Routing   : Accuracy, F1-macro, F1 per class, Confusion Matrix
    T2 Priority          : Accuracy, F1-macro, F1 per class
    T3 ETA Regressor     : MAE, RMSE, RΒ²
    T4 Similarity Search : Recall@1, Recall@5, Recall@10 (same-department)

Run:
    python evaluation/evaluate.py
"""

import os, sys, json, warnings
import numpy as np
import pandas as pd
import joblib

from sklearn.model_selection import StratifiedKFold, cross_validate
from sklearn.metrics import (
    accuracy_score, f1_score, classification_report,
    confusion_matrix, mean_absolute_error, mean_squared_error, r2_score,
)
from sklearn.pipeline import Pipeline

warnings.filterwarnings("ignore")
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

from inference.embedding_engine import get_embedding_engine
from inference.vector_store import NumpyVectorStore

BASE_DIR  = os.path.dirname(os.path.dirname(__file__))
DATA_PATH = os.path.join(BASE_DIR, "data", "synthetic_complaints.csv")
SAVE_DIR  = os.path.join(BASE_DIR, "models", "saved")


def load_artifacts():
    emb     = joblib.load(os.path.join(SAVE_DIR, "embedding_engine.pkl"))
    les     = joblib.load(os.path.join(SAVE_DIR, "label_encoders.pkl"))
    off_clf = joblib.load(os.path.join(SAVE_DIR, "officer_classifier.pkl"))
    pri_clf = joblib.load(os.path.join(SAVE_DIR, "priority_classifier.pkl"))
    eta_reg = joblib.load(os.path.join(SAVE_DIR, "eta_regressor.pkl"))
    vs      = NumpyVectorStore().load(os.path.join(SAVE_DIR, "vector_store.pkl"))
    return emb, les, off_clf, pri_clf, eta_reg, vs


def recall_at_k(store: NumpyVectorStore, X: np.ndarray,
                df: pd.DataFrame, k: int) -> float:
    """
    Recall@K for similarity search.
    A retrieval is considered a hit if β‰₯1 of the top-K results
    belongs to the same department as the query.
    Self-match is excluded.
    """
    n    = len(df)
    hits = 0
    for i in range(n):
        results = store.search(X[i], top_k=k + 1)
        results = [r for r in results
                   if r["complaint_id"] != df.iloc[i]["complaint_id"]][:k]
        if any(r["department"] == df.iloc[i]["department"] for r in results):
            hits += 1
    return hits / n


def print_section(title: str):
    print(f"\n{'='*60}")
    print(f"  {title}")
    print(f"{'='*60}")


def main():
    print("\n╔══════════════════════════════════════════════════════════╗")
    print("β•‘       COMPLAINT AUTO-ROUTING β€” EVALUATION SUITE         β•‘")
    print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•")

    # ── Load data & artifacts
    df       = pd.read_csv(DATA_PATH)
    emb, les, off_clf, pri_clf, eta_reg, vs = load_artifacts()
    print(f"\nDataset: {len(df)} complaints")
    X = emb.encode(df["text"].tolist())
    print(f"Embeddings: {X.shape}")

    le_off = les["officer"]
    le_pri = les["priority"]
    y_off  = le_off.transform(df["officer_id"])
    y_pri  = le_pri.transform(df["priority"])
    y_eta  = df["eta_days"].values

    # ═══════════════════════════════════════════════════
    # T1: Officer Routing
    # ═══════════════════════════════════════════════════
    print_section("T1 β€” OFFICER ROUTING (SVM)")
    cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    cv_off = cross_validate(
        off_clf, X, y_off, cv=cv,
        scoring=["accuracy", "f1_macro"],
        return_train_score=False,
    )
    print(f"  CV Accuracy (5-fold) : {cv_off['test_accuracy'].mean():.4f} Β± {cv_off['test_accuracy'].std():.4f}")
    print(f"  CV F1-macro (5-fold) : {cv_off['test_f1_macro'].mean():.4f} Β± {cv_off['test_f1_macro'].std():.4f}")

    y_pred_off = off_clf.predict(X)
    print(f"\n  Full-data Accuracy   : {accuracy_score(y_off, y_pred_off):.4f}")
    print(f"  Full-data F1-macro   : {f1_score(y_off, y_pred_off, average='macro'):.4f}")
    print("\n  Per-class Report:")
    print(classification_report(
        y_off, y_pred_off,
        target_names=le_off.classes_,
        digits=3,
    ))

    # ═══════════════════════════════════════════════════
    # T2: Priority Prediction
    # ═══════════════════════════════════════════════════
    print_section("T2 β€” PRIORITY PREDICTION (Random Forest)")
    cv_pri = cross_validate(
        pri_clf, X, y_pri, cv=cv,
        scoring=["accuracy", "f1_macro"],
        return_train_score=False,
    )
    print(f"  CV Accuracy (5-fold) : {cv_pri['test_accuracy'].mean():.4f} Β± {cv_pri['test_accuracy'].std():.4f}")
    print(f"  CV F1-macro (5-fold) : {cv_pri['test_f1_macro'].mean():.4f} Β± {cv_pri['test_f1_macro'].std():.4f}")

    y_pred_pri = pri_clf.predict(X)
    print(f"\n  Full-data Accuracy   : {accuracy_score(y_pri, y_pred_pri):.4f}")
    print(f"  Full-data F1-macro   : {f1_score(y_pri, y_pred_pri, average='macro'):.4f}")
    print("\n  Per-class Report:")
    print(classification_report(
        y_pri, y_pred_pri,
        target_names=le_pri.classes_,
        digits=3,
    ))

    # ═══════════════════════════════════════════════════
    # T3: ETA Prediction
    # ═══════════════════════════════════════════════════
    print_section("T3 β€” ETA PREDICTION (Gradient Boosting Regressor)")
    cv_eta = cross_validate(
        eta_reg, X, y_eta, cv=cv,
        scoring=["neg_mean_absolute_error", "neg_root_mean_squared_error", "r2"],
        return_train_score=False,
    )
    cv_mae  = -cv_eta["test_neg_mean_absolute_error"].mean()
    cv_rmse = -cv_eta["test_neg_root_mean_squared_error"].mean()
    cv_r2   =  cv_eta["test_r2"].mean()
    print(f"  CV MAE  (5-fold) : {cv_mae:.3f} days")
    print(f"  CV RMSE (5-fold) : {cv_rmse:.3f} days")
    print(f"  CV RΒ²   (5-fold) : {cv_r2:.4f}")

    y_pred_eta = eta_reg.predict(X)
    print(f"\n  Full-data MAE    : {mean_absolute_error(y_eta, y_pred_eta):.3f} days")
    print(f"  Full-data RMSE   : {np.sqrt(mean_squared_error(y_eta, y_pred_eta)):.3f} days")
    print(f"  Full-data RΒ²     : {r2_score(y_eta, y_pred_eta):.4f}")

    # ═══════════════════════════════════════════════════
    # T4: Similarity Search
    # ═══════════════════════════════════════════════════
    print_section("T4 β€” SIMILARITY SEARCH (Cosine / NumpyVectorStore)")
    print("  Computing Recall@K … (this may take ~30 seconds)")
    r1  = recall_at_k(vs, X, df, k=1)
    r5  = recall_at_k(vs, X, df, k=5)
    r10 = recall_at_k(vs, X, df, k=10)
    print(f"  Recall@1  : {r1:.4f}")
    print(f"  Recall@5  : {r5:.4f}")
    print(f"  Recall@10 : {r10:.4f}")
    print("  (criterion: β‰₯1 retrieved complaint from same department)")

    # ═══════════════════════════════════════════════════
    # Summary JSON
    # ═══════════════════════════════════════════════════
    summary = {
        "T1_officer_routing": {
            "cv_accuracy_mean": float(cv_off["test_accuracy"].mean()),
            "cv_accuracy_std":  float(cv_off["test_accuracy"].std()),
            "cv_f1_macro_mean": float(cv_off["test_f1_macro"].mean()),
            "cv_f1_macro_std":  float(cv_off["test_f1_macro"].std()),
        },
        "T2_priority_prediction": {
            "cv_accuracy_mean": float(cv_pri["test_accuracy"].mean()),
            "cv_accuracy_std":  float(cv_pri["test_accuracy"].std()),
            "cv_f1_macro_mean": float(cv_pri["test_f1_macro"].mean()),
            "cv_f1_macro_std":  float(cv_pri["test_f1_macro"].std()),
        },
        "T3_eta_prediction": {
            "cv_mae_days":  float(cv_mae),
            "cv_rmse_days": float(cv_rmse),
            "cv_r2":        float(cv_r2),
        },
        "T4_similarity_search": {
            "recall@1":  float(r1),
            "recall@5":  float(r5),
            "recall@10": float(r10),
        },
    }

    out_path = os.path.join(SAVE_DIR, "evaluation_report.json")
    with open(out_path, "w") as f:
        json.dump(summary, f, indent=2)

    print(f"\n{'='*60}")
    print("  SUMMARY")
    print(f"{'='*60}")
    print(json.dumps(summary, indent=2))
    print(f"\n[OK] Evaluation report saved -> {out_path}")


if __name__ == "__main__":
    main()