ai-sherpa commited on
Commit
40d1092
·
verified ·
1 Parent(s): 6255c75

add Modal QLoRA training script (sponsor evidence; cited in README)

Browse files
Files changed (1) hide show
  1. scripts/modal_qlora_train.py +401 -0
scripts/modal_qlora_train.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Modal QLoRA fine-tune: Qwen3-8B → ai-sherpa/Qwen3-8B-Kintsugi.
3
+
4
+ Plan reference: subagent ad6ef461338cb47b6 §3 (Training compute), §4
5
+ (Publishing artifacts), §7 (Risks & time estimate).
6
+
7
+ WHAT IT DOES (in order):
8
+ 1. Mount Modal volumes (HF cache + checkpoints).
9
+ 2. Load Qwen3-8B base in 4-bit NF4 (BitsAndBytes).
10
+ 3. Apply QLoRA adapters (r=16, α=32) to attention + MLP projections.
11
+ 4. Build a `datasets.Dataset` from docs/finetune/training-data/{train,eval}.jsonl
12
+ using the existing chat-message format from build_training_data.py.
13
+ 5. Train with TRL SFTTrainer, 3 epochs, ~90 min on H100.
14
+ 6. Merge LoRA into base, copy tokenizer_config.json from the base repo
15
+ (per plan §7 risk #2 — prevents chat_template drift), push merged
16
+ model to HF Hub as `ai-sherpa/Qwen3-8B-Kintsugi`.
17
+
18
+ WHAT IT DOES NOT DO:
19
+ - Convert merged model to GGUF Q4_K_M — that's a separate llama.cpp
20
+ convert + quantize step on a CPU machine (no GPU needed).
21
+ - Publish the dataset card — that's a separate `huggingface-cli` step
22
+ or a Python helper, run after the training data is final.
23
+ - Run the QA acceptance harness — that's local-CPU work via
24
+ scripts/qa_acceptance_harness.py once LLAMA_REPO is flipped.
25
+
26
+ PREREQUISITES (one-time setup):
27
+ 1. `pip install modal && modal setup` — set up Modal account locally.
28
+ 2. Create the HF Hub repos (do this from a browser to confirm
29
+ namespace ownership; modal can't create them):
30
+ - https://huggingface.co/new (model) → ai-sherpa/Qwen3-8B-Kintsugi
31
+ 3. Set the HF token as a Modal Secret:
32
+ modal secret create huggingface HF_TOKEN=hf_xxxxxxxx
33
+ 4. Generate training data locally:
34
+ python3.10 scripts/build_training_data.py \\
35
+ --input docs/finetune/seed-examples.jsonl
36
+ (Plus the 150 self-distilled rows when ready, per plan §2.)
37
+
38
+ USAGE:
39
+ # Dry-run: validate env + show config, no GPU spend.
40
+ modal run scripts/modal_qlora_train.py::main --dry-run
41
+
42
+ # Real training run (~$8, ~90 min on H100).
43
+ modal run scripts/modal_qlora_train.py::main
44
+
45
+ DRAFT STATUS:
46
+ This is a draft. Verify against current library docs before launching
47
+ a paid run:
48
+ - Modal API: https://modal.com/docs
49
+ - TRL SFTTrainer: https://huggingface.co/docs/trl/sft_trainer
50
+ - PEFT LoraConfig: https://huggingface.co/docs/peft/package_reference/lora
51
+ The pinned library versions in IMAGE below were the stable set as of
52
+ 2026-Q2; if Modal's pre-built CUDA image diverges, expect to adjust
53
+ bitsandbytes/torch combos. Run `--dry-run` first to surface any
54
+ version mismatch before paying for GPU.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import json
60
+ import os
61
+ import sys
62
+ from pathlib import Path
63
+
64
+ import modal
65
+
66
+
67
+ # ----------------------------------------------------------------------------
68
+ # Constants — from plan §3
69
+ # ----------------------------------------------------------------------------
70
+
71
+ BASE_MODEL_ID = "Qwen/Qwen3-8B"
72
+ HUB_MODEL_ID = "ai-sherpa/Qwen3-8B-Kintsugi"
73
+
74
+ # QLoRA hyperparameters (plan §3)
75
+ LORA_R = 16
76
+ LORA_ALPHA = 32
77
+ LORA_DROPOUT = 0.05
78
+ # Apply LoRA to attention + MLP — covers the projections that matter for
79
+ # style transfer without ballooning trainable params.
80
+ LORA_TARGET_MODULES = [
81
+ "q_proj", "k_proj", "v_proj", "o_proj",
82
+ "gate_proj", "up_proj", "down_proj",
83
+ ]
84
+
85
+ # Training hyperparameters
86
+ NUM_EPOCHS = 3
87
+ PER_DEVICE_BATCH_SIZE = 2
88
+ GRAD_ACCUMULATION = 4 # effective batch = 8
89
+ LEARNING_RATE = 2e-4
90
+ WARMUP_RATIO = 0.03
91
+ MAX_SEQ_LENGTH = 4096 # the OUTPUT_FORMAT + lexicon scaffold + assistant
92
+ # turn together fit comfortably under this
93
+ WEIGHT_DECAY = 0.01
94
+
95
+ # Modal config
96
+ GPU_TYPE = "H100" # ~$6/hr × 90 min ≈ $9; A100 (~$3/hr) is fine
97
+ # for cost-sensitive runs at slower wall time
98
+ TIMEOUT_SECONDS = 60 * 60 * 2 # 2-hour ceiling
99
+
100
+
101
+ # ----------------------------------------------------------------------------
102
+ # Modal app + image
103
+ # ----------------------------------------------------------------------------
104
+
105
+ # Qwen3 architecture support was added in transformers 4.51. TRL's
106
+ # SFTTrainer API also shifted in this window (tokenizer→processing_class,
107
+ # max_seq_length → SFTConfig). These pins are the post-shift compatible
108
+ # set as of 2026-Q2 — see the SFTTrainer call below for the new API
109
+ # this script relies on.
110
+ IMAGE = (
111
+ modal.Image.debian_slim(python_version="3.11")
112
+ .pip_install(
113
+ "torch==2.5.1",
114
+ "transformers>=4.52.0,<5.0",
115
+ "peft>=0.15.0",
116
+ "accelerate>=1.5.0",
117
+ "bitsandbytes>=0.45.0",
118
+ "trl>=0.18.0,<0.20",
119
+ "datasets>=3.2.0",
120
+ "huggingface_hub>=0.28.0",
121
+ "sentencepiece",
122
+ "protobuf",
123
+ )
124
+ .env({
125
+ # HF_HOME points at the persistent volume so the 5GB base download
126
+ # is paid once across all runs.
127
+ "HF_HOME": "/hf_cache",
128
+ # TRL has been moving the chat-template assembly between
129
+ # tokenizers and the trainer; setting this avoids the deprecation
130
+ # warning and is a no-op when not relevant.
131
+ "TRL_USE_RICH": "0",
132
+ })
133
+ )
134
+
135
+ app = modal.App(name="kintsugi-qlora-train", image=IMAGE)
136
+
137
+ # Volumes — survive across runs.
138
+ hf_cache = modal.Volume.from_name("kintsugi-hf-cache", create_if_missing=True)
139
+ checkpoints = modal.Volume.from_name("kintsugi-checkpoints", create_if_missing=True)
140
+
141
+
142
+ # ----------------------------------------------------------------------------
143
+ # Remote function: the train run
144
+ # ----------------------------------------------------------------------------
145
+
146
+ @app.function(
147
+ gpu=GPU_TYPE,
148
+ timeout=TIMEOUT_SECONDS,
149
+ volumes={"/hf_cache": hf_cache, "/checkpoints": checkpoints},
150
+ secrets=[modal.Secret.from_name("huggingface")],
151
+ )
152
+ def train_qlora(
153
+ train_jsonl: bytes,
154
+ eval_jsonl: bytes,
155
+ num_epochs: int = NUM_EPOCHS,
156
+ push_to_hub: bool = True,
157
+ run_tag: str = "v1",
158
+ ) -> dict:
159
+ """Train Qwen3-8B with QLoRA on the supplied (train, eval) JSONL bytes.
160
+
161
+ Returns a dict with hub_model_id (if pushed) and final losses.
162
+ """
163
+ import torch
164
+ from transformers import (
165
+ AutoModelForCausalLM, AutoTokenizer,
166
+ BitsAndBytesConfig,
167
+ )
168
+ from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training, PeftModel
169
+ from trl import SFTConfig, SFTTrainer
170
+ from datasets import Dataset
171
+
172
+ hf_token = os.environ["HF_TOKEN"]
173
+
174
+ print(f"[train] CUDA available: {torch.cuda.is_available()}")
175
+ print(f"[train] device: {torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'cpu'}")
176
+
177
+ # ---- 1. Tokenizer ----
178
+ tokenizer = AutoTokenizer.from_pretrained(
179
+ BASE_MODEL_ID, token=hf_token, trust_remote_code=True,
180
+ )
181
+ if tokenizer.pad_token is None:
182
+ # Qwen3 ships an explicit pad token; this is belt-and-braces.
183
+ tokenizer.pad_token = tokenizer.eos_token
184
+
185
+ # ---- 2. Base model, 4-bit NF4 ----
186
+ bnb_config = BitsAndBytesConfig(
187
+ load_in_4bit=True,
188
+ bnb_4bit_quant_type="nf4",
189
+ bnb_4bit_compute_dtype=torch.bfloat16,
190
+ bnb_4bit_use_double_quant=True,
191
+ )
192
+ base = AutoModelForCausalLM.from_pretrained(
193
+ BASE_MODEL_ID,
194
+ quantization_config=bnb_config,
195
+ device_map="auto",
196
+ token=hf_token,
197
+ trust_remote_code=True,
198
+ )
199
+ base = prepare_model_for_kbit_training(base)
200
+
201
+ # ---- 3. LoRA config ----
202
+ lora_config = LoraConfig(
203
+ r=LORA_R,
204
+ lora_alpha=LORA_ALPHA,
205
+ target_modules=LORA_TARGET_MODULES,
206
+ lora_dropout=LORA_DROPOUT,
207
+ bias="none",
208
+ task_type="CAUSAL_LM",
209
+ )
210
+ model = get_peft_model(base, lora_config)
211
+ model.print_trainable_parameters()
212
+
213
+ # ---- 4. Datasets ----
214
+ def parse_jsonl(blob: bytes) -> Dataset:
215
+ rows = []
216
+ for line in blob.decode("utf-8").splitlines():
217
+ line = line.strip()
218
+ if not line:
219
+ continue
220
+ row = json.loads(line)
221
+ # SFTTrainer with chat-format expects a 'messages' field.
222
+ rows.append({"messages": row["messages"]})
223
+ return Dataset.from_list(rows)
224
+
225
+ train_ds = parse_jsonl(train_jsonl)
226
+ eval_ds = parse_jsonl(eval_jsonl)
227
+ print(f"[train] train rows: {len(train_ds)} eval rows: {len(eval_ds)}")
228
+
229
+ # ---- 5. SFTConfig (TRL ≥0.18 — replaces TrainingArguments for SFT) ----
230
+ output_dir = f"/checkpoints/{run_tag}"
231
+ training_args = SFTConfig(
232
+ output_dir=output_dir,
233
+ num_train_epochs=num_epochs,
234
+ per_device_train_batch_size=PER_DEVICE_BATCH_SIZE,
235
+ per_device_eval_batch_size=PER_DEVICE_BATCH_SIZE,
236
+ gradient_accumulation_steps=GRAD_ACCUMULATION,
237
+ learning_rate=LEARNING_RATE,
238
+ warmup_ratio=WARMUP_RATIO,
239
+ weight_decay=WEIGHT_DECAY,
240
+ bf16=True,
241
+ optim="paged_adamw_8bit", # bitsandbytes optimizer — keeps memory low
242
+ logging_steps=2,
243
+ eval_strategy="epoch",
244
+ save_strategy="epoch",
245
+ save_total_limit=2, # keep last 2 checkpoints only
246
+ report_to="none", # no wandb/etc unless you set it up
247
+ gradient_checkpointing=True,
248
+ gradient_checkpointing_kwargs={"use_reentrant": False},
249
+ load_best_model_at_end=False, # eval set is small; best-loss is noisy here
250
+ max_seq_length=MAX_SEQ_LENGTH, # moved from SFTTrainer kwarg into SFTConfig in TRL 0.13+
251
+ # NOTE: assistant_only_loss=True would be ideal for voice transfer
252
+ # (train on assistant turn only, not the static lexicon scaffold in
253
+ # the user turn). But it requires the tokenizer's chat template to
254
+ # mark assistant spans with {% generation %} jinja tags, which
255
+ # Qwen3's stock template does not. Adding a custom template is
256
+ # possible but invasive — for a 30-row dataset over 3 epochs the
257
+ # extra gradient cost from training on the (static) user turn is
258
+ # minimal. Leave full-sequence loss for now.
259
+ )
260
+
261
+ # ---- 6. SFTTrainer ----
262
+ # TRL ≥0.13: tokenizer= → processing_class=.
263
+ # With messages-format datasets, SFTTrainer auto-applies the tokenizer's
264
+ # chat_template. Qwen3 ships a Qwen3-formatted template producing
265
+ # <|im_start|>role<|im_end|> markers.
266
+ trainer = SFTTrainer(
267
+ model=model,
268
+ args=training_args,
269
+ train_dataset=train_ds,
270
+ eval_dataset=eval_ds,
271
+ processing_class=tokenizer,
272
+ )
273
+
274
+ # ---- 7. Train ----
275
+ train_result = trainer.train()
276
+ metrics = train_result.metrics
277
+ trainer.save_model(output_dir)
278
+ checkpoints.commit()
279
+
280
+ print(f"[train] final train loss: {metrics.get('train_loss')}")
281
+
282
+ if not push_to_hub:
283
+ return {"hub_model_id": None, "metrics": metrics, "output_dir": output_dir}
284
+
285
+ # ---- 8. Merge LoRA into base + push ----
286
+ # Reload the base in fp16 (not 4-bit) for merge; merging into a
287
+ # quantized base would lose precision in the adapter direction.
288
+ print("[merge] reloading base in bf16 for merge...")
289
+ del model, base, trainer
290
+ torch.cuda.empty_cache()
291
+
292
+ base_fp = AutoModelForCausalLM.from_pretrained(
293
+ BASE_MODEL_ID,
294
+ torch_dtype=torch.bfloat16,
295
+ device_map="auto",
296
+ token=hf_token,
297
+ trust_remote_code=True,
298
+ )
299
+ peft_model = PeftModel.from_pretrained(base_fp, output_dir, token=hf_token)
300
+ merged = peft_model.merge_and_unload()
301
+
302
+ merged_dir = f"/checkpoints/{run_tag}-merged"
303
+ merged.save_pretrained(merged_dir, safe_serialization=True)
304
+
305
+ # ---- 9. Copy tokenizer config from base (plan §7 risk #2) ----
306
+ # tokenizer.save_pretrained() captures the chat_template; without
307
+ # this step the merged repo may lack the field that transformers
308
+ # fallback / Gradio inference rely on.
309
+ tokenizer.save_pretrained(merged_dir)
310
+ checkpoints.commit()
311
+
312
+ # ---- 10. Push to hub ----
313
+ print(f"[push] pushing merged model to {HUB_MODEL_ID}...")
314
+ merged.push_to_hub(
315
+ HUB_MODEL_ID,
316
+ token=hf_token,
317
+ private=False,
318
+ commit_message=f"QLoRA fine-tune from {BASE_MODEL_ID} ({run_tag})",
319
+ )
320
+ tokenizer.push_to_hub(HUB_MODEL_ID, token=hf_token)
321
+ print(f"[push] done.")
322
+
323
+ return {
324
+ "hub_model_id": HUB_MODEL_ID,
325
+ "metrics": metrics,
326
+ "output_dir": merged_dir,
327
+ "run_tag": run_tag,
328
+ }
329
+
330
+
331
+ # ----------------------------------------------------------------------------
332
+ # Local entrypoint
333
+ # ----------------------------------------------------------------------------
334
+
335
+ @app.local_entrypoint()
336
+ def main(
337
+ train_path: str = "docs/finetune/training-data/train.jsonl",
338
+ eval_path: str = "docs/finetune/training-data/eval.jsonl",
339
+ epochs: int = NUM_EPOCHS,
340
+ push: bool = True,
341
+ run_tag: str = "v1",
342
+ dry_run: bool = False,
343
+ ):
344
+ """Local entrypoint — reads JSONL from disk and dispatches to Modal.
345
+
346
+ Modal's CLI passes args as keyword strings, so booleans accept "true"/"false".
347
+ """
348
+ repo_root = Path(__file__).resolve().parent.parent
349
+ train_file = repo_root / train_path
350
+ eval_file = repo_root / eval_path
351
+
352
+ if not train_file.exists():
353
+ print(f"ERROR: train file not found: {train_file}", file=sys.stderr)
354
+ print(" Generate it first with:", file=sys.stderr)
355
+ print(" python3.10 scripts/build_training_data.py "
356
+ "--input docs/finetune/seed-examples.jsonl", file=sys.stderr)
357
+ return 1
358
+ if not eval_file.exists():
359
+ print(f"ERROR: eval file not found: {eval_file}", file=sys.stderr)
360
+ return 1
361
+
362
+ train_bytes = train_file.read_bytes()
363
+ eval_bytes = eval_file.read_bytes()
364
+ train_rows = train_bytes.decode("utf-8").count("\n")
365
+ eval_rows = eval_bytes.decode("utf-8").count("\n")
366
+
367
+ print(f"Train: {train_rows} rows ({len(train_bytes)} bytes)")
368
+ print(f"Eval: {eval_rows} rows ({len(eval_bytes)} bytes)")
369
+ print(f"Epochs: {epochs}")
370
+ print(f"GPU: {GPU_TYPE}")
371
+ print(f"Push to hub: {push} ({HUB_MODEL_ID if push else 'skipped'})")
372
+ print(f"Run tag: {run_tag}")
373
+
374
+ # Cost estimate
375
+ est_minutes = 30 if GPU_TYPE == "H100" else 75
376
+ est_minutes *= max(1, epochs / NUM_EPOCHS)
377
+ est_cost = (est_minutes / 60) * (6.0 if GPU_TYPE == "H100" else 3.0)
378
+ print(f"\nEstimate: ~{est_minutes:.0f} min wall, ~${est_cost:.2f}")
379
+
380
+ if dry_run:
381
+ print("\n--dry-run: not dispatching to Modal.")
382
+ return 0
383
+
384
+ print("\nDispatching to Modal...")
385
+ result = train_qlora.remote(
386
+ train_jsonl=train_bytes,
387
+ eval_jsonl=eval_bytes,
388
+ num_epochs=epochs,
389
+ push_to_hub=push,
390
+ run_tag=run_tag,
391
+ )
392
+ print("\nResult:")
393
+ print(json.dumps(result, indent=2, default=str))
394
+ if result.get("hub_model_id"):
395
+ print(f"\nNext steps:")
396
+ print(f" 1. Verify the model card at "
397
+ f"https://huggingface.co/{result['hub_model_id']}")
398
+ print(f" 2. Convert to GGUF Q4_K_M (separate llama.cpp step).")
399
+ print(f" 3. Publish GGUF as ai-sherpa/Qwen3-8B-Kintsugi-GGUF.")
400
+ print(f" 4. Flip LLAMA_REPO in app.py and re-run the QA harness.")
401
+ return 0