76d991c447
Auto Update PR / update-prs (push) Has been cancelled
CI / format-check (push) Has been cancelled
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / live-api-tests (push) Has been cancelled
CI / plugin-integration-test (push) Has been cancelled
CI / ollama-integration-test (push) Has been cancelled
CI / test-fork-pr (push) Has been cancelled
506 lines
15 KiB
Python
506 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright 2025 Google LLC.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Benchmark for fuzzy alignment in the resolver.
|
|
|
|
Measures wall-time and correctness of _fuzzy_align_extraction across
|
|
realistic input sizes. Run from repo root:
|
|
|
|
python benchmarks/fuzzy_benchmark.py
|
|
python benchmarks/fuzzy_benchmark.py --sizes planted_contiguous,perf_1k
|
|
python benchmarks/fuzzy_benchmark.py --sizes large --runs 1
|
|
python benchmarks/fuzzy_benchmark.py --tokenizer unicode
|
|
python benchmarks/fuzzy_benchmark.py --algorithm lcs
|
|
python benchmarks/fuzzy_benchmark.py --algorithm legacy
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import platform
|
|
import random
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
from langextract import resolver as resolver_lib
|
|
from langextract.core import data
|
|
from langextract.core import tokenizer as tokenizer_lib
|
|
|
|
_WORD_POOL = [
|
|
"patient",
|
|
"diagnosed",
|
|
"with",
|
|
"diabetes",
|
|
"hypertension",
|
|
"medication",
|
|
"prescribed",
|
|
"daily",
|
|
"chronic",
|
|
"condition",
|
|
"treatment",
|
|
"history",
|
|
"symptoms",
|
|
"blood",
|
|
"pressure",
|
|
"glucose",
|
|
"insulin",
|
|
"kidney",
|
|
"liver",
|
|
"cardiac",
|
|
"pulmonary",
|
|
"neurological",
|
|
"assessment",
|
|
"examination",
|
|
"laboratory",
|
|
"results",
|
|
"normal",
|
|
"elevated",
|
|
"decreased",
|
|
"follow",
|
|
"appointment",
|
|
"scheduled",
|
|
"monitor",
|
|
"progress",
|
|
"clinical",
|
|
"evaluation",
|
|
"imaging",
|
|
"therapy",
|
|
"dosage",
|
|
"adverse",
|
|
"reaction",
|
|
"prognosis",
|
|
"referral",
|
|
"discharge",
|
|
"admission",
|
|
"surgery",
|
|
"recovery",
|
|
"emergency",
|
|
"outpatient",
|
|
"inpatient",
|
|
"consultation",
|
|
"diagnosis",
|
|
"pathology",
|
|
"specimen",
|
|
"biopsy",
|
|
"cultures",
|
|
"antibiotics",
|
|
"analgesic",
|
|
"sedation",
|
|
"ventilation",
|
|
"intubation",
|
|
"catheter",
|
|
"drainage",
|
|
"infusion",
|
|
]
|
|
|
|
|
|
def _generate_source_text(n_tokens: int, seed: int = 42) -> str:
|
|
"""Generates deterministic source text from _WORD_POOL."""
|
|
rng = random.Random(seed)
|
|
words = [rng.choice(_WORD_POOL) for _ in range(n_tokens)]
|
|
return " ".join(words)
|
|
|
|
|
|
def _plant_span(source: str, target: str, position: int) -> str:
|
|
"""Inserts target text at approximately token position in source."""
|
|
words = source.split()
|
|
target_words = target.split()
|
|
pos = min(position, len(words))
|
|
words[pos : pos + len(target_words)] = target_words
|
|
return " ".join(words)
|
|
|
|
|
|
def _plant_gapped(source: str, tokens: list[str], start: int, gap: int) -> str:
|
|
"""Inserts tokens with gaps between them in source."""
|
|
words = source.split()
|
|
for i, token in enumerate(tokens):
|
|
pos = min(start + i * (gap + 1), len(words) - 1)
|
|
words[pos] = token
|
|
return " ".join(words)
|
|
|
|
|
|
def _make_extraction(text: str) -> data.Extraction:
|
|
return data.Extraction(
|
|
extraction_class="entity",
|
|
extraction_text=text,
|
|
)
|
|
|
|
|
|
def _build_cases() -> dict[str, dict]:
|
|
"""Builds benchmark cases with planted spans for correctness oracles."""
|
|
cases = {}
|
|
|
|
# --- Planted correctness cases (small, fast) ---
|
|
|
|
base_200 = _generate_source_text(200, seed=42)
|
|
|
|
# Contiguous positive: plant exact 3-token span at known position.
|
|
planted_source = _plant_span(base_200, "metformin hydrochloride tablet", 50)
|
|
cases["planted_contiguous"] = {
|
|
"description": "3-token planted contiguous match in 200 tokens",
|
|
"source": planted_source,
|
|
"extraction_text": "metformin hydrochloride tablet",
|
|
"expect_match": True,
|
|
"expect_token_interval": (50, 53),
|
|
"expect_char_interval": (451, 481),
|
|
"expect_substring": "metformin hydrochloride tablet",
|
|
}
|
|
|
|
# Fuzzy positive: extraction has stemming variation.
|
|
cases["planted_fuzzy"] = {
|
|
"description": "3-token fuzzy match (stemming) in 200 tokens",
|
|
"source": planted_source,
|
|
"extraction_text": "metformins hydrochlorides tablets",
|
|
"expect_match": True,
|
|
"expect_token_interval": (50, 53),
|
|
"expect_char_interval": (451, 481),
|
|
"expect_substring": "metformin hydrochloride tablet",
|
|
}
|
|
|
|
# Gapped positive: extraction tokens scattered with noise between them.
|
|
gapped_source = _plant_gapped(
|
|
_generate_source_text(200, seed=99),
|
|
["metformin", "hydrochloride", "tablet"],
|
|
start=40,
|
|
gap=3,
|
|
)
|
|
cases["planted_gapped"] = {
|
|
"description": "3-token gapped match (gap=3) in 200 tokens",
|
|
"source": gapped_source,
|
|
"extraction_text": "metformin hydrochloride tablet",
|
|
"expect_match": True,
|
|
"expect_token_interval": (40, 49),
|
|
"expect_char_interval": (371, 461),
|
|
"expect_substring": (
|
|
"metformin pulmonary antibiotics assessment"
|
|
" hydrochloride hypertension pressure with tablet"
|
|
),
|
|
}
|
|
|
|
# Near-miss negative: tokens not present in source.
|
|
cases["planted_negative"] = {
|
|
"description": "3-token near-miss negative in 200 tokens",
|
|
"source": base_200,
|
|
"extraction_text": "warfarin coumadin anticoagulant",
|
|
"expect_match": False,
|
|
}
|
|
|
|
# --- Perf stress case (in-vocabulary extraction, keeps overlap filter hot) ---
|
|
|
|
source_perf = _generate_source_text(1000, seed=42)
|
|
cases["perf_1k"] = {
|
|
"description": "5-token in-vocab extraction, 1000-token source (perf)",
|
|
"source": source_perf,
|
|
"extraction_text": "patient diagnosed chronic condition treatment",
|
|
}
|
|
|
|
# --- Scale cases (opt-in) ---
|
|
|
|
source_large = _generate_source_text(5000, seed=42)
|
|
cases["large"] = {
|
|
"description": "5-token in-vocab extraction, 5000-token source (opt-in)",
|
|
"source": source_large,
|
|
"extraction_text": "patient diagnosed chronic condition treatment",
|
|
}
|
|
|
|
source_stress = _generate_source_text(10000, seed=42)
|
|
cases["stress"] = {
|
|
"description": "5-token in-vocab extraction, 10000-token source (opt-in)",
|
|
"source": source_stress,
|
|
"extraction_text": "patient diagnosed chronic condition treatment",
|
|
}
|
|
|
|
return cases
|
|
|
|
|
|
_DEFAULT_SIZES = (
|
|
"planted_contiguous,planted_fuzzy,planted_gapped,planted_negative,perf_1k"
|
|
)
|
|
|
|
|
|
def _get_metadata(
|
|
tokenizer_name: str,
|
|
seed: int,
|
|
threshold: float,
|
|
algorithm: str,
|
|
min_density: float,
|
|
) -> dict:
|
|
"""Collects run metadata for reproducibility."""
|
|
git_sha = "unknown"
|
|
try:
|
|
git_sha = (
|
|
subprocess.check_output(
|
|
["git", "rev-parse", "--short", "HEAD"],
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
.decode()
|
|
.strip()
|
|
)
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
pass
|
|
|
|
return {
|
|
"python_version": platform.python_version(),
|
|
"platform": platform.platform(),
|
|
"tokenizer": tokenizer_name,
|
|
"seed": seed,
|
|
"fuzzy_alignment_threshold": threshold,
|
|
"fuzzy_alignment_algorithm": algorithm,
|
|
"fuzzy_alignment_min_density": min_density,
|
|
"git_sha": git_sha,
|
|
}
|
|
|
|
|
|
def _run_single(
|
|
aligner: resolver_lib.WordAligner,
|
|
source_text: str,
|
|
extraction_text: str,
|
|
tokenizer: tokenizer_lib.Tokenizer,
|
|
threshold: float,
|
|
algorithm: str,
|
|
min_density: float,
|
|
) -> dict:
|
|
"""Runs a single fuzzy alignment and returns timing + result."""
|
|
resolver_lib._normalize_token.cache_clear()
|
|
|
|
tokenized = tokenizer.tokenize(source_text)
|
|
source_tokens = [t.lower() for t in _tokenize_words(source_text, tokenizer)]
|
|
extraction = _make_extraction(extraction_text)
|
|
|
|
start = time.perf_counter()
|
|
if algorithm == "lcs":
|
|
result = aligner._lcs_fuzzy_align_extraction(
|
|
extraction=extraction,
|
|
source_tokens=source_tokens,
|
|
tokenized_text=tokenized,
|
|
token_offset=0,
|
|
char_offset=0,
|
|
fuzzy_alignment_threshold=threshold,
|
|
fuzzy_alignment_min_density=min_density,
|
|
tokenizer_impl=tokenizer,
|
|
)
|
|
else:
|
|
result = aligner._fuzzy_align_extraction(
|
|
extraction=extraction,
|
|
source_tokens=source_tokens,
|
|
tokenized_text=tokenized,
|
|
token_offset=0,
|
|
char_offset=0,
|
|
fuzzy_alignment_threshold=threshold,
|
|
tokenizer_impl=tokenizer,
|
|
)
|
|
elapsed = time.perf_counter() - start
|
|
|
|
matched_substring = None
|
|
if result and result.char_interval:
|
|
start_pos = result.char_interval.start_pos
|
|
end_pos = result.char_interval.end_pos
|
|
matched_substring = source_text[start_pos:end_pos]
|
|
|
|
return {
|
|
"elapsed_ms": round(elapsed * 1000, 2),
|
|
"matched": result is not None,
|
|
"alignment_status": result.alignment_status.value if result else None,
|
|
"token_interval": (
|
|
f"{result.token_interval.start_index}"
|
|
f"-{result.token_interval.end_index}"
|
|
if result and result.token_interval
|
|
else None
|
|
),
|
|
"char_interval": (
|
|
f"{result.char_interval.start_pos}-{result.char_interval.end_pos}"
|
|
if result and result.char_interval
|
|
else None
|
|
),
|
|
"matched_substring": matched_substring,
|
|
}
|
|
|
|
|
|
def _tokenize_words(text: str, tokenizer: tokenizer_lib.Tokenizer) -> list[str]:
|
|
"""Extracts word strings from tokenized text."""
|
|
tokenized = tokenizer.tokenize(text)
|
|
return [
|
|
text[t.char_interval.start_pos : t.char_interval.end_pos]
|
|
for t in tokenized.tokens
|
|
]
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Benchmark fuzzy alignment performance"
|
|
)
|
|
parser.add_argument(
|
|
"--sizes",
|
|
default=_DEFAULT_SIZES,
|
|
help="Comma-separated case names (default: planted + perf_1k)",
|
|
)
|
|
parser.add_argument(
|
|
"--runs", type=int, default=3, help="Number of runs per case"
|
|
)
|
|
parser.add_argument(
|
|
"--tokenizer",
|
|
choices=["regex", "unicode"],
|
|
default="regex",
|
|
help="Tokenizer backend (default: regex)",
|
|
)
|
|
parser.add_argument(
|
|
"--threshold",
|
|
type=float,
|
|
default=0.75,
|
|
help="Fuzzy alignment threshold (default: 0.75)",
|
|
)
|
|
parser.add_argument(
|
|
"--algorithm",
|
|
choices=["lcs", "legacy"],
|
|
default="lcs",
|
|
help="Fuzzy alignment algorithm (default: lcs)",
|
|
)
|
|
parser.add_argument(
|
|
"--min-density",
|
|
type=float,
|
|
default=1 / 3,
|
|
help="Min matched-to-span density for LCS algorithm (default: 1/3)",
|
|
)
|
|
parser.add_argument("--json-output", help="Write results to JSON file")
|
|
args = parser.parse_args()
|
|
|
|
cases = _build_cases()
|
|
selected = [s.strip() for s in args.sizes.split(",")]
|
|
|
|
if args.tokenizer == "unicode":
|
|
tokenizer = tokenizer_lib.UnicodeTokenizer()
|
|
else:
|
|
tokenizer = tokenizer_lib.RegexTokenizer()
|
|
|
|
aligner = resolver_lib.WordAligner()
|
|
metadata = _get_metadata(
|
|
args.tokenizer, 42, args.threshold, args.algorithm, args.min_density
|
|
)
|
|
|
|
results = {"_metadata": metadata}
|
|
print(f"Fuzzy alignment benchmark ({args.runs} runs per case)\n")
|
|
print(f" algorithm: {args.algorithm}")
|
|
print(f" tokenizer: {args.tokenizer}")
|
|
print(f" threshold: {args.threshold}")
|
|
if args.algorithm == "lcs":
|
|
print(f" min_density: {args.min_density:.3f}")
|
|
print(f" git: {metadata['git_sha']}\n")
|
|
|
|
for name in selected:
|
|
if name not in cases:
|
|
print(f" {name}: unknown case, skipping\n")
|
|
continue
|
|
|
|
case = cases[name]
|
|
source = case["source"]
|
|
extraction_text = case["extraction_text"]
|
|
expect_match = case.get("expect_match")
|
|
n_source_tokens = len(_tokenize_words(source, tokenizer))
|
|
|
|
print(f" {name}: {case['description']}", flush=True)
|
|
print(f" source tokens: {n_source_tokens}", flush=True)
|
|
|
|
expect_token = case.get("expect_token_interval")
|
|
expect_char = case.get("expect_char_interval")
|
|
expect_sub = case.get("expect_substring")
|
|
|
|
timings = []
|
|
last_result = None
|
|
correctness = "n/a"
|
|
for i in range(args.runs):
|
|
print(f" run {i + 1}/{args.runs}...", end="", flush=True)
|
|
result = _run_single(
|
|
aligner,
|
|
source,
|
|
extraction_text,
|
|
tokenizer,
|
|
args.threshold,
|
|
args.algorithm,
|
|
args.min_density,
|
|
)
|
|
timings.append(result["elapsed_ms"])
|
|
last_result = result
|
|
print(f" {result['elapsed_ms']:.1f}ms", flush=True)
|
|
|
|
# Check oracle on every run. All configured expectations are checked.
|
|
if expect_match is not None:
|
|
if result["matched"] != expect_match:
|
|
correctness = "FAIL"
|
|
print(f" FAIL: expected matched={expect_match}", flush=True)
|
|
if expect_token and result["token_interval"]:
|
|
expected = f"{expect_token[0]}-{expect_token[1]}"
|
|
if result["token_interval"] != expected:
|
|
correctness = "FAIL"
|
|
print(
|
|
f" FAIL: token_interval {result['token_interval']}"
|
|
f" != {expected}",
|
|
flush=True,
|
|
)
|
|
if expect_char and result["char_interval"]:
|
|
expected = f"{expect_char[0]}-{expect_char[1]}"
|
|
if result["char_interval"] != expected:
|
|
correctness = "FAIL"
|
|
print(
|
|
f" FAIL: char_interval {result['char_interval']}"
|
|
f" != {expected}",
|
|
flush=True,
|
|
)
|
|
if expect_sub and result["matched_substring"] != expect_sub:
|
|
correctness = "FAIL"
|
|
print(" FAIL: substring mismatch", flush=True)
|
|
|
|
if correctness != "FAIL":
|
|
correctness = "PASS" if expect_match is not None else "n/a"
|
|
|
|
avg_ms = sum(timings) / len(timings)
|
|
min_ms = min(timings)
|
|
max_ms = max(timings)
|
|
|
|
print(f" avg: {avg_ms:.1f}ms min: {min_ms:.1f}ms max: {max_ms:.1f}ms")
|
|
print(f" matched: {last_result['matched']} correctness: {correctness}")
|
|
if last_result["matched_substring"]:
|
|
sub = last_result["matched_substring"]
|
|
if len(sub) > 80:
|
|
sub = sub[:80] + "..."
|
|
print(f" substring: {sub!r}")
|
|
print(flush=True)
|
|
|
|
results[name] = {
|
|
"description": case["description"],
|
|
"source_tokens": n_source_tokens,
|
|
"runs": args.runs,
|
|
"avg_ms": round(avg_ms, 2),
|
|
"min_ms": round(min_ms, 2),
|
|
"max_ms": round(max_ms, 2),
|
|
"matched": last_result["matched"],
|
|
"correctness": correctness,
|
|
"token_interval": last_result["token_interval"],
|
|
"char_interval": last_result["char_interval"],
|
|
"matched_substring": last_result["matched_substring"],
|
|
}
|
|
|
|
if args.json_output:
|
|
with open(args.json_output, "w") as f:
|
|
json.dump(results, f, indent=2)
|
|
print(f"Results written to {args.json_output}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|