rulechef-live / cascade.py
adaamko's picture
Restore the static page (no cpu-basic quota for Gradio)
27e064c verified
Raw History Blame Contribute Delete
4.03 kB
"""The cascade's pieces, shared by the offline replay (sim.py) and the app: the gate, rule execution, scoring.
Gate: a request is answered by rules only when every trigger token in it is explained. Trigger tokens are tokens
with a digit, plus words the teacher often puts inside spans (learned). A trigger is explained when an active rule's
span covers it, or when its context is a learned skip context: (previous word, digit shape) pairs the teacher has
seen at least SKIP_MIN times and labelled at most SKIP_RATIO of the time, such as "Article 9" or "§ 9".
"""
import os
import re
from collections import Counter, defaultdict
from rulechef.core import Rule, TaskType
from rulechef.executor import RuleExecutor
TYPES = ["DATETIME", "CODE", "QUANTITY"]
TOK = re.compile(r"§|[A-Za-z]+|\d+(?:[.,/:-]\d+)*")
_E = os.environ.get
WORD_MIN, WORD_RATIO = int(_E("WORD_MIN", 3)), float(_E("WORD_RATIO", 0.3)) # a word is a trigger if >= 3 teacher spans contain it and >= 30 % of its uses are inside one
SKIP_MIN, SKIP_RATIO = int(_E("SKIP_MIN", 5)), float(_E("SKIP_RATIO", 0.3)) # a (previous word, shape) context is skipped if seen >= 5 times and the teacher labels it <= 30 % of the time
TRUST_MIN, TRUST_PREC = int(_E("TRUST_MIN", 3)), float(_E("TRUST_PREC", 0.8)) # a rule answers only if it matched >= 3 times on the labelled data with precision >= 0.8
_EX = RuleExecutor()
def shape(tok):
return re.sub(r"\d+", "9", tok)
def tokens(text):
out, prev = [], ""
for m in TOK.finditer(text):
out.append((m.start(), m.end(), m.group(), prev))
prev = m.group().lower()
return out
def key(s):
return (s["text"].strip(), s["type"])
class Gate:
def __init__(self, trigger_words=(), skip=()):
self.trigger_words = set(trigger_words)
self.skip = {tuple(k) for k in skip}
@classmethod
def learn(cls, labelled):
words, ctx = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0])
for text, spans in labelled:
for s, e, tok, prev in tokens(text):
inside = any(sp["start"] < e and s < sp["end"] for sp in spans)
d = ctx[(prev, shape(tok))] if tok[0].isdigit() else (words[tok] if tok.isalpha() else None)
if d is not None:
d[0] += 1
d[1] += inside
trig = {w for w, (n, k) in words.items() if k >= WORD_MIN and k / n >= WORD_RATIO}
skip = {c for c, (n, k) in ctx.items() if n >= SKIP_MIN and k / n <= SKIP_RATIO}
return cls(trig, skip)
def unexplained(self, text, spans):
out = []
for s, e, tok, prev in tokens(text):
digit = tok[0].isdigit()
if not (digit or tok in self.trigger_words):
continue
if any(sp["start"] < e and s < sp["end"] for sp in spans):
continue
if digit and (prev, shape(tok)) in self.skip:
continue
out.append(tok)
return out
def to_dict(self):
return {"trigger_words": sorted(self.trigger_words), "skip": sorted(list(k) for k in self.skip)}
def run_rules(rules, text):
"""RuleChef's own executor; returns entity dicts with rule attribution (rule_id)."""
if not rules:
return []
out = _EX.apply_rules(rules, {"text": text}, TaskType.NER, "text")
return [{"start": e["start"], "end": e["end"], "type": e.get("type"), "text": e["text"], "rule_id": e.get("rule_id")}
for e in out.get("entities", [])]
def match_counts(pred, gold):
"""RuleChef's TAB scorer: (text, type) multiset match. Returns tp, fp, fn."""
g = Counter(key(s) for s in gold)
p = Counter(key(s) for s in pred)
tp = sum((p & g).values())
return tp, sum(p.values()) - tp, sum(g.values()) - tp
def prf(tp, fp, fn):
p = tp / (tp + fp) if tp + fp else 1.0
r = tp / (tp + fn) if tp + fn else 1.0
return p, r, (2 * p * r / (p + r) if p + r else 0.0)
def rules_from_dicts(ds):
return [Rule.from_dict(d) for d in ds]