400 lines
13 KiB
Python
400 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""LoRA fine-tuning launcher for Hermes 4 on Timmy trajectory data.
|
|
|
|
Wraps ``mlx_lm.lora`` with project-specific defaults and pre-flight checks.
|
|
Requires Apple Silicon (M-series) and the ``mlx-lm`` package.
|
|
|
|
Usage::
|
|
|
|
# Minimal — uses defaults (expects data in ~/timmy-lora-training/)
|
|
python scripts/lora_finetune.py
|
|
|
|
# Custom model path and data
|
|
python scripts/lora_finetune.py \\
|
|
--model /path/to/hermes4-mlx \\
|
|
--data ~/timmy-lora-training \\
|
|
--iters 500 \\
|
|
--adapter-path ~/timmy-lora-adapter
|
|
|
|
# Dry run (print command, don't execute)
|
|
python scripts/lora_finetune.py --dry-run
|
|
|
|
# After training, test with the adapter
|
|
python scripts/lora_finetune.py --test \\
|
|
--prompt "List the open PRs on the Timmy Time Dashboard repo"
|
|
|
|
# Fuse adapter into base model for Ollama import
|
|
python scripts/lora_finetune.py --fuse \\
|
|
--save-path ~/timmy-fused-model
|
|
|
|
Typical workflow::
|
|
|
|
# 1. Export trajectories
|
|
python scripts/export_trajectories.py --verbose
|
|
|
|
# 2. Prepare training dir
|
|
mkdir -p ~/timmy-lora-training
|
|
cp ~/timmy-training-data.jsonl ~/timmy-lora-training/train.jsonl
|
|
|
|
# 3. Fine-tune
|
|
python scripts/lora_finetune.py --verbose
|
|
|
|
# 4. Test
|
|
python scripts/lora_finetune.py --test
|
|
|
|
# 5. Fuse + import to Ollama
|
|
python scripts/lora_finetune.py --fuse
|
|
ollama create timmy-hermes4 -f Modelfile.timmy-hermes4
|
|
|
|
Epic: #1091 Project Bannerlord — AutoLoRA Sovereignty Loop (Step 4 of 7)
|
|
Refs: #1103
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# ── Defaults ──────────────────────────────────────────────────────────────────
|
|
|
|
DEFAULT_DATA_DIR = Path.home() / "timmy-lora-training"
|
|
DEFAULT_ADAPTER_PATH = Path.home() / "timmy-lora-adapter"
|
|
DEFAULT_FUSED_PATH = Path.home() / "timmy-fused-model"
|
|
|
|
# mlx-lm model path — local HuggingFace checkout of Hermes 4 in MLX format.
|
|
# Set MLX_HERMES4_PATH env var or pass --model to override.
|
|
DEFAULT_MODEL_PATH_ENV = "MLX_HERMES4_PATH"
|
|
|
|
# Training hyperparameters (conservative for 36 GB M3 Max)
|
|
DEFAULT_BATCH_SIZE = 1
|
|
DEFAULT_LORA_LAYERS = 16
|
|
DEFAULT_ITERS = 1000
|
|
DEFAULT_LEARNING_RATE = 1e-5
|
|
|
|
# Test prompt used after training
|
|
DEFAULT_TEST_PROMPT = (
|
|
"List the open PRs on the Timmy Time Dashboard repo and triage them by priority."
|
|
)
|
|
|
|
|
|
# ── Pre-flight checks ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def _check_apple_silicon() -> bool:
|
|
"""Return True if running on Apple Silicon."""
|
|
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
|
|
|
|
|
def _check_mlx_lm() -> bool:
|
|
"""Return True if mlx-lm is installed and mlx_lm.lora is runnable."""
|
|
return shutil.which("mlx_lm.lora") is not None or _can_import("mlx_lm")
|
|
|
|
|
|
def _can_import(module: str) -> bool:
|
|
try:
|
|
import importlib
|
|
|
|
importlib.import_module(module)
|
|
return True
|
|
except ImportError:
|
|
return False
|
|
|
|
|
|
def _resolve_model_path(model_arg: str | None) -> str | None:
|
|
"""Resolve model path from arg or environment variable."""
|
|
if model_arg:
|
|
return model_arg
|
|
import os
|
|
|
|
env_path = os.environ.get(DEFAULT_MODEL_PATH_ENV)
|
|
if env_path:
|
|
return env_path
|
|
return None
|
|
|
|
|
|
def _preflight(model_path: str | None, data_dir: Path, verbose: bool) -> list[str]:
|
|
"""Run pre-flight checks and return a list of warnings (empty = all OK)."""
|
|
warnings: list[str] = []
|
|
|
|
if not _check_apple_silicon():
|
|
warnings.append(
|
|
"Not running on Apple Silicon. mlx-lm requires an M-series Mac.\n"
|
|
" Alternative: use Unsloth on Google Colab / RunPod / Modal."
|
|
)
|
|
|
|
if not _check_mlx_lm():
|
|
warnings.append(
|
|
"mlx-lm not found. Install with:\n pip install mlx-lm"
|
|
)
|
|
|
|
if model_path is None:
|
|
warnings.append(
|
|
f"No model path specified. Set {DEFAULT_MODEL_PATH_ENV} or pass --model.\n"
|
|
" Download Hermes 4 in MLX format from HuggingFace:\n"
|
|
" https://huggingface.co/collections/NousResearch/hermes-4-collection-68a7\n"
|
|
" or convert the GGUF:\n"
|
|
" mlx_lm.convert --hf-path NousResearch/Hermes-4-14B --mlx-path ~/hermes4-mlx"
|
|
)
|
|
elif not Path(model_path).exists():
|
|
warnings.append(f"Model path does not exist: {model_path}")
|
|
|
|
train_file = data_dir / "train.jsonl"
|
|
if not train_file.exists():
|
|
warnings.append(
|
|
f"Training data not found: {train_file}\n"
|
|
" Generate it with:\n"
|
|
" python scripts/export_trajectories.py --verbose\n"
|
|
f" mkdir -p {data_dir}\n"
|
|
f" cp ~/timmy-training-data.jsonl {train_file}"
|
|
)
|
|
|
|
if verbose and not warnings:
|
|
print("Pre-flight checks: all OK")
|
|
|
|
return warnings
|
|
|
|
|
|
# ── Command builders ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def _build_train_cmd(
|
|
model_path: str,
|
|
data_dir: Path,
|
|
adapter_path: Path,
|
|
batch_size: int,
|
|
lora_layers: int,
|
|
iters: int,
|
|
learning_rate: float,
|
|
) -> list[str]:
|
|
return [
|
|
sys.executable, "-m", "mlx_lm.lora",
|
|
"--model", model_path,
|
|
"--train",
|
|
"--data", str(data_dir),
|
|
"--batch-size", str(batch_size),
|
|
"--lora-layers", str(lora_layers),
|
|
"--iters", str(iters),
|
|
"--learning-rate", str(learning_rate),
|
|
"--adapter-path", str(adapter_path),
|
|
]
|
|
|
|
|
|
def _build_test_cmd(
|
|
model_path: str,
|
|
adapter_path: Path,
|
|
prompt: str,
|
|
) -> list[str]:
|
|
return [
|
|
sys.executable, "-m", "mlx_lm.generate",
|
|
"--model", model_path,
|
|
"--adapter-path", str(adapter_path),
|
|
"--prompt", prompt,
|
|
"--max-tokens", "512",
|
|
]
|
|
|
|
|
|
def _build_fuse_cmd(
|
|
model_path: str,
|
|
adapter_path: Path,
|
|
save_path: Path,
|
|
) -> list[str]:
|
|
return [
|
|
sys.executable, "-m", "mlx_lm.fuse",
|
|
"--model", model_path,
|
|
"--adapter-path", str(adapter_path),
|
|
"--save-path", str(save_path),
|
|
]
|
|
|
|
|
|
# ── Runner ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _run(cmd: list[str], dry_run: bool, verbose: bool) -> int:
|
|
"""Print and optionally execute a command."""
|
|
print("\nCommand:")
|
|
print(" " + " \\\n ".join(cmd))
|
|
if dry_run:
|
|
print("\n(dry-run — not executing)")
|
|
return 0
|
|
|
|
print()
|
|
result = subprocess.run(cmd)
|
|
return result.returncode
|
|
|
|
|
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="LoRA fine-tuning launcher for Hermes 4 (AutoLoRA Step 4)",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog=__doc__,
|
|
)
|
|
|
|
# Mode flags (mutually exclusive-ish)
|
|
mode = parser.add_mutually_exclusive_group()
|
|
mode.add_argument(
|
|
"--test",
|
|
action="store_true",
|
|
help="Run inference test with trained adapter instead of training",
|
|
)
|
|
mode.add_argument(
|
|
"--fuse",
|
|
action="store_true",
|
|
help="Fuse adapter into base model (for Ollama import)",
|
|
)
|
|
|
|
# Paths
|
|
parser.add_argument(
|
|
"--model",
|
|
default=None,
|
|
help=f"Path to local MLX model (or set {DEFAULT_MODEL_PATH_ENV} env var)",
|
|
)
|
|
parser.add_argument(
|
|
"--data",
|
|
type=Path,
|
|
default=DEFAULT_DATA_DIR,
|
|
help=f"Training data directory (default: {DEFAULT_DATA_DIR})",
|
|
)
|
|
parser.add_argument(
|
|
"--adapter-path",
|
|
type=Path,
|
|
default=DEFAULT_ADAPTER_PATH,
|
|
help=f"LoRA adapter output path (default: {DEFAULT_ADAPTER_PATH})",
|
|
)
|
|
parser.add_argument(
|
|
"--save-path",
|
|
type=Path,
|
|
default=DEFAULT_FUSED_PATH,
|
|
help=f"Fused model output path (default: {DEFAULT_FUSED_PATH})",
|
|
)
|
|
|
|
# Hyperparameters
|
|
parser.add_argument(
|
|
"--batch-size",
|
|
type=int,
|
|
default=DEFAULT_BATCH_SIZE,
|
|
help=f"Training batch size (default: {DEFAULT_BATCH_SIZE}; reduce to 1 if OOM)",
|
|
)
|
|
parser.add_argument(
|
|
"--lora-layers",
|
|
type=int,
|
|
default=DEFAULT_LORA_LAYERS,
|
|
help=f"Number of LoRA layers (default: {DEFAULT_LORA_LAYERS}; reduce if OOM)",
|
|
)
|
|
parser.add_argument(
|
|
"--iters",
|
|
type=int,
|
|
default=DEFAULT_ITERS,
|
|
help=f"Training iterations (default: {DEFAULT_ITERS})",
|
|
)
|
|
parser.add_argument(
|
|
"--learning-rate",
|
|
type=float,
|
|
default=DEFAULT_LEARNING_RATE,
|
|
help=f"Learning rate (default: {DEFAULT_LEARNING_RATE})",
|
|
)
|
|
|
|
# Misc
|
|
parser.add_argument(
|
|
"--prompt",
|
|
default=DEFAULT_TEST_PROMPT,
|
|
help="Prompt for --test mode",
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Print command without executing",
|
|
)
|
|
parser.add_argument(
|
|
"--verbose",
|
|
"-v",
|
|
action="store_true",
|
|
help="Print extra progress information",
|
|
)
|
|
parser.add_argument(
|
|
"--skip-preflight",
|
|
action="store_true",
|
|
help="Skip pre-flight checks (useful in CI)",
|
|
)
|
|
|
|
args = parser.parse_args(argv)
|
|
model_path = _resolve_model_path(args.model)
|
|
|
|
# ── Pre-flight ──────────────────────────────────────────────────────────
|
|
if not args.skip_preflight:
|
|
warnings = _preflight(model_path, args.data, args.verbose)
|
|
if warnings:
|
|
for w in warnings:
|
|
print(f"WARNING: {w}\n")
|
|
if not args.dry_run:
|
|
print("Aborting due to pre-flight warnings. Use --dry-run to see commands anyway.")
|
|
return 1
|
|
|
|
if model_path is None:
|
|
# Allow dry-run without a model for documentation purposes
|
|
model_path = "<path-to-hermes4-mlx>"
|
|
|
|
# ── Mode dispatch ────────────────────────────────────────────────────────
|
|
if args.test:
|
|
print(f"Testing fine-tuned model with adapter: {args.adapter_path}")
|
|
cmd = _build_test_cmd(model_path, args.adapter_path, args.prompt)
|
|
return _run(cmd, args.dry_run, args.verbose)
|
|
|
|
if args.fuse:
|
|
print(f"Fusing adapter {args.adapter_path} into base model → {args.save_path}")
|
|
cmd = _build_fuse_cmd(model_path, args.adapter_path, args.save_path)
|
|
rc = _run(cmd, args.dry_run, args.verbose)
|
|
if rc == 0 and not args.dry_run:
|
|
print(
|
|
f"\nFused model saved to: {args.save_path}\n"
|
|
"To import into Ollama:\n"
|
|
f" ollama create timmy-hermes4 -f Modelfile.hermes4-14b\n"
|
|
" (edit Modelfile to point FROM to the fused GGUF path)"
|
|
)
|
|
return rc
|
|
|
|
# Default: train
|
|
print(f"Starting LoRA fine-tuning")
|
|
print(f" Model: {model_path}")
|
|
print(f" Data: {args.data}")
|
|
print(f" Adapter path: {args.adapter_path}")
|
|
print(f" Iterations: {args.iters}")
|
|
print(f" Batch size: {args.batch_size}")
|
|
print(f" LoRA layers: {args.lora_layers}")
|
|
print(f" Learning rate:{args.learning_rate}")
|
|
print()
|
|
print("Estimated time: 2-8 hours on M3 Max (depends on dataset size).")
|
|
print("If OOM: reduce --lora-layers to 8 or --batch-size stays at 1.")
|
|
|
|
cmd = _build_train_cmd(
|
|
model_path=model_path,
|
|
data_dir=args.data,
|
|
adapter_path=args.adapter_path,
|
|
batch_size=args.batch_size,
|
|
lora_layers=args.lora_layers,
|
|
iters=args.iters,
|
|
learning_rate=args.learning_rate,
|
|
)
|
|
rc = _run(cmd, args.dry_run, args.verbose)
|
|
|
|
if rc == 0 and not args.dry_run:
|
|
print(
|
|
f"\nTraining complete! Adapter saved to: {args.adapter_path}\n"
|
|
"Test with:\n"
|
|
f" python scripts/lora_finetune.py --test\n"
|
|
"Then fuse + import to Ollama:\n"
|
|
f" python scripts/lora_finetune.py --fuse"
|
|
)
|
|
|
|
return rc
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|