Spaces:
Sleeping
Sleeping
| """Offline sentiment evaluation harness β teach *why* each metric exists. | |
| Run a sentiment model over a labeled CSV and produce a Markdown error-analysis | |
| report plus a JSON summary. The CLI drives the model DIRECTLY (no HTTP): offline | |
| evaluation wants a reproducible, single-process run, not a live server. | |
| Why these particular metrics (the whole point of this file): | |
| * **Accuracy** is the headline number but lies under class imbalance β a model | |
| that always says "neutral" scores well if most rows are neutral. | |
| * **Macro F1** averages the per-class F1 with EQUAL weight per class, so a model | |
| that ignores the rare class is punished. It is the honest single number when | |
| classes are imbalanced (and sentiment sets usually are). | |
| * **The confusion matrix** shows *which* classes get confused β e.g. sarcasm | |
| read as positive, mixed sentiment collapsed to neutral. A single score hides | |
| that; the matrix is where error analysis actually starts. | |
| * **Latency p50/p95** separates the typical request (p50 = median) from the slow | |
| tail (p95). Users feel the tail: a good median with a bad p95 still feels | |
| janky. One mean would blur both together. | |
| Heavy imports (torch/transformers via SentimentModel) live INSIDE ``run()`` so | |
| the pure helpers below stay importable β and unit-testable β without the ML | |
| stack. This mirrors backend/app/model.py's lazy-import design. | |
| """ | |
| import argparse | |
| import csv | |
| import io | |
| import json | |
| import statistics | |
| import sys | |
| import time | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| # --- Pure helpers (no torch, no app) ----------------------------------------- | |
| # Same 2000-char boundary the API enforces (backend/app/schemas.MAX_CHARS). | |
| # Eval inputs are never silently truncated: an overlong row is a data bug the | |
| # author should see and fix, not something we quietly reshape under them. | |
| MAX_TEXT_CHARS = 2000 | |
| REQUIRED_COLUMNS = ("text", "true_label") | |
| class EvalRow: | |
| """One labeled example. ``category``/``notes`` are optional metadata that | |
| make the error-analysis table readable (why is THIS example hard?).""" | |
| id: str | |
| text: str | |
| true_label: str | |
| category: str | |
| notes: str | |
| def validate_label_compatibility( | |
| dataset_labels: set[str], model_labels: set[str], allow_mismatch: bool | |
| ) -> None: | |
| """Fail fast when the dataset asks for a class the model cannot predict. | |
| Running a binary (negative/positive) model on a CSV that contains | |
| ``neutral`` rows is not an error the model reports β it just silently maps | |
| every neutral example onto its nearest class and reports plausible-looking | |
| but misleading metrics. We refuse by default and force an explicit | |
| ``--allow-label-mismatch`` opt-in. | |
| """ | |
| if dataset_labels.issubset(model_labels): | |
| return | |
| extra = sorted(dataset_labels - model_labels) | |
| if allow_mismatch: | |
| print( | |
| f"WARNING: dataset labels {extra} are not in model labels " | |
| "β metrics may mislead" | |
| ) | |
| return | |
| raise SystemExit( | |
| f"Dataset labels {sorted(dataset_labels)} are not a subset of model " | |
| f"labels {sorted(model_labels)}. Unpredictable classes: {extra}. " | |
| f"Use --allow-label-mismatch to run anyway." | |
| ) | |
| def parse_eval_rows(csv_text: str) -> list[EvalRow]: | |
| """Parse eval CSV text into ``EvalRow`` records, validating at the boundary. | |
| Rejects (never silently repairs) two data bugs: a missing required column, | |
| and a row whose text exceeds ``MAX_TEXT_CHARS``. Truncating an overlong row | |
| would change the very input being scored, so we fail loudly and name the row | |
| instead. Blank-text rows are skipped (a trailing newline is not a data | |
| point); ``category``/``notes`` are optional. | |
| """ | |
| reader = csv.DictReader(io.StringIO(csv_text)) | |
| fields = reader.fieldnames or [] | |
| missing = [c for c in REQUIRED_COLUMNS if c not in fields] | |
| if missing: | |
| raise ValueError( | |
| f"CSV missing required column(s): {missing}. Found: {list(fields)}" | |
| ) | |
| rows: list[EvalRow] = [] | |
| for i, raw in enumerate(reader): | |
| text = (raw.get("text") or "").strip() | |
| if not text: | |
| continue | |
| if len(text) > MAX_TEXT_CHARS: | |
| # +2: 1 for the header line, 1 for 0-based -> 1-based row numbering, | |
| # so the number matches what a spreadsheet shows. | |
| raise ValueError( | |
| f"Row {i + 2} text is {len(text)} chars, exceeds the " | |
| f"{MAX_TEXT_CHARS}-char limit. Fix the data; rows are never " | |
| "truncated." | |
| ) | |
| true_label = (raw.get("true_label") or "").strip() | |
| rows.append( | |
| EvalRow( | |
| id=(raw.get("id") or str(i + 1)).strip(), | |
| text=text, | |
| true_label=true_label, | |
| category=(raw.get("category") or "").strip(), | |
| notes=(raw.get("notes") or "").strip(), | |
| ) | |
| ) | |
| return rows | |
| def compute_metrics( | |
| y_true: list[str], y_pred: list[str], labels: list[str] | |
| ) -> dict: | |
| """Score predictions with scikit-learn β the reference implementation. | |
| ``labels=`` is passed explicitly to BOTH calls so the confusion matrix rows | |
| and the per-class report share one class order (otherwise sklearn infers a | |
| different order for each and they stop lining up). ``zero_division=0`` keeps | |
| a class that got zero predictions from raising a warning and instead scores | |
| it 0 β which is the honest reading: the model never picked it. | |
| """ | |
| from sklearn.metrics import ( | |
| accuracy_score, | |
| classification_report, | |
| confusion_matrix, | |
| f1_score, | |
| ) | |
| report = classification_report( | |
| y_true, y_pred, labels=labels, output_dict=True, zero_division=0 | |
| ) | |
| per_class = { | |
| label: { | |
| "precision": report[label]["precision"], | |
| "recall": report[label]["recall"], | |
| "f1": report[label]["f1-score"], | |
| "support": int(report[label]["support"]), | |
| } | |
| for label in labels | |
| } | |
| cm = confusion_matrix(y_true, y_pred, labels=labels).tolist() | |
| return { | |
| "accuracy": accuracy_score(y_true, y_pred), | |
| # macro = unweighted mean over classes, so a rare class counts as much | |
| # as a common one. This is the number that exposes class imbalance. | |
| "macro_f1": f1_score( | |
| y_true, y_pred, labels=labels, average="macro", zero_division=0 | |
| ), | |
| "per_class": per_class, | |
| "confusion_matrix": cm, | |
| } | |
| def latency_percentiles(latencies_ms: list[float]) -> dict: | |
| """p50 (median = typical request) and p95 (the slow tail users feel). | |
| p95 uses ``statistics.quantiles(n=20)`` β the 19th of 20 cut points is the | |
| 95th percentile. With fewer than two samples there is no distribution to cut, | |
| so both percentiles collapse to the single observation. | |
| """ | |
| if len(latencies_ms) < 2: | |
| only = latencies_ms[0] if latencies_ms else 0.0 | |
| return {"p50": only, "p95": only} | |
| p50 = statistics.median(latencies_ms) | |
| # method="inclusive" keeps p95 within the observed range β a reported p95 | |
| # latency should never exceed the slowest request we actually measured | |
| # (the default "exclusive" method extrapolates past the max on small n). | |
| p95 = statistics.quantiles(latencies_ms, n=20, method="inclusive")[18] | |
| return {"p50": p50, "p95": p95} | |
| def collect_wrong_examples( | |
| rows: list[EvalRow], y_pred: list[str], confidences: list[float] | |
| ) -> list[dict]: | |
| """The misclassified rows β the actual product of an eval. | |
| Aggregate scores tell you *how much* the model is wrong; this table tells you | |
| *where*, with the confidence attached so you can spot the dangerous case: | |
| confidently wrong (sarcasm read as glowing praise at 0.95) is worse than | |
| hesitantly wrong (a mixed review at 0.4).""" | |
| wrong = [] | |
| for row, pred, conf in zip(rows, y_pred, confidences): | |
| if pred != row.true_label: | |
| wrong.append( | |
| { | |
| "id": row.id, | |
| "text": row.text, | |
| "category": row.category, | |
| "true": row.true_label, | |
| "predicted": pred, | |
| "confidence": conf, | |
| "notes": row.notes, | |
| } | |
| ) | |
| return wrong | |
| def build_summary( | |
| model_id: str, | |
| model_name: str, | |
| data_file: str, | |
| rows: list[EvalRow], | |
| y_pred: list[str], | |
| confidences: list[float], | |
| latencies_ms: list[float], | |
| metrics: dict, | |
| labels: list[str], | |
| ) -> dict: | |
| """Assemble the machine-readable summary (also the JSON printed to stdout).""" | |
| lat = latency_percentiles(latencies_ms) | |
| return { | |
| "model_id": model_id, | |
| "model_name": model_name, | |
| "data_file": data_file, | |
| "n_examples": len(rows), | |
| "labels": list(labels), | |
| "accuracy": round(metrics["accuracy"], 4), | |
| "macro_f1": round(metrics["macro_f1"], 4), | |
| "latency_p50_ms": round(lat["p50"], 2), | |
| "latency_p95_ms": round(lat["p95"], 2), | |
| "per_class": metrics["per_class"], | |
| "confusion_matrix": metrics["confusion_matrix"], | |
| "wrong_examples": collect_wrong_examples(rows, y_pred, confidences), | |
| } | |
| def _md_cell(text: str) -> str: | |
| """Escape a value so it survives inside a Markdown table cell.""" | |
| return text.replace("|", "\\|").replace("\n", " ").strip() | |
| def render_report(summary: dict) -> str: | |
| """Render the human-facing Markdown error-analysis report from a summary.""" | |
| labels = summary["labels"] | |
| lines: list[str] = [] | |
| lines.append(f"# Sentiment Evaluation Report β `{summary['model_id']}`") | |
| lines.append("") | |
| lines.append(f"- **Model:** `{summary['model_name']}`") | |
| lines.append(f"- **Dataset:** `{summary['data_file']}` " | |
| f"({summary['n_examples']} labeled examples)") | |
| lines.append(f"- **Labels:** {', '.join(labels)}") | |
| lines.append("") | |
| # Honest-scope note (required): what this eval does and does NOT claim. | |
| lines.append("> **Sentiment models are not creativity judges.** This project " | |
| "tests emotional polarity, confidence, model disagreement, and " | |
| "explanation quality across writing styles β not whether the " | |
| "writing is *good*.") | |
| lines.append("") | |
| lines.append("## Headline metrics") | |
| lines.append("") | |
| lines.append(f"- **Accuracy:** {summary['accuracy']:.3f} β the share of " | |
| "correct predictions. Easy to read, but inflated when one class " | |
| "dominates the dataset.") | |
| lines.append(f"- **Macro F1:** {summary['macro_f1']:.3f} β the per-class F1 " | |
| "averaged with equal weight per class. Lower than accuracy here " | |
| "because the model handles the classes unevenly; this is the " | |
| "number that exposes class imbalance.") | |
| lines.append(f"- **Latency p50:** {summary['latency_p50_ms']:.1f} ms β the " | |
| "typical (median) single-text request.") | |
| lines.append(f"- **Latency p95:** {summary['latency_p95_ms']:.1f} ms β the " | |
| "slow tail. Users feel the tail, so p95 matters more than an " | |
| "average that would blur it away.") | |
| lines.append("") | |
| lines.append("## Per-class precision / recall / F1") | |
| lines.append("") | |
| lines.append("| Class | Precision | Recall | F1 | Support |") | |
| lines.append("| --- | --- | --- | --- | --- |") | |
| for label in labels: | |
| pc = summary["per_class"][label] | |
| lines.append(f"| {label} | {pc['precision']:.2f} | {pc['recall']:.2f} " | |
| f"| {pc['f1']:.2f} | {pc['support']} |") | |
| lines.append("") | |
| lines.append("## Confusion matrix") | |
| lines.append("") | |
| lines.append("Rows = true label, columns = predicted label. The diagonal is " | |
| "correct; every off-diagonal cell is a specific confusion (which " | |
| "class gets mistaken for which).") | |
| lines.append("") | |
| lines.append("| true \\ pred | " + " | ".join(labels) + " |") | |
| lines.append("| --- | " + " | ".join("---" for _ in labels) + " |") | |
| for i, label in enumerate(labels): | |
| cells = " | ".join(str(c) for c in summary["confusion_matrix"][i]) | |
| lines.append(f"| **{label}** | {cells} |") | |
| lines.append("") | |
| lines.append("## Misclassified examples") | |
| lines.append("") | |
| wrong = summary["wrong_examples"] | |
| if not wrong: | |
| lines.append("_No misclassifications β every example matched its label._") | |
| else: | |
| lines.append(f"{len(wrong)} of {summary['n_examples']} examples were " | |
| "misclassified. Confidence is the model's probability for " | |
| "the class it *chose* β high confidence on a wrong answer is " | |
| "the failure mode to watch.") | |
| lines.append("") | |
| lines.append("| Text | Category | True | Predicted | Confidence |") | |
| lines.append("| --- | --- | --- | --- | --- |") | |
| for w in wrong: | |
| lines.append( | |
| f"| {_md_cell(w['text'])} | {_md_cell(w['category'])} " | |
| f"| {w['true']} | {w['predicted']} | {w['confidence']:.2f} |" | |
| ) | |
| lines.append("") | |
| lines.append("## Top failure modes") | |
| lines.append("") | |
| lines.append("These are the general failure modes sentiment models exhibit. " | |
| "This 36-row set triggers sarcasm, mixed sentiment, and ambiguity " | |
| "most sharply (see the table above); the finance and formal rows " | |
| "happened to land correctly here, but both are classic weak spots " | |
| "that surface on larger or harder sets.") | |
| lines.append("") | |
| lines.append("1. **Sarcasm** β positive words carrying negative intent " | |
| "(\"amazing, another crash\") are read literally.") | |
| lines.append("2. **Mixed sentiment** β a review that praises one thing and " | |
| "pans another gets collapsed to a single dominant class.") | |
| lines.append("3. **Long / formal text** β flat, low-affect prose has no " | |
| "strong sentiment signal, so predictions drift.") | |
| lines.append("4. **Finance / domain mismatch** β a general social-media model " | |
| "misreads finance and news sentiment (\"shares tumbled\").") | |
| lines.append("5. **Missing context** β very short or ambiguous phrases " | |
| "(\"Meh\", \"It is fine\") lack the context a human uses.") | |
| lines.append("") | |
| lines.append("## Machine-readable summary") | |
| lines.append("") | |
| lines.append("```json") | |
| lines.append(json.dumps({k: summary[k] for k in ( | |
| "model_id", "accuracy", "macro_f1", "latency_p50_ms", | |
| "latency_p95_ms", "confusion_matrix", | |
| )}, indent=2)) | |
| lines.append("```") | |
| lines.append("") | |
| return "\n".join(lines) | |
| # --- CLI + model integration (heavy imports live here) ----------------------- | |
| _BACKEND_DIR = Path(__file__).resolve().parents[1] / "backend" | |
| def _load_model(model_id: str): | |
| """Build and load the registry model. torch/transformers are pulled in only | |
| here β via the backend's own lazy-loading SentimentModel β so importing this | |
| module for the pure helpers above never touches the ML stack.""" | |
| if str(_BACKEND_DIR) not in sys.path: | |
| sys.path.insert(0, str(_BACKEND_DIR)) | |
| from app.model import build_model | |
| from app.model_registry import get_model_config | |
| try: | |
| cfg = get_model_config(model_id) | |
| except ValueError as exc: | |
| raise SystemExit(str(exc)) | |
| return build_model(cfg), cfg | |
| def run(model_id: str, data_path: str, out_path: str, allow_mismatch: bool) -> dict: | |
| """End-to-end eval: load data, guard labels, predict + time, score, report.""" | |
| rows = parse_eval_rows(Path(data_path).read_text(encoding="utf-8")) | |
| if not rows: | |
| raise SystemExit(f"No labeled rows found in {data_path}") | |
| model, cfg = _load_model(model_id) | |
| # Guard BEFORE load(): model.labels is populated from the registry in | |
| # __init__, so an incompatible dataset fails instantly without paying the | |
| # ~500MB weight load (mirrors the API's validate-before-load pattern). | |
| dataset_labels = {r.true_label for r in rows} | |
| validate_label_compatibility(dataset_labels, set(model.labels), allow_mismatch) | |
| print(f"Loading {model_id} ({cfg.name})...") | |
| model.load() | |
| print(f"Loaded on device: {model.device}") | |
| # One untimed warmup: the first inference on MPS eats one-off graph/compile | |
| # cost that would otherwise masquerade as a p95 latency spike. | |
| model.predict([rows[0].text]) | |
| y_true: list[str] = [] | |
| y_pred: list[str] = [] | |
| confidences: list[float] = [] | |
| latencies_ms: list[float] = [] | |
| for row in rows: | |
| start = time.perf_counter() # monotonic clock β correct for durations | |
| pred = model.predict([row.text])[0] | |
| latencies_ms.append((time.perf_counter() - start) * 1000) | |
| y_true.append(row.true_label) | |
| y_pred.append(pred["label"]) | |
| confidences.append(max(pred["scores"].values())) | |
| # Label order for the report: the model's own labels first, then any extra | |
| # dataset labels (only reachable via --allow-label-mismatch) appended so the | |
| # confusion matrix still accounts for them. | |
| labels = list(model.labels) + [ | |
| label for label in sorted(dataset_labels) if label not in model.labels | |
| ] | |
| metrics = compute_metrics(y_true, y_pred, labels) | |
| summary = build_summary( | |
| model_id=model_id, | |
| model_name=cfg.name, | |
| data_file=data_path, | |
| rows=rows, | |
| y_pred=y_pred, | |
| confidences=confidences, | |
| latencies_ms=latencies_ms, | |
| metrics=metrics, | |
| labels=labels, | |
| ) | |
| Path(out_path).write_text(render_report(summary), encoding="utf-8") | |
| print(f"\nWrote report to {out_path}") | |
| print(json.dumps({k: summary[k] for k in ( | |
| "model_id", "n_examples", "accuracy", "macro_f1", | |
| "latency_p50_ms", "latency_p95_ms", | |
| )}, indent=2)) | |
| return summary | |
| def main(argv: list[str] | None = None) -> None: | |
| parser = argparse.ArgumentParser( | |
| description="Evaluate a sentiment model on a labeled CSV and write a " | |
| "Markdown error-analysis report.", | |
| ) | |
| parser.add_argument( | |
| "--model-id", default="twitter-roberta", | |
| help="Registry model id (default: twitter-roberta).", | |
| ) | |
| parser.add_argument("--data", required=True, help="Path to the labeled eval CSV.") | |
| parser.add_argument("--out", required=True, help="Path to write the report.") | |
| parser.add_argument( | |
| "--allow-label-mismatch", action="store_true", | |
| help="Run even if the dataset has labels the model cannot predict " | |
| "(metrics will be misleading).", | |
| ) | |
| args = parser.parse_args(argv) | |
| run(args.model_id, args.data, args.out, args.allow_label_mismatch) | |
| if __name__ == "__main__": | |
| main() | |