@tuned-tensor/local 0.2.9 → 0.4.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +110 -201
  3. package/dist/active-model.d.ts +16 -0
  4. package/dist/active-model.js +88 -0
  5. package/dist/active-model.js.map +1 -0
  6. package/dist/artifacts.d.ts +5 -4
  7. package/dist/artifacts.js +57 -33
  8. package/dist/artifacts.js.map +1 -1
  9. package/dist/compare.d.ts +1 -8
  10. package/dist/compare.js +1 -23
  11. package/dist/compare.js.map +1 -1
  12. package/dist/contracts.d.ts +234 -365
  13. package/dist/contracts.js +95 -224
  14. package/dist/contracts.js.map +1 -1
  15. package/dist/dataset.d.ts +11 -4
  16. package/dist/dataset.js +67 -125
  17. package/dist/dataset.js.map +1 -1
  18. package/dist/doctor.d.ts +2 -3
  19. package/dist/doctor.js +23 -161
  20. package/dist/doctor.js.map +1 -1
  21. package/dist/evaluation.d.ts +11 -71
  22. package/dist/evaluation.js +212 -572
  23. package/dist/evaluation.js.map +1 -1
  24. package/dist/general-regression.d.ts +10 -0
  25. package/dist/general-regression.js +16 -0
  26. package/dist/general-regression.js.map +1 -0
  27. package/dist/huggingface-cache.d.ts +8 -7
  28. package/dist/huggingface-cache.js +16 -8
  29. package/dist/huggingface-cache.js.map +1 -1
  30. package/dist/index.d.ts +2 -10
  31. package/dist/index.js +358 -627
  32. package/dist/index.js.map +1 -1
  33. package/dist/local-project.d.ts +1 -11
  34. package/dist/local-project.js +33 -61
  35. package/dist/local-project.js.map +1 -1
  36. package/dist/model-registry.d.ts +3 -16
  37. package/dist/model-registry.js +76 -292
  38. package/dist/model-registry.js.map +1 -1
  39. package/dist/model-server.d.ts +7 -3
  40. package/dist/model-server.js +34 -25
  41. package/dist/model-server.js.map +1 -1
  42. package/dist/orchestrator.d.ts +11 -25
  43. package/dist/orchestrator.js +393 -563
  44. package/dist/orchestrator.js.map +1 -1
  45. package/dist/prefetch.d.ts +1 -5
  46. package/dist/prefetch.js +14 -19
  47. package/dist/prefetch.js.map +1 -1
  48. package/dist/process-runner.d.ts +10 -29
  49. package/dist/process-runner.js +63 -169
  50. package/dist/process-runner.js.map +1 -1
  51. package/dist/process-training.d.ts +0 -1
  52. package/dist/process-training.js +10 -87
  53. package/dist/process-training.js.map +1 -1
  54. package/dist/store.d.ts +1 -3
  55. package/dist/store.js +92 -290
  56. package/dist/store.js.map +1 -1
  57. package/docs/architecture.md +87 -152
  58. package/docs/spark.md +66 -97
  59. package/examples/dry-runner.json +14 -0
  60. package/examples/general-regression.jsonl +4 -0
  61. package/examples/local-runner.json +13 -6
  62. package/examples/smoke-spec.json +41 -0
  63. package/package.json +6 -6
  64. package/training/local-runner/pyproject.toml +0 -5
  65. package/training/local-runner/src/evaluate.py +89 -234
  66. package/training/local-runner/src/model_contract.py +47 -0
  67. package/training/local-runner/src/prefetch.py +18 -4
  68. package/training/local-runner/src/serve.py +60 -117
  69. package/training/local-runner/src/sft_data.py +97 -0
  70. package/training/local-runner/src/train.py +146 -346
  71. package/training/local-runner/uv.lock +0 -1197
  72. package/dist/labeling-sanitize.d.ts +0 -31
  73. package/dist/labeling-sanitize.js +0 -158
  74. package/dist/labeling-sanitize.js.map +0 -1
  75. package/dist/labeling.d.ts +0 -155
  76. package/dist/labeling.js +0 -496
  77. package/dist/labeling.js.map +0 -1
  78. package/dist/openrouter.d.ts +0 -29
  79. package/dist/openrouter.js +0 -66
  80. package/dist/openrouter.js.map +0 -1
  81. package/dist/server.d.ts +0 -9
  82. package/dist/server.js +0 -211
  83. package/dist/server.js.map +0 -1
  84. package/docs/local-workflow-remediation-2026-07-13.md +0 -130
  85. package/docs/local-workflow-ux-review-2026-07-13.md +0 -458
  86. package/examples/dpo-preferences.jsonl +0 -2
  87. package/examples/dpo-run-request.json +0 -34
  88. package/examples/smoke-run-request.json +0 -34
  89. package/training/local-runner/src/train_dpo.py +0 -286
@@ -1,31 +1,24 @@
1
1
  from __future__ import annotations
2
2
 
3
- import base64
4
3
  import json
5
4
  import os
6
5
  import tarfile
7
6
  import time
8
- import urllib.request
9
- from io import BytesIO
10
7
  from pathlib import Path
11
8
  from tempfile import TemporaryDirectory
12
9
  from typing import Any
13
10
 
14
11
  import torch
15
- from datasets import Dataset as HFDataset
16
- from peft import LoraConfig, PeftModel, get_peft_model
17
- from PIL import Image
12
+ from peft import LoraConfig, get_peft_model
13
+ from torch.nn.utils.rnn import pad_sequence
18
14
  from torch.utils.data import Dataset
19
- from transformers import (
20
- AutoModelForCausalLM,
21
- AutoModelForImageTextToText,
22
- AutoProcessor,
23
- AutoTokenizer,
24
- DataCollatorForLanguageModeling,
25
- Trainer,
26
- TrainingArguments,
15
+ from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments
16
+
17
+ from model_contract import (
18
+ CERTIFIED_BASE_MODEL,
19
+ assert_certified_model_config,
27
20
  )
28
- from trl import SFTConfig, SFTTrainer
21
+ from sft_data import IGNORE_INDEX, build_assistant_only_example
29
22
 
30
23
 
31
24
  TRAINING_DIR = Path(os.environ.get("SM_CHANNEL_TRAINING", "/opt/ml/input/data/training"))
@@ -35,15 +28,14 @@ HYPERPARAMETERS_PATH = Path(
35
28
  )
36
29
  MODEL_DIR = Path(os.environ.get("SM_MODEL_DIR", "/opt/ml/model"))
37
30
  OUTPUT_DIR = Path(os.environ.get("SM_OUTPUT_DIR", "/opt/ml/output"))
38
- ROW_SOURCE_DIR_KEY = "_tt_source_dir"
39
- MAX_ARCHIVE_MEMBERS = 20_000
40
- MAX_ARCHIVE_EXPANDED_BYTES = 20 * 1024 * 1024 * 1024
41
31
 
42
32
 
43
33
  def load_hyperparameters() -> dict[str, str]:
44
34
  if not HYPERPARAMETERS_PATH.is_file():
45
35
  return {}
46
- raw = json.loads(HYPERPARAMETERS_PATH.read_text())
36
+ raw = json.loads(HYPERPARAMETERS_PATH.read_text(encoding="utf-8"))
37
+ if not isinstance(raw, dict):
38
+ raise ValueError("Hyperparameters must be a JSON object")
47
39
  return {str(key): str(value) for key, value in raw.items()}
48
40
 
49
41
 
@@ -68,329 +60,174 @@ def hp_float(name: str, default: float) -> float:
68
60
  return float(hp(name, str(default)) or default)
69
61
 
70
62
 
71
- def hp_bool(name: str, default: bool) -> bool:
72
- return (hp(name, str(default)) or "").lower() in {"1", "true", "yes", "y"}
73
-
74
-
75
63
  def model_revision_kwargs(model_source: str) -> dict[str, str]:
76
64
  revision = hp("base_model_revision")
77
65
  return {"revision": revision} if revision and not Path(model_source).exists() else {}
78
66
 
79
67
 
68
+ def assert_certified_request() -> None:
69
+ requested_model = hp("base_model", CERTIFIED_BASE_MODEL)
70
+ if requested_model != CERTIFIED_BASE_MODEL:
71
+ raise ValueError(
72
+ f"The bundled trainer currently certifies only {CERTIFIED_BASE_MODEL}; got {requested_model!r}"
73
+ )
74
+ loader = hp("model_loader", "causal_lm")
75
+ if loader != "causal_lm":
76
+ raise ValueError(f"The bundled trainer is text-only and requires model_loader=causal_lm; got {loader!r}")
77
+ def require_cuda() -> dict[str, Any]:
78
+ if not torch.cuda.is_available():
79
+ raise RuntimeError(
80
+ "TT Local bundled training requires an NVIDIA CUDA GPU. "
81
+ "Run this workflow on DGX Spark and verify it first with tt-local doctor."
82
+ )
83
+ device_index = torch.cuda.current_device()
84
+ properties = torch.cuda.get_device_properties(device_index)
85
+ return {
86
+ "device_index": device_index,
87
+ "device_name": properties.name,
88
+ "compute_capability": f"{properties.major}.{properties.minor}",
89
+ "bf16_supported": torch.cuda.is_bf16_supported(),
90
+ }
91
+
92
+
80
93
  def load_rows() -> list[dict[str, Any]]:
81
94
  rows: list[dict[str, Any]] = []
82
95
  for path in sorted(TRAINING_DIR.rglob("*.jsonl")):
83
- for line_number, line in enumerate(path.read_text().splitlines(), start=1):
96
+ for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
84
97
  if not line.strip():
85
98
  continue
86
- row = json.loads(line)
87
- if "messages" not in row:
88
- raise ValueError(f"{path}:{line_number} missing messages")
89
- row[ROW_SOURCE_DIR_KEY] = str(path.parent)
99
+ try:
100
+ row = json.loads(line)
101
+ except json.JSONDecodeError as exc:
102
+ raise ValueError(f"{path}:{line_number} contains invalid JSON") from exc
103
+ if not isinstance(row, dict) or "messages" not in row:
104
+ raise ValueError(f"{path}:{line_number} must contain a messages array")
90
105
  rows.append(row)
91
106
  if not rows:
92
107
  raise ValueError(f"No chat JSONL rows found under {TRAINING_DIR}")
93
108
  return rows
94
109
 
95
110
 
96
- def safe_extract_archive(path: Path, destination: Path) -> None:
97
- destination.mkdir(parents=True, exist_ok=True)
98
- destination_root = destination.resolve()
99
- with tarfile.open(path, "r:gz") as tar:
100
- members = tar.getmembers()
101
- if len(members) > MAX_ARCHIVE_MEMBERS:
102
- raise ValueError(f"Model archive exceeds {MAX_ARCHIVE_MEMBERS} members")
103
- expanded_bytes = sum(max(0, member.size) for member in members if member.isfile())
104
- if expanded_bytes > MAX_ARCHIVE_EXPANDED_BYTES:
105
- raise ValueError("Model archive exceeds the 20 GiB expanded-size limit")
106
- for member in members:
107
- destination_path = (destination / member.name).resolve()
108
- try:
109
- destination_path.relative_to(destination_root)
110
- except ValueError:
111
- raise ValueError(f"Unsafe archive member: {member.name}")
112
- if member.issym() or member.islnk() or member.isdev():
113
- raise ValueError(f"Unsafe archive member type: {member.name}")
114
- tar.extractall(destination)
115
-
116
-
117
- def resolve_model_source(tmp: Path) -> str:
118
- archive = BASE_MODEL_DIR / "model.tar.gz"
119
- if archive.is_file():
120
- extracted = tmp / "base-model"
121
- safe_extract_archive(archive, extracted)
122
- candidates = [path for path in extracted.iterdir() if path.is_dir()]
123
- return str(candidates[0] if len(candidates) == 1 else extracted)
111
+ def resolve_model_source() -> str:
124
112
  if BASE_MODEL_DIR.is_dir() and any(BASE_MODEL_DIR.iterdir()):
125
113
  return str(BASE_MODEL_DIR)
126
- return hp("base_model") or "Qwen/Qwen3.5-2B"
127
-
128
-
129
- def strip_file_uri(value: str | None) -> str | None:
130
- if not value:
131
- return value
132
- if value.startswith("file://"):
133
- return value[7:]
134
- return value
135
-
136
-
137
- def resolve_adapter_path(value: str | None, tmp: Path) -> str | None:
138
- path_value = strip_file_uri(value)
139
- if not path_value:
140
- return None
141
- path = Path(path_value)
142
- if path.is_file() and path.name.endswith(".tar.gz"):
143
- extracted = tmp / "parent-adapter"
144
- safe_extract_archive(path, extracted)
145
- candidates = [candidate for candidate in extracted.rglob("adapter_config.json")]
146
- if candidates:
147
- return str(candidates[0].parent)
148
- directories = [candidate for candidate in extracted.iterdir() if candidate.is_dir()]
149
- return str(directories[0] if len(directories) == 1 else extracted)
150
- return str(path)
151
-
152
-
153
- def chat_template_kwargs() -> dict[str, Any]:
154
- """Optional kwargs forwarded to apply_chat_template (for example
155
- {"enable_thinking": false} for thinking-mode models), passed by the
156
- runner as a JSON hyperparameter so training text matches inference."""
157
- raw = hp("chat_template_kwargs")
158
- if not raw:
159
- return {}
160
- if isinstance(raw, dict):
161
- return raw
162
- try:
163
- parsed = json.loads(str(raw))
164
- return parsed if isinstance(parsed, dict) else {}
165
- except json.JSONDecodeError:
166
- return {}
167
-
168
-
169
- def row_to_text(tokenizer: Any, row: dict[str, Any]) -> str:
170
- messages = row["messages"]
171
- try:
172
- return tokenizer.apply_chat_template(
173
- messages,
174
- tokenize=False,
175
- add_generation_prompt=False,
176
- **chat_template_kwargs(),
177
- )
178
- except Exception:
179
- parts: list[str] = []
180
- for message in messages:
181
- role = message.get("role", "user")
182
- content = message.get("content", "")
183
- if not isinstance(content, str):
184
- content = json.dumps(content, ensure_ascii=False)
185
- parts.append(f"{role}: {content}")
186
- return "\n".join(parts)
187
-
188
-
189
- class TextDataset(Dataset[dict[str, torch.Tensor]]):
190
- def __init__(self, texts: list[str], tokenizer: Any, max_length: int):
191
- self.examples = [
192
- tokenizer(
193
- text,
194
- truncation=True,
195
- max_length=max_length,
196
- padding=False,
197
- return_tensors=None,
198
- )
199
- for text in texts
200
- ]
114
+ return CERTIFIED_BASE_MODEL
115
+
116
+
117
+ class AssistantOnlyDataset(Dataset[dict[str, torch.Tensor]]):
118
+ def __init__(
119
+ self,
120
+ rows: list[dict[str, Any]],
121
+ tokenizer: Any,
122
+ *,
123
+ max_length: int,
124
+ ):
125
+ self.examples: list[dict[str, torch.Tensor]] = []
126
+ for row_index, row in enumerate(rows, start=1):
127
+ try:
128
+ tokenized = build_assistant_only_example(
129
+ tokenizer,
130
+ row["messages"],
131
+ max_length=max_length,
132
+ )
133
+ except Exception as exc:
134
+ raise ValueError(f"Training row {row_index} is not valid Qwen SFT data: {exc}") from exc
135
+ self.examples.append({
136
+ key: torch.tensor(value, dtype=torch.long)
137
+ for key, value in tokenized.items()
138
+ })
201
139
 
202
140
  def __len__(self) -> int:
203
141
  return len(self.examples)
204
142
 
205
143
  def __getitem__(self, index: int) -> dict[str, torch.Tensor]:
206
- item = self.examples[index]
207
- return {
208
- "input_ids": torch.tensor(item["input_ids"], dtype=torch.long),
209
- "attention_mask": torch.tensor(item["attention_mask"], dtype=torch.long),
210
- }
144
+ return self.examples[index]
211
145
 
212
146
 
213
- def image_from_data_uri(value: str) -> Image.Image:
214
- _, encoded = value.split(",", 1)
215
- return Image.open(BytesIO(base64.b64decode(encoded))).convert("RGB")
216
-
217
-
218
- def load_image(value: str, base_dir: Path) -> Image.Image:
219
- if value.startswith("data:"):
220
- return image_from_data_uri(value)
221
- if value.startswith("http://") or value.startswith("https://"):
222
- with urllib.request.urlopen(value, timeout=30) as response:
223
- return Image.open(BytesIO(response.read())).convert("RGB")
224
- path_value = value[7:] if value.startswith("file://") else value
225
- path = Path(path_value).expanduser()
226
- if not path.is_absolute():
227
- path = base_dir / path
228
- return Image.open(path).convert("RGB")
229
-
230
-
231
- def image_value_from_part(part: dict[str, Any], fallback: str | None) -> str | None:
232
- for key in ("image", "path", "uri", "data_uri"):
233
- value = part.get(key)
234
- if isinstance(value, str) and value:
235
- return value
236
- return fallback
237
-
238
-
239
- def multimodal_content(
240
- content: Any,
241
- top_level_images: list[str],
242
- base_dir: Path,
243
- ) -> tuple[str | list[dict[str, Any]], list[Image.Image]]:
244
- if isinstance(content, str):
245
- return content, []
246
- if not isinstance(content, list):
247
- return str(content), []
248
-
249
- images: list[Image.Image] = []
250
- image_index = 0
251
- converted: list[dict[str, Any]] = []
252
- for part in content:
253
- if not isinstance(part, dict):
254
- converted.append({"type": "text", "text": str(part)})
255
- continue
256
- if part.get("type") != "image":
257
- converted.append(part)
258
- continue
259
- fallback = top_level_images[image_index] if image_index < len(top_level_images) else None
260
- image_index += 1
261
- image_value = image_value_from_part(part, fallback)
262
- if image_value:
263
- images.append(load_image(image_value, base_dir))
264
- converted.append({"type": "image"})
265
- return converted, images
266
-
267
-
268
- def row_to_multimodal_example(row: dict[str, Any]) -> dict[str, Any]:
269
- source_dir = Path(str(row.get(ROW_SOURCE_DIR_KEY) or TRAINING_DIR))
270
- top_level_images = [str(value) for value in row.get("images", []) if isinstance(value, str)]
271
- all_images: list[Image.Image] = []
272
- messages: list[dict[str, Any]] = []
273
- for message in row["messages"]:
274
- content, images = multimodal_content(message.get("content", ""), top_level_images, source_dir)
275
- all_images.extend(images)
276
- messages.append({
277
- "role": message.get("role", "user"),
278
- "content": content,
279
- })
280
- if not all_images and top_level_images:
281
- all_images = [load_image(value, source_dir) for value in top_level_images]
282
- return {
283
- "messages": messages,
284
- "images": all_images,
285
- }
147
+ class AssistantOnlyCollator:
148
+ def __init__(self, pad_token_id: int):
149
+ self.pad_token_id = pad_token_id
150
+
151
+ def __call__(self, features: list[dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]:
152
+ return {
153
+ "input_ids": pad_sequence(
154
+ [feature["input_ids"] for feature in features],
155
+ batch_first=True,
156
+ padding_value=self.pad_token_id,
157
+ ),
158
+ "attention_mask": pad_sequence(
159
+ [feature["attention_mask"] for feature in features],
160
+ batch_first=True,
161
+ padding_value=0,
162
+ ),
163
+ "labels": pad_sequence(
164
+ [feature["labels"] for feature in features],
165
+ batch_first=True,
166
+ padding_value=IGNORE_INDEX,
167
+ ),
168
+ }
286
169
 
287
170
 
288
- def create_text_model_and_tokenizer(model_source: str):
289
- trust_remote_code = hp_bool("trust_remote_code", True)
171
+ def create_model_and_tokenizer(model_source: str) -> tuple[Any, Any, torch.dtype]:
290
172
  token = os.getenv("HF_TOKEN")
291
- tokenizer = AutoTokenizer.from_pretrained(
292
- model_source,
173
+ source_kwargs: dict[str, Any] = {
293
174
  **model_revision_kwargs(model_source),
294
- trust_remote_code=trust_remote_code,
295
- token=token,
296
- )
297
- if tokenizer.pad_token is None:
175
+ "trust_remote_code": False,
176
+ "token": token,
177
+ }
178
+ if not Path(model_source).exists():
179
+ source_kwargs["local_files_only"] = True
180
+ tokenizer = AutoTokenizer.from_pretrained(model_source, **source_kwargs)
181
+ if tokenizer.eos_token_id is None:
182
+ raise ValueError(f"{CERTIFIED_BASE_MODEL} tokenizer has no EOS token")
183
+ if tokenizer.pad_token_id is None:
298
184
  tokenizer.pad_token = tokenizer.eos_token
299
185
 
300
- dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
186
+ dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
301
187
  model = AutoModelForCausalLM.from_pretrained(
302
188
  model_source,
303
- **model_revision_kwargs(model_source),
304
- trust_remote_code=trust_remote_code,
305
- token=token,
306
- torch_dtype=dtype if torch.cuda.is_available() else None,
307
- device_map="auto" if torch.cuda.is_available() else None,
308
- )
309
- model.config.use_cache = False
310
- return model, tokenizer
311
-
312
-
313
- def create_multimodal_model_and_processor(model_source: str):
314
- trust_remote_code = hp_bool("trust_remote_code", True)
315
- token = os.getenv("HF_TOKEN")
316
- processor = AutoProcessor.from_pretrained(
317
- model_source,
318
- **model_revision_kwargs(model_source),
319
- trust_remote_code=trust_remote_code,
320
- token=token,
321
- )
322
- if getattr(processor, "tokenizer", None) is not None and processor.tokenizer.pad_token is None:
323
- processor.tokenizer.pad_token = processor.tokenizer.eos_token
324
-
325
- dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
326
- model = AutoModelForImageTextToText.from_pretrained(
327
- model_source,
328
- **model_revision_kwargs(model_source),
329
- trust_remote_code=trust_remote_code,
330
- token=token,
331
- torch_dtype=dtype if torch.cuda.is_available() else None,
332
- device_map="auto" if torch.cuda.is_available() else None,
189
+ **source_kwargs,
190
+ dtype=dtype,
191
+ device_map={"": torch.cuda.current_device()},
333
192
  )
193
+ assert_certified_model_config(model.config)
334
194
  model.config.use_cache = False
335
- return model, processor
195
+ return model, tokenizer, dtype
336
196
 
337
197
 
338
- def lora_target_modules(default: str) -> list[str] | str:
339
- raw = hp("lora_target_modules", default) or default
340
- if raw == "all-linear":
341
- return raw
342
- return [item.strip() for item in raw.split(",") if item.strip()]
343
-
344
-
345
- def maybe_apply_lora(model: Any):
346
- rank = hp_int("lora_rank", 16)
347
- alpha = hp_int("lora_alpha", 32)
348
- dropout = hp_float("lora_dropout", 0.05)
198
+ def apply_lora(model: Any) -> Any:
349
199
  config = LoraConfig(
350
- r=rank,
351
- lora_alpha=alpha,
352
- lora_dropout=dropout,
353
- bias="none",
354
- task_type="CAUSAL_LM",
355
- target_modules=lora_target_modules("all-linear"),
356
- )
357
- return get_peft_model(model, config)
358
-
359
-
360
- def multimodal_lora_config() -> LoraConfig:
361
- return LoraConfig(
362
200
  r=hp_int("lora_rank", 16),
363
201
  lora_alpha=hp_int("lora_alpha", 32),
364
202
  lora_dropout=hp_float("lora_dropout", 0.05),
365
203
  bias="none",
366
- target_modules=lora_target_modules("q_proj,v_proj"),
204
+ task_type="CAUSAL_LM",
205
+ target_modules="all-linear",
367
206
  )
207
+ return get_peft_model(model, config)
368
208
 
369
209
 
370
210
  def create_model_archive() -> Path:
371
211
  OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
372
212
  archive_path = OUTPUT_DIR / "model.tar.gz"
373
- with tarfile.open(archive_path, "w:gz") as tar:
374
- tar.add(MODEL_DIR, arcname="model")
213
+ with tarfile.open(archive_path, "w:gz") as archive:
214
+ archive.add(MODEL_DIR, arcname="model")
375
215
  return archive_path
376
216
 
377
217
 
378
- def run_text_training(rows: list[dict[str, Any]], model_source: str) -> dict[str, Any]:
379
- with TemporaryDirectory() as tmp:
380
- parent_adapter = resolve_adapter_path(hp("parent_model_artifact"), Path(tmp))
381
- model, tokenizer = create_text_model_and_tokenizer(model_source)
382
- model = (
383
- PeftModel.from_pretrained(model, parent_adapter, is_trainable=True)
384
- if parent_adapter
385
- else maybe_apply_lora(model)
386
- )
387
-
388
- texts = [row_to_text(tokenizer, row) for row in rows]
389
- dataset = TextDataset(texts, tokenizer, max_length=hp_int("max_seq_length", 2048))
390
- collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
218
+ def run_training(rows: list[dict[str, Any]], model_source: str) -> tuple[dict[str, Any], torch.dtype]:
219
+ model, tokenizer, dtype = create_model_and_tokenizer(model_source)
220
+ model = apply_lora(model)
221
+ dataset = AssistantOnlyDataset(
222
+ rows,
223
+ tokenizer,
224
+ max_length=hp_int("max_seq_length", 2048),
225
+ )
226
+ collator = AssistantOnlyCollator(tokenizer.pad_token_id)
391
227
 
228
+ with TemporaryDirectory(prefix="tt-local-sft-") as output_dir:
392
229
  args = TrainingArguments(
393
- output_dir=tmp,
230
+ output_dir=output_dir,
394
231
  num_train_epochs=hp_int("n_epochs", 3),
395
232
  learning_rate=hp_float("learning_rate", 0.00001),
396
233
  per_device_train_batch_size=hp_int("per_device_train_batch_size", 1),
@@ -399,8 +236,9 @@ def run_text_training(rows: list[dict[str, Any]], model_source: str) -> dict[str
399
236
  save_strategy="no",
400
237
  report_to=[],
401
238
  remove_unused_columns=False,
402
- bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
403
- fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(),
239
+ bf16=dtype == torch.bfloat16,
240
+ fp16=dtype == torch.float16,
241
+ optim="adamw_torch",
404
242
  )
405
243
  trainer = Trainer(
406
244
  model=model,
@@ -412,77 +250,39 @@ def run_text_training(rows: list[dict[str, Any]], model_source: str) -> dict[str
412
250
 
413
251
  model.save_pretrained(MODEL_DIR)
414
252
  tokenizer.save_pretrained(MODEL_DIR)
415
- return result.metrics
416
-
417
-
418
- def run_multimodal_training(rows: list[dict[str, Any]], model_source: str) -> dict[str, Any]:
419
- with TemporaryDirectory() as tmp:
420
- parent_adapter = resolve_adapter_path(hp("parent_model_artifact"), Path(tmp))
421
- model, processor = create_multimodal_model_and_processor(model_source)
422
- if parent_adapter:
423
- model = PeftModel.from_pretrained(model, parent_adapter, is_trainable=True)
424
- dataset = HFDataset.from_list([row_to_multimodal_example(row) for row in rows])
425
-
426
- args = SFTConfig(
427
- output_dir=tmp,
428
- num_train_epochs=hp_int("n_epochs", 3),
429
- learning_rate=hp_float("learning_rate", 0.00001),
430
- per_device_train_batch_size=hp_int("per_device_train_batch_size", 1),
431
- gradient_accumulation_steps=hp_int("gradient_accumulation_steps", 8),
432
- logging_steps=1,
433
- save_strategy="no",
434
- report_to="none",
435
- remove_unused_columns=False,
436
- bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
437
- fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(),
438
- gradient_checkpointing=True,
439
- gradient_checkpointing_kwargs={"use_reentrant": False},
440
- dataset_kwargs={"skip_prepare_dataset": True},
441
- max_length=None,
442
- )
443
- trainer_values: dict[str, Any] = {
444
- "model": model,
445
- "args": args,
446
- "train_dataset": dataset,
447
- "processing_class": processor,
448
- }
449
- if not parent_adapter:
450
- trainer_values["peft_config"] = multimodal_lora_config()
451
- trainer = SFTTrainer(**trainer_values)
452
- result = trainer.train()
453
- trainer.save_model(MODEL_DIR)
454
- processor.save_pretrained(MODEL_DIR)
455
- return result.metrics
253
+ return result.metrics, dtype
456
254
 
457
255
 
458
256
  def main() -> None:
459
257
  started = time.time()
258
+ assert_certified_request()
259
+ cuda = require_cuda()
460
260
  MODEL_DIR.mkdir(parents=True, exist_ok=True)
461
261
  OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
462
262
 
463
263
  rows = load_rows()
464
- with TemporaryDirectory(prefix="tt-local-base-model-") as base_tmp:
465
- model_source = resolve_model_source(Path(base_tmp))
466
- model_loader = hp("model_loader", "causal_lm")
467
- if model_loader == "image_text_to_text":
468
- metrics = run_multimodal_training(rows, model_source)
469
- else:
470
- metrics = run_text_training(rows, model_source)
471
-
264
+ model_source = resolve_model_source()
265
+ metrics, dtype = run_training(rows, model_source)
472
266
  archive_path = create_model_archive()
473
267
  output_metrics = {
474
268
  "training_rows": len(rows),
475
- "model_source": str(BASE_MODEL_DIR) if BASE_MODEL_DIR.is_dir() and any(BASE_MODEL_DIR.iterdir()) else hp("base_model"),
269
+ "base_model": CERTIFIED_BASE_MODEL,
476
270
  "base_model_revision": hp("base_model_revision"),
477
- "model_loader": model_loader,
478
- "parent_model_artifact": hp("parent_model_artifact"),
271
+ "model_source": model_source,
272
+ "model_loader": "causal_lm",
273
+ "loss_mask": "final_assistant_only",
274
+ "device": "cuda",
275
+ "cuda_device": cuda["device_name"],
276
+ "cuda_compute_capability": cuda["compute_capability"],
277
+ "dtype": str(dtype).removeprefix("torch."),
479
278
  "train_runtime": round(time.time() - started, 3),
480
279
  **{key: float(value) for key, value in metrics.items() if isinstance(value, (int, float))},
481
280
  "model_archive": str(archive_path),
482
281
  }
483
- (MODEL_DIR / "training-metrics.json").write_text(json.dumps(output_metrics, indent=2))
484
- (OUTPUT_DIR / "training-metrics.json").write_text(json.dumps(output_metrics, indent=2))
485
- print(json.dumps(output_metrics, indent=2))
282
+ metrics_json = json.dumps(output_metrics, indent=2)
283
+ (MODEL_DIR / "training-metrics.json").write_text(metrics_json, encoding="utf-8")
284
+ (OUTPUT_DIR / "training-metrics.json").write_text(metrics_json, encoding="utf-8")
285
+ print(metrics_json)
486
286
 
487
287
 
488
288
  if __name__ == "__main__":