qarar 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nomeda/__init__.py +71 -0
- nomeda/blocks.py +112 -0
- nomeda/calibrate.py +165 -0
- nomeda/config.py +103 -0
- nomeda/costs.py +93 -0
- nomeda/data/__init__.py +2 -0
- nomeda/data/batching.py +194 -0
- nomeda/data/family_map.yaml +51 -0
- nomeda/data/loader.py +119 -0
- nomeda/data/variants.py +274 -0
- nomeda/data/weights.py +48 -0
- nomeda/encoder.py +152 -0
- nomeda/features.py +255 -0
- nomeda/loss.py +125 -0
- nomeda/metrics.py +217 -0
- nomeda/model.py +224 -0
- nomeda/partial_cache.py +280 -0
- nomeda/registry.py +170 -0
- nomeda/runtime.py +204 -0
- nomeda/text.py +97 -0
- nomeda/trainer.py +255 -0
- qarar/__init__.py +34 -0
- qarar-1.0.2.dist-info/METADATA +179 -0
- qarar-1.0.2.dist-info/RECORD +26 -0
- qarar-1.0.2.dist-info/WHEEL +5 -0
- qarar-1.0.2.dist-info/top_level.txt +2 -0
nomeda/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""NOMEDA-Decision v1: one Arabic state -> many independent typed decisions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .config import (NomedaConfig, NomedConfig, DEFAULT_CONFIG, k_bucket,
|
|
8
|
+
K_BUCKETS, K_BUCKET_NAMES)
|
|
9
|
+
from .model import NomedaModel, NomedModel
|
|
10
|
+
from .runtime import NomedaRuntime, NomedRuntime, validate_questions
|
|
11
|
+
|
|
12
|
+
__version__ = "1.0.0"
|
|
13
|
+
__all__ = ["load", "Nomeda", "Nomed",
|
|
14
|
+
"NomedaConfig", "NomedaModel", "NomedaRuntime",
|
|
15
|
+
"NomedConfig", "NomedModel", "NomedRuntime",
|
|
16
|
+
"validate_questions", "k_bucket"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Nomeda:
|
|
20
|
+
"""Loaded model + calibration; ``predict`` mirrors the Laya output shape."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, model: NomedaModel, temperatures: dict | None = None,
|
|
23
|
+
thresholds: dict | None = None, device: str = "cpu"):
|
|
24
|
+
self.model = model
|
|
25
|
+
self.encoder_id = model.cfg.encoder_id
|
|
26
|
+
self.encoder_revision = model.cfg.encoder_revision
|
|
27
|
+
self.runtime = NomedaRuntime(model, temperatures or {}, thresholds or {}, device=device)
|
|
28
|
+
|
|
29
|
+
def predict(self, state, questions):
|
|
30
|
+
return self.runtime.predict(state, questions)
|
|
31
|
+
|
|
32
|
+
def predict_batch(self, states, questions):
|
|
33
|
+
return self.runtime.predict_batch(states, questions)
|
|
34
|
+
|
|
35
|
+
def param_report(self) -> dict:
|
|
36
|
+
return self.model.param_report()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load(path_or_hub_id: str, device: str = "auto", tiny: bool = False) -> Nomeda:
|
|
40
|
+
"""Load a NOMEDA-Decision model from a local export directory or a Hub id."""
|
|
41
|
+
if device == "auto":
|
|
42
|
+
import torch
|
|
43
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
44
|
+
if Path(path_or_hub_id).is_dir():
|
|
45
|
+
root = Path(path_or_hub_id)
|
|
46
|
+
raw = json.loads((root / "config.json").read_text(encoding="utf-8"))
|
|
47
|
+
cfg = NomedaConfig(**{k: v for k, v in raw.items() if k in NomedaConfig.__dataclass_fields__})
|
|
48
|
+
enc_dir = root / "encoder"
|
|
49
|
+
model = NomedaModel(cfg, device=device, tiny=tiny)
|
|
50
|
+
if enc_dir.exists():
|
|
51
|
+
from transformers import AutoModel, AutoTokenizer
|
|
52
|
+
model.encoder.tok = AutoTokenizer.from_pretrained(enc_dir)
|
|
53
|
+
model.encoder.model = AutoModel.from_pretrained(enc_dir).to(device)
|
|
54
|
+
head = root / "decision_head.safetensors"
|
|
55
|
+
if head.exists():
|
|
56
|
+
from safetensors.torch import load_file
|
|
57
|
+
state = load_file(str(head))
|
|
58
|
+
model.load_state_dict(state, strict=False)
|
|
59
|
+
temps = json.loads((root / "temperatures.json").read_text(encoding="utf-8")) if (root / "temperatures.json").exists() else {}
|
|
60
|
+
thr = json.loads((root / "thresholds.json").read_text(encoding="utf-8")) if (root / "thresholds.json").exists() else {}
|
|
61
|
+
model.eval()
|
|
62
|
+
model.to(device)
|
|
63
|
+
return Nomeda(model, temps, thr, device=device)
|
|
64
|
+
# Hub id
|
|
65
|
+
from huggingface_hub import snapshot_download
|
|
66
|
+
d = snapshot_download(repo_id=path_or_hub_id)
|
|
67
|
+
return load(d, device=device, tiny=tiny)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---- backward-compatible alias (published as `Nomed` in v0.1-beta) --------
|
|
71
|
+
Nomed = Nomeda
|
nomeda/blocks.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Decision blocks, option-state block and attention pooling (§3.4–3.6)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import torch
|
|
5
|
+
import torch.nn as nn
|
|
6
|
+
import torch.nn.functional as F
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MultiheadAttention(nn.Module):
|
|
10
|
+
def __init__(self, d: int, n_heads: int, dropout: float = 0.0):
|
|
11
|
+
super().__init__()
|
|
12
|
+
assert d % n_heads == 0
|
|
13
|
+
self.d, self.h = d, n_heads
|
|
14
|
+
self.dh = d // n_heads
|
|
15
|
+
self.q = nn.Linear(d, d)
|
|
16
|
+
self.k = nn.Linear(d, d)
|
|
17
|
+
self.v = nn.Linear(d, d)
|
|
18
|
+
self.o = nn.Linear(d, d)
|
|
19
|
+
self.dropout = dropout
|
|
20
|
+
|
|
21
|
+
def forward(self, q, k, v, q_mask=None, k_mask=None):
|
|
22
|
+
B, Lq, _ = q.shape
|
|
23
|
+
Lk = k.shape[1]
|
|
24
|
+
Q = self.q(q).view(B, Lq, self.h, self.dh).transpose(1, 2)
|
|
25
|
+
K = self.k(k).view(B, Lk, self.h, self.dh).transpose(1, 2)
|
|
26
|
+
V = self.v(v).view(B, Lk, self.h, self.dh).transpose(1, 2)
|
|
27
|
+
attn_mask = None
|
|
28
|
+
if k_mask is not None:
|
|
29
|
+
attn_mask = torch.zeros(B, 1, 1, Lk, device=q.device, dtype=Q.dtype)
|
|
30
|
+
attn_mask = attn_mask.masked_fill(~k_mask[:, None, None, :].bool(), -1e4)
|
|
31
|
+
out = F.scaled_dot_product_attention(
|
|
32
|
+
Q, K, V, attn_mask=attn_mask,
|
|
33
|
+
dropout_p=self.dropout if self.training else 0.0)
|
|
34
|
+
out = out.transpose(1, 2).reshape(B, Lq, self.d)
|
|
35
|
+
return self.o(out)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class FFN(nn.Module):
|
|
39
|
+
def __init__(self, d: int, mult: int, dropout: float):
|
|
40
|
+
super().__init__()
|
|
41
|
+
self.net = nn.Sequential(nn.Linear(d, d * mult), nn.GELU(), nn.Dropout(dropout), nn.Linear(d * mult, d))
|
|
42
|
+
|
|
43
|
+
def forward(self, x):
|
|
44
|
+
return self.net(x)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class DecisionBlock(nn.Module):
|
|
48
|
+
"""Self-attn within a question -> cross-attn to its own state -> FFN (pre-norm)."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, d, n_heads, mult, dropout):
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.ln1, self.ln2, self.ln3 = nn.LayerNorm(d), nn.LayerNorm(d), nn.LayerNorm(d)
|
|
53
|
+
self.self_attn = MultiheadAttention(d, n_heads, dropout)
|
|
54
|
+
self.cross_attn = MultiheadAttention(d, n_heads, dropout)
|
|
55
|
+
self.ffn = FFN(d, mult, dropout)
|
|
56
|
+
|
|
57
|
+
def forward(self, Q, q_mask, S_pad, S_mask, state_idx):
|
|
58
|
+
Q = Q + self.self_attn(self.ln1(Q), self.ln1(Q), self.ln1(Q), q_mask=q_mask, k_mask=q_mask)
|
|
59
|
+
# cross-attention grouped by state: gather each question's own state (vectorised).
|
|
60
|
+
# Equivalent to a per-state loop but without one kernel launch + sync per state.
|
|
61
|
+
x = self.ln2(Q)
|
|
62
|
+
k = S_pad.index_select(0, state_idx)
|
|
63
|
+
kmask = S_mask.index_select(0, state_idx)
|
|
64
|
+
Q = Q + self.cross_attn(x, k, k, k_mask=kmask)
|
|
65
|
+
Q = Q + self.ffn(self.ln3(Q))
|
|
66
|
+
return Q
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OptionStateBlock(nn.Module):
|
|
70
|
+
def __init__(self, d, n_heads, mult, dropout):
|
|
71
|
+
super().__init__()
|
|
72
|
+
self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d)
|
|
73
|
+
self.cross_attn = MultiheadAttention(d, n_heads, dropout)
|
|
74
|
+
self.ffn = FFN(d, mult, dropout)
|
|
75
|
+
|
|
76
|
+
def forward(self, E, S_pad, S_mask, state_idx):
|
|
77
|
+
x = self.ln1(E).unsqueeze(1) # [Nopt,1,d]
|
|
78
|
+
k = S_pad.index_select(0, state_idx)
|
|
79
|
+
kmask = S_mask.index_select(0, state_idx)
|
|
80
|
+
out = self.cross_attn(x, k, k, k_mask=kmask)
|
|
81
|
+
E = E + out.squeeze(1)
|
|
82
|
+
E = E + self.ffn(self.ln2(E))
|
|
83
|
+
return E
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class AttnPool(nn.Module):
|
|
87
|
+
def __init__(self, d):
|
|
88
|
+
super().__init__()
|
|
89
|
+
self.query = nn.Parameter(torch.randn(d) * 0.02)
|
|
90
|
+
self.ln = nn.LayerNorm(d)
|
|
91
|
+
|
|
92
|
+
def forward(self, Q, mask):
|
|
93
|
+
attn = (Q @ self.query) / (Q.shape[-1] ** 0.5) # [B,L]
|
|
94
|
+
attn = attn.masked_fill(~mask.bool(), -1e4)
|
|
95
|
+
p = torch.softmax(attn, dim=-1).unsqueeze(1)
|
|
96
|
+
pooled = torch.bmm(p, Q).squeeze(1)
|
|
97
|
+
return self.ln(pooled)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class CompatibilityScorer(nn.Module):
|
|
101
|
+
def __init__(self, d, dropout):
|
|
102
|
+
super().__init__()
|
|
103
|
+
self.ln = nn.LayerNorm(4 * d)
|
|
104
|
+
self.w1 = nn.Linear(4 * d, d)
|
|
105
|
+
self.w2 = nn.Linear(d, 1)
|
|
106
|
+
self.dropout = nn.Dropout(dropout)
|
|
107
|
+
|
|
108
|
+
def forward(self, q, E):
|
|
109
|
+
h = torch.cat([q, E, q * E, (q - E).abs()], dim=-1)
|
|
110
|
+
h = self.ln(h)
|
|
111
|
+
h = self.dropout(F.gelu(self.w1(h)))
|
|
112
|
+
return self.w2(h).squeeze(-1)
|
nomeda/calibrate.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Calibration: temperatures per (type, K-bucket) and abstention thresholds (§6)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import torch
|
|
11
|
+
import torch.nn.functional as F
|
|
12
|
+
|
|
13
|
+
from .config import k_bucket
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def ece(probs: List[float], correct: List[int], n_bins: int = 15) -> float:
|
|
17
|
+
"""Expected calibration error, confidence = max(p), equal-width bins (Laya's definition)."""
|
|
18
|
+
if not probs:
|
|
19
|
+
return 0.0
|
|
20
|
+
total = len(probs)
|
|
21
|
+
out = 0.0
|
|
22
|
+
for b in range(n_bins):
|
|
23
|
+
lo, hi = b / n_bins, (b + 1) / n_bins
|
|
24
|
+
idx = [i for i, p in enumerate(probs) if (p > lo or (b == 0 and p >= lo)) and p <= hi]
|
|
25
|
+
if not idx:
|
|
26
|
+
continue
|
|
27
|
+
conf = sum(probs[i] for i in idx) / len(idx)
|
|
28
|
+
acc = sum(correct[i] for i in idx) / len(idx)
|
|
29
|
+
out += (len(idx) / total) * abs(conf - acc)
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fit_temperature(logits_list, targets_list, clamp: Tuple[float, float] = (0.5, 5.0)) -> float:
|
|
34
|
+
"""Minimise NLL over a single temperature; logits_list holds per-item 1-D tensors
|
|
35
|
+
(K may vary between items)."""
|
|
36
|
+
logT = torch.zeros(1, requires_grad=True)
|
|
37
|
+
opt = torch.optim.LBFGS([logT], lr=0.1, max_iter=50)
|
|
38
|
+
tg = [int(t) for t in targets_list]
|
|
39
|
+
|
|
40
|
+
def closure():
|
|
41
|
+
opt.zero_grad()
|
|
42
|
+
T = torch.exp(logT).clamp(*clamp)
|
|
43
|
+
loss = 0.0
|
|
44
|
+
for l, t in zip(logits_list, tg):
|
|
45
|
+
loss = loss + F.cross_entropy((l / T).unsqueeze(0).float(), torch.tensor([t]))
|
|
46
|
+
loss = loss / max(1, len(logits_list))
|
|
47
|
+
loss.backward()
|
|
48
|
+
return loss
|
|
49
|
+
|
|
50
|
+
opt.step(closure)
|
|
51
|
+
with torch.no_grad():
|
|
52
|
+
return float(torch.exp(logT).clamp(*clamp))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def fit_temperatures(buckets: Dict[Tuple[str, str], Tuple[list, list]],
|
|
56
|
+
min_items: int = 200) -> dict:
|
|
57
|
+
"""buckets: {(type, K-bucket): ([logits_1d...], [targets...])} -> temperatures.json dict."""
|
|
58
|
+
out: Dict[str, Dict[str, float]] = {}
|
|
59
|
+
per_type: Dict[str, Tuple[list, list]] = {}
|
|
60
|
+
for (qtype, kb), (logits, targets) in buckets.items():
|
|
61
|
+
per_type.setdefault(qtype, ([], []))
|
|
62
|
+
per_type[qtype][0].extend(logits)
|
|
63
|
+
per_type[qtype][1].extend(targets)
|
|
64
|
+
if len(logits) < min_items:
|
|
65
|
+
continue
|
|
66
|
+
out.setdefault(qtype, {})[kb] = fit_temperature(logits, targets)
|
|
67
|
+
for qtype, (ls, ts) in per_type.items():
|
|
68
|
+
out.setdefault(qtype, {})["default"] = fit_temperature(ls, ts)
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fit_abstention(conf: List[float], correct: List[int], target_acc: float = 0.95) -> float:
|
|
73
|
+
"""Smallest threshold so answered questions reach accuracy >= target_acc."""
|
|
74
|
+
pairs = sorted(zip(conf, correct), reverse=True)
|
|
75
|
+
if not pairs:
|
|
76
|
+
return 0.0
|
|
77
|
+
best_t = 1.0
|
|
78
|
+
n_ok = 0
|
|
79
|
+
for i, (c, ok) in enumerate(pairs):
|
|
80
|
+
n_ok += ok
|
|
81
|
+
acc = n_ok / (i + 1)
|
|
82
|
+
if acc >= target_acc:
|
|
83
|
+
best_t = c
|
|
84
|
+
return float(best_t)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def coverage(conf: List[float], threshold: float) -> float:
|
|
88
|
+
if not conf:
|
|
89
|
+
return 0.0
|
|
90
|
+
return sum(1 for c in conf if c >= threshold) / len(conf)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def save(path: str, obj: dict) -> None:
|
|
94
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
95
|
+
json.dump(obj, f, ensure_ascii=False, indent=2)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ---- fair calibrated comparison (cal_cv, §0.4) ---------------------------
|
|
99
|
+
def group_of(record_id, view_group) -> str:
|
|
100
|
+
"""Fold-group id: view_group and V2 en↔ar pairs stay together."""
|
|
101
|
+
if view_group:
|
|
102
|
+
return f"vg:{view_group}"
|
|
103
|
+
rid = str(record_id)
|
|
104
|
+
if ":en:" in rid or ":ar:" in rid:
|
|
105
|
+
return "pair:" + rid.replace(":en:", ":*:").replace(":ar:", ":*:")
|
|
106
|
+
return f"solo:{rid}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _fold_of(group_id: str, folds: int) -> int:
|
|
110
|
+
h = int(hashlib.sha1(str(group_id).encode()).hexdigest()[:8], 16)
|
|
111
|
+
return h % folds
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _fit_temps_from_items(items: List[dict], min_items: int,
|
|
115
|
+
clamp: Tuple[float, float] = (0.5, 5.0)) -> dict:
|
|
116
|
+
buckets: Dict[Tuple[str, str], Tuple[list, list]] = defaultdict(lambda: ([], []))
|
|
117
|
+
per_type: Dict[str, Tuple[list, list]] = defaultdict(lambda: ([], []))
|
|
118
|
+
for it in items:
|
|
119
|
+
kb = it["kb"]
|
|
120
|
+
buckets[(it["type"], kb)][0].append(it["logits"])
|
|
121
|
+
buckets[(it["type"], kb)][1].append(it["idx"])
|
|
122
|
+
per_type[it["type"]][0].append(it["logits"])
|
|
123
|
+
per_type[it["type"]][1].append(it["idx"])
|
|
124
|
+
out: Dict[str, dict] = {}
|
|
125
|
+
for (qt, kb), (L, T) in buckets.items():
|
|
126
|
+
if len(L) >= min_items:
|
|
127
|
+
out.setdefault(qt, {})[kb] = fit_temperature(L, T, clamp)
|
|
128
|
+
for qt, (L, T) in per_type.items():
|
|
129
|
+
out.setdefault(qt, {})["default"] = fit_temperature(L, T, clamp)
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _score_items(items: List[dict], temps: dict) -> Tuple[float, float, List[float], List[int]]:
|
|
134
|
+
nll = 0.0
|
|
135
|
+
confs, oks = [], []
|
|
136
|
+
for it in items:
|
|
137
|
+
T = float(temps.get(it["type"], {}).get(it["kb"], temps.get(it["type"], {}).get("default", 1.0)))
|
|
138
|
+
p = torch.softmax(it["logits"] / T, dim=-1)
|
|
139
|
+
nll += -math.log(max(float(p[it["idx"]]), 1e-12))
|
|
140
|
+
confs.append(float(p.max()))
|
|
141
|
+
oks.append(int(int(torch.argmax(p)) == it["idx"]))
|
|
142
|
+
return nll / max(1, len(items)), ece(confs, oks), confs, oks
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def cal_cv_from_rows(rows: List[dict], folds: int = 2, min_items: int = 100) -> dict:
|
|
146
|
+
"""Fit on fold A / score on B then swap; average NLL and ECE (§0.4)."""
|
|
147
|
+
items = []
|
|
148
|
+
for r in rows:
|
|
149
|
+
if "logits" not in r or "labels" not in r:
|
|
150
|
+
continue
|
|
151
|
+
items.append({
|
|
152
|
+
"group": group_of(r.get("record_id"), r.get("view_group")),
|
|
153
|
+
"type": r["type"], "kb": k_bucket(len(r["labels"])),
|
|
154
|
+
"logits": torch.tensor(r["logits"]), "idx": r["labels"].index(r["gold"]),
|
|
155
|
+
})
|
|
156
|
+
fold_of = [_fold_of(it["group"], folds) for it in items]
|
|
157
|
+
nlls, eces = [], []
|
|
158
|
+
for f in range(folds):
|
|
159
|
+
fit = [it for it, ff in zip(items, fold_of) if ff != f]
|
|
160
|
+
ev = [it for it, ff in zip(items, fold_of) if ff == f]
|
|
161
|
+
temps = _fit_temps_from_items(fit, min_items)
|
|
162
|
+
n, e, _, _ = _score_items(ev, temps)
|
|
163
|
+
nlls.append(n); eces.append(e)
|
|
164
|
+
return {"nll_cal_cv": round(sum(nlls) / folds, 4),
|
|
165
|
+
"ece_cal_cv": round(sum(eces) / folds, 4)}
|
nomeda/config.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""NOMEDA-Decision v1 configuration.
|
|
2
|
+
|
|
3
|
+
All hyper-parameters live here and are loaded from YAML. Defaults match the
|
|
4
|
+
implementation spec.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field, asdict
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
ENCODER_ID = "silma-ai/silma-embedding-matryoshka-v0.1"
|
|
15
|
+
# Pinned revision (verified, see docs/VERIFIED.md)
|
|
16
|
+
ENCODER_REVISION = "011e3b07c9ae2e38f2312a237b489b3b0a640307"
|
|
17
|
+
|
|
18
|
+
K_BUCKETS = [(2, 2), (3, 5), (6, 10), (11, 10**9)]
|
|
19
|
+
K_BUCKET_NAMES = ["2", "3-5", "6-10", "11+"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def k_bucket(k: int) -> str:
|
|
23
|
+
for (lo, hi), name in zip(K_BUCKETS, K_BUCKET_NAMES):
|
|
24
|
+
if lo <= k <= hi:
|
|
25
|
+
return name
|
|
26
|
+
raise ValueError(k)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class NomedaConfig:
|
|
31
|
+
# encoder
|
|
32
|
+
encoder_id: str = ENCODER_ID
|
|
33
|
+
encoder_revision: str = ENCODER_REVISION
|
|
34
|
+
encoder_dim: int = 768
|
|
35
|
+
encoder_layers: int = 12
|
|
36
|
+
encoder_max_len: int = 512
|
|
37
|
+
encoder_vocab: int = 64000
|
|
38
|
+
|
|
39
|
+
# decision layer
|
|
40
|
+
d_model: int = 384
|
|
41
|
+
n_heads: int = 6
|
|
42
|
+
ffn_mult: int = 4
|
|
43
|
+
n_decision_blocks: int = 4
|
|
44
|
+
dropout: float = 0.1
|
|
45
|
+
option_state_xattn: bool = True
|
|
46
|
+
|
|
47
|
+
# state chunking
|
|
48
|
+
state_max_len: int = 512
|
|
49
|
+
state_chunk_stride: int = 384
|
|
50
|
+
state_max_chunks: int = 8
|
|
51
|
+
|
|
52
|
+
# text
|
|
53
|
+
text_max_len: int = 128
|
|
54
|
+
n_rank_levels: int = 32
|
|
55
|
+
q_bucket: int = 128 # questions per length-bucketed sub-batch (padding control)
|
|
56
|
+
|
|
57
|
+
# freezing
|
|
58
|
+
freeze_encoder: bool = True
|
|
59
|
+
unfreeze_top_layers: int = 0
|
|
60
|
+
# Optional GRADUAL unfreeze. When non-empty, e.g. [0, 2, 4, 6], the number of
|
|
61
|
+
# trainable top encoder layers is re-set at the start of each epoch to the
|
|
62
|
+
# given value (last value repeats if epochs exceed the list). Empty = fixed
|
|
63
|
+
# `unfreeze_top_layers` for the whole run. Only used as an escalation after
|
|
64
|
+
# fixed unfreezing fails to earn its place (plan C4.4).
|
|
65
|
+
unfreeze_schedule: List[int] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
# loss
|
|
68
|
+
label_smoothing: float = 0.02
|
|
69
|
+
w_spherical: float = 0.5
|
|
70
|
+
w_rps: float = 1.0
|
|
71
|
+
lambda_kl: float = 0.0
|
|
72
|
+
kl_en_ar_only: bool = True # restrict consistency KL to V2 en<->ar pairs
|
|
73
|
+
|
|
74
|
+
# training
|
|
75
|
+
lr_decision: float = 3e-4
|
|
76
|
+
lr_encoder: float = 2e-5
|
|
77
|
+
weight_decay: float = 0.01
|
|
78
|
+
warmup_frac: float = 0.03
|
|
79
|
+
grad_clip: float = 1.0
|
|
80
|
+
bf16: bool = True
|
|
81
|
+
seed: int = 13
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_yaml(cls, path: str | Path) -> "NomedaConfig":
|
|
85
|
+
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
|
86
|
+
known = {f for f in cls.__dataclass_fields__}
|
|
87
|
+
unknown = set(data) - known
|
|
88
|
+
if unknown:
|
|
89
|
+
raise ValueError(f"unknown config keys: {sorted(unknown)}")
|
|
90
|
+
return cls(**data)
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> dict:
|
|
93
|
+
return asdict(self)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
DEFAULT_CONFIG = NomedaConfig()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---- backward-compatible aliases -----------------------------------------
|
|
100
|
+
# The class was originally published as `NomedConfig` in NOMEDA-Decision
|
|
101
|
+
# v0.1-beta. Keep the old name working so existing code and the published model
|
|
102
|
+
# card do not break; new code should use `NomedaConfig`.
|
|
103
|
+
NomedConfig = NomedaConfig
|
nomeda/costs.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Cost-aware decisions (§Stage 7, step 4a).
|
|
2
|
+
|
|
3
|
+
Given calibrated probabilities p(y) over the supplied options and a cost matrix
|
|
4
|
+
C[action, y], the Bayes-optimal single-step action is
|
|
5
|
+
|
|
6
|
+
a* = argmin_a Σ_y p(y) · C[a, y]
|
|
7
|
+
|
|
8
|
+
This is exact when the probabilities are calibrated, needs no training, and is the
|
|
9
|
+
baseline that the RL policy (4b-4d) must beat.
|
|
10
|
+
|
|
11
|
+
Cost design is an owner product decision: matrices live in ``costs/*.yaml``, are versioned,
|
|
12
|
+
and carry the reason for each number. Nothing here ships a default matrix silently.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
19
|
+
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class CostMatrix:
|
|
25
|
+
"""Actions x outcomes cost matrix for one question.
|
|
26
|
+
|
|
27
|
+
actions: ordered action ids.
|
|
28
|
+
matrix: {action: {option_label: cost}}; every action must price every option.
|
|
29
|
+
"""
|
|
30
|
+
actions: List[str]
|
|
31
|
+
matrix: Dict[str, Dict[str, float]]
|
|
32
|
+
version: str = "unknown"
|
|
33
|
+
notes: str = ""
|
|
34
|
+
|
|
35
|
+
def validate(self, option_labels: Sequence[str]) -> None:
|
|
36
|
+
if not self.actions:
|
|
37
|
+
raise ValueError("cost matrix has no actions")
|
|
38
|
+
for a in self.actions:
|
|
39
|
+
if a not in self.matrix:
|
|
40
|
+
raise ValueError(f"cost matrix missing action {a!r}")
|
|
41
|
+
row = self.matrix[a]
|
|
42
|
+
missing = [lab for lab in option_labels if lab not in row]
|
|
43
|
+
if missing:
|
|
44
|
+
raise ValueError(f"action {a!r} does not price options {missing}")
|
|
45
|
+
for lab in option_labels:
|
|
46
|
+
c = row[lab]
|
|
47
|
+
if not isinstance(c, (int, float)) or c != c:
|
|
48
|
+
raise ValueError(f"cost for {a!r}/{lab!r} is not a number: {c!r}")
|
|
49
|
+
if c < 0:
|
|
50
|
+
raise ValueError(f"cost for {a!r}/{lab!r} is negative: {c}")
|
|
51
|
+
|
|
52
|
+
def as_features(self, option_labels: Sequence[str]) -> List[List[float]]:
|
|
53
|
+
"""Matrix rows in canonical option order (for the policy head)."""
|
|
54
|
+
return [[float(self.matrix[a][lab]) for lab in option_labels] for a in self.actions]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def expected_cost(probs: Sequence[float], costs: Sequence[float]) -> float:
|
|
58
|
+
"""Σ_y p(y) · C[a, y] for one action."""
|
|
59
|
+
if len(probs) != len(costs):
|
|
60
|
+
raise ValueError(f"length mismatch: {len(probs)} probs vs {len(costs)} costs")
|
|
61
|
+
return float(sum(p * c for p, c in zip(probs, costs)))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def bayes_action(probs: Sequence[float], labels: Sequence[str],
|
|
65
|
+
cost: CostMatrix) -> Tuple[str, float, Dict[str, float]]:
|
|
66
|
+
"""Return (best_action, its expected cost, {action: expected cost})."""
|
|
67
|
+
cost.validate(labels)
|
|
68
|
+
ec = {a: expected_cost(probs, [cost.matrix[a][lab] for lab in labels]) for a in cost.actions}
|
|
69
|
+
best = min(cost.actions, key=lambda a: (ec[a], a)) # lexicographic tie-break
|
|
70
|
+
return best, ec[best], ec
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_costs(path: str | Path, option_labels: Sequence[str]) -> CostMatrix:
|
|
74
|
+
"""Load and validate a cost matrix from YAML for a given option list."""
|
|
75
|
+
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
|
76
|
+
cm = CostMatrix(actions=list(data.get("actions", [])),
|
|
77
|
+
matrix=data.get("matrix", {}),
|
|
78
|
+
version=str(data.get("version", "unknown")),
|
|
79
|
+
notes=str(data.get("notes", "")))
|
|
80
|
+
cm.validate(option_labels)
|
|
81
|
+
return cm
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def apply_costs(answer: dict, cost: CostMatrix, labels: Sequence[str]) -> dict:
|
|
85
|
+
"""Attach `action`, `expected_cost` and per-action expected costs to a runtime answer."""
|
|
86
|
+
probs = [float(answer["probabilities"][lab]) for lab in labels]
|
|
87
|
+
best, best_ec, ec = bayes_action(probs, labels, cost)
|
|
88
|
+
out = dict(answer)
|
|
89
|
+
out["action"] = best
|
|
90
|
+
out["expected_cost"] = round(best_ec, 6)
|
|
91
|
+
out["expected_cost_by_action"] = {a: round(v, 6) for a, v in ec.items()}
|
|
92
|
+
out["cost_version"] = cost.version
|
|
93
|
+
return out
|
nomeda/data/__init__.py
ADDED