Study Reinforcement Learning

← home

lecture 05 of 34 · Foundations · 2–4 h

Trust Region Policy Optimization (TRPO)

unreviewed draftNo one has checked this end to end. Treat the math, code, and citations as unverified. Source on GitHub.

Updated June 30, 2026. About 12 minutes to read.

checkpoint, for when you finish
Explain what "trust region" means here and what problem it's solving.

Goal: Understand why policy updates fail, learn TRPO’s solution, see why PPO was needed


Why trust regions

In Lecture 04 (A2C), we updated policies with gradient descent:

θ_new = θ_old + α * ∇J(θ)

One bad update can destroy a policy. A common failure mode: after hundreds of episodes of learning, a single large gradient step causes the agent to forget everything and start acting randomly, and it never recovers. This is not a freak event; policy gradient methods hit it regularly.

TRPO solves this by asking: what’s the largest policy update we can make safely? This lecture is about that question and TRPO’s answer. Lecture 06 (PPO) covers a simpler approximation that everyone actually uses.


Part 1: The problem with policy gradients

Small parameter change, big performance change

Policies are fragile:

# Policy before: π_old(a|s)
# After small gradient step: π_new(a|s)

# In some states, π_new might be completely different!
# Example: π_old(action_1|state) = 0.51, π_new(action_1|state) = 0.49
# Action flips from likely to unlikely!

The root cause

Policy gradient uses first-order approximation:

J(θ_new) ≈ J(θ_old) + ∇J(θ_old)^T (θ_new - θ_old)

This is only valid for tiny changes in θ. But we don’t know how tiny!

Concrete example: CartPole

# State: Cart at position 0.1, velocity 0.5, pole angle 10°

# Old policy: π_old(left|s) = 0.7, π_old(right|s) = 0.3
# Policy says: strongly prefer left

# After one gradient update with learning rate 0.01:
# New policy: π_new(left|s) = 0.2, π_new(right|s) = 0.8
# Policy flipped to strongly prefer right!

# Performance collapsed: from 200 steps to 20 steps

We need to constrain how much the policy can change.


Part 2: Trust regions

The idea

Instead of:

maximize J(θ)

Do:

maximize J(θ_new)
subject to: KL(π_old || π_new) ≤ δ

Where KL is KL divergence, measuring how different the policies are.

What is KL divergence?

For discrete actions:

KL(π_old || π_new) = Σ_a π_old(a|s) log(π_old(a|s) / π_new(a|s))

Intuition: How surprised would π_old be to see actions from π_new?

  • KL = 0: Policies are identical
  • KL > 0: Policies are different
  • KL = ∞: Policies assign probability 0 vs >0 to some action

Example: CartPole

# Old policy:
π_old(left) = 0.7, π_old(right) = 0.3

# New policy 1 (small change):
π_new1(left) = 0.65, π_new1(right) = 0.35
KL = 0.7*log(0.7/0.65) + 0.3*log(0.3/0.35) = 0.0043

# New policy 2 (big change):
π_new2(left) = 0.2, π_new2(right) = 0.8
KL = 0.7*log(0.7/0.2) + 0.3*log(0.3/0.8) = 0.892

# TRPO would allow new policy 1, reject new policy 2

Part 3: TRPO algorithm

The optimization problem

maximize_θ: E_s[E_a~π_new[A(s,a)]]
subject to: E_s[KL(π_old(·|s) || π_new(·|s))] ≤ δ

Where:

  • A(s,a) is advantage function
  • δ is trust region size (typically 0.01)
  • Expectation over states in trajectory

Solving the constrained optimization

This is hard! TRPO uses:

  1. Linear approximation of objective
  2. Quadratic approximation of constraint
  3. Conjugate gradient to find search direction
  4. Line search to satisfy constraint

Step-by-step TRPO

Step 1: Collect trajectories using current policy π_old

Step 2: Compute advantages A(s,a) using GAE (from Lecture 04)

Step 3: Approximate objective

L(θ) = E[π_new(a|s)/π_old(a|s) * A(s,a)]

Gradient: g = ∇_θ L(θ) |_θ=θ_old

Step 4: Approximate KL constraint

KL ≈ (θ - θ_old)^T H (θ - θ_old) / 2

Where H = Hessian of KL divergence (Fisher information matrix)

Step 5: Solve for search direction

Find direction s that maximizes: g^T s
Subject to: s^T H s ≤ δ

Solution (Lagrangian): s = √(2δ / (g^T H^{-1} g)) * H^{-1} g

Step 6: Line search

Try: θ_new = θ_old + α^j s  for j=0,1,2,...
Accept first θ_new that:
  1. Improves objective: L(θ_new) > L(θ_old)
  2. Satisfies constraint: KL(θ_new, θ_old) ≤ δ

Part 4: Implementation (simplified TRPO)

This is complex. The code below shows the key parts:

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

class TRPOAgent:
    """
    Trust Region Policy Optimization agent.

    Simplified implementation focusing on key concepts.
    """

    def __init__(
        self,
        policy,
        value_net,
        max_kl=0.01,
        damping=0.1,
        value_lr=1e-3
    ):
        self.policy = policy
        self.value_net = value_net
        self.max_kl = max_kl  # Trust region size
        self.damping = damping

        # Value function optimizer (policy updated via TRPO)
        self.value_optimizer = torch.optim.Adam(
            self.value_net.parameters(), lr=value_lr
        )

    def select_action(self, state):
        """Sample action from current policy."""
        state_tensor = torch.tensor(state, dtype=torch.float32).unsqueeze(0)

        with torch.no_grad():
            logits = self.policy(state_tensor)
            probs = F.softmax(logits, dim=-1)
            dist = torch.distributions.Categorical(probs)
            action = dist.sample()

        return action.item()

    def compute_advantages(self, states, rewards, dones, gamma=0.99, lam=0.95):
        """Compute GAE advantages."""
        values = self.value_net(states).squeeze().detach().numpy()

        advantages = []
        gae = 0
        for t in reversed(range(len(rewards))):
            if t == len(rewards) - 1:
                next_value = 0
            else:
                next_value = values[t+1]

            if dones[t]:
                next_value = 0

            delta = rewards[t] + gamma * next_value - values[t]
            gae = delta + gamma * lam * gae
            advantages.insert(0, gae)

        advantages = torch.tensor(advantages, dtype=torch.float32)
        returns = advantages + torch.tensor(values, dtype=torch.float32)

        # Normalize advantages
        advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)

        return advantages, returns

    def get_log_probs(self, states, actions):
        """Get log probabilities of actions under current policy."""
        logits = self.policy(states)
        probs = F.softmax(logits, dim=-1)
        dist = torch.distributions.Categorical(probs)
        log_probs = dist.log_prob(actions)
        return log_probs

    def compute_policy_loss(self, states, actions, advantages, old_log_probs):
        """
        Compute surrogate loss: L(θ) = E[π_new/π_old * A]
        """
        new_log_probs = self.get_log_probs(states, actions)

        # Importance sampling ratio: π_new(a|s) / π_old(a|s)
        ratio = torch.exp(new_log_probs - old_log_probs)

        # Surrogate loss
        loss = (ratio * advantages).mean()

        return loss

    def compute_kl(self, states, old_logits):
        """
        Compute KL divergence between old and new policies.
        KL(π_old || π_new) = Σ π_old(a) log(π_old(a) / π_new(a))
        """
        new_logits = self.policy(states)

        old_probs = F.softmax(old_logits, dim=-1)
        new_probs = F.softmax(new_logits, dim=-1)

        # KL divergence
        kl = (old_probs * (torch.log(old_probs) - torch.log(new_probs))).sum(dim=-1).mean()

        return kl

    def fisher_vector_product(self, states, old_logits, v):
        """
        Compute Fisher-vector product: H * v
        where H is the Hessian of KL divergence (Fisher information matrix)

        Uses automatic differentiation instead of computing H explicitly.
        """
        kl = self.compute_kl(states, old_logits)

        # Gradient of KL
        grads = torch.autograd.grad(kl, self.policy.parameters(), create_graph=True)
        flat_grad = torch.cat([grad.view(-1) for grad in grads])

        # Gradient-vector product
        gv_product = (flat_grad * v).sum()

        # Hessian-vector product (second derivative)
        hvp = torch.autograd.grad(gv_product, self.policy.parameters())
        hvp_flat = torch.cat([grad.contiguous().view(-1) for grad in hvp])

        # Add damping for numerical stability
        return hvp_flat + self.damping * v

    def conjugate_gradient(self, states, old_logits, b, n_steps=10):
        """
        Solve H * x = b using conjugate gradient.

        Returns x ≈ H^{-1} * b
        """
        x = torch.zeros_like(b)
        r = b.clone()
        p = r.clone()
        rs_old = (r * r).sum()

        for _ in range(n_steps):
            Ap = self.fisher_vector_product(states, old_logits, p)
            alpha = rs_old / ((p * Ap).sum() + 1e-8)
            x += alpha * p
            r -= alpha * Ap
            rs_new = (r * r).sum()

            if rs_new < 1e-10:
                break

            p = r + (rs_new / rs_old) * p
            rs_old = rs_new

        return x

    def line_search(
        self,
        states,
        actions,
        advantages,
        old_log_probs,
        old_logits,
        fullstep,
        expected_improve
    ):
        """
        Backtracking line search to find step size that satisfies:
        1. Improves objective
        2. Satisfies KL constraint
        """
        old_params = self.get_flat_params()
        old_loss = self.compute_policy_loss(states, actions, advantages, old_log_probs)

        # Backtracking coefficients
        alphas = [0.5**i for i in range(10)]

        for alpha in alphas:
            # Try new parameters
            new_params = old_params + alpha * fullstep
            self.set_flat_params(new_params)

            # Check improvement
            new_loss = self.compute_policy_loss(states, actions, advantages, old_log_probs)
            actual_improve = new_loss - old_loss

            # Check KL constraint
            kl = self.compute_kl(states, old_logits)

            # Accept step if improves and satisfies constraint
            if actual_improve > 0 and kl <= self.max_kl:
                return True

        # No acceptable step found, revert to old parameters
        self.set_flat_params(old_params)
        return False

    def get_flat_params(self):
        """Get flattened policy parameters."""
        return torch.cat([param.view(-1) for param in self.policy.parameters()])

    def set_flat_params(self, flat_params):
        """Set policy parameters from flattened vector."""
        offset = 0
        for param in self.policy.parameters():
            param_size = param.numel()
            param.data.copy_(flat_params[offset:offset+param_size].view_as(param))
            offset += param_size

    def update(self, states, actions, rewards, dones):
        """
        TRPO update.

        Steps:
        1. Compute advantages
        2. Compute policy gradient
        3. Compute search direction using conjugate gradient
        4. Line search for step size
        5. Update value function
        """
        # Convert to tensors
        states = torch.tensor(np.array(states), dtype=torch.float32)
        actions = torch.tensor(actions, dtype=torch.long)
        # rewards stays as a Python list; compute_advantages expects that

        # Compute advantages and returns
        advantages, returns = self.compute_advantages(states, rewards, dones)

        # Get old policy log probs and logits (before update)
        with torch.no_grad():
            old_log_probs = self.get_log_probs(states, actions)
            old_logits = self.policy(states)

        # Compute policy loss
        loss = self.compute_policy_loss(states, actions, advantages, old_log_probs)

        # Compute policy gradient
        grads = torch.autograd.grad(loss, self.policy.parameters())
        policy_grad = torch.cat([grad.view(-1) for grad in grads])

        # Compute search direction using conjugate gradient
        # Solve: H * s = g (where H is Fisher information matrix)
        search_direction = self.conjugate_gradient(states, old_logits, policy_grad)

        # Compute maximum step size
        # s^T H s ≤ 2δ → α_max = √(2δ / (s^T H s))
        sHs = (search_direction * self.fisher_vector_product(
            states, old_logits, search_direction
        )).sum()
        max_step_size = torch.sqrt(2 * self.max_kl / (sHs + 1e-8))
        fullstep = max_step_size * search_direction

        # Line search
        expected_improve = (policy_grad * fullstep).sum()
        success = self.line_search(
            states, actions, advantages, old_log_probs,
            old_logits, fullstep, expected_improve
        )

        # Update value function (standard supervised learning)
        for _ in range(5):  # Multiple epochs
            value_loss = F.mse_loss(self.value_net(states).squeeze(), returns)
            self.value_optimizer.zero_grad()
            value_loss.backward()
            self.value_optimizer.step()

        return {
            'policy_loss': loss.item(),
            'value_loss': value_loss.item(),
            'kl': self.compute_kl(states, old_logits).item(),
            'line_search_success': success
        }


def train_trpo(env_name='CartPole-v1', n_episodes=500):
    """Train TRPO agent."""
    import gymnasium as gym

    env = gym.make(env_name)

    # Policy and value networks
    state_dim = env.observation_space.shape[0]
    action_dim = env.action_space.n

    policy = nn.Sequential(
        nn.Linear(state_dim, 64),
        nn.Tanh(),
        nn.Linear(64, 64),
        nn.Tanh(),
        nn.Linear(64, action_dim)
    )

    value_net = nn.Sequential(
        nn.Linear(state_dim, 64),
        nn.Tanh(),
        nn.Linear(64, 64),
        nn.Tanh(),
        nn.Linear(64, 1)
    )

    agent = TRPOAgent(policy, value_net, max_kl=0.01)

    rewards_per_episode = []

    for episode in range(n_episodes):
        # Collect trajectory
        states, actions, rewards, dones = [], [], [], []

        state, _ = env.reset()
        done = False

        while not done:
            action = agent.select_action(state)
            next_state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated

            states.append(state)
            actions.append(action)
            rewards.append(reward)
            dones.append(done)

            state = next_state

        # Update agent
        metrics = agent.update(states, actions, rewards, dones)

        # Logging
        episode_reward = sum(rewards)
        rewards_per_episode.append(episode_reward)

        if (episode + 1) % 50 == 0:
            avg_reward = np.mean(rewards_per_episode[-50:])
            print(f"Episode {episode+1}: Avg Reward = {avg_reward:.2f}")
            print(f"  KL = {metrics['kl']:.4f}, "
                  f"Line Search Success = {metrics['line_search_success']}")

    return agent, rewards_per_episode

Part 5: Why TRPO is complicated

Computational cost

  1. Conjugate gradient: Requires multiple Hessian-vector products (~10-20)
  2. Line search: May need multiple forward passes (~10)
  3. Per update: ~30x more computation than vanilla policy gradient

Implementation difficulty

  1. Fisher information matrix computation (needs second derivatives)
  2. Conjugate gradient solver
  3. Line search with KL checking
  4. Numerical stability issues

This is why TRPO is rarely used in practice.


Part 6: TRPO’s contributions

Despite the complexity, TRPO introduced ideas that shaped everything that followed:

1. Monotonic improvement guarantee

TRPO (approximately) guarantees:

J(π_new) ≥ J(π_old)

Policy never gets worse! (In practice, line search ensures this)

2. Safe exploration

Robot learning: One bad policy can damage hardware. TRPO prevents catastrophic failures.

3. Inspired PPO

PPO (Lecture 06) achieves similar results with much simpler implementation:

  • No conjugate gradient
  • No line search
  • No second derivatives
  • Just clipping!

Part 7: Common implementation gotchas

Gotcha 1: Fisher information matrix

Computing Fisher exactly is expensive. Use empirical Fisher:

# True Fisher: E_s,a[∇ log π(a|s) ∇ log π(a|s)^T]
# Empirical Fisher: Average over sampled trajectories

Gotcha 2: Conjugate gradient steps

Too few steps: Poor solution Too many steps: Numerical instability

Good default: 10-20 steps

Gotcha 3: Damping

Fisher matrix can be singular. Add damping:

H_damped = H + λI  # λ = 0.1 typical

Gotcha 4: Line search failures

If line search fails (no step satisfies constraint), keep old policy:

if not success:
    params = old_params  # Revert

Happens ~5-10% of updates.

Gotcha 5: Computational cost

TRPO is slow relative to alternatives. Each update requires multiple Hessian-vector products (conjugate gradient) and then multiple forward passes (line search), making per-update cost significantly higher than DQN or A3C. Expect wall-clock training time to be substantially longer. Use PPO instead.


Part 8: When to use TRPO

Use TRPO when:

  1. Safety-critical applications (robotics)
  2. Need monotonic improvement guarantees
  3. Willing to pay computational cost
  4. Research purposes (understanding trust regions)

Don’t use TRPO when:

  1. Want fast training (use PPO)
  2. Need simple implementation (use PPO)
  3. Limited compute (use PPO)
  4. Just use PPO (seriously, everyone does)

Recap

Policy gradients fail catastrophically when a single update moves the policy too far. Trust regions address this by constraining the KL divergence between old and new policies. TRPO formalizes that constraint and solves it using conjugate gradient and line search, giving an approximate monotonic improvement guarantee. The cost is steep: ~30x more computation than vanilla policy gradient, plus a complex implementation. PPO achieves similar stability with a simple clipped objective (see Lecture 06).


What’s next

Next lecture (06: PPO) covers Proximal Policy Optimization. PPO achieves TRPO’s benefits with a much simpler implementation: just a clipped surrogate objective, no conjugate gradient required. It’s now the most widely used deep RL algorithm.


References

  1. Schulman, Levine, Abbeel, Jordan, Moritz. “Trust Region Policy Optimization.” ICML 2015. arXiv:1502.05477
  2. Kakade. “A Natural Policy Gradient.” NeurIPS 2001. (Proceedings published 2002; no arXiv preprint: predates ML arXiv adoption.)
  3. Schulman, Wolski, Dhariwal, Radford, Klimov. “Proximal Policy Optimization Algorithms.” arXiv:1707.06347, 2017.

See also ../../reference/papers/.


Exercises

Don’t implement TRPO from scratch unless you specifically need to. Instead:

  1. Read the TRPO paper (arXiv:1502.05477) to understand trust regions conceptually
  2. Compare the algorithm to PPO (next lecture)
  3. Work through the conjugate gradient derivation on paper; it clarifies why the Fisher-vector product trick works

Note: implementing TRPO end-to-end is a useful exercise for understanding constrained optimization in RL, but it’s fragile and slow in practice. PPO covers the same ground with a fraction of the code.