| """ |
| Fine-tune Qwen/Qwen2.5-3B-Instruct on Modal with Unsloth (4-bit QLoRA + SFT). |
| |
| Prerequisites (run once on your Mac): |
| pip install modal |
| modal setup |
| |
| Run training: |
| modal run modal_apps/train_modal.py |
| |
| This uploads train.jsonl to a Modal Volume, trains on a GPU, and saves: |
| /model/adapter — LoRA adapter weights |
| /model/merged — merged 16-bit model |
| |
| Download results after training: |
| modal volume get android-dataset-model adapter ./trained_model/adapter |
| modal volume get android-dataset-model merged ./trained_model/merged |
| """ |
|
|
| from __future__ import annotations |
|
|
| import pathlib |
|
|
| import modal |
|
|
| app = modal.App("android-skill-finetune") |
|
|
| |
| |
| |
|
|
| MODEL_NAME = "Qwen/Qwen2.5-3B-Instruct" |
| PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent |
| LOCAL_DATASET = PROJECT_ROOT / "data" / "train.jsonl" |
| REMOTE_DATASET = "/data/train.jsonl" |
| MODEL_DIR = pathlib.Path("/model") |
| ADAPTER_DIR = MODEL_DIR / "adapter" |
| MERGED_DIR = MODEL_DIR / "merged" |
| CHECKPOINT_DIR = MODEL_DIR / "checkpoints" |
| MAX_SEQ_LENGTH = 2048 |
|
|
| LORA_R = 32 |
| LORA_ALPHA = 32 |
| NUM_EPOCHS = 5 |
| BATCH_SIZE = 8 |
|
|
| GPU_TYPE = "A10G" |
| TIMEOUT_SECONDS = 2 * 60 * 60 |
|
|
| |
| |
| |
|
|
| dataset_volume = modal.Volume.from_name( |
| "android-dataset-data", |
| create_if_missing=True, |
| ) |
| model_volume = modal.Volume.from_name( |
| "android-dataset-model", |
| create_if_missing=True, |
| ) |
| model_cache_volume = modal.Volume.from_name( |
| "android-dataset-hf-cache", |
| create_if_missing=True, |
| ) |
|
|
| |
| |
| |
|
|
| train_image = ( |
| modal.Image.debian_slim(python_version="3.11") |
| .pip_install_from_requirements( |
| str(pathlib.Path(__file__).parent / "requirements-modal.txt") |
| ) |
| .env( |
| { |
| "HF_HOME": "/model_cache", |
| "HF_HUB_ENABLE_HF_TRANSFER": "1", |
| } |
| ) |
| ) |
|
|
| with train_image.imports(): |
| import unsloth |
|
|
| from datasets import load_dataset |
| from trl import SFTConfig, SFTTrainer |
| from unsloth import FastLanguageModel, is_bf16_supported |
| from unsloth.chat_templates import get_chat_template |
|
|
|
|
| |
| |
| |
|
|
|
|
| @app.function( |
| image=train_image, |
| gpu=GPU_TYPE, |
| timeout=TIMEOUT_SECONDS, |
| volumes={ |
| "/data": dataset_volume, |
| "/model": model_volume, |
| "/model_cache": model_cache_volume, |
| }, |
| ) |
| def train() -> None: |
| dataset_volume.reload() |
|
|
| data_path = pathlib.Path(REMOTE_DATASET) |
| if not data_path.exists(): |
| raise FileNotFoundError( |
| f"Dataset not found at {data_path}. " |
| "Run `modal run modal_apps/train_modal.py` from the project directory." |
| ) |
|
|
| print(f"Loading model: {MODEL_NAME}") |
| model, tokenizer = FastLanguageModel.from_pretrained( |
| model_name=MODEL_NAME, |
| max_seq_length=MAX_SEQ_LENGTH, |
| dtype=None, |
| load_in_4bit=True, |
| ) |
|
|
| print(f"Applying QLoRA (rank={LORA_R})") |
| model = FastLanguageModel.get_peft_model( |
| model, |
| r=LORA_R, |
| target_modules=[ |
| "q_proj", |
| "k_proj", |
| "v_proj", |
| "o_proj", |
| "gate_proj", |
| "up_proj", |
| "down_proj", |
| ], |
| lora_alpha=LORA_ALPHA, |
| lora_dropout=0, |
| bias="none", |
| use_gradient_checkpointing="unsloth", |
| random_state=3407, |
| max_seq_length=MAX_SEQ_LENGTH, |
| ) |
|
|
| tokenizer = get_chat_template( |
| tokenizer, |
| chat_template="qwen-2.5", |
| ) |
|
|
| print(f"Loading dataset: {data_path}") |
| dataset = load_dataset("json", data_files=str(data_path), split="train") |
| print(f"Training examples: {len(dataset)}") |
|
|
| def formatting_prompts_func(examples): |
| texts = [ |
| tokenizer.apply_chat_template( |
| messages, |
| tokenize=False, |
| add_generation_prompt=False, |
| ) |
| for messages in examples["messages"] |
| ] |
| return {"text": texts} |
|
|
| dataset = dataset.map(formatting_prompts_func, batched=True) |
|
|
| CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| trainer = SFTTrainer( |
| model=model, |
| tokenizer=tokenizer, |
| train_dataset=dataset, |
| args=SFTConfig( |
| output_dir=str(CHECKPOINT_DIR), |
| num_train_epochs=NUM_EPOCHS, |
| per_device_train_batch_size=BATCH_SIZE, |
| gradient_accumulation_steps=1, |
| warmup_steps=10, |
| learning_rate=2e-4, |
| fp16=not is_bf16_supported(), |
| bf16=is_bf16_supported(), |
| logging_steps=10, |
| optim="adamw_8bit", |
| seed=3407, |
| report_to="none", |
| max_seq_length=MAX_SEQ_LENGTH, |
| dataset_text_field="text", |
| packing=False, |
| ), |
| ) |
|
|
| print("Starting training...") |
| trainer.train() |
|
|
| ADAPTER_DIR.mkdir(parents=True, exist_ok=True) |
| MERGED_DIR.mkdir(parents=True, exist_ok=True) |
|
|
| print(f"Saving LoRA adapter to {ADAPTER_DIR}") |
| model.save_pretrained(str(ADAPTER_DIR)) |
| tokenizer.save_pretrained(str(ADAPTER_DIR)) |
|
|
| print(f"Saving merged 16-bit model to {MERGED_DIR}") |
| model.save_pretrained_merged( |
| str(MERGED_DIR), |
| tokenizer, |
| save_method="merged_16bit", |
| ) |
|
|
| model_volume.commit() |
| print("Training complete. Model saved to /model volume.") |
| print(f" Adapter: {ADAPTER_DIR}") |
| print(f" Merged: {MERGED_DIR}") |
|
|
|
|
| |
| |
| |
|
|
|
|
| @app.local_entrypoint() |
| def main(dataset: str = "train_intent.jsonl") -> None: |
| """Upload dataset and launch training. |
| |
| Args: |
| dataset: Filename under data/ — use train_intent.jsonl for intent extraction. |
| """ |
| dataset_path = PROJECT_ROOT / "data" / dataset |
| if not dataset_path.exists(): |
| raise FileNotFoundError( |
| f"Local dataset not found: {dataset_path.resolve()}" |
| ) |
|
|
| remote_name = "train.jsonl" |
| try: |
| dataset_volume.remove_file(remote_name) |
| except Exception: |
| pass |
|
|
| print(f"Uploading {dataset_path} to dataset volume...") |
| with dataset_volume.batch_upload() as batch: |
| batch.put_file(str(dataset_path), remote_name) |
|
|
| print("Launching training on Modal GPU...") |
| train.remote() |
|
|
| print() |
| print("Done! Download your model with:") |
| print(" modal volume get android-dataset-model adapter ./trained_model/adapter") |
| print(" modal volume get android-dataset-model merged ./trained_model/merged") |
|
|