Skip to content

DQN

StableTested and in active use. Breaking changes will be called out.

DQN learns a Q-network that scores every action. The agent acts greedily on those scores — with ε-greedy exploration — replays past experience, and stabilises learning with a target network. The same core powers Double, Dueling, Noisy, C51 and Rainbow.

Discrete actions only, because the update takes a max over actions.

Q-networkε-greedy policy + valueEnvironmentbatched, on GPUReplay buffer100k transitionsTarget Q-netperiodic copy of Qε-greedy actiontransition (s,a,r,s′)minibatchtarget Q(s′,·)
environment interactionlearning updatetarget network
TD targety = r + γ · max Q′(s′, a′)

Three ideas do the work.

A Q-network with ε-greedy. The network maps an observation to one value per action. The policy takes the highest, except with probability ε, when it acts randomly. ε decays from 1.0 toward ~0.05 over training.

Experience replay. Transitions go into a buffer and updates sample from it uniformly. This decorrelates consecutive samples — sequential states are nearly identical, and training on them directly is unstable — and it lets each transition contribute to many updates.

A target network. The bootstrap max Q(s′, a′) uses a frozen copy of the network rather than the network being trained. Without it, the target moves every time the weights move, and the regression chases itself.

from noeira.nn.constants import DT
from noeira.nn.combinators.sequential import Sequential
from noeira.nn.primitives.linear import Linear
from noeira.nn.primitives.activations import ReLU
from noeira.deep_agents.dqn import DQNAgent
from noeira.deep_agents.training.blocks import UniformSampleCpuStep
from noeira.envs.cartpole import CartPoleEnv
comptime OBS_DIM = 4
comptime NUM_ACTIONS = 2
comptime HIDDEN = 64
comptime BATCH = 32
comptime CAPACITY = 4_096
comptime QNet = Sequential[
Linear[OBS_DIM, HIDDEN], ReLU[HIDDEN],
Linear[HIDDEN, HIDDEN], ReLU[HIDDEN],
Linear[HIDDEN, NUM_ACTIONS],
]
def main() raises:
var agent = DQNAgent[
"cpu",
UniformSampleCpuStep[OBS_DIM, 1, BATCH, CAPACITY],
QNet,
](
lr=1e-3,
gamma=0.99,
tau=0.005,
epsilon=1.0,
epsilon_decay=0.995,
epsilon_min=0.01,
learning_starts=200,
)
var env = CartPoleEnv[DT]()
_ = agent.train(env, 2_000, print_every=500, verbose=True)
print("mean return:", agent.mean_return())
print("greedy eval:", agent.eval(CartPoleEnv[DT](), num_episodes=5))

This is examples/cartpole/cartpole_dqn_training.mojo — a 2,000-step smoke run that finishes in seconds.

DQNAgent[train_target, SAMPLE, Q_NET, DOUBLE = False]
ParameterMeaning
train_target"cpu" or "gpu" — selects the kernels
SAMPLEreplay-buffer block; carries OBS, ACT, BATCH, CAPACITY
Q_NETany Module mapping OBS_DIM → NUM_ACTIONS
DOUBLETrue enables Double DQN’s decoupled action selection

Note that the SAMPLE block, not the agent, carries the dimensions — they appear once at instantiation and flow everywhere else.

ArgumentTypicalWhat it does
lr1e-3Adam learning rate
gamma0.99discount
tau0.005Polyak coefficient for the target copy
epsilon / _decay / _min1.0 / 0.995 / 0.01exploration schedule
learning_starts200–10_000steps collected before the first update

learning_starts matters more than it looks. Updating against a nearly empty buffer means training on a handful of highly correlated transitions, which can put the network in a bad basin before real data arrives.

Swap the target string, the sample block, and add a DeviceContext:

from max.gpu.host import DeviceContext
from noeira.deep_agents.training.blocks import UniformSampleGpuStep
from noeira.deep_agents.training.batched_env import BatchedGpuDiscreteEnv
comptime N_ENVS = 64
comptime EnvT = BatchedGpuDiscreteEnv[CartPole[DT], N_ENVS, OBS_DIM, 1]
with DeviceContext() as ctx:
var agent = DQNAgent[
"gpu",
UniformSampleGpuStep[OBS_DIM, 1, 128, 100_000],
QNet,
](ctx=ctx, gamma=0.99, tau=0.005, eps_end=0.05)
_ = agent.train[EnvT, N_ENVS=N_ENVS](
EnvT(ctx), 200_000, updates_per_step=N_ENVS,
)

Every variant shares this core; each changes one piece.

VariantChange
Double DQNselect the action with the online net, evaluate it with the target — reduces overestimation
Duelingsplit the head into V(s) and A(s,a)
Noisylearned noise in the linear layers replaces ε-greedy entirely
PERsample by TD error instead of uniformly, via a sum-tree
C51predict a distribution over returns across 51 atoms instead of a mean
Rainbowall of the above at once
from noeira.deep_agents.c51 import C51Agent # C51 and Rainbow

Terminal versus truncated. The bootstrap must be masked on genuine termination but not on a time-limit cutoff. noeira exposes was_terminated() separately from done for this reason — see Traits.

Replay gather at pixel scale. With 4×84×84 observations, sampling minibatches from a large buffer dominates step time — not the forward or backward pass. Profile before optimizing the network.

Terminal window
pixi run mojo run -I . examples/cartpole/cartpole_dqn_training.mojo
pixi run mojo run -I . examples/lunar_lander/lunar_lander_dqn_training.mojo
pixi run -e nvidia mojo run -I . examples/arcade_games/rainbow_pong_training_gpu.mojo
pixi run -e nvidia mojo run -I . examples/arcade_games/rainbow_pong_pixel_training_gpu.mojo
  • SAC — the continuous-action counterpart
  • PPO — on-policy, and the structural contrast
  • Algorithms overview — the full catalogue