ROBOTICS FIELD NOTESENGLISH EDITION / 21 October 2026
Guides / 2026

How to Train a Robot with Reinforcement Learning: A Complete Beginner's Guide

Train a simulated robot arm to reach targets with MuJoCo, Gymnasium and PPO, then test, evaluate, debug and plan the step to real hardware.

In this tutorial you will build a small robot arm in the MuJoCo physics simulator, wrap it as a Gymnasium environment, and train it with PPO, a reinforcement learning algorithm, to move its tip to a randomly placed target. You will then measure the trained policy against two baselines on the same targets: a random policy and a hand-written controller.

Each step ends with a check you can run. The full project is a set of ten files that you can download or copy from the code blocks below. Nothing here needs a physical robot or a graphics card.

Before you start

Who this tutorial is for

It is for readers who can open a terminal, run Python scripts and edit a text file, but have never trained a robot with reinforcement learning. No calculus or probability theory is assumed. Basic Python (functions, classes, NumPy arrays) will help you follow the code.

What you need

  • Windows 10 or 11 (64-bit), macOS, or Linux on a 64-bit processor.
  • Python 3.12 or 3.13. The pinned NumPy 2.5 release requires Python 3.12 or newer.
  • A few gigabytes of free disk space for the virtual environment, checkpoints and logs. On Linux, install the CPU-only PyTorch build described below; the default Linux build also downloads NVIDIA CUDA libraries and is much larger.
  • Ordinary laptop memory. We did not measure peak RAM for this guide; the default run holds four small simulations and two networks of 64 units per layer.
  • No GPU. PPO with a small fully connected network runs on the CPU, and Stable-Baselines3 recommends the CPU for this case.

[2] [3]

What the finished project does

A three-joint arm stands on a pedestal. At the start of each episode a red target appears somewhere the arm can reach. Every 40 ms the policy reads 18 numbers about the arm and the target and outputs three numbers that nudge the joint servos. After 4 seconds the episode ends. It counts as a success if the tip is within 2 cm of the target at that moment.

By the end you will have a trained policy file, training curves, an evaluation table with a confidence interval for the success rate, and a comparison with the two baselines. How well your policy performs is something you measure; this guide does not promise a number.

Reinforcement learning explained from zero

Reinforcement learning (RL) trains a decision-maker by trial and error. It tries actions, receives a score called a reward after each one, and gradually prefers actions that lead to higher total reward. Nobody shows it the right movement. That is the difference from imitation learning, where a robot copies recorded demonstrations.

Agent and environment

The agent is the part that decides. Here it is a neural network called the policy. The environment is everything the agent acts on: the simulated arm, its motors, gravity, the floor, the target and the rules for scoring and ending an episode. In code, the environment is the ReachEnv class and the agent is the PPO model from Stable-Baselines3.

Observation versus full state

The simulator's state is everything needed to compute the next instant: every joint angle and speed, contact forces, actuator internals and more. The observation is the subset the policy is shown. Ours has 18 numbers: three joint angles, three joint speeds, three servo setpoints, the tip position, the target position and the vector from tip to target. A real robot could measure each of these with joint encoders and a target position from a camera or a user. The policy never sees contact forces or the exact Jacobian; keeping observations to quantities a real robot can measure is a habit worth forming early.

Actions, and why this tutorial moves setpoints

A policy can command a robot in three common ways: joint torques, joint velocities, or joint position targets. Torque control gives the most freedom but leaves the policy to learn gravity compensation and stability from scratch, and a bad torque command moves a real arm violently. Position targets hand that low-level work to a servo loop, which is how many hobby and industrial arms are driven. This tutorial uses position servos and lets each action change the setpoints by at most 0.05 rad per step. The limit caps how fast the commanded pose can move (1.25 rad/s), which keeps exploration gentle.

Reward

The reward is the only feedback the agent gets, so it defines the task. Ours is mostly the negative distance between tip and target: every step the tip is 30 cm away costs 0.3 points, and every step at the target costs almost nothing. A small penalty for large actions discourages jerky motion, and a small bonus inside 2 cm makes staying there worth more than hovering nearby. The reward function section shows what goes wrong when these terms are badly chosen.

Episodes, resets and endings

An episode is one attempt: reset the arm, place a target, run 100 control steps. Gymnasium distinguishes two ways an episode ends. Terminated means the task itself reached an end state, such as a fall or a crash. Truncated means an outside limit stopped it, usually a time limit. The difference matters to the algorithm: after a truncation it still estimates the value of the unfinished future; after a termination it does not.

ReachEnv truncates at 100 steps and terminates only if the simulation becomes numerically unstable. It deliberately does not end the episode when the tip first touches the target, because then a policy could score by swinging through the target at speed. [4] [5]

Policy and neural network

The policy is a function from observation to action. Here it is a small neural network: 18 inputs, two hidden layers of 64 units, and 3 outputs. During training it outputs a probability distribution over actions, a mean plus some spread, so it can try variations. For evaluation you usually take the mean, which is called deterministic prediction. A second network of the same shape, the value function, learns to predict how much reward the current state will lead to.

PPO in plain terms

Proximal Policy Optimization repeats one cycle. First it runs the current policy for a fixed number of steps and records what happened. Then it compares each action's outcome with what the value network expected: actions that did better than expected become more likely, actions that did worse become less likely. Finally it updates the networks several times on that batch, but clips each update so the new policy cannot move far from the one that collected the data. That clipping is what makes PPO stable enough to be a common first choice. [6] [7]

Exploration

To discover that bending the elbow helps, the policy has to try bending the elbow. PPO explores by sampling actions from its distribution, so early training looks like twitching. In simulation that costs nothing. On a real arm, random exploration can hit joint limits at speed, strike the table or a person, and overheat motors. That is one reason training happens in simulation and why the action limit above exists.

The sim-to-real gap

A policy learns the simulator, not the robot. Real joints have friction and backlash the model lacks, motors saturate and heat up, sensors are noisy and late, and link masses differ from the file. A policy that relies on any of those details being exact can fail on hardware. The last section of this guide covers what to check before a policy goes anywhere near a physical arm.

For a non-technical comparison of reinforcement learning and imitation learning, read how robots learn.

Project architecture

Two pieces of software do different jobs. MuJoCo computes the physics. Gymnasium defines the standard interface (reset, step, observation and action spaces) that any RL library can use. Stable-Baselines3 provides a tested PPO implementation built on PyTorch. Keeping the environment independent of the algorithm means you can test it fully before training and later swap PPO for another algorithm. [8] [3]

Reinforcement learning loop for the robot arm: the policy sends three actions to the servos, MuJoCo simulates 40 ms, and 18 observation numbers plus a reward return to the policy.
Original diagram of the tutorial code. One pass around the loop is one control step of 40 ms. Open the diagram for a larger view.

The project folder looks like this once training and evaluation have run. Only the first ten files are written by you; the runs/ and results/ folders are created by the scripts.

Project layout
rl-reach/
├── requirements.txt
├── check_setup.py
├── assets/
│   └── reach_arm.xml
├── reach_env.py
├── test_env.py
├── train_ppo.py
├── plot_training.py
├── evaluate.py
├── view_policy.py
├── README.md
├── runs/
│   └── ppo_seed0/        created by train_ppo.py
└── results/              created by evaluate.py
Workflow of the reinforcement learning tutorial files, from the MuJoCo model through the environment test, PPO training and plotting to evaluation.
Original diagram. Run the files in this order, and do not start training until the environment test passes. Open the diagram for a larger view.

Download the complete project files from the rl-reach download folder, or copy each file from the code blocks below.

Installation and environment setup

Step 1: Check the computer

Find out what you are working with before installing anything. These commands only read information.

Commands that report the operating system, Python version, memory, processor, GPU and disk space.
CheckWindows (PowerShell)macOS (Terminal)Linux (terminal)
Operating systemwinversw_verscat /etc/os-release
Python versionpy --listpython3 --versionpython3 --version
Memory (RAM)systeminfo | findstr Memorysysctl hw.memsizefree -h
ProcessorGet-CimInstance Win32_Processor | Select Name, NumberOfLogicalProcessorssysctl -n machdep.cpu.brand_stringlscpu
NVIDIA GPU and drivernvidia-smiNot applicablenvidia-smi
Free disk spaceGet-PSDrive Cdf -h .df -h .

If Python 3.12 or 3.13 is missing, install it from python.org or your system package manager first. On Windows, the py launcher lists installed versions and lets you choose one with py -3.12.

A CUDA-capable GPU is optional and does not help this project. The network is tiny and most time goes into stepping the simulator, which runs on the CPU. Stable-Baselines3 warns when PPO with an MlpPolicy is put on a GPU, and the training script fixes device='cpu'. A GPU starts paying off for image observations, large networks, or GPU-based simulators such as MuJoCo Playground or Isaac Lab, which are outside this tutorial. [3] [9]

Step 2: Create the project and a virtual environment

A virtual environment keeps this project's packages separate from the rest of your system, so a version pinned here cannot break another project.

macOS and Linux
mkdir rl-reach
cd rl-reach
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
Windows PowerShell
mkdir rl-reach
cd rl-reach
py -3.12 -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip

If PowerShell refuses to run Activate.ps1, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned once, or use the Command Prompt and .venv\Scripts\activate.bat instead. Your prompt shows (.venv) while the environment is active. Every command below assumes it is active.

Pin the dependencies

Save this file as requirements.txt in the project folder. Exact versions make the project reproducible: someone installing it next year gets the same libraries you used.

requirements.txt
# Python 3.12 or 3.13. NumPy 2.5 requires Python 3.12 or newer.
# Versions checked against PyPI and the projects' release tags on 21 October 2026.
mujoco==3.16.0
gymnasium==1.4.0
stable-baselines3==2.9.0
torch==2.14.1
numpy==2.5.4
matplotlib==3.11.2
Why each package is in the project. Versions were checked on PyPI and the projects' release tags on 21 October 2026.
PackageVersionRole
mujoco3.16.0Physics simulator and Python bindings; the MuJoCo library is bundled
gymnasium1.4.0Environment interface and API checker
stable-baselines32.9.0PPO implementation, environment checker, callbacks, logging
torch2.14.1Neural networks used by Stable-Baselines3
numpy2.5.4Arrays and maths; needs Python 3.12 or newer
matplotlib3.11.2Training plots and saved images

[2] [9] [10] [1] [11]

Install

On Linux, install the CPU-only PyTorch build first. Without this step pip installs the default Linux wheel, which also downloads NVIDIA CUDA libraries you do not need here. Windows and macOS can skip it. The index URL is the one the PyTorch install selector gives for Linux, pip and CPU; check the selector if the command fails. [12]

Linux only, before requirements.txt
pip install torch==2.14.1 --index-url https://download.pytorch.org/whl/cpu
All systems
pip install -r requirements.txt

Verify the installation

Save check_setup.py and run it. It prints the operating system, Python version, CPU count, free disk space, every package version and whether PyTorch sees a CUDA GPU. Compare the versions with requirements.txt.

check_setup.py
"""check_setup.py: print the computer and package details that matter for this project.

Run it before installing (to see Python, CPU and disk) and again after installing (to see versions).
"""

import importlib
import os
import platform
import shutil
import sys

print(f"Operating system : {platform.platform()}")
print(f"Machine          : {platform.machine()}")
print(f"Python           : {sys.version.split()[0]} ({sys.executable})")
print(f"Logical CPUs     : {os.cpu_count()}")
free_gb = shutil.disk_usage(".").free / 1e9
print(f"Free disk here   : {free_gb:.1f} GB")

if sys.version_info < (3, 12):
    print("WARNING: requirements.txt pins NumPy 2.5, which needs Python 3.12 or newer.")

for name in ["mujoco", "gymnasium", "stable_baselines3", "torch", "numpy", "matplotlib"]:
    try:
        module = importlib.import_module(name)
        print(f"{name:<17}: {getattr(module, '__version__', 'unknown version')}")
    except ImportError:
        print(f"{name:<17}: not installed")

try:
    import torch

    print(f"CUDA available   : {torch.cuda.is_available()}")
    if torch.cuda.is_available():
        print(f"CUDA device      : {torch.cuda.get_device_name(0)}")
    mps = getattr(torch.backends, "mps", None)
    print(f"Apple MPS        : {bool(mps and mps.is_available())}")
    print("This project trains on the CPU either way (device='cpu' in train_ppo.py).")
except ImportError:
    pass
Terminal
python check_setup.py

Then confirm that MuJoCo itself loads by running a one-line simulation. It should print 3.16.0.

Terminal
python -c "import mujoco; m = mujoco.MjModel.from_xml_string('<mujoco/>'); print(mujoco.__version__)"

Installation problems

Common installation errors and fixes.
SymptomLikely causeFix
No matching distribution found for numpy==2.5.4Python older than 3.12 in the virtual environmentDelete .venv and recreate it with Python 3.12 or 3.13; check with python --version
No matching distribution for torch or mujocoPython too new for the published wheels, or a 32-bit PythonUse 64-bit Python 3.12 or 3.13
ModuleNotFoundError after a successful installThe virtual environment is not active, or a different python is on the PATHActivate .venv and run python -c "import sys; print(sys.executable)" to confirm it points inside .venv
Very large download on LinuxDefault PyTorch wheel with CUDA librariespip uninstall torch, then install from the CPU index URL above
pip asks for a C compiler or builds MuJoCo from sourceNo pre-built wheel for your platform or PythonSwitch to a supported Python version rather than building from source

The robot simulation model

Step 3: Build the simulated robot

MuJoCo models are written in MJCF, an XML format. Create a folder called assets and save this file as assets/reach_arm.xml. The comments in the file explain each part.

assets/reach_arm.xml
<!--
  reach_arm.xml: a three-joint robot arm for the TechniaHQRobot reinforcement learning tutorial.

  Frames: world z points up, x points forward. Lengths are in metres, masses in kilograms,
  angles in radians. All joint angles are zero when the arm points straight up.

  The arm is a teaching model, not a copy of a commercial robot. Its proportions are
  roughly those of a small desktop arm.
-->
<mujoco model="reach_arm">
  <compiler angle="radian" autolimits="true"/>

  <!-- 2 ms physics step. The environment applies one action every 20 physics steps (25 Hz). -->
  <option timestep="0.002" gravity="0 0 -9.81" integrator="implicitfast"/>

  <default>
    <!-- Passive joint damping and rotor inertia (armature) keep the joints numerically well behaved. -->
    <joint type="hinge" damping="0.5" armature="0.01"/>
    <!-- Arm geoms (contype 2) collide with the floor (conaffinity 2) but not with each other. -->
    <geom contype="2" conaffinity="1" density="0" rgba="0.25 0.45 0.65 1"/>
    <!-- Position servos: the policy moves each servo's setpoint; the servo produces torque. -->
    <position kp="100" dampratio="1" inheritrange="1"/>
  </default>

  <visual>
    <global offwidth="640" offheight="480"/>
  </visual>

  <worldbody>
    <light pos="0 -0.6 1.5" dir="0 0.4 -1"/>
    <geom name="floor" type="plane" size="1 1 0.05" rgba="0.9 0.9 0.88 1" contype="1" conaffinity="2"/>

    <!-- Fixed pedestal. A body without a joint is welded to the world. -->
    <body name="pedestal">
      <geom name="pedestal" type="cylinder" pos="0 0 0.05" size="0.06 0.05" rgba="0.3 0.3 0.3 1"/>

      <!-- Joint 1: base yaw turns the whole arm about the vertical axis. -->
      <body name="turntable" pos="0 0 0.1">
        <joint name="base_yaw" axis="0 0 1" range="-2.6 2.6"/>
        <geom name="turntable" type="cylinder" pos="0 0 0.025" size="0.045 0.025" mass="0.5"/>

        <!-- Joint 2: shoulder pitch tilts the upper arm forward and back. -->
        <body name="upper_arm" pos="0 0 0.05">
          <joint name="shoulder_pitch" axis="0 1 0" range="-1.6 1.6"/>
          <geom name="upper_arm" type="capsule" fromto="0 0 0 0 0 0.3" size="0.03" mass="1.0"/>

          <!-- Joint 3: elbow pitch bends the forearm relative to the upper arm. -->
          <body name="forearm" pos="0 0 0.3">
            <joint name="elbow_pitch" axis="0 1 0" range="-2.4 2.4"/>
            <geom name="forearm" type="capsule" fromto="0 0 0 0 0 0.25" size="0.025" mass="0.6"/>
            <!-- The end effector is a site: a named point with no mass or collision. -->
            <site name="end_effector" pos="0 0 0.25" size="0.015" rgba="1 0.8 0.1 1"/>
          </body>
        </body>
      </body>
    </body>

    <!-- The target is a mocap body: the code places it, physics never moves it, and it collides with nothing. -->
    <body name="target" mocap="true" pos="0.3 0 0.3">
      <geom name="target" type="sphere" size="0.02" contype="0" conaffinity="0" rgba="0.9 0.2 0.2 0.6"/>
    </body>
  </worldbody>

  <actuator>
    <!-- inheritrange="1" copies each joint range into the servo's control range. -->
    <position name="base_yaw" joint="base_yaw" forcerange="-10 10"/>
    <position name="shoulder_pitch" joint="shoulder_pitch" forcerange="-25 25"/>
    <position name="elbow_pitch" joint="elbow_pitch" forcerange="-12 12"/>
  </actuator>
</mujoco>

The arm is a chain of bodies. Each body is attached to its parent by a hinge joint, which allows rotation about one axis. Geoms give bodies their shape, collision surface and, through the mass attribute, their mass. A site is a named point without mass; the end_effector site at the tip is the point we want on the target.

Important model parameters and why they have these values.
ElementValueMeaning
Base yaw jointz axis, ±2.6 radTurns the arm left and right
Shoulder pitch jointy axis, ±1.6 radTilts the upper arm forward and back
Elbow pitch jointy axis, ±2.4 radBends the forearm
Link lengths0.30 m and 0.25 mShoulder sits 0.15 m above the floor; maximum reach 0.55 m from it
Moving masses0.5, 1.0 and 0.6 kgTurntable, upper arm, forearm; 2.1 kg in total
Position servoskp = 100 N·m/rad, dampratio = 1Torque = 100 × (setpoint − angle), with damping set for no overshoot
Torque limits10, 25 and 12 N·mCaps on each servo's output torque
Timestep0.002 s, implicitfastSmall enough for stable contact; implicitfast suits actuator damping
Collision filtercontype and conaffinityArm parts touch the floor but not each other

[13]

All lengths are in metres, masses in kilograms and angles in radians, because the compiler line sets angle="radian". The world frame has z pointing up. With every joint at zero the arm points straight up. The target is a mocap body: Python code moves it, physics ignores it, and its sphere has collisions switched off, so the arm can pass through it without being pushed.

The servo gain is a compromise. Gravity pulls a bent arm down, so a position servo settles slightly below its setpoint. A higher gain shrinks that error but makes the simulation stiffer. With kp = 100, holding the arm bent at the shoulder and elbow (1 rad each) left an error of 0.034 rad at the shoulder and 0.007 rad at the elbow in our run. The policy observes both the angle and the setpoint, so it can learn to compensate.

Load and look at the model

MuJoCo's viewer opens the file directly. Drag with the mouse to rotate the camera; double-click a body and Ctrl+right-drag to push it. The arm should stand upright and resist gentle pushes. [11]

Terminal
python -m mujoco.viewer --mjcf=assets/reach_arm.xml

On a computer without a display, such as a remote server, skip the viewer. Step 5 includes an off-screen rendering check instead.

For a larger model with 29 actuators, see MuJoCo for humanoid robotics.

The custom Gymnasium environment

Step 4: Build the reinforcement learning environment

Save this file as reach_env.py in the project folder, next to the assets folder. It is the most important file in the project: it defines what the policy sees, what it can do and how it is scored.

reach_env.py
"""reach_env.py: a Gymnasium environment in which a three-joint MuJoCo arm reaches a random target.

The policy controls position servos. Each action moves the three servo setpoints by at most
`max_target_step` radians. The servos then produce the joint torques during 20 physics steps.
"""

from pathlib import Path

import gymnasium as gym
import mujoco
import numpy as np
from gymnasium import spaces

MODEL_PATH = Path(__file__).resolve().parent / "assets" / "reach_arm.xml"

# Joint ranges used only to place targets. They sit inside the hard joint limits so that every
# target can be reached without pressing a joint against its stop.
TARGET_JOINT_LOW = np.array([-2.0, -1.2, -2.0])
TARGET_JOINT_HIGH = np.array([2.0, 1.2, 2.0])


class ReachEnv(gym.Env):
    """Move the end effector to within `success_threshold` metres of the target and stay there."""

    metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 25}

    def __init__(
        self,
        render_mode=None,
        max_episode_steps=100,
        frame_skip=20,
        max_target_step=0.05,
        success_threshold=0.02,
        action_cost=0.01,
        success_bonus=0.1,
    ):
        if render_mode is not None and render_mode not in self.metadata["render_modes"]:
            raise ValueError(f"Unsupported render_mode {render_mode!r}")
        self.render_mode = render_mode

        # The model and its simulation state. `_scratch` is a second state used only to compute
        # target positions, so target sampling never disturbs the running simulation.
        self.model = mujoco.MjModel.from_xml_path(str(MODEL_PATH))
        self.data = mujoco.MjData(self.model)
        self._scratch = mujoco.MjData(self.model)
        assert self.model.nq == 3 and self.model.nu == 3, "The model must have three joints and three actuators."

        # Timing: 20 physics steps of 2 ms give one control step of 40 ms (25 Hz).
        self.frame_skip = frame_skip
        self.control_dt = self.model.opt.timestep * frame_skip
        self.max_episode_steps = max_episode_steps

        # Task and reward settings.
        self.max_target_step = max_target_step
        self.success_threshold = success_threshold
        self.action_cost = action_cost
        self.success_bonus = success_bonus

        # Look up names once. Integer ids are faster than names inside the step loop.
        self.ee_site = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_SITE, "end_effector")
        target_body = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "target")
        self.target_mocap = int(self.model.body_mocapid[target_body])
        self.ctrl_low = self.model.actuator_ctrlrange[:, 0].copy()
        self.ctrl_high = self.model.actuator_ctrlrange[:, 1].copy()

        # Actions: one number per joint in [-1, 1], scaled to a setpoint change in step().
        self.action_space = spaces.Box(low=-1.0, high=1.0, shape=(3,), dtype=np.float32)
        # Observations: 18 scaled numbers, described in _get_obs(), clipped to [-10, 10].
        self.observation_space = spaces.Box(low=-10.0, high=10.0, shape=(18,), dtype=np.float32)

        self.target_pos = np.zeros(3)
        self.steps = 0
        self.first_reach_step = None
        self._renderer = None
        self._viewer = None

    # ----------------------------------------------------------------- Gymnasium API

    def reset(self, *, seed=None, options=None):
        # Seeds self.np_random. Every random choice below must use self.np_random.
        super().reset(seed=seed)
        options = options or {}

        mujoco.mj_resetData(self.model, self.data)
        start = self.np_random.uniform(-0.1, 0.1, size=3)
        self.data.qpos[:] = start
        self.data.qvel[:] = 0.0
        self.data.ctrl[:] = start  # The servos begin by holding the starting pose.

        if "target" in options:
            self.target_pos = np.asarray(options["target"], dtype=np.float64).copy()
        else:
            self.target_pos = self._sample_target()
        self.data.mocap_pos[self.target_mocap] = self.target_pos

        # mj_forward computes positions such as site_xpos from qpos without advancing time.
        mujoco.mj_forward(self.model, self.data)
        self.steps = 0
        self.first_reach_step = None

        distance = self._distance()
        if self.render_mode == "human":
            self.render()
        return self._get_obs(), self._get_info(distance, {})

    def step(self, action):
        action = np.clip(np.asarray(action, dtype=np.float64), -1.0, 1.0)

        # 1. Apply the action: move each servo setpoint, then keep it inside the joint range.
        self.data.ctrl[:] = np.clip(
            self.data.ctrl + action * self.max_target_step, self.ctrl_low, self.ctrl_high
        )

        # 2. Advance the physics by one control period, then refresh positions for the new state.
        bad_before = self.data.warning[int(mujoco.mjtWarning.mjWARN_BADQACC)].number
        mujoco.mj_step(self.model, self.data, nstep=self.frame_skip)
        mujoco.mj_forward(self.model, self.data)
        self.steps += 1

        # 3. Score the new state.
        distance = self._distance()
        reward, parts = self._reward(distance, action)
        if self.first_reach_step is None and distance < self.success_threshold:
            self.first_reach_step = self.steps

        # 4. Decide whether the episode ends.
        # terminated: the episode reached a terminal state. Here that only happens when the
        # simulation became numerically unstable (MuJoCo resets the state and counts a warning).
        unstable = (
            self.data.warning[int(mujoco.mjtWarning.mjWARN_BADQACC)].number > bad_before
            or not np.all(np.isfinite(self.data.qpos))
            or not np.all(np.isfinite(self.data.qvel))
        )
        terminated = bool(unstable)
        # truncated: the time limit ended the episode, not the task.
        truncated = bool(self.steps >= self.max_episode_steps)

        info = self._get_info(distance, parts)
        if terminated or truncated:
            # Success is judged on the final state, so passing through the target does not count.
            info["is_success"] = bool(distance < self.success_threshold and not terminated)

        if self.render_mode == "human":
            self.render()
        return self._get_obs(), float(reward), terminated, truncated, info

    def render(self):
        if self.render_mode == "rgb_array":
            if self._renderer is None:
                self._renderer = mujoco.Renderer(self.model, height=480, width=640)
            self._renderer.update_scene(self.data)
            return self._renderer.render()
        if self.render_mode == "human":
            if self._viewer is None:
                import mujoco.viewer

                self._viewer = mujoco.viewer.launch_passive(self.model, self.data)
            if self._viewer.is_running():
                self._viewer.sync()
        return None

    def close(self):
        if self._renderer is not None:
            self._renderer.close()
            self._renderer = None
        if self._viewer is not None:
            self._viewer.close()
            self._viewer = None

    # ----------------------------------------------------------------- task details

    def _sample_target(self):
        """Pick a random joint configuration and use its end-effector position as the target.

        Targets made this way are reachable by construction. Points too close to the floor or to
        the pedestal are rejected.
        """
        for _ in range(100):
            q = self.np_random.uniform(TARGET_JOINT_LOW, TARGET_JOINT_HIGH)
            self._scratch.qpos[:] = q
            mujoco.mj_kinematics(self.model, self._scratch)
            point = self._scratch.site_xpos[self.ee_site].copy()
            if point[2] > 0.08 and np.linalg.norm(point[:2]) > 0.10:
                return point
        raise RuntimeError("Could not sample a valid target in 100 attempts.")

    def _distance(self):
        return float(np.linalg.norm(self.data.site_xpos[self.ee_site] - self.target_pos))

    def _reward(self, distance, action):
        parts = {
            "reward_distance": -distance,
            "reward_action": -self.action_cost * float(np.sum(np.square(action))),
            "reward_success": self.success_bonus if distance < self.success_threshold else 0.0,
        }
        return sum(parts.values()), parts

    def _get_obs(self):
        """18 numbers, each divided by a fixed scale so most values fall roughly within [-1, 1].

        0-2   joint angles / pi                  (rad)
        3-5   joint velocities / 10              (rad/s)
        6-8   servo setpoints / pi               (rad)
        9-11  end-effector position / 0.6        (m)
        12-14 target position / 0.6              (m)
        15-17 (target - end effector) / 0.6      (m)
        """
        ee = self.data.site_xpos[self.ee_site]
        obs = np.concatenate(
            [
                self.data.qpos / np.pi,
                self.data.qvel / 10.0,
                self.data.ctrl / np.pi,
                ee / 0.6,
                self.target_pos / 0.6,
                (self.target_pos - ee) / 0.6,
            ]
        )
        # Clipping only matters if something goes badly wrong, for example a violent collision.
        return np.clip(obs, -10.0, 10.0).astype(np.float32)

    def _get_info(self, distance, parts):
        return {
            "distance": distance,
            "steps_to_reach": -1 if self.first_reach_step is None else self.first_reach_step,
            **parts,
        }

How the environment works

ReachEnv methods and what each one does.
MethodWhat it does
__init__Loads the model, creates a second MjData for target sampling, looks up the end-effector site and target body by name, reads the servo control ranges, and declares a 3-number action space in [-1, 1] and an 18-number observation space
resetSeeds the random generator with super().reset(seed=seed), places the arm near upright, sets the servos to hold that pose, draws a target, and calls mj_forward so positions are up to date
stepClips the action, moves the setpoints, runs 20 physics steps, refreshes positions, computes reward and distance, and decides terminated, truncated and is_success
_sample_targetPicks random joint angles, computes where the tip would be, and uses that point; every target is reachable by construction
_rewardReturns the total reward and its three parts so you can inspect them separately
_get_obsBuilds the 18 scaled numbers in float32
render and closeReturn an image (rgb_array) or update an interactive viewer (human), and release them

[8]

Some details matter more than they look.

  • super().reset(seed=seed) must come first in reset. It creates self.np_random, and every random choice uses it. That is what makes reset(seed=42) repeatable.
  • mj_step advances time but leaves positions such as site_xpos as they were computed before the final integration. Calling mj_forward afterwards makes the distance and observation describe the state the policy will act from. Gymnasium's own Reacher-v5 changed its reward for the same reason.
  • Observations are divided by fixed scales (π for angles, 10 rad/s for speeds, 0.6 m for positions) so most values fall between -1 and 1. Neural networks learn more reliably when inputs have similar sizes.
  • Actions are kept in [-1, 1] and scaled inside step. Stable-Baselines3 and Gymnasium both recommend a symmetric, normalized action space.
  • The observation space is declared with finite bounds of ±10 and the observation is clipped to them. Gymnasium's checker warns about infinite bounds.
  • is_success is added to info only on the last step. Stable-Baselines3 reads that key to log a success rate, and the Monitor wrapper writes it to the episode log.
  • If MuJoCo detects a numerical blow-up it resets its own state and raises a warning counter. The environment treats that as termination rather than continuing from a reset arm.

[8] [14] [5] [10] [9]

Test the environment before training

Step 5: Test without training

Training on a broken environment wastes hours and produces curves that look like a learning problem. Save test_env.py and run it. It stops at the first failed check.

test_env.py
"""test_env.py: check ReachEnv before any training.

Run:  python test_env.py
      python test_env.py --image arm.png   (also saves a rendered frame; needs an OpenGL backend)

The script stops with an AssertionError at the first failed check.
"""

import argparse

import numpy as np
from gymnasium.utils.env_checker import check_env

from reach_env import ReachEnv

parser = argparse.ArgumentParser()
parser.add_argument("--image", help="save one rendered frame to this PNG file")
args = parser.parse_args()

# 1. Gymnasium's own API checks: spaces, reset/step return types, seeding and determinism.
env = ReachEnv()
check_env(env, skip_render_check=True)
print("Gymnasium API check passed.")

# 2. Seeding: the same seed must give the same start and target.
obs_a, _ = env.reset(seed=42)
obs_b, _ = env.reset(seed=42)
assert np.array_equal(obs_a, obs_b), "reset(seed=42) twice gave different observations"
obs, info = env.reset(seed=42)
assert env.observation_space.contains(obs), "first observation is outside the observation space"
print(f"Observation shape {obs.shape}, dtype {obs.dtype}. Target {np.round(env.target_pos, 3)} m.")
print(f"Initial distance to target: {info['distance']:.3f} m")

# 3. One episode of random actions. Print every 10th step and check every value.
low = env.model.jnt_range[:, 0] - 0.05  # MuJoCo joint limits are soft, so allow a small overshoot.
high = env.model.jnt_range[:, 1] + 0.05
print(f"{'step':>4} {'distance':>9} {'r_dist':>8} {'r_action':>9} {'r_success':>9} {'reward':>8}")
for t in range(1, 1000):
    action = env.action_space.sample()
    obs, reward, terminated, truncated, info = env.step(action)
    assert np.all(np.isfinite(obs)) and np.isfinite(reward), f"non-finite value at step {t}"
    assert np.all(env.data.qpos >= low) and np.all(env.data.qpos <= high), f"joint outside its range at step {t}"
    parts = info["reward_distance"] + info["reward_action"] + info["reward_success"]
    assert abs(parts - reward) < 1e-9, "reward components do not add up to the reward"
    if t % 10 == 0 or terminated or truncated:
        print(f"{t:>4} {info['distance']:9.3f} {info['reward_distance']:8.3f} {info['reward_action']:9.4f} "
              f"{info['reward_success']:9.2f} {reward:8.3f}")
    if terminated or truncated:
        break

# 4. The episode must end by truncation at exactly max_episode_steps and report success.
assert truncated and not terminated, "episode should end by time limit, not termination"
assert t == env.max_episode_steps, f"episode ended after {t} steps, expected {env.max_episode_steps}"
assert "is_success" in info, "final info must contain is_success"
print(f"Episode truncated after {t} steps. is_success={info['is_success']}.")

# 5. Zero action: the servos should hold the pose, so the arm barely moves.
env.reset(seed=7)
start = env.data.site_xpos[env.ee_site].copy()
for _ in range(25):
    env.step(np.zeros(3, dtype=np.float32))
drift = np.linalg.norm(env.data.site_xpos[env.ee_site] - start)
print(f"End-effector drift after 1 s of zero actions: {drift * 1000:.1f} mm")
assert drift < 0.02, "the arm drifts with zero action; check actuator gains and gravity"

# 6. Extreme actions must keep the servo setpoints inside the actuator control range.
env.reset(seed=3)
for _ in range(100):
    env.step(np.ones(3, dtype=np.float32))
assert np.all(env.data.ctrl <= env.ctrl_high + 1e-12), "setpoint exceeded the control range"
print(f"Setpoints after 100 steps of +1 actions: {np.round(env.data.ctrl, 3)} (limits {env.ctrl_high})")
env.close()

# 7. Optional rendering check.
if args.image:
    import matplotlib.pyplot as plt

    render_env = ReachEnv(render_mode="rgb_array")
    render_env.reset(seed=42)
    frame = render_env.render()
    plt.imsave(args.image, frame)
    render_env.close()
    print(f"Saved a {frame.shape[1]}x{frame.shape[0]} frame to {args.image}")

print("All environment checks passed.")
Terminal
python test_env.py

The checks cover the Gymnasium API, seeding, a full random episode with finite values and joint limits, the reward parts adding up, truncation at exactly 100 steps, zero-action drift and setpoint limits. This is the output from our run; your numbers will match only if your library versions and platform match, but every check should pass.

Output of python test_env.py, recorded for this guide
Gymnasium API check passed.
Observation shape (18,), dtype float32. Target [ 0.152 -0.03   0.479] m.
Initial distance to target: 0.263 m
step  distance   r_dist  r_action r_success   reward
  10     0.266   -0.266   -0.0095      0.00   -0.276
  20     0.299   -0.299   -0.0097      0.00   -0.309
  30     0.286   -0.286   -0.0084      0.00   -0.295
  40     0.305   -0.305   -0.0113      0.00   -0.317
  50     0.322   -0.322   -0.0104      0.00   -0.333
  60     0.365   -0.365   -0.0122      0.00   -0.377
  70     0.400   -0.400   -0.0050      0.00   -0.405
  80     0.368   -0.368   -0.0113      0.00   -0.379
  90     0.389   -0.389   -0.0180      0.00   -0.407
 100     0.428   -0.428   -0.0011      0.00   -0.429
Episode truncated after 100 steps. is_success=False.
End-effector drift after 1 s of zero actions: 2.3 mm
Setpoints after 100 steps of +1 actions: [2.6 1.6 2.4] (limits [2.6 1.6 2.4])
All environment checks passed.

A random policy wanders and its distance stays far from 2 cm, which is expected. If your output shows NaN, an AssertionError or a joint far outside its range, stop and fix the environment first; the troubleshooting table lists the usual causes.

To check rendering, add --image arm.png. On a Linux machine without a display, MuJoCo needs an off-screen OpenGL backend; set MUJOCO_GL=egl (with a GPU driver) or MUJOCO_GL=osmesa (software rendering, if installed) before the command. Rendering is optional and has no effect on training.

Terminal
python test_env.py --image arm.png
MUJOCO_GL=egl python test_env.py --image arm.png   # Linux without a display

Reward function design

Step 6: Design and test the reward

The reward at each control step is the sum of three terms, all computed in _reward:

Reward per step
r = − d  −  0.01 × (a₁² + a₂² + a₃²)  +  0.1 × [d < 0.02 m]

d   distance from the tip to the target, in metres
a   the three actions, each between −1 and 1
[ ] 1 when the condition is true, otherwise 0
The three reward terms.
TermSize per stepIn plain English
−dabout −0.4 at the start, 0 at the targetBeing far away costs points every step, so getting close quickly is worth more
−0.01 × Σa²0 to −0.03Large, jerky commands cost a little; the arm is not punished for moving, only for moving harshly
+0.1 inside 2 cm0 or +0.1Staying at the target is worth more than hovering at 2.5 cm

Over a 100-step episode on our 200 evaluation targets, holding still scored -41.8 on average, because the targets start 42 cm from the tip on average. The hand-written controller's successful episodes averaged -2.6, reaching the target after a median of 33 steps. Both figures are measured, from the executed folder. The scale is deliberate: per-step rewards close to the range −1 to 1 keep the value network's job easy, and the distance term dominates so the arm's main incentive is the task itself.

Dense versus sparse rewards

A sparse reward pays only on success, for example +1 if the tip ends within 2 cm. It describes the task exactly but gives no clue about direction, and a random policy almost never succeeds, so the agent has nothing to learn from. The dense −d term tells the agent after every step whether it got warmer or colder. Stable-Baselines3's guide recommends starting with an informative, shaped reward and a simplified task. [5]

Reward hacking

An RL agent optimizes exactly what you wrote, not what you meant. Three ways this reward could go wrong if changed carelessly:

  • Remove the time limit's fixed length and end the episode on first contact with a large bonus: the policy learns to fling the tip through the target at full speed, because nothing rewards stopping there.
  • Make the action penalty large (for example 1.0): standing still becomes the best strategy, because the cost of moving exceeds the distance saved.
  • Reward progress (the decrease in distance) without the distance term: moving away and back again earns as much as staying put, and oscillation can score well.

Our design avoids these by judging success on the final state, keeping the action cost small, and penalizing distance directly. Before training, read the per-step reward parts printed by test_env.py: with random actions the distance term should dominate and the success term should almost always be zero. During evaluation, the summary reports final distance separately from return, so a policy cannot look good on reward alone.

PPO training implementation

Step 7: Train the policy

Save train_ppo.py. It checks the environment with Stable-Baselines3's checker, builds four training copies and a separate evaluation copy, trains PPO, writes logs and checkpoints, and always saves the final model, even after Ctrl+C.

train_ppo.py
"""train_ppo.py: train a PPO policy for ReachEnv with Stable-Baselines3.

Run:     python train_ppo.py --timesteps 500000 --seed 0
Resume:  python train_ppo.py --resume runs/ppo_seed0/checkpoints/ppo_reach_200000_steps.zip --timesteps 300000

Outputs, under runs/ppo_seed<seed>/ unless --run-dir is given:
  config.json        settings, seeds and package versions (config_from_<steps>.json after a resume)
  logs/progress.csv  training metrics written by SB3's logger (logs_from_<steps>/ after a resume)
  monitor/*.csv      one row per finished training episode
  eval/evaluations.npz   periodic evaluation results (eval_from_<steps>/ after a resume)
  checkpoints/       intermediate models
  best_model/best_model.zip   highest mean evaluation reward so far
  final_model.zip    model at the end of training
"""

import argparse
import json
import platform
import sys
from pathlib import Path

import gymnasium
import mujoco
import numpy as np
import stable_baselines3
import torch
from stable_baselines3 import PPO
from stable_baselines3.common.callbacks import CallbackList, CheckpointCallback, EvalCallback
from stable_baselines3.common.env_checker import check_env
from stable_baselines3.common.env_util import make_vec_env
from stable_baselines3.common.logger import configure
from stable_baselines3.common.utils import set_random_seed

from reach_env import ReachEnv

# Starting values for this task, not universal settings. Change one at a time and re-evaluate.
PPO_CONFIG = {
    "learning_rate": 3e-4,  # step size of each network update
    "n_steps": 1024,        # steps collected per environment before each update
    "batch_size": 256,      # samples per gradient step; 1024 x 4 envs = 4096 = 16 batches
    "n_epochs": 10,         # passes over each batch of collected experience
    "gamma": 0.99,          # discount: how much later rewards count
    "gae_lambda": 0.95,     # bias/variance trade-off when estimating advantages
    "clip_range": 0.2,      # how far one update may move the policy
    "ent_coef": 0.0,        # bonus for keeping actions random; 0 relies on the policy's own noise
    "vf_coef": 0.5,         # weight of the value-function loss
    "max_grad_norm": 0.5,   # cap on gradient size, for stability
    "policy_kwargs": {"net_arch": {"pi": [64, 64], "vf": [64, 64]}},
}


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--timesteps", type=int, default=500_000,
                        help="environment steps to train (when resuming: additional steps)")
    parser.add_argument("--seed", type=int, default=0)
    parser.add_argument("--n-envs", type=int, default=4, help="parallel copies of the environment")
    parser.add_argument("--run-dir", default=None)
    parser.add_argument("--resume", default=None, help="path to a saved .zip model to continue training")
    args = parser.parse_args()

    run_dir = Path(args.run_dir or f"runs/ppo_seed{args.seed}")
    run_dir.mkdir(parents=True, exist_ok=True)

    # Seed Python, NumPy and PyTorch. Results can still differ across computers and versions.
    set_random_seed(args.seed)

    # Check the environment against the API Stable-Baselines3 expects.
    check = ReachEnv()
    check_env(check, warn=True)
    check.close()

    # Training environments: n_envs copies stepped one after another in this process.
    # Monitor records each finished episode, including is_success and the final distance.
    train_env = make_vec_env(
        ReachEnv,
        n_envs=args.n_envs,
        seed=args.seed,
        monitor_dir=str(run_dir / "monitor"),
        # When resuming, append to the existing monitor files instead of overwriting them.
        monitor_kwargs={"info_keywords": ("is_success", "distance"), "override_existing": args.resume is None},
    )
    # A separate evaluation environment with a different seed, so evaluation targets differ.
    eval_env = make_vec_env(ReachEnv, n_envs=1, seed=args.seed + 10_000)

    if args.resume:
        model = PPO.load(args.resume, env=train_env, device="cpu")
        print(f"Resumed from {args.resume} at {model.num_timesteps} steps.")
    else:
        model = PPO("MlpPolicy", train_env, seed=args.seed, device="cpu", verbose=1, **PPO_CONFIG)

    # Write metrics to the screen and to a CSV file. A resumed run writes to new folders
    # (logs_from_<steps>, eval_from_<steps>) so the earlier files are kept.
    suffix = "" if not args.resume else f"_from_{model.num_timesteps}"
    model.set_logger(configure(str(run_dir / f"logs{suffix}"), ["stdout", "csv"]))

    # Callback frequencies count calls to env.step() on the vectorized env, and each call
    # advances all n_envs copies. Dividing by n_envs makes the numbers below environment steps.
    checkpoint = CheckpointCallback(
        save_freq=max(50_000 // args.n_envs, 1),
        save_path=str(run_dir / "checkpoints"),
        name_prefix="ppo_reach",
    )
    evaluation = EvalCallback(
        eval_env,
        n_eval_episodes=20,
        eval_freq=max(25_000 // args.n_envs, 1),
        log_path=str(run_dir / f"eval{suffix}"),
        best_model_save_path=str(run_dir / "best_model"),  # after a resume, "best" restarts from the resume point
        deterministic=True,
    )

    config = {
        "args": vars(args),
        "ppo": PPO_CONFIG,
        "env": {
            "max_episode_steps": 100, "frame_skip": 20, "max_target_step": 0.05,
            "success_threshold": 0.02, "action_cost": 0.01, "success_bonus": 0.1,
        },
        "versions": {
            "python": sys.version.split()[0], "platform": platform.platform(),
            "mujoco": mujoco.__version__, "gymnasium": gymnasium.__version__,
            "stable_baselines3": stable_baselines3.__version__, "torch": torch.__version__,
            "numpy": np.__version__,
        },
    }
    (run_dir / f"config{suffix}.json").write_text(json.dumps(config, indent=2))

    try:
        model.learn(
            total_timesteps=args.timesteps,
            callback=CallbackList([checkpoint, evaluation]),
            reset_num_timesteps=args.resume is None,
        )
    except KeyboardInterrupt:
        # Ctrl+C stops training; the model trained so far is still saved below.
        print("Training interrupted.")
    finally:
        model.save(run_dir / "final_model")
        print(f"Saved {run_dir / 'final_model.zip'} after {model.num_timesteps} steps.")
        train_env.close()
        eval_env.close()


if __name__ == "__main__":
    main()

What the PPO settings mean

PPO parameters in train_ppo.py. These are starting values for this task, not universal settings.
ParameterValue hereSB3 defaultWhat it controls
learning_rate3e-43e-4Size of each network update. Too high and training becomes unstable; too low and it crawls
n_steps1024 per env2048Steps collected per environment before an update; with 4 envs, 4,096 steps (about 41 episodes) per update
batch_size25664Samples per gradient step; 4,096 divides into 16 batches
n_epochs1010Passes over each collected batch. More passes reuse data harder and risk overfitting it
gamma0.990.99Discount factor: a reward 100 steps away counts about 0.37 as much as one now
gae_lambda0.950.95Balances bias and noise when estimating whether an action was better than expected
clip_range0.20.2Limits how much one update can change the probability of an action
ent_coef0.00.0Bonus for staying random. Zero relies on the policy's learned action spread to explore
net_arch64-64 for policy and value64-64Size of the two networks

[3] [9] [6] [7]

Larger batch sizes per update were chosen because four environments produce 4,096 steps per rollout; 256 keeps the number of gradient steps per update close to SB3's defaults. None of these values has been tuned for this task. If you change them, change one at a time and compare across several seeds.

Run, stop and resume

Terminal
python train_ppo.py --timesteps 500000 --seed 0

The default is 500,000 environment steps, about 5,000 episodes. That is a reasonable first budget for a small reaching task, not a guarantee of success; the curves in Step 8 tell you whether it was enough. Training prints a table of metrics after each update and an evaluation summary every 25,000 steps.

Press Ctrl+C to stop. The script saves final_model.zip with the steps completed so far. Checkpoints are written every 50,000 steps. To continue from a checkpoint, pass it with --resume; --timesteps then means additional steps. Logs and settings from the resumed part go to new names (logs_from_<steps>, eval_from_<steps>, config_from_<steps>.json) and the monitor files are appended to, so nothing earlier is overwritten. best_model.zip is then compared only with evaluations made after the resume. A resumed run is not identical to an uninterrupted one, because the random number streams restart. [9]

Terminal
python train_ppo.py --resume runs/ppo_seed0/checkpoints/ppo_reach_200000_steps.zip --timesteps 300000

Callback frequencies in Stable-Baselines3 count calls to the vectorized environment, and each call advances all four copies. The script divides by the number of environments so that 25,000 and 50,000 mean environment steps, as the callback documentation recommends. [15]

Seeds: set_random_seed seeds Python, NumPy and PyTorch, and make_vec_env seeds each environment copy. Stable-Baselines3 still does not promise bit-identical results across machines or library versions, so record the versions (config.json does) and compare results across seeds rather than expecting exact repeats. [5]

Monitor the training

Step 8: Watch the right numbers

Training writes three kinds of records: logs/progress.csv (one row per update, written by SB3's logger), monitor/*.monitor.csv (one row per finished training episode, with success and final distance), and eval/evaluations.npz (the periodic deterministic evaluations). Save plot_training.py and run it at any time, including while training is running.

plot_training.py
"""plot_training.py: plot the metrics recorded during training.

Run:  python plot_training.py --run-dir runs/ppo_seed0
Writes <run-dir>/training_curves.png and prints the last logged values.
"""

import argparse
import csv
from pathlib import Path

import matplotlib

matplotlib.use("Agg")  # write files without opening a window
import matplotlib.pyplot as plt
import numpy as np


def read_progress(run_dir):
    """Merge logs*/progress.csv files (one per run or resume) into columns keyed by metric name."""
    rows = []
    for path in sorted(run_dir.glob("logs*/progress.csv")):
        with open(path, newline="") as f:
            rows.extend(csv.DictReader(f))
    rows.sort(key=lambda r: float(r.get("time/total_timesteps") or 0))
    return rows


def column(rows, key):
    xs, ys = [], []
    for r in rows:
        if r.get(key) not in (None, "") and r.get("time/total_timesteps") not in (None, ""):
            xs.append(float(r["time/total_timesteps"]))
            ys.append(float(r[key]))
    return np.array(xs), np.array(ys)


def read_monitor_distances(run_dir):
    """Final distance of every training episode, ordered by wall-clock end time."""
    episodes = []
    for path in sorted((run_dir / "monitor").glob("*.monitor.csv")):
        with open(path, newline="") as f:
            next(f)  # first line is a JSON comment written by Monitor
            for r in csv.DictReader(f):
                episodes.append((float(r["t"]), float(r["distance"])))
    episodes.sort()
    return np.array([d for _, d in episodes])


def read_evaluations(run_dir):
    steps, success, reward = [], [], []
    for path in sorted(run_dir.glob("eval*/evaluations.npz")):
        data = np.load(path)
        steps.extend(data["timesteps"])
        reward.extend(data["results"].mean(axis=1))
        if "successes" in data.files:
            success.extend(data["successes"].mean(axis=1))
    return np.array(steps), np.array(reward), np.array(success)


def moving_average(values, window=100):
    if len(values) < window:
        return values
    return np.convolve(values, np.ones(window) / window, mode="valid")


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--run-dir", default="runs/ppo_seed0")
    args = parser.parse_args()
    run_dir = Path(args.run_dir)

    rows = read_progress(run_dir)
    if not rows:
        raise SystemExit(f"No progress.csv found under {run_dir}. Has training started?")
    eval_steps, eval_reward, eval_success = read_evaluations(run_dir)
    distances = read_monitor_distances(run_dir)

    fig, axes = plt.subplots(2, 3, figsize=(15, 8))
    ax = axes[0, 0]
    ax.plot(*column(rows, "rollout/ep_rew_mean"), label="training (last 100 episodes)")
    if len(eval_steps):
        ax.plot(eval_steps, eval_reward, "o-", label="evaluation (deterministic)")
    ax.set(title="Episode reward", xlabel="environment steps", ylabel="return")
    ax.legend()

    ax = axes[0, 1]
    ax.plot(*column(rows, "rollout/success_rate"), label="training (stochastic actions)")
    if len(eval_success):
        ax.plot(eval_steps, eval_success, "o-", label="evaluation (deterministic)")
    ax.set(title="Success rate (final distance < 2 cm)", xlabel="environment steps", ylim=(-0.05, 1.05))
    ax.legend()

    ax = axes[0, 2]
    if len(distances):
        ax.plot(moving_average(distances) * 1000)
        ax.axhline(20, color="grey", linestyle="--", label="success threshold")
        ax.legend()
    ax.set(title="Final distance, training episodes (100-episode average)", xlabel="episode", ylabel="mm")

    ax = axes[1, 0]
    ax.plot(*column(rows, "train/value_loss"))
    ax.set(title="Value loss", xlabel="environment steps", yscale="log")

    ax = axes[1, 1]
    ax.plot(*column(rows, "train/policy_gradient_loss"), label="policy gradient loss")
    ax.plot(*column(rows, "train/approx_kl"), label="approximate KL")
    ax.plot(*column(rows, "train/clip_fraction"), label="clip fraction")
    ax.set(title="Policy update size", xlabel="environment steps")
    ax.legend()

    ax = axes[1, 2]
    ax.plot(*column(rows, "time/fps"))
    ax.set(title="Training speed", xlabel="environment steps", ylabel="steps per second")

    fig.tight_layout()
    out = run_dir / "training_curves.png"
    fig.savefig(out, dpi=120)
    print(f"Saved {out}")

    last = rows[-1]
    for key in ["time/total_timesteps", "rollout/ep_rew_mean", "rollout/success_rate",
                "train/value_loss", "train/approx_kl", "train/std", "time/fps"]:
        print(f"{key:<24} {last.get(key, '')}")


if __name__ == "__main__":
    main()
Terminal
python plot_training.py --run-dir runs/ppo_seed0
Metrics to watch and what they tell you.
MetricWhereWhat to look for
rollout/ep_rew_meanprogress.csvAverage return of the last 100 training episodes. Should rise from about −40
rollout/ep_len_meanprogress.csvAlways 100 here, because episodes end only by time limit. A lower value means terminations: investigate
rollout/success_rateprogress.csvTraining successes, with exploration noise. Usually below the evaluation rate
eval/success_rate, eval/mean_rewardprogress.csv and evaluations.npzDeterministic performance on separate targets every 25,000 steps
Final distancemonitor filesShould fall towards and below 20 mm
train/value_lossprogress.csvLarge early, then lower; a sudden explosion signals unstable updates
train/approx_kl, train/clip_fractionprogress.csvSize of each policy update. Persistently high values suggest the learning rate is too high
train/stdprogress.csvSpread of the action distribution. It shrinks as the policy commits
time/fpsprogress.csvEnvironment steps per second, including updates. Use it to estimate run time on your machine

[9]

Rising reward is not proof that the robot does the task. The reward could climb because the arm gets from 40 cm to 5 cm away while never entering the 2 cm success zone. That is why the success rate and final distance are plotted separately and why Step 9 uses a fixed success definition.

If the curves look wrong

  • Reward flat from the start: re-run test_env.py, then check that actions change the setpoints (print env.data.ctrl), that the target moves between resets, and that the observation contains the target. Only then consider hyperparameters.
  • Reward rises, then collapses: look at approx_kl and value_loss around the collapse. A lower learning rate (1e-4) or fewer epochs is a reasonable first change.
  • Reward improves but success stays near zero: the policy gets close but not within 2 cm. Train longer, compare final-distance curves, or check that the success bonus is actually paid (reward_success in info).
  • Training success is high but evaluation success low: evaluation uses unseen targets and deterministic actions. Check how many evaluation episodes back the number (20 per evaluation here) before drawing conclusions.

For resource use, watch the training process in Task Manager, Activity Monitor or top, and note time/fps. We did not measure memory or speed for this guide, so record your own figures in the run folder; if memory keeps growing during training, see the troubleshooting table.

Evaluate the trained robot and compare with baselines

Step 9: Evaluate on fixed, unseen targets

Training metrics are noisy and use random actions. A separate evaluation runs the finished policy on a fixed list of episodes and reports numbers you can compare. Define success before you look at results; here it is a final distance below 0.02 m after 100 steps, the same rule the environment uses.

evaluate.py
"""evaluate.py: measure a trained policy and two baselines on the same targets.

Run:  python evaluate.py --model runs/ppo_seed0/best_model/best_model.zip --episodes 200
Writes results/episodes.csv (one row per episode and policy) and results/summary.json.

Success is fixed before evaluation: the end effector is within 0.02 m of the target at the
final step of the 100-step episode.
"""

import argparse
import csv
import json
import math
from pathlib import Path

import mujoco
import numpy as np

from reach_env import ReachEnv

EVAL_SEED = 2026  # different from the training seeds; episode i uses seed EVAL_SEED + i


class RandomPolicy:
    """Uniform random actions: the floor any learned policy must clear."""

    def __init__(self, env, seed):
        self.env = env
        self.env.action_space.seed(seed)

    def __call__(self, obs):
        return self.env.action_space.sample()


class JacobianController:
    """Hand-designed baseline: damped least-squares inverse kinematics.

    Uses the simulator's exact Jacobian, which the learned policy never sees.
    dq = J^T (J J^T + lambda^2 I)^-1 * error, then scaled to the [-1, 1] action range.
    """

    def __init__(self, env, gain=0.5, damping=0.05):
        self.env, self.gain, self.damping = env, gain, damping
        self.jacp = np.zeros((3, env.model.nv))
        self.jacr = np.zeros((3, env.model.nv))

    def __call__(self, obs):
        env = self.env
        mujoco.mj_jacSite(env.model, env.data, self.jacp, self.jacr, env.ee_site)
        error = env.target_pos - env.data.site_xpos[env.ee_site]
        jac = self.jacp
        dq = jac.T @ np.linalg.solve(jac @ jac.T + self.damping**2 * np.eye(3), error)
        return np.clip(self.gain * dq / env.max_target_step, -1.0, 1.0).astype(np.float32)


class PPOPolicy:
    def __init__(self, path, deterministic):
        from stable_baselines3 import PPO  # imported here so the baselines run without PyTorch

        self.model = PPO.load(path, device="cpu")
        self.deterministic = deterministic

    def __call__(self, obs):
        action, _ = self.model.predict(obs, deterministic=self.deterministic)
        return action


def run_episodes(name, policy_factory, episodes):
    env = ReachEnv()
    policy = policy_factory(env)
    rows = []
    for i in range(episodes):
        obs, info = env.reset(seed=EVAL_SEED + i)  # same seed -> same start pose and target for every policy
        episode_return, action_sq, terminated, truncated = 0.0, 0.0, False, False
        while not (terminated or truncated):
            action = policy(obs)
            obs, reward, terminated, truncated, info = env.step(action)
            episode_return += reward
            action_sq += float(np.sum(np.square(action)))
        rows.append({
            "policy": name,
            "episode": i,
            "success": int(info["is_success"]),
            "final_distance_m": info["distance"],
            "steps_to_reach": info["steps_to_reach"],
            "episode_length": env.steps,
            "return": episode_return,
            "mean_squared_action": action_sq / env.steps,
        })
    env.close()
    return rows


def wilson_interval(successes, n, z=1.96):
    """95% confidence interval for a success rate measured on n episodes."""
    if n == 0:
        return (0.0, 0.0)
    p = successes / n
    centre = (p + z * z / (2 * n)) / (1 + z * z / n)
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
    return (max(0.0, centre - half), min(1.0, centre + half))


def summarise(rows):
    n = len(rows)
    successes = sum(r["success"] for r in rows)
    final = np.array([r["final_distance_m"] for r in rows])
    reached = [r["steps_to_reach"] for r in rows if r["steps_to_reach"] > 0]
    low, high = wilson_interval(successes, n)
    return {
        "episodes": n,
        "success_rate": successes / n,
        "success_rate_95ci": [low, high],
        "final_distance_mean_m": float(final.mean()),
        "final_distance_std_m": float(final.std(ddof=1)) if n > 1 else 0.0,
        "final_distance_median_m": float(np.median(final)),
        "episode_length_mean": float(np.mean([r["episode_length"] for r in rows])),
        "steps_to_reach_mean": float(np.mean(reached)) if reached else None,
        "return_mean": float(np.mean([r["return"] for r in rows])),
    }


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--model", help="trained PPO .zip; omit to evaluate only the baselines")
    parser.add_argument("--episodes", type=int, default=200)
    parser.add_argument("--stochastic", action="store_true", help="sample PPO actions instead of using the mean")
    parser.add_argument("--out", default="results")
    args = parser.parse_args()

    policies = {
        "random": lambda env: RandomPolicy(env, seed=EVAL_SEED),
        "jacobian_controller": lambda env: JacobianController(env),
    }
    if args.model:
        policies["ppo"] = lambda env: PPOPolicy(args.model, deterministic=not args.stochastic)

    all_rows, summary = [], {}
    for name, factory in policies.items():
        rows = run_episodes(name, factory, args.episodes)
        all_rows.extend(rows)
        summary[name] = summarise(rows)

    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=True)
    with open(out / "episodes.csv", "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=list(all_rows[0].keys()))
        writer.writeheader()
        writer.writerows(all_rows)
    meta = {"eval_seed": EVAL_SEED, "model": args.model, "deterministic_ppo": not args.stochastic}
    (out / "summary.json").write_text(json.dumps({"settings": meta, "policies": summary}, indent=2))

    print(f"{'policy':<20} {'success':>8} {'95% CI':>15} {'final dist (mm)':>18} {'steps to reach':>15}")
    for name, s in summary.items():
        ci = f"{s['success_rate_95ci'][0]:.2f}-{s['success_rate_95ci'][1]:.2f}"
        dist = f"{s['final_distance_mean_m'] * 1000:.1f} ± {s['final_distance_std_m'] * 1000:.1f}"
        steps = "-" if s["steps_to_reach_mean"] is None else f"{s['steps_to_reach_mean']:.1f}"
        print(f"{name:<20} {s['success_rate']:>8.2f} {ci:>15} {dist:>18} {steps:>15}")
    print(f"Wrote {out / 'episodes.csv'} and {out / 'summary.json'}")


if __name__ == "__main__":
    main()
Terminal
python evaluate.py --model runs/ppo_seed0/best_model/best_model.zip --episodes 200

Episode i of every policy uses seed 2026 + i, so all policies face exactly the same starting poses and targets, and none of these seeds are used in training. The script writes results/episodes.csv with one row per episode and results/summary.json with the averages.

The success rate is reported with a 95% Wilson confidence interval. With 200 episodes, an observed rate of 90% is compatible with a true rate of roughly 85 to 93%, so a 2-point difference between two policies on 200 episodes is not evidence that one is better. Final distance is reported as mean ± standard deviation across episodes, and the median is in the summary file.

By default PPO is evaluated deterministically, using the mean action. That is usually better and makes runs repeatable, but it hides how the policy behaves with its training noise; run with --stochastic to see that too. Neither mode tells you how the policy copes with changes the simulator never showed it, such as a heavier forearm. [5]

Step 10: Compare with baselines

A number like 85% success means little alone. Two baselines put it in context. The random policy is the floor: anything learned must beat it by a wide margin. The Jacobian controller is a classical alternative written in a dozen lines: it uses the simulator's exact Jacobian (how each joint moves the tip) and damped least squares to step the setpoints toward the target. It has information the policy never sees, so it is a demanding comparison.

We ran evaluate.py without --model, which evaluates only the two baselines, on 200 episodes. These are measured results for the baselines from our run, recorded in the downloadable executed folder. The PPO row is empty because PPO was not trained for this guide.

Baseline results from our run (200 episodes each, seeds 2026–2225). The PPO row is a template to fill with your own measurement.
PolicySuccess rate (95% CI)Final distance, mm (mean ± SD)Steps to first reach (mean)Mean return
Random actions0.0% (0.0–1.9%)445.8 ± 179.822.0-44.1
Jacobian controller (gain 0.5)74.5% (68.0–80.0%)44.5 ± 95.433.0-9.9
PPO, your trained modelMeasureMeasureMeasureMeasure

The controller's failures are instructive. In our run, 50 of its 51 failed episodes ended with the shoulder within 0.05 rad of its ±1.6 rad limit. When the arm starts upright, the base joint has no effect on the tip, so the controller's first moves are poorly defined, and on targets behind or beside the robot it often leans the wrong way and ends with the shoulder pressed against its ±1.6 rad limit, where the Jacobian step cannot get it out. A learned policy has a chance of discovering a better strategy, such as turning first, but nothing guarantees it. If PPO beats the controller on your measurement, check which targets it wins on. If it does not, that is a legitimate result: a hand-written controller with full model knowledge is hard to beat on a task this simple.

To watch the trained policy, run view_policy.py. On macOS use mjpython instead of python, because MuJoCo's passive viewer needs that launcher there. [11]

view_policy.py
"""view_policy.py: watch a trained policy in MuJoCo's interactive viewer.

Run (Windows, Linux):  python view_policy.py --model runs/ppo_seed0/best_model/best_model.zip
Run (macOS):           mjpython view_policy.py --model runs/ppo_seed0/best_model/best_model.zip

Close the viewer window to stop. Needs a display; it will not work over a plain SSH session.
"""

import argparse
import time

from stable_baselines3 import PPO

from reach_env import ReachEnv

parser = argparse.ArgumentParser()
parser.add_argument("--model", required=True)
parser.add_argument("--episodes", type=int, default=5)
args = parser.parse_args()

model = PPO.load(args.model, device="cpu")
env = ReachEnv(render_mode="human")
try:
    for episode in range(args.episodes):
        obs, info = env.reset(seed=500 + episode)
        terminated = truncated = False
        while not (terminated or truncated):
            start = time.time()
            action, _ = model.predict(obs, deterministic=True)
            obs, reward, terminated, truncated, info = env.step(action)
            # Sleep so one control step takes about 40 ms of wall time (real-time playback).
            time.sleep(max(0.0, env.control_dt - (time.time() - start)))
            if env._viewer is not None and not env._viewer.is_running():
                raise SystemExit
        print(f"Episode {episode}: final distance {info['distance'] * 1000:.1f} mm, success {info['is_success']}")
finally:
    env.close()
Terminal
python view_policy.py --model runs/ppo_seed0/best_model/best_model.zip
mjpython view_policy.py --model runs/ppo_seed0/best_model/best_model.zip   # macOS

Reproducibility and project files

Step 11: Save and reproduce the experiment

What to keep for each experiment.
File or folderWhy it matters
assets/reach_arm.xmlThe physics: masses, gains and limits change results
reach_env.pyObservations, actions, reward and success rule
train_ppo.py, evaluate.py, plot_training.pyExact training and measurement procedure
requirements.txt and runs/<run>/config.jsonLibrary versions, Python version and platform actually used
SeedsTraining seed in the run folder name and config.json; evaluation seed 2026 in evaluate.py
runs/<run>/checkpoints/ and final_model.zip, best_model/The trained policies themselves
runs/<run>/logs*, monitor/, eval*/Raw training records behind every plot
results/episodes.csv and summary.jsonPer-episode evaluation results, not only averages
training_curves.pngThe plot, regenerated from the logs above

Put the project folder under version control (git), excluding .venv/. Commit before each training run so every run folder corresponds to a known version of the code. Name runs by what changed, for example runs/ppo_seed0_kp100.

To rerun from a clean machine: install Python 3.12 or 3.13, create a fresh virtual environment, install requirements.txt (with the CPU PyTorch step on Linux), run test_env.py, then train with the same seed and evaluate with the same episode count. Expect small numerical differences across operating systems and processor types. Train at least three seeds (0, 1, 2) and report the spread across them; one run of an RL algorithm is an anecdote. [5]

Three seeds (macOS and Linux)
for seed in 0 1 2; do python train_ppo.py --seed $seed; done
for seed in 0 1 2; do python evaluate.py --model runs/ppo_seed$seed/best_model/best_model.zip --out results/seed$seed; done

The project README collects the commands in one place.

README.md
# rl-reach: train a robot arm to reach with reinforcement learning

Companion project for the TechniaHQRobot guide
"How to Train a Robot with Reinforcement Learning: A Complete Beginner's Guide"
(https://techniahqrobot.com/guides/train-robot-reinforcement-learning/).

A three-joint MuJoCo arm learns, with PPO from Stable-Baselines3, to move its tip within 2 cm
of a random target and stay there. Everything runs on a CPU, in simulation.

## Files

| File | Purpose |
| --- | --- |
| requirements.txt | Pinned package versions (Python 3.12 or 3.13) |
| check_setup.py | Prints Python, CPU, disk and package versions |
| assets/reach_arm.xml | MuJoCo model of the arm, servos and target |
| reach_env.py | Gymnasium environment |
| test_env.py | Environment checks; run before training |
| train_ppo.py | PPO training with logs, checkpoints and evaluation |
| plot_training.py | Training curves from the logs |
| evaluate.py | Success rate and distance for PPO, a random policy and a Jacobian controller |
| view_policy.py | Watch a trained policy in the MuJoCo viewer |
| executed/ | Outputs recorded when this project was checked (see below) |

## Commands

```bash
python3.12 -m venv .venv
source .venv/bin/activate            # Windows: .venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install torch==2.14.1 --index-url https://download.pytorch.org/whl/cpu   # Linux only
pip install -r requirements.txt
python check_setup.py
python test_env.py
python train_ppo.py --timesteps 500000 --seed 0
python plot_training.py --run-dir runs/ppo_seed0
python evaluate.py --model runs/ppo_seed0/best_model/best_model.zip --episodes 200
python view_policy.py --model runs/ppo_seed0/best_model/best_model.zip   # macOS: mjpython
```

## What was executed

See executed/execution.json for the exact environment. In short: the model and environment
were run with MuJoCo 3.16.0 and Gymnasium 1.4.0, test_env.py passed, and evaluate.py was run
for the two baselines. plot_training.py was run only on synthetic log files to test its parsing.
train_ppo.py, view_policy.py and the PPO part of evaluate.py were not executed, because PyTorch
and Stable-Baselines3 could not be installed in that environment. No PPO result is claimed
anywhere in this project.

Troubleshooting

Step 12: Diagnose common failures

Check the environment and reward before touching PPO settings. Most RL problems in a new project are environment bugs.

Problems, likely causes, how to check and what to change.
ProblemLikely causesHow to checkFix
ImportError or ModuleNotFoundErrorVirtual environment not active; package missingpython -c "import sys; print(sys.executable)"; pip listActivate .venv; pip install -r requirements.txt
Incompatible package versionsMixed installs, an old global package, wrong Pythonpython check_setup.py; compare with requirements.txtRecreate .venv and reinstall pinned versions
Simulator crash or ValueError on loadXML typo, wrong path to reach_arm.xml, edited attributepython -m mujoco.viewer --mjcf=assets/reach_arm.xml shows the parse errorFix the XML line reported; keep reach_env.py next to assets/
Observation shape mismatchChanged _get_obs without updating observation_space, or loaded a model trained with another shapePrint obs.shape and env.observation_space.shapeMake both 18; retrain after any observation change
Actions outside the expected rangeCustom code skips clipping; action space editedPrint the min and max of actions in a test episodeKeep the Box at [-1, 1]; step() clips again
NaN or infinite valuesUnstable physics after raising kp or the timestep; division by zero in custom codetest_env.py asserts finite values; check data.warning countersRestore kp = 100 and timestep 0.002; check new reward terms
Reward never improvesBug in the target, observation or action path; reward sign errorRun test_env.py; print the target per reset and ctrl per stepFix the environment first, then try a smaller learning rate
Movement looks random after trainingToo few steps; watching a stochastic policy; wrong model fileCheck ep_rew_mean and success curves; use deterministic=TrueTrain longer; load best_model.zip; compare with the random baseline
Succeeds in training, fails in evaluationEvaluation uses new targets and deterministic actions; too few evaluation episodesRun evaluate.py with 200 episodes, and with --stochasticTrain on more varied targets, or longer; report confidence intervals
Training uses too much RAMToo many parallel environments; a very large n_steps; other programsWatch memory while changing --n-envsUse --n-envs 2 and a smaller n_steps; close other applications
Simulation is slowRendering during training; debug output each step; a very small timestepCompare time/fps with render_mode=NoneNever render while training; keep frame_skip 20; profile with fewer envs
Arm moves through the target or behaves unnaturallySuccess counted on first contact; tiny action cost; very high servo gainsPlot the distance over an episode in evaluate.pyJudge success at the end, as here; keep the action cost; check the servo settings
Environment termination errors (reset needed, step after done)Calling step() after terminated or truncated without reset()Read the traceback for the call orderCall reset() whenever terminated or truncated is True

[5] [4]

Sim-to-real transfer and safety

Step 13: Moving toward a real robot

This tutorial trains and evaluates a policy in simulation. Running it on a physical arm is a separate engineering project, and a policy file should never be connected directly to motors. The steps below are the minimum before any hardware test.

  • Validate the model: measure the real arm's link lengths, masses and joint ranges, and command the same setpoint changes in simulation and on the robot. Compare the joint trajectories before involving the policy.
  • Match kinematics and dynamics: if the real servo is slower, has a different gain or saturates earlier, change the model rather than hoping the policy copes.
  • Check joint and actuator limits on the hardware side: software limits in the robot's own controller, set tighter than the mechanical stops.
  • Define action scaling to the real interface: 0.05 rad per 40 ms must mean the same thing to the real servo driver, at the same control rate.
  • Model observation noise and latency: real encoders are noisy and a camera-based target arrives late. Add noise and a delay of one or two steps to the simulated observations and check that performance holds.
  • Use domain randomization where it helps: vary masses, servo gains, friction and delays across training episodes so the policy does not depend on one exact setting. Peng and colleagues reported pushing-task transfer with dynamics randomization under their conditions; it improves robustness to the ranges you randomize, but it does not guarantee transfer.
  • Test robustness before hardware: evaluate the policy on deliberately wrong models (heavier forearm, weaker servo) and see how fast success falls.
  • Add independent safety limits: a torque, speed and workspace limiter outside the policy, and a hardware emergency stop that removes power. The policy must not be able to override them.
  • Start with low-risk experiments: reduced speed, reduced torque limits, nobody within reach, and a target in open space.
  • Measure the sim-to-real gap: run the same evaluation protocol on hardware, with the same success definition, and report both numbers side by side.

[16]

For the physical side of this gap, read what changes from simulation to physical walking.

For the safety and conformity questions, see humanoid robot safety standards.

Limitations and next learning steps

The project is deliberately small. Its limits are worth knowing so you do not read more into a result than it supports.

  • The arm is a teaching model, not a replica of a commercial robot. Its servos are ideal position controllers with torque limits, without friction, backlash, motor heating or sensor noise.
  • The policy receives the exact target position. Finding the target with a camera is a separate perception problem.
  • Targets are points; there is no orientation, no obstacle and no object to grasp.
  • One training run with one seed says little. Results depend on the seed, the library versions and the computer.
  • Success within 2 cm in simulation says nothing about safety near people.

Next steps, roughly in order of difficulty: train three seeds and compare them; add observation noise and a one-step delay and measure the effect; randomize the forearm mass and servo gain; add an obstacle and a collision penalty; try SAC, an off-policy algorithm in Stable-Baselines3, on the same environment; then move to a published robot model.

For a full humanoid training setup and empty evaluation templates, continue with how to train a humanoid robot to walk in simulation.

Technical glossary

Terms used in this tutorial.
TermMeaning
ActionWhat the policy outputs each step; here three numbers that move the servo setpoints
AdvantageHow much better an action turned out than the value network expected
AgentThe learner that chooses actions; here the PPO policy
CheckpointA saved copy of the model during training
Deterministic evaluationUsing the policy's mean action instead of sampling
Domain randomizationVarying simulator parameters during training to reduce dependence on one exact model
End effectorThe working tip of a robot arm; here a site at the forearm's end
EntropyHow spread out the policy's action distribution is
EnvironmentThe world the agent acts in, with rules for reward and episode ends
EpisodeOne attempt from reset to termination or truncation
JacobianA matrix that says how each joint's motion moves the end effector
MJCFMuJoCo's XML model format
ObservationThe numbers the policy sees each step
PolicyThe function, here a neural network, that maps observations to actions
PPOProximal Policy Optimization, an RL algorithm that limits how much each update changes the policy
RewardThe score the environment returns after each action
RolloutA batch of experience collected with the current policy
SeedThe starting number for a random generator; the same seed repeats the same random choices
SetpointThe angle a position servo is told to hold
Sim-to-real gapThe difference between simulated and physical behaviour that can make a policy fail on hardware
Terminated / truncatedEpisode ended by the task itself / by an outside limit such as time
Value functionA network that predicts the future reward from a state

References and official documentation

Keep these open while you work. Each one supports a specific part of the project; the numbered source list below gives the details and check dates.

Where each part of the project is documented.
TopicPrimary reference
Environment interface, reset and step, checkerGymnasium: Create a custom environment; Env API reference
PPO parameters, CPU useStable-Baselines3: PPO
Normalization, evaluation, seeds, time limitsStable-Baselines3: RL Tips and Tricks
Checkpoint and evaluation frequencyStable-Baselines3: Callbacks
MJCF actuators, damping, rangesMuJoCo XML reference
Viewer, Python API, mjpythonMuJoCo Python bindings
The algorithmSchulman et al., Proximal Policy Optimization Algorithms
Advantage estimationSchulman et al., Generalized Advantage Estimation
Dynamics randomizationPeng et al., Sim-to-Real Transfer with Dynamics Randomization
Installing PyTorchPyTorch: Start Locally

[8] [4] [3] [5] [15] [13] [11] [6] [7] [16] [12]

Download every file, including the recorded test output and baseline results, from the rl-reach download folder.

Sources and verification

  1. MuJoCo 3.16.0 changelog and release ↗Google DeepMind on GitHub · Read 21 October 2026

    Released 14 October 2026. Breaking changes listed in the changelog concern resource and decoder plugins, not the Python calls used here. The prebuilt Linux library from this release compiled and stepped reach_arm.xml.

  2. PyPI project pages for torch, numpy and matplotlib ↗Python Package Index · Read 21 October 2026

    torch 2.14.1 (30 September 2026, Python 3.10-3.14); numpy 2.5.4 (10 October 2026, requires Python 3.12 or newer); matplotlib 3.11.2 (11 September 2026, Python 3.11 or newer); mujoco 3.16.0; gymnasium 1.4.0; stable-baselines3 2.9.0.

  3. PPO ↗Stable-Baselines3 documentation · Read 21 October 2026

    Parameter definitions for n_steps, batch_size, n_epochs, gamma, gae_lambda, clip_range and ent_coef. States that PPO is meant to run primarily on the CPU when no CNN is used.

  4. Env API reference ↗Farama Foundation, Gymnasium documentation · Read 21 October 2026

    Defines terminated as reaching a terminal state of the task and truncated as a condition outside the task, typically a time limit. Explains reset(seed=...).

  5. Reinforcement Learning Tips and Tricks ↗Stable-Baselines3 documentation · Read 21 October 2026

    Recommends actions rescaled to [-1, 1], normalized observations, a separate test environment, deterministic evaluation, several seeds, check_env for custom environments, and returning truncated=True on timeouts.

  6. Proximal Policy Optimization Algorithms ↗Schulman, Wolski, Dhariwal, Radford and Klimov, arXiv · Read 21 October 2026

    Submitted 20 July 2017. Introduces an objective that allows several epochs of minibatch updates on the same collected data.

  7. High-Dimensional Continuous Control Using Generalized Advantage Estimation ↗Schulman, Moritz, Levine, Jordan and Abbeel, arXiv · Read 21 October 2026

    Origin of the advantage estimator controlled by gae_lambda.

  8. Create a custom environment ↗Farama Foundation, Gymnasium documentation · Read 21 October 2026

    Supports the reset/step structure, calling super().reset(seed=seed) before using self.np_random, the five values returned by step(), and gymnasium.utils.env_checker.check_env.

  9. Stable-Baselines3 v2.9.0 release tag, setup.py and source ↗DLR-RM on GitHub · Read 21 October 2026

    Tagged 15 June 2026. Requires Python 3.10 or newer, gymnasium>=0.29.1,<2.0, numpy<3.0 and torch>=2.8. PPO defaults, the GPU warning for MlpPolicy, success-rate logging from info['is_success'], and resume behaviour (reset_num_timesteps=False adds to the step counter) were read from this tag.

  10. Gymnasium v1.4.0 release tag, pyproject.toml and env_checker.py ↗Farama Foundation on GitHub · Read 21 October 2026

    Tagged 5 October 2026. Requires Python 3.10 or newer (classifiers to 3.14). check_env warns about infinite Box bounds and non-normalized action spaces, and tests seeding and step determinism.

  11. MuJoCo Python bindings ↗Google DeepMind, MuJoCo documentation · Read 21 October 2026

    Read from doc/python.rst at tag 3.16.0. pip install mujoco includes the MuJoCo library; python -m mujoco.viewer --mjcf=... opens a model; launch_passive needs the mjpython launcher on macOS.

  12. PyTorch: Start Locally ↗PyTorch Foundation · Read 21 October 2026

    The install selector generates the command for each platform and compute option. The page states Python 3.10 to 3.14 for current releases.

  13. MuJoCo XML reference: position actuator ↗Google DeepMind, MuJoCo documentation · Read 21 October 2026

    Read from doc/XMLreference.rst at tag 3.16.0. kp is the position gain; dampratio=1 gives critical damping; inheritrange copies the joint range into ctrlrange; kv and dampratio are best used with the implicitfast or implicit integrators.

  14. Reacher-v5 ↗Farama Foundation, Gymnasium documentation · Read 21 October 2026

    Gymnasium's built-in two-joint reaching task with torque actions. Version 5 changed the distance reward to use the state after the physics step, the same point this tutorial handles with mj_forward.

  15. Callbacks ↗Stable-Baselines3 documentation · Read 21 October 2026

    With several environments, each env.step() call counts as n_envs steps; divide eval_freq and save_freq by n_envs.

  16. Sim-to-Real Transfer of Robotic Control with Dynamics Randomization ↗Peng, Andrychowicz, Zaremba and Abbeel, arXiv · Read 21 October 2026

    Randomizes simulator dynamics during training and reports transfer of an object-pushing policy to a physical arm. One study under its own conditions, not a guarantee for other robots.

Evidence limits

  • train_ppo.py, view_policy.py and evaluate.py with --model were not executed. Their Stable-Baselines3 calls were checked against the 2.9.0 source tag. plot_training.py was run only on synthetic logs in SB3's file formats.
  • The executed scripts ran on Linux with Python 3.13.16 and NumPy 2.5.3. requirements.txt pins NumPy 2.5.4; the Windows and macOS commands were not run.
  • MuJoCo was built from source for this check because PyPI was unreachable. The pip wheel is the supported route and was not tested here.
  • The interactive viewer and off-screen rendering were not tested; the research machine had no OpenGL libraries.
  • Training time, memory use and the number of steps PPO needs on this task were not measured.

Article history

First version. The model, environment test and baseline evaluation were executed; PPO training was not, and the page says where.

Report a correction