sysone 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {sysone-0.2.0 → sysone-0.3.0}/PKG-INFO +19 -10
  2. {sysone-0.2.0 → sysone-0.3.0}/README.md +14 -5
  3. {sysone-0.2.0 → sysone-0.3.0}/docs/DESIGN.md +180 -22
  4. {sysone-0.2.0 → sysone-0.3.0}/examples/decoder_bev_decision.ipynb +33 -4
  5. {sysone-0.2.0 → sysone-0.3.0}/examples/decoder_typed_decisions.ipynb +31 -3
  6. {sysone-0.2.0 → sysone-0.3.0}/examples/encoder_bev_decision.ipynb +33 -4
  7. {sysone-0.2.0 → sysone-0.3.0}/examples/encoder_typed_decisions.ipynb +31 -3
  8. sysone-0.3.0/examples/encoder_typed_decisions_ordinal_weight.ipynb +268 -0
  9. {sysone-0.2.0 → sysone-0.3.0}/examples/quickstart.ipynb +9 -2
  10. {sysone-0.2.0 → sysone-0.3.0}/pyproject.toml +5 -4
  11. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/_util.py +11 -3
  12. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/architectures/base.py +5 -1
  13. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/architectures/modernbert.py +5 -0
  14. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/base.py +132 -16
  15. sysone-0.3.0/src/sysone/calibration.py +108 -0
  16. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/checkpoint.py +1 -1
  17. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/evaluation.py +18 -4
  18. sysone-0.3.0/src/sysone/losses.py +39 -0
  19. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/training.py +71 -10
  20. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/tuning.py +1 -1
  21. sysone-0.3.0/src/sysone/version.py +1 -0
  22. sysone-0.3.0/tests/test_autosave.py +118 -0
  23. sysone-0.3.0/tests/test_calibration.py +93 -0
  24. sysone-0.3.0/tests/test_losses.py +93 -0
  25. sysone-0.3.0/tests/test_precision.py +54 -0
  26. {sysone-0.2.0 → sysone-0.3.0}/tests/test_tuning.py +18 -1
  27. sysone-0.2.0/src/sysone/losses.py +0 -17
  28. sysone-0.2.0/src/sysone/version.py +0 -1
  29. {sysone-0.2.0 → sysone-0.3.0}/.gitignore +0 -0
  30. {sysone-0.2.0 → sysone-0.3.0}/LICENSE +0 -0
  31. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/__init__.py +0 -0
  32. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/answers.py +0 -0
  33. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/architectures/__init__.py +0 -0
  34. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/architectures/qwen.py +0 -0
  35. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/architectures/registry.py +0 -0
  36. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/cache.py +0 -0
  37. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/data.py +0 -0
  38. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/encoding.py +0 -0
  39. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/features.py +0 -0
  40. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/heads.py +0 -0
  41. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/layout.py +0 -0
  42. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/models.py +0 -0
  43. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/prompts.py +0 -0
  44. {sysone-0.2.0 → sysone-0.3.0}/src/sysone/questions.py +0 -0
  45. {sysone-0.2.0 → sysone-0.3.0}/tests/__init__.py +0 -0
  46. {sysone-0.2.0 → sysone-0.3.0}/tests/conftest.py +0 -0
  47. {sysone-0.2.0 → sysone-0.3.0}/tests/test_architectures.py +0 -0
  48. {sysone-0.2.0 → sysone-0.3.0}/tests/test_cache.py +0 -0
  49. {sysone-0.2.0 → sysone-0.3.0}/tests/test_data.py +0 -0
  50. {sysone-0.2.0 → sysone-0.3.0}/tests/test_evaluation.py +0 -0
  51. {sysone-0.2.0 → sysone-0.3.0}/tests/test_layout.py +0 -0
  52. {sysone-0.2.0 → sysone-0.3.0}/tests/test_model.py +0 -0
  53. {sysone-0.2.0 → sysone-0.3.0}/tests/test_questions.py +0 -0
  54. {sysone-0.2.0 → sysone-0.3.0}/tests/test_real_models.py +0 -0
  55. {sysone-0.2.0 → sysone-0.3.0}/tests/test_training.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sysone
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Fine-tune and calibrate system-one decision models with a scikit-learn style fit / predict interface.
5
5
  Project-URL: Homepage, https://github.com/ForwardMetric/sysone
6
6
  Project-URL: Repository, https://github.com/ForwardMetric/sysone
@@ -24,12 +24,12 @@ Classifier: Programming Language :: Python :: 3.13
24
24
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
25
25
  Requires-Python: >=3.10
26
26
  Requires-Dist: bitsandbytes>=0.45; sys_platform == 'linux' or sys_platform == 'win32'
27
- Requires-Dist: huggingface-hub>=0.25
27
+ Requires-Dist: huggingface-hub>=1.3
28
28
  Requires-Dist: numpy>=1.26
29
29
  Requires-Dist: peft>=0.13
30
- Requires-Dist: safetensors>=0.4
31
- Requires-Dist: torch>=2.4
32
- Requires-Dist: transformers>=5.0
30
+ Requires-Dist: safetensors>=0.4.3
31
+ Requires-Dist: torch>=2.8
32
+ Requires-Dist: transformers>=5.1
33
33
  Provides-Extra: pandas
34
34
  Requires-Dist: pandas>=2.0; extra == 'pandas'
35
35
  Description-Content-Type: text/markdown
@@ -109,27 +109,36 @@ Example notebooks, each comparing accuracy before and after training:
109
109
  [encoder_bev_decision.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/encoder_bev_decision.ipynb) (ModernBERT);
110
110
  - typed-decisions, soft labels from several raters:
111
111
  [decoder_typed_decisions.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/decoder_typed_decisions.ipynb) (Qwen3) and
112
- [encoder_typed_decisions.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/encoder_typed_decisions.ipynb) (ModernBERT).
112
+ [encoder_typed_decisions.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/encoder_typed_decisions.ipynb) (ModernBERT);
113
+ - measuring `ordinal_weight` for score questions on typed-decisions:
114
+ [encoder_typed_decisions_ordinal_weight.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/encoder_typed_decisions_ordinal_weight.ipynb).
113
115
 
114
116
  Notebooks other than the quickstart are named `<model type>_<dataset>.ipynb`.
115
117
 
116
118
  ## Status
117
119
 
118
- This is an early version (0.2). Training covers every `tuning` mode:
120
+ This is an early version (0.3). Training covers every `tuning` mode:
119
121
 
120
122
  - `"head"`: the backbone is frozen, its outputs are computed once and cached (in memory, and on
121
123
  disk with `cache_dir`), and only the decision head trains;
122
124
  - `"lora"`, `"qlora"` and `"full"`: adapters or whole layers of the backbone train along with the
123
125
  head. These run on a laptop GPU (Apple MPS) for small datasets; a CUDA GPU is recommended for
124
126
  full-size runs. `"qlora"` needs bitsandbytes and an NVIDIA GPU and falls back to `"lora"`
125
- elsewhere, with a warning. It has trained in real 4-bit on a Colab GPU.
127
+ elsewhere, with a warning. It has trained, saved and reloaded in real 4-bit on a Colab GPU.
126
128
 
127
129
  `DecoderModel` (Qwen3) defaults to `"qlora"`. `EncoderModel` (ModernBERT) defaults to `"full"`:
128
130
  adapters barely moved ModernBERT, while full tuning reached 0.79 test accuracy on typed-decisions.
129
131
 
130
132
  Training stops at `epochs`, by early stopping, or at `target_loss`. A run that is interrupted
131
- (Ctrl-C), or saved and loaded, resumes exactly where it stopped. Calibration and `head="full"` are
132
- designed but not yet implemented.
133
+ (Ctrl-C), or saved and loaded, resumes exactly where it stopped. With `checkpoint_dir`, `fit` also
134
+ saves the model when it ends or is interrupted, and every `checkpoint_every` steps if set, so a
135
+ disconnected notebook can pick the run up again; nothing is written to disk by default.
136
+
137
+ Score questions add an ordinal term to the loss (`ordinal_weight`), so a near miss costs less than
138
+ a far one. After training, probabilities are calibrated with temperatures fitted on the validation
139
+ slice, so `answer_confidence` matches how often answers are right; reports include Brier score and
140
+ expected calibration error, before and after calibration. `head="full"` is designed but not yet
141
+ implemented.
133
142
 
134
143
  See [docs/DESIGN.md](https://github.com/ForwardMetric/sysone/blob/main/docs/DESIGN.md) for the full design: every parameter, the layouts,
135
144
  architectures, heads, training and checkpoint format.
@@ -73,27 +73,36 @@ Example notebooks, each comparing accuracy before and after training:
73
73
  [encoder_bev_decision.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/encoder_bev_decision.ipynb) (ModernBERT);
74
74
  - typed-decisions, soft labels from several raters:
75
75
  [decoder_typed_decisions.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/decoder_typed_decisions.ipynb) (Qwen3) and
76
- [encoder_typed_decisions.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/encoder_typed_decisions.ipynb) (ModernBERT).
76
+ [encoder_typed_decisions.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/encoder_typed_decisions.ipynb) (ModernBERT);
77
+ - measuring `ordinal_weight` for score questions on typed-decisions:
78
+ [encoder_typed_decisions_ordinal_weight.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/encoder_typed_decisions_ordinal_weight.ipynb).
77
79
 
78
80
  Notebooks other than the quickstart are named `<model type>_<dataset>.ipynb`.
79
81
 
80
82
  ## Status
81
83
 
82
- This is an early version (0.2). Training covers every `tuning` mode:
84
+ This is an early version (0.3). Training covers every `tuning` mode:
83
85
 
84
86
  - `"head"`: the backbone is frozen, its outputs are computed once and cached (in memory, and on
85
87
  disk with `cache_dir`), and only the decision head trains;
86
88
  - `"lora"`, `"qlora"` and `"full"`: adapters or whole layers of the backbone train along with the
87
89
  head. These run on a laptop GPU (Apple MPS) for small datasets; a CUDA GPU is recommended for
88
90
  full-size runs. `"qlora"` needs bitsandbytes and an NVIDIA GPU and falls back to `"lora"`
89
- elsewhere, with a warning. It has trained in real 4-bit on a Colab GPU.
91
+ elsewhere, with a warning. It has trained, saved and reloaded in real 4-bit on a Colab GPU.
90
92
 
91
93
  `DecoderModel` (Qwen3) defaults to `"qlora"`. `EncoderModel` (ModernBERT) defaults to `"full"`:
92
94
  adapters barely moved ModernBERT, while full tuning reached 0.79 test accuracy on typed-decisions.
93
95
 
94
96
  Training stops at `epochs`, by early stopping, or at `target_loss`. A run that is interrupted
95
- (Ctrl-C), or saved and loaded, resumes exactly where it stopped. Calibration and `head="full"` are
96
- designed but not yet implemented.
97
+ (Ctrl-C), or saved and loaded, resumes exactly where it stopped. With `checkpoint_dir`, `fit` also
98
+ saves the model when it ends or is interrupted, and every `checkpoint_every` steps if set, so a
99
+ disconnected notebook can pick the run up again; nothing is written to disk by default.
100
+
101
+ Score questions add an ordinal term to the loss (`ordinal_weight`), so a near miss costs less than
102
+ a far one. After training, probabilities are calibrated with temperatures fitted on the validation
103
+ slice, so `answer_confidence` matches how often answers are right; reports include Brier score and
104
+ expected calibration error, before and after calibration. `head="full"` is designed but not yet
105
+ implemented.
97
106
 
98
107
  See [docs/DESIGN.md](https://github.com/ForwardMetric/sysone/blob/main/docs/DESIGN.md) for the full design: every parameter, the layouts,
99
108
  architectures, heads, training and checkpoint format.
@@ -189,6 +189,7 @@ DecoderModel(
189
189
 
190
190
  # objective and data
191
191
  loss="soft-ce", # "soft-ce" | "rlcd"
192
+ ordinal_weight=1.0, # how strongly score questions respect level order (RPS term); 0 = off
192
193
  label_smoothing=0.0,
193
194
  shuffle_options="auto", # "auto" | None | list of question types
194
195
  balance=None, # None | "category" | "question"
@@ -200,6 +201,8 @@ DecoderModel(
200
201
  cache_max_gb=5, # size cap for cache_dir; least recently used sets are deleted first
201
202
  precision="auto", # "auto" | "fp32" | "bf16" | "fp16"
202
203
  gradient_checkpointing="auto",
204
+ checkpoint_dir=None, # folder fit saves to when training ends or is interrupted; None = never
205
+ checkpoint_every=None, # also save every this many optimizer steps; None = only at the end
203
206
  device="auto", # "auto" | "cuda" | "mps" | "cpu"
204
207
  seed=0,
205
208
  verbose=1,
@@ -313,6 +316,8 @@ nothing scales them with the data, which would be guesswork that fails silently.
313
316
  `"lora"` / `"qlora"` give a warning that they are ignored.
314
317
  - `cache_dir` with a backbone that learns gives a warning that it is ignored: features can only
315
318
  be cached when the backbone is frozen.
319
+ - `ordinal_weight` must be a number of at least 0; `checkpoint_every` must be None or a positive
320
+ integer, and needs `checkpoint_dir`.
316
321
  - `target_loss` must be a positive number or None; `validation_interval` must be `"auto"` or a
317
322
  positive integer; `cache_max_gb` must be positive.
318
323
 
@@ -553,8 +558,8 @@ can then be used or saved, and a later `fit` resumes from the last completed ste
553
558
 
554
559
  **Frozen backbone (`tuning="head"`).** Nothing in the backbone learns, so:
555
560
  - the backbone runs without gradients and in eval mode (no dropout);
556
- - its weights are loaded in bf16 where the device supports it, otherwise fp32, which halves
557
- memory;
561
+ - its weights are loaded in half precision, which halves memory: bf16 where the device supports
562
+ it natively, fp16 on CUDA GPUs without native bf16 (such as the T4), otherwise fp32;
558
563
  - with `cache_features="auto"`, the option and summary vectors for every example are computed
559
564
  once and cached on the CPU in half precision. Every epoch then trains the head on the cache,
560
565
  without running the backbone again.
@@ -565,9 +570,10 @@ fit in memory, the backbone is run again each epoch instead.
565
570
 
566
571
  **Backbone that learns (`"lora"`, `"qlora"`, `"full"`).**
567
572
  - Weights being trained (adapters, fully trained layers, embeddings) are kept in fp32 for the
568
- optimizer. The forward pass runs under bf16 autocast where supported. With `"qlora"` the frozen
569
- weights are stored in 4-bit; with `"lora"` and `"full"` they are loaded in bf16 where supported,
570
- since they are never updated.
573
+ optimizer. The forward pass runs under autocast to the backbone's half precision (bf16, or fp16
574
+ on CUDA GPUs without native bf16). With fp16 the loss is scaled before the backward pass, so
575
+ small gradients don't round to zero. With `"qlora"` the frozen weights are stored in 4-bit; with
576
+ `"lora"` and `"full"` they are loaded in half precision, since they are never updated.
571
577
  - Gradient checkpointing (`"auto"`) turns on when sequences are long enough that activations,
572
578
  not weights, dominate memory: when the estimated activations of one forward pass (about
573
579
  `34·s·h + 5·a·s²` bytes per example and layer, at the 95th-percentile length, for the layers
@@ -607,20 +613,69 @@ and never copied by sysone.
607
613
 
608
614
  Losses:
609
615
  - `soft-ce` (default): cross-entropy against the target distribution, over each row's own
610
- options. For hard labels this is ordinary cross-entropy.
616
+ options. For hard labels this is ordinary cross-entropy. Score questions add a
617
+ ranked-probability term (below).
611
618
  - `rlcd`: a policy-gradient term over noisy logit samples, rewarded by a proper scoring rule
612
619
  (log, spherical, and ranked-probability for score questions), plus soft cross-entropy.
613
620
 
621
+ **Score questions are ordered.** Cross-entropy treats a score's levels as unrelated categories:
622
+ predicting level 2 when the answer is 3 costs as much as predicting 0. For score questions the
623
+ loss adds `ordinal_weight` times the **ranked probability score** (RPS), the squared distance between the
624
+ predicted and target cumulative distributions, averaged over the `K − 1` thresholds between
625
+ levels:
626
+
627
+ ```
628
+ RPS = 1/(K−1) · Σ_{k=1}^{K−1} (P(level < k) − T(level < k))²
629
+ ```
630
+
631
+ It is 0 for a perfect prediction and at most 1, whatever the number of levels, and near misses
632
+ cost less than far ones. It is a proper scoring rule, so like cross-entropy it is minimised only
633
+ by honest probabilities, which keeps it compatible with calibration; soft rater targets work as
634
+ they are. Cross-entropy stays in the loss because RPS alone gives a weak signal when the model is
635
+ confidently wrong. Choice and noul questions are unaffected, and so are the head and the
636
+ checkpoint format.
637
+
638
+ `ordinal_weight` names what it controls rather than the method, so the method can change without a
639
+ rename. Its default, 1.0, weights the two terms equally. Equal weight is not equal influence:
640
+ cross-entropy on score questions is typically around 1, while RPS, a squared gap between
641
+ probabilities, is typically 0.05 to 0.2, so cross-entropy still dominates at 1.0. Measured on
642
+ typed-decisions (see milestone 3), the term helps a frozen backbone and is neutral with full
643
+ tuning, so the default stays at 1.0.
644
+
645
+ One weight applies to every score question in the data. That suits most datasets, because RPS is
646
+ on the same 0-to-1 scale for any number of levels, so a 4-level weather scale and a 10-level spam
647
+ scale count evenly. Different score questions could still want different weights, for example a
648
+ scale raters disagree on more. The planned extension, built only if measurements call for it, is
649
+ a dict keyed like `report_["by_question"]`:
650
+
651
+ ```python
652
+ DecoderModel(ordinal_weight={"severity": 2.0, "operations/spam": 0.5, "*": 1.0})
653
+ ```
654
+
655
+ Keys are a question id, or `category/qid` where ids repeat across categories, with `"*"` for all
656
+ other score questions. A plain number keeps meaning "this weight for every score question", so the
657
+ extension changes nothing for existing code. The weight stays a model setting rather than a key
658
+ inside question definitions, which `predict` and the Jev contract share.
659
+
614
660
  ### Calibration
615
661
 
616
- With `calibrate=True`, temperatures are fitted after training on the validation slice, with LBFGS
617
- on log T:
662
+ With `calibrate=True`, temperatures are fitted after training on the validation slice: logits are
663
+ divided by a temperature `T`, chosen to minimise the cross-entropy against the validation targets
664
+ (soft targets as they are), with LBFGS on `log T`:
618
665
  - one temperature per question type;
619
- - one per (type, option-count bucket), with buckets 2, 3–5, 6–10 and 11+, when a bucket has
620
- enough rows;
666
+ - one per (type, option-count bucket), with buckets 2, 3–5, 6–10 and 11+, for buckets with at least
667
+ 50 validation questions; smaller buckets use their type's temperature;
668
+ - a type with fewer than 50 validation questions keeps `T = 1`, and the log says so;
621
669
  - every temperature clamped to [0.5, 5].
622
670
 
623
- A per-category temperature is optional, for categories with enough validation rows.
671
+ Temperatures are refitted at the end of every `fit`, including a resumed one, and saved with the
672
+ model. `predict` and `evaluate` apply them, so `probabilities` and `answer_confidence` are
673
+ calibrated; `answer_confidence` is the calibrated probability of the answer given (the top option,
674
+ or for noul the larger of P(true) and P(false)). `report_` records the temperatures and the
675
+ validation slice's expected calibration error before and after.
676
+
677
+ A per-category temperature is optional, for categories with enough validation rows (not in
678
+ milestone 3).
624
679
 
625
680
  The same slice also chose the stopping epoch. Each temperature is a single number per bucket, so
626
681
  reusing the slice biases it very little, and it avoids holding back a second slice of the data.
@@ -651,7 +706,14 @@ A report is a plain dict: `n_questions`, `skipped`, and the metrics `overall`, `
651
706
  - **score_error**: for score questions, the absolute gap between predicted and target expected
652
707
  level.
653
708
  - **auc**: for noul questions, when both labels occur.
654
- - **expected calibration error** before and after temperature scaling, once calibration exists.
709
+ - **brier**: the squared distance between predicted and target distributions, summed over a
710
+ question's options and averaged over questions; lower is better.
711
+ - **ece**: expected calibration error of the top option. Questions are grouped into 15 equal-width
712
+ bins by the predicted probability of their top option; in each bin, that probability is compared
713
+ with how often the top option is right, and the gaps are averaged, weighted by bin size.
714
+
715
+ `model.evaluate(data, calibrated=False)` scores the probabilities before temperature scaling, so
716
+ the effect of calibration shows as two rows in `compare`.
655
717
 
656
718
  Baselines:
657
719
  - **random guess**: every option equally likely.
@@ -710,6 +772,37 @@ applies when it starts, e.g. `resuming at epoch 12 of 20` or
710
772
  Either way, a structural parameter changed since the last fit (base model, layers, layout, head
711
773
  shape, token limits, tuning mode) starts over instead, as before.
712
774
 
775
+ #### Autosave
776
+
777
+ A long run on a cloud notebook can be lost to a disconnect. Autosave is off by default, so a
778
+ default run spends no time writing to disk. It is turned on with `checkpoint_dir`:
779
+
780
+ | settings | what is saved |
781
+ |---|---|
782
+ | neither (default) | nothing |
783
+ | `checkpoint_dir` | when training ends or is interrupted (Ctrl-C, or an error during training) |
784
+ | `checkpoint_dir` and `checkpoint_every=N` | also every `N` optimizer steps |
785
+ | `checkpoint_every` alone | rejected: there is nowhere to save |
786
+
787
+ - **How often:** a full-tuning training state can be gigabytes, and writing it to a network drive
788
+ takes time, so `checkpoint_every` is a trade-off between time spent saving and work lost to a
789
+ disconnect. The log shows the size and duration of each save to help choose it.
790
+ - **What:** a complete `save(checkpoint_dir, training_state=True)`. The model files hold the best
791
+ weights so far; the training state holds the last step's. `Model.load(checkpoint_dir)` gives a
792
+ usable model at any time, and `Model.load(checkpoint_dir).fit(<the same data>)` resumes the run
793
+ exactly.
794
+ - **Safely:** each save is written to a temporary folder next to `checkpoint_dir` and swapped in
795
+ when complete, so a disconnect during a save leaves the previous checkpoint intact. Only the
796
+ latest checkpoint is kept.
797
+ - **Restart information:** each save also writes `run.json`, a small readable summary for picking
798
+ the run up again: the model class, sysone version, step and epoch reached out of `epochs`, the
799
+ best validation loss and its step, when it was saved, the training data's fingerprint, the data
800
+ file's path when `fit` was given a path, and the resume command. `fit` logs the folder and the
801
+ interval when it starts.
802
+
803
+ The data itself is never copied into the checkpoint; resuming needs the same data, checked
804
+ against the fingerprint (a different dataset warm-starts, as above).
805
+
713
806
  ## Repository layout
714
807
 
715
808
  ```
@@ -728,8 +821,8 @@ src/sysone/
728
821
  layout.py # parallel and sequential position ids, option ids, masks
729
822
  heads.py # compact and full decision heads
730
823
  tuning.py # layer plans, freezing, LoRA / QLoRA by layer index (peft, bitsandbytes)
731
- losses.py # soft_ce, rlcd
732
- calibration.py # temperature fitting
824
+ losses.py # soft_ce, the ranked-probability term for score questions, rlcd
825
+ calibration.py # temperature fitting, ECE
733
826
  training.py # training loop, validation split, balancing, stopping, resumable state
734
827
  features.py # cached option and summary vectors for a frozen backbone
735
828
  cache.py # the on-disk feature cache: sets, size cap, cache_info, clear_cache
@@ -741,7 +834,9 @@ examples/ # notebooks: quickstart, public datasets with hard and so
741
834
  docs/
742
835
  ```
743
836
 
744
- Dependencies are torch, transformers (5.0 or later, for per-layer-type masks) and peft. Because QLoRA is the default, bitsandbytes is
837
+ Dependencies are torch (2.8 or later: training the backbone on Apple GPUs fails in earlier
838
+ versions), transformers (5.1 or later: 5.0's ModernBERT ignores per-layer-type masks) and peft. The
839
+ minimums in `pyproject.toml` are the ones CI tests, by installing the lowest allowed versions. Because QLoRA is the default, bitsandbytes is
745
840
  also a regular dependency on platforms where it supports 4-bit training, selected with a
746
841
  platform marker in `pyproject.toml`. Elsewhere it is not installed and the fallback to `"lora"`
747
842
  applies.
@@ -760,7 +855,7 @@ applies.
760
855
  the two architectures, the compact head, the in-memory feature cache, `fit` / `predict` /
761
856
  `save` / `load`, `evaluate` / `baselines` / `compare`, and three example notebooks.
762
857
 
763
- ### Milestone 2: training the backbone
858
+ ### Milestone 2: training the backbone (done, 0.2.0)
764
859
 
765
860
  **Goal:** let the backbone learn, not only the head, and make long runs manageable: stopping
766
861
  when good enough, resuming where a run left off, and not repeating the backbone pass for
@@ -795,16 +890,79 @@ barely moved ModernBERT; see `tuning`).
795
890
  questions (best measured once backbone training is in, so the two effects aren't mixed), and
796
891
  calibration.
797
892
 
798
- ### Milestone 3: calibration and reporting
893
+ ### Milestone 3: better scores, calibrated probabilities, sturdier long runs (done, 0.3.0)
894
+
895
+ **Goal:** close the largest measured accuracy gap (score questions), make the reported
896
+ probabilities mean what they say, and make long GPU runs survive disconnects and older GPUs.
897
+ Release as 0.3.0.
799
898
 
800
- Temperature fitting per question type and option-count bucket, expected calibration error in
801
- reports, and `answer_confidence` that means what it says.
899
+ **In scope:**
900
+ 1. **Ranked-probability term for score questions** (`ordinal_weight`; see Losses). It changes the
901
+ model's outputs, so it comes before calibration.
902
+ 2. **Calibration** as described under Calibration: temperatures per type and option-count bucket,
903
+ fitted on the validation slice, saved and applied; a calibrated `answer_confidence`.
904
+ 3. **Reporting:** `brier` and `ece` in every report, `evaluate(..., calibrated=False)`, and the
905
+ calibration summary in `report_`.
906
+ 4. **Autosave**, off by default (`checkpoint_dir`, `checkpoint_every`, `run.json`; see Autosave).
907
+ 5. **GPUs without native bf16** (for example the T4): `precision="auto"` uses fp16 there, with loss
908
+ scaling when the backbone learns, instead of slow emulated bf16. Native support is checked
909
+ without counting emulation.
910
+ 6. **Dependency minimums** raised to versions that are tested, and tested in CI.
911
+ 7. **CI:** GitHub Actions runs ruff and the fast tests on every pull request, on CPU-only torch,
912
+ with both the newest dependencies and the declared minimums. (Publishing from CI is left for
913
+ later.)
914
+ 8. **Tests:** RPS against hand-computed values (hard and soft targets, 2 to 11 levels); temperature
915
+ fitting recovers a known temperature on synthetic logits; buckets fall back below 50
916
+ questions; ECE and Brier against hand-computed values; autosave writes atomically, keeps the
917
+ best weights in the model files, resumes exactly from an autosave, and saves on interrupt;
918
+ fp16 with loss scaling takes a training step (CUDA only).
919
+ 9. **Measurements** on a CUDA GPU, recorded in the notebooks' saved outputs and in this document:
920
+ - `ordinal_weight`: score error and exact-level accuracy for 0, 0.5, 1 and 2 on typed-decisions and
921
+ bev-decision, to set the default, also per score question (`by_question`) to see whether
922
+ different questions want different weights (the dict extension under Losses);
923
+ - three seeds of fully tuned ModernBERT-base on typed-decisions (the 0.79 result), with
924
+ accuracy per question type;
925
+ - the unmeasured learning-rate defaults: ModernBERT adapters and Qwen3 full tuning;
926
+ - saving and reloading a 4-bit QLoRA model;
927
+ - gradient checkpointing's cost on a GPU, to revisit the `"auto"` rule.
928
+
929
+ **Done when:** all of the above is in and tested, the notebooks show score error, Brier and ECE
930
+ before and after calibration, the 0.79 result holds across seeds, and 0.3.0 is on PyPI with the
931
+ notebooks installing `sysone>=0.3`.
932
+
933
+ **Measured** (ModernBERT-base on typed-decisions, Colab L4, one seed;
934
+ `examples/encoder_typed_decisions_ordinal_weight.ipynb`):
935
+
936
+ | run | accuracy | score accuracy | score error | minutes |
937
+ |---|---|---|---|---|
938
+ | head, `ordinal_weight=0` | — | 0.4262 | 0.5686 | — |
939
+ | head, `ordinal_weight=2` | — | 0.4388 | 0.5255 | — |
940
+ | full, `ordinal_weight=0` | 0.7865 | 0.7662 | 0.2253 | 20.9 |
941
+ | full, `ordinal_weight=2` | 0.7870 | 0.7700 | 0.2240 | 21.0 |
942
+
943
+ - `ordinal_weight`: with the backbone frozen, 2 lowers score error by 7.6% and 0.5 and 1 change
944
+ nothing; with full tuning, 2 changes score error by 0.6% and accuracy not at all. The default
945
+ stays at 1.0. Per score question the full-tuning changes go both ways (−17% to +11%), within
946
+ one-seed noise, so per-question weights are not needed. Soft labels from several raters already
947
+ carry most of the order information once the backbone trains.
948
+ - The 0.79 result held in both full-tuning runs above (same seed).
949
+ - `"qlora"` trained, saved and reloaded in real 4-bit (NF4 projections, fp32 adapters, the
950
+ ModernBERT-base backbone at 130 MB) with identical predictions.
951
+ - fp16 with loss scaling trained, saved and reloaded on a CUDA GPU with identical predictions;
952
+ the CUDA-only tests pass, including real 4-bit loading.
953
+
954
+ **Moved to milestone 4:** the other seeds of fully tuned ModernBERT; the learning-rate defaults
955
+ for ModernBERT adapters and Qwen3 full tuning; gradient checkpointing's cost on a GPU;
956
+ `ordinal_weight` on bev-decision; a CI job on a GPU.
957
+
958
+ **Not in milestone 3:** per-question `ordinal_weight` (unless the measurements call for it);
959
+ per-category temperatures; reading the encoder summary as an average
960
+ instead of `[CLS]` (full tuning already works for ModernBERT); more base models (Llama, Mistral);
961
+ training one model on many decision datasets before specialising; publishing from CI;
962
+ `head="full"`; `loss="rlcd"`.
802
963
 
803
964
  ## Open items
804
965
 
805
- - `"qlora"` has trained and evaluated in real 4-bit on a Colab NVIDIA GPU (Qwen3-0.6B on
806
- typed-decisions). Saving and reloading a 4-bit model has only been tested through the fallback;
807
- it still needs a run on an NVIDIA GPU.
808
966
  - Whether bitsandbytes supports 4-bit training on Apple Silicon yet needs checking. If it does,
809
967
  the fallback there can be dropped.
810
968
  - A state-first causal layout, which would let one state's cache be shared across its questions,
@@ -39,7 +39,7 @@
39
39
  "import importlib.util\n",
40
40
  "\n",
41
41
  "if importlib.util.find_spec(\"sysone\") is None:\n",
42
- " %pip install -q \"sysone>=0.2\""
42
+ " %pip install -q \"sysone>=0.3\""
43
43
  ]
44
44
  },
45
45
  {
@@ -223,7 +223,14 @@
223
223
  " different).\n",
224
224
  "- **log_loss**: cross-entropy against the label; lower is better.\n",
225
225
  "- **score_error**: for score questions, the gap between predicted and true expected level.\n",
226
- "- **auc**: for yes/no questions, how well the model ranks yes above no (0.5 = chance)."
226
+ "- **auc**: for yes/no questions, how well the model ranks yes above no (0.5 = chance).\n",
227
+ "- **brier**: squared distance between the predicted probabilities and the label; lower is better.\n",
228
+ "- **ece**: expected calibration error, how far \"says X% sure\" is from \"right X% of the time\";\n",
229
+ " lower is better.\n",
230
+ "\n",
231
+ "Probabilities are calibrated after training: a temperature per question type, fitted on the\n",
232
+ "validation slice, softens over-confident answers. This changes **log_loss**, **brier** and **ece**,\n",
233
+ "never **accuracy**, since the top answer stays the same."
227
234
  ],
228
235
  "id": "cell-10"
229
236
  },
@@ -237,6 +244,27 @@
237
244
  ],
238
245
  "id": "cell-11"
239
246
  },
247
+ {
248
+ "cell_type": "markdown",
249
+ "metadata": {},
250
+ "source": [
251
+ "### Calibration\n",
252
+ "\n",
253
+ "The same model before and after temperature scaling. Accuracy is identical; the probabilities, and\n",
254
+ "so **log_loss**, **brier** and **ece**, change. `lora.report_[\"calibration\"]` has the fitted\n",
255
+ "temperatures and the validation slice's ECE before and after."
256
+ ]
257
+ },
258
+ {
259
+ "cell_type": "code",
260
+ "metadata": {},
261
+ "execution_count": null,
262
+ "outputs": [],
263
+ "source": [
264
+ "calibration = {\"lora\": results[\"lora\"], \"lora, before calibration\": lora.evaluate(test, calibrated=False)}\n",
265
+ "compare(calibration, by=\"overall\")[[\"accuracy\", \"log_loss\", \"brier\", \"ece\"]]"
266
+ ]
267
+ },
240
268
  {
241
269
  "cell_type": "markdown",
242
270
  "metadata": {},
@@ -267,8 +295,9 @@
267
295
  "Training only the head on a frozen backbone is the cheapest setting and the least accurate: the\n",
268
296
  "backbone was never adapted to these questions. Expect clear gains over random guessing, and LoRA and\n",
269
297
  "QLoRA to do better than head-only on the same rows. QLoRA should land close to LoRA; a gap of a\n",
270
- "point or two is within run-to-run noise. Score questions are the hardest, because the loss treats\n",
271
- "levels as unrelated categories.\n",
298
+ "point or two is within run-to-run noise. Score questions are the hardest. For them the loss adds an\n",
299
+ "ordinal term (`ordinal_weight`), so predicting a level next to the right one costs less than one far\n",
300
+ "away; **score_error** shows how far off the predicted level is on average.\n",
272
301
  "\n",
273
302
  "Things to try: more training rows (`N_TRAIN`), more `BACKBONE_EPOCHS`, a different `num_layers`\n",
274
303
  "(default 0.7 of Qwen's 28 layers), or `lora_targets=\"all\"`. A long run stopped with Ctrl-C continues\n",
@@ -41,7 +41,7 @@
41
41
  "import importlib.util\n",
42
42
  "\n",
43
43
  "if importlib.util.find_spec(\"sysone\") is None:\n",
44
- " %pip install -q \"sysone>=0.2\""
44
+ " %pip install -q \"sysone>=0.3\""
45
45
  ]
46
46
  },
47
47
  {
@@ -225,6 +225,13 @@
225
225
  "- **log_loss**: cross-entropy against the label; lower is better.\n",
226
226
  "- **score_error**: for score questions, the gap between predicted and true expected level.\n",
227
227
  "- **auc**: for yes/no questions, how well the model ranks yes above no (0.5 = chance).\n",
228
+ "- **brier**: squared distance between the predicted probabilities and the label; lower is better.\n",
229
+ "- **ece**: expected calibration error, how far \"says X% sure\" is from \"right X% of the time\";\n",
230
+ " lower is better.\n",
231
+ "\n",
232
+ "Probabilities are calibrated after training: a temperature per question type, fitted on the\n",
233
+ "validation slice, softens over-confident answers. This changes **log_loss**, **brier** and **ece**,\n",
234
+ "never **accuracy**, since the top answer stays the same.\n",
228
235
  "\n",
229
236
  "With soft labels, **distance** and **log_loss** matter as much as accuracy: when raters split\n",
230
237
  "45/55, either answer is defensible, but the probabilities should reflect the split."
@@ -241,6 +248,27 @@
241
248
  ],
242
249
  "id": "cell-11"
243
250
  },
251
+ {
252
+ "cell_type": "markdown",
253
+ "metadata": {},
254
+ "source": [
255
+ "### Calibration\n",
256
+ "\n",
257
+ "The same model before and after temperature scaling. Accuracy is identical; the probabilities, and\n",
258
+ "so **log_loss**, **brier** and **ece**, change. `lora.report_[\"calibration\"]` has the fitted\n",
259
+ "temperatures and the validation slice's ECE before and after."
260
+ ]
261
+ },
262
+ {
263
+ "cell_type": "code",
264
+ "metadata": {},
265
+ "execution_count": null,
266
+ "outputs": [],
267
+ "source": [
268
+ "calibration = {\"lora\": results[\"lora\"], \"lora, before calibration\": lora.evaluate(test, calibrated=False)}\n",
269
+ "compare(calibration, by=\"overall\")[[\"accuracy\", \"log_loss\", \"brier\", \"ece\"]]"
270
+ ]
271
+ },
244
272
  {
245
273
  "cell_type": "markdown",
246
274
  "metadata": {},
@@ -269,8 +297,8 @@
269
297
  "- **average answer** is the bar to beat. With a frozen backbone, the head has limited room to get\n",
270
298
  " past it; fine-tuning the backbone with LoRA or QLoRA is what should move these numbers further.\n",
271
299
  "- QLoRA should land close to LoRA; a gap of a point or two is within run-to-run noise.\n",
272
- "- Probabilities are not calibrated yet (temperatures stay at 1.0), which affects **distance** and\n",
273
- " **log_loss** more than accuracy.\n",
300
+ "- Probabilities are calibrated on the validation slice, which shows in **ece**, **brier** and\n",
301
+ " **log_loss** (see the calibration table), not in accuracy.\n",
274
302
  "- A long run stopped with Ctrl-C continues from where it stopped with `lora.fit(train)`."
275
303
  ]
276
304
  }
@@ -39,7 +39,7 @@
39
39
  "import importlib.util\n",
40
40
  "\n",
41
41
  "if importlib.util.find_spec(\"sysone\") is None:\n",
42
- " %pip install -q \"sysone>=0.2\""
42
+ " %pip install -q \"sysone>=0.3\""
43
43
  ]
44
44
  },
45
45
  {
@@ -194,7 +194,14 @@
194
194
  " different).\n",
195
195
  "- **log_loss**: cross-entropy against the label; lower is better.\n",
196
196
  "- **score_error**: for score questions, the gap between predicted and true expected level.\n",
197
- "- **auc**: for yes/no questions, how well the model ranks yes above no (0.5 = chance)."
197
+ "- **auc**: for yes/no questions, how well the model ranks yes above no (0.5 = chance).\n",
198
+ "- **brier**: squared distance between the predicted probabilities and the label; lower is better.\n",
199
+ "- **ece**: expected calibration error, how far \"says X% sure\" is from \"right X% of the time\";\n",
200
+ " lower is better.\n",
201
+ "\n",
202
+ "Probabilities are calibrated after training: a temperature per question type, fitted on the\n",
203
+ "validation slice, softens over-confident answers. This changes **log_loss**, **brier** and **ece**,\n",
204
+ "never **accuracy**, since the top answer stays the same."
198
205
  ],
199
206
  "id": "cell-10"
200
207
  },
@@ -208,6 +215,27 @@
208
215
  ],
209
216
  "id": "cell-11"
210
217
  },
218
+ {
219
+ "cell_type": "markdown",
220
+ "metadata": {},
221
+ "source": [
222
+ "### Calibration\n",
223
+ "\n",
224
+ "The same model before and after temperature scaling. Accuracy is identical; the probabilities, and\n",
225
+ "so **log_loss**, **brier** and **ece**, change. `full.report_[\"calibration\"]` has the fitted\n",
226
+ "temperatures and the validation slice's ECE before and after."
227
+ ]
228
+ },
229
+ {
230
+ "cell_type": "code",
231
+ "metadata": {},
232
+ "execution_count": null,
233
+ "outputs": [],
234
+ "source": [
235
+ "calibration = {\"full\": results[\"full\"], \"full, before calibration\": full.evaluate(test, calibrated=False)}\n",
236
+ "compare(calibration, by=\"overall\")[[\"accuracy\", \"log_loss\", \"brier\", \"ece\"]]"
237
+ ]
238
+ },
211
239
  {
212
240
  "cell_type": "markdown",
213
241
  "metadata": {},
@@ -237,8 +265,9 @@
237
265
  "\n",
238
266
  "Training only the head on a frozen backbone is the cheapest setting and the least accurate: the\n",
239
267
  "backbone was never adapted to these questions. Expect clear gains over random guessing, and full\n",
240
- "fine-tuning to do much better than head-only on the same rows. Score questions are the hardest,\n",
241
- "because the loss treats levels as unrelated categories.\n",
268
+ "fine-tuning to do much better than head-only on the same rows. Score questions are the hardest. For\n",
269
+ "them the loss adds an ordinal term (`ordinal_weight`), so predicting a level next to the right one\n",
270
+ "costs less than one far away; **score_error** shows how far off the predicted level is on average.\n",
242
271
  "\n",
243
272
  "Things to try: more training rows (`N_TRAIN`), more `BACKBONE_EPOCHS`, or a larger encoder\n",
244
273
  "(`EncoderModel(\"answerdotai/ModernBERT-large\", tuning=\"full\")`, which needs more GPU memory). A\n",