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 +22 -0
- sysone/_util.py +102 -0
- sysone/answers.py +57 -0
- sysone/architectures/__init__.py +12 -0
- sysone/architectures/base.py +144 -0
- sysone/architectures/modernbert.py +43 -0
- sysone/architectures/qwen.py +48 -0
- sysone/architectures/registry.py +53 -0
- sysone/base.py +890 -0
- sysone/cache.py +196 -0
- sysone/checkpoint.py +96 -0
- sysone/data.py +190 -0
- sysone/encoding.py +80 -0
- sysone/evaluation.py +167 -0
- sysone/features.py +98 -0
- sysone/heads.py +66 -0
- sysone/layout.py +129 -0
- sysone/losses.py +17 -0
- sysone/models.py +24 -0
- sysone/prompts.py +60 -0
- sysone/questions.py +186 -0
- sysone/training.py +399 -0
- sysone/tuning.py +210 -0
- sysone/version.py +1 -0
- sysone-0.2.0.dist-info/METADATA +144 -0
- sysone-0.2.0.dist-info/RECORD +28 -0
- sysone-0.2.0.dist-info/WHEEL +4 -0
- sysone-0.2.0.dist-info/licenses/LICENSE +21 -0
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)
|