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/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """Fine-tune and calibrate system-one decision models with a scikit-learn style fit / predict interface."""
2
+
3
+ from .architectures import Architecture, register_architecture
4
+ from .base import NotFittedError, SystemOneModel
5
+ from .cache import cache_info, clear_cache
6
+ from .evaluation import baselines, compare
7
+ from .models import DecoderModel, EncoderModel
8
+ from .version import __version__
9
+
10
+ __all__ = [
11
+ "Architecture",
12
+ "DecoderModel",
13
+ "EncoderModel",
14
+ "NotFittedError",
15
+ "SystemOneModel",
16
+ "__version__",
17
+ "baselines",
18
+ "cache_info",
19
+ "clear_cache",
20
+ "compare",
21
+ "register_architecture",
22
+ ]
sysone/_util.py ADDED
@@ -0,0 +1,102 @@
1
+ """Small helpers shared across modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import math
7
+ from numbers import Integral, Real
8
+
9
+ import torch
10
+
11
+
12
+ def resolve_count(value, total: int, name: str) -> int:
13
+ """Resolve a layer count given as an int, a fraction in (0, 1], or "all".
14
+
15
+ Follows scikit-learn's convention: an int is a count and a float is a fraction, so `1` is one
16
+ layer and `1.0` is all of them. A fraction is rounded to the nearest whole layer, and a fraction
17
+ above 0 gives at least one.
18
+ """
19
+ if value is None or value == "all":
20
+ return total
21
+ if isinstance(value, bool):
22
+ raise ValueError("%s must be an int, a fraction in (0, 1] or 'all', got %r" % (name, value))
23
+ if isinstance(value, Integral):
24
+ if not 0 <= value <= total:
25
+ raise ValueError("%s=%d is outside 0..%d (the model has %d layers here)" % (name, value, total, total))
26
+ return int(value)
27
+ if isinstance(value, Real):
28
+ if not 0.0 <= value <= 1.0 or math.isnan(value):
29
+ raise ValueError("%s as a fraction must be in [0, 1], got %r" % (name, value))
30
+ if value == 0:
31
+ return 0
32
+ return max(1, min(total, int(round(value * total))))
33
+ raise ValueError("%s must be an int, a fraction in (0, 1] or 'all', got %r" % (name, value))
34
+
35
+
36
+ def resolve_device(device) -> torch.device:
37
+ if device in (None, "auto"):
38
+ if torch.cuda.is_available():
39
+ return torch.device("cuda")
40
+ if torch.backends.mps.is_available():
41
+ return torch.device("mps")
42
+ return torch.device("cpu")
43
+ return torch.device(device)
44
+
45
+
46
+ _PRECISIONS = {"fp32": torch.float32, "bf16": torch.bfloat16, "fp16": torch.float16}
47
+
48
+
49
+ def resolve_dtype(precision: str, device: torch.device) -> torch.dtype:
50
+ """The dtype for frozen backbone weights. "auto" is bf16 where the device supports it."""
51
+ if precision == "auto":
52
+ if device.type == "cuda" and torch.cuda.is_bf16_supported():
53
+ return torch.bfloat16
54
+ if device.type == "mps":
55
+ return torch.bfloat16
56
+ return torch.float32
57
+ if precision not in _PRECISIONS:
58
+ raise ValueError("precision must be one of auto, %s, got %r" % (", ".join(_PRECISIONS), precision))
59
+ return _PRECISIONS[precision]
60
+
61
+
62
+ @contextlib.contextmanager
63
+ def quiet_transformers():
64
+ """Silence transformers' load reports (e.g. weights of layers dropped by num_layers)."""
65
+ from transformers.utils import logging
66
+
67
+ previous = logging.get_verbosity()
68
+ bars = logging.is_progress_bar_enabled()
69
+ logging.set_verbosity_error()
70
+ logging.disable_progress_bar()
71
+ try:
72
+ yield
73
+ finally:
74
+ logging.set_verbosity(previous)
75
+ if bars:
76
+ logging.enable_progress_bar()
77
+
78
+
79
+ def is_out_of_memory(exc: BaseException) -> bool:
80
+ return isinstance(exc, torch.OutOfMemoryError) or "out of memory" in str(exc).lower()
81
+
82
+
83
+ def empty_device_cache() -> None:
84
+ if torch.cuda.is_available():
85
+ torch.cuda.empty_cache()
86
+ if torch.backends.mps.is_available():
87
+ torch.mps.empty_cache()
88
+
89
+
90
+ def autocast(device: torch.device, dtype: torch.dtype | None):
91
+ """Autocast to `dtype` on `device`, or nothing when `dtype` is None."""
92
+ if dtype is None:
93
+ return contextlib.nullcontext()
94
+ return torch.autocast(device_type=device.type, dtype=dtype)
95
+
96
+
97
+ def format_bytes(n: float) -> str:
98
+ for unit in ("B", "KB", "MB", "GB"):
99
+ if abs(n) < 1024 or unit == "GB":
100
+ return ("%d %s" if unit == "B" else "%.1f %s") % (n, unit)
101
+ n /= 1024
102
+ return "%.1f GB" % n
sysone/answers.py ADDED
@@ -0,0 +1,57 @@
1
+ """Turn option probabilities into answers in the Jev `/v1/systemone` shape."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from .questions import Question
8
+
9
+ _DIGITS = 4
10
+
11
+
12
+ def choice_confidence(p: Sequence[float]) -> float:
13
+ """`(max p - 1/n) / (1 - 1/n)`: 0 for a uniform distribution, 1 for a certain one."""
14
+ n = len(p)
15
+ if n < 2:
16
+ return 1.0
17
+ return max(0.0, (max(p) - 1.0 / n) / (1.0 - 1.0 / n))
18
+
19
+
20
+ def score_confidence(p: Sequence[float]) -> float:
21
+ """One minus the spread around the mode, normalised by the spread of a uniform distribution."""
22
+ n = len(p)
23
+ if n < 2:
24
+ return 1.0
25
+ mode = max(range(n), key=p.__getitem__)
26
+ spread = sum(v * abs(i - mode) for i, v in enumerate(p))
27
+ center = (n - 1) / 2
28
+ uniform_spread = sum(abs(i - center) for i in range(n)) / n
29
+ return max(0.0, 1.0 - spread / uniform_spread)
30
+
31
+
32
+ def answer(question: Question, p: Sequence[float]) -> dict:
33
+ """One answer dict for `question` from its option probabilities (in option order)."""
34
+ p = [float(v) for v in p]
35
+ top = max(p)
36
+ if question.type == "choice":
37
+ return {
38
+ "type": "choice",
39
+ "choice": question.keys[p.index(top)],
40
+ "probabilities": {str(key): round(v, _DIGITS) for key, v in zip(question.keys, p)},
41
+ "confidence": round(choice_confidence(p), _DIGITS),
42
+ "answer_confidence": round(top, _DIGITS),
43
+ }
44
+ if question.type == "score":
45
+ return {
46
+ "type": "score",
47
+ "score": round(sum(i * v for i, v in enumerate(p)), _DIGITS),
48
+ "legend": {str(i): text for i, text in enumerate(question.texts)},
49
+ "probabilities": {str(i): round(v, _DIGITS) for i, v in enumerate(p)},
50
+ "confidence": round(score_confidence(p), _DIGITS),
51
+ "answer_confidence": round(top, _DIGITS),
52
+ }
53
+ return {
54
+ "type": "noul",
55
+ "noul": round(p[1], _DIGITS),
56
+ "answer_confidence": round(top, _DIGITS),
57
+ }
@@ -0,0 +1,12 @@
1
+ from .base import Architecture
2
+ from .modernbert import ModernBertArchitecture
3
+ from .qwen import QwenArchitecture
4
+ from .registry import architecture_for, register_architecture
5
+
6
+ __all__ = [
7
+ "Architecture",
8
+ "ModernBertArchitecture",
9
+ "QwenArchitecture",
10
+ "architecture_for",
11
+ "register_architecture",
12
+ ]
@@ -0,0 +1,144 @@
1
+ """The Architecture interface: everything model-specific sits behind it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from abc import ABC, abstractmethod
7
+
8
+ import torch
9
+ from torch import nn
10
+
11
+ from .._util import quiet_transformers, resolve_count
12
+ from ..layout import Pieces, attention_mask
13
+
14
+
15
+ def encode_text(tokenizer, text: str) -> list[int]:
16
+ """Token ids for one piece of text, without special tokens. Pieces are joined afterwards, so the
17
+ exact token span of every option is known."""
18
+ return tokenizer(text, add_special_tokens=False)["input_ids"]
19
+
20
+
21
+ class Architecture(ABC):
22
+ """How to use one model family.
23
+
24
+ Subclasses say how pieces are ordered and which positions are read (`pieces`). Loading, layer
25
+ access, masks and the forward pass have defaults that suit Hugging Face models whose base
26
+ model has a `layers` list and accepts masks as a dict keyed by layer type.
27
+ """
28
+
29
+ #: `AutoConfig.model_type` values this architecture handles.
30
+ model_types: tuple[str, ...] = ()
31
+ #: True for left-to-right models, whose tokens only see what came before them.
32
+ causal: bool = False
33
+ #: Module paths (regex fragments under a layer) for the named adapter target groups.
34
+ lora_targets: dict[str, list[str]] = {}
35
+ #: `backbone_lr="auto"`: the learning rate for fully trained layers ("full") and for adapters
36
+ #: ("adapter"). Subclasses set values measured on their model family.
37
+ learning_rates: dict[str, float] = {"full": 2.5e-5, "adapter": 1e-4}
38
+
39
+ # -- loading -----------------------------------------------------------------------------
40
+
41
+ def load(self, name: str, num_layers, dtype: torch.dtype, revision: str | None = None,
42
+ quantization=None, device: torch.device | None = None) -> nn.Module:
43
+ """Load the base model with only the first `num_layers` layers built.
44
+
45
+ `quantization` is a transformers `BitsAndBytesConfig`; a quantized model is placed on
46
+ `device` while loading, since it can't be moved afterwards.
47
+
48
+ Always uses `sdpa` attention: flash attention supports only standard causal or padding
49
+ masks and would silently ignore the layout's masks.
50
+ """
51
+ from transformers import AutoConfig, AutoModel
52
+
53
+ config = AutoConfig.from_pretrained(name, revision=revision)
54
+ total = config.num_hidden_layers
55
+ keep = resolve_count(num_layers, total, "num_layers")
56
+ if keep < 1:
57
+ raise ValueError("num_layers must keep at least one layer")
58
+ config.num_hidden_layers = keep
59
+ if getattr(config, "layer_types", None):
60
+ config.layer_types = list(config.layer_types[:keep])
61
+ extra = {}
62
+ if quantization is not None:
63
+ extra = {"quantization_config": quantization, "device_map": {"": device}}
64
+ with quiet_transformers():
65
+ model = AutoModel.from_pretrained(name, config=config, dtype=dtype, attn_implementation="sdpa",
66
+ revision=revision, **extra)
67
+ model.sysone_total_layers = total
68
+ return model
69
+
70
+ def layers(self, model: nn.Module) -> nn.ModuleList:
71
+ return model.layers
72
+
73
+ def embeddings(self, model: nn.Module) -> nn.Module:
74
+ """The token embeddings, trained with `train_embeddings=True`."""
75
+ return model.get_input_embeddings()
76
+
77
+ def adapter_modules(self, model: nn.Module, targets, layer_indices) -> list[str]:
78
+ """Full names of the linear modules in `layer_indices` that get adapters.
79
+
80
+ `targets` is a named group (`"attention"`, `"mlp"`, `"all"`) translated through
81
+ `lora_targets`, or a list of module names, matched by path within a layer (`"q_proj"` or
82
+ `"attn.Wo"`) and checked against the loaded model.
83
+ """
84
+ groups = {**self.lora_targets, "all": [p for g in ("attention", "mlp") for p in self.lora_targets.get(g, [])]}
85
+ if isinstance(targets, str):
86
+ if targets not in groups or not groups[targets]:
87
+ raise ValueError("lora_targets=%r is not supported by %s; use one of %s or a list of module names"
88
+ % (targets, self.name(), ", ".join(repr(g) for g in groups if groups[g])))
89
+ patterns = [re.compile(r"(^|\.)(%s)$" % p) for p in groups[targets]]
90
+ else:
91
+ patterns = [re.compile(r"(^|\.)%s$" % re.escape(t)) for t in targets]
92
+ layers = self.layers(model)
93
+ prefix = next(n for n, m in model.named_modules() if m is layers)
94
+ names, matched = [], set()
95
+ for i in layer_indices:
96
+ for sub, module in layers[i].named_modules():
97
+ if not isinstance(module, nn.Linear):
98
+ continue
99
+ hits = [j for j, p in enumerate(patterns) if p.search(sub)]
100
+ if hits:
101
+ names.append("%s.%d.%s" % (prefix, i, sub))
102
+ matched.update(hits)
103
+ if not isinstance(targets, str) and len(matched) < len(patterns):
104
+ missing = [t for j, t in enumerate(targets) if j not in matched]
105
+ available = sorted(sub for sub, m in layers[0].named_modules() if isinstance(m, nn.Linear))
106
+ raise ValueError("lora_targets %s matched no module of %s; its layers have these linear modules: %s"
107
+ % (missing, self.name(), ", ".join(available)))
108
+ return names
109
+
110
+ def max_positions(self, model: nn.Module) -> int:
111
+ return int(model.config.max_position_embeddings)
112
+
113
+ # -- sequence layout -----------------------------------------------------------------------
114
+
115
+ def check_tokenizer(self, tokenizer) -> None:
116
+ if tokenizer.pad_token_id is None:
117
+ raise ValueError("the tokenizer has no pad token")
118
+
119
+ @abstractmethod
120
+ def pieces(self, tokenizer, head: list[int], state: list[int], options: list[list[int]]) -> Pieces:
121
+ """Arrange already-tokenized pieces: `head` (task line and question), `state`, and each
122
+ option's own text. Adds the model's special tokens and delimiters."""
123
+
124
+ # -- forward -----------------------------------------------------------------------------
125
+
126
+ def attention(self, model: nn.Module, real, position_ids, option_ids) -> dict[str, torch.Tensor]:
127
+ """Masks keyed by layer type, the form the model's forward accepts."""
128
+ layer_types = set(getattr(model.config, "layer_types", None) or ["full_attention"])
129
+ masks = {}
130
+ for layer_type in layer_types:
131
+ window = model.config.sliding_window if layer_type == "sliding_attention" else None
132
+ masks[layer_type] = attention_mask(real, position_ids, option_ids, causal=self.causal,
133
+ sliding_window=window)
134
+ return masks
135
+
136
+ def hidden_states(self, model: nn.Module, batch: dict[str, torch.Tensor]) -> torch.Tensor:
137
+ masks = self.attention(model, batch["real"], batch["position_ids"], batch["option_ids"])
138
+ out = model(input_ids=batch["input_ids"], attention_mask=masks, position_ids=batch["position_ids"],
139
+ use_cache=False) if self.causal else \
140
+ model(input_ids=batch["input_ids"], attention_mask=masks, position_ids=batch["position_ids"])
141
+ return out.last_hidden_state
142
+
143
+ def name(self) -> str:
144
+ return self.model_types[0] if self.model_types else type(self).__name__
@@ -0,0 +1,43 @@
1
+ """ModernBERT: a bidirectional encoder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..layout import Pieces
6
+ from .base import Architecture
7
+
8
+
9
+ class ModernBertArchitecture(Architecture):
10
+ """`[CLS] head [SEP] [MASK] option … [MASK] option [SEP] state [SEP]`.
11
+
12
+ Each option is read at the `[MASK]` that opens it, which sees everything in a bidirectional
13
+ model. The summary is read at `[CLS]`.
14
+ """
15
+
16
+ model_types = ("modernbert",)
17
+ causal = False
18
+ lora_targets = {
19
+ # ModernBERT names both its attention output and its MLP output `Wo`, so targets are
20
+ # matched by path, never by bare module name.
21
+ "attention": [r"attn\.Wqkv", r"attn\.Wo"],
22
+ "mlp": [r"mlp\.Wi", r"mlp\.Wo"],
23
+ }
24
+ # Full tuning at 5e-5 reached 0.79 test accuracy on typed-decisions; adapters at 1e-4 barely
25
+ # moved in 4 epochs, so their default is ten times higher (not yet measured).
26
+ learning_rates = {"full": 5e-5, "adapter": 1e-3}
27
+
28
+ def check_tokenizer(self, tokenizer) -> None:
29
+ super().check_tokenizer(tokenizer)
30
+ missing = [name for name in ("cls_token_id", "sep_token_id", "mask_token_id")
31
+ if getattr(tokenizer, name) is None]
32
+ if missing:
33
+ raise ValueError("the tokenizer has no %s" % ", ".join(missing))
34
+
35
+ def pieces(self, tokenizer, head, state, options) -> Pieces:
36
+ cls, sep, mask = tokenizer.cls_token_id, tokenizer.sep_token_id, tokenizer.mask_token_id
37
+ return Pieces(
38
+ prefix=[cls, *head, sep],
39
+ options=[[mask, *option] for option in options],
40
+ option_read=[0] * len(options),
41
+ suffix=[sep, *state, sep],
42
+ summary=("prefix", 0),
43
+ )
@@ -0,0 +1,48 @@
1
+ """Qwen3: a causal decoder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..layout import Pieces
6
+ from .base import Architecture, encode_text
7
+
8
+ OPEN, CLOSE, CUE = "<option>", "</option>", "The answer is:"
9
+
10
+
11
+ class QwenArchitecture(Architecture):
12
+ """`head state <option>option</option> … The answer is:`.
13
+
14
+ A causal token only sees what came before it, so:
15
+ - the question comes before the state, and every state token is read knowing what is asked;
16
+ - options come after the state, so they can see it;
17
+ - each option is read at the last token of its own text, just before `</option>`, the first
18
+ point at which it has seen the whole option. `<option>` / `</option>` are plain-text
19
+ delimiters split into ordinary pretrained tokens; no vector is read from them;
20
+ - the summary is the last token of the answer cue, which has seen everything.
21
+ """
22
+
23
+ model_types = ("qwen3",)
24
+ causal = True
25
+ lora_targets = {
26
+ "attention": [r"self_attn\.(q|k|v|o)_proj"],
27
+ "mlp": [r"mlp\.(gate|up|down)_proj"],
28
+ }
29
+ # Adapters at 1e-4 measured on typed-decisions; the full-tuning rate is not yet measured.
30
+ learning_rates = {"full": 2.5e-5, "adapter": 1e-4}
31
+
32
+ def pieces(self, tokenizer, head, state, options) -> Pieces:
33
+ gap, newline, open_, close, cue = _delimiters(tokenizer)
34
+ return Pieces(
35
+ prefix=[*head, *gap, *state, *newline],
36
+ options=[[*open_, *option, *close] for option in options],
37
+ option_read=[len(open_) + len(option) - 1 for option in options],
38
+ suffix=list(cue),
39
+ summary=("suffix", -1),
40
+ )
41
+
42
+
43
+ def _delimiters(tokenizer):
44
+ cached = getattr(tokenizer, "_sysone_qwen_delimiters", None)
45
+ if cached is None:
46
+ cached = tuple(tuple(encode_text(tokenizer, text)) for text in ("\n\n", "\n", OPEN, CLOSE, CUE))
47
+ tokenizer._sysone_qwen_delimiters = cached
48
+ return cached
@@ -0,0 +1,53 @@
1
+ """Look up the architecture for a base model by its `AutoConfig.model_type`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+
7
+ from .base import Architecture
8
+ from .modernbert import ModernBertArchitecture
9
+ from .qwen import QwenArchitecture
10
+
11
+ _REGISTRY: dict[str, type[Architecture]] = {}
12
+
13
+
14
+ def register_architecture(model_type: str, cls: type[Architecture]) -> None:
15
+ """Make `cls` handle base models whose `AutoConfig.model_type` is `model_type`."""
16
+ if not (isinstance(cls, type) and issubclass(cls, Architecture)):
17
+ raise TypeError("register_architecture needs an Architecture subclass, got %r" % (cls,))
18
+ _REGISTRY[model_type] = cls
19
+
20
+
21
+ def architecture_for(model_name: str, revision: str | None = None) -> Architecture:
22
+ from transformers import AutoConfig
23
+
24
+ model_type = AutoConfig.from_pretrained(model_name, revision=revision).model_type
25
+ if model_type not in _REGISTRY:
26
+ raise ValueError("no architecture is registered for model type %r (from %s); supported: %s. "
27
+ "Write an Architecture subclass and call register_architecture(), or pass "
28
+ "architecture=" % (model_type, model_name, ", ".join(sorted(_REGISTRY))))
29
+ return _REGISTRY[model_type]()
30
+
31
+
32
+ def architecture_spec(arch: Architecture) -> str:
33
+ """A string that `architecture_from_spec` turns back into the same architecture."""
34
+ for model_type, cls in _REGISTRY.items():
35
+ if type(arch) is cls:
36
+ return model_type
37
+ return "%s:%s" % (type(arch).__module__, type(arch).__qualname__)
38
+
39
+
40
+ def architecture_from_spec(spec: str) -> Architecture:
41
+ if spec in _REGISTRY:
42
+ return _REGISTRY[spec]()
43
+ module, _, qualname = spec.partition(":")
44
+ if not qualname:
45
+ raise ValueError("unknown architecture %r" % spec)
46
+ obj = importlib.import_module(module)
47
+ for part in qualname.split("."):
48
+ obj = getattr(obj, part)
49
+ return obj()
50
+
51
+
52
+ register_architecture("modernbert", ModernBertArchitecture)
53
+ register_architecture("qwen3", QwenArchitecture)