Reinforcement Learning Training#

This tutorial shows you how to train reinforcement learning agents using EmbodiChain’s RL framework. You’ll learn how to configure training via JSON or YAML, set up environments, policies, and algorithms, and launch training sessions.

Overview#

The RL framework provides a modular, extensible stack for robotics tasks:

  • Trainer: Orchestrates the training loop (calls algorithm for data collection and updates, handles logging/eval/save)

  • Algorithm: Controls data collection process (interacts with environment, fills buffer, computes advantages/returns) and updates the policy (e.g., PPO)

  • Policy: Neural network models implementing a unified interface (get_action/get_value/evaluate_actions)

  • Buffer: On-policy rollout storage and minibatch iterator (managed by algorithm)

  • Env Factory: Build environments from a JSON or YAML config via registry

Architecture#

The framework follows a clean separation of concerns:

  • Trainer: Orchestrates the training loop (calls algorithm for data collection and updates, handles logging/eval/save)

  • Algorithm: Controls data collection process (interacts with environment, fills buffer, computes advantages/returns) and updates the policy (e.g., PPO)

  • Policy: Neural network models implementing a unified interface

  • Buffer: On-policy rollout storage and minibatch iterator (managed by algorithm)

  • Env Factory: Build environments from a JSON or YAML config via registry

The core components and their relationships:

  • Trainer → Policy, Env, Algorithm (via callbacks for statistics)

  • Algorithm → Policy, RolloutBuffer (algorithm manages its own buffer)

Configuration via JSON or YAML#

Training is configured via a JSON or YAML file that defines runtime settings, environment, policy, and algorithm parameters. EmbodiChain loads either format with load_config(). Two environment sources are supported:

  • trainer.gym_config: simulator EmbodiedEnv tasks such as CartPole and PushCube

  • trainer.learning_env: lightweight registered tensor environments such as PointMass

algorithm.name selects the update rule. Standard algorithms (PPO/GRPO) use Trainer and SyncCollector. Differentiable algorithms (APG) use DifferentiableTrainer and DifferentiableCollector. Both paths share evaluate_episodes() for deterministic evaluation metrics under eval/*.

Example Configuration#

Training configurations are task-local: PushCube uses embodichain_tasks/configs/tasks/manipulation/push_cube/agents and CartPole uses embodichain_tasks/configs/tasks/classic_control/cart_pole/agents. PointMass APG/PPO configs live under embodichain_tasks/configs/tasks/classic_control/point_mass/agents:

Example: train_config.json
 1{
 2    "trainer": {
 3        "exp_name": "push_cube_ppo",
 4        "gym_config": "embodichain_tasks/configs/tasks/manipulation/push_cube/env.json",
 5        "seed": 42,
 6        "device": "cuda:0",
 7        "headless": true,
 8        "gpu_id": 0,
 9        "num_envs": 64,
10        "iterations": 1000,
11        "buffer_size": 1024,
12        "enable_eval": true,
13        "num_eval_envs": 16,
14        "num_eval_episodes": 3,
15        "eval_freq": 100,
16        "save_freq": 100,
17        "use_wandb": true,
18        "wandb_project_name": "embodichain-push_cube",
19        "events": {
20            "eval": {
21                "record_camera": {
22                    "func": "record_camera_data_async",
23                    "mode": "interval",
24                    "interval_step": 1,
25                    "params": {
26                        "name": "main_cam",
27                        "resolution": [640, 480],
28                        "eye": [-1.4, 1.4, 2.0],
29                        "target": [0, 0, 0],
30                        "up": [0, 0, 1],
31                        "intrinsics": [600, 600, 320, 240],
32                        "save_path": "./outputs/videos_ppo1/eval"
33                    }
34                }
35            }
36        },
37        "renderer": "hybrid"
38    },
39    "policy": {
40        "name": "actor_critic",
41        "actor": {
42            "type": "mlp",
43            "network_cfg": {
44                "hidden_sizes": [
45                    256,
46                    256
47                ],
48                "activation": "relu"
49            }
50        },
51        "critic": {
52            "type": "mlp",
53            "network_cfg": {
54                "hidden_sizes": [
55                    256,
56                    256
57                ],
58                "activation": "relu"
59            }
60        }
61    },
62    "algorithm": {
63        "name": "ppo",
64        "cfg": {
65            "optimizer": {
66                "name": "adam",
67                "learning_rate": 0.0001
68            },
69            "n_epochs": 10,
70            "batch_size": 8192,
71            "gamma": 0.99,
72            "gae_lambda": 0.95,
73            "clip_coef": 0.2,
74            "ent_coef": 0.01,
75            "vf_coef": 0.5,
76            "max_grad_norm": 0.5
77        }
78    }
79}
Example: train_config.yaml (CartPole)
 1trainer:
 2  exp_name: cart_pole_ppo
 3  gym_config: embodichain_tasks/configs/tasks/classic_control/cart_pole/env.yaml
 4  seed: 42
 5  device: cuda:0
 6  headless: true
 7  num_envs: 64
 8  iterations: 1000
 9  buffer_size: 1024
10  eval_freq: 200
11  save_freq: 200
12  use_wandb: false
13  wandb_project_name: embodichain-cart_pole
14  events:
15    eval:
16      record_camera:
17        func: record_camera_data_async
18        mode: interval
19        interval_step: 1
20        params:
21          name: main_cam
22          resolution:
23          - 640
24          - 480
25          eye:
26          - -1.4
27          - 1.4
28          - 2.5
29          target:
30          - 0
31          - 0
32          - 0.7
33          up:
34          - 0
35          - 0
36          - 1
37          intrinsics:
38          - 600
39          - 600
40          - 320
41          - 240
42          save_path: ./outputs/videos/eval
43  renderer: fast-rt
44policy:
45  name: actor_critic
46  actor:
47    type: mlp
48    network_cfg:
49      hidden_sizes:
50      - 256
51      - 256
52      activation: relu
53  critic:
54    type: mlp
55    network_cfg:
56      hidden_sizes:
57      - 256
58      - 256
59      activation: relu
60algorithm:
61  name: ppo
62  cfg:
63    optimizer:
64      name: adam
65      learning_rate: 0.0001
66    n_epochs: 10
67    batch_size: 8192
68    gamma: 0.99
69    gae_lambda: 0.95
70    clip_coef: 0.2
71    ent_coef: 0.01
72    vf_coef: 0.5
73    max_grad_norm: 0.5
Example: train_apg.yaml (PointMass)
 1trainer:
 2  exp_name: point_mass_apg
 3  learning_env:
 4    name: PointMassRL
 5    cfg:
 6      max_episode_steps: 100
 7      success_threshold: 0.03
 8  seed: 42
 9  device: cuda:0
10  num_envs: 64
11  iterations: 500
12  segment_length: 100
13  update_horizon: 100
14  enable_eval: true
15  eval_freq: 32000
16  num_eval_envs: 32
17  num_eval_episodes: 128
18  eval_seed: 10042
19  best_eval_metric: eval/success_rate
20  best_eval_mode: max
21  save_frequency_updates: 50
22  use_wandb: false
23  wandb_project_name: embodichain-point-mass
24
25policy:
26  name: actor_only
27  initial_log_std: -2.0
28  actor:
29    type: mlp
30    network_cfg:
31      hidden_sizes: [64, 64]
32      activation: tanh
33      last_activation: tanh
34      orthogonal_init: [1.414, 1.414, 0.01]
35
36algorithm:
37  name: apg
38  cfg:
39    optimizer:
40      name: adam
41      learning_rate: 0.00025
42    gamma: 0.99
43    max_grad_norm: 0.5
44    ent_coef: 0.0

PointMass uses one differentiable PyTorch dynamics implementation for both APG and PPO. Launch either config with the same CLI:

embodichain train-rl --config embodichain_tasks/configs/tasks/classic_control/point_mass/agents/apg.yaml
embodichain train-rl --config embodichain_tasks/configs/tasks/classic_control/point_mass/agents/ppo.yaml

Configuration Sections#

Runtime Settings#

The trainer section controls experiment setup:

  • exp_name: Experiment name (used for output directories)

  • seed: Random seed for reproducibility

  • device: Runtime device string, e.g. "cpu" or "cuda:0"

  • headless: Whether to run simulation in headless mode

  • iterations: Number of training iterations

  • buffer_size: Steps collected per rollout (e.g., 1024)

  • eval_freq: Frequency of evaluation (in steps)

  • save_freq: Frequency of checkpoint saving (in steps)

  • use_wandb: Whether to enable Weights & Biases logging (set in the config file)

  • wandb_project_name: Weights & Biases project name

Environment Configuration#

The env section defines the task environment:

  • id: Environment registry ID (e.g., “PushCubeRL”)

  • cfg: Environment-specific configuration parameters

For RL environments, use the actions field for action preprocessing and extensions for task-specific parameters:

  • actions: Action Manager config (e.g., DeltaQposTerm with scale)

  • extensions: Task-specific parameters (e.g., success_threshold)

Example:

"env": {
  "id": "PushCubeRL",
  "cfg": {
    "num_envs": 4,
    "actions": {
      "delta_qpos": {
        "func": "DeltaQposTerm",
        "params": { "scale": 0.1 }
      }
    },
    "extensions": {
      "success_threshold": 0.1
    }
  }
}

Policy Configuration#

The policy section defines the neural network policy:

  • name: Policy name (e.g., “actor_critic”, “vla”)

  • action_dim: Optional policy output action dimension. If omitted, it is inferred from env.action_space.

  • actor: Actor network configuration (required for actor_critic)

  • critic: Critic network configuration (required for actor_critic)

Example:

"policy": {
  "name": "actor_critic",
  "actor": {
    "type": "mlp",
    "network_cfg": {
      "hidden_sizes": [256, 256],
      "activation": "relu"
    }
  },
  "critic": {
    "type": "mlp",
    "network_cfg": {
      "hidden_sizes": [256, 256],
      "activation": "relu"
    }
  }
}

Algorithm Configuration#

The algorithm section defines the RL algorithm:

  • name: Algorithm name (e.g., “ppo”, “grpo”)

  • cfg: Algorithm-specific hyperparameters

PPO example:

"algorithm": {
  "name": "ppo",
  "cfg": {
    "learning_rate": 0.0001,
    "n_epochs": 10,
    "batch_size": 64,
    "gamma": 0.99,
    "gae_lambda": 0.95,
    "clip_coef": 0.2,
    "ent_coef": 0.01,
    "vf_coef": 0.5,
    "max_grad_norm": 0.5
  }
}

GRPO example (for Embodied AI / from-scratch training, e.g. CartPole):

"algorithm": {
  "name": "grpo",
  "cfg": {
    "learning_rate": 0.0001,
    "n_epochs": 10,
    "batch_size": 8192,
    "gamma": 0.99,
    "clip_coef": 0.2,
    "ent_coef": 0.001,
    "kl_coef": 0,
    "group_size": 4,
    "eps": 1e-8,
    "reset_every_rollout": true,
    "max_grad_norm": 0.5,
    "truncate_at_first_done": true
  }
}

For GRPO: use actor_only policy. Set kl_coef=0 for from-scratch training; kl_coef=0.02 for VLA/LLM fine-tuning.

Training Script#

The training script (train.py) is located in embodichain/learning/rl/:

Code for train.py
  1# ----------------------------------------------------------------------------
  2# Copyright (c) 2021-2026 DexForce Technology Co., Ltd.
  3#
  4# Licensed under the Apache License, Version 2.0 (the "License");
  5# you may not use this file except in compliance with the License.
  6# You may obtain a copy of the License at
  7#
  8#     http://www.apache.org/licenses/LICENSE-2.0
  9#
 10# Unless required by applicable law or agreed to in writing, software
 11# distributed under the License is distributed on an "AS IS" BASIS,
 12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 13# See the License for the specific language governing permissions and
 14# limitations under the License.
 15# ----------------------------------------------------------------------------
 16
 17from __future__ import annotations
 18
 19import argparse
 20import os
 21import random
 22import time
 23from collections.abc import Sequence
 24from pathlib import Path
 25
 26import numpy as np
 27import torch
 28import wandb
 29from torch.utils.tensorboard import SummaryWriter
 30from copy import deepcopy
 31
 32from embodichain.learning.rl.models import build_policy, get_registered_policy_names
 33from embodichain.learning.rl.models import build_mlp_from_cfg
 34from embodichain.learning.rl.algo import (
 35    RolloutKind,
 36    build_algo,
 37    get_registered_algo_names,
 38)
 39from embodichain.learning.rl.differentiable_trainer import (
 40    DifferentiableTrainer,
 41    DifferentiableTrainerCfg,
 42)
 43from embodichain.learning.rl.env import build_learning_env
 44from embodichain.learning.rl.routing import get_trainer_class
 45from embodichain.learning.rl.utils import dict_to_tensordict, flatten_dict_observation
 46from embodichain.learning.rl.utils.trainer import Trainer
 47from embodichain.utils import logger
 48from embodichain.lab.gym.utils.registration import (
 49    build_env,
 50    discover_task_packages,
 51    execute_init_hooks,
 52)
 53from embodichain.lab.gym.utils.gym_utils import config_to_cfg, get_manager_modules
 54from embodichain.lab.gym.utils.profiler import EnvProfilerCfg
 55from embodichain.utils.utility import load_config
 56from embodichain.utils.module_utils import find_function_from_modules
 57from embodichain.lab.sim import SimulationManagerCfg
 58from embodichain.lab.sim.cfg import RenderCfg
 59from embodichain.lab.gym.envs.managers.cfg import EventCfg
 60
 61
 62def _seed_training_rng(seed: int, device: torch.device) -> None:
 63    """Seed policy/trainer RNGs without changing deterministic-kernel settings."""
 64    random.seed(seed)
 65    np.random.seed(seed)
 66    torch.manual_seed(seed)
 67    if device.type == "cuda":
 68        torch.cuda.manual_seed_all(seed)
 69
 70
 71def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
 72    """Parse command-line arguments.
 73
 74    Args:
 75        argv: Arguments excluding the command name. Uses ``sys.argv`` when
 76            omitted.
 77
 78    Returns:
 79        Parsed training arguments.
 80    """
 81    parser = argparse.ArgumentParser(
 82        prog="embodichain train-rl",
 83        description="Train an RL agent from a JSON or YAML config.",
 84    )
 85    parser.add_argument(
 86        "--config",
 87        type=str,
 88        required=True,
 89        help="Path to training config file (.json, .yaml, or .yml).",
 90    )
 91    parser.add_argument(
 92        "--distributed",
 93        action=argparse.BooleanOptionalAction,
 94        default=None,
 95        help="Enable or disable multi-GPU distributed training",
 96    )
 97    parser.add_argument(
 98        "--profile",
 99        action="store_true",
100        default=False,
101        help=(
102            "Enable per-section time profiling of gym env reset/step "
103            "(report on env.close()). Requires trainer.gym_config."
104        ),
105    )
106    parser.add_argument(
107        "--profile_output",
108        type=str,
109        default=None,
110        help="Dump the profiling report as JSON on env.close() (requires --profile).",
111    )
112    return parser.parse_args(argv)
113
114
115def _resolve_profile_output(
116    path: str | None,
117    *,
118    rank: int,
119    world_size: int,
120) -> str | None:
121    if path is None or world_size <= 1:
122        return path
123    output = Path(path)
124    return str(output.with_name(f"{output.stem}_rank{rank}{output.suffix}"))
125
126
127def _build_learning_policy(
128    policy_block: dict,
129    env,
130    device: torch.device,
131):
132    obs_dim = int(env.single_observation_space.shape[-1])
133    action_dim = int(env.single_action_space.shape[-1])
134    policy_name = policy_block["name"].lower()
135    actor_cfg = policy_block.get("actor")
136    critic_cfg = policy_block.get("critic")
137    actor = (
138        build_mlp_from_cfg(actor_cfg, obs_dim, action_dim)
139        if actor_cfg is not None
140        else None
141    )
142    critic = (
143        build_mlp_from_cfg(critic_cfg, obs_dim, 1) if critic_cfg is not None else None
144    )
145    policy = build_policy(
146        policy_block,
147        env.single_observation_space,
148        env.single_action_space,
149        device,
150        actor=actor,
151        critic=critic,
152    )
153    if "initial_log_std" in policy_block and hasattr(policy, "log_std"):
154        with torch.no_grad():
155            policy.log_std.fill_(float(policy_block["initial_log_std"]))
156    return policy
157
158
159def _train_learning_env(
160    cfg_data: dict,
161    *,
162    distributed: bool | None,
163    profile: bool = False,
164):
165    """Train a lightweight registered environment through the unified CLI."""
166    if profile:
167        raise ValueError(
168            "--profile requires trainer.gym_config; learning_env is unsupported."
169        )
170    trainer_cfg = cfg_data["trainer"]
171    policy_block = cfg_data["policy"]
172    algorithm_block = cfg_data["algorithm"]
173    distributed = (
174        bool(trainer_cfg.get("distributed", False))
175        if distributed is None
176        else distributed
177    )
178    if distributed:
179        raise ValueError(
180            "Learning environments do not yet support distributed training."
181        )
182
183    discover_task_packages()
184    execute_init_hooks()
185    seed = int(trainer_cfg.get("seed", 1))
186    device = torch.device(trainer_cfg.get("device", "cpu"))
187    if device.type == "cuda" and not torch.cuda.is_available():
188        raise ValueError("CUDA was requested but is not available.")
189    if device.type == "cuda":
190        torch.cuda.set_device(device)
191        torch.cuda.manual_seed_all(seed)
192    np.random.seed(seed)
193    torch.manual_seed(seed)
194
195    env_block = trainer_cfg["learning_env"]
196    if isinstance(env_block, str):
197        env_name = env_block
198        env_cfg = {}
199    else:
200        env_name = env_block["name"]
201        env_cfg = dict(env_block.get("cfg", {}))
202    num_envs = int(trainer_cfg.get("num_envs", 64))
203    env = build_learning_env(
204        env_name,
205        num_envs=num_envs,
206        device=device,
207        **env_cfg,
208    )
209
210    enable_eval = bool(trainer_cfg.get("enable_eval", False))
211    eval_env = None
212    if enable_eval:
213        eval_env = build_learning_env(
214            env_name,
215            num_envs=int(trainer_cfg.get("num_eval_envs", 16)),
216            device=device,
217            **env_cfg,
218        )
219
220    policy = _build_learning_policy(policy_block, env, device)
221    algorithm = build_algo(
222        algorithm_block["name"],
223        dict(algorithm_block.get("cfg", {})),
224        policy,
225        device,
226    )
227    trainer_class = get_trainer_class(algorithm)
228
229    exp_name = trainer_cfg.get("exp_name", f"{env_name}_{algorithm_block['name']}")
230    run_stamp = time.strftime("%Y%m%d_%H%M%S")
231    run_base = Path("outputs") / f"{exp_name}_{run_stamp}"
232    log_dir = run_base / "logs" / exp_name
233    checkpoint_dir = run_base / "checkpoints"
234    checkpoint_dir.mkdir(parents=True, exist_ok=True)
235    writer = SummaryWriter(str(log_dir))
236    use_wandb = bool(trainer_cfg.get("use_wandb", False))
237    if use_wandb:
238        wandb.init(
239            project=trainer_cfg.get("wandb_project_name", "embodichain-generic"),
240            name=exp_name,
241            config=cfg_data,
242        )
243
244    eval_freq = int(trainer_cfg.get("eval_freq", 0)) if enable_eval else 0
245    eval_seed = int(trainer_cfg.get("eval_seed", seed + 10_000))
246    iterations = int(trainer_cfg.get("iterations", 250))
247    try:
248        if trainer_class is DifferentiableTrainer:
249            segment_length = int(trainer_cfg.get("segment_length", 16))
250            update_horizon = int(trainer_cfg.get("update_horizon", segment_length))
251            diff_cfg = DifferentiableTrainerCfg(
252                segment_length=segment_length,
253                update_horizon=update_horizon,
254                deterministic_actions=bool(
255                    trainer_cfg.get("deterministic_actions", False)
256                ),
257                checkpoint_dir=str(checkpoint_dir),
258                experiment_name=exp_name,
259                save_frequency_updates=int(
260                    trainer_cfg.get("save_frequency_updates", 0)
261                ),
262                eval_frequency_steps=eval_freq,
263                num_eval_episodes=int(trainer_cfg.get("num_eval_episodes", 5)),
264                eval_seed=eval_seed,
265                use_wandb=use_wandb,
266                best_eval_metric=trainer_cfg.get("best_eval_metric", "eval/avg_reward"),
267                best_eval_mode=trainer_cfg.get("best_eval_mode", "max"),
268            )
269            trainer = DifferentiableTrainer(
270                cfg=diff_cfg,
271                env=env,
272                policy=policy,
273                algorithm=algorithm,
274                writer=writer,
275                eval_env=eval_env,
276            )
277            default_steps = iterations * update_horizon * num_envs
278        else:
279            buffer_size = int(
280                trainer_cfg.get("buffer_size", trainer_cfg.get("rollout_steps", 256))
281            )
282            trainer = Trainer(
283                policy=policy,
284                env=env,
285                algorithm=algorithm,
286                buffer_size=buffer_size,
287                batch_size=int(algorithm.cfg.batch_size),
288                writer=writer,
289                eval_freq=eval_freq,
290                save_freq=int(trainer_cfg.get("save_freq", 0)),
291                checkpoint_dir=str(checkpoint_dir),
292                exp_name=exp_name,
293                use_wandb=use_wandb,
294                eval_env=eval_env,
295                num_eval_episodes=int(trainer_cfg.get("num_eval_episodes", 5)),
296                eval_seed=eval_seed,
297                best_eval_metric=trainer_cfg.get("best_eval_metric", "eval/avg_reward"),
298                best_eval_mode=trainer_cfg.get("best_eval_mode", "max"),
299            )
300            default_steps = iterations * buffer_size * num_envs
301        total_timesteps = int(trainer_cfg.get("total_timesteps", default_steps))
302        trainer.train(total_timesteps)
303        trainer.save_checkpoint()
304        return trainer.get_summary()
305    finally:
306        writer.close()
307        if use_wandb:
308            wandb.finish()
309        env.close()
310        if eval_env is not None:
311            eval_env.close()
312
313
314def train_from_config(
315    config_path: str,
316    distributed: bool | None = None,
317    *,
318    profile: bool = False,
319    profile_output: str | None = None,
320):
321    """Run training from a config file path.
322
323    Args:
324        config_path: Path to the training config file (.json, .yaml, or .yml).
325        distributed: If True, run multi-GPU distributed training.
326            If None, use trainer.distributed from config.
327        profile: Enable gym ``EnvProfiler`` on the training environment.
328        profile_output: Optional JSON dump path for the profiling report.
329    """
330    if profile_output is not None and not profile:
331        raise ValueError("--profile_output requires --profile.")
332
333    cfg_data = load_config(config_path)
334
335    trainer_cfg = cfg_data["trainer"]
336    if "learning_env" in trainer_cfg:
337        return _train_learning_env(
338            cfg_data,
339            distributed=distributed,
340            profile=profile,
341        )
342    policy_block = cfg_data["policy"]
343    algo_block = cfg_data["algorithm"]
344
345    if distributed is None:
346        distributed = bool(trainer_cfg.get("distributed", False))
347
348    rank = 0
349    world_size = 1
350    local_rank = 0
351    if distributed:
352        if not torch.distributed.is_available():
353            raise RuntimeError(
354                "Distributed training requested but torch.distributed is not available."
355            )
356        if not torch.cuda.is_available():
357            raise RuntimeError(
358                "Distributed training with NCCL backend requires CUDA, "
359                "but torch.cuda.is_available() is False."
360            )
361        local_rank = int(os.environ.get("LOCAL_RANK", 0))
362        if local_rank < 0 or local_rank >= torch.cuda.device_count():
363            raise ValueError(
364                f"LOCAL_RANK {local_rank} is out of range "
365                f"(available GPUs: {torch.cuda.device_count()})."
366            )
367        torch.cuda.set_device(local_rank)
368        if not torch.distributed.is_initialized():
369            torch.distributed.init_process_group(backend="nccl")
370        rank = torch.distributed.get_rank()
371        world_size = torch.distributed.get_world_size()
372
373    exp_name = trainer_cfg.get("exp_name", "generic_exp")
374    seed = int(trainer_cfg.get("seed", 1))
375    device_str = trainer_cfg.get("device", "cpu")
376    if distributed:
377        device_str = f"cuda:{local_rank}"
378    iterations = int(trainer_cfg.get("iterations", 250))
379    buffer_size = int(
380        trainer_cfg.get("buffer_size", trainer_cfg.get("rollout_steps", 2048))
381    )
382    enable_eval = bool(trainer_cfg.get("enable_eval", False))
383    eval_freq = int(trainer_cfg.get("eval_freq", 10000))
384    save_freq = int(trainer_cfg.get("save_freq", 50000))
385    num_eval_episodes = int(trainer_cfg.get("num_eval_episodes", 5))
386    eval_seed = int(trainer_cfg.get("eval_seed", seed + 10_000))
387    headless = bool(trainer_cfg.get("headless", True))
388    renderer = trainer_cfg.get("renderer", "hybrid")
389    gpu_id = int(trainer_cfg.get("gpu_id", 0))
390    num_envs = trainer_cfg.get("num_envs", None)
391    wandb_project_name = trainer_cfg.get("wandb_project_name", "embodichain-generic")
392
393    if not isinstance(device_str, str):
394        raise ValueError(
395            f"runtime.device must be a string such as 'cpu' or 'cuda:0'. Got: {device_str!r}"
396        )
397    try:
398        device = torch.device(device_str)
399    except RuntimeError as exc:
400        raise ValueError(
401            f"Failed to parse runtime.device='{device_str}': {exc}"
402        ) from exc
403
404    if device.type == "cuda":
405        if not torch.cuda.is_available():
406            raise ValueError(
407                "CUDA device requested but torch.cuda.is_available() is False."
408            )
409        index = (
410            device.index if device.index is not None else torch.cuda.current_device()
411        )
412        device_count = torch.cuda.device_count()
413        if index < 0 or index >= device_count:
414            raise ValueError(
415                f"CUDA device index {index} is out of range (available devices: {device_count})."
416            )
417        torch.cuda.set_device(index)
418        device = torch.device(f"cuda:{index}")
419    elif device.type != "cpu":
420        raise ValueError(f"Unsupported device type: {device}")
421    if rank == 0:
422        logger.log_info(f"Device: {device}")
423    if distributed and rank == 0:
424        logger.log_info(f"Distributed training: world_size={world_size}")
425
426    # Seeds
427    effective_seed = seed + rank
428    _seed_training_rng(effective_seed, device)
429    torch.backends.cudnn.deterministic = True
430
431    # Outputs
432    if distributed:
433        run_stamp = time.strftime("%Y%m%d_%H%M%S") if rank == 0 else None
434        run_stamp_list = [run_stamp]
435        torch.distributed.broadcast_object_list(run_stamp_list, src=0)
436        run_stamp = run_stamp_list[0]
437    else:
438        run_stamp = time.strftime("%Y%m%d_%H%M%S")
439    run_base = os.path.join("outputs", f"{exp_name}_{run_stamp}")
440    log_dir = os.path.join(run_base, "logs")
441    checkpoint_dir = os.path.join(run_base, "checkpoints")
442    if rank == 0:
443        os.makedirs(log_dir, exist_ok=True)
444        os.makedirs(checkpoint_dir, exist_ok=True)
445    writer = SummaryWriter(f"{log_dir}/{exp_name}") if rank == 0 else None
446
447    # Initialize Weights & Biases (optional)
448    use_wandb = trainer_cfg.get("use_wandb", False)
449    if use_wandb and rank == 0:
450        wandb.init(project=wandb_project_name, name=exp_name, config=cfg_data)
451
452    gym_config_path = Path(trainer_cfg["gym_config"])
453    if rank == 0:
454        logger.log_info(f"Current working directory: {Path.cwd()}")
455
456    gym_config_data = load_config(str(gym_config_path))
457    gym_env_cfg = config_to_cfg(gym_config_data, manager_modules=get_manager_modules())
458    gym_env_cfg.seed = effective_seed
459    if num_envs is not None:
460        gym_env_cfg.num_envs = int(num_envs)
461
462    # Ensure sim configuration mirrors runtime overrides
463    if gym_env_cfg.sim_cfg is None:
464        gym_env_cfg.sim_cfg = SimulationManagerCfg()
465    if device.type == "cuda":
466        gpu_index = device.index
467        if gpu_index is None:
468            gpu_index = torch.cuda.current_device()
469        gym_env_cfg.sim_cfg.sim_device = torch.device(f"cuda:{gpu_index}")
470        if hasattr(gym_env_cfg.sim_cfg, "gpu_id"):
471            gym_env_cfg.sim_cfg.gpu_id = gpu_index
472    else:
473        gym_env_cfg.sim_cfg.sim_device = torch.device("cpu")
474    gym_env_cfg.sim_cfg.headless = headless
475    gym_env_cfg.sim_cfg.render_cfg = RenderCfg(renderer=renderer)
476    gym_env_cfg.sim_cfg.gpu_id = gpu_id
477    if profile:
478        gym_env_cfg.profiler = EnvProfilerCfg(
479            enable_time=True,
480            output_path=_resolve_profile_output(
481                profile_output,
482                rank=rank,
483                world_size=world_size,
484            ),
485        )
486    if rank == 0:
487        logger.log_info(
488            f"Loaded gym_config from {gym_config_path} (env_id={gym_config_data['id']}, num_envs={gym_env_cfg.num_envs}, headless={gym_env_cfg.sim_cfg.headless}, renderer={gym_env_cfg.sim_cfg.render_cfg.renderer}, sim_device={gym_env_cfg.sim_cfg.sim_device})"
489        )
490
491    env = build_env(gym_config_data["id"], base_env_cfg=gym_env_cfg)
492    sample_obs, _ = env.reset(seed=effective_seed)
493    sample_obs_td = dict_to_tensordict(sample_obs, device)
494    obs_dim = flatten_dict_observation(sample_obs_td).shape[-1]
495    flat_obs_space = env.flattened_observation_space
496
497    # Create evaluation environment only if enabled
498    eval_env = None
499    num_eval_envs = trainer_cfg.get("num_eval_envs", 4)
500    if enable_eval and rank == 0:
501        eval_gym_env_cfg = deepcopy(gym_env_cfg)
502        eval_gym_env_cfg.num_envs = num_eval_envs
503        eval_gym_env_cfg.seed = eval_seed
504        eval_gym_env_cfg.sim_cfg.headless = True
505        eval_gym_env_cfg.profiler = None
506        eval_env = build_env(gym_config_data["id"], base_env_cfg=eval_gym_env_cfg)
507        logger.log_info(
508            f"Evaluation environment created (num_envs={num_eval_envs}, headless=True)"
509        )
510
511    # Environment construction intentionally uses task/evaluation seeds. Reset
512    # the trainer stream so policy initialization is independent of scene work.
513    _seed_training_rng(effective_seed, device)
514
515    # Build Policy via registry
516    policy_name = policy_block["name"]
517    env_action_dim = (
518        env.get_wrapper_attr("action_manager").total_action_dim
519        if env.get_wrapper_attr("action_manager") is not None
520        else len(env.get_wrapper_attr("active_joint_ids"))
521    )
522    action_dim = policy_block.get("action_dim", env_action_dim)
523    action_dim = int(action_dim)
524    if action_dim != env_action_dim:
525        raise ValueError(
526            f"Configured policy.action_dim={action_dim} does not match env action dim {env_action_dim}."
527        )
528    # Build Policy via registry (actor/critic must be explicitly defined in JSON when using actor_critic/actor_only)
529    if policy_name.lower() == "actor_critic":
530        actor_cfg = policy_block.get("actor")
531        critic_cfg = policy_block.get("critic")
532        if actor_cfg is None or critic_cfg is None:
533            raise ValueError(
534                "ActorCritic requires 'actor' and 'critic' definitions in JSON (policy.actor / policy.critic)."
535            )
536
537        actor = build_mlp_from_cfg(actor_cfg, obs_dim, action_dim)
538        critic = build_mlp_from_cfg(critic_cfg, obs_dim, 1)
539
540        policy = build_policy(
541            policy_block,
542            flat_obs_space,
543            env.action_space,
544            device,
545            actor=actor,
546            critic=critic,
547        )
548    elif policy_name.lower() == "actor_only":
549        actor_cfg = policy_block.get("actor")
550        if actor_cfg is None:
551            raise ValueError(
552                "ActorOnly requires 'actor' definition in JSON (policy.actor)."
553            )
554
555        actor = build_mlp_from_cfg(actor_cfg, obs_dim, action_dim)
556
557        policy = build_policy(
558            policy_block,
559            flat_obs_space,
560            env.action_space,
561            device,
562            actor=actor,
563        )
564    else:
565        policy = build_policy(
566            policy_block, env.observation_space, env.action_space, device
567        )
568
569    # Build Algorithm via factory
570    algo_name = algo_block["name"].lower()
571    algo_cfg = algo_block["cfg"]
572    algo = build_algo(
573        algo_name,
574        algo_cfg,
575        policy,
576        device,
577        distributed=distributed,
578    )
579    if algo.rollout_kind is RolloutKind.DIFFERENTIABLE:
580        raise ValueError(
581            "Differentiable algorithms require trainer.learning_env; "
582            "simulator gym_config environments use standard rollouts."
583        )
584
585    # Build Trainer
586    event_modules = [
587        "embodichain.lab.gym.envs.managers.randomization",
588        "embodichain.lab.gym.envs.managers.record",
589        "embodichain.lab.gym.envs.managers.events",
590    ]
591    events_dict = trainer_cfg.get("events", {})
592    train_event_cfg = {}
593    eval_event_cfg = {}
594    # Parse train events
595    for event_name, event_info in events_dict.get("train", {}).items():
596        event_func_str = event_info.get("func")
597        mode = event_info.get("mode", "interval")
598        params = event_info.get("params", {})
599        interval_step = event_info.get("interval_step", 1)
600        event_func = find_function_from_modules(
601            event_func_str, event_modules, raise_if_not_found=True
602        )
603        train_event_cfg[event_name] = EventCfg(
604            func=event_func,
605            mode=mode,
606            params=params,
607            interval_step=interval_step,
608            is_global=event_info.get("is_global", False),
609        )
610    # Parse eval events (only if evaluation is enabled)
611    if enable_eval:
612        for event_name, event_info in events_dict.get("eval", {}).items():
613            event_func_str = event_info.get("func")
614            mode = event_info.get("mode", "interval")
615            params = event_info.get("params", {})
616            interval_step = event_info.get("interval_step", 1)
617            event_func = find_function_from_modules(
618                event_func_str, event_modules, raise_if_not_found=True
619            )
620            eval_event_cfg[event_name] = EventCfg(
621                func=event_func,
622                mode=mode,
623                params=params,
624                interval_step=interval_step,
625                is_global=event_info.get("is_global", False),
626            )
627    trainer = Trainer(
628        policy=policy,
629        env=env,
630        algorithm=algo,
631        buffer_size=buffer_size,
632        batch_size=algo_cfg["batch_size"],
633        writer=writer,
634        eval_freq=eval_freq if enable_eval else 0,  # Disable eval if not enabled
635        save_freq=save_freq,
636        checkpoint_dir=checkpoint_dir,
637        exp_name=exp_name,
638        use_wandb=use_wandb,
639        eval_env=eval_env,  # None if enable_eval=False
640        event_cfg=train_event_cfg,
641        eval_event_cfg=eval_event_cfg if (enable_eval and rank == 0) else {},
642        num_eval_episodes=num_eval_episodes,
643        distributed=distributed,
644        rank=rank,
645        world_size=world_size,
646        eval_seed=eval_seed,
647        best_eval_metric=trainer_cfg.get("best_eval_metric", "eval/avg_reward"),
648        best_eval_mode=trainer_cfg.get("best_eval_mode", "max"),
649    )
650
651    if rank == 0:
652        logger.log_info("Generic training initialized")
653        logger.log_info(f"Task: {type(env).__name__}")
654        logger.log_info(
655            f"Policy: {policy_name} (available: {get_registered_policy_names()})"
656        )
657        logger.log_info(
658            f"Algorithm: {algo_name} (available: {get_registered_algo_names()})"
659        )
660
661    total_steps = int(iterations * buffer_size * env.num_envs * world_size)
662    if rank == 0:
663        logger.log_info(
664            f"Total steps: {total_steps} (iterations≈{iterations}, world_size={world_size})"
665        )
666
667    try:
668        trainer.train(total_steps)
669    except KeyboardInterrupt:
670        if rank == 0:
671            logger.log_info("Training interrupted by user")
672    finally:
673        trainer.save_checkpoint()
674        if writer is not None:
675            writer.close()
676        if use_wandb and rank == 0:
677            try:
678                wandb.finish()
679            except Exception:
680                pass
681
682        # Clean up environments to prevent resource leaks
683        try:
684            if env is not None:
685                env.close()
686        except Exception as e:
687            if rank == 0:
688                logger.log_warning(f"Failed to close training environment: {e}")
689
690        try:
691            if eval_env is not None:
692                eval_env.close()
693        except Exception as e:
694            if rank == 0:
695                logger.log_warning(f"Failed to close evaluation environment: {e}")
696
697        if distributed and torch.distributed.is_initialized():
698            torch.distributed.destroy_process_group()
699
700        if rank == 0:
701            logger.log_info("Training finished")
702
703
704def cli(argv: Sequence[str] | None = None) -> None:
705    """Command-line interface for RL training.
706
707    Parses CLI arguments and launches training from a config file.
708
709    Task packages are discovered (and init hooks executed) before training so
710    that task environments registered in separate packages (e.g.
711    ``embodichain_tasks``) are available to ``build_env``. This mirrors the
712    ``run_env`` CLI.
713    """
714    args = parse_args(argv)
715
716    # Discover all installed task packages and run init hooks (register custom
717    # manager modules / asset resolvers) before building any environment.
718    discover_task_packages()
719    execute_init_hooks()
720
721    train_from_config(
722        args.config,
723        distributed=args.distributed,
724        profile=args.profile,
725        profile_output=args.profile_output,
726    )
727
728
729if __name__ == "__main__":
730    cli()
731
732
733__all__ = ["cli", "parse_args", "train_from_config"]

The Script Explained#

The training script performs the following steps:

  1. Parse Configuration: Loads the config file (.json, .yaml, or .yml) and extracts runtime/env/policy/algorithm blocks

  2. Setup: Initializes device, seeds, output directories, TensorBoard, and Weights & Biases

  3. Build Components: - Environment via build_env() factory - Policy via build_policy() registry - Algorithm via build_algo() factory

  4. Create Trainer: Instantiates the Trainer with all components

  5. Train: Runs the training loop until completion

Launching Training#

To start training, run:

embodichain train-rl --config embodichain_tasks/configs/tasks/classic_control/cart_pole/agents/ppo.yaml

JSON configs are also supported:

embodichain train-rl --config embodichain_tasks/configs/tasks/manipulation/push_cube/agents/ppo.json

Outputs#

All outputs are written to ./outputs/<exp_name>_<timestamp>/:

  • logs/: TensorBoard logs

  • checkpoints/: Model checkpoints

Training Process#

The training process follows this sequence:

  1. Rollout Phase: SyncCollector interacts with the environment and writes policy-side fields into a shared rollout TensorDict with uniform [N, T + 1] layout. EmbodiedEnv writes environment-side step fields such as reward, done, terminated, and truncated into the same rollout via set_rollout_buffer(). The final slot of transition-only fields is reserved as padding, while obs[:, -1] and value[:, -1] remain valid bootstrap data.

  2. Advantage/Return Computation: Algorithm computes advantages and returns from the collected rollout (e.g. GAE for PPO, step-wise group normalization for GRPO) and converts it to a transition-aligned view over the valid first T steps before minibatch optimization.

  3. Update Phase: Algorithm updates the policy with update(rollout)

  4. Logging: Trainer logs training losses and aggregated metrics to TensorBoard and Weights & Biases

  5. Evaluation (periodic): Trainer evaluates the current policy

  6. Checkpointing (periodic): Trainer saves model checkpoints

Policy Interface#

All policies must inherit from the Policy abstract base class:

from abc import ABC, abstractmethod
import torch.nn as nn

class Policy(nn.Module, ABC):
    device: torch.device

    def get_action(self, tensordict, deterministic: bool = False):
        """Samples action, sample_log_prob, and value into the TensorDict."""
        ...

    @abstractmethod
    def forward(self, tensordict, deterministic: bool = False):
        """Writes action, sample_log_prob, and value into the TensorDict."""
        raise NotImplementedError

    @abstractmethod
    def get_value(self, tensordict):
        """Writes value estimate into the TensorDict."""
        raise NotImplementedError

    @abstractmethod
    def evaluate_actions(self, tensordict):
        """Returns a new TensorDict with log_prob, entropy, and value."""
        raise NotImplementedError

Available Policies#

  • ActorCritic: MLP-based Gaussian policy with learnable log_std. Requires external actor and critic modules to be provided (defined in the training config file). Used with PPO.

  • ActorOnly: Actor-only policy without Critic. Used with GRPO (group-relative advantage estimation).

  • VLAPlaceholderPolicy: Placeholder for Vision-Language-Action policies

Algorithms#

Available Algorithms#

  • PPO: Proximal Policy Optimization with GAE

  • GRPO: Group Relative Policy Optimization (no Critic, step-wise returns, masked group normalization). Use actor_only policy. Set kl_coef=0 for from-scratch training (CartPole, dense reward); kl_coef=0.02 for VLA/LLM fine-tuning.

Adding a New Algorithm#

To add a new algorithm:

  1. Create a new algorithm class in embodichain/learning/rl/algo/

  2. Implement update(rollout) and consume the shared rollout TensorDict

  3. Register in algo/__init__.py:

from tensordict import TensorDict
from embodichain.learning.rl.algo import BaseAlgorithm, register_algo

@register_algo("my_algo")
class MyAlgorithm(BaseAlgorithm):
    def __init__(self, cfg, policy):
        self.cfg = cfg
        self.policy = policy
        self.device = torch.device(cfg.device)

    def update(self, rollout: TensorDict):
        """Update the policy using a collected rollout."""
        # compute advantages / returns from rollout
        # optimize policy parameters
        return {"loss": 0.0}

Adding a New Policy#

To add a new policy:

  1. Create a new policy class inheriting from the Policy abstract base class

  2. Register in models/__init__.py:

from embodichain.learning.rl.models import register_policy, Policy

@register_policy("my_policy")
class MyPolicy(Policy):
    def __init__(self, obs_dim, action_dim, device, config):
        super().__init__()
        self.device = device
        # Initialize your networks here

    def get_action(self, tensordict, deterministic=False):
        ...
    def forward(self, tensordict, deterministic=False):
        ...
    def get_value(self, tensordict):
        ...
    def evaluate_actions(self, tensordict):
        ...

Current built-in MLP policies use flattened observations in the training path. If your policy requires structured or multi-modal inputs, keep the richer obs_space interface and define a matching rollout/collector schema.

Adding a New Environment#

To add a new RL environment:

  1. Create an environment class inheriting from EmbodiedEnv (with Action Manager configured for action preprocessing and standardized info structure):

from embodichain.lab.gym.envs import EmbodiedEnv, EmbodiedEnvCfg
from embodichain.lab.gym.utils.registration import register_env
import torch

@register_env("MyTaskRL", override=True)
class MyTaskEnv(EmbodiedEnv):
    def __init__(self, cfg: EmbodiedEnvCfg = None, **kwargs):
        super().__init__(cfg, **kwargs)

    def compute_task_state(self, **kwargs):
        """Compute success/failure conditions and metrics."""
        is_success = ...  # Define success condition
        is_fail = torch.zeros_like(is_success)
        metrics = {"distance": ..., "error": ...}
        return is_success, is_fail, metrics
  1. Configure the environment in your config file with actions and extensions:

"env": {
  "id": "MyTaskRL",
  "cfg": {
    "num_envs": 4,
    "actions": {
      "delta_qpos": {
        "func": "DeltaQposTerm",
        "params": { "scale": 0.1 }
      }
    },
    "extensions": {
      "success_threshold": 0.05
    }
  }
}

The EmbodiedEnv with Action Manager provides:

  • Action Preprocessing: Configurable via actions (DeltaQposTerm, QposTerm, EefPoseTerm, etc.)

  • Standardized Info: Implements get_info() using compute_task_state() template method

Best Practices#

  • Use EmbodiedEnv with Action Manager for RL Tasks: Inherit from EmbodiedEnv and configure actions in your config. The Action Manager handles action preprocessing (delta_qpos, qpos, qvel, qf, eef_pose) in a modular way.

  • Action Configuration: Use the actions field in your config file. Example: "delta_qpos": {"func": "DeltaQposTerm", "params": {"scale": 0.1}}.

  • Device Management: Device is single-sourced from runtime.cuda. All components (trainer/algorithm/policy/env) share the same device.

  • Observation Format: Environments should provide consistent observation shape/types (torch.float32) and a single done = terminated | truncated.

  • Algorithm Interface: Algorithms implement update(rollout) and consume a shared rollout TensorDict. Collection is handled by SyncCollector plus environment-side rollout writes in EmbodiedEnv.

  • Reward Configuration: Use the RewardManager in your environment config to define reward components. Organize reward components in info["rewards"] dictionary and metrics in info["metrics"] dictionary. The trainer performs dense per-step logging directly from environment info.

  • Template Methods: Override compute_task_state() to define success/failure conditions and metrics. Override check_truncated() for custom truncation logic.

  • Configuration: Use JSON for all hyperparameters. This makes experiments reproducible and easy to track.

  • Logging: Metrics are automatically logged to TensorBoard and Weights & Biases. Check outputs/<exp_name>/logs/ for TensorBoard logs.

  • Checkpoints: Regular checkpoints are saved to outputs/<exp_name>/checkpoints/. Use these to resume training or evaluate policies.

See Also#