Spaces:
Runtime error
Runtime error
Add parquet benchmark support and isolate app imports
Browse filesAdd 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 +11 -0
- requirements.txt +2 -0
- simpliscribe/__init__.py +12 -2
- simpliscribe/benchmark.py +102 -3
- tests/test_benchmark.py +24 -1
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
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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"
|