🧠 DQN Fundamentals
🎯 What is DQN?
DQN (Deep Q-Network), introduced by DeepMind in 2013/2015, is a landmark algorithm that combined deep learning with reinforcement learning. It learned to play Atari games from raw pixels at superhuman level, sparking the modern deep RL revolution.
Why DQN Matters
End-to-end learning: From pixels to actions. Human-level: Superhuman on 49 Atari games. Deep RL: Bridged deep learning and RL. Foundation: Basis for modern algorithms. Practical: Works on many problems. Influential: Sparked the deep RL era.
# DQN — the big picture
import torch
import torch.nn as nn
class DQN(nn.Module):
"""Deep Q-Network: maps state to Q-values."""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
"""Return Q-values for all actions."""
return self.net(state)
# DQN's three key innovations:
# 1. Deep neural network as Q-function approximator
# 2. Experience replay buffer to break correlations
# 3. Target network to stabilize training
# The DQN loss:
# L(θ) = E[(r + γ max_a' Q(s', a'; θ⁻) - Q(s, a; θ))²]
#
# Where:
# - θ: online network parameters (updated every step)
# - θ⁻: target network parameters (updated periodically)
# - (s, a, r, s'): transition from replay buffer
# Comparison with classical RL:
comparison = {
'Tabular Q-Learning': {
'state_space': 'Discrete, small',
'function': 'Lookup table',
'generalization': 'None',
'examples': 'GridWorld, small MDPs'
},
'DQN': {
'state_space': 'Continuous, high-dim',
'function': 'Neural network',
'generalization': 'Yes',
'examples': 'Atari, control tasks'
}
}
for method, info in comparison.items():
print(f"\n{method}:")
for k, v in info.items():
print(f" {k}: {v}")📊 Q-Learning Review
🎯 The Foundation
Q-Learning (Watkins, 1989) is a model-free, off-policy RL algorithm. It learns the optimal action-value function Q*(s,a) directly from experience using the Bellman optimality equation.
The Bellman Equation
Q*(s,a) = E[r + γ max_a' Q*(s', a')]. Update rule: Q(s,a) ← Q(s,a) + α[r + γ max_a' Q(s',a') - Q(s,a)]. Off-policy: Learns optimal policy regardless of behavior. Bootstrapping: Updates using its own estimates. Model-free: No environment model needed.
# Tabular Q-Learning from scratch
import numpy as np
class TabularQLearning:
"""Classical Q-learning with lookup table."""
def __init__(self, n_states, n_actions, alpha=0.1, gamma=0.99, epsilon=0.1):
self.n_states = n_states
self.n_actions = n_actions
self.alpha = alpha # Learning rate
self.gamma = gamma # Discount factor
self.epsilon = epsilon # Exploration rate
# Q-table: [n_states, n_actions]
self.Q = np.zeros((n_states, n_actions))
def select_action(self, state):
"""ε-greedy action selection."""
if np.random.random() < self.epsilon:
return np.random.randint(self.n_actions)
return np.argmax(self.Q[state])
def update(self, state, action, reward, next_state, done):
"""Q-learning update."""
# Current Q-value
current_q = self.Q[state, action]
# TD target
if done:
target = reward
else:
target = reward + self.gamma * np.max(self.Q[next_state])
# TD error
td_error = target - current_q
# Update
self.Q[state, action] += self.alpha * td_error
return td_error
def train(self, env, episodes=1000):
"""Train on environment."""
rewards_history = []
for episode in range(episodes):
state = env.reset()
if isinstance(state, tuple):
state = state[0]
total_reward = 0
done = False
while not done:
action = self.select_action(state)
next_state, reward, done, truncated, _ = env.step(action)
if isinstance(done, bool) and truncated:
done = True
self.update(state, action, reward, next_state, done)
state = next_state
total_reward += reward
rewards_history.append(total_reward)
return rewards_history
# THE PROBLEM WITH TABULAR Q-LEARNING
print("=== Tabular Q-Learning Limitations ===")
print("""
For small state spaces: Works perfectly.
For large state spaces:
- GridWorld 10×10: 100 states — manageable
- GridWorld 100×100: 10,000 states — getting large
- Chess: ~10^47 states — impossible
- Go: ~10^170 states — impossible
- Atari (210×160 pixels): 256^(210×160) states — astronomically large
Problems:
1. Memory: Table can't fit
2. Sample efficiency: Must visit every state-action pair
3. Generalization: No transfer between similar states
4. Continuous states: Infinite states
""")
# THE SOLUTION: FUNCTION APPROXIMATION
print("=== Solution: Function Approximation ===")
print("""
Instead of Q-table: Q: S × A → ℝ
Use a function: Q_θ: S × A → ℝ
The function is parameterized by θ (neural network weights).
Benefits:
✓ Generalization: Similar states → similar Q-values
✓ Memory: Fixed size regardless of state space
✓ Continuous: Handles any input
✓ Scaling: Works for complex problems
The challenge:
- Training instability
- Moving target problem
- Correlated samples
DQN solves these!
""")
# FROM TABULAR TO DEEP
print("=== Tabular to Deep ===")
print("""
Tabular Q-Learning:
Q(s, a) is a table entry
Update: Q(s,a) ← Q(s,a) + α[r + γ max Q(s',a') - Q(s,a)]
DQN:
Q(s, a; θ) is a neural network output
Update: θ ← θ - α ∇_θ L(θ)
Loss: L(θ) = E[(r + γ max Q(s',a'; θ⁻) - Q(s,a; θ))²]
Key changes:
1. Replace table with neural network
2. Replace direct update with gradient descent
3. Add experience replay
4. Add target network
""")
# KEY CONCEPTS
print("=== Key RL Concepts in DQN ===")
print("""
Off-policy:
- Behavior policy: ε-greedy (exploration)
- Target policy: greedy (exploitation)
- Can learn from old data
Bootstrapping:
- Update using Q-estimates
- Not just raw returns
- Faster learning
TD Learning:
- Temporal difference
- Learn from incomplete episodes
- Balance bias-variance
Model-free:
- No environment model
- Learn from experience
- Versatile
""")🎯 Function Approximation
🎯 Neural Networks as Q-Functions
Instead of a Q-table, DQN uses a neural network Q(s,a;θ) to approximate the Q-function. This allows generalization across similar states and handles high-dimensional inputs like images.
Network Design
Input: State (or state-action pair). Output: Q-value per action. Architecture: MLP for low-dim, CNN for images. Loss: MSE between predicted and target Q. Optimization: Gradient descent (Adam, RMSprop). Key: One forward pass gives all Q-values.
# Function approximation with neural networks
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
# 1. MLP FOR LOW-DIM STATE
class MLP_QNetwork(nn.Module):
"""Simple MLP for vector observations."""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
return self.net(state)
# 2. CNN FOR IMAGE INPUT
class CNN_QNetwork(nn.Module):
"""CNN for Atari-style image input."""
def __init__(self, n_frames, action_dim, hidden=512):
super().__init__()
# Convolutional feature extractor
self.conv = nn.Sequential(
nn.Conv2d(n_frames, 32, kernel_size=8, stride=4),
nn.ReLU(),
nn.Conv2d(32, 64, kernel_size=4, stride=2),
nn.ReLU(),
nn.Conv2d(64, 64, kernel_size=3, stride=1),
nn.ReLU(),
)
# Compute conv output size
# Input: (batch, n_frames, 84, 84)
# After convs: (batch, 64, 7, 7)
self.fc = nn.Sequential(
nn.Linear(64 * 7 * 7, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
"""state: (batch, n_frames, 84, 84)"""
features = self.conv(state)
features = features.view(features.size(0), -1)
return self.fc(features)
# 3. DUELING ARCHITECTURE (preview)
class DuelingQNetwork(nn.Module):
"""Dueling architecture: separate V and A streams."""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
# Shared feature extractor
self.features = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU()
)
# Value stream: V(s)
self.value_stream = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, 1)
)
# Advantage stream: A(s, a)
self.advantage_stream = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
features = self.features(state)
value = self.value_stream(features)
advantage = self.advantage_stream(features)
# Combine: Q(s,a) = V(s) + A(s,a) - mean_a A(s,a)
q_values = value + advantage - advantage.mean(dim=-1, keepdim=True)
return q_values
# 4. ARCHITECTURE COMPARISON
print("=== Network Architecture Guide ===")
print("""
Input Type Architecture Example
---------- ------------ -------
Vector (< 100) MLP CartPole, LunarLander
Vector (100-1000) MLP with more layers Custom control
Image CNN Atari, video
Sequence LSTM/GRU Partially observable
Graph GNN Molecules, traffic
Multi-modal Transformer Language + vision
Layer sizes:
- Small tasks: 64-128 hidden units
- Medium: 128-256
- Large: 256-512
- Very large: 512-1024
Activation:
- ReLU: Standard, fast
- Tanh: Bounded, stable
- LeakyReLU: Avoid dead neurons
- ELU: Smooth, no dead neurons
Output:
- One head per action (standard)
- Dueling: separate V and A heads
- Distributional: distribution over returns
""")
# 5. EXAMPLE: CartPole NETWORK
print("=== Example: CartPole DQN ===")
class CartPoleDQN(nn.Module):
def __init__(self, state_dim=4, action_dim=2):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, 128),
nn.ReLU(),
nn.Linear(128, 128),
nn.ReLU(),
nn.Linear(128, action_dim)
)
def forward(self, state):
return self.net(state)
# Create network
model = CartPoleDQN()
state = torch.randn(1, 4)
q_values = model(state)
print(f"State: {state.numpy()}")
print(f"Q-values: {q_values.detach().numpy()}")
print(f"Best action: {q_values.argmax().item()}")
# 6. NUMBER OF PARAMETERS
print(f"\nCartPole DQN parameters: {sum(p.numel() for p in model.parameters())}")
# 7. TARGET OUTPUT
print("\n=== Network Output Interpretation ===")
print("""
For state s, network outputs Q(s, a) for all a:
Q(s, 0) = Expected return if we take action 0
Q(s, 1) = Expected return if we take action 1
...
Policy: π(s) = argmax_a Q(s, a)
Q-values are in the same units as rewards.
Higher Q = better action.
Important: Q-values are NOT probabilities!
They can be any real number (usually related to reward scale).
""")
# 8. INITIALIZATION
print("=== Weight Initialization ===")
print("""
Good initialization matters for training stability.
Common choices:
1. Xavier/Glorot: Good for tanh
- Used in many DQN implementations
2. He/Kaiming: Good for ReLU
- Better for deep networks
3. Orthogonal: Stable training
- Used in some implementations
4. Small random: Safe default
- Ensures similar initial Q-values
DQN original paper:
- Used small random initialization
- Uniform [-0.05, 0.05]
Modern implementations:
- He initialization for ReLU
- Orthogonal for RL
Rule of thumb:
- Small initial Q-values → stable training
- Large → can cause divergence
""")
# 9. WHY THIS WORKS
print("=== Why Function Approximation Works ===")
print("""
1. GENERALIZATION
Similar states → similar Q-values
Can handle unseen states
2. COMPRESSION
Fixed parameters vs. huge table
Efficient memory usage
3. FEATURE LEARNING
Learns useful representations
End-to-end training
4. SCALING
Works with any input size
Handles images, sequences, etc.
5. SMOOTHNESS
Continuous function
No lookup needed
""")💾 Experience Replay
🎯 Breaking Correlations
Experience Replay is DQN's first key innovation. It stores transitions (s, a, r, s', done) in a buffer and samples random minibatches for training. This breaks temporal correlations and improves sample efficiency.
Why Experience Replay?
Break correlations: Sequential data is highly correlated. Sample efficiency: Reuse each transition multiple times. Smooth distribution: Random samples approximate i.i.d. Stability: Less variance in gradients. Off-policy: Learn from old data. Buffer size: Balance memory and diversity.
# Experience Replay Buffer
import numpy as np
import torch
from collections import deque
import random
class ReplayBuffer:
"""Fixed-size experience replay buffer."""
def __init__(self, capacity=100000):
self.buffer = deque(maxlen=capacity)
self.capacity = capacity
def push(self, state, action, reward, next_state, done):
"""Add transition to buffer."""
self.buffer.append((state, action, reward, next_state, done))
def sample(self, batch_size):
"""Sample random minibatch."""
batch = random.sample(self.buffer, batch_size)
states, actions, rewards, next_states, dones = zip(*batch)
return (
np.array(states, dtype=np.float32),
np.array(actions, dtype=np.int64),
np.array(rewards, dtype=np.float32),
np.array(next_states, dtype=np.float32),
np.array(dones, dtype=np.float32)
)
def __len__(self):
return len(self.buffer)
# TORCH VERSION (for GPU training)
class TorchReplayBuffer:
"""Efficient replay buffer for PyTorch."""
def __init__(self, capacity, state_dim, device='cpu'):
self.capacity = capacity
self.device = device
# Pre-allocate arrays
self.states = np.zeros((capacity, state_dim), dtype=np.float32)
self.next_states = np.zeros((capacity, state_dim), dtype=np.float32)
self.actions = np.zeros(capacity, dtype=np.int64)
self.rewards = np.zeros(capacity, dtype=np.float32)
self.dones = np.zeros(capacity, dtype=np.float32)
self.position = 0
self.size = 0
def push(self, state, action, reward, next_state, done):
"""Add transition."""
idx = self.position
self.states[idx] = state
self.actions[idx] = action
self.rewards[idx] = reward
self.next_states[idx] = next_state
self.dones[idx] = done
self.position = (self.position + 1) % self.capacity
self.size = min(self.size + 1, self.capacity)
def sample(self, batch_size):
"""Sample random minibatch."""
indices = np.random.randint(0, self.size, size=batch_size)
return (
torch.FloatTensor(self.states[indices]).to(self.device),
torch.LongTensor(self.actions[indices]).to(self.device),
torch.FloatTensor(self.rewards[indices]).to(self.device),
torch.FloatTensor(self.next_states[indices]).to(self.device),
torch.FloatTensor(self.dones[indices]).to(self.device)
)
def __len__(self):
return self.size
# PRIORITIZED EXPERIENCE REPLAY (improvement)
class PrioritizedReplayBuffer:
"""Sample important transitions more frequently."""
def __init__(self, capacity, alpha=0.6, beta=0.4):
self.capacity = capacity
self.alpha = alpha # Priority exponent
self.beta = beta # Importance sampling exponent
self.buffer = []
self.priorities = np.zeros(capacity, dtype=np.float32)
self.position = 0
self.size = 0
def push(self, state, action, reward, next_state, done, td_error=None):
"""Add transition with priority."""
# Max priority for new experiences
priority = self.priorities[:self.size].max() if self.size > 0 else 1.0
if len(self.buffer) < self.capacity:
self.buffer.append((state, action, reward, next_state, done))
else:
self.buffer[self.position] = (state, action, reward, next_state, done)
self.priorities[self.position] = priority
self.position = (self.position + 1) % self.capacity
self.size = min(self.size + 1, self.capacity)
def sample(self, batch_size):
"""Sample based on priorities."""
# Compute sampling probabilities
priorities = self.priorities[:self.size]
probs = priorities ** self.alpha
probs /= probs.sum()
# Sample indices
indices = np.random.choice(self.size, batch_size, p=probs)
# Importance sampling weights
weights = (self.size * probs[indices]) ** (-self.beta)
weights /= weights.max()
# Get batch
batch = [self.buffer[idx] for idx in indices]
states, actions, rewards, next_states, dones = zip(*batch)
return (
np.array(states, dtype=np.float32),
np.array(actions, dtype=np.int64),
np.array(rewards, dtype=np.float32),
np.array(next_states, dtype=np.float32),
np.array(dones, dtype=np.float32),
indices,
np.array(weights, dtype=np.float32)
)
def update_priorities(self, indices, td_errors):
"""Update priorities based on TD errors."""
for idx, td_error in zip(indices, td_errors):
self.priorities[idx] = abs(td_error) + 1e-6
# USAGE EXAMPLE
print("=== Experience Replay Usage ===")
buffer = ReplayBuffer(capacity=10000)
# During training:
# 1. Interact with environment
# 2. Store transition
state = np.random.randn(4)
action = 0
reward = 1.0
next_state = np.random.randn(4)
done = False
buffer.push(state, action, reward, next_state, done)
# 3. Sample random minibatch
if len(buffer) >= 32:
states, actions, rewards, next_states, dones = buffer.sample(32)
print(f"Sampled batch shape: {states.shape}")
# WHY IT WORKS
print("\n=== Why Experience Replay Works ===")
print("""
Without replay:
Train on sequential data
Highly correlated samples
Gradients have high variance
Can diverge
With replay:
Train on random minibatches
Samples ~ i.i.d.
Lower variance gradients
Stable training
Benefits:
1. Sample efficiency: 1 transition → many updates
2. Stability: Decorrelated samples
3. Generalization: Diverse experience
4. Off-policy: Learn from old data
5. Break feedback loops: Not just recent data
""")
# BUFFER SIZE CONSIDERATIONS
print("=== Buffer Size ===")
print("""
Too small:
✗ Samples too correlated
✗ Doesn't help with decorrelation
✗ Less sample reuse
Too large:
✗ Old policies contribute
✗ Slower sampling (memory)
✗ Off-policyness grows
Typical sizes:
- CartPole: 10,000 - 100,000
- Atari: 1,000,000
- Complex tasks: 1,000,000+
- Continuous control: 100,000 - 1,000,000
Rule of thumb:
10-100x larger than the longest episode
Enough to hold diverse experience
""")
# FRAME STACKING
print("=== Frame Stacking ===")
print("""
For Atari, single frames don't provide motion info.
Solution: Stack 4 consecutive frames.
State: (4, 84, 84) — channels = frames
This gives the network:
- Velocity information
- Motion direction
- Temporal context
Implementation:
frame_buffer = deque(maxlen=4)
state = np.stack(frame_buffer, axis=0)
""")
# N-STEP RETURNS
print("=== N-Step Returns ===")
print("""
Instead of 1-step TD:
target = r + γ max Q(s', a')
Use n-step:
target = r + γr' + γ²r'' + ... + γ^n max Q(s_{t+n}, a')
Benefits:
- Faster propagation of rewards
- Better credit assignment
- Trade-off with bias
DQN implementations:
- Standard DQN: 1-step
- Rainbow: 3-step (n=3)
- Ape-X: variable n-step
For most tasks:
- n=3 or n=5 works well
""")🎯 Target Network
🎯 Stabilizing the Moving Target
Target Network is DQN's second key innovation. It's a copy of the Q-network that's held fixed for a period, providing stable targets during training. This prevents the "moving target" problem.
The Moving Target Problem
Problem: Target uses Q(s',a';θ) — same θ being updated. Result: Chasing a moving target — unstable. Solution: Use separate network Q(s',a';θ⁻). Update: Copy θ to θ⁻ every C steps. Effect: Stable targets, better convergence. Trade-off: Slower adaptation but more stable.
# Target Network — the key to DQN stability
import torch
import torch.nn as nn
import numpy as np
class DQNAgentWithTarget:
"""DQN agent with target network."""
def __init__(
self,
state_dim,
action_dim,
hidden=128,
lr=1e-3,
gamma=0.99,
tau=0.005, # Soft update rate
target_update=1000, # Hard update frequency
use_soft_update=False,
device='cpu'
):
self.device = device
self.gamma = gamma
self.tau = tau
self.target_update = target_update
self.use_soft_update = use_soft_update
# Online network: Q(s, a; θ)
self.online_net = self._build_network(state_dim, action_dim, hidden).to(device)
# Target network: Q(s, a; θ⁻)
self.target_net = self._build_network(state_dim, action_dim, hidden).to(device)
# Initialize target = online
self.target_net.load_state_dict(self.online_net.state_dict())
self.target_net.eval() # Never train target directly
# Optimizer
self.optimizer = torch.optim.Adam(self.online_net.parameters(), lr=lr)
self.steps = 0
def _build_network(self, state_dim, action_dim, hidden):
return nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def select_action(self, state, epsilon=0.1):
"""ε-greedy action selection."""
if np.random.random() < epsilon:
return np.random.randint(self.online_net[-1].out_features)
with torch.no_grad():
state = torch.FloatTensor(state).unsqueeze(0).to(self.device)
q_values = self.online_net(state)
return q_values.argmax().item()
def compute_loss(self, batch):
"""Compute DQN loss with target network."""
states, actions, rewards, next_states, dones = batch
# Current Q-values: Q(s, a; θ)
q_values = self.online_net(states)
q_values = q_values.gather(1, actions.unsqueeze(1)).squeeze(1)
# Target Q-values: r + γ max_a' Q(s', a'; θ⁻)
with torch.no_grad():
next_q_values = self.target_net(next_states)
max_next_q = next_q_values.max(dim=1)[0]
targets = rewards + self.gamma * max_next_q * (1 - dones)
# Loss
loss = nn.MSELoss()(q_values, targets)
return loss
def update(self, batch):
"""One training step."""
loss = self.compute_loss(batch)
self.optimizer.zero_grad()
loss.backward()
# Gradient clipping for stability
torch.nn.utils.clip_grad_norm_(self.online_net.parameters(), 10.0)
self.optimizer.step()
# Update target network
self.steps += 1
if self.use_soft_update:
# Soft update: θ⁻ ← τθ + (1-τ)θ⁻
self.soft_update()
else:
# Hard update: θ⁻ ← θ every C steps
if self.steps % self.target_update == 0:
self.hard_update()
return loss.item()
def soft_update(self):
"""Polyak averaging: θ⁻ ← τθ + (1-τ)θ⁻"""
for target_param, online_param in zip(
self.target_net.parameters(), self.online_net.parameters()
):
target_param.data.copy_(
self.tau * online_param.data +
(1 - self.tau) * target_param.data
)
def hard_update(self):
"""Copy: θ⁻ ← θ"""
self.target_net.load_state_dict(self.online_net.state_dict())
# WHY TARGET NETWORK WORKS
print("=== The Moving Target Problem ===")
print("""
WITHOUT target network:
Target: r + γ max Q(s', a'; θ)
Update: θ ← θ - α ∇ (Q(s,a;θ) - target)²
Problem: Target depends on θ
When θ changes, target changes
Like chasing your own tail
Result: Unstable, can diverge
WITH target network:
Target: r + γ max Q(s', a'; θ⁻)
Update: θ ← θ - α ∇ (Q(s,a;θ) - target)²
Update: θ⁻ ← θ periodically
θ⁻ changes slowly or periodically
Target is stable during updates
Like supervised learning
Result: Stable, converges
""")
# UPDATE STRATEGIES
print("=== Update Strategies ===")
print("""
1. HARD UPDATE (original DQN):
Every C steps: θ⁻ ← θ
C = 1000-10000 steps typically
Pros: Simple, stable
Cons: Sudden changes, tuning C
2. SOFT UPDATE (Polyak averaging):
Every step: θ⁻ ← τθ + (1-τ)θ⁻
τ = 0.001-0.01 typically
Pros: Smooth, no sudden changes
Cons: More hyperparameters
3. EXPONENTIAL MOVING AVERAGE:
Similar to soft update
θ⁻ = (1-α)θ⁻ + αθ
Recommendations:
- Small tasks: Hard update (C=1000)
- Large tasks: Soft update (τ=0.005)
- Continuous control: Soft update
- Atari: Hard update (C=10000)
""")
# UPDATE FREQUENCY
print("=== Update Frequency ===")
print("""
Hard update frequency C:
Too small: Target changes too fast → unstable
Too large: Target too stale → slow learning
Typical values:
Atari: 10,000 steps
CartPole: 100-500 steps
General: 1,000 steps
Soft update rate τ:
Too small: Target too slow
Too large: Target too fast
Typical: 0.001 - 0.01
Rule of thumb:
Hard update: Every 1,000-10,000 steps
Soft update: Every step with τ=0.005
""")
# COMMON MISTAKES
print("=== Common Mistakes ===")
print("""
❌ Forgetting to freeze target network
Fix: Set target_net.eval() and use no_grad()
❌ Using same network for online and target
Fix: Create separate networks
❌ Not updating target network
Fix: Update every C steps or soft update
❌ Updating target with optimizer
Fix: Only copy parameters, don't optimize
❌ Initializing target randomly
Fix: target = online at start
❌ Wrong sign in target calculation
Fix: target = r + γ max Q(s', a') * (1 - done)
""")📉 Loss Function (Huber)
🎯 Stable Learning
DQN uses the Huber loss (smooth L1) instead of MSE for training. This makes learning more robust to outliers and large TD errors, which are common in RL.
Why Huber Loss?
MSE: Quadratic, sensitive to outliers. Huber: Linear for large errors, quadratic for small. Benefit: Clips large gradients. RL context: TD errors can be huge early. Gradient clipping: Built-in. Stability: Better convergence.
# Loss functions for DQN
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
# 1. MEAN SQUARED ERROR (MSE)
def mse_loss(q_values, targets):
"""Standard MSE loss: (Q - target)²"""
return F.mse_loss(q_values, targets)
# 2. HUBER LOSS (Smooth L1) — DQN's choice
def huber_loss(q_values, targets, delta=1.0):
"""
Huber loss: quadratic for |x| < δ, linear for |x| > δ
L(x) = 0.5 * x² if |x| < δ
= δ * (|x| - 0.5*δ) otherwise
"""
return F.smooth_l1_loss(q_values, targets, beta=delta)
# 3. CLIPPED MSE (alternative)
def clipped_mse_loss(q_values, targets, clip_value=1.0):
"""Clip TD errors to [-c, c] before squaring."""
errors = targets - q_values
clipped = torch.clamp(errors, -clip_value, clip_value)
return (clipped ** 2).mean()
# COMPARISON
print("=== Loss Function Comparison ===")
print("""
MSE: L(x) = x²
✓ Smooth everywhere
✓ Simple
✗ Sensitive to outliers
✗ Large gradients
✗ Can diverge
Huber: L(x) = x² if |x|<δ, δ|x| otherwise
✓ Robust to outliers
✓ Bounded gradients
✓ Stable
✗ Slightly more complex
Clipped MSE: L(x) = clip(x)²
✓ Simple
✓ Bounded
✗ Non-smooth at clip point
""")
# VISUALIZE DIFFERENCES
errors = np.linspace(-5, 5, 100)
mse_losses = errors ** 2
huber_losses = np.where(
np.abs(errors) < 1,
0.5 * errors ** 2,
np.abs(errors) - 0.5
)
print("\nError values and losses:")
print(f"{'Error':<10} {'MSE':<12} {'Huber':<12}")
print("-" * 36)
for e in [-5, -2, -1, -0.5, 0, 0.5, 1, 2, 5]:
mse = e ** 2
huber = 0.5 * e**2 if abs(e) < 1 else abs(e) - 0.5
print(f"{e:<10} {mse:<12.4f} {huber:<12.4f}")
# WHY THIS MATTERS
print("\n=== Why This Matters in RL ===")
print("""
In RL:
- Rewards can be large
- TD errors start large
- Q-values are unknown scale
With MSE:
- Large TD errors → huge gradients
- Training unstable
- Can diverge
With Huber:
- Large TD errors → clipped gradients
- Stable training
- Still accurate for small errors
The clipping threshold δ:
- δ=1: Standard in DQN
- Smaller: More robust, less accurate
- Larger: Closer to MSE
""")
# IMPLEMENTATION IN DQN
class DQNWithHuberLoss(nn.Module):
"""DQN with Huber loss."""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
return self.net(state)
def compute_loss(self, states, actions, rewards, next_states, dones,
target_net, gamma=0.99, delta=1.0):
"""Compute Huber loss."""
# Current Q-values
q_values = self.forward(states)
q_values = q_values.gather(1, actions.unsqueeze(1)).squeeze(1)
# Target Q-values
with torch.no_grad():
next_q_values = target_net(next_states)
max_next_q = next_q_values.max(dim=1)[0]
targets = rewards + gamma * max_next_q * (1 - dones)
# Huber loss
loss = F.smooth_l1_loss(q_values, targets, beta=delta)
return loss
# DQN ORIGINAL LOSS (with specific implementation detail)
print("=== Original DQN Loss ===")
print("""
From Mnih et al. (2015):
L(θ) = E[(y - Q(s, a; θ))²]
where y = r + γ max_a' Q(s', a'; θ⁻)
They clip TD errors:
|y - Q(s, a; θ)| ≤ 1
This is equivalent to Huber loss with δ=1.
Actually, they used a slightly different formulation:
- Gradient clipping at [-1, 1]
- Prevents exploding gradients
- Stable training
Modern implementations:
- Use smooth_l1_loss (Huber)
- Or clip_grad_norm_ for gradients
""")
# PRACTICAL TIPS
print("=== Practical Tips ===")
print("""
1. Use Huber loss (smooth_l1) with δ=1
- Standard for DQN
- Robust and stable
2. Clip gradients as well
- torch.nn.utils.clip_grad_norm_(params, 10.0)
- Prevents rare large updates
3. Normalize rewards
- Divide by running std
- Scale to ~[-1, 1]
- Much more stable
4. Watch for Q-value explosion
- Q-values should be bounded
- If growing → problem
- Check learning rate
5. Consider reward scaling
- Similar to normalization
- Per-environment
- Important for multi-task
""")
# REWARD NORMALIZATION
print("=== Reward Normalization ===")
class RewardNormalizer:
"""Normalize rewards using running statistics."""
def __init__(self, epsilon=1e-8, clip=10.0):
self.mean = 0.0
self.var = 1.0
self.count = 1e-4
self.epsilon = epsilon
self.clip = clip
def __call__(self, reward):
"""Normalize a single reward."""
# Update running stats
self.count += 1
delta = reward - self.mean
self.mean += delta / self.count
self.var += delta * (reward - self.mean)
# Normalize
std = np.sqrt(self.var / self.count) + self.epsilon
normalized = (reward - self.mean) / std
# Clip for safety
return np.clip(normalized, -self.clip, self.clip)
# Usage
normalizer = RewardNormalizer()
rewards = [1, 2, 3, 100, 5, 6, 7, 8, 9, 10]
print("\nReward normalization:")
for r in rewards:
normalized = normalizer(r)
print(f" {r} → {normalized:.4f}")
# ADVANCED: DISTRIBUTIONAL DQN
print("\n=== Distributional DQN (C51) ===")
print("""
Instead of single Q-value, learn distribution:
Q(s,a) = E[Z(s,a)]
where Z(s,a) is a random variable.
C51 (Categorical DQN):
- 51 atoms for distribution
- Bellman update on distribution
- Cross-entropy loss
Benefits:
- Better learning signal
- Handles uncertainty
- State-of-the-art performance
Loss: KL divergence between predicted and target distributions
""")🏗️ Network Architecture
🎯 Designing the Q-Network
The architecture of the Q-network is crucial for DQN's success. For low-dimensional states, MLPs work well. For images (like Atari), CNNs are essential. Modern improvements include dueling and distributional heads.
Architecture Choices
MLP: For vector states (CartPole, MuJoCo). CNN: For image states (Atari, pixels). Dueling: Separate V and A. Distributional: Learn distribution. Recurrent: For POMDPs. Transformer: For sequences.
# Network architectures for DQN
import torch
import torch.nn as nn
import torch.nn.functional as F
# 1. MLP FOR LOW-DIM STATE
class MLP_DQN(nn.Module):
"""Standard MLP for vector observations."""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
return self.net(state)
# 2. CNN FOR IMAGE STATE (Atari)
class CNN_DQN(nn.Module):
"""
DQN CNN for Atari.
Input: 4 frames of 84×84 grayscale
"""
def __init__(self, n_frames=4, action_dim=18, hidden=512):
super().__init__()
# Feature extractor
self.conv = nn.Sequential(
nn.Conv2d(n_frames, 32, kernel_size=8, stride=4),
nn.ReLU(),
nn.Conv2d(32, 64, kernel_size=4, stride=2),
nn.ReLU(),
nn.Conv2d(64, 64, kernel_size=3, stride=1),
nn.ReLU()
)
# Compute conv output size: 64 × 7 × 7 = 3136
self.fc = nn.Sequential(
nn.Linear(64 * 7 * 7, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
"""state: (batch, n_frames, 84, 84)"""
features = self.conv(state)
features = features.flatten(start_dim=1)
return self.fc(features)
# 3. DUELING ARCHITECTURE
class DuelingDQN(nn.Module):
"""
Dueling DQN: Separate V(s) and A(s,a) streams.
Q(s,a) = V(s) + A(s,a) - mean_a A(s,a)
"""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
# Shared feature extractor
self.features = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU()
)
# Value stream: V(s)
self.value_stream = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, 1)
)
# Advantage stream: A(s,a)
self.advantage_stream = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
features = self.features(state)
value = self.value_stream(features)
advantage = self.advantage_stream(features)
# Combine streams
q_values = value + advantage - advantage.mean(dim=-1, keepdim=True)
return q_values
# 4. NOISY NETWORK (for exploration)
class NoisyLinear(nn.Module):
"""Noisy linear layer for exploration."""
def __init__(self, in_features, out_features, sigma_init=0.5):
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.sigma_init = sigma_init
# Learnable parameters
self.weight_mu = nn.Parameter(torch.empty(out_features, in_features))
self.weight_sigma = nn.Parameter(torch.empty(out_features, in_features))
self.bias_mu = nn.Parameter(torch.empty(out_features))
self.bias_sigma = nn.Parameter(torch.empty(out_features))
self.reset_parameters()
def reset_parameters(self):
mu_range = 1 / (self.in_features ** 0.5)
self.weight_mu.data.uniform_(-mu_range, mu_range)
self.weight_sigma.data.fill_(self.sigma_init / (self.in_features ** 0.5))
self.bias_mu.data.uniform_(-mu_range, mu_range)
self.bias_sigma.data.fill_(self.sigma_init / (self.out_features ** 0.5))
def forward(self, x):
# Sample noise
if self.training:
weight_epsilon = torch.randn_like(self.weight_sigma)
bias_epsilon = torch.randn_like(self.bias_sigma)
weight = self.weight_mu + self.weight_sigma * weight_epsilon
bias = self.bias_mu + self.bias_sigma * bias_epsilon
else:
weight = self.weight_mu
bias = self.bias_mu
return F.linear(x, weight, bias)
# 5. RECURRENT DQN (for POMDPs)
class RecurrentDQN(nn.Module):
"""DQN with LSTM for partial observability."""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
self.features = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU()
)
self.lstm = nn.LSTM(hidden, hidden, batch_first=True)
self.head = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state, hidden=None):
"""
state: (batch, seq_len, state_dim) for sequences
"""
features = self.features(state)
lstm_out, hidden = self.lstm(features, hidden)
q_values = self.head(lstm_out)
return q_values, hidden
# 6. DISTRIBUTIONAL DQN (C51)
class C51_DQN(nn.Module):
"""
Distributional DQN with 51 atoms.
Instead of Q(s,a), outputs distribution over returns.
"""
def __init__(self, state_dim, action_dim, num_atoms=51, v_min=-10, v_max=10):
super().__init__()
self.action_dim = action_dim
self.num_atoms = num_atoms
self.v_min = v_min
self.v_max = v_max
# Support: discrete values
self.register_buffer(
'support',
torch.linspace(v_min, v_max, num_atoms)
)
self.net = nn.Sequential(
nn.Linear(state_dim, 128),
nn.ReLU(),
nn.Linear(128, 128),
nn.ReLU(),
nn.Linear(128, action_dim * num_atoms)
)
def forward(self, state):
"""Return distribution over returns for each action."""
logits = self.net(state)
logits = logits.view(-1, self.action_dim, self.num_atoms)
return F.softmax(logits, dim=-1)
def get_q_values(self, state):
"""Compute expected Q-values from distribution."""
probs = self.forward(state)
q_values = (probs * self.support).sum(dim=-1)
return q_values
# COMPARISON TABLE
print("=== Architecture Comparison ===")
print("""
Architecture Input Type Use Case Advantages
------------ ---------- -------- ----------
MLP Vector Control tasks Simple, fast
CNN Image Atari, pixels Image features
Dueling Any General improvement Better credit assignment
Noisy Any Exploration Learned exploration
Recurrent Sequence POMDPs Memory
C51 Any General improvement Uncertainty
Transformer Sequence Complex tasks Attention
When to use what:
- Vector state: MLP with 2-3 hidden layers
- Image state: CNN (like original DQN)
- Partial observability: Recurrent (LSTM/GRU)
- Better performance: Dueling + C51 + Noisy
- Best default: Dueling DQN with some tricks
""")
# MODERN DQN ARCHITECTURE
print("=== Modern DQN Architecture ===")
print("""
For most tasks, this works well:
Input (state_dim)
↓
Linear(state_dim, 256) + ReLU
↓
Linear(256, 256) + ReLU
↓
┌─────────────────────┐
│ Dueling Head │
│ Value: Linear(256, 1)
│ Advantage: Linear(256, action_dim)
│ Q = V + A - mean(A)
└─────────────────────┘
↓
Output (action_dim Q-values)
Improvements to add:
1. Layer normalization after each layer
2. NoisyLinear for exploration
3. C51 for distributional RL
4. Prioritized experience replay
5. N-step returns
""")
# PRACTICAL TIPS
print("=== Architecture Tips ===")
print("""
1. START SIMPLE
- 2-3 hidden layers
- 128-256 units each
- ReLU activation
2. FOR IMAGES
- CNN with 3 conv layers
- Followed by 1-2 FC layers
- Standard Atari architecture works
3. FOR CONTINUOUS CONTROL
- Deeper networks
- Layer normalization
- Careful initialization
4. NORMALIZATION
- Layer norm helps with distribution shift
- Batch norm can cause issues (batch correlations)
- Layer norm is safer
5. ACTIVATION FUNCTIONS
- ReLU: Standard, fast
- Tanh: Older, still works
- LeakyReLU: Avoid dead neurons
6. INITIALIZATION
- He/Kaiming for ReLU
- Small random for stability
7. OUTPUT
- One Q-value per action
- No activation on output (unbounded)
- Initialize to small values
""")🎲 Exploration (ε-greedy)
🎯 Balancing Exploration and Exploitation
Exploration is crucial for RL. DQN uses ε-greedy: with probability ε, take a random action; otherwise, take the greedy action. ε typically decays over time.
Exploration Strategies
ε-greedy: Random action with probability ε. Linear decay: ε from 1.0 to 0.1. Exponential: Faster decay initially. Adaptive: Adjust based on performance. Noisy nets: Learned exploration. Boltzmann: Sample from softmax over Q.
# Exploration strategies for DQN
import numpy as np
import torch
# 1. ε-GREEDY EXPLORATION
class EpsilonGreedy:
"""ε-greedy exploration with decay."""
def __init__(self, start=1.0, end=0.01, decay=0.995, decay_type='exponential'):
self.epsilon = start
self.start = start
self.end = end
self.decay = decay
self.decay_type = decay_type
def select_action(self, q_values, n_actions):
"""Select action using ε-greedy."""
if np.random.random() < self.epsilon:
return np.random.randint(n_actions)
return np.argmax(q_values)
def update(self):
"""Decay epsilon."""
if self.decay_type == 'exponential':
self.epsilon = max(self.end, self.epsilon * self.decay)
elif self.decay_type == 'linear':
self.epsilon = max(self.end, self.epsilon - self.decay)
def set_epsilon(self, value):
self.epsilon = value
# 2. EXPONENTIAL DECAY
def exponential_decay(step, start=1.0, end=0.01, decay_steps=10000):
"""Exponential decay over steps."""
if step >= decay_steps:
return end
fraction = step / decay_steps
return start * (end / start) ** fraction
# 3. LINEAR DECAY
def linear_decay(step, start=1.0, end=0.01, decay_steps=100000):
"""Linear decay over steps."""
if step >= decay_steps:
return end
return start - (start - end) * (step / decay_steps)
# 4. ADAPTIVE ε-GREEDY
class AdaptiveEpsilon:
"""
Adjust epsilon based on performance.
Increase if performance drops.
"""
def __init__(self, initial=1.0, min_eps=0.01, max_eps=1.0):
self.epsilon = initial
self.min_eps = min_eps
self.max_eps = max_eps
self.recent_rewards = []
def update(self, reward):
self.recent_rewards.append(reward)
if len(self.recent_rewards) > 100:
self.recent_rewards.pop(0)
# Simple heuristic: increase exploration if rewards are low
if len(self.recent_rewards) >= 50:
avg_reward = np.mean(self.recent_rewards[-50:])
if avg_reward < 0:
self.epsilon = min(self.max_eps, self.epsilon + 0.01)
else:
self.epsilon = max(self.min_eps, self.epsilon - 0.001)
# 5. BOLTZMANN EXPLORATION (softmax)
def boltzmann_exploration(q_values, temperature=1.0):
"""
Sample action from softmax over Q-values.
P(a) = exp(Q(s,a)/τ) / Σ exp(Q(s,a')/τ)
"""
q_values = np.array(q_values)
# Subtract max for numerical stability
q_values = q_values - q_values.max()
# Softmax with temperature
probs = np.exp(q_values / temperature)
probs /= probs.sum()
return np.random.choice(len(q_values), p=probs)
# 6. ENTROPY REGULARIZATION
def entropy_bonus(q_values, temperature=1.0):
"""
Add entropy bonus to encourage exploration.
L = -Q(s,a) - β * H(π)
"""
q_values = np.array(q_values)
q_values = q_values - q_values.max()
probs = np.exp(q_values / temperature)
probs /= probs.sum()
# Entropy
entropy = -np.sum(probs * np.log(probs + 1e-8))
return entropy
# COMPARISON OF STRATEGIES
print("=== Exploration Strategies Comparison ===")
print("""
Strategy Pros Cons
-------- ---- ----
ε-greedy Simple, works well Random actions in bad states
Linear decay Predictable Needs tuning of steps
Exponential decay Fast initial learning Sensitive to decay rate
Boltzmann Smooth exploration Temperature tuning
Noisy networks Learned exploration More complex
Thompson sampling Optimal exploration Complex, slow
UCB Efficient Hard in DQN
""")
# DECAY SCHEDULES
print("=== Decay Schedules ===")
print("""
DQN original (Atari):
- ε: 1.0 → 0.1 over 1,000,000 steps
- Linear decay
- Then constant at 0.1
Typical for CartPole:
- ε: 1.0 → 0.01 over 10,000-50,000 steps
- Exponential decay (0.995 per step)
For continuous control:
- ε: 1.0 → 0.1
- Or use Gaussian noise (for continuous actions)
Rule of thumb:
- Start: 1.0 (fully random)
- End: 0.01-0.1 (mostly greedy)
- Decay steps: 10-20% of total training
""")
# SCHEDULE COMPARISON
print("\n=== Schedule Comparison (per step) ===")
print(f"{'Step':<10} {'Linear':<12} {'Exponential':<12}")
print("-" * 36)
for step in [0, 1000, 5000, 10000, 50000]:
lin = linear_decay(step, decay_steps=100000)
exp = exponential_decay(step, decay_steps=10000)
print(f"{step:<10} {lin:<12.4f} {exp:<12.4f}")
# PRACTICAL TIPS
print("\n=== Practical Tips ===")
print("""
1. ALWAYS DECAY ε
- Constant ε = poor performance
- Decay to ~0.01-0.1
- Balance exploration vs exploitation
2. USE EXPONENTIAL FOR SHORT TASKS
- Reaches low ε faster
- Good for CartPole, LunarLander
3. USE LINEAR FOR LONG TASKS
- More predictable
- Standard for Atari
4. EVALUATE WITHOUT EXPLORATION
- ε=0 for evaluation
- Clean performance measurement
5. CONSIDER NOISY NETWORKS
- Learned exploration
- State-dependent noise
- Better than ε-greedy
6. FOR CONTINUOUS ACTIONS
- Gaussian noise on actions
- Or Ornstein-Uhlenbeck noise
- No ε-greedy (actions are continuous)
7. ADAPTIVE METHODS
- Rarely used in practice
- Can help in specific cases
- Simpler decay is usually fine
""")
# COMMON MISTAKES
print("=== Common Mistakes ===")
print("""
❌ Not decaying ε
→ Policy stays random forever
❌ Decaying too fast
→ Premature convergence, poor exploration
❌ Decaying too slow
→ Wastes time on random actions
❌ Using ε-greedy in evaluation
→ Wrong performance measurement
❌ Not resetting ε for continued training
→ Should start high again
❌ Same ε for all problems
→ Tune per task
""")🎓 Training Loop
🎯 Putting It All Together
The DQN training loop combines all components: interaction with environment, storing transitions, sampling batches, computing loss, and updating networks. Getting the loop right is essential for success.
The Training Loop
1. Interact: Take action, observe reward, next state. 2. Store: Push transition to replay buffer. 3. Sample: Get random minibatch. 4. Compute: Loss = Huber(Q - target). 5. Update: Gradient descent step. 6. Sync: Periodically copy to target. 7. Repeat.
# Complete DQN training loop
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
import random
from collections import deque
import gym
class DQNAgent:
"""Complete DQN agent."""
def __init__(
self,
state_dim,
action_dim,
hidden=128,
lr=1e-3,
gamma=0.99,
buffer_size=100000,
batch_size=64,
target_update=1000,
epsilon_start=1.0,
epsilon_end=0.01,
epsilon_decay=0.995,
device='cpu'
):
self.state_dim = state_dim
self.action_dim = action_dim
self.gamma = gamma
self.batch_size = batch_size
self.target_update = target_update
self.device = device
# Networks
self.online_net = self._build_network(state_dim, action_dim, hidden).to(device)
self.target_net = self._build_network(state_dim, action_dim, hidden).to(device)
self.target_net.load_state_dict(self.online_net.state_dict())
self.target_net.eval()
# Optimizer
self.optimizer = optim.Adam(self.online_net.parameters(), lr=lr)
# Replay buffer
self.replay_buffer = deque(maxlen=buffer_size)
# Exploration
self.epsilon = epsilon_start
self.epsilon_end = epsilon_end
self.epsilon_decay = epsilon_decay
# Counters
self.steps = 0
self.episodes = 0
def _build_network(self, state_dim, action_dim, hidden):
return nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def select_action(self, state, evaluate=False):
"""Select action using ε-greedy."""
if evaluate or np.random.random() > self.epsilon:
with torch.no_grad():
state_tensor = torch.FloatTensor(state).unsqueeze(0).to(self.device)
q_values = self.online_net(state_tensor)
return q_values.argmax().item()
else:
return np.random.randint(self.action_dim)
def store_transition(self, state, action, reward, next_state, done):
"""Store transition in replay buffer."""
self.replay_buffer.append((state, action, reward, next_state, done))
def sample_batch(self):
"""Sample random minibatch."""
batch = random.sample(self.replay_buffer, self.batch_size)
states, actions, rewards, next_states, dones = zip(*batch)
return (
torch.FloatTensor(np.array(states)).to(self.device),
torch.LongTensor(actions).to(self.device),
torch.FloatTensor(rewards).to(self.device),
torch.FloatTensor(np.array(next_states)).to(self.device),
torch.FloatTensor(dones).to(self.device)
)
def update(self):
"""One training step."""
if len(self.replay_buffer) < self.batch_size:
return None
states, actions, rewards, next_states, dones = self.sample_batch()
# Current Q-values
q_values = self.online_net(states)
q_values = q_values.gather(1, actions.unsqueeze(1)).squeeze(1)
# Target Q-values
with torch.no_grad():
next_q_values = self.target_net(next_states)
max_next_q = next_q_values.max(dim=1)[0]
targets = rewards + self.gamma * max_next_q * (1 - dones)
# Loss (Huber)
loss = nn.SmoothL1Loss()(q_values, targets)
# Update
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.online_net.parameters(), 10.0)
self.optimizer.step()
# Update target network
self.steps += 1
if self.steps % self.target_update == 0:
self.target_net.load_state_dict(self.online_net.state_dict())
return loss.item()
def decay_epsilon(self):
"""Decay exploration rate."""
self.epsilon = max(self.epsilon_end, self.epsilon * self.epsilon_decay)
# TRAINING LOOP
def train_dqn(env_name='CartPole-v1', n_episodes=500):
"""Complete training loop."""
env = gym.make(env_name)
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
agent = DQNAgent(state_dim, action_dim)
rewards_history = []
for episode in range(n_episodes):
state = env.reset()
if isinstance(state, tuple):
state = state[0]
episode_reward = 0
done = False
while not done:
# Select and execute action
action = agent.select_action(state)
next_state, reward, done, truncated, _ = env.step(action)
if isinstance(done, bool) and truncated:
done = True
# Store transition
agent.store_transition(state, action, reward, next_state, done)
# Update network
loss = agent.update()
# Move to next state
state = next_state
episode_reward += reward
# Episode complete
agent.episodes += 1
agent.decay_epsilon()
rewards_history.append(episode_reward)
# Logging
if episode % 20 == 0:
avg_reward = np.mean(rewards_history[-20:])
print(f"Episode {episode}, "
f"Reward: {episode_reward:.1f}, "
f"Avg (last 20): {avg_reward:.1f}, "
f"ε: {agent.epsilon:.3f}")
if avg_reward > 475:
print(f"Solved in {episode} episodes!")
break
env.close()
return agent, rewards_history
# RUN TRAINING
if __name__ == "__main__":
try:
agent, rewards = train_dqn()
except Exception as e:
print(f"Training demo: {e}")
# KEY TRAINING CONSIDERATIONS
print("\n=== Training Best Practices ===")
print("""
1. WARMUP
- Don't train until buffer has enough samples
- Fill buffer with random actions first
- Usually 1,000-10,000 steps
2. UPDATE FREQUENCY
- Update every step (after warmup)
- Or every N steps
- More updates = faster learning
3. BATCH SIZE
- 32-256 typically
- Larger = more stable
- Smaller = faster, noisier
4. TARGET UPDATE
- Hard: every 1,000-10,000 steps
- Soft: every step with τ=0.005
- Choose based on task
5. GRADIENT CLIPPING
- Clip norm to 10.0
- Prevents exploding gradients
- Essential for stability
6. REWARD SCALING
- Normalize rewards to [-1, 1]
- Or divide by running std
- Important for stability
7. EVALUATION
- Separate eval loop
- ε=0 (no exploration)
- Every N episodes
8. MONITORING
- Track: reward, loss, Q-values, ε
- Q-values should be stable
- Loss should decrease
""")
# DEBUGGING CHECKLIST
print("=== Debugging DQN ===")
print("""
If DQN isn't working:
1. CHECK THE BASICS
□ Replay buffer working?
□ Target network being updated?
□ ε decaying properly?
□ Rewards being stored correctly?
2. CHECK THE NETWORK
□ Q-values reasonable scale?
□ Loss decreasing?
□ Gradients not exploding?
□ Network output shape correct?
3. CHECK HYPERPARAMETERS
□ Learning rate not too high?
□ Batch size reasonable?
□ Target update frequency?
□ γ appropriate for task?
4. CHECK THE ENVIRONMENT
□ Rewards shaped well?
□ Episode length reasonable?
□ Action space correct?
5. COMMON ISSUES
- Q-values exploding → reduce lr, clip gradients
- Policy not improving → increase training
- Unstable training → use target network properly
- Slow learning → check batch size, lr
""")🚀 DQN Improvements
🎯 Beyond Vanilla DQN
Vanilla DQN works but has limitations. A series of improvements make it more stable, sample-efficient, and performant. These range from small tweaks to major architectural changes.
Key Improvements
Double DQN: Reduces overestimation. Dueling DQN: Better credit assignment. Prioritized replay: Sample important transitions. Multi-step: Faster reward propagation. Noisy nets: Learned exploration. Distributional: Learn uncertainty. Rainbow: All combined.
# DQN improvements — overview and code
import torch
import torch.nn as nn
import numpy as np
# 1. DOUBLE DQN (reduces overestimation)
class DoubleDQN(nn.Module):
"""
Fixes overestimation bias in vanilla DQN.
Vanilla: target = r + γ max_a Q(s', a; θ⁻)
Double: target = r + γ Q(s', argmax_a Q(s', a; θ); θ⁻)
"""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
return self.net(state)
def compute_target(self, next_states, rewards, dones, target_net, gamma=0.99):
with torch.no_grad():
# Select action using ONLINE network
next_q_online = self.forward(next_states)
next_actions = next_q_online.argmax(dim=1)
# Evaluate using TARGET network
next_q_target = target_net(next_states)
next_q = next_q_target.gather(1, next_actions.unsqueeze(1)).squeeze(1)
targets = rewards + gamma * next_q * (1 - dones)
return targets
# 2. DUELING DQN (better credit assignment)
class DuelingDQN(nn.Module):
"""
Separates V(s) and A(s,a).
Q(s,a) = V(s) + A(s,a) - mean_a A(s,a)
"""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
self.features = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU()
)
self.value = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, 1)
)
self.advantage = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
features = self.features(state)
value = self.value(features)
advantage = self.advantage(features)
q_values = value + advantage - advantage.mean(dim=1, keepdim=True)
return q_values
# 3. PRIORITIZED EXPERIENCE REPLAY
class PrioritizedReplayBuffer:
"""Sample transitions based on TD error magnitude."""
def __init__(self, capacity, alpha=0.6, beta_start=0.4, beta_frames=100000):
self.capacity = capacity
self.alpha = alpha
self.beta = beta_start
self.beta_start = beta_start
self.beta_frames = beta_frames
self.frame = 0
self.buffer = []
self.priorities = np.zeros(capacity)
self.position = 0
def push(self, transition, td_error=None):
# New experiences have max priority
if td_error is None:
priority = self.priorities.max() if len(self.buffer) > 0 else 1.0
else:
priority = abs(td_error) + 1e-6
if len(self.buffer) < self.capacity:
self.buffer.append(transition)
else:
self.buffer[self.position] = transition
self.priorities[self.position] = priority
self.position = (self.position + 1) % self.capacity
def sample(self, batch_size):
# Update beta for importance sampling
self.frame += 1
self.beta = min(1.0, self.beta_start + (1 - self.beta_start) * self.frame / self.beta_frames)
# Compute sampling probabilities
priorities = self.priorities[:len(self.buffer)] ** self.alpha
probs = priorities / priorities.sum()
# Sample
indices = np.random.choice(len(self.buffer), batch_size, p=probs)
samples = [self.buffer[i] for i in indices]
# Importance sampling weights
weights = (len(self.buffer) * probs[indices]) ** (-self.beta)
weights = weights / weights.max()
return samples, indices, weights
def update_priorities(self, indices, td_errors):
for idx, td_error in zip(indices, td_errors):
self.priorities[idx] = abs(td_error) + 1e-6
# 4. N-STEP RETURNS
def compute_n_step_target(rewards, next_states, dones, target_net, gamma=0.99, n=3):
"""
n-step return:
R^(n) = r_t + γr_{t+1} + ... + γ^(n-1)r_{t+n-1} + γ^n max Q(s_{t+n})
"""
n_step_rewards = []
current_reward = 0
discount = 1.0
for i in range(n):
if i < len(rewards):
current_reward += discount * rewards[i]
discount *= gamma
if dones[i]:
break
# Bootstrap from n-th state
with torch.no_grad():
next_q = target_net(next_states[-1]).max(dim=1)[0]
target = current_reward + discount * next_q
return target
# 5. NOISY NETWORKS (learned exploration)
class NoisyLinear(nn.Module):
"""Noisy linear layer for exploration."""
def __init__(self, in_features, out_features, sigma_init=0.017):
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.weight_mu = nn.Parameter(torch.empty(out_features, in_features))
self.weight_sigma = nn.Parameter(torch.empty(out_features, in_features))
self.bias_mu = nn.Parameter(torch.empty(out_features))
self.bias_sigma = nn.Parameter(torch.empty(out_features))
self.register_buffer('weight_epsilon', torch.empty(out_features, in_features))
self.register_buffer('bias_epsilon', torch.empty(out_features))
# Initialize
self.weight_mu.data.uniform_(-0.1, 0.1)
self.weight_sigma.data.fill_(sigma_init)
self.bias_mu.data.uniform_(-0.1, 0.1)
self.bias_sigma.data.fill_(sigma_init)
def forward(self, x):
if self.training:
self.weight_epsilon.normal_()
self.bias_epsilon.normal_()
weight = self.weight_mu + self.weight_sigma * self.weight_epsilon
bias = self.bias_mu + self.bias_sigma * self.bias_epsilon
else:
weight = self.weight_mu
bias = self.bias_mu
return nn.functional.linear(x, weight, bias)
# 6. CATEGORICAL DQN (C51 — distributional)
class CategoricalDQN(nn.Module):
"""Distributional DQN with 51 atoms."""
def __init__(self, state_dim, action_dim, num_atoms=51, v_min=-10, v_max=10):
super().__init__()
self.action_dim = action_dim
self.num_atoms = num_atoms
self.v_min = v_min
self.v_max = v_max
self.register_buffer('support', torch.linspace(v_min, v_max, num_atoms))
self.net = nn.Sequential(
nn.Linear(state_dim, 128),
nn.ReLU(),
nn.Linear(128, 128),
nn.ReLU(),
nn.Linear(128, action_dim * num_atoms)
)
def forward(self, state):
logits = self.net(state)
logits = logits.view(-1, self.action_dim, self.num_atoms)
return nn.functional.softmax(logits, dim=-1)
# COMBINING IMPROVEMENTS: RAINBOW
print("=== Rainbow DQN ===")
print("""
Rainbow (Hessel et al., 2018) combines 6 improvements:
1. Double DQN
- Reduces overestimation
2. Dueling DQN
- Better credit assignment
3. Prioritized Experience Replay
- Sample important transitions
4. Multi-step Returns
- Faster reward propagation
5. Distributional DQN (C51)
- Learn distribution over returns
6. Noisy Networks
- Learned exploration
Ablation study showed:
- All 6 contribute
- Biggest gains: prioritized replay + multi-step
- Smaller gains: dueling + distributional
Rainbow vs. individual:
- Rainbow > sum of parts
- More stable training
- Better final performance
""")
# IMPROVEMENT EFFECTS
print("\n=== Improvement Effects (Atari) ===")
print(f"{'Method':<25} {'Median Score':<15} {'Notes':<30}")
print("-" * 70)
print(f"{'DQN (baseline)':<25} {'~200%':<15} {'Human-level':<30}")
print(f"{'+Double DQN':<25} {'~300%':<15} {'Reduced overestimation':<30}")
print(f"{'+Dueling':<25} {'~400%':<15} {'Better credit assignment':<30}")
print(f"{'+Prioritized':<25} {'~500%':<15} {'Sample efficiency':<30}")
print(f"{'+Multi-step':<25} {'~600%':<15} {'Faster propagation':<30}")
print(f"{'+Noisy Nets':<25} {'~650%':<15} {'Learned exploration':<30}")
print(f"{'+Distributional':<25} {'~750%':<15} {'Uncertainty aware':<30}")
print(f"{'Rainbow (all)':<25} {'~1000%':<15} {'State-of-the-art':<30}")
# IMPLEMENTATION ORDER
print("\n=== Implementation Order ===")
print("""
If you're implementing from scratch:
1. Start with VANILLA DQN
- Get it working first
- Basic replay buffer
- Target network
- Huber loss
2. Add DOUBLE DQN
- Simple change to target computation
- Immediate improvement
3. Add DUELING DQN
- Change network architecture
- Small code change
4. Add PRIORITIZED REPLAY
- More complex
- Bigger gains
5. Add MULTI-STEP RETURNS
- Small change
- Good gains
6. Add NOISY NETS
- Replace linear layers
- Remove ε-greedy
7. Add DISTRIBUTIONAL
- Major change
- Largest single gain
Or: Just use RAINBOW
- All improvements
- Proven to work
- More complex to implement
""")🔀 Double DQN
🎯 Reducing Overestimation
Double DQN (van Hasselt et al., 2016) fixes the overestimation bias in vanilla DQN. It's a simple change with significant impact: use online network to select actions, target network to evaluate them.
The Overestimation Problem
Vanilla DQN: target = r + γ max_a Q(s',a;θ⁻). Problem: max of noisy estimates is biased upward. Result: Q-values inflated, suboptimal policy. Double DQN: Decouple selection and evaluation. Formula: target = r + γ Q(s', argmax_a Q(s',a;θ); θ⁻). Impact: More accurate Q-values.
# Double DQN — fixing overestimation bias
import torch
import torch.nn as nn
import numpy as np
# THE OVERESTIMATION PROBLEM
print("=== The Overestimation Problem ===")
print("""
Why does vanilla DQN overestimate?
Consider a state where all actions have true Q = 0.
But our estimates are noisy: Q_est(a) ~ N(0, ε²)
Vanilla target: max_a Q_est(a)
Expected value: E[max_a Q_est(a)] > max_a E[Q_est(a)] = 0
The max operation picks the action with the highest noise,
so the estimate is biased upward!
This bias:
- Grows with the number of actions
- Compounds over iterations
- Leads to suboptimal policies
Double DQN fixes this by decoupling:
- Action selection: online network
- Action evaluation: target network
""")
# DEMONSTRATION
np.random.seed(42)
n_actions = 10
n_samples = 10000
true_q = np.zeros(n_actions)
noisy_estimates = np.random.randn(n_samples, n_actions) * 0.5
# Vanilla: max over noisy estimates
vanilla_max = noisy_estimates.max(axis=1).mean()
# Double: pick action with online, evaluate with target (independent noise)
online_estimates = np.random.randn(n_samples, n_actions) * 0.5
target_estimates = np.random.randn(n_samples, n_actions) * 0.5
best_actions = online_estimates.argmax(axis=1)
double_estimates = target_estimates[np.arange(n_samples), best_actions]
double_max = double_estimates.mean()
print(f"True Q-value: 0.0")
print(f"Vanilla DQN: {vanilla_max:.3f} (overestimate)")
print(f"Double DQN: {double_max:.3f} (unbiased!)")
# DOUBLE DQN IMPLEMENTATION
class DoubleDQNAgent:
"""Double DQN agent."""
def __init__(
self,
state_dim,
action_dim,
hidden=128,
lr=1e-3,
gamma=0.99
):
self.gamma = gamma
# Online network
self.online_net = self._build_net(state_dim, action_dim, hidden)
# Target network
self.target_net = self._build_net(state_dim, action_dim, hidden)
self.target_net.load_state_dict(self.online_net.state_dict())
self.target_net.eval()
self.optimizer = torch.optim.Adam(self.online_net.parameters(), lr=lr)
def _build_net(self, state_dim, action_dim, hidden):
return nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def compute_target(self, next_states, rewards, dones):
"""
Compute Double DQN target:
target = r + γ Q(s', argmax_a Q(s', a; θ); θ⁻)
"""
with torch.no_grad():
# Step 1: Select action using ONLINE network
next_q_online = self.online_net(next_states)
next_actions = next_q_online.argmax(dim=1)
# Step 2: Evaluate action using TARGET network
next_q_target = self.target_net(next_states)
next_q = next_q_target.gather(
1, next_actions.unsqueeze(1)
).squeeze(1)
# Step 3: Compute target
targets = rewards + self.gamma * next_q * (1 - dones)
return targets
def compute_loss(self, batch):
"""Compute Double DQN loss."""
states, actions, rewards, next_states, dones = batch
# Current Q-values
q_values = self.online_net(states)
q_values = q_values.gather(1, actions.unsqueeze(1)).squeeze(1)
# Double DQN target
targets = self.compute_target(next_states, rewards, dones)
# Huber loss
loss = nn.SmoothL1Loss()(q_values, targets)
return loss
def update(self, batch, target_update_freq=1000, step=0):
"""Update networks."""
loss = self.compute_loss(batch)
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.online_net.parameters(), 10.0)
self.optimizer.step()
# Update target
if step % target_update_freq == 0:
self.target_net.load_state_dict(self.online_net.state_dict())
return loss.item()
# COMPARISON
print("\n=== Comparison ===")
print("""
Vanilla DQN:
Selection: max_a Q(s', a; θ⁻)
Evaluation: Q(s', max_action; θ⁻)
Same network for both → coupled → biased
Double DQN:
Selection: argmax_a Q(s', a; θ)
Evaluation: Q(s', selected_action; θ⁻)
Different networks → decoupled → unbiased
Implementation difference:
VANILLA:
next_q = target_net(next_states).max(dim=1)[0]
DOUBLE:
next_actions = online_net(next_states).argmax(dim=1)
next_q = target_net(next_states).gather(1, next_actions.unsqueeze(1))
""")
# WHY IT WORKS
print("\n=== Why Double DQN Works ===")
print("""
Mathematical intuition:
Let Q_est(s,a) = Q_true(s,a) + ε_a
where ε_a is noise (mean 0, variance σ²)
Vanilla:
max_a Q_est(s,a) = max_a [Q_true(s,a) + ε_a]
The max picks the largest ε (on average)
→ overestimation bias of order σ√(2 log n)
Double:
a* = argmax_a Q_online(s,a)
Q_target(s,a*)
Since ε_online and ε_target are independent:
E[Q_target(s,a*)] = Q_true(s,a*) (unbiased!)
The key insight:
Noise in argmax doesn't matter if evaluation is independent.
""")
# RESULTS
print("\n=== Empirical Results ===")
print("""
Atari results (median human-normalized score):
DQN: ~200%
Double DQN: ~300% (50% improvement!)
Benefits:
- More accurate Q-values
- Better policies
- More stable learning
- Simple change (few lines of code)
- Works with other improvements
When it helps most:
- Large action spaces
- Noisy environments
- Long training runs
Cost:
- Same as vanilla DQN
- No extra parameters
- No extra computation
- Almost free improvement
""")
# DOUBLE DQN IN PRACTICE
print("\n=== Double DQN in Practice ===")
print("""
USE DOUBLE DQN ALWAYS!
- No downside
- Simple to implement
- Improves performance
- Standard in modern DQN
Combined with:
- Dueling architecture
- Prioritized replay
- Multi-step returns
- Distributional RL
Part of Rainbow:
- One of 6 key components
- Small but important gain
Real-world impact:
- Better Q-value estimates
- More accurate value predictions
- Better decisions
- Used in production systems
""")🏛️ Dueling DQN
🎯 Better Credit Assignment
Dueling DQN (Wang et al., 2016) separates the Q-function into a state value V(s) and an advantage A(s,a). This helps the network learn which states are valuable without having to learn the effect of each action.
The Dueling Architecture
Two streams: Value V(s) and Advantage A(s,a). Combine: Q(s,a) = V(s) + A(s,a) - mean_a A(s,a). Why subtract mean: Identifiability — forces unique solution. Benefit: Better credit assignment. Result: Learns faster, more accurate. Intuition: Some states are good regardless of action.
# Dueling DQN — separating value and advantage
import torch
import torch.nn as nn
import torch.nn.functional as F
# THE DUELING ARCHITECTURE
class DuelingDQN(nn.Module):
"""
Dueling DQN: separate value and advantage streams.
Q(s,a) = V(s) + A(s,a) - mean_a A(s,a)
"""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
# Shared feature extractor
self.features = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU()
)
# Value stream: V(s)
self.value_stream = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, 1)
)
# Advantage stream: A(s,a)
self.advantage_stream = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
"""Compute Q-values using dueling architecture."""
features = self.features(state)
# Value V(s)
value = self.value_stream(features)
# Advantage A(s,a)
advantage = self.advantage_stream(features)
# Combine: Q(s,a) = V(s) + A(s,a) - mean_a A(s,a)
q_values = value + advantage - advantage.mean(dim=-1, keepdim=True)
return q_values
def get_value_and_advantage(self, state):
"""Return V(s) and A(s,a) separately for analysis."""
features = self.features(state)
value = self.value_stream(features)
advantage = self.advantage_stream(features)
return value, advantage
# WHY THE MEAN SUBTRACTION?
print("=== Why Subtract the Mean? ===")
print("""
Without mean subtraction:
Q(s,a) = V(s) + A(s,a)
Problem: Not identifiable!
V(s) + A(s,a) = V(s) + c + A(s,a) - c
Multiple solutions give the same Q-values
With mean subtraction:
Q(s,a) = V(s) + A(s,a) - mean_a A(s,a)
This forces: mean_a A(s,a) = 0
Result:
- Unique decomposition
- V(s) = mean_a Q(s,a) (average value)
- A(s,a) = Q(s,a) - V(s) (relative advantage)
Alternative (max subtraction):
Q(s,a) = V(s) + A(s,a) - max_a A(s,a)
This forces: max_a A(s,a) = 0
- Q(s, best_action) = V(s)
- V(s) = max value over actions
Both work. Mean is more common.
""")
# VISUALIZING THE STREAMS
print("\n=== Visualizing Value and Advantage ===")
print("""
Example: Playing a video game
Consider two states:
State A: You're about to win
V(s_A) = HIGH (this state is good)
A(s_A, each action) = similar (any action wins)
State B: You're in a tight spot
V(s_B) = LOW (this state is bad)
A(s_B, actions) = vary a lot (some actions help, some hurt)
Dueling DQN learns this structure:
- Value stream: How good is this state?
- Advantage stream: How much better is each action?
This decomposition helps:
1. Learn V(s) even without action info
2. Better generalize across similar states
3. Faster learning on state-only rewards
""")
# COMPARISON WITH STANDARD DQN
print("=== Dueling vs Standard DQN ===")
print("""
Standard DQN:
features → Q(s,a) directly
Network must learn Q(s,a) for each action
Dueling DQN:
features → V(s) and A(s,a) separately
Combine into Q(s,a)
Advantages of Dueling:
1. Better credit assignment
2. Learns state values efficiently
3. More robust to action-space changes
4. Can learn from state-only rewards
Results:
- Improved performance on Atari
- Better on tasks with many actions
- Faster learning
- Part of Rainbow
""")
# COMPARISON CODE
def compare_architectures():
"""Compare standard and dueling DQN."""
class StandardDQN(nn.Module):
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
return self.net(state)
state_dim = 8
action_dim = 4
standard = StandardDQN(state_dim, action_dim)
dueling = DuelingDQN(state_dim, action_dim)
# Count parameters
standard_params = sum(p.numel() for p in standard.parameters())
dueling_params = sum(p.numel() for p in dueling.parameters())
print(f"\nParameter comparison:")
print(f" Standard DQN: {standard_params:,}")
print(f" Dueling DQN: {dueling_params:,}")
print(f" Difference: {dueling_params - standard_params:,}")
compare_architectures()
# DUELING WITH PRIORITIZED REPLAY
class DuelingAgent:
"""Complete dueling DQN agent."""
def __init__(self, state_dim, action_dim, hidden=128, lr=1e-3):
self.online_net = DuelingDQN(state_dim, action_dim, hidden)
self.target_net = DuelingDQN(state_dim, action_dim, hidden)
self.target_net.load_state_dict(self.online_net.state_dict())
self.target_net.eval()
self.optimizer = torch.optim.Adam(self.online_net.parameters(), lr=lr)
def compute_loss(self, batch, gamma=0.99):
"""Compute loss with dueling network."""
states, actions, rewards, next_states, dones = batch
# Current Q-values (using dueling forward)
q_values = self.online_net(states)
q_values = q_values.gather(1, actions.unsqueeze(1)).squeeze(1)
# Target using Double DQN trick
with torch.no_grad():
next_actions = self.online_net(next_states).argmax(dim=1)
next_q = self.target_net(next_states).gather(
1, next_actions.unsqueeze(1)
).squeeze(1)
targets = rewards + gamma * next_q * (1 - dones)
loss = nn.SmoothL1Loss()(q_values, targets)
return loss
# ADVANCED: DUELING WITH ATTENTION
class DuelingWithAttention(nn.Module):
"""Dueling DQN with attention over state features."""
def __init__(self, state_dim, action_dim, hidden=128):
super().__init__()
# Feature extractor
self.features = nn.Sequential(
nn.Linear(state_dim, hidden),
nn.ReLU()
)
# Attention
self.attention = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, 1),
nn.Sigmoid()
)
# Value stream
self.value_stream = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, 1)
)
# Advantage stream
self.advantage_stream = nn.Sequential(
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, action_dim)
)
def forward(self, state):
features = self.features(state)
attention_weights = self.attention(features)
weighted_features = features * attention_weights
value = self.value_stream(weighted_features)
advantage = self.advantage_stream(weighted_features)
q_values = value + advantage - advantage.mean(dim=-1, keepdim=True)
return q_values
# RESULTS AND IMPACT
print("\n=== Dueling DQN Results ===")
print("""
Atari results:
- Improved median score by ~100% over vanilla DQN
- Better on games with many actions
- More stable training
Specific games where dueling shines:
- Seaquest (multi-action underwater)
- Asterix (complex action space)
- Zaxxon (3D isometric)
- Beam Rider (multi-action)
Why it helps:
1. Separates state value from action choice
2. Better credit assignment
3. More efficient learning
Part of Rainbow:
- One of 6 improvements
- Combined with others gives best results
""")
# PRACTICAL TIPS
print("\n=== Practical Tips ===")
print("""
1. USE DUELING BY DEFAULT
- No significant downside
- Improves performance
- Small code change
2. COMBINE WITH OTHER IMPROVEMENTS
- Double DQN: use both
- Prioritized replay: works well
- Multi-step returns: good
- Noisy nets: complementary
3. AGGREGATION FUNCTION
- Mean: Standard, works well
- Max: Alternative, sometimes better
- Softmax: Rarely used
4. NETWORK SIZE
- Dueling doesn't need bigger network
- Same architecture size
- Just different structure
5. FOR CONTINUOUS ACTIONS
- Dueling less critical
- SAC has similar ideas
- Focus on other techniques
6. DEBUGGING
- Check V and A ranges
- A should center near 0
- V should be similar to Q mean
""")
# FULL DUELING ARCHITECTURE WITH IMPROVEMENTS
print("\n=== Full Dueling DQN Architecture ===")
print("""
Input: state (state_dim)
↓
Linear(state_dim, 256) + ReLU
↓
Linear(256, 256) + ReLU
↓
┌─────────────────────────────────────┐
│ │
▼ ▼
Value Stream Advantage Stream
Linear(256, 256) + ReLU Linear(256, 256) + ReLU
Linear(256, 1) Linear(256, action_dim)
↓ ↓
V(s) A(s,a)
│ │
└─────────────┬─────────────────────┘
↓
Q = V + A - mean(A)
↓
Q-values
Additions for better performance:
- NoisyLinear instead of Linear (exploration)
- LayerNorm after linear (stability)
- Distributional head (uncertainty)
- Shared features before streams (efficiency)
""")🌈 Rainbow DQN
🎯 All the Improvements Combined
Rainbow (Hessel et al., 2018) combines 6 key DQN improvements into one algorithm: Double DQN, Dueling DQN, Prioritized Replay, Multi-step Returns, Distributional DQN, and Noisy Nets.
The 6 Components
Double DQN: Reduce overestimation. Dueling DQN: Separate V and A. Prioritized: Important transitions. Multi-step: n-step returns. Distributional: C51. Noisy: Learned exploration. Result: State-of-the-art.
# Rainbow DQN — combining all improvements
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
# 1. NOISY LINEAR (for exploration)
class NoisyLinear(nn.Module):
def __init__(self, in_features, out_features, sigma_init=0.5):
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.sigma_init = sigma_init
# Learnable parameters
self.weight_mu = nn.Parameter(torch.empty(out_features, in_features))
self.weight_sigma = nn.Parameter(torch.empty(out_features, in_features))
self.bias_mu = nn.Parameter(torch.empty(out_features))
self.bias_sigma = nn.Parameter(torch.empty(out_features))
self.reset_parameters()
def reset_parameters(self):
mu_range = 1 / (self.in_features ** 0.5)
self.weight_mu.data.uniform_(-mu_range, mu_range)
self.weight_sigma.data.fill_(self.sigma_init / (self.in_features ** 0.5))
self.bias_mu.data.uniform_(-mu_range, mu_range)
self.bias_sigma.data.fill_(self.sigma_init / (self.out_features ** 0.5))
def forward(self, x):
if self.training:
weight_epsilon = torch.randn_like(self.weight_sigma)
bias_epsilon = torch.randn_like(self.bias_sigma)
weight = self.weight_mu + self.weight_sigma * weight_epsilon
bias = self.bias_mu + self.bias_sigma * bias_epsilon
else:
weight = self.weight_mu
bias = self.bias_mu
return F.linear(x, weight, bias)
# 2. RAINBOW NETWORK (Distributional + Dueling + Noisy)
class RainbowNetwork(nn.Module):
"""
Rainbow network combining:
- Dueling architecture
- Distributional RL (C51)
- Noisy networks
"""
def __init__(
self,
state_dim,
action_dim,
num_atoms=51,
v_min=-10,
v_max=10,
hidden=256,
noisy_std=0.5
):
super().__init__()
self.action_dim = action_dim
self.num_atoms = num_atoms
self.v_min = v_min
self.v_max = v_max
# Register support
self.register_buffer(
'support',
torch.linspace(v_min, v_max, num_atoms)
)
# Shared feature layers (NoisyLinear)
self.features = nn.Sequential(
NoisyLinear(state_dim, hidden, noisy_std),
nn.ReLU(),
NoisyLinear(hidden, hidden, noisy_std),
nn.ReLU()
)
# Dueling: Value stream
self.value_stream = nn.Sequential(
NoisyLinear(hidden, hidden, noisy_std),
nn.ReLU(),
NoisyLinear(hidden, num_atoms, noisy_std)
)
# Dueling: Advantage stream
self.advantage_stream = nn.Sequential(
NoisyLinear(hidden, hidden, noisy_std),
nn.ReLU(),
NoisyLinear(hidden, action_dim * num_atoms, noisy_std)
)
def forward(self, state):
"""
Forward pass returns probability distribution over atoms.
Returns:
probs: (batch, action_dim, num_atoms) — distribution
"""
batch_size = state.size(0)
features = self.features(state)
# Value stream: (batch, num_atoms)
value = self.value_stream(features)
value = value.view(batch_size, 1, self.num_atoms)
# Advantage stream: (batch, action_dim, num_atoms)
advantage = self.advantage_stream(features)
advantage = advantage.view(batch_size, self.action_dim, self.num_atoms)
# Combine: Q = V + A - mean(A)
q_atoms = value + advantage - advantage.mean(dim=1, keepdim=True)
# Convert to probabilities
probs = F.softmax(q_atoms, dim=-1)
probs = probs.clamp(min=1e-3) # Avoid zero probabilities
return probs
def get_q_values(self, state):
"""Compute expected Q-values from distribution."""
probs = self.forward(state)
q_values = (probs * self.support).sum(dim=-1)
return q_values
# 3. PRIORITIZED REPLAY BUFFER
class PrioritizedReplayBuffer:
"""Prioritized experience replay."""
def __init__(self, capacity, alpha=0.6, beta_start=0.4, beta_frames=100000):
self.capacity = capacity
self.alpha = alpha
self.beta = beta_start
self.beta_start = beta_start
self.beta_frames = beta_frames
self.frame = 0
self.buffer = []
self.priorities = np.zeros(capacity)
self.position = 0
def push(self, transition, td_error=None):
if td_error is None:
priority = self.priorities.max() if len(self.buffer) > 0 else 1.0
else:
priority = abs(td_error) + 1e-6
if len(self.buffer) < self.capacity:
self.buffer.append(transition)
else:
self.buffer[self.position] = transition
self.priorities[self.position] = priority
self.position = (self.position + 1) % self.capacity
def sample(self, batch_size):
self.frame += 1
self.beta = min(1.0, self.beta_start +
(1 - self.beta_start) * self.frame / self.beta_frames)
priorities = self.priorities[:len(self.buffer)] ** self.alpha
probs = priorities / priorities.sum()
indices = np.random.choice(len(self.buffer), batch_size, p=probs)
samples = [self.buffer[i] for i in indices]
weights = (len(self.buffer) * probs[indices]) ** (-self.beta)
weights = weights / weights.max()
return samples, indices, weights
def update_priorities(self, indices, td_errors):
for idx, td_error in zip(indices, td_errors):
self.priorities[idx] = abs(td_error) + 1e-6
# 4. RAINBOW AGENT
class RainbowAgent:
"""Complete Rainbow DQN agent."""
def __init__(
self,
state_dim,
action_dim,
num_atoms=51,
v_min=-10,
v_max=10,
lr=6.25e-5,
gamma=0.99,
n_step=3,
buffer_size=1000000,
batch_size=32,
target_update=8000
):
self.action_dim = action_dim
self.num_atoms = num_atoms
self.v_min = v_min
self.v_max = v_max
self.gamma = gamma
self.n_step = n_step
self.batch_size = batch_size
self.target_update = target_update
# Networks
self.online_net = RainbowNetwork(
state_dim, action_dim, num_atoms, v_min, v_max
)
self.target_net = RainbowNetwork(
state_dim, action_dim, num_atoms, v_min, v_max
)
self.target_net.load_state_dict(self.online_net.state_dict())
self.target_net.eval()
# Optimizer
self.optimizer = torch.optim.Adam(self.online_net.parameters(), lr=lr)
# Replay buffer
self.replay_buffer = PrioritizedReplayBuffer(buffer_size)
# N-step buffer
self.n_step_buffer = []
self.steps = 0
def store_transition(self, state, action, reward, next_state, done):
"""Store transition with n-step returns."""
self.n_step_buffer.append((state, action, reward, next_state, done))
if len(self.n_step_buffer) < self.n_step:
return
# Compute n-step return
R = 0
for i, (_, _, r, _, d) in enumerate(self.n_step_buffer):
R += (self.gamma ** i) * r
if d:
break
# Get first state and action
first_state = self.n_step_buffer[0][0]
first_action = self.n_step_buffer[0][1]
# Get last next_state
last_next_state = self.n_step_buffer[-1][3]
last_done = self.n_step_buffer[-1][4]
# Store n-step transition
self.replay_buffer.push(
(first_state, first_action, R, last_next_state, last_done)
)
# Remove first element
self.n_step_buffer.pop(0)
def select_action(self, state, evaluate=False):
"""Select action (uses noisy nets in training)."""
with torch.no_grad():
state_tensor = torch.FloatTensor(state).unsqueeze(0)
q_values = self.online_net.get_q_values(state_tensor)
return q_values.argmax().item()
def update(self):
"""One training step."""
if len(self.replay_buffer.buffer) < self.batch_size:
return None
# Sample prioritized batch
batch, indices, weights = self.replay_buffer.sample(self.batch_size)
weights = torch.FloatTensor(weights)
states, actions, rewards, next_states, dones = zip(*batch)
states = torch.FloatTensor(np.array(states))
actions = torch.LongTensor(actions)
rewards = torch.FloatTensor(rewards)
next_states = torch.FloatTensor(np.array(next_states))
dones = torch.FloatTensor(dones)
# Forward pass
dist = self.online_net(states) # (batch, action_dim, num_atoms)
dist = dist[range(len(actions)), actions] # (batch, num_atoms)
# Compute target distribution (Distributional + Double DQN)
with torch.no_grad():
# Double DQN: select action with online net
next_q_online = self.online_net.get_q_values(next_states)
next_actions = next_q_online.argmax(dim=1)
# Evaluate with target net
next_dist = self.target_net(next_states)
next_dist = next_dist[range(len(next_actions)), next_actions]
# Project distribution
target_dist = self.project_distribution(
next_dist, rewards, dones
)
# Loss: KL divergence
loss = -(target_dist * torch.log(dist + 1e-8)).sum(dim=1)
loss = (loss * weights).mean()
# Update
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.online_net.parameters(), 10.0)
self.optimizer.step()
# Update priorities
with torch.no_grad():
td_errors = (target_dist - dist).abs().sum(dim=1).numpy()
self.replay_buffer.update_priorities(indices, td_errors)
# Update target network
self.steps += 1
if self.steps % self.target_update == 0:
self.target_net.load_state_dict(self.online_net.state_dict())
return loss.item()
def project_distribution(self, next_dist, rewards, dones):
"""
Project the target distribution onto the support.
This is the C51 projection step.
"""
batch_size = rewards.size(0)
support = self.online_net.support
num_atoms = self.num_atoms
# Compute target support: r + γ * support
target_support = rewards.unsqueeze(1) + \
self.gamma * support.unsqueeze(0) * \
(1 - dones.unsqueeze(1))
target_support = target_support.clamp(self.v_min, self.v_max)
# Compute bin width
delta_z = (self.v_max - self.v_min) / (num_atoms - 1)
# Project
b = (target_support - self.v_min) / delta_z
l = b.floor().long()
u = b.ceil().long()
# Handle edge cases
l = l.clamp(0, num_atoms - 1)
u = u.clamp(0, num_atoms - 1)
# Distribute probability
target_dist = torch.zeros_like(next_dist)
offset = torch.arange(batch_size).unsqueeze(1) * num_atoms
target_dist.view(-1).index_add_(
0, (l + offset).view(-1),
(next_dist * (u.float() - b)).view(-1)
)
target_dist.view(-1).index_add_(
0, (u + offset).view(-1),
(next_dist * (b - l.float())).view(-1)
)
return target_dist
# RAINBOW COMPONENTS SUMMARY
print("=== Rainbow Components ===")
print("""
1. Double DQN
- Reduces overestimation
- Online selects, target evaluates
2. Dueling DQN
- Separate V and A
- Q = V + A - mean(A)
3. Prioritized Replay
- Sample by TD error
- Importance sampling weights
4. Multi-step Returns (n=3)
- R = Σ γ^i r_i
- Faster propagation
5. Distributional (C51, n_atoms=51)
- Learn distribution
- KL loss
6. Noisy Networks
- Learned exploration
- Replaces ε-greedy
Result: State-of-the-art DQN
""")
# RAINBOW RESULTS
print("=== Rainbow Results ===")
print("""
Atari benchmark (57 games):
Method Median Score Mean Score
------------- ------------ ----------
DQN ~200% ~230%
Double DQN ~300% ~330%
Prioritized DQN ~400% ~430%
Dueling DQN ~400% ~470%
A3C ~500% ~620%
Rainbow ~1000% ~1200%
Rainbow is dramatically better
- State-of-the-art at publication
- Still competitive today
- Reference for DQN improvements
Ablation study showed:
- All 6 components contribute
- Prioritized replay: biggest individual gain
- Multi-step: second biggest
- Distributional: third
- Dueling: moderate
- Double: small but consistent
- Noisy: good for final performance
""")
# IMPLEMENTATION TIPS
print("\n=== Implementation Tips ===")
print("""
1. START WITH VANILLA DQN
- Make sure it works first
- Then add improvements
2. ADD IN THIS ORDER
- Double DQN (easy)
- Dueling (easy)
- Multi-step (easy)
- Prioritized (medium)
- Noisy (easy)
- Distributional (hardest)
3. HYPERPARAMETERS
- n_step = 3
- num_atoms = 51
- v_min = -10, v_max = 10
- lr = 6.25e-5
- Adam optimizer
- target_update = 8000
- batch_size = 32
- buffer_size = 1M
- alpha = 0.5 (prioritization)
- beta = 0.4 → 1.0
4. COMMON ISSUES
- Distributional projection bugs
- Numerical stability
- Priority explosion
- Memory usage
5. MONITORING
- Q-value distribution
- Loss evolution
- Priority distribution
- TD errors
""")
# CONCLUSION
print("\n=== Conclusion ===")
print("""
Rainbow is the culmination of DQN research.
It demonstrates:
- Careful engineering matters
- Multiple improvements compound
- Theory-informed design works
- Benchmark results are reproducible
Learning path:
1. Understand vanilla DQN
2. Learn each improvement
3. See how they combine
4. Implement Rainbow
5. Adapt to your problem
Beyond Rainbow:
- R2D2 (recurrent + distributed)
- Agent57 (all improvements + more)
- Efficient Rainbow variants
- Modern algorithms (SAC, Dreamer)
But the principles from Rainbow remain:
- Double DQN: decouple selection/evaluation
- Dueling: separate V and A
- Prioritized: focus on important transitions
- Multi-step: bootstrap faster
- Distributional: model uncertainty
- Noisy: learned exploration
""")🚀 Future: Beyond DQN
🎯 The Next Generation
DQN was just the beginning. Modern value-based methods like R2D2, Agent57, and offline RL algorithms have pushed performance further. And policy-based methods now dominate many domains.
Future Directions
R2D2: Recurrent + distributed. Agent57: Beyond human on all Atari. Offline RL: Learn from fixed data. Transformer RL: Sequence modeling. Model-based: DreamerV3. Foundation models: Gato, RT-2. Real-world: Deployment at scale.
# Beyond DQN — modern algorithms
import numpy as np
# 1. R2D2 (Recurrent Replay Distributed DQN)
print("=== R2D2 ===")
print("""
R2D2 (Kapturowski et al., 2019):
- Combines DQN with LSTM
- Distributed training
- Prioritized replay
- n-step returns
- Double DQN
Key innovations:
1. Recurrent network (LSTM) for memory
2. Distributed experience collection
3. Stored hidden states in replay
4. Burn-in for sequence initialization
5. Prioritized replay with sequence
Results:
- State-of-the-art on Atari
- Beyond human on 52/57 games
- Handles partially observable tasks
Architecture:
Input → Conv → LSTM → Dueling heads
Training:
- Parallel actors
- Central learner
- Replay buffer of sequences
""")
# R2D2 pseudocode
print("--- R2D2 Architecture ---")
print("""
class R2D2Network(nn.Module):
def __init__(self):
self.conv = CNN()
self.lstm = LSTM(hidden_size=512)
self.value = Linear(512, 1)
self.advantage = Linear(512, action_dim)
def forward(self, state, hidden):
features = self.conv(state)
lstm_out, new_hidden = self.lstm(features, hidden)
value = self.value(lstm_out)
advantage = self.advantage(lstm_out)
q = value + advantage - advantage.mean()
return q, new_hidden
# Store sequences in replay
# Sample sequences, not individual transitions
# Use burn-in for LSTM initialization
""")
# 2. AGENT57
print("\n=== Agent57 ===")
print("""
Agent57 (Badia et al., 2020):
- First algorithm to beat humans on ALL 57 Atari games
- Combines many techniques:
* R2D2 architecture
* Never Give Up (NGU) exploration
* Intrinsic rewards
* Meta-controller
* Population of agents
Key insight:
- Different games need different strategies
- Adapt exploration per task
- Population-based training
Complexity:
- 57 separate policies
- Meta-controller selects per episode
- Training takes millions of steps
Result:
- Superhuman on all Atari games
- Breakthrough result
- Shows value of exploration
""")
# 3. OFFLINE RL
print("=== Offline RL ===")
print("""
Learn from fixed dataset without interaction:
Methods:
1. BCQ (Batch-Constrained Q-Learning)
2. CQL (Conservative Q-Learning)
3. IQL (Implicit Q-Learning)
4. TD3+BC
5. Decision Transformer
6. AWAC
Key challenges:
- Distribution shift
- Extrapolation error
- Uncertainty
Applications:
- Healthcare (patient data)
- Robotics (demonstrations)
- Recommendation
- Autonomous driving
- Finance
Online vs Offline:
Online: Interact, learn, explore
Offline: Fixed data, no exploration
Offline: Safer, but harder
""")
# 4. TRANSFORMER RL
print("=== Transformer RL ===")
print("""
Decision Transformer (Chen et al., 2021):
- Treat RL as sequence modeling
- Input: (R, s, a, R, s, a, ...)
- Output: next action
- Train with supervised learning
Benefits:
- No TD learning
- No bootstrapping
- Simple, stable
- Long context
Extensions:
- Trajectory Transformer
- Multi-game Decision Transformer
- Gato (multi-modal)
- RT-2 (robotics)
Power:
- Leverages transformer scaling
- Combines with LLM capabilities
- Foundation for general agents
""")
# Decision Transformer pseudocode
print("--- Decision Transformer ---")
print("""
# Input: trajectory as sequence
# (R_0, s_0, a_0, R_1, s_1, a_1, ..., R_T, s_T, a_T)
# Goal: predict a_t given (R_t, s_t, history)
def decision_transformer_forward(trajectory):
# Embed each token
R_emb = embed_return(R)
s_emb = embed_state(s)
a_emb = embed_action(a)
# Interleave tokens
tokens = interleave(R_emb, s_emb, a_emb)
# Transformer
hidden = transformer(tokens)
# Predict actions
action_pred = action_head(hidden)
return action_pred
# Train with cross-entropy on actions
# At test time, condition on target return
""")
# 5. MODEL-BASED RL
print("=== Model-Based RL ===")
print("""
Learn environment model, plan with it:
DreamerV3 (Hafner et al., 2023):
- Learns latent world model
- Trains actor-critic in imagination
- Sample efficient
- General across domains
Architecture:
1. Encoder: state → latent
2. Dynamics: (latent, action) → next latent
3. Reward: latent → reward
4. Decoder: latent → state
5. Actor-Critic: trained in imagination
Benefits:
- 10-100x more sample efficient
- Planning capability
- Transfer learning
Results:
- Minecraft diamond from scratch
- 150+ Atari games
- Various robotics tasks
- General algorithm
""")
# DreamerV3 pseudocode
print("--- DreamerV3 ---")
print("""
# Learn world model
for each iteration:
# Collect data with current policy
data = collect_episodes(env, policy)
# Update world model
for _ in range(wm_updates):
batch = sample(data)
update_encoder_decoder(batch)
update_dynamics(batch)
update_reward(batch)
# Train actor-critic in imagination
for _ in range(ac_updates):
# Imagine trajectories
latent = sample_state()
for t in range(horizon):
action = actor(latent)
latent = dynamics(latent, action)
reward = reward_model(latent)
# Update actor and critic using imagined returns
update_actor_critic(imagined_trajectories)
""")
# 6. FOUNDATION MODELS FOR RL
print("=== Foundation Models for RL ===")
print("""
Gato (Reed et al., 2022):
- Single model for many tasks
- Text, images, robot control
- Same transformer architecture
- Trained on diverse data
RT-2 (Google DeepMind):
- Vision-Language-Action model
- Robot control via LLM
- Leverages internet data
- Generalization to new tasks
Foundation policies:
- Pretrain on diverse tasks
- Fine-tune for specific
- Transfer learning
- Generalization
Applications:
- Robotics (RT-1, RT-2, Octo)
- Games (multi-game DT)
- Manipulation
- Navigation
- Dialogue
Future:
- Unified RL + LLM
- Language-conditioned policies
- Embodied AI
- General agents
""")
# 7. KEY CHALLENGES
print("=== Open Challenges ===")
print("""
1. SAMPLE EFFICIENCY
- Real-world data expensive
- Need faster learning
- Model-based methods help
2. GENERALIZATION
- New tasks, environments
- Distribution shift
- Foundation models address
3. SAFETY
- Constrained optimization
- Provable guarantees
- Human oversight
4. SCALABILITY
- Large state/action spaces
- Long horizons
- Multi-agent systems
5. STABILITY
- Hyperparameter sensitivity
- Non-stationarity
- Training dynamics
6. INTERPRETABILITY
- Understanding policies
- Explaining decisions
- Building trust
7. EVALUATION
- Off-policy evaluation
- Safety metrics
- Real-world impact
""")
# 8. WHERE TO GO FROM HERE
print("=== Where to Go From Here ===")
print("""
If you've mastered DQN:
1. LEARN POLICY GRADIENT METHODS
- REINFORCE, PPO, SAC
- Better for continuous control
- Foundation of modern RL
2. EXPLORE ADVANCED VARIANTS
- R2D2, Agent57
- IQN, FQF (distributional)
- QR-DQN
3. TRY MODEL-BASED
- DreamerV3, MuZero
- Sample efficiency
- Planning capability
4. STUDY OFFLINE RL
- CQL, IQL, TD3+BC
- Real-world applications
- Safety guarantees
5. EXPLORE TRANSFORMER RL
- Decision Transformer
- Gato, RT-2
- Foundation models
6. APPLY TO REAL PROBLEMS
- Games
- Robotics
- Control
- Recommendation
7. CONTRIBUTE TO RESEARCH
- Open problems
- New algorithms
- Benchmarks
Recommended resources:
- Sutton & Barto (textbook)
- Spinning Up (OpenAI)
- CleanRL (implementations)
- Deep RL course (Berkeley)
- Papers with Code
""")
# CONCLUSION
print("\n=== Conclusion ===")
print("""
DQN was a breakthrough but not the end.
Evolution of value-based methods:
2013: DQN
2015: Nature DQN (with target network)
2016: Double DQN, Dueling DQN
2017: Prioritized Replay, Distributional
2018: Rainbow
2019: R2D2
2020: Agent57
2021: Decision Transformer
2022: Gato, RT-1
2023: DreamerV3, RT-2
2024+: Foundation models for RL
Each generation:
- Better performance
- More general
- More sample efficient
- More aligned with human values
The future of RL:
- Multi-modal foundation models
- Real-world deployment
- Safe, aligned agents
- Human-AI collaboration
DQN taught us:
- Deep learning + RL works
- Stability requires careful design
- Sample efficiency matters
- Benchmarks drive progress
Now go build the next breakthrough!
""")