@tuned-tensor/local 0.1.3 → 0.1.5

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.
@@ -1,24 +1,31 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import json
4
5
  import os
5
6
  import tarfile
6
7
  import time
7
- from dataclasses import dataclass
8
+ import urllib.request
9
+ from io import BytesIO
8
10
  from pathlib import Path
9
11
  from tempfile import TemporaryDirectory
10
12
  from typing import Any
11
13
 
12
14
  import torch
15
+ from datasets import Dataset as HFDataset
13
16
  from peft import LoraConfig, get_peft_model
17
+ from PIL import Image
14
18
  from torch.utils.data import Dataset
15
19
  from transformers import (
16
20
  AutoModelForCausalLM,
21
+ AutoModelForImageTextToText,
22
+ AutoProcessor,
17
23
  AutoTokenizer,
18
24
  DataCollatorForLanguageModeling,
19
25
  Trainer,
20
26
  TrainingArguments,
21
27
  )
28
+ from trl import SFTConfig, SFTTrainer
22
29
 
23
30
 
24
31
  TRAINING_DIR = Path(os.environ.get("SM_CHANNEL_TRAINING", "/opt/ml/input/data/training"))
@@ -28,6 +35,7 @@ HYPERPARAMETERS_PATH = Path(
28
35
  )
29
36
  MODEL_DIR = Path(os.environ.get("SM_MODEL_DIR", "/opt/ml/model"))
30
37
  OUTPUT_DIR = Path(os.environ.get("SM_OUTPUT_DIR", "/opt/ml/output"))
38
+ ROW_SOURCE_DIR_KEY = "_tt_source_dir"
31
39
 
32
40
 
33
41
  def load_hyperparameters() -> dict[str, str]:
@@ -71,6 +79,7 @@ def load_rows() -> list[dict[str, Any]]:
71
79
  row = json.loads(line)
72
80
  if "messages" not in row:
73
81
  raise ValueError(f"{path}:{line_number} missing messages")
82
+ row[ROW_SOURCE_DIR_KEY] = str(path.parent)
74
83
  rows.append(row)
75
84
  if not rows:
76
85
  raise ValueError(f"No chat JSONL rows found under {TRAINING_DIR}")
@@ -132,7 +141,82 @@ class TextDataset(Dataset[dict[str, torch.Tensor]]):
132
141
  }
133
142
 
134
143
 
135
- def create_model_and_tokenizer(model_source: str):
144
+ def image_from_data_uri(value: str) -> Image.Image:
145
+ _, encoded = value.split(",", 1)
146
+ return Image.open(BytesIO(base64.b64decode(encoded))).convert("RGB")
147
+
148
+
149
+ def load_image(value: str, base_dir: Path) -> Image.Image:
150
+ if value.startswith("data:"):
151
+ return image_from_data_uri(value)
152
+ if value.startswith("http://") or value.startswith("https://"):
153
+ with urllib.request.urlopen(value, timeout=30) as response:
154
+ return Image.open(BytesIO(response.read())).convert("RGB")
155
+ path_value = value[7:] if value.startswith("file://") else value
156
+ path = Path(path_value).expanduser()
157
+ if not path.is_absolute():
158
+ path = base_dir / path
159
+ return Image.open(path).convert("RGB")
160
+
161
+
162
+ def image_value_from_part(part: dict[str, Any], fallback: str | None) -> str | None:
163
+ for key in ("image", "path", "uri", "data_uri"):
164
+ value = part.get(key)
165
+ if isinstance(value, str) and value:
166
+ return value
167
+ return fallback
168
+
169
+
170
+ def multimodal_content(
171
+ content: Any,
172
+ top_level_images: list[str],
173
+ base_dir: Path,
174
+ ) -> tuple[str | list[dict[str, Any]], list[Image.Image]]:
175
+ if isinstance(content, str):
176
+ return content, []
177
+ if not isinstance(content, list):
178
+ return str(content), []
179
+
180
+ images: list[Image.Image] = []
181
+ image_index = 0
182
+ converted: list[dict[str, Any]] = []
183
+ for part in content:
184
+ if not isinstance(part, dict):
185
+ converted.append({"type": "text", "text": str(part)})
186
+ continue
187
+ if part.get("type") != "image":
188
+ converted.append(part)
189
+ continue
190
+ fallback = top_level_images[image_index] if image_index < len(top_level_images) else None
191
+ image_index += 1
192
+ image_value = image_value_from_part(part, fallback)
193
+ if image_value:
194
+ images.append(load_image(image_value, base_dir))
195
+ converted.append({"type": "image"})
196
+ return converted, images
197
+
198
+
199
+ def row_to_multimodal_example(row: dict[str, Any]) -> dict[str, Any]:
200
+ source_dir = Path(str(row.get(ROW_SOURCE_DIR_KEY) or TRAINING_DIR))
201
+ top_level_images = [str(value) for value in row.get("images", []) if isinstance(value, str)]
202
+ all_images: list[Image.Image] = []
203
+ messages: list[dict[str, Any]] = []
204
+ for message in row["messages"]:
205
+ content, images = multimodal_content(message.get("content", ""), top_level_images, source_dir)
206
+ all_images.extend(images)
207
+ messages.append({
208
+ "role": message.get("role", "user"),
209
+ "content": content,
210
+ })
211
+ if not all_images and top_level_images:
212
+ all_images = [load_image(value, source_dir) for value in top_level_images]
213
+ return {
214
+ "messages": messages,
215
+ "images": all_images,
216
+ }
217
+
218
+
219
+ def create_text_model_and_tokenizer(model_source: str):
136
220
  trust_remote_code = hp_bool("trust_remote_code", True)
137
221
  token = os.getenv("HF_TOKEN")
138
222
  tokenizer = AutoTokenizer.from_pretrained(
@@ -155,6 +239,36 @@ def create_model_and_tokenizer(model_source: str):
155
239
  return model, tokenizer
156
240
 
157
241
 
242
+ def create_multimodal_model_and_processor(model_source: str):
243
+ trust_remote_code = hp_bool("trust_remote_code", True)
244
+ token = os.getenv("HF_TOKEN")
245
+ processor = AutoProcessor.from_pretrained(
246
+ model_source,
247
+ trust_remote_code=trust_remote_code,
248
+ token=token,
249
+ )
250
+ if getattr(processor, "tokenizer", None) is not None and processor.tokenizer.pad_token is None:
251
+ processor.tokenizer.pad_token = processor.tokenizer.eos_token
252
+
253
+ dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float16
254
+ model = AutoModelForImageTextToText.from_pretrained(
255
+ model_source,
256
+ trust_remote_code=trust_remote_code,
257
+ token=token,
258
+ torch_dtype=dtype if torch.cuda.is_available() else None,
259
+ device_map="auto" if torch.cuda.is_available() else None,
260
+ )
261
+ model.config.use_cache = False
262
+ return model, processor
263
+
264
+
265
+ def lora_target_modules(default: str) -> list[str] | str:
266
+ raw = hp("lora_target_modules", default) or default
267
+ if raw == "all-linear":
268
+ return raw
269
+ return [item.strip() for item in raw.split(",") if item.strip()]
270
+
271
+
158
272
  def maybe_apply_lora(model: Any):
159
273
  rank = hp_int("lora_rank", 16)
160
274
  alpha = hp_int("lora_alpha", 32)
@@ -165,11 +279,21 @@ def maybe_apply_lora(model: Any):
165
279
  lora_dropout=dropout,
166
280
  bias="none",
167
281
  task_type="CAUSAL_LM",
168
- target_modules="all-linear",
282
+ target_modules=lora_target_modules("all-linear"),
169
283
  )
170
284
  return get_peft_model(model, config)
171
285
 
172
286
 
287
+ def multimodal_lora_config() -> LoraConfig:
288
+ return LoraConfig(
289
+ r=hp_int("lora_rank", 16),
290
+ lora_alpha=hp_int("lora_alpha", 32),
291
+ lora_dropout=hp_float("lora_dropout", 0.05),
292
+ bias="none",
293
+ target_modules=lora_target_modules("q_proj,v_proj"),
294
+ )
295
+
296
+
173
297
  def create_model_archive() -> Path:
174
298
  OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
175
299
  archive_path = OUTPUT_DIR / "model.tar.gz"
@@ -178,14 +302,8 @@ def create_model_archive() -> Path:
178
302
  return archive_path
179
303
 
180
304
 
181
- def main() -> None:
182
- started = time.time()
183
- MODEL_DIR.mkdir(parents=True, exist_ok=True)
184
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
185
-
186
- rows = load_rows()
187
- model_source = resolve_model_source()
188
- model, tokenizer = create_model_and_tokenizer(model_source)
305
+ def run_text_training(rows: list[dict[str, Any]], model_source: str) -> dict[str, Any]:
306
+ model, tokenizer = create_text_model_and_tokenizer(model_source)
189
307
  model = maybe_apply_lora(model)
190
308
 
191
309
  texts = [row_to_text(tokenizer, row) for row in rows]
@@ -216,17 +334,69 @@ def main() -> None:
216
334
 
217
335
  model.save_pretrained(MODEL_DIR)
218
336
  tokenizer.save_pretrained(MODEL_DIR)
337
+ return result.metrics
338
+
339
+
340
+ def run_multimodal_training(rows: list[dict[str, Any]], model_source: str) -> dict[str, Any]:
341
+ model, processor = create_multimodal_model_and_processor(model_source)
342
+ dataset = HFDataset.from_list([row_to_multimodal_example(row) for row in rows])
343
+
344
+ with TemporaryDirectory() as tmp:
345
+ args = SFTConfig(
346
+ output_dir=tmp,
347
+ num_train_epochs=hp_int("n_epochs", 3),
348
+ learning_rate=hp_float("learning_rate", 0.00001),
349
+ per_device_train_batch_size=hp_int("per_device_train_batch_size", 1),
350
+ gradient_accumulation_steps=hp_int("gradient_accumulation_steps", 8),
351
+ logging_steps=1,
352
+ save_strategy="no",
353
+ report_to="none",
354
+ remove_unused_columns=False,
355
+ bf16=torch.cuda.is_available() and torch.cuda.is_bf16_supported(),
356
+ fp16=torch.cuda.is_available() and not torch.cuda.is_bf16_supported(),
357
+ gradient_checkpointing=True,
358
+ gradient_checkpointing_kwargs={"use_reentrant": False},
359
+ dataset_kwargs={"skip_prepare_dataset": True},
360
+ max_length=None,
361
+ )
362
+ trainer = SFTTrainer(
363
+ model=model,
364
+ args=args,
365
+ train_dataset=dataset,
366
+ processing_class=processor,
367
+ peft_config=multimodal_lora_config(),
368
+ )
369
+ result = trainer.train()
370
+ trainer.save_model(MODEL_DIR)
371
+ processor.save_pretrained(MODEL_DIR)
372
+ return result.metrics
373
+
374
+
375
+ def main() -> None:
376
+ started = time.time()
377
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
378
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
379
+
380
+ rows = load_rows()
381
+ model_source = resolve_model_source()
382
+ model_loader = hp("model_loader", "causal_lm")
383
+ if model_loader == "image_text_to_text":
384
+ metrics = run_multimodal_training(rows, model_source)
385
+ else:
386
+ metrics = run_text_training(rows, model_source)
387
+
219
388
  archive_path = create_model_archive()
220
- metrics = {
389
+ output_metrics = {
221
390
  "training_rows": len(rows),
222
391
  "model_source": model_source,
392
+ "model_loader": model_loader,
223
393
  "train_runtime": round(time.time() - started, 3),
224
- **{key: float(value) for key, value in result.metrics.items() if isinstance(value, (int, float))},
394
+ **{key: float(value) for key, value in metrics.items() if isinstance(value, (int, float))},
225
395
  "model_archive": str(archive_path),
226
396
  }
227
- (MODEL_DIR / "training-metrics.json").write_text(json.dumps(metrics, indent=2))
228
- (OUTPUT_DIR / "training-metrics.json").write_text(json.dumps(metrics, indent=2))
229
- print(json.dumps(metrics, indent=2))
397
+ (MODEL_DIR / "training-metrics.json").write_text(json.dumps(output_metrics, indent=2))
398
+ (OUTPUT_DIR / "training-metrics.json").write_text(json.dumps(output_metrics, indent=2))
399
+ print(json.dumps(output_metrics, indent=2))
230
400
 
231
401
 
232
402
  if __name__ == "__main__":