RoboticsModule 20 · From-scratch code · 6 of 6

minimalRL: reinforcement learning in one file each

Eleven reinforcement learning algorithms, each in a single PyTorch file of about 70–190 lines, all aimed at the same pole-balancing game.

Reposeungeunrho/minimalRL
LicenceMIT
Stars3,252 (7 Nov 2026, GitHub API)
Core code12 Python files, 1,606 lines in total (counted with wc -l). REINFORCE 66, DQN 115, PPO 118.
StatusNot archived, but quiet: the last commit was 22 Apr 2023. The README requires gym > 0.26.2.
NeedsPython 3, PyTorch, Gym (we used Gymnasium, see below). CPU is enough.

Tested at commit c8efed8 on a MacBook with an Apple M4, Python 3.14.5, torch 2.15.0, gymnasium 1.4.0. Short code excerpts are quoted with credit to seungeunrho (MIT).

On this page
  1. Start here (no maths needed)
  2. Summary
  3. How the code works: the algorithm ladder
  4. Run it, tested (CartPole)
  5. Connect to Robo-Helper: DQN vs A* in our room
  6. Why it matters for robotics
  7. Reality check
  8. Self-check
  9. Further reading

Start here (no maths needed)

A program can learn to balance a stick on a moving cart just by trying, falling, and keeping score. Nobody tells it the rules of balancing.

Think of it like this

Balance a broom upright on your palm. When it tips left, you move your hand left. Nobody gave you an equation. You dropped it a lot, and slowly your hand learned what works. CartPole is that game for a computer. A pole stands on a cart. Each moment the program pushes the cart left or right. It gets 1 point for every moment the pole stays up, up to 500. Reinforcement learning (RL) is the "drop it a lot and learn" part.

How it works

  1. The program looks at four numbers: where the cart is, how fast it moves, the pole's angle, and how fast the pole is tipping.
  2. It picks "push left" or "push right".
  3. The game says how many points that was worth, and whether the pole fell.
  4. After many tries, it nudges its neural network so good choices become more likely.
  5. Different algorithms (REINFORCE, DQN, PPO…) are different ways of doing step 4.

Why robots care

A pole on a cart is a tiny version of a robot keeping its balance: a two-wheeled robot, a walking robot, an arm holding a full cup. The same algorithms, especially PPO, are used to teach simulated robots to walk.

Try it

Words to know

Episode
One try, from start until the pole falls or time runs out.
Reward
Points the game gives after each move.
Policy
The program's rule for choosing a move.
Q-value
"How many points do I expect if I do this move now and play well after?"
Replay memory
A big notebook of past moves the program re-reads to learn from them again.
Environment
The game or world the program acts in. Here, CartPole.

Going deeper: everything below is the full version. On a first read, look at the real scores and the Robo-Helper experiment, then come back for the code.

1. Summary

TL;DR

minimalRL puts each classic deep RL algorithm in one short PyTorch file, with the environment fixed so you can focus on the algorithm. We ran REINFORCE, DQN and PPO on CartPole-v1 for 3 minutes each. All three learned: REINFORCE peaked at an average of 317, DQN and PPO both hit the maximum of 500. PPO got there fastest in episodes (a perfect 20-episode average at episode 320), then partly collapsed to about 150. Then we wrapped Robo-Helper's room as an environment and trained a DQN to drive to a block. It matched A* exactly, but it broke when the cat moved, unless the cat was part of what it could see.

2. How the code works: the algorithm ladder

Every file has the same shape: a small network class, a train_net (or train) function, and a main() loop that plays episodes and prints the average score every 20 episodes. The algorithms differ only in what the network predicts and what loss it learns from.

RungFileWhat it learnsThe new idea
1REINFORCE.pyA policy (probabilities of left/right)After an episode, make each action more likely in proportion to the total reward that followed it.
2actor_critic.pyPolicy + a value estimateA "critic" guesses how good a state is, so the actor learns from "better or worse than expected", every few steps instead of every episode.
3a3c.py, a2c.pySame, with many workersSeveral copies of the game play at once (asynchronously in A3C, in lockstep in A2C) for steadier learning.
4ppo.pyPolicy + valueReuse each batch for several updates, but clip how far the policy may move per update. Uses GAE to estimate advantages.
sidedqn.pyQ-values onlyQ-learning with a neural net, a replay memory and a frozen target network.
sideacer.py, vtrace.pyPolicy + value, from older dataCorrections that let the learner use data from a slightly older policy.
continuousddpg.py, sac.py, ppo-continuous.pyActions as real numbersFor torques and speeds, not left/right. These three use Pendulum-v1, not CartPole.

REINFORCE in three lines

# REINFORCE.py (MIT, seungeunrho)
for r, prob in self.data[::-1]:
    R = r + gamma * R
    loss = -torch.log(prob) * R

Walk the episode backwards, adding up discounted reward R. Then push up the log-probability of each action, scaled by R. Good episodes make their actions more likely. That's the whole algorithm.

DQN: the one idea that makes it click

# dqn.py (MIT, seungeunrho)
q_a = q_out.gather(1,a)
max_q_prime = q_target(s_prime).max(1)[0].unsqueeze(1)
target = r + gamma * max_q_prime * done_mask

This is exactly the Q-learning target from Lesson 7: reward plus γ times the best Q at the next state, and just the reward if the episode ended (done_mask = 0). Three things are new:

PPO's clip

# ppo.py (MIT, seungeunrho)
surr1 = ratio * advantage
surr2 = torch.clamp(ratio, 1-eps_clip, 1+eps_clip) * advantage
loss = -torch.min(surr1, surr2) + F.smooth_l1_loss(self.v(s) , td_target.detach())

ratio is "how much more likely is this action now than when we collected the data". Clamping it to 0.9–1.1 (eps_clip = 0.1) stops one update from changing the policy too much. That is what lets PPO safely reuse each batch 3 times (K_epoch = 3).

3. Run it, tested (CartPole)

All runs on 7 Nov 2026, MacBook (Apple M4), CPU, Python 3.14.5, torch 2.15.0, in a private venv in a scratch folder. Other jobs were running on the laptop at the same time, so on a quiet machine more episodes would fit in 3 minutes.

The Gym problem

$ pip install "gym>0.26.2"
ERROR: Could not find a version that satisfies the requirement gym>0.26.2 (from versions: 0.0.2, ... 0.26.1, 0.26.2)

$ pip install "gym==0.26.2"        # installs, then:
AttributeError: module 'numpy' has no attribute 'bool8'. Did you mean: 'bool'?

Gym stopped at 0.26.2 and uses np.bool8, which numpy 2 removed. Gym's own install message points to Gymnasium: "replace 'import gym' with 'import gymnasium as gym'". So we made modified copies of three files in the scratch folder only. The clone stays untouched. Two changes per file:

$ diff minimalRL/dqn.py mrl-mod/dqn.py
1c1
< import gym
---
> import gymnasium as gym
95a96
>             done = done or truncated

The second change is a bug fix. Since Gym 0.26, hitting the 500-step time limit sets truncated, not done. The original loops only stop on done. Once the pole is balanced well, they would keep calling step() past the limit. The comment in REINFORCE.py, "CartPole-v1 forced to terminates at 500 step", is from before that API change. We found this by reading the code. We didn't run the unfixed version.

pip install torch gymnasium
python runcap.py mrl-mod/REINFORCE.py 180     # runs a script, kills it after 180 s, prints wall time
python runcap.py mrl-mod/dqn.py 180
python runcap.py mrl-mod/ppo.py 180

REINFORCE

# 7 Nov 2026, MacBook (Apple M4), CPU. Selected lines (one every 200 episodes) + the end.
# of episode :20, avg score : 21.65
# of episode :200, avg score : 31.65
# of episode :400, avg score : 53.1
# of episode :600, avg score : 59.35
# of episode :800, avg score : 104.45
# of episode :1000, avg score : 136.5
# of episode :1200, avg score : 177.0
# of episode :1400, avg score : 200.05
# of episode :1600, avg score : 194.65
# of episode :1800, avg score : 241.1
# of episode :1900, avg score : 286.45
# of episode :1920, avg score : 292.2
[wall time 180.1s, cap 180s]

Slow and steady. First 20-episode average above 200 at episode 1,140 (221.7). Best: 317.05 at episode 1,700. It never reached 500 in 3 minutes.

DQN

# 7 Nov 2026, MacBook (Apple M4), CPU. Selected lines + the end.
n_episode :20, score : 10.2, n_buffer : 203, eps : 7.9%
n_episode :200, score : 9.6, n_buffer : 1976, eps : 7.0%
n_episode :300, score : 13.9, n_buffer : 3110, eps : 6.5%
n_episode :320, score : 30.8, n_buffer : 3726, eps : 6.4%
n_episode :340, score : 101.4, n_buffer : 5754, eps : 6.3%
n_episode :360, score : 207.9, n_buffer : 9913, eps : 6.2%
n_episode :560, score : 160.9, n_buffer : 45130, eps : 5.2%
n_episode :960, score : 314.1, n_buffer : 50000, eps : 3.2%
n_episode :1760, score : 478.8, n_buffer : 50000, eps : 1.0%
n_episode :1960, score : 485.1, n_buffer : 50000, eps : 1.0%
n_episode :2560, score : 286.1, n_buffer : 50000, eps : 1.0%
n_episode :2840, score : 475.4, n_buffer : 50000, eps : 1.0%
[wall time 180.1s, cap 180s]

Nothing happens for 200 episodes. That's on purpose: training only starts once the replay memory holds 2,000 moves. Then it jumps from about 10 to 208 in 60 episodes. It hit a perfect 500.0 average at episode 1,900, and wobbled between about 290 and 485 after that. (DQN prints "score", not "avg score", but it is the same 20-episode average.)

PPO

# 7 Nov 2026, MacBook (Apple M4), CPU. Every line to episode 340, then selected lines.
# of episode :20, avg score : 33.0
# of episode :40, avg score : 35.5
# of episode :60, avg score : 45.4
# of episode :80, avg score : 68.2
# of episode :100, avg score : 81.2
# of episode :120, avg score : 137.3
# of episode :140, avg score : 212.9
# of episode :160, avg score : 275.7
# of episode :180, avg score : 371.2
# of episode :200, avg score : 279.7
# of episode :220, avg score : 338.6
# of episode :240, avg score : 196.4
# of episode :260, avg score : 269.9
# of episode :280, avg score : 448.9
# of episode :300, avg score : 482.6
# of episode :320, avg score : 500.0
# of episode :340, avg score : 497.2
# of episode :400, avg score : 500.0
# of episode :560, avg score : 334.7
# of episode :640, avg score : 499.1
# of episode :720, avg score : 152.3
# of episode :800, avg score : 149.2
# of episode :820, avg score : 170.2
[wall time 180.1s, cap 180s]

Fastest learner by episodes: above 200 at episode 140, a perfect 500.0 at episode 320. Fewer episodes fit in 3 minutes (820) because good episodes are long, up to 500 steps each. Then it partly collapsed, to about 150 by episode 720. This is a known RL failure. Nothing in the file saves the best policy or lowers the learning rate, so it can unlearn. Real projects save checkpoints and evaluate them separately.

Both DQN and PPO also printed a PyTorch warning that building a tensor from a list of numpy arrays "is extremely slow". It's harmless here.

4. Connect to Robo-Helper: DQN vs A* in our room

Lesson 7 did tabular Q-learning in a maze. dqn.py is the next rung: the same target rule, plus a neural network, replay memory and a target network. So we tested it on Robo-Helper's real room, against the A* planner from Lesson 6.

The setup

Results

$ SOLUTIONS=…/18-robot-brain/project/solutions python -I grid_dqn.py
# 7 Nov 2026, MacBook (Apple M4), CPU, torch 2.15.0
room 10x8, 42 free squares, start (1, 1), goal blue block (6, 3), cat (4, 1)
A* knowing the cat: 9 steps, 14 us per plan, 0 training
Lesson 11 drive_to (finds the cat on the way, replans): ok=True, 11 moves, 0.10 ms
DQN (position only): trained 1500 episodes in 9.3 s; first optimal greedy run at episode 200
  from start: ok=True, 9 steps, 0 bumps | from every free square: reaches block 40/40, shortest path 40/40
  twist, cat moves to (5, 3): DQN ok=False, 60 steps, 53 bumps | A* 7 steps
  twist, cat leaves the room: DQN ok=True, 9 steps, 0 bumps | A* 7 steps
  twist, 10% wheel slip (200 runs): DQN success 100% (10.2 steps) | A* open-loop 50% (9.0) | A* replanning every step 100% (10.0)
  twist, 20% wheel slip (200 runs): DQN success 100% (11.8 steps) | A* open-loop 22% (9.0) | A* replanning every step 100% (11.6)
DQN (position + cat), 4000 episodes in 78.8 s: reaches the block for 40/40 cat positions, shortest path on 40/40 | A*: 40/40

A second run with the same seeds gave identical results, but different times (DQN training 39.7 s and 165.7 s, A* 54 µs per plan), because the laptop was busy with other jobs (load average about 20). Times here are rough. The step counts and success rates are exact.

TestA* (Lesson 6/11)DQN, sees only its positionDQN, also sees the cat
Training needednone~200 episodes to the first optimal run; 1,500 run (9–40 s)4,000 episodes (79–166 s)
Start → blue block, cat at (4, 1)9 steps if it knows the cat; 11 if it discovers it on the way9 steps, 0 bumps9 steps
From all 40 start squares40/40 shortest40/40 shortestnot tested
Cat moves to (5, 3)replans: 7 stepsfails: 53 bumps in 60 stepstrained for it: 40/40 cat squares, all shortest
Cat leaves7 steps9 steps (still detours)(covered above)
20% wheel slip, 200 runsopen-loop 22%; replanning 100%, 11.6 steps100%, 11.8 stepsnot tested

What it shows:

And PPO? In Lesson 12, RLHF by hand, Robo-Helper learned a reward model from a person's route choices, then used best-of-n to pick a route. The lesson explains that real RLHF goes one step further: it trains the policy itself with PPO (or GRPO) on the learned reward, with a KL penalty as a leash. ppo.py is that update in 118 lines. The clip in the loss above is a second leash of the same kind: "improve, but don't move too far from the policy that collected the data".

Next-step exercise

In grid_dqn.py, set the goal to the red block at (2, 1), which is only 1 step from the start. Predict before running: will the position-only DQN need more or fewer episodes than for the blue block? Then add a second cat and see whether the cat-aware DQN still gets 40/40, without changing the number of episodes.

5. Why it matters for robotics

6. Reality check

7. Self-check

What three things does DQN add to the Lesson 7 Q-learning table?

A neural network that estimates Q for any state, a replay memory of past moves to sample training batches from, and a target network that is only copied every so often, so the target doesn't move with every update.

Why did DQN's score stay around 10 for the first 200 episodes?

It doesn't train until the replay memory holds more than 2,000 moves. Before that, it is acting with random starting weights.

What does PPO's clip do, and why does it help?

It limits how much more or less likely an action can become in one update (here between 0.9× and 1.1×). That makes it safe to reuse the same batch for several updates.

In our room, why did the position-only DQN fail when the cat moved?

The cat wasn't part of its state, so it couldn't know anything changed. It kept choosing the move it learned, into the cat. A* replans with the new obstacle. Adding the cat to the state and training with the cat in many places fixed it.

With 20% wheel slip, why did "A* open-loop" fail but the DQN succeed?

Open-loop A* follows a fixed list of moves, so one slip puts it off its path for good. The DQN chooses from wherever it actually is. A* that replans every step does just as well.

8. Further reading