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
Example: train_config.yaml (CartPole)
Example: train_apg.yaml (PointMass)

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: Ordered flat Action Manager config (for example, RelativeJointPositionAction with a selected control part and scale)

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

Example:

"env": {
  "id": "PushCubeRL",
  "cfg": {
    "num_envs": 4,
    "actions": {
      "delta_qpos": {
        "func": "RelativeJointPositionAction",
        "params": { "part_name": "arm", "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 json
 21import os
 22import random
 23import time
 24from collections.abc import Sequence
 25from copy import deepcopy
 26from pathlib import Path
 27
 28import numpy as np
 29import torch
 30import wandb
 31import yaml
 32from torch.utils.tensorboard import SummaryWriter
 33
 34from embodichain.learning.rl.checkpoint import (
 35    _atomic_write,
 36    _resolve_resume,
 37    _sync_errors,
 38    _validate_saved_files,
 39)
 40from embodichain.learning.rl.policies import get_registered_policy_names
 41from embodichain.learning.rl.policy_evaluation.manifest import (
 42    write_run_manifest,
 43)
 44from embodichain.learning.rl.algo import (
 45    RolloutKind,
 46    build_algo,
 47    get_registered_algo_names,
 48)
 49from embodichain.learning.rl.differentiable_trainer import (
 50    DifferentiableTrainer,
 51    DifferentiableTrainerCfg,
 52)
 53from embodichain.learning.rl.runtime import (
 54    _close_environment,
 55    _build_learning_environment,
 56    _build_learning_policy,
 57    build_gym_policy_runtime,
 58    build_learning_policy_runtime,
 59)
 60from embodichain.learning.rl.routing import get_trainer_class
 61from embodichain.learning.rl.utils.trainer import Trainer
 62from embodichain.utils import logger, set_seed
 63from embodichain.lab.gym.utils.registration import (
 64    build_env,
 65    discover_task_packages,
 66    execute_init_hooks,
 67)
 68from embodichain.lab.gym.utils.profiler import EnvProfilerCfg
 69from embodichain.utils.utility import load_config
 70from embodichain.utils.module_utils import find_function_from_modules
 71from embodichain.lab.gym.envs.managers.cfg import EventCfg
 72
 73_CAMERA_RECORDERS = {"record_camera_data", "record_camera_data_async"}
 74
 75
 76def _seed_training_rng(seed: int, device: torch.device) -> None:
 77    """Seed policy/trainer RNGs without changing deterministic-kernel settings."""
 78    random.seed(seed)
 79    np.random.seed(seed)
 80    torch.manual_seed(seed)
 81    if device.type == "cuda":
 82        torch.cuda.manual_seed_all(seed)
 83
 84
 85def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
 86    """Parse command-line arguments.
 87
 88    Args:
 89        argv: Arguments excluding the command name. Uses ``sys.argv`` when
 90            omitted.
 91
 92    Returns:
 93        Parsed training arguments.
 94    """
 95    parser = argparse.ArgumentParser(
 96        prog="embodichain train-rl",
 97        description="Train an RL agent from a JSON or YAML config.",
 98    )
 99    parser.add_argument(
100        "--config",
101        type=str,
102        required=True,
103        help="Path to training config file (.json, .yaml, or .yml).",
104    )
105    parser.add_argument(
106        "--distributed",
107        action=argparse.BooleanOptionalAction,
108        default=None,
109        help="Enable or disable multi-GPU distributed training",
110    )
111    parser.add_argument(
112        "--profile",
113        action="store_true",
114        default=False,
115        help=(
116            "Enable per-section time profiling of gym env reset/step "
117            "(report on env.close()). Requires trainer.gym_config."
118        ),
119    )
120    parser.add_argument(
121        "--profile_output",
122        type=str,
123        default=None,
124        help="Dump the profiling report as JSON on env.close() (requires --profile).",
125    )
126    parser.add_argument(
127        "--resume",
128        type=str,
129        default=None,
130        help="Resume a checkpoint file, or latest/best selected from --run.",
131    )
132    parser.add_argument(
133        "--run",
134        type=str,
135        default=None,
136        help="Source run directory for --resume latest/best.",
137    )
138    parser.add_argument(
139        "--resume-scheduler",
140        choices=("restore", "restart"),
141        default="restore",
142        help="Restore the saved LR curve, or restart it with the current config.",
143    )
144    args = parser.parse_args(argv)
145    if args.resume in {"latest", "best"} and args.run is None:
146        parser.error("--resume latest/best requires --run")
147    if args.run is not None and args.resume not in {"latest", "best"}:
148        parser.error("--run requires --resume latest/best")
149    if args.resume is None and args.resume_scheduler != "restore":
150        parser.error("--resume-scheduler requires --resume")
151    return args
152
153
154def _resolve_profile_output(
155    path: str | None,
156    *,
157    rank: int,
158    world_size: int,
159) -> str | None:
160    if path is None or world_size <= 1:
161        return path
162    output = Path(path)
163    return str(output.with_name(f"{output.stem}_rank{rank}{output.suffix}"))
164
165
166def _event_params(
167    event_info: dict,
168    *,
169    run_base: str | Path,
170    phase: str,
171) -> dict:
172    """Place default camera recordings under the current training run."""
173    params = dict(event_info.get("params", {}))
174    function_name = str(event_info.get("func", "")).rsplit(".", 1)[-1]
175    if function_name in _CAMERA_RECORDERS:
176        params.setdefault("save_path", str(Path(run_base) / "videos" / phase))
177    return params
178
179
180def _train_learning_env(
181    cfg_data: dict,
182    *,
183    config_path: str | Path,
184    distributed: bool | None,
185    profile: bool = False,
186    resume: str | Path | None = None,
187    run: str | Path | None = None,
188    resume_scheduler: str = "restore",
189) -> dict[str, object]:
190    """Train a lightweight registered environment through the unified CLI."""
191    if profile:
192        raise ValueError(
193            "--profile requires trainer.gym_config; learning_env is unsupported."
194        )
195    trainer_cfg = cfg_data["trainer"]
196    algorithm_block = cfg_data["algorithm"]
197    distributed = (
198        bool(trainer_cfg.get("distributed", False))
199        if distributed is None
200        else distributed
201    )
202    if distributed:
203        raise ValueError(
204            "Learning environments do not yet support distributed training."
205        )
206
207    discover_task_packages()
208    execute_init_hooks()
209    seed = int(trainer_cfg.get("seed", 1))
210    device = torch.device(trainer_cfg.get("device", "cpu"))
211    if device.type == "cuda" and not torch.cuda.is_available():
212        raise ValueError("CUDA was requested but is not available.")
213    if device.type == "cuda":
214        torch.cuda.set_device(device)
215    set_seed(
216        seed,
217        deterministic=bool(trainer_cfg.get("torch_deterministic", False)),
218    )
219
220    num_envs = int(trainer_cfg.get("num_envs", 64))
221    runtime = build_learning_policy_runtime(
222        cfg_data,
223        num_envs=num_envs,
224        device=device,
225    )
226    env = runtime.env
227    policy = runtime.policy
228    env_name = runtime.env_id
229
230    enable_eval = bool(trainer_cfg.get("enable_eval", False))
231    eval_env = None
232    if enable_eval:
233        _eval_name, eval_env = _build_learning_environment(
234            cfg_data,
235            num_envs=int(trainer_cfg.get("num_eval_envs", 16)),
236            device=device,
237        )
238
239    algorithm = build_algo(
240        algorithm_block["name"],
241        dict(algorithm_block.get("cfg", {})),
242        policy,
243        device,
244    )
245    trainer_class = get_trainer_class(algorithm)
246
247    exp_name = trainer_cfg.get("exp_name", f"{env_name}_{algorithm_block['name']}")
248    run_stamp = f"{time.strftime('%Y%m%d_%H%M%S')}_{time.time_ns() % 1_000_000_000:09d}"
249    run_base = Path("outputs") / f"{exp_name}_{run_stamp}"
250    log_dir = run_base / "logs" / exp_name
251    checkpoint_dir = run_base / "checkpoints"
252    checkpoint_dir.mkdir(parents=True, exist_ok=True)
253    writer = SummaryWriter(str(log_dir))
254    use_wandb = bool(trainer_cfg.get("use_wandb", False))
255    if use_wandb:
256        wandb.init(
257            project=trainer_cfg.get("wandb_project_name", "embodichain-generic"),
258            name=exp_name,
259            config=cfg_data,
260        )
261
262    eval_freq = int(trainer_cfg.get("eval_freq", 0)) if enable_eval else 0
263    eval_seed = int(trainer_cfg.get("eval_seed", seed + 10_000))
264    iterations = int(trainer_cfg.get("iterations", 250))
265    try:
266        if trainer_class is DifferentiableTrainer:
267            segment_length = int(trainer_cfg.get("segment_length", 16))
268            update_horizon = int(trainer_cfg.get("update_horizon", segment_length))
269            diff_cfg = DifferentiableTrainerCfg(
270                segment_length=segment_length,
271                update_horizon=update_horizon,
272                rollout_mode=str(trainer_cfg.get("rollout_mode", "segmented")),
273                gradient_accumulation_steps=int(
274                    trainer_cfg.get("gradient_accumulation_steps", 1)
275                ),
276                deterministic_actions=bool(
277                    trainer_cfg.get("deterministic_actions", False)
278                ),
279                clip_actions_to_space=bool(
280                    trainer_cfg.get("clip_actions_to_space", False)
281                ),
282                action_adjoint_max_norm=float(
283                    trainer_cfg.get("action_adjoint_max_norm", 0.0)
284                ),
285                normalize_observations=bool(
286                    trainer_cfg.get("normalize_observations", False)
287                ),
288                rollout_seed=seed,
289                checkpoint_dir=str(checkpoint_dir),
290                experiment_name=exp_name,
291                save_frequency_updates=int(
292                    trainer_cfg.get("save_frequency_updates", 0)
293                ),
294                eval_frequency_steps=eval_freq,
295                num_eval_episodes=int(trainer_cfg.get("num_eval_episodes", 5)),
296                eval_seed=eval_seed,
297                use_wandb=use_wandb,
298                best_eval_metric=trainer_cfg.get("best_eval_metric", "eval/avg_reward"),
299                best_eval_mode=trainer_cfg.get("best_eval_mode", "max"),
300            )
301            trainer = DifferentiableTrainer(
302                cfg=diff_cfg,
303                env=env,
304                policy=policy,
305                algorithm=algorithm,
306                writer=writer,
307                eval_env=eval_env,
308            )
309            default_steps = (
310                iterations
311                * update_horizon
312                * num_envs
313                * diff_cfg.gradient_accumulation_steps
314            )
315        else:
316            buffer_size = int(
317                trainer_cfg.get("buffer_size", trainer_cfg.get("rollout_steps", 256))
318            )
319            trainer = Trainer(
320                policy=policy,
321                env=env,
322                algorithm=algorithm,
323                buffer_size=buffer_size,
324                batch_size=int(algorithm.cfg.batch_size),
325                writer=writer,
326                eval_freq=eval_freq,
327                save_freq=int(trainer_cfg.get("save_freq", 0)),
328                checkpoint_dir=str(checkpoint_dir),
329                exp_name=exp_name,
330                use_wandb=use_wandb,
331                eval_env=eval_env,
332                num_eval_episodes=int(trainer_cfg.get("num_eval_episodes", 5)),
333                eval_seed=eval_seed,
334                best_eval_metric=trainer_cfg.get("best_eval_metric", "eval/avg_reward"),
335                best_eval_mode=trainer_cfg.get("best_eval_mode", "max"),
336                save_frequency_updates=int(
337                    trainer_cfg.get("save_frequency_updates", 0)
338                ),
339            )
340            default_steps = iterations * buffer_size * num_envs
341        _configure_checkpoints(trainer, cfg_data, config_path, run_base)
342        _resume_training(trainer, resume, run, resume_scheduler)
343        if (
344            trainer_class is DifferentiableTrainer
345            and diff_cfg.rollout_mode == "complete"
346            and "total_timesteps" not in trainer_cfg
347        ):
348            trainer.train(total_updates=iterations)
349        else:
350            total_timesteps = int(trainer_cfg.get("total_timesteps", default_steps))
351            trainer.train(total_timesteps)
352        _save_final_checkpoint(trainer)
353        summary = trainer.get_summary()
354    finally:
355        writer.close()
356        if use_wandb:
357            wandb.finish()
358        env.close()
359        if eval_env is not None:
360            eval_env.close()
361    return summary
362
363
364def train_from_config(
365    config_path: str,
366    distributed: bool | None = None,
367    *,
368    profile: bool = False,
369    profile_output: str | None = None,
370    resume: str | Path | None = None,
371    run: str | Path | None = None,
372    resume_scheduler: str = "restore",
373) -> dict[str, object] | None:
374    """Run training from a config file path.
375
376    Args:
377        config_path: Path to the training config file (.json, .yaml, or .yml).
378        distributed: If True, run multi-GPU distributed training.
379            If None, use trainer.distributed from config.
380        profile: Enable gym ``EnvProfiler`` on the training environment.
381        profile_output: Optional JSON dump path for the profiling report.
382        resume: Checkpoint file, or latest/best selected from run.
383        run: Source run directory when resume selects latest/best.
384        resume_scheduler: Restore the saved LR curve or restart the configured
385            curve with optimizer momentum retained.
386
387    Returns:
388        The lightweight trainer summary, or ``None`` for simulator training.
389    """
390    if profile_output is not None and not profile:
391        raise ValueError("--profile_output requires --profile.")
392
393    if resume_scheduler not in {"restore", "restart"}:
394        raise ValueError("resume_scheduler must be 'restore' or 'restart'.")
395    if resume is None and resume_scheduler != "restore":
396        raise ValueError("resume_scheduler restart requires resume.")
397    if resume is None and run is not None:
398        raise ValueError("run requires resume latest/best.")
399    cfg_data = load_config(config_path)
400
401    trainer_cfg = cfg_data["trainer"]
402    if "learning_env" in trainer_cfg:
403        return _train_learning_env(
404            cfg_data,
405            config_path=config_path,
406            distributed=distributed,
407            profile=profile,
408            resume=resume,
409            run=run,
410            resume_scheduler=resume_scheduler,
411        )
412    policy_block = cfg_data["policy"]
413    algo_block = cfg_data["algorithm"]
414
415    if distributed is None:
416        distributed = bool(trainer_cfg.get("distributed", False))
417
418    rank = 0
419    world_size = 1
420    local_rank = 0
421    if distributed:
422        if not torch.distributed.is_available():
423            raise RuntimeError(
424                "Distributed training requested but torch.distributed is not available."
425            )
426        if not torch.cuda.is_available():
427            raise RuntimeError(
428                "Distributed training with NCCL backend requires CUDA, "
429                "but torch.cuda.is_available() is False."
430            )
431        local_rank = int(os.environ.get("LOCAL_RANK", 0))
432        if local_rank < 0 or local_rank >= torch.cuda.device_count():
433            raise ValueError(
434                f"LOCAL_RANK {local_rank} is out of range "
435                f"(available GPUs: {torch.cuda.device_count()})."
436            )
437        torch.cuda.set_device(local_rank)
438        if not torch.distributed.is_initialized():
439            torch.distributed.init_process_group(backend="nccl")
440        rank = torch.distributed.get_rank()
441        world_size = torch.distributed.get_world_size()
442
443    exp_name = trainer_cfg.get("exp_name", "generic_exp")
444    seed = int(trainer_cfg.get("seed", 1))
445    device_str = trainer_cfg.get("device", "cpu")
446    if distributed:
447        device_str = f"cuda:{local_rank}"
448    iterations = int(trainer_cfg.get("iterations", 250))
449    buffer_size = int(
450        trainer_cfg.get("buffer_size", trainer_cfg.get("rollout_steps", 2048))
451    )
452    enable_eval = bool(trainer_cfg.get("enable_eval", False))
453    eval_freq = int(trainer_cfg.get("eval_freq", 10000))
454    save_freq = int(
455        trainer_cfg.get(
456            "save_freq", 0 if "save_frequency_updates" in trainer_cfg else 50000
457        )
458    )
459    num_eval_episodes = int(trainer_cfg.get("num_eval_episodes", 5))
460    eval_seed = int(trainer_cfg.get("eval_seed", seed + 10_000))
461    headless = bool(trainer_cfg.get("headless", True))
462    renderer = trainer_cfg.get("renderer")
463    gpu_id = int(trainer_cfg.get("gpu_id", 0))
464    num_envs = trainer_cfg.get("num_envs", None)
465    wandb_project_name = trainer_cfg.get("wandb_project_name", "embodichain-generic")
466
467    if not isinstance(device_str, str):
468        raise ValueError(
469            f"runtime.device must be a string such as 'cpu' or 'cuda:0'. Got: {device_str!r}"
470        )
471    try:
472        device = torch.device(device_str)
473    except RuntimeError as exc:
474        raise ValueError(
475            f"Failed to parse runtime.device='{device_str}': {exc}"
476        ) from exc
477
478    if device.type == "cuda":
479        if not torch.cuda.is_available():
480            raise ValueError(
481                "CUDA device requested but torch.cuda.is_available() is False."
482            )
483        index = (
484            device.index if device.index is not None else torch.cuda.current_device()
485        )
486        device_count = torch.cuda.device_count()
487        if index < 0 or index >= device_count:
488            raise ValueError(
489                f"CUDA device index {index} is out of range (available devices: {device_count})."
490            )
491        torch.cuda.set_device(index)
492        device = torch.device(f"cuda:{index}")
493    elif device.type != "cpu":
494        raise ValueError(f"Unsupported device type: {device}")
495    if rank == 0:
496        logger.log_info(f"Device: {device}")
497    if distributed and rank == 0:
498        logger.log_info(f"Distributed training: world_size={world_size}")
499
500    # Seeds
501    effective_seed = seed + rank
502    _seed_training_rng(effective_seed, device)
503    torch.backends.cudnn.deterministic = True
504
505    # Outputs
506    if distributed:
507        run_stamp = (
508            f"{time.strftime('%Y%m%d_%H%M%S')}_{time.time_ns() % 1_000_000_000:09d}"
509            if rank == 0
510            else None
511        )
512        run_stamp_list = [run_stamp]
513        torch.distributed.broadcast_object_list(run_stamp_list, src=0)
514        run_stamp = run_stamp_list[0]
515    else:
516        run_stamp = (
517            f"{time.strftime('%Y%m%d_%H%M%S')}_{time.time_ns() % 1_000_000_000:09d}"
518        )
519    run_base = os.path.join("outputs", f"{exp_name}_{run_stamp}")
520    log_dir = os.path.join(run_base, "logs")
521    checkpoint_dir = os.path.join(run_base, "checkpoints")
522    if rank == 0:
523        os.makedirs(log_dir, exist_ok=True)
524        os.makedirs(checkpoint_dir, exist_ok=True)
525    writer = SummaryWriter(f"{log_dir}/{exp_name}") if rank == 0 else None
526
527    # Initialize Weights & Biases (optional)
528    use_wandb = trainer_cfg.get("use_wandb", False)
529    if use_wandb and rank == 0:
530        wandb.init(project=wandb_project_name, name=exp_name, config=cfg_data)
531
532    if rank == 0:
533        logger.log_info(f"Current working directory: {Path.cwd()}")
534
535    profiler = (
536        EnvProfilerCfg(
537            enable_time=True,
538            output_path=_resolve_profile_output(
539                profile_output,
540                rank=rank,
541                world_size=world_size,
542            ),
543        )
544        if profile
545        else None
546    )
547    # Resolve active event callables before constructing the first World.
548    event_modules = [
549        "embodichain.lab.gym.envs.managers.randomization",
550        "embodichain.lab.gym.envs.managers.record",
551        "embodichain.lab.gym.envs.managers.events",
552    ]
553    events_dict = trainer_cfg.get("events", {})
554    train_event_cfg = {}
555    eval_event_cfg = {}
556    # Parse train events
557    for event_name, event_info in events_dict.get("train", {}).items():
558        event_func_str = event_info.get("func")
559        mode = event_info.get("mode", "interval")
560        params = _event_params(event_info, run_base=run_base, phase="train")
561        interval_step = event_info.get("interval_step", 1)
562        event_func = find_function_from_modules(
563            event_func_str, event_modules, raise_if_not_found=True
564        )
565        train_event_cfg[event_name] = EventCfg(
566            func=event_func,
567            mode=mode,
568            params=params,
569            interval_step=interval_step,
570            is_global=event_info.get("is_global", False),
571        )
572    # Parse eval events (only if evaluation is enabled)
573    if enable_eval and rank == 0:
574        for event_name, event_info in events_dict.get("eval", {}).items():
575            event_func_str = event_info.get("func")
576            mode = event_info.get("mode", "interval")
577            params = _event_params(event_info, run_base=run_base, phase="eval")
578            interval_step = event_info.get("interval_step", 1)
579            event_func = find_function_from_modules(
580                event_func_str, event_modules, raise_if_not_found=True
581            )
582            eval_event_cfg[event_name] = EventCfg(
583                func=event_func,
584                mode=mode,
585                params=params,
586                interval_step=interval_step,
587                is_global=event_info.get("is_global", False),
588            )
589    runtime = build_gym_policy_runtime(
590        cfg_data,
591        device=device,
592        num_envs=num_envs,
593        headless=headless,
594        renderer=renderer,
595        gpu_id=gpu_id,
596        seed=effective_seed,
597        config_dir=Path(config_path).expanduser().resolve().parent,
598        profiler=profiler,
599        trainer_events=(*train_event_cfg.values(), *eval_event_cfg.values()),
600    )
601    env = runtime.env
602    policy = runtime.policy
603    gym_config_path = runtime.gym_config_path
604    gym_config_data = runtime.gym_config
605    gym_env_cfg = runtime.env_cfg
606    if gym_config_path is None or gym_config_data is None or gym_env_cfg is None:
607        raise RuntimeError("Simulator Policy runtime is missing task configuration")
608    if rank == 0:
609        logger.log_info(
610            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}, device={gym_env_cfg.sim_cfg.device})"
611        )
612
613    # Create evaluation environment only if enabled
614    eval_env = None
615    num_eval_envs = trainer_cfg.get("num_eval_envs", 4)
616    if enable_eval and rank == 0:
617        eval_gym_env_cfg = deepcopy(gym_env_cfg)
618        eval_gym_env_cfg.num_envs = int(num_eval_envs)
619        eval_gym_env_cfg.seed = eval_seed
620        eval_gym_env_cfg.sim_cfg.headless = True
621        eval_gym_env_cfg.profiler = None
622        eval_env = build_env(gym_config_data["id"], base_env_cfg=eval_gym_env_cfg)
623        logger.log_info(
624            f"Evaluation environment created (num_envs={num_eval_envs}, headless=True)"
625        )
626
627    # Environment construction intentionally uses task/evaluation seeds. Reset
628    # the trainer stream so policy initialization is independent of scene work.
629    _seed_training_rng(effective_seed, device)
630
631    policy_name = policy_block["name"]
632
633    # Build Algorithm via factory
634    algo_name = algo_block["name"].lower()
635    algo_cfg = algo_block["cfg"]
636    algo = build_algo(
637        algo_name,
638        algo_cfg,
639        policy,
640        device,
641        distributed=distributed,
642    )
643    if algo.rollout_kind is RolloutKind.DIFFERENTIABLE:
644        raise ValueError(
645            "Differentiable algorithms require trainer.learning_env; "
646            "simulator gym_config environments use standard rollouts."
647        )
648
649    # Build Trainer
650    trainer = Trainer(
651        policy=policy,
652        env=env,
653        algorithm=algo,
654        buffer_size=buffer_size,
655        batch_size=algo_cfg["batch_size"],
656        writer=writer,
657        eval_freq=eval_freq if enable_eval else 0,  # Disable eval if not enabled
658        save_freq=save_freq,
659        save_frequency_updates=int(trainer_cfg.get("save_frequency_updates", 0)),
660        checkpoint_dir=checkpoint_dir,
661        exp_name=exp_name,
662        use_wandb=use_wandb,
663        eval_env=eval_env,  # None if enable_eval=False
664        event_cfg=train_event_cfg,
665        eval_event_cfg=eval_event_cfg if (enable_eval and rank == 0) else {},
666        num_eval_episodes=num_eval_episodes,
667        distributed=distributed,
668        rank=rank,
669        world_size=world_size,
670        eval_seed=eval_seed,
671        best_eval_metric=trainer_cfg.get("best_eval_metric", "eval/avg_reward"),
672        best_eval_mode=trainer_cfg.get("best_eval_mode", "max"),
673    )
674
675    if rank == 0:
676        logger.log_info("Generic training initialized")
677        logger.log_info(f"Task: {type(env).__name__}")
678        logger.log_info(
679            f"Policy: {policy_name} (available: {get_registered_policy_names()})"
680        )
681        logger.log_info(
682            f"Algorithm: {algo_name} (available: {get_registered_algo_names()})"
683        )
684
685    total_steps = int(iterations * buffer_size * env.num_envs * world_size)
686    if rank == 0:
687        logger.log_info(
688            f"Total steps: {total_steps} (iterations≈{iterations}, world_size={world_size})"
689        )
690
691    try:
692        _configure_checkpoints(
693            trainer,
694            cfg_data,
695            config_path,
696            run_base,
697            gym_config=gym_config_path,
698            gym_data=gym_config_data,
699        )
700        _resume_training(trainer, resume, run, resume_scheduler)
701        trainer.train(total_steps)
702        _save_final_checkpoint(trainer)
703    except KeyboardInterrupt:
704        if rank == 0:
705            logger.log_info("Training interrupted by user")
706    finally:
707        if writer is not None:
708            writer.close()
709        if use_wandb and rank == 0:
710            try:
711                wandb.finish()
712            except Exception:
713                pass
714
715        # Clean up environments to prevent resource leaks
716        try:
717            if env is not None:
718                runtime.close()
719        except Exception as e:
720            if rank == 0:
721                logger.log_warning(f"Failed to close training environment: {e}")
722
723        try:
724            if eval_env is not None:
725                _close_environment(eval_env)
726        except Exception as e:
727            if rank == 0:
728                logger.log_warning(f"Failed to close evaluation environment: {e}")
729
730        if distributed and torch.distributed.is_initialized():
731            torch.distributed.destroy_process_group()
732
733    if rank == 0:
734        logger.log_info("Training finished")
735
736
737def _checkpoint_context(cfg_data: dict, gym_data: dict | None = None) -> dict:
738    policy = dict(cfg_data["policy"])
739    policy.pop("initial_log_std", None)
740    trainer = cfg_data["trainer"]
741    result = {"policy": policy, "learning_env": trainer.get("learning_env")}
742    if gym_data is not None:
743        result["gym"] = gym_data
744    result["best_eval_metric"] = trainer.get("best_eval_metric", "eval/avg_reward")
745    result["best_eval_mode"] = trainer.get("best_eval_mode", "max")
746    return result
747
748
749def _configure_checkpoints(
750    trainer,
751    cfg_data: dict,
752    config_path: str | Path,
753    run_base: str | Path,
754    *,
755    gym_config: str | Path | None = None,
756    gym_data: dict | None = None,
757) -> None:
758    trainer._checkpoint_context = deepcopy(_checkpoint_context(cfg_data, gym_data))
759    trainer._checkpoint_config = deepcopy(cfg_data)
760    if getattr(trainer, "rank", 0) != 0:
761        return
762    trainer._checkpoint_run = Path(run_base)
763    config_dir = Path(run_base) / "configs"
764    train_path = config_dir / f"train{Path(config_path).suffix.lower() or '.yaml'}"
765    gym_path = (
766        config_dir / f"gym{Path(gym_config or 'gym.yaml').suffix.lower() or '.yaml'}"
767        if gym_data is not None
768        else None
769    )
770    snapshots = {train_path: trainer._checkpoint_config}
771    if gym_path is not None:
772        snapshots[gym_path] = deepcopy(gym_data)
773
774    def update_manifest(summary: dict) -> None:
775        for path, data in snapshots.items():
776            contents = (
777                json.dumps(data, indent=2)
778                if path.suffix == ".json"
779                else yaml.safe_dump(data, sort_keys=False)
780            ).encode("utf-8")
781            _atomic_write(path, lambda stream: stream.write(contents))
782        snapshots.clear()
783        write_run_manifest(
784            run_base,
785            train_config=train_path,
786            gym_config=gym_path,
787            latest_checkpoint=summary["latest_checkpoint_path"],
788            best_checkpoint=summary["best_checkpoint_path"],
789        )
790
791    trainer._checkpoint_callback = update_manifest
792
793
794def _save_final_checkpoint(trainer) -> None:
795    """Write the final update only if periodic/best saving has not written it."""
796    if getattr(trainer, "_last_saved_update", None) != trainer.num_updates:
797        trainer.save_checkpoint()
798        return
799    error = None
800    if getattr(trainer, "rank", 0) == 0:
801        try:
802            _validate_saved_files(trainer)
803        except Exception as exc:
804            error = exc
805    _sync_errors(error, getattr(trainer, "distributed", False))
806
807
808def _resume_training(trainer, resume, run, scheduler: str) -> None:
809    error = None
810    path = None
811    try:
812        path = _resolve_resume(resume, run)
813    except Exception as exc:
814        error = exc
815    _sync_errors(error, getattr(trainer, "distributed", False))
816    if path is not None:
817        trainer.load_checkpoint(path, resume_scheduler=scheduler)
818        if getattr(trainer, "rank", 0) == 0:
819            logger.log_info(
820                f"Resumed step={trainer.global_step}, updates={trainer.num_updates}, "
821                f"learning_rate={trainer.algorithm.current_learning_rate():.6g}, "
822                f"scheduler={scheduler}"
823            )
824
825
826def cli(argv: Sequence[str] | None = None) -> None:
827    """Command-line interface for RL training.
828
829    Parses CLI arguments and launches training from a config file.
830
831    Task packages are discovered (and init hooks executed) before training so
832    that task environments registered in separate packages (e.g.
833    ``embodichain_tasks``) are available to ``build_env``. This mirrors the
834    ``run_task`` CLI.
835    """
836    args = parse_args(argv)
837
838    # Discover all installed task packages and run init hooks (register custom
839    # manager modules / asset resolvers) before building any environment.
840    discover_task_packages()
841    execute_init_hooks()
842
843    try:
844        train_from_config(
845            args.config,
846            distributed=args.distributed,
847            profile=args.profile,
848            profile_output=args.profile_output,
849            resume=args.resume,
850            run=args.run,
851            resume_scheduler=args.resume_scheduler,
852        )
853    finally:
854        from embodichain.lab.sim.sim_manager import SimulationManager
855
856        SimulationManager.flush_cleanup_queue()
857
858
859if __name__ == "__main__":
860    cli()
861
862
863__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

  • configs/: Training config and referenced gym config snapshots

  • evaluations/: Timestamped policy evaluation reports

  • run-manifest.json: Training configs and best/latest checkpoint index used by eval-policy

A training run can be evaluated Headless or opened in its simulator task:

embodichain eval-policy outputs/<exp_name>_<timestamp>
embodichain eval-policy outputs/<exp_name>_<timestamp> --viewer

See Policy Evaluation for EmbodiChain .pt training runs and the external Motion Profile example.

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.policies 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": "RelativeJointPositionAction",
        "params": { "part_name": "arm", "scale": 0.1 }
      }
    },
    "extensions": {
      "success_threshold": 0.05
    }
  }
}

The EmbodiedEnv with Action Manager provides:

  • Action Composition: Configure ordered joint, EEF, and gripper terms via actions; each term owns and directly applies its selected resources.

  • 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. For example: "delta_qpos": {"func": "RelativeJointPositionAction", "params": {"part_name": "arm", "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>_<timestamp>/logs/ for TensorBoard logs.

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

See Also#