# Copyright © 2024 Apple Inc. import argparse import math import os import re import types from pathlib import Path import mlx.core as mx import mlx.nn as nn import mlx.optimizers as optim import numpy as np import yaml from tqdm import tqdm from .cli_ui import make_console, print_lora_run_header, rprint from .tuner.callbacks import get_reporting_callbacks from .tuner.datasets import CacheDataset, load_dataset from .tuner.trainer import TrainingArgs, TrainingCallback, evaluate, train from .tuner.utils import ( build_schedule, linear_to_lora_layers, load_adapters, print_trainable_parameters, ) from .utils import _parse_size, load, save_config yaml_loader = yaml.SafeLoader yaml_loader.add_implicit_resolver( "tag:yaml.org,2002:float", re.compile( """^(?: [-+]?(?:[0-9][0-9_]*)\\.[0-9_]*(?:[eE][-+]?[0-9]+)? |[-+]?(?:[0-9][0-9_]*)(?:[eE][-+]?[0-9]+) |\\.[0-9_]+(?:[eE][-+][0-9]+)? |[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\\.[0-9_]* |[-+]?\\.(?:inf|Inf|INF) |\\.(?:nan|NaN|NAN))$""", re.X, ), list("-+0123456789."), ) CONFIG_DEFAULTS = { "model": "Qwen/Qwen3-0.6b", "train": False, "fine_tune_type": "lora", "optimizer": "adam", "optimizer_config": { "adam": {}, "adamw": {}, "muon": {}, "sgd": {}, "adafactor": {}, }, "data": "mlx-community/WikiSQL", "seed": 0, "num_layers": 16, "batch_size": 4, "iters": 1000, "val_batches": 25, "learning_rate": 1e-5, "steps_per_report": 10, "steps_per_eval": 200, "resume_adapter_file": None, "adapter_path": "adapters", "save_every": 100, "test": False, "test_batches": 500, "max_seq_length": 2048, "config": None, "grad_checkpoint": False, "grad_accumulation_steps": 1, "clear_cache_threshold": 0, "lr_schedule": None, "lora_parameters": {"rank": 8, "dropout": 0.0, "scale": 20.0}, "mask_prompt": False, "report_to": None, "project_name": None, "trust_remote_code": False, } def build_parser(): parser = argparse.ArgumentParser(description="LoRA or QLoRA finetuning.") parser.add_argument( "--model", type=str, help="The path to the local model directory or Hugging Face repo.", ) # Training args parser.add_argument( "--train", action="store_true", help="Do training", default=None, ) parser.add_argument( "--data", type=str, help=( "Directory with {train, valid, test}.jsonl files or the name " "of a Hugging Face dataset (e.g., 'mlx-community/wikisql')" ), ) parser.add_argument( "--fine-tune-type", type=str, choices=["lora", "dora", "full"], help="Type of fine-tuning to perform: lora, dora, or full.", ) parser.add_argument( "--optimizer", type=str, choices=["adam", "adamw", "muon", "sgd", "adafactor"], default=None, help="Optimizer to use for training: adam, adamw, sgd, or adafactor.", ) parser.add_argument( "--mask-prompt", action="store_true", help="Mask the prompt in the loss when training", default=None, ) parser.add_argument( "--num-layers", type=int, help="Number of layers to fine-tune. Default is 16, use -1 for all.", ) parser.add_argument("--batch-size", type=int, help="Minibatch size.") parser.add_argument("--iters", type=int, help="Iterations to train for.") parser.add_argument( "--val-batches", type=int, help="Number of validation batches, -1 uses the entire validation set.", ) parser.add_argument("--learning-rate", type=float, help="Adam learning rate.") parser.add_argument( "--steps-per-report", type=int, help="Number of training steps between loss reporting.", ) parser.add_argument( "--steps-per-eval", type=int, help="Number of training steps between validations.", ) parser.add_argument( "--grad-accumulation-steps", type=int, help="Number of steps to accumulate before each optimizer update.", ) parser.add_argument( "--resume-adapter-file", type=str, help="Load path to resume training from the given fine-tuned weights.", ) parser.add_argument( "--adapter-path", type=str, help="Save/load path for the fine-tuned weights.", ) parser.add_argument( "--save-every", type=int, help="Save the model every N iterations.", ) parser.add_argument( "--test", action="store_true", help="Evaluate on the test set after training", default=None, ) parser.add_argument( "--test-batches", type=int, help="Number of test set batches, -1 uses the entire test set.", ) parser.add_argument( "--max-seq-length", type=int, help="Maximum sequence length.", ) parser.add_argument( "-c", "--config", type=str, help="A YAML configuration file with the training options", ) parser.add_argument( "--grad-checkpoint", action="store_true", help="Use gradient checkpointing to reduce memory use.", default=None, ) parser.add_argument( "--clear-cache-threshold", type=_parse_size, default=0, help="Clear the allocator cache between steps if it grows too large.", ) parser.add_argument( "--report-to", type=str, default=None, help="Services to report logs to ('wandb', 'swanlab', or 'wandb,swanlab').", ) parser.add_argument( "--project-name", type=str, default=None, help="Project name for logging. Defaults to the name of the root directory.", ) parser.add_argument("--seed", type=int, help="The PRNG seed") parser.add_argument( "--trust-remote-code", action="store_true", help="Enable trusting remote code for tokenizer/model loading.", ) return parser def train_model( args, model: nn.Module, train_set, valid_set, training_callback: TrainingCallback = None, ): mx.random.seed(args.seed) model.freeze() if args.num_layers > len(model.layers): raise ValueError( f"Requested to train {args.num_layers} layers " f"but the model only has {len(model.layers)} layers." ) if args.fine_tune_type == "full": for l in model.layers[-max(args.num_layers, 0) :]: l.unfreeze() args.lora_parameters = None elif args.fine_tune_type in ["lora", "dora"]: # Convert linear layers to lora/dora layers and unfreeze in the process linear_to_lora_layers( model, args.num_layers, args.lora_parameters, use_dora=(args.fine_tune_type == "dora"), ) else: raise ValueError(f"Received unknown fine-tune-type {args.fine_tune_type}") # Resume from weights if provided if args.resume_adapter_file is not None: rprint(f"Loading fine-tuned weights from {args.resume_adapter_file}") model.load_weights(args.resume_adapter_file, strict=False) if mx.distributed.init().rank() == 0: print_lora_run_header(make_console(), args) print_trainable_parameters(model) adapter_path = Path(args.adapter_path) adapter_path.mkdir(parents=True, exist_ok=True) adapter_file = adapter_path / "adapters.safetensors" save_config(vars(args), adapter_path / "adapter_config.json") # init training args training_args = TrainingArgs( batch_size=args.batch_size, iters=args.iters, val_batches=args.val_batches, steps_per_report=args.steps_per_report, steps_per_eval=args.steps_per_eval, steps_per_save=args.save_every, adapter_file=adapter_file, max_seq_length=args.max_seq_length, grad_checkpoint=args.grad_checkpoint, grad_accumulation_steps=args.grad_accumulation_steps, ) # Initialize the selected optimizer lr = build_schedule(args.lr_schedule) if args.lr_schedule else args.learning_rate optimizer_name = args.optimizer.lower() optimizer_config = args.optimizer_config.get(optimizer_name, {}) if optimizer_name == "adam": opt_class = optim.Adam elif optimizer_name == "adamw": opt_class = optim.AdamW elif optimizer_name == "muon": opt_class = optim.Muon elif optimizer_name == "sgd": opt_class = optim.SGD elif optimizer_name == "adafactor": opt_class = optim.Adafactor else: raise ValueError(f"Unsupported optimizer: {optimizer_name}") opt = opt_class(learning_rate=lr, **optimizer_config) # Train model train( model=model, args=training_args, optimizer=opt, train_dataset=CacheDataset(train_set), val_dataset=CacheDataset(valid_set), training_callback=training_callback, ) def evaluate_model(args, model: nn.Module, test_set): rank = mx.distributed.init().rank() n_batches = len(test_set) // args.batch_size if args.test_batches != -1: n_batches = min(n_batches, args.test_batches) pbar = tqdm(total=n_batches, desc="Calculating loss...") if rank == 0 else None test_loss = evaluate( model=model, dataset=CacheDataset(test_set), batch_size=args.batch_size, num_batches=args.test_batches, max_seq_length=args.max_seq_length, progress_callback=pbar.update if pbar else None, ) if pbar is not None: pbar.close() test_ppl = math.exp(test_loss) rprint(f"Test loss {test_loss:.3f}, Test ppl {test_ppl:.3f}.") def run(args, training_callback: TrainingCallback = None): np.random.seed(args.seed) training_callback = get_reporting_callbacks( args.report_to, project_name=args.project_name, log_dir=args.adapter_path, config=vars(args), ) rprint("Loading pretrained model") model, tokenizer = load( args.model, tokenizer_config={"trust_remote_code": args.trust_remote_code}, trust_remote_code=args.trust_remote_code, ) rprint("Loading datasets") train_set, valid_set, test_set = load_dataset(args, tokenizer) if args.test and not args.train: # Allow testing without LoRA layers by providing empty path if args.adapter_path != "": load_adapters(model, args.adapter_path) elif args.train: rprint("Training") train_model(args, model, train_set, valid_set, training_callback) else: raise ValueError("Must provide at least one of --train or --test") if args.test: rprint("Testing") evaluate_model(args, model, test_set) def main(): os.environ["TOKENIZERS_PARALLELISM"] = "true" parser = build_parser() args = parser.parse_args() config = args.config args = vars(args) if config: rprint("Loading configuration file", config) with open(config, "r") as file: config = yaml.load(file, yaml_loader) # Prefer parameters from command-line arguments for k, v in config.items(): if args.get(k, None) is None: args[k] = v # Update defaults for unspecified parameters for k, v in CONFIG_DEFAULTS.items(): if args.get(k, None) is None: args[k] = v run(types.SimpleNamespace(**args)) if __name__ == "__main__": print( "Calling `python -m mlx_lm.lora...` directly is deprecated." " Use `mlx_lm.lora...` or `python -m mlx_lm lora ...` instead." ) main()