fxsab commited on
Commit
bdd5681
·
1 Parent(s): c5d45ca

Add parquet benchmark support and isolate app imports

Browse files

Add support for loading benchmark cases from parquet files with an optional case limit.

Adjust benchmark scoring to count only populated expected fields.

Lazily expose the package app to avoid pulling web dependencies into benchmark imports.

Update tests, documentation, and dependencies for the new benchmark flow.

README.md CHANGED
@@ -101,6 +101,17 @@ Case files support either:
101
 
102
  - `raw_text`: benchmark only the structuring stage
103
  - `file_path`: benchmark the full OCR + structuring pipeline
 
 
 
 
 
 
 
 
 
 
 
104
 
105
  Example file-based case shape:
106
 
 
101
 
102
  - `raw_text`: benchmark only the structuring stage
103
  - `file_path`: benchmark the full OCR + structuring pipeline
104
+ - `.parquet` with a `ground_truth` column: auto-converted into synthetic prescription benchmark cases
105
+
106
+ Parquet input requires `pandas` and `pyarrow` in the active environment.
107
+
108
+ Example parquet benchmark run:
109
+
110
+ ```bash
111
+ python -m simpliscribe.benchmark --cases 0000.parquet --limit 25 --output data/benchmark_runs/parquet_0000.json
112
+ ```
113
+
114
+ Use `--limit` while iterating on larger parquet datasets so the benchmark stays fast enough to compare fallback and endpoint modes.
115
 
116
  Example file-based case shape:
117
 
requirements.txt CHANGED
@@ -2,10 +2,12 @@ fastapi
2
  huggingface_hub
3
  httpx
4
  jinja2
 
5
  paddleocr
6
  paddlepaddle
7
  Pillow
8
  PyMuPDF
 
9
  pytest
10
  python-dotenv
11
  python-multipart
 
2
  huggingface_hub
3
  httpx
4
  jinja2
5
+ pandas
6
  paddleocr
7
  paddlepaddle
8
  Pillow
9
  PyMuPDF
10
+ pyarrow
11
  pytest
12
  python-dotenv
13
  python-multipart
simpliscribe/__init__.py CHANGED
@@ -1,3 +1,13 @@
1
- from .main import app
2
 
3
- __all__ = ["app"]
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
 
3
+ from typing import Any
4
+
5
+ __all__ = ["app"]
6
+
7
+
8
+ def __getattr__(name: str) -> Any:
9
+ if name == "app":
10
+ from .main import app
11
+
12
+ return app
13
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
simpliscribe/benchmark.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import argparse
4
  import json
 
5
  import time
6
  from dataclasses import dataclass
7
  from datetime import datetime, timezone
@@ -14,12 +15,102 @@ from .ocr import extract_ocr_text
14
  DEFAULT_BENCHMARK_CASES = Path(__file__).resolve().parent.parent / "data" / "benchmark_cases.sample.json"
15
  DEFAULT_BENCHMARK_OUTPUT_DIR = Path(__file__).resolve().parent.parent / "data" / "benchmark_runs"
16
  SCORABLE_FIELDS = ("name", "type", "dosage", "frequency", "duration")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  def normalize_for_score(value: Any) -> str:
20
  return " ".join(str(value or "").strip().lower().split())
21
 
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  @dataclass
24
  class CaseScore:
25
  case_id: str
@@ -37,10 +128,15 @@ class CaseScore:
37
  error: str = ""
38
 
39
 
40
- def load_cases(cases_path: Path) -> list[dict[str, Any]]:
 
 
 
41
  payload = json.loads(cases_path.read_text(encoding="utf-8"))
42
  if not isinstance(payload, list):
43
  raise ValueError("Benchmark cases file must contain a JSON array.")
 
 
44
  return payload
45
 
46
 
@@ -51,7 +147,7 @@ def score_case(case: dict[str, Any], actual: list[dict[str, Any]]) -> CaseScore:
51
 
52
  field_results: list[dict[str, Any]] = []
53
  matched_fields = 0
54
- total_fields = max(len(expected), len(actual)) * len(SCORABLE_FIELDS)
55
 
56
  for index in range(max(len(expected), len(actual))):
57
  expected_med = expected[index] if index < len(expected) else {}
@@ -59,6 +155,8 @@ def score_case(case: dict[str, Any], actual: list[dict[str, Any]]) -> CaseScore:
59
  for field in SCORABLE_FIELDS:
60
  expected_value = normalize_for_score(expected_med.get(field, ""))
61
  actual_value = normalize_for_score(actual_med.get(field, ""))
 
 
62
  matched = expected_value == actual_value and expected_value != ""
63
  if matched:
64
  matched_fields += 1
@@ -205,6 +303,7 @@ def parse_args() -> argparse.Namespace:
205
  parser = argparse.ArgumentParser(description="Run a local extraction benchmark against sample prescription cases.")
206
  parser.add_argument("--cases", default=str(DEFAULT_BENCHMARK_CASES), help="Path to benchmark cases JSON file.")
207
  parser.add_argument("--output", default="", help="Optional path to write benchmark results JSON.")
 
208
  return parser.parse_args()
209
 
210
 
@@ -213,7 +312,7 @@ def main() -> None:
213
  cases_path = Path(args.cases)
214
  output_path = Path(args.output) if args.output else DEFAULT_BENCHMARK_OUTPUT_DIR / "latest.json"
215
 
216
- cases = load_cases(cases_path)
217
  result = run_benchmark(cases, base_dir=cases_path.parent)
218
  save_benchmark_result(result, output_path)
219
  print_summary(result)
 
2
 
3
  import argparse
4
  import json
5
+ import re
6
  import time
7
  from dataclasses import dataclass
8
  from datetime import datetime, timezone
 
15
  DEFAULT_BENCHMARK_CASES = Path(__file__).resolve().parent.parent / "data" / "benchmark_cases.sample.json"
16
  DEFAULT_BENCHMARK_OUTPUT_DIR = Path(__file__).resolve().parent.parent / "data" / "benchmark_runs"
17
  SCORABLE_FIELDS = ("name", "type", "dosage", "frequency", "duration")
18
+ SUPPORTED_PARQUET_SUFFIXES = {".parquet"}
19
+
20
+ PARQUET_FREQUENCY_MAP = {
21
+ "take once daily": "once daily",
22
+ "take twice daily": "twice daily",
23
+ "every 12 hours": "twice daily",
24
+ "every 8 hours": "three times daily",
25
+ "at bedtime": "at bedtime",
26
+ "as needed for pain": "as needed",
27
+ "as directed": "Refer to prescription",
28
+ "after meals": "Refer to prescription",
29
+ "before meals": "Refer to prescription",
30
+ "with food": "Refer to prescription",
31
+ }
32
 
33
 
34
  def normalize_for_score(value: Any) -> str:
35
  return " ".join(str(value or "").strip().lower().split())
36
 
37
 
38
+ def normalize_ground_truth_text(raw_text: str) -> str:
39
+ cleaned = str(raw_text or "").replace("<s_ocr>", "").replace("</s>", "")
40
+ return re.sub(r"\s+", " ", cleaned).strip()
41
+
42
+
43
+ def extract_medication_section(raw_text: str) -> str:
44
+ match = re.search(r"medications:\s*(.*?)\s*signature:", raw_text, flags=re.IGNORECASE)
45
+ if not match:
46
+ return ""
47
+ return match.group(1).strip()
48
+
49
+
50
+ def parse_medication_line(line: str) -> tuple[str, str]:
51
+ match = re.match(r"^(?P<name>[A-Za-z][A-Za-z\s-]*?)\s+(?P<dosage>\d+(?:\.\d+)?\s*(?:mg|ml|mcg|g))$", line.strip(), flags=re.IGNORECASE)
52
+ if not match:
53
+ return line.strip(), ""
54
+ return match.group("name").strip().title(), re.sub(r"\s+", " ", match.group("dosage")).strip()
55
+
56
+
57
+ def normalize_instruction(instruction: str) -> str:
58
+ normalized = normalize_for_score(instruction)
59
+ return PARQUET_FREQUENCY_MAP.get(normalized, "Refer to prescription")
60
+
61
+
62
+ def parquet_ground_truth_to_case(raw_text: str, case_id: str, label: str) -> dict[str, Any]:
63
+ normalized_text = normalize_ground_truth_text(raw_text)
64
+ medication_section = extract_medication_section(normalized_text)
65
+ parts = [part.strip() for part in medication_section.split("-") if part.strip()]
66
+
67
+ raw_lines: list[str] = []
68
+ expected_medications: list[dict[str, Any]] = []
69
+ index = 0
70
+ while index < len(parts):
71
+ medication_line = parts[index]
72
+ instruction = parts[index + 1] if index + 1 < len(parts) else ""
73
+ name, dosage = parse_medication_line(medication_line)
74
+ raw_lines.append(f"{medication_line} {instruction}".strip())
75
+ expected_medications.append(
76
+ {
77
+ "name": name,
78
+ "type": "",
79
+ "dosage": dosage,
80
+ "frequency": normalize_instruction(instruction),
81
+ "duration": "N/A",
82
+ }
83
+ )
84
+ index += 2
85
+
86
+ return {
87
+ "id": case_id,
88
+ "label": label,
89
+ "raw_text": "\n".join(raw_lines),
90
+ "expected_medications": expected_medications,
91
+ }
92
+
93
+
94
+ def load_parquet_cases(cases_path: Path, limit: int | None = None) -> list[dict[str, Any]]:
95
+ try:
96
+ import pandas as pd
97
+ except ImportError as exc:
98
+ raise RuntimeError("Parquet benchmark input requires pandas and pyarrow installed.") from exc
99
+
100
+ dataframe = pd.read_parquet(cases_path)
101
+ if "ground_truth" not in dataframe.columns:
102
+ raise ValueError("Parquet benchmark input must contain a ground_truth column.")
103
+
104
+ cases: list[dict[str, Any]] = []
105
+ for row_index, row in enumerate(dataframe.itertuples(index=False), start=1):
106
+ if limit is not None and len(cases) >= limit:
107
+ break
108
+ case_id = f"{cases_path.stem}-{row_index}"
109
+ label = f"{cases_path.stem} row {row_index}"
110
+ cases.append(parquet_ground_truth_to_case(getattr(row, "ground_truth", ""), case_id, label))
111
+ return cases
112
+
113
+
114
  @dataclass
115
  class CaseScore:
116
  case_id: str
 
128
  error: str = ""
129
 
130
 
131
+ def load_cases(cases_path: Path, limit: int | None = None) -> list[dict[str, Any]]:
132
+ if cases_path.suffix.lower() in SUPPORTED_PARQUET_SUFFIXES:
133
+ return load_parquet_cases(cases_path, limit=limit)
134
+
135
  payload = json.loads(cases_path.read_text(encoding="utf-8"))
136
  if not isinstance(payload, list):
137
  raise ValueError("Benchmark cases file must contain a JSON array.")
138
+ if limit is not None:
139
+ return payload[:limit]
140
  return payload
141
 
142
 
 
147
 
148
  field_results: list[dict[str, Any]] = []
149
  matched_fields = 0
150
+ total_fields = 0
151
 
152
  for index in range(max(len(expected), len(actual))):
153
  expected_med = expected[index] if index < len(expected) else {}
 
155
  for field in SCORABLE_FIELDS:
156
  expected_value = normalize_for_score(expected_med.get(field, ""))
157
  actual_value = normalize_for_score(actual_med.get(field, ""))
158
+ if expected_value:
159
+ total_fields += 1
160
  matched = expected_value == actual_value and expected_value != ""
161
  if matched:
162
  matched_fields += 1
 
303
  parser = argparse.ArgumentParser(description="Run a local extraction benchmark against sample prescription cases.")
304
  parser.add_argument("--cases", default=str(DEFAULT_BENCHMARK_CASES), help="Path to benchmark cases JSON file.")
305
  parser.add_argument("--output", default="", help="Optional path to write benchmark results JSON.")
306
+ parser.add_argument("--limit", type=int, default=0, help="Optional maximum number of cases to run. Use 0 for all cases.")
307
  return parser.parse_args()
308
 
309
 
 
312
  cases_path = Path(args.cases)
313
  output_path = Path(args.output) if args.output else DEFAULT_BENCHMARK_OUTPUT_DIR / "latest.json"
314
 
315
+ cases = load_cases(cases_path, limit=args.limit or None)
316
  result = run_benchmark(cases, base_dir=cases_path.parent)
317
  save_benchmark_result(result, output_path)
318
  print_summary(result)
tests/test_benchmark.py CHANGED
@@ -3,6 +3,8 @@ from simpliscribe.benchmark import run_case
3
  from simpliscribe.benchmark import run_benchmark
4
  from simpliscribe.benchmark import score_case
5
 
 
 
6
 
7
  def test_score_case_counts_matching_fields():
8
  case = {
@@ -156,4 +158,25 @@ def test_run_case_supports_file_based_ocr(monkeypatch, tmp_path):
156
  assert result.source_kind == "file"
157
  assert result.source_path == "sample.png"
158
  assert result.raw_text == "Paracetamol 650 tab od 5 days"
159
- assert result.actual[0]["frequency"] == "once daily"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from simpliscribe.benchmark import run_benchmark
4
  from simpliscribe.benchmark import score_case
5
 
6
+ import pandas as pd
7
+
8
 
9
  def test_score_case_counts_matching_fields():
10
  case = {
 
158
  assert result.source_kind == "file"
159
  assert result.source_path == "sample.png"
160
  assert result.raw_text == "Paracetamol 650 tab od 5 days"
161
+ assert result.actual[0]["frequency"] == "once daily"
162
+
163
+
164
+ def test_load_cases_supports_parquet_ground_truth(tmp_path):
165
+ parquet_path = tmp_path / "synthetic.parquet"
166
+ dataframe = pd.DataFrame(
167
+ [
168
+ {
169
+ "ground_truth": "<s_ocr> doctor_name: Dr. A medications: - Paracetamol 650 mg - Take once daily - Cetirizine 10 mg - At bedtime signature: Dr. A </s>"
170
+ }
171
+ ]
172
+ )
173
+ dataframe.to_parquet(parquet_path)
174
+
175
+ cases = load_cases(parquet_path)
176
+
177
+ assert len(cases) == 1
178
+ assert cases[0]["raw_text"] == "Paracetamol 650 mg Take once daily\nCetirizine 10 mg At bedtime"
179
+ assert cases[0]["expected_medications"][0]["name"] == "Paracetamol"
180
+ assert cases[0]["expected_medications"][0]["dosage"] == "650 mg"
181
+ assert cases[0]["expected_medications"][0]["frequency"] == "once daily"
182
+ assert cases[0]["expected_medications"][1]["frequency"] == "at bedtime"