Spaces:
Sleeping
Sleeping
Download evaluate.py from vojmahdal/sentiment_analysis: direct link, hf CLI and curl.
- Browser
- Download file 12.9 kB
-
https://huggingface.co/spaces/vojmahdal/sentiment_analysis/resolve/main/evaluate.py
- Command line
-
hf download hf://spaces/vojmahdal/sentiment_analysis/evaluate.py
-
curl -L -o evaluate.py https://huggingface.co/spaces/vojmahdal/sentiment_analysis/resolve/main/evaluate.py
12.9 kB
| """ | |
| Reproducible evaluation of the extraction system against a gold standard. | |
| python evaluate.py tables \\ | |
| --reference testovani/reference_master.csv \\ | |
| --export BERT=testy_BERT.csv --export CLAUDE=testy_CLAUDE.csv \\ | |
| [--ner-match set|multiset] [--json results.json] | |
| python evaluate.py anonymize # PII recall of the anonymizer on tiny samples | |
| ``tables`` joins every engine's exported records (``/records/export``, CSV or | |
| JSON) to the gold labels by KEY - the ``input_id`` the ingest step assigns | |
| (``<file stem>#<row>``, e.g. ``test_set1#17``) - never by row order, and | |
| prints for each engine: coverage, sentiment / topic accuracy (+ macro-F1 and | |
| the majority-class baseline), entity-level NER precision / recall / F1, | |
| latency (median / p95 / mean over MEASURED rows only) and cost. | |
| Requirements for a valid run (see evaluation/README.md for the full procedure): | |
| * the reference file is the gold standard (columns: set, row_id, text, | |
| gold_topic, gold_sentiment, gold_entities as JSON); | |
| * the input files were named so their stem ends in the set number | |
| (``test_set1.csv`` <-> ``reference_set1``), which ties ``input_id`` to a | |
| gold row; | |
| * for NER, the system ran with ``STORE_RAW_ENTITIES=true`` (otherwise the | |
| exported entity text is redacted and only entity TYPES can match) - use | |
| synthetic or consented data for such a run; | |
| * messages with no exported row (failed analyses are never stored) count as | |
| missing: reported as coverage and as ``accuracy_all`` (missing = wrong); | |
| * NER is scored only on entity types the reference set itself uses (e.g. a | |
| reference with just PER/ORG/LOC/MISC labels ignores predicted DATE/TIME | |
| entities - see ``ner.excluded_types`` in the JSON output for counts, and | |
| evaluation/README.md for why). | |
| Metric definitions live in ``evaluation/metrics.py``. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import json | |
| import re | |
| import sys | |
| from pathlib import Path | |
| from typing import Any | |
| from evaluation import metrics | |
| _KEY = re.compile(r"(\d+)#(\d+)$") | |
| _SENTIMENTS = ["negative", "neutral", "positive"] | |
| # --------------------------------------------------------------------------- | |
| # Loading | |
| # --------------------------------------------------------------------------- | |
| def _set_number(name: str) -> int | None: | |
| m = re.search(r"(\d+)$", (name or "").strip()) | |
| return int(m.group(1)) if m else None | |
| def load_reference(path: str | Path) -> dict[tuple[int, int], dict[str, Any]]: | |
| """Gold rows keyed by (set number, row id).""" | |
| gold: dict[tuple[int, int], dict[str, Any]] = {} | |
| with open(path, encoding="utf-8-sig", newline="") as f: | |
| for row in csv.DictReader(f): | |
| set_no = _set_number(row["set"]) | |
| if set_no is None: | |
| raise ValueError(f"Cannot read a set number from '{row['set']}'.") | |
| gold[(set_no, int(row["row_id"]))] = { | |
| "topic": row["gold_topic"], | |
| "sentiment": metrics.map_sentiment(row["gold_sentiment"]), | |
| "entities": json.loads(row["gold_entities"] or "[]"), | |
| } | |
| return gold | |
| def parse_entity_cell(cell: str) -> list[dict[str, str]]: | |
| """CSV export cell 'TYPE:text; TYPE:text' -> entity dicts.""" | |
| cell = (cell or "").replace("&", "&") | |
| out = [] | |
| for chunk in cell.split(";"): | |
| chunk = chunk.strip() | |
| if ":" in chunk: | |
| etype, etext = chunk.split(":", 1) | |
| if etype.strip() and etext.strip(): | |
| out.append({"type": etype.strip(), "text": etext.strip()}) | |
| return out | |
| def _number(value: Any) -> float | None: | |
| if value in (None, ""): | |
| return None | |
| try: | |
| return float(value) | |
| except (TypeError, ValueError): | |
| return None | |
| def load_export(path: str | Path) -> dict[tuple[int, int], dict[str, Any]]: | |
| """Exported records keyed by (set number, row) taken from ``input_id``.""" | |
| path = Path(path) | |
| if path.suffix.lower() == ".json": | |
| raw_rows = json.loads(path.read_text(encoding="utf-8-sig")) | |
| else: | |
| with open(path, encoding="utf-8-sig", newline="") as f: | |
| raw_rows = list(csv.DictReader(f)) | |
| rows: dict[tuple[int, int], dict[str, Any]] = {} | |
| for r in raw_rows: | |
| m = _KEY.search(str(r.get("input_id") or "")) | |
| if not m: | |
| continue # exported before V9 (no input_id) or from another source | |
| entities = r.get("entities") | |
| if isinstance(entities, str): | |
| entities = parse_entity_cell(entities) | |
| rows[(int(m.group(1)), int(m.group(2)))] = { | |
| "topic": r.get("topic"), | |
| "sentiment": metrics.map_sentiment(r.get("sentiment")), | |
| "entities": entities or [], | |
| "latency_ms": _number(r.get("latency_ms")), | |
| "cost_usd": _number(r.get("cost_usd")), | |
| "engine": r.get("engine"), | |
| } | |
| return rows | |
| # --------------------------------------------------------------------------- | |
| # Evaluation | |
| # --------------------------------------------------------------------------- | |
| def evaluate_engine( | |
| gold: dict[tuple[int, int], dict[str, Any]], | |
| predictions: dict[tuple[int, int], dict[str, Any]], | |
| ner_match: str = "set", | |
| ) -> dict[str, Any]: | |
| keys = sorted(gold) | |
| covered = [k for k in keys if k in predictions] | |
| result: dict[str, Any] = { | |
| "gold_rows": len(keys), | |
| "covered": len(covered), | |
| "missing": len(keys) - len(covered), | |
| "coverage": len(covered) / len(keys) if keys else 0.0, | |
| "unmatched_export_rows": len([k for k in predictions if k not in gold]), | |
| } | |
| topic_labels = sorted({g["topic"] for g in gold.values() if g["topic"]}) | |
| for name, labels in (("sentiment", _SENTIMENTS), ("topic", topic_labels)): | |
| g = [gold[k][name] for k in covered] | |
| p = [predictions[k][name] for k in covered] | |
| scores = metrics.classification_scores(g, p, labels) | |
| # accuracy_all: rows with no prediction count as wrong. | |
| scores["accuracy_all"] = ( | |
| sum(1 for a, b in zip(g, p) if a == b) / len(keys) if keys else 0.0 | |
| ) | |
| result[name] = scores | |
| # A predicted entity of a type the gold standard never uses (e.g. DATE/TIME, | |
| # which processors/temporal.py adds but this reference set's annotation | |
| # scheme does not cover - see evaluation/README.md) can only ever be a false | |
| # positive: the gold side has no matching label space to score it against. | |
| # Scoring it anyway would penalize the system for extracting something the | |
| # reference set was never asked to judge, not for a real NER mistake. | |
| gold_entity_types = {e["type"] for row in gold.values() for e in row.get("entities", []) if e.get("type")} | |
| excluded_types: dict[str, int] = {} | |
| scored_predictions: list[list[dict[str, Any]]] = [] | |
| for k in covered: | |
| kept, dropped = [], 0 | |
| for e in predictions[k]["entities"]: | |
| if gold_entity_types and e.get("type") not in gold_entity_types: | |
| dropped += 1 | |
| excluded_types[e["type"]] = excluded_types.get(e["type"], 0) + 1 | |
| else: | |
| kept.append(e) | |
| scored_predictions.append(kept) | |
| result["ner"] = metrics.ner_scores( | |
| [gold[k]["entities"] for k in covered], | |
| scored_predictions, | |
| match=ner_match, | |
| ) | |
| result["ner"]["excluded_types"] = excluded_types | |
| costs = [predictions[k]["cost_usd"] for k in covered] | |
| measured_costs = [c for c in costs if c is not None] | |
| is_local = all((predictions[k].get("engine") or "local") == "local" for k in covered) if covered else False | |
| result["performance"] = { | |
| "latency_ms": metrics.latency_stats(predictions[k]["latency_ms"] for k in covered), | |
| "cost_usd_total": sum(measured_costs) if measured_costs else None, | |
| "cost_rows_measured": len(measured_costs), | |
| "note": ( | |
| "local engine: no external API fee (compute cost not measured)" | |
| if is_local | |
| else "cost summed over rows that report it" | |
| ), | |
| } | |
| return result | |
| def _fmt(value: Any, digits: int = 3) -> str: | |
| return "n/a" if value is None else f"{value:.{digits}f}" | |
| def print_tables(results: dict[str, dict[str, Any]]) -> None: | |
| print("=== Coverage and classification ===") | |
| header = f"{'Engine':10s} {'covered':>9s} {'sent.acc':>9s} {'sent.F1':>8s} {'topic.acc':>10s} {'topic.F1':>9s} {'base(sent)':>11s}" | |
| print(header) | |
| for label, r in results.items(): | |
| print( | |
| f"{label:10s} {r['covered']:4d}/{r['gold_rows']:<4d} " | |
| f"{r['sentiment']['accuracy']:9.3f} {r['sentiment']['macro_f1']:8.3f} " | |
| f"{r['topic']['accuracy']:10.3f} {r['topic']['macro_f1']:9.3f} " | |
| f"{r['sentiment']['majority_baseline']:11.3f}" | |
| ) | |
| print(f"\n=== NER (entity level, match={next(iter(results.values()))['ner']['match']}) ===") | |
| print(f"{'Engine':10s} {'P':>7s} {'R':>7s} {'F1':>7s} {'TP':>6s} {'FP':>6s} {'FN':>6s}") | |
| for label, r in results.items(): | |
| n = r["ner"] | |
| print(f"{label:10s} {n['precision']:7.3f} {n['recall']:7.3f} {n['f1']:7.3f} {n['tp']:6d} {n['fp']:6d} {n['fn']:6d}") | |
| if n["excluded_types"]: | |
| print(f" (ignored, not in reference's label space: {n['excluded_types']})") | |
| print("\n=== Performance (measured rows only) ===") | |
| print(f"{'Engine':10s} {'n':>5s} {'median ms':>10s} {'p95 ms':>9s} {'mean ms':>9s} {'cost USD':>10s} note") | |
| for label, r in results.items(): | |
| lat, perf = r["performance"]["latency_ms"], r["performance"] | |
| print( | |
| f"{label:10s} {lat['n']:5d} {_fmt(lat['median'], 0):>10s} {_fmt(lat['p95'], 0):>9s} " | |
| f"{_fmt(lat['mean'], 0):>9s} {_fmt(perf['cost_usd_total'], 4):>10s} {perf['note']}" | |
| ) | |
| def run_tables(args: argparse.Namespace) -> int: | |
| gold = load_reference(args.reference) | |
| results: dict[str, dict[str, Any]] = {} | |
| for spec in args.export: | |
| if "=" not in spec: | |
| print(f"--export expects LABEL=path, got '{spec}'", file=sys.stderr) | |
| return 2 | |
| label, path = spec.split("=", 1) | |
| predictions = load_export(path) | |
| if not predictions: | |
| print(f"{label}: no rows with an input_id in {path} - was it exported after V9?", file=sys.stderr) | |
| return 2 | |
| results[label] = evaluate_engine(gold, predictions, args.ner_match) | |
| print_tables(results) | |
| if args.json: | |
| Path(args.json).write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") | |
| print(f"\nWritten: {args.json}") | |
| return 0 | |
| # --------------------------------------------------------------------------- | |
| # Anonymization (tiny illustrative check, see README for a real evaluation) | |
| # --------------------------------------------------------------------------- | |
| def evaluate_anonymize() -> None: | |
| from processors import anonymizer | |
| samples = [ | |
| ("My name is John Smith, email john@example.com", ["John Smith", "john@example.com"]), | |
| ("Call Sarah at +1 202 555 0143", ["Sarah", "+1 202 555 0143"]), | |
| ("I live in Berlin and work at Google", ["Berlin", "Google"]), | |
| ] | |
| def recall(fn) -> float: | |
| found = total = 0 | |
| for text, pii_list in samples: | |
| anon = fn(text) | |
| for pii in pii_list: | |
| total += 1 | |
| found += pii.lower() not in anon.lower() | |
| return found / total if total else 0.0 | |
| print("=== Anonymization (PII recall, 3 illustrative samples) ===") | |
| print(f"Active backend: {anonymizer.backend_name()}") | |
| print(f"Recall (regex only): {recall(anonymizer._regex_anonymize):.2%}") | |
| try: | |
| print(f"Recall (full system): {recall(anonymizer.anonymize_text):.2%}") | |
| except anonymizer.AnonymizationUnavailable as exc: | |
| print(f"Full system unavailable: {exc}") | |
| print("Sample is far too small for a claim - use a labelled PII set (see evaluation/README.md).") | |
| def main(argv: list[str] | None = None) -> int: | |
| parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) | |
| sub = parser.add_subparsers(dest="command", required=True) | |
| tables = sub.add_parser("tables", help="recompute the result tables from exports + gold standard") | |
| tables.add_argument("--reference", required=True) | |
| tables.add_argument("--export", action="append", required=True, metavar="LABEL=path") | |
| tables.add_argument("--ner-match", choices=("set", "multiset"), default="set") | |
| tables.add_argument("--json", help="also write the full results to this file") | |
| tables.set_defaults(func=run_tables) | |
| anon = sub.add_parser("anonymize", help="illustrative PII recall check") | |
| anon.set_defaults(func=lambda _a: evaluate_anonymize() or 0) | |
| args = parser.parse_args(argv) | |
| return args.func(args) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |