AAJerry commited on
Commit
f3c950c
·
verified ·
1 Parent(s): 891030d

Add trainer/train.py

Browse files
Files changed (1) hide show
  1. trainer/train.py +265 -0
trainer/train.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import torch
10
+ from datasets import Dataset
11
+ from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
12
+ from transformers import (
13
+ AutoModelForCausalLM,
14
+ AutoTokenizer,
15
+ BitsAndBytesConfig,
16
+ Trainer,
17
+ TrainerCallback,
18
+ TrainingArguments,
19
+ set_seed,
20
+ )
21
+ from transformers.trainer_utils import get_last_checkpoint
22
+
23
+ from .common import ARTIFACT_ROOT, CHECKPOINT_ROOT, DATASET_ROOT, atomic_json, ensure_dirs, log_event, update_status
24
+
25
+
26
+ BASE_MODEL = os.environ.get("BASE_MODEL", "Qwen/Qwen3-1.7B")
27
+ MAX_LENGTH = int(os.environ.get("MAX_LENGTH", "2048"))
28
+ OUTPUT_NAME = os.environ.get("OUTPUT_NAME", "adapter")
29
+ TRAIN_LIMIT = int(os.environ.get("TRAIN_LIMIT", "0"))
30
+ MAX_STEPS = int(os.environ.get("MAX_STEPS", "-1"))
31
+ SEED = int(os.environ.get("TRAIN_SEED", "42"))
32
+
33
+
34
+ def load_rows(path: Path, limit: int = 0) -> list[dict[str, Any]]:
35
+ rows: list[dict[str, Any]] = []
36
+ with path.open("r", encoding="utf-8") as handle:
37
+ for line in handle:
38
+ rows.append(json.loads(line))
39
+ if limit and len(rows) >= limit:
40
+ break
41
+ return rows
42
+
43
+
44
+ def common_prefix_length(left: list[int], right: list[int]) -> int:
45
+ length = 0
46
+ for a, b in zip(left, right):
47
+ if a != b:
48
+ break
49
+ length += 1
50
+ return length
51
+
52
+
53
+ def as_token_ids(value: Any) -> list[int]:
54
+ if isinstance(value, dict) or hasattr(value, "keys"):
55
+ value = value["input_ids"]
56
+ if isinstance(value, torch.Tensor):
57
+ value = value.tolist()
58
+ if value and isinstance(value[0], list):
59
+ value = value[0]
60
+ return list(value)
61
+
62
+
63
+ def tokenized_dataset(tokenizer: Any, rows: list[dict[str, Any]]) -> Dataset:
64
+ dataset = Dataset.from_list([{"messages": row["messages"], "id": row["id"]} for row in rows])
65
+
66
+ def encode(example: dict[str, Any]) -> dict[str, Any]:
67
+ messages = example["messages"]
68
+ prefix = messages[:-1]
69
+ prefix_ids = as_token_ids(
70
+ tokenizer.apply_chat_template(
71
+ prefix,
72
+ tokenize=True,
73
+ add_generation_prompt=True,
74
+ enable_thinking=False,
75
+ )
76
+ )
77
+ full_ids = as_token_ids(
78
+ tokenizer.apply_chat_template(
79
+ messages,
80
+ tokenize=True,
81
+ add_generation_prompt=False,
82
+ enable_thinking=False,
83
+ )
84
+ )
85
+ if tokenizer.eos_token_id is not None and (not full_ids or full_ids[-1] != tokenizer.eos_token_id):
86
+ full_ids.append(tokenizer.eos_token_id)
87
+ mask_to = common_prefix_length(prefix_ids, full_ids)
88
+ input_ids = full_ids[:MAX_LENGTH]
89
+ labels = [-100] * min(mask_to, len(input_ids)) + input_ids[min(mask_to, len(input_ids)) :]
90
+ if not any(label != -100 for label in labels):
91
+ raise ValueError(f"Assistant response was fully truncated for example {example['id']}")
92
+ return {"input_ids": input_ids, "attention_mask": [1] * len(input_ids), "labels": labels}
93
+
94
+ return dataset.map(encode, remove_columns=dataset.column_names, desc="Tokenizing conversations")
95
+
96
+
97
+ @dataclass
98
+ class AssistantOnlyCollator:
99
+ pad_token_id: int
100
+
101
+ def __call__(self, features: list[dict[str, list[int]]]) -> dict[str, torch.Tensor]:
102
+ max_len = max(len(feature["input_ids"]) for feature in features)
103
+ input_ids = []
104
+ attention_mask = []
105
+ labels = []
106
+ for feature in features:
107
+ padding = max_len - len(feature["input_ids"])
108
+ input_ids.append(feature["input_ids"] + [self.pad_token_id] * padding)
109
+ attention_mask.append(feature["attention_mask"] + [0] * padding)
110
+ labels.append(feature["labels"] + [-100] * padding)
111
+ return {
112
+ "input_ids": torch.tensor(input_ids, dtype=torch.long),
113
+ "attention_mask": torch.tensor(attention_mask, dtype=torch.long),
114
+ "labels": torch.tensor(labels, dtype=torch.long),
115
+ }
116
+
117
+
118
+ class StatusCallback(TrainerCallback):
119
+ def on_log(self, args: TrainingArguments, state: Any, control: Any, logs: dict[str, Any] | None = None, **_: Any) -> None:
120
+ if not logs:
121
+ return
122
+ numeric = {key: round(float(value), 6) for key, value in logs.items() if isinstance(value, (int, float))}
123
+ update_status(
124
+ "smoke_train" if OUTPUT_NAME == "smoke_adapter" else "full_train",
125
+ f"Training step {state.global_step}",
126
+ global_step=state.global_step,
127
+ max_steps=state.max_steps,
128
+ metrics=numeric,
129
+ )
130
+
131
+
132
+ def main() -> None:
133
+ ensure_dirs()
134
+ if not torch.cuda.is_available():
135
+ raise RuntimeError("QLoRA training requires the requested CUDA GPU")
136
+ set_seed(SEED)
137
+ output_dir = CHECKPOINT_ROOT / OUTPUT_NAME
138
+ adapter_dir = ARTIFACT_ROOT / OUTPUT_NAME
139
+ output_dir.mkdir(parents=True, exist_ok=True)
140
+ adapter_dir.mkdir(parents=True, exist_ok=True)
141
+
142
+ tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, use_fast=True, trust_remote_code=False)
143
+ if tokenizer.pad_token_id is None:
144
+ tokenizer.pad_token = tokenizer.eos_token
145
+ tokenizer.padding_side = "right"
146
+
147
+ train_rows = load_rows(DATASET_ROOT / "train.jsonl", TRAIN_LIMIT)
148
+ eval_limit = min(200, max(30, len(train_rows) // 10)) if TRAIN_LIMIT else 500
149
+ eval_rows = load_rows(DATASET_ROOT / "validation.jsonl", eval_limit)
150
+ train_dataset = tokenized_dataset(tokenizer, train_rows)
151
+ eval_dataset = tokenized_dataset(tokenizer, eval_rows)
152
+
153
+ quantization = BitsAndBytesConfig(
154
+ load_in_4bit=True,
155
+ bnb_4bit_quant_type="nf4",
156
+ bnb_4bit_use_double_quant=True,
157
+ bnb_4bit_compute_dtype=torch.bfloat16,
158
+ )
159
+ model = AutoModelForCausalLM.from_pretrained(
160
+ BASE_MODEL,
161
+ quantization_config=quantization,
162
+ torch_dtype=torch.bfloat16,
163
+ device_map={"": 0},
164
+ trust_remote_code=False,
165
+ attn_implementation="sdpa",
166
+ )
167
+ model.config.use_cache = False
168
+ model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True)
169
+ lora = LoraConfig(
170
+ r=int(os.environ.get("LORA_R", "16")),
171
+ lora_alpha=int(os.environ.get("LORA_ALPHA", "32")),
172
+ lora_dropout=float(os.environ.get("LORA_DROPOUT", "0.05")),
173
+ bias="none",
174
+ task_type="CAUSAL_LM",
175
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
176
+ )
177
+ model = get_peft_model(model, lora)
178
+ model.print_trainable_parameters()
179
+
180
+ save_strategy = os.environ.get("SAVE_STRATEGY", "steps")
181
+ smoke = OUTPUT_NAME == "smoke_adapter"
182
+ args = TrainingArguments(
183
+ output_dir=str(output_dir),
184
+ overwrite_output_dir=False,
185
+ num_train_epochs=float(os.environ.get("EPOCHS", "2")),
186
+ max_steps=MAX_STEPS,
187
+ per_device_train_batch_size=int(os.environ.get("BATCH_SIZE", "4")),
188
+ per_device_eval_batch_size=int(os.environ.get("EVAL_BATCH_SIZE", "4")),
189
+ gradient_accumulation_steps=int(os.environ.get("GRAD_ACCUM", "4")),
190
+ learning_rate=float(os.environ.get("LEARNING_RATE", "1e-4")),
191
+ lr_scheduler_type="cosine",
192
+ warmup_ratio=0.03,
193
+ weight_decay=0.0,
194
+ max_grad_norm=1.0,
195
+ optim="paged_adamw_8bit",
196
+ bf16=True,
197
+ fp16=False,
198
+ tf32=True,
199
+ logging_steps=1 if smoke else 10,
200
+ eval_strategy="no" if smoke else "steps",
201
+ eval_steps=100,
202
+ save_strategy=save_strategy,
203
+ save_steps=100,
204
+ save_total_limit=3,
205
+ load_best_model_at_end=not smoke,
206
+ metric_for_best_model="eval_loss",
207
+ greater_is_better=False,
208
+ report_to="none",
209
+ remove_unused_columns=False,
210
+ dataloader_num_workers=2,
211
+ dataloader_pin_memory=True,
212
+ gradient_checkpointing=True,
213
+ seed=SEED,
214
+ data_seed=SEED,
215
+ )
216
+ trainer = Trainer(
217
+ model=model,
218
+ args=args,
219
+ train_dataset=train_dataset,
220
+ eval_dataset=None if smoke else eval_dataset,
221
+ data_collator=AssistantOnlyCollator(tokenizer.pad_token_id),
222
+ callbacks=[StatusCallback()],
223
+ )
224
+ last_checkpoint = get_last_checkpoint(str(output_dir)) if output_dir.exists() and not smoke else None
225
+ update_status(
226
+ "smoke_train" if smoke else "full_train",
227
+ "Model loaded; optimizer starting",
228
+ base_model=BASE_MODEL,
229
+ train_examples=len(train_dataset),
230
+ validation_examples=len(eval_dataset),
231
+ resume_from=last_checkpoint,
232
+ gpu=torch.cuda.get_device_name(0),
233
+ )
234
+ result = trainer.train(resume_from_checkpoint=last_checkpoint)
235
+ trainer.save_metrics("train", result.metrics)
236
+ model.save_pretrained(adapter_dir, safe_serialization=True)
237
+ tokenizer.save_pretrained(adapter_dir)
238
+ metrics = {key: float(value) for key, value in result.metrics.items() if isinstance(value, (int, float))}
239
+ atomic_json(adapter_dir / "run_metrics.json", metrics)
240
+ atomic_json(
241
+ adapter_dir / "training_config.json",
242
+ {
243
+ "base_model": BASE_MODEL,
244
+ "method": "QLoRA NF4 double-quantization with BF16 compute",
245
+ "assistant_only_loss": True,
246
+ "thinking": False,
247
+ "max_length": MAX_LENGTH,
248
+ "seed": SEED,
249
+ "train_examples": len(train_dataset),
250
+ "validation_examples": len(eval_dataset),
251
+ "lora_r": lora.r,
252
+ "lora_alpha": lora.lora_alpha,
253
+ },
254
+ )
255
+ log_event("training_complete", output=OUTPUT_NAME, metrics=metrics)
256
+ update_status(
257
+ "smoke_complete" if smoke else "training_complete",
258
+ f"{OUTPUT_NAME} training completed",
259
+ metrics=metrics,
260
+ adapter=str(adapter_dir),
261
+ )
262
+
263
+
264
+ if __name__ == "__main__":
265
+ main()