Feature Extraction
Transformers
Safetensors
pivot
decision-making
classification
scoring
custom_code
Instructions to use Q1z/Pivot with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use Q1z/Pivot with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("feature-extraction", model="Q1z/Pivot", trust_remote_code=True)# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("Q1z/Pivot", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse files- README.md +72 -0
- config.json +192 -0
- configuration_pivot.py +11 -0
- dsbt_config.yaml +88 -0
- evaluation_status.json +4 -0
- export_verification.json +8 -0
- model.safetensors +3 -0
- modeling_pivot.py +64 -0
- pivot_data.py +462 -0
- pivot_infer.py +100 -0
- pivot_model.py +356 -0
- pivot_serving_schema.py +68 -0
- predict_example.py +9 -0
- provenance.json +46 -0
- requirements.txt +4 -0
- run_metrics.json +13 -0
- serving/example_request.json +32 -0
- serving/example_response.json +79 -0
- serving/schema.json +101 -0
- tokenizer.json +0 -0
- tokenizer_config.json +12 -0
README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
base_model: LiquidAI/LFM2.5-Encoder-350M
|
| 3 |
+
base_model_relation: finetune
|
| 4 |
+
library_name: transformers
|
| 5 |
+
tags:
|
| 6 |
+
- safetensors
|
| 7 |
+
- custom_code
|
| 8 |
+
- decision-making
|
| 9 |
+
- classification
|
| 10 |
+
- routing
|
| 11 |
+
- scoring
|
| 12 |
+
---
|
| 13 |
+
# Pivot
|
| 14 |
+
|
| 15 |
+
Pivot is a decision model developed by **Q1z**. It scores a supplied set of
|
| 16 |
+
options and returns a choice with probabilities. It does not generate chat text.
|
| 17 |
+
|
| 18 |
+
Built on [LiquidAI/LFM2.5-Encoder-350M](https://huggingface.co/LiquidAI/LFM2.5-Encoder-350M)
|
| 19 |
+
with a trained DSBT option-set scorer. Context and options are encoded separately,
|
| 20 |
+
mean-pooled, scored together, and normalized over the valid options.
|
| 21 |
+
|
| 22 |
+
## Model details
|
| 23 |
+
|
| 24 |
+
- Parameters: 357,631,745
|
| 25 |
+
- Weight types: float32
|
| 26 |
+
- Format: Safetensors, full model including the decision scorer
|
| 27 |
+
- Outputs: choice, noul (Boolean choice), and discrete score distributions
|
| 28 |
+
- Base revision: `b886781f7c6f10ca9b7096e21b83e30a073c2f39`
|
| 29 |
+
- Saved checkpoint: epoch index 0, global step 3102
|
| 30 |
+
- Export verification: exact weight round-trip and typed output agreement PASS
|
| 31 |
+
|
| 32 |
+
## Quick start
|
| 33 |
+
|
| 34 |
+
Install `torch`, `transformers`, `safetensors`, and `numpy` using the versions
|
| 35 |
+
recorded in `requirements.txt`. Review the bundled custom code before trusting it.
|
| 36 |
+
|
| 37 |
+
```python
|
| 38 |
+
from transformers import AutoModel, AutoTokenizer
|
| 39 |
+
|
| 40 |
+
repo = "Q1z/Pivot"
|
| 41 |
+
tokenizer = AutoTokenizer.from_pretrained(repo, trust_remote_code=True)
|
| 42 |
+
model = AutoModel.from_pretrained(repo, trust_remote_code=True).eval()
|
| 43 |
+
result = model.choose(tokenizer,
|
| 44 |
+
context="My invoice has the wrong total.",
|
| 45 |
+
options=["billing", "technical support", "sales"])
|
| 46 |
+
print(result)
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
For typed questions use `model.decide(tokenizer, state=..., questions=...)`;
|
| 50 |
+
see `serving/example_request.json` and `predict_example.py`.
|
| 51 |
+
CPU is supported. For CUDA, call `model.to("cuda")` before inference.
|
| 52 |
+
After downloading the complete repository, use its local path with
|
| 53 |
+
`local_files_only=True`; no base-model weights are fetched during loading.
|
| 54 |
+
Private repository access requires an authorized Hugging Face token.
|
| 55 |
+
|
| 56 |
+
## Evaluation and limitations
|
| 57 |
+
|
| 58 |
+
JevBench results will be added after testing. This export runs no full benchmark.
|
| 59 |
+
The smoke test verifies loading and output structure, not accuracy or calibration.
|
| 60 |
+
Probabilities have not been independently validated for calibration.
|
| 61 |
+
Multilingual quality and speed are not yet benchmarked. Supplied options determine
|
| 62 |
+
the available answers; this is not an open-ended text generator.
|
| 63 |
+
|
| 64 |
+
## Package
|
| 65 |
+
|
| 66 |
+
`model.safetensors` is the only weight file. `config.json`, tokenizer files, and
|
| 67 |
+
bundled Python modules implement loading and inference. `dsbt_config.yaml` records
|
| 68 |
+
the saved training configuration. `provenance.json` records the checkpoint hash.
|
| 69 |
+
Training backups and optimizer state are not included. Keep the source best.pt
|
| 70 |
+
separately if further training is needed. ONNX is not exported.
|
| 71 |
+
|
| 72 |
+
Project: https://trypivot.me
|
config.json
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_type": "pivot",
|
| 3 |
+
"architectures": [
|
| 4 |
+
"PivotModel"
|
| 5 |
+
],
|
| 6 |
+
"auto_map": {
|
| 7 |
+
"AutoConfig": "configuration_pivot.PivotConfig",
|
| 8 |
+
"AutoModel": "modeling_pivot.PivotModel"
|
| 9 |
+
},
|
| 10 |
+
"dsbt_config": {
|
| 11 |
+
"backbone": {
|
| 12 |
+
"encoder": "huggingface",
|
| 13 |
+
"model_id": "LiquidAI/LFM2.5-Encoder-350M",
|
| 14 |
+
"revision": "b886781f7c6f10ca9b7096e21b83e30a073c2f39",
|
| 15 |
+
"trust_remote_code": true,
|
| 16 |
+
"pooling": "mean",
|
| 17 |
+
"hidden_size": 1024,
|
| 18 |
+
"max_position_embeddings": 8192,
|
| 19 |
+
"stub_hidden_size": 32,
|
| 20 |
+
"stub_vocab_size": 256
|
| 21 |
+
},
|
| 22 |
+
"scorer": {
|
| 23 |
+
"type": "mlp",
|
| 24 |
+
"hidden_size": 1024,
|
| 25 |
+
"dropout": 0.0
|
| 26 |
+
},
|
| 27 |
+
"data": {
|
| 28 |
+
"k_min": 2,
|
| 29 |
+
"k_max": 16,
|
| 30 |
+
"max_context_tokens": 512,
|
| 31 |
+
"max_option_tokens": 64,
|
| 32 |
+
"noul_true": "true",
|
| 33 |
+
"noul_false": "false",
|
| 34 |
+
"split_ratios": [
|
| 35 |
+
0.8,
|
| 36 |
+
0.1,
|
| 37 |
+
0.1
|
| 38 |
+
],
|
| 39 |
+
"family_sample_caps": {
|
| 40 |
+
"casehold": 0.15
|
| 41 |
+
}
|
| 42 |
+
},
|
| 43 |
+
"train": {
|
| 44 |
+
"epochs": 4,
|
| 45 |
+
"warmup_epochs": 2,
|
| 46 |
+
"batch_size": 32,
|
| 47 |
+
"grad_accum": 1,
|
| 48 |
+
"lr_encoder": 1e-05,
|
| 49 |
+
"lr_scorer": 5e-05,
|
| 50 |
+
"weight_decay": 0.01,
|
| 51 |
+
"grad_clip": 1.0,
|
| 52 |
+
"precision": "bf16",
|
| 53 |
+
"gradient_checkpointing": true,
|
| 54 |
+
"seed": 42,
|
| 55 |
+
"decoupling": "pcgrad",
|
| 56 |
+
"log_every": 20,
|
| 57 |
+
"num_workers": 4,
|
| 58 |
+
"early_stop_patience": 2,
|
| 59 |
+
"checkpoint_dir": "checkpoints",
|
| 60 |
+
"log_dir": "logs",
|
| 61 |
+
"lora": {
|
| 62 |
+
"enabled": false,
|
| 63 |
+
"r": 16,
|
| 64 |
+
"alpha": 32,
|
| 65 |
+
"dropout": 0.05,
|
| 66 |
+
"target_modules": [
|
| 67 |
+
"q_proj",
|
| 68 |
+
"k_proj",
|
| 69 |
+
"v_proj",
|
| 70 |
+
"out_proj",
|
| 71 |
+
"in_proj"
|
| 72 |
+
]
|
| 73 |
+
}
|
| 74 |
+
},
|
| 75 |
+
"eval": {
|
| 76 |
+
"k_strata": [
|
| 77 |
+
2,
|
| 78 |
+
4,
|
| 79 |
+
8,
|
| 80 |
+
16
|
| 81 |
+
],
|
| 82 |
+
"option_mode": "resample_strata",
|
| 83 |
+
"ece_bins": 15,
|
| 84 |
+
"calibration_epsilon": 0.02,
|
| 85 |
+
"temperature_scaling": false,
|
| 86 |
+
"jev_baseline": null,
|
| 87 |
+
"latency_batches": 20,
|
| 88 |
+
"require_latency_vs_jev": true,
|
| 89 |
+
"win_on_overall_acc_only": false,
|
| 90 |
+
"mid_epoch_max_examples": 4096,
|
| 91 |
+
"full_frozen_eval_only_at_end": true,
|
| 92 |
+
"frozen_eval_path": null
|
| 93 |
+
},
|
| 94 |
+
"modal_contract": {
|
| 95 |
+
"softmax_dtype": "fp32",
|
| 96 |
+
"casehold_cap": 0.15,
|
| 97 |
+
"forbid_jev_softlabel_distill": true,
|
| 98 |
+
"win_requires": [
|
| 99 |
+
"k_stratified_acc_brier_ece",
|
| 100 |
+
"latency_p50_p95_vs_jev",
|
| 101 |
+
"live_brier_pcgrad",
|
| 102 |
+
"casehold_sample_cap_le_015"
|
| 103 |
+
],
|
| 104 |
+
"gpu": "H100",
|
| 105 |
+
"expected_steps_batch64": "16878\u201322504 for 3\u20134 epochs; early-stop may cut short",
|
| 106 |
+
"budget_note": "~$8\u201316 expected at $3.95/h for 2\u20134h; keep \u2265$8 reserve"
|
| 107 |
+
},
|
| 108 |
+
"brier": {
|
| 109 |
+
"reduction": "mean_over_real_options"
|
| 110 |
+
}
|
| 111 |
+
},
|
| 112 |
+
"encoder_config": {
|
| 113 |
+
"transformers_version": "5.17.0",
|
| 114 |
+
"architectures": [
|
| 115 |
+
"Lfm2BidirectionalForMaskedLM"
|
| 116 |
+
],
|
| 117 |
+
"output_hidden_states": false,
|
| 118 |
+
"return_dict": true,
|
| 119 |
+
"dtype": "float32",
|
| 120 |
+
"chunk_size_feed_forward": 0,
|
| 121 |
+
"is_encoder_decoder": false,
|
| 122 |
+
"id2label": {
|
| 123 |
+
"0": "LABEL_0",
|
| 124 |
+
"1": "LABEL_1"
|
| 125 |
+
},
|
| 126 |
+
"label2id": {
|
| 127 |
+
"LABEL_0": 0,
|
| 128 |
+
"LABEL_1": 1
|
| 129 |
+
},
|
| 130 |
+
"problem_type": null,
|
| 131 |
+
"vocab_size": 65536,
|
| 132 |
+
"hidden_size": 1024,
|
| 133 |
+
"intermediate_size": 6656,
|
| 134 |
+
"num_hidden_layers": 16,
|
| 135 |
+
"num_attention_heads": 16,
|
| 136 |
+
"num_key_value_heads": 8,
|
| 137 |
+
"max_position_embeddings": 128000,
|
| 138 |
+
"initializer_range": 0.02,
|
| 139 |
+
"norm_eps": 1e-05,
|
| 140 |
+
"use_cache": false,
|
| 141 |
+
"pad_token_id": 0,
|
| 142 |
+
"bos_token_id": 1,
|
| 143 |
+
"eos_token_id": 7,
|
| 144 |
+
"tie_word_embeddings": true,
|
| 145 |
+
"rope_parameters": {
|
| 146 |
+
"rope_theta": 1000000.0,
|
| 147 |
+
"rope_type": "default"
|
| 148 |
+
},
|
| 149 |
+
"conv_bias": false,
|
| 150 |
+
"conv_L_cache": 3,
|
| 151 |
+
"block_multiple_of": 256,
|
| 152 |
+
"block_ffn_dim_multiplier": 1.0,
|
| 153 |
+
"block_auto_adjust_ff_dim": true,
|
| 154 |
+
"full_attn_idxs": null,
|
| 155 |
+
"layer_types": [
|
| 156 |
+
"conv",
|
| 157 |
+
"conv",
|
| 158 |
+
"full_attention",
|
| 159 |
+
"conv",
|
| 160 |
+
"conv",
|
| 161 |
+
"full_attention",
|
| 162 |
+
"conv",
|
| 163 |
+
"conv",
|
| 164 |
+
"full_attention",
|
| 165 |
+
"conv",
|
| 166 |
+
"full_attention",
|
| 167 |
+
"conv",
|
| 168 |
+
"full_attention",
|
| 169 |
+
"conv",
|
| 170 |
+
"full_attention",
|
| 171 |
+
"conv"
|
| 172 |
+
],
|
| 173 |
+
"_name_or_path": "LiquidAI/LFM2.5-Encoder-350M",
|
| 174 |
+
"block_dim": 1024,
|
| 175 |
+
"block_mlp_init_scale": 1.0,
|
| 176 |
+
"block_norm_eps": 1e-05,
|
| 177 |
+
"block_out_init_scale": 1.0,
|
| 178 |
+
"block_use_swiglu": true,
|
| 179 |
+
"block_use_xavier_init": true,
|
| 180 |
+
"conv_dim": 1024,
|
| 181 |
+
"conv_dim_out": 1024,
|
| 182 |
+
"conv_use_xavier_init": true,
|
| 183 |
+
"model_type": "lfm2",
|
| 184 |
+
"num_heads": 16,
|
| 185 |
+
"use_pos_enc": true,
|
| 186 |
+
"auto_map": {
|
| 187 |
+
"AutoModel": "modeling_lfm2_bidirectional.Lfm2BidirectionalModel",
|
| 188 |
+
"AutoModelForMaskedLM": "modeling_lfm2_bidirectional.Lfm2BidirectionalForMaskedLM"
|
| 189 |
+
},
|
| 190 |
+
"output_attentions": false
|
| 191 |
+
}
|
| 192 |
+
}
|
configuration_pivot.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration for the Pivot set-scored decision encoder."""
|
| 2 |
+
from transformers import PretrainedConfig
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class PivotConfig(PretrainedConfig):
|
| 6 |
+
model_type = "pivot"
|
| 7 |
+
|
| 8 |
+
def __init__(self, dsbt_config=None, encoder_config=None, **kwargs):
|
| 9 |
+
super().__init__(**kwargs)
|
| 10 |
+
self.dsbt_config = dsbt_config or {}
|
| 11 |
+
self.encoder_config = encoder_config or {}
|
dsbt_config.yaml
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
backbone:
|
| 2 |
+
encoder: huggingface
|
| 3 |
+
model_id: LiquidAI/LFM2.5-Encoder-350M
|
| 4 |
+
revision: b886781f7c6f10ca9b7096e21b83e30a073c2f39
|
| 5 |
+
trust_remote_code: true
|
| 6 |
+
pooling: mean
|
| 7 |
+
hidden_size: 1024
|
| 8 |
+
max_position_embeddings: 8192
|
| 9 |
+
stub_hidden_size: 32
|
| 10 |
+
stub_vocab_size: 256
|
| 11 |
+
scorer:
|
| 12 |
+
type: mlp
|
| 13 |
+
hidden_size: 1024
|
| 14 |
+
dropout: 0.0
|
| 15 |
+
data:
|
| 16 |
+
k_min: 2
|
| 17 |
+
k_max: 16
|
| 18 |
+
max_context_tokens: 512
|
| 19 |
+
max_option_tokens: 64
|
| 20 |
+
noul_true: 'true'
|
| 21 |
+
noul_false: 'false'
|
| 22 |
+
split_ratios:
|
| 23 |
+
- 0.8
|
| 24 |
+
- 0.1
|
| 25 |
+
- 0.1
|
| 26 |
+
family_sample_caps:
|
| 27 |
+
casehold: 0.15
|
| 28 |
+
train:
|
| 29 |
+
epochs: 4
|
| 30 |
+
warmup_epochs: 2
|
| 31 |
+
batch_size: 32
|
| 32 |
+
grad_accum: 1
|
| 33 |
+
lr_encoder: 1.0e-05
|
| 34 |
+
lr_scorer: 5.0e-05
|
| 35 |
+
weight_decay: 0.01
|
| 36 |
+
grad_clip: 1.0
|
| 37 |
+
precision: bf16
|
| 38 |
+
gradient_checkpointing: true
|
| 39 |
+
seed: 42
|
| 40 |
+
decoupling: pcgrad
|
| 41 |
+
log_every: 20
|
| 42 |
+
num_workers: 4
|
| 43 |
+
early_stop_patience: 2
|
| 44 |
+
checkpoint_dir: checkpoints
|
| 45 |
+
log_dir: logs
|
| 46 |
+
lora:
|
| 47 |
+
enabled: false
|
| 48 |
+
r: 16
|
| 49 |
+
alpha: 32
|
| 50 |
+
dropout: 0.05
|
| 51 |
+
target_modules:
|
| 52 |
+
- q_proj
|
| 53 |
+
- k_proj
|
| 54 |
+
- v_proj
|
| 55 |
+
- out_proj
|
| 56 |
+
- in_proj
|
| 57 |
+
eval:
|
| 58 |
+
k_strata:
|
| 59 |
+
- 2
|
| 60 |
+
- 4
|
| 61 |
+
- 8
|
| 62 |
+
- 16
|
| 63 |
+
option_mode: resample_strata
|
| 64 |
+
ece_bins: 15
|
| 65 |
+
calibration_epsilon: 0.02
|
| 66 |
+
temperature_scaling: false
|
| 67 |
+
jev_baseline: null
|
| 68 |
+
latency_batches: 20
|
| 69 |
+
require_latency_vs_jev: true
|
| 70 |
+
win_on_overall_acc_only: false
|
| 71 |
+
mid_epoch_max_examples: 4096
|
| 72 |
+
full_frozen_eval_only_at_end: true
|
| 73 |
+
frozen_eval_path: null
|
| 74 |
+
modal_contract:
|
| 75 |
+
softmax_dtype: fp32
|
| 76 |
+
casehold_cap: 0.15
|
| 77 |
+
forbid_jev_softlabel_distill: true
|
| 78 |
+
win_requires:
|
| 79 |
+
- k_stratified_acc_brier_ece
|
| 80 |
+
- latency_p50_p95_vs_jev
|
| 81 |
+
- live_brier_pcgrad
|
| 82 |
+
- casehold_sample_cap_le_015
|
| 83 |
+
gpu: H100
|
| 84 |
+
expected_steps_batch64: "16878\u201322504 for 3\u20134 epochs; early-stop may cut\
|
| 85 |
+
\ short"
|
| 86 |
+
budget_note: "~$8\u201316 expected at $3.95/h for 2\u20134h; keep \u2265$8 reserve"
|
| 87 |
+
brier:
|
| 88 |
+
reduction: mean_over_real_options
|
evaluation_status.json
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"status": "skipped",
|
| 3 |
+
"reason": "user_requested_export_only"
|
| 4 |
+
}
|
export_verification.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"package_format": "pivot-hf-v1",
|
| 3 |
+
"reload_verified": true,
|
| 4 |
+
"parameters": 357631745,
|
| 5 |
+
"tensor_dtypes": [
|
| 6 |
+
"float32"
|
| 7 |
+
]
|
| 8 |
+
}
|
model.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8bda6d789bbd7e136ab870db3a5ce060bdc299ea87c3302dce4dffff7707fcc1
|
| 3 |
+
size 1430546228
|
modeling_pivot.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pivot: load full fine-tuned weights without fetching base-model weights."""
|
| 2 |
+
import torch
|
| 3 |
+
from torch import nn
|
| 4 |
+
from transformers import AutoConfig, AutoModel, PreTrainedModel
|
| 5 |
+
from .configuration_pivot import PivotConfig
|
| 6 |
+
from .pivot_model import (SetBrierEncoder, StubEncoder, HuggingFaceEncoder,
|
| 7 |
+
MLPScorer, DotScorer, _wrap_lora)
|
| 8 |
+
from .pivot_infer import decide_typed, predict
|
| 9 |
+
from .pivot_data import DecisionCollator
|
| 10 |
+
from .pivot_serving_schema import build_response
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PivotModel(PreTrainedModel):
|
| 14 |
+
config_class = PivotConfig
|
| 15 |
+
base_model_prefix = "network"
|
| 16 |
+
_no_split_modules = ["SetBrierEncoder"]
|
| 17 |
+
|
| 18 |
+
def __init__(self, config):
|
| 19 |
+
super().__init__(config)
|
| 20 |
+
cfg = config.dsbt_config
|
| 21 |
+
backbone = cfg["backbone"]
|
| 22 |
+
if backbone.get("encoder") == "stub":
|
| 23 |
+
encoder = StubEncoder(int(backbone.get("stub_vocab_size", 256)),
|
| 24 |
+
int(backbone.get("stub_hidden_size", 32)))
|
| 25 |
+
else:
|
| 26 |
+
body_config = dict(config.encoder_config)
|
| 27 |
+
model_type = body_config.pop("model_type")
|
| 28 |
+
body = AutoModel.from_config(AutoConfig.for_model(model_type, **body_config))
|
| 29 |
+
encoder = HuggingFaceEncoder.__new__(HuggingFaceEncoder)
|
| 30 |
+
nn.Module.__init__(encoder)
|
| 31 |
+
encoder.hidden_size = int(body.config.hidden_size)
|
| 32 |
+
lora = cfg["train"].get("lora")
|
| 33 |
+
encoder.model = _wrap_lora(body, lora) if lora and lora.get("enabled") else body
|
| 34 |
+
hidden = encoder.hidden_size
|
| 35 |
+
sc = cfg["scorer"]
|
| 36 |
+
if sc.get("type", "mlp") == "mlp":
|
| 37 |
+
scorer = MLPScorer(hidden, int(sc.get("hidden_size", hidden)), float(sc.get("dropout", 0)))
|
| 38 |
+
elif sc["type"] == "dot":
|
| 39 |
+
scorer = DotScorer(hidden)
|
| 40 |
+
else:
|
| 41 |
+
raise ValueError("Unsupported scorer")
|
| 42 |
+
self.network = SetBrierEncoder(encoder, scorer)
|
| 43 |
+
self.post_init()
|
| 44 |
+
|
| 45 |
+
def forward(self, ctx_ids, ctx_mask, opt_ids, opt_mask, opt_attn):
|
| 46 |
+
return self.network(ctx_ids, ctx_mask, opt_ids, opt_mask, opt_attn)
|
| 47 |
+
|
| 48 |
+
@torch.no_grad()
|
| 49 |
+
def decide(self, tokenizer, state, questions):
|
| 50 |
+
self.eval()
|
| 51 |
+
data = self.config.dsbt_config["data"]
|
| 52 |
+
return decide_typed(self.network, tokenizer, state, questions,
|
| 53 |
+
max_context_tokens=int(data["max_context_tokens"]),
|
| 54 |
+
max_option_tokens=int(data["max_option_tokens"]),
|
| 55 |
+
device=next(self.parameters()).device, model_id="Pivot")
|
| 56 |
+
|
| 57 |
+
@torch.no_grad()
|
| 58 |
+
def choose(self, tokenizer, context, options):
|
| 59 |
+
self.eval()
|
| 60 |
+
data = self.config.dsbt_config["data"]
|
| 61 |
+
return predict(self.network, tokenizer, context, options,
|
| 62 |
+
max_context_tokens=int(data["max_context_tokens"]),
|
| 63 |
+
max_option_tokens=int(data["max_option_tokens"]),
|
| 64 |
+
device=next(self.parameters()).device)
|
pivot_data.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JSONL dataset, dynamic-K set augmentation, masks, and collate (§5).
|
| 2 |
+
|
| 3 |
+
Each record:
|
| 4 |
+
|
| 5 |
+
{
|
| 6 |
+
"context": str,
|
| 7 |
+
"options": [str, ...],
|
| 8 |
+
"label": int,
|
| 9 |
+
"task_type": "choice" | "noul" | "score",
|
| 10 |
+
"meta": {"domain": ..., "source_family": ..., "pair_id": ..., "hard_negatives": [...]}
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
On every training step (§5.3): sample K, keep gold, add hard negatives, fill
|
| 14 |
+
distractors, shuffle option order, remap ``label`` to the new gold index, pad
|
| 15 |
+
to Kmax with opt_mask=0.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import hashlib
|
| 21 |
+
import json
|
| 22 |
+
import random
|
| 23 |
+
from dataclasses import dataclass, field
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any, Iterator, Optional, Sequence
|
| 26 |
+
|
| 27 |
+
import torch
|
| 28 |
+
from torch.utils.data import Dataset
|
| 29 |
+
|
| 30 |
+
TASK_TYPES = {"choice", "noul", "score"}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class DecisionExample:
|
| 35 |
+
context: str
|
| 36 |
+
options: list[str]
|
| 37 |
+
label: int
|
| 38 |
+
task_type: str
|
| 39 |
+
meta: dict[str, Any] = field(default_factory=dict)
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def gold_text(self) -> str:
|
| 43 |
+
return self.options[self.label]
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def source_family(self) -> str:
|
| 47 |
+
fam = self.meta.get("source_family")
|
| 48 |
+
if fam:
|
| 49 |
+
return str(fam)
|
| 50 |
+
# Unique singleton family so untagged rows cannot leak across splits.
|
| 51 |
+
key = json.dumps([self.context, self.options, self.label], ensure_ascii=True)
|
| 52 |
+
digest = hashlib.md5(key.encode("utf-8")).hexdigest()[:16]
|
| 53 |
+
return f"singleton::{digest}"
|
| 54 |
+
|
| 55 |
+
@property
|
| 56 |
+
def hard_negatives(self) -> list[str]:
|
| 57 |
+
raw = self.meta.get("hard_negatives") or []
|
| 58 |
+
return [str(x) for x in raw]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def parse_record(obj: dict[str, Any], *, noul_true: str = "true", noul_false: str = "false") -> DecisionExample:
|
| 62 |
+
if "context" not in obj or "label" not in obj:
|
| 63 |
+
raise ValueError("record requires 'context' and 'label'")
|
| 64 |
+
task_type = str(obj.get("task_type", "choice"))
|
| 65 |
+
if task_type not in TASK_TYPES:
|
| 66 |
+
raise ValueError(f"task_type must be one of {sorted(TASK_TYPES)}")
|
| 67 |
+
options = obj.get("options")
|
| 68 |
+
if not options:
|
| 69 |
+
if task_type == "noul":
|
| 70 |
+
options = [noul_true, noul_false]
|
| 71 |
+
else:
|
| 72 |
+
raise ValueError("record requires a non-empty options list")
|
| 73 |
+
options = [str(x) for x in options]
|
| 74 |
+
label = int(obj["label"])
|
| 75 |
+
if not 0 <= label < len(options):
|
| 76 |
+
raise ValueError(f"label {label} out of range for {len(options)} options")
|
| 77 |
+
meta = dict(obj.get("meta") or {})
|
| 78 |
+
return DecisionExample(
|
| 79 |
+
context=str(obj["context"]),
|
| 80 |
+
options=options,
|
| 81 |
+
label=label,
|
| 82 |
+
task_type=task_type,
|
| 83 |
+
meta=meta,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def load_jsonl(
|
| 88 |
+
path: str | Path,
|
| 89 |
+
*,
|
| 90 |
+
noul_true: str = "true",
|
| 91 |
+
noul_false: str = "false",
|
| 92 |
+
) -> list[DecisionExample]:
|
| 93 |
+
path = Path(path)
|
| 94 |
+
rows: list[DecisionExample] = []
|
| 95 |
+
with path.open("r", encoding="utf-8") as f:
|
| 96 |
+
for line_no, line in enumerate(f, 1):
|
| 97 |
+
line = line.strip()
|
| 98 |
+
if not line:
|
| 99 |
+
continue
|
| 100 |
+
try:
|
| 101 |
+
obj = json.loads(line)
|
| 102 |
+
except json.JSONDecodeError as exc:
|
| 103 |
+
raise ValueError(f"{path}:{line_no} invalid JSON") from exc
|
| 104 |
+
rows.append(parse_record(obj, noul_true=noul_true, noul_false=noul_false))
|
| 105 |
+
return rows
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def split_by_source_family(
|
| 109 |
+
examples: Sequence[DecisionExample],
|
| 110 |
+
*,
|
| 111 |
+
seed: int,
|
| 112 |
+
ratios: Sequence[float] = (0.8, 0.1, 0.1),
|
| 113 |
+
) -> tuple[list[DecisionExample], list[DecisionExample], list[DecisionExample]]:
|
| 114 |
+
"""Assign whole source families to train / val / holdout (no family leakage)."""
|
| 115 |
+
if len(ratios) != 3:
|
| 116 |
+
raise ValueError("ratios must be (train, val, holdout)")
|
| 117 |
+
families: dict[str, list[DecisionExample]] = {}
|
| 118 |
+
for ex in examples:
|
| 119 |
+
families.setdefault(ex.source_family, []).append(ex)
|
| 120 |
+
keys = sorted(families)
|
| 121 |
+
rng = random.Random(seed)
|
| 122 |
+
rng.shuffle(keys)
|
| 123 |
+
n = len(keys)
|
| 124 |
+
n_train = int(round(n * ratios[0]))
|
| 125 |
+
n_val = int(round(n * ratios[1]))
|
| 126 |
+
n_train = min(n_train, n)
|
| 127 |
+
n_val = min(n_val, n - n_train)
|
| 128 |
+
train_k = keys[:n_train]
|
| 129 |
+
val_k = keys[n_train : n_train + n_val]
|
| 130 |
+
hold_k = keys[n_train + n_val :]
|
| 131 |
+
if not train_k and keys:
|
| 132 |
+
train_k, val_k, hold_k = keys[:1], keys[1:2], keys[2:]
|
| 133 |
+
def _take(ks: Sequence[str]) -> list[DecisionExample]:
|
| 134 |
+
out: list[DecisionExample] = []
|
| 135 |
+
for k in ks:
|
| 136 |
+
out.extend(families[k])
|
| 137 |
+
return out
|
| 138 |
+
return _take(train_k), _take(val_k), _take(hold_k)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def family_counts(examples: Sequence[DecisionExample]) -> dict[str, int]:
|
| 143 |
+
counts: dict[str, int] = {}
|
| 144 |
+
for ex in examples:
|
| 145 |
+
fam = ex.source_family
|
| 146 |
+
counts[fam] = counts.get(fam, 0) + 1
|
| 147 |
+
return counts
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def compute_family_sample_weights(
|
| 151 |
+
examples: Sequence[DecisionExample],
|
| 152 |
+
caps: dict[str, float] | None,
|
| 153 |
+
) -> list[float]:
|
| 154 |
+
"""Per-example sample weights so capped families have train mass ≤ cap.
|
| 155 |
+
|
| 156 |
+
For a single capped family with raw fraction ``f > cap``, each of its rows
|
| 157 |
+
gets weight ``cap * (N - n_f) / (n_f * (1 - cap))`` so that
|
| 158 |
+
``P(family) = cap`` under WeightedRandomSampler (with replacement).
|
| 159 |
+
Uncapped rows keep weight 1. Multiple caps are applied independently on the
|
| 160 |
+
uncapped pool (greedy): each capped family is solved against the current
|
| 161 |
+
non-capped mass. Empty caps → all ones.
|
| 162 |
+
"""
|
| 163 |
+
n = len(examples)
|
| 164 |
+
if n == 0:
|
| 165 |
+
return []
|
| 166 |
+
weights = [1.0] * n
|
| 167 |
+
if not caps:
|
| 168 |
+
return weights
|
| 169 |
+
counts = family_counts(examples)
|
| 170 |
+
# Apply stricter caps first.
|
| 171 |
+
ordered = sorted(
|
| 172 |
+
((str(fam), float(cap)) for fam, cap in caps.items() if float(cap) > 0),
|
| 173 |
+
key=lambda x: x[1],
|
| 174 |
+
)
|
| 175 |
+
for fam, cap in ordered:
|
| 176 |
+
if not 0 < cap < 1:
|
| 177 |
+
raise ValueError(f"family_sample_caps[{fam!r}]={cap} must be in (0, 1)")
|
| 178 |
+
n_f = counts.get(fam, 0)
|
| 179 |
+
if n_f == 0:
|
| 180 |
+
continue
|
| 181 |
+
frac = n_f / n
|
| 182 |
+
if frac <= cap:
|
| 183 |
+
continue
|
| 184 |
+
# Exact single-family solution vs current weight-1 mass outside the family.
|
| 185 |
+
outside = sum(weights[i] for i, ex in enumerate(examples) if ex.source_family != fam)
|
| 186 |
+
# P = (n_f * w) / (n_f * w + outside) = cap => w = cap * outside / (n_f * (1-cap))
|
| 187 |
+
w = (cap * outside) / (n_f * (1.0 - cap))
|
| 188 |
+
for i, ex in enumerate(examples):
|
| 189 |
+
if ex.source_family == fam:
|
| 190 |
+
weights[i] = float(w)
|
| 191 |
+
return weights
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def expected_family_mass(weights: Sequence[float], examples: Sequence[DecisionExample], family: str) -> float:
|
| 195 |
+
"""Expected draw fraction for ``family`` under the given sample weights."""
|
| 196 |
+
total = float(sum(weights))
|
| 197 |
+
if total <= 0:
|
| 198 |
+
return 0.0
|
| 199 |
+
mass = sum(w for w, ex in zip(weights, examples) if ex.source_family == family)
|
| 200 |
+
return mass / total
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def build_option_pool(examples: Sequence[DecisionExample]) -> list[str]:
|
| 204 |
+
seen: set[str] = set()
|
| 205 |
+
pool: list[str] = []
|
| 206 |
+
for ex in examples:
|
| 207 |
+
for opt in ex.options:
|
| 208 |
+
if opt not in seen:
|
| 209 |
+
seen.add(opt)
|
| 210 |
+
pool.append(opt)
|
| 211 |
+
for hn in ex.hard_negatives:
|
| 212 |
+
if hn not in seen:
|
| 213 |
+
seen.add(hn)
|
| 214 |
+
pool.append(hn)
|
| 215 |
+
if not pool:
|
| 216 |
+
pool = ["true", "false", "unknown", "not applicable"]
|
| 217 |
+
return pool
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def build_pair_gold_index(examples: Sequence[DecisionExample]) -> dict[str, list[str]]:
|
| 221 |
+
"""Map pair_id -> gold texts of siblings (contrastive flips)."""
|
| 222 |
+
idx: dict[str, list[str]] = {}
|
| 223 |
+
for ex in examples:
|
| 224 |
+
pid = ex.meta.get("pair_id")
|
| 225 |
+
if not pid:
|
| 226 |
+
continue
|
| 227 |
+
idx.setdefault(str(pid), []).append(ex.gold_text)
|
| 228 |
+
return idx
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@dataclass
|
| 232 |
+
class AugmentedSet:
|
| 233 |
+
options: list[str]
|
| 234 |
+
label: int
|
| 235 |
+
k: int
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class SetAugmenter:
|
| 239 |
+
"""On-the-fly variable-K option sets with shuffle + label remap."""
|
| 240 |
+
|
| 241 |
+
def __init__(
|
| 242 |
+
self,
|
| 243 |
+
*,
|
| 244 |
+
k_min: int,
|
| 245 |
+
k_max: int,
|
| 246 |
+
pool: Sequence[str],
|
| 247 |
+
pair_golds: Optional[dict[str, list[str]]] = None,
|
| 248 |
+
):
|
| 249 |
+
if k_min < 2:
|
| 250 |
+
raise ValueError("k_min must be >= 2")
|
| 251 |
+
if k_max < k_min:
|
| 252 |
+
raise ValueError("k_max must be >= k_min")
|
| 253 |
+
self.k_min = k_min
|
| 254 |
+
self.k_max = k_max
|
| 255 |
+
self.pool = list(pool)
|
| 256 |
+
self.pair_golds = pair_golds or {}
|
| 257 |
+
|
| 258 |
+
def sample_k(self, rng: random.Random) -> int:
|
| 259 |
+
return rng.randint(self.k_min, self.k_max)
|
| 260 |
+
|
| 261 |
+
def _hard_negatives(self, example: DecisionExample) -> list[str]:
|
| 262 |
+
gold = example.gold_text
|
| 263 |
+
out: list[str] = []
|
| 264 |
+
seen = {gold}
|
| 265 |
+
for text in example.hard_negatives:
|
| 266 |
+
if text not in seen:
|
| 267 |
+
out.append(text)
|
| 268 |
+
seen.add(text)
|
| 269 |
+
pid = example.meta.get("pair_id")
|
| 270 |
+
if pid:
|
| 271 |
+
for text in self.pair_golds.get(str(pid), []):
|
| 272 |
+
if text not in seen:
|
| 273 |
+
out.append(text)
|
| 274 |
+
seen.add(text)
|
| 275 |
+
return out
|
| 276 |
+
|
| 277 |
+
def build(
|
| 278 |
+
self,
|
| 279 |
+
example: DecisionExample,
|
| 280 |
+
rng: random.Random,
|
| 281 |
+
k: Optional[int] = None,
|
| 282 |
+
) -> AugmentedSet:
|
| 283 |
+
k = int(k if k is not None else self.sample_k(rng))
|
| 284 |
+
k = max(self.k_min, min(self.k_max, k))
|
| 285 |
+
gold = example.gold_text
|
| 286 |
+
chosen = [gold]
|
| 287 |
+
chosen_set = {gold}
|
| 288 |
+
|
| 289 |
+
for text in self._hard_negatives(example):
|
| 290 |
+
if len(chosen) >= k:
|
| 291 |
+
break
|
| 292 |
+
if text not in chosen_set:
|
| 293 |
+
chosen.append(text)
|
| 294 |
+
chosen_set.add(text)
|
| 295 |
+
|
| 296 |
+
# Fill remaining slots from the distractor pool (other labels' texts, etc.).
|
| 297 |
+
candidates = [t for t in self.pool if t not in chosen_set]
|
| 298 |
+
rng.shuffle(candidates)
|
| 299 |
+
for text in candidates:
|
| 300 |
+
if len(chosen) >= k:
|
| 301 |
+
break
|
| 302 |
+
chosen.append(text)
|
| 303 |
+
chosen_set.add(text)
|
| 304 |
+
|
| 305 |
+
filler = 0
|
| 306 |
+
while len(chosen) < k:
|
| 307 |
+
dummy = f"[distractor {filler}]"
|
| 308 |
+
filler += 1
|
| 309 |
+
if dummy not in chosen_set:
|
| 310 |
+
chosen.append(dummy)
|
| 311 |
+
chosen_set.add(dummy)
|
| 312 |
+
|
| 313 |
+
chosen = chosen[:k]
|
| 314 |
+
# Gold is at index 0 of `chosen` before shuffle. Remap via the permutation,
|
| 315 |
+
# not str.index, so duplicate strings cannot attach the label to the wrong slot.
|
| 316 |
+
perm = list(range(k))
|
| 317 |
+
rng.shuffle(perm)
|
| 318 |
+
shuffled = [chosen[i] for i in perm]
|
| 319 |
+
new_label = perm.index(0)
|
| 320 |
+
return AugmentedSet(options=shuffled, label=new_label, k=k)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
class DecisionDataset(Dataset):
|
| 324 |
+
def __init__(
|
| 325 |
+
self,
|
| 326 |
+
examples: Sequence[DecisionExample],
|
| 327 |
+
augmenter: SetAugmenter,
|
| 328 |
+
*,
|
| 329 |
+
seed: int,
|
| 330 |
+
augment: bool = True,
|
| 331 |
+
fixed_k: Optional[int] = None,
|
| 332 |
+
epoch: int = 0,
|
| 333 |
+
):
|
| 334 |
+
self.examples = list(examples)
|
| 335 |
+
self.augmenter = augmenter
|
| 336 |
+
self.seed = int(seed)
|
| 337 |
+
self.augment = augment
|
| 338 |
+
self.fixed_k = fixed_k
|
| 339 |
+
self.epoch = int(epoch)
|
| 340 |
+
|
| 341 |
+
def set_epoch(self, epoch: int) -> None:
|
| 342 |
+
self.epoch = int(epoch)
|
| 343 |
+
|
| 344 |
+
def __len__(self) -> int:
|
| 345 |
+
return len(self.examples)
|
| 346 |
+
|
| 347 |
+
def _rng(self, index: int) -> random.Random:
|
| 348 |
+
# Deterministic per (epoch, index) so a seeded DataLoader is reproducible.
|
| 349 |
+
return random.Random(self.seed + 1_000_003 * (self.epoch + 1) + index)
|
| 350 |
+
|
| 351 |
+
def __getitem__(self, index: int) -> dict[str, Any]:
|
| 352 |
+
ex = self.examples[index]
|
| 353 |
+
rng = self._rng(index)
|
| 354 |
+
if self.augment:
|
| 355 |
+
aug = self.augmenter.build(ex, rng, k=self.fixed_k)
|
| 356 |
+
options, label = aug.options, aug.label
|
| 357 |
+
else:
|
| 358 |
+
options, label = list(ex.options), int(ex.label)
|
| 359 |
+
if self.fixed_k is not None:
|
| 360 |
+
aug = self.augmenter.build(ex, rng, k=self.fixed_k)
|
| 361 |
+
options, label = aug.options, aug.label
|
| 362 |
+
return {
|
| 363 |
+
"context": ex.context,
|
| 364 |
+
"options": options,
|
| 365 |
+
"label": label,
|
| 366 |
+
"task_type": ex.task_type,
|
| 367 |
+
"k": len(options),
|
| 368 |
+
"gold_text": ex.gold_text,
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
class DecisionCollator:
|
| 373 |
+
"""Pad variable-K option lists to Kmax and tokenize."""
|
| 374 |
+
|
| 375 |
+
def __init__(
|
| 376 |
+
self,
|
| 377 |
+
tokenizer: Any,
|
| 378 |
+
*,
|
| 379 |
+
k_max: int,
|
| 380 |
+
max_context_tokens: int,
|
| 381 |
+
max_option_tokens: int,
|
| 382 |
+
):
|
| 383 |
+
self.tokenizer = tokenizer
|
| 384 |
+
self.k_max = k_max
|
| 385 |
+
self.max_context_tokens = max_context_tokens
|
| 386 |
+
self.max_option_tokens = max_option_tokens
|
| 387 |
+
|
| 388 |
+
def _tok(self, texts: list[str], max_length: int) -> dict[str, torch.Tensor]:
|
| 389 |
+
if getattr(self.tokenizer, "is_hash_tokenizer", False):
|
| 390 |
+
return self.tokenizer(texts, max_length=max_length)
|
| 391 |
+
return self.tokenizer(
|
| 392 |
+
texts,
|
| 393 |
+
padding="max_length",
|
| 394 |
+
truncation=True,
|
| 395 |
+
max_length=max_length,
|
| 396 |
+
return_tensors="pt",
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
def __call__(self, batch: list[dict[str, Any]]) -> dict[str, torch.Tensor]:
|
| 400 |
+
bsz = len(batch)
|
| 401 |
+
kmax = self.k_max
|
| 402 |
+
contexts = [row["context"] for row in batch]
|
| 403 |
+
ctx = self._tok(contexts, self.max_context_tokens)
|
| 404 |
+
|
| 405 |
+
opt_ids = torch.full(
|
| 406 |
+
(bsz, kmax, self.max_option_tokens),
|
| 407 |
+
fill_value=int(getattr(self.tokenizer, "pad_token_id", 0) or 0),
|
| 408 |
+
dtype=torch.long,
|
| 409 |
+
)
|
| 410 |
+
opt_attn = torch.zeros(bsz, kmax, self.max_option_tokens, dtype=torch.long)
|
| 411 |
+
opt_mask = torch.zeros(bsz, kmax, dtype=torch.bool)
|
| 412 |
+
labels = torch.zeros(bsz, dtype=torch.long)
|
| 413 |
+
|
| 414 |
+
flat_opts: list[str] = []
|
| 415 |
+
coords: list[tuple[int, int]] = []
|
| 416 |
+
for b, row in enumerate(batch):
|
| 417 |
+
options = row["options"]
|
| 418 |
+
if len(options) > kmax:
|
| 419 |
+
raise ValueError(f"example has K={len(options)} > k_max={kmax}")
|
| 420 |
+
lab = int(row["label"])
|
| 421 |
+
if not 0 <= lab < len(options):
|
| 422 |
+
raise ValueError("label not in real option range after shuffle")
|
| 423 |
+
labels[b] = lab
|
| 424 |
+
for i, text in enumerate(options):
|
| 425 |
+
opt_mask[b, i] = True
|
| 426 |
+
flat_opts.append(text)
|
| 427 |
+
coords.append((b, i))
|
| 428 |
+
|
| 429 |
+
if flat_opts:
|
| 430 |
+
tok = self._tok(flat_opts, self.max_option_tokens)
|
| 431 |
+
for n, (b, i) in enumerate(coords):
|
| 432 |
+
opt_ids[b, i] = tok["input_ids"][n]
|
| 433 |
+
opt_attn[b, i] = tok["attention_mask"][n]
|
| 434 |
+
|
| 435 |
+
return {
|
| 436 |
+
"ctx_ids": ctx["input_ids"],
|
| 437 |
+
"ctx_mask": ctx["attention_mask"],
|
| 438 |
+
"opt_ids": opt_ids,
|
| 439 |
+
"opt_mask": opt_mask,
|
| 440 |
+
"opt_attn": opt_attn,
|
| 441 |
+
"y": labels,
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def iter_k_views(
|
| 446 |
+
examples: Sequence[DecisionExample],
|
| 447 |
+
augmenter: SetAugmenter,
|
| 448 |
+
k_values: Sequence[int],
|
| 449 |
+
*,
|
| 450 |
+
seed: int,
|
| 451 |
+
) -> Iterator[tuple[int, DecisionDataset]]:
|
| 452 |
+
"""Deterministic fixed-K holdout views for K-stratified eval."""
|
| 453 |
+
for k in k_values:
|
| 454 |
+
ds = DecisionDataset(
|
| 455 |
+
examples,
|
| 456 |
+
augmenter,
|
| 457 |
+
seed=seed + 17 * int(k),
|
| 458 |
+
augment=True,
|
| 459 |
+
fixed_k=int(k),
|
| 460 |
+
epoch=0,
|
| 461 |
+
)
|
| 462 |
+
yield int(k), ds
|
pivot_infer.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Serving contract (§8): context + option list → {choice, index, probs}."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
from typing import Any, Optional
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
from .pivot_data import DecisionCollator
|
| 12 |
+
from .pivot_model import SetBrierEncoder, build_tokenizer
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@torch.no_grad()
|
| 16 |
+
def predict(
|
| 17 |
+
model: SetBrierEncoder,
|
| 18 |
+
tokenizer: Any,
|
| 19 |
+
context: str,
|
| 20 |
+
options: list[str],
|
| 21 |
+
*,
|
| 22 |
+
max_context_tokens: int,
|
| 23 |
+
max_option_tokens: int,
|
| 24 |
+
device: Optional[torch.device] = None,
|
| 25 |
+
) -> dict[str, Any]:
|
| 26 |
+
"""One-forward decision. ``probs`` has length K and sums to 1. No generation."""
|
| 27 |
+
if len(options) < 2:
|
| 28 |
+
raise ValueError("need at least two options")
|
| 29 |
+
device = device or next(model.parameters()).device
|
| 30 |
+
collator = DecisionCollator(
|
| 31 |
+
tokenizer,
|
| 32 |
+
k_max=len(options),
|
| 33 |
+
max_context_tokens=max_context_tokens,
|
| 34 |
+
max_option_tokens=max_option_tokens,
|
| 35 |
+
)
|
| 36 |
+
batch = collator(
|
| 37 |
+
[{"context": context, "options": options, "label": 0, "k": len(options), "gold_text": options[0]}]
|
| 38 |
+
)
|
| 39 |
+
batch = {k: v.to(device) for k, v in batch.items() if k != "y"}
|
| 40 |
+
return model.decide(
|
| 41 |
+
ctx_ids=batch["ctx_ids"],
|
| 42 |
+
ctx_mask=batch["ctx_mask"],
|
| 43 |
+
opt_ids=batch["opt_ids"],
|
| 44 |
+
opt_mask=batch["opt_mask"],
|
| 45 |
+
opt_attn=batch["opt_attn"],
|
| 46 |
+
option_texts=options,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@torch.no_grad()
|
| 52 |
+
def decide_typed(
|
| 53 |
+
model: SetBrierEncoder,
|
| 54 |
+
tokenizer: Any,
|
| 55 |
+
state: str,
|
| 56 |
+
questions: list[dict[str, Any]],
|
| 57 |
+
*,
|
| 58 |
+
max_context_tokens: int,
|
| 59 |
+
max_option_tokens: int,
|
| 60 |
+
device: Optional[torch.device] = None,
|
| 61 |
+
model_id: str = "Pivot-Alpha",
|
| 62 |
+
) -> dict[str, Any]:
|
| 63 |
+
"""Jev-style: one unstructured state → many typed probabilistic decisions.
|
| 64 |
+
|
| 65 |
+
Each question dict:
|
| 66 |
+
id: str
|
| 67 |
+
primitive: "choice" | "noul" | "score"
|
| 68 |
+
options: list[str] (K>=2)
|
| 69 |
+
description: optional str
|
| 70 |
+
"""
|
| 71 |
+
from .pivot_serving_schema import build_response, build_typed_decision
|
| 72 |
+
|
| 73 |
+
if not questions:
|
| 74 |
+
raise ValueError("need at least one typed question")
|
| 75 |
+
decisions = []
|
| 76 |
+
for q in questions:
|
| 77 |
+
prim = str(q.get("primitive", "choice"))
|
| 78 |
+
if prim not in {"choice", "noul", "score"}:
|
| 79 |
+
raise ValueError(f"bad primitive {prim}")
|
| 80 |
+
options = list(q["options"])
|
| 81 |
+
raw = predict(
|
| 82 |
+
model,
|
| 83 |
+
tokenizer,
|
| 84 |
+
state,
|
| 85 |
+
options,
|
| 86 |
+
max_context_tokens=max_context_tokens,
|
| 87 |
+
max_option_tokens=max_option_tokens,
|
| 88 |
+
device=device,
|
| 89 |
+
)
|
| 90 |
+
decisions.append(
|
| 91 |
+
build_typed_decision(
|
| 92 |
+
decision_id=str(q.get("id", f"d{len(decisions)}")),
|
| 93 |
+
primitive=prim, # type: ignore[arg-type]
|
| 94 |
+
options=options,
|
| 95 |
+
index=int(raw["index"]),
|
| 96 |
+
probs=[float(x) for x in raw["probs"]],
|
| 97 |
+
description=q.get("description"),
|
| 98 |
+
)
|
| 99 |
+
)
|
| 100 |
+
return build_response(state=state, decisions=decisions, model_id=model_id)
|
pivot_model.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Set-Brier Encoder: LFM2.5 (or stub) + option-set scorer + masked softmax.
|
| 2 |
+
|
| 3 |
+
The same probability vector ``p`` is used for training losses and inference.
|
| 4 |
+
Padded option slots are filled with -inf before softmax and never receive
|
| 5 |
+
softmax mass or loss. Pad option content is zeroed before the scorer so it
|
| 6 |
+
cannot enter ``p``.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import hashlib
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from typing import Any, Optional
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
import torch.nn.functional as F
|
| 18 |
+
|
| 19 |
+
PAD_ID = 0
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def masked_mean_pool(last_hidden: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 23 |
+
"""Mean-pool non-padding tokens of last_hidden_state. Enc(·) ∈ R^d."""
|
| 24 |
+
mask = attention_mask.unsqueeze(-1).to(dtype=last_hidden.dtype)
|
| 25 |
+
summed = (last_hidden * mask).sum(dim=1)
|
| 26 |
+
denom = mask.sum(dim=1).clamp_min(1e-6)
|
| 27 |
+
return summed / denom
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def mask_option_logits(logits: torch.Tensor, opt_mask: torch.Tensor) -> torch.Tensor:
|
| 31 |
+
"""Replace pad slots with dtype.min so they get zero softmax mass.
|
| 32 |
+
|
| 33 |
+
Uses ``masked_fill`` (not a post-softmax multiply) so pads do not affect
|
| 34 |
+
the partition function of real options.
|
| 35 |
+
"""
|
| 36 |
+
fill = torch.finfo(logits.dtype).min
|
| 37 |
+
return logits.masked_fill(~opt_mask.bool(), fill)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class HashTokenizer:
|
| 41 |
+
"""Deterministic whitespace tokenizer for stub / smoke runs (no HF download)."""
|
| 42 |
+
|
| 43 |
+
def __init__(self, vocab_size: int = 256, pad_token_id: int = PAD_ID):
|
| 44 |
+
if vocab_size < 4:
|
| 45 |
+
raise ValueError("vocab_size must be >= 4")
|
| 46 |
+
self.vocab_size = vocab_size
|
| 47 |
+
self.pad_token_id = pad_token_id
|
| 48 |
+
self.unk_token_id = 1
|
| 49 |
+
self.is_hash_tokenizer = True
|
| 50 |
+
|
| 51 |
+
def token_id(self, token: str) -> int:
|
| 52 |
+
digest = hashlib.md5(token.encode("utf-8")).digest()
|
| 53 |
+
return 2 + (int.from_bytes(digest[:4], "little") % (self.vocab_size - 2))
|
| 54 |
+
|
| 55 |
+
def encode(self, text: str, max_length: int) -> tuple[list[int], list[int]]:
|
| 56 |
+
pieces = text.lower().split() or ["<empty>"]
|
| 57 |
+
ids = [self.token_id(p) for p in pieces][:max_length]
|
| 58 |
+
attn = [1] * len(ids)
|
| 59 |
+
while len(ids) < max_length:
|
| 60 |
+
ids.append(self.pad_token_id)
|
| 61 |
+
attn.append(0)
|
| 62 |
+
return ids, attn
|
| 63 |
+
|
| 64 |
+
def __call__(
|
| 65 |
+
self,
|
| 66 |
+
texts: list[str],
|
| 67 |
+
*,
|
| 68 |
+
max_length: int,
|
| 69 |
+
padding: str = "max_length",
|
| 70 |
+
truncation: bool = True,
|
| 71 |
+
return_tensors: Optional[str] = "pt",
|
| 72 |
+
) -> dict[str, torch.Tensor]:
|
| 73 |
+
del padding, truncation
|
| 74 |
+
ids, attn = zip(*(self.encode(t, max_length) for t in texts))
|
| 75 |
+
out = {
|
| 76 |
+
"input_ids": torch.tensor(ids, dtype=torch.long),
|
| 77 |
+
"attention_mask": torch.tensor(attn, dtype=torch.long),
|
| 78 |
+
}
|
| 79 |
+
if return_tensors != "pt":
|
| 80 |
+
raise ValueError("HashTokenizer only supports return_tensors='pt'")
|
| 81 |
+
return out
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class StubEncoder(nn.Module):
|
| 85 |
+
"""Tiny embedding encoder so CI/smoke never downloads the HF backbone."""
|
| 86 |
+
|
| 87 |
+
def __init__(self, vocab_size: int, hidden_size: int, pad_token_id: int = PAD_ID):
|
| 88 |
+
super().__init__()
|
| 89 |
+
self.hidden_size = hidden_size
|
| 90 |
+
self.embed = nn.Embedding(vocab_size, hidden_size, padding_idx=pad_token_id)
|
| 91 |
+
|
| 92 |
+
def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 93 |
+
return masked_mean_pool(self.embed(input_ids), attention_mask)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class HuggingFaceEncoder(nn.Module):
|
| 97 |
+
"""LiquidAI LFM2.5-Encoder body + masked mean-pool."""
|
| 98 |
+
|
| 99 |
+
def __init__(
|
| 100 |
+
self,
|
| 101 |
+
model_id: str,
|
| 102 |
+
revision: str,
|
| 103 |
+
*,
|
| 104 |
+
trust_remote_code: bool = True,
|
| 105 |
+
torch_dtype: Optional[torch.dtype] = None,
|
| 106 |
+
lora: Optional[dict[str, Any]] = None,
|
| 107 |
+
):
|
| 108 |
+
super().__init__()
|
| 109 |
+
from transformers import AutoModel
|
| 110 |
+
|
| 111 |
+
kwargs: dict[str, Any] = {
|
| 112 |
+
"revision": revision,
|
| 113 |
+
"trust_remote_code": trust_remote_code,
|
| 114 |
+
}
|
| 115 |
+
if torch_dtype is not None:
|
| 116 |
+
kwargs["torch_dtype"] = torch_dtype
|
| 117 |
+
self.model = AutoModel.from_pretrained(model_id, **kwargs)
|
| 118 |
+
self.hidden_size = int(getattr(self.model.config, "hidden_size", 1024))
|
| 119 |
+
if lora and lora.get("enabled"):
|
| 120 |
+
self.model = _wrap_lora(self.model, lora)
|
| 121 |
+
|
| 122 |
+
def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 123 |
+
out = self.model(input_ids=input_ids, attention_mask=attention_mask)
|
| 124 |
+
hidden = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]
|
| 125 |
+
return masked_mean_pool(hidden, attention_mask)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _wrap_lora(model: nn.Module, lora: dict[str, Any]) -> nn.Module:
|
| 129 |
+
try:
|
| 130 |
+
from peft import LoraConfig, get_peft_model
|
| 131 |
+
except ImportError as exc:
|
| 132 |
+
raise ImportError("LoRA requested but peft is not installed. pip install dsbt[lora]") from exc
|
| 133 |
+
cfg = LoraConfig(
|
| 134 |
+
r=int(lora.get("r", 16)),
|
| 135 |
+
lora_alpha=int(lora.get("alpha", 32)),
|
| 136 |
+
lora_dropout=float(lora.get("dropout", 0.05)),
|
| 137 |
+
target_modules=list(lora.get("target_modules") or ["q_proj", "v_proj"]),
|
| 138 |
+
bias="none",
|
| 139 |
+
task_type="FEATURE_EXTRACTION",
|
| 140 |
+
)
|
| 141 |
+
return get_peft_model(model, cfg)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class MLPScorer(nn.Module):
|
| 145 |
+
"""Default scorer: 2-layer MLP on [h_c; h_o; h_c ⊙ h_o]."""
|
| 146 |
+
|
| 147 |
+
def __init__(self, hidden_size: int, mlp_hidden: int, dropout: float = 0.0):
|
| 148 |
+
super().__init__()
|
| 149 |
+
in_dim = 3 * hidden_size
|
| 150 |
+
self.net = nn.Sequential(
|
| 151 |
+
nn.Linear(in_dim, mlp_hidden),
|
| 152 |
+
nn.GELU(),
|
| 153 |
+
nn.Dropout(dropout),
|
| 154 |
+
nn.Linear(mlp_hidden, 1),
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
def forward(self, h_c: torch.Tensor, h_o: torch.Tensor) -> torch.Tensor:
|
| 158 |
+
# h_c: [B, d], h_o: [B, K, d] -> scores [B, K]
|
| 159 |
+
# Align dtypes: HF encoder may be bf16 while scorer Linear defaults to fp32.
|
| 160 |
+
wdtype = next(self.net.parameters()).dtype
|
| 161 |
+
h_c = h_c.to(dtype=wdtype)
|
| 162 |
+
h_o = h_o.to(dtype=wdtype)
|
| 163 |
+
h_c_exp = h_c.unsqueeze(1).expand_as(h_o)
|
| 164 |
+
feat = torch.cat([h_c_exp, h_o, h_c_exp * h_o], dim=-1)
|
| 165 |
+
return self.net(feat).squeeze(-1)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class DotScorer(nn.Module):
|
| 169 |
+
"""Allowed simpler baseline: s_i = h_c^T h_{o,i} / sqrt(d)."""
|
| 170 |
+
|
| 171 |
+
def __init__(self, hidden_size: int):
|
| 172 |
+
super().__init__()
|
| 173 |
+
self.scale = hidden_size ** 0.5
|
| 174 |
+
|
| 175 |
+
def forward(self, h_c: torch.Tensor, h_o: torch.Tensor) -> torch.Tensor:
|
| 176 |
+
# Match dtype if encoder/scorer mix bf16/fp32
|
| 177 |
+
if h_c.dtype != h_o.dtype:
|
| 178 |
+
h_o = h_o.to(dtype=h_c.dtype)
|
| 179 |
+
return torch.einsum("bd,bkd->bk", h_c, h_o) / self.scale
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@dataclass
|
| 183 |
+
class SetBrierOutput:
|
| 184 |
+
"""Forward bundle. ``probs`` is the inference distribution (same p as train)."""
|
| 185 |
+
|
| 186 |
+
logits: torch.Tensor # masked s [B, Kmax]
|
| 187 |
+
probs: torch.Tensor # p = softmax(s) [B, Kmax]
|
| 188 |
+
pred_index: torch.Tensor # argmax among real options [B]
|
| 189 |
+
h_c: torch.Tensor
|
| 190 |
+
h_o: torch.Tensor
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
class SetBrierEncoder(nn.Module):
|
| 194 |
+
"""Decision encoder: Enc(context), Enc(options), score, masked softmax."""
|
| 195 |
+
|
| 196 |
+
def __init__(self, encoder: nn.Module, scorer: nn.Module):
|
| 197 |
+
super().__init__()
|
| 198 |
+
self.encoder = encoder
|
| 199 |
+
self.scorer = scorer
|
| 200 |
+
|
| 201 |
+
@property
|
| 202 |
+
def hidden_size(self) -> int:
|
| 203 |
+
return int(self.encoder.hidden_size)
|
| 204 |
+
|
| 205 |
+
def encode(self, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
| 206 |
+
return self.encoder(input_ids=input_ids, attention_mask=attention_mask)
|
| 207 |
+
|
| 208 |
+
def score_options(
|
| 209 |
+
self,
|
| 210 |
+
h_c: torch.Tensor,
|
| 211 |
+
h_o: torch.Tensor,
|
| 212 |
+
opt_mask: torch.Tensor,
|
| 213 |
+
) -> torch.Tensor:
|
| 214 |
+
"""Score options. Pad embeddings are zeroed; pad logits are -inf.
|
| 215 |
+
|
| 216 |
+
Pads are never allowed to contribute to ``p``: zeroing removes pad
|
| 217 |
+
*content* from the scorer, and ``masked_fill`` removes pad *slots*
|
| 218 |
+
from the softmax partition.
|
| 219 |
+
"""
|
| 220 |
+
mask = opt_mask.unsqueeze(-1).to(dtype=h_o.dtype)
|
| 221 |
+
h_o_real = h_o * mask
|
| 222 |
+
logits = self.scorer(h_c, h_o_real)
|
| 223 |
+
return mask_option_logits(logits, opt_mask)
|
| 224 |
+
|
| 225 |
+
def forward(
|
| 226 |
+
self,
|
| 227 |
+
ctx_ids: torch.Tensor,
|
| 228 |
+
ctx_mask: torch.Tensor,
|
| 229 |
+
opt_ids: torch.Tensor,
|
| 230 |
+
opt_mask: torch.Tensor,
|
| 231 |
+
opt_attn: torch.Tensor,
|
| 232 |
+
) -> SetBrierOutput:
|
| 233 |
+
"""
|
| 234 |
+
ctx_ids: [B, Lc]
|
| 235 |
+
ctx_mask: [B, Lc]
|
| 236 |
+
opt_ids: [B, Kmax, Lo]
|
| 237 |
+
opt_mask: [B, Kmax]
|
| 238 |
+
opt_attn: [B, Kmax, Lo]
|
| 239 |
+
"""
|
| 240 |
+
h_c = self.encode(ctx_ids, ctx_mask)
|
| 241 |
+
bsz, kmax, lo = opt_ids.shape
|
| 242 |
+
flat_ids = opt_ids.reshape(bsz * kmax, lo)
|
| 243 |
+
flat_attn = opt_attn.reshape(bsz * kmax, lo)
|
| 244 |
+
h_o = self.encode(flat_ids, flat_attn).reshape(bsz, kmax, -1)
|
| 245 |
+
logits = self.score_options(h_c, h_o, opt_mask)
|
| 246 |
+
# Live softmax — this is the inference p and the Brier p. Do not detach.
|
| 247 |
+
# Research hard rule: compute p in fp32 even when logits are bf16
|
| 248 |
+
# (pad -inf / partition stability for calibration).
|
| 249 |
+
probs = F.softmax(logits.float(), dim=-1)
|
| 250 |
+
pred_index = probs.argmax(dim=-1)
|
| 251 |
+
return SetBrierOutput(
|
| 252 |
+
logits=logits,
|
| 253 |
+
probs=probs,
|
| 254 |
+
pred_index=pred_index,
|
| 255 |
+
h_c=h_c,
|
| 256 |
+
h_o=h_o,
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
@torch.no_grad()
|
| 260 |
+
def decide(
|
| 261 |
+
self,
|
| 262 |
+
ctx_ids: torch.Tensor,
|
| 263 |
+
ctx_mask: torch.Tensor,
|
| 264 |
+
opt_ids: torch.Tensor,
|
| 265 |
+
opt_mask: torch.Tensor,
|
| 266 |
+
opt_attn: torch.Tensor,
|
| 267 |
+
option_texts: list[str],
|
| 268 |
+
) -> dict[str, Any]:
|
| 269 |
+
"""Serving contract: {choice, index, probs} with probs over real options."""
|
| 270 |
+
self.eval()
|
| 271 |
+
out = self.forward(ctx_ids, ctx_mask, opt_ids, opt_mask, opt_attn)
|
| 272 |
+
# Packed real options occupy mask=1 slots (collate puts them at 0..K-1).
|
| 273 |
+
# CPU copy for JSON only; this is not a calibration map.
|
| 274 |
+
probs = out.probs[0][opt_mask[0].bool()].cpu().float()
|
| 275 |
+
probs = probs.clamp_min(0)
|
| 276 |
+
z = probs.sum().clamp_min(1e-12)
|
| 277 |
+
probs = probs / z
|
| 278 |
+
index = int(out.pred_index[0].item())
|
| 279 |
+
return {
|
| 280 |
+
"choice": option_texts[index],
|
| 281 |
+
"index": index,
|
| 282 |
+
"probs": probs.tolist(),
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
def encoder_parameters(self):
|
| 286 |
+
return self.encoder.parameters()
|
| 287 |
+
|
| 288 |
+
def scorer_parameters(self):
|
| 289 |
+
return self.scorer.parameters()
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def build_tokenizer(cfg: dict[str, Any]):
|
| 293 |
+
backbone = cfg["backbone"]
|
| 294 |
+
if backbone.get("encoder", "huggingface") == "stub":
|
| 295 |
+
return HashTokenizer(vocab_size=int(backbone.get("stub_vocab_size", 256)))
|
| 296 |
+
from transformers import AutoTokenizer
|
| 297 |
+
|
| 298 |
+
return AutoTokenizer.from_pretrained(
|
| 299 |
+
backbone["model_id"],
|
| 300 |
+
revision=backbone["revision"],
|
| 301 |
+
trust_remote_code=bool(backbone.get("trust_remote_code", True)),
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _torch_dtype(precision: str, device: torch.device) -> Optional[torch.dtype]:
|
| 306 |
+
if precision == "bf16" and device.type == "cuda" and torch.cuda.is_bf16_supported():
|
| 307 |
+
return torch.bfloat16
|
| 308 |
+
return torch.float32
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def build_model(cfg: dict[str, Any], device: Optional[torch.device] = None) -> SetBrierEncoder:
|
| 312 |
+
"""Build SetBrierEncoder from config. Stub path never touches HuggingFace weights."""
|
| 313 |
+
device = device or torch.device("cpu")
|
| 314 |
+
backbone = cfg["backbone"]
|
| 315 |
+
scorer_cfg = cfg["scorer"]
|
| 316 |
+
mode = backbone.get("encoder", "huggingface")
|
| 317 |
+
precision = cfg["train"].get("precision", "fp32")
|
| 318 |
+
dtype = _torch_dtype(precision, device)
|
| 319 |
+
|
| 320 |
+
if mode == "stub":
|
| 321 |
+
hidden = int(backbone.get("stub_hidden_size", backbone.get("hidden_size", 32)))
|
| 322 |
+
encoder: nn.Module = StubEncoder(
|
| 323 |
+
vocab_size=int(backbone.get("stub_vocab_size", 256)),
|
| 324 |
+
hidden_size=hidden,
|
| 325 |
+
)
|
| 326 |
+
elif mode == "huggingface":
|
| 327 |
+
encoder = HuggingFaceEncoder(
|
| 328 |
+
model_id=backbone["model_id"],
|
| 329 |
+
revision=backbone["revision"],
|
| 330 |
+
trust_remote_code=bool(backbone.get("trust_remote_code", True)),
|
| 331 |
+
torch_dtype=dtype if dtype != torch.float32 else None,
|
| 332 |
+
lora=cfg["train"].get("lora"),
|
| 333 |
+
)
|
| 334 |
+
hidden = encoder.hidden_size
|
| 335 |
+
else:
|
| 336 |
+
raise ValueError(f"unknown backbone.encoder {mode!r}")
|
| 337 |
+
|
| 338 |
+
stype = scorer_cfg.get("type", "mlp")
|
| 339 |
+
if stype == "mlp":
|
| 340 |
+
scorer: nn.Module = MLPScorer(
|
| 341 |
+
hidden_size=hidden,
|
| 342 |
+
mlp_hidden=int(scorer_cfg.get("hidden_size", hidden)),
|
| 343 |
+
dropout=float(scorer_cfg.get("dropout", 0.0)),
|
| 344 |
+
)
|
| 345 |
+
elif stype == "dot":
|
| 346 |
+
scorer = DotScorer(hidden_size=hidden)
|
| 347 |
+
else:
|
| 348 |
+
raise ValueError(f"unknown scorer.type {stype!r}; use 'mlp' or 'dot'")
|
| 349 |
+
|
| 350 |
+
model = SetBrierEncoder(encoder, scorer)
|
| 351 |
+
model.to(device)
|
| 352 |
+
# Keep encoder+scorer on the same compute dtype (bf16 on H100). Softmax p
|
| 353 |
+
# stays fp32 in forward() — that is the calibration contract.
|
| 354 |
+
if dtype == torch.bfloat16:
|
| 355 |
+
model.to(dtype=dtype)
|
| 356 |
+
return model
|
pivot_serving_schema.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pivot-Alpha / Jev-aligned typed decision schema (English).
|
| 2 |
+
|
| 3 |
+
Jev: unstructured state in → typed probabilistic decisions out.
|
| 4 |
+
We are NOT a free-text generator. Each call returns structured decisions
|
| 5 |
+
the host program can wire into a workflow.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Any, Literal, Optional
|
| 10 |
+
|
| 11 |
+
Primitive = Literal["choice", "noul", "score"]
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def build_typed_decision(
|
| 15 |
+
*,
|
| 16 |
+
decision_id: str,
|
| 17 |
+
primitive: Primitive,
|
| 18 |
+
options: list[str],
|
| 19 |
+
index: int,
|
| 20 |
+
probs: list[float],
|
| 21 |
+
description: Optional[str] = None,
|
| 22 |
+
) -> dict[str, Any]:
|
| 23 |
+
if len(options) != len(probs):
|
| 24 |
+
raise ValueError("options/probs length mismatch")
|
| 25 |
+
if not (0 <= index < len(options)):
|
| 26 |
+
raise ValueError("index out of range")
|
| 27 |
+
# named map for program consumption
|
| 28 |
+
prob_map = {str(opt): float(p) for opt, p in zip(options, probs)}
|
| 29 |
+
value = options[index]
|
| 30 |
+
out: dict[str, Any] = {
|
| 31 |
+
"id": decision_id,
|
| 32 |
+
"primitive": primitive,
|
| 33 |
+
"description": description,
|
| 34 |
+
"options": list(options),
|
| 35 |
+
"index": int(index),
|
| 36 |
+
"value": value,
|
| 37 |
+
"probs": prob_map,
|
| 38 |
+
"prob_vector": [float(p) for p in probs],
|
| 39 |
+
"confidence": float(max(probs) if probs else 0.0),
|
| 40 |
+
}
|
| 41 |
+
if primitive == "noul":
|
| 42 |
+
# binary probabilistic decision (yes-mass = prob of first true-like option if present)
|
| 43 |
+
true_aliases = {"true", "yes", "y", "1"}
|
| 44 |
+
true_idx = next((i for i, o in enumerate(options) if str(o).lower() in true_aliases), index)
|
| 45 |
+
out["p_true"] = float(probs[true_idx])
|
| 46 |
+
if primitive == "score":
|
| 47 |
+
# expected score if options are ordered numeric levels; else keep categorical
|
| 48 |
+
try:
|
| 49 |
+
levels = [float(o) for o in options]
|
| 50 |
+
out["expected"] = float(sum(l * p for l, p in zip(levels, probs)))
|
| 51 |
+
except ValueError:
|
| 52 |
+
out["expected"] = None
|
| 53 |
+
return out
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def build_response(
|
| 57 |
+
*,
|
| 58 |
+
state: str,
|
| 59 |
+
decisions: list[dict[str, Any]],
|
| 60 |
+
model_id: str = "Pivot-Alpha",
|
| 61 |
+
) -> dict[str, Any]:
|
| 62 |
+
return {
|
| 63 |
+
"model": model_id,
|
| 64 |
+
"contract": "unstructured_state_in__typed_probabilistic_decisions_out",
|
| 65 |
+
"state": state,
|
| 66 |
+
"decisions": decisions,
|
| 67 |
+
"schema_version": "pivot-alpha-v1",
|
| 68 |
+
}
|
predict_example.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
import json
|
| 3 |
+
from transformers import AutoModel, AutoTokenizer
|
| 4 |
+
|
| 5 |
+
root = Path(__file__).resolve().parent
|
| 6 |
+
tokenizer = AutoTokenizer.from_pretrained(root, trust_remote_code=True, local_files_only=True)
|
| 7 |
+
model = AutoModel.from_pretrained(root, trust_remote_code=True, local_files_only=True).eval()
|
| 8 |
+
request = json.loads((root / "serving/example_request.json").read_text())
|
| 9 |
+
print(json.dumps(model.decide(tokenizer, **request), indent=2))
|
provenance.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"source": {
|
| 3 |
+
"type": "huggingface_dataset",
|
| 4 |
+
"repo": "Yinhaoc/dsbt-cleared-corpus-v1",
|
| 5 |
+
"revision": "0afd5031a19ece346b3287b8804ebb6b7c5a276d",
|
| 6 |
+
"file": "best.pt"
|
| 7 |
+
},
|
| 8 |
+
"checkpoint_sha256": "73d1d2959409229825a39e5a0136cae38541cd5a22d7b4836061683da96d59c8",
|
| 9 |
+
"created_utc": "2026-09-20T21:55:59.682825+00:00",
|
| 10 |
+
"checkpoint": {
|
| 11 |
+
"epoch": 0,
|
| 12 |
+
"global_step": 3102,
|
| 13 |
+
"extra": {
|
| 14 |
+
"epoch": 0,
|
| 15 |
+
"warmup": true,
|
| 16 |
+
"steps_per_sec": 1.8812464419600063,
|
| 17 |
+
"train_L_dec": 0.5377311386806899,
|
| 18 |
+
"train_L_cal": 0.040688835784281306,
|
| 19 |
+
"train_acc": 0.7659171502256609,
|
| 20 |
+
"val_acc": 0.319091796875,
|
| 21 |
+
"val_brier": 0.11288549791788682,
|
| 22 |
+
"val_ece": 0.1171102523803711,
|
| 23 |
+
"seconds": 1648.9068133831024
|
| 24 |
+
},
|
| 25 |
+
"completed_epochs": 1,
|
| 26 |
+
"optimizer_present": true,
|
| 27 |
+
"base_model": "LiquidAI/LFM2.5-Encoder-350M",
|
| 28 |
+
"base_revision": "b886781f7c6f10ca9b7096e21b83e30a073c2f39",
|
| 29 |
+
"configured_epochs": 4,
|
| 30 |
+
"evaluation_batch_saved": false,
|
| 31 |
+
"note": "best.pt is the best epoch, not proof of the final training epoch or eval progress."
|
| 32 |
+
},
|
| 33 |
+
"source_sha256": {
|
| 34 |
+
"__init__.py": "0143657a43a2b1fe4bc7196971f4792087ed0d32ef426c248a2e2474863ea4d9",
|
| 35 |
+
"__main__.py": "df98998af529a0dc97c09725fe512169bdc6c4dd718c908cc381c16de6d904f0",
|
| 36 |
+
"config.py": "3970b69bb4893c5f113dbac75fee901c43c9905a8733789beaec40fe32f7e1df",
|
| 37 |
+
"data.py": "21919e9fd760df1665ac27cc1f63df86edaa312101ccc1c008a0770ce00593da",
|
| 38 |
+
"eval.py": "3e986c0ae827d9457ee1016dedf405cfd21f25b56bc209249427a8c1b62652e9",
|
| 39 |
+
"infer.py": "be72f8a76ac22a8a1cc93d946b61cad499e0c63dac5d54d4b671f7329b8176b5",
|
| 40 |
+
"losses.py": "01a7f50adb43b563cfc66d7bc5782a75e9a1cf7f9170dc86ffaee46a6c702c3d",
|
| 41 |
+
"model.py": "8b17e60eca7f2a54d8579fc88cff8eb70cd1202222e8872c7e3447772df61724",
|
| 42 |
+
"seed.py": "8549ad3cdf0cca42c777b50943101ebddf6bee02e336b5b7f43c4068916b3f5d",
|
| 43 |
+
"serving_schema.py": "8a777f03e595b675dd2d0eb6c12a9247990bb48dd794bb0a806d1c18e297159a",
|
| 44 |
+
"train.py": "8276a95b8d2b6d5f5dfe5cb9072c9b5ee4fa9fb8e401e8e2ebaaff4d5c0ce369"
|
| 45 |
+
}
|
| 46 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch==2.8.0
|
| 2 |
+
transformers==5.17.0
|
| 3 |
+
safetensors==0.8.0
|
| 4 |
+
numpy==1.26.4
|
run_metrics.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"mode": "export_only",
|
| 3 |
+
"training": "not_run",
|
| 4 |
+
"frozen_eval": {
|
| 5 |
+
"status": "skipped",
|
| 6 |
+
"reason": "user_requested_export_only"
|
| 7 |
+
},
|
| 8 |
+
"latency_benchmark": "not_run",
|
| 9 |
+
"typed_smoke": "passed",
|
| 10 |
+
"safetensors": true,
|
| 11 |
+
"onnx": "not_exported",
|
| 12 |
+
"jev_win_verified": false
|
| 13 |
+
}
|
serving/example_request.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"state": "Customer dispute: invoice 120 vs PO 100, age=3d, region=US",
|
| 3 |
+
"questions": [
|
| 4 |
+
{
|
| 5 |
+
"id": "route",
|
| 6 |
+
"primitive": "choice",
|
| 7 |
+
"options": [
|
| 8 |
+
"billing",
|
| 9 |
+
"tech",
|
| 10 |
+
"sales"
|
| 11 |
+
]
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"id": "approve",
|
| 15 |
+
"primitive": "noul",
|
| 16 |
+
"options": [
|
| 17 |
+
"true",
|
| 18 |
+
"false"
|
| 19 |
+
]
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"id": "severity",
|
| 23 |
+
"primitive": "score",
|
| 24 |
+
"options": [
|
| 25 |
+
"0",
|
| 26 |
+
"1",
|
| 27 |
+
"2",
|
| 28 |
+
"3"
|
| 29 |
+
]
|
| 30 |
+
}
|
| 31 |
+
]
|
| 32 |
+
}
|
serving/example_response.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model": "Pivot",
|
| 3 |
+
"contract": "unstructured_state_in__typed_probabilistic_decisions_out",
|
| 4 |
+
"state": "Customer dispute: invoice 120 vs PO 100, age=3d, region=US",
|
| 5 |
+
"decisions": [
|
| 6 |
+
{
|
| 7 |
+
"id": "route",
|
| 8 |
+
"primitive": "choice",
|
| 9 |
+
"description": null,
|
| 10 |
+
"options": [
|
| 11 |
+
"billing",
|
| 12 |
+
"tech",
|
| 13 |
+
"sales"
|
| 14 |
+
],
|
| 15 |
+
"index": 0,
|
| 16 |
+
"value": "billing",
|
| 17 |
+
"probs": {
|
| 18 |
+
"billing": 0.709779679775238,
|
| 19 |
+
"tech": 0.013995149172842503,
|
| 20 |
+
"sales": 0.27622511982917786
|
| 21 |
+
},
|
| 22 |
+
"prob_vector": [
|
| 23 |
+
0.709779679775238,
|
| 24 |
+
0.013995149172842503,
|
| 25 |
+
0.27622511982917786
|
| 26 |
+
],
|
| 27 |
+
"confidence": 0.709779679775238
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"id": "approve",
|
| 31 |
+
"primitive": "noul",
|
| 32 |
+
"description": null,
|
| 33 |
+
"options": [
|
| 34 |
+
"true",
|
| 35 |
+
"false"
|
| 36 |
+
],
|
| 37 |
+
"index": 1,
|
| 38 |
+
"value": "false",
|
| 39 |
+
"probs": {
|
| 40 |
+
"true": 0.44199028611183167,
|
| 41 |
+
"false": 0.5580097436904907
|
| 42 |
+
},
|
| 43 |
+
"prob_vector": [
|
| 44 |
+
0.44199028611183167,
|
| 45 |
+
0.5580097436904907
|
| 46 |
+
],
|
| 47 |
+
"confidence": 0.5580097436904907,
|
| 48 |
+
"p_true": 0.44199028611183167
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"id": "severity",
|
| 52 |
+
"primitive": "score",
|
| 53 |
+
"description": null,
|
| 54 |
+
"options": [
|
| 55 |
+
"0",
|
| 56 |
+
"1",
|
| 57 |
+
"2",
|
| 58 |
+
"3"
|
| 59 |
+
],
|
| 60 |
+
"index": 3,
|
| 61 |
+
"value": "3",
|
| 62 |
+
"probs": {
|
| 63 |
+
"0": 0.007189917378127575,
|
| 64 |
+
"1": 0.22580669820308685,
|
| 65 |
+
"2": 0.04488111659884453,
|
| 66 |
+
"3": 0.7221222519874573
|
| 67 |
+
},
|
| 68 |
+
"prob_vector": [
|
| 69 |
+
0.007189917378127575,
|
| 70 |
+
0.22580669820308685,
|
| 71 |
+
0.04488111659884453,
|
| 72 |
+
0.7221222519874573
|
| 73 |
+
],
|
| 74 |
+
"confidence": 0.7221222519874573,
|
| 75 |
+
"expected": 2.4819356873631477
|
| 76 |
+
}
|
| 77 |
+
],
|
| 78 |
+
"schema_version": "pivot-alpha-v1"
|
| 79 |
+
}
|
serving/schema.json
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
| 3 |
+
"title": "Pivot typed decision response",
|
| 4 |
+
"type": "object",
|
| 5 |
+
"required": [
|
| 6 |
+
"model",
|
| 7 |
+
"contract",
|
| 8 |
+
"state",
|
| 9 |
+
"decisions",
|
| 10 |
+
"schema_version"
|
| 11 |
+
],
|
| 12 |
+
"properties": {
|
| 13 |
+
"model": {
|
| 14 |
+
"const": "Pivot"
|
| 15 |
+
},
|
| 16 |
+
"state": {
|
| 17 |
+
"type": "string"
|
| 18 |
+
},
|
| 19 |
+
"contract": {
|
| 20 |
+
"const": "unstructured_state_in__typed_probabilistic_decisions_out"
|
| 21 |
+
},
|
| 22 |
+
"schema_version": {
|
| 23 |
+
"const": "pivot-alpha-v1"
|
| 24 |
+
},
|
| 25 |
+
"decisions": {
|
| 26 |
+
"type": "array",
|
| 27 |
+
"minItems": 1,
|
| 28 |
+
"items": {
|
| 29 |
+
"type": "object",
|
| 30 |
+
"required": [
|
| 31 |
+
"id",
|
| 32 |
+
"primitive",
|
| 33 |
+
"options",
|
| 34 |
+
"index",
|
| 35 |
+
"value",
|
| 36 |
+
"probs",
|
| 37 |
+
"prob_vector",
|
| 38 |
+
"confidence"
|
| 39 |
+
],
|
| 40 |
+
"properties": {
|
| 41 |
+
"id": {
|
| 42 |
+
"type": "string"
|
| 43 |
+
},
|
| 44 |
+
"primitive": {
|
| 45 |
+
"enum": [
|
| 46 |
+
"choice",
|
| 47 |
+
"noul",
|
| 48 |
+
"score"
|
| 49 |
+
]
|
| 50 |
+
},
|
| 51 |
+
"options": {
|
| 52 |
+
"type": "array",
|
| 53 |
+
"minItems": 2,
|
| 54 |
+
"items": {
|
| 55 |
+
"type": "string"
|
| 56 |
+
}
|
| 57 |
+
},
|
| 58 |
+
"index": {
|
| 59 |
+
"type": "integer",
|
| 60 |
+
"minimum": 0
|
| 61 |
+
},
|
| 62 |
+
"value": {
|
| 63 |
+
"type": "string"
|
| 64 |
+
},
|
| 65 |
+
"probs": {
|
| 66 |
+
"type": "object",
|
| 67 |
+
"additionalProperties": {
|
| 68 |
+
"type": "number",
|
| 69 |
+
"minimum": 0,
|
| 70 |
+
"maximum": 1
|
| 71 |
+
}
|
| 72 |
+
},
|
| 73 |
+
"prob_vector": {
|
| 74 |
+
"type": "array",
|
| 75 |
+
"items": {
|
| 76 |
+
"type": "number",
|
| 77 |
+
"minimum": 0,
|
| 78 |
+
"maximum": 1
|
| 79 |
+
}
|
| 80 |
+
},
|
| 81 |
+
"confidence": {
|
| 82 |
+
"type": "number",
|
| 83 |
+
"minimum": 0,
|
| 84 |
+
"maximum": 1
|
| 85 |
+
},
|
| 86 |
+
"p_true": {
|
| 87 |
+
"type": "number",
|
| 88 |
+
"minimum": 0,
|
| 89 |
+
"maximum": 1
|
| 90 |
+
},
|
| 91 |
+
"expected": {
|
| 92 |
+
"type": [
|
| 93 |
+
"number",
|
| 94 |
+
"null"
|
| 95 |
+
]
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
}
|
tokenizer.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
tokenizer_config.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backend": "tokenizers",
|
| 3 |
+
"bos_token": "<|startoftext|>",
|
| 4 |
+
"clean_up_tokenization_spaces": false,
|
| 5 |
+
"eos_token": "<|im_end|>",
|
| 6 |
+
"is_local": false,
|
| 7 |
+
"local_files_only": false,
|
| 8 |
+
"mask_token": "<|mask|>",
|
| 9 |
+
"model_max_length": 1000000000000000019884624838656,
|
| 10 |
+
"pad_token": "<|pad|>",
|
| 11 |
+
"tokenizer_class": "TokenizersBackend"
|
| 12 |
+
}
|