Text Classification
Transformers
Safetensors
English
deberta-v2
deberta-v3
schema-conditioned
candidate-scoring
zero-shot-classification
structured-output
text-embeddings-inference
Instructions to use mobarmg/jev-schema-scorer-deberta-v3-large with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use mobarmg/jev-schema-scorer-deberta-v3-large with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-classification", model="mobarmg/jev-schema-scorer-deberta-v3-large")# Load model directly from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained("mobarmg/jev-schema-scorer-deberta-v3-large") model = AutoModelForSequenceClassification.from_pretrained("mobarmg/jev-schema-scorer-deberta-v3-large", device_map="auto") - Notebooks
- Google Colab
- Kaggle
Upload scorer
Browse files- README.md +157 -0
- config.json +44 -0
- schema_scorer.py +337 -0
- tokenizer.json +0 -0
- 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 |
+
}
|