JoelAjitesh commited on
Commit
63519e7
·
verified ·
1 Parent(s): 9bf877a

Grounded Pointer QA: checkpoint, standalone inference, model card

Browse files
README.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language: en
3
+ license: mit
4
+ library_name: pytorch
5
+ base_model: FacebookAI/roberta-base
6
+ pipeline_tag: question-answering
7
+ tags:
8
+ - extractive-qa
9
+ - grounded-qa
10
+ - hallucination-free
11
+ - selective-prediction
12
+ - abstention
13
+ - rag
14
+ datasets:
15
+ - rajpurkar/squad_v2
16
+ metrics:
17
+ - exact_match
18
+ model-index:
19
+ - name: grounded-pointer-qa
20
+ results:
21
+ - task:
22
+ type: question-answering
23
+ dataset:
24
+ name: SQuAD v2 (held-out test half, retrieval setting)
25
+ type: rajpurkar/squad_v2
26
+ metrics:
27
+ - type: exact_match
28
+ value: 74.6
29
+ name: EM (EM-optimal gate)
30
+ - type: precision
31
+ value: 91.7
32
+ name: Answered precision @ 90%-precision gate (9% coverage)
33
+ ---
34
+
35
+ # Grounded Pointer QA
36
+
37
+ An extractive question-answering model that **cannot hallucinate by
38
+ construction**: its output layer can only point at spans inside retrieved
39
+ passages of *your* documents — it has no vocabulary to generate from. A
40
+ trained abstention head refuses when the loaded knowledge doesn't contain the
41
+ answer, decoding is deterministic (argmax; same question + same documents =
42
+ same answer forever), and knowledge is **hot-swappable**: point it at a new
43
+ folder of text files and it answers from those, no retraining.
44
+
45
+ Built on `roberta-base` (125M params) with pointer + abstention heads,
46
+ finetuned on SQuAD v2 **against real TF-IDF retrieval** — the model never saw
47
+ gold passages during training, only what the retriever actually returned, so
48
+ it learned to abstain on retrieval misses too.
49
+
50
+ ## Operating modes
51
+
52
+ The checkpoint ships with a calibrated confidence gate
53
+ (`P(answerable) × P(span)`), selected on a calibration split and verified on
54
+ a held-out test split:
55
+
56
+ | Mode | Gate | Coverage | Answered precision | EM |
57
+ |---|---|---|---|---|
58
+ | "Right or silent" (shipped default) | 0.965 | 9% | **91.7%** | 57.1 |
59
+ | EM-optimal | 0.295 | 45% | 72.3% | 74.6 |
60
+
61
+ Pass a lower `gate` to `ask()` for more coverage at lower precision.
62
+
63
+ ## Usage
64
+
65
+ ```python
66
+ # files needed: proqa.pt, modeling_proqa.py (both in this repo)
67
+ # pip install torch transformers scikit-learn numpy (+ pypdf for PDFs)
68
+ from modeling_proqa import GroundedQA
69
+
70
+ qa = GroundedQA("proqa.pt")
71
+ qa.load_folder("path/to/your/notes") # .txt / .md / .pdf
72
+
73
+ qa.ask("when does the vendor contract expire?")
74
+ # {'answer': '30 November 2026', 'source': '...expires on 30 November 2026...',
75
+ # 'confidence': 0.98}
76
+
77
+ qa.ask("what is the capital of France?") # not in your docs
78
+ # {'answer': None, 'source': None, 'confidence': 0.0}
79
+ ```
80
+
81
+ ## Honest limitations
82
+
83
+ - Single-span extraction only: no summarization, no aggregation across
84
+ passages, no multi-turn conversation.
85
+ - Trained on Wikipedia-style prose; **tables read poorly** — convert rows to
86
+ sentences ("ATA chapter 32 is Landing Gear.") for big accuracy gains.
87
+ - TF-IDF retrieval is lexical: paraphrases sharing no words with your
88
+ documents may cause (safe) abstentions.
89
+ - Single training run, single seed; in-domain calibration.
90
+
91
+ Full writeup, ablations (from-scratch 22.4 EM → 74.6 EM ladder), and negative
92
+ results: see the project repository's `paper/`.
93
+
94
+ ## Training
95
+
96
+ One NVIDIA RTX 5060 Ti (16 GB): ~2.5 h finetune (batch 8 × 4 passages × 384
97
+ tokens, bf16, lr 2e-5, 2 epochs) + calibration pass.
__pycache__/modeling_proqa.cpython-311.pyc ADDED
Binary file (15.3 kB). View file
 
modeling_proqa.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone inference for Grounded Pointer QA (proqa).
2
+
3
+ Self-contained: everything needed to load the checkpoint and ask questions
4
+ over your own documents. Requires: torch, transformers, scikit-learn, numpy.
5
+
6
+ from modeling_proqa import GroundedQA
7
+ qa = GroundedQA("proqa.pt") # or a hf_hub_download path
8
+ qa.load_folder(r"C:\\my\\notes") # .txt / .md files
9
+ print(qa.ask("who approved the budget?")) # quote + source, or None
10
+ """
11
+
12
+ import os
13
+ from dataclasses import dataclass
14
+
15
+ import numpy as np
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.nn.functional as F
19
+ from sklearn.feature_extraction.text import TfidfVectorizer
20
+ from transformers import AutoConfig, AutoModel, AutoTokenizer
21
+
22
+
23
+ @dataclass
24
+ class ProConfig:
25
+ backbone: str = "roberta-base"
26
+ max_len: int = 384
27
+ k_passages: int = 4
28
+
29
+
30
+ class ProReaderQA(nn.Module):
31
+ def __init__(self, cfg: ProConfig):
32
+ super().__init__()
33
+ self.cfg = cfg
34
+ self.backbone = AutoModel.from_config(AutoConfig.from_pretrained(cfg.backbone))
35
+ h = self.backbone.config.hidden_size
36
+ self.span_head = nn.Linear(h, 2)
37
+ self.abstain_head = nn.Sequential(nn.Linear(h, h), nn.Tanh(), nn.Linear(h, 1))
38
+
39
+ def forward(self, input_ids, attention_mask, context_mask):
40
+ b, k, L = input_ids.shape
41
+ out = self.backbone(input_ids=input_ids.view(b * k, L),
42
+ attention_mask=attention_mask.view(b * k, L)
43
+ ).last_hidden_state
44
+ start_logits, end_logits = self.span_head(out).unbind(dim=-1)
45
+ neg_inf = torch.finfo(start_logits.dtype).min
46
+ cm = context_mask.view(b * k, L)
47
+ start_logits = start_logits.masked_fill(~cm, neg_inf).view(b, k * L)
48
+ end_logits = end_logits.masked_fill(~cm, neg_inf).view(b, k * L)
49
+ cls = out[:, 0].view(b, k, -1).mean(dim=1)
50
+ return start_logits, end_logits, self.abstain_head(cls).squeeze(-1)
51
+
52
+
53
+ def chunk_text(text, chunk_words=150, overlap=40):
54
+ words = text.split()
55
+ if not words:
56
+ return []
57
+ chunks, step = [], max(chunk_words - overlap, 1)
58
+ for i in range(0, len(words), step):
59
+ chunks.append(" ".join(words[i:i + chunk_words]))
60
+ if i + chunk_words >= len(words):
61
+ break
62
+ return chunks
63
+
64
+
65
+ class GroundedQA:
66
+ def __init__(self, checkpoint_path: str, device: str = None):
67
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
68
+ ckpt = torch.load(checkpoint_path, map_location=self.device)
69
+ self.cfg = ProConfig(**ckpt["config"])
70
+ self.model = ProReaderQA(self.cfg).to(self.device).eval()
71
+ self.model.load_state_dict(ckpt["state_dict"])
72
+ self.gate = ckpt.get("gate_threshold", 0.5)
73
+ self.tok = AutoTokenizer.from_pretrained(self.cfg.backbone)
74
+ self.passages, self._vec, self._mat = [], None, None
75
+
76
+ # ---------- knowledge ----------
77
+ def load_passages(self, passages):
78
+ self.passages = list(passages)
79
+ self._vec = TfidfVectorizer(lowercase=True, ngram_range=(1, 2),
80
+ sublinear_tf=True, min_df=1)
81
+ self._mat = self._vec.fit_transform(self.passages)
82
+
83
+ def load_folder(self, folder, chunk_words=150):
84
+ passages = []
85
+ for root, _, files in os.walk(folder):
86
+ for name in sorted(files):
87
+ path = os.path.join(root, name)
88
+ if name.lower().endswith((".txt", ".md")):
89
+ with open(path, encoding="utf-8", errors="ignore") as f:
90
+ passages.extend(chunk_text(f.read(), chunk_words))
91
+ elif name.lower().endswith(".pdf"):
92
+ try:
93
+ from pypdf import PdfReader
94
+ text = "\n".join(p.extract_text() or ""
95
+ for p in PdfReader(path).pages)
96
+ passages.extend(chunk_text(text, chunk_words))
97
+ except ImportError:
98
+ pass # pip install pypdf for PDF support
99
+ if not passages:
100
+ raise ValueError(f"no readable documents under {folder}")
101
+ self.load_passages(passages)
102
+
103
+ # ---------- ask ----------
104
+ @torch.no_grad()
105
+ def ask(self, question: str, gate: float = None):
106
+ """Returns dict(answer, source, confidence) or dict(answer=None, ...)."""
107
+ assert self.passages, "load knowledge first (load_folder / load_passages)"
108
+ gate = self.gate if gate is None else gate
109
+ k, L = self.cfg.k_passages, self.cfg.max_len
110
+
111
+ q = self._vec.transform([question])
112
+ sims = (q @ self._mat.T).toarray()[0]
113
+ top = list(np.argsort(-sims)[:k])
114
+ while len(top) < k:
115
+ top.append(top[-1])
116
+ passages = [self.passages[j] for j in top]
117
+
118
+ q_ids = self.tok(question, add_special_tokens=False, truncation=True,
119
+ max_length=64)["input_ids"]
120
+ question = self.tok.decode(q_ids)
121
+ enc = self.tok([question] * k, passages, truncation="only_second",
122
+ max_length=L, padding="max_length",
123
+ return_offsets_mapping=True, return_tensors="pt")
124
+ cm = torch.zeros(k, L, dtype=torch.bool)
125
+ for s in range(k):
126
+ cm[s] = torch.tensor([sid == 1 for sid in enc.sequence_ids(s)])
127
+ if top[s] in top[:s]: # dedup tiny indexes
128
+ cm[s] = False
129
+
130
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16,
131
+ enabled=self.device == "cuda"):
132
+ s_log, e_log, a_log = self.model(
133
+ enc["input_ids"].unsqueeze(0).to(self.device),
134
+ enc["attention_mask"].bool().unsqueeze(0).to(self.device),
135
+ cm.unsqueeze(0).to(self.device))
136
+
137
+ s_lp = F.log_softmax(s_log.float(), -1).view(1, k, L)
138
+ e_lp = F.log_softmax(e_log.float(), -1).view(1, k, L)
139
+ scores = s_lp.unsqueeze(3) + e_lp.unsqueeze(2)
140
+ valid = torch.ones(L, L, dtype=torch.bool, device=scores.device).triu()
141
+ valid &= ~torch.ones(L, L, dtype=torch.bool, device=scores.device).triu(80)
142
+ flat = scores.masked_fill(~valid, float("-inf")).view(1, -1)
143
+ best, idx = flat.max(-1)
144
+ pi, rem = int(idx // (L * L)), int(idx % (L * L))
145
+ s, e = rem // L, rem % L
146
+ conf = float(a_log.float().sigmoid() * best.exp())
147
+
148
+ if conf < gate:
149
+ return {"answer": None, "source": None, "confidence": conf}
150
+ o = enc["offset_mapping"][pi]
151
+ return {"answer": passages[pi][int(o[s][0]): int(o[e][1])],
152
+ "source": passages[pi], "confidence": conf}
153
+
154
+
155
+ if __name__ == "__main__":
156
+ import sys
157
+ qa = GroundedQA(sys.argv[1] if len(sys.argv) > 1 else "proqa.pt")
158
+ qa.load_folder(sys.argv[2] if len(sys.argv) > 2 else ".")
159
+ print(f"loaded {len(qa.passages)} passages; gate={qa.gate:.2f}")
160
+ while True:
161
+ q = input("question> ").strip()
162
+ if q in ("", "/quit"):
163
+ break
164
+ r = qa.ask(q)
165
+ if r["answer"] is None:
166
+ print(f" [abstains] (conf={r['confidence']:.2f})")
167
+ else:
168
+ print(f" \"{r['answer']}\" (conf={r['confidence']:.2f})")
proqa.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:27f876d2b24a30f86f4afb3c4328a1fec22c3374c993f0abbfc4d36d298fec90
3
+ size 501023103