← All tutorials

Deep Deterministic Policy Gradient (DDPG): Actor and Critic

An actor network outputs continuous torques and a critic network scores them.

Deep Q-Networks (DQN) chooses from a short list of actions: its banana-collecting agent had four (forward, backward, turn left, turn right), and DQN scores each one and takes the biggest.

A robot arm needs a continuous action: how hard to twist each joint, any real number from −1 to 1, so there are infinitely many actions to score. The running example is the RF-DDPG repo, a DDPG solution for the Unity ML-Agents Reacher task, where a double-jointed arm keeps its hand on a floating target.

DDPG uses two networks. An actor turns the state into torques, and a critic scores that state and those torques. The critic learns its scores from rewards as DQN does, and the actor moves its torques in the direction the critic scores higher.

The two networks and their two updates are enough to run DDPG; three stabilizers, taught in Training DDPG: Target Networks, Replay and Noise, make it train reliably.

All twenty arms act every step, but learning runs in bursts (Figure 1).

120 arms act and store, every step2learn from random past steps3slow copies catch up a little0256512768environment step
Figure 1. ① Acting never stops; ② and ③ run in bursts every 256 steps.

On this page

1 The Reacher arm 2 Why argmax fails for continuous actions 3 Actor and critic networks 4 The actor-critic loop 5 The critic update 6 The actor update 7 The DDPG loop in code 8 References

1 The Reacher arm

The Reacher environment is a double-jointed arm bolted to a table, one segment driven by the shoulder and one by the elbow. The job is to keep the hand on a small floating ball for a whole episode, not to touch it once.

Every step the agent gets thirty-three numbers: how bent each joint is, how fast it swings, where the segments are, and where the target sits.

It answers with four torques, two per joint, one for each axis it can turn about, each any real number from −1 to 1. That is a continuous action space: infinitely many settings, recomputed every step.

Episodes run a fixed number of steps. A step with the hand on the target pays +0.1, any other step pays 0, and the episode score is the total.

Physics simulationarm, ball, +0.1 per step on targetAgentreads numbers, not pixels33 numbersreward +0.1 or 04 torqueseach -1 to 1
Figure 2. Each step, 33 numbers and a reward in, 4 torques out.

The repo trains twenty identical arms in parallel in one Unity build; Unity runs the physics and Python runs the networks. env is the repo's Unity environment (UnityEnvironment from the unityagents package), and brain_name, which is env.brain_names[0], is the key of the arms in its output. s is (20, 33), one row per arm, and actions is (20, 4): random torques for now, which the actor of Section 3 replaces in Section 7.

import numpy as np
NUM_ARMS = 20

out = env.reset(train_mode=True)
info = out[brain_name]
s = info.vector_observations
scores = np.zeros(NUM_ARMS)
while True:
    actions = np.random.uniform(
        -1, 1, (NUM_ARMS, 4))
    out = env.step(actions)
    info = out[brain_name]
    scores += info.rewards
    s = info.vector_observations
    if any(info.local_done):
        break
  1. 1Resets all 20 arms and reads their states.
  2. 2Steps the arms with random torques in [−1, 1] until any episode ends, adding up rewards.
The task counts as solved when the average score over the twenty arms, held over a hundred episodes, reaches 30, about 300 steps on target per episode.
average score ≥ 30
Each episode's score is first averaged over the twenty arms, then over the last 100 episodes, so one lucky arm or one lucky episode cannot pass the bar of 30.
on-target steps ≈ 300.1 = 300
Each on-target step pays 0.1, so an average of 30 means about 300 steps per episode with the hand on the ball. The bar is really a holding time.

Three things make it hard. Most steps pay 0, so the agent must work out which of hundreds of past torques earned each +0.1: the credit assignment problem. A slightly wrong torque fails quietly, drifting the hand a millimeter off. And the arm never starts in the same pose twice, so the policy must work from any 33-number state.

Early in training the hand moves in circles and scores under 1; late in training it stays on the ball and scores 35 to 38 (the training curve in Training DDPG: Target Networks, Replay and Noise).

2 Why argmax fails for continuous actions

DQN chooses with one rule: take the action with the biggest Q value. That needs a finite list of actions.

DQN scores every action on a list and keeps the best; DDPG has a network output the move directly.
a = argmaxa Q(s, a)
DQN picks the move with the biggest score: Q(s, a) is the score of move a in state s, and argmax returns that move. With four buttons that is four scores to compare. With four continuous torques there are infinitely many moves, so there is no list to take the biggest of.
104 = 10,000 combos
Splitting each of the 4 torques into 10 levels gives 10 to the power 4 combinations, and every extra torque multiplies the count by 10 again (the curse of dimensionality). The levels also throw away precision: 0.61 versus 0.62 on the elbow can decide whether the hand holds the ball.
a = μ(s)
In DDPG the actor μ is a network that maps the state s straight to the four torques a. Choosing a move costs one forward pass, with no list and no search.

The DDPG paper (Lillicrap et al., 2016) keeps DQN's Q-network (here the critic), its replay buffer and its target network, and adds an actor. The replay buffer holds up to 100,000 past steps from all twenty arms, and each update samples a random minibatch of 256 from it. The target network is a slow copy of each network, moved a share τ = 1e-3 of the way toward the live one after every update, so its values hold still. Deep Q-Networks (DQN) teaches the replay buffer and the target network. The actor is trained with the deterministic policy gradient, which means it follows the gradient the critic hands it. Training DDPG: Target Networks, Replay and Noise shows why the replay buffer and the slow target copies are needed and what raising or lowering each setting does.

DDPG is actor-critic and model-free (it learns from rewards alone, with no model of how the world responds). One algorithm, architecture and set of hyperparameters (settings chosen before training) solved more than 20 simulated physics tasks.

DQN: pick from a list 4 buttons, each with a Q value -1 3.1 -0.33 4.8 +0.33 6.2 ← argmax +1 3.9 the list is finite: picking works DDPG: the actor outputs the action no list, no search, no argmax actor emits 0.62 -1 -0.5 0 0.5 1 every value in between is a legal move one torque, infinitely many values

Figure 3. Argmax needs a list; the actor outputs a continuous torque directly.

3 Actor and critic networks

DDPG splits the job in two. The actor is a policy network: state in, four torques out. The critic is a Q-network: a state-action pair in, one score out.

The layer sizes come from the repo. The actor's last layer is a tanh, which squashes every output into [−1, 1], so it cannot ask for an impossible torque. Clipping would have zero gradient outside the range; tanh is smooth, so gradients keep flowing. The repo also applies batch normalization to the input state, and in the actor after the first two hidden layers; the snippets leave it out.

ACTOR state in, torques out 33-number state state only 256 units hidden layer 128 units hidden layer 64 units hidden layer 4 torques tanh: range [-1, 1] same torques every time: deterministic CRITIC (state, action) to Q 33-number state state goes first 256 units state features 260 numbers 256 + 4 torques 128 units hidden layer 64 units hidden layer 1 Q value score of this action judges pairs, never picks actions

Figure 4. Same layer sizes, opposite jobs: the actor proposes, the critic judges.

import torch
import torch.nn as nn
import torch.nn.functional as F

S, A = 33, 4
L = nn.Linear
actor = nn.Sequential()
actor.append(L(S, 256))
actor.append(nn.ReLU())
actor.append(L(256, 128))
actor.append(nn.ReLU())
actor.append(L(128, 64))
actor.append(nn.ReLU())
actor.append(L(64, A))
actor.append(nn.Tanh())

The critic adds the action late. The state goes through a 256-unit layer first, then torch.cat joins the 4 torques to it, giving 260 numbers, and the head turns them into one score.

class Critic(nn.Module):
    def __init__(self):
        super().__init__()
        self.fs = L(S, 256)
        self.f1 = L(256 + A, 128)
        self.f2 = L(128, 64)
        self.out = L(64, 1)

    def forward(self, s, a):
        h = F.relu(self.fs(s))
        h = torch.cat([h, a], 1)
        h = F.relu(self.f1(h))
        h = F.relu(self.f2(h))
        return self.out(h)
  1. 1Builds the layers, with S, A and L from the actor snippet; the action joins after the first layer.
  2. 2Encodes the state, appends the action (now 260 numbers per row), and outputs one Q value per row.

Deterministic means the actor outputs the same torques for the same state, with no sampling. A stochastic policy samples from a distribution and explores without added noise, but it jitters, and an arm should hold still. DDPG adds exploration separately, as noise on the actions.

Each network has roughly 50,000 parameters, just over 100,000 together, plus their target copies.

4 The actor-critic loop

DDPG runs one loop: the actor turns the state into torques, the world returns a reward and the next state, the critic scores the state and torques, and the critic's gradient goes back to the actor.

s 4 torques r, s′ reward, next state, action gradient flows back 33-number state angles, speeds, positions actor 33 → 256 → 128 → 64 → 4 tanh output environment Unity Reacher reward +0.1 / 0 next state s′ critic scores (state, action)

Figure 5. One lap of the loop; the buffer and the target critic are left out.

5 The critic update

The critic learns like DQN's Q-network, by a Bellman backup: a move's value should equal the reward it just earned plus the discounted value of where it leaves the arm. The discount factor γ (gamma, 0.99 in the repo) shrinks that future part: near 1, far rewards count almost fully; at 0, only the reward just earned counts. One recorded step (state, torques, reward, next state) is a transition, and the next value comes from the target actor and target critic, not from a search.

In one critic update the target networks set a target value for the move, and the live critic is trained to match it.
y = r + γ·Q′(s′, μ′(s′))
From the next state s′, the target actor μ′ picks the next move, and the target critic Q′ evaluates that pair. The target y is the reward r just seen plus γ times that value, so a move is worth what it paid now plus a discounted share of what it leads to. This is the same Bellman target as DQN, with the target actor standing in for the max over moves. The primes mark the target networks, so the target holds still while the critic learns.
loss = E[(y − Q(s,a))2]
The loss is E, the average over the minibatch, of the squared gap between y and the live critic's guess Q(s,a) for the move actually taken. Averaging means one odd transition cannot yank the critic around; squaring makes a big error pull much harder than a small one. When the loss reaches zero, the critic's scores agree with reward plus discounted next score everywhere it was trained.

In PyTorch, the update is one function over a minibatch (a small random set of transitions from the replay buffer); r and done have shape (B, 1) for a batch of B.

@torch.no_grad()
def td_target(r, s_next, done):
    a2 = actor_target(s_next)
    q2 = critic_target(s_next, a2)
    keep = 1 - done
    return r + gamma * keep * q2

def critic_update(batch):
    s, a, r, s_next, done = batch
    y = td_target(r, s_next, done)
    q = critic(s, a)
    loss = F.mse_loss(q, y)
    critic_opt.zero_grad()
    loss.backward()
    critic_opt.step()
  1. 1Builds y: the target actor picks the next action a2 and the target critic scores it (critic card, line 1). keep is 0 where an arm's episode ended, so no future value is added there; no_grad keeps y fixed.
  2. 2Moves the critic toward y by mean squared error (line 2). learn() in Section 7 calls it, and the setup block there creates critic, critic_opt, gamma and the two target copies.

The critic never picks an action; it only judges pairs. Choosing is the actor's job.

6 The actor update

The actor never sees a target action. The critic is differentiable, so the gradient of Q with respect to the action says which way the torques should move to score higher, and the actor's weights shift that way.

-101one torque valuecritic's Q for one statepeakactor'soutput nowstep uphillnext updates
Figure 6. Each actor update moves its torque uphill on the critic's curve.

The chain rule turns that into one line, the deterministic policy gradient theorem (Silver et al., 2014):

The actor changes its weights so its torques move the way that raises the critic's score.
∇θJ ≈ E[∇aQ(s, μ(s)) · ∇θμ(s)]
Change the weights so the torques move toward a higher critic score. ∇ is the gradient, the direction of steepest increase. ∇aQ says how the critic's score changes when the torques change, which way is uphill. ∇θμ says how the actor's torques change when its weights θ change. Multiplying them, the chain rule, gives how the score changes when the weights change. E is the average over a batch of states from the replay buffer, and ∇aQ is taken at the actor's own action, a = μ(s). J is the actor's objective, the critic's score of its own moves, and ≈ is there because the states come from the buffer and the climb follows the critic's estimate, not the true return: a wrong critic sends the actor confidently the wrong way.

In code, the actor minimizes minus Q: critic(s, actor(s)) scores the actor's own torques with the critic, and backward multiplies the two factors by the chain rule. Only the actor's optimizer steps, so the critic is not changed here.

def actor_update(batch):
    s = batch[0]
    q = critic(s, actor(s))
    actor_loss = -q.mean()
    actor_opt.zero_grad()
    actor_loss.backward()
    actor_opt.step()
  1. 1Takes the states of the same minibatch; the actor's loss is minus the critic's score of its action (actor card, line 1), and the mean is the card's E.
  2. 2Updates only the actor. Backward also leaves gradients on the critic's weights; critic_update zeroes them before the critic's next step. learn() in Section 7 calls it, and actor_opt is created there.

The actor goes through the critic because the Unity physics can be run but not differentiated. The critic is a learned, differentiable score of each action, trained on real rewards.

Early on the critic's scores are close to random, and the actor follows them anyway: a wrong critic teaches the actor a wrong action. The three stabilizers taught in Training DDPG: Target Networks, Replay and Noise limit this.

7 The DDPG loop in code

The code below runs the schedule of Figure 1 with all twenty arms: act and store every step (①), and every 256 steps run 64 updates on random minibatches (②), each followed by a small nudge of the target copies (③). The first block sets up the pieces Section 5 and Section 6 use.

import random
from collections import deque
from copy import deepcopy
import numpy as np
import torch
from torch.optim import Adam

critic = Critic()
actor_target = deepcopy(actor)
critic_target = deepcopy(critic)
pairs = [(actor_target, actor),
         (critic_target, critic)]
actor_opt = Adam(
    actor.parameters(), lr=1e-3)
critic_opt = Adam(
    critic.parameters(), lr=1e-3)
gamma, tau = 0.99, 1e-3
buf = deque(maxlen=100_000)
last100 = deque(maxlen=100)
  1. 1Imports for the buffer, the target copies and the optimizers.
  2. 2Makes the critic from the Critic class of Section 3, a slow target copy of each network, pairs to match each target copy with its live network, an Adam optimizer for each (learning rate 1e-3), the discount γ = 0.99, the target share τ = 1e-3, a buffer that keeps the newest 100,000 transitions, and last100, the scores of the last 100 episodes.

Three helpers turn rows into tensors, store each arm's transition, and nudge the target copies.

def to_tensor(rows):
    arr = np.array(rows)
    return torch.tensor(
        arr, dtype=torch.float32)

def store(s, act, info):
    obs = info.vector_observations
    r = info.rewards
    done = info.local_done
    buf.extend(
        zip(s, act, r, obs, done))
    return obs

@torch.no_grad()
def soft_update(tgt, live):
    dst = list(tgt.parameters())
    src = list(live.parameters())
    for t, p in zip(dst, src):
        t.lerp_(p, tau)
  1. 1Turns a list of rows into one float32 tensor, for the actor and for sampled minibatches.
  2. 2Stores one transition per arm (state, torques, reward, next state obs, episode-end flag) and returns the next states (①).
  3. 3Moves every target weight a share τ of the way toward its live weight (③).

learn() samples one minibatch and runs one update on it.

def learn():
    if len(buf) <= 256:
        return
    rows = random.sample(buf, 256)
    cols = [to_tensor(c)
            for c in zip(*rows)]
    s, a, r, s_next, done = cols
    r = r[:, None]
    done = done[:, None]
    batch = s, a, r, s_next, done
    critic_update(batch)
    actor_update(batch)
    for tgt, live in pairs:
        soft_update(tgt, live)
  1. 1Waits until the buffer holds more than 256 transitions.
  2. 2Draws a random minibatch of 256 (②) and makes the five tensors, one row per sampled step; r and done become one column so they match the critic's 256 by 1 output.
  3. 3Runs critic_update of Section 5, then actor_update of Section 6, on this minibatch. The repo also clips the critic's gradient norm to 1 (DDPG in practice on Training DDPG: Target Networks, Replay and Noise).
  4. 4Nudges both target copies (③).

The loop is the episode loop of Section 1, with the actor choosing the torques and storing and learning added.

def solved():
    if len(last100) < 100:
        return False
    return np.mean(last100) >= 30

step = 0
while not solved():
    out = env.reset(
        train_mode=True)
    info = out[brain_name]
    s = info.vector_observations
    scores = np.zeros(NUM_ARMS)
    while True:
        s_tensor = to_tensor(s)
        with torch.no_grad():
            act = actor(s_tensor)
        act = act.numpy()
        out = env.step(act)
        info = out[brain_name]
        s = store(s, act, info)
        scores += info.rewards
        step += 1
        if step % 256 == 0:
            for _ in range(64):
                learn()
        if any(info.local_done):
            break
    last100.append(scores.mean())
  1. 1The solved rule of Section 1: the average score over the last 100 episodes reaches 30.
  2. 2Each episode resets all 20 arms and zeroes their scores, as in Section 1; step counts steps across episodes.
  3. 3Every step all 20 arms act and store one transition each (①). Training also adds exploration noise to act before env.step, taught in the noise section of Training DDPG: Target Networks, Replay and Noise.
  4. 4Every 256 steps, 64 updates run (② and ③); the episode ends when any arm's episode ends, and its 20-arm mean score goes into last100.

Why does each of the slow copies, the buffer and the exploration noise matter, and what does one full update look like on the real arm? Training DDPG: Target Networks, Replay and Noise answers both.

8 References

  1. Mnih, V., et al. (2015). Human-level control through deep reinforcement learning. Nature 518, 529 to 533. doi:10.1038/nature14236.
  2. Lillicrap, T. P., et al. (2016). Continuous Control with Deep Reinforcement Learning. ICLR. arXiv:1509.02971.
  3. Silver, D., et al. (2014). Deterministic Policy Gradient Algorithms. ICML, PMLR 32, 387 to 395.