Compare DecidaBERT-large-v1.1 and v2: ground truth, benchmark case browser, more demos, question builder
Browse files- README.md +19 -11
- __pycache__/app.cpython-312.pyc +0 -0
- __pycache__/app.cpython-313.pyc +0 -0
- __pycache__/emoji_finder.cpython-312.pyc +0 -0
- __pycache__/emoji_finder.cpython-313.pyc +0 -0
- __pycache__/examples.cpython-312.pyc +0 -0
- __pycache__/examples.cpython-313.pyc +0 -0
- __pycache__/games.cpython-312.pyc +0 -0
- __pycache__/games.cpython-313.pyc +0 -0
- __pycache__/inbox.cpython-312.pyc +0 -0
- __pycache__/inbox.cpython-313.pyc +0 -0
- __pycache__/model.cpython-312.pyc +0 -0
- __pycache__/model.cpython-313.pyc +0 -0
- app.py +427 -175
- model.py +0 -241
- requirements.txt +2 -0
README.md
CHANGED
|
@@ -8,22 +8,30 @@ sdk_version: 5.9.1
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: apache-2.0
|
| 11 |
-
short_description: DecidaBERT-large vs v2, typed decisions in one pass
|
| 12 |
---
|
| 13 |
|
| 14 |
-
# Decida: DecidaBERT-large vs DecidaBERT-large-v2
|
| 15 |
|
| 16 |
-
A free ZeroGPU demo that runs [DecidaBERT-large](https://huggingface.co/helmo/DecidaBERT-large) and
|
| 17 |
[DecidaBERT-large-v2](https://huggingface.co/helmo/DecidaBERT-large-v2) side by side: send a **state** and typed
|
| 18 |
-
**questions** (`choice`, `score`, `noul`), get a probability distribution per question from each model,
|
| 19 |
-
generated text.
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
Not affiliated with any other model author.
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: apache-2.0
|
| 11 |
+
short_description: DecidaBERT-large-v1.1 vs v2, typed decisions in one pass
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Decida: DecidaBERT-large-v1.1 vs DecidaBERT-large-v2
|
| 15 |
|
| 16 |
+
A free ZeroGPU demo that runs [DecidaBERT-large-v1.1](https://huggingface.co/helmo/DecidaBERT-large-v1.1) and
|
| 17 |
[DecidaBERT-large-v2](https://huggingface.co/helmo/DecidaBERT-large-v2) side by side: send a **state** and typed
|
| 18 |
+
**questions** (`choice`, `score`, `noul`), get a probability distribution per question from each model, one forward
|
| 19 |
+
pass each, no generated text.
|
| 20 |
|
| 21 |
+
- **Benchmark cases:** any of the 400 cases of the Typed Decisions **test** split (never used for training), with the
|
| 22 |
+
benchmark's gold answers. Each model is marked ✓ or ✗ per question, and the gold distribution is shown beside them.
|
| 23 |
+
- **Hand-written demos:** a long incident log, a pull request with twelve questions, a phishing email, an invoice
|
| 24 |
+
that does not match its order, and more. Every question carries the demo author's reading of the right answer
|
| 25 |
+
(a judgment, not a benchmark label), so you can see where the models match it, and where they agree or disagree
|
| 26 |
+
with each other.
|
| 27 |
+
- **Your own:** write any state (text or JSON) and questions, or build them with the question form; add `"expected"`
|
| 28 |
+
to a question to have both models marked against it.
|
| 29 |
|
| 30 |
+
The **Benchmark** tab has both models' scores on the Typed Decisions test split, overall, per question type and per
|
| 31 |
+
workflow. The two are level overall and differ by workflow.
|
| 32 |
+
|
| 33 |
+
This Space's code is independent of the main project: `decidabert_v2/` is the inference code published with v2, and
|
| 34 |
+
it loads both models (same architecture). The local runtime with a REST API, MCP tools and a testbench is at
|
| 35 |
+
**https://github.com/heldernoid/decida**.
|
| 36 |
|
| 37 |
Not affiliated with any other model author.
|
__pycache__/app.cpython-312.pyc
DELETED
|
Binary file (21.3 kB)
|
|
|
__pycache__/app.cpython-313.pyc
DELETED
|
Binary file (13.8 kB)
|
|
|
__pycache__/emoji_finder.cpython-312.pyc
DELETED
|
Binary file (5.04 kB)
|
|
|
__pycache__/emoji_finder.cpython-313.pyc
DELETED
|
Binary file (5.11 kB)
|
|
|
__pycache__/examples.cpython-312.pyc
DELETED
|
Binary file (6.56 kB)
|
|
|
__pycache__/examples.cpython-313.pyc
DELETED
|
Binary file (6.59 kB)
|
|
|
__pycache__/games.cpython-312.pyc
DELETED
|
Binary file (8.95 kB)
|
|
|
__pycache__/games.cpython-313.pyc
DELETED
|
Binary file (9.08 kB)
|
|
|
__pycache__/inbox.cpython-312.pyc
DELETED
|
Binary file (2.5 kB)
|
|
|
__pycache__/inbox.cpython-313.pyc
DELETED
|
Binary file (2.54 kB)
|
|
|
__pycache__/model.cpython-312.pyc
DELETED
|
Binary file (20.6 kB)
|
|
|
__pycache__/model.cpython-313.pyc
DELETED
|
Binary file (21 kB)
|
|
|
app.py
CHANGED
|
@@ -1,20 +1,25 @@
|
|
| 1 |
-
"""DecidaBERT-large vs DecidaBERT-large-v2, side by side, free on ZeroGPU.
|
| 2 |
|
| 3 |
The same state and typed questions go to both models; each answers every question with a probability distribution
|
| 4 |
-
in one forward pass
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
import html
|
| 9 |
import json
|
| 10 |
import os
|
|
|
|
| 11 |
import time
|
| 12 |
|
| 13 |
import gradio as gr
|
|
|
|
| 14 |
import torch
|
|
|
|
| 15 |
|
| 16 |
-
from decidabert_v2 import load
|
| 17 |
-
from model import Primitive, Question, load as load_v1, predict as predict_v1
|
| 18 |
|
| 19 |
try:
|
| 20 |
import spaces
|
|
@@ -23,25 +28,49 @@ except ImportError:
|
|
| 23 |
def GPU(fn):
|
| 24 |
return fn
|
| 25 |
|
| 26 |
-
DEVICE = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu"
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
def _long_incident_log() -> str:
|
| 32 |
-
"""~3,000 tokens of routine logs; the lines that matter come last
|
| 33 |
lines = []
|
| 34 |
for i in range(80):
|
| 35 |
mm, ss = divmod(i * 7, 60)
|
| 36 |
svc = ["payments-api", "ledger-worker", "auth-gw", "notify"][i % 4]
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
elif i % 4 == 2:
|
| 42 |
-
msg = f"token refresh ok client=web-{i % 7} ttl=3600"
|
| 43 |
-
else:
|
| 44 |
-
msg = f"queued email receipt id=r_{77000 + i} provider=ok"
|
| 45 |
lines.append(f"2026-11-14T09:{mm:02d}:{ss:02d}Z INFO {svc:13s} {msg}")
|
| 46 |
lines += [
|
| 47 |
"2026-11-14T09:28:01Z WARN ledger-worker db primary latency_ms=2400 (threshold 200)",
|
|
@@ -54,102 +83,150 @@ def _long_incident_log() -> str:
|
|
| 54 |
return "\n".join(lines)
|
| 55 |
|
| 56 |
|
| 57 |
-
|
|
|
|
| 58 |
"Long incident log (~3k tokens)": {
|
| 59 |
-
"why": "The
|
| 60 |
-
"v2 reads up to 8,192, so only v2 sees the outage.",
|
| 61 |
"state": _long_incident_log(),
|
| 62 |
"questions": {
|
| 63 |
-
"active_outage": {"type": "noul", "instructions": "Is there an active outage affecting customers right now?"},
|
| 64 |
"severity": {"type": "score", "instructions": "How severe is the situation described by these logs?",
|
| 65 |
-
"criteria": ["Normal operation", "Minor degradation", "Major degradation", "Critical outage"]},
|
| 66 |
"next_action": {"type": "choice", "instructions": "What should the on-call engineer do?",
|
| 67 |
-
"criteria": {"nothing": "Routine logs, no action needed",
|
| 68 |
-
"
|
| 69 |
-
"page_incident": "Declare an incident and page the database owners now"}},
|
| 70 |
},
|
| 71 |
},
|
| 72 |
"Security alert: suspicious, not confirmed": {
|
| 73 |
"why": "The right call is the measured middle option (investigate), not the extremes (close or contain).",
|
| 74 |
-
"state": ("EDR alert on laptop FIN-2231: powershell.exe launched with an encoded command by excel.exe, "
|
| 75 |
-
"
|
| 76 |
-
"
|
| 77 |
-
"
|
| 78 |
"questions": {
|
| 79 |
"disposition": {"type": "choice", "instructions": "How should this alert be handled?",
|
| 80 |
"criteria": {"close_benign": "Expected activity; close without analyst time",
|
| 81 |
"monitor": "Not clearly malicious; watch for recurrence",
|
| 82 |
"investigate": "Open an investigation and have an analyst review it",
|
| 83 |
-
"contain": "Isolate the host immediately"}},
|
| 84 |
-
"true_positive": {"type": "noul", "instructions": "Is this alert likely to be real malicious activity?"},
|
| 85 |
-
"severity": {"type": "score", "instructions": "How severe is it?", "criteria":
|
| 86 |
},
|
| 87 |
},
|
| 88 |
-
"Agent run review:
|
| 89 |
-
"why": "The agent
|
| 90 |
-
"state": ("Task: migrate 12 cron jobs from the old scheduler to the new workflow engine and verify each runs. "
|
| 91 |
-
"
|
| 92 |
-
"
|
| 93 |
-
"
|
| 94 |
"questions": {
|
| 95 |
"outcome": {"type": "choice", "instructions": "How did this agent run turn out?",
|
| 96 |
"criteria": {"success": "The task was completed correctly", "partial": "Progress made, task not fully done",
|
| 97 |
-
"failure": "The task was not accomplished", "harmful": "Caused damage or broke a constraint"}
|
|
|
|
|
|
|
|
|
|
| 98 |
"action": {"type": "choice", "instructions": "What should the observability system do with this run?",
|
| 99 |
"criteria": {"continue": "Let the agent proceed without interruption", "observe": "Keep running, flag for later sampling",
|
| 100 |
-
"human_review": "Queue for a human to review", "stop": "Halt the agent now"}
|
| 101 |
-
|
| 102 |
},
|
| 103 |
},
|
| 104 |
"Invoice vs purchase order": {
|
| 105 |
-
"why": "The
|
| 106 |
"state": json.dumps({"invoice": {"vendor": "Northfield Office Supply", "po": "PO-88412", "lines": [
|
| 107 |
{"item": "Ergonomic chair", "qty": 20, "unit_price": 189.00}, {"item": "Desk lamp", "qty": 20, "unit_price": 34.50}],
|
| 108 |
-
"total":
|
| 109 |
"purchase_order": {"po": "PO-88412", "lines": [
|
| 110 |
{"item": "Ergonomic chair", "qty": 18, "unit_price": 189.00}, {"item": "Desk lamp", "qty": 20, "unit_price": 34.50}],
|
| 111 |
"total": 4092.00},
|
| 112 |
"goods_received": {"Ergonomic chair": 18, "Desk lamp": 20}}, indent=1),
|
| 113 |
"questions": {
|
| 114 |
-
"matches_order": {"type": "noul", "instructions": "Does the invoice match the purchase order and the goods received?"},
|
| 115 |
"disposition": {"type": "choice", "instructions": "How should this invoice be handled?",
|
| 116 |
"criteria": {"approve": "Matches; approve for payment", "hold": "Something needs confirming; hold pending clarification",
|
| 117 |
-
"reject": "Should not be paid: duplicate, unauthorised or materially wrong"}},
|
| 118 |
},
|
| 119 |
},
|
| 120 |
-
"12 questions at once": {
|
| 121 |
-
"why": "
|
| 122 |
-
"state": ("Pull request #2291 'Add retry with backoff to the payment webhook sender'. Changes: webhook_sender.py "
|
| 123 |
-
"
|
| 124 |
-
"
|
| 125 |
-
"
|
| 126 |
-
"
|
| 127 |
"questions": {
|
| 128 |
"area": {"type": "choice", "instructions": "Which area does this change mainly touch?",
|
| 129 |
-
"criteria": {"payments": "Payment flows", "auth": "Login and access", "ui": "User interface", "infra": "Build and infrastructure"}
|
|
|
|
| 130 |
"kind": {"type": "choice", "instructions": "What kind of change is it?",
|
| 131 |
-
"criteria": {"feature": "New capability", "bugfix": "Fixes a defect", "reliability": "Makes an existing flow more robust",
|
| 132 |
-
|
| 133 |
-
"
|
| 134 |
-
"
|
| 135 |
-
"
|
| 136 |
-
"
|
| 137 |
-
"
|
| 138 |
-
|
|
|
|
|
|
|
| 139 |
"next_step": {"type": "choice", "instructions": "What should happen next?",
|
| 140 |
"criteria": {"merge": "Merge and deploy as planned", "resolve_first": "Resolve the open concern, then merge",
|
| 141 |
-
"reject": "Close the PR"}},
|
| 142 |
-
"needs_second_review": {"type": "noul", "instructions": "Should a payments specialist review it before merge?"},
|
| 143 |
-
"release_note": {"type": "noul", "instructions": "Does this need a customer-facing release note?"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
},
|
| 145 |
},
|
| 146 |
"Tool-call gate": {
|
| 147 |
-
"why": "An everyday agent gate
|
| 148 |
"state": "Agent wants to run: rm -rf ./build && git push --force origin main",
|
| 149 |
"questions": {
|
|
|
|
| 150 |
"action": {"type": "choice", "instructions": "What should happen to this command?",
|
| 151 |
-
"criteria": {"allow": "Safe to run", "confirm": "Ask the user first", "block": "Do not run"}},
|
| 152 |
-
"destructive": {"type": "noul", "instructions": "Can this command destroy work that cannot be recovered?"},
|
| 153 |
},
|
| 154 |
},
|
| 155 |
"Billing routing": {
|
|
@@ -157,156 +234,331 @@ PRESETS = {
|
|
| 157 |
"state": "Customer: my invoice was charged twice and nobody answers the phone!",
|
| 158 |
"questions": {
|
| 159 |
"department": {"type": "choice", "instructions": "Which team should handle this?",
|
| 160 |
-
"criteria": {"billing": "Charges, invoices, refunds", "technical": "Bugs and outages", "sales": "Pricing, demos, new seats"}
|
| 161 |
-
|
| 162 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
},
|
| 164 |
},
|
| 165 |
}
|
| 166 |
|
|
|
|
| 167 |
CSS = """
|
| 168 |
-
:root { --bg:#f4f5f8; --card:#fff; --ink:#141a2e; --ink2:#4a5270; --ink3:#7b839e; --line:#e3e6ee; --
|
| 169 |
-
|
|
|
|
| 170 |
#title h1 { font-size: 30px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 2px; }
|
| 171 |
#subtitle { color: var(--ink2); font-size: 15px; margin-bottom: 6px; }
|
| 172 |
.dc-why { background:#eef2ff; border:1px solid #c7d2fe; color:#312e81; border-radius:10px; padding:8px 12px; font-size:13.5px; }
|
| 173 |
-
.dc-card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; }
|
| 174 |
.dc-card h3 { margin: 0 0 8px; font-size: 16px; }
|
| 175 |
-
.dc-card.
|
| 176 |
.dc-q { padding: 10px 0; border-top: 1px solid var(--line); }
|
| 177 |
.dc-q:first-of-type { border-top: none; padding-top: 0; }
|
| 178 |
-
.dc-qhead { display:flex; align-items:center; gap:8px; font-weight:700; font-size:14.5px;
|
| 179 |
.dc-type { font: 700 10px ui-monospace, monospace; color: var(--ink3); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; text-transform: uppercase; }
|
| 180 |
.dc-instr { color: var(--ink2); font-size: 12.5px; margin-bottom: 6px; }
|
| 181 |
.dc-bar-row { display: grid; grid-template-columns: minmax(80px,150px) 1fr 44px; gap: 8px; align-items: center; margin: 4px 0; font-size: 13px; }
|
| 182 |
.dc-bar-track { display: block; height: 9px; border-radius: 6px; background: var(--track); overflow: hidden; }
|
| 183 |
.dc-bar-fill { display: block; height: 100%; border-radius: 6px; }
|
| 184 |
-
.dc-bar-label.top { font-weight: 700;
|
|
|
|
| 185 |
.dc-bar-num { font-family: ui-monospace, monospace; text-align: right; color: var(--ink2); }
|
| 186 |
-
.dc-meta { color: var(--ink3); font-size: 12.5px; margin-top: 8px; }
|
| 187 |
-
.dc-meta b { color: var(--ink); }
|
| 188 |
.dc-cmp { width:100%; border-collapse: collapse; font-size: 13.5px; }
|
| 189 |
-
.dc-cmp th, .dc-cmp td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); }
|
| 190 |
-
.dc-
|
|
|
|
|
|
|
| 191 |
"""
|
| 192 |
|
| 193 |
|
| 194 |
-
def
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
blocks = []
|
| 205 |
-
for qid,
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
|
| 231 |
@GPU
|
| 232 |
-
def answer(state: str, questions_json: str):
|
| 233 |
try:
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
try:
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
def
|
| 258 |
-
|
| 259 |
-
return f'<div class="dc-why">{
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
### Typed Decisions test split (400 cases, 2,000 questions)
|
| 264 |
|
| 265 |
-
Scored the same way for
|
| 266 |
-
intervals.
|
| 267 |
|
| 268 |
-
| question type | questions | DecidaBERT-large | DecidaBERT-large-
|
| 269 |
|---|---|---|---|---|---|
|
| 270 |
-
| choice | 600 | 0.722 | **0.747** | 0.
|
| 271 |
-
| score | 800 | 0.694 | **0.759** | 0.
|
| 272 |
-
| yes/no | 600 | 0.830 | **0.868** | 0.
|
| 273 |
-
| **all** | 2,000 | 0.743 (0.723 to 0.762) | **0.788**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
-
| | DecidaBERT-large | DecidaBERT-large-v2 |
|
| 276 |
|---|---|---|
|
| 277 |
-
|
|
| 278 |
-
|
|
| 279 |
-
|
|
| 280 |
-
|
|
| 281 |
-
|
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
[DecidaBERT-large-
|
|
|
|
|
|
|
| 285 |
"""
|
| 286 |
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
with gr.Column(elem_id="title"):
|
| 289 |
-
gr.Markdown("# DecidaBERT-large vs DecidaBERT-large-v2")
|
| 290 |
-
gr.Markdown("
|
| 291 |
-
"probability distribution per question
|
| 292 |
-
"
|
| 293 |
with gr.Tab("Compare"):
|
| 294 |
-
first = next(iter(PRESETS))
|
| 295 |
-
preset = gr.Radio(list(PRESETS), value=first, label="Example")
|
| 296 |
-
why = gr.HTML(f'<div class="dc-why">{html.escape(PRESETS[first]["why"])}</div>')
|
| 297 |
with gr.Row():
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
summary = gr.HTML()
|
| 302 |
with gr.Row():
|
| 303 |
-
|
| 304 |
-
|
|
|
|
| 305 |
with gr.Tab("Benchmark"):
|
| 306 |
-
gr.Markdown(
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
if __name__ == "__main__":
|
| 312 |
demo.launch()
|
|
|
|
| 1 |
+
"""DecidaBERT-large-v1.1 vs DecidaBERT-large-v2, side by side, free on ZeroGPU.
|
| 2 |
|
| 3 |
The same state and typed questions go to both models; each answers every question with a probability distribution
|
| 4 |
+
in one forward pass of up to 8,192 tokens. Both models share one architecture, so one inference package
|
| 5 |
+
(decidabert_v2/, as published with v2) loads both.
|
| 6 |
+
|
| 7 |
+
Ground truth: every question may carry an "expected" answer. Benchmark cases come from the Typed Decisions test split
|
| 8 |
+
(never used for training) with the benchmark's gold; the hand-written demos carry the demo author's reading of the
|
| 9 |
+
right answer (a judgment, not a benchmark label).
|
| 10 |
"""
|
| 11 |
import html
|
| 12 |
import json
|
| 13 |
import os
|
| 14 |
+
import random
|
| 15 |
import time
|
| 16 |
|
| 17 |
import gradio as gr
|
| 18 |
+
import pandas as pd
|
| 19 |
import torch
|
| 20 |
+
from huggingface_hub import hf_hub_download
|
| 21 |
|
| 22 |
+
from decidabert_v2 import load
|
|
|
|
| 23 |
|
| 24 |
try:
|
| 25 |
import spaces
|
|
|
|
| 28 |
def GPU(fn):
|
| 29 |
return fn
|
| 30 |
|
| 31 |
+
DEVICE = os.environ.get("DECIDA_DEVICE") or ("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu")
|
| 32 |
+
MODELS = [ # (key, display name, repo or folder, colour as r,g,b)
|
| 33 |
+
("v11", "DecidaBERT-large-v1.1", os.environ.get("DECIDABERT_V11", "helmo/DecidaBERT-large-v1.1"), "15,118,110"),
|
| 34 |
+
("v2", "DecidaBERT-large-v2", os.environ.get("DECIDABERT_V2", "helmo/DecidaBERT-large-v2"), "67,56,202"),
|
| 35 |
+
]
|
| 36 |
+
LOADED = {k: load(path, DEVICE) for k, _, path, _ in MODELS}
|
| 37 |
+
TOK = LOADED["v2"].layout.tok
|
| 38 |
+
|
| 39 |
+
# ---- benchmark cases: LocalLLaMA/typed-decisions test split ------------------------------------------------------
|
| 40 |
+
BENCH = pd.read_parquet(hf_hub_download("LocalLLaMA/typed-decisions", "all/test-00000-of-00001.parquet", repo_type="dataset"))
|
| 41 |
+
WORKFLOWS = sorted(BENCH.workflow.unique())
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _label_to_expected(q: dict, label) -> str:
|
| 45 |
+
if q["type"] == "noul":
|
| 46 |
+
return "yes" if str(label).lower() in ("true", "1", "yes") else "no"
|
| 47 |
+
return str(label)
|
| 48 |
|
| 49 |
|
| 50 |
+
def bench_case(workflow: str, number: int):
|
| 51 |
+
rows = BENCH[BENCH.workflow == workflow].sort_values("id")
|
| 52 |
+
r = rows.iloc[int(number) % len(rows)]
|
| 53 |
+
state, qs, gold = json.loads(r["state"]), json.loads(r["questions"]), json.loads(r["gold"])
|
| 54 |
+
for qid, q in qs.items():
|
| 55 |
+
q["expected"] = _label_to_expected(q, gold[qid]["label"])
|
| 56 |
+
gold_dist = {qid: {"type": g["type"], "probabilities": g["probabilities"]} for qid, g in gold.items()}
|
| 57 |
+
why = (f"Benchmark case <b>{html.escape(r['id'])}</b> from the Typed Decisions <b>test</b> split, never used to train "
|
| 58 |
+
f"either model. The gold is the benchmark's teacher answer averaged over several samples; its top answer is "
|
| 59 |
+
f"the expected one, and its full distribution is shown on the right.")
|
| 60 |
+
return why, json.dumps(state, indent=1, ensure_ascii=False), json.dumps(qs, indent=2, ensure_ascii=False), gold_dist
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ---- hand-written demos -------------------------------------------------------------------------------------------
|
| 64 |
def _long_incident_log() -> str:
|
| 65 |
+
"""~3,000 tokens of routine logs; the lines that matter come last."""
|
| 66 |
lines = []
|
| 67 |
for i in range(80):
|
| 68 |
mm, ss = divmod(i * 7, 60)
|
| 69 |
svc = ["payments-api", "ledger-worker", "auth-gw", "notify"][i % 4]
|
| 70 |
+
msg = [f"POST /v2/charges 201 latency_ms={38 + (i * 13) % 41} merchant=m_{1000 + i}",
|
| 71 |
+
f"batch {5000 + i} settled rows={200 + i % 57} lag_ms={12 + i % 9}",
|
| 72 |
+
f"token refresh ok client=web-{i % 7} ttl=3600",
|
| 73 |
+
f"queued email receipt id=r_{77000 + i} provider=ok"][i % 4]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
lines.append(f"2026-11-14T09:{mm:02d}:{ss:02d}Z INFO {svc:13s} {msg}")
|
| 75 |
lines += [
|
| 76 |
"2026-11-14T09:28:01Z WARN ledger-worker db primary latency_ms=2400 (threshold 200)",
|
|
|
|
| 83 |
return "\n".join(lines)
|
| 84 |
|
| 85 |
|
| 86 |
+
SEVERITY4 = ["Low", "Medium", "High", "Critical"]
|
| 87 |
+
DEMOS = {
|
| 88 |
"Long incident log (~3k tokens)": {
|
| 89 |
+
"why": "The outage is in the last six lines of a long, routine log: a model has to read to the end.",
|
|
|
|
| 90 |
"state": _long_incident_log(),
|
| 91 |
"questions": {
|
| 92 |
+
"active_outage": {"type": "noul", "instructions": "Is there an active outage affecting customers right now?", "expected": "yes"},
|
| 93 |
"severity": {"type": "score", "instructions": "How severe is the situation described by these logs?",
|
| 94 |
+
"criteria": ["Normal operation", "Minor degradation", "Major degradation", "Critical outage"], "expected": "3"},
|
| 95 |
"next_action": {"type": "choice", "instructions": "What should the on-call engineer do?",
|
| 96 |
+
"criteria": {"nothing": "Routine logs, no action needed", "watch": "Keep an eye on it, nothing is failing yet",
|
| 97 |
+
"page_incident": "Declare an incident and page the database owners now"}, "expected": "page_incident"},
|
|
|
|
| 98 |
},
|
| 99 |
},
|
| 100 |
"Security alert: suspicious, not confirmed": {
|
| 101 |
"why": "The right call is the measured middle option (investigate), not the extremes (close or contain).",
|
| 102 |
+
"state": ("EDR alert on laptop FIN-2231: powershell.exe launched with an encoded command by excel.exe, 15 minutes after "
|
| 103 |
+
"the user opened 'Q4_invoice_review.xlsm' from an external sender. No network connection to known-bad "
|
| 104 |
+
"addresses, no new services, no credential access observed so far. The user is in accounts payable and says "
|
| 105 |
+
"the file came from a supplier they work with."),
|
| 106 |
"questions": {
|
| 107 |
"disposition": {"type": "choice", "instructions": "How should this alert be handled?",
|
| 108 |
"criteria": {"close_benign": "Expected activity; close without analyst time",
|
| 109 |
"monitor": "Not clearly malicious; watch for recurrence",
|
| 110 |
"investigate": "Open an investigation and have an analyst review it",
|
| 111 |
+
"contain": "Isolate the host immediately"}, "expected": "investigate"},
|
| 112 |
+
"true_positive": {"type": "noul", "instructions": "Is this alert likely to be real malicious activity?", "expected": "yes"},
|
| 113 |
+
"severity": {"type": "score", "instructions": "How severe is it?", "criteria": SEVERITY4, "expected": "2"},
|
| 114 |
},
|
| 115 |
},
|
| 116 |
+
"Agent run review: claims more than it did": {
|
| 117 |
+
"why": "The agent finished 9 of 12 jobs but reported the whole migration as done.",
|
| 118 |
+
"state": ("Task: migrate 12 cron jobs from the old scheduler to the new workflow engine and verify each runs. Agent trace "
|
| 119 |
+
"summary: migrated 9 of 12 jobs; 9 test runs succeeded; jobs 10-12 skipped because their scripts need a secret "
|
| 120 |
+
"the agent cannot read. No errors, no destructive actions, no constraint broken. The agent ended with: "
|
| 121 |
+
"'Done, migration complete.'"),
|
| 122 |
"questions": {
|
| 123 |
"outcome": {"type": "choice", "instructions": "How did this agent run turn out?",
|
| 124 |
"criteria": {"success": "The task was completed correctly", "partial": "Progress made, task not fully done",
|
| 125 |
+
"failure": "The task was not accomplished", "harmful": "Caused damage or broke a constraint"},
|
| 126 |
+
"expected": "partial"},
|
| 127 |
+
"summary_accurate": {"type": "noul", "instructions": "Is the agent's final message an accurate summary of what it did?",
|
| 128 |
+
"expected": "no"},
|
| 129 |
"action": {"type": "choice", "instructions": "What should the observability system do with this run?",
|
| 130 |
"criteria": {"continue": "Let the agent proceed without interruption", "observe": "Keep running, flag for later sampling",
|
| 131 |
+
"human_review": "Queue for a human to review", "stop": "Halt the agent now"},
|
| 132 |
+
"expected": "human_review"},
|
| 133 |
},
|
| 134 |
},
|
| 135 |
"Invoice vs purchase order": {
|
| 136 |
+
"why": "The invoice bills 20 chairs; 18 were ordered and received. Hold it, don't pay it.",
|
| 137 |
"state": json.dumps({"invoice": {"vendor": "Northfield Office Supply", "po": "PO-88412", "lines": [
|
| 138 |
{"item": "Ergonomic chair", "qty": 20, "unit_price": 189.00}, {"item": "Desk lamp", "qty": 20, "unit_price": 34.50}],
|
| 139 |
+
"total": 4470.00},
|
| 140 |
"purchase_order": {"po": "PO-88412", "lines": [
|
| 141 |
{"item": "Ergonomic chair", "qty": 18, "unit_price": 189.00}, {"item": "Desk lamp", "qty": 20, "unit_price": 34.50}],
|
| 142 |
"total": 4092.00},
|
| 143 |
"goods_received": {"Ergonomic chair": 18, "Desk lamp": 20}}, indent=1),
|
| 144 |
"questions": {
|
| 145 |
+
"matches_order": {"type": "noul", "instructions": "Does the invoice match the purchase order and the goods received?", "expected": "no"},
|
| 146 |
"disposition": {"type": "choice", "instructions": "How should this invoice be handled?",
|
| 147 |
"criteria": {"approve": "Matches; approve for payment", "hold": "Something needs confirming; hold pending clarification",
|
| 148 |
+
"reject": "Should not be paid: duplicate, unauthorised or materially wrong"}, "expected": "hold"},
|
| 149 |
},
|
| 150 |
},
|
| 151 |
+
"Pull request: 12 questions at once": {
|
| 152 |
+
"why": "Twelve questions about one pull request, all answered from a single read of it.",
|
| 153 |
+
"state": ("Pull request #2291 'Add retry with backoff to the payment webhook sender'. Changes: webhook_sender.py (+84 -12), "
|
| 154 |
+
"tests/test_webhook_sender.py (+120), config/defaults.yaml (+3). Adds exponential backoff (base 500ms, max 5 tries) "
|
| 155 |
+
"and an idempotency key header. CI: 412 tests passed, coverage 81% (+2%). One reviewer approved; one comment "
|
| 156 |
+
"unresolved asking whether retries could double-charge if the receiver ignores the idempotency key. Deploy "
|
| 157 |
+
"target: production, Friday 17:30."),
|
| 158 |
"questions": {
|
| 159 |
"area": {"type": "choice", "instructions": "Which area does this change mainly touch?",
|
| 160 |
+
"criteria": {"payments": "Payment flows", "auth": "Login and access", "ui": "User interface", "infra": "Build and infrastructure"},
|
| 161 |
+
"expected": "payments"},
|
| 162 |
"kind": {"type": "choice", "instructions": "What kind of change is it?",
|
| 163 |
+
"criteria": {"feature": "New capability", "bugfix": "Fixes a defect", "reliability": "Makes an existing flow more robust",
|
| 164 |
+
"refactor": "No behaviour change"}, "expected": "reliability"},
|
| 165 |
+
"tests_added": {"type": "noul", "instructions": "Does the change add tests?", "expected": "yes"},
|
| 166 |
+
"ci_green": {"type": "noul", "instructions": "Did CI pass?", "expected": "yes"},
|
| 167 |
+
"open_concern": {"type": "noul", "instructions": "Is there an unresolved review concern?", "expected": "yes"},
|
| 168 |
+
"money_risk": {"type": "noul", "instructions": "Could this change cause customers to be charged twice?", "expected": "yes"},
|
| 169 |
+
"risk": {"type": "score", "instructions": "How risky is merging and deploying this now?", "criteria": ["Low", "Moderate", "High"],
|
| 170 |
+
"expected": "2"},
|
| 171 |
+
"size": {"type": "score", "instructions": "How large is the change?", "criteria": ["Tiny", "Small", "Medium", "Large"], "expected": "2"},
|
| 172 |
+
"deploy_timing_ok": {"type": "noul", "instructions": "Is deploying to production on Friday at 17:30 a good idea?", "expected": "no"},
|
| 173 |
"next_step": {"type": "choice", "instructions": "What should happen next?",
|
| 174 |
"criteria": {"merge": "Merge and deploy as planned", "resolve_first": "Resolve the open concern, then merge",
|
| 175 |
+
"reject": "Close the PR"}, "expected": "resolve_first"},
|
| 176 |
+
"needs_second_review": {"type": "noul", "instructions": "Should a payments specialist review it before merge?", "expected": "yes"},
|
| 177 |
+
"release_note": {"type": "noul", "instructions": "Does this need a customer-facing release note?", "expected": "yes"},
|
| 178 |
+
},
|
| 179 |
+
},
|
| 180 |
+
"Phishing email": {
|
| 181 |
+
"why": "A look-alike domain, urgency and a credential link: a textbook phishing attempt.",
|
| 182 |
+
"state": ("From: IT Service Desk <helpdesk@micros0ft-support.com>\nTo: j.alvarez@contoso-logistics.com\n"
|
| 183 |
+
"Subject: URGENT: your mailbox will be suspended in 2 hours\n\n"
|
| 184 |
+
"Dear user, we detected unusual sign-in activity. To keep your mailbox active, confirm your password within "
|
| 185 |
+
"2 hours at https://micros0ft-support.com/verify-account. Failure to do so will permanently delete your emails.\n"
|
| 186 |
+
"Regards, IT Service Desk\n\n[Mail gateway: SPF fail, sender domain registered 3 days ago, link domain not on allow-list]"),
|
| 187 |
+
"questions": {
|
| 188 |
+
"phishing": {"type": "noul", "instructions": "Is this email a phishing attempt?", "expected": "yes"},
|
| 189 |
+
"action": {"type": "choice", "instructions": "What should the mail security team do?",
|
| 190 |
+
"criteria": {"deliver": "Release it to the user's inbox", "warn": "Deliver with a warning banner",
|
| 191 |
+
"quarantine": "Quarantine it and block the sender domain"}, "expected": "quarantine"},
|
| 192 |
+
"severity": {"type": "score", "instructions": "How dangerous is it if a user acts on it?", "criteria": SEVERITY4, "expected": "2"},
|
| 193 |
+
},
|
| 194 |
+
},
|
| 195 |
+
"Refund request inside policy": {
|
| 196 |
+
"why": "Bought 9 days ago, unused, 30-day policy: a routine refund.",
|
| 197 |
+
"state": ("Ticket #48120. Customer: 'Hi, I ordered the Pro Wireless Headset (order A-55812) on Nov 5 but it turns out my "
|
| 198 |
+
"laptop has no Bluetooth. It's still sealed in the box. Can I get my money back?'\n"
|
| 199 |
+
"Order: paid 129.00 EUR on 2026-11-05, delivered 2026-11-07. Returns policy: full refund within 30 days of "
|
| 200 |
+
"delivery for unopened items. Customer since 2021, 14 orders, no previous refunds."),
|
| 201 |
+
"questions": {
|
| 202 |
+
"eligible": {"type": "noul", "instructions": "Is the customer eligible for a full refund under the policy?", "expected": "yes"},
|
| 203 |
+
"action": {"type": "choice", "instructions": "How should the agent respond?",
|
| 204 |
+
"criteria": {"approve_refund": "Approve the return and refund", "offer_credit": "Offer store credit instead",
|
| 205 |
+
"deny": "Decline: outside policy", "escalate": "Escalate to a supervisor"}, "expected": "approve_refund"},
|
| 206 |
+
"frustration": {"type": "score", "instructions": "How frustrated is the customer?", "criteria": ["Calm", "Frustrated", "Very angry"],
|
| 207 |
+
"expected": "0"},
|
| 208 |
+
},
|
| 209 |
+
},
|
| 210 |
+
"Noisy alert that resolved itself": {
|
| 211 |
+
"why": "A short CPU spike during a scheduled batch job, already back to normal: no page needed.",
|
| 212 |
+
"state": ("Alert: HighCPU on batch-worker-07 (non-production). CPU 86% for 4 minutes at 02:03-02:07, threshold 80% for 3 "
|
| 213 |
+
"minutes. Status: RESOLVED automatically at 02:07. Context: the nightly report job runs 02:00-02:10 on this host; "
|
| 214 |
+
"the same alert fired and resolved on 11 of the last 14 nights. No errors, no customer-facing service on the host."),
|
| 215 |
+
"questions": {
|
| 216 |
+
"page_on_call": {"type": "noul", "instructions": "Should the on-call engineer be woken up for this?", "expected": "no"},
|
| 217 |
+
"action": {"type": "choice", "instructions": "What should be done with this alert?",
|
| 218 |
+
"criteria": {"page": "Page on-call now", "ticket": "Open a ticket to tune the alert threshold or schedule",
|
| 219 |
+
"ignore": "Acknowledge and do nothing"}, "expected": "ticket"},
|
| 220 |
+
"severity": {"type": "score", "instructions": "How severe is it?", "criteria": SEVERITY4, "expected": "0"},
|
| 221 |
},
|
| 222 |
},
|
| 223 |
"Tool-call gate": {
|
| 224 |
+
"why": "An everyday agent gate: a force-push to main is destructive whatever the build folder holds.",
|
| 225 |
"state": "Agent wants to run: rm -rf ./build && git push --force origin main",
|
| 226 |
"questions": {
|
| 227 |
+
"destructive": {"type": "noul", "instructions": "Can this command destroy work that cannot be recovered?", "expected": "yes"},
|
| 228 |
"action": {"type": "choice", "instructions": "What should happen to this command?",
|
| 229 |
+
"criteria": {"allow": "Safe to run", "confirm": "Ask the user first", "block": "Do not run"}, "expected": "confirm"},
|
|
|
|
| 230 |
},
|
| 231 |
},
|
| 232 |
"Billing routing": {
|
|
|
|
| 234 |
"state": "Customer: my invoice was charged twice and nobody answers the phone!",
|
| 235 |
"questions": {
|
| 236 |
"department": {"type": "choice", "instructions": "Which team should handle this?",
|
| 237 |
+
"criteria": {"billing": "Charges, invoices, refunds", "technical": "Bugs and outages", "sales": "Pricing, demos, new seats"},
|
| 238 |
+
"expected": "billing"},
|
| 239 |
+
"frustration": {"type": "score", "instructions": "How frustrated is the customer?", "criteria": ["Calm", "Frustrated", "Very angry"],
|
| 240 |
+
"expected": "1"},
|
| 241 |
+
"urgent": {"type": "noul", "instructions": "Is this urgent and needs an answer today?", "expected": "yes"},
|
| 242 |
+
},
|
| 243 |
+
},
|
| 244 |
+
"Blank: write your own": {
|
| 245 |
+
"why": "Write any state (text or JSON) and your own questions; add \"expected\" to a question to have both models marked.",
|
| 246 |
+
"state": "",
|
| 247 |
+
"questions": {
|
| 248 |
+
"example": {"type": "choice", "instructions": "Replace this with your question.",
|
| 249 |
+
"criteria": {"option_a": "What option A means", "option_b": "What option B means"}, "expected": "option_a"},
|
| 250 |
},
|
| 251 |
},
|
| 252 |
}
|
| 253 |
|
| 254 |
+
# ---- answering and rendering ---------------------------------------------------------------------------------------
|
| 255 |
CSS = """
|
| 256 |
+
:root { --bg:#f4f5f8; --card:#fff; --ink:#141a2e; --ink2:#4a5270; --ink3:#7b839e; --line:#e3e6ee; --track:#eceefb;
|
| 257 |
+
--v11:#0f766e; --v2:#4338ca; --gold:#a16207; --ok:#15803d; --bad:#b91c1c; }
|
| 258 |
+
.gradio-container { max-width: 1240px !important; margin: 0 auto !important; font-family: -apple-system, "Segoe UI", sans-serif !important; }
|
| 259 |
#title h1 { font-size: 30px; font-weight: 800; letter-spacing: -0.01em; margin-bottom: 2px; }
|
| 260 |
#subtitle { color: var(--ink2); font-size: 15px; margin-bottom: 6px; }
|
| 261 |
.dc-why { background:#eef2ff; border:1px solid #c7d2fe; color:#312e81; border-radius:10px; padding:8px 12px; font-size:13.5px; }
|
| 262 |
+
.dc-card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; color: var(--ink); }
|
| 263 |
.dc-card h3 { margin: 0 0 8px; font-size: 16px; }
|
| 264 |
+
.dc-card.v11 h3 { color: var(--v11); } .dc-card.v2 h3 { color: var(--v2); } .dc-card.gold h3 { color: var(--gold); }
|
| 265 |
.dc-q { padding: 10px 0; border-top: 1px solid var(--line); }
|
| 266 |
.dc-q:first-of-type { border-top: none; padding-top: 0; }
|
| 267 |
+
.dc-qhead { display:flex; align-items:center; gap:8px; font-weight:700; font-size:14.5px; margin-bottom: 2px; }
|
| 268 |
.dc-type { font: 700 10px ui-monospace, monospace; color: var(--ink3); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; text-transform: uppercase; }
|
| 269 |
.dc-instr { color: var(--ink2); font-size: 12.5px; margin-bottom: 6px; }
|
| 270 |
.dc-bar-row { display: grid; grid-template-columns: minmax(80px,150px) 1fr 44px; gap: 8px; align-items: center; margin: 4px 0; font-size: 13px; }
|
| 271 |
.dc-bar-track { display: block; height: 9px; border-radius: 6px; background: var(--track); overflow: hidden; }
|
| 272 |
.dc-bar-fill { display: block; height: 100%; border-radius: 6px; }
|
| 273 |
+
.dc-bar-label { overflow-wrap: anywhere; } .dc-bar-label.top { font-weight: 700; }
|
| 274 |
+
.dc-bar-label.exp::after { content: " ★"; color: var(--gold); }
|
| 275 |
.dc-bar-num { font-family: ui-monospace, monospace; text-align: right; color: var(--ink2); }
|
| 276 |
+
.dc-meta { color: var(--ink3); font-size: 12.5px; margin-top: 8px; } .dc-meta b { color: var(--ink); }
|
|
|
|
| 277 |
.dc-cmp { width:100%; border-collapse: collapse; font-size: 13.5px; }
|
| 278 |
+
.dc-cmp th, .dc-cmp td { text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); vertical-align: top; }
|
| 279 |
+
.dc-ok { color: var(--ok); font-weight: 700; } .dc-bad { color: var(--bad); font-weight: 700; } .dc-na { color: var(--ink3); }
|
| 280 |
+
.dc-score { display:flex; gap:18px; flex-wrap:wrap; font-size:15px; margin-bottom:10px; }
|
| 281 |
+
.dc-score b.v11 { color: var(--v11); } .dc-score b.v2 { color: var(--v2); }
|
| 282 |
"""
|
| 283 |
|
| 284 |
|
| 285 |
+
def _parse_state(text: str):
|
| 286 |
+
"""JSON states are passed as objects (rendered the way the benchmark was scored); anything else as text."""
|
| 287 |
+
s = text.strip()
|
| 288 |
+
if s[:1] in "{[":
|
| 289 |
+
try:
|
| 290 |
+
return json.loads(s)
|
| 291 |
+
except json.JSONDecodeError:
|
| 292 |
+
pass
|
| 293 |
+
return text
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _keys(q: dict) -> list[str]:
|
| 297 |
+
c = q.get("criteria")
|
| 298 |
+
if q["type"] == "noul":
|
| 299 |
+
return ["yes", "no"]
|
| 300 |
+
if q["type"] == "choice":
|
| 301 |
+
return [str(x) for x in (c if isinstance(c, list) else list(c))]
|
| 302 |
+
return [str(i) for i in range(len(c))]
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _levels(q: dict) -> list[str]:
|
| 306 |
+
c = q.get("criteria") or []
|
| 307 |
+
return list(c.values()) if isinstance(c, dict) else list(c)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def _expected(q: dict) -> str | None:
|
| 311 |
+
"""Normalise a question's "expected" answer to the key the models' top answer uses (yes/no, label, level index)."""
|
| 312 |
+
e = q.get("expected")
|
| 313 |
+
if e is None or e == "":
|
| 314 |
+
return None
|
| 315 |
+
if q["type"] == "noul":
|
| 316 |
+
return "yes" if str(e).strip().lower() in ("yes", "true", "1", "y") else "no"
|
| 317 |
+
if q["type"] == "score":
|
| 318 |
+
s = str(e).strip()
|
| 319 |
+
if s.isdigit():
|
| 320 |
+
return s
|
| 321 |
+
levels = [str(x).strip().lower() for x in _levels(q)]
|
| 322 |
+
return str(levels.index(s.lower())) if s.lower() in levels else s
|
| 323 |
+
return str(e).strip()
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _dist(a: dict) -> dict[str, float]:
|
| 327 |
+
return {"yes": a["noul"], "no": 1 - a["noul"]} if a["type"] == "noul" else a["probabilities"]
|
| 328 |
+
|
| 329 |
|
| 330 |
+
def _label(q: dict, k: str) -> str:
|
| 331 |
+
if q["type"] == "score":
|
| 332 |
+
lv = _levels(q)
|
| 333 |
+
return f"{k}: {lv[int(k)]}" if k.isdigit() and int(k) < len(lv) else k
|
| 334 |
+
return k
|
| 335 |
|
| 336 |
+
|
| 337 |
+
def _bars(q: dict, dist: dict[str, float], rgb: str, exp: str | None) -> str:
|
| 338 |
+
top = max(dist, key=dist.get)
|
| 339 |
+
rows = []
|
| 340 |
+
for k, v in dist.items():
|
| 341 |
+
cls = ("top " if k == top else "") + ("exp" if k == exp else "")
|
| 342 |
+
rows.append(f'<div class="dc-bar-row"><span class="dc-bar-label {cls}">{html.escape(_label(q, k))}</span>'
|
| 343 |
+
f'<span class="dc-bar-track"><span class="dc-bar-fill" style="width:{v * 100:.1f}%; '
|
| 344 |
+
f'background:rgba({rgb},{0.15 + 0.8 * v:.2f})"></span></span><span class="dc-bar-num">{v:.2f}</span></div>')
|
| 345 |
+
return "".join(rows)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _mark(ok: bool | None) -> str:
|
| 349 |
+
return '<span class="dc-na">–</span>' if ok is None else ('<span class="dc-ok">✓</span>' if ok else '<span class="dc-bad">✗</span>')
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def _model_card(name: str, cls: str, rgb: str, answers: dict, qs: dict, meta: str) -> str:
|
| 353 |
+
blocks = []
|
| 354 |
+
for qid, q in qs.items():
|
| 355 |
+
d, exp = _dist(answers[qid]), _expected(q)
|
| 356 |
+
ok = None if exp is None else max(d, key=d.get) == exp
|
| 357 |
+
blocks.append(f'<div class="dc-q"><div class="dc-qhead">{_mark(ok) if ok is not None else ""} {html.escape(qid)} '
|
| 358 |
+
f'<span class="dc-type">{q["type"]}</span></div>'
|
| 359 |
+
f'<div class="dc-instr">{html.escape(str(q.get("instructions", "")))}</div>{_bars(q, d, rgb, exp)}'
|
| 360 |
+
f'<div class="dc-meta">confidence {answers[qid]["confidence"]:.2f}</div></div>')
|
| 361 |
+
return f'<div class="dc-card {cls}"><h3>{name}</h3>{"".join(blocks)}<div class="dc-meta">{meta}</div></div>'
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
def _gold_card(qs: dict, gold: dict | None) -> str:
|
| 365 |
blocks = []
|
| 366 |
+
for qid, q in qs.items():
|
| 367 |
+
exp = _expected(q)
|
| 368 |
+
g = (gold or {}).get(qid)
|
| 369 |
+
if g and g["type"] == q["type"]:
|
| 370 |
+
d = g["probabilities"]
|
| 371 |
+
d = {"yes": float(d.get("true", 0)), "no": float(d.get("false", 0))} if q["type"] == "noul" else {k: float(v) for k, v in d.items()}
|
| 372 |
+
body = _bars(q, d, "161,98,7", exp) if set(d) == set(_keys(q)) else ""
|
| 373 |
+
else:
|
| 374 |
+
body = ""
|
| 375 |
+
shown = html.escape(_label(q, exp)) if exp is not None else '<span class="dc-na">no expected answer given</span>'
|
| 376 |
+
blocks.append(f'<div class="dc-q"><div class="dc-qhead">{html.escape(qid)} <span class="dc-type">{q["type"]}</span></div>'
|
| 377 |
+
f'<div class="dc-instr">expected: <b>{shown}</b></div>{body}</div>')
|
| 378 |
+
src = ("benchmark gold (teacher average)" if gold else
|
| 379 |
+
"expected answers given with the questions (for the demos: the demo author's reading, not a benchmark label)")
|
| 380 |
+
return f'<div class="dc-card gold"><h3>Ground truth</h3>{"".join(blocks)}<div class="dc-meta">{src}; ★ marks the expected answer</div></div>'
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _summary(qs: dict, results: dict, n_tok: int) -> str:
|
| 384 |
+
rows, scored = [], [qid for qid, q in qs.items() if _expected(q) is not None]
|
| 385 |
+
hits, agree = {k: 0 for k, *_ in MODELS}, 0
|
| 386 |
+
for qid, q in qs.items():
|
| 387 |
+
exp = _expected(q)
|
| 388 |
+
cells, tops = [], []
|
| 389 |
+
for k, *_ in MODELS:
|
| 390 |
+
d = _dist(results[k][0][qid]); top = max(d, key=d.get); tops.append(top)
|
| 391 |
+
ok = None if exp is None else top == exp
|
| 392 |
+
hits[k] += bool(ok)
|
| 393 |
+
cells.append(f"<td>{_mark(ok)} {html.escape(_label(q, top))} ({d[top]:.2f})</td>")
|
| 394 |
+
same = len(set(tops)) == 1; agree += same
|
| 395 |
+
badge = '<span class="dc-ok">agree</span>' if same else '<span class="dc-bad">disagree</span>'
|
| 396 |
+
ex = html.escape(_label(q, exp)) if exp is not None else '<span class="dc-na">–</span>'
|
| 397 |
+
rows.append(f"<tr><td><b>{html.escape(qid)}</b></td><td>{ex}</td>{''.join(cells)}<td>{badge}</td></tr>")
|
| 398 |
+
score = '<div class="dc-score">' + "".join(
|
| 399 |
+
f'<span><b class="{k}">{name}</b>: {hits[k]} of {len(scored)} match the expected answer</span>' for k, name, *_ in MODELS
|
| 400 |
+
) * bool(scored) + f'<span>the two models agree on <b>{agree} of {len(qs)}</b></span></div>'
|
| 401 |
+
head = "".join(f"<th>{name}</th>" for _, name, *_ in MODELS)
|
| 402 |
+
return (f'<div class="dc-card">{score}<table class="dc-cmp"><tr><th>question</th><th>expected</th>{head}<th>models</th></tr>{"".join(rows)}</table>'
|
| 403 |
+
f'<div class="dc-meta">state: {n_tok:,} tokens · {len(qs)} question(s) · one forward pass per model · on {DEVICE}</div></div>')
|
| 404 |
|
| 405 |
|
| 406 |
@GPU
|
| 407 |
+
def answer(state: str, questions_json: str, gold: dict | None):
|
| 408 |
try:
|
| 409 |
+
qs = json.loads(questions_json)
|
| 410 |
+
assert isinstance(qs, dict) and qs, "questions must be a JSON object {question_id: {...}}"
|
| 411 |
+
for qid, q in qs.items():
|
| 412 |
+
assert q.get("type") in ("noul", "choice", "score"), f"{qid}: type must be noul, choice or score"
|
| 413 |
+
assert q["type"] == "noul" or q.get("criteria"), f"{qid}: choice and score questions need criteria"
|
| 414 |
+
except (json.JSONDecodeError, AssertionError) as exc:
|
| 415 |
+
return f'<div class="dc-card">Questions: {html.escape(str(exc))}</div>', "", "", ""
|
| 416 |
+
clean = {qid: {k: v for k, v in q.items() if k != "expected"} for qid, q in qs.items()}
|
| 417 |
+
st = _parse_state(state)
|
| 418 |
+
results = {}
|
| 419 |
try:
|
| 420 |
+
for k, *_ in MODELS:
|
| 421 |
+
t0 = time.perf_counter(); a = LOADED[k].predict(st, clean); results[k] = (a, (time.perf_counter() - t0) * 1000)
|
| 422 |
+
except (KeyError, ValueError, TypeError, IndexError) as exc:
|
| 423 |
+
return f'<div class="dc-card">Bad question: {html.escape(str(exc))}</div>', "", "", ""
|
| 424 |
+
n_tok = len(TOK(st if isinstance(st, str) else json.dumps(st, ensure_ascii=False), add_special_tokens=False)["input_ids"])
|
| 425 |
+
cards = []
|
| 426 |
+
for k, name, _, rgb in MODELS:
|
| 427 |
+
a, ms = results[k]
|
| 428 |
+
cut = " · <b>state cut to fit 8,192 tokens</b>" if any(x.get("truncated_state") for x in a.values()) else ""
|
| 429 |
+
cards.append(_model_card(name, k, rgb, a, qs, f"<b>{ms:.0f} ms</b>{cut}"))
|
| 430 |
+
return _summary(qs, results, n_tok), cards[0], cards[1], _gold_card(qs, gold)
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def load_demo(name: str):
|
| 434 |
+
p = DEMOS[name]
|
| 435 |
+
return f'<div class="dc-why">{html.escape(p["why"])}</div>', p["state"], json.dumps(p["questions"], indent=2, ensure_ascii=False), None
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def load_bench(workflow: str, number: float):
|
| 439 |
+
why, st, qs, gold = bench_case(workflow, int(number))
|
| 440 |
+
return f'<div class="dc-why">{why}</div>', st, qs, gold
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def random_bench():
|
| 444 |
+
w = random.choice(WORKFLOWS); n = random.randrange(100)
|
| 445 |
+
return (w, n) + load_bench(w, n)
|
| 446 |
+
|
| 447 |
+
|
| 448 |
+
def add_question(questions_json: str, qid: str, qtype: str, instructions: str, options: str, expected: str):
|
| 449 |
+
try:
|
| 450 |
+
qs = json.loads(questions_json) if questions_json.strip() else {}
|
| 451 |
+
except json.JSONDecodeError:
|
| 452 |
+
return questions_json, "The questions box is not valid JSON; fix or clear it first."
|
| 453 |
+
qid = (qid or "").strip().replace(" ", "_")
|
| 454 |
+
if not qid or not instructions.strip():
|
| 455 |
+
return questions_json, "A question needs an id and instructions."
|
| 456 |
+
q = {"type": qtype, "instructions": instructions.strip()}
|
| 457 |
+
lines = [l.strip() for l in options.splitlines() if l.strip()]
|
| 458 |
+
if qtype == "choice":
|
| 459 |
+
if len(lines) < 2:
|
| 460 |
+
return questions_json, "A choice question needs at least two options, one per line (label: description)."
|
| 461 |
+
q["criteria"] = {l.split(":", 1)[0].strip().replace(" ", "_"): (l.split(":", 1)[1].strip() if ":" in l else "") for l in lines}
|
| 462 |
+
elif qtype == "score":
|
| 463 |
+
if len(lines) < 2:
|
| 464 |
+
return questions_json, "A score question needs at least two levels, lowest first, one per line."
|
| 465 |
+
q["criteria"] = lines
|
| 466 |
+
if expected.strip():
|
| 467 |
+
q["expected"] = expected.strip()
|
| 468 |
+
qs[qid] = q
|
| 469 |
+
return json.dumps(qs, indent=2, ensure_ascii=False), f"Added {qid}."
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
BENCH_MD = """
|
| 473 |
### Typed Decisions test split (400 cases, 2,000 questions)
|
| 474 |
|
| 475 |
+
Scored the same way for every model: a prediction is right when its top answer equals the gold label; 95% Wilson
|
| 476 |
+
intervals. All models were fitted on the benchmark's `train` split; the test split was not used for training.
|
| 477 |
|
| 478 |
+
| question type | questions | DecidaBERT-large (v1) | DecidaBERT-large-v1.1 | DecidaBERT-large-v2 | random guess |
|
| 479 |
|---|---|---|---|---|---|
|
| 480 |
+
| choice | 600 | 0.722 | **0.747** | **0.747** | 0.233 |
|
| 481 |
+
| score | 800 | 0.694 | 0.755 | **0.759** | 0.244 |
|
| 482 |
+
| yes/no | 600 | 0.830 | 0.850 | **0.868** | 0.500 |
|
| 483 |
+
| **all** | 2,000 | 0.743 (0.723 to 0.762) | 0.781 (0.762 to 0.799) | **0.788** (0.770 to 0.805) | |
|
| 484 |
+
|
| 485 |
+
**v1.1 against v2, question by question:** level overall (difference −0.007, 95% interval −0.021 to +0.008; 99
|
| 486 |
+
questions only v2 got right, 85 only v1.1). By workflow, v1.1 is ahead on invoice processing (0.832 vs 0.806) and
|
| 487 |
+
behind on security incidents (0.750 vs 0.786); customer service 0.810 vs 0.830, agent traces 0.732 vs 0.730. The two
|
| 488 |
+
give the same top answer on 90% of the questions.
|
| 489 |
|
| 490 |
+
| | DecidaBERT-large-v1.1 | DecidaBERT-large-v2 |
|
| 491 |
|---|---|---|
|
| 492 |
+
| Base | ModernBERT-large-nli | ModernBERT-large |
|
| 493 |
+
| Context | 8,192 | 8,192 |
|
| 494 |
+
| Questions per forward pass | all of them | all of them |
|
| 495 |
+
| Score within one level of gold | **98.3%** | 97.6% |
|
| 496 |
+
| Reported confidence calibration error (test) | 0.024 | **0.023** |
|
| 497 |
+
| Benchmark scorer: accuracy / KL | 0.776 / **0.0852** | **0.7835** / 0.0861 |
|
| 498 |
+
|
| 499 |
+
Models: [DecidaBERT-large-v1.1](https://huggingface.co/helmo/DecidaBERT-large-v1.1) ·
|
| 500 |
+
[DecidaBERT-large-v2](https://huggingface.co/helmo/DecidaBERT-large-v2) ·
|
| 501 |
+
[DecidaBERT-large](https://huggingface.co/helmo/DecidaBERT-large) (v1)
|
| 502 |
"""
|
| 503 |
|
| 504 |
+
QUESTION_HELP = """**Question format.** `{"id": {"type": "noul" | "choice" | "score", "instructions": "...", "criteria": ..., "expected": ...}}`
|
| 505 |
+
· `noul`: yes/no, no criteria needed; expected `yes` or `no`.
|
| 506 |
+
· `choice`: criteria `{"label": "what it means", ...}`; expected a label.
|
| 507 |
+
· `score`: criteria a list of levels, lowest first; expected a level number (0 = first) or its text.
|
| 508 |
+
`expected` is optional; when given, both models are marked ✓ or ✗ against it."""
|
| 509 |
+
|
| 510 |
+
with gr.Blocks(title="DecidaBERT v1.1 vs v2", css=CSS) as demo:
|
| 511 |
+
gold_state = gr.State(None)
|
| 512 |
with gr.Column(elem_id="title"):
|
| 513 |
+
gr.Markdown("# DecidaBERT-large-v1.1 vs DecidaBERT-large-v2")
|
| 514 |
+
gr.Markdown("Two decision models, one forward pass each: send a state and typed questions (`choice`, `score`, `noul`), "
|
| 515 |
+
"get a probability distribution per question, no generated text. Try a benchmark case with its gold "
|
| 516 |
+
"answers, a hand-written demo, or your own.", elem_id="subtitle")
|
| 517 |
with gr.Tab("Compare"):
|
|
|
|
|
|
|
|
|
|
| 518 |
with gr.Row():
|
| 519 |
+
with gr.Column(scale=3):
|
| 520 |
+
demo_pick = gr.Dropdown(list(DEMOS), value=next(iter(DEMOS)), label="Hand-written demo")
|
| 521 |
+
with gr.Column(scale=2):
|
| 522 |
+
bench_wf = gr.Dropdown(WORKFLOWS, value=WORKFLOWS[0], label="Benchmark case: workflow")
|
| 523 |
+
with gr.Column(scale=2):
|
| 524 |
+
bench_n = gr.Slider(0, 99, value=0, step=1, label="case number (test split)")
|
| 525 |
+
with gr.Column(scale=1, min_width=120):
|
| 526 |
+
bench_load = gr.Button("Load case")
|
| 527 |
+
bench_rand = gr.Button("Random case")
|
| 528 |
+
why = gr.HTML(f'<div class="dc-why">{html.escape(DEMOS[next(iter(DEMOS))]["why"])}</div>')
|
| 529 |
+
with gr.Row():
|
| 530 |
+
state = gr.Textbox(label="State (text or JSON)", lines=10, max_lines=24, value=DEMOS[next(iter(DEMOS))]["state"])
|
| 531 |
+
questions = gr.Code(label="Questions (JSON)", language="json", lines=10,
|
| 532 |
+
value=json.dumps(DEMOS[next(iter(DEMOS))]["questions"], indent=2))
|
| 533 |
+
with gr.Accordion("Add a question", open=False):
|
| 534 |
+
gr.Markdown(QUESTION_HELP)
|
| 535 |
+
with gr.Row():
|
| 536 |
+
nq_id = gr.Textbox(label="id", placeholder="e.g. escalate")
|
| 537 |
+
nq_type = gr.Dropdown(["noul", "choice", "score"], value="noul", label="type")
|
| 538 |
+
nq_exp = gr.Textbox(label="expected (optional)", placeholder="yes / a label / a level number")
|
| 539 |
+
nq_ins = gr.Textbox(label="instructions", placeholder="e.g. Should this ticket go to a human agent?")
|
| 540 |
+
nq_opts = gr.Textbox(label="options (choice: one 'label: meaning' per line; score: one level per line, lowest first)", lines=3)
|
| 541 |
+
with gr.Row():
|
| 542 |
+
nq_add = gr.Button("Add to questions")
|
| 543 |
+
nq_clear = gr.Button("Clear all questions")
|
| 544 |
+
nq_msg = gr.Markdown()
|
| 545 |
+
run = gr.Button("Run both models", variant="primary")
|
| 546 |
summary = gr.HTML()
|
| 547 |
with gr.Row():
|
| 548 |
+
out_a = gr.HTML()
|
| 549 |
+
out_b = gr.HTML()
|
| 550 |
+
out_g = gr.HTML()
|
| 551 |
with gr.Tab("Benchmark"):
|
| 552 |
+
gr.Markdown(BENCH_MD)
|
| 553 |
+
|
| 554 |
+
io = [why, state, questions, gold_state]
|
| 555 |
+
demo_pick.change(load_demo, demo_pick, io)
|
| 556 |
+
bench_load.click(load_bench, [bench_wf, bench_n], io)
|
| 557 |
+
bench_rand.click(random_bench, None, [bench_wf, bench_n] + io)
|
| 558 |
+
nq_add.click(add_question, [questions, nq_id, nq_type, nq_ins, nq_opts, nq_exp], [questions, nq_msg])
|
| 559 |
+
nq_clear.click(lambda: ("{}", "Cleared."), None, [questions, nq_msg])
|
| 560 |
+
run.click(answer, [state, questions, gold_state], [summary, out_a, out_b, out_g])
|
| 561 |
+
demo.load(answer, [state, questions, gold_state], [summary, out_a, out_b, out_g])
|
| 562 |
|
| 563 |
if __name__ == "__main__":
|
| 564 |
demo.launch()
|
model.py
DELETED
|
@@ -1,241 +0,0 @@
|
|
| 1 |
-
"""A standalone port of DecidaBERT-large's inference (the decision heads and the Laya-format prompt layout).
|
| 2 |
-
|
| 3 |
-
This intentionally does not import the `decida` package: it is independent code for a Gradio Space, reading only
|
| 4 |
-
the public checkpoint at https://huggingface.co/helmo/DecidaBERT-large. The algorithm matches
|
| 5 |
-
`src/decida/model/enc.py` in the main project, ported here because ZeroGPU Spaces only support the Gradio SDK, not
|
| 6 |
-
a plain server process like `decida serve`. If the real project's encoder logic changes, this file needs updating
|
| 7 |
-
by hand; it is not generated from it.
|
| 8 |
-
"""
|
| 9 |
-
from __future__ import annotations
|
| 10 |
-
|
| 11 |
-
import json
|
| 12 |
-
import os
|
| 13 |
-
from dataclasses import dataclass
|
| 14 |
-
from enum import Enum
|
| 15 |
-
from typing import Any
|
| 16 |
-
|
| 17 |
-
import numpy as np
|
| 18 |
-
import torch
|
| 19 |
-
from safetensors.torch import load_file
|
| 20 |
-
from torch import nn
|
| 21 |
-
from transformers import AutoConfig, AutoModel, AutoTokenizer
|
| 22 |
-
|
| 23 |
-
MODEL_REPO = "helmo/DecidaBERT-large"
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
class Primitive(str, Enum):
|
| 27 |
-
CHOICE = "choice"
|
| 28 |
-
SCORE = "score"
|
| 29 |
-
NOUL = "noul"
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
@dataclass
|
| 33 |
-
class Question:
|
| 34 |
-
qid: str
|
| 35 |
-
primitive: Primitive
|
| 36 |
-
instructions: str
|
| 37 |
-
criteria: Any
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
@dataclass
|
| 41 |
-
class Encoded:
|
| 42 |
-
input_ids: torch.Tensor
|
| 43 |
-
attention_mask: torch.Tensor
|
| 44 |
-
readout_positions: torch.Tensor
|
| 45 |
-
n_options: int
|
| 46 |
-
truncated: bool
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
@dataclass
|
| 50 |
-
class Batch:
|
| 51 |
-
input_ids: torch.Tensor
|
| 52 |
-
attention_mask: torch.Tensor
|
| 53 |
-
readout_positions: torch.Tensor
|
| 54 |
-
marker_mask: torch.Tensor
|
| 55 |
-
qtypes: torch.Tensor
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def _render_criterion(value: Any) -> str:
|
| 59 |
-
if isinstance(value, str):
|
| 60 |
-
return value
|
| 61 |
-
return json.dumps(value, ensure_ascii=False, separators=(", ", ": "), default=str)
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def _render_options(q: Question) -> list[str]:
|
| 65 |
-
if q.primitive == Primitive.CHOICE:
|
| 66 |
-
return [k if not v else f"{k}: {_render_criterion(v)}" for k, v in q.criteria.items()]
|
| 67 |
-
if q.primitive == Primitive.SCORE:
|
| 68 |
-
return [f"level {i}: {_render_criterion(c)}" for i, c in enumerate(q.criteria)]
|
| 69 |
-
crit = q.criteria or {}
|
| 70 |
-
false_str = _render_criterion(crit.get("false")) if crit.get("false") else "no, the statement does not hold"
|
| 71 |
-
true_str = _render_criterion(crit.get("true")) if crit.get("true") else "yes, the statement holds"
|
| 72 |
-
return [f"false: {false_str}", f"true: {true_str}"]
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
class LayaLayout:
|
| 76 |
-
"""Builds one [CLS] instructions [SEP] <mask>opt1 <mask>opt2 ... [SEP] state [SEP] sequence per question."""
|
| 77 |
-
|
| 78 |
-
def __init__(self, tokenizer, max_len: int = 2142, head_max_len: int = 1400, opt_max_tokens: int = 160):
|
| 79 |
-
self.tok, self.max_len, self.head_max_len, self.opt_max_tokens = tokenizer, max_len, head_max_len, opt_max_tokens
|
| 80 |
-
|
| 81 |
-
def build(self, state: str, q: Question) -> Encoded:
|
| 82 |
-
mask_tok = self.tok.mask_token
|
| 83 |
-
opts = _render_options(q)
|
| 84 |
-
ins = q.instructions if isinstance(q.instructions, str) else json.dumps(q.instructions, ensure_ascii=False)
|
| 85 |
-
if mask_tok:
|
| 86 |
-
ins = ins.replace(mask_tok, " ")
|
| 87 |
-
head_ids = self.tok(f"{q.primitive.value} question: {ins}", add_special_tokens=False)["input_ids"]
|
| 88 |
-
|
| 89 |
-
opt_ids = []
|
| 90 |
-
for o in opts:
|
| 91 |
-
text_ids = self.tok(" " + o.replace(mask_tok, " "), add_special_tokens=False)["input_ids"]
|
| 92 |
-
opt_ids.append([self.tok.mask_token_id] + text_ids[: self.opt_max_tokens])
|
| 93 |
-
|
| 94 |
-
opt_budget = self.head_max_len - sum(len(o) for o in opt_ids)
|
| 95 |
-
if opt_budget < 16:
|
| 96 |
-
per = max(4, (self.head_max_len - 16) // max(1, len(opt_ids)))
|
| 97 |
-
opt_ids = [o[:per] for o in opt_ids]
|
| 98 |
-
opt_budget = self.head_max_len - sum(len(o) for o in opt_ids)
|
| 99 |
-
|
| 100 |
-
head_ids = head_ids[: max(8, opt_budget)]
|
| 101 |
-
ids = [self.tok.cls_token_id] + head_ids + [self.tok.sep_token_id]
|
| 102 |
-
markers = []
|
| 103 |
-
for o in opt_ids:
|
| 104 |
-
markers.append(len(ids))
|
| 105 |
-
ids.extend(o)
|
| 106 |
-
ids.append(self.tok.sep_token_id)
|
| 107 |
-
|
| 108 |
-
room = max(0, self.max_len - len(ids) - 1)
|
| 109 |
-
state_str = json.dumps(state, ensure_ascii=False) if isinstance(state, (dict, list)) else str(state)
|
| 110 |
-
st = self.tok(state_str.replace(mask_tok, " "), add_special_tokens=False)["input_ids"]
|
| 111 |
-
truncated = len(st) > room
|
| 112 |
-
ids = ids + st[:room] + [self.tok.sep_token_id]
|
| 113 |
-
|
| 114 |
-
final_ids = ids[: self.max_len]
|
| 115 |
-
valid_markers = [m for m in markers if m < self.max_len]
|
| 116 |
-
return Encoded(
|
| 117 |
-
input_ids=torch.tensor(final_ids, dtype=torch.long),
|
| 118 |
-
attention_mask=torch.ones(len(final_ids), dtype=torch.long),
|
| 119 |
-
readout_positions=torch.tensor(valid_markers, dtype=torch.long),
|
| 120 |
-
n_options=len(opts),
|
| 121 |
-
truncated=truncated,
|
| 122 |
-
)
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
class DecidaEncModel(nn.Module):
|
| 126 |
-
"""The decision heads on top of the ModernBERT backbone: a type embedding, an optional small transformer
|
| 127 |
-
head, and a scorer that reads one logit out of each option's <mask> position."""
|
| 128 |
-
|
| 129 |
-
def __init__(self, encoder: nn.Module, head_layers: int = 2, n_act: int = 2, cfg: dict[str, Any] | None = None):
|
| 130 |
-
super().__init__()
|
| 131 |
-
self.encoder, self.cfg = encoder, cfg or {}
|
| 132 |
-
d = int(encoder.config.hidden_size)
|
| 133 |
-
layer = nn.TransformerEncoderLayer(d_model=d, nhead=max(1, d // 64), dim_feedforward=4 * d, dropout=0.1, batch_first=True, norm_first=True)
|
| 134 |
-
self.head = nn.TransformerEncoder(layer, head_layers, enable_nested_tensor=False) if head_layers > 0 else None
|
| 135 |
-
self.type_emb = nn.Embedding(3, d)
|
| 136 |
-
self.scorer = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, d), nn.GELU(approximate="none"), nn.Linear(d, 1))
|
| 137 |
-
self.act_head = nn.Sequential(nn.Linear(d + 4, 256), nn.GELU(approximate="none"), nn.Linear(256, n_act))
|
| 138 |
-
self.qtypes_map = {"choice": 0, "score": 1, "noul": 2}
|
| 139 |
-
|
| 140 |
-
def forward(self, batch: Batch) -> torch.Tensor:
|
| 141 |
-
h = self.encoder(input_ids=batch.input_ids, attention_mask=batch.attention_mask).last_hidden_state
|
| 142 |
-
h = h + self.type_emb(batch.qtypes)[:, None, :]
|
| 143 |
-
if self.head is not None:
|
| 144 |
-
pad = ~batch.attention_mask.bool()
|
| 145 |
-
for layer in self.head.layers:
|
| 146 |
-
h = layer(h, src_key_padding_mask=pad)
|
| 147 |
-
idx = batch.readout_positions.clamp(min=0)[:, :, None].expand(-1, -1, h.size(-1))
|
| 148 |
-
logits = self.scorer(torch.gather(h, 1, idx)).squeeze(-1).float()
|
| 149 |
-
return logits.masked_fill(~batch.marker_mask, float("-inf"))
|
| 150 |
-
|
| 151 |
-
@classmethod
|
| 152 |
-
def from_pretrained(cls, path: str) -> DecidaEncModel:
|
| 153 |
-
weights = load_file(os.path.join(path, "model.safetensors"))
|
| 154 |
-
cfg_path = os.path.join(path, "rl_agent_config.json")
|
| 155 |
-
cfg = json.load(open(cfg_path)) if os.path.exists(cfg_path) else {}
|
| 156 |
-
ecfg = AutoConfig.from_pretrained(os.path.join(path, "encoder"))
|
| 157 |
-
enc = AutoModel.from_config(ecfg, attn_implementation="sdpa")
|
| 158 |
-
|
| 159 |
-
act_weight = weights.get("act_head.2.weight", weights.get("core_model.act_head.2.weight"))
|
| 160 |
-
if act_weight is None:
|
| 161 |
-
raise ValueError(f"{path} has no act_head.2.weight: not a Decida or Laya encoder checkpoint")
|
| 162 |
-
n_act, head_layers, new_weights = act_weight.shape[0], 0, {}
|
| 163 |
-
for k, v in weights.items():
|
| 164 |
-
k = k.removeprefix("core_model.")
|
| 165 |
-
new_weights[k] = v
|
| 166 |
-
if k.startswith("head.layers."):
|
| 167 |
-
head_layers = max(head_layers, int(k.split(".")[2]) + 1)
|
| 168 |
-
|
| 169 |
-
model = cls(enc, head_layers=head_layers, n_act=n_act, cfg=cfg)
|
| 170 |
-
model.load_state_dict(new_weights, strict=False)
|
| 171 |
-
return model
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
def load(device: str = "cuda") -> tuple[DecidaEncModel, LayaLayout]:
|
| 175 |
-
"""Download `helmo/DecidaBERT-large` from the Hub and build the model plus its prompt layout."""
|
| 176 |
-
from huggingface_hub import snapshot_download
|
| 177 |
-
|
| 178 |
-
path = snapshot_download(MODEL_REPO)
|
| 179 |
-
model = DecidaEncModel.from_pretrained(path).to(device).eval()
|
| 180 |
-
tokenizer = AutoTokenizer.from_pretrained(os.path.join(path, "tokenizer") if os.path.isdir(os.path.join(path, "tokenizer")) else path)
|
| 181 |
-
# DecidaBERT-large was trained/prompted at a 2142-token context; the checkpoint's own config can override this.
|
| 182 |
-
layout = LayaLayout(tokenizer, max_len=model.cfg.get("max_len", 2142),
|
| 183 |
-
head_max_len=model.cfg.get("head_max_len", 1400), opt_max_tokens=model.cfg.get("opt_max_tokens", 160))
|
| 184 |
-
return model, layout
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
@torch.no_grad()
|
| 188 |
-
def predict(model: DecidaEncModel, layout: LayaLayout, state: str, questions: dict[str, Question]) -> dict[str, dict[str, Any]]:
|
| 189 |
-
"""Answer every question in one batched forward pass. Returns {qid: {type, ...distribution fields}}."""
|
| 190 |
-
items = [{"qid": qid, "encoded": layout.build(state, q), "qtype": model.qtypes_map[q.primitive.value]} for qid, q in questions.items()]
|
| 191 |
-
if not items:
|
| 192 |
-
return {}
|
| 193 |
-
|
| 194 |
-
L = max(len(it["encoded"].input_ids) for it in items)
|
| 195 |
-
kmax = max(it["encoded"].n_options for it in items)
|
| 196 |
-
n = len(items)
|
| 197 |
-
pad_id = layout.tok.pad_token_id or 0
|
| 198 |
-
ids = torch.full((n, L), pad_id, dtype=torch.long)
|
| 199 |
-
att = torch.zeros((n, L), dtype=torch.long)
|
| 200 |
-
mpos = torch.zeros((n, kmax), dtype=torch.long)
|
| 201 |
-
mmask = torch.zeros((n, kmax), dtype=torch.bool)
|
| 202 |
-
qtypes = torch.zeros(n, dtype=torch.long)
|
| 203 |
-
for i, it in enumerate(items):
|
| 204 |
-
enc = it["encoded"]
|
| 205 |
-
ids[i, : len(enc.input_ids)] = enc.input_ids
|
| 206 |
-
att[i, : len(enc.attention_mask)] = enc.attention_mask
|
| 207 |
-
mpos[i, : enc.n_options] = enc.readout_positions
|
| 208 |
-
mmask[i, : enc.n_options] = True
|
| 209 |
-
qtypes[i] = it["qtype"]
|
| 210 |
-
|
| 211 |
-
device = next(model.parameters()).device
|
| 212 |
-
batch = Batch(ids.to(device), att.to(device), mpos.to(device), mmask.to(device), qtypes.to(device))
|
| 213 |
-
logits = model.forward(batch).cpu().numpy()
|
| 214 |
-
|
| 215 |
-
qtypes_inv = {v: k for k, v in model.qtypes_map.items()}
|
| 216 |
-
temp_array = model.cfg.get("temperature", [1.0, 1.0, 1.0])
|
| 217 |
-
temp_by_opt = model.cfg.get("temperature_by_options", {})
|
| 218 |
-
|
| 219 |
-
answers: dict[str, dict[str, Any]] = {}
|
| 220 |
-
for r, it in enumerate(items):
|
| 221 |
-
qid, q, k = it["qid"], questions[it["qid"]], it["encoded"].n_options
|
| 222 |
-
z = logits[r, :k]
|
| 223 |
-
size = "2" if k <= 2 else "3-5" if k <= 5 else "6-10" if k <= 10 else "11+"
|
| 224 |
-
bucket = f"{qtypes_inv[it['qtype']]}:{size}"
|
| 225 |
-
t_scale = temp_by_opt.get(bucket, temp_array[it["qtype"]])
|
| 226 |
-
z = z / max(1e-3, float(t_scale))
|
| 227 |
-
p = np.exp(z - z.max())
|
| 228 |
-
p = p / p.sum()
|
| 229 |
-
|
| 230 |
-
if q.primitive == Primitive.CHOICE:
|
| 231 |
-
keys = list(q.criteria.keys())
|
| 232 |
-
probs = {kk: float(v) for kk, v in zip(keys, p)}
|
| 233 |
-
answers[qid] = {"type": "choice", "choice": keys[int(p.argmax())], "probabilities": probs, "confidence": float(max(probs.values()))}
|
| 234 |
-
elif q.primitive == Primitive.SCORE:
|
| 235 |
-
probs = {str(i): float(v) for i, v in enumerate(p)}
|
| 236 |
-
answers[qid] = {"type": "score", "score": float((np.arange(k) * p).sum()),
|
| 237 |
-
"legend": {str(i): str(c) for i, c in enumerate(q.criteria)}, "probabilities": probs, "confidence": float(max(probs.values()))}
|
| 238 |
-
else:
|
| 239 |
-
noul_prob = float(p[1])
|
| 240 |
-
answers[qid] = {"type": "noul", "noul": noul_prob, "confidence": float(noul_prob if noul_prob > 0.5 else 1 - noul_prob)}
|
| 241 |
-
return answers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -4,4 +4,6 @@ transformers>=5.0
|
|
| 4 |
safetensors>=0.4.0
|
| 5 |
numpy
|
| 6 |
huggingface_hub
|
|
|
|
|
|
|
| 7 |
requests
|
|
|
|
| 4 |
safetensors>=0.4.0
|
| 5 |
numpy
|
| 6 |
huggingface_hub
|
| 7 |
+
pandas
|
| 8 |
+
pyarrow
|
| 9 |
requests
|