@tuned-tensor/cli 0.20.0 → 0.22.0
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.
- package/README.md +123 -7
- package/dist/{chunk-4Q54NHKR.js → chunk-4SUXBHIY.js} +1715 -392
- package/dist/chunk-4SUXBHIY.js.map +1 -0
- package/dist/index.js +1910 -356
- package/dist/index.js.map +1 -1
- package/dist/local-runtime.js +147 -49
- package/dist/local-runtime.js.map +1 -1
- package/docs/local-runtime/architecture.md +44 -1
- package/docs/local-runtime/aws-gpu.md +2 -0
- package/docs/local-runtime/decision.md +142 -0
- package/docs/spec-architecture.html +2 -2
- package/docs/spec-workflow.md +18 -3
- package/examples/single-spec/decision/tunedtensor.json +630 -0
- package/package.json +5 -2
- package/training/adapter/src/evaluate.py +14 -7
- package/training/adapter/src/hub_download.py +384 -0
- package/training/adapter/src/model_contract.py +38 -5
- package/training/adapter/src/prefetch.py +26 -32
- package/training/adapter/src/prefetch_dataset.py +116 -0
- package/training/adapter/src/serve.py +42 -12
- package/training/adapter/src/train.py +8 -0
- package/training/decision/pyproject.toml +25 -0
- package/training/decision/src/common.py +127 -0
- package/training/decision/src/evaluate.py +70 -0
- package/training/decision/src/train.py +181 -0
- package/training/decision/uv.lock +1175 -0
- package/dist/chunk-4Q54NHKR.js.map +0 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Prefetch the dataset files a behavior spec trains on from the Hugging Face Hub.
|
|
2
|
+
|
|
3
|
+
Mirrors prefetch.py for models: the files land in the shared Hugging Face cache
|
|
4
|
+
(``<HF_HOME>/hub/datasets--org--name``), the revision is pinned to an immutable
|
|
5
|
+
commit, and every requested file is checked against its content-addressed blob.
|
|
6
|
+
Only the split files named in the spec are downloaded, never the whole repo.
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from hub_download import configure_download_environment, download_snapshot, verify_blob_digest
|
|
15
|
+
from prefetch import configure_hugging_face_cache, write_json
|
|
16
|
+
|
|
17
|
+
HUGGING_FACE_REPO_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}")
|
|
18
|
+
COMMIT_SHA = re.compile(r"[0-9a-fA-F]{40}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_repo_path(value: Any) -> str:
|
|
22
|
+
if not isinstance(value, str) or not value or value.startswith(("/", "\\")):
|
|
23
|
+
raise ValueError(f"Dataset file must be a repository-relative path: {value!r}")
|
|
24
|
+
parts = re.split(r"[/\\]", value)
|
|
25
|
+
if any(part in ("", ".", "..") for part in parts) or re.match(r"^[A-Za-z]:", value):
|
|
26
|
+
raise ValueError(f"Dataset file must be a repository-relative path: {value!r}")
|
|
27
|
+
return "/".join(parts)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def verify_dataset_files(snapshot: Path, files: dict[str, str]) -> dict[str, dict[str, Any]]:
|
|
31
|
+
"""Check each split file is a complete cache entry inside the dataset repo.
|
|
32
|
+
|
|
33
|
+
Normal caches link snapshot files to content-addressed blobs, which are
|
|
34
|
+
checked against their digest. Caches without symlink support (Windows
|
|
35
|
+
without Developer Mode) hold plain copies; those are contained and hashed.
|
|
36
|
+
"""
|
|
37
|
+
repository = snapshot.parent.parent.resolve()
|
|
38
|
+
verified: dict[str, dict[str, Any]] = {}
|
|
39
|
+
for split, relative in files.items():
|
|
40
|
+
path = snapshot / relative
|
|
41
|
+
if not path.is_file():
|
|
42
|
+
raise ValueError(f"Dataset file {relative} ({split}) is missing from snapshot {snapshot}")
|
|
43
|
+
target = path.resolve()
|
|
44
|
+
if repository not in target.parents:
|
|
45
|
+
raise ValueError(f"Dataset file {relative} escapes the Hugging Face dataset cache: {target}")
|
|
46
|
+
size = target.stat().st_size
|
|
47
|
+
if size == 0:
|
|
48
|
+
raise ValueError(f"Dataset file {relative} ({split}) is empty")
|
|
49
|
+
_, sha256 = verify_blob_digest(path)
|
|
50
|
+
verified[split] = {
|
|
51
|
+
"path": relative,
|
|
52
|
+
"local_path": str(path),
|
|
53
|
+
"size_bytes": size,
|
|
54
|
+
"sha256": sha256,
|
|
55
|
+
}
|
|
56
|
+
return verified
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> None:
|
|
60
|
+
parser = argparse.ArgumentParser(description="Prefetch Hugging Face dataset files for a TT behavior spec.")
|
|
61
|
+
parser.add_argument("--input", required=True)
|
|
62
|
+
parser.add_argument("--output", required=True)
|
|
63
|
+
args = parser.parse_args()
|
|
64
|
+
|
|
65
|
+
payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
|
|
66
|
+
repo = str(payload["repo"])
|
|
67
|
+
if not HUGGING_FACE_REPO_ID.fullmatch(repo):
|
|
68
|
+
raise ValueError(f"Dataset repo must be a Hugging Face id like org/name: {repo!r}")
|
|
69
|
+
revision = payload.get("revision")
|
|
70
|
+
if revision is not None and not COMMIT_SHA.fullmatch(str(revision)):
|
|
71
|
+
raise ValueError("Dataset revision must be a 40-character Hugging Face commit SHA")
|
|
72
|
+
raw_files = payload.get("files")
|
|
73
|
+
if not isinstance(raw_files, dict) or not raw_files:
|
|
74
|
+
raise ValueError("Dataset prefetch requires at least one split file")
|
|
75
|
+
files = {str(split): validate_repo_path(path) for split, path in raw_files.items()}
|
|
76
|
+
|
|
77
|
+
configure_hugging_face_cache(payload.get("model_cache"))
|
|
78
|
+
configure_download_environment()
|
|
79
|
+
from huggingface_hub import constants
|
|
80
|
+
|
|
81
|
+
local_only = bool(payload.get("local_files_only", False))
|
|
82
|
+
action = "Verifying cached" if local_only else "Prefetching"
|
|
83
|
+
print(f"{action} dataset {repo} in {constants.HF_HUB_CACHE}...", flush=True)
|
|
84
|
+
snapshot_path, commit = download_snapshot(
|
|
85
|
+
repo_id=repo,
|
|
86
|
+
repo_type="dataset",
|
|
87
|
+
revision=revision,
|
|
88
|
+
allow_patterns=sorted(set(files.values())),
|
|
89
|
+
local_files_only=local_only,
|
|
90
|
+
label="dataset_prefetch",
|
|
91
|
+
)
|
|
92
|
+
snapshot = Path(snapshot_path)
|
|
93
|
+
if not COMMIT_SHA.fullmatch(snapshot.name):
|
|
94
|
+
raise ValueError("Hugging Face dataset snapshot did not resolve to a 40-character immutable commit SHA")
|
|
95
|
+
if revision and snapshot.name.lower() != str(revision).lower():
|
|
96
|
+
raise ValueError(f"Hugging Face returned dataset revision {snapshot.name}, not requested revision {revision}")
|
|
97
|
+
print(f"Verifying dataset files under {snapshot}...", flush=True)
|
|
98
|
+
verified = verify_dataset_files(snapshot, files)
|
|
99
|
+
|
|
100
|
+
write_json(args.output, {
|
|
101
|
+
"ok": True,
|
|
102
|
+
"repo": repo,
|
|
103
|
+
"repo_type": "dataset",
|
|
104
|
+
"requested_revision": revision,
|
|
105
|
+
"snapshot_revision": snapshot.name.lower(),
|
|
106
|
+
"snapshot_path": str(snapshot),
|
|
107
|
+
"hf_home": str(constants.HF_HOME),
|
|
108
|
+
"hub_cache": str(constants.HF_HUB_CACHE),
|
|
109
|
+
"files": verified,
|
|
110
|
+
"size_bytes": sum(item["size_bytes"] for item in verified.values()),
|
|
111
|
+
"commit": commit,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
main()
|
|
@@ -11,7 +11,11 @@ from pathlib import Path
|
|
|
11
11
|
from tempfile import TemporaryDirectory
|
|
12
12
|
|
|
13
13
|
from evaluate import configure_hugging_face_cache, resolve_adapter_path
|
|
14
|
-
from model_contract import
|
|
14
|
+
from model_contract import (
|
|
15
|
+
assert_certified_base_model_revision,
|
|
16
|
+
assert_certified_model_config,
|
|
17
|
+
is_certified_base_model,
|
|
18
|
+
)
|
|
15
19
|
|
|
16
20
|
class BearerAuthMiddleware:
|
|
17
21
|
"""Keep TT's auth boundary on ALL routes, including upstream admin APIs.
|
|
@@ -38,6 +42,27 @@ class BearerAuthMiddleware:
|
|
|
38
42
|
await self.app(scope, receive, send)
|
|
39
43
|
|
|
40
44
|
|
|
45
|
+
def native_chat_template(model_source: str) -> str:
|
|
46
|
+
source = Path(model_source)
|
|
47
|
+
template_file = source / "chat_template.jinja"
|
|
48
|
+
template = (template_file.read_text(encoding="utf-8") if template_file.is_file()
|
|
49
|
+
else json.loads((source / "tokenizer_config.json").read_text())["chat_template"])
|
|
50
|
+
if not isinstance(template, str):
|
|
51
|
+
raise ValueError("Serving requires an unambiguous string chat template.")
|
|
52
|
+
return template
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def pin_native_content_format(args: list[str], model_source: str) -> None:
|
|
56
|
+
if "--chat-template" not in args:
|
|
57
|
+
return
|
|
58
|
+
# The wrapper handles system content parts, which confuses upstream's AST
|
|
59
|
+
# detection for string-only native templates. Detect the ORIGINAL template
|
|
60
|
+
# using the detector from our pinned vLLM runtime, then pin that format.
|
|
61
|
+
from vllm.renderers.hf import _detect_content_format
|
|
62
|
+
content_format = _detect_content_format(native_chat_template(model_source), default="string")
|
|
63
|
+
args += ["--chat-template-content-format", content_format]
|
|
64
|
+
|
|
65
|
+
|
|
41
66
|
def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path) -> list[str]:
|
|
42
67
|
args = [
|
|
43
68
|
"serve", model_source,
|
|
@@ -58,6 +83,8 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
|
|
|
58
83
|
]
|
|
59
84
|
if os.environ.get("TT_API_KEY"):
|
|
60
85
|
args += ["--middleware", "serve.BearerAuthMiddleware"]
|
|
86
|
+
if not is_certified_base_model(os.environ.get("TT_BASE_MODEL", "")):
|
|
87
|
+
args += ["--load-format", "safetensors"]
|
|
61
88
|
base = os.environ["TT_BASE_MODEL"]
|
|
62
89
|
# Upstream model-specific parsers, not a TT parser or model-size heuristic.
|
|
63
90
|
parser = {
|
|
@@ -81,14 +108,9 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
|
|
|
81
108
|
"path": adapter_path, "base_model_name": base_name})]
|
|
82
109
|
prompt = os.environ.get("TT_SYSTEM_PROMPT", "").strip()
|
|
83
110
|
if prompt:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
template = template_file.read_text(encoding="utf-8")
|
|
88
|
-
else:
|
|
89
|
-
template = json.loads((source / "tokenizer_config.json").read_text())["chat_template"]
|
|
90
|
-
if not isinstance(template, str):
|
|
91
|
-
raise ValueError("Serving requires an unambiguous string chat template.")
|
|
111
|
+
template = native_chat_template(model_source)
|
|
112
|
+
from vllm.renderers.hf import _detect_content_format
|
|
113
|
+
content_format = _detect_content_format(template, default="string")
|
|
92
114
|
# Literal data, never prompt text interpolated as Jinja source. Preserve
|
|
93
115
|
# tool calls/results verbatim while merging leading system context.
|
|
94
116
|
prefix = "{%- set tt = namespace(system=" + json.dumps(prompt) + ", history=[]) -%}"
|
|
@@ -101,7 +123,9 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
|
|
|
101
123
|
prefix += "{%- set tt.system = tt.system + '\\n\\n' + part.text -%}"
|
|
102
124
|
prefix += "{%- endif -%}{%- endfor -%}{%- endif -%}"
|
|
103
125
|
prefix += "{%- else -%}{%- set tt.history = tt.history + [message] -%}{%- endif -%}{%- endfor -%}"
|
|
104
|
-
|
|
126
|
+
# Match the ORIGINAL native template's expected content representation.
|
|
127
|
+
system_content = "[{'type': 'text', 'text': tt.system}]" if content_format == "openai" else "tt.system"
|
|
128
|
+
prefix += "{%- set messages = [{'role': 'system', 'content': " + system_content + "}] + tt.history -%}"
|
|
105
129
|
path = temp_dir / "chat-template.jinja"
|
|
106
130
|
path.write_text(prefix + template, encoding="utf-8")
|
|
107
131
|
args += ["--chat-template", str(path)]
|
|
@@ -111,9 +135,14 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
|
|
|
111
135
|
def prepare_model_source() -> str:
|
|
112
136
|
base = os.environ["TT_BASE_MODEL"]
|
|
113
137
|
revision = os.environ.get("TT_BASE_MODEL_REVISION")
|
|
114
|
-
assert_certified_base_model_revision(base, revision, "Serving base model revision")
|
|
115
|
-
configure_hugging_face_cache(os.environ.get("HF_HOME"))
|
|
116
138
|
source = os.environ.get("TT_MODEL_SOURCE", base)
|
|
139
|
+
assert_certified_base_model_revision(
|
|
140
|
+
base,
|
|
141
|
+
revision,
|
|
142
|
+
"Serving base model revision",
|
|
143
|
+
local_source=source != base and Path(source).is_dir(),
|
|
144
|
+
)
|
|
145
|
+
configure_hugging_face_cache(os.environ.get("HF_HOME"))
|
|
117
146
|
if not Path(source).is_dir():
|
|
118
147
|
if source != base:
|
|
119
148
|
raise ValueError("Explicit serving snapshot is not an existing directory.")
|
|
@@ -143,6 +172,7 @@ def run_server() -> None:
|
|
|
143
172
|
model_source = prepare_model_source()
|
|
144
173
|
adapter = resolve_adapter_path(os.environ.get("TT_MODEL_ARTIFACT"), Path(temp))
|
|
145
174
|
args = build_vllm_args(model_source, adapter, Path(temp))
|
|
175
|
+
pin_native_content_format(args, model_source)
|
|
146
176
|
from vllm.entrypoints.cli.main import main
|
|
147
177
|
previous = sys.argv
|
|
148
178
|
try:
|
|
@@ -27,6 +27,7 @@ from model_contract import (
|
|
|
27
27
|
assert_certified_model_config,
|
|
28
28
|
chat_template_kwargs,
|
|
29
29
|
parse_lora_target_modules,
|
|
30
|
+
uncertified_load_kwargs,
|
|
30
31
|
)
|
|
31
32
|
from sft_data import IGNORE_INDEX, build_assistant_only_example
|
|
32
33
|
|
|
@@ -82,6 +83,7 @@ def assert_certified_request() -> None:
|
|
|
82
83
|
str(requested_model),
|
|
83
84
|
hp("base_model_revision"),
|
|
84
85
|
"Training base model revision",
|
|
86
|
+
local_source=Path(resolve_model_source()).is_dir(),
|
|
85
87
|
)
|
|
86
88
|
loader = hp("model_loader", "causal_lm")
|
|
87
89
|
if loader not in ("causal_lm", "image_text_to_text"):
|
|
@@ -199,6 +201,11 @@ def create_model_and_tokenizer(model_source: str) -> tuple[Any, Any, torch.dtype
|
|
|
199
201
|
raise ValueError(f"{hp('base_model', CERTIFIED_BASE_MODEL)} tokenizer has no EOS token")
|
|
200
202
|
if tokenizer.pad_token_id is None:
|
|
201
203
|
tokenizer.pad_token = tokenizer.eos_token
|
|
204
|
+
if not tokenizer.chat_template:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"{hp('base_model', CERTIFIED_BASE_MODEL)} has no chat template; "
|
|
207
|
+
"choose an instruct or chat variant of the model"
|
|
208
|
+
)
|
|
202
209
|
|
|
203
210
|
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
204
211
|
# Multimodal checkpoints whose text tower is not registered in the
|
|
@@ -212,6 +219,7 @@ def create_model_and_tokenizer(model_source: str) -> tuple[Any, Any, torch.dtype
|
|
|
212
219
|
model = model_class.from_pretrained(
|
|
213
220
|
model_source,
|
|
214
221
|
**source_kwargs,
|
|
222
|
+
**uncertified_load_kwargs(str(hp("base_model", CERTIFIED_BASE_MODEL))),
|
|
215
223
|
dtype=dtype,
|
|
216
224
|
device_map={"": torch.cuda.current_device()},
|
|
217
225
|
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tuned-tensor-decision"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"huggingface-hub>=0.36.0",
|
|
7
|
+
"laya==0.3.22",
|
|
8
|
+
"numpy>=2.0.0",
|
|
9
|
+
"safetensors>=0.4.5",
|
|
10
|
+
"torch>=2.11.0",
|
|
11
|
+
"transformers>=4.48.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.uv.sources]
|
|
15
|
+
torch = [
|
|
16
|
+
{ index = "pytorch-cu130", marker = "sys_platform == 'linux'" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[[tool.uv.index]]
|
|
20
|
+
name = "pytorch-cu130"
|
|
21
|
+
url = "https://download.pytorch.org/whl/cu130"
|
|
22
|
+
explicit = true
|
|
23
|
+
|
|
24
|
+
[tool.uv]
|
|
25
|
+
package = false
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Shared helpers for the typed decision-model engine (Laya-format checkpoints).
|
|
2
|
+
|
|
3
|
+
Node owns the behavior spec, splits, scoring and reports. This package only
|
|
4
|
+
loads a decision checkpoint, predicts labels for opaque IDs, and fine-tunes the
|
|
5
|
+
checkpoint's encoder and decision head on labelled rows.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import torch
|
|
17
|
+
|
|
18
|
+
CHECKPOINT_FILES = ("rl_agent_config.json", "model.safetensors", "tokenizer/*", "encoder/*")
|
|
19
|
+
QUESTION_ID = "decision"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config(argv: list[str] | None = None) -> dict[str, Any]:
|
|
23
|
+
parser = argparse.ArgumentParser()
|
|
24
|
+
parser.add_argument("--config", required=True)
|
|
25
|
+
args = parser.parse_args(argv)
|
|
26
|
+
return json.loads(Path(args.config).read_text(encoding="utf-8"))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def ensure_private_directory(path: str | Path) -> Path:
|
|
30
|
+
destination = Path(path)
|
|
31
|
+
destination.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
32
|
+
destination.chmod(0o700)
|
|
33
|
+
return destination
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_json(path: str | Path, value: Any) -> None:
|
|
37
|
+
write_text(path, json.dumps(value, indent=2) + "\n")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def write_jsonl(path: str | Path, rows: list[Any]) -> None:
|
|
41
|
+
write_text(path, "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_text(path: str | Path, content: str) -> None:
|
|
45
|
+
destination = Path(path)
|
|
46
|
+
ensure_private_directory(destination.parent)
|
|
47
|
+
descriptor, temporary_name = tempfile.mkstemp(dir=destination.parent, prefix=f".{destination.name}.", suffix=".tmp")
|
|
48
|
+
temporary = Path(temporary_name)
|
|
49
|
+
try:
|
|
50
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
51
|
+
os.fchmod(output.fileno(), 0o600)
|
|
52
|
+
output.write(content)
|
|
53
|
+
output.flush()
|
|
54
|
+
os.fsync(output.fileno())
|
|
55
|
+
os.replace(temporary, destination)
|
|
56
|
+
finally:
|
|
57
|
+
temporary.unlink(missing_ok=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_jsonl(path: str | Path) -> list[dict[str, Any]]:
|
|
61
|
+
rows = []
|
|
62
|
+
for number, line in enumerate(Path(path).read_text(encoding="utf-8").splitlines(), start=1):
|
|
63
|
+
if not line.strip():
|
|
64
|
+
continue
|
|
65
|
+
row = json.loads(line)
|
|
66
|
+
if not isinstance(row, dict):
|
|
67
|
+
raise ValueError(f"{path}:{number} must be a JSON object")
|
|
68
|
+
rows.append(row)
|
|
69
|
+
return rows
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def pick_device(requested: str | None) -> torch.device:
|
|
73
|
+
if requested and requested != "auto":
|
|
74
|
+
device = torch.device(requested)
|
|
75
|
+
if device.type == "cuda" and not torch.cuda.is_available():
|
|
76
|
+
raise RuntimeError("Decision device cuda was requested, but PyTorch cannot see a CUDA GPU.")
|
|
77
|
+
if device.type == "mps" and not torch.backends.mps.is_available():
|
|
78
|
+
raise RuntimeError("Decision device mps was requested, but PyTorch cannot use Apple MPS.")
|
|
79
|
+
return device
|
|
80
|
+
if torch.cuda.is_available():
|
|
81
|
+
return torch.device("cuda")
|
|
82
|
+
if torch.backends.mps.is_available():
|
|
83
|
+
return torch.device("mps")
|
|
84
|
+
return torch.device("cpu")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def option_labels(question: dict[str, Any]) -> list[str]:
|
|
88
|
+
"""Answer labels in the model's option order; these are the strings Node scores."""
|
|
89
|
+
kind = question["type"]
|
|
90
|
+
criteria = question.get("criteria")
|
|
91
|
+
if kind == "choice":
|
|
92
|
+
return [str(label) for label in (criteria.keys() if isinstance(criteria, dict) else criteria)]
|
|
93
|
+
if kind == "noul":
|
|
94
|
+
return ["false", "true"]
|
|
95
|
+
if kind == "score":
|
|
96
|
+
return [str(index) for index in range(len(criteria))]
|
|
97
|
+
raise ValueError(f"Unsupported decision type: {kind!r}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def label_index(question: dict[str, Any], output: str) -> int:
|
|
101
|
+
labels = option_labels(question)
|
|
102
|
+
normalized = output.strip().lower()
|
|
103
|
+
for index, label in enumerate(labels):
|
|
104
|
+
if label.strip().lower() == normalized:
|
|
105
|
+
return index
|
|
106
|
+
raise ValueError(f"Expected output {output!r} is not one of the decision labels {labels}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_checkpoint_dir(model: str, revision: str | None) -> Path:
|
|
110
|
+
"""Return a local checkpoint directory, downloading only the files a load needs."""
|
|
111
|
+
local = Path(model)
|
|
112
|
+
if local.is_dir():
|
|
113
|
+
return local
|
|
114
|
+
if local.is_absolute() or model.startswith(("./", "../")):
|
|
115
|
+
raise FileNotFoundError(f"Decision checkpoint directory not found: {model}")
|
|
116
|
+
from huggingface_hub import snapshot_download
|
|
117
|
+
|
|
118
|
+
return Path(snapshot_download(model, revision=revision, allow_patterns=list(CHECKPOINT_FILES)))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def load_agent(model: str, revision: str | None, device: torch.device):
|
|
122
|
+
from laya import Agent
|
|
123
|
+
|
|
124
|
+
checkpoint = resolve_checkpoint_dir(model, revision)
|
|
125
|
+
# Load the resolved directory so Hub and local checkpoints take the same path.
|
|
126
|
+
agent = Agent(str(checkpoint), device=str(device))
|
|
127
|
+
return agent, checkpoint
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Predict one typed decision per input row.
|
|
2
|
+
|
|
3
|
+
The config names only opaque row IDs and inputs. Reference labels stay in Node,
|
|
4
|
+
which joins these predictions to trusted expected outputs before scoring.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from common import QUESTION_ID, load_agent, load_config, option_labels, pick_device, read_jsonl, write_json, write_jsonl
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def answer_distribution(question: dict[str, Any], answer: dict[str, Any]) -> dict[str, float]:
|
|
17
|
+
labels = option_labels(question)
|
|
18
|
+
if question["type"] == "noul":
|
|
19
|
+
p_true = float(answer["noul"])
|
|
20
|
+
return {"false": 1.0 - p_true, "true": p_true}
|
|
21
|
+
# Laya keys choice probabilities by label and score probabilities by level index.
|
|
22
|
+
return {label: float(answer["probabilities"][label]) for label in labels}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def predict_rows(agent, question: dict[str, Any], rows: list[dict[str, Any]], batch_size: int) -> list[dict[str, Any]]:
|
|
26
|
+
predictions: list[dict[str, Any]] = []
|
|
27
|
+
for start in range(0, len(rows), batch_size):
|
|
28
|
+
batch = rows[start:start + batch_size]
|
|
29
|
+
began = time.perf_counter()
|
|
30
|
+
results = agent.predict_batch([row["input"] for row in batch], {QUESTION_ID: question}, batch_size=batch_size)
|
|
31
|
+
latency_ms = int(round((time.perf_counter() - began) * 1000 / max(1, len(batch))))
|
|
32
|
+
for row, result in zip(batch, results):
|
|
33
|
+
distribution = answer_distribution(question, result["answers"][QUESTION_ID])
|
|
34
|
+
labels = list(distribution)
|
|
35
|
+
values = np.array([distribution[label] for label in labels])
|
|
36
|
+
predictions.append({
|
|
37
|
+
"id": row["id"],
|
|
38
|
+
"prediction": labels[int(values.argmax())],
|
|
39
|
+
"probabilities": {label: round(float(value), 6) for label, value in distribution.items()},
|
|
40
|
+
"confidence": round(float(values.max()), 6),
|
|
41
|
+
"latency_ms": latency_ms,
|
|
42
|
+
})
|
|
43
|
+
return predictions
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
config = load_config()
|
|
48
|
+
device = pick_device(config.get("device"))
|
|
49
|
+
rows = read_jsonl(config["inputs_path"])
|
|
50
|
+
for row in rows:
|
|
51
|
+
if set(row) != {"id", "input"}:
|
|
52
|
+
raise ValueError("Decision evaluation rows must contain only id and input.")
|
|
53
|
+
began = time.perf_counter()
|
|
54
|
+
agent, checkpoint = load_agent(config["model"], config.get("revision"), device)
|
|
55
|
+
load_seconds = time.perf_counter() - began
|
|
56
|
+
predictions = predict_rows(agent, config["question"], rows, int(config.get("batch_size", 16)))
|
|
57
|
+
write_jsonl(config["output_path"], predictions)
|
|
58
|
+
write_json(config["metrics_path"], {
|
|
59
|
+
"ok": True,
|
|
60
|
+
"model": config["model"],
|
|
61
|
+
"revision": config.get("revision"),
|
|
62
|
+
"checkpoint_dir": str(checkpoint),
|
|
63
|
+
"device": str(agent.device),
|
|
64
|
+
"rows": len(predictions),
|
|
65
|
+
"load_seconds": round(load_seconds, 3),
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Fine-tune a Laya-format decision checkpoint on labelled rows for one typed question.
|
|
2
|
+
|
|
3
|
+
The encoder and decision head are trained with cross-entropy over the option
|
|
4
|
+
markers, a strictly proper scoring rule, so the tuned probabilities remain a
|
|
5
|
+
calibration target rather than only an argmax. Options are shuffled per example
|
|
6
|
+
so the head learns option meaning instead of option position. The output
|
|
7
|
+
directory is a complete checkpoint that `laya.load(<dir>)` accepts.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import random
|
|
13
|
+
import shutil
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import nullcontext
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import torch
|
|
20
|
+
import torch.nn.functional as F
|
|
21
|
+
from laya.common import QTYPES, build_sequence, collate_items
|
|
22
|
+
from safetensors.torch import save_file
|
|
23
|
+
|
|
24
|
+
from common import (
|
|
25
|
+
ensure_private_directory,
|
|
26
|
+
label_index,
|
|
27
|
+
load_agent,
|
|
28
|
+
load_config,
|
|
29
|
+
option_labels,
|
|
30
|
+
pick_device,
|
|
31
|
+
read_jsonl,
|
|
32
|
+
write_json,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def internal_question(question: dict[str, Any]) -> dict[str, Any]:
|
|
37
|
+
"""Laya's internal question form; mirrors `Agent._to_internal` for the fields TT emits."""
|
|
38
|
+
criteria = question.get("criteria")
|
|
39
|
+
if question["type"] == "choice" and isinstance(criteria, list):
|
|
40
|
+
criteria = {label: None for label in criteria}
|
|
41
|
+
return {"t": question["type"], "ins": question["instructions"], "crit": criteria}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def encode_example(tok, cfg: dict[str, Any], question: dict[str, Any], text: str, label: int, order: list[int]) -> dict[str, Any]:
|
|
45
|
+
ids, markers = build_sequence(
|
|
46
|
+
tok,
|
|
47
|
+
text,
|
|
48
|
+
question,
|
|
49
|
+
cfg.get("max_len", 512),
|
|
50
|
+
cfg.get("head_max_len", 192),
|
|
51
|
+
option_order=order,
|
|
52
|
+
)
|
|
53
|
+
if len(markers) != len(order):
|
|
54
|
+
raise ValueError("A decision option marker was truncated; shorten the instructions or option descriptions.")
|
|
55
|
+
# Slot s shows option order[s], so the target is the slot holding the labelled option.
|
|
56
|
+
return {"ids": ids, "markers": markers, "qtype": QTYPES[question["t"]], "label": order.index(label)}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def autocast(device: torch.device):
|
|
60
|
+
if device.type == "cuda":
|
|
61
|
+
dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
|
|
62
|
+
return torch.autocast("cuda", dtype=dtype)
|
|
63
|
+
return nullcontext()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_checkpoint(model, source: Path, output: Path, cfg: dict[str, Any], provenance: dict[str, Any]) -> None:
|
|
67
|
+
ensure_private_directory(output)
|
|
68
|
+
for name in ("tokenizer", "encoder"):
|
|
69
|
+
if (source / name).is_dir():
|
|
70
|
+
shutil.copytree(source / name, output / name, dirs_exist_ok=True)
|
|
71
|
+
state = {key: value.detach().to("cpu").contiguous() for key, value in model.state_dict().items()}
|
|
72
|
+
# The base checkpoint's fitted temperatures describe its logits, not the tuned ones.
|
|
73
|
+
state["temperature"] = torch.ones_like(state["temperature"])
|
|
74
|
+
save_file(state, str(output / "model.safetensors"))
|
|
75
|
+
tuned = {key: value for key, value in cfg.items() if key != "temperature_by_options"}
|
|
76
|
+
tuned["temperature"] = [1.0, 1.0, 1.0]
|
|
77
|
+
tuned["tuned_tensor"] = provenance
|
|
78
|
+
(output / "rl_agent_config.json").write_text(json.dumps(tuned, indent=2) + "\n", encoding="utf-8")
|
|
79
|
+
# Copied tokenizer/encoder files keep the cache's modes; keep the whole checkpoint owner-only.
|
|
80
|
+
for path in output.rglob("*"):
|
|
81
|
+
path.chmod(0o700 if path.is_dir() else 0o600)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> None:
|
|
85
|
+
config = load_config()
|
|
86
|
+
seed = int(config.get("seed", 0))
|
|
87
|
+
random.seed(seed)
|
|
88
|
+
torch.manual_seed(seed)
|
|
89
|
+
device = pick_device(config.get("device"))
|
|
90
|
+
question = config["question"]
|
|
91
|
+
labels = option_labels(question)
|
|
92
|
+
rows = read_jsonl(config["train_path"])
|
|
93
|
+
if not rows:
|
|
94
|
+
raise ValueError("Decision training needs at least one labelled row.")
|
|
95
|
+
targets = [label_index(question, str(row["output"])) for row in rows]
|
|
96
|
+
|
|
97
|
+
agent, checkpoint = load_agent(config["model"], config.get("revision"), device)
|
|
98
|
+
model = agent.model
|
|
99
|
+
if config.get("freeze_encoder", False):
|
|
100
|
+
for parameter in model.encoder.parameters():
|
|
101
|
+
parameter.requires_grad_(False)
|
|
102
|
+
trainable = [parameter for parameter in model.parameters() if parameter.requires_grad]
|
|
103
|
+
optimizer = torch.optim.AdamW(trainable, lr=float(config.get("learning_rate", 2e-5)), weight_decay=0.01)
|
|
104
|
+
epochs = int(config.get("epochs", 3))
|
|
105
|
+
batch_size = int(config.get("batch_size", 8))
|
|
106
|
+
shuffle_options = bool(config.get("shuffle_options", True))
|
|
107
|
+
steps_per_epoch = (len(rows) + batch_size - 1) // batch_size
|
|
108
|
+
total_steps = epochs * steps_per_epoch
|
|
109
|
+
warmup = max(1, total_steps // 10)
|
|
110
|
+
scheduler = torch.optim.lr_scheduler.LambdaLR(
|
|
111
|
+
optimizer,
|
|
112
|
+
lambda step: min(1.0, (step + 1) / warmup) * max(0.0, (total_steps - step) / max(1, total_steps - warmup + 1)),
|
|
113
|
+
)
|
|
114
|
+
q = internal_question(question)
|
|
115
|
+
pad_id = agent.tok.pad_token_id if agent.tok.pad_token_id is not None else 0
|
|
116
|
+
|
|
117
|
+
model.train()
|
|
118
|
+
history: list[dict[str, Any]] = []
|
|
119
|
+
began = time.perf_counter()
|
|
120
|
+
step = 0
|
|
121
|
+
for epoch in range(epochs):
|
|
122
|
+
order_of_rows = list(range(len(rows)))
|
|
123
|
+
random.shuffle(order_of_rows)
|
|
124
|
+
epoch_loss = 0.0
|
|
125
|
+
for start in range(0, len(rows), batch_size):
|
|
126
|
+
items = []
|
|
127
|
+
for index in order_of_rows[start:start + batch_size]:
|
|
128
|
+
order = list(range(len(labels)))
|
|
129
|
+
if shuffle_options:
|
|
130
|
+
random.shuffle(order)
|
|
131
|
+
items.append([encode_example(agent.tok, agent.cfg, q, str(rows[index]["input"]), targets[index], order)])
|
|
132
|
+
batch = collate_items(items, pad_id)
|
|
133
|
+
with autocast(device):
|
|
134
|
+
logits, _ = model(
|
|
135
|
+
batch["input_ids"].to(device),
|
|
136
|
+
batch["attention_mask"].to(device),
|
|
137
|
+
batch["marker_pos"].to(device),
|
|
138
|
+
batch["marker_mask"].to(device),
|
|
139
|
+
batch["qtype"].to(device),
|
|
140
|
+
)
|
|
141
|
+
loss = F.cross_entropy(logits.float(), batch["label"].to(device))
|
|
142
|
+
optimizer.zero_grad(set_to_none=True)
|
|
143
|
+
loss.backward()
|
|
144
|
+
torch.nn.utils.clip_grad_norm_(trainable, 1.0)
|
|
145
|
+
optimizer.step()
|
|
146
|
+
scheduler.step()
|
|
147
|
+
step += 1
|
|
148
|
+
epoch_loss += float(loss.item()) * len(items)
|
|
149
|
+
print(json.dumps({"event": "step", "epoch": epoch + 1, "step": step, "total_steps": total_steps, "loss": round(float(loss.item()), 6)}), flush=True)
|
|
150
|
+
history.append({"epoch": epoch + 1, "loss": round(epoch_loss / len(rows), 6)})
|
|
151
|
+
|
|
152
|
+
model.eval()
|
|
153
|
+
seconds = time.perf_counter() - began
|
|
154
|
+
provenance = {
|
|
155
|
+
"base_model": config["model"],
|
|
156
|
+
"base_revision": config.get("revision"),
|
|
157
|
+
"question_type": question["type"],
|
|
158
|
+
"labels": labels,
|
|
159
|
+
"train_rows": len(rows),
|
|
160
|
+
"epochs": epochs,
|
|
161
|
+
"learning_rate": float(config.get("learning_rate", 2e-5)),
|
|
162
|
+
"freeze_encoder": bool(config.get("freeze_encoder", False)),
|
|
163
|
+
"seed": seed,
|
|
164
|
+
}
|
|
165
|
+
output = Path(config["output_dir"])
|
|
166
|
+
save_checkpoint(model, checkpoint, output, agent.cfg, provenance)
|
|
167
|
+
write_json(config["metrics_path"], {
|
|
168
|
+
"ok": True,
|
|
169
|
+
"device": str(device),
|
|
170
|
+
"train_rows": len(rows),
|
|
171
|
+
"epochs": epochs,
|
|
172
|
+
"steps": step,
|
|
173
|
+
"train_seconds": round(seconds, 3),
|
|
174
|
+
"loss_history": history,
|
|
175
|
+
"final_loss": history[-1]["loss"],
|
|
176
|
+
"trainable_parameters": sum(parameter.numel() for parameter in trainable),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
main()
|