DPO Customization

View as Markdown

Run in Google Colab

Learn how to use the NeMo Platform to align a model with DPO (Direct Preference Optimization) on a preference dataset. For each prompt, DPO trains on a chosen (preferred) and a rejected response so the model prefers the chosen style — no separate reward model required.

This tutorial uses the rl customization backend (powered by NVIDIA NeMo-RL), which runs DPO on a Ray cluster. Unlike the SFT and LoRA tutorials (Docker GPU jobs), rl requires a Kubernetes-backed NeMo Platform. DPO here is full-weight (no LoRA/adapter); the output is a full model entity.

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

Prerequisites

Before starting this tutorial, ensure you have:

  1. Completed the Quickstart to install the NeMo Platform and Python SDK.
  2. Installed the Python SDK (PyPI wrapper: pip install "nemo-platform[all,nemo-rl-plugin]" so RlJobInput is available; source checkout: run make bootstrap from the repository root). nemo-platform[all] does not include the RL plugin.
  3. Installed the datasets package: pip install datasets.
  4. A platform configured with platform.runtime: kubernetes. The rl (DPO) backend provisions a Ray cluster and has no local Docker fallbacksubmit fails fast on a Docker-runtime platform. Multi-node jobs (parallelism.num_nodes > 1) additionally require the platform-side NMP_RL_MULTINODE_SHARED_STORAGE_PATH.
  5. A Hugging Face token with access to the gated base model (this tutorial uses meta-llama/Llama-3.2-1B-Instruct). Export it as HF_TOKEN.
  6. At least one GPU with CUDA 13+ and a GPU execution profile (nemo jobs list-execution-profiles).

Quick Start

1. Initialize the SDK

The SDK needs your NeMo Platform server URL. By default http://localhost:8080 is used; set NMP_BASE_URL to override:

1export NMP_BASE_URL=<YOUR_NMP_BASE_URL>
1import json
2import os
3import time
4import uuid
5from pathlib import Path
6from nemo_platform import NeMoPlatform, ConflictError
7from nemo_platform.types.secrets import PlatformSecretResponse
8from nemo_platform.types.files import HuggingfaceStorageConfigParam
9from nemo_rl_plugin.schema import RlJobInput
10
11
12def max_wait_time_checker(seconds: int, label: str = ""):
13 """Return a check() that raises TimeoutError once `seconds` have elapsed."""
14 start = time.time()
15
16 def check():
17 if time.time() - start > seconds:
18 raise TimeoutError(f"{label} took longer than {seconds} seconds")
19
20 return check
21
22
23NMP_BASE_URL = os.environ.get("NMP_BASE_URL", "http://localhost:8080")
24sdk = NeMoPlatform(base_url=NMP_BASE_URL, workspace="default")

2. Prepare the Preference Dataset

DPO trains on preference data. The rl backend takes a single dataset fileset that holds both training.jsonl and validation.jsonl, and auto-detects the row schema from the first line. Three preference formats are supported (see the platform’s BinaryPreferenceDatasetItemSchema / HelpSteer3DatasetItemSchema / Tulu3PreferenceDatasetItemSchema):

Binary Preference Format

Simple prompt / chosen / rejected (the prompt may be a string or a list of chat messages):

1{"prompt": "What is the capital of France?", "chosen": "The capital of France is Paris.", "rejected": "I'm not sure."}

HelpSteer3 Format (used here)

A conversation context (string or chat messages), two candidate response1 / response2, and a signed overall_preference in -3..3 — negative means response 1 is preferred, positive means response 2, 0 is a tie. This is the raw schema of nvidia/HelpSteer3, so no conversion is needed:

1{"context": [{"role": "user", "content": "Explain how to use git rebase"}], "response1": "...", "response2": "...", "overall_preference": -2}

Tulu3 Preference Format

Full chat conversations for both the chosen and rejected branches (each a list of messages ending with the assistant turn):

1{"chosen": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "preferred"}], "rejected": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "dispreferred"}]}

Download nvidia/HelpSteer3

We use nvidia/HelpSteer3 (the preference subset), NVIDIA’s open preference dataset. It ships native train and validation splits and matches the HelpSteer3 schema above, so we upload the rows as-is — the platform’s HelpSteer3Dataset loader handles the overall_preference semantics (including ties) at training time.

1from datasets import load_dataset, Dataset
2
3print("Loading dataset nvidia/HelpSteer3 (preference subset)")
4ds = load_dataset("nvidia/HelpSteer3", "preference")
5
6# Small subsets keep the tutorial fast; larger sets train better but take longer.
7training_size = 3000
8validation_size = 300
9DATASET_NAME = "dpo-dataset"
10DATASET_PATH = Path("dpo-dataset").absolute()
11os.makedirs(DATASET_PATH, exist_ok=True)
12
13train_dataset = ds["train"]
14validation_dataset = ds["validation"]
15assert isinstance(train_dataset, Dataset) and isinstance(validation_dataset, Dataset)
16
17# Save raw HelpSteer3 rows directly — no conversion. The platform detects the
18# HelpSteer3 schema from the row keys (context / response1 / response2 / overall_preference).
19train_dataset.select(range(training_size)).to_json(f"{DATASET_PATH}/training.jsonl")
20validation_dataset.select(range(validation_size)).to_json(f"{DATASET_PATH}/validation.jsonl")
21
22print(f"Saved training.jsonl ({training_size} rows) and validation.jsonl ({validation_size} rows)")
23with open(f"{DATASET_PATH}/training.jsonl") as f:
24 sample = json.loads(f.readline())
25print("Sample keys:", sorted(sample.keys()))
26print("overall_preference:", sample["overall_preference"])

3. Create FileSet and Upload Preference Data

Upload both JSONL files to a single FileSet so the DPO job can read them.

1try:
2 sdk.files.filesets.create(workspace="default", name=DATASET_NAME, description="DPO preference data")
3 print(f"Created fileset: {DATASET_NAME}")
4except ConflictError:
5 print(f"Fileset '{DATASET_NAME}' already exists, continuing...")
6
7sdk.files.upload(local_path=DATASET_PATH, remote_path="", fileset=DATASET_NAME, workspace="default")
8
9print("Preference data:")
10print(json.dumps([f.model_dump() for f in sdk.files.list(fileset=DATASET_NAME, workspace="default").data], indent=2, default=str))

4. Secrets Setup

The base model (meta-llama/Llama-3.2-1B-Instruct) is gated, so store your Hugging Face token as a platform secret named hf-token and reference it on the model fileset.

1HF_TOKEN = os.getenv("HF_TOKEN")
2if not HF_TOKEN:
3 raise RuntimeError("Set HF_TOKEN before running this tutorial.")
4
5def create_or_get_secret(name: str, value: str, label: str) -> PlatformSecretResponse:
6 try:
7 secret = sdk.secrets.create(name=name, workspace="default", value=value)
8 print(f"Created secret: {name}")
9 return secret
10 except ConflictError:
11 print(f"Secret '{name}' already exists, continuing...")
12 return sdk.secrets.retrieve(name=name, workspace="default")
13
14
15hf_secret = create_or_get_secret("hf-token", HF_TOKEN, "HF_TOKEN")

5. Create Base Model FileSet and Model Entity

DPO starts from an instruction-tuned base model. The model entity’s spec is inferred asynchronously after creation.

1HF_REPO_ID = "meta-llama/Llama-3.2-1B-Instruct"
2MODEL_NAME = "llama-3-2-1b-instruct"
3
4storage = HuggingfaceStorageConfigParam(
5 type="huggingface",
6 repo_id=HF_REPO_ID,
7 repo_type="model",
8 token_secret=hf_secret.name,
9)
10
11try:
12 base_model_fs = sdk.files.filesets.create(
13 workspace="default", name=MODEL_NAME, description="Llama 3.2 1B Instruct base model", storage=storage
14 )
15 print(f"Created base model fileset: {MODEL_NAME}")
16except ConflictError:
17 base_model_fs = sdk.files.filesets.retrieve(workspace="default", name=MODEL_NAME)
18 print("Base model fileset already exists.")
19
20try:
21 base_model = sdk.models.create(workspace="default", name=MODEL_NAME, fileset=f"default/{MODEL_NAME}")
22except ConflictError:
23 base_model = sdk.models.retrieve(workspace="default", name=MODEL_NAME)
24
25print(f"Base model fileset: fileset://default/{base_model.name}")
26
27# Wait for the ModelSpec to be inferred from the checkpoint.
28check = max_wait_time_checker(600, "Model spec")
29while not base_model.spec:
30 check()
31 time.sleep(10)
32 base_model = sdk.models.retrieve(workspace="default", name=MODEL_NAME)
33print("Model spec ready")

6. Create the DPO Customization Job

Submit a DPO job to the rl backend with RlJobInput. Note the DPO-specific shape:

  • model is a string ref to the model entity; dataset is a single string ref to the preference fileset (holding both files).
  • The training method is {"type": "dpo", ...} — full-weight, no finetuning_type/LoRA.
  • ref_policy_kl_penalty is β (DPO paper): how strongly the policy stays tied to the reference model.
  • rl auto-generates the job id (rl-<hex>); read it back from the response.

Other configurable knobs: optimizer_type, adam_eps, activation_checkpointing, keep_top_k, val_at_end, preference_loss_weight, sft_loss_weight. Run nemo customization rl explain for the live schema.

1job_suffix = uuid.uuid4().hex[:8]
2OUTPUT_NAME = f"llama-3-2-1b-dpo-{job_suffix}"
3
4spec = RlJobInput(
5 model=f"default/{base_model.name}",
6 dataset=f"default/{DATASET_NAME}",
7 training={
8 "type": "dpo",
9 "epochs": 1,
10 "batch_size": 16,
11 "micro_batch_size": 1,
12 "learning_rate": 5e-6,
13 "max_seq_length": 4096,
14 "ref_policy_kl_penalty": 0.1,
15 "parallelism": {
16 "num_nodes": 1,
17 "num_gpus_per_node": 1,
18 "tensor_parallel_size": 1,
19 "pipeline_parallel_size": 1,
20 },
21 },
22 output={"name": OUTPUT_NAME},
23)
24
25# `rl` auto-generates the job id (rl-<hex>); do not pass name=.
26job = sdk.customization.rl.jobs.create(spec=spec, workspace="default")
27print(f"Job ID: {job.job.name}")
28print(f"Output model: {OUTPUT_NAME}")

7. Track Training Progress

The DPO job runs four steps: download -> dpo-training (Ray) -> upload -> model-entity. We poll the top-level job status and surface the training step’s progress.

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

Interpreting DPO training metrics (in status_details.metrics):

  • loss — the DPO loss; should trend down as the policy learns to separate chosen from rejected.
  • Reward margin (chosen minus rejected reward) — should trend up: the model increasingly prefers chosen responses.
  • Validation loss — watch for divergence from training loss (overfitting). Raise ref_policy_kl_penalty (β) or add sft_loss_weight if the policy drifts too far from the reference.

8. Validate the Output Model

DPO produces a full-weight model entity (not an adapter). Confirm it was registered.

1model_entity = sdk.models.retrieve(workspace="default", name=OUTPUT_NAME)
2print(model_entity.model_dump_json(indent=2))

9. Deploy and Evaluate (optional)

The DPO output is a full model, so it deploys like any full-weight checkpoint (see the Full SFT tutorial for details). We deploy with vLLM and send a chat completion.

1deploy_suffix = uuid.uuid4().hex[:8]
2DEPLOYMENT_CONFIG_NAME = f"dpo-deployment-cfg-{deploy_suffix}"
3DEPLOYMENT_NAME = f"dpo-deployment-{deploy_suffix}"
4
5deployment_config = sdk.inference.deployment_configs.create(
6 workspace="default",
7 name=DEPLOYMENT_CONFIG_NAME,
8 engine="vllm",
9 model_spec={"model_namespace": "default", "model_name": OUTPUT_NAME},
10 executor_config={"gpu": 1, "image_name": "vllm/vllm-openai", "image_tag": "v0.22.1"},
11)
12
13deployment = sdk.inference.deployments.create(
14 workspace="default", name=DEPLOYMENT_NAME, config=deployment_config.name
15)
16print(f"Deployment name: {deployment.name}")
1check = max_wait_time_checker(1800, "Deployment")
2while True:
3 check()
4 deployment_status = sdk.inference.deployments.retrieve(name=deployment.name, workspace="default")
5 clear_output(wait=True)
6 print(f"Deployment status: {deployment_status.status}")
7 deployment_state = str(deployment_status.status).lower()
8 if deployment_state in ("ready", "running"):
9 if not sdk.models.wait_for_gateway(deployment.name, workspace="default", timeout=60):
10 raise RuntimeError("Inference gateway did not become ready")
11 break
12 if deployment_state in ("failed", "error", "terminated", "lost"):
13 raise RuntimeError(f"Deployment failed with status: {deployment_status.status}")
14 time.sleep(15)
1messages = [
2 {"role": "system", "content": "You are a helpful assistant."},
3 {"role": "user", "content": "Write a short, friendly email to a colleague asking to reschedule our meeting to Thursday."},
4]
5
6response = sdk.inference.gateway.provider.post(
7 "v1/chat/completions",
8 name=deployment.name,
9 workspace="default",
10 body={"model": f"default/{OUTPUT_NAME}", "messages": messages, "temperature": 0.7, "max_tokens": 256},
11)
12print("Model output:\n")
13print(response["choices"][0]["message"]["content"])

Conclusion

You aligned a base model with DPO on the NeMo Platform using the rl backend:

  • Uploaded a HelpSteer3 preference dataset as-is (the platform detects the schema natively).
  • Submitted a full-weight DPO job that ran on a Ray cluster via the Kubernetes executor.
  • Registered the output as a full model entity and (optionally) deployed it for inference.

Next steps: tune the alignment strength with ref_policy_kl_penalty (β), add sft_loss_weight to anchor the policy to the chosen responses, enable activation_checkpointing for memory headroom, or scale up with parallelism. See the Training Configuration reference for the full hyperparameter set.