"""gemmaExtract: typed field extraction in one forward pass (no text generation) on a Gemma 4 backbone.""" import math import torch import torch.nn as nn TYPES = {"text": 0, "list": 1, "number": 2, "date": 3, "time": 4, "duration": 5, "enum": 6} OPS = ["none", "sum", "diff", "avg", "count", "div", "mul"] # none = chep span; con lai tinh tren toan hang (kieu TagOp) ORDERED = {"diff", "div"} def num(t, en=False): """Chuoi -> float hoac None. en=True: ',' hang nghin, '.' thap phan (DROP, TAT-QA). Mac dinh (hoa don VN/ID, kieu hon hop): 2 loai dau -> dau cuoi la thap phan; 1 loai dau -> thap phan tru khi theo sau dung 3 chu so; '(12)' -> -12. Giong data/common.py parse_num.""" import re m = re.search(r"-?\d[\d.,]*\d|-?\d", t or "") if not m: return None x = m.group() neg = x.startswith("-") or ("(" in t and ")" in t) x = x.lstrip("-") if en: x = x.replace(",", "") if x.count(".") > 1: return None else: seps = [c for c in x if c in ".,"] if seps: last = max(x.rfind("."), x.rfind(",")) dec = len(set(seps)) == 2 or (len(seps) == 1 and len(x) - last - 1 != 3) x = re.sub(r"[.,]", "", x[:last]) + "." + x[last + 1:] if dec else re.sub(r"[.,]", "", x) try: v = float(x) except ValueError: return None return -v if neg else v MONTHS = {m: i + 1 for i, m in enumerate(["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"])} def date(t, dayfirst=True): """Chuoi ngay -> 'YYYY-MM-DD' hoac None (ngay truoc thang mac dinh, nhu hoa don VN/MY). Giong data/common.py parse_date.""" import datetime, re m = re.search(r"(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})", t or "") vn = re.search(r"(\d{1,2})\s*tháng\s*(\d{1,2})\s*,?\s*năm\s*(\d{4})", t or "", re.I) # "20 tháng 10 năm 2026" if m: y, mo, d = map(int, m.groups()) elif vn: d, mo, y = map(int, vn.groups()) else: m = re.search(r"(\d{1,2})[-/.](\d{1,2})[-/.](\d{4}|\d{2})", t or "") m2 = re.search(r"(\d{1,2})[-/ .]*([A-Za-z]{3})[A-Za-z]*[-/ .,]*(\d{4}|\d{2})", t or "") m3 = re.search(r"([A-Za-z]{3})[A-Za-z]*[-/ .]*(\d{1,2})[-/ .,]+(\d{4}|\d{2})", t or "") if m: d, mo, y = map(int, m.groups()) if not dayfirst: d, mo = mo, d elif m2 and m2.group(2).lower() in MONTHS: d, mo, y = int(m2.group(1)), MONTHS[m2.group(2).lower()], int(m2.group(3)) elif m3 and m3.group(1).lower() in MONTHS: mo, d, y = MONTHS[m3.group(1).lower()], int(m3.group(2)), int(m3.group(3)) else: return None y += 2000 if y < 100 else 0 try: return datetime.date(y, mo, d).isoformat() except ValueError: return None def number_style(text): """Tu nhan kieu so cua van ban: True = EN (1,234.5), False = kieu cham hang nghin (1.234,5 VN/ID). Dem '1,234' + '5.56' so voi '1.234' + '5,56'; ngang nhau -> False (num() kieu hon hop van doc dung phan lon).""" import re en = len(re.findall(r"\d,\d{3}(?!\d)", text)) + len(re.findall(r"\d\.\d{1,2}(?!\d)", text)) vn = len(re.findall(r"\d\.\d{3}(?!\d)", text)) + len(re.findall(r"\d,\d{1,2}(?!\d)", text)) return en > vn def snap(text, a, b): """Noi span ra tron con so: tokenizer Gemma tach tung chu so nen dau ra hay cat vun ('1951' -> '1' + '51'; dev DROP/TAT-QA: 152/280 loi so phai tinh la vi vay). Gom ca dau ngoac am '(46)'.""" dig = lambda i: 0 <= i < len(text) and text[i].isdigit() while a > 0 and (dig(a - 1) or (text[a - 1] in ".," and dig(a - 2))): a -= 1 while b < len(text) and (dig(b) or (text[b] in ".," and dig(b + 1))): b += 1 if a > 0 and b < len(text) and text[a - 1] == "(" and text[b] == ")": a, b = a - 1, b + 1 return a, b def trim(text, a, b): """Bo khoang trang hai dau span (offset token Gemma kem dau cach dung truoc).""" while a < b and text[a].isspace(): a += 1 while b > a and text[b - 1].isspace(): b -= 1 return a, b def merge(spans): out = [] for a, b in sorted(spans): if out and a <= out[-1][1]: out[-1] = (out[-1][0], max(out[-1][1], b)) else: out.append((a, b)) return out def compute(op, vals, reverse=False): if op == "count": return float(len(vals)) if not vals: return None if op == "sum": return sum(vals) if op == "avg": return sum(vals) / len(vals) if op == "mul": out = 1.0 for v in vals: out *= v return out if len(vals) < 2: return None a, b = (vals[1], vals[0]) if reverse else (vals[0], vals[1]) if op == "diff": return a - b return a / b if b else None SPAN_TYPES = {"text", "number", "date", "time", "duration"} # 1 gia tri = 1 span MAX_SPAN_TOK = 64 def _ids(tok, text): return tok(text, add_special_tokens=False)["input_ids"] def encode(tok, rec, max_len=1024): """-> dict: ids, state (a, b) vi tri token state, off (offset ky tu cua token state), fields [(ten, kieu, (a, b) span instruction)].""" schema, fields = _ids(tok, "\n\nSCHEMA FIELDS:\n"), [] for i, (name, f) in enumerate(rec["fields"].items()): schema += _ids(tok, "\nFIELD %d\nID: %s\nTYPE: %s\nINSTRUCTION: " % (i + 1, name, f["type"])) a = len(schema) schema += _ids(tok, f.get("instructions") or name) fields.append((name, f["type"], (a, len(schema)))) if f.get("options"): schema += _ids(tok, "\nOPTIONS: " + " | ".join(map(str, f["options"]))) schema += _ids(tok, "\nEND FIELD\n") prefix = [tok.bos_token_id] + _ids(tok, "STATE:\n") t = tok(rec["state"], add_special_tokens=False, return_offsets_mapping=True) room = max_len - len(prefix) - len(schema) if room < 16: raise ValueError("schema qua dai: %d token" % len(schema)) sid, soff = t["input_ids"][:room], [tuple(o) for o in t["offset_mapping"][:room]] base = len(prefix) + len(sid) return {"ids": prefix + sid + schema, "state": (len(prefix), base), "off": soff, "fields": [(n, ty, (a + base, b + base)) for n, ty, (a, b) in fields]} def labels(enc, rec): """Nhan train cho moi field: null, cap (start, end) token chap nhan duoc (moi lan xuat hien), nhan B/I/O cho list. Field co dap an nhung moi span bi cat do max_len -> bo khoi loss (None).""" off, out = enc["off"], [] for name, ty, _ in enc["fields"]: ans = rec["answers"].get(name) if ans is None: out.append(None); continue pairs, tags, opnd = [], [0] * len(off), [0] * len(off) der = None if ans["null"] else ans.get("derivation") if der and der.get("op") in OPS and der["op"] != "none": spans_txt = [] for s, e in sorted(der["operands"]): toks = [i for i, (a, b) in enumerate(off) if a < e and b > s] for i in toks: opnd[i] = 1 spans_txt.append(rec["state"][s:e]) if not any(opnd): out.append(None); continue vals, order = [num(t, en=True) for t in spans_txt], None # derivation chi co o DROP/TAT-QA (EN) if der["op"] in ORDERED and len(vals) == 2 and None not in vals and isinstance(ans.get("value"), (int, float)): for rev in (False, True): v = compute(der["op"], vals, rev) if v is not None and abs(v - ans["value"]) <= 1e-6 + 1e-3 * abs(ans["value"]): order = int(rev); break out.append({"null": False, "pairs": [], "tags": tags, "op": OPS.index(der["op"]), "opnd": opnd, "order": order}) continue for s, e in ([] if ans["null"] else ans["spans"]): toks = [i for i, (a, b) in enumerate(off) if a < e and b > s] if toks: pairs.append((toks[0], toks[-1])) tags[toks[0]] = 1 for i in toks[1:]: tags[i] = 2 if not ans["null"] and not pairs: out.append(None); continue out.append({"null": ans["null"], "pairs": pairs, "tags": tags, "op": 0 if ty == "number" and not ans["null"] else None, "opnd": None, "order": None}) return out class Routing(nn.Module): """Query field cross-attention vao memory + FFN (EvidenceRoutingLayer cua Clef).""" def __init__(self, w, heads, ff): super().__init__() self.qn, self.mn, self.fn = nn.LayerNorm(w), nn.LayerNorm(w), nn.LayerNorm(w) self.att = nn.MultiheadAttention(w, heads, batch_first=True) self.ff = nn.Sequential(nn.Linear(w, ff), nn.GELU(), nn.Linear(ff, w)) def forward(self, q, mem): m = self.mn(mem) q = q + self.att(self.qn(q), m, m, need_weights=False)[0] return q + self.ff(self.fn(q)) class S1Head(nn.Module): def __init__(self, hidden, width=1024, routing=2, layers=4, heads=16, ff=4096): super().__init__() self.w = width self.norm = nn.LayerNorm(hidden) self.mem = nn.Linear(hidden, width, bias=False) self.qproj = nn.Linear(hidden, width, bias=False) self.gproj = nn.Linear(hidden, width, bias=False) self.type_emb = nn.Embedding(len(TYPES), width) self.route = nn.ModuleList([Routing(width, heads, ff) for _ in range(routing)]) self.joint = nn.ModuleList([nn.TransformerDecoderLayer(width, heads, ff, activation="gelu", batch_first=True, norm_first=True) for _ in range(layers)]) self.fnorm = nn.LayerNorm(width) self.null = nn.Linear(width, 1) self.ks, self.ke, self.kt = (nn.Linear(hidden, width, bias=False) for _ in range(3)) self.qs, self.qe = nn.Linear(width, width), nn.Linear(width, width) self.qt = nn.Linear(width, 3 * width) self.op = nn.Linear(width, len(OPS)) self.kn, self.qn = nn.Linear(hidden, width, bias=False), nn.Linear(width, width) self.order = nn.Linear(width, 2) def forward(self, hidden, enc): """hidden: (L, H) cua 1 record. -> null (F,), start/end (F, S), tag (F, S, 3).""" h = self.norm(hidden.float()) mem = self.mem(h).unsqueeze(0) q = torch.stack([h[a:b].mean(0) for _, _, (a, b) in enc["fields"]]) ty = torch.tensor([TYPES[t] for _, t, _ in enc["fields"]], device=h.device) q = (self.qproj(q) + self.gproj(h[-1]) + self.type_emb(ty)).unsqueeze(0) for layer in self.route: q = layer(q, mem) for layer in self.joint: q = layer(q, mem) f = self.fnorm(q[0]) a, b = enc["state"] hs = h[a:b] sc = 1 / math.sqrt(self.w) start = self.qs(f) @ self.ks(hs).T * sc end = self.qe(f) @ self.ke(hs).T * sc tag = torch.einsum("fcw,sw->fsc", self.qt(f).view(len(f), 3, self.w), self.kt(hs)) * sc opnd = self.qn(f) @ self.kn(hs).T * sc return self.null(f).squeeze(-1), start, end, tag, self.op(f), opnd, self.order(f) def loss(out, labs, types): null, start, end, tag, op, opnd, order = out terms = [] for i, (lab, ty) in enumerate(zip(labs, types)): if lab is None: continue terms.append(nn.functional.binary_cross_entropy_with_logits(null[i], torch.tensor(float(lab["null"]), device=null.device))) if ty == "list": terms.append(nn.functional.cross_entropy(tag[i], torch.tensor(lab["tags"], device=tag.device))) elif not lab["null"] and lab["pairs"]: # chap nhan bat ky lan xuat hien nao ls, le = start[i].log_softmax(-1), end[i].log_softmax(-1) terms.append(-torch.logsumexp(torch.stack([ls[s] + le[e] for s, e in lab["pairs"]]), 0)) if lab.get("op") is not None: terms.append(nn.functional.cross_entropy(op[i:i + 1], torch.tensor([lab["op"]], device=op.device))) if lab.get("opnd") is not None: terms.append(nn.functional.binary_cross_entropy_with_logits(opnd[i], torch.tensor(lab["opnd"], dtype=torch.float, device=opnd.device))) if lab.get("order") is not None: terms.append(nn.functional.cross_entropy(order[i:i + 1], torch.tensor([lab["order"]], device=order.device))) return torch.stack(terms).sum() / max(1, len(terms)) if terms else None @torch.no_grad() def decode(out, enc, state_text, null_threshold=0.5, en=None): """-> {ten: {null, p_null, guess, confidence, spans}}. guess tra ca khi null (nhu Clairic).""" null, start, end, tag, op, opnd, order = out off, res = enc["off"], {} if en is None: # khong truyen locale -> tu nhan theo van ban en = number_style(state_text) for i, (name, ty, _) in enumerate(enc["fields"]): p_null = torch.sigmoid(null[i]).item() if ty == "list": tg = tag[i].argmax(-1).tolist() pr = tag[i].softmax(-1) spans, j = [], 0 while j < len(tg): if tg[j] == 1 or (tg[j] == 2 and (j == 0 or tg[j - 1] == 0)): k = j + 1 while k < len(tg) and tg[k] == 2: k += 1 spans.append((*trim(state_text, off[j][0], off[k - 1][1]), pr[j, tg[j]].item())) j = k else: j += 1 vals = list(dict.fromkeys(state_text[s:e].strip() for s, e, _ in spans if state_text[s:e].strip())) conf = min((p for *_, p in spans), default=1 - p_null) res[name] = {"null": p_null > null_threshold or not vals, "p_null": p_null, "guess": vals, "confidence": conf, "spans": [[s, e] for s, e, _ in spans]} continue ps, pe = start[i].softmax(-1), end[i].softmax(-1) S = len(off) band = torch.triu(torch.ones(S, S, device=ps.device)) - torch.triu(torch.ones(S, S, device=ps.device), diagonal=MAX_SPAN_TOK) joint = ps[:, None] * pe[None, :] * band best = int(joint.argmax()) s, e = divmod(best, S) cs, ce = trim(state_text, off[s][0], off[e][1]) r = {"null": p_null > null_threshold, "p_null": p_null, "guess": state_text[cs:ce].strip(), "confidence": joint.view(-1)[best].item() * (1 - p_null), "spans": [[cs, ce]]} if ty == "number": o = OPS[int(op[i].argmax())] r["op"] = o if o != "none": # gia tri tinh tu toan hang da danh dau tg = (opnd[i] > 0).tolist() spans, j = [], 0 while j < len(tg): if tg[j]: k = j while k + 1 < len(tg) and tg[k + 1]: k += 1 spans.append((off[j][0], off[k][1])); j = k + 1 else: j += 1 spans = merge(snap(state_text, a, b) for a, b in spans) vals = [v for v in (num(state_text[a:b], en) for a, b in spans) if v is not None] rev = bool(order[i].argmax()) v = compute(o, vals, rev) r.update({"value": v, "guess": ("%g" % v) if v is not None else r["guess"], "operands": [[a, b] for a, b in spans], "reverse": rev}) else: cs, ce = snap(state_text, cs, ce) r.update({"guess": state_text[cs:ce].strip(), "spans": [[cs, ce]]}) r["value"] = num(r["guess"], en) elif ty == "date": r["value"] = date(r["guess"]) res[name] = r return res # ---- bidirectional full-attention layers --------------------------------------------------------------------- from transformers.masking_utils import create_masks_for_generate def enable_noncausal(wrapper, sliding=False): cfg = wrapper.config.get_text_config() if hasattr(wrapper.config, "get_text_config") else wrapper.config assert getattr(cfg, "_attn_implementation", "sdpa") == "sdpa", "noncausal can sdpa" W = getattr(cfg, "sliding_window", None) if sliding and not W: raise ValueError("noncausal sliding: model khong co sliding_window") def pre(module, args, kwargs): if args or kwargs.get("past_key_values") is not None or kwargs.get("use_cache"): raise ValueError("noncausal: chi goi keyword, khong KV cache") ids, emb = kwargs.get("input_ids"), kwargs.get("inputs_embeds") B, L = (ids.shape if ids is not None else emb.shape[:2]) dev = ids.device if ids is not None else emb.device pad = kwargs.get("attention_mask") if pad is None: pad = torch.ones((B, L), dtype=torch.long, device=dev) if isinstance(pad, dict): return args, kwargs like = torch.empty((B, L, 1), dtype=next(module.parameters()).dtype, device=dev) pos = kwargs.get("position_ids") if pos is None: pos = torch.arange(L, device=dev).unsqueeze(0).expand(B, -1) m = create_masks_for_generate(config=cfg, inputs_embeds=like, attention_mask=pad, past_key_values=None, position_ids=pos) if not isinstance(m, dict): m = {"full_attention": m} m = dict(m) m["full_attention"] = pad[:, None, None, :].bool() if sliding: i = torch.arange(L, device=dev) # | eye: hang cua token pad xa hon W sau token that cuoi se rong -> sdpa ra NaN, 0*NaN lan sang token that m["sliding_attention"] = (((i[:, None] - i[None, :]).abs() < W) & pad[:, None, None, :].bool()) | torch.eye(L, dtype=torch.bool, device=dev) kwargs["attention_mask"] = m return args, kwargs wrapper.register_forward_pre_hook(pre, with_kwargs=True) # ---- nap model + API ------------------------------------------------------------------------------------------ import json as _json, os as _os, re as _re _VI = _re.compile(r"[ăâđêôơưạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹ]", _re.I) SUPPORTED = {"text", "list", "number", "date"} def detect_language(text): """'vi' neu co dau tieng Viet; 'en' neu chu cai gan nhu toan ASCII; con lai 'default' (nguong null cao hon: do tren XQuAD 7 ngon ngu khac, nguong 0.4 tra null nham 3-8%, 0.8 con 0.3-3%).""" if len(_VI.findall(text)) > 3: return "vi" letters = [c for c in text if c.isalpha()] if letters and sum(c.isascii() for c in letters) / len(letters) > 0.98: return "en" return "default" class GemmaExtract: """Typed field extraction in one forward pass, no text generation. from gemma_extract import GemmaExtract m = GemmaExtract.from_pretrained("beyoru/gemmaExtract") m.extract("Invoice No: INV-0042 ... Total: 1,200.00", {"invoice_no": {"type": "text", "instructions": "Invoice number"}, "total": {"type": "number", "instructions": "Total amount due"}}) """ def __init__(self, path, device="cuda", dtype=torch.bfloat16): from transformers import AutoModelForCausalLM, AutoTokenizer self.cfg = _json.load(open(_os.path.join(path, "extract_config.json"))) self.tok = AutoTokenizer.from_pretrained(path) top = AutoModelForCausalLM.from_pretrained(path, dtype=dtype, attn_implementation="sdpa").to(device) self.enc = top.model enable_noncausal(self.enc, sliding=False) self.enc.eval() h = self.cfg["head"] self.head = S1Head(self.cfg["hidden"], width=h["width"], routing=h["routing"], layers=h["layers"], heads=h["heads"], ff=h["ff"]).to(device) from safetensors.torch import load_file self.head.load_state_dict(load_file(_os.path.join(path, "head.safetensors"))) self.head.eval() self.device, self.dtype = device, dtype @classmethod def from_pretrained(cls, repo_or_path, **kw): if not _os.path.isdir(repo_or_path): from huggingface_hub import snapshot_download repo_or_path = snapshot_download(repo_or_path) return cls(repo_or_path, **kw) def _passes(self, fields): names, m = list(fields), self.cfg["max_fields_per_pass"] return [{n: fields[n] for n in names[i:i + m]} for i in range(0, len(names), m)] @torch.no_grad() def extract(self, state, fields, null_threshold=None, number_style=None): """state: document text. fields: {name: {"type": "text|list|number|date", "instructions": "...", "null_threshold": float}}. null_threshold: default per language (auto-detected). number_style: "en" (1,234.5) / "vi" (1.234,5) / None = auto. Returns {"answers": {name: {value, guess, null, p_null, confidence, spans, ...}}, "usage": {...}}.""" for k, v in fields.items(): if v.get("type") not in SUPPORTED: raise ValueError("field %s: type must be one of %s" % (k, sorted(SUPPORTED))) lang = detect_language(state) thr_all = self.cfg["null_threshold"].get(lang, self.cfg["null_threshold"]["default"]) if null_threshold is None else null_threshold en = {"en": True, "vi": False}.get(number_style) answers, usage = {}, {"state_tokens": 0, "passes": 0} queue = self._passes({k: {"type": v["type"], "instructions": v.get("instructions") or k} for k, v in fields.items()}) for part in queue: try: e = encode(self.tok, {"state": state, "fields": part}, self.cfg["max_length"]) except ValueError: if len(part) > 1: queue += [{k: v} for k, v in part.items()] continue raise with torch.autocast("cuda" if "cuda" in str(self.device) else "cpu", dtype=self.dtype): h = self.enc(input_ids=torch.tensor([e["ids"]], device=self.device)).last_hidden_state[0] out = self.head(h, e) for k, r in decode(out, e, state, 0.5, en=en).items(): t = fields[k].get("null_threshold", thr_all) r["null"] = bool(r["p_null"] > t) or (isinstance(r["guess"], list) and not r["guess"]) r["spans"] = [{"start": a, "end": b, "text": state[a:b]} for a, b in r.get("spans", [])] r["value"] = None if r["null"] else r.get("value", r["guess"]) answers[k] = r usage["state_tokens"] = max(usage["state_tokens"], e["state"][1] - e["state"][0]) usage["passes"] += 1 usage["truncated"] = usage["state_tokens"] < len(self.tok(state, add_special_tokens=False)["input_ids"]) return {"answers": answers, "usage": usage, "language": lang}