TD3: Twin Delayed Deep Deterministic Policy Gradient

TD3 : Guide Complet — Twin Delayed Deep Deterministic Policy Gradient

TD3: Complete Guide — Twin Delayed Deep Deterministic Policy Gradient

Summary — TD3 (Twin Delayed Deep Deterministic Policy Gradient) is a reinforcement learning algorithm proposed by Fujimoto et al. in 2018 as a major improvement over DDPG. TD3 fixes three fundamental flaws of DDPG that caused systematic overestimation of Q-values: it uses two critic networks and takes the minimum for the target (delayed double Q-learning), it updates the actor less frequently than the critics (delayed policy update, every 2 steps), and it adds clipped noise to target actions (target policy smoothing). These three corrections make TD3 the reference for continuous action problems, significantly outperforming the original DDPG on all MuJoCo benchmarks.


Mathematical Principle

1. The Three Flaws of DDPG

DDPG suffers from three major problems:

  1. Overestimation of Q-values: A single critic tends to overestimate Q-values, which propagates through updates and degrades performance. Like classical Q-learning, the target max(Q(s’,a’)) introduces a positive bias.
  2. Too-frequent actor updates: The actor is updated at every step while the critic has not yet converged. A policy updated on an unstable critic is like a student who changes learning methods every day.
  3. High variance of targets: Target actions are deterministic, making Q-estimation fragile and overconfident.

2. Correction 1: Double Q-Learning (Twin Critics)

TD3 maintains two critic networks Q_1 and Q_2 with their own parameters. To compute the target, the minimum of the two is used:

y = r + gamma · min(Q_1'(s', a'), Q_2'(s', a'))

The minimum of the two critics eliminates the overestimation bias: if one overestimates, the other compensates. Both critics are trained independently on the same data.

3. Correction 2: Delayed Policy Update

The actor and targets are only updated every d steps (d=2 typically):

If t % d == 0:
    mu_theta ← mu_theta + ∇_mu J · lr
    theta' ← tau · theta + (1-tau) · theta'  (soft update)

During this time, the two critics are updated at every step. This allows the critic to converge properly before being used to guide the policy.

4. Correction 3: Target Policy Smoothing

Clipped noise is added to target actions to regularize Q-estimation:

a' = mu'(s') + clip(epsilon, -c, c)
where epsilon ~ N(0, sigma)

This noise has two beneficial effects: 1) it prevents the agent from exploiting imperfections in a single critic, 2) it smooths the Q-function around actions, making the policy more robust to perturbations.

5. Complete Algorithm

Initialize theta, phi_1, phi_2, theta_target, phi_1_target, phi_2_target
Replay buffer B
For each episode:
  Observe s_0
  For each step t:
    1. Choose a_t = mu_theta(s_t) + N(0, sigma_explore)
    2. Observe r_t, s_{t+1}, done
    3. Store (s_t, a_t, r_t, s_{t+1}, done) in B
    4. Sample mini-batch from B
    5. a' = mu_target(s') + clip(N(0, sigma), -c, c)
    6. y = r + gamma · (1-done) · min(Q_1_target(s', a'), Q_2_target(s', a'))
    7. Minimize L_i = MSE(Q_i(s, a), y) for i = 1, 2
    8. If t % policy_delay == 0:
       a. ∇_mu J = E[∇_a Q_1(s, a) · ∇_mu mu(s)]
       b. mu_theta ← mu_theta + lr · ∇_mu J
       c. Soft update targets (theta, phi_1, phi_2)

Intuition

Imagine DDPG as a brilliant but impulsive student. He overestimates his abilities (Q overestimation bias), changes strategy too quickly (actor updated before the critic converges), and has blind confidence in his own judgments (deterministic actions without skepticism).

TD3 is the same student but with a wise mentor: two critics separately evaluate the situation and the mentor keeps the most conservative opinion (minimum of the two Q values); they wait before changing strategy (delayed update: giving the critic time to properly evaluate); and they don’t blindly believe perfect scores but add a bit of skepticism (noise on target actions). Result: the student makes healthier decisions and performs better on exams.


Python Implementation

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
from copy import deepcopy


class Actor(nn.Module):
    def __init__(self, state_dim, action_dim, max_action, hidden=256):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim, hidden), nn.ReLU(),
            nn.Linear(hidden, hidden), nn.ReLU(),
            nn.Linear(hidden, action_dim), nn.Tanh()
        )
        self.max_action = max_action

    def forward(self, x):
        return self.max_action * self.net(x)


class Critic(nn.Module):
    def __init__(self, state_dim, action_dim, hidden=256):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim + action_dim, hidden), nn.ReLU(),
            nn.Linear(hidden, hidden), nn.ReLU(),
            nn.Linear(hidden, 1)
        )

    def forward(self, s, a):
        return self.net(torch.cat([s, a], dim=1)).squeeze(-1)


class TD3:
    def __init__(self, state_dim, action_dim, max_action, lr=3e-4, gamma=0.99,
                 tau=0.005, policy_delay=2, policy_noise=0.2, noise_clip=0.5):
        self.gamma = gamma
        self.tau = tau
        self.policy_delay = policy_delay
        self.policy_noise = policy_noise
        self.noise_clip = noise_clip
        self.total_it = 0

        self.actor = Actor(state_dim, action_dim, max_action)
        self.actor_target = deepcopy(self.actor)
        self.actor_opt = torch.optim.Adam(self.actor.parameters(), lr=lr)

        self.critic1 = Critic(state_dim, action_dim)
        self.critic2 = Critic(state_dim, action_dim)
        self.critic1_target = deepcopy(self.critic1)
        self.critic2_target = deepcopy(self.critic2)
        self.critic1_opt = torch.optim.Adam(self.critic1.parameters(), lr=lr)
        self.critic2_opt = torch.optim.Adam(self.critic2.parameters(), lr=lr)

    def select_action(self, state, noise=0.1):
        state = torch.FloatTensor(state).unsqueeze(0)
        action = self.actor(state).cpu().data.numpy().flatten()
        action += np.random.normal(0, noise, size=action.shape)
        return np.clip(action, -1, 1)

    def train(self, replay_buffer, batch_size=256):
        s, a, r, s_next, done = replay_buffer.sample(batch_size)
        s, a, r, s_next, done = map(lambda x: torch.FloatTensor(x), [s, a, r, s_next, done])
        r = r.unsqueeze(-1)
        done = done.unsqueeze(-1)

        # Target policy smoothing
        noise = (torch.randn_like(a) * self.policy_noise).clamp(-self.noise_clip, self.noise_clip)
        next_a = self.actor_target(s_next) + noise

        # Double Q target
        target_q1 = self.critic1_target(s_next, next_a)
        target_q2 = self.critic2_target(s_next, next_a)
        target_q = r + (1 - done) * self.gamma * torch.min(target_q1, target_q2)

        # Critic update
        current_q1 = self.critic1(s, a)
        current_q2 = self.critic2(s, a)
        loss1 = F.mse_loss(current_q1, target_q)
        loss2 = F.mse_loss(current_q2, target_q)
        self.critic1_opt.zero_grad()
        self.critic2_opt.zero_grad()
        loss1.backward()
        loss2.backward()
        self.critic1_opt.step()
        self.critic2_opt.step()

        # Delayed actor update
        self.total_it += 1
        if self.total_it % self.policy_delay == 0:
            # Policy gradient
            actor_loss = -self.critic1(s, self.actor(s)).mean()
            self.actor_opt.zero_grad()
            actor_loss.backward()
            self.actor_opt.step()

            # Soft update targets
            for param, target in zip(self.actor.parameters(), self.actor_target.parameters()):
                target.data.copy_(self.tau * param.data + (1 - self.tau) * target.data)
            for param, target in zip(self.critic1.parameters(), self.critic1_target.parameters()):
                target.data.copy_(self.tau * param.data + (1 - self.tau) * target.data)
            for param, target in zip(self.critic2.parameters(), self.critic2_target.parameters()):
                target.data.copy_(self.tau * param.data + (1 - self.tau) * target.data)

        return loss1.item(), loss2.item(), actor_loss.item() if self.total_it % self.policy_delay == 0 else None

Hyperparameters

Hyperparameter Typical Value Description
policy_delay 2 Update the actor every N steps (default value in the paper: 2)
policy_noise 0.2 Standard deviation of noise added to target actions
noise_clip 0.5 Noise clipping range (0.5 in the original paper)
gamma 0.99 Discount factor
tau 0.005 Soft update rate for targets
lr 3e-4 Adam learning rate for all optimizers
hidden_dim 256 Hidden layer size (256 in the paper)

Advantages

  1. Superior performance to DDPG: On MuJoCo benchmarks (HalfCheetah, Walker2d, Ant, Hopper), TD3 systematically outperforms DDPG, sometimes significantly. The three corrections eliminate the divergence caused by overestimation.
  2. Stability: Less sensitive to hyperparameters than DDPG thanks to the double critics and delayed update.
  3. Simplicity: No special architecture needed — just two copies of the critic networks and a timer for the delayed update.

Limitations

  1. Exploration: Like DDPG, TD3 uses Gaussian noise exploration on actions, which is not optimal for complex policies. Methods like SAC with entropy maximization offer smarter exploration.
  2. Two critics: Doubles the computational cost for critic updates (2 forward + 2 backward instead of 1).
  3. Additional hyperparameters: policy_delay, policy_noise, noise_clip increase the search space compared to DDPG. A poor choice can degrade performance.

4 Concrete Use Cases

1. Robotics — Manipulator Arms

To control a robotic arm with continuous joints, TD3 learns precise and smooth trajectories. The double critics prevent the robot from being wrong about the quality of a movement and missing its target.

2. Autonomous Driving

In driving simulators, TD3 controls acceleration, braking, and steering continuously. The delayed update ensures that critical decisions (brake? turn?) are based on a reliable evaluation.

3. Algorithmic Trading

TD3 can learn to continuously adjust the position sizes of a portfolio. The double critics avoid overestimating potential profits, while target policy smoothing makes the strategy more robust to market variations.

4. Drone Control

Flying a drone requires precise motor control (continuous action). TD3 learns stable flight policies, and target policy smoothing prevents abrupt commands that could cause a crash.


TD3 vs Other Continuous Action Algorithms

TD3 vs SAC

SAC uses entropy maximization for smarter exploration, while TD3 uses simple Gaussian noise. SAC is generally more sample-efficient, but TD3 is simpler to implement and can outperform SAC on some environments with good tuning.

TD3 vs PPO

PPO works in pure policy gradient mode (without double target critic) with objective function clipping. PPO is more stable but can be less performant on tasks requiring fine continuous precision. TD3 is off-policy (reuses experiences) while PPO is on-policy.

TD3 vs DDPG

It is a direct improvement: same base architecture (actor-critic, replay buffer, soft update), but with the three corrections that eliminate DDPG’s weaknesses. TD3 is the DDPG that DDPG should have been.


Conclusion

TD3 has become the reference baseline for continuous action problems. Its three corrections are simple but effective, demonstrating that the fundamental problems of reinforcement learning (overestimation, variance, instability) can be solved by targeted architectural modifications rather than radical algorithm changes.

The success of TD3 has inspired extensions like TD3+BC which adds a behavior constraint for offline learning (learning from data without environment interaction), a rapidly growing field.


See Also


About Salah YAHIAOUI

Salah YAHIAOUI publie sur The Pingouin des tutoriels Python et des décryptages sur l’intelligence artificielle. Les cas pratiques récents précisent leurs données, leurs tests et leurs limites. À propos, méthode éditoriale et contact.

View all posts by Salah YAHIAOUI →