eval_ground_truth.py 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. #!/usr/bin/env python3
  2. """
  3. One-off: measure current LCSC category-resolver accuracy against
  4. ground_truth.ndjson (built by scout_ground_truth.py).
  5. For each ground-truth row, re-runs the normal ingestion pipeline on its
  6. source BOM file to get the same BomRow the app would produce, calls the
  7. real resolver, and compares its top-3 guess against the real LCSC category.
  8. Also flags cases where the real category isn't even in the candidate list
  9. `categories.get_subcategories_for()` offers for that row's ComponentCategory —
  10. those are candidate-list gaps, not resolver mistakes.
  11. Usage:
  12. python -m bom_assistant.suppliers.lcsc.eval_ground_truth
  13. """
  14. from __future__ import annotations
  15. import json
  16. from pathlib import Path
  17. from bom_assistant.ingestion.column_mapper import map_columns
  18. from bom_assistant.ingestion.normalizer import normalize
  19. from bom_assistant.ingestion.parser import parse_bom
  20. from bom_assistant.session.models import BomRow
  21. from bom_assistant.suppliers.lcsc.categories import get_subcategories_for
  22. from bom_assistant.suppliers.lcsc.category_resolver import resolve
  23. _EXAMPLES_DIR = Path(__file__).resolve().parents[3] / "examples" / "full"
  24. _GT_PATH = Path(__file__).parent / "ground_truth.ndjson"
  25. def _rows_for(source: str) -> list[BomRow]:
  26. path = _EXAMPLES_DIR / source
  27. raw = parse_bom(path.read_bytes(), path.name)
  28. mapped = map_columns(raw)
  29. return normalize(mapped)
  30. def main() -> None:
  31. if not _GT_PATH.exists():
  32. print(f"no ground truth found at {_GT_PATH} — run scout_ground_truth.py first")
  33. return
  34. gt_entries = [json.loads(line) for line in _GT_PATH.read_text(encoding="utf-8").splitlines() if line.strip()]
  35. rows_by_source: dict[str, list[BomRow]] = {}
  36. for entry in gt_entries:
  37. src = entry["source"]
  38. if src not in rows_by_source:
  39. rows_by_source[src] = _rows_for(src)
  40. top1 = top3 = candidate_gap = total = 0
  41. mismatches: list[str] = []
  42. for entry in gt_entries:
  43. rows = rows_by_source[entry["source"]]
  44. row = next((r for r in rows if entry["designator"] in r.designators), None)
  45. if row is None:
  46. print(f"warn: no matching row for designator {entry['designator']!r} in {entry['source']}")
  47. continue
  48. total += 1
  49. real_id = entry["category_id"]
  50. real_name = entry["category_name"]
  51. candidates = get_subcategories_for(row.category)
  52. candidate_ids = {c["id"] for c in candidates}
  53. in_candidates = real_id in candidate_ids
  54. if not in_candidates:
  55. candidate_gap += 1
  56. guesses = resolve(row)
  57. guess_ids = [g.id for g in guesses]
  58. hit1 = bool(guess_ids) and guess_ids[0] == real_id
  59. hit3 = real_id in guess_ids
  60. top1 += hit1
  61. top3 += hit3
  62. if not hit3:
  63. guess_str = " | ".join(f"{g.name}({g.id})" for g in guesses) if guesses else "???"
  64. gap_note = " [NOT IN CANDIDATE LIST]" if not in_candidates else ""
  65. mismatches.append(
  66. f" {entry['designator']:6} [{row.category.value:10}] val={entry['value']!r:20} "
  67. f"real={real_name}({real_id}){gap_note} guessed={guess_str}"
  68. )
  69. print(f"\n=== Eval results ({total} ground-truth rows) ===")
  70. print(f"top-1 accuracy: {top1}/{total} ({top1 / total:.0%})" if total else "no rows")
  71. print(f"top-3 accuracy: {top3}/{total} ({top3 / total:.0%})" if total else "")
  72. print(f"real category missing from candidate list: {candidate_gap}/{total}")
  73. if mismatches:
  74. print(f"\n=== Mismatches ({len(mismatches)}) ===")
  75. for m in mismatches:
  76. print(m)
  77. if __name__ == "__main__":
  78. main()