Back to Reinforcement Learning
AI/ML · Reinforcement Learning

DDPG & TD3

DQN derives its policy from an argmax over the action values, which requires enumerating the actions. For a continuous action space that is not merely expensive, it does not exist. The deterministic policy gradient resolves this with a substitution that is obvious in hindsight: if the argmax cannot be computed, train a network to approximate it. So there are two networks — a critic Q(s, a) fitted by the usual Bellman regression, and an actor that outputs a single action and is trained to maximize the critic's output at that action. The actor's gradient is the critic's gradient with respect to the action, chained back through the actor, which means the critic must be differentiable in the action and the actor is climbing a surface the critic defines. That last clause is the whole story of this method's difficulty. The actor is an optimizer pointed at a learned function, and a learned function has errors — sharp spurious peaks in regions where little data exists. The actor will find them, because finding maxima is exactly its job, and it will then take the action at the peak, collect data showing the peak is not real, and the critic will correct it while the actor moves to the next one. DDPG does this and is famously brittle. TD3 adds three targeted countermeasures: two critics with the smaller value taken, so overestimation is replaced by deliberate pessimism; delayed actor updates, so the actor climbs a critic that has had time to settle; and noise added to the target action, which smooths the surface so a spike one action-width wide cannot be exploited. None of the three is clever. All three are aimed at the same failure.

ReinforcementControl

Off-policy reinforcement learning with a deterministic policy. Being off-policy restores the replay buffer that the policy-gradient methods gave up, and being deterministic means exploration must be injected from outside the policy rather than arising from it — the two facts that distinguish this branch from everything above it.

Also known as: Deep Deterministic Policy Gradient, Twin Delayed DDPG, TD3, Deterministic policy gradient

Intuition

What it actually does

DQN derives its policy from an argmax over the action values, which requires enumerating the actions. For a continuous action space that is not merely expensive, it does not exist. The deterministic policy gradient resolves this with a substitution that is obvious in hindsight: if the argmax cannot be computed, train a network to approximate it. So there are two networks — a critic Q(s, a) fitted by the usual Bellman regression, and an actor that outputs a single action and is trained to maximize the critic's output at that action. The actor's gradient is the critic's gradient with respect to the action, chained back through the actor, which means the critic must be differentiable in the action and the actor is climbing a surface the critic defines. That last clause is the whole story of this method's difficulty. The actor is an optimizer pointed at a learned function, and a learned function has errors — sharp spurious peaks in regions where little data exists. The actor will find them, because finding maxima is exactly its job, and it will then take the action at the peak, collect data showing the peak is not real, and the critic will correct it while the actor moves to the next one. DDPG does this and is famously brittle. TD3 adds three targeted countermeasures: two critics with the smaller value taken, so overestimation is replaced by deliberate pessimism; delayed actor updates, so the actor climbs a critic that has had time to settle; and noise added to the target action, which smooths the surface so a spike one action-width wide cannot be exploited. None of the three is clever. All three are aimed at the same failure.

Assumptions it makes
  • The action space is continuous and the critic is differentiable in the action — the chain rule through the critic is the mechanism, and a discrete action space has no gradient to take
  • The action-value surface is smooth enough in the action that a local gradient step is informative, which target policy smoothing both assumes and enforces
  • The replay buffer covers the state-action region the policy now operates in; everywhere else the critic extrapolates and the actor seeks the optimistic extrapolation
  • The environment is a Markov decision process in the observation given to the networks
  • Transitions are cheap enough to collect in the hundreds of thousands, which in practice means a simulator
  • Exploration can be injected as noise on the action, which is acceptable in simulation and usually not on live hardware
Objective

Loss function

Two objectives, and they are not the same kind of object. The critic loss is an ordinary regression with an interpretable value: it measures how far the action values are from their bootstrapped targets, and it behaves like a supervised loss. The actor objective is not a loss at all — there is no target and no data-derived quantity in it. It says: adjust the actor so the critic rates its output higher. That is an optimizer pointed at another network's output, and the consequence is the method's central pathology. Wherever the critic is wrong in the optimistic direction, the actor is attracted to exactly that point, because the actor is a maximizer and a spurious peak is a maximum. The critic will eventually be corrected there, since the actor keeps taking that action and collecting contradicting data, but in the meantime the policy is bad and the process can oscillate. All three TD3 modifications attack this. The min over two critics converts the maximization bias into a pessimistic one, and pessimism is safe here in a way optimism is not: underestimating an action's value makes the actor avoid it, which costs some performance, while overestimating one makes the actor seek it, which compounds. Delaying the actor lets the critic settle between policy moves, reducing the rate at which the actor chases noise. And adding clipped noise to the target action makes the regression target an average over a small neighbourhood, so a peak narrower than the noise cannot survive it. Read together, TD3 is not three tricks but one hypothesis about what goes wrong, tested three ways.

clipped double-Q: two independently initialized critics, the smaller value taken, trading an upward bias for a deliberate downward one because only the upward bias compounds
the target action with clipped noise added — target policy smoothing, which flattens spurious narrow peaks the actor would otherwise climb
a deterministic actor: one action, not a distribution, so exploration has to come from outside the policy
the actor maximizes only the first critic, not the min — using the min here would make the actor chase the more pessimistic surface and slow it for no benefit
Optimization

How it is minimized

Alternating off-policy updates from a replay buffer: twin critics by Bellman regression on a smoothed, pessimistic target, and a delayed deterministic actor by gradient ascent through the critic

the critic's gradient with respect to the action — the whole method requires the critic to be differentiable in the action, which is why this branch exists only for continuous control
the actor Jacobian; the chain rule joins two networks, and the actor inherits every error in the surface it is climbing
the Polyak coefficient, typically 0.005: target networks drift continuously rather than jumping, which is smoother than DQN's periodic copy
the target critics, whose slow drift is what keeps the regression target approximately stationary

Each piece is a response to something that breaks without it, and the list is worth holding in mind as a diagnostic checklist. The replay buffer is back, because the method is off-policy again — this is the branch's main advantage over PPO and typically worth several-fold in sample efficiency on continuous control. Target networks are back for the same reason as in DQN, but updated by Polyak averaging rather than periodic copying, which makes the target drift smoothly instead of stepping and suits a setting where the actor is also moving. Exploration must be injected because the policy is deterministic: Gaussian noise added to the action at collection time, with a scale expressed in action units, which makes it a hyperparameter with a physical meaning rather than an abstract one. The three TD3 changes then address the actor-exploits-critic failure. Clipped double-Q is the largest single improvement and is a genuine change of sign in the bias: two critics, the smaller value used for the target, accepting underestimation because only overestimation is self-reinforcing. Delayed policy updates — typically one actor step per two critic steps — reduce how often the actor moves relative to how often the surface it climbs is corrected. Target policy smoothing adds clipped noise to the action used in the target, which is a regularizer stating that actions close together should have similar values, and it is what stops a one-action-wide artifact from being exploitable. In practice TD3 turns a method that required careful per-environment tuning into one that mostly works, which is why plain DDPG is now of mainly historical interest.

Hyperparameters
  • exploration noise scale

    The only exploration mechanism, since the policy is deterministic. Expressed in action units, which makes it depend on the action scaling and not transfer between environments (0.1 of the action range)

  • target smoothing noise and clip

    Noise added to the target action, clipped so it stays local. The regularizer asserting that nearby actions have similar values, and what makes a narrow critic artifact unexploitable (sigma 0.2, clipped to 0.5)

  • policy delay

    Critic updates per actor update. Letting the critic settle between policy moves is the cheapest of the three TD3 fixes and one of the most effective (2)

  • Polyak tau

    How fast target networks track the online ones. Small keeps the regression target stationary; too small and value information stops propagating at all (0.001 to 0.01)

  • replay buffer size

    How much history stays available. Too small and the critic forgets regions the policy has left; too large and much of it describes a policy long abandoned (1e5 to 1e6 transitions)

  • learning rates

    Actor and critic, usually equal, and both well below what a supervised model of the same size would take because the regression target is non-stationary (1e-3 down to 1e-4)

  • action bounds and squashing

    A tanh output scaled to the action range. Saturation is a real failure mode: a saturated tanh has near-zero gradient, so an actor pinned at a bound stops learning (tanh scaled to the environment limits)

  • warmup with random actions

    Pure random actions for the first steps, so the critic is fitted on something before the actor starts climbing it. Skipping this is a common cause of early collapse (1,000 to 25,000 steps)

Convergence & cost

No convergence guarantee. The deadly triad is present in full — bootstrapping, off-policy sampling, function approximation — and the deterministic policy gradient adds a second coupled optimization on top of it, so this is less theoretically grounded than anything above it in this category. The characteristic failure has a name worth remembering: the actor exploits the critic. Q-values climb, the actor moves to the region where the critic is most optimistic, the realized returns do not follow, and the whole thing can oscillate or collapse. Diagnosing it requires plotting predicted Q against realized discounted return, and a gap that grows is the signature; the critic loss will not show it, because the critic is tracking its own inflated targets consistently. Plain DDPG fails this way often enough that reproducing published results was a known problem, which is the context TD3 was written in. The second failure is actor saturation: with a tanh output, an actor driven to the action bound has a vanishing gradient and stops responding, which presents as a policy frozen at an extreme with a critic that keeps training. The third is a buffer that is too small relative to how fast the policy moves, so the critic forgets regions and the actor rediscovers them repeatedly. And seed variance in this family is among the worst in reinforcement learning — a single-seed result here is close to meaningless, and published comparisons in this area have repeatedly turned out to be measuring the seed and the code-level details rather than the algorithm.

Per gradient step: two critic forward-backward passes plus two target-critic forwards, and on actor steps one actor forward, one critic forward and a backward through both — so roughly five to six network passes per critic update and a little more on the delayed actor steps. That is more per update than PPO but on far fewer environment interactions, which is the trade this branch makes. Per environment step: one actor forward. Memory is dominated by the replay buffer of full transitions, which for continuous state and action vectors is modest compared with a frame buffer but still the largest single allocation. Sample complexity is the reason to be here: on continuous control, an off-policy method of this family typically reaches a given return with several times fewer environment steps than an on-policy method, and that gap is the entire argument for tolerating the instability.

Applications

How, where, and why it is used

Time-Series Forecasting

Not applicable

There is no action to differentiate the critic with respect to and no policy whose output would be maximized, because a forecaster does not act; the continuous-valued output of a forecast is a prediction, not a control signal.

Anomaly Detection

Not applicable

Detection scores observations without acting, so there is no action space for a deterministic actor to output into — and an inflated critic value here reports the actor exploiting a modelling error rather than anything unusual in the data.

Optimization

Primary fit
How

Learn a deterministic policy over a continuous action space by training an actor to maximize a learned action-value function, with the actor's gradient chained through the critic and both fitted off-policy from a replay buffer.

Where
  • Continuous action spaces, where a value-based argmax does not exist and this branch is the value-based answer to that
  • The deterministic policy gradient as a construction: an intractable argmax replaced by a network trained to approximate it
  • Optimizer-exploits-model as a named failure, with three independent countermeasures aimed at the same hypothesis
  • Pessimism as a deliberate bias: taking the min of two estimates because only the optimistic error is self-reinforcing
Why

The most transferable lesson in this category, and it has nothing to do with reinforcement learning specifically. Whenever an optimizer is pointed at a learned model, it will find that model's errors, because finding extrema is what optimizers do and a modelling error in the favourable direction is an extremum. That is the same failure as reward hacking against a learned reward model, the same as an adversarial example against a classifier, and the same as any Bayesian-optimization loop that runs away to where its surrogate is most optimistic. TD3's three answers generalize equally well. Take the minimum of an ensemble rather than the mean, because in this setting the bias direction matters more than the bias magnitude. Update the optimizer less often than the model it optimizes against, so it is climbing something that has settled. And smooth the model's output over a neighbourhood, so an artifact narrower than the smoothing cannot be exploited. Where this family is the wrong choice is straightforward: discrete actions belong to the DQN family, since the argmax is available and approximating it adds only failure modes; and where stability matters more than sample efficiency, PPO is far more forgiving. Within the family itself, SAC generally dominates TD3 — it has fewer knobs, needs no explicit exploration schedule, and is usually at least as good.

Featurization
  • Scale actions to a fixed range with a tanh and check for saturation, since a saturated actor has a vanishing gradient and stops learning at the bound
  • Express exploration noise in action units and re-tune it when the action scaling changes, because it does not transfer
  • Warm up with random actions before the actor starts training, so the critic is fitted on something before it is climbed
  • Plot predicted Q against realized discounted return; a growing gap is the actor exploiting the critic, and the loss curve cannot show it
Evaluation

Median return over at least five seeds with the interquartile range shown, evaluated with exploration noise disabled — this family has some of the worst seed variance in the field, and single-seed comparisons here have a documented history of measuring the seed. Track predicted Q against realized return on the same axes throughout, since that gap is the failure the method exists to manage.

Pitfalls
  • A single critic, which reintroduces the overestimation that clipped double-Q exists to invert
  • An actor updated as often as the critic, so it chases a surface that has not settled between its own moves
  • No target smoothing, which leaves narrow critic artifacts directly exploitable by a maximizer
  • Exploration noise carried over from another environment, where the action scale made it mean something different
Also applied in

Control & Operations

Primary fit
How

Learn a continuous-valued controller — a torque, a flow rate, a setpoint — from simulated interaction, with off-policy replay making the most of every transition. Deployment is the deterministic actor with exploration noise removed.

Where
  • Robotic manipulation and locomotion, where torques and joint targets are continuous and high-dimensional
  • Process control with continuous actuators, where discretizing the action space would either be coarse or explode combinatorially
  • Settings where each environment interaction is expensive enough that off-policy sample efficiency is the deciding property
  • Problems with a differentiable, well-behaved action-value surface, which is the condition under which this method is well-behaved too
Why

The natural home of this family and the reason it exists. Continuous actuators are what operations problems actually have, and the several-fold sample-efficiency advantage over an on-policy method is the difference between a feasible and an infeasible simulator budget. Three cautions carry real weight here. The deterministic policy is an advantage at deployment — no sampling, reproducible decisions, a controller that behaves the same way twice — and it is precisely why exploration has to be added artificially during training, which means a production system doing online learning would be injecting noise into a live actuator. That is usually unacceptable, and it is the strongest argument for training entirely in simulation. The critic is only meaningful on the state-action distribution the buffer contains, so a policy asked to operate outside it is being advised by extrapolation, and the actor will actively seek the most optimistic extrapolation available. And a hard constraint must be enforced by an interlock rather than by the reward, because an actor whose entire function is to maximize a learned surface will find whatever the reward function failed to forbid.

Featurization
  • Normalize observations with running statistics and checkpoint them with the weights, since restoring without them produces a silently different controller
  • Scale actions to the actuator limits with a tanh and monitor saturation, because an actor pinned at a bound has stopped learning
  • Randomize simulator dynamics if the policy must transfer, as a deterministic policy fitted to one dynamics model exploits that model exactly
  • Never inject exploration noise into a live actuator; train in simulation and deploy the deterministic actor with noise removed
Evaluation

Simulated return against the incumbent controller on matched scenarios with noise disabled, plus constraint violations counted separately rather than averaged in. Report performance under perturbed dynamics: a deterministic policy that is optimal on the nominal model and brittle off it transfers badly, and robustness here predicts deployment far better than nominal return does.

Pitfalls
  • Online learning on live hardware, which means adding exploration noise to a real actuator
  • Trusting the critic outside the state-action region the buffer covers, where the actor actively seeks the most optimistic extrapolation
  • A hard constraint expressed in the reward, which an explicit maximizer will treat as a price
  • Normalization statistics left out of the checkpoint, the most common way a working policy fails on restore

Computer Vision

Adapted
How

A convolutional encoder feeds both actor and critic for control from pixels, trained end to end by the TD error and the deterministic policy gradient, with no vision objective anywhere in the loss.

Where
  • Visual servoing and manipulation from camera input, where perception and control are learned as one network
  • Encoder gradient routing as a concrete design decision — the actor's gradient is normally stopped before it reaches the encoder
  • Auxiliary self-supervised or reconstruction losses on the encoder, which are close to standard here because reward alone is too weak a signal
  • Data augmentation on observations, which turned out to be one of the most effective interventions for pixel-based continuous control
Why

Included because there is a specific and instructive detail here, not because this is a vision method — it is not, and a genuine vision task deserves a supervised model. The detail is gradient routing. With a shared encoder, the actor's objective is to maximize the critic, and its gradient flows back through the critic and into the encoder, which means the actor can improve its objective by changing the representation rather than by choosing better actions. That is a degenerate solution and it destabilizes training, so the standard practice is to let only the critic's loss train the encoder and stop the actor's gradient at the encoder boundary. It is a one-line change that decides whether pixel-based runs work, and it generalizes: whenever two objectives share a representation and one of them is an optimizer pointed at the other, the routing has to be decided deliberately. The broader caution is the same as for DQN. Reward is a far weaker supervisory signal than labels, the sample requirements reflect that, and a pretrained frozen encoder is usually the better engineering choice unless the control task genuinely needs features the reward alone can discover.

Featurization
  • Stop the actor's gradient at the encoder; letting it through lets the actor improve its objective by changing the representation instead of the action
  • Apply random-shift augmentation to observations, which is among the most effective single interventions for pixel-based continuous control
  • Stack frames or use a recurrent encoder, since velocity is not observable in a single image and the problem is not Markov without it
  • Store observations as uint8 and convert at sample time, because the replay buffer rather than the network is what exhausts memory
Evaluation

Return with exploration disabled, not any vision metric — the encoder exists to support control and its features are only as good as the decisions they enable. If representation quality is itself the question, probe the frozen encoder on a supervised task, which usually shows it has kept what the reward depended on and discarded the rest.

Pitfalls
  • Letting the actor gradient reach a shared encoder, which permits a degenerate solution and destabilizes the run
  • Reaching for this on an actual vision task, where a supervised model wins by orders of magnitude on both accuracy and sample cost
  • Materializing stacked frames per transition, which multiplies replay memory by the stack depth
  • Expecting general visual features from reward supervision, which produces representations narrowly specialized to the reward
Deployment

What it costs to run

Training

Five or six network passes per critic update and a little more on the delayed actor steps, against far fewer environment interactions than an on-policy method needs — typically several times fewer on continuous control, which is the entire argument for this family. Memory is dominated by the replay buffer of full transitions, modest for vector observations and the largest allocation in the job for pixel ones. Budget at least five seeds and expect wide spread: variance in this family is among the worst in the field, and single-seed results here have a documented history of not replicating. Illustrative, not a measured benchmark.

Inference

One actor forward pass and no sampling at all. The deterministic policy is a genuine operational advantage over the stochastic families — the same state produces the same action every time, which makes the controller reproducible, auditable and straightforward to test. The critics are training machinery and do not ship. What does ship alongside the weights is the observation normalization and the action scaling, and both are part of the model rather than of the pipeline.

Retraining

Offline retraining in simulation, then evaluation of the deterministic actor, then a staged rollout. Online learning is particularly unattractive here because exploration is injected as noise on the action: continuous learning on a live system means deliberately perturbing a real actuator, which is rarely acceptable. The trigger for a retraining cycle is usually simulator revalidation rather than policy degradation, since the simulator goes stale first.

Drift & monitoring
  • Predicted Q against realized discounted return — a growing gap is the actor exploiting the critic, and it is the failure this method is built around managing
  • Actor output distribution against the action bounds: mass pinned at a limit means a saturated tanh with vanishing gradient, so the policy has stopped learning there
  • Realized return of the deterministic policy against the incumbent on matched scenarios, with noise disabled
  • Critic loss for both critics separately, since a large and persistent divergence between the twins means one has drifted and the min is no longer doing what it was meant to
  • State-action coverage of the replay buffer relative to where the policy now operates, because the actor actively seeks the least-covered optimistic region
  • Observation-normalization and action-scaling parameters, which are part of the model and drift with the state distribution
Production gotchas
  • The actor is an optimizer pointed at a learned function, so it will find the critic's errors. That is not a bug to fix but the property the whole method has to be engineered around
  • A single critic reintroduces maximization bias with a maximizer attached to it, which is materially worse than in DQN — clipped double-Q is the largest single improvement TD3 makes and is not optional
  • Exploration noise is expressed in action units, so it does not transfer between environments with different action scales and a copied hyperparameter silently means something else
  • Exploration noise must be removed at deployment. A deterministic policy that is still being perturbed is not the policy that was evaluated
  • A saturated tanh actor has a near-zero gradient, so a policy pinned at an action bound stops learning while the critic keeps training, which looks like a plateau rather than a failure
  • With a shared encoder, the actor's gradient must be stopped before it reaches the encoder, or the actor can improve its objective by changing the representation instead of the action
  • Skipping the random-action warmup means the actor starts climbing a critic fitted on almost nothing, which is a common cause of early collapse
  • Seed variance in this family is severe enough that published comparisons have repeatedly turned out to be measuring seeds and code-level details rather than algorithms
Trade-offs

Pros and cons, in context

Strengths
  • Continuous actions without a tractable argmax

    This is the branch's reason to exist. Replacing the enumeration DQN relies on with a network trained to approximate it is what makes value-based methods available for continuous control at all

  • Off-policy, so the replay buffer and its sample efficiency come back

    Typically several times fewer environment interactions than an on-policy method for the same return on continuous control, which is the whole argument for tolerating the additional instability

  • The deployed policy is deterministic

    Same state, same action, every time — reproducible, auditable and simple to test, which is a real operational advantage over the stochastic families and rarely mentioned as one

  • TD3's three fixes are one hypothesis tested three ways

    Pessimistic ensembling, delayed optimizer updates and smoothing the optimized surface all target the same failure. The decomposition is unusually clean and each piece generalizes far outside reinforcement learning

  • Pessimism is the safe direction for the bias

    Taking the min of two critics underestimates values, which makes the actor avoid an action — a bounded cost. Overestimating makes the actor seek it, which compounds. Choosing the direction rather than minimizing magnitude is the insight

Limitations
  • The actor exploits the critic, by construction

    Its objective is to find the maximum of a learned surface, and a modelling error in the optimistic direction is a maximum. Every countermeasure manages this; none removes it, because it is what the method is

  • Exploration is external and does not transfer

    A deterministic policy supplies no exploration, so noise is added in action units, tuned per environment, and injected into the actuator — which makes online learning on real hardware effectively unavailable

  • Severe seed variance and brittleness

    Among the worst in the field. Plain DDPG was hard enough to reproduce that TD3 was written partly in response, and comparisons in this area have repeatedly turned out to be measuring seeds and implementation details

  • Discrete actions are not an option

    The gradient of the critic with respect to the action is the whole mechanism and it does not exist for a discrete action space. Discretizing a continuous one to use DQN instead works up to two or three dimensions and then dies

  • Actor saturation stops learning silently

    A tanh output driven to the action bound has a vanishing gradient, so the policy freezes at an extreme while the critic keeps training. It presents as a plateau rather than as a failure and is easy to miss

  • Largely superseded within its own branch

    SAC generally matches or beats TD3 with fewer hyperparameters and no explicit exploration schedule, because a maximum-entropy stochastic policy supplies its own exploration. TD3 remains the clearer explanation of the failure both are managing

Implementation

Intuitive, idiomatic, optimized

The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.

1. Intuitive

The math, transcribed literally. No dependencies.

"""DDPG - the deterministic policy gradient, transcribed.

Two networks and one chain rule. The critic is fitted by Bellman regression.
The actor is trained by pushing its output uphill on the critic's surface,
which means dQ/da is computed explicitly and then chained into the actor.

Watch what the actor's objective IS: there is no target in it and no quantity
derived from data. It says "make the critic rate this action higher". That is
an optimizer pointed at a learned function, and it is the whole difficulty.
"""

import math
import random


def make_critic(n_state, n_action, n_hidden):
    """Q(s, a) over the concatenation [s; a]. It must be differentiable in a -
    that requirement is why this branch exists only for continuous control."""
    scale = 1.0 / math.sqrt(n_state + n_action)
    return {
        "w1": [[random.uniform(-scale, scale) for _ in range(n_state + n_action)]
               for _ in range(n_hidden)],
        "b1": [0.0] * n_hidden,
        "w2": [random.uniform(-scale, scale) for _ in range(n_hidden)],
        "b2": 0.0,
    }


def critic_forward(critic, state, action):
    joint = list(state) + list(action)

    hidden = []
    for row, bias in zip(critic["w1"], critic["b1"]):
        total = bias
        for weight, feature in zip(row, joint):
            total += weight * feature
        hidden.append(total if total > 0.0 else 0.0)        # ReLU

    q = critic["b2"]
    for weight, activation in zip(critic["w2"], hidden):
        q += weight * activation

    return q, hidden, joint


def critic_backward(critic, joint, hidden, td_error, lr, n_state):
    """One SGD step on the squared TD error, and dQ/da on the way out.

    The action gradient is the piece the actor needs, and it costs nothing
    extra: it is the same backward pass, read off at the input layer instead of
    stopping at the weights.
    """
    grad_q = -td_error

    grad_hidden = [0.0] * len(hidden)
    for index in range(len(hidden)):
        grad_hidden[index] = grad_q * critic["w2"][index]
        critic["w2"][index] -= lr * grad_q * hidden[index]
    critic["b2"] -= lr * grad_q

    # dQ/d(input), accumulated across hidden units, then sliced to the action
    # half of the concatenation.
    grad_input = [0.0] * len(joint)
    for unit in range(len(hidden)):
        if hidden[unit] <= 0.0:
            continue                                        # ReLU is closed here
        delta = grad_hidden[unit]
        for index in range(len(joint)):
            grad_input[index] += delta * critic["w1"][unit][index]
            critic["w1"][unit][index] -= lr * delta * joint[index]
        critic["b1"][unit] -= lr * delta

    return grad_input[n_state:]


def actor_forward(actor, state, action_limit):
    """A DETERMINISTIC policy: one action, not a distribution.

    tanh bounds the output to the actuator range. It is also a real failure
    mode - a saturated tanh has a near-zero derivative, so an actor driven to
    the bound stops learning while the critic keeps training.
    """
    pre_activation = []
    for row in actor["w"]:
        total = 0.0
        for weight, feature in zip(row, state):
            total += weight * feature
        pre_activation.append(total)

    action = [action_limit * math.tanh(z) for z in pre_activation]
    return action, pre_activation


def actor_backward(actor, state, pre_activation, dq_daction, lr, action_limit):
    """Chain dQ/da into the actor. ASCENT: the actor maximizes the critic.

    da_k/dw[k][i] = action_limit * (1 - tanh(z_k)^2) * s[i], so the whole
    update is dQ/da_k times that. Two networks joined by one chain rule, and
    the actor inherits every error in the surface it is climbing.
    """
    for index in range(len(pre_activation)):
        squashed = math.tanh(pre_activation[index])
        local = action_limit * (1.0 - squashed * squashed)
        coefficient = dq_daction[index] * local
        for feature_index, feature in enumerate(state):
            actor["w"][index][feature_index] += lr * coefficient * feature


def soft_update(target, online, tau):
    """Polyak averaging: the target drifts continuously rather than jumping.

    DQN copies its target network periodically. Here the actor is moving too,
    so a smoothly drifting target suits the setting better than a step change.
    """
    for key in target:
        if isinstance(target[key], list) and target[key] and isinstance(target[key][0], list):
            for row_index in range(len(target[key])):
                for index in range(len(target[key][row_index])):
                    target[key][row_index][index] = (
                        tau * online[key][row_index][index]
                        + (1.0 - tau) * target[key][row_index][index]
                    )
        elif isinstance(target[key], list):
            for index in range(len(target[key])):
                target[key][index] = tau * online[key][index] + (1.0 - tau) * target[key][index]
        else:
            target[key] = tau * online[key] + (1.0 - tau) * target[key]


def train(env, n_state, n_action, steps=100_000, n_hidden=64, lr=1e-3, gamma=0.99,
          tau=0.005, action_limit=1.0, noise_scale=0.1, buffer_size=50_000,
          warmup=1_000, batch_size=32):
    scale = 1.0 / math.sqrt(n_state)
    actor = {"w": [[random.uniform(-scale, scale) for _ in range(n_state)]
                   for _ in range(n_action)]}
    critic = make_critic(n_state, n_action, n_hidden)

    target_actor = {"w": [row[:] for row in actor["w"]]}
    target_critic = {
        "w1": [row[:] for row in critic["w1"]],
        "b1": critic["b1"][:],
        "w2": critic["w2"][:],
        "b2": critic["b2"],
    }

    buffer = []
    state = env.reset()

    for step in range(steps):
        # The policy is deterministic, so it supplies NO exploration. Noise has
        # to be added from outside, and its scale is in action units - which is
        # why this hyperparameter does not transfer between environments.
        if step < warmup:
            action = [random.uniform(-action_limit, action_limit) for _ in range(n_action)]
        else:
            action, _ = actor_forward(actor, state, action_limit)
            action = [
                max(-action_limit, min(action_limit, a + random.gauss(0.0, noise_scale)))
                for a in action
            ]

        next_state, reward, done = env.step(action)
        buffer.append((state, action, reward, next_state, done))
        if len(buffer) > buffer_size:
            buffer.pop(0)

        state = env.reset() if done else next_state
        if len(buffer) < warmup:
            continue

        for _ in range(batch_size):
            s, a, r, s_next, terminal = random.choice(buffer)

            # The target: bootstrap through the TARGET actor and TARGET critic,
            # both drifting slowly, so the regression target is approximately
            # stationary while the online networks move.
            if terminal:
                bootstrap = 0.0                             # no future past a terminal
            else:
                next_action, _ = actor_forward(target_actor, s_next, action_limit)
                bootstrap, _, _ = critic_forward(target_critic, s_next, next_action)

            q, hidden, joint = critic_forward(critic, s, a)
            td_error = r + gamma * bootstrap - q

            # One backward pass serves both networks: it fits the critic and
            # hands back dQ/da for the actor.
            dq_daction = critic_backward(critic, joint, hidden, td_error, lr, n_state)

            # The actor is evaluated at ITS OWN current output, not at the
            # stored action - the objective is "what would the critic say about
            # what I would do now", which is what makes this off-policy.
            current_action, pre_activation = actor_forward(actor, s, action_limit)
            _, actor_hidden, actor_joint = critic_forward(critic, s, current_action)
            dq_dcurrent = critic_backward(critic, actor_joint, actor_hidden, 0.0, 0.0, n_state)

            actor_backward(actor, s, pre_activation, dq_dcurrent, lr, action_limit)

            soft_update(target_critic, critic, tau)
            soft_update(target_actor, actor, tau)

    return actor, critic

Two critic passes plus two target passes per sample, all scalar and in the interpreter. No batching anywhere.

2. IdiomaticPyTorch

How this language actually wants it written.

"""TD3 - twin critics, delayed actor, smoothed targets, typed throughout."""

from __future__ import annotations

from dataclasses import dataclass
from typing import NamedTuple

import numpy as np
import torch
from numpy.typing import NDArray
from torch import Tensor, nn


class Batch(NamedTuple):
    states: Tensor
    actions: Tensor
    rewards: Tensor
    next_states: Tensor
    # Terminal, not done: a time-limit truncation still has a future and must
    # still be bootstrapped. Conflating them teaches the agent the world ends
    # at the episode limit.
    terminals: Tensor


@dataclass(frozen=True)
class Td3Config:
    n_state: int
    n_action: int
    action_limit: float
    hidden: tuple[int, ...] = (256, 256)
    learning_rate: float = 3e-4
    gamma: float = 0.99
    tau: float = 0.005
    # Exploration, in ACTION UNITS. The policy is deterministic and supplies
    # none of its own, so this is the entire mechanism - and it does not
    # transfer between environments with different action scales.
    exploration_noise: float = 0.1
    # Target policy smoothing: noise on the target action, clipped so it stays
    # local. It asserts that nearby actions have similar values, which is what
    # makes a critic artifact narrower than the noise unexploitable.
    target_noise: float = 0.2
    target_noise_clip: float = 0.5
    # One actor step per two critic steps, so the actor climbs a surface that
    # has had time to settle between its own moves.
    policy_delay: int = 2
    warmup_steps: int = 10_000

    def __post_init__(self) -> None:
        if self.action_limit <= 0.0:
            raise ValueError("action limit must be positive")
        if not 0.0 < self.tau <= 1.0:
            raise ValueError(f"tau must be in (0, 1], got {self.tau}")
        if self.policy_delay < 1:
            raise ValueError("policy delay must be at least one")
        if self.target_noise_clip < self.target_noise:
            raise ValueError("clipping below the noise scale makes the smoothing one-sided")


class Actor(nn.Module):
    """Deterministic: one action, not a distribution."""

    def __init__(self, config: Td3Config) -> None:
        super().__init__()
        layers: list[nn.Module] = []
        width = config.n_state
        for size in config.hidden:
            layers += [nn.Linear(width, size), nn.ReLU()]
            width = size
        layers += [nn.Linear(width, config.n_action), nn.Tanh()]
        self.net = nn.Sequential(*layers)
        self.action_limit = config.action_limit

    def forward(self, states: Tensor) -> Tensor:
        return self.action_limit * self.net(states)


class TwinCritic(nn.Module):
    """Two independently initialized critics over the same (s, a) input.

    Independent initialization is the point: their errors have to be
    uncorrelated for the min to remove anything.
    """

    def __init__(self, config: Td3Config) -> None:
        super().__init__()
        self.q1 = self._build(config)
        self.q2 = self._build(config)

    @staticmethod
    def _build(config: Td3Config) -> nn.Module:
        layers: list[nn.Module] = []
        width = config.n_state + config.n_action
        for size in config.hidden:
            layers += [nn.Linear(width, size), nn.ReLU()]
            width = size
        layers.append(nn.Linear(width, 1))
        return nn.Sequential(*layers)

    def forward(self, states: Tensor, actions: Tensor) -> tuple[Tensor, Tensor]:
        joint = torch.cat([states, actions], dim=1)
        return self.q1(joint).squeeze(-1), self.q2(joint).squeeze(-1)


class ReplayBuffer:
    """Fixed-capacity ring over pre-allocated arrays, one per field."""

    def __init__(self, capacity: int, n_state: int, n_action: int) -> None:
        if capacity < 1:
            raise ValueError("capacity must be positive")
        self._states = np.zeros((capacity, n_state), dtype=np.float32)
        self._next_states = np.zeros((capacity, n_state), dtype=np.float32)
        self._actions = np.zeros((capacity, n_action), dtype=np.float32)
        self._rewards = np.zeros(capacity, dtype=np.float32)
        self._terminals = np.zeros(capacity, dtype=np.bool_)
        self._capacity = capacity
        self._cursor = 0
        self._size = 0

    def __len__(self) -> int:
        return self._size

    def add(self, state: NDArray[np.float32], action: NDArray[np.float32],
            reward: float, next_state: NDArray[np.float32], terminal: bool) -> None:
        index = self._cursor
        self._states[index] = state
        self._actions[index] = action
        self._rewards[index] = reward
        self._next_states[index] = next_state
        self._terminals[index] = terminal
        self._cursor = (self._cursor + 1) % self._capacity
        self._size = min(self._size + 1, self._capacity)

    def sample(self, batch_size: int, rng: np.random.Generator) -> Batch:
        if self._size < batch_size:
            raise ValueError(f"buffer holds {self._size}, cannot sample {batch_size}")
        index = rng.integers(0, self._size, size=batch_size)
        return Batch(
            torch.from_numpy(self._states[index]),
            torch.from_numpy(self._actions[index]),
            torch.from_numpy(self._rewards[index]),
            torch.from_numpy(self._next_states[index]),
            torch.from_numpy(self._terminals[index]),
        )


@torch.no_grad()
def smoothed_pessimistic_target(
    target_actor: Actor, target_critic: TwinCritic, batch: Batch, config: Td3Config
) -> Tensor:
    """The TD3 target: smoothed action, minimum over two critics.

    The min is a deliberate change of BIAS DIRECTION, not a reduction in its
    magnitude. Underestimating an action's value makes the actor avoid it,
    which costs a bounded amount of performance. Overestimating makes the actor
    SEEK it, which compounds - because the actor is a maximizer and a spurious
    peak is a maximum.
    """
    noise = (torch.randn_like(batch.actions) * config.target_noise).clamp(
        -config.target_noise_clip, config.target_noise_clip
    )
    next_actions = (target_actor(batch.next_states) + noise).clamp(
        -config.action_limit, config.action_limit
    )

    q1, q2 = target_critic(batch.next_states, next_actions)
    next_value = torch.min(q1, q2).masked_fill(batch.terminals, 0.0)
    return batch.rewards + config.gamma * next_value


def update(
    actor: Actor, critic: TwinCritic,
    target_actor: Actor, target_critic: TwinCritic,
    actor_optimizer: torch.optim.Optimizer, critic_optimizer: torch.optim.Optimizer,
    batch: Batch, config: Td3Config, step: int,
) -> dict[str, float]:
    target = smoothed_pessimistic_target(target_actor, target_critic, batch, config)
    q1, q2 = critic(batch.states, batch.actions)
    critic_loss = nn.functional.mse_loss(q1, target) + nn.functional.mse_loss(q2, target)

    critic_optimizer.zero_grad(set_to_none=True)
    critic_loss.backward()
    critic_optimizer.step()

    stats = {"critic_loss": float(critic_loss), "mean_q": float(q1.mean())}

    # Delayed: the actor moves only every policy_delay critic updates, so it is
    # climbing a surface that has settled rather than chasing one that moves
    # underneath it every step.
    if step % config.policy_delay != 0:
        return stats

    # The actor maximizes q1 alone, not the min. Using the min here would make
    # the actor climb the more pessimistic of two surfaces for no benefit; the
    # pessimism belongs in the TARGET, where the compounding happens.
    actor_loss = -critic.q1(
        torch.cat([batch.states, actor(batch.states)], dim=1)
    ).mean()

    actor_optimizer.zero_grad(set_to_none=True)
    actor_loss.backward()
    actor_optimizer.step()

    with torch.no_grad():
        for online, target_parameter in zip(critic.parameters(), target_critic.parameters()):
            target_parameter.mul_(1 - config.tau).add_(online, alpha=config.tau)
        for online, target_parameter in zip(actor.parameters(), target_actor.parameters()):
            target_parameter.mul_(1 - config.tau).add_(online, alpha=config.tau)

    stats["actor_loss"] = float(actor_loss)
    return stats

What changed: The single critic becomes two independently initialized ones with the minimum taken in the target, which changes the direction of the bias rather than its size — the actor is a maximizer, so an optimistic error compounds and a pessimistic one merely costs performance. The actor is delayed so it climbs a surface that has settled. Clipped noise is added to the target action, which asserts that nearby actions have similar values and makes a narrow critic artifact unexploitable. Terminal is separated from truncation, and the buffer becomes pre-allocated contiguous arrays rather than a list of tuples.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionalsNo mutable default arguments

Four critic passes plus one actor pass per update, with the actor skipped on most steps.

3. OptimizedPyTorch

Faster, and the justification for each change.

"""TD3 - fused twin critics, foreach Polyak, actor work skipped when delayed.

None of this changes the algorithm. All of it removes work that a direct
implementation does per step and that adds up at a million environment steps.
"""

from __future__ import annotations

import numpy as np
import torch
from torch import Tensor, nn


class FusedTwinCritic(nn.Module):
    """Both critics as one batched matrix multiply.

    Two separate networks mean two GEMMs per layer, each half the size and each
    too small to saturate anything. Stacking them into a single weight tensor
    with a leading pair dimension turns every layer into one baddbmm - identical
    arithmetic, half the kernel launches, and the pair dimension keeps the two
    sets of parameters genuinely independent, which is what the min requires.
    """

    def __init__(self, n_input: int, hidden: int) -> None:
        super().__init__()
        # Leading dim 2: index 0 is critic one, index 1 is critic two.
        self.w1 = nn.Parameter(torch.empty(2, n_input, hidden))
        self.b1 = nn.Parameter(torch.zeros(2, 1, hidden))
        self.w2 = nn.Parameter(torch.empty(2, hidden, 1))
        self.b2 = nn.Parameter(torch.zeros(2, 1, 1))
        for tensor in (self.w1, self.w2):
            nn.init.orthogonal_(tensor, gain=1.0)

    def forward(self, states: Tensor, actions: Tensor) -> Tensor:
        joint = torch.cat([states, actions], dim=1)
        # (2, batch, n_input) without copying: expand is a stride trick.
        batched = joint.unsqueeze(0).expand(2, -1, -1)

        hidden = torch.baddbmm(self.b1, batched, self.w1).relu_()   # ReLU in place
        return torch.baddbmm(self.b2, hidden, self.w2).squeeze(-1)  # (2, batch)


class ContiguousReplay:
    """One flat allocation per field; a batch is one fancy-index per field.

    A list of per-transition tuples allocates twice per environment step and
    scatters every sample, which at a million steps is the largest avoidable
    cost in the whole loop.
    """

    def __init__(self, capacity: int, n_state: int, n_action: int, device: torch.device) -> None:
        # Single dtype throughout, so a sampled batch needs no conversion and
        # transfers as one contiguous block per field.
        self.states = torch.zeros((capacity, n_state), dtype=torch.float32, device=device)
        self.next_states = torch.zeros((capacity, n_state), dtype=torch.float32, device=device)
        self.actions = torch.zeros((capacity, n_action), dtype=torch.float32, device=device)
        self.rewards = torch.zeros(capacity, dtype=torch.float32, device=device)
        self.terminals = torch.zeros(capacity, dtype=torch.bool, device=device)
        self.capacity = capacity
        self.cursor = 0
        self.size = 0

    def add(self, state: Tensor, action: Tensor, reward: float,
            next_state: Tensor, terminal: bool) -> None:
        index = self.cursor
        # copy_ writes into storage that already exists; assignment would not.
        self.states[index].copy_(state)
        self.actions[index].copy_(action)
        self.next_states[index].copy_(next_state)
        self.rewards[index] = reward
        self.terminals[index] = terminal
        self.cursor = (self.cursor + 1) % self.capacity
        self.size = min(self.size + 1, self.capacity)

    def sample(self, batch_size: int, generator: torch.Generator):
        index = torch.randint(0, self.size, (batch_size,), generator=generator,
                              device=self.states.device)
        return (
            self.states[index], self.actions[index], self.rewards[index],
            self.next_states[index], self.terminals[index],
        )


@torch.no_grad()
def polyak_foreach(target_params: list[Tensor], online_params: list[Tensor], tau: float) -> None:
    """Soft-update every parameter tensor in two fused calls.

    A Python loop with mul_ and add_ per tensor runs hundreds of tiny kernel
    launches on EVERY step - and unlike DQN's periodic copy, this happens
    continuously. The foreach ops batch the whole parameter list into one
    launch each, which is a real fraction of wall-clock in a direct TD3.
    """
    torch._foreach_mul_(target_params, 1.0 - tau)
    torch._foreach_add_(target_params, online_params, alpha=tau)


@torch.no_grad()
def target_value(
    target_actor: nn.Module, target_critic: FusedTwinCritic,
    next_states: Tensor, rewards: Tensor, terminals: Tensor,
    gamma: float, action_limit: float, noise: float, noise_clip: float,
) -> Tensor:
    """Smoothed pessimistic target, with the min taken across the pair axis.

    Because both critics live in one tensor, the minimum is a reduction over
    dimension 0 rather than a comparison between two separate results - no
    second forward pass and no intermediate tensors to materialize.
    """
    smoothing = (torch.randn_like(rewards.unsqueeze(-1)) * noise).clamp(-noise_clip, noise_clip)
    next_actions = (target_actor(next_states) + smoothing).clamp(-action_limit, action_limit)

    pair = target_critic(next_states, next_actions)          # (2, batch)
    next_value = pair.min(dim=0).values.masked_fill(terminals, 0.0)
    return rewards + gamma * next_value


def critic_step(critic: FusedTwinCritic, optimizer: torch.optim.Optimizer,
                states: Tensor, actions: Tensor, target: Tensor) -> Tensor:
    """One loss covering both critics: the pair axis broadcasts against the
    shared target, so there is no second loss term to add."""
    pair = critic(states, actions)                           # (2, batch)
    loss = nn.functional.mse_loss(pair, target.unsqueeze(0).expand_as(pair))

    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()
    return loss.detach()

What changed: Three removals, none of which touches the algorithm. The twin critics become one weight tensor with a leading pair dimension, so every layer is a single batched matmul instead of two half-sized ones and the min is a reduction rather than a comparison between separate results. The Polyak update — which unlike DQN's periodic copy runs on every step — becomes two fused foreach calls instead of hundreds of tiny per-tensor kernel launches. And the replay buffer is one flat allocation per field in a single dtype, so a batch is one fancy-index per field rather than a gather over scattered tuples.

  • Batch work to amortize interpreter overhead

    The Polyak update touches every parameter tensor on every step; batching them into two foreach calls replaces hundreds of per-tensor launches with two.

    Trade-off: It requires the parameter lists to stay in matching order, which a model refactor can silently break — mismatched ordering soft-updates the wrong tensors with no error.

  • Vectorize to NumPy/BLAS (@, np.dot, einsum)

    Stacking both critics under a leading pair dimension turns two half-sized GEMMs per layer into one batched matmul, and makes the min a reduction over that axis.

    Trade-off: The two critics now share a module and an optimizer, so anything that needs them treated separately — a per-critic learning rate, a staggered reset — no longer has a natural place.

  • Ensure contiguous memory layout and a single dtype

    Every replay field is one flat float32 allocation, so a sampled batch is one contiguous fancy-index per field with no dtype conversion on the path.

    Trade-off: Capacity is fixed at construction and the whole buffer is allocated up front, whether or not training ever fills it.

  • Pre-allocate output arrays and use in-place operations

    copy_ writes transitions into existing storage and relu_ activates in place, so neither the collection path nor the critic forward allocates per step.

    Trade-off: In-place activation destroys the pre-activation values, so any diagnostic that wanted to inspect them has to recompute the layer.

One batched matmul per critic layer instead of two; two fused calls per Polyak update. Illustrative, not a measured benchmark.