DQN · Study
English translation
Execution conditions
Run the baseline Queue example with NumPy. Compare policies on seed=100…104, which was not used for training.
PyTorch is required. This is a short synthetic experiment; its configuration does not guarantee learning performance.
Public environment activities use gymnasium and stable-baselines3. gymnasium[classic-control] is required only for visualization.
If the required package is missing, run %pip install control==0.10.2 gymnasium==1.3.0 stable-baselines3==2.9.2 in a Colab or Jupyter code cell.
Topics covered
As the state space grows, verify whether replacing a Q-table with a neural network actually improves the policy.
Function approximation
DQN replaces the Q-table with a neural network. Replay reuses stored experiences, and the target network separates updates to the parameters used to form targets.
Read a public implementation
Map the buffer, learning start, target updates, input normalization, and exploration schedule as well as the policy name. Returns from different environments do not share the same performance unit.
Worked example
- Environment: Queue.
- Network: .
- Optimizer: Adam,
lr=.001. - Discount Factor: .
- Replay: 3000. Batch: 32.
- Training budget: 1800 transitions.
Compare state representations in a Q-table and a neural network on the same Queue problem, and evaluate the learned policy against rule-based policies.
"""Synthetic service queue. An action chooses capacity, including its energy cost."""
import numpy as np
class Queue:
def reset(self, seed=0):
self.rng = np.random.default_rng(seed)
self.q, self.t = 0, 0
return self.q
def step(self, action):
capacity = (0, 2, 4)[int(action)]
arriving = int(self.rng.poisson(2))
raw = max(0, self.q - capacity) + arriving
dropped = max(0, raw - 20)
self.q = min(20, raw)
self.t += 1
reward = -(self.q + .2 * capacity**2 + 10 * dropped)
return self.q, reward, False, self.t >= 100, {'queue': self.q, 'capacity': capacity, 'dropped': dropped}
def evaluate(policy, seeds=range(100, 105)):
rows = []
for seed in seeds:
env = Queue(); q = env.reset(seed)
total = waiting = energy = dropped = 0
while True:
q, r, terminated, truncated, info = env.step(policy(q))
total += r; waiting += q; energy += info['capacity']**2; dropped += info['dropped']
if terminated or truncated: break
rows.append([total, waiting / env.t, energy / env.t, dropped])
return np.array(rows)
policies = {'constant': lambda q: 1, 'threshold': lambda q: 0 if q == 0 else (1 if q < 5 else 2)}
for name, policy in policies.items():
print(name, evaluate(policy).mean(axis=0))
# Requires the Queue and evaluate definitions in the preceding cell.
import random
from collections import deque
import torch
from torch import nn
def train_dqn(seed=0, variant='dqn', steps=1800):
torch.manual_seed(seed); rng = random.Random(seed)
class Network(nn.Module):
def __init__(self):
super().__init__()
self.shared = nn.Sequential(nn.Linear(1, 32), nn.ReLU())
self.values = nn.Linear(32, 1 if variant == 'dueling' else 3)
self.advantage = nn.Linear(32, 3)
def forward(self, x):
h = self.shared(x); v = self.values(h)
if variant != 'dueling': return v
a = self.advantage(h)
return v + a - a.mean(dim=-1, keepdim=True)
online = Network(); target = Network(); target.load_state_dict(online.state_dict())
optimizer = torch.optim.Adam(online.parameters(), lr=.001)
buffer = deque(maxlen=3000); priorities = deque(maxlen=3000)
env = Queue(); q = env.reset(seed); losses = []; sample_counts = [0, 0]
def tensor(states): return torch.tensor(states, dtype=torch.float32).reshape(-1, 1) / 20
for step in range(steps):
epsilon = max(.1, 1 - step / 1000)
action = rng.randrange(3) if rng.random() < epsilon else int(online(tensor([q])).argmax())
nq, reward, terminated, truncated, _ = env.step(action)
buffer.append((q, action, reward / 20, nq, terminated))
priorities.append(max(priorities, default=1.0))
q = env.reset(seed + step + 1) if terminated or truncated else nq
if len(buffer) < 64: continue
probabilities = np.array(priorities, dtype=float)**.6
probabilities /= probabilities.sum()
if variant == 'per':
# Sampling with replacement; importance weighting uses the same probabilities.
ids = rng.choices(range(len(buffer)), weights=probabilities, k=32)
weights = (len(buffer) * probabilities[ids])**(-.4)
weights /= weights.max()
else:
ids = rng.sample(range(len(buffer)), 32); weights = np.ones(32)
bq, ba, br, bn, bt = zip(*(buffer[i] for i in ids))
predicted = online(tensor(bq)).gather(1, torch.tensor(ba).reshape(-1, 1)).flatten()
with torch.no_grad():
if variant == 'double':
selected = online(tensor(bn)).argmax(dim=1, keepdim=True)
next_value = target(tensor(bn)).gather(1, selected).flatten()
else: next_value = target(tensor(bn)).max(dim=1).values
expected = torch.tensor(br, dtype=torch.float32) + .95 * (~torch.tensor(bt)) * next_value
td = expected - predicted
loss = (torch.tensor(weights, dtype=torch.float32) * td.square()).mean()
optimizer.zero_grad(); loss.backward(); nn.utils.clip_grad_norm_(online.parameters(), 5); optimizer.step()
for i, delta in zip(ids, td.detach().numpy()): priorities[i] = abs(float(delta)) + 1e-5
losses.append(float(loss.detach()))
if step % 100 == 0: target.load_state_dict(online.state_dict())
def policy(q):
with torch.no_grad(): return int(online(tensor([q])).argmax())
return policy, losses
policy,losses=train_dqn(0,variant='dqn')
print(evaluate(policy).mean(0))
constant [-921.2 8.052 4. 3.6 ] threshold [-387.4 2.77 5.52 0. ] [-437.32 2.114 11.296 0. ]
Official documentation and data
Reference example
View more examples