maskflow-bench 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- maskflow_bench-0.1.0/.gitignore +16 -0
- maskflow_bench-0.1.0/PKG-INFO +30 -0
- maskflow_bench-0.1.0/README.md +18 -0
- maskflow_bench-0.1.0/pyproject.toml +37 -0
- maskflow_bench-0.1.0/src/maskflow_bench/__init__.py +25 -0
- maskflow_bench-0.1.0/src/maskflow_bench/adapters/__init__.py +15 -0
- maskflow_bench-0.1.0/src/maskflow_bench/adapters/base.py +31 -0
- maskflow_bench-0.1.0/src/maskflow_bench/adapters/maskflow_adapter.py +27 -0
- maskflow_bench-0.1.0/src/maskflow_bench/corpus.py +73 -0
- maskflow_bench-0.1.0/src/maskflow_bench/labels.py +97 -0
- maskflow_bench-0.1.0/src/maskflow_bench/loader.py +105 -0
- maskflow_bench-0.1.0/src/maskflow_bench/matching.py +130 -0
- maskflow_bench-0.1.0/src/maskflow_bench/offsets.py +27 -0
- maskflow_bench-0.1.0/src/maskflow_bench/profiling.py +78 -0
- maskflow_bench-0.1.0/src/maskflow_bench/report.py +149 -0
- maskflow_bench-0.1.0/src/maskflow_bench/runner.py +77 -0
- maskflow_bench-0.1.0/src/maskflow_bench/scorer.py +29 -0
- maskflow_bench-0.1.0/tests/test_corpus.py +49 -0
- maskflow_bench-0.1.0/tests/test_labels.py +25 -0
- maskflow_bench-0.1.0/tests/test_loader.py +107 -0
- maskflow_bench-0.1.0/tests/test_matching.py +106 -0
- maskflow_bench-0.1.0/tests/test_scorer.py +36 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: maskflow-bench
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MaskFlow benchmark scoring core: load labelled PII data, score detections under strict/partial-overlap precision-recall-F1, write JSON/Markdown reports. Powers `maskflow bench --my-data`.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: maskflow-core<0.9,>=0.8.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: maskflow-pack-india<0.6,>=0.5.1; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# maskflow-bench
|
|
14
|
+
|
|
15
|
+
The scoring core behind MaskFlow's accuracy measurements: load a labelled
|
|
16
|
+
JSONL corpus, canonicalize its entity taxonomy, score detections under
|
|
17
|
+
strict-span and partial-overlap precision/recall/F1, write `results.json`
|
|
18
|
+
and `results.md`.
|
|
19
|
+
|
|
20
|
+
Most people reach this through `maskflow bench --my-data <path>`
|
|
21
|
+
(`maskflow-cli`) — see [`docs/bench.md`](../../docs/bench.md) for the
|
|
22
|
+
labelled-data schema and command reference.
|
|
23
|
+
|
|
24
|
+
This package intentionally ships **one** adapter — MaskFlow itself
|
|
25
|
+
(`adapters.maskflow_adapter.MaskflowAdapter`) — with no dependency beyond
|
|
26
|
+
`maskflow-core`. The multi-adapter comparison against Presidio,
|
|
27
|
+
mask-privacy, a naive-regex baseline, and an LLM judge that produced the
|
|
28
|
+
published IndiaPII-Bench tables lives in `bench/indiapii/harness/` (repo
|
|
29
|
+
dev tooling, not published), and imports this package for the parts that
|
|
30
|
+
don't change per adapter.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# maskflow-bench
|
|
2
|
+
|
|
3
|
+
The scoring core behind MaskFlow's accuracy measurements: load a labelled
|
|
4
|
+
JSONL corpus, canonicalize its entity taxonomy, score detections under
|
|
5
|
+
strict-span and partial-overlap precision/recall/F1, write `results.json`
|
|
6
|
+
and `results.md`.
|
|
7
|
+
|
|
8
|
+
Most people reach this through `maskflow bench --my-data <path>`
|
|
9
|
+
(`maskflow-cli`) — see [`docs/bench.md`](../../docs/bench.md) for the
|
|
10
|
+
labelled-data schema and command reference.
|
|
11
|
+
|
|
12
|
+
This package intentionally ships **one** adapter — MaskFlow itself
|
|
13
|
+
(`adapters.maskflow_adapter.MaskflowAdapter`) — with no dependency beyond
|
|
14
|
+
`maskflow-core`. The multi-adapter comparison against Presidio,
|
|
15
|
+
mask-privacy, a naive-regex baseline, and an LLM judge that produced the
|
|
16
|
+
published IndiaPII-Bench tables lives in `bench/indiapii/harness/` (repo
|
|
17
|
+
dev tooling, not published), and imports this package for the parts that
|
|
18
|
+
don't change per adapter.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "maskflow-bench"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MaskFlow benchmark scoring core: load labelled PII data, score detections under strict/partial-overlap precision-recall-F1, write JSON/Markdown reports. Powers `maskflow bench --my-data`."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
dependencies = [
|
|
9
|
+
# Only adapters.maskflow_adapter needs this, to call
|
|
10
|
+
# maskflow_core.detection.detect(). No presidio/mask-privacy/anthropic
|
|
11
|
+
# here -- those five competitor adapters stay dev-only in
|
|
12
|
+
# bench/indiapii/harness/adapters/, never a dependency of this package.
|
|
13
|
+
"maskflow-core>=0.8.0,<0.9",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=8.0",
|
|
19
|
+
# test_scorer.py's end-to-end test needs a real recognizer registered
|
|
20
|
+
# to detect something -- this package's own runtime code never imports
|
|
21
|
+
# a pack (see module docstring), only its test suite does.
|
|
22
|
+
"maskflow-pack-india>=0.5.1,<0.6",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.uv.sources]
|
|
26
|
+
maskflow-core = { workspace = true }
|
|
27
|
+
maskflow-pack-india = { workspace = true }
|
|
28
|
+
|
|
29
|
+
[tool.uv]
|
|
30
|
+
package = true
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["src/maskflow_bench"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""maskflow-bench: the corpus-agnostic PII-detection scoring core.
|
|
2
|
+
|
|
3
|
+
Loads a labelled JSONL corpus into `Document` objects (corpus.py),
|
|
4
|
+
canonicalizes its entity taxonomy (labels.py), scores an adapter's raw
|
|
5
|
+
(start, end, label) predictions against it under strict-span and
|
|
6
|
+
partial-overlap matching (matching.py), times and profiles that run
|
|
7
|
+
(profiling.py, runner.py), and writes `results.json`/`results.md`
|
|
8
|
+
(report.py).
|
|
9
|
+
|
|
10
|
+
This package has exactly one built-in adapter, `adapters.maskflow_adapter.
|
|
11
|
+
MaskflowAdapter` (wraps `maskflow_core.detection.detect`, zero extra
|
|
12
|
+
third-party dependencies) -- the `loader`/`scorer` modules on top of it are
|
|
13
|
+
what `maskflow bench --my-data <path>` (packages/maskflow-cli) uses to
|
|
14
|
+
score MaskFlow against a user's own labelled documents.
|
|
15
|
+
|
|
16
|
+
It is also the shared implementation behind bench/indiapii/harness's,
|
|
17
|
+
bench/intlpii/harness's, and bench/scanbench/harness's own dev-only,
|
|
18
|
+
multi-adapter comparisons (Presidio, mask-privacy, naive regex, an LLM
|
|
19
|
+
judge) used to produce the published IndiaPII-Bench-style figures -- those
|
|
20
|
+
adapters, and the harness CLIs that run all of them together, stay in
|
|
21
|
+
bench/ since they pull heavy, optional third-party dependencies this
|
|
22
|
+
package never needs.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Just the one adapter this package owns (`maskflow_adapter.MaskflowAdapter`)
|
|
2
|
+
plus the `Adapter` protocol (`base.Adapter`) it implements. The other five
|
|
3
|
+
competitor adapters (Presidio, mask-privacy, naive regex, an LLM judge)
|
|
4
|
+
stay in bench/indiapii/harness/adapters/ -- they pull heavy, optional
|
|
5
|
+
third-party dependencies (presidio-analyzer, mask-privacy, anthropic) this
|
|
6
|
+
package never needs, and they are only ever used by the dev-only
|
|
7
|
+
multi-adapter comparison harness, never by `maskflow bench --my-data`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .base import Adapter, AdapterEntry
|
|
13
|
+
from .maskflow_adapter import MaskflowAdapter
|
|
14
|
+
|
|
15
|
+
__all__ = ["Adapter", "AdapterEntry", "MaskflowAdapter"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The Adapter protocol every competitor detector implements.
|
|
2
|
+
|
|
3
|
+
`available()` is checked once per harness run, before any `detect()` call:
|
|
4
|
+
an adapter missing an optional dependency (presidio, mask-privacy) or an
|
|
5
|
+
API key (the LLM adapter) reports `(False, reason)` and is skipped, never
|
|
6
|
+
crashing the run. `detect()` itself is called once per document by
|
|
7
|
+
runner.py, which wraps each call in its own bounded try/except so one bad
|
|
8
|
+
document can't take out the rest of an adapter's run either.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Adapter(Protocol):
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
def available(self) -> tuple[bool, str]:
|
|
20
|
+
"""Returns (True, "") if this adapter can run, else (False, reason)."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
def detect(self, text: str) -> list[tuple[int, int, str]]:
|
|
24
|
+
"""Returns (start, end, raw_label) spans in this adapter's own
|
|
25
|
+
label vocabulary -- translated to the corpus's canonical labels by
|
|
26
|
+
labels.py, not here."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# adapter instance, label_map -- the pair runner.py needs for each entry.
|
|
31
|
+
AdapterEntry = tuple[Adapter, dict[str, str]]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Adapter 1: MaskFlow itself, both bundled packs.
|
|
2
|
+
|
|
3
|
+
Importing maskflow_pack_intl and maskflow_pack_india registers every
|
|
4
|
+
recognizer against maskflow-core's global registry as a side effect (same
|
|
5
|
+
mechanism bench/indiapii/report.py already relies on for its own,
|
|
6
|
+
unrelated L1-L3 accuracy report) -- there is no separate "activate" step.
|
|
7
|
+
PIIType values already equal the corpus's own label vocabulary (the corpus
|
|
8
|
+
was generated from this pack's own types), so no label translation table
|
|
9
|
+
is needed here; labels.py's identity_map() is used by the caller.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import maskflow_pack_india # noqa: F401 (import-time registration side effect)
|
|
15
|
+
import maskflow_pack_intl # noqa: F401 (import-time registration side effect)
|
|
16
|
+
from maskflow_core.detection import detect
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MaskflowAdapter:
|
|
20
|
+
name = "maskflow"
|
|
21
|
+
|
|
22
|
+
def available(self) -> tuple[bool, str]:
|
|
23
|
+
return True, ""
|
|
24
|
+
|
|
25
|
+
def detect(self, text: str) -> list[tuple[int, int, str]]:
|
|
26
|
+
spans = detect(text)
|
|
27
|
+
return [(s.start, s.end, str(s.entity_type)) for s in spans]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Loads bench/indiapii/data/*.jsonl into Document objects.
|
|
2
|
+
|
|
3
|
+
Each corpus line has an `entities` list mixing `value_class: "positive"`
|
|
4
|
+
(real gold spans) and `value_class: "hard_negative"` (decoy spans, shaped
|
|
5
|
+
like real PII but never gold -- see generator/hard_negatives.py). Document
|
|
6
|
+
keeps these separate: `gold` is the only thing matching.py scores recall
|
|
7
|
+
against; `decoys` exist purely so a prediction landing on one still counts
|
|
8
|
+
as a false positive for whatever type it claimed (see matching.py).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Document:
|
|
21
|
+
id: str
|
|
22
|
+
text: str
|
|
23
|
+
domain: str
|
|
24
|
+
lang: str
|
|
25
|
+
gold: tuple[tuple[int, int, str], ...]
|
|
26
|
+
decoys: tuple[tuple[int, int, str], ...]
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def size_bytes(self) -> int:
|
|
30
|
+
return len(self.text.encode("utf-8"))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_line(line: str) -> Document:
|
|
34
|
+
row = json.loads(line)
|
|
35
|
+
gold = []
|
|
36
|
+
decoys = []
|
|
37
|
+
for e in row["entities"]:
|
|
38
|
+
span = (e["start"], e["end"], e["label"])
|
|
39
|
+
if e["value_class"] == "positive":
|
|
40
|
+
gold.append(span)
|
|
41
|
+
else:
|
|
42
|
+
decoys.append(span)
|
|
43
|
+
return Document(
|
|
44
|
+
id=row["id"],
|
|
45
|
+
text=row["text"],
|
|
46
|
+
domain=row["domain"],
|
|
47
|
+
lang=row["lang"],
|
|
48
|
+
gold=tuple(gold),
|
|
49
|
+
decoys=tuple(decoys),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def iter_corpus(path: Path) -> Iterator[Document]:
|
|
54
|
+
with path.open(encoding="utf-8") as f:
|
|
55
|
+
for line in f:
|
|
56
|
+
line = line.strip()
|
|
57
|
+
if line:
|
|
58
|
+
yield _parse_line(line)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_corpus(path: Path, limit: int | None = None) -> list[Document]:
|
|
62
|
+
"""Loads `path` in file order (the corpus's own doc-id order), which is
|
|
63
|
+
already deterministic per-seed at generation time -- `limit` therefore
|
|
64
|
+
always selects the same first-N documents run to run, which is what
|
|
65
|
+
the CI regression subset (see harness/tests/test_ci_regression.py)
|
|
66
|
+
relies on for a stable baseline.
|
|
67
|
+
"""
|
|
68
|
+
docs = []
|
|
69
|
+
for i, doc in enumerate(iter_corpus(path)):
|
|
70
|
+
if limit is not None and i >= limit:
|
|
71
|
+
break
|
|
72
|
+
docs.append(doc)
|
|
73
|
+
return docs
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Canonical entity taxonomy (the corpus's own positive labels) and each
|
|
2
|
+
adapter's raw-label -> canonical-label map.
|
|
3
|
+
|
|
4
|
+
The canonical taxonomy is never a new ontology invented here -- it's
|
|
5
|
+
whatever `value_class: "positive"` labels actually appear in the loaded
|
|
6
|
+
corpus, so a future corpus version's label set is picked up automatically
|
|
7
|
+
rather than drifting out of sync with a hardcoded list here.
|
|
8
|
+
|
|
9
|
+
Every LABEL_MAP below is intentionally partial: a raw label an adapter
|
|
10
|
+
emits with no entry here is dropped before scoring rather than counted as
|
|
11
|
+
a false positive against some unrelated canonical type -- see matching.py
|
|
12
|
+
and the harness plan's "Label mapping" section for why (mirrors the
|
|
13
|
+
`target_types`-restricted convention already used by
|
|
14
|
+
bench/indiapii/metrics.py).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
|
|
21
|
+
from .corpus import Document
|
|
22
|
+
|
|
23
|
+
# One-line definitions handed to the LLM adapter's prompt -- the taxonomy
|
|
24
|
+
# itself always comes from canonical_labels(docs) below; this dict is just
|
|
25
|
+
# human-readable glosses for whichever of those labels happen to have one.
|
|
26
|
+
LABEL_DESCRIPTIONS: dict[str, str] = {
|
|
27
|
+
"AADHAAR": "12-digit Indian national ID (Aadhaar), may be spaced/hyphenated in groups of 4",
|
|
28
|
+
"AADHAAR_MASKED": "Aadhaar number with first 8 digits masked, only last 4 digits visible",
|
|
29
|
+
"ABHA_ADDRESS": "Ayushman Bharat Health Account address, email-shaped (e.g. name@abdm)",
|
|
30
|
+
"ABHA_NUMBER": "14-digit Ayushman Bharat Health Account number",
|
|
31
|
+
"BANK_ACCOUNT_IN": "Indian bank account number, 9-18 digits",
|
|
32
|
+
"DRIVING_LICENCE": "Indian driving licence number (state code + digits)",
|
|
33
|
+
"GSTIN": "15-character Goods and Services Tax Identification Number",
|
|
34
|
+
"IFSC": "11-character bank branch code (4 letters + 0 + 6 alphanumeric)",
|
|
35
|
+
"INDIAN_ADDRESS": "a residential/postal address in India (street, locality, city, state)",
|
|
36
|
+
"INDIAN_MOBILE": "Indian mobile phone number, 10 digits, optionally with +91 prefix",
|
|
37
|
+
"INDIAN_PASSPORT": "Indian passport number (1 letter + 7 digits) or its MRZ block",
|
|
38
|
+
"PAN": "10-character Permanent Account Number (5 letters + 4 digits + 1 letter)",
|
|
39
|
+
"PERSON_NAME": "a person's full name",
|
|
40
|
+
"PIN_CODE": "6-digit Indian postal PIN code",
|
|
41
|
+
"UPI_VPA": "UPI Virtual Payment Address, looks like username@bank-handle",
|
|
42
|
+
"VEHICLE_REG": "Indian vehicle registration number (state code + district + series + digits)",
|
|
43
|
+
"VOTER_ID": "Voter ID / EPIC number, 3 letters + 7 digits",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def canonical_labels(docs: Iterable[Document]) -> tuple[str, ...]:
|
|
48
|
+
labels: set[str] = set()
|
|
49
|
+
for doc in docs:
|
|
50
|
+
for _start, _end, label in doc.gold:
|
|
51
|
+
labels.add(label)
|
|
52
|
+
return tuple(sorted(labels))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# maskflow's own PIIType values are literally the corpus's label vocabulary
|
|
56
|
+
# (the corpus was generated from maskflow-pack-india's own types) -- no
|
|
57
|
+
# translation needed, but adapters.base.Adapter still calls through this
|
|
58
|
+
# module for a uniform code path, so this is an identity function.
|
|
59
|
+
def identity_map(raw_labels: Iterable[str]) -> dict[str, str]:
|
|
60
|
+
return {label: label for label in raw_labels}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
PRESIDIO_LABEL_MAP: dict[str, str] = {
|
|
64
|
+
"PERSON": "PERSON_NAME",
|
|
65
|
+
"PHONE_NUMBER": "INDIAN_MOBILE",
|
|
66
|
+
"LOCATION": "INDIAN_ADDRESS",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Same as PRESIDIO_LABEL_MAP, plus the two custom pattern recognizers
|
|
70
|
+
# presidio_custom_adapter.py registers directly under these names.
|
|
71
|
+
PRESIDIO_CUSTOM_LABEL_MAP: dict[str, str] = {
|
|
72
|
+
**PRESIDIO_LABEL_MAP,
|
|
73
|
+
"IN_AADHAAR": "AADHAAR",
|
|
74
|
+
"IN_PAN": "PAN",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# mask-privacy's DLP registry (core/dlp/registry.py) has zero India-specific
|
|
78
|
+
# entity types (confirmed by inspecting the installed package -- 38 raw
|
|
79
|
+
# types, all US/EU/generic: BANK_ACCT_NUM, PHONE_NUM, EMAIL_ADDR, US_SSN,
|
|
80
|
+
# VEHICLE_PLATE, ...). Its Tier-2 NLP tier is Presidio underneath, reusing
|
|
81
|
+
# the same spaCy PERSON/LOCATION labels as PRESIDIO_LABEL_MAP.
|
|
82
|
+
MASK_PRIVACY_LABEL_MAP: dict[str, str] = {
|
|
83
|
+
"PERSON": "PERSON_NAME",
|
|
84
|
+
"LOCATION": "INDIAN_ADDRESS",
|
|
85
|
+
"PHONE_NUM": "INDIAN_MOBILE",
|
|
86
|
+
"PHONE_NUM_INTL": "INDIAN_MOBILE",
|
|
87
|
+
"BANK_ACCT_NUM": "BANK_ACCOUNT_IN",
|
|
88
|
+
"VEHICLE_PLATE": "VEHICLE_REG",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
# naive_regex_adapter.py's own made-up label names, mapped to canonical.
|
|
92
|
+
NAIVE_REGEX_LABEL_MAP: dict[str, str] = {
|
|
93
|
+
"PHONE_SHAPED": "INDIAN_MOBILE",
|
|
94
|
+
"AADHAAR_SHAPED": "AADHAAR",
|
|
95
|
+
"PAN_SHAPED": "PAN",
|
|
96
|
+
"PINCODE_SHAPED": "PIN_CODE",
|
|
97
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Lenient JSONL loader for a user's own labelled data (`maskflow bench
|
|
2
|
+
--my-data <path>`, packages/maskflow-cli), as opposed to corpus.py's
|
|
3
|
+
strict parser for the bundled benchmark corpora.
|
|
4
|
+
|
|
5
|
+
A user checking "is it accurate on my documents?" has no reason to know
|
|
6
|
+
about `domain`/`lang`/`value_class` -- those are benchmark-corpus
|
|
7
|
+
bookkeeping, not concepts a newcomer's own labelled file would naturally
|
|
8
|
+
carry. Only `text` and `entities[].{start,end,label}` are required; every
|
|
9
|
+
other field gets a sensible default. The result is the exact same
|
|
10
|
+
`Document` dataclass corpus.py produces, so every downstream function
|
|
11
|
+
(labels.canonical_labels, matching.evaluate, runner.run_adapter,
|
|
12
|
+
report.write_report) is used unmodified.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .corpus import Document
|
|
23
|
+
|
|
24
|
+
_REQUIRED_ENTITY_FIELDS = ("start", "end", "label")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class LoaderError(Exception):
|
|
29
|
+
"""Raised on one malformed line, with the 1-based line number so the
|
|
30
|
+
CLI can report exactly where a user's file needs fixing -- never a raw
|
|
31
|
+
KeyError/TypeError/JSONDecodeError with no context."""
|
|
32
|
+
|
|
33
|
+
line_no: int
|
|
34
|
+
message: str
|
|
35
|
+
|
|
36
|
+
def __str__(self) -> str:
|
|
37
|
+
return f"line {self.line_no}: {self.message}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_line(line_no: int, line: str) -> Document:
|
|
41
|
+
try:
|
|
42
|
+
row = json.loads(line)
|
|
43
|
+
except json.JSONDecodeError as e:
|
|
44
|
+
raise LoaderError(line_no, f"invalid JSON ({e})") from e
|
|
45
|
+
if not isinstance(row, dict):
|
|
46
|
+
raise LoaderError(line_no, "each line must be a JSON object")
|
|
47
|
+
|
|
48
|
+
if "text" not in row:
|
|
49
|
+
raise LoaderError(line_no, "missing required field 'text'")
|
|
50
|
+
text = row["text"]
|
|
51
|
+
if not isinstance(text, str):
|
|
52
|
+
raise LoaderError(line_no, "'text' must be a string")
|
|
53
|
+
|
|
54
|
+
if "entities" not in row:
|
|
55
|
+
raise LoaderError(line_no, "missing required field 'entities'")
|
|
56
|
+
raw_entities = row["entities"]
|
|
57
|
+
if not isinstance(raw_entities, list):
|
|
58
|
+
raise LoaderError(line_no, "'entities' must be a list")
|
|
59
|
+
|
|
60
|
+
gold = []
|
|
61
|
+
decoys = []
|
|
62
|
+
for i, entity in enumerate(raw_entities):
|
|
63
|
+
if not isinstance(entity, dict):
|
|
64
|
+
raise LoaderError(line_no, f"entities[{i}] must be an object")
|
|
65
|
+
missing = [f for f in _REQUIRED_ENTITY_FIELDS if f not in entity]
|
|
66
|
+
if missing:
|
|
67
|
+
raise LoaderError(line_no, f"entities[{i}] missing field(s): {', '.join(missing)}")
|
|
68
|
+
start, end, label = entity["start"], entity["end"], entity["label"]
|
|
69
|
+
if not isinstance(start, int) or not isinstance(end, int):
|
|
70
|
+
raise LoaderError(line_no, f"entities[{i}]: 'start'/'end' must be integers")
|
|
71
|
+
if not isinstance(label, str):
|
|
72
|
+
raise LoaderError(line_no, f"entities[{i}]: 'label' must be a string")
|
|
73
|
+
span = (start, end, label)
|
|
74
|
+
if entity.get("value_class", "positive") == "positive":
|
|
75
|
+
gold.append(span)
|
|
76
|
+
else:
|
|
77
|
+
decoys.append(span)
|
|
78
|
+
|
|
79
|
+
return Document(
|
|
80
|
+
id=str(row.get("id", f"line-{line_no}")),
|
|
81
|
+
text=text,
|
|
82
|
+
domain=str(row.get("domain", "user_data")),
|
|
83
|
+
lang=str(row.get("lang", "en")),
|
|
84
|
+
gold=tuple(gold),
|
|
85
|
+
decoys=tuple(decoys),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def iter_my_data(path: Path) -> Iterator[Document]:
|
|
90
|
+
with path.open(encoding="utf-8") as f:
|
|
91
|
+
for line_no, line in enumerate(f, start=1):
|
|
92
|
+
line = line.strip()
|
|
93
|
+
if line:
|
|
94
|
+
yield _parse_line(line_no, line)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_my_data(path: Path, limit: int | None = None) -> list[Document]:
|
|
98
|
+
"""Loads `path` in file order. `limit` scores only the first N lines --
|
|
99
|
+
a quick smoke run on a large file, same convention as corpus.py's."""
|
|
100
|
+
docs = []
|
|
101
|
+
for i, doc in enumerate(iter_my_data(path)):
|
|
102
|
+
if limit is not None and i >= limit:
|
|
103
|
+
break
|
|
104
|
+
docs.append(doc)
|
|
105
|
+
return docs
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Strict-span and partial-overlap precision/recall/F1 scoring.
|
|
2
|
+
|
|
3
|
+
Gold spans for a Document are `Document.gold` only (`value_class ==
|
|
4
|
+
"positive"`) -- `Document.decoys` (hard-negative spans) are never gold, but
|
|
5
|
+
a prediction landing on one is still scored: after label-mapping (see
|
|
6
|
+
labels.py), any predicted span left unmatched to a gold span -- whether it
|
|
7
|
+
overlaps background text or a decoy -- counts as a false positive for its
|
|
8
|
+
mapped canonical type. That's what makes the corpus's hard negatives (e.g.
|
|
9
|
+
a naive-regex hit on PAN_SHAPED_INVOICE_NO) actually cost precision.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
from .corpus import Document
|
|
18
|
+
|
|
19
|
+
RawSpan = tuple[int, int, str] # (start, end, raw_label) as an adapter emits it
|
|
20
|
+
GoldSpan = tuple[int, int, str] # (start, end, canonical_label)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MatchMode(str, Enum):
|
|
24
|
+
STRICT = "strict"
|
|
25
|
+
PARTIAL = "partial"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PRFResult:
|
|
30
|
+
entity_type: str
|
|
31
|
+
tp: int = 0
|
|
32
|
+
fp: int = 0
|
|
33
|
+
fn: int = 0
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def precision(self) -> float | None:
|
|
37
|
+
denom = self.tp + self.fp
|
|
38
|
+
return self.tp / denom if denom else None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def recall(self) -> float | None:
|
|
42
|
+
denom = self.tp + self.fn
|
|
43
|
+
return self.tp / denom if denom else None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def f1(self) -> float | None:
|
|
47
|
+
"""None only when there was nothing to score on one side (precision
|
|
48
|
+
or recall has no denominator -- no predictions AND/OR no gold). A
|
|
49
|
+
detector that fired on this type but matched no gold span (tp=0,
|
|
50
|
+
fp>0, fn>0) has a *measured* F1 of 0.0, not "not applicable" -- the
|
|
51
|
+
report renders that as `0.0%`, distinct from `—`."""
|
|
52
|
+
p, r = self.precision, self.recall
|
|
53
|
+
if p is None or r is None:
|
|
54
|
+
return None
|
|
55
|
+
if p + r == 0:
|
|
56
|
+
return 0.0
|
|
57
|
+
return 2 * p * r / (p + r)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _mapped_predictions(
|
|
61
|
+
predictions: list[RawSpan], label_map: dict[str, str]
|
|
62
|
+
) -> list[tuple[int, int, str]]:
|
|
63
|
+
"""Drops any prediction whose raw label has no canonical mapping --
|
|
64
|
+
never scored as a false positive against an unrelated type."""
|
|
65
|
+
out = []
|
|
66
|
+
for start, end, raw_label in predictions:
|
|
67
|
+
canonical = label_map.get(raw_label)
|
|
68
|
+
if canonical is not None:
|
|
69
|
+
out.append((start, end, canonical))
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _score_document(
|
|
74
|
+
gold: tuple[GoldSpan, ...],
|
|
75
|
+
predictions: list[tuple[int, int, str]],
|
|
76
|
+
mode: MatchMode,
|
|
77
|
+
results: dict[str, PRFResult],
|
|
78
|
+
) -> None:
|
|
79
|
+
unclaimed_pred = list(range(len(predictions)))
|
|
80
|
+
|
|
81
|
+
def overlaps(a: tuple[int, int, str], b: tuple[int, int, str]) -> bool:
|
|
82
|
+
return max(a[0], b[0]) < min(a[1], b[1])
|
|
83
|
+
|
|
84
|
+
for g_start, g_end, g_label in gold:
|
|
85
|
+
gold_span = (g_start, g_end, g_label)
|
|
86
|
+
match_idx = None
|
|
87
|
+
for idx in unclaimed_pred:
|
|
88
|
+
p_start, p_end, p_label = predictions[idx]
|
|
89
|
+
if p_label != g_label:
|
|
90
|
+
continue
|
|
91
|
+
if mode is MatchMode.STRICT:
|
|
92
|
+
hit = (p_start, p_end) == (g_start, g_end)
|
|
93
|
+
else:
|
|
94
|
+
hit = overlaps((p_start, p_end, p_label), gold_span)
|
|
95
|
+
if hit:
|
|
96
|
+
match_idx = idx
|
|
97
|
+
break
|
|
98
|
+
if match_idx is not None:
|
|
99
|
+
results[g_label].tp += 1
|
|
100
|
+
unclaimed_pred.remove(match_idx)
|
|
101
|
+
else:
|
|
102
|
+
results[g_label].fn += 1
|
|
103
|
+
|
|
104
|
+
for idx in unclaimed_pred:
|
|
105
|
+
_p_start, _p_end, p_label = predictions[idx]
|
|
106
|
+
if p_label in results:
|
|
107
|
+
results[p_label].fp += 1
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def evaluate(
|
|
111
|
+
docs: list[Document],
|
|
112
|
+
predictions_by_doc: list[list[RawSpan]],
|
|
113
|
+
label_map: dict[str, str],
|
|
114
|
+
canonical_labels: tuple[str, ...],
|
|
115
|
+
mode: MatchMode,
|
|
116
|
+
) -> dict[str, PRFResult]:
|
|
117
|
+
"""Scores each doc's already-computed raw predictions (see runner.py,
|
|
118
|
+
which owns calling detect() so it can time/guard each call once and
|
|
119
|
+
reuse the same predictions for both matching modes) against
|
|
120
|
+
`doc.gold`, after mapping raw labels through `label_map`. Restricted to
|
|
121
|
+
`canonical_labels` so an adapter's detections of types outside this
|
|
122
|
+
corpus's taxonomy never enter the tally at all.
|
|
123
|
+
"""
|
|
124
|
+
results: dict[str, PRFResult] = {t: PRFResult(entity_type=t) for t in canonical_labels}
|
|
125
|
+
for doc, raw_predictions in zip(docs, predictions_by_doc, strict=True):
|
|
126
|
+
predictions = _mapped_predictions(raw_predictions, label_map)
|
|
127
|
+
predictions = [p for p in predictions if p[2] in results]
|
|
128
|
+
gold = tuple(g for g in doc.gold if g[2] in results)
|
|
129
|
+
_score_document(gold, predictions, mode, results)
|
|
130
|
+
return results
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Recovers character offsets for adapters that only return matched
|
|
2
|
+
substrings, not (start, end) positions -- mask-privacy's Tier-2 NLP
|
|
3
|
+
entities and the LLM adapter both need this (see their modules' docstrings
|
|
4
|
+
for why offsets aren't available directly from those APIs).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def locate_span(text: str, value: str, used_starts: set[int]) -> tuple[int, int] | None:
|
|
11
|
+
"""Finds the first occurrence of `value` in `text` whose start index
|
|
12
|
+
isn't already in `used_starts`, claims it, and returns (start, end).
|
|
13
|
+
Returns None if `value` doesn't occur (or every occurrence is already
|
|
14
|
+
claimed) -- the caller drops that finding and counts it as unlocatable
|
|
15
|
+
rather than guessing.
|
|
16
|
+
"""
|
|
17
|
+
if not value:
|
|
18
|
+
return None
|
|
19
|
+
cursor = 0
|
|
20
|
+
while True:
|
|
21
|
+
idx = text.find(value, cursor)
|
|
22
|
+
if idx == -1:
|
|
23
|
+
return None
|
|
24
|
+
if idx not in used_starts:
|
|
25
|
+
used_starts.add(idx)
|
|
26
|
+
return idx, idx + len(value)
|
|
27
|
+
cursor = idx + 1
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Latency (ms/KB) and peak-memory (RSS delta) measurement for one
|
|
2
|
+
adapter's run over a corpus.
|
|
3
|
+
|
|
4
|
+
Known limitation (documented, not hidden): adapters run in-process,
|
|
5
|
+
sequentially, not subprocess-isolated -- ru_maxrss is a whole-process,
|
|
6
|
+
monotonically-nondecreasing high-water mark, so peak_memory_mb here is an
|
|
7
|
+
*attributable delta* across one adapter's calls, not a hard ceiling that
|
|
8
|
+
adapter alone ever touched. Good enough for a first cut / relative
|
|
9
|
+
comparison, not a substitute for real per-process isolation.
|
|
10
|
+
|
|
11
|
+
A second limitation, specifically because `maskflow bench --my-data`
|
|
12
|
+
(packages/maskflow-cli) makes this module reachable on Windows, unlike the
|
|
13
|
+
dev-only multi-adapter harness that only ever ran on Linux/macOS CI:
|
|
14
|
+
`resource` is POSIX-only. On a platform without it, peak_memory_mb is
|
|
15
|
+
always reported as 0.0 rather than crashing -- timing (latency_ms_per_kb,
|
|
16
|
+
median/p95 doc ms) is unaffected either way.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import resource
|
|
26
|
+
except ImportError: # Windows
|
|
27
|
+
resource = None # type: ignore[assignment]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _current_maxrss_mb() -> float:
|
|
31
|
+
if resource is None:
|
|
32
|
+
return 0.0
|
|
33
|
+
raw = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
34
|
+
# macOS reports ru_maxrss in bytes; Linux reports it in KB.
|
|
35
|
+
return raw / (1024 * 1024) if sys.platform == "darwin" else raw / 1024
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ProfileResult:
|
|
40
|
+
latency_ms_per_kb: float
|
|
41
|
+
median_doc_ms: float
|
|
42
|
+
p95_doc_ms: float
|
|
43
|
+
peak_memory_mb: float
|
|
44
|
+
errors: int = 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Profiler:
|
|
49
|
+
_doc_ms: list[float] = field(default_factory=list)
|
|
50
|
+
_total_bytes: int = 0
|
|
51
|
+
_start_rss_mb: float = 0.0
|
|
52
|
+
_started: bool = False
|
|
53
|
+
errors: int = 0
|
|
54
|
+
|
|
55
|
+
def start(self) -> None:
|
|
56
|
+
self._start_rss_mb = _current_maxrss_mb()
|
|
57
|
+
self._started = True
|
|
58
|
+
|
|
59
|
+
def record(self, elapsed_seconds: float, size_bytes: int) -> None:
|
|
60
|
+
self._doc_ms.append(elapsed_seconds * 1000)
|
|
61
|
+
self._total_bytes += size_bytes
|
|
62
|
+
|
|
63
|
+
def finish(self) -> ProfileResult:
|
|
64
|
+
end_rss_mb = _current_maxrss_mb()
|
|
65
|
+
total_ms = sum(self._doc_ms)
|
|
66
|
+
total_kb = (self._total_bytes / 1024) or 1.0
|
|
67
|
+
sorted_ms = sorted(self._doc_ms)
|
|
68
|
+
median = sorted_ms[len(sorted_ms) // 2] if sorted_ms else 0.0
|
|
69
|
+
p95_idx = min(len(sorted_ms) - 1, int(len(sorted_ms) * 0.95)) if sorted_ms else 0
|
|
70
|
+
p95 = sorted_ms[p95_idx] if sorted_ms else 0.0
|
|
71
|
+
start_rss = self._start_rss_mb if self._started else end_rss_mb
|
|
72
|
+
return ProfileResult(
|
|
73
|
+
latency_ms_per_kb=total_ms / total_kb,
|
|
74
|
+
median_doc_ms=median,
|
|
75
|
+
p95_doc_ms=p95,
|
|
76
|
+
peak_memory_mb=max(0.0, end_rss_mb - start_rss),
|
|
77
|
+
errors=self.errors,
|
|
78
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Writes results.json (full structured data) and results.md (generated
|
|
2
|
+
tables) from a run_all() result. Module path is
|
|
3
|
+
`maskflow_bench.report`, distinct from the unrelated, pre-existing
|
|
4
|
+
`bench.indiapii.report` (the pack-india L1-L3 dev accuracy report) -- see
|
|
5
|
+
harness/__init__.py's docstring.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
# datetime.UTC is 3.11+ only; this package's floor is requires-python
|
|
13
|
+
# >=3.10 (it's now reachable from maskflow-cli, unlike when this lived in
|
|
14
|
+
# the dev-only bench/indiapii/harness/ and never ran on 3.10 in CI).
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .matching import PRFResult
|
|
19
|
+
from .runner import AdapterRunResult
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _prf_to_dict(r: PRFResult) -> dict[str, float | int | None]:
|
|
23
|
+
return {
|
|
24
|
+
"precision": r.precision,
|
|
25
|
+
"recall": r.recall,
|
|
26
|
+
"f1": r.f1,
|
|
27
|
+
"tp": r.tp,
|
|
28
|
+
"fp": r.fp,
|
|
29
|
+
"fn": r.fn,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def to_json_dict(
|
|
34
|
+
corpus_name: str,
|
|
35
|
+
num_docs: int,
|
|
36
|
+
canonical_labels: tuple[str, ...],
|
|
37
|
+
results: dict[str, AdapterRunResult],
|
|
38
|
+
) -> dict:
|
|
39
|
+
adapters: dict[str, dict] = {}
|
|
40
|
+
for name, r in results.items():
|
|
41
|
+
entry: dict = {"available": r.available, "skipped_reason": r.skipped_reason}
|
|
42
|
+
if r.available:
|
|
43
|
+
entry["strict"] = {label: _prf_to_dict(v) for label, v in r.strict.items()}
|
|
44
|
+
entry["partial"] = {label: _prf_to_dict(v) for label, v in r.partial.items()}
|
|
45
|
+
if r.profile is not None:
|
|
46
|
+
entry["latency_ms_per_kb"] = r.profile.latency_ms_per_kb
|
|
47
|
+
entry["median_doc_ms"] = r.profile.median_doc_ms
|
|
48
|
+
entry["p95_doc_ms"] = r.profile.p95_doc_ms
|
|
49
|
+
entry["peak_memory_mb"] = r.profile.peak_memory_mb
|
|
50
|
+
entry["doc_errors"] = r.profile.errors
|
|
51
|
+
adapters[name] = entry
|
|
52
|
+
return {
|
|
53
|
+
"corpus": corpus_name,
|
|
54
|
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
55
|
+
"num_docs": num_docs,
|
|
56
|
+
"canonical_labels": list(canonical_labels),
|
|
57
|
+
"adapters": adapters,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def write_json(path: Path, data: dict) -> None:
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _f1_cell(results: dict[str, AdapterRunResult], adapter_name: str, mode: str, label: str) -> str:
|
|
67
|
+
r = results[adapter_name]
|
|
68
|
+
if not r.available:
|
|
69
|
+
return "skipped"
|
|
70
|
+
prf = getattr(r, mode).get(label)
|
|
71
|
+
if prf is None or prf.f1 is None:
|
|
72
|
+
return "—"
|
|
73
|
+
return f"{prf.f1:.1%}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _pivot_table(
|
|
77
|
+
results: dict[str, AdapterRunResult],
|
|
78
|
+
adapter_names: list[str],
|
|
79
|
+
canonical_labels: tuple[str, ...],
|
|
80
|
+
mode: str,
|
|
81
|
+
title: str,
|
|
82
|
+
) -> str:
|
|
83
|
+
header = "| entity_type | " + " | ".join(adapter_names) + " |"
|
|
84
|
+
sep = "|---" * (len(adapter_names) + 1) + "|"
|
|
85
|
+
lines = [f"### {title}", "", header, sep]
|
|
86
|
+
for label in canonical_labels:
|
|
87
|
+
row = [_f1_cell(results, name, mode, label) for name in adapter_names]
|
|
88
|
+
lines.append(f"| {label} | " + " | ".join(row) + " |")
|
|
89
|
+
return "\n".join(lines)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _latency_table(results: dict[str, AdapterRunResult], adapter_names: list[str]) -> str:
|
|
93
|
+
header = "| adapter | ms/KB | median ms/doc | p95 ms/doc | peak memory (MB) | doc errors |"
|
|
94
|
+
sep = "|---|---|---|---|---|---|"
|
|
95
|
+
lines = ["### Latency & memory", "", header, sep]
|
|
96
|
+
for name in adapter_names:
|
|
97
|
+
r = results[name]
|
|
98
|
+
if not r.available:
|
|
99
|
+
lines.append(f"| {name} | skipped ({r.skipped_reason}) | | | | |")
|
|
100
|
+
continue
|
|
101
|
+
p = r.profile
|
|
102
|
+
assert p is not None
|
|
103
|
+
lines.append(
|
|
104
|
+
f"| {name} | {p.latency_ms_per_kb:.3f} | {p.median_doc_ms:.3f} | "
|
|
105
|
+
f"{p.p95_doc_ms:.3f} | {p.peak_memory_mb:.1f} | {p.errors} |"
|
|
106
|
+
)
|
|
107
|
+
return "\n".join(lines)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def to_markdown(
|
|
111
|
+
corpus_name: str,
|
|
112
|
+
num_docs: int,
|
|
113
|
+
canonical_labels: tuple[str, ...],
|
|
114
|
+
results: dict[str, AdapterRunResult],
|
|
115
|
+
) -> str:
|
|
116
|
+
adapter_names = list(results.keys())
|
|
117
|
+
parts = [
|
|
118
|
+
f"# {corpus_name} benchmark results",
|
|
119
|
+
"",
|
|
120
|
+
f"{num_docs} documents, {len(canonical_labels)} canonical entity types. "
|
|
121
|
+
'F1 shown per entity per adapter. "0.0%" is a *measured* zero -- the adapter '
|
|
122
|
+
'made predictions for this type but none matched a gold span. "—" means F1 '
|
|
123
|
+
"is undefined: the adapter made no prediction for this type at all (its "
|
|
124
|
+
"recognizer / label map doesn't cover it), or the corpus has no gold spans "
|
|
125
|
+
"for it. \"skipped\" means the adapter's dependency/API key wasn't available "
|
|
126
|
+
"in this environment.",
|
|
127
|
+
"",
|
|
128
|
+
_pivot_table(results, adapter_names, canonical_labels, "strict", "Strict-span F1"),
|
|
129
|
+
"",
|
|
130
|
+
_pivot_table(results, adapter_names, canonical_labels, "partial", "Partial-overlap F1"),
|
|
131
|
+
"",
|
|
132
|
+
_latency_table(results, adapter_names),
|
|
133
|
+
"",
|
|
134
|
+
]
|
|
135
|
+
return "\n".join(parts)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def write_report(
|
|
139
|
+
out_dir: Path,
|
|
140
|
+
corpus_name: str,
|
|
141
|
+
num_docs: int,
|
|
142
|
+
canonical_labels: tuple[str, ...],
|
|
143
|
+
results: dict[str, AdapterRunResult],
|
|
144
|
+
) -> None:
|
|
145
|
+
data = to_json_dict(corpus_name, num_docs, canonical_labels, results)
|
|
146
|
+
write_json(out_dir / "results.json", data)
|
|
147
|
+
md = to_markdown(corpus_name, num_docs, canonical_labels, results)
|
|
148
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
(out_dir / "results.md").write_text(md, encoding="utf-8")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Orchestrates: for each adapter, check availability, run detect() once
|
|
2
|
+
per document (timed, error-guarded), then score the same cached
|
|
3
|
+
predictions under both matching modes -- no adapter's detect() is ever
|
|
4
|
+
called twice for the same document.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from .adapters import AdapterEntry
|
|
13
|
+
from .corpus import Document
|
|
14
|
+
from .matching import MatchMode, PRFResult, evaluate
|
|
15
|
+
from .profiling import Profiler, ProfileResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class AdapterRunResult:
|
|
20
|
+
name: str
|
|
21
|
+
available: bool
|
|
22
|
+
skipped_reason: str = ""
|
|
23
|
+
strict: dict[str, PRFResult] = field(default_factory=dict)
|
|
24
|
+
partial: dict[str, PRFResult] = field(default_factory=dict)
|
|
25
|
+
profile: ProfileResult | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run_adapter(
|
|
29
|
+
entry: AdapterEntry, docs: list[Document], canonical_labels: tuple[str, ...]
|
|
30
|
+
) -> AdapterRunResult:
|
|
31
|
+
adapter, label_map = entry
|
|
32
|
+
ok, reason = adapter.available()
|
|
33
|
+
if not ok:
|
|
34
|
+
return AdapterRunResult(name=adapter.name, available=False, skipped_reason=reason)
|
|
35
|
+
|
|
36
|
+
# One untimed warm-up call so a one-time cost every adapter pays somewhere
|
|
37
|
+
# (spaCy model load, first-call JIT/regex compilation, ...) lands the same
|
|
38
|
+
# way for all of them -- some adapters already pay it inside available()
|
|
39
|
+
# (e.g. presidio's engine construction), others pay it lazily on their
|
|
40
|
+
# first detect() call (maskflow's NER pass) -- without this, whichever
|
|
41
|
+
# adapter happens to defer its warm-up into the timed loop looks
|
|
42
|
+
# artificially slower for reasons that have nothing to do with steady-
|
|
43
|
+
# state per-document latency, which is what ms/KB is meant to measure.
|
|
44
|
+
try:
|
|
45
|
+
adapter.detect("warm-up, no PII here.")
|
|
46
|
+
except Exception: # noqa: BLE001 -- warm-up failures don't affect timing/scoring
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
profiler = Profiler()
|
|
50
|
+
profiler.start()
|
|
51
|
+
predictions_by_doc: list[list[tuple[int, int, str]]] = []
|
|
52
|
+
for doc in docs:
|
|
53
|
+
t0 = time.perf_counter()
|
|
54
|
+
try:
|
|
55
|
+
preds = adapter.detect(doc.text)
|
|
56
|
+
except Exception: # noqa: BLE001 -- one bad doc must not sink the whole adapter run
|
|
57
|
+
preds = []
|
|
58
|
+
profiler.errors += 1
|
|
59
|
+
profiler.record(time.perf_counter() - t0, doc.size_bytes)
|
|
60
|
+
predictions_by_doc.append(preds)
|
|
61
|
+
|
|
62
|
+
strict = evaluate(docs, predictions_by_doc, label_map, canonical_labels, MatchMode.STRICT)
|
|
63
|
+
partial = evaluate(docs, predictions_by_doc, label_map, canonical_labels, MatchMode.PARTIAL)
|
|
64
|
+
|
|
65
|
+
return AdapterRunResult(
|
|
66
|
+
name=adapter.name,
|
|
67
|
+
available=True,
|
|
68
|
+
strict=strict,
|
|
69
|
+
partial=partial,
|
|
70
|
+
profile=profiler.finish(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_all(
|
|
75
|
+
entries: list[AdapterEntry], docs: list[Document], canonical_labels: tuple[str, ...]
|
|
76
|
+
) -> dict[str, AdapterRunResult]:
|
|
77
|
+
return {entry[0].name: run_adapter(entry, docs, canonical_labels) for entry in entries}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Scores MaskFlow itself against a user's own labelled data
|
|
2
|
+
(`maskflow bench --my-data <path>`). Thin composition of loader.py +
|
|
3
|
+
labels.py + runner.py + adapters.maskflow_adapter -- the same pieces
|
|
4
|
+
bench/indiapii/harness/__main__.py assembles ad hoc for the bundled
|
|
5
|
+
corpora, wired together here as one reusable call so the CLI (and anyone
|
|
6
|
+
else) doesn't have to re-derive it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .adapters.maskflow_adapter import MaskflowAdapter
|
|
14
|
+
from .labels import canonical_labels, identity_map
|
|
15
|
+
from .loader import load_my_data
|
|
16
|
+
from .runner import AdapterRunResult, run_adapter
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def score_my_data(
|
|
20
|
+
path: Path, limit: int | None = None
|
|
21
|
+
) -> tuple[int, tuple[str, ...], AdapterRunResult]:
|
|
22
|
+
"""Returns (num_docs, canonical_labels, result). Raises LoaderError
|
|
23
|
+
(loader.py) on a malformed line, FileNotFoundError if `path` doesn't
|
|
24
|
+
exist."""
|
|
25
|
+
docs = load_my_data(path, limit=limit)
|
|
26
|
+
labels = canonical_labels(docs)
|
|
27
|
+
entry = (MaskflowAdapter(), identity_map(labels))
|
|
28
|
+
result = run_adapter(entry, docs, labels)
|
|
29
|
+
return len(docs), labels, result
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from maskflow_bench.corpus import load_corpus
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _write_jsonl(path: Path, rows: list[dict]) -> None:
|
|
10
|
+
path.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_load_corpus_splits_positive_and_hard_negative(tmp_path: Path) -> None:
|
|
14
|
+
row = {
|
|
15
|
+
"id": "doc-1",
|
|
16
|
+
"text": "PAN: ABCDE1234F, invoice INV-0001",
|
|
17
|
+
"entities": [
|
|
18
|
+
{"start": 5, "end": 15, "label": "PAN", "value_class": "positive"},
|
|
19
|
+
{
|
|
20
|
+
"start": 25,
|
|
21
|
+
"end": 33,
|
|
22
|
+
"label": "PAN_SHAPED_INVOICE_NO",
|
|
23
|
+
"value_class": "hard_negative",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
"domain": "kyc_form",
|
|
27
|
+
"lang": "en",
|
|
28
|
+
}
|
|
29
|
+
path = tmp_path / "corpus.jsonl"
|
|
30
|
+
_write_jsonl(path, [row])
|
|
31
|
+
|
|
32
|
+
docs = load_corpus(path)
|
|
33
|
+
assert len(docs) == 1
|
|
34
|
+
doc = docs[0]
|
|
35
|
+
assert doc.gold == ((5, 15, "PAN"),)
|
|
36
|
+
assert doc.decoys == ((25, 33, "PAN_SHAPED_INVOICE_NO"),)
|
|
37
|
+
assert doc.size_bytes == len(doc.text.encode("utf-8"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_load_corpus_limit_takes_first_n_in_file_order(tmp_path: Path) -> None:
|
|
41
|
+
rows = [
|
|
42
|
+
{"id": f"doc-{i}", "text": "t", "entities": [], "domain": "d", "lang": "en"}
|
|
43
|
+
for i in range(5)
|
|
44
|
+
]
|
|
45
|
+
path = tmp_path / "corpus.jsonl"
|
|
46
|
+
_write_jsonl(path, rows)
|
|
47
|
+
|
|
48
|
+
docs = load_corpus(path, limit=2)
|
|
49
|
+
assert [d.id for d in docs] == ["doc-0", "doc-1"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from maskflow_bench.corpus import Document
|
|
4
|
+
from maskflow_bench.labels import canonical_labels, identity_map
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_canonical_labels_derived_from_gold_only() -> None:
|
|
8
|
+
docs = [
|
|
9
|
+
Document(
|
|
10
|
+
id="d1",
|
|
11
|
+
text="x",
|
|
12
|
+
domain="t",
|
|
13
|
+
lang="en",
|
|
14
|
+
gold=((0, 1, "PAN"), (2, 3, "AADHAAR")),
|
|
15
|
+
decoys=((4, 5, "PAN_SHAPED_INVOICE_NO"),),
|
|
16
|
+
)
|
|
17
|
+
]
|
|
18
|
+
labels = canonical_labels(docs)
|
|
19
|
+
assert labels == ("AADHAAR", "PAN")
|
|
20
|
+
assert "PAN_SHAPED_INVOICE_NO" not in labels
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_identity_map_is_reflexive() -> None:
|
|
24
|
+
m = identity_map(("AADHAAR", "PAN"))
|
|
25
|
+
assert m == {"AADHAAR": "AADHAAR", "PAN": "PAN"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from maskflow_bench.loader import LoaderError, load_my_data
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _write(path: Path, lines: list[dict]) -> None:
|
|
11
|
+
path.write_text("\n".join(json.dumps(r) for r in lines) + "\n", encoding="utf-8")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_minimal_line_needs_only_text_and_entities(tmp_path: Path) -> None:
|
|
15
|
+
path = tmp_path / "my.jsonl"
|
|
16
|
+
# PAN below is structurally shaped but not a real allotted number --
|
|
17
|
+
# synthetic fixture data only.
|
|
18
|
+
_write(
|
|
19
|
+
path,
|
|
20
|
+
[{"text": "PAN: ABCDE1234F", "entities": [{"start": 5, "end": 15, "label": "PAN"}]}],
|
|
21
|
+
)
|
|
22
|
+
docs = load_my_data(path)
|
|
23
|
+
assert len(docs) == 1
|
|
24
|
+
doc = docs[0]
|
|
25
|
+
assert doc.gold == ((5, 15, "PAN"),)
|
|
26
|
+
assert doc.decoys == ()
|
|
27
|
+
# Defaults applied when the user's file doesn't supply them.
|
|
28
|
+
assert doc.domain == "user_data"
|
|
29
|
+
assert doc.lang == "en"
|
|
30
|
+
assert doc.id == "line-1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_explicit_id_domain_lang_are_preserved(tmp_path: Path) -> None:
|
|
34
|
+
path = tmp_path / "my.jsonl"
|
|
35
|
+
_write(
|
|
36
|
+
path,
|
|
37
|
+
[
|
|
38
|
+
{
|
|
39
|
+
"id": "ticket-42",
|
|
40
|
+
"text": "x",
|
|
41
|
+
"entities": [],
|
|
42
|
+
"domain": "support_ticket",
|
|
43
|
+
"lang": "en-hi",
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
)
|
|
47
|
+
doc = load_my_data(path)[0]
|
|
48
|
+
assert doc.id == "ticket-42"
|
|
49
|
+
assert doc.domain == "support_ticket"
|
|
50
|
+
assert doc.lang == "en-hi"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_hard_negative_value_class_goes_to_decoys(tmp_path: Path) -> None:
|
|
54
|
+
path = tmp_path / "my.jsonl"
|
|
55
|
+
_write(
|
|
56
|
+
path,
|
|
57
|
+
[
|
|
58
|
+
{
|
|
59
|
+
"text": "invoice INV-0001",
|
|
60
|
+
"entities": [
|
|
61
|
+
{"start": 8, "end": 16, "label": "PAN_SHAPED", "value_class": "hard_negative"}
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
)
|
|
66
|
+
doc = load_my_data(path)[0]
|
|
67
|
+
assert doc.gold == ()
|
|
68
|
+
assert doc.decoys == ((8, 16, "PAN_SHAPED"),)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_limit_takes_first_n_in_file_order(tmp_path: Path) -> None:
|
|
72
|
+
path = tmp_path / "my.jsonl"
|
|
73
|
+
_write(path, [{"text": f"t{i}", "entities": []} for i in range(5)])
|
|
74
|
+
docs = load_my_data(path, limit=2)
|
|
75
|
+
assert [d.text for d in docs] == ["t0", "t1"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.mark.parametrize(
|
|
79
|
+
"row",
|
|
80
|
+
[
|
|
81
|
+
{"entities": []}, # missing text
|
|
82
|
+
{"text": "x"}, # missing entities
|
|
83
|
+
{"text": "x", "entities": [{"start": 0, "end": 1}]}, # entity missing label
|
|
84
|
+
{"text": "x", "entities": [{"start": 0, "label": "PAN"}]}, # entity missing end
|
|
85
|
+
{"text": "x", "entities": "not-a-list"},
|
|
86
|
+
{"text": 123, "entities": []},
|
|
87
|
+
{"text": "x", "entities": [{"start": "0", "end": 1, "label": "PAN"}]},
|
|
88
|
+
],
|
|
89
|
+
)
|
|
90
|
+
def test_malformed_line_raises_loader_error_with_line_number(tmp_path: Path, row: dict) -> None:
|
|
91
|
+
path = tmp_path / "my.jsonl"
|
|
92
|
+
# Line 1 is valid, so the error must point at line 2, not line 1.
|
|
93
|
+
_write(path, [{"text": "ok", "entities": []}])
|
|
94
|
+
with path.open("a", encoding="utf-8") as f:
|
|
95
|
+
f.write(json.dumps(row) + "\n")
|
|
96
|
+
|
|
97
|
+
with pytest.raises(LoaderError) as exc_info:
|
|
98
|
+
load_my_data(path)
|
|
99
|
+
assert exc_info.value.line_no == 2
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_invalid_json_raises_loader_error(tmp_path: Path) -> None:
|
|
103
|
+
path = tmp_path / "my.jsonl"
|
|
104
|
+
path.write_text("{not json\n", encoding="utf-8")
|
|
105
|
+
with pytest.raises(LoaderError) as exc_info:
|
|
106
|
+
load_my_data(path)
|
|
107
|
+
assert exc_info.value.line_no == 1
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from maskflow_bench.corpus import Document
|
|
4
|
+
from maskflow_bench.matching import MatchMode, evaluate
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _doc(gold: tuple, decoys: tuple = ()) -> Document:
|
|
8
|
+
return Document(id="d1", text="x" * 100, domain="test", lang="en", gold=gold, decoys=decoys)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_strict_exact_match_is_tp() -> None:
|
|
12
|
+
docs = [_doc(gold=((0, 5, "PAN"),))]
|
|
13
|
+
preds = [[(0, 5, "PAN")]]
|
|
14
|
+
r = evaluate(docs, preds, {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
|
|
15
|
+
assert r["PAN"].tp == 1
|
|
16
|
+
assert r["PAN"].fp == 0
|
|
17
|
+
assert r["PAN"].fn == 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_strict_offset_mismatch_is_fn_and_fp() -> None:
|
|
21
|
+
docs = [_doc(gold=((0, 5, "PAN"),))]
|
|
22
|
+
preds = [[(0, 6, "PAN")]] # one char off
|
|
23
|
+
r = evaluate(docs, preds, {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
|
|
24
|
+
assert r["PAN"].tp == 0
|
|
25
|
+
assert r["PAN"].fn == 1
|
|
26
|
+
assert r["PAN"].fp == 1
|
|
27
|
+
# fired + had gold + matched nothing -> a *measured* 0.0, not None.
|
|
28
|
+
assert r["PAN"].precision == 0.0
|
|
29
|
+
assert r["PAN"].recall == 0.0
|
|
30
|
+
assert r["PAN"].f1 == 0.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_partial_overlap_counts_as_tp() -> None:
|
|
34
|
+
docs = [_doc(gold=((10, 20, "AADHAAR"),))]
|
|
35
|
+
preds = [[(12, 18, "AADHAAR")]] # contained, not exact
|
|
36
|
+
r = evaluate(docs, preds, {"AADHAAR": "AADHAAR"}, ("AADHAAR",), MatchMode.PARTIAL)
|
|
37
|
+
assert r["AADHAAR"].tp == 1
|
|
38
|
+
assert r["AADHAAR"].fp == 0
|
|
39
|
+
assert r["AADHAAR"].fn == 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_no_overlap_is_fn_and_fp() -> None:
|
|
43
|
+
docs = [_doc(gold=((10, 20, "AADHAAR"),))]
|
|
44
|
+
preds = [[(30, 40, "AADHAAR")]]
|
|
45
|
+
r = evaluate(docs, preds, {"AADHAAR": "AADHAAR"}, ("AADHAAR",), MatchMode.PARTIAL)
|
|
46
|
+
assert r["AADHAAR"].tp == 0
|
|
47
|
+
assert r["AADHAAR"].fn == 1
|
|
48
|
+
assert r["AADHAAR"].fp == 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_hard_negative_hit_counts_as_false_positive() -> None:
|
|
52
|
+
# A prediction landing on a hard-negative decoy span (never gold) is
|
|
53
|
+
# still a false positive for whatever canonical type it claimed.
|
|
54
|
+
docs = [_doc(gold=(), decoys=((0, 10, "PAN_SHAPED_INVOICE_NO"),))]
|
|
55
|
+
preds = [[(0, 10, "PAN")]]
|
|
56
|
+
r = evaluate(docs, preds, {"PAN": "PAN"}, ("PAN",), MatchMode.PARTIAL)
|
|
57
|
+
assert r["PAN"].tp == 0
|
|
58
|
+
assert r["PAN"].fp == 1
|
|
59
|
+
assert r["PAN"].fn == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_unmapped_raw_label_is_dropped_not_scored() -> None:
|
|
63
|
+
# An adapter's raw label with no entry in label_map (e.g. Presidio's
|
|
64
|
+
# ORGANIZATION) must never count as a false positive against an
|
|
65
|
+
# unrelated canonical type.
|
|
66
|
+
docs = [_doc(gold=((0, 5, "PAN"),))]
|
|
67
|
+
preds = [[(50, 60, "ORGANIZATION")]]
|
|
68
|
+
r = evaluate(docs, preds, {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
|
|
69
|
+
assert r["PAN"].tp == 0
|
|
70
|
+
assert r["PAN"].fp == 0
|
|
71
|
+
assert r["PAN"].fn == 1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_precision_recall_f1_none_when_no_denominator() -> None:
|
|
75
|
+
docs = [_doc(gold=())]
|
|
76
|
+
preds = [[]]
|
|
77
|
+
r = evaluate(docs, preds, {}, ("PAN",), MatchMode.STRICT)
|
|
78
|
+
assert r["PAN"].precision is None
|
|
79
|
+
assert r["PAN"].recall is None
|
|
80
|
+
assert r["PAN"].f1 is None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_f1_is_zero_not_none_when_gold_exists_but_nothing_matched() -> None:
|
|
84
|
+
# Gold present, no predictions at all: recall has a denominator (0.0),
|
|
85
|
+
# precision does not -> still "nothing to score" on the precision side.
|
|
86
|
+
docs = [_doc(gold=((0, 5, "PAN"),))]
|
|
87
|
+
r = evaluate(docs, [[]], {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
|
|
88
|
+
assert r["PAN"].recall == 0.0
|
|
89
|
+
assert r["PAN"].precision is None
|
|
90
|
+
assert r["PAN"].f1 is None # can't compute F1 with no precision
|
|
91
|
+
|
|
92
|
+
# But once the detector fires (fp>0) it has both sides -> measured 0.0.
|
|
93
|
+
docs2 = [_doc(gold=((0, 5, "PAN"),))]
|
|
94
|
+
r2 = evaluate(docs2, [[(50, 60, "PAN")]], {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
|
|
95
|
+
assert r2["PAN"].f1 == 0.0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_multiple_gold_spans_claim_distinct_predictions() -> None:
|
|
99
|
+
# Two gold spans of the same type must each match a different
|
|
100
|
+
# prediction, not both greedily match the first candidate.
|
|
101
|
+
docs = [_doc(gold=((0, 5, "PAN"), (10, 15, "PAN")))]
|
|
102
|
+
preds = [[(0, 5, "PAN"), (10, 15, "PAN")]]
|
|
103
|
+
r = evaluate(docs, preds, {"PAN": "PAN"}, ("PAN",), MatchMode.STRICT)
|
|
104
|
+
assert r["PAN"].tp == 2
|
|
105
|
+
assert r["PAN"].fp == 0
|
|
106
|
+
assert r["PAN"].fn == 0
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import maskflow_pack_india # noqa: F401 -- registers AADHAAR/PAN/etc. recognizers
|
|
7
|
+
from maskflow_bench.scorer import score_my_data
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_score_my_data_runs_maskflow_end_to_end(tmp_path: Path) -> None:
|
|
11
|
+
path = tmp_path / "my.jsonl"
|
|
12
|
+
# Structurally valid (checksum-passing) but fabricated for this test --
|
|
13
|
+
# not a real allotted PAN.
|
|
14
|
+
row = {
|
|
15
|
+
"text": "Please update my PAN ABCDE1234F on file.",
|
|
16
|
+
"entities": [{"start": 22, "end": 32, "label": "PAN"}],
|
|
17
|
+
}
|
|
18
|
+
path.write_text(json.dumps(row) + "\n", encoding="utf-8")
|
|
19
|
+
|
|
20
|
+
num_docs, labels, result = score_my_data(path)
|
|
21
|
+
|
|
22
|
+
assert num_docs == 1
|
|
23
|
+
assert labels == ("PAN",)
|
|
24
|
+
assert result.name == "maskflow"
|
|
25
|
+
assert result.available is True
|
|
26
|
+
assert "PAN" in result.strict
|
|
27
|
+
assert "PAN" in result.partial
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_score_my_data_respects_limit(tmp_path: Path) -> None:
|
|
31
|
+
path = tmp_path / "my.jsonl"
|
|
32
|
+
rows = [{"text": f"doc {i}", "entities": []} for i in range(5)]
|
|
33
|
+
path.write_text("\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
num_docs, _labels, _result = score_my_data(path, limit=2)
|
|
36
|
+
assert num_docs == 2
|