mobarmg commited on
Commit
fd858db
·
verified ·
1 Parent(s): 2cb0a92

Upload scorer

Browse files
Files changed (5) hide show
  1. README.md +157 -0
  2. config.json +44 -0
  3. schema_scorer.py +337 -0
  4. tokenizer.json +0 -0
  5. tokenizer_config.json +31 -0
README.md ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language: en
3
+ license: mit
4
+ library_name: transformers
5
+ base_model: microsoft/deberta-v3-large
6
+ pipeline_tag: text-classification
7
+ inference: false
8
+ tags:
9
+ - deberta-v3
10
+ - schema-conditioned
11
+ - candidate-scoring
12
+ - zero-shot-classification
13
+ - structured-output
14
+ ---
15
+
16
+ # Schema-conditioned candidate scorer (DeBERTa-v3-large)
17
+
18
+ One DeBERTa-v3-large encoder with a **single scalar head** scores `(state, question + candidate)` pairs.
19
+ Deterministic code groups the scalar logits per question and decodes them into three answer primitives:
20
+
21
+ | primitive | input schema | answer |
22
+ |---|---|---|
23
+ | `choice` | `criteria: {option_id: description}` | argmax option id + probabilities |
24
+ | `noul` | optional `criteria: {"true": ..., "false": ...}` | p(proposition is true) |
25
+ | `score` | `criteria: [level 0 description, level 1, ...]` | expected level index + per-level probabilities |
26
+
27
+ The question text, criteria and option ids are **read at inference time**, never baked into the weights,
28
+ so the same checkpoint answers new questions over new label sets without retraining.
29
+
30
+ Try it in the Space: **[mobarmg/jev-schema-scorer](https://huggingface.co/spaces/mobarmg/jev-schema-scorer)**.
31
+
32
+ ## How it works
33
+
34
+ For every candidate of a question the model sees a sentence pair:
35
+
36
+ ```
37
+ sequence_a = the state (free text, or a JSON object serialised)
38
+ sequence_b = {"candidate": {"id": "<option id>", "description": "<option description>"},
39
+ "type": "choice", "instructions": "...", "criteria": {...}}
40
+ ```
41
+
42
+ The candidate sits right after `[SEP]`, so the only tokens that differ between a question's candidates
43
+ are where the encoder attends most easily. Each pair yields one logit; a softmax over the question's
44
+ candidates gives the answer distribution. Training minimises cross-entropy between that grouped softmax
45
+ and a target distribution (one-hot for `choice` / `score`, `[1-p, p]` for `noul`).
46
+
47
+ ## Usage
48
+
49
+ The repo ships `schema_scorer.py` with the request compiler, decoder and a small adapter.
50
+
51
+ ```python
52
+ from huggingface_hub import hf_hub_download
53
+ import importlib.util
54
+
55
+ repo = "mobarmg/jev-schema-scorer-deberta-v3-large"
56
+ spec = importlib.util.spec_from_file_location("schema_scorer", hf_hub_download(repo, "schema_scorer.py"))
57
+ schema_scorer = importlib.util.module_from_spec(spec); spec.loader.exec_module(schema_scorer)
58
+
59
+ scorer = schema_scorer.LocalSystemOne(repo)
60
+ scorer.system_one(
61
+ "Nine days of silence on a signed quote is a joke. Our launch event is on the 28th and we still "
62
+ "do not have the licence keys your sales team promised. Somebody pick up a phone.",
63
+ {
64
+ "department": {"type": "choice", "instructions": "Which team should handle this?",
65
+ "criteria": {"billing": "Payment or subscription issues",
66
+ "technical": "Bugs or integration problems",
67
+ "sales": "Pricing or plan questions"}},
68
+ "frustration": {"type": "score", "instructions": "How frustrated the customer appears",
69
+ "criteria": ["Calm, just stating facts", "Frustrated but civil", "Very angry, strong language"]},
70
+ "is_urgent": {"type": "noul", "instructions": "The message conveys urgency"},
71
+ },
72
+ )
73
+ # {'answers': {'department': {'type': 'choice', 'choice': 'sales', 'probabilities': {...}},
74
+ # 'frustration': {'type': 'score', 'score': 2.0, 'probabilities': [...], 'legend': {...}},
75
+ # 'is_urgent': {'type': 'noul', 'noul': 0.99}}}
76
+ ```
77
+
78
+ Without the helper, it is a plain `DebertaV2ForSequenceClassification` with `num_labels=1`: tokenize
79
+ `(state, serialised question + candidate)` pairs, take `logits[:, 0]`, and softmax over each question's
80
+ candidates.
81
+
82
+ Limits: state + schema + candidate must fit in 512 tokens; every question needs at least two candidates.
83
+
84
+ ## Training data
85
+
86
+ Fine-tuned from `microsoft/deberta-v3-large` on 7,650 questions over 3,000 short English texts spanning
87
+ 30 domains (support triage, moderation, product reviews, email routing, resume screening, banking,
88
+ insurance claims, telehealth messages, IT helpdesk, dating safety, and more). Each domain defines one
89
+ `choice`, one `score` and one `noul` question. The texts and labels are synthetic, written to a per-domain
90
+ spec. To make the model read the schema instead of memorising label ids, each training example draws a
91
+ random instruction wording and criteria wording, shuffles the `choice` options, and replaces the option
92
+ ids with opaque ids (`opt_a`, `k2`, `bravo`, ...) half of the time.
93
+
94
+ Held-out eval split: 1,350 questions (15% of records per domain, stratified over label combinations).
95
+
96
+ ## Evaluation
97
+
98
+ Held-out eval split (1,350 questions, 45 per domain), measured with `bench_dataset.py`:
99
+
100
+ | primitive | metric | DeBERTa-v3-large scorer | chance |
101
+ |---|---|---|---|
102
+ | `choice` | accuracy | **0.889** | 0.255 |
103
+ | `noul` | accuracy | **0.940** | 0.500 |
104
+ | `noul` | Brier score (lower is better) | **0.052** | 0.250 |
105
+ | `score` | mean absolute error in levels (lower is better) | **0.183** | 0.700 |
106
+
107
+ <details>
108
+ <summary>Per-domain results</summary>
109
+
110
+ | domain | choice acc (chance) | noul acc / Brier | score MAE (uniform) |
111
+ |---|---|---|---|
112
+ | auto_service | 0.733 (0.200) | 0.933 / 0.067 | 0.105 (0.667) |
113
+ | banking | 0.867 (0.200) | 1.000 / 0.002 | 0.283 (0.600) |
114
+ | bug_report | 0.867 (0.250) | 0.933 / 0.067 | 0.097 (0.667) |
115
+ | dating_safety | 0.933 (0.250) | 0.933 / 0.067 | 0.176 (0.533) |
116
+ | ecommerce_order | 1.000 (0.250) | 1.000 / 0.000 | 0.133 (0.600) |
117
+ | education | 1.000 (0.250) | 1.000 / 0.000 | 0.064 (0.600) |
118
+ | email_routing | 0.800 (0.250) | 0.933 / 0.061 | 0.189 (0.733) |
119
+ | fitness_nutrition | 0.867 (0.250) | 1.000 / 0.000 | 0.024 (0.733) |
120
+ | gov_services | 0.800 (0.200) | 1.000 / 0.000 | 0.074 (0.667) |
121
+ | health_symptom | 1.000 (0.200) | 1.000 / 0.000 | 0.213 (1.100) |
122
+ | hr_workplace | 0.800 (0.200) | 1.000 / 0.000 | 0.041 (0.600) |
123
+ | insurance_claim | 1.000 (0.250) | 0.933 / 0.067 | 0.266 (0.733) |
124
+ | it_helpdesk | 0.800 (0.250) | 1.000 / 0.000 | 0.430 (0.533) |
125
+ | job_posting | 0.933 (0.250) | 0.933 / 0.066 | 0.116 (0.667) |
126
+ | legal_clause | 0.933 (0.250) | 0.800 / 0.205 | 0.421 (0.533) |
127
+ | mental_health | 0.667 (0.200) | 1.000 / 0.000 | 0.072 (0.667) |
128
+ | moderation | 1.000 (0.333) | 0.800 / 0.170 | 0.137 (0.667) |
129
+ | news | 1.000 (0.250) | 0.867 / 0.105 | 0.101 (0.667) |
130
+ | pharmacy | 0.933 (0.250) | 0.933 / 0.067 | 0.429 (0.600) |
131
+ | product_review | 0.867 (0.333) | 0.933 / 0.028 | 0.071 (1.133) |
132
+ | real_estate | 0.867 (0.250) | 1.000 / 0.000 | 0.000 (0.533) |
133
+ | restaurant_review | 0.933 (0.250) | 0.933 / 0.067 | 0.175 (1.267) |
134
+ | resume_screening | 0.933 (0.333) | 0.933 / 0.059 | 0.074 (0.667) |
135
+ | scientific_abstract | 1.000 (0.250) | 1.000 / 0.003 | 0.217 (0.733) |
136
+ | security_alert | 0.867 (0.200) | 0.800 / 0.134 | 0.814 (0.900) |
137
+ | smart_home | 0.933 (0.250) | 0.933 / 0.025 | 0.000 (0.733) |
138
+ | social_post | 0.800 (0.333) | 0.867 / 0.114 | 0.310 (0.667) |
139
+ | support_triage | 0.933 (0.333) | 0.867 / 0.134 | 0.009 (0.600) |
140
+ | survey | 0.800 (0.333) | 0.933 / 0.067 | 0.133 (0.600) |
141
+ | travel | 0.800 (0.250) | 1.000 / 0.000 | 0.304 (0.600) |
142
+
143
+ </details>
144
+
145
+ Chance is the uniform-guess baseline: `1/k` accuracy for `choice`, 0.5 for `noul`, and the MAE of
146
+ predicting the middle level for `score`.
147
+
148
+ ## Intended use and caveats
149
+
150
+ - Intended for experiments with schema-conditioned classification: routing, triage, moderation-style
151
+ labelling, ordinal scoring, and yes/no propositions over short English texts.
152
+ - Trained on synthetic data. Expect degraded accuracy far from the training domains, on long inputs, on
153
+ non-English text, and on criteria that require world knowledge or reasoning across the text.
154
+ - Probabilities are often very peaked (the grouped softmax is trained on one-hot targets); treat them as
155
+ rankings rather than calibrated confidences.
156
+ - Not a safety classifier. Do not use its `moderation`, `health`, or `security` outputs to make
157
+ consequential decisions without human review.
config.json ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "DebertaV2ForSequenceClassification"
4
+ ],
5
+ "attention_probs_dropout_prob": 0.1,
6
+ "bos_token_id": null,
7
+ "dtype": "float32",
8
+ "eos_token_id": null,
9
+ "hidden_act": "gelu",
10
+ "hidden_dropout_prob": 0.1,
11
+ "hidden_size": 1024,
12
+ "id2label": {
13
+ "0": "LABEL_0"
14
+ },
15
+ "initializer_range": 0.02,
16
+ "intermediate_size": 4096,
17
+ "label2id": {
18
+ "LABEL_0": 0
19
+ },
20
+ "layer_norm_eps": 1e-07,
21
+ "legacy": true,
22
+ "max_position_embeddings": 512,
23
+ "max_relative_positions": -1,
24
+ "model_type": "deberta-v2",
25
+ "norm_rel_ebd": "layer_norm",
26
+ "num_attention_heads": 16,
27
+ "num_hidden_layers": 24,
28
+ "pad_token_id": 0,
29
+ "pooler_dropout": 0.0,
30
+ "pooler_hidden_act": "gelu",
31
+ "pooler_hidden_size": 1024,
32
+ "pos_att_type": [
33
+ "p2c",
34
+ "c2p"
35
+ ],
36
+ "position_biased_input": false,
37
+ "position_buckets": 256,
38
+ "relative_attention": true,
39
+ "share_att_key": true,
40
+ "tie_word_embeddings": true,
41
+ "transformers_version": "5.17.0",
42
+ "type_vocab_size": 0,
43
+ "vocab_size": 128100
44
+ }
schema_scorer.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Schema-conditioned BERT candidate scorer.
3
+
4
+ One BERT encoder with a single scalar head scores (state, question+candidate)
5
+ pairs. Deterministic code groups the scalar logits per question and decodes
6
+ them into `choice`, `noul`, and `score` answers. The schema (instructions,
7
+ criteria, candidate ids) is read at inference time, never baked into weights.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import random
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ import torch
19
+ from torch import nn
20
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer
21
+
22
+ SUPPORTED_PRIMITIVES = ("choice", "noul", "score")
23
+
24
+ # Serialization layout. Must match between training and inference.
25
+ # SCORER_CANDIDATE_FIRST=0 restores the trailing-candidate layout (ablation only).
26
+ CANDIDATE_FIRST = os.environ.get("SCORER_CANDIDATE_FIRST", "1") == "1"
27
+
28
+
29
+ # --------------------------------------------------------------------------- #
30
+ # 1. Request compiler
31
+ # --------------------------------------------------------------------------- #
32
+ def serialize(value: Any) -> str:
33
+ return json.dumps(value, ensure_ascii=False, sort_keys=False)
34
+
35
+
36
+ def compile_question(state: Any, question: dict) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
37
+ """
38
+ Turn one question into (candidates, text_pairs).
39
+
40
+ candidates: [(candidate_id, description), ...] in decode order.
41
+ text_pairs: [(sequence_a, sequence_b), ...], one per candidate, where
42
+ sequence_a = the state and
43
+ sequence_b = the full question schema plus the candidate under review.
44
+ """
45
+ kind = question.get("type")
46
+
47
+ if kind == "choice":
48
+ criteria = question.get("criteria")
49
+ if not isinstance(criteria, dict):
50
+ raise ValueError("choice questions need a dict of criteria {id: description}")
51
+ candidates = [(str(k), str(v)) for k, v in criteria.items()]
52
+
53
+ elif kind == "score":
54
+ criteria = question.get("criteria")
55
+ if not isinstance(criteria, list):
56
+ raise ValueError("score questions need an ordered list of level descriptions")
57
+ candidates = [(str(i), str(d)) for i, d in enumerate(criteria)]
58
+
59
+ elif kind == "noul":
60
+ criteria = question.get("criteria") or {}
61
+ candidates = [
62
+ ("false", str(criteria.get("false", "No. The proposition is false for this state."))),
63
+ ("true", str(criteria.get("true", "Yes. The proposition is true for this state."))),
64
+ ]
65
+
66
+ else:
67
+ raise ValueError(f"Unsupported primitive: {kind!r} (expected one of {SUPPORTED_PRIMITIVES})")
68
+
69
+ if len(candidates) < 2:
70
+ raise ValueError("This implementation requires at least two candidates per question.")
71
+
72
+ state_text = state if isinstance(state, str) else serialize(state)
73
+ schema = {
74
+ "type": kind,
75
+ "instructions": question.get("instructions", ""),
76
+ }
77
+ if question.get("criteria") is not None:
78
+ schema["criteria"] = question["criteria"]
79
+
80
+ # Candidate first: the only tokens that differ between a question's
81
+ # candidates sit right after [SEP], where the encoder attends most easily.
82
+ # Trailing placement (candidate after the full schema) trains much slower.
83
+ if CANDIDATE_FIRST:
84
+ pairs = [
85
+ (state_text, serialize({"candidate": {"id": cid, "description": desc}, **schema}))
86
+ for cid, desc in candidates
87
+ ]
88
+ else:
89
+ pairs = [
90
+ (state_text, serialize({**schema, "candidate": {"id": cid, "description": desc}}))
91
+ for cid, desc in candidates
92
+ ]
93
+ return candidates, pairs
94
+
95
+
96
+ # --------------------------------------------------------------------------- #
97
+ # 2. Decoder (logits -> structured answer)
98
+ # --------------------------------------------------------------------------- #
99
+ def decode_answer(kind: str, candidates: list[tuple[str, str]], logits: torch.Tensor) -> dict:
100
+ probabilities = logits.float().softmax(dim=0)
101
+
102
+ if kind == "noul":
103
+ return {"type": "noul", "noul": float(probabilities[1])}
104
+
105
+ if kind == "choice":
106
+ selected = int(probabilities.argmax())
107
+ return {
108
+ "type": "choice",
109
+ "choice": candidates[selected][0],
110
+ "probabilities": {cid: float(probabilities[i]) for i, (cid, _) in enumerate(candidates)},
111
+ }
112
+
113
+ if kind == "score":
114
+ expected = sum(i * float(probabilities[i]) for i in range(len(candidates)))
115
+ return {
116
+ "type": "score",
117
+ "score": expected,
118
+ "probabilities": [float(p) for p in probabilities],
119
+ "legend": dict(candidates),
120
+ }
121
+
122
+ raise ValueError(f"Unsupported primitive: {kind!r}")
123
+
124
+
125
+ # --------------------------------------------------------------------------- #
126
+ # 3. Model wrapper: inference
127
+ # --------------------------------------------------------------------------- #
128
+ def pick_device() -> torch.device:
129
+ if torch.cuda.is_available():
130
+ return torch.device("cuda")
131
+ if torch.backends.mps.is_available():
132
+ return torch.device("mps")
133
+ return torch.device("cpu")
134
+
135
+
136
+ class LocalSystemOne:
137
+ """TypeSafe-shaped adapter over a trained one-logit candidate scorer."""
138
+
139
+ def __init__(self, checkpoint: str, max_length: int | None = None, device: torch.device | None = None):
140
+ self.device = device or pick_device()
141
+ self.tokenizer = AutoTokenizer.from_pretrained(checkpoint)
142
+ self.model = AutoModelForSequenceClassification.from_pretrained(checkpoint, dtype=torch.float32).to(self.device).eval()
143
+ if self.model.config.num_labels != 1:
144
+ raise ValueError("Expected a trained one-logit candidate scorer (num_labels == 1).")
145
+ # Default to the architecture's positional limit (512 for BERT/DeBERTa, 8192 for ModernBERT).
146
+ self.max_length = max_length or getattr(self.model.config, "max_position_embeddings", 512)
147
+
148
+ @torch.inference_mode()
149
+ def score_pairs(self, pairs: list[tuple[str, str]], batch_size: int = 32) -> torch.Tensor:
150
+ chunks = []
151
+ for start in range(0, len(pairs), batch_size):
152
+ batch = pairs[start : start + batch_size]
153
+ encoded = self.tokenizer(
154
+ [a for a, _ in batch],
155
+ [b for _, b in batch],
156
+ padding=True,
157
+ truncation=False,
158
+ return_tensors="pt",
159
+ )
160
+ if encoded["input_ids"].shape[1] > self.max_length:
161
+ raise ValueError(
162
+ f"State + question + criteria exceed the input limit "
163
+ f"({encoded['input_ids'].shape[1]} > {self.max_length} tokens)."
164
+ )
165
+ encoded = {k: v.to(self.device) for k, v in encoded.items()}
166
+ chunks.append(self.model(**encoded).logits.squeeze(-1).float().cpu())
167
+ return torch.cat(chunks) if chunks else torch.empty(0)
168
+
169
+ def system_one(self, state: Any, questions: dict[str, dict], batch_size: int = 32) -> dict:
170
+ all_pairs: list[tuple[str, str]] = []
171
+ groups = []
172
+ for name, question in questions.items():
173
+ candidates, pairs = compile_question(state, question)
174
+ start = len(all_pairs)
175
+ all_pairs.extend(pairs)
176
+ groups.append((name, question["type"], candidates, start, len(all_pairs)))
177
+
178
+ if not all_pairs:
179
+ return {"model": "local-bert-scorer", "answers": {}}
180
+
181
+ logits = self.score_pairs(all_pairs, batch_size=batch_size)
182
+ answers = {
183
+ name: decode_answer(kind, candidates, logits[start:end])
184
+ for name, kind, candidates, start, end in groups
185
+ }
186
+ return {"model": "local-bert-scorer", "answers": answers}
187
+
188
+
189
+ # --------------------------------------------------------------------------- #
190
+ # 4. Training: grouped distribution loss
191
+ # --------------------------------------------------------------------------- #
192
+ @dataclass
193
+ class Example:
194
+ state: Any
195
+ question: dict
196
+ target: list[float] # distribution over the compiled candidates, sums to 1
197
+
198
+
199
+ def target_for(kind: str, label: Any, n_candidates: int) -> list[float]:
200
+ """Helper to build a target distribution from a plain label."""
201
+ if kind == "noul":
202
+ p = float(label)
203
+ return [1.0 - p, p]
204
+ if kind == "choice":
205
+ # label is the index of the correct candidate
206
+ t = [0.0] * n_candidates
207
+ t[int(label)] = 1.0
208
+ return t
209
+ if kind == "score":
210
+ # label is either a level index or a full distribution
211
+ if isinstance(label, (list, tuple)):
212
+ return [float(x) for x in label]
213
+ t = [0.0] * n_candidates
214
+ t[int(label)] = 1.0
215
+ return t
216
+ raise ValueError(kind)
217
+
218
+
219
+ def grouped_distribution_loss(logits: torch.Tensor, targets: torch.Tensor, group_sizes: list[int]) -> torch.Tensor:
220
+ """
221
+ logits, targets: flat 1-D tensors, concatenation of per-question groups.
222
+ Loss = mean over questions of cross-entropy(target_dist, softmax(group logits)).
223
+ """
224
+ losses = []
225
+ offset = 0
226
+ for size in group_sizes:
227
+ g_logits = logits[offset : offset + size]
228
+ g_target = targets[offset : offset + size]
229
+ losses.append(-(g_target * g_logits.log_softmax(dim=0)).sum())
230
+ offset += size
231
+ return torch.stack(losses).mean()
232
+
233
+
234
+ def train_scorer(
235
+ base_model: str,
236
+ examples: list[Example],
237
+ output_dir: str,
238
+ *,
239
+ epochs: int = 2,
240
+ questions_per_batch: int = 8,
241
+ lr: float = 3e-5,
242
+ max_length: int = 256,
243
+ seed: int = 0,
244
+ device: torch.device | None = None,
245
+ log_every: int = 25,
246
+ eval_fn=None,
247
+ bf16: bool | None = None,
248
+ gradient_checkpointing: bool = False,
249
+ ) -> str:
250
+ """
251
+ Fine-tune an encoder as a one-logit candidate scorer and save it.
252
+
253
+ bf16: autocast matmuls to bfloat16. Default False; see note below.
254
+ gradient_checkpointing: trade compute for activation memory (large models
255
+ on 24 GB cards).
256
+ """
257
+ random.seed(seed)
258
+ torch.manual_seed(seed)
259
+ device = device or pick_device()
260
+ # Default fp32. Candidates of one question differ by a handful of tokens, so
261
+ # their logits differ by tiny amounts early in training; bf16 rounds those to
262
+ # identical values and the grouped loss gets no gradient (observed: bert-base
263
+ # stuck at chance and DeBERTa-v3-large NaN under bf16 autocast).
264
+ if bf16 is None:
265
+ bf16 = False
266
+
267
+ tokenizer = AutoTokenizer.from_pretrained(base_model)
268
+ # dtype=float32: transformers 5 otherwise keeps the checkpoint's stored dtype;
269
+ # deberta-v3-large ships in fp16, and fp16 master weights train to NaN.
270
+ model = AutoModelForSequenceClassification.from_pretrained(base_model, num_labels=1, dtype=torch.float32)
271
+ if gradient_checkpointing:
272
+ model.gradient_checkpointing_enable()
273
+ model.to(device).train()
274
+ print(f" base={base_model} params={sum(p.numel() for p in model.parameters()) / 1e6:.0f}M "
275
+ f"device={device} bf16={bf16} grad_ckpt={gradient_checkpointing}")
276
+
277
+ optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
278
+ total_steps = epochs * ((len(examples) + questions_per_batch - 1) // questions_per_batch)
279
+ warmup = max(1, int(0.06 * total_steps))
280
+
281
+ def lr_lambda(step: int) -> float:
282
+ if step < warmup:
283
+ return (step + 1) / warmup
284
+ return max(0.0, (total_steps - step) / max(1, total_steps - warmup))
285
+
286
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
287
+
288
+ step = 0
289
+ for epoch in range(epochs):
290
+ order = list(range(len(examples)))
291
+ random.shuffle(order)
292
+ running = 0.0
293
+ for b in range(0, len(order), questions_per_batch):
294
+ batch = [examples[i] for i in order[b : b + questions_per_batch]]
295
+ pairs, targets, sizes = [], [], []
296
+ for ex in batch:
297
+ candidates, ex_pairs = compile_question(ex.state, ex.question)
298
+ if len(ex.target) != len(candidates):
299
+ raise ValueError("target distribution length must equal candidate count")
300
+ pairs.extend(ex_pairs)
301
+ targets.extend(ex.target)
302
+ sizes.append(len(candidates))
303
+
304
+ encoded = tokenizer(
305
+ [a for a, _ in pairs],
306
+ [b_ for _, b_ in pairs],
307
+ padding=True,
308
+ truncation="only_first", # truncate the state, never the schema
309
+ max_length=max_length,
310
+ return_tensors="pt",
311
+ )
312
+ encoded = {k: v.to(device) for k, v in encoded.items()}
313
+ with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=bf16):
314
+ logits = model(**encoded).logits.squeeze(-1).float()
315
+ loss = grouped_distribution_loss(logits, torch.tensor(targets, device=device), sizes)
316
+
317
+ optimizer.zero_grad(set_to_none=True)
318
+ loss.backward()
319
+ nn.utils.clip_grad_norm_(model.parameters(), 1.0)
320
+ optimizer.step()
321
+ scheduler.step()
322
+
323
+ running += loss.item()
324
+ step += 1
325
+ if step % log_every == 0:
326
+ print(f" epoch {epoch + 1}/{epochs} step {step}/{total_steps} loss {running / log_every:.4f}")
327
+ running = 0.0
328
+
329
+ if eval_fn is not None:
330
+ model.eval()
331
+ eval_fn(model, tokenizer, epoch)
332
+ model.train()
333
+
334
+ model.eval()
335
+ model.save_pretrained(output_dir)
336
+ tokenizer.save_pretrained(output_dir)
337
+ return output_dir
tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
tokenizer_config.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_prefix_space": true,
3
+ "backend": "tokenizers",
4
+ "bos_token": "[CLS]",
5
+ "cls_token": "[CLS]",
6
+ "do_lower_case": false,
7
+ "eos_token": "[SEP]",
8
+ "extra_special_tokens": [
9
+ "[PAD]",
10
+ "[CLS]",
11
+ "[SEP]"
12
+ ],
13
+ "is_local": true,
14
+ "local_files_only": false,
15
+ "mask_token": "[MASK]",
16
+ "max_length": 256,
17
+ "model_max_length": 1000000000000000019884624838656,
18
+ "pad_to_multiple_of": null,
19
+ "pad_token": "[PAD]",
20
+ "pad_token_type_id": 0,
21
+ "padding_side": "right",
22
+ "sep_token": "[SEP]",
23
+ "split_by_punct": false,
24
+ "stride": 0,
25
+ "tokenizer_class": "DebertaV2Tokenizer",
26
+ "truncation_side": "right",
27
+ "truncation_strategy": "only_first",
28
+ "unk_id": 3,
29
+ "unk_token": "[UNK]",
30
+ "vocab_type": "spm"
31
+ }