@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.
@@ -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()