File size: 11,137 Bytes
a2a5f1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
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
232
233
234
235
236
237
238
239
240
241
242
"""A standalone port of DecidaBERT-large's inference (the decision heads and the Laya-format prompt layout).

This intentionally does not import the `decida` package: it is independent code for a Gradio Space, reading only
the public checkpoint at https://huggingface.co/helmo/DecidaBERT-large. The algorithm matches
`src/decida/model/enc.py` in the main project, ported here because ZeroGPU Spaces only support the Gradio SDK, not
a plain server process like `decida serve`. If the real project's encoder logic changes, this file needs updating
by hand; it is not generated from it.
"""
from __future__ import annotations

import json
import os
from dataclasses import dataclass
from enum import Enum
from typing import Any

import numpy as np
import torch
from safetensors.torch import load_file
from torch import nn
from transformers import AutoConfig, AutoModel, AutoTokenizer

MODEL_REPO = "helmo/DecidaBERT-large"


class Primitive(str, Enum):
    CHOICE = "choice"
    SCORE = "score"
    NOUL = "noul"


@dataclass
class Question:
    qid: str
    primitive: Primitive
    instructions: str
    criteria: Any


@dataclass
class Encoded:
    input_ids: torch.Tensor
    attention_mask: torch.Tensor
    readout_positions: torch.Tensor
    n_options: int
    truncated: bool


@dataclass
class Batch:
    input_ids: torch.Tensor
    attention_mask: torch.Tensor
    readout_positions: torch.Tensor
    marker_mask: torch.Tensor
    qtypes: torch.Tensor


def _render_criterion(value: Any) -> str:
    if isinstance(value, str):
        return value
    return json.dumps(value, ensure_ascii=False, separators=(", ", ": "), default=str)


def _render_options(q: Question) -> list[str]:
    if q.primitive == Primitive.CHOICE:
        return [k if not v else f"{k}: {_render_criterion(v)}" for k, v in q.criteria.items()]
    if q.primitive == Primitive.SCORE:
        return [f"level {i}: {_render_criterion(c)}" for i, c in enumerate(q.criteria)]
    crit = q.criteria or {}
    false_str = _render_criterion(crit.get("false")) if crit.get("false") else "no, the statement does not hold"
    true_str = _render_criterion(crit.get("true")) if crit.get("true") else "yes, the statement holds"
    return [f"false: {false_str}", f"true: {true_str}"]


class LayaLayout:
    """Builds one [CLS] instructions [SEP] <mask>opt1 <mask>opt2 ... [SEP] state [SEP] sequence per question."""

    def __init__(self, tokenizer, max_len: int = 2142, head_max_len: int = 1400, opt_max_tokens: int = 160):
        self.tok, self.max_len, self.head_max_len, self.opt_max_tokens = tokenizer, max_len, head_max_len, opt_max_tokens

    def build(self, state: str, q: Question) -> Encoded:
        mask_tok = self.tok.mask_token
        opts = _render_options(q)
        ins = q.instructions if isinstance(q.instructions, str) else json.dumps(q.instructions, ensure_ascii=False)
        if mask_tok:
            ins = ins.replace(mask_tok, " ")
        head_ids = self.tok(f"{q.primitive.value} question: {ins}", add_special_tokens=False)["input_ids"]

        opt_ids = []
        for o in opts:
            text_ids = self.tok(" " + o.replace(mask_tok, " "), add_special_tokens=False)["input_ids"]
            opt_ids.append([self.tok.mask_token_id] + text_ids[: self.opt_max_tokens])

        opt_budget = self.head_max_len - sum(len(o) for o in opt_ids)
        if opt_budget < 16:
            per = max(4, (self.head_max_len - 16) // max(1, len(opt_ids)))
            opt_ids = [o[:per] for o in opt_ids]
            opt_budget = self.head_max_len - sum(len(o) for o in opt_ids)

        head_ids = head_ids[: max(8, opt_budget)]
        ids = [self.tok.cls_token_id] + head_ids + [self.tok.sep_token_id]
        markers = []
        for o in opt_ids:
            markers.append(len(ids))
            ids.extend(o)
        ids.append(self.tok.sep_token_id)

        room = max(0, self.max_len - len(ids) - 1)
        state_str = json.dumps(state, ensure_ascii=False) if isinstance(state, (dict, list)) else str(state)
        st = self.tok(state_str.replace(mask_tok, " "), add_special_tokens=False)["input_ids"]
        truncated = len(st) > room
        ids = ids + st[:room] + [self.tok.sep_token_id]

        final_ids = ids[: self.max_len]
        valid_markers = [m for m in markers if m < self.max_len]
        return Encoded(
            input_ids=torch.tensor(final_ids, dtype=torch.long),
            attention_mask=torch.ones(len(final_ids), dtype=torch.long),
            readout_positions=torch.tensor(valid_markers, dtype=torch.long),
            n_options=len(opts),
            truncated=truncated,
        )


class DecidaEncModel(nn.Module):
    """The decision heads on top of the ModernBERT backbone: a type embedding, an optional small transformer
    head, and a scorer that reads one logit out of each option's <mask> position."""

    def __init__(self, encoder: nn.Module, head_layers: int = 2, n_act: int = 2, cfg: dict[str, Any] | None = None):
        super().__init__()
        self.encoder, self.cfg = encoder, cfg or {}
        d = int(encoder.config.hidden_size)
        layer = nn.TransformerEncoderLayer(d_model=d, nhead=max(1, d // 64), dim_feedforward=4 * d, dropout=0.1, batch_first=True, norm_first=True)
        self.head = nn.TransformerEncoder(layer, head_layers, enable_nested_tensor=False) if head_layers > 0 else None
        self.type_emb = nn.Embedding(3, d)
        self.scorer = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, d), nn.GELU(approximate="none"), nn.Linear(d, 1))
        self.act_head = nn.Sequential(nn.Linear(d + 4, 256), nn.GELU(approximate="none"), nn.Linear(256, n_act))
        self.qtypes_map = {"choice": 0, "score": 1, "noul": 2}

    def forward(self, batch: Batch) -> torch.Tensor:
        h = self.encoder(input_ids=batch.input_ids, attention_mask=batch.attention_mask).last_hidden_state
        h = h + self.type_emb(batch.qtypes)[:, None, :]
        if self.head is not None:
            pad = ~batch.attention_mask.bool()
            for layer in self.head.layers:
                h = layer(h, src_key_padding_mask=pad)
        idx = batch.readout_positions.clamp(min=0)[:, :, None].expand(-1, -1, h.size(-1))
        logits = self.scorer(torch.gather(h, 1, idx)).squeeze(-1).float()
        return logits.masked_fill(~batch.marker_mask, float("-inf"))

    @classmethod
    def from_pretrained(cls, path: str) -> DecidaEncModel:
        weights = load_file(os.path.join(path, "model.safetensors"))
        cfg_path = os.path.join(path, "rl_agent_config.json")
        cfg = json.load(open(cfg_path)) if os.path.exists(cfg_path) else {}
        ecfg = AutoConfig.from_pretrained(os.path.join(path, "encoder"))
        enc = AutoModel.from_config(ecfg, attn_implementation="sdpa")

        act_weight = weights.get("act_head.2.weight", weights.get("core_model.act_head.2.weight"))
        if act_weight is None:
            raise ValueError(f"{path} has no act_head.2.weight: not a Decida or Laya encoder checkpoint")
        n_act, head_layers, new_weights = act_weight.shape[0], 0, {}
        for k, v in weights.items():
            k = k.removeprefix("core_model.")
            new_weights[k] = v
            if k.startswith("head.layers."):
                head_layers = max(head_layers, int(k.split(".")[2]) + 1)

        model = cls(enc, head_layers=head_layers, n_act=n_act, cfg=cfg)
        model.load_state_dict(new_weights, strict=False)
        return model


def load(device: str = "cuda") -> tuple[DecidaEncModel, LayaLayout]:
    """Download `helmo/DecidaBERT-large` from the Hub and build the model plus its prompt layout."""
    from huggingface_hub import snapshot_download

    path = snapshot_download(MODEL_REPO)
    model = DecidaEncModel.from_pretrained(path).to(device).eval()
    tokenizer = AutoTokenizer.from_pretrained(os.path.join(path, "tokenizer") if os.path.isdir(os.path.join(path, "tokenizer")) else path)
    # DecidaBERT-large was trained/prompted at a 2142-token context; the checkpoint's own config can override this.
    layout = LayaLayout(tokenizer, max_len=model.cfg.get("max_len", 2142),
                         head_max_len=model.cfg.get("head_max_len", 1400), opt_max_tokens=model.cfg.get("opt_max_tokens", 160))
    return model, layout


@torch.no_grad()
def predict(model: DecidaEncModel, layout: LayaLayout, state: str, questions: dict[str, Question]) -> dict[str, dict[str, Any]]:
    """Answer every question in one batched forward pass. Returns {qid: {type, ...distribution fields}}."""
    items = [{"qid": qid, "encoded": layout.build(state, q), "qtype": model.qtypes_map[q.primitive.value]} for qid, q in questions.items()]
    if not items:
        return {}

    L = max(len(it["encoded"].input_ids) for it in items)
    kmax = max(it["encoded"].n_options for it in items)
    n = len(items)
    pad_id = layout.tok.pad_token_id or 0
    ids = torch.full((n, L), pad_id, dtype=torch.long)
    att = torch.zeros((n, L), dtype=torch.long)
    mpos = torch.zeros((n, kmax), dtype=torch.long)
    mmask = torch.zeros((n, kmax), dtype=torch.bool)
    qtypes = torch.zeros(n, dtype=torch.long)
    for i, it in enumerate(items):
        enc = it["encoded"]
        ids[i, : len(enc.input_ids)] = enc.input_ids
        att[i, : len(enc.attention_mask)] = enc.attention_mask
        mpos[i, : enc.n_options] = enc.readout_positions
        mmask[i, : enc.n_options] = True
        qtypes[i] = it["qtype"]

    device = next(model.parameters()).device
    batch = Batch(ids.to(device), att.to(device), mpos.to(device), mmask.to(device), qtypes.to(device))
    logits = model.forward(batch).cpu().numpy()

    qtypes_inv = {v: k for k, v in model.qtypes_map.items()}
    temp_array = model.cfg.get("temperature", [1.0, 1.0, 1.0])
    temp_by_opt = model.cfg.get("temperature_by_options", {})

    answers: dict[str, dict[str, Any]] = {}
    for r, it in enumerate(items):
        qid, q, k = it["qid"], questions[it["qid"]], it["encoded"].n_options
        z = logits[r, :k]
        size = "2" if k <= 2 else "3-5" if k <= 5 else "6-10" if k <= 10 else "11+"
        bucket = f"{qtypes_inv[it['qtype']]}:{size}"
        t_scale = temp_by_opt.get(bucket, temp_array[it["qtype"]])
        z = z / max(1e-3, float(t_scale))
        p = np.exp(z - z.max())
        p = p / p.sum()

        if q.primitive == Primitive.CHOICE:
            keys = list(q.criteria.keys())
            probs = {kk: float(v) for kk, v in zip(keys, p)}
            answers[qid] = {"type": "choice", "choice": keys[int(p.argmax())], "probabilities": probs, "confidence": float(max(probs.values()))}
        elif q.primitive == Primitive.SCORE:
            probs = {str(i): float(v) for i, v in enumerate(p)}
            answers[qid] = {"type": "score", "score": float((np.arange(k) * p).sum()),
                             "legend": {str(i): str(c) for i, c in enumerate(q.criteria)}, "probabilities": probs, "confidence": float(max(probs.values()))}
        else:
            noul_prob = float(p[1])
            answers[qid] = {"type": "noul", "noul": noul_prob, "confidence": float(noul_prob if noul_prob > 0.5 else 1 - noul_prob)}
    return answers