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