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/cache.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""The on-disk feature cache for a frozen backbone (`cache_dir`).
|
|
2
|
+
|
|
3
|
+
Vectors are grouped into **sets**, one folder per combination of everything that determines them
|
|
4
|
+
(base model and revision, kept layers, layout, precision, prompt format, token limits). Within a
|
|
5
|
+
set, each question is looked up by a hash of its encoded tokens, so growing the data only
|
|
6
|
+
computes the new questions. Each write adds one shard file to the set.
|
|
7
|
+
|
|
8
|
+
The whole folder is capped at `cache_max_gb`: the least recently used sets are deleted first, and
|
|
9
|
+
a set that would exceed the cap on its own is never written. Nothing is written if it would
|
|
10
|
+
leave less than 10% of the disk free.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import time
|
|
20
|
+
from array import array
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import torch
|
|
26
|
+
|
|
27
|
+
from ._util import format_bytes
|
|
28
|
+
from .features import CACHE_DTYPE, Features
|
|
29
|
+
from .layout import Encoded
|
|
30
|
+
|
|
31
|
+
FORMAT = 1
|
|
32
|
+
SET_PREFIX = "set-"
|
|
33
|
+
META_FILE = "set.json"
|
|
34
|
+
#: Writing is skipped when it would leave less than this fraction of the disk free.
|
|
35
|
+
MIN_FREE_FRACTION = 0.10
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def question_key(encoded: Encoded) -> bytes:
|
|
39
|
+
"""A 16-byte hash of everything about an encoded question that its vectors depend on."""
|
|
40
|
+
h = hashlib.blake2b(digest_size=16)
|
|
41
|
+
for values in (encoded.input_ids, encoded.position_ids, encoded.option_ids, encoded.read_positions,
|
|
42
|
+
[encoded.summary_position]):
|
|
43
|
+
h.update(array("q", values).tobytes())
|
|
44
|
+
h.update(b"|")
|
|
45
|
+
return h.digest()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _set_name(parts: dict) -> str:
|
|
49
|
+
text = json.dumps({"format": FORMAT, **parts}, sort_keys=True, default=str)
|
|
50
|
+
return SET_PREFIX + hashlib.sha256(text.encode()).hexdigest()[:16]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _folder_bytes(path: Path) -> int:
|
|
54
|
+
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _read_meta(path: Path) -> dict | None:
|
|
58
|
+
try:
|
|
59
|
+
with open(path / META_FILE, encoding="utf-8") as f:
|
|
60
|
+
return json.load(f)
|
|
61
|
+
except (OSError, ValueError):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
66
|
+
tmp = path.with_suffix(".tmp")
|
|
67
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
68
|
+
json.dump(data, f, indent=2, default=str)
|
|
69
|
+
os.replace(tmp, path)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _sets(root: Path) -> list[tuple[Path, dict]]:
|
|
73
|
+
if not root.is_dir():
|
|
74
|
+
return []
|
|
75
|
+
found = []
|
|
76
|
+
for path in sorted(root.glob(SET_PREFIX + "*")):
|
|
77
|
+
meta = _read_meta(path) if path.is_dir() else None
|
|
78
|
+
if meta is not None:
|
|
79
|
+
found.append((path, meta))
|
|
80
|
+
return found
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FeatureCache:
|
|
84
|
+
"""One set of cached vectors in `root`, identified by `parts`."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, root, parts: dict, max_bytes: float, log: Callable[[str], None]):
|
|
87
|
+
self.root = Path(root).expanduser()
|
|
88
|
+
self.parts = parts
|
|
89
|
+
self.path = self.root / _set_name(parts)
|
|
90
|
+
self.max_bytes = max_bytes
|
|
91
|
+
self.log = log
|
|
92
|
+
|
|
93
|
+
def _touch(self, meta: dict) -> None:
|
|
94
|
+
meta["last_used"] = time.time()
|
|
95
|
+
_write_json(self.path / META_FILE, meta)
|
|
96
|
+
|
|
97
|
+
def lookup(self, keys: Sequence[bytes]) -> dict[bytes, Features]:
|
|
98
|
+
"""The cached vectors for whichever of `keys` are in this set."""
|
|
99
|
+
from safetensors.torch import load_file
|
|
100
|
+
|
|
101
|
+
meta = _read_meta(self.path)
|
|
102
|
+
if meta is None:
|
|
103
|
+
return {}
|
|
104
|
+
wanted, found = set(keys), {}
|
|
105
|
+
for shard in sorted(self.path.glob("shard-*.safetensors")):
|
|
106
|
+
data = load_file(str(shard))
|
|
107
|
+
offsets = data["offsets"].tolist()
|
|
108
|
+
for i, key in enumerate(bytes(row) for row in data["keys"].numpy()):
|
|
109
|
+
if key in wanted and key not in found:
|
|
110
|
+
found[key] = Features(data["options"][offsets[i]: offsets[i + 1]].clone(),
|
|
111
|
+
data["summary"][i].clone())
|
|
112
|
+
self._touch(meta)
|
|
113
|
+
return found
|
|
114
|
+
|
|
115
|
+
def add(self, keys: Sequence[bytes], features: Sequence[Features]) -> int:
|
|
116
|
+
"""Write new vectors as one shard; returns the bytes written (0 when skipped)."""
|
|
117
|
+
from safetensors.torch import save_file
|
|
118
|
+
|
|
119
|
+
if not keys:
|
|
120
|
+
return 0
|
|
121
|
+
tensors = {
|
|
122
|
+
"keys": torch.tensor([list(k) for k in keys], dtype=torch.uint8),
|
|
123
|
+
"offsets": torch.tensor([0] + [f.options.shape[0] for f in features], dtype=torch.int64).cumsum(0),
|
|
124
|
+
"options": torch.cat([f.options for f in features]).to(CACHE_DTYPE).contiguous(),
|
|
125
|
+
"summary": torch.stack([f.summary for f in features]).to(CACHE_DTYPE).contiguous(),
|
|
126
|
+
}
|
|
127
|
+
size = sum(t.numel() * t.element_size() for t in tensors.values())
|
|
128
|
+
current = _folder_bytes(self.path) if self.path.is_dir() else 0
|
|
129
|
+
if current + size > self.max_bytes:
|
|
130
|
+
self.log("feature cache: this set would take %s, more than cache_max_gb=%g; not writing it"
|
|
131
|
+
% (format_bytes(current + size), self.max_bytes / 1024**3))
|
|
132
|
+
return 0
|
|
133
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
disk = shutil.disk_usage(self.root)
|
|
135
|
+
if disk.free - size < MIN_FREE_FRACTION * disk.total:
|
|
136
|
+
self.log("feature cache: writing %s would leave less than %d%% of the disk free; not writing it"
|
|
137
|
+
% (format_bytes(size), int(100 * MIN_FREE_FRACTION)))
|
|
138
|
+
return 0
|
|
139
|
+
self._evict(size)
|
|
140
|
+
self.path.mkdir(exist_ok=True)
|
|
141
|
+
meta = _read_meta(self.path) or {"format": FORMAT, "parts": self.parts, "created": time.time(),
|
|
142
|
+
"n_questions": 0}
|
|
143
|
+
n_shards = len(list(self.path.glob("shard-*.safetensors")))
|
|
144
|
+
shard = self.path / ("shard-%05d.safetensors" % n_shards)
|
|
145
|
+
tmp = shard.with_suffix(".tmp")
|
|
146
|
+
save_file(tensors, str(tmp))
|
|
147
|
+
os.replace(tmp, shard)
|
|
148
|
+
meta["n_questions"] += len(keys)
|
|
149
|
+
self._touch(meta)
|
|
150
|
+
return shard.stat().st_size
|
|
151
|
+
|
|
152
|
+
def _evict(self, incoming: int) -> None:
|
|
153
|
+
"""Delete the least recently used other sets until `incoming` more bytes fit the cap."""
|
|
154
|
+
others = sorted(((p, m) for p, m in _sets(self.root) if p != self.path),
|
|
155
|
+
key=lambda pm: pm[1].get("last_used", 0))
|
|
156
|
+
total = _folder_bytes(self.root)
|
|
157
|
+
while others and total + incoming > self.max_bytes:
|
|
158
|
+
path, _ = others.pop(0)
|
|
159
|
+
freed = _folder_bytes(path)
|
|
160
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
161
|
+
total -= freed
|
|
162
|
+
self.log("feature cache: deleted the least recently used set %s (%s) to stay under cache_max_gb"
|
|
163
|
+
% (path.name, format_bytes(freed)))
|
|
164
|
+
|
|
165
|
+
def folder_bytes(self) -> int:
|
|
166
|
+
return _folder_bytes(self.root) if self.root.is_dir() else 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cache_info(cache_dir) -> list[dict]:
|
|
170
|
+
"""The cache sets in `cache_dir`, most recently used first: their folder, size, number of
|
|
171
|
+
questions, last use, and the settings that identify them."""
|
|
172
|
+
rows = []
|
|
173
|
+
for path, meta in _sets(Path(cache_dir).expanduser()):
|
|
174
|
+
size = _folder_bytes(path)
|
|
175
|
+
last = meta.get("last_used")
|
|
176
|
+
rows.append({
|
|
177
|
+
"set": path.name,
|
|
178
|
+
"path": str(path),
|
|
179
|
+
"model": meta.get("parts", {}).get("model_name"),
|
|
180
|
+
"n_questions": meta.get("n_questions", 0),
|
|
181
|
+
"size_bytes": size,
|
|
182
|
+
"size": format_bytes(size),
|
|
183
|
+
"last_used": datetime.fromtimestamp(last, timezone.utc).isoformat(timespec="seconds") if last else None,
|
|
184
|
+
"parts": meta.get("parts", {}),
|
|
185
|
+
})
|
|
186
|
+
return sorted(rows, key=lambda r: r["last_used"] or "", reverse=True)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def clear_cache(cache_dir) -> int:
|
|
190
|
+
"""Delete every cache set in `cache_dir` and return the bytes freed. Other files in the folder
|
|
191
|
+
are left alone."""
|
|
192
|
+
freed = 0
|
|
193
|
+
for path, _ in _sets(Path(cache_dir).expanduser()):
|
|
194
|
+
freed += _folder_bytes(path)
|
|
195
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
196
|
+
return freed
|
sysone/checkpoint.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Files of a saved model beyond `config.json` and the head.
|
|
2
|
+
|
|
3
|
+
- `adapter/`: LoRA adapters in peft's format (`adapter_config.json`, `adapter_model.safetensors`);
|
|
4
|
+
- `backbone.safetensors`: only the fully trained layers and embeddings, in the backbone's precision;
|
|
5
|
+
- `training_state/`: what a later `fit` needs to resume the same run.
|
|
6
|
+
|
|
7
|
+
Frozen backbone weights are never saved.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import torch
|
|
18
|
+
from torch import nn
|
|
19
|
+
|
|
20
|
+
from .training import TrainState
|
|
21
|
+
from .tuning import add_adapters, is_adapter
|
|
22
|
+
|
|
23
|
+
ADAPTER_DIR = "adapter"
|
|
24
|
+
ADAPTER_WEIGHTS = "adapter_model.safetensors"
|
|
25
|
+
BACKBONE_FILE = "backbone.safetensors"
|
|
26
|
+
STATE_DIR = "training_state"
|
|
27
|
+
_STATE_META = "state.json"
|
|
28
|
+
_STATE_TENSORS = "state.pt"
|
|
29
|
+
#: peft stores adapter weights without the adapter's name, under the wrapper's prefix.
|
|
30
|
+
_PEFT_PREFIX = "base_model.model."
|
|
31
|
+
_ADAPTER_KEY = re.compile(r"\.(lora_[AB])\.default\.")
|
|
32
|
+
_PEFT_KEY = re.compile(r"\.(lora_[AB])\.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save_adapter(backbone: nn.Module, path: Path) -> None:
|
|
36
|
+
from safetensors.torch import save_file
|
|
37
|
+
|
|
38
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
backbone.sysone_lora_config.save_pretrained(str(path))
|
|
40
|
+
weights = {_PEFT_PREFIX + _ADAPTER_KEY.sub(r".\1.", name): p.detach().cpu().contiguous()
|
|
41
|
+
for name, p in backbone.named_parameters() if is_adapter(name)}
|
|
42
|
+
save_file(weights, str(path / ADAPTER_WEIGHTS))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_adapter(backbone: nn.Module, path: Path) -> None:
|
|
46
|
+
from peft import LoraConfig
|
|
47
|
+
from safetensors.torch import load_file
|
|
48
|
+
|
|
49
|
+
add_adapters(backbone, LoraConfig.from_pretrained(str(path)))
|
|
50
|
+
params = dict(backbone.named_parameters())
|
|
51
|
+
for key, value in load_file(str(path / ADAPTER_WEIGHTS)).items():
|
|
52
|
+
name = _PEFT_KEY.sub(r".\1.default.", key.removeprefix(_PEFT_PREFIX))
|
|
53
|
+
if name not in params:
|
|
54
|
+
raise ValueError("the saved adapter has a weight %r that this model doesn't" % key)
|
|
55
|
+
with torch.no_grad():
|
|
56
|
+
params[name].copy_(value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def save_backbone_weights(named: list[tuple[str, nn.Parameter]], dtype: torch.dtype, path: Path) -> None:
|
|
60
|
+
from safetensors.torch import save_file
|
|
61
|
+
|
|
62
|
+
save_file({name: p.detach().to("cpu", dtype).contiguous() for name, p in named}, str(path))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_backbone_weights(backbone: nn.Module, path: Path) -> None:
|
|
66
|
+
from safetensors.torch import load_file
|
|
67
|
+
|
|
68
|
+
weights = load_file(str(path))
|
|
69
|
+
missing = set(weights) - set(dict(backbone.named_parameters()))
|
|
70
|
+
if missing:
|
|
71
|
+
raise ValueError("%s has weights this model doesn't: %s" % (path.name, sorted(missing)[:5]))
|
|
72
|
+
backbone.load_state_dict(weights, strict=False)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_training_state(state: TrainState, path: Path) -> None:
|
|
76
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
meta = {k: getattr(state, k) for k in ("fingerprint", "step", "last_check", "best_step", "running_loss",
|
|
78
|
+
"running_n")}
|
|
79
|
+
meta["best_loss"] = state.best_loss if math.isfinite(state.best_loss) else None
|
|
80
|
+
with open(path / _STATE_META, "w", encoding="utf-8") as f:
|
|
81
|
+
json.dump(meta, f, indent=2)
|
|
82
|
+
torch.save({"epoch_generator": state.epoch_generator, "rng": state.rng, "optimizer": state.optimizer,
|
|
83
|
+
"current": state.current}, path / _STATE_TENSORS)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_training_state(path: Path) -> TrainState | None:
|
|
87
|
+
if not (path / _STATE_META).is_file():
|
|
88
|
+
return None
|
|
89
|
+
with open(path / _STATE_META, encoding="utf-8") as f:
|
|
90
|
+
meta = json.load(f)
|
|
91
|
+
tensors = torch.load(path / _STATE_TENSORS, map_location="cpu", weights_only=True)
|
|
92
|
+
best = meta.pop("best_loss")
|
|
93
|
+
if "stale" in meta: # saved before patience was counted in epochs: assume the best check was the last one
|
|
94
|
+
meta.pop("stale")
|
|
95
|
+
meta["best_step"] = meta["last_check"]
|
|
96
|
+
return TrainState(**meta, best_loss=math.inf if best is None else best, **tensors)
|
sysone/data.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Turn the accepted input forms into examples: one per (state, question, label).
|
|
2
|
+
|
|
3
|
+
Accepted forms:
|
|
4
|
+
|
|
5
|
+
1. Records: an iterable of dicts with `state`, `questions`, and labels in `expected`, `gold` or a
|
|
6
|
+
`label` inside each question. Rows may mix question sets freely.
|
|
7
|
+
2. Records plus a question catalog (`questions={id: question_dict}`), so rows carry only `state`
|
|
8
|
+
and their labels.
|
|
9
|
+
3. A long DataFrame, one row per (state, question): `state | question_id | answer` with a
|
|
10
|
+
catalog, or self-contained `state | type | instructions | criteria | answer`.
|
|
11
|
+
|
|
12
|
+
A path to a `.jsonl`, `.json`, `.csv` or `.parquet` file is read first. A `datasets.Dataset` is
|
|
13
|
+
iterated as records. Fields holding JSON text (common in published datasets) are decoded.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
from collections import Counter
|
|
21
|
+
from collections.abc import Iterable, Mapping
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from .questions import Question, parse_question, target_from_gold, target_from_label
|
|
27
|
+
|
|
28
|
+
_MISSING = object()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Example:
|
|
33
|
+
"""One question about one state. `target` is None at prediction time."""
|
|
34
|
+
|
|
35
|
+
state: str
|
|
36
|
+
question: Question
|
|
37
|
+
qid: str
|
|
38
|
+
target: list[float] | None = None
|
|
39
|
+
category: str | None = None
|
|
40
|
+
row: int = 0 # index of the source row; questions from one row share a state
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def state_text(state: Any) -> str:
|
|
44
|
+
if isinstance(state, str):
|
|
45
|
+
return state
|
|
46
|
+
return json.dumps(state, ensure_ascii=False, default=str)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _maybe_json(value: Any) -> Any:
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
stripped = value.strip()
|
|
52
|
+
if stripped[:1] in ("{", "["):
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(stripped)
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
return value
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_frame(data: Any) -> bool:
|
|
61
|
+
return hasattr(data, "columns") and hasattr(data, "to_dict") and hasattr(data, "iterrows")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _read_path(path: Path) -> Any:
|
|
65
|
+
suffix = path.suffix.lower()
|
|
66
|
+
if suffix == ".jsonl":
|
|
67
|
+
with open(path, encoding="utf-8") as f:
|
|
68
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
69
|
+
if suffix == ".json":
|
|
70
|
+
with open(path, encoding="utf-8") as f:
|
|
71
|
+
return json.load(f)
|
|
72
|
+
if suffix in (".csv", ".parquet"):
|
|
73
|
+
try:
|
|
74
|
+
import pandas as pd
|
|
75
|
+
except ImportError as exc:
|
|
76
|
+
raise ImportError("reading %s files needs pandas: pip install sysone[pandas]" % suffix) from exc
|
|
77
|
+
return pd.read_csv(path) if suffix == ".csv" else pd.read_parquet(path)
|
|
78
|
+
raise ValueError("cannot read %s: use a .jsonl, .json, .csv or .parquet file" % path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _frame_rows(frame: Any) -> Iterable[dict]:
|
|
82
|
+
columns = set(frame.columns)
|
|
83
|
+
if "state" not in columns:
|
|
84
|
+
raise ValueError("a DataFrame needs a 'state' column")
|
|
85
|
+
if "question_id" in columns:
|
|
86
|
+
mode = "catalog"
|
|
87
|
+
elif {"type", "instructions"} <= columns:
|
|
88
|
+
mode = "inline"
|
|
89
|
+
else:
|
|
90
|
+
raise ValueError("a DataFrame needs either a 'question_id' column (with questions=catalog) or "
|
|
91
|
+
"'type' and 'instructions' columns")
|
|
92
|
+
has_answer = "answer" in columns
|
|
93
|
+
for record in frame.to_dict(orient="records"):
|
|
94
|
+
row: dict = {"state": record["state"]}
|
|
95
|
+
if "category" in columns and record.get("category") is not None:
|
|
96
|
+
row["category"] = record["category"]
|
|
97
|
+
if mode == "catalog":
|
|
98
|
+
qid = str(record["question_id"])
|
|
99
|
+
row["questions"] = [qid]
|
|
100
|
+
else:
|
|
101
|
+
qid = "question"
|
|
102
|
+
qdef = {"type": record["type"], "instructions": record["instructions"]}
|
|
103
|
+
criteria = _maybe_json(record.get("criteria"))
|
|
104
|
+
if criteria is not None and not (isinstance(criteria, float) and criteria != criteria): # NaN
|
|
105
|
+
qdef["criteria"] = criteria
|
|
106
|
+
row["questions"] = {qid: qdef}
|
|
107
|
+
if has_answer and not _is_null(record["answer"]):
|
|
108
|
+
row["expected"] = {qid: record["answer"]}
|
|
109
|
+
yield row
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_null(value: Any) -> bool:
|
|
113
|
+
return value is None or (isinstance(value, float) and value != value)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _rows(data: Any) -> Iterable[dict]:
|
|
117
|
+
if isinstance(data, (str, os.PathLike)):
|
|
118
|
+
data = _read_path(Path(data))
|
|
119
|
+
if _is_frame(data):
|
|
120
|
+
return _frame_rows(data)
|
|
121
|
+
if isinstance(data, Mapping):
|
|
122
|
+
raise TypeError("pass a list of rows (dicts), not a single dict")
|
|
123
|
+
return data
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _resolve_questions(row: dict, catalog: Mapping | None, where: str) -> dict:
|
|
127
|
+
questions = _maybe_json(row.get("questions", _MISSING))
|
|
128
|
+
if questions is _MISSING or questions is None:
|
|
129
|
+
if catalog is None:
|
|
130
|
+
raise ValueError("%s: no 'questions' and no questions= catalog was given" % where)
|
|
131
|
+
referenced = list(_maybe_json(row.get("expected")) or {}) + list(_maybe_json(row.get("gold")) or {})
|
|
132
|
+
questions = {qid: catalog[qid] for qid in dict.fromkeys(referenced) if _in_catalog(catalog, qid, where)}
|
|
133
|
+
elif isinstance(questions, (list, tuple)):
|
|
134
|
+
# a list of question ids, looked up in the catalog
|
|
135
|
+
if catalog is None:
|
|
136
|
+
raise ValueError("%s: 'questions' lists ids but no questions= catalog was given" % where)
|
|
137
|
+
questions = {qid: catalog[qid] for qid in questions if _in_catalog(catalog, qid, where)}
|
|
138
|
+
elif not isinstance(questions, dict):
|
|
139
|
+
raise ValueError("%s: 'questions' must be a dict of question id -> question" % where)
|
|
140
|
+
return {qid: _maybe_json(qdef) for qid, qdef in questions.items()}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _in_catalog(catalog: Mapping, qid: Any, where: str) -> bool:
|
|
144
|
+
if qid not in catalog:
|
|
145
|
+
raise ValueError("%s: question %r is not in the questions= catalog" % (where, qid))
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def examples_from(data: Any, questions: Mapping | None = None, *, labeled: bool = True,
|
|
150
|
+
label_smoothing: float = 0.0) -> tuple[list[Example], Counter]:
|
|
151
|
+
"""Examples from any accepted input form.
|
|
152
|
+
|
|
153
|
+
With `labeled=True` (training), questions without a label are skipped and counted in the
|
|
154
|
+
returned `Counter` under "unlabeled". Malformed rows or questions raise `ValueError` naming
|
|
155
|
+
the row and question.
|
|
156
|
+
"""
|
|
157
|
+
examples: list[Example] = []
|
|
158
|
+
skipped: Counter = Counter()
|
|
159
|
+
for i, row in enumerate(_rows(data)):
|
|
160
|
+
where = "row %d" % i
|
|
161
|
+
if not isinstance(row, Mapping):
|
|
162
|
+
raise ValueError("%s: expected a dict, got %s" % (where, type(row).__name__))
|
|
163
|
+
if "state" not in row:
|
|
164
|
+
raise ValueError("%s: missing 'state'" % where)
|
|
165
|
+
state = state_text(row["state"])
|
|
166
|
+
category = row.get("category")
|
|
167
|
+
category = None if _is_null(category) else str(category)
|
|
168
|
+
qdefs = _resolve_questions(row, questions, where)
|
|
169
|
+
expected = _maybe_json(row.get("expected")) or {}
|
|
170
|
+
gold = _maybe_json(row.get("gold")) or {}
|
|
171
|
+
for qid, qdef in qdefs.items():
|
|
172
|
+
qwhere = "%s, question %r" % (where, qid)
|
|
173
|
+
question = parse_question(qid, qdef)
|
|
174
|
+
target = None
|
|
175
|
+
if labeled:
|
|
176
|
+
sources = [name for name, present in (("expected", qid in expected), ("gold", qid in gold),
|
|
177
|
+
("label", isinstance(qdef, dict) and "label" in qdef))
|
|
178
|
+
if present]
|
|
179
|
+
if len(sources) > 1:
|
|
180
|
+
raise ValueError("%s: has labels in both %s; give exactly one" % (qwhere, " and ".join(sources)))
|
|
181
|
+
if not sources:
|
|
182
|
+
skipped["unlabeled"] += 1
|
|
183
|
+
continue
|
|
184
|
+
if sources[0] == "gold":
|
|
185
|
+
target = target_from_gold(question, _maybe_json(gold[qid]), where=qwhere)
|
|
186
|
+
else:
|
|
187
|
+
value = expected[qid] if sources[0] == "expected" else qdef["label"]
|
|
188
|
+
target = target_from_label(question, value, label_smoothing, where=qwhere)
|
|
189
|
+
examples.append(Example(state, question, str(qid), target, category, i))
|
|
190
|
+
return examples, skipped
|
sysone/encoding.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Turn (state, question) pairs into token layouts, with truncation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from .architectures.base import Architecture, encode_text
|
|
9
|
+
from .layout import Encoded, build
|
|
10
|
+
from .prompts import PromptFormat
|
|
11
|
+
from .questions import Question
|
|
12
|
+
|
|
13
|
+
#: The percentile of training-data lengths that "auto" token limits are set to.
|
|
14
|
+
AUTO_PERCENTILE = 95
|
|
15
|
+
#: Room kept for the question, options and delimiters when capping an "auto" state limit.
|
|
16
|
+
_RESERVED_TOKENS = 256
|
|
17
|
+
_MAX_AUTO_OPTION_TOKENS = 256
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Encoder:
|
|
21
|
+
def __init__(self, arch: Architecture, tokenizer, prompt: PromptFormat, layout: str,
|
|
22
|
+
max_state_tokens: int, max_option_tokens: int, max_len: int):
|
|
23
|
+
self.arch = arch
|
|
24
|
+
self.tokenizer = tokenizer
|
|
25
|
+
self.prompt = prompt
|
|
26
|
+
self.layout = layout
|
|
27
|
+
self.max_state_tokens = max_state_tokens
|
|
28
|
+
self.max_option_tokens = max_option_tokens
|
|
29
|
+
self.max_len = max_len
|
|
30
|
+
self._cache: dict[str, list[int]] = {}
|
|
31
|
+
|
|
32
|
+
def tokens(self, text: str) -> list[int]:
|
|
33
|
+
ids = self._cache.get(text)
|
|
34
|
+
if ids is None:
|
|
35
|
+
ids = encode_text(self.tokenizer, text)
|
|
36
|
+
self._cache[text] = ids
|
|
37
|
+
return ids
|
|
38
|
+
|
|
39
|
+
def encode(self, state: str, question: Question, order: Sequence[int] | None = None) -> Encoded:
|
|
40
|
+
"""Lay out one question about one state. Slot `s` holds option `order[s]` (default: the
|
|
41
|
+
question's own order)."""
|
|
42
|
+
head = self.tokens(self.prompt.head(question))
|
|
43
|
+
texts = self.prompt.options(question)
|
|
44
|
+
if order is not None:
|
|
45
|
+
texts = [texts[i] for i in order]
|
|
46
|
+
options = [self.tokens(t)[: self.max_option_tokens] for t in texts]
|
|
47
|
+
state_ids = self.tokens(state)[: self.max_state_tokens]
|
|
48
|
+
encoded = build(self.arch.pieces(self.tokenizer, head, state_ids, options), self.layout)
|
|
49
|
+
overflow = len(encoded.input_ids) - self.max_len
|
|
50
|
+
if overflow > 0:
|
|
51
|
+
if overflow > len(state_ids):
|
|
52
|
+
raise ValueError("the question %r with %d options needs more than max_len=%d tokens even "
|
|
53
|
+
"with no state; shorten its options or raise max_len"
|
|
54
|
+
% (question.instructions[:60], question.k, self.max_len))
|
|
55
|
+
state_ids = state_ids[: len(state_ids) - overflow]
|
|
56
|
+
encoded = build(self.arch.pieces(self.tokenizer, head, state_ids, options), self.layout)
|
|
57
|
+
return encoded
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _percentile(values: list[int], q: float) -> int:
|
|
61
|
+
if not values:
|
|
62
|
+
return 1
|
|
63
|
+
ordered = sorted(values)
|
|
64
|
+
index = min(len(ordered) - 1, max(0, math.ceil(q / 100 * len(ordered)) - 1))
|
|
65
|
+
return max(1, ordered[index])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def measure_limits(encoder: Encoder, states: Sequence[str], questions: Sequence[Question],
|
|
69
|
+
max_state_tokens, max_option_tokens) -> tuple[int, int]:
|
|
70
|
+
"""Resolve "auto" token limits from the training data: the 95th percentile of token lengths,
|
|
71
|
+
capped so a sequence still fits the model."""
|
|
72
|
+
if max_state_tokens == "auto":
|
|
73
|
+
lengths = [len(encoder.tokens(s)) for s in dict.fromkeys(states)]
|
|
74
|
+
cap = max(1, encoder.max_len - _RESERVED_TOKENS)
|
|
75
|
+
max_state_tokens = min(_percentile(lengths, AUTO_PERCENTILE), cap)
|
|
76
|
+
if max_option_tokens == "auto":
|
|
77
|
+
texts = {t for q in dict.fromkeys(questions) for t in encoder.prompt.options(q)}
|
|
78
|
+
lengths = [len(encoder.tokens(t)) for t in texts]
|
|
79
|
+
max_option_tokens = min(_percentile(lengths, AUTO_PERCENTILE), _MAX_AUTO_OPTION_TOKENS)
|
|
80
|
+
return int(max_state_tokens), int(max_option_tokens)
|