sysone 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sysone/tuning.py ADDED
@@ -0,0 +1,210 @@
1
+ """How much of the backbone learns: layer plans, freezing, and LoRA / QLoRA adapters.
2
+
3
+ The kept layers are split from the top down:
4
+
5
+ bottom ── frozen ── adapters (n_lora) ── fully trained (n_full) ── top
6
+
7
+ The head always trains. With `train_embeddings` the token embeddings train too.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import re
14
+ import sys
15
+ import warnings
16
+ from dataclasses import dataclass
17
+
18
+ import torch
19
+ from torch import nn
20
+
21
+ from ._util import resolve_count
22
+ from .architectures.base import Architecture
23
+
24
+ #: (trainable_layers, lora_layers) when the user leaves them at None.
25
+ _DEFAULT_LAYERS = {"head": (0, 0), "qlora": (0, 0.6), "lora": (0, 0.6), "full": ("all", 0)}
26
+ #: Adapter weights carry this in their parameter names (peft's naming).
27
+ _ADAPTER_NAME = re.compile(r"\.lora_[AB]\.")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class LayerPlan:
32
+ n_layers: int # layers kept by num_layers
33
+ n_full: int # fully trained top layers
34
+ n_lora: int # layers with adapters, directly below the fully trained ones
35
+ train_embeddings: bool = False
36
+
37
+ @property
38
+ def full_range(self) -> range:
39
+ return range(self.n_layers - self.n_full, self.n_layers)
40
+
41
+ @property
42
+ def lora_range(self) -> range:
43
+ top = self.n_layers - self.n_full
44
+ return range(top - self.n_lora, top)
45
+
46
+ @property
47
+ def learns(self) -> bool:
48
+ """True when anything in the backbone trains, so its outputs change every step."""
49
+ return bool(self.n_full or self.n_lora or self.train_embeddings)
50
+
51
+ @property
52
+ def lowest_trained(self) -> int:
53
+ """The lowest layer gradients flow through (`n_layers` when the backbone is frozen)."""
54
+ if self.train_embeddings:
55
+ return 0
56
+ return self.n_layers - self.n_full - self.n_lora
57
+
58
+
59
+ def layer_plan(tuning: str, n_layers: int, trainable_layers, lora_layers, train_embeddings: bool) -> LayerPlan:
60
+ full_default, lora_default = _DEFAULT_LAYERS[tuning]
61
+ n_full = resolve_count(full_default if trainable_layers is None else trainable_layers, n_layers,
62
+ "trainable_layers")
63
+ n_lora = resolve_count(lora_default if lora_layers is None else lora_layers, n_layers, "lora_layers")
64
+ if n_full + n_lora > n_layers:
65
+ raise ValueError("trainable_layers + lora_layers = %d + %d is more than the %d kept layers"
66
+ % (n_full, n_lora, n_layers))
67
+ return LayerPlan(n_layers, n_full, n_lora, bool(train_embeddings))
68
+
69
+
70
+ def quantization_available(device: torch.device) -> bool:
71
+ """4-bit and 8-bit loading need bitsandbytes and, in practice, an NVIDIA GPU."""
72
+ if device.type != "cuda":
73
+ return False
74
+ try:
75
+ import bitsandbytes # noqa: F401
76
+ except ImportError:
77
+ return False
78
+ return True
79
+
80
+
81
+ def effective_tuning(tuning: str, device: torch.device, stacklevel: int = 2) -> str:
82
+ """`tuning`, except that "qlora" trains as "lora" where quantized loading is unavailable."""
83
+ if tuning == "qlora" and not quantization_available(device):
84
+ warnings.warn("tuning='qlora' needs bitsandbytes and an NVIDIA GPU, which aren't available here "
85
+ "(device=%s); using tuning='lora' instead: the same adapters, with the frozen weights "
86
+ "unquantized, so only memory use differs" % device, UserWarning, stacklevel=stacklevel + 1)
87
+ return "lora"
88
+ return tuning
89
+
90
+
91
+ def quantization_config(bits: int, quant_type: str, compute_dtype: torch.dtype):
92
+ from transformers import BitsAndBytesConfig
93
+
94
+ if bits == 4:
95
+ return BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type=quant_type,
96
+ bnb_4bit_compute_dtype=compute_dtype)
97
+ return BitsAndBytesConfig(load_in_8bit=True)
98
+
99
+
100
+ def lora_config(arch: Architecture, backbone: nn.Module, plan: LayerPlan, targets, rank: int, alpha, dropout: float):
101
+ from peft import LoraConfig
102
+
103
+ modules = arch.adapter_modules(backbone, targets, plan.lora_range)
104
+ if not modules:
105
+ raise ValueError("lora_targets=%r found no linear modules in layers %d-%d"
106
+ % (targets, plan.lora_range.start, plan.lora_range.stop - 1))
107
+ return LoraConfig(r=rank, lora_alpha=2 * rank if alpha is None else alpha, lora_dropout=dropout,
108
+ target_modules=modules, bias="none")
109
+
110
+
111
+ def add_adapters(backbone: nn.Module, config) -> None:
112
+ """Put LoRA adapters on `backbone` in place; the model stays a plain Hugging Face model."""
113
+ from peft import inject_adapter_in_model
114
+
115
+ with _ignore_incompatible_torchao():
116
+ inject_adapter_in_model(config, backbone)
117
+ backbone.sysone_lora_config = config
118
+
119
+
120
+ @contextlib.contextmanager
121
+ def _ignore_incompatible_torchao():
122
+ """peft refuses to add any adapter when an older torchao is installed (Google Colab ships one),
123
+ although sysone never uses torchao. In that case, tell peft torchao isn't available while
124
+ adapters are added, instead of asking the user to uninstall it."""
125
+ from peft import import_utils
126
+
127
+ if not hasattr(import_utils, "is_torchao_available"): # older peft: no such check
128
+ yield
129
+ return
130
+ try:
131
+ import_utils.is_torchao_available()
132
+ problem = None
133
+ except ImportError as exc:
134
+ problem = exc
135
+ if problem is None:
136
+ yield
137
+ return
138
+ warnings.warn("ignoring the installed torchao (%s); sysone doesn't use it" % problem, UserWarning, stacklevel=4)
139
+ original = import_utils.is_torchao_available
140
+ holders = [m for name, m in list(sys.modules.items())
141
+ if name.startswith("peft") and getattr(m, "is_torchao_available", None) is original]
142
+ for module in holders:
143
+ module.is_torchao_available = _torchao_unavailable
144
+ try:
145
+ yield
146
+ finally:
147
+ for module in holders:
148
+ module.is_torchao_available = original
149
+
150
+
151
+ def _torchao_unavailable() -> bool:
152
+ return False
153
+
154
+
155
+ def is_adapter(name: str) -> bool:
156
+ return bool(_ADAPTER_NAME.search(name))
157
+
158
+
159
+ def trained_parameters(arch: Architecture, backbone: nn.Module, plan: LayerPlan) -> dict[str, list]:
160
+ """`{"adapter": [(name, param)], "full": [(name, param)]}`: the backbone weights that train,
161
+ in a fixed order. "full" holds fully trained layers and embeddings."""
162
+ layers = arch.layers(backbone)
163
+ full = {id(p) for i in plan.full_range for p in layers[i].parameters()}
164
+ if plan.train_embeddings:
165
+ full |= {id(p) for p in arch.embeddings(backbone).parameters()}
166
+ groups: dict[str, list] = {"adapter": [], "full": []}
167
+ for name, p in backbone.named_parameters():
168
+ if is_adapter(name):
169
+ groups["adapter"].append((name, p))
170
+ elif id(p) in full:
171
+ groups["full"].append((name, p))
172
+ return groups
173
+
174
+
175
+ def round_trained(groups: dict[str, list], dtype: torch.dtype) -> None:
176
+ """Store fully trained layers in the backbone's precision once training ends, as they are saved,
177
+ so a model predicts the same before and after `save` / `load`. Adapters stay in fp32."""
178
+ for _, p in groups.get("full", []):
179
+ p.requires_grad_(False)
180
+ p.data = p.data.to(dtype)
181
+
182
+
183
+ def set_trainable(arch: Architecture, backbone: nn.Module, plan: LayerPlan) -> dict[str, list]:
184
+ """Freeze everything except the planned weights, which are kept in fp32 for the optimizer."""
185
+ backbone.requires_grad_(False)
186
+ groups = trained_parameters(arch, backbone, plan)
187
+ for _, p in groups["adapter"] + groups["full"]:
188
+ if p.dtype != torch.float32:
189
+ p.data = p.data.float()
190
+ p.requires_grad_(True)
191
+ return groups
192
+
193
+
194
+ def wants_checkpointing(setting, plan: LayerPlan, backbone: nn.Module, seq_len: int, micro_batch: int) -> bool:
195
+ """Whether to turn on gradient checkpointing.
196
+
197
+ "auto" turns it on when the activations kept for the backward pass would take more memory
198
+ than the backbone's weights: about `34·s·h + 5·a·s²` bytes per example and layer in half
199
+ precision, for the layers gradients flow through.
200
+ """
201
+ if setting != "auto":
202
+ return bool(setting)
203
+ if not plan.learns:
204
+ return False
205
+ config = backbone.config
206
+ layers = plan.n_layers - plan.lowest_trained
207
+ per_layer = 34 * seq_len * config.hidden_size + 5 * config.num_attention_heads * seq_len * seq_len
208
+ activations = micro_batch * layers * per_layer
209
+ weights = sum(p.numel() * p.element_size() for p in backbone.parameters())
210
+ return activations > weights
sysone/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.5
2
+ Name: sysone
3
+ Version: 0.2.0
4
+ Summary: Fine-tune and calibrate system-one decision models with a scikit-learn style fit / predict interface.
5
+ Project-URL: Homepage, https://github.com/ForwardMetric/sysone
6
+ Project-URL: Repository, https://github.com/ForwardMetric/sysone
7
+ Project-URL: Issues, https://github.com/ForwardMetric/sysone/issues
8
+ Project-URL: Design, https://github.com/ForwardMetric/sysone/blob/main/docs/DESIGN.md
9
+ Author: Goutham Balaraman
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: calibration,classification,decision models,fine-tuning,lora,modernbert,qlora,qwen,transformers
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
25
+ Requires-Python: >=3.10
26
+ Requires-Dist: bitsandbytes>=0.45; sys_platform == 'linux' or sys_platform == 'win32'
27
+ Requires-Dist: huggingface-hub>=0.25
28
+ Requires-Dist: numpy>=1.26
29
+ Requires-Dist: peft>=0.13
30
+ Requires-Dist: safetensors>=0.4
31
+ Requires-Dist: torch>=2.4
32
+ Requires-Dist: transformers>=5.0
33
+ Provides-Extra: pandas
34
+ Requires-Dist: pandas>=2.0; extra == 'pandas'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # sysone
38
+
39
+ Fine-tune and calibrate "system one" decision models: small transformers that answer typed
40
+ questions (choice, score, yes/no) about a piece of text in a single forward pass. You pick a base
41
+ model, call `fit` on labelled rows and `predict` on new ones. sysone handles tokenization,
42
+ position ids, attention masks, layer freezing, the decision head and the training loop.
43
+
44
+ Supported base models: **ModernBERT** (`EncoderModel`) and **Qwen3** (`DecoderModel`). Others can
45
+ be added by writing one architecture class.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install sysone
51
+ pip install "sysone[pandas]" # also installs pandas, for the comparison tables of `compare`
52
+ ```
53
+
54
+ On Linux and Windows this also installs bitsandbytes, for 4-bit training (`tuning="qlora"`).
55
+
56
+ ## Quickstart
57
+
58
+ ```python
59
+ from sysone import DecoderModel
60
+
61
+ questions = {
62
+ "team": {"type": "choice", "instructions": "Which team should handle it?",
63
+ "criteria": ["sales", "operations", "servicing"]},
64
+ "severity": {"type": "score", "instructions": "How severe is it?",
65
+ "criteria": ["none", "minor", "major", "critical"]},
66
+ "refund": {"type": "noul", "instructions": "Is the customer asking for a refund?"},
67
+ }
68
+
69
+ rows = [
70
+ {"state": "Hi, my order is stuck in the warehouse and our line is down.",
71
+ "category": "operations",
72
+ "questions": questions,
73
+ "expected": {"team": "operations", "severity": 3, "refund": False}},
74
+ # ... more rows; rows may mix different question sets
75
+ ]
76
+
77
+ model = DecoderModel(tuning="head").fit(rows)
78
+
79
+ model.predict("I'd like my money back for the broken unit.", questions)
80
+ # {"answers": {"team": {"type": "choice", "choice": "servicing", "probabilities": {...}, "confidence": ...},
81
+ # "severity": {"type": "score", "score": 1.2, "legend": {...}, ...},
82
+ # "refund": {"type": "noul", "noul": 0.97, ...}},
83
+ # "usage": {"input_tokens": 118, "output_tokens": 0}}
84
+
85
+ model.save("my-model")
86
+ model = DecoderModel.load("my-model")
87
+ ```
88
+
89
+ To measure the model on held-out labelled rows, and compare it with simple baselines:
90
+
91
+ ```python
92
+ from sysone import baselines, compare
93
+
94
+ results = baselines(test_rows, train=rows) # random guess, average answer
95
+ results["trained"] = model.evaluate(test_rows)
96
+ compare(results) # accuracy per question type, as a table
97
+ ```
98
+
99
+ Answers follow the Jev `/v1/systemone` response shape. Training data can be records like the
100
+ above, records plus a shared question catalog, a long DataFrame, or a `.jsonl` / `.csv` /
101
+ `.parquet` file. Labels go in `expected`, in a `label` inside each question, or as soft `gold`
102
+ probabilities.
103
+
104
+ Example notebooks, each comparing accuracy before and after training:
105
+
106
+ - [quickstart.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/quickstart.ipynb): synthetic support tickets, a few minutes;
107
+ - bev-decision-150K, 18 domains, hard labels:
108
+ [decoder_bev_decision.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/decoder_bev_decision.ipynb) (Qwen3) and
109
+ [encoder_bev_decision.ipynb](https://github.com/ForwardMetric/sysone/blob/main/examples/encoder_bev_decision.ipynb) (ModernBERT);
110
+ - typed-decisions, soft labels from several raters:
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).
113
+
114
+ Notebooks other than the quickstart are named `<model type>_<dataset>.ipynb`.
115
+
116
+ ## Status
117
+
118
+ This is an early version (0.2). Training covers every `tuning` mode:
119
+
120
+ - `"head"`: the backbone is frozen, its outputs are computed once and cached (in memory, and on
121
+ disk with `cache_dir`), and only the decision head trains;
122
+ - `"lora"`, `"qlora"` and `"full"`: adapters or whole layers of the backbone train along with the
123
+ head. These run on a laptop GPU (Apple MPS) for small datasets; a CUDA GPU is recommended for
124
+ 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.
126
+
127
+ `DecoderModel` (Qwen3) defaults to `"qlora"`. `EncoderModel` (ModernBERT) defaults to `"full"`:
128
+ adapters barely moved ModernBERT, while full tuning reached 0.79 test accuracy on typed-decisions.
129
+
130
+ 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
+
134
+ See [docs/DESIGN.md](https://github.com/ForwardMetric/sysone/blob/main/docs/DESIGN.md) for the full design: every parameter, the layouts,
135
+ architectures, heads, training and checkpoint format.
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ uv sync # or: pip install -e . with the dev dependencies
141
+ uv run pytest # fast tests on tiny random-weight models, no downloads
142
+ uv run pytest -m slow # end-to-end tests on the real base models (~1.8 GB download)
143
+ uv run ruff check src tests
144
+ ```
@@ -0,0 +1,28 @@
1
+ sysone/__init__.py,sha256=dSv6QpRXTyVFbHBtjbO9FBqerurg_df3OA0QkDPNyNA,623
2
+ sysone/_util.py,sha256=7kB_8dq4GKr-3LDMr4vWdGw6oCewf0nfqraJLCXSLTU,3595
3
+ sysone/answers.py,sha256=LuqmHerUsD-7fTiHQd_rZMCU24_O-IoOZUmLcj8w8sk,2023
4
+ sysone/base.py,sha256=RTrLLwHysrCuhJiCzPlp0QQoI4aMc_SBMXKbxN9prNM,44033
5
+ sysone/cache.py,sha256=Pxo74YQ6in55aJg6ejcdFVArLIJ9wGHJTexOo04TrCs,7947
6
+ sysone/checkpoint.py,sha256=_Sv13u4-pWDpdOJu1s6SHDKprQBqHXZNQWzL5BCjEF4,3897
7
+ sysone/data.py,sha256=mGuUfOkCdbmJqalunaiR3xM1CkcNOMO_1kH-zOPIB5c,8002
8
+ sysone/encoding.py,sha256=OALfxVoDqiwnXfg0Sjgaf6dqRwAsYDn3ZoMOL-Ti7fc,3628
9
+ sysone/evaluation.py,sha256=hICp7VzkRmNT_FW04nnV-fy7Or-rhz_nsMY9FWGWR60,7718
10
+ sysone/features.py,sha256=kgod_IvBxRCkwkY0RIbk8BYMH56qEsuO05OfTcO44Fs,4024
11
+ sysone/heads.py,sha256=FeTWnYvKiQO1bdm3ZAyCxJAZHiWl0bgX2LgVcXC9Tcg,3070
12
+ sysone/layout.py,sha256=tDW40SYMSGpT4JbsXqrGEV7xwxsppQBlGnNd_QsQKyo,5473
13
+ sysone/losses.py,sha256=58gubFLKxZN8S0hM5xyFu3DY50Jup7dKFvlvufVAmQQ,626
14
+ sysone/models.py,sha256=OmA25u1iLNPxKx4EOHUX6JsMeH4weXopOIIsXWat62A,961
15
+ sysone/prompts.py,sha256=0UjA-QpVqePrh1zuvbqlvTkW1P9ZuzA7cFo5NuyZG5I,2190
16
+ sysone/questions.py,sha256=HfgRQCiALD_hG0YMjb39Ic0Cbc5BDeLROLejusK-iQw,8252
17
+ sysone/training.py,sha256=w59lLbjGzKlQ38EyhLYgdv8eNEnbetgSdVdZdBYB144,18043
18
+ sysone/tuning.py,sha256=dZApkJG1SVazXNCDkl0heo_73qDgEaFG0aIS8jq1ezc,8457
19
+ sysone/version.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
20
+ sysone/architectures/__init__.py,sha256=WwbBxetVcw9lsFsvGPHkomTOaS30e2JGp2HUE11mIKQ,317
21
+ sysone/architectures/base.py,sha256=dzMKKb8ALf5ghp8dYCGdGNXkN5Jk6V556tknwV_yEes,7359
22
+ sysone/architectures/modernbert.py,sha256=AoQbZxRjQ2eChR8rlZ6tjVa8_MYApwJhPi6BM-6bSJk,1708
23
+ sysone/architectures/qwen.py,sha256=--tfHT140dQXEmvKBm6ek9gAaoMM15dK0_kjX6zUHMM,1924
24
+ sysone/architectures/registry.py,sha256=7W_hr-hHX3JRKhEBsZLdVh836_C7FqHCm_CXoXre6HI,2044
25
+ sysone-0.2.0.dist-info/METADATA,sha256=P6urB5koPoo7uEGvxVcPnekBjXGOKOzKlgTFzAAdgGg,6714
26
+ sysone-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
27
+ sysone-0.2.0.dist-info/licenses/LICENSE,sha256=vc1flUELWk-ba73Rv_DNFquov4HjzJGku7jzHqSBRiI,1074
28
+ sysone-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Goutham Balaraman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.