LoRA Model Customization

View as Markdown

Run in Google Colab

LoRA Model Customization Job

Learn how to use the NeMo Platform to create a LoRA (Low-Rank Adaptation) customization job using a custom dataset. In this tutorial we use LoRA to fine-tune a question-answering model from the SQuAD dataset.

LoRA is a parameter-efficient fine-tuning method that requires fewer computational resources than full fine-tuning. If you need full model fine-tuning instead, see the Full SFT Customization Job tutorial.

Time to complete: approximately 45 minutes. Job duration increases with model size and dataset size.

Prerequisites

Before starting this tutorial, ensure you have:

  1. Completed the Quickstart to install and deploy NeMo Platform locally
  2. Installed the Python SDK (PyPI wrapper: pip install "nemo-platform[all]"; source checkout: run make bootstrap from the repository root)
  3. Installed the datasets package for loading SQuAD: pip install datasets
  4. At least one GPU with CUDA 12.8+

Quick Start

1. Initialize SDK

The SDK needs to know your NeMo Platform server URL. By default, http://localhost:8080 is used. If NeMo Platform is running elsewhere, set the NMP_BASE_URL environment variable:

1export NMP_BASE_URL=<YOUR_NMP_BASE_URL>
1import json
2import os
3import re
4import time
5import uuid
6from pathlib import Path
7from nemo_platform import NeMoPlatform, ConflictError
8from nemo_platform.types.secrets import PlatformSecretResponse
9from nemo_platform.types.files import HuggingfaceStorageConfigParam
10
11
12def sanitize_name(prefix: str, name: str):
13 """Sanitize model_name for deployment/config naming. Compatible with platform naming rules."""
14 name = name.split("/")[-1]
15 sanitized = re.sub(r"[^a-z0-9@.+_-]", "-", name.lower())
16 sanitized = re.sub(r"-+", "-", sanitized).strip("-")
17 return f"{prefix}-{sanitized}"[:59].rstrip("-")
18
19
20def max_wait_time_checker(seconds: int, job_name: str = ""):
21 """Return a check() that raises TimeoutError if called after `seconds` have elapsed."""
22 start_time = time.time()
23
24 def check():
25 if time.time() - start_time > seconds:
26 raise TimeoutError(f"{job_name} took longer than {seconds} seconds")
27
28 return check
29
30
31NMP_BASE_URL = os.environ.get("NMP_BASE_URL", "http://localhost:8080")
32client = NeMoPlatform(
33 base_url=NMP_BASE_URL,
34 workspace="default"
35)

2. Prepare Dataset

Create your data in JSONL format (one JSON object per line). For SFT with LoRA, the platform expects prompt/completion pairs.

Dataset structure:

  • Training files under training/ (or root with training.jsonl)
  • Validation files under validation/ (or validation.jsonl)

SFT format: Each line is a JSON object with:

  • prompt: The input (e.g. context + question)
  • completion: The desired model output

Example record (single line in .jsonl):

1{"prompt": "Context: ... Question: What is X? Answer:", "completion": "X is ..."}

Download SQuAD and Convert to SFT Format

We use the SQuAD dataset and convert it to prompt/completion JSONL.

1from datasets import load_dataset, DatasetDict
2
3print("Loading dataset rajpurkar/squad")
4raw_dataset = load_dataset("rajpurkar/squad")
5if not isinstance(raw_dataset, DatasetDict):
6 raise ValueError("Dataset does not contain expected splits")
7print("Loaded dataset")
8
9VALIDATION_PROPORTION = 0.05
10SEED = 1234
11training_size = 3000
12validation_size = 300
13DATASET_NAME = "sft-dataset"
14DATASET_PATH = Path("sft-dataset").absolute()
15
16os.makedirs(DATASET_PATH, exist_ok=True)
17train_set = raw_dataset.get("train")
18split_dataset = train_set.train_test_split(test_size=VALIDATION_PROPORTION, seed=SEED)
19train_ds = split_dataset["train"].select(range(min(training_size, len(split_dataset["train"]))))
20validation_ds = split_dataset["test"].select(range(min(validation_size, len(split_dataset["test"]))))
21
22def convert_squad_to_sft_format(example):
23 prompt = f"Context: {example['context']} Question: {example['question']} Answer:"
24 completion = example["answers"]["text"][0]
25 return {"prompt": prompt, "completion": completion}
26
27with open(f"{DATASET_PATH}/training.jsonl", "w", encoding="utf-8") as f:
28 for example in train_ds:
29 f.write(json.dumps(convert_squad_to_sft_format(example)) + "\n")
30with open(f"{DATASET_PATH}/validation.jsonl", "w", encoding="utf-8") as f:
31 for example in validation_ds:
32 f.write(json.dumps(convert_squad_to_sft_format(example)) + "\n")
33
34print(f"Saved training.jsonl with {len(train_ds)} rows")
35print(f"Saved validation.jsonl with {len(validation_ds)} rows")
36with open(f"{DATASET_PATH}/training.jsonl", "r") as f:
37 sample = json.loads(f.readline())
38print("Sample prompt (first 200 chars):", sample["prompt"][:200] + "...")
39print("Sample completion:", sample["completion"])

3. Create FileSet and Upload Training Data

Upload the training and validation JSONL files to a FileSet so the customization job can use them.

1try:
2 client.files.filesets.create(
3 workspace="default",
4 name=DATASET_NAME,
5 description="SFT training data",
6 cache=True,
7 )
8 print(f"Created fileset: {DATASET_NAME}")
9except ConflictError:
10 print(f"Fileset '{DATASET_NAME}' already exists, continuing...")
11
12client.files.fsspec.put(
13 lpath=DATASET_PATH,
14 rpath=f"default/{DATASET_NAME}/",
15 recursive=True
16)
17print("Training data:")
18print(client.files.list(fileset=DATASET_NAME, workspace="default"))

4. Secrets Setup

For Huggingface models that require authentication, create a secret with your HF token. Get a token from Huggingface Settings and accept the model terms.

This is generally true for LLaMa based models (e.g. Llama-3.2-1B-Instruct).

1export HF_TOKEN=<your-huggingface-token>
1HF_TOKEN = os.getenv("HF_TOKEN")
2
3def create_or_get_secret(name: str, value: str | None, label: str) -> PlatformSecretResponse | None:
4 if not value:
5 print(f"{label} is not set - skipping setting secret")
6 return None
7 try:
8 secret = client.secrets.create(name=name, workspace="default", value=value)
9 print(f"Created secret: {name}")
10 print(secret.model_dump_json(indent=2))
11 return secret
12 except ConflictError:
13 print(f"Secret '{name}' already exists, continuing...")
14 secret = client.secrets.retrieve(name=name, workspace="default")
15 print(secret.model_dump_json(indent=2))
16 return secret
17
18
19hf_secret = create_or_get_secret("hf-token", HF_TOKEN, "HF_TOKEN")

5. Create Base Model FileSet and Model Entity

Create a fileset pointing to Qwen/Qwen3-0.6B and a Model Entity that references it. Model download happens when the customization job runs.

1HF_REPO_ID = "Qwen/Qwen3-0.6B"
2MODEL_NAME = "qwen3-0.6b"
3
4try:
5 storage = HuggingfaceStorageConfigParam(
6 type="huggingface",
7 repo_id=HF_REPO_ID,
8 repo_type="model",
9 )
10 if hf_secret:
11 storage["token_secret"] = hf_secret.name
12 base_model_fs = client.files.filesets.create(
13 workspace="default",
14 name=MODEL_NAME,
15 description="Qwen3 0.6b base model from Huggingface",
16 storage=storage,
17 cache=True,
18 )
19except ConflictError:
20 base_model_fs = client.files.filesets.retrieve(workspace="default", name=MODEL_NAME)
21
22try:
23 base_model = client.models.create(
24 workspace="default",
25 name=MODEL_NAME,
26 fileset=f"default/{MODEL_NAME}",
27 trust_remote_code=False,
28 )
29except ConflictError:
30 client.models.update(
31 workspace="default",
32 name=MODEL_NAME,
33 fileset=f"default/{MODEL_NAME}",
34 trust_remote_code=False,
35 )
36 base_model = client.models.retrieve(workspace="default", name=MODEL_NAME)
37
38print(f"Base model fileset: fileset://default/{base_model.name}")
39print(client.files.list(fileset=MODEL_NAME, workspace="default"))
40
41time_check = max_wait_time_checker(600, "Model Spec")
42while not base_model.spec:
43 time_check()
44 time.sleep(10)
45 base_model = client.models.retrieve(workspace="default", name=MODEL_NAME)
46
47# Clear verbose linear_layers list for cleaner output
48base_model.spec.linear_layers = None
49print(f"ModelSpec: {base_model.spec}")

6. Create LoRA Customization Job

Submit to the Automodel backend using AutomodelJobInput with finetuning_type: lora. After training completes, deploy the base model with LoRA support manually (step 8).

1from nemo_automodel_plugin.schema import AutomodelJobInput
2
3job_suffix = uuid.uuid4().hex[:4]
4JOB_NAME = f"my-sft-job-{job_suffix}"
5OUTPUT_NAME = f"lora-adapter-{job_suffix}"
6
7spec = AutomodelJobInput(
8 model=f"default/{base_model.name}",
9 dataset={"training": f"default/{DATASET_NAME}"},
10 training={
11 "training_type": "sft",
12 "finetuning_type": "lora",
13 "max_seq_length": 2048,
14 },
15 schedule={"epochs": 2},
16 batch={"global_batch_size": 64, "micro_batch_size": 1},
17 optimizer={"learning_rate": 5e-5},
18 parallelism={
19 "num_gpus_per_node": 1,
20 "num_nodes": 1,
21 "tensor_parallel_size": 1,
22 "pipeline_parallel_size": 1,
23 "context_parallel_size": 1,
24 "expert_parallel_size": 1,
25 },
26 output={"name": OUTPUT_NAME},
27)
28
29job = client.customization.automodel.jobs.create(
30 spec=spec, workspace="default", name=JOB_NAME
31)
32print(f"Submitted job: {job.job.name}")
33print(f"Output adapter: {OUTPUT_NAME}")

7. Track Training Progress

Poll job status until it completes. Progress (step/max_steps) is shown when available.

1from IPython.display import clear_output
2
3time_check = max_wait_time_checker(3600, "Customization Job")
4while True:
5 time_check()
6 status = client.jobs.get_status(name=job.job.name, workspace="default")
7 clear_output(wait=True)
8 print(f"Job Status: {status.status}")
9 step = max_steps = training_phase = None
10 for job_step in status.steps or []:
11 if job_step.name == "training":
12 for task in job_step.tasks or []:
13 d = task.status_details or {}
14 step, max_steps = d.get("step"), d.get("max_steps")
15 training_phase = d.get("phase")
16 break
17 break
18 if step is not None and max_steps is not None:
19 print(f"Training: Step {step}/{max_steps} ({100 * step / max_steps:.1f}%)")
20 if training_phase:
21 print(f"Phase: {training_phase}")
22 if status.status in ("completed", "failed", "cancelled", "error"):
23 print(f"\nJob finished: {status.status}")
24 break
25 time.sleep(10)
26
27assert status.status == "completed"

8. Validate Output Model and Deployment

With the base model entity and LoRA adapter from training, create a NIM deployment with lora_enabled=True so the adapter is served alongside the base weights. Check the model entity and deployment status.

1deploy_suffix = uuid.uuid4().hex[:4]
2DEPLOYMENT_CONFIG_NAME = f"lora-deploy-cfg-{deploy_suffix}"
3deployment_name = f"lora-deploy-{deploy_suffix}"
4
5deployment_config = client.inference.deployment_configs.create(
6 workspace="default",
7 name=DEPLOYMENT_CONFIG_NAME,
8 engine="vllm",
9 model_spec={
10 "model_namespace": "default",
11 "model_name": MODEL_NAME,
12 "lora_enabled": True,
13 },
14 executor_config={
15 "gpu": 1,
16 "image_name": "vllm/vllm-openai",
17 "image_tag": "v0.22.1",
18 "additional_args": ["--max-lora-rank", "32"],
19 },
20)
21
22deployment = client.inference.deployments.create(
23 workspace="default",
24 name=deployment_name,
25 config=deployment_config.name,
26)
27
28model_entity = client.models.retrieve(workspace="default", name=MODEL_NAME)
29if model_entity.spec:
30 model_entity.spec.linear_layers = None
31print(model_entity.model_dump_json(indent=2))
32print(f"Deployment status: {deployment.status}")

9. Monitor Deployment Until Ready

Wait for the deployment to reach RUNNING/READY before sending inference requests.

1TIMEOUT_MINUTES = 30
2start_time = time.time()
3time_check = max_wait_time_checker(TIMEOUT_MINUTES * 60, "Deployment")
4print(f"Monitoring deployment '{deployment_name}'... (timeout {TIMEOUT_MINUTES} min)\n")
5
6while True:
7 time.sleep(15)
8 time_check()
9 deployment_status = client.inference.deployments.retrieve(name=deployment_name, workspace="default")
10 elapsed = time.time() - start_time
11 clear_output(wait=True)
12 print(f"Deployment: {deployment_name}")
13 print(f"Status: {deployment_status.status}")
14 print(f"Elapsed: {int(elapsed // 60)}m {int(elapsed % 60)}s")
15 if deployment_status.status in ("RUNNING", "READY"):
16 print("\nDeployment is ready!")
17 if not client.models.wait_for_gateway(deployment_name, workspace="default", timeout=60):
18 raise RuntimeError("Inference gateway did not become ready")
19 break
20 if deployment_status.status in ("FAILED", "ERROR", "TERMINATED"):
21 raise RuntimeError(f"Deployment failed with status: {deployment_status.status}")
22
23assert deployment_status.status in ("RUNNING", "READY")

10. Check Model Output

Send a chat completion request to the deployed LoRA model and compare the output to the expected answer.

1context = "The Apollo 11 mission was the first manned mission to land on the Moon. It was launched on July 16, 1969, and Neil Armstrong became the first person to walk on the lunar surface on July 20, 1969. Buzz Aldrin joined him shortly after, while Michael Collins remained in lunar orbit."
2question = "Who was the first person to walk on the Moon?"
3messages = [
4 {"role": "user", "content": f"Based on the following context, answer the question.\n\nContext: {context}\n\nQuestion: {question}"}
5]
6response = client.inference.gateway.provider.post(
7 "v1/chat/completions",
8 name=deployment_name,
9 workspace="default",
10 body={
11 "model": OUTPUT_NAME,
12 "messages": messages,
13 "temperature": 0,
14 "max_tokens": 256,
15 }
16)
17print("=" * 60)
18print("MODEL INFERENCE")
19print("=" * 60)
20print(f"Question: {question}")
21print(f"Expected: Neil Armstrong")
22print(f"Model output: {response['choices'][0]['message']['content']}")

Conclusion

You have started a LoRA customization job, monitored it to completion, and evaluated the fine-tuned model. Use the output.name to access the model for further inference or evaluation.

Next Steps