"""Evaluation harness: run prediction engine against ground truth and produce reports.""" from __future__ import annotations import json import sys import time from collections import Counter from pathlib import Path import numpy as np import pandas as pd sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app.services.data_service import data_service FIELDS = [ ("greige_epi", "Greige EPI"), ("greige_ppi", "Greige PPI"), ("finish_epi", "FINISH EPI"), ("finish_ppi", "FINISH PPI"), ("reed_count", "Reed Count"), ("ends_per_dent", "Ends per dent"), ("reed_space", "Reed space"), ("finish_width", "FINISH WIDTH"), ("target_gsm", "FINISH GSM"), ] def percent_error(pred_value: float | None, actual_value: float | None) -> float | None: if pred_value is None or actual_value is None or actual_value == 0: return None return abs(pred_value - actual_value) / abs(actual_value) * 100 def run_eval(sample: pd.DataFrame, label: str) -> dict: """Run predictions on a sample and return metrics.""" results = [] errors = {fld: [] for fld, _ in FIELDS} conf_dist = Counter() case_dist = Counter() fields_ok = {fld: Counter() for fld, _ in FIELDS if fld != "target_gsm"} fields_ok["target_gsm"] = Counter() t0 = time.time() total = len(sample) for i, (_, row) in enumerate(sample.iterrows()): try: pred = data_service.predict_construction({ "weave": str(row["weave"]), "blend": str(row["blend"]), "warp_count": float(row["warp_count"]), "weft_count": float(row["weft_count"]), "finish_epi": float(row["FINISH EPI"]), "finish_ppi": float(row["FINISH PPI"]), "target_gsm": float(row["FINISH GSM"]) if pd.notna(row.get("FINISH GSM")) else None, }) except Exception as e: continue rec = pred.get("recommendation", {}) sp = pred.get("search_path", {}) dq = pred.get("data_quality", {}) conf_dist[dq.get("confidence", "very_low")] += 1 case_dist[sp.get("case_number", "?")] += 1 row_res = {"article": str(row.get("master_article", "")), "case": sp.get("case_number", "?"), "range": sp.get("range", "?"), "matches": sp.get("matches_found", 0)} for fld, col in FIELDS: p = rec.get(fld) a = None if col in row.index: a = float(row[col]) if pd.notna(row.get(col)) else None err = percent_error(p, a) row_res[fld + "_pred"] = p row_res[fld + "_actual"] = a row_res[fld + "_err"] = err if err is not None: errors[fld].append(err) # Count within-threshold if err is not None: if err <= 5: fields_ok[fld]["within5"] += 1 elif err <= 10: fields_ok[fld]["within10"] += 1 elif err <= 20: fields_ok[fld]["within20"] += 1 else: fields_ok[fld]["beyond20"] += 1 else: fields_ok[fld]["no_actual"] += 1 results.append(row_res) elapsed = time.time() - t0 report = { "label": label, "total_articles": total, "predicted_articles": len(results), "elapsed_seconds": round(elapsed, 1), "avg_ms_per_article": round(elapsed / max(len(results), 1) * 1000, 1), } field_report = {} for fld, _ in FIELDS: arr = errors[fld] if arr: a = np.array(arr) field_report[fld] = { "MAE_pct": round(float(a.mean()), 3), "Median_pct": round(float(np.median(a)), 3), "P90_pct": round(float(np.percentile(a, 90)), 3), "P10_pct": round(float(np.percentile(a, 10)), 3), "Std_pct": round(float(a.std()), 3), "Max_pct": round(float(a.max()), 3), "count": int(len(a)), "within_5pct": fields_ok[fld].get("within5", 0), "within_10pct": fields_ok[fld].get("within10", 0), "within_20pct": fields_ok[fld].get("within20", 0), "beyond_20pct": fields_ok[fld].get("beyond20", 0), } else: field_report[fld] = {"MAE_pct": None, "count": 0} report["fields"] = field_report report["confidence_dist"] = dict(conf_dist) report["case_dist"] = {f"Case_{k}": v for k, v in case_dist.items()} # Summary score (exclude finish_epi/finish_ppi: exact-by-design per case study) scores = [] for fld in ["greige_epi", "greige_ppi", "target_gsm", "reed_count", "reed_space", "finish_width"]: e = field_report.get(fld, {}).get("MAE_pct") if e is not None: scores.append(e) report["overall_MAE_pct"] = round(float(np.mean(scores)), 3) if scores else None return report def get_sample_A() -> pd.DataFrame: """Original 25 diverse articles (fixed sample).""" data_service.load_data() df = data_service.df test_candidates = df.dropna(subset=[ "weave", "blend", "warp_count", "weft_count", "Greige EPI", "Greige PPI", "FINISH EPI", "FINISH PPI", "Reed Count", "Ends per dent", "Reed space", "FINISH GSM", "FINISH WIDTH", ]) test_set = test_candidates[ test_candidates["weave"].isin([ "PLAIN", "2/1 S TWILL", "3/1 S TWILL", "4/1 S SATIN", "OXFORD", "DOBBY", ]) ] test_set = test_set[test_set["blend"].isin(["100%CO", "65%PES 35%CO", "97%CO 3%EA", "60%CO 40%PES"])] test_set = test_set.sort_values("FINISH EPI").reset_index(drop=True) idx = [int(i * len(test_set) / 26) for i in range(1, 26)] return test_set.iloc[idx].copy() def get_sample_B(seed: int) -> pd.DataFrame: """Random 25 articles (different per seed).""" data_service.load_data() df = data_service.df test_candidates = df.dropna(subset=[ "weave", "blend", "warp_count", "weft_count", "Greige EPI", "Greige PPI", "FINISH EPI", "FINISH PPI", "Reed Count", "Ends per dent", "Reed space", "FINISH GSM", "FINISH WIDTH", ]) test_set = test_candidates[ test_candidates["weave"].isin([ "PLAIN", "2/1 S TWILL", "3/1 S TWILL", "4/1 S SATIN", "OXFORD", "DOBBY", ]) ] test_set = test_set[test_set["blend"].isin(["100%CO", "65%PES 35%CO", "97%CO 3%EA", "60%CO 40%PES"])] sample = test_set.sample(n=25, random_state=seed) return sample.copy() def get_sample_C() -> dict: """Full validation report (200 articles).""" return data_service.get_validation_report(sample_size=200, seed=42) def compare_reports(baseline: dict, current: dict, label: str) -> str: """Generate comparison table between two reports.""" lines = [f"\n{'='*100}", f" COMPARISON: {label}", f"{'='*100}\n"] lines.append(f"{'Field':18s} {'Baseline MAE%':>15s} {'Current MAE%':>15s} {'Delta':>12s} {'P90 Base':>10s} {'P90 Curr':>10s} {'Δ P90':>10s}") lines.append("-" * 100) for fld, _ in FIELDS: b = baseline.get("fields", {}).get(fld, {}) c = current.get("fields", {}).get(fld, {}) b_mae = b.get("MAE_pct") c_mae = c.get("MAE_pct") b_p90 = b.get("P90_pct") c_p90 = c.get("P90_pct") if b_mae is not None and c_mae is not None: delta = c_mae - b_mae sign = "+" if delta > 0 else "" color = "IMPROVED" if delta < 0 else ("REGRESSED" if delta > 0 else "SAME") lines.append(f"{fld:18s} {b_mae:>14.3f}% {c_mae:>14.3f}% {sign}{delta:>10.3f}% {b_p90:>9.3f}% {c_p90:>9.3f}% {c_p90-b_p90 if b_p90 and c_p90 else 0:>+9.3f}%") else: lines.append(f"{fld:18s} {'N/A':>15s} {'N/A':>15s}") # Confidence distribution b_conf = baseline.get("confidence_dist", {}) c_conf = current.get("confidence_dist", {}) lines.append(f"\n{'Confidence':18s} {'Baseline':>15s} {'Current':>15s} {'Delta':>12s}") lines.append("-" * 60) for level in ["high", "medium", "low", "very_low"]: bv = b_conf.get(level, 0) cv = c_conf.get(level, 0) d = cv - bv lines.append(f"{level:18s} {bv:>15d} {cv:>15d} {d:+12d}") # Overall b_ov = baseline.get('overall_MAE_pct','-') c_ov = current.get('overall_MAE_pct','-') lines.append(f"\n{'Overall MAE%':18s} {str(b_ov):>15s} {str(c_ov):>15s}") return "\n".join(lines) def print_report(report: dict, title: str = "REPORT") -> None: """Print a readable report.""" print(f"\n{'='*100}") print(f" {title}: {report['label']}") print(f"{'='*100}") print(f" Articles: {report['total_articles']} | Predicted: {report['predicted_articles']} | Time: {report['elapsed_seconds']}s ({report['avg_ms_per_article']}ms/art)") print(f" Overall MAE: {report.get('overall_MAE_pct', 'N/A')}%") print() print(f" {'Field':18s} {'MAE%':>8s} {'Median%':>8s} {'P90%':>8s} {'P10%':>8s} {'≤5%':>6s} {'≤10%':>6s} {'≤20%':>6s} {'>20%':>6s}") print(f" {'-'*80}") for fld, _ in FIELDS: f = report["fields"].get(fld, {}) if f.get("MAE_pct") is not None: print(f" {fld:18s} {f['MAE_pct']:>7.2f}% {f['Median_pct']:>7.2f}% {f['P90_pct']:>7.2f}% {f['P10_pct']:>7.2f}% {f['within_5pct']:>5d} {f['within_10pct']:>5d} {f['within_20pct']:>5d} {f['beyond_20pct']:>5d}") else: print(f" {fld:18s} {'N/A':>8s}") print() print(f" Confidence: {report.get('confidence_dist', {})}") print(f" Cases: {report.get('case_dist', {})}") if __name__ == "__main__": import argparse ap = argparse.ArgumentParser() ap.add_argument("--mode", choices=["baseline", "compare"], required=True) ap.add_argument("--seed_b", type=int, default=42) ap.add_argument("--baseline_file", type=str, default=None) ap.add_argument("--report_file", type=str, default=None) args = ap.parse_args() data_service.load_data() if args.mode == "baseline": # Test Set A: original 25 fixed articles sample_a = get_sample_A() report_a = run_eval(sample_a, "Test Set A — 25 fixed diverse articles") print_report(report_a, "BASELINE") # Test Set B: random 25 new articles sample_b = get_sample_B(args.seed_b) report_b = run_eval(sample_b, f"Test Set B — 25 random articles (seed={args.seed_b})") print_report(report_b, "BASELINE") # Test Set C: full 200 validation print(f"\n{'='*100}") print(f" Test Set C — 200-sample validation report") print(f"{'='*100}") report_c = get_sample_C() print(json.dumps(report_c, indent=2)) # Combined combined = { "set_a": report_a, "set_b": report_b, "set_c": report_c, } elif args.mode == "compare": with open(args.baseline_file) as f: baseline = json.load(f) # Re-evaluate with current code sample_a = get_sample_A() report_a = run_eval(sample_a, "Test Set A — 25 fixed diverse articles") print_report(report_a, "CURRENT") sample_b = get_sample_B(args.seed_b) report_b = run_eval(sample_b, f"Test Set B — 25 random articles (seed={args.seed_b})") print_report(report_b, "CURRENT") report_c = get_sample_C() # Comparison print(compare_reports(baseline["set_a"], report_a, "TEST SET A (Fixed 25)")) print(compare_reports(baseline["set_b"], report_b, "TEST SET B (Random 25)")) print(f"\n{'='*100}") print(f" Test Set C — 200-sample validation report") print(f"{'='*100}") print(json.dumps(report_c, indent=2)) combined = { "set_a": report_a, "set_b": report_b, "set_c": report_c, } if args.report_file: with open(args.report_file, "w") as f: json.dump(combined, f, indent=2) print(f"\nReport saved to {args.report_file}")