Gating Networks › Code Implementation

Michael BrenndoerferUpdated January 3, 202610 min read
Language AI HandbookAll chapters
Part 33 of 67Mixture of Experts
Chapter 241Gating Networks › Code Implementation
Reading section 7 of 11 · Code Implementation · 10 minSection 7 of 11 · 10 min

Book contents

Section 7 of 11 · 10 min read

Gating Networks › Code ImplementationOne section · Sections in this chapter7 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement a gating network from scratch to see these concepts in action.

In[8]:
Code
import numpy as np
import torch

# Set random seed for reproducibility
torch.manual_seed(42)
np.random.seed(42)

Basic Router Module

We start with the core router implementation. The router is a linear layer that projects token representations to expert scores.

In[9]:
Code
import torch
import torch.nn as nn
import torch.nn.functional as F


class Router(nn.Module):
    """
    Gating network for Mixture of Experts.
    Routes tokens to top-k experts based on learned routing scores.
    """

    def __init__(self, d_model: int, num_experts: int, top_k: int = 2):
        super().__init__()
        self.num_experts = num_experts
        self.top_k = top_k

        # Single linear layer: projects from d_model to num_experts
        # No bias term, common in production MoE implementations
        self.gate = nn.Linear(d_model, num_experts, bias=False)

    def forward(self, x: torch.Tensor):
        """
        Compute routing weights for input tokens.

        Args:
            x: Input tensor of shape (batch_size, seq_len, d_model)

        Returns:
            routing_weights: Weights for selected experts (batch, seq, top_k)
            selected_experts: Indices of selected experts (batch, seq, top_k)
            router_probs: Full probability distribution (batch, seq, num_experts)
        """
        # Compute routing logits
        logits = self.gate(x)  # (batch, seq, num_experts)

        # Convert to probabilities via softmax
        router_probs = F.softmax(logits, dim=-1)

        # Select top-k experts
        routing_weights, selected_experts = torch.topk(
            router_probs, self.top_k, dim=-1
        )

        # Renormalize weights to sum to 1
        routing_weights = routing_weights / routing_weights.sum(
            dim=-1, keepdim=True
        )

        return routing_weights, selected_experts, router_probs

Let's verify the router produces expected outputs:

In[10]:
Code
# Create a router with 8 experts, selecting top-2
d_model = 256
num_experts = 8
router = Router(d_model, num_experts, top_k=2)

# Simulate a batch of token representations
batch_size = 2
seq_len = 4
x = torch.randn(batch_size, seq_len, d_model)

# Get routing decisions
routing_weights, selected_experts, router_probs = router(x)
Out[11]:
Console
Input shape: torch.Size([2, 4, 256])
Routing weights shape: torch.Size([2, 4, 2])
Selected experts shape: torch.Size([2, 4, 2])

First token routing probabilities (all experts):
[0.184 0.073 0.083 0.066 0.336 0.049 0.031 0.178]

First token selected experts: [4, 0]
First token weights for selected experts: [0.646 0.354]
Weights sum: 1.000

The router produces probability distributions over all experts, then selects the top-2 and renormalizes their weights.

Routing Score Analysis

To understand router behavior, let's examine how routing probabilities distribute across experts for different inputs.

In[12]:
Code
def analyze_routing_distribution(router, num_samples=1000, d_model=256):
    """
    Analyze how the router distributes tokens across experts.
    """
    # Generate random token representations
    x = torch.randn(num_samples, 1, d_model)

    with torch.no_grad():
        _, selected_experts, router_probs = router(x)

    # Count how often each expert is selected
    expert_counts = torch.zeros(router.num_experts)
    for i in range(router.num_experts):
        expert_counts[i] = (selected_experts == i).sum().item()

    # Compute mean probability assigned to each expert
    mean_probs = router_probs.squeeze(1).mean(dim=0)

    return expert_counts, mean_probs
In[13]:
Code
# Analyze an untrained router
counts, mean_probs = analyze_routing_distribution(router)
Out[14]:
Console
Expert selection counts (out of 2000 selections):
  Expert 0:  265 ██████████████████████████████████████
  Expert 1:  251 ████████████████████████████████████
  Expert 2:  261 █████████████████████████████████████
  Expert 3:  215 ██████████████████████████████
  Expert 4:  241 ██████████████████████████████████
  Expert 5:  227 ████████████████████████████████
  Expert 6:  278 ████████████████████████████████████████
  Expert 7:  262 █████████████████████████████████████

Mean routing probabilities:
  Expert 0: 0.126 ██████████
  Expert 1: 0.126 ██████████
  Expert 2: 0.128 ██████████
  Expert 3: 0.120 █████████
  Expert 4: 0.122 █████████
  Expert 5: 0.122 █████████
  Expert 6: 0.129 ██████████
  Expert 7: 0.126 ██████████

With random initialization, the router distributes fairly evenly across experts. This is expected before training shapes the routing behavior.

Out[15]:
Visualization
Bar chart showing selection counts for 8 experts with mean line.
Selection frequency: Count of tokens routed to each expert shows roughly uniform distribution.
Bar chart showing mean routing probabilities for 8 experts with uniform reference line.
Average probability: Mean routing probability per expert approaches uniform (1/8).

Visualizing Routing Entropy

Routing entropy measures how "decisive" the router is. Low entropy means the router strongly prefers specific experts; high entropy means uncertainty across many experts.

In[16]:
Code
def compute_routing_entropy(router_probs):
    """
    Compute entropy of routing distributions.
    Higher entropy = more uniform, lower = more concentrated.
    """
    # Avoid log(0)
    probs = router_probs.clamp(min=1e-10)
    entropy = -torch.sum(probs * torch.log(probs), dim=-1)
    return entropy


# Compute entropy for our sample
_, _, router_probs = router(x)
entropies = compute_routing_entropy(router_probs)
In[17]:
Code
# Maximum possible entropy (uniform distribution over 8 experts)
max_entropy = np.log(num_experts)
Out[18]:
Console
Maximum entropy (uniform over 8 experts): 2.079
Mean observed entropy: 1.904
Entropy ratio (observed/max): 91.56%

An untrained router produces near-maximum entropy, showing uniform routing. Training should reduce this as the router learns to make more decisive choices.

Simulating Trained Router Behavior

Let's simulate what happens when a router learns domain-specific patterns. We'll create synthetic "domain" embeddings and train the router to recognize them.

Why can the loss hit ~0 almost immediately in this demo?

This is a supervised toy classification setup: we generate synthetic "domains" as Gaussian clusters and directly train the router to predict the domain label. If the clusters are well separated (large prototype_scale) and noise is small (noise_std), a linear gate can drive logits to large magnitudes, making softmax saturate and the loss appear to drop to ~0 in just a few optimization steps.

To keep the curve informative (and connect to the stability discussion above), the demo below uses overlapping domains and adds a small router z-loss term (z_loss_coef) to discourage runaway logits.

In[19]:
Code
# Create synthetic domain representations
# Imagine these are averaged representations from different document types
num_domains = 4
prototype_scale = 1.0
noise_std = 0.8
batch_size_per_domain = 64

# NOTE: If you crank up prototype_scale and reduce noise_std, this becomes
# an almost trivial supervised classification problem and the loss can drop
# to ~0 in just a few optimization steps (softmax saturation).
domain_prototypes = torch.randn(num_domains, d_model) * prototype_scale

# Create a router with one expert per domain
domain_router = Router(d_model, num_domains, top_k=1)

# Simulate "labeled" training: each sample comes from a known domain
# We'll train the router to route domain samples to corresponding experts

z_loss_coef = 1e-2
optimizer = torch.optim.Adam(
    domain_router.parameters(), lr=0.005, weight_decay=1e-4
)

losses = []
for epoch in range(200):
    epoch_loss = 0
    for domain_idx in range(num_domains):
        # Generate samples from this domain (prototype + noise)
        samples = (
            domain_prototypes[domain_idx]
            + torch.randn(batch_size_per_domain, d_model) * noise_std
        )
        samples = samples.unsqueeze(1)  # Add seq dimension

        # Train on logits directly for numerical stability
        logits = domain_router.gate(samples).squeeze(1)  # (batch, num_domains)
        labels = torch.full(
            (logits.shape[0],),
            domain_idx,
            dtype=torch.long,
            device=logits.device,
        )

        # Cross-entropy: encourage routing to the correct expert
        nll = F.cross_entropy(logits, labels)

        # Router z-loss: discourages runaway logits (keeps softmax from saturating)
        z = torch.logsumexp(logits, dim=-1)
        z_loss = (z**2).mean()

        loss = nll + z_loss_coef * z_loss

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

        epoch_loss += loss.item()

    losses.append(epoch_loss / num_domains)
Out[20]:
Visualization
Line plot showing decreasing training loss over 200 epochs.
Router training loss over 200 epochs during domain-specific learning (with overlapping synthetic domains and a small router z-loss to prevent softmax saturation). The decreasing loss shows the router's improved accuracy in mapping inputs to their corresponding experts.

The router quickly learns to distinguish domain-specific patterns. Let's verify it routes correctly:

In[21]:
Code
# Test routing accuracy on new samples
correct = 0
total = 0
domain_accuracies = []

with torch.no_grad():
    for domain_idx in range(num_domains):
        samples = (
            domain_prototypes[domain_idx] + torch.randn(100, d_model) * 0.3
        )
        samples = samples.unsqueeze(1)

        _, selected, _ = domain_router(samples)
        selected = selected.squeeze()

        # Calculate accuracy for this domain
        domain_acc = (selected == domain_idx).float().mean().item()
        domain_accuracies.append(domain_acc)

        correct += (selected == domain_idx).sum().item()
        total += 100
Out[22]:
Console
Routing accuracy: 100.0%

Per-domain accuracy:
  Domain 0: 100.0%
  Domain 1: 100.0%
  Domain 2: 100.0%
  Domain 3: 100.0%

The trained router successfully learns to route tokens from different domains to appropriate experts.

Out[23]:
Visualization
Heatmap showing routing accuracy for 4 domains, with high values on the diagonal.
Routing confusion matrix for the trained domain router. Strong diagonal values indicate that tokens from each domain are correctly routed to their corresponding expert. This shows the router's ability to learn real specialization patterns.

Router Weight Visualization

We can visualize the router's learned prototypes to understand what patterns it captures.

In[24]:
Code
# Extract router weights for visualization
weights = domain_router.gate.weight.detach().numpy()
Out[25]:
Visualization
Heatmap showing router weights for 4 experts across 50 dimensions with distinct patterns.
Heatmap of the learned router weight matrix, showing the first 50 dimensions of each expert's prototype. The distinct horizontal bands of weight intensity indicate that each expert has specialized its prototype to respond to a unique set of features in the input representation.

Each row shows a distinct pattern, representing the "prototype" that input must match to route to that expert. The router learns to position these prototypes to distinguish between domain-specific representations.