What it actually does
Tabular Q-learning stores one number per state-action pair, which means it cannot play a game whose screen has more configurations than there are atoms in anything. Replace the table with a network that maps a state to a vector of action values and the method generalizes: states that look alike get similar values without being visited separately. That substitution is the entire idea, and it breaks the convergence proof immediately. The proof relied on each update touching exactly one cell; a network update moves every state at once, including the state the target was just computed from, so the agent is chasing a target that moves because it moved. DQN makes this work with two blunt interventions. Experience goes into a large replay buffer and minibatches are drawn from it at random, which breaks the temporal correlation that makes consecutive samples nearly identical. And the target is computed from a separate copy of the network that is frozen for thousands of steps, which stops the target from moving every time the estimate does. Neither is a fix. They convert a method that reliably diverges into one that usually does not, and the residual failure is not an error message — it is a Q-value curve that climbs smoothly to a thousand while the agent gets worse. The right time to reach for DQN is when actions are discrete, a simulator can supply tens of millions of transitions, and a policy-gradient method is not a better fit. That is a narrower set of conditions than its fame suggests.
- The environment is a Markov decision process in the state actually given to the network — frame stacking exists because a single observation usually is not
- The action space is discrete and small enough to emit one value per action, which is what rules out continuous control entirely
- Similar states have similar values, which is the generalization assumption the network embodies and the reason a table was worth replacing
- Transitions are cheap enough to collect tens of millions of them, which in practice means a simulator
- The replay buffer is a reasonable approximation of a stationary distribution, which it is not, and the whole stabilization stack exists to make the gap survivable
- Rewards are on a bounded, known scale, or have been clipped onto one, because gradient magnitude is set by reward magnitude
Loss function
Read literally, this is a regression: predict the bootstrapped return of the action you took, squared error, gradient descent. Two things make that reading misleading in ways that matter. First, it is not the gradient of anything. The target contains the parameters, and the derivative through it is deliberately discarded — this is a semi-gradient method, and the quantity being descended is not the gradient of L or of any other function. That is why nothing here has the convergence guarantee an ordinary regression would, and why "the loss went down" is not evidence that learning is working. Second, the distribution is not fixed. The buffer fills with whatever the current policy visits, so the regression problem changes as the policy does, and old transitions describe a world the agent has left. Put these together with function approximation and you have the deadly triad — bootstrapping, off-policy sampling, and approximation — which is known to diverge and which DQN does not escape. The replay buffer and the target network narrow the window in which it happens rather than closing it. In practice the squared error is also replaced by a Huber loss, because a single transition with an outsized TD error would otherwise produce a gradient large enough to destroy the network in one step, and rewards are clipped to a fixed range for the same reason. Both are admissions that the objective as written is not stable enough to optimize directly.
- the target network: a frozen copy of the weights, refreshed every few thousand steps, whose only job is to stop the target moving with the estimate
- the replay buffer — a large pool of past transitions sampled uniformly, which is what makes the minibatch approximately i.i.d.
- the greedy bootstrap, off-policy and unchanged from tabular Q-learning; the source of the maximization bias Double DQN exists to remove
- the discount, which also bounds the magnitude a diverging value estimate can reach before it is visible
How it is minimized
Semi-gradient descent on the squared (in practice Huber) TD error, with Adam or RMSProp, uniform replay, and a periodically refreshed target network
- the Double DQN target: the online network selects the action, the target network values it, which removes the bias from taking a max over noisy estimates
- Huber loss — quadratic near zero, linear beyond delta, so one anomalous TD error cannot produce a network-destroying gradient
- taken through the prediction only, never through the target; the discarded term is what makes this a semi-gradient rather than a gradient
- the learning rate, typically 1e-4 and an order of magnitude below what supervised training of the same network would tolerate
Everything in a working DQN is a countermeasure to a specific pathology, and the method is best learned as that list. Replay exists because consecutive transitions are almost the same sample, and training a network on a strongly autocorrelated stream makes it fit the last few seconds and forget everything else. The target network exists because a bootstrapped target computed from the network being updated is a feedback loop, and freezing it for several thousand steps turns a moving target into a sequence of stationary regression problems. Double DQN exists because a max over noisy estimates systematically selects whichever action got lucky, and in a table that bias was an annoyance while under approximation it compounds into value estimates that climb without bound. Huber loss and reward clipping exist because a single large TD error times a network-sized gradient is enough to destroy the parameters irrecoverably. Dueling heads exist because in most states the action barely matters, and forcing the network to learn |A| nearly identical numbers wastes its capacity on a quantity with no decision content. Prioritized replay exists because uniform sampling spends most of its budget on transitions the network already predicts correctly. None of these is optional in a competitive implementation, and the compounding of them is what Rainbow measured: the combination substantially outperforms the sum of the parts, which is the real lesson — DQN is not one algorithm but a stack of stabilizers, and removing any one of them degrades a run in a way that looks like a hyperparameter problem rather than a missing component.
replay buffer size
How much history stays available. Too small and the network overfits the recent policy and forgets; too large and most samples describe a policy the agent abandoned long ago (1e5 to 1e6 transitions)
target network update period
The single most important stability knob. Longer means a more stationary target and slower propagation of value information; shorter means faster learning and a return of the feedback loop (1,000 to 10,000 gradient steps)
learning rate
An order of magnitude below supervised training of the same architecture, because the regression targets are non-stationary and a large step chases noise into divergence (1e-4 to 2.5e-4 with Adam)
epsilon schedule
Exploration, annealed over the first fraction of training. A floor is kept rather than annealing to zero, because a deterministic greedy policy in a deterministic environment stops generating new data entirely (1.0 to 0.01 over ~1e6 steps)
batch size
Averages the gradient over independent transitions; larger batches stabilize the semi-gradient step at a proportional cost per update (32 to 512)
train frequency and warmup
Gradient steps per environment step, and how many transitions are collected before the first update. Training on an almost-empty buffer is training on one correlated episode (one update per 4 steps; 50k step warmup)
n-step return length
Bootstraps after n rewards instead of one, trading the bias of a bad value estimate for the variance of a longer sampled return. The cheapest real improvement available (1 to 5)
discount (gamma)
The effective horizon. Above 0.99 the fixed point becomes numerically delicate under approximation, and small value errors compound over the longer horizon (0.99, rarely above)
There is no convergence guarantee, and this is the central fact about the method rather than a footnote. Combining bootstrapping, off-policy sampling, and function approximation is the deadly triad, and counterexamples where the parameters diverge on trivially small problems have been known since the early 1990s. DQN does not resolve the triad; the replay buffer and the target network reduce the frequency and severity of the divergence enough that the method works on many problems. What follows from that is a distinctive set of failure modes. The most dangerous is silent value explosion: Q-estimates climb smoothly into the hundreds or thousands while the loss stays small, because the network is tracking its own inflated targets consistently, and the policy degrades throughout without a single error being raised. Monitoring the mean predicted Q against the actual discounted return observed is the only reliable detection, and it is routinely omitted. The second is catastrophic forgetting when the buffer is too small relative to how fast the policy moves, which appears as periodic collapses in return that recover and collapse again. The third is seed variance: identical code and hyperparameters across different random seeds can produce runs that solve the task and runs that never leave the floor, so any single-seed comparison between two variants is uninformative, and reporting a median over at least five seeds is the minimum credible protocol. Where the method does succeed, the qualitative pattern is a long flat stretch with no apparent progress followed by a fast rise, because the value function must become roughly correct globally before the greedy policy derived from it becomes any good.
Per gradient step: one forward and backward pass over a minibatch through the online network, plus one forward pass through the target network, so roughly three network passes per update at batch size B. Per environment step: one forward pass to act. Wall-clock is usually dominated by environment simulation rather than by the network, which is why vectorized environments matter more than a faster GPU for most problems. Memory is dominated by the replay buffer: one million transitions of stacked 84x84 frames is around 7 GB if frames are stored as uint8 and shared between consecutive transitions, and around four times that if the stacking is materialized per transition — a distinction that decides whether the buffer fits in RAM. Sample complexity is the binding cost: tens of millions of environment steps for an Atari-scale task, which makes a fast simulator a hard prerequisite rather than a convenience.
How, where, and why it is used
Time-Series Forecasting
Not applicableForecasting has no action to value and no state the forecaster influences, so the one structure this method exploits — that a decision now changes what is observed next — is absent; the moment a forecast drives a decision the problem belongs under control-and-operations instead.
Anomaly Detection
Not applicableDetection scores observations without acting on them, and a large TD error here is a statement about an under-trained region of the value network rather than about anything unusual in the data — the same quantity prioritized replay uses to decide what to train on next.
Optimization
Primary fitLearn a policy for a sequential decision problem with discrete actions by regressing action values against bootstrapped targets, with a network supplying generalization across states that were never visited. The policy is the argmax over the network output; it is never represented explicitly.
- Sequential decisions over a state space far too large to enumerate, where the tabular methods stop being an option at all
- The deadly triad as a concrete engineering problem rather than a theoretical caution — bootstrapping, off-policy data, and approximation combined
- Stabilization as a design discipline: replay for decorrelation, a frozen target for stationarity, Double DQN for the maximization bias, Huber for gradient magnitude
- Off-policy learning as the property that makes replay legal, and therefore makes sample reuse and learning from logged data possible
The pivotal entry in this category, because it is where the guarantees end and engineering judgment starts. Three things transfer beyond reinforcement learning. The first is that a bootstrapped target is a feedback loop, and any system that fits a model to targets derived from its own current output needs an explicit mechanism to break the loop — freezing a copy is the simplest one and it recurs far outside RL. The second is that a max over noisy estimates is biased upward, always, and the cleaner the estimator looks the easier that is to miss; the Double DQN fix of separating selection from evaluation is the general remedy. The third is the monitoring lesson: this method fails by producing confident, smoothly increasing, entirely wrong numbers, and the only detection is comparing predictions against realized outcomes. A loss curve cannot see it. Where DQN is the wrong choice is equally instructive — continuous actions make the argmax intractable and call for DDPG or SAC, an on-policy problem with an expensive simulator is better served by PPO, and a problem with no state to carry forward is a bandit and should be treated as one.
- Stack several consecutive observations, or use a recurrent encoder, when a single frame is not Markov — velocity is not observable from one image
- Clip or normalize rewards to a fixed scale, since the loss magnitude and therefore the gradient magnitude are set by the reward units
- Store frames once as uint8 and stack by index at sample time; materializing the stack per transition multiplies buffer memory by the stack depth
- Warm up the buffer before the first gradient step, because training on a nearly empty buffer is training on one correlated episode
Median return over at least five random seeds, with the interquartile range reported — single-seed comparisons between variants are uninformative, because seed variance on the same code routinely exceeds the difference between two genuinely different algorithms. Evaluate a separate greedy or low-epsilon policy periodically rather than reading the training return, which is contaminated by exploration. And plot mean predicted Q against the realized discounted return on the same axes: their divergence is the only visible symptom of the failure mode that matters most.
- Treating a falling loss as evidence of learning, when a network tracking its own inflated targets has a small loss and a worthless policy
- Comparing variants on one seed each, where the difference observed is usually seed variance
- Bootstrapping past a terminal state, which injects a fictional future return and is the most common implementation bug
- Confusing the end of an episode with a time-limit truncation, which is not terminal and must still be bootstrapped
Control & Operations
Primary fitLearn a discrete control policy — a setpoint from a menu, a dispatch choice, a mode switch — from simulated interaction, with a network generalizing across a continuous sensor state that no table could index. Deployment is a frozen network evaluated greedily, with exploration disabled.
- Dispatch and routing over a discrete action menu with a high-dimensional state description
- Discrete setpoint or mode selection in process control, where the choice set is small but the state is continuous and large
- Inventory and replenishment policies with discrete order quantities and a long-horizon cost
- Traffic signal control and similar scheduling problems where a simulator already exists and is the reason the method is feasible
The natural applied home, and the qualifier that decides most projects is the simulator. DQN needs tens of millions of transitions and learns from a stream it partly generates, so a system that cannot be simulated faithfully cannot be trained on — and a system that can be simulated faithfully often admits a classical optimizer or a model-predictive controller that comes with guarantees this method lacks. The honest decision rule is to reach for DQN when the action set is discrete and small, the state is too rich to enumerate, the horizon is long enough that greedy heuristics visibly leave value behind, and a simulator already exists. Two cautions carry real weight in operations. The first is that off-policy learning from historical logs, which is the property that makes this method attractive to teams with years of data, still requires the logged actions to have covered the action space — logs generated by a deterministic legacy controller contain no information about the actions it never took, and the network will extrapolate confidently into exactly that gap. The second is that a greedy argmax over a network output has no notion of a constraint; safety must come from an interlock outside the policy, not from the reward function, because a reward penalty is a preference and a constraint is not.
- Include every sensor the decision depends on in the state, since a violated Markov assumption shows up as an unlearnable plateau rather than an error
- Normalize observations to a consistent scale; a network trained on raw sensor units learns slowly and generalizes badly across ranges
- Encode constraint proximity explicitly, because the network learns what is in the state and infers nothing from what is not
- Check that historical logs actually explored the action set before attempting to learn from them offline
Simulated return against the incumbent controller on identical scenarios, plus constraint violations counted separately rather than averaged into the return — a policy that is better on average and breaches a limit is not better. Before deployment, shadow-run the frozen policy against the live controller and compare decisions, since the sim-to-real gap surfaces as systematic disagreement in specific regimes rather than as uniformly degraded performance.
- Learning from logs produced by a deterministic legacy controller, which leaves the network extrapolating into actions never observed
- Expressing a hard constraint as a reward penalty, which makes it negotiable to an optimizer that is explicitly trading it off
- Leaving exploration enabled at deployment, so a production controller periodically takes a random action
- Trusting a simulator that was validated on the incumbent policy's operating region, which is precisely the region the new policy will leave
Recommendation & Ranking
ViableTreat a session as a sequence of decisions and learn action values for a narrowed candidate set, optimizing long-horizon session or retention value rather than the immediate click a supervised ranker maximizes. Off-policy learning is what makes logged impressions usable as training data.
- Slate or strategy selection over a small candidate set produced by an upstream retrieval stage
- Long-horizon objectives — session length, return visits — that a per-impression supervised loss cannot express
- Sequential surfaces where an early recommendation changes what the user does next, which is the structure that makes this a control problem at all
- Settings with large logged interaction histories, since being off-policy is what allows them to be replayed
A real but demanding fit, and the demands are what usually decide it. The attraction is genuine: a supervised ranker optimizes the next click and is structurally unable to represent the fact that a recommendation changes the state a user is in, while a value-based method optimizes the discounted future directly. Three obstacles stand in the way. The action space must be narrowed to something the network can produce a value for, so retrieval has to come first and the method operates on a candidate set rather than a catalogue. The reward must be defined over a horizon long enough to matter and short enough to observe, and picking it is the hardest modelling decision in the project rather than a detail. And offline evaluation is unreliable here in a way it is not elsewhere — logged data reflects the policy that generated it, and off-policy estimates of a new policy have variance that grows with how much the two differ, which is exactly the case where an estimate is needed. The practical consequence is that online experiments decide, which makes iteration slow and expensive. Where teams succeed, it is usually with a small discrete strategy space rather than item-level actions, and with a policy-gradient method as a serious alternative that is often the better one.
- Narrow the action set with a retrieval stage first; a network cannot emit a value per item over a catalogue
- Define the reward over an explicit horizon and state it as a modelling decision, because the discount is choosing what the system optimizes
- Keep the behaviour policy's action probabilities in the logs, since every off-policy estimator needs them and they cannot be recovered later
- Include session context in the state, or the problem collapses to a contextual bandit with extra machinery and no extra benefit
Online A/B tests are the decision; off-policy estimates are for triage only and their variance grows precisely when the candidate policy differs most from the logged one. Report the long-horizon metric the system was built to optimize, not click-through, since improving click-through was never the reason to take on this complexity.
- Item-level action spaces, where the network has nothing to emit a value over
- Off-policy estimates trusted beyond triage, where their variance is largest exactly when the decision matters
- Logged propensities never recorded, which makes every correction method unavailable after the fact
- A state with no session context, which makes the problem a contextual bandit dressed up as control
Computer Vision
AdaptedA convolutional encoder maps raw pixels to action values, trained end to end by the TD error alone — the representation is learned from reward rather than from labels, and no vision objective appears anywhere in the loss.
- Visual control from pixels, where perception and policy are trained as one network rather than as a pipeline
- Frame stacking as the standard answer to a single image being non-Markov, since velocity is not visible in one frame
- Reward-driven representation learning as a study object — what features emerge when the only supervision is a scalar
- Auxiliary self-supervised losses added to the encoder, which is the standard remedy for how weak that supervision is
Included because it is instructive rather than because DQN is a vision method — it is not, and treating it as one is the error to avoid. What it demonstrates is how little supervision a convolutional encoder can be trained on: a scalar reward, delayed and sparse, is many orders of magnitude less information per sample than a label, and the resulting sample requirements show it. Tens of millions of frames to learn a representation that a supervised task would fit in thousands is the cost of the substitution, and it is the clearest available argument for why pretraining a visual encoder separately and freezing it is usually the better engineering choice. The pathologies are also specific: the encoder is being trained against a non-stationary target, so the features themselves drift while the value head is trying to fit them, which is a moving foundation the supervised setting never has. If the actual task is a vision task — detection, segmentation, classification — this is the wrong tool by a wide margin. It earns its place only when the output is an action and the input happens to be pixels.
- Stack consecutive frames or use a recurrent encoder; one frame does not contain velocity and the problem is not Markov without it
- Store frames as uint8 and convert at sample time, since the buffer and not the network is what exhausts memory
- Downsample and grayscale aggressively — the resolution needed for control is far below the resolution needed for recognition
- Consider a pretrained frozen encoder, because reward is a far weaker supervisory signal than labels and rarely the better source of features
Return, not any vision metric — the encoder exists to support a policy and its features are only as good as the decisions they enable. If representation quality is the actual question, probe the frozen features on a supervised task, which tends to reveal that they encode what the reward depended on and discard everything else.
- Reaching for this on an actual vision task, where a supervised model is better by orders of magnitude in both accuracy and sample cost
- Materializing stacked frames per transition, which multiplies replay memory by the stack depth and is the usual reason the buffer will not fit
- Expecting general visual features from reward supervision, which produces representations narrowly specialized to the reward
- Ignoring that the encoder is fitting a non-stationary target, so the features drift underneath the value head throughout training
What it costs to run
The expensive entry in this category by a wide margin. Tens of millions of environment steps for an Atari-scale task, one gradient step per handful of environment steps, and three network passes per gradient step. Wall-clock is usually dominated by the simulator rather than the network, which makes vectorized environments a larger lever than a faster accelerator on most problems. Buffer memory is the other hard constraint: a million transitions of stacked frames is a few gigabytes stored as uint8 with frames shared, and several times that if the stacking is materialized. Budget for at least five seeds, because a single run is not evidence. Illustrative, not a measured benchmark.
One forward pass and an argmax — milliseconds on CPU for a small network, and the policy is never represented separately from the value function. Serving is unremarkable; what deserves attention is that the deployed artefact is a frozen network whose outputs are absolute value estimates, and those estimates are commonly inflated even in a policy that performs well. Rank order is what the policy uses and rank order is what can be trusted; the magnitudes should not be shown to anyone as expected returns.
Retrained rather than updated continuously in almost every production setting, because online learning against live traffic means a live system taking exploratory actions and a value function that can silently diverge without anyone noticing. The usual arrangement is periodic offline retraining in a simulator against refreshed data, followed by shadow evaluation and a staged rollout. Cadence is set by how fast the environment moves; a drifted environment invalidates the simulator before it invalidates the policy, which means simulator revalidation is the real trigger.
- Mean predicted Q against the realized discounted return on the same axes — their divergence is the only visible symptom of value explosion, and a loss curve cannot show it
- Realized return under the deployed policy versus the incumbent, on matched scenarios rather than in aggregate
- Action distribution over time: a policy collapsing onto one action is a value function that has stopped discriminating, which looks like stability and is not
- State-distribution shift between the simulator and production, since the network extrapolates confidently in regions it never trained on
- TD-error distribution on fresh transitions, whose growth means the environment moved away from what the network learned
- Epsilon at serving, which must be zero or near it — an exploring production controller is a bug that looks like variance
- The values can diverge silently. Q-estimates climbing into the thousands with a small loss and a degrading policy raise no error anywhere, and only a prediction-against-realized-return plot exposes it
- Bootstrapping past a terminal state injects a fictional future return — the most common implementation bug in the method and one that produces a plausible-looking training curve
- A time-limit truncation is not a terminal state. Treating it as one teaches the agent that the world ends at the episode limit, and the resulting policy is visibly short-sighted
- Seed variance often exceeds the difference between algorithm variants, so a single-seed comparison is not evidence and a five-seed median is the minimum credible protocol
- Materializing stacked frames per transition multiplies buffer memory by the stack depth, which is usually why a buffer that should fit does not
- Reward scale sets gradient scale. An unclipped reward of a thousand produces a gradient large enough to destroy the network in one step, which is what Huber loss and reward clipping exist to prevent
- Learning offline from logs produced by a deterministic policy leaves the network extrapolating into actions never taken, with confident values and no data behind them
- Exploration left enabled at serving means the production policy periodically acts at random — the same trap as tabular Q-learning, with far more expensive consequences
Pros and cons, in context
Generalizes across states, which makes state spaces that no table can index tractable
This is the entire reason to accept the loss of every tabular guarantee. A network gives similar values to similar states without visiting them separately, and that is what moved value-based RL from gridworlds to raw pixels
Off-policy, so experience can be stored and reused
The replay buffer is only legal because the method is off-policy, and it is what makes DQN dramatically more sample-efficient than an on-policy method per transition collected. It is also what allows learning from logged data at all
The stabilizers are modular and each addresses a nameable pathology
Replay for correlation, a frozen target for the feedback loop, Double DQN for maximization bias, Huber for gradient magnitude, dueling for wasted capacity, prioritization for wasted samples. That decomposition is rare and it makes the method unusually teachable
The policy is free once the values exist
No separate policy network, no actor to keep synchronized with a critic, and no policy-gradient variance to control. For discrete actions this is a genuine simplification over actor-critic methods
The improvements compose better than they add
Rainbow's result — that combining the extensions substantially outperforms the sum of their individual contributions — is the practical argument for treating the full stack as the method rather than as optional refinements
No convergence guarantee, and the divergence it can suffer is silent
Bootstrapping plus off-policy sampling plus function approximation is the deadly triad and DQN does not escape it. The characteristic failure is a smoothly rising value curve, a small loss, and a policy getting steadily worse, with no error raised anywhere
Extremely sample-inefficient
Tens of millions of environment steps for tasks a person learns in minutes. This makes a fast, faithful simulator a prerequisite rather than a convenience, and it rules the method out wherever transitions are expensive or physical
Discrete actions only
The argmax over actions is the mechanism, and it does not exist for a continuous action space. Discretizing a continuous space works up to two or three dimensions and then dies to the exponential, which is why DDPG, TD3 and SAC exist
Seed variance frequently exceeds the effect being measured
Identical code and hyperparameters can produce a run that solves the task and a run that never starts. Any comparison on fewer than about five seeds is measuring the seed, which is a large fraction of published comparisons
Hyperparameter sensitivity concentrated in unintuitive places
Target update period, buffer size and reward scale matter far more than network width or depth, and none of them behaves like a familiar supervised hyperparameter. Tuning intuition transferred from supervised learning misleads here
Overestimation is built into the update
A max over noisy estimates is biased upward by construction. Double DQN reduces it substantially and does not remove it, and the residual bias is why reported value estimates should never be presented as expected returns
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.
The math, transcribed literally. No dependencies.
"""DQN - the whole method transcribed, network included.
No autodiff and no tensors: a two-layer MLP with hand-written backprop, so the
semi-gradient is visible as code. Watch for where the gradient is NOT taken -
through the target - because that omission is the entire reason none of the
usual convergence results apply here.
"""
import math
import random
def make_network(n_inputs, n_hidden, n_actions):
"""Weights as lists of lists. Small random init, never zeros: identical
hidden units receive identical gradients and stay identical forever."""
scale = 1.0 / math.sqrt(n_inputs)
return {
"w1": [[random.uniform(-scale, scale) for _ in range(n_inputs)] for _ in range(n_hidden)],
"b1": [0.0] * n_hidden,
"w2": [[random.uniform(-scale, scale) for _ in range(n_hidden)] for _ in range(n_actions)],
"b2": [0.0] * n_actions,
}
def copy_network(net):
"""The target network is a frozen snapshot of the online one. Freezing it
is what stops the target moving every time the estimate does."""
return {
"w1": [row[:] for row in net["w1"]],
"b1": net["b1"][:],
"w2": [row[:] for row in net["w2"]],
"b2": net["b2"][:],
}
def forward(net, state):
"""Returns (q_values, hidden). The hidden activations are kept because the
backward pass needs them and recomputing them would be the same work."""
hidden = []
for row, bias in zip(net["w1"], net["b1"]):
total = bias
for weight, feature in zip(row, state):
total += weight * feature
hidden.append(total if total > 0.0 else 0.0) # ReLU
q_values = []
for row, bias in zip(net["w2"], net["b2"]):
total = bias
for weight, activation in zip(row, hidden):
total += weight * activation
q_values.append(total)
return q_values, hidden
def backward(net, state, hidden, action, td_error, lr):
"""One SGD step on the squared TD error, for the taken action only.
The derivative of (y - Q(s,a))^2 with respect to theta is
-2 (y - Q(s,a)) dQ(s,a)/dtheta. But y depends on theta too, through the
target network, and that dependence is simply not differentiated. The
discarded term is what makes this a SEMI-gradient method: the direction
being descended is not the gradient of this loss or of any other function.
"""
# Only the taken action's output unit appears in the loss at all. The
# other |A| - 1 outputs get no gradient, which is why one transition
# teaches the network about exactly one action.
grad_output = -td_error
grad_hidden = [0.0] * len(hidden)
for index, weight in enumerate(net["w2"][action]):
grad_hidden[index] = grad_output * weight
net["w2"][action][index] -= lr * grad_output * hidden[index]
net["b2"][action] -= lr * grad_output
for unit in range(len(hidden)):
if hidden[unit] <= 0.0:
continue # ReLU is closed here
delta = grad_hidden[unit]
for index, feature in enumerate(state):
net["w1"][unit][index] -= lr * delta * feature
net["b1"][unit] -= lr * delta
def train(env, n_inputs, n_actions, steps=200_000, n_hidden=64, lr=1e-3,
gamma=0.99, batch_size=32, buffer_size=50_000, target_period=1_000,
warmup=1_000, epsilon_start=1.0, epsilon_end=0.05, epsilon_steps=50_000):
online = make_network(n_inputs, n_hidden, n_actions)
target = copy_network(online)
buffer = [] # (state, action, reward, next_state, done)
state = env.reset()
for step in range(steps):
fraction = min(1.0, step / epsilon_steps)
epsilon = epsilon_start + fraction * (epsilon_end - epsilon_start)
if random.random() < epsilon:
action = random.randrange(n_actions)
else:
q_values, _ = forward(online, state)
action = max(range(n_actions), key=lambda a: q_values[a])
next_state, reward, done = env.step(action)
buffer.append((state, action, reward, next_state, done))
if len(buffer) > buffer_size:
buffer.pop(0) # a ring buffer, by hand
state = env.reset() if done else next_state
# Training on a nearly empty buffer is training on one correlated
# episode, which is precisely what replay exists to prevent.
if len(buffer) < warmup:
continue
for _ in range(batch_size):
# Uniform sampling is the decorrelation mechanism. Consecutive
# transitions are almost the same sample; a network trained on
# that stream fits the last few seconds and forgets the rest.
s, a, r, s_next, terminal = random.choice(buffer)
# The bootstrap comes from the FROZEN copy. Taking it from the
# online network closes a feedback loop between the estimate and
# its own target, which is the configuration that diverges.
if terminal:
bootstrap = 0.0 # no future past a terminal
else:
next_q, _ = forward(target, s_next)
bootstrap = max(next_q)
y = r + gamma * bootstrap
q_values, hidden = forward(online, s)
td_error = y - q_values[a]
backward(online, s, hidden, a, td_error, lr)
if step % target_period == 0:
# Refresh the frozen copy. Too often and the feedback loop comes
# back; too rarely and value information stops propagating at all.
target = copy_network(online)
return onlineOne scalar multiply-accumulate per weight per sample, in the interpreter. Roughly six orders of magnitude off a real implementation.
How this language actually wants it written.
"""DQN - typed, Double-Q targets, Huber loss, truncation handled correctly."""
from __future__ import annotations
from dataclasses import dataclass
from typing import NamedTuple, Protocol
import numpy as np
import torch
from torch import Tensor, nn
class Transition(NamedTuple):
state: np.ndarray
action: int
reward: float
next_state: np.ndarray
# Terminal means the MDP ended and there is no future. Truncated means the
# episode hit a wall-clock or step limit and the future is still there.
# Collapsing the two teaches the agent the world ends at the time limit,
# and the resulting policy is visibly short-sighted.
terminal: bool
class Environment(Protocol):
def reset(self) -> np.ndarray: ...
def step(self, action: int) -> tuple[np.ndarray, float, bool, bool]: ...
@dataclass(frozen=True)
class DqnConfig:
n_inputs: int
n_actions: int
hidden: tuple[int, ...] = (128, 128)
learning_rate: float = 1e-4
gamma: float = 0.99
batch_size: int = 32
buffer_size: int = 100_000
warmup_steps: int = 5_000
target_update_period: int = 2_000
train_frequency: int = 4
grad_clip_norm: float = 10.0
huber_delta: float = 1.0
def __post_init__(self) -> None:
if not 0.0 <= self.gamma < 1.0:
raise ValueError(f"gamma must be in [0, 1), got {self.gamma}")
if self.warmup_steps < self.batch_size:
raise ValueError("warmup must exceed the batch size, or the first batch repeats samples")
if self.n_actions < 2:
raise ValueError("a single-action problem is not a control problem")
class ReplayBuffer:
"""A fixed-capacity ring over pre-allocated arrays.
A list of Python tuples reallocates, fragments, and costs a gather per
sample. One contiguous array per field costs one fancy-index per batch.
"""
def __init__(self, capacity: int, n_inputs: int) -> None:
if capacity < 1:
raise ValueError("capacity must be positive")
self._states = np.zeros((capacity, n_inputs), dtype=np.float32)
self._next_states = np.zeros((capacity, n_inputs), dtype=np.float32)
self._actions = np.zeros(capacity, dtype=np.int64)
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, transition: Transition) -> None:
index = self._cursor
self._states[index] = transition.state
self._next_states[index] = transition.next_state
self._actions[index] = transition.action
self._rewards[index] = transition.reward
self._terminals[index] = transition.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) -> tuple[Tensor, ...]:
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 (
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]),
)
def build_network(config: DqnConfig) -> nn.Module:
layers: list[nn.Module] = []
width = config.n_inputs
for size in config.hidden:
layers += [nn.Linear(width, size), nn.ReLU()]
width = size
layers.append(nn.Linear(width, config.n_actions))
return nn.Sequential(*layers)
def double_dqn_target(
online: nn.Module,
target: nn.Module,
rewards: Tensor,
next_states: Tensor,
terminals: Tensor,
gamma: float,
) -> Tensor:
"""Online network SELECTS the action, target network VALUES it.
A plain max over the target network's own outputs picks whichever action
the noise happened to favour, and that bias compounds under function
approximation into value estimates that climb without bound. Splitting
selection from evaluation makes the two sources of noise independent, so
the max can no longer systematically select for its own error.
"""
with torch.no_grad():
best_actions = online(next_states).argmax(dim=1, keepdim=True)
next_values = target(next_states).gather(1, best_actions).squeeze(1)
# Terminal transitions have no future. Truncated ones DO, which is why
# the flag stored is terminal and not done.
next_values = next_values.masked_fill(terminals, 0.0)
return rewards + gamma * next_values
def train_step(
online: nn.Module,
target: nn.Module,
optimizer: torch.optim.Optimizer,
batch: tuple[Tensor, ...],
config: DqnConfig,
) -> float:
states, actions, rewards, next_states, terminals = batch
y = double_dqn_target(online, target, rewards, next_states, terminals, config.gamma)
predicted = online(states).gather(1, actions.unsqueeze(1)).squeeze(1)
# Huber rather than MSE: one transition with an outsized TD error would
# otherwise produce a gradient large enough to destroy the network in a
# single step, and there is no recovering from that.
loss = nn.functional.smooth_l1_loss(predicted, y, beta=config.huber_delta)
optimizer.zero_grad(set_to_none=True)
loss.backward()
# A second bound on step size, for the case where a whole batch is bad.
nn.utils.clip_grad_norm_(online.parameters(), config.grad_clip_norm)
optimizer.step()
return float(loss.item())What changed: Four changes, each addressing a nameable failure rather than a style preference. Terminal and truncated become distinct flags, because collapsing them teaches the agent the world ends at the time limit. The target becomes Double DQN, so a max over noisy estimates can no longer select for its own error. The squared error becomes Huber with gradient clipping, because a single outsized TD error otherwise produces an unrecoverable step. And the buffer becomes pre-allocated contiguous arrays rather than a list of tuples, which turns per-sample gathers into one fancy-index per batch.
Three network passes per gradient step at batch size 32; one contiguous gather per batch.
Faster, and the justification for each change.
"""DQN - uint8 frame storage, index-based stacking, vectorized envs.
The two costs that actually bind are replay memory and simulator throughput.
Neither is the network, which is why almost nothing here is about the network.
"""
from __future__ import annotations
import numpy as np
import torch
from torch import Tensor
STACK = 4
class FrameRingBuffer:
"""A million transitions of stacked 84x84 frames, in about 7 GB.
Two decisions do all the work. Frames are stored as uint8 and converted at
sample time, which is a 4x saving over float32 for free - the network wants
floats, the buffer does not. And the stack is assembled by INDEX rather
than stored: consecutive transitions share three of their four frames, so
materializing the stack per transition would store every frame four times
and turn 7 GB into 28. That single distinction decides whether the buffer
fits in RAM, and it is the usual reason one does not.
"""
def __init__(self, capacity: int, height: int, width: int) -> None:
# One flat allocation per field, single dtype, written in place.
self._frames = np.zeros((capacity, height, width), dtype=np.uint8)
self._actions = np.zeros(capacity, dtype=np.int64)
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 add(self, frame: np.ndarray, action: int, reward: float, terminal: bool) -> None:
index = self._cursor
self._frames[index] = frame # in place; no per-transition allocation
self._actions[index] = action
self._rewards[index] = reward
self._terminals[index] = terminal
self._cursor = (self._cursor + 1) % self._capacity
self._size = min(self._size + 1, self._capacity)
def _stack_indices(self, index: np.ndarray) -> np.ndarray:
"""Gather STACK consecutive frame indices per sample, wrapping the ring.
One broadcast subtraction builds the whole (batch, STACK) index matrix,
so no Python loop over samples appears anywhere in the hot path.
"""
offsets = np.arange(STACK - 1, -1, -1, dtype=np.int64)
return (index[:, None] - offsets[None, :]) % self._capacity
def sample(self, batch_size: int, rng: np.random.Generator, device: torch.device):
# Avoid the cursor: a window straddling it mixes the newest transitions
# with the oldest ones and produces a state that never existed.
valid_low = STACK
index = rng.integers(valid_low, self._size - 1, size=batch_size)
states = self._frames[self._stack_indices(index)]
next_states = self._frames[self._stack_indices(index + 1)]
return (
# One transfer per field, one dtype conversion on the device rather
# than |batch| conversions on the host.
torch.from_numpy(states).to(device, non_blocking=True).float().div_(255.0),
torch.from_numpy(self._actions[index]).to(device, non_blocking=True),
torch.from_numpy(self._rewards[index]).to(device, non_blocking=True),
torch.from_numpy(next_states).to(device, non_blocking=True).float().div_(255.0),
torch.from_numpy(self._terminals[index]).to(device, non_blocking=True),
)
def n_step_returns(rewards: np.ndarray, gamma: float, n: int) -> np.ndarray:
"""Accumulate n rewards before bootstrapping, as one reversed pass.
Trades the bias of a bad value estimate for the variance of a longer
sampled return, and it is the cheapest real improvement available to a
DQN - a few lines for a change in learning speed that usually exceeds
anything gained by tuning the network.
"""
out = np.zeros_like(rewards)
running = 0.0
for position in range(len(rewards) - 1, -1, -1):
running = rewards[position] + gamma * running
out[position] = running
return out
@torch.no_grad()
def act_vectorized(
online: torch.nn.Module,
states: Tensor,
epsilon: float,
n_actions: int,
generator: torch.Generator,
) -> Tensor:
"""One forward pass covering every environment in the vector.
Simulator throughput, not gradient computation, is what bounds wall-clock
on most problems. Stepping N environments in lockstep amortizes both the
interpreter overhead and the per-call launch cost of the forward pass over
the whole batch, which is a far larger lever than a faster accelerator.
"""
greedy = online(states).argmax(dim=1)
explore = torch.rand(states.shape[0], generator=generator, device=states.device) < epsilon
random_actions = torch.randint(
n_actions, (states.shape[0],), generator=generator, device=states.device
)
return torch.where(explore, random_actions, greedy)What changed: Nothing here is about the network, because the network is not what binds. Frames are stored once as uint8 and the four-frame stack is assembled by index at sample time — consecutive transitions share three of their four frames, so materializing the stack per transition would store every frame four times and quadruple a buffer that already decides whether the job fits in RAM. Environments step in lockstep under one forward pass, because simulator throughput bounds wall-clock on most problems. And n-step returns are added, which is a few lines for more learning speed than any network tuning produces.
Ensure contiguous memory layout and a single dtype
Frames live as uint8 in one flat array and are converted to float on the device at sample time, which is a 4x memory saving over storing float32 and moves the conversion off the host.
Trade-off: Every sample pays a cast and a divide, and the buffer can only hold quantized observations — a continuous state vector cannot use this representation at all.
Pre-allocate output arrays and use in-place operations
The ring is allocated once per field and written in place, so adding a transition performs no allocation and the buffer never fragments or reallocates.
Trade-off: Capacity is fixed at construction and the cursor region must be excluded from sampling, an easily forgotten correctness obligation that produces states which never existed.
Batch work to amortize interpreter overhead
Stacking indices are built with one broadcast subtraction and every environment acts from a single forward pass, so no Python loop over samples or environments remains in the hot path.
Trade-off: Environments must step in lockstep, and the greedy action is computed for every environment including those about to explore, so a fraction of that compute is discarded.
Roughly 7 GB for 1e6 stacked-frame transitions instead of ~28 GB. Illustrative, not a measured benchmark.