@tuned-tensor/cli 0.20.0 → 0.21.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 +40 -7
- package/dist/{chunk-4Q54NHKR.js → chunk-PVLX7LJI.js} +381 -148
- package/dist/chunk-PVLX7LJI.js.map +1 -0
- package/dist/index.js +1679 -325
- package/dist/index.js.map +1 -1
- package/dist/local-runtime.js +41 -6
- package/dist/local-runtime.js.map +1 -1
- package/docs/local-runtime/architecture.md +6 -1
- package/docs/local-runtime/decision.md +142 -0
- package/docs/spec-architecture.html +2 -2
- package/docs/spec-workflow.md +11 -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/model_contract.py +38 -5
- package/training/adapter/src/prefetch.py +6 -1
- package/training/adapter/src/serve.py +37 -11
- 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,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()
|