GRPO and Reward Environments

View as Markdown

GRPO (Group Relative Policy Optimization) trains a model against a reward environment instead of labeled completions. For each prompt the platform samples a group of responses, the environment scores them, and the policy is updated toward the responses that scored better than the others in their group.

That last part is why GRPO behaves differently from every other customization method here: the learning signal is the spread of rewards inside a group. If every response to a prompt earns the same score, that prompt contributes nothing.

GRPO runs on the rl backend, the same one that runs DPO, and requires a NeMo Platform configured with platform.runtime: kubernetes — it provisions a Ray cluster and has no local Docker fallback.

What you need

Unlike SFT or DPO, GRPO takes two FileSets:

FileSetpurposeHolds
EnvironmentenvironmentCode and config that runs a rollout and returns a reward
Datasetdatasettraining.jsonl (required) and validation.jsonl (optional) — prompt rows

Plus a registered model entity, exactly as for the other backends.

Prompt JSONL must not live inside the environment package. Schema validation rejects any .jsonl in the environment FileSet, so keep the two FileSets separate.

Choose an environment format

The environment package declares its format in a nemo-environment.yaml manifest at its root. Three formats are supported.

You haveFormatShips wheelsconfig_paths live under
A Gym server tree whose dependencies are already resolvable at spin-upnative-v1Noresponses_api_agents/, resources_servers/, responses_api_models/
Any verifiers environment installable as a wheel like a Prime Intellect hub environmentadapter-wheels-v1Yesconfigs/
Any other environment code, including a Gym server tree whose dependencies you want vendoredwheels-v1YesAnywhere in the package

All three formats run on the same Gym runtime and are supported equally. The format decides two things only: where a package’s dependencies come from, and where its config_paths may live.

The manifest

Every package declares itself in nemo-environment.yaml at its root. The shape is the same in all three formats:

1format: wheels-v1 # or native-v1, adapter-wheels-v1
2config_paths: # at least one, relative to the package root
3 - configs/policy_model.yaml
4 - configs/my_env.yaml
5metadata:
6 name: my-env # required
7 description: Custom reward environment

adapter-wheels-v1 adds one additional block, because the agent harness comes from the training image rather than the package:

1adapter:
2 agent: verifiers_agent # the only harness built into the image
3 agent_type: responses_api_agents

Rules that apply to every format:

  • nemo-environment.yaml sits at the package root.
  • Every config_paths entry is relative, contains no .., is not a symlink, and exists in the package.
  • No .jsonl anywhere in the package.
  • wheels/ is non-empty and contains only .whl files, for the two wheels formats.
  • Unknown manifest keys are rejected.

How dependencies are installed

The format you choose does not change what runs — all three start on the same Gym runtime. It changes where dependencies come from, and that is what usually decides whether a job starts at all.

There are two installs at job start:

  1. Gym builds one venv per server from that server’s pyproject.toml or requirements.txt, plus nemo-gym at the image version and Gym’s pinned ray[default] and openai. A package’s wheels/ directory is offered here as a candidate poola package index is still enabled, so anything the wheelhouse misses is fetched from it.
  2. The job then installs the package’s vendored closure into each agent and resources-server venv with --no-index. Fully offline, but only for what wheels/ already carries, and only after step 1 succeeded.
FormatServer dependencies come fromNeeds egress at spin-up
native-v1A package index, resolved from the server’s requirements.txtYes
wheels-v1The package’s wheels/, for anything it vendorsNo, if the closure also covers step 1
adapter-wheels-v1The package’s wheels/, plus the agent harness’s own requirementsYes — see below

A wheels-v1 job runs with no egress only when wheels/ carries the server’s full requirement closure, nemo-gym at exactly the training-image version, and Gym’s pinned ray[default] and openai — the venv is seeded empty, so nothing carries over from the image. Completeness of wheels/ is what makes an offline run work, not the format name. Full detail: GRPO Environment Packages.

adapter-wheels-v1 requires network access when the job starts. The package wheels/ directory covers the hub environment; verifiers_agent also installs verifiers from GitHub.

Egress is an operator setting, not a job field: NMP_RL_SANDBOX_ALLOW_INTERNET, plus NMP_RL_SANDBOX_PUBLIC_DNS_ALLOW for hosts outside the built-in *.com / *.org allowance. Ask your platform operator which is configured before choosing a format.

Vendoring a wheel closure

Wheels must match the architecture of the nodes that run GRPO training, not the machine that builds the package. The training images are published for both linux/amd64 and linux/arm64, so there is no single correct answer — check what your cluster runs and target that:

$kubectl get nodes -o jsonpath='{.items[*].status.nodeInfo.architecture}' # amd64 or arm64
$ARCH=x86_64 # amd64 nodes
$# ARCH=aarch64 # arm64 nodes

The interpreter is Python 3.13 on every architecture. A closure resolved for the wrong architecture passes --validate-only, which checks layout rather than wheel tags, and then fails on the cluster with has no wheels with a matching platform tag.

$pip download <requirements> --dest my-env/wheels \
> --only-binary=:all: \
> --python-version 3.13 \
> --platform "manylinux_2_39_$ARCH" \
> --platform "manylinux_2_28_$ARCH" \
> --platform "manylinux_2_17_$ARCH" \
> --platform "manylinux2014_$ARCH"

--platform is repeated because pip matches these tags literally rather than expanding a compatibility range. Name each tag you need, including older glibc floors.

Clear wheels/ before rebuilding into it. Copies overwrite by filename, so a wheel from an earlier run survives whenever the new closure resolved that project to a different version, and the package then vendors both.

Build the environment package

Pick the subsection matching the format you chose above. Each produces a directory you validate and then upload.

native-v1 — a Gym server tree, dependencies resolved at spin-up

For native-v1, the package is a slice of the Gym source tree with its directory structure preserved, plus a manifest you add at the root:

resources_servers/example_single_tool_call/
app.py
configs/example_single_tool_call.yaml
requirements.txt
nemo-environment.yaml
1format: native-v1
2config_paths:
3 - resources_servers/example_single_tool_call/configs/example_single_tool_call.yaml
4metadata:
5 name: example-single-tool-call

Gym’s own configs point datasets[].jsonl_fpath at a file inside the server directory. That file cannot ship in the environment package. Remove the data directory and supply prompts through the dataset FileSet instead.

native-v1 ships no wheels, so its server’s dependencies resolve from a package index at spin-up and the job needs egress. If that is not available on your cluster, package the same server tree as wheels-v1 instead and vendor the closure — see How dependencies are installed.

wheels-v1 — any environment, dependencies vendored

Use the same directory layout as native-v1 — the server’s own tree, copied from a Gym checkout — and add a wheels/ directory holding the full dependency closure so nothing is fetched at job start. config_paths may live anywhere in the package. This is the format to choose when the cluster has no egress. Layout, the closure contents, and a worked example: GRPO Environment Packages.

adapter-wheels-v1 — a verifiers environment, via the converter

A verifiers environment — a Prime Intellect hub package, for example — is the one case with a scripted path. pi-to-gym-conversion downloads the package, vendors its full wheel closure, writes the configs and manifest, and builds the prompt JSONL.

Run it on a machine with internet access. Training clusters have no hub egress and consume uploaded FileSets only. From a platform install the converter is on PATH; from this repository:

$uv run --package nmp-rl pi-to-gym-conversion \
> --hub-id primeintellect/ascii-tree \
> --hub-version 0.1.5 \
> --out-dir ./ascii-tree-pkg \
> --dataset-dir ./ascii-tree-data \
> --validation-fraction 0.1

Pin --hub-version. Left unset, the converter takes whatever the index offers at that moment; a later release can narrow Requires-Python and fail the download, or install but not run on the training image’s Python.

The converter writes:

ascii-tree-pkg/
nemo-environment.yaml
configs/policy_model.yaml # points Gym at the job's own vLLM engine
configs/verifiers_agent.yaml # vf_env_id, vf_env_args, max_tokens, temperature
wheels/*.whl
ascii-tree-data/
training.jsonl
validation.jsonl # only with --validation-fraction > 0

Add --upload (with NMP_BASE_URL set) to create both FileSets and upload them in the same command.

Every package needs a policy_model server

Gym server configs reference the model they roll out against by name, conventionally policy_model, and Gym resolves every reference when it merges configs — long before any rollout. If nothing in your config_paths defines that server, spin-up fails with ServerRefNotFoundError: ... Available responses_api_models: (none).

The converter writes this file for you. Hand-built packages must add it, in every format:

1policy_model:
2 responses_api_models:
3 vllm_model:
4 entrypoint: app.py
5 base_url: ${policy_base_url}
6 api_key: ${policy_api_key}
7 model: ${policy_model_name}
8 return_token_id_information: true
9 uses_reasoning_parser: true

The three interpolations resolve to the job’s vLLM endpoint. List this file first in config_paths.

FormatPath
adapter-wheels-v1, wheels-v1configs/policy_model.yaml
native-v1responses_api_models/vllm_model/configs/policy_model.yaml

For native-v1 only the server-type prefix is checked, so responses_api_models/policy_model/configs/policy_model.yaml works too — Gym runs the directory named in the YAML body, not the one the config file sits in.

Custom implementations need {server_type}/{implementation}/ (with app.py and requirements.txt) in the FileSet for native-v1 and wheels-v1. YAML under configs/ does not replace that directory, and a directory without requirements.txt or pyproject.toml is not recognised as a server at all. Details: GRPO Environment Packages.

pi-to-gym-conversion currently only vendors wheels for x86_64 today. On an arm64 cluster, build the closure yourself with the pip download command above and pass it via --wheels-dir.

Validate before uploading, whichever format you built

$uv run --package nmp-rl pi-to-gym-conversion --validate-only ./my-env-pkg

Despite the command’s name this validates any of the three formats, not just converter output. It prints {"valid": true, "format": "...", "name": "..."} or exits non-zero with the specific violation. The same checks run at submit time, so validating locally catches the failure earlier and for free.

Prompt dataset format

GRPO rows are rollout rows, not prompt/completion pairs and not preference triples. The prompt goes under responses_create_params.input, in OpenAI Responses API shape.

1{
2 "task_idx": 0,
3 "vf_env_id": "ascii-tree",
4 "responses_create_params": {"input": [{"role": "user", "content": "Draw a binary tree of depth 3."}]},
5 "agent_ref": {"type": "responses_api_agents", "name": "verifiers_agent"},
6 "question": "Draw a binary tree of depth 3.",
7 "answer": "",
8 "task": "ascii-tree",
9 "example_id": "ex-0",
10 "info": {}
11}
FieldRequiredMeaning
responses_create_paramsYesThe prompt. Messages go under input.
agent_refYesObject routing the row to an agent. name is the Gym instance in the environment YAML (converter: verifiers_agent).
vf_env_idverifiers environmentsPassed to verifiers.load_environment(). Set it to the manifest’s metadata.vf_env_id.
task_idxverifiers environmentsRow index.
answer, task, example_id, info, questionNoPassed through to the environment.

Extra keys are allowed and forwarded to the environment. Only the agent-agnostic fields are schema-checked, so vf_env_id and task_idx are not rejected when missing or mismatched — a wrong vf_env_id surfaces as the environment failing to load, not as a validation error.

Row shapes are checked inside the training container, not at submit. Submitting only verifies that the dataset FileSet contains training.jsonl and that the environment package’s manifest is valid, so a malformed row costs a job start. Validate rows locally before uploading.

Validation runs exactly one rollout per row of validation.jsonl — there is no validation counterpart to num_generations_per_prompt. To score a prompt k times, repeat its row k times.

Upload both FileSets

nemo files upload takes a directory and uploads it recursively, preserving relative paths:

$ENV=ascii-tree-env
$nemo files filesets create "$ENV" --workspace default --purpose environment --exist-ok
$nemo files upload ./ascii-tree-pkg/ "$ENV" --workspace default
$nemo files list "$ENV" --workspace default
$
$DATA=ascii-tree-env-dataset
$nemo files filesets create "$DATA" --workspace default --purpose dataset --exist-ok
$nemo files upload ./ascii-tree-data/ "$DATA" --workspace default

The trailing slash on the local path matters — it selects the directory’s contents rather than the directory itself. Without it everything nests one level deeper under the directory’s basename, which leaves the manifest off the FileSet root. Confirm with nemo files list before submitting.

--purpose environment is enforced, not cosmetic: submit rejects an environment FileSet whose purpose is anything other than environment or generic.

Relative paths must be preserved. config_paths is matched against the FileSet listing, so a package flattened during upload fails with config_paths reference files that are not in the package.

These are the names --upload would have used: it derives them from the hub slug as <slug>-env and <slug>-env-dataset. Pass --environment-name / --dataset-name to override.

Submit the job

There is no grpo subcommand. GRPO submits through nemo customization rl submit, selected by training.type.

1{
2 "model": "default/qwen3-0.6b",
3 "dataset": "default/ascii-tree-env-dataset",
4 "environment": "default/ascii-tree-env",
5 "training": {
6 "type": "grpo",
7 "epochs": 1,
8 "learning_rate": 1e-6,
9 "max_seq_length": 2048,
10 "batch_size": 32,
11 "micro_batch_size": 1,
12 "num_generations_per_prompt": 8,
13 "temperature": 1.0,
14 "parallelism": { "num_nodes": 1, "num_gpus_per_node": 1 }
15 },
16 "output": { "name": "qwen3-0.6b-ascii-tree" }
17}
$nemo customization rl submit ./job.json --workspace default

training.type is required — it is the union discriminator, and omitting it fails with union_tag_not_found rather than defaulting to DPO. model, dataset, and environment are plain string references.

Read the rl-<hex> job id from the name field of the submit response; rl submit has no --name flag.

Train an adapter instead of full weights

GRPO trains every weight by default. Set finetuning_type to train a LoRA adapter instead — useful when the full-weight run does not fit in memory, or when you want several environments’ worth of behaviour over one shared base deployment.

1{
2 "model": "default/qwen3-0.6b",
3 "dataset": "default/ascii-tree-env-dataset",
4 "environment": "default/ascii-tree-env",
5 "training": {
6 "type": "grpo",
7 "finetuning_type": "lora",
8 "lora": { "rank": 16, "alpha": 32, "dropout": 0.0 },
9 "epochs": 1,
10 "batch_size": 32,
11 "num_generations_per_prompt": 8
12 },
13 "output": { "name": "qwen3-0.6b-ascii-tree" }
14}
finetuning_typeTrainsProduces
all_weights (default)Every weightA model entity, which needs its own deployment
loraAn adapterAn HF PEFT adapter entity, registered against the base model
lora_mergedRejected. GRPO does not merge adapters at export.

You do not set the output type. It is inferred from finetuning_type, so output carries only name either way.

LoRA fields

FieldDefaultDescription
rank16LoRA rank.
alpha32Scaling factor. The effective learning-rate multiplier on the adapter is alpha / rank, so changing rank alone also changes the effective learning rate.
dropout0.0Dropout applied after the adapter.
target_modulesnullModule names to adapt.
exclude_modulesnullModule name patterns to skip.
use_tritonunset → compiler picksTriton LoRA kernels: true at tensor_parallel_size 1, false above. Leave it unset. Setting it true with tensor_parallel_size above 1 is rejected at submit, because the kernels cannot accept the sharded adapter weights tensor parallelism produces.

Module selection is either/or. Leaving both target_modules and exclude_modules unset adapts every linear layer. Setting either list turns that behavior off, so exclude_modules on its own does not mean “all linear layers except these.” Use target_modules when you want a specific set, and neither field when you want all of them.

Omitting the lora block while setting finetuning_type: "lora" is fine — defaults are filled in. Supplying a lora block alongside all_weights is rejected.

Using the adapter

The adapter hot-reloads onto a READY deployment of the base model that has lora_enabled: true, so there is no new deployment to create before evaluating it. Route inference through the provider gateway (/provider/<name>/-/v1 with model: default--<adapter>); the model-entity path always resolves to the base model.

A full-weight GRPO job instead registers a new model entity, which does need its own deployment.

Key hyperparameters

All GRPO knobs live under training, including policy_backend (a sibling of parallelism, not a field on it).

FieldDefaultNotes
num_generations_per_prompt8Group size. The spread of rewards within a group is the entire learning signal.
num_prompts_per_stepDerivedFrom batch_size / num_generations_per_prompt. Their product must be a multiple of batch_size, so prefer a value that divides batch_size evenly.
temperature1.0Must be greater than 0. Greedy sampling makes every response in a group identical and the run a no-op.
max_rollout_turns1Multi-turn rollouts. Most single-step environments use 1.
overlong_filteringfalseZero the loss contribution of responses truncated at the generation cap.
ref_policy_kl_penalty0.0KL coefficient against the reference policy.
val_at_startfalseRun validation before the first step, so baseline and result come from one job.
policy_backendautomodelThe NeMo-RL worker that trains the model, chosen explicitly and never inferred. Set it on training (for example training.policy_backend), not under training.parallelism. automodel supports LoRA, expert_parallel_size above 1, and automodel_kwargs, and needs Transformer Engine (Hopper or newer). dtensor runs stock HuggingFace modules on PyTorch FSDP2 for pre-Hopper GPUs, and supports none of those three. Requesting an automodel-only feature under dtensor is rejected at submit, with every conflict reported at once.
batching_strategydynamicHow rollouts are grouped into training micro-batches. dynamic fills each micro-batch to a token budget, so short rollouts share a batch instead of each paying for a full-length pad. static puts one rollout per slot. sequence_packing concatenates rollouts, and is rejected for VLM, multimodal, and context-parallel runs.
train_mb_tokensDerivedToken budget per micro-batch, read by dynamic and sequence_packing. Defaults to max_seq_length × micro_batch_size.
sequence_length_round64Round bucketed sequence lengths up to a multiple of this. Read only by dynamic.
router_aux_loss_coefUnsetMoE router auxiliary-loss coefficient, applied as a top-level HuggingFace config override. Set 0.0 to drop the load-balancing term during RL.
hf_config_overridesUnsetPassed to NeMo-RL verbatim and forwarded to the training model as config kwargs and to vLLM as hf_overrides. Nested keys are preserved, so use this for models that namespace their config — Qwen3.5 reads router_aux_loss_coef under text_config.
automodel_kwargsUnsetPassed through to Automodel. {"force_hf": true} loads stock HuggingFace modules when a model’s custom backbone is not compatible with the parallelizer. Requires policy_backend: automodel.

Setting router_aux_loss_coef and a router_aux_loss_coef key inside hf_config_overrides is rejected — they write the same top-level key. Keep one. Use hf_config_overrides when the model nests it, because a top-level key the model does not read is absorbed silently and the aux loss stays on, surfacing only as degraded accuracy many steps in.

Advanced clipping and advantage-estimation settings (ratio_clip_c, advantage_clip_low / advantage_clip_high, normalize_rewards, use_leave_one_out_baseline, top_k) are documented in Training Configuration.

Read the results

Monitor GRPO on reward, not loss. The GRPO surrogate loss oscillates near zero and carries no signal about run quality.

MetricRead it for
train_rewardMean reward over the step’s rollouts. The headline.
val_accuracyThe validation pass’s mean reward. NeMo-RL names it accuracy; it is not an accuracy in the classifier sense, and there is no val_reward.
train_truncation_rateRising truncation is a common reason reward stops improving
train_baseline_reward/pct_mixedShare of prompt groups whose responses disagreed. Falling toward zero means there is no gradient left to learn from
train_approx_entropyEntropy collapse — responses degenerate while reward still looks acceptable
train_token_mult_prob_error, train_sampling_importance_ratioDrift between the sampling policy and the training policy. Both sit near 1 on a healthy run.
train_timing/total_step_timeWall clock per training step, in seconds

status_details also carries two constants stated once when training starts: training_type (grpo or dpobackend is nemo_rl for both) and rollouts_per_step, so the rollouts generated so far is step × rollouts_per_step.

train_total_reward/stddev, /p25 and /p75 describe reward spread, but they are close to uninformative when the environment returns a binary 0/1 reward — which most verifier environments do. On a Bernoulli reward, stddev is a deterministic function of the mean and adds nothing to train_reward, and the quartiles collapse to three states: [0, 0] below mean reward 0.25, [0, 1] between 0.25 and 0.75, [1, 1] above. A p25 pinned at 0 for a whole run is correct, not a bug. Use baseline_reward/pct_0 / pct_1 / pct_mixed as the spread for binary rewards; keep the dispersion series for continuous or multi-component rewards.

Troubleshooting

MessageFix
Missing nemo-environment.yaml at environment rootPlace the manifest at the FileSet root, not in a subdirectory
config_paths reference files that are not in the packageRe-upload preserving relative paths
Prompt JSONL must not live in the environment packageMove rows to the dataset FileSet
native-v1 config_paths must be under (...)Move the config under a Gym server directory, or switch format
adapter-wheels-v1 config_paths should live under configs/Move the config under configs/
must carry a non-empty wheels/ directoryVendor the dependency closure
adapter.agent ... is not built into the training imageUse a supported harness, or switch format
ServerRefNotFoundError: ... Available responses_api_models: (none)A config references a model server nothing defines — include configs/policy_model.yaml
Environment fileset ... has purpose 'dataset'; expected purpose='environment'Recreate the fileset with --purpose environment
AlmostServerError, after an “Almost-Servers Detected” bannerA server block failed validation — most often a domain that is missing, empty, or not in the closed set. Set a valid domain on every resources_servers block
Missing pyproject.toml or requirements.txt for uv venv setup in server dirA custom implementation directory has no install marker. Add requirements.txt
The job runs but scores an environment you did not shipSame missing install marker, on a directory whose name matches a Gym built-in — the built-in was used instead. Add requirements.txt, or rename the implementation
ModuleNotFoundError at the first rolloutThe server’s venv is missing an import: an empty or incomplete requirements.txt, or a wheelhouse that did not cover the venv build
your requirements are unsatisfiablewheels/ vendors two versions of one project; clear it and rebuild the closure
OpenSandbox is not yet available on this clusterOperator setting. The cluster has not enabled sandboxed Gym — see Cluster prerequisites
Sandboxed GRPO requires the job-storage PVC claim nameOperator setting, same section
has no wheels with a matching platform tagThe closure was built for the wrong interpreter or architecture — rebuild per Vendoring a wheel closure
Spin-up hangs, then fails on a network or resolver errorThe environment’s venv build cannot reach an index. See How dependencies are installed

Cluster prerequisites

GRPO fails at submit, before any GPU is claimed, unless the platform operator has configured sandboxed Gym. Both settings live on the platform, not in the job JSON:

SettingWhy
NMP_SANDBOX_CLUSTER_CAPABLE=true (Helm: sandboxClusterCapable)Declares that OpenSandbox is installed. The platform chart does not install it. GRPO defaults to sandboxed Gym (NMP_RL_SANDBOXED_GYM_DEFAULT) and fails closed without this
NMP_RL_JOB_STORAGE_PVC_CLAIMThe Gym sandbox mounts the job-storage claim itself to read the downloaded environment and dataset, and only learns the claim by name
NMP_RL_SANDBOX_ALLOW_INTERNETNeeded whenever an environment’s venv build has to reach a package index — which is every native-v1 package, and every adapter-wheels-v1 one
NMP_RL_SANDBOX_PUBLIC_DNS_ALLOWExtra hosts, consulted only when the above is on. The built-in allowance covers *.com and *.org, so a host on another TLD (for example hub.primeintellect.ai) must be named

If a submit fails on either of the first two, that is a platform configuration gap, not a problem with your package. Neither is settable per job.

Installing OpenSandbox is a separate operator task: OpenSandbox, or OpenSandbox with Kata for the Kata runtime.

Next Steps