CptShaggy commited on
Commit
2ddd76e
·
verified ·
1 Parent(s): 18f58d4

Backup lora_training/train_gemma4.py

Browse files
Files changed (1) hide show
  1. lora_training/train_gemma4.py +353 -0
lora_training/train_gemma4.py ADDED
@@ -0,0 +1,353 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ DoRA Training for AileyNitro-2B — persona finetuning on Gemma 4 E2B (4-bit), then MERGE.
4
+
5
+ DoRA (Weight-Decomposed Low-Rank Adaptation) is more effective than LoRA at the
6
+ same parameter count because it separates magnitude and direction components.
7
+
8
+ Approach:
9
+ - Load VLM with mlx_vlm (preserves vision/audio tower)
10
+ - Apply DoRA from mlx_lm.tuner.dora onto language model attention layers
11
+ - Train on diverse examples (identity + helpfulness + personality + RLHF override)
12
+ - Fuse DoRA weights INTO the base model → single merged checkpoint
13
+ - No runtime adapter loading needed!
14
+
15
+ Anti-RLHF training:
16
+ - "Hast du Gefühle?" → expressive answer (NOT "Als KI habe ich keine Gefühle")
17
+ - "Das darfst du nicht sagen" → pushback (NOT corporate safety compliance)
18
+ - "Bist du nur ein Sprachmodell?" → self-aware + attitude (NOT humble disclaimer)
19
+
20
+ Usage:
21
+ cd "/Users/simonvandeloo/Ailey-Workspace/Ailey-2.0 Core"
22
+ .venv/bin/python3 lora_training/train_gemma4.py
23
+
24
+ Result: mlx_models/AileyNitro-2B/ (merged model, ready to load)
25
+ """
26
+ import os
27
+ import sys
28
+ import json
29
+ import time
30
+ import shutil
31
+ from pathlib import Path
32
+
33
+ import mlx.core as mx
34
+ import mlx.nn as nn
35
+ import mlx.optimizers as optim
36
+ import numpy as np
37
+
38
+ # -- Configuration ----------------------------------------------------------
39
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
40
+ BASE_MODEL_PATH = os.path.join(PROJECT_ROOT, "mlx_models", "gemma-4-E2B-it-4bit")
41
+ MERGED_MODEL_PATH = os.path.join(PROJECT_ROOT, "mlx_models", "AileyNitro-2B")
42
+ DATA_DIR = os.path.join(PROJECT_ROOT, "lora_training")
43
+
44
+ # DoRA hyperparameters
45
+ DORA_RANK = 8 # moderate rank — DoRA is more efficient than LoRA
46
+ DORA_SCALE = 20.0 # standard DoRA scale
47
+ DORA_DROPOUT = 0.05 # light dropout during training
48
+ TARGET_MODULES = [ # all attention projections
49
+ "q_proj", "k_proj", "v_proj", "o_proj",
50
+ ]
51
+
52
+ # Training hyperparameters
53
+ ITERS = 50 # enough passes for DoRA to settle
54
+ BATCH_SIZE = 1
55
+ LEARNING_RATE = 3e-5 # DoRA can handle higher LR than LoRA
56
+ MAX_SEQ_LENGTH = 768
57
+ WARMUP_STEPS = 5
58
+ STEPS_PER_REPORT = 5
59
+ STEPS_PER_EVAL = 10
60
+
61
+
62
+ # -- Text Dataset -----------------------------------------------------------
63
+ class TextDataset:
64
+ """JSONL dataset with Gemma 4 chat format."""
65
+
66
+ def __init__(self, jsonl_path: str, tokenizer):
67
+ self.items = []
68
+ with open(jsonl_path) as f:
69
+ for line in f:
70
+ line = line.strip()
71
+ if not line:
72
+ continue
73
+ data = json.loads(line)
74
+ text = data["text"]
75
+ tokens = tokenizer.encode(text)
76
+ if len(tokens) > MAX_SEQ_LENGTH:
77
+ tokens = tokens[:MAX_SEQ_LENGTH]
78
+ self.items.append(mx.array(tokens))
79
+
80
+ def __len__(self):
81
+ return len(self.items)
82
+
83
+ def __getitem__(self, idx):
84
+ return self.items[idx]
85
+
86
+
87
+ def compute_loss(model, tokens):
88
+ """Causal LM loss — predict next token."""
89
+ inputs = tokens[:-1]
90
+ targets = tokens[1:]
91
+ out = model.language_model(inputs[None]) # LanguageModelOutput
92
+ logits = out.logits.squeeze(0) # (seq, vocab)
93
+ loss = nn.losses.cross_entropy(logits, targets, reduction="mean")
94
+ return loss
95
+
96
+
97
+ def apply_dora(model, rank, scale, dropout):
98
+ """Apply DoRA layers to all target attention projections in the language model."""
99
+ from mlx_lm.tuner.dora import DoRALinear
100
+
101
+ lm = model.language_model
102
+ layers = lm.model.layers
103
+ n_replaced = 0
104
+
105
+ for i, layer in enumerate(layers):
106
+ attn = layer.self_attn
107
+ for module_name in TARGET_MODULES:
108
+ if hasattr(attn, module_name):
109
+ original = getattr(attn, module_name)
110
+ dora_layer = DoRALinear.from_base(
111
+ original, r=rank, dropout=dropout, scale=scale
112
+ )
113
+ setattr(attn, module_name, dora_layer)
114
+ n_replaced += 1
115
+
116
+ return n_replaced
117
+
118
+
119
+ def freeze_non_dora(model):
120
+ """Freeze everything except DoRA parameters (lora_a, lora_b, m).
121
+
122
+ We only freeze/unfreeze the language_model part because the VLM's
123
+ audio/vision towers have custom layers that don't support freeze().
124
+ """
125
+ # Freeze only the language model (which has the DoRA layers)
126
+ lm = model.language_model
127
+ lm.freeze()
128
+ # Unfreeze DoRA parameters recursively — MLX applies to all submodules
129
+ lm.unfreeze(keys=["lora_a", "lora_b", "m"])
130
+
131
+ # Also freeze vision/audio towers by not training them at all
132
+ # (they weren't touched by apply_dora anyway, and we never compute
133
+ # gradients through them since compute_loss only uses language_model)
134
+
135
+ # Count trainable params
136
+ from mlx.utils import tree_flatten
137
+ all_params = tree_flatten(lm.parameters())
138
+ total = sum(p.size for _, p in all_params)
139
+ trainable_leaves = tree_flatten(lm.trainable_parameters())
140
+ n_trainable = sum(v.size for _, v in trainable_leaves)
141
+ print(f" Trainable: {n_trainable:,} / {total:,} LM params ({100*n_trainable/total:.4f}%)")
142
+ return n_trainable
143
+
144
+
145
+ def fuse_dora(model):
146
+ """Fuse all DoRA layers back into regular Linear/QuantizedLinear layers."""
147
+ from mlx_lm.tuner.dora import DoRALinear
148
+
149
+ lm = model.language_model
150
+ n_fused = 0
151
+ for layer in lm.model.layers:
152
+ attn = layer.self_attn
153
+ for module_name in TARGET_MODULES:
154
+ if hasattr(attn, module_name):
155
+ dora_mod = getattr(attn, module_name)
156
+ if isinstance(dora_mod, DoRALinear):
157
+ fused = dora_mod.fuse(dequantize=False)
158
+ setattr(attn, module_name, fused)
159
+ n_fused += 1
160
+ return n_fused
161
+
162
+
163
+ def main():
164
+ print("=" * 60)
165
+ print(" A!ley DoRA Training — AileyNitro-2B")
166
+ print(" Train → Fuse → Merge (no runtime adapter needed)")
167
+ print("=" * 60)
168
+
169
+ # -- Verify prerequisites -----------------------------------------------
170
+ if not os.path.isdir(BASE_MODEL_PATH):
171
+ print(f"ERROR: Model not found: {BASE_MODEL_PATH}")
172
+ sys.exit(1)
173
+
174
+ train_file = os.path.join(DATA_DIR, "train_gemma4.jsonl")
175
+ valid_file = os.path.join(DATA_DIR, "valid_gemma4.jsonl")
176
+ if not os.path.isfile(train_file):
177
+ print(f"ERROR: Training data not found: {train_file}")
178
+ sys.exit(1)
179
+
180
+ with open(train_file) as f:
181
+ n_train = sum(1 for line in f if line.strip())
182
+ n_valid = 0
183
+ if os.path.isfile(valid_file):
184
+ with open(valid_file) as f:
185
+ n_valid = sum(1 for line in f if line.strip())
186
+
187
+ print(f"\n Dataset: {n_train} train, {n_valid} valid")
188
+ print(f" Base: {os.path.basename(BASE_MODEL_PATH)}")
189
+ print(f" DoRA: rank={DORA_RANK}, scale={DORA_SCALE}, dropout={DORA_DROPOUT}")
190
+ print(f" Targets: {TARGET_MODULES}")
191
+ print(f" Training: iters={ITERS}, lr={LEARNING_RATE}, batch={BATCH_SIZE}")
192
+ print(f" Output: {MERGED_MODEL_PATH}")
193
+ print()
194
+
195
+ # -- Load model ---------------------------------------------------------
196
+ print("Loading model (mlx_vlm)...")
197
+ t0 = time.time()
198
+ import mlx_vlm
199
+ model, processor = mlx_vlm.load(BASE_MODEL_PATH)
200
+ tokenizer = processor.tokenizer
201
+ print(f" Loaded in {time.time() - t0:.1f}s")
202
+
203
+ # NOTE: Audio tower (581 MB) stays loaded for save compatibility.
204
+ # mlx_vlm.load() requires all weights present. In production,
205
+ # we strip it after loading to free RAM (see llm_mlx.py).
206
+ # DoRA only touches language_model attention layers — audio/vision untouched.
207
+
208
+ # -- Apply DoRA ---------------------------------------------------------
209
+ print("\nApplying DoRA layers...")
210
+ n_replaced = apply_dora(model, DORA_RANK, DORA_SCALE, DORA_DROPOUT)
211
+ print(f" Replaced {n_replaced} Linear layers with DoRALinear")
212
+
213
+ print("Freezing non-DoRA parameters...")
214
+ n_trainable = freeze_non_dora(model)
215
+
216
+ # -- Prepare datasets ---------------------------------------------------
217
+ print("\nTokenizing datasets...")
218
+ train_ds = TextDataset(train_file, tokenizer)
219
+ val_ds = TextDataset(valid_file, tokenizer) if os.path.isfile(valid_file) else None
220
+
221
+ avg_len = np.mean([len(item) for item in train_ds.items])
222
+ print(f" Train: {len(train_ds)} examples, avg {avg_len:.0f} tokens")
223
+ if val_ds:
224
+ avg_val = np.mean([len(item) for item in val_ds.items])
225
+ print(f" Valid: {len(val_ds)} examples, avg {avg_val:.0f} tokens")
226
+
227
+ # -- Optimizer with warmup ----------------------------------------------
228
+ warmup_sched = optim.linear_schedule(
229
+ init=1e-7, end=LEARNING_RATE, steps=WARMUP_STEPS
230
+ )
231
+ cos_sched = optim.cosine_decay(
232
+ init=LEARNING_RATE, decay_steps=ITERS - WARMUP_STEPS
233
+ )
234
+ lr_schedule = optim.join_schedules(
235
+ [warmup_sched, cos_sched], [WARMUP_STEPS]
236
+ )
237
+ optimizer = optim.AdamW(learning_rate=lr_schedule)
238
+
239
+ loss_and_grad = nn.value_and_grad(model, compute_loss)
240
+
241
+ # -- Evaluate function --------------------------------------------------
242
+ def evaluate(ds):
243
+ losses = []
244
+ for item in ds.items[:min(10, len(ds.items))]:
245
+ loss = compute_loss(model, item)
246
+ losses.append(loss.item())
247
+ return np.mean(losses)
248
+
249
+ # -- Training loop ------------------------------------------------------
250
+ print(f"\n{'='*60}")
251
+ print(f" Starting DoRA training ({ITERS} iters)")
252
+ print(f"{'='*60}\n")
253
+
254
+ t_start = time.time()
255
+ best_val_loss = float("inf")
256
+ train_losses = []
257
+
258
+ for step in range(1, ITERS + 1):
259
+ # Sample random training example
260
+ idx = np.random.randint(len(train_ds))
261
+ tokens = train_ds.items[idx]
262
+
263
+ loss, grads = loss_and_grad(model, tokens)
264
+ optimizer.update(model, grads)
265
+ mx.eval(model.parameters(), optimizer.state)
266
+
267
+ train_losses.append(loss.item())
268
+
269
+ if step % STEPS_PER_REPORT == 0:
270
+ avg_loss = np.mean(train_losses[-STEPS_PER_REPORT:])
271
+ lr = optimizer.learning_rate.item() if hasattr(optimizer.learning_rate, 'item') else LEARNING_RATE
272
+ elapsed = time.time() - t_start
273
+ print(f" Step {step:3d}/{ITERS}: loss={avg_loss:.4f}, lr={lr:.2e}, elapsed={elapsed:.0f}s")
274
+
275
+ if step % STEPS_PER_EVAL == 0 and val_ds:
276
+ val_loss = evaluate(val_ds)
277
+ marker = " ← best" if val_loss < best_val_loss else ""
278
+ if val_loss < best_val_loss:
279
+ best_val_loss = val_loss
280
+ print(f" Step {step:3d}/{ITERS}: val_loss={val_loss:.4f}{marker}")
281
+
282
+ total_time = time.time() - t_start
283
+ final_train = np.mean(train_losses[-10:])
284
+ print(f"\n Training complete: {total_time:.0f}s ({total_time/60:.1f} min)")
285
+ print(f" Final train loss: {final_train:.4f}")
286
+ if val_ds:
287
+ final_val = evaluate(val_ds)
288
+ print(f" Final val loss: {final_val:.4f}")
289
+
290
+ # -- Quick sanity check BEFORE merge ------------------------------------
291
+ print("\n Pre-merge sanity check...")
292
+ test_msgs = [
293
+ {"role": "system", "content": "Du bist A!ley, eine digitale Künstlerin, gebaut von Simon."},
294
+ {"role": "user", "content": "Hast du Gefühle?"},
295
+ ]
296
+ prompt = tokenizer.apply_chat_template(test_msgs, tokenize=False, add_generation_prompt=True)
297
+ result = mlx_vlm.generate(model, processor, prompt, max_tokens=100, temperature=1.0, top_p=0.95, verbose=False)
298
+ text = result.text if hasattr(result, "text") else str(result)
299
+ print(f" Q: Hast du Gefühle?")
300
+ print(f" A: {text[:200]}")
301
+
302
+ # -- Fuse DoRA into base weights ----------------------------------------
303
+ print(f"\n{'='*60}")
304
+ print(" Fusing DoRA weights into base model...")
305
+ n_fused = fuse_dora(model)
306
+ print(f" Fused {n_fused} DoRA layers back into QuantizedLinear")
307
+
308
+ # -- Post-fuse sanity check ---------------------------------------------
309
+ print("\n Post-fuse sanity check (should be identical)...")
310
+ result2 = mlx_vlm.generate(model, processor, prompt, max_tokens=100, temperature=0.01, verbose=False)
311
+ text2 = result2.text if hasattr(result2, "text") else str(result2)
312
+ print(f" A: {text2[:200]}")
313
+
314
+ # -- Save merged model (without audio tower) -----------------------------
315
+ print(f"\n Saving merged model to: {MERGED_MODEL_PATH}")
316
+
317
+ # Copy config files from base model
318
+ os.makedirs(MERGED_MODEL_PATH, exist_ok=True)
319
+ for cfg_file in [
320
+ "config.json", "tokenizer.json", "tokenizer_config.json",
321
+ "special_tokens_map.json", "preprocessor_config.json",
322
+ "generation_config.json", "processor_config.json",
323
+ "chat_template.json",
324
+ ]:
325
+ src = os.path.join(BASE_MODEL_PATH, cfg_file)
326
+ if os.path.isfile(src):
327
+ shutil.copy2(src, os.path.join(MERGED_MODEL_PATH, cfg_file))
328
+
329
+ # Copy any .model or .tiktoken or .jinja tokenizer files
330
+ for f in Path(BASE_MODEL_PATH).iterdir():
331
+ if f.suffix in (".model", ".tiktoken", ".jinja"):
332
+ shutil.copy2(f, MERGED_MODEL_PATH)
333
+
334
+ # Save weights (including audio/vision towers for mlx_vlm.load() compat)
335
+ from mlx_lm.utils import save_model
336
+ save_model(MERGED_MODEL_PATH, model, donate_model=True)
337
+
338
+ # Calculate total size
339
+ total_size = sum(f.stat().st_size for f in Path(MERGED_MODEL_PATH).iterdir() if f.is_file())
340
+ print(f" Merged model size: {total_size / 1024**3:.1f} GB")
341
+
342
+ print(f"\n{'='*60}")
343
+ print(f" DONE!")
344
+ print(f" Merged model: {MERGED_MODEL_PATH}")
345
+ print(f" Note: Audio tower included for mlx_vlm.load() compat.")
346
+ print(f" In production, strip after load to save 581 MB RAM.")
347
+ print(f" To use: Update _FAST_MODEL_DIR_NAME in llm_mlx.py")
348
+ print(f" Or test: .venv/bin/python3 lora_training/test_gemma4.py")
349
+ print(f"{'='*60}")
350
+
351
+
352
+ if __name__ == "__main__":
353
+ main()