Deep Reinforcement Learning • Part 3 of 4
📝Draft

Dueling Networks

Separating state value from action advantage

Dueling Networks

Standard DQN learns Q-values directly: given a state and action, output a single number representing expected return. But this conflates two distinct questions: “How good is this state?” and “How much does my action choice matter?”

Dueling Networks separate these questions with a clever architecture change. By decomposing Q-values into state value and action advantage, the network can learn more efficiently, especially in states where the action choice barely matters.

The Value-Advantage Decomposition

📖Advantage Function

The advantage function A(s,a)A(s, a) measures how much better action aa is compared to the average action in state ss: A(s,a)=Q(s,a)V(s)A(s, a) = Q(s, a) - V(s) where V(s)=maxaQ(s,a)V(s) = \max_a Q(s, a) (under a greedy policy) or V(s)=Eπ[Q(s,a)]V(s) = \mathbb{E}_\pi[Q(s, a)] (under policy π\pi).

Think about driving a car:

State value V(s): “I’m on an open highway with no traffic. Things are going well.” This doesn’t depend on whether you’re about to turn left, right, or go straight.

Advantage A(s, a): “Turning right here leads to my destination faster than going straight.” This compares specific actions.

Q-value Q(s, a): “If I turn right from this highway position, here’s my expected total reward.”

The key insight: V(s) captures the “baseline goodness” of a state, while A(s, a) captures how actions differ from that baseline.

Why separate them?

In many states, the action choice barely matters:

  • In Breakout, when the ball is far from the paddle, all actions are roughly equivalent
  • In navigation, when you’re in an open area far from obstacles, any direction is fine
  • In games, when you have an overwhelming advantage, most moves win

If we learn V(s) separately, we can update it from every experience, even when actions don’t matter. Standard DQN would struggle to learn anything useful in these states.

Mathematical Details

The fundamental decomposition: Q(s,a)=V(s)+A(s,a)Q(s, a) = V(s) + A(s, a)

By definition, advantages sum to zero under the optimal policy (since V(s)=maxaQ(s,a)V(s) = \max_a Q(s, a)): maxaA(s,a)=0\max_a A(s, a) = 0

Or under an average interpretation: Ea[A(s,a)]=0\mathbb{E}_a[A(s, a)] = 0

This decomposition is always valid, the question is whether it’s useful to learn V and A separately rather than Q directly.

Why it helps learning:

Consider a state where all actions give similar returns. Standard DQN must learn: Q(s,a1)Q(s,a2)Q(s,a3)...10.0Q(s, a_1) \approx Q(s, a_2) \approx Q(s, a_3) \approx ... \approx 10.0

The dueling architecture can instead learn: V(s)=10.0,A(s,ai)0 for all aiV(s) = 10.0, \quad A(s, a_i) \approx 0 \text{ for all } a_i

The value stream learns the important information (state is worth 10.0) while the advantage stream learns the less important information (actions are roughly equivalent).

The Dueling Architecture

The dueling architecture is surprisingly simple:

Input State
     |
[Shared Convolutional Layers]
     |
[Shared Dense Layer(s)]
     |
     +--------------------+
     |                    |
[Value Stream]    [Advantage Stream]
     |                    |
  V(s) [1]         A(s,a) [|A|]
     |                    |
     +--------+----------+
              |
         [Combine]
              |
        Q(s,a) [|A|]

Two streams process the shared features:

  1. Value stream: Outputs a single number V(s)
  2. Advantage stream: Outputs |A| numbers, one per action

Then we combine them to get Q-values.

📌Dueling Network for CartPole

For CartPole (4-dimensional state, 2 actions):

Standard DQN:

State [4] -> Dense [128] -> ReLU -> Dense [128] -> ReLU -> Q [2]

Output: Q(s, left), Q(s, right)

Dueling DQN:

State [4] -> Dense [128] -> ReLU -> Dense [128] -> ReLU
                                         |
                    +--------------------+--------------------+
                    |                                         |
            Dense [64] -> ReLU -> Dense [1]          Dense [64] -> ReLU -> Dense [2]
                    |                                         |
                   V(s)                              A(s, left), A(s, right)
                    |                                         |
                    +--------------------+--------------------+
                                         |
                                    [Combine]
                                         |
                               Q(s, left), Q(s, right)

Same input, same output, different internal structure.

</>Implementation
import torch
import torch.nn as nn
import torch.nn.functional as F

class DuelingDQN(nn.Module):
    """
    Dueling Network Architecture.

    Separates Q-value estimation into value and advantage streams,
    combining them to produce final Q-values.
    """

    def __init__(self, state_dim: int, n_actions: int, hidden_dim: int = 128):
        super().__init__()

        self.n_actions = n_actions

        # Shared feature extraction layers
        self.feature_layer = nn.Sequential(
            nn.Linear(state_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU()
        )

        # Value stream: state -> V(s)
        self.value_stream = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim // 2),
            nn.ReLU(),
            nn.Linear(hidden_dim // 2, 1)  # Single output: V(s)
        )

        # Advantage stream: state -> A(s, a) for all actions
        self.advantage_stream = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim // 2),
            nn.ReLU(),
            nn.Linear(hidden_dim // 2, n_actions)  # One output per action
        )

    def forward(self, state: torch.Tensor) -> torch.Tensor:
        """
        Forward pass computing Q-values via V + A decomposition.

        Args:
            state: Batch of states [batch_size, state_dim]

        Returns:
            Q-values for all actions [batch_size, n_actions]
        """
        # Extract shared features
        features = self.feature_layer(state)

        # Compute value and advantages
        value = self.value_stream(features)           # [batch_size, 1]
        advantages = self.advantage_stream(features)  # [batch_size, n_actions]

        # Combine using mean-centering (explained below)
        # Q(s,a) = V(s) + A(s,a) - mean_a(A(s,a))
        q_values = value + advantages - advantages.mean(dim=1, keepdim=True)

        return q_values

    def get_value(self, state: torch.Tensor) -> torch.Tensor:
        """Get state value V(s) directly (useful for analysis)."""
        features = self.feature_layer(state)
        return self.value_stream(features)

    def get_advantages(self, state: torch.Tensor) -> torch.Tensor:
        """Get advantages A(s,a) directly (useful for analysis)."""
        features = self.feature_layer(state)
        advantages = self.advantage_stream(features)
        return advantages - advantages.mean(dim=1, keepdim=True)

The Identifiability Problem

📖Identifiability

A decomposition is identifiable if we can uniquely recover its components from the combined output. The naive dueling combination Q=V+AQ = V + A is not identifiable: adding a constant to V and subtracting it from A gives the same Q.

Here’s the problem with naive combination:

If Q(s,a)=V(s)+A(s,a)Q(s, a) = V(s) + A(s, a), then we could also write: Q(s,a)=[V(s)+c]+[A(s,a)c]Q(s, a) = [V(s) + c] + [A(s, a) - c]

for any constant cc. The Q-values are identical, but V and A are different!

Why does this matter?

Without constraints, the network can put arbitrary information in V or A. The value stream might learn V(s) = 1000000 while advantages are huge negative numbers. This makes the decomposition meaningless and can hurt learning stability.

The solution: Force advantages to have a specific property.

Two common approaches:

  1. Max-centering: Subtract maxaA(s,a)\max_a A(s, a), so the best action has advantage 0
  2. Mean-centering: Subtract meanaA(s,a)\text{mean}_a A(s, a), so advantages average to 0
Mathematical Details

Option 1: Max-centering Q(s,a;θ)=V(s;θV)+(A(s,a;θA)maxaA(s,a;θA))Q(s, a; \theta) = V(s; \theta_V) + \left( A(s, a; \theta_A) - \max_{a'} A(s, a'; \theta_A) \right)

This ensures maxaA(s,a)=0\max_a A(s, a) = 0, matching the theoretical definition.

Problem: The max operation makes optimization less smooth. Small changes in advantages can cause the max to jump between actions, creating discontinuities.

Option 2: Mean-centering (recommended) Q(s,a;θ)=V(s;θV)+(A(s,a;θA)1AaA(s,a;θA))Q(s, a; \theta) = V(s; \theta_V) + \left( A(s, a; \theta_A) - \frac{1}{|A|} \sum_{a'} A(s, a'; \theta_A) \right)

This ensures aA(s,a)=0\sum_a A(s, a) = 0.

Why mean-centering works better:

  • Smoother gradients (mean is differentiable everywhere)
  • More stable optimization
  • V(s) still approximates state value well in practice
  • The DeepMind paper found mean-centering performed better
</>Implementation
class DuelingDQN(nn.Module):
    """Dueling DQN with different aggregation methods."""

    def __init__(self, state_dim: int, n_actions: int,
                 hidden_dim: int = 128, aggregation: str = "mean"):
        super().__init__()

        self.aggregation = aggregation

        # ... (same feature, value, advantage layers as before)
        self.feature_layer = nn.Sequential(
            nn.Linear(state_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU()
        )

        self.value_stream = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim // 2),
            nn.ReLU(),
            nn.Linear(hidden_dim // 2, 1)
        )

        self.advantage_stream = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim // 2),
            nn.ReLU(),
            nn.Linear(hidden_dim // 2, n_actions)
        )

    def forward(self, state: torch.Tensor) -> torch.Tensor:
        features = self.feature_layer(state)
        value = self.value_stream(features)           # [batch_size, 1]
        advantages = self.advantage_stream(features)  # [batch_size, n_actions]

        if self.aggregation == "mean":
            # Recommended: subtract mean advantage
            # Q = V + (A - mean(A))
            q_values = value + advantages - advantages.mean(dim=1, keepdim=True)

        elif self.aggregation == "max":
            # Alternative: subtract max advantage
            # Q = V + (A - max(A))
            q_values = value + advantages - advantages.max(dim=1, keepdim=True)[0]

        elif self.aggregation == "none":
            # Not recommended: no centering (for comparison only)
            q_values = value + advantages

        return q_values


def demonstrate_identifiability():
    """Show that mean-centering makes V interpretable."""

    # Create a simple dueling network
    net = DuelingDQN(state_dim=4, n_actions=3, aggregation="mean")

    # Random state
    state = torch.randn(1, 4)

    # Get outputs
    q_values = net(state)
    value = net.value_stream(net.feature_layer(state))
    raw_advantages = net.advantage_stream(net.feature_layer(state))

    print(f"Q-values: {q_values.detach().numpy()}")
    print(f"V(s): {value.item():.3f}")
    print(f"Raw A(s,a): {raw_advantages.detach().numpy()}")
    print(f"Mean of raw A: {raw_advantages.mean().item():.3f}")

    # After mean-centering, V approximates the mean Q-value
    print(f"\nMean Q-value: {q_values.mean().item():.3f}")
    print(f"V(s) (should be close): {value.item():.3f}")

Dueling Architecture for Atari

For visual inputs like Atari games, we add convolutional layers before the value/advantage split:

Frame Stack [4, 84, 84]
        |
    Conv2D 32, 8x8, stride 4
        |
       ReLU
        |
    Conv2D 64, 4x4, stride 2
        |
       ReLU
        |
    Conv2D 64, 3x3, stride 1
        |
       ReLU
        |
     Flatten
        |
    Dense 512
        |
       ReLU
        |
    +--------+--------+
    |                 |
Dense 512        Dense 512
    |                 |
   ReLU              ReLU
    |                 |
Dense 1          Dense |A|
    |                 |
   V(s)           A(s, a)
    |                 |
    +-------+---------+
            |
    Q = V + (A - mean(A))

The convolutional layers are shared, they extract visual features useful for both streams. Only the final fully-connected layers are separate.

</>Implementation
class DuelingDQNConv(nn.Module):
    """
    Dueling DQN with convolutional layers for image input.
    Architecture matches the original DeepMind paper.
    """

    def __init__(self, n_actions: int, in_channels: int = 4):
        super().__init__()

        # Shared convolutional backbone (same as standard DQN)
        self.conv = nn.Sequential(
            nn.Conv2d(in_channels, 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()
        )

        # Calculate conv output size: for 84x84 input -> 7x7x64 = 3136
        conv_output_size = 64 * 7 * 7

        # Shared dense layer
        self.fc_shared = nn.Sequential(
            nn.Linear(conv_output_size, 512),
            nn.ReLU()
        )

        # Value stream
        self.value_stream = nn.Sequential(
            nn.Linear(512, 512),
            nn.ReLU(),
            nn.Linear(512, 1)
        )

        # Advantage stream
        self.advantage_stream = nn.Sequential(
            nn.Linear(512, 512),
            nn.ReLU(),
            nn.Linear(512, n_actions)
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """
        Args:
            x: Image input [batch_size, channels, height, width]
               Normalized to [0, 1] range

        Returns:
            Q-values [batch_size, n_actions]
        """
        # Shared feature extraction
        conv_out = self.conv(x)
        conv_out = conv_out.view(conv_out.size(0), -1)  # Flatten
        features = self.fc_shared(conv_out)

        # Separate streams
        value = self.value_stream(features)
        advantages = self.advantage_stream(features)

        # Combine with mean-centering
        q_values = value + advantages - advantages.mean(dim=1, keepdim=True)

        return q_values


def count_parameters(model: nn.Module) -> dict:
    """Compare parameter counts between streams."""

    total = sum(p.numel() for p in model.parameters())

    # Count by component (rough approximation)
    conv_params = sum(p.numel() for p in model.conv.parameters())
    shared_params = sum(p.numel() for p in model.fc_shared.parameters())
    value_params = sum(p.numel() for p in model.value_stream.parameters())
    advantage_params = sum(p.numel() for p in model.advantage_stream.parameters())

    return {
        "total": total,
        "conv": conv_params,
        "shared_fc": shared_params,
        "value_stream": value_params,
        "advantage_stream": advantage_params
    }


# Example usage
model = DuelingDQNConv(n_actions=18)  # 18 actions in Atari
params = count_parameters(model)
print(f"Total parameters: {params['total']:,}")
print(f"Convolutional: {params['conv']:,}")
print(f"Shared FC: {params['shared_fc']:,}")
print(f"Value stream: {params['value_stream']:,}")
print(f"Advantage stream: {params['advantage_stream']:,}")

Why Dueling Networks Help

Scenario 1: State where actions don’t matter

In Pong, when the ball is moving away from your paddle, you have many frames before you need to act. Standard DQN must:

  • Learn that Q(s, up) is approximately 5.2
  • Learn that Q(s, down) is approximately 5.2
  • Learn that Q(s, stay) is approximately 5.2

Dueling DQN learns:

  • V(s) is approximately 5.2 (updated from any transition starting in s)
  • A(s, up), A(s, down), A(s, stay) are all approximately 0

The value stream can be updated from any of these transitions, making learning faster.

Scenario 2: Sparse action differences

In many games, most actions are equivalent in most states. If only 10% of states have meaningful action differences:

  • Standard DQN: Updates action values from every transition, but only 10% are “informative”
  • Dueling DQN: Value stream learns from 100% of transitions, advantage stream focuses on the 10% that matter

Scenario 3: Policy evaluation

If we’re just evaluating a policy (not improving it), we mainly care about V(s). Dueling networks can directly estimate this through the value stream, while standard DQN would need to compute max/average over Q(s, a).

Mathematical Details

Gradient flow analysis:

Consider a transition (s,a,r,s)(s, a, r, s') where the optimal action in ss is aa^*.

Standard DQN gradient: Q(s,a)θ=fθ(s,a)θ\frac{\partial Q(s, a)}{\partial \theta} = \frac{\partial f_\theta(s, a)}{\partial \theta}

Only parameters affecting Q(s,a)Q(s, a) are updated.

Dueling DQN gradient: Q(s,a)θ=V(s;θV)θV+θA[A(s,a;θA)1AaA(s,a;θA)]\frac{\partial Q(s, a)}{\partial \theta} = \frac{\partial V(s; \theta_V)}{\partial \theta_V} + \frac{\partial}{\partial \theta_A}\left[A(s, a; \theta_A) - \frac{1}{|A|}\sum_{a'} A(s, a'; \theta_A)\right]

The value stream parameters θV\theta_V receive gradient from every transition. The advantage stream receives gradient that depends on all actions (due to mean subtraction).

Effective sample efficiency:

If kk out of A|A| actions have non-zero TD error contribution, standard DQN updates kk action-values. Dueling DQN always updates V(s), plus the relative advantages.

Combining Dueling with Other Improvements

💡Tip

Dueling Networks combine naturally with other DQN improvements. The architecture change is orthogonal to changes in:

  • Target computation (Double DQN)
  • Sampling (Prioritized Experience Replay)
  • Exploration (Noisy Networks)
</>Implementation
class DuelingDoubleDQN:
    """
    Combining Dueling architecture with Double DQN target computation.
    """

    def __init__(self, state_dim: int, n_actions: int,
                 hidden_dim: int = 128, gamma: float = 0.99,
                 lr: float = 1e-4, tau: float = 0.005):

        # Use dueling architecture for both networks
        self.online_net = DuelingDQN(state_dim, n_actions, hidden_dim)
        self.target_net = DuelingDQN(state_dim, n_actions, hidden_dim)

        # Initialize target network
        self.target_net.load_state_dict(self.online_net.state_dict())

        self.optimizer = torch.optim.Adam(self.online_net.parameters(), lr=lr)
        self.gamma = gamma
        self.tau = tau
        self.n_actions = n_actions

    def compute_loss(self, batch: dict) -> torch.Tensor:
        """
        Compute Double DQN loss with Dueling architecture.

        The architecture is Dueling, the target computation is Double DQN.
        """
        states = batch['states']
        actions = batch['actions']
        rewards = batch['rewards']
        next_states = batch['next_states']
        dones = batch['dones']

        # Current Q-values from dueling online network
        current_q = self.online_net(states)
        current_q = current_q.gather(1, actions.unsqueeze(1)).squeeze(1)

        with torch.no_grad():
            # Double DQN: online network selects, target network evaluates
            # Both networks are dueling architecture
            next_q_online = self.online_net(next_states)
            best_actions = next_q_online.argmax(dim=1)

            next_q_target = self.target_net(next_states)
            next_q = next_q_target.gather(1, best_actions.unsqueeze(1)).squeeze(1)

            # Compute targets
            targets = rewards + self.gamma * next_q * (1 - dones.float())

        loss = F.mse_loss(current_q, targets)
        return loss

    def soft_update(self):
        """Soft update target network."""
        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 select_action(self, state: torch.Tensor, epsilon: float = 0.0) -> int:
        """Epsilon-greedy action selection."""
        if torch.rand(1).item() < epsilon:
            return torch.randint(self.n_actions, (1,)).item()

        with torch.no_grad():
            q_values = self.online_net(state.unsqueeze(0))
            return q_values.argmax(dim=1).item()


def demonstrate_dueling_double_dqn():
    """Show how Dueling + Double DQN work together."""

    agent = DuelingDoubleDQN(state_dim=4, n_actions=2)

    # Create a fake batch
    batch = {
        'states': torch.randn(32, 4),
        'actions': torch.randint(0, 2, (32,)),
        'rewards': torch.randn(32),
        'next_states': torch.randn(32, 4),
        'dones': torch.zeros(32)
    }

    # Compute loss
    loss = agent.compute_loss(batch)
    print(f"Loss: {loss.item():.4f}")

    # Check that both networks are dueling
    print(f"\nOnline network value stream output: "
          f"{agent.online_net.value_stream[-1].out_features}")
    print(f"Online network advantage stream output: "
          f"{agent.online_net.advantage_stream[-1].out_features}")

When Does Dueling Help Most?

Dueling networks provide the biggest improvements when:

  1. Many actions are similar: If most actions in most states give similar returns, the value stream learns efficiently while the advantage stream captures the small differences.

  2. State value varies more than advantages: Games where “being in a good position” matters more than “choosing the right move” benefit from explicit value estimation.

  3. Large action spaces: With many actions, learning a single V(s) plus relative advantages is easier than learning absolute Q-values for each action.

  4. Policy evaluation matters: If you care about estimating how well you’re doing (not just acting optimally), the value stream gives direct V(s) estimates.

When Dueling helps less:

  • Every action matters equally in every state: If action differences are always important, you’re learning |A| values either way.
  • Very small action spaces: With 2-3 actions, the overhead of two streams may not pay off.
  • Highly stochastic environments: If state value is hard to estimate due to noise, the value stream advantage diminishes.
📌Atari Results

The original Dueling DQN paper tested on 57 Atari games. Results showed:

Games where Dueling helped most:

  • Enduro: 1000%+ improvement over DQN
  • Boxing: 500%+ improvement
  • Star Gunner: 300%+ improvement

Common pattern: Games where positioning/situation matters more than precise timing. “Being in a good state” is more important than “choosing the exact right action this frame.”

Games where Dueling helped least:

  • Games requiring precise action timing
  • Games where every frame’s action is critical

Overall: Dueling improved mean and median performance across all 57 games, even though it changed nothing about the loss function or training procedure.

Implementation Checklist

</>Implementation
def create_dueling_dqn_checklist():
    """Key implementation points for Dueling DQN."""

    checklist = """
    DUELING DQN IMPLEMENTATION CHECKLIST
    ====================================

    Architecture:
    [ ] Shared feature extraction layers (conv or dense)
    [ ] Separate value stream (output: 1)
    [ ] Separate advantage stream (output: n_actions)
    [ ] Mean-centering in forward pass: Q = V + (A - mean(A))

    Key details:
    [ ] Value and advantage streams should have similar capacity
    [ ] Mean subtraction happens at every forward pass
    [ ] Both online and target networks use dueling architecture

    Common mistakes:
    [ ] Forgetting mean subtraction (breaks identifiability)
    [ ] Using max instead of mean (less stable optimization)
    [ ] Different architectures for online vs target network

    Hyperparameters (same as DQN):
    [ ] Learning rate: 1e-4 to 6.25e-5
    [ ] Discount factor: 0.99
    [ ] Target update: soft (tau=0.005) or hard (every 10k steps)
    [ ] Batch size: 32

    Works well with:
    [ ] Double DQN (orthogonal improvement)
    [ ] Prioritized Experience Replay (orthogonal improvement)
    [ ] Noisy Networks (orthogonal improvement)
    """
    return checklist


# Complete training integration
def train_dueling_dqn(env, num_episodes: int = 1000):
    """Example training loop with Dueling DQN."""

    state_dim = env.observation_space.shape[0]
    n_actions = env.action_space.n

    # Initialize agent with dueling architecture
    agent = DuelingDoubleDQN(
        state_dim=state_dim,
        n_actions=n_actions,
        hidden_dim=128,
        gamma=0.99,
        lr=1e-4,
        tau=0.005
    )

    # Standard replay buffer (can upgrade to PER)
    buffer = ReplayBuffer(capacity=100000)

    epsilon = 1.0
    epsilon_decay = 0.995
    epsilon_min = 0.01
    batch_size = 32

    for episode in range(num_episodes):
        state = env.reset()
        episode_reward = 0
        done = False

        while not done:
            # Epsilon-greedy action selection
            action = agent.select_action(
                torch.FloatTensor(state),
                epsilon=epsilon
            )

            next_state, reward, done, _ = env.step(action)
            buffer.push(state, action, reward, next_state, done)

            state = next_state
            episode_reward += reward

            # Train if buffer has enough samples
            if len(buffer) >= batch_size:
                batch = buffer.sample(batch_size)
                loss = agent.compute_loss(batch)

                agent.optimizer.zero_grad()
                loss.backward()
                agent.optimizer.step()

                # Soft update target network
                agent.soft_update()

        # Decay epsilon
        epsilon = max(epsilon_min, epsilon * epsilon_decay)

        if episode % 100 == 0:
            print(f"Episode {episode}, Reward: {episode_reward:.1f}, "
                  f"Epsilon: {epsilon:.3f}")

    return agent

Key Takeaways

ℹ️Note

Dueling Networks in a nutshell:

  1. Decompose Q into V + A: Separate “how good is this state” from “how much better is this action”

  2. Architecture change only: Same loss function, same training procedure, different network structure

  3. Mean-centering for identifiability: Q = V + (A - mean(A)) ensures V learns meaningful state values

  4. Free efficiency gain: Learn state value from every transition, even when actions don’t matter

  5. Combines with everything: Orthogonal to Double DQN, PER, noisy nets, etc.

The dueling architecture represents a clean example of how structural inductive biases can improve learning without changing the fundamental algorithm.

Next, we’ll see how to combine dueling networks with all the other DQN improvements in Rainbow, achieving state-of-the-art performance on Atari.