|
1 | 1 | #!/usr/bin/env python3 |
2 | | -"""Generate a browsable HTML report of a learned ruleset against gold data. |
3 | | -
|
4 | | -Loads any ruleset (checkpoint / comparison result / dataset JSON) with |
5 | | -RuleChef.load_rules, runs each rule over labeled data, classifies every match as |
6 | | -a true or false positive against the gold entities, and writes a self-contained |
7 | | -HTML file. Per rule you get its pattern, precision, TP/FP counts, and collapsible |
8 | | -lists of every true positive and false positive with the matched span |
9 | | -highlighted in its surrounding context (nothing is truncated). Open the file in |
10 | | -any browser; a search box filters rules by name. |
11 | | -
|
12 | | -Dataset-agnostic. Provide gold data either as a JSONL file (one |
13 | | -{"text": ..., "entities": [{"text","start","end","type"}]} per line) or use the |
14 | | -built-in --dataset tab convenience loader. |
15 | | -
|
16 | | -Usage: |
17 | | - python benchmarks/rule_report.py \ |
18 | | - --rules benchmarks/results/results_extract_tab.ckpt_rulechef.json \ |
19 | | - --dataset tab --test 600 --out rules_tab.html |
20 | | -
|
21 | | - python benchmarks/rule_report.py --rules my_rules.json --data my_gold.jsonl |
| 2 | +"""TAB-convenience wrapper around ``rulechef.report`` (the installed |
| 3 | +``rulechef-report`` command). Adds the --dataset tab loader used in the paper; |
| 4 | +for your own data use ``rulechef-report --rules ... --data gold.jsonl``. |
22 | 5 | """ |
23 | 6 |
|
24 | 7 | import argparse |
25 | | -import html |
26 | | -import json |
27 | 8 | import sys |
28 | | -import tempfile |
29 | 9 | from pathlib import Path |
30 | | -from unittest.mock import MagicMock |
31 | 10 |
|
32 | 11 | sys.path.insert(0, str(Path(__file__).parent)) |
33 | 12 |
|
34 | | -CONTEXT = 90 # chars of context shown on each side of a matched span |
35 | | - |
36 | | - |
37 | | -def load_gold(args): |
38 | | - """Return (list of {text, entities}, list of entity types).""" |
39 | | - if args.data: |
40 | | - rows = [] |
41 | | - for line in Path(args.data).read_text().splitlines(): |
42 | | - line = line.strip() |
43 | | - if line: |
44 | | - rows.append(json.loads(line)) |
45 | | - types = sorted({e["type"] for r in rows for e in r.get("entities", [])}) |
46 | | - return rows, types |
47 | | - if args.dataset == "tab": |
48 | | - from benchmark_extract import TAB_FORMAT, TAB_SEMANTIC, load_tab_ds |
49 | | - |
50 | | - types = TAB_FORMAT + TAB_SEMANTIC |
51 | | - _, test = load_tab_ds(args.train, args.test, args.seed, types) |
52 | | - return test, types |
53 | | - raise SystemExit("Provide --data <jsonl> or --dataset tab") |
54 | | - |
55 | | - |
56 | | -def is_tp(pred, gold_entities): |
57 | | - """True positive: a gold entity of the same type whose span overlaps.""" |
58 | | - ps, pe = pred.get("start"), pred.get("end") |
59 | | - for g in gold_entities: |
60 | | - if g.get("type") != pred.get("type"): |
61 | | - continue |
62 | | - gs, ge = g.get("start"), g.get("end") |
63 | | - if ps is not None and gs is not None: |
64 | | - if max(ps, gs) < min(pe, ge): # overlap |
65 | | - return True |
66 | | - elif pred.get("text") and pred["text"].strip() == (g.get("text") or "").strip(): |
67 | | - return True |
68 | | - return False |
69 | | - |
70 | | - |
71 | | -def context_html(text, pred): |
72 | | - """Matched span highlighted in its surrounding context (span never trimmed).""" |
73 | | - s, e = pred.get("start"), pred.get("end") |
74 | | - span = pred.get("text", "") |
75 | | - if s is None or e is None: |
76 | | - idx = text.find(span) |
77 | | - if idx < 0: |
78 | | - return html.escape(span) |
79 | | - s, e = idx, idx + len(span) |
80 | | - left = text[max(0, s - CONTEXT) : s] |
81 | | - right = text[e : e + CONTEXT] |
82 | | - pre = "…" if s - CONTEXT > 0 else "" |
83 | | - post = "…" if e + CONTEXT < len(text) else "" |
84 | | - return ( |
85 | | - pre |
86 | | - + html.escape(left) |
87 | | - + "<mark>" |
88 | | - + html.escape(text[s:e]) |
89 | | - + "</mark>" |
90 | | - + html.escape(right) |
91 | | - + post |
92 | | - ) |
| 13 | +from rulechef.report import generate, load_jsonl # noqa: E402 |
93 | 14 |
|
94 | 15 |
|
95 | 16 | def main(): |
96 | | - from rulechef import RuleChef |
97 | | - from rulechef.core import Task, TaskType |
98 | | - |
99 | | - p = argparse.ArgumentParser(description="Browsable HTML report of rules vs gold data") |
100 | | - p.add_argument("--rules", required=True, help="rules file (checkpoint / result / dataset JSON)") |
101 | | - p.add_argument("--data", default=None, help="gold data as JSONL: {text, entities:[...]}") |
102 | | - p.add_argument("--dataset", default=None, choices=["tab"], help="built-in loader") |
| 17 | + p = argparse.ArgumentParser(description="Rule report (TAB convenience wrapper)") |
| 18 | + p.add_argument("--rules", required=True) |
| 19 | + p.add_argument("--data", default=None, help="gold JSONL; alternative to --dataset") |
| 20 | + p.add_argument("--dataset", default=None, choices=["tab"]) |
103 | 21 | p.add_argument("--train", type=int, default=1000) |
104 | 22 | p.add_argument("--test", type=int, default=600) |
105 | 23 | p.add_argument("--seed", type=int, default=42) |
106 | 24 | p.add_argument("--out", default="rule_report.html") |
107 | 25 | args = p.parse_args() |
108 | 26 |
|
109 | | - gold, types = load_gold(args) |
110 | | - |
111 | | - task = Task( |
112 | | - name="inspect", |
113 | | - description="Extract entity spans.", |
114 | | - input_schema={"text": "str"}, |
115 | | - output_schema={"entities": "List[{text,start,end,type}]"}, |
116 | | - type=TaskType.NER, |
117 | | - text_field="text", |
118 | | - ) |
119 | | - chef = RuleChef( |
120 | | - task=task, client=MagicMock(), dataset_name="inspect", storage_path=tempfile.mkdtemp() |
121 | | - ) |
122 | | - rules = chef.load_rules(args.rules) |
123 | | - print(f"Loaded {len(rules)} rules from {args.rules}; scoring on {len(gold)} docs") |
124 | | - |
125 | | - # Per rule, collect every match classified as TP or FP. |
126 | | - per_rule = {r.id: {"rule": r, "tp": [], "fp": []} for r in rules} |
127 | | - for doc in gold: |
128 | | - text = doc["text"] |
129 | | - gold_entities = doc.get("entities", []) |
130 | | - for r in rules: |
131 | | - out = chef.learner._apply_rules([r], {"text": text}, TaskType.NER, "text") |
132 | | - for pred in out.get("entities", []) or []: |
133 | | - bucket = "tp" if is_tp(pred, gold_entities) else "fp" |
134 | | - per_rule[r.id][bucket].append(context_html(text, pred)) |
135 | | - |
136 | | - def stats(info): |
137 | | - tp, fp = len(info["tp"]), len(info["fp"]) |
138 | | - n = tp + fp |
139 | | - return tp, fp, n, (tp / n if n else None) |
140 | | - |
141 | | - # Sort by precision (accuracy), highest first; rules that never fired go last. |
142 | | - def sort_key(info): |
143 | | - tp, fp, n, p = stats(info) |
144 | | - return (0 if n else 1, -(p if p is not None else 0), -n) |
145 | | - |
146 | | - cards = sorted(per_rule.values(), key=sort_key) |
147 | | - |
148 | | - def items(matches): |
149 | | - return "".join(f"<li>{m}</li>" for m in matches) or '<li class="none">none</li>' |
150 | | - |
151 | | - def badge(p): |
152 | | - if p is None: |
153 | | - return "na" |
154 | | - return "hi" if p >= 0.8 else "mid" if p >= 0.5 else "lo" |
155 | | - |
156 | | - parts = [] |
157 | | - for info in cards: |
158 | | - r = info["rule"] |
159 | | - tp, fp, n, p = stats(info) |
160 | | - pr = f"{p:.0%}" if p is not None else "n/a" |
161 | | - out_type = (r.output_template or {}).get("type", "?") |
162 | | - parts.append( |
163 | | - f""" |
164 | | -<div class="rule" data-name="{html.escape(r.name.lower())}"> |
165 | | - <div class="head"> |
166 | | - <span class="rname">{html.escape(r.name)}</span> |
167 | | - <span class="type">{html.escape(str(out_type))}</span> |
168 | | - <span class="prec {badge(p)}">{pr}</span> |
169 | | - <span class="pills"><span class="tp">{tp} TP</span><span class="fp">{fp} FP</span></span> |
170 | | - </div> |
171 | | - <details class="pat"><summary>pattern</summary><code>{html.escape(r.pattern)}</code></details> |
172 | | - <details open><summary>False positives <b>({fp})</b></summary><ul class="fp">{items(info["fp"])}</ul></details> |
173 | | - <details><summary>True positives <b>({tp})</b></summary><ul class="tp">{items(info["tp"])}</ul></details> |
174 | | -</div>""" |
175 | | - ) |
| 27 | + if args.data: |
| 28 | + rows = load_jsonl(args.data) |
| 29 | + elif args.dataset == "tab": |
| 30 | + from benchmark_extract import TAB_FORMAT, TAB_SEMANTIC, load_tab_ds |
176 | 31 |
|
177 | | - css = """ |
178 | | -:root{--bg:#f6f7f9;--card:#fff;--bd:#e3e6ea;--fg:#1f2328;--mut:#6b7280} |
179 | | -*{box-sizing:border-box} |
180 | | -body{font:14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; |
181 | | - background:var(--bg);color:var(--fg);max-width:920px;margin:0 auto;padding:1.5rem 1rem 4rem} |
182 | | -h1{font-size:18px;font-weight:650;margin:.2rem 0 1rem} |
183 | | -.bar{position:sticky;top:0;background:var(--bg);padding:.6rem 0;z-index:5} |
184 | | -#q{width:100%;padding:.6rem .8rem;font-size:14px;border:1px solid var(--bd);border-radius:8px;background:var(--card)} |
185 | | -.rule{background:var(--card);border:1px solid var(--bd);border-radius:10px;padding:.7rem .9rem;margin:.7rem 0; |
186 | | - box-shadow:0 1px 2px rgba(0,0,0,.03)} |
187 | | -.head{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap} |
188 | | -.rname{font-weight:650;font-size:14px} |
189 | | -.type{font-size:10.5px;letter-spacing:.03em;color:#475569;background:#eef2f6;border:1px solid #e0e6ec; |
190 | | - padding:1px 7px;border-radius:999px;text-transform:uppercase} |
191 | | -.prec{font-weight:650;font-size:12px;padding:1px 8px;border-radius:999px;color:#fff} |
192 | | -.prec.hi{background:#16a34a}.prec.mid{background:#d97706}.prec.lo{background:#dc2626}.prec.na{background:#9aa3ad} |
193 | | -.pills{margin-left:auto;display:flex;gap:.4rem;font-size:11.5px} |
194 | | -.pills span{padding:1px 7px;border-radius:999px;border:1px solid var(--bd);color:var(--mut)} |
195 | | -.pills .tp{color:#15803d;border-color:#bbf7d0;background:#f0fdf4} |
196 | | -.pills .fp{color:#b91c1c;border-color:#fecaca;background:#fef2f2} |
197 | | -details{margin-top:.5rem} |
198 | | -summary{cursor:pointer;color:#475569;font-size:12px;user-select:none} |
199 | | -summary:hover{color:#1f2328} |
200 | | -.pat code{display:block;margin-top:.4rem;padding:.5rem .6rem;background:#0f172a;color:#e2e8f0;border-radius:6px; |
201 | | - font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word} |
202 | | -ul{margin:.4rem 0 .2rem;padding:0;list-style:none} |
203 | | -li{padding:.3rem .5rem;border-left:2px solid #eef0f3;margin:.2rem 0;font-size:12.5px; |
204 | | - white-space:pre-wrap;overflow-wrap:anywhere;color:#374151} |
205 | | -li.none{color:var(--mut);font-style:italic;border-left-color:transparent} |
206 | | -.fp li{border-left-color:#fca5a5}.tp li{border-left-color:#86efac} |
207 | | -mark{padding:0 2px;border-radius:3px;font-weight:600} |
208 | | -.fp mark{background:#fecaca}.tp mark{background:#bbf7d0} |
209 | | -""" |
210 | | - js = """ |
211 | | -const q=document.getElementById('q'); |
212 | | -q.addEventListener('input',()=>{const v=q.value.toLowerCase(); |
213 | | -document.querySelectorAll('.rule').forEach(r=>r.style.display=r.dataset.name.includes(v)?'':'none');}); |
214 | | -""" |
215 | | - title = f"Rule report — {len(rules)} rules, {len(gold)} docs (sorted by precision)" |
216 | | - doc = f"""<!doctype html><html lang=en><meta charset=utf-8> |
217 | | -<meta name=viewport content="width=device-width,initial-scale=1"> |
218 | | -<title>Rule report</title><style>{css}</style> |
219 | | -<h1>{title}</h1> |
220 | | -<div class="bar"><input id=q placeholder="filter rules by name…"></div> |
221 | | -{''.join(parts)}<script>{js}</script>""" |
222 | | - Path(args.out).write_text(doc) |
223 | | - print(f"Wrote {args.out} ({len(doc) // 1024} KB) — open it in a browser") |
| 32 | + _, rows = load_tab_ds(args.train, args.test, args.seed, TAB_FORMAT + TAB_SEMANTIC) |
| 33 | + else: |
| 34 | + raise SystemExit("Provide --data <gold.jsonl> or --dataset tab") |
| 35 | + generate(args.rules, rows, args.out) |
224 | 36 |
|
225 | 37 |
|
226 | 38 | if __name__ == "__main__": |
|
0 commit comments