Skip to content

Reinforcement Learning: From Curiosity to Mastery

Ahsan Kabir edited this page Dec 23, 2025 · 1 revision

Reinforcement Learning: From Curiosity to Mastery

A Complete Beginner's Guide

Table of Contents


Part I: The Foundation - Thinking Like an Agent

Chapter 1: The Game of Learning: An Introduction

1.1 The Three Paradigms of Machine Learning

Learning Type Analogy Real-World Example Key Characteristic
Supervised Learning Recipe follower with labeled ingredients Spam filter Needs correct answers
Unsupervised Learning Ingredient explorer finding patterns Customer segmentation Discovers structure
Reinforcement Learning Experimental chef tasting dishes Game-playing AI Learns from trial and error

1.2 The Core Cast of Characters

The Treasure Hunt Game Analogy:

import numpy as np

class TreasureHunt:
    """Simple RL environment demonstrating core concepts"""
    
    def __init__(self):
        # Agent: The treasure hunter (you!)
        self.agent_position = "start_camp"
        
        # Environment: The world map
        self.map = {
            "start_camp": {"north": "dark_forest", "east": "river"},
            "dark_forest": {"south": "start_camp", "east": "mysterious_cave"},
            "river": {"west": "start_camp", "north": "waterfall"},
            "mysterious_cave": {"west": "dark_forest", "down": "treasure_room"},
            "waterfall": {"south": "river", "behind": "hidden_grove"},
            "hidden_grove": {"enter": "ancient_temple"},
            "treasure_room": {},  # Terminal state
            "ancient_temple": {}   # Terminal state
        }
        
        # Rewards: Feedback signals
        self.rewards = {
            "start_camp": 0,
            "dark_forest": -1,      # Spooky, lose energy
            "river": -1,            # Crossing costs energy
            "mysterious_cave": -2,  # Dark and dangerous
            "waterfall": 0,
            "hidden_grove": 5,      # Beautiful discovery
            "treasure_room": 100,   # JACKPOT!
            "ancient_temple": 50    # Great discovery
        }
        
        # Actions available at each state
        self.available_actions = {
            "start_camp": ["north", "east"],
            "dark_forest": ["south", "east"],
            "river": ["west", "north"],
            "mysterious_cave": ["west", "down"],
            "waterfall": ["south", "behind"],
            "hidden_grove": ["enter"],
            "treasure_room": [],
            "ancient_temple": []
        }
    
    def step(self, action):
        """Take an action, return new state and reward"""
        current_state = self.agent_position
        
        if action not in self.available_actions[current_state]:
            print(f"Invalid action '{action}' from state '{current_state}'!")
            return current_state, -10, False  # Penalty for invalid action
        
        # Determine next state with some randomness (real environments are stochastic!)
        if np.random.random() < 0.1:  # 10% chance of getting lost
            possible_states = list(self.available_actions.keys())
            next_state = np.random.choice(possible_states)
            print("Oops! You got lost and ended up somewhere unexpected!")
        else:
            # Normal transition
            next_state = self.map[current_state][action]
        
        self.agent_position = next_state
        reward = self.rewards[next_state]
        
        # Check if terminal state
        done = (next_state in ["treasure_room", "ancient_temple"])
        
        return next_state, reward, done
    
    def reset(self):
        """Reset the game"""
        self.agent_position = "start_camp"
        return self.agent_position

1.3 The Fundamental Dilemma: Exploration vs. Exploitation

The Multi-Armed Bandit Problem:

import numpy as np
import matplotlib.pyplot as plt

class MultiArmedBandit:
    """Simple exploration-exploitation problem"""
    
    def __init__(self, n_arms=3):
        # Each arm has a true reward probability (unknown to agent)
        self.true_probs = np.random.random(n_arms)
        print(f"True probabilities (secret!): {self.true_probs}")
        
        # Agent's estimates
        self.estimates = np.zeros(n_arms)
        self.counts = np.zeros(n_arms)
        
    def pull_arm(self, arm):
        """Pull an arm, return reward (0 or 1)"""
        reward = 1 if np.random.random() < self.true_probs[arm] else 0
        return reward
    
    def update_estimate(self, arm, reward):
        """Update estimate using sample average"""
        self.counts[arm] += 1
        n = self.counts[arm]
        self.estimates[arm] = ((n-1) * self.estimates[arm] + reward) / n
    
    def epsilon_greedy(self, epsilon=0.1):
        """Epsilon-greedy action selection"""
        if np.random.random() < epsilon:
            # Explore: random arm
            return np.random.randint(len(self.true_probs))
        else:
            # Exploit: best estimated arm
            return np.argmax(self.estimates)

def run_bandit_experiment():
    """Run a bandit experiment"""
    bandit = MultiArmedBandit(n_arms=5)
    total_reward = 0
    rewards_history = []
    
    for t in range(1000):
        # Use epsilon-greedy strategy
        epsilon = max(0.01, 1.0 / (t/100 + 1))  # Decreasing epsilon
        action = bandit.epsilon_greedy(epsilon)
        
        # Get reward and update
        reward = bandit.pull_arm(action)
        bandit.update_estimate(action, reward)
        
        total_reward += reward
        rewards_history.append(reward)
    
    # Plot results
    plt.figure(figsize=(10, 5))
    
    plt.subplot(1, 2, 1)
    cumulative_rewards = np.cumsum(rewards_history)
    plt.plot(cumulative_rewards)
    plt.xlabel('Steps')
    plt.ylabel('Cumulative Reward')
    plt.title('Cumulative Rewards Over Time')
    plt.grid(True, alpha=0.3)
    
    plt.subplot(1, 2, 2)
    plt.bar(range(len(bandit.estimates)), bandit.estimates)
    plt.xlabel('Arm')
    plt.ylabel('Estimated Value')
    plt.title('Final Arm Estimates')
    plt.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    return total_reward, bandit.estimates

1.4 RL in the Real World

Domain Application Key RL Component
Healthcare Personalized treatment plans Reward = Patient health improvement
Finance Algorithmic trading Action = Buy/Sell/Hold decisions
Robotics Robot manipulation State = Sensor readings
Games AlphaGo, Dota 2 Policy = Game strategy
Autonomous Vehicles Self-driving cars Transition = Road dynamics

1.5 Your First Complete RL Project

class GridWorld:
    """A simple grid world environment for beginners"""
    
    def __init__(self, size=5):
        self.size = size
        self.state = (0, 0)  # Start at top-left
        self.goal = (size-1, size-1)  # Goal at bottom-right
        
        # Define obstacles (walls)
        self.obstacles = [(1, 1), (2, 2), (3, 1)]
        
        # Define rewards
        self.rewards = {
            self.goal: 10,  # Goal reward
            (2, 3): -5,     # Trap
            (1, 3): 2,      # Small positive reward
        }
        
        # Actions: 0=up, 1=right, 2=down, 3=left
        self.actions = [0, 1, 2, 3]
        self.action_map = {
            0: (-1, 0),   # up
            1: (0, 1),    # right
            2: (1, 0),    # down
            3: (0, -1)    # left
        }
    
    def reset(self):
        """Reset environment to start state"""
        self.state = (0, 0)
        return self.state
    
    def step(self, action):
        """Take action, return (next_state, reward, done)"""
        current_row, current_col = self.state
        
        # Get movement
        row_change, col_change = self.action_map[action]
        new_row = current_row + row_change
        new_col = current_col + col_change
        
        # Check boundaries
        if new_row < 0 or new_row >= self.size or new_col < 0 or new_col >= self.size:
            new_row, new_col = current_row, current_col  # Stay in place
        
        # Check obstacles
        if (new_row, new_col) in self.obstacles:
            new_row, new_col = current_row, current_col  # Stay in place
        
        self.state = (new_row, new_col)
        
        # Get reward
        reward = self.rewards.get((new_row, new_col), -0.1)  # Small penalty for each step
        
        # Check if done
        done = (self.state == self.goal)
        
        return self.state, reward, done
    
    def render(self):
        """Print the current grid state"""
        grid = [['.' for _ in range(self.size)] for _ in range(self.size)]
        
        # Mark obstacles
        for r, c in self.obstacles:
            grid[r][c] = 'X'
        
        # Mark goal
        grid[self.goal[0]][self.goal[1]] = 'G'
        
        # Mark agent
        agent_r, agent_c = self.state
        grid[agent_r][agent_c] = 'A'
        
        # Print grid
        print('+' + '---' * self.size + '+')
        for row in grid:
            print('|' + ' '.join(f' {cell} ' for cell in row) + '|')
        print('+' + '---' * self.size + '+')
        
        # Legend
        print("\nLegend: A = Agent, G = Goal, X = Obstacle, . = Empty")

Try running the GridWorld:

# Create and test the GridWorld
env = GridWorld(size=5)
state = env.reset()

print("Initial State:")
env.render()

# Try some actions
actions = [1, 1, 2, 2, 1, 2]  # right, right, down, down, right, down

for i, action in enumerate(actions):
    print(f"\nStep {i+1}: Taking action {action}")
    state, reward, done = env.step(action)
    env.render()
    print(f"Reward: {reward}, Done: {done}")
    
    if done:
        print("Goal reached!")
        break

Chapter 2: The Blueprint - Markov Decision Processes Demystified

2.1 What Exactly is an MDP?

Formal Definition: An MDP is defined by the 5-tuple: $(S, A, P, R, \gamma)$

  • $S$: Set of states
  • $A$: Set of actions
  • $P$: Transition probability $P(s'|s,a)$
  • $R$: Reward function $R(s,a,s')$
  • $\gamma$: Discount factor $(0 \leq \gamma \leq 1)$

2.2 Building Your First MDP

class SimpleMDP:
    """Implementation of a simple MDP for the Coffee Shop Problem"""
    
    def __init__(self):
        # Define states
        self.states = ['home', 'walking', 'shop', 'work']
        
        # Define actions
        self.actions = ['stay', 'go_to_shop', 'go_to_work', 'buy_coffee']
        
        # Transition probabilities P(s'|s,a)
        self.transitions = {
            ('home', 'go_to_shop'): {'walking': 1.0},
            ('walking', 'stay'): {'shop': 0.8, 'walking': 0.2},  # 20% chance of getting lost
            ('shop', 'buy_coffee'): {'work': 1.0},
            ('work', 'stay'): {'work': 1.0},
            # Invalid actions lead back to same state
            ('home', 'stay'): {'home': 1.0},
            ('walking', 'go_to_work'): {'walking': 1.0},  # Can't go directly to work from walking
        }
        
        # Rewards R(s,a,s')
        self.rewards = {
            ('home', 'go_to_shop', 'walking'): -1,      # Effort to leave home
            ('walking', 'stay', 'shop'): +5,            # Successfully reached shop
            ('walking', 'stay', 'walking'): -2,         # Got lost
            ('shop', 'buy_coffee', 'work'): +10,        # Got coffee and went to work
            ('work', 'stay', 'work'): +1,               # Being productive
            ('home', 'stay', 'home'): 0,                # Doing nothing
        }
        
        # Discount factor
        self.gamma = 0.9
        
        # Current state
        self.current_state = 'home'
    
    def get_possible_actions(self, state):
        """Get valid actions for a given state"""
        valid_actions = []
        for action in self.actions:
            if (state, action) in self.transitions:
                valid_actions.append(action)
        return valid_actions
    
    def step(self, action):
        """Take action, return (next_state, reward)"""
        current_state = self.current_state
        
        # Check if action is valid
        if (current_state, action) not in self.transitions:
            print(f"Invalid action '{action}' from state '{current_state}'")
            return current_state, 0
        
        # Get transition probabilities
        next_state_probs = self.transitions[(current_state, action)]
        
        # Sample next state
        next_states = list(next_state_probs.keys())
        probabilities = list(next_state_probs.values())
        next_state = np.random.choice(next_states, p=probabilities)
        
        # Get reward
        reward = self.rewards.get((current_state, action, next_state), 0)
        
        # Update state
        self.current_state = next_state
        
        return next_state, reward
    
    def reset(self):
        """Reset MDP to initial state"""
        self.current_state = 'home'
        return self.current_state
    
    def simulate_episode(self, policy):
        """Simulate one episode following a given policy"""
        state = self.reset()
        total_reward = 0
        episode_history = []
        
        for step in range(20):  # Max 20 steps
            # Get action from policy
            if state in policy:
                action = policy[state]
            else:
                # Default to 'stay' if no policy defined
                action = 'stay'
            
            # Take action
            next_state, reward = self.step(action)
            
            # Record step
            episode_history.append({
                'step': step,
                'state': state,
                'action': action,
                'reward': reward,
                'next_state': next_state
            })
            
            total_reward += (self.gamma ** step) * reward
            state = next_state
            
            # Check if terminal state (work)
            if state == 'work':
                break
        
        return episode_history, total_reward

2.3 Understanding the Markov Property

Definition: $P(s_{t+1} | s_t, a_t, s_{t-1}, a_{t-1}, ...) = P(s_{t+1} | s_t, a_t)$

Example: Traffic Light MDP

class TrafficLightMDP:
    """MDP for traffic light control"""
    
    def __init__(self):
        # States: (light_color, queue_length)
        # light_color: 0=red, 1=green, 2=yellow
        # queue_length: 0-10 cars
        self.states = [(color, length) for color in range(3) for length in range(11)]
        
        # Actions: 0=keep_current, 1=switch_to_red, 2=switch_to_green
        self.actions = [0, 1, 2]
        
        # Transition probabilities
        self.transitions = self._build_transition_model()
        
        # Rewards
        self.rewards = self._build_reward_model()
        
        self.gamma = 0.9
    
    def _build_transition_model(self):
        """Build transition probability model"""
        transitions = {}
        
        for state in self.states:
            color, queue = state
            
            for action in self.actions:
                next_state_probs = {}
                
                if action == 0:  # Keep current
                    next_color = color
                elif action == 1:  # Switch to red
                    next_color = 0
                else:  # Switch to green
                    next_color = 1
                
                # Queue dynamics
                # Cars arrive randomly
                arrival_rate = 0.3
                # Cars depart if light is green
                departure_rate = 0.8 if next_color == 1 else 0
                
                # Simplified: only consider +/- 1 car changes
                for delta in [-1, 0, 1]:
                    new_queue = max(0, min(10, queue + delta))
                    
                    if delta == -1:  # Car leaves
                        prob = departure_rate * 0.5
                    elif delta == 1:  # Car arrives
                        prob = arrival_rate
                    else:  # No change
                        prob = 1 - departure_rate * 0.5 - arrival_rate
                    
                    next_state = (next_color, new_queue)
                    next_state_probs[next_state] = prob
                
                transitions[(state, action)] = next_state_probs
        
        return transitions
    
    def _build_reward_model(self):
        """Build reward model"""
        rewards = {}
        
        for (state, action), next_states in self.transitions.items():
            color, queue = state
            
            for next_state, prob in next_states.items():
                next_color, next_queue = next_state
                
                # Reward: negative for long queues, negative for switching lights
                reward = -queue  # Penalty for current queue length
                
                if action != 0:  # Penalty for switching
                    reward -= 2
                
                if next_color == 1 and next_queue > 0:  # Bonus for serving cars
                    reward += 5
                
                rewards[(state, action, next_state)] = reward
        
        return rewards
    
    def visualize_state_space(self):
        """Visualize the state space"""
        import matplotlib.pyplot as plt
        
        fig, axes = plt.subplots(1, 3, figsize=(15, 5))
        colors = ['red', 'green', 'yellow']
        
        for color_idx, color_name in enumerate(colors):
            ax = axes[color_idx]
            
            # Extract states for this color
            states_for_color = [(q, 0) for q in range(11)]
            
            # Simple visualization
            ax.bar(range(11), [q for q in range(11)])
            ax.set_xlabel('Queue Length')
            ax.set_ylabel('State Value')
            ax.set_title(f'Light Color: {color_name}')
            ax.set_ylim(0, 10)
        
        plt.tight_layout()
        plt.show()

2.4 Discounting and Returns

Why Discount Future Rewards?

  1. Mathematical convenience: Guarantees finite returns
  2. Economic sense: Money now is worth more than money later
  3. Uncertainty: Distant future is less certain
  4. Biological basis: Animals prefer immediate rewards

Return Calculation:

def calculate_return(rewards, gamma=0.9):
    """
    Calculate discounted return from a sequence of rewards
    
    Return = R₁ + γR₂ + γ²R₃ + γ³R₄ + ...
    """
    total = 0
    for i, reward in enumerate(rewards):
        total += (gamma ** i) * reward
    return total

def calculate_returns_from_history(history, gamma=0.9):
    """Calculate returns from episode history"""
    returns = []
    G = 0
    
    # Work backwards from the end
    for step in reversed(history):
        G = step['reward'] + gamma * G
        returns.insert(0, G)  # Insert at beginning
    
    return returns

# Example usage
rewards = [1, 2, 3, 4, 5]
print(f"Undiscounted return: {sum(rewards)}")
print(f"Discounted return (γ=0.9): {calculate_return(rewards, gamma=0.9)}")
print(f"Discounted return (γ=0.5): {calculate_return(rewards, gamma=0.5)}")

2.5 Policies: The Agent's Strategy

class Policy:
    """Base class for policies"""
    
    def __init__(self, mdp):
        self.mdp = mdp
    
    def select_action(self, state):
        """Select action given state - to be implemented by subclasses"""
        raise NotImplementedError
    
    def update(self, state, action, reward, next_state):
        """Update policy based on experience"""
        pass

class RandomPolicy(Policy):
    """Select actions randomly"""
    
    def select_action(self, state):
        valid_actions = self.mdp.get_possible_actions(state)
        return np.random.choice(valid_actions)

class GreedyPolicy(Policy):
    """Select best action based on Q-values"""
    
    def __init__(self, mdp, Q):
        super().__init__(mdp)
        self.Q = Q  # Q-value table
    
    def select_action(self, state):
        valid_actions = self.mdp.get_possible_actions(state)
        
        if not valid_actions:
            return None
        
        # Find action with maximum Q-value
        q_values = [self.Q.get((state, action), 0) for action in valid_actions]
        max_q = max(q_values)
        
        # If multiple actions have same Q-value, choose randomly among them
        best_actions = [action for action, q in zip(valid_actions, q_values) if q == max_q]
        return np.random.choice(best_actions)

class EpsilonGreedyPolicy(Policy):
    """Epsilon-greedy policy for exploration"""
    
    def __init__(self, mdp, Q, epsilon=0.1):
        super().__init__(mdp)
        self.Q = Q
        self.epsilon = epsilon
    
    def select_action(self, state):
        valid_actions = self.mdp.get_possible_actions(state)
        
        if not valid_actions:
            return None
        
        if np.random.random() < self.epsilon:
            # Explore: random action
            return np.random.choice(valid_actions)
        else:
            # Exploit: greedy action
            q_values = [self.Q.get((state, action), 0) for action in valid_actions]
            max_q = max(q_values)
            best_actions = [action for action, q in zip(valid_actions, q_values) if q == max_q]
            return np.random.choice(best_actions)
    
    def decay_epsilon(self, decay_rate=0.995, min_epsilon=0.01):
        """Decay exploration rate over time"""
        self.epsilon = max(min_epsilon, self.epsilon * decay_rate)

2.6 Chapter Project: The Pizza Delivery MDP

class PizzaDeliveryMDP:
    """MDP for pizza delivery optimization"""
    
    def __init__(self, n_locations=5):
        self.n_locations = n_locations
        
        # States: (current_location, has_pizza, time_remaining)
        self.states = []
        for loc in range(n_locations):
            for has_pizza in [0, 1]:
                for time_rem in range(6):  # 0-5 time units remaining
                    self.states.append((loc, has_pizza, time_rem))
        
        # Actions: 0=wait, 1=move_to_loc_0, ..., n_locations-1=move_to_loc_n-1
        self.actions = list(range(n_locations + 1))  # +1 for wait
        
        # Special locations
        self.pizzeria = 0
        self.delivery_locations = list(range(1, n_locations))
        
        # Build transition and reward models
        self.transitions = self._build_transitions()
        self.rewards = self._build_rewards()
        
        self.gamma = 0.9
    
    def _build_transitions(self):
        """Build transition probabilities"""
        transitions = {}
        
        for state in self.states:
            current_loc, has_pizza, time_rem = state
            
            for action in self.actions:
                next_state_probs = {}
                
                if time_rem == 0:
                    # Terminal state - stay here forever
                    next_state_probs[state] = 1.0
                else:
                    # Time decreases by 1
                    new_time = time_rem - 1
                    
                    if action == 0:  # Wait
                        new_loc = current_loc
                        new_has_pizza = has_pizza
                        next_state_probs[(new_loc, new_has_pizza, new_time)] = 1.0
                    
                    else:  # Move to location (action-1)
                        target_loc = action - 1
                        
                        # 80% chance of successful move, 20% chance of getting stuck
                        if np.random.random() < 0.8:
                            new_loc = target_loc
                        else:
                            new_loc = current_loc
                        
                        # Update pizza status
                        if new_loc == self.pizzeria:
                            new_has_pizza = 1  # Pick up pizza
                        elif new_loc in self.delivery_locations and has_pizza:
                            new_has_pizza = 0  # Deliver pizza
                        else:
                            new_has_pizza = has_pizza  # Keep current status
                        
                        next_state_probs[(new_loc, new_has_pizza, new_time)] = 1.0
                
                transitions[(state, action)] = next_state_probs
        
        return transitions
    
    def _build_rewards(self):
        """Build reward function"""
        rewards = {}
        
        for (state, action), next_states in self.transitions.items():
            current_loc, has_pizza, time_rem = state
            
            for next_state, prob in next_states.items():
                new_loc, new_has_pizza, new_time = next_state
                
                # Base reward
                reward = -1  # Cost for each time step
                
                # Delivery reward
                if has_pizza and new_has_pizza == 0 and current_loc != new_loc:
                    # Successfully delivered pizza
                    reward += 20
                
                # Pickup reward (smaller than delivery)
                if has_pizza == 0 and new_has_pizza == 1 and current_loc != new_loc:
                    # Successfully picked up pizza
                    reward += 5
                
                # Timeout penalty
                if new_time == 0 and new_has_pizza == 1:
                    # Failed to deliver before timeout
                    reward -= 50
                
                rewards[(state, action, next_state)] = reward
        
        return rewards
    
    def simulate_delivery(self, policy, max_steps=20):
        """Simulate one delivery episode"""
        # Start at pizzeria without pizza, with 5 time units
        state = (self.pizzeria, 0, 5)
        total_reward = 0
        history = []
        
        for step in range(max_steps):
            current_loc, has_pizza, time_rem = state
            
            # Get action from policy
            action = policy.select_action(state)
            
            if action is None:
                break
            
            # Take action
            next_states = self.transitions[(state, action)]
            next_state = np.random.choice(
                list(next_states.keys()),
                p=list(next_states.values())
            )
            
            reward = self.rewards[(state, action, next_state)]
            
            # Record
            history.append({
                'step': step,
                'state': state,
                'action': action,
                'reward': reward,
                'next_state': next_state
            })
            
            total_reward += (self.gamma ** step) * reward
            state = next_state
            
            # Check termination
            _, has_pizza, time_rem = state
            if time_rem == 0:
                break
        
        return history, total_reward

# Create and test the MDP
pizza_mdp = PizzaDeliveryMDP(n_locations=5)

# Test random policy
random_policy = RandomPolicy(pizza_mdp)
history, total_reward = pizza_mdp.simulate_delivery(random_policy)

print(f"Random Policy Total Reward: {total_reward:.2f}")
print("\nDelivery History:")
for step in history[:5]:  # Show first 5 steps
    print(f"Step {step['step']}: At location {step['state'][0]}, "
          f"Pizza: {'Yes' if step['state'][1] else 'No'}, "
          f"Action: {step['action']}, Reward: {step['reward']}")

Chapter 3: The Dream of Perfection - What is Optimal?

3.1 Value Functions: Measuring "Goodness"

State-Value Function $V^\pi(s)$: "How good is it to be in state $s$ when following policy $\pi$?"

Action-Value Function $Q^\pi(s,a)$: "How good is it to take action $a$ in state $s$, then follow policy $\pi$?"

class ValueFunctions:
    """Implementation of value functions"""
    
    @staticmethod
    def calculate_state_value(mdp, state, policy, gamma=0.9, depth=0, max_depth=10):
        """
        Calculate V^π(s) recursively
        
        V^π(s) = Σ_a π(a|s) Σ_s' P(s'|s,a) [R(s,a,s') + γV^π(s')]
        """
        if depth > max_depth:
            return 0
        
        state_value = 0
        
        # Get valid actions
        valid_actions = mdp.get_possible_actions(state)
        
        for action in valid_actions:
            # Action probability under policy
            action_prob = policy.get_action_probability(state, action)
            
            if action_prob == 0:
                continue
            
            # Sum over possible next states
            next_state_probs = mdp.transitions.get((state, action), {})
            
            for next_state, trans_prob in next_state_probs.items():
                reward = mdp.rewards.get((state, action, next_state), 0)
                
                # Recursive call for next state's value
                next_state_value = ValueFunctions.calculate_state_value(
                    mdp, next_state, policy, gamma, depth + 1, max_depth
                )
                
                state_value += action_prob * trans_prob * (reward + gamma * next_state_value)
        
        return state_value
    
    @staticmethod
    def calculate_action_value(mdp, state, action, policy, gamma=0.9, depth=0, max_depth=10):
        """
        Calculate Q^π(s,a) recursively
        
        Q^π(s,a) = Σ_s' P(s'|s,a) [R(s,a,s') + γV^π(s')]
        """
        if depth > max_depth:
            return 0
        
        action_value = 0
        next_state_probs = mdp.transitions.get((state, action), {})
        
        for next_state, trans_prob in next_state_probs.items():
            reward = mdp.rewards.get((state, action, next_state), 0)
            
            # Calculate next state's value
            next_state_value = ValueFunctions.calculate_state_value(
                mdp, next_state, policy, gamma, depth + 1, max_depth
            )
            
            action_value += trans_prob * (reward + gamma * next_state_value)
        
        return action_value
    
    @staticmethod
    def compute_value_table(mdp, policy, gamma=0.9, max_depth=10):
        """Compute V^π(s) for all states"""
        V = {}
        
        for state in mdp.states:
            V[state] = ValueFunctions.calculate_state_value(
                mdp, state, policy, gamma, 0, max_depth
            )
        
        return V
    
    @staticmethod
    def compute_q_table(mdp, policy, gamma=0.9, max_depth=10):
        """Compute Q^π(s,a) for all state-action pairs"""
        Q = {}
        
        for state in mdp.states:
            valid_actions = mdp.get_possible_actions(state)
            
            for action in valid_actions:
                Q[(state, action)] = ValueFunctions.calculate_action_value(
                    mdp, state, action, policy, gamma, 0, max_depth
                )
        
        return Q

3.2 The Bellman Equations

Bellman Expectation Equation for $V^\pi$:

V^π(s) = Σ_a π(a|s) Σ_s' P(s'|s,a) [R(s,a,s') + γV^π(s')]

Bellman Expectation Equation for $Q^\pi$:

Q^π(s,a) = Σ_s' P(s'|s,a) [R(s,a,s') + γ Σ_a' π(a'|s') Q^π(s',a')]
def bellman_expectation_iteration(mdp, policy, gamma=0.9, threshold=1e-4):
    """
    Solve Bellman expectation equations through iteration
    
    Returns: V^π for all states
    """
    # Initialize value function
    V = {state: 0 for state in mdp.states}
    
    iteration = 0
    while True:
        delta = 0
        new_V = {}
        
        for state in mdp.states:
            state_value = 0
            
            # Get valid actions
            valid_actions = mdp.get_possible_actions(state)
            
            for action in valid_actions:
                # Action probability under policy
                action_prob = policy.get_action_probability(state, action)
                
                if action_prob == 0:
                    continue
                
                # Sum over possible next states
                next_state_probs = mdp.transitions.get((state, action), {})
                
                for next_state, trans_prob in next_state_probs.items():
                    reward = mdp.rewards.get((state, action, next_state), 0)
                    
                    state_value += action_prob * trans_prob * (reward + gamma * V[next_state])
            
            new_V[state] = state_value
            delta = max(delta, abs(new_V[state] - V[state]))
        
        # Update values
        V = new_V
        iteration += 1
        
        # Check convergence
        if delta < threshold:
            print(f"Converged after {iteration} iterations with delta={delta:.6f}")
            break
        
        if iteration % 100 == 0:
            print(f"Iteration {iteration}, delta={delta:.6f}")
    
    return V

def bellman_optimality_iteration(mdp, gamma=0.9, threshold=1e-4):
    """
    Solve Bellman optimality equations through iteration
    
    Returns: V* and π* for all states
    """
    # Initialize value function
    V = {state: 0 for state in mdp.states}
    
    iteration = 0
    while True:
        delta = 0
        new_V = {}
        
        for state in mdp.states:
            # Find maximum over actions
            max_value = float('-inf')
            
            # Get valid actions
            valid_actions = mdp.get_possible_actions(state)
            
            for action in valid_actions:
                action_value = 0
                
                # Sum over possible next states
                next_state_probs = mdp.transitions.get((state, action), {})
                
                for next_state, trans_prob in next_state_probs.items():
                    reward = mdp.rewards.get((state, action, next_state), 0)
                    
                    action_value += trans_prob * (reward + gamma * V[next_state])
                
                max_value = max(max_value, action_value)
            
            new_V[state] = max_value
            delta = max(delta, abs(new_V[state] - V[state]))
        
        # Update values
        V = new_V
        iteration += 1
        
        # Check convergence
        if delta < threshold:
            print(f"Converged after {iteration} iterations with delta={delta:.6f}")
            break
        
        if iteration % 100 == 0:
            print(f"Iteration {iteration}, delta={delta:.6f}")
    
    # Extract optimal policy from optimal values
    optimal_policy = {}
    
    for state in mdp.states:
        best_action = None
        best_value = float('-inf')
        
        valid_actions = mdp.get_possible_actions(state)
        
        for action in valid_actions:
            action_value = 0
            
            # Calculate Q-value for this state-action pair
            next_state_probs = mdp.transitions.get((state, action), {})
            
            for next_state, trans_prob in next_state_probs.items():
                reward = mdp.rewards.get((state, action, next_state), 0)
                
                action_value += trans_prob * (reward + gamma * V[next_state])
            
            if action_value > best_value:
                best_value = action_value
                best_action = action
        
        if best_action is not None:
            optimal_policy[state] = {best_action: 1.0}
        else:
            # No valid actions (terminal state)
            optimal_policy[state] = {}
    
    return V, optimal_policy

3.3 Finding Optimal Policies

Optimal Value Functions:

  • $V^*(s) = \max_\pi V^\pi(s)$
  • $Q^*(s,a) = \max_\pi Q^\pi(s,a)$

Optimal Policy: $\pi^(s) = \arg\max_a Q^(s,a)$

class OptimalPolicyFinder:
    """Methods for finding optimal policies"""
    
    @staticmethod
    def value_iteration(mdp, gamma=0.9, threshold=1e-4):
        """Value iteration algorithm"""
        return bellman_optimality_iteration(mdp, gamma, threshold)
    
    @staticmethod
    def policy_iteration(mdp, gamma=0.9, threshold=1e-4):
        """Policy iteration algorithm"""
        # Start with random policy
        policy = {}
        for state in mdp.states:
            valid_actions = mdp.get_possible_actions(state)
            if valid_actions:
                # Uniform random policy
                policy[state] = {action: 1.0/len(valid_actions) for action in valid_actions}
            else:
                policy[state] = {}
        
        iteration = 0
        while True:
            iteration += 1
            
            # Policy Evaluation
            V = bellman_expectation_iteration(mdp, policy, gamma, threshold)
            
            # Policy Improvement
            policy_stable = True
            new_policy = {}
            
            for state in mdp.states:
                valid_actions = mdp.get_possible_actions(state)
                
                if not valid_actions:
                    new_policy[state] = {}
                    continue
                
                # Find best action(s)
                best_actions = []
                best_value = float('-inf')
                
                for action in valid_actions:
                    action_value = 0
                    
                    # Calculate Q-value
                    next_state_probs = mdp.transitions.get((state, action), {})
                    
                    for next_state, trans_prob in next_state_probs.items():
                        reward = mdp.rewards.get((state, action, next_state), 0)
                        action_value += trans_prob * (reward + gamma * V[next_state])
                    
                    if action_value > best_value:
                        best_value = action_value
                        best_actions = [action]
                    elif abs(action_value - best_value) < 1e-6:
                        best_actions.append(action)
                
                # Greedy policy: equal probability for all best actions
                new_action_dist = {action: 1.0/len(best_actions) for action in best_actions}
                new_policy[state] = new_action_dist
                
                # Check if policy changed
                if state in policy:
                    old_best = max(policy[state].items(), key=lambda x: x[1])[0]
                    new_best = max(new_action_dist.items(), key=lambda x: x[1])[0]
                    
                    if old_best != new_best:
                        policy_stable = False
            
            policy = new_policy
            
            if policy_stable:
                print(f"Policy converged after {iteration} iterations")
                break
        
        # Convert to deterministic policy
        deterministic_policy = {}
        for state, action_dist in policy.items():
            if action_dist:
                best_action = max(action_dist.items(), key=lambda x: x[1])[0]
                deterministic_policy[state] = best_action
            else:
                deterministic_policy[state] = None
        
        return V, deterministic_policy
    
    @staticmethod
    def compare_algorithms(mdp, gamma=0.9):
        """Compare value iteration and policy iteration"""
        import time
        
        print("="*60)
        print("COMPARING OPTIMAL POLICY FINDING ALGORITHMS")
        print("="*60)
        
        # Value Iteration
        print("\n1. VALUE ITERATION")
        start_time = time.time()
        V_vi, policy_vi = OptimalPolicyFinder.value_iteration(mdp, gamma)
        vi_time = time.time() - start_time
        
        # Policy Iteration
        print("\n2. POLICY ITERATION")
        start_time = time.time()
        V_pi, policy_pi = OptimalPolicyFinder.policy_iteration(mdp, gamma)
        pi_time = time.time() - start_time
        
        # Compare results
        print("\n" + "="*60)
        print("COMPARISON RESULTS")
        print("="*60)
        print(f"Value Iteration Time:    {vi_time:.4f} seconds")
        print(f"Policy Iteration Time:   {pi_time:.4f} seconds")
        
        # Check if policies are equivalent
        policy_match = True
        for state in mdp.states:
            if state in policy_vi and state in policy_pi:
                if policy_vi[state] != policy_pi[state]:
                    policy_match = False
                    print(f"Policy mismatch at state {state}: VI={policy_vi[state]}, PI={policy_pi[state]}")
        
        if policy_match:
            print("\n✓ Both algorithms found the same optimal policy!")
        else:
            print("\n✗ Algorithms found different policies")
        
        # Compare value functions
        value_diff = 0
        for state in mdp.states:
            if state in V_vi and state in V_pi:
                diff = abs(V_vi[state] - V_pi[state])
                value_diff = max(value_diff, diff)
        
        print(f"Maximum value difference: {value_diff:.6f}")
        
        return V_vi, policy_vi, V_pi, policy_pi

3.4 Solving Small MDPs Manually

The 3-State Maze Example:

def solve_3_state_maze_manually():
    """
    Solve a simple 3-state maze manually
    States: A -- B -- C
    Start: A, Goal: C (reward +10)
    Each move: cost -1
    γ = 0.9
    """
    
    print("="*60)
    print("SOLVING 3-STATE MAZE MANUALLY")
    print("="*60)
    
    print("\nMaze Layout:")
    print("A -- B -- C")
    print("Start: A, Goal: C")
    print("Move cost: -1, Goal reward: +10, γ = 0.9")
    
    print("\n" + "-"*60)
    print("STEP 1: Define terminal state values")
    print("-"*60)
    
    # Terminal state: C (goal reached)
    V_C = 0  # No future rewards from terminal state
    print(f"V(C) = 0 (terminal state)")
    
    print("\n" + "-"*60)
    print("STEP 2: Work backwards from the goal")
    print("-"*60)
    
    # State B: Can only go to C
    print("\nState B:")
    print("Only action: go to C")
    print("Reward: -1 (move cost) + 10 (goal reward) = 9")
    print("V(B) = R + γV(C)")
    print(f"     = 9 + 0.9 * 0")
    V_B = 9
    print(f"     = {V_B}")
    
    print("\n" + "-"*60)
    print("STEP 3: Continue working backwards")
    print("-"*60)
    
    # State A: Can go to B
    print("\nState A:")
    print("Only action: go to B")
    print("Reward: -1 (move cost)")
    print("V(A) = R + γV(B)")
    print(f"     = -1 + 0.9 * {V_B}")
    V_A = -1 + 0.9 * V_B
    print(f"     = {V_A:.1f}")
    
    print("\n" + "-"*60)
    print("FINAL VALUE FUNCTION")
    print("-"*60)
    
    print(f"V(A) = {V_A:.1f}")
    print(f"V(B) = {V_B}")
    print(f"V(C) = {V_C}")
    
    print("\n" + "-"*60)
    print("OPTIMAL POLICY")
    print("-"*60)
    
    print("From A: go to B (only choice)")
    print("From B: go to C (only choice)")
    print("From C: terminal state")
    
    return {"A": V_A, "B": V_B, "C": V_C}

# Run the manual solution
maze_values = solve_3_state_maze_manually()

3.5 Visualizing Value Functions

def visualize_value_function(mdp, V, title="Value Function"):
    """Visualize value function for grid-like MDPs"""
    import matplotlib.pyplot as plt
    import numpy as np
    
    # Check if states are grid coordinates
    sample_state = next(iter(V.keys()))
    
    if isinstance(sample_state, tuple) and len(sample_state) == 2:
        # Extract grid dimensions
        rows = max(s[0] for s in V.keys()) + 1
        cols = max(s[1] for s in V.keys()) + 1
        
        # Create grid
        grid = np.zeros((rows, cols))
        
        for (r, c), value in V.items():
            grid[r, c] = value
        
        # Plot
        plt.figure(figsize=(10, 8))
        
        # Heatmap
        plt.subplot(1, 2, 1)
        im = plt.imshow(grid, cmap='RdYlGn', aspect='auto')
        plt.colorbar(im, label='State Value')
        plt.title(f'{title} - Heatmap')
        plt.xlabel('Column')
        plt.ylabel('Row')
        
        # Add value annotations
        for i in range(rows):
            for j in range(cols):
                plt.text(j, i, f'{grid[i, j]:.1f}', 
                        ha='center', va='center', 
                        color='black' if abs(grid[i, j]) < 5 else 'white')
        
        # 3D surface plot
        plt.subplot(1, 2, 2, projection='3d')
        X, Y = np.meshgrid(range(cols), range(rows))
        surf = plt.gca().plot_surface(X, Y, grid, cmap='RdYlGn', 
                                      linewidth=0, antialiased=False)
        plt.colorbar(surf, label='State Value', shrink=0.5)
        plt.title(f'{title} - 3D Surface')
        plt.xlabel('Column')
        plt.ylabel('Row')
        plt.gca().set_zlabel('Value')
        
        plt.tight_layout()
        plt.show()
        
    else:
        # Bar chart for non-grid states
        plt.figure(figsize=(12, 6))
        
        states = list(V.keys())
        values = [V[s] for s in states]
        
        # Convert states to strings for labeling
        state_labels = [str(s) for s in states]
        
        plt.bar(range(len(states)), values, color='steelblue', alpha=0.7)
        plt.xticks(range(len(states)), state_labels, rotation=45, ha='right')
        plt.ylabel('State Value')
        plt.title(title)
        plt.grid(True, alpha=0.3)
        plt.tight_layout()
        plt.show()

def plot_value_iteration_convergence(mdp, gamma=0.9):
    """Plot convergence of value iteration"""
    import matplotlib.pyplot as plt
    
    V = {state: 0 for state in mdp.states}
    deltas = []
    values_history = []
    
    iteration = 0
    while True:
        delta = 0
        new_V = {}
        
        for state in mdp.states:
            max_value = float('-inf')
            valid_actions = mdp.get_possible_actions(state)
            
            for action in valid_actions:
                action_value = 0
                next_state_probs = mdp.transitions.get((state, action), {})
                
                for next_state, trans_prob in next_state_probs.items():
                    reward = mdp.rewards.get((state, action, next_state), 0)
                    action_value += trans_prob * (reward + gamma * V[next_state])
                
                max_value = max(max_value, action_value)
            
            new_V[state] = max_value
            delta = max(delta, abs(new_V[state] - V[state]))
        
        V = new_V
        deltas.append(delta)
        values_history.append(V.copy())
        
        iteration += 1
        
        if delta < 1e-6 or iteration > 100:
            break
    
    # Plot convergence
    plt.figure(figsize=(15, 5))
    
    # Delta plot
    plt.subplot(1, 3, 1)
    plt.plot(deltas, 'b-', linewidth=2)
    plt.yscale('log')
    plt.xlabel('Iteration')
    plt.ylabel('Max Delta (log scale)')
    plt.title('Value Iteration Convergence')
    plt.grid(True, alpha=0.3)
    
    # Value evolution for some states
    plt.subplot(1, 3, 2)
    sample_states = list(mdp.states)[:5]  # First 5 states
    for i, state in enumerate(sample_states):
        state_values = [values[state] for values in values_history]
        plt.plot(state_values, label=f'State {state}', linewidth=2)
    
    plt.xlabel('Iteration')
    plt.ylabel('State Value')
    plt.title('Value Evolution')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # Final value distribution
    plt.subplot(1, 3, 3)
    final_values = list(V.values())
    plt.hist(final_values, bins=20, color='steelblue', alpha=0.7, edgecolor='black')
    plt.xlabel('Final State Value')
    plt.ylabel('Frequency')
    plt.title('Distribution of Final Values')
    plt.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    print(f"Converged in {iteration} iterations")
    print(f"Final max delta: {deltas[-1]:.6f}")
    print(f"Average state value: {np.mean(final_values):.4f}")
    
    return V, deltas

3.6 Chapter Project: Value Iteration for Taxi Problem

class TaxiWorld:
    """Taxi environment for value iteration project"""
    
    def __init__(self, grid_size=4):
        self.grid_size = grid_size
        
        # States: (taxi_row, taxi_col, passenger_location, destination)
        # passenger_location: 0=in taxi, 1=location1, 2=location2, 3=location3, 4=location4
        # destination: 1-4
        self.states = []
        
        for r in range(grid_size):
            for c in range(grid_size):
                for passenger_loc in range(5):  # 0-4
                    for destination in range(1, 5):
                        self.states.append((r, c, passenger_loc, destination))
        
        # Actions: 0=up, 1=right, 2=down, 3=left, 4=pickup, 5=dropoff
        self.actions = list(range(6))
        
        # Special locations
        self.passenger_locations = {
            1: (0, 0),
            2: (0, grid_size-1),
            3: (grid_size-1, 0),
            4: (grid_size-1, grid_size-1)
        }
        
        self.destinations = self.passenger_locations
        
        # Build transition model
        self.transitions = self._build_transition_model()
        
        # Build reward model
        self.rewards = self._build_reward_model()
        
        self.gamma = 0.9
    
    def _build_transition_model(self):
        """Build transition probabilities"""
        transitions = {}
        
        for state in self.states:
            taxi_r, taxi_c, passenger_loc, destination = state
            
            for action in self.actions:
                next_state_probs = {}
                
                if action < 4:  # Movement
                    # Determine new taxi position
                    if action == 0:  # up
                        new_r, new_c = max(0, taxi_r - 1), taxi_c
                    elif action == 1:  # right
                        new_r, new_c = taxi_r, min(self.grid_size-1, taxi_c + 1)
                    elif action == 2:  # down
                        new_r, new_c = min(self.grid_size-1, taxi_r + 1), taxi_c
                    else:  # left
                        new_r, new_c = taxi_r, max(0, taxi_c - 1)
                    
                    # Passenger location unchanged
                    new_passenger_loc = passenger_loc
                
                elif action == 4:  # pickup
                    new_r, new_c = taxi_r, taxi_c
                    
                    # Check if taxi is at passenger location
                    if passenger_loc > 0:  # Passenger at a location
                        passenger_loc_coords = self.passenger_locations[passenger_loc]
                        if (taxi_r, taxi_c) == passenger_loc_coords:
                            # Successful pickup
                            new_passenger_loc = 0  # Passenger in taxi
                        else:
                            # Failed pickup
                            new_passenger_loc = passenger_loc
                    else:
                        # Passenger already in taxi
                        new_passenger_loc = passenger_loc
                
                else:  # dropoff
                    new_r, new_c = taxi_r, taxi_c
                    
                    # Check if passenger is in taxi and at destination
                    if passenger_loc == 0:  # Passenger in taxi
                        destination_coords = self.destinations[destination]
                        if (taxi_r, taxi_c) == destination_coords:
                            # Successful dropoff
                            new_passenger_loc = destination  # Passenger delivered
                        else:
                            # Failed dropoff
                            new_passenger_loc = passenger_loc
                    else:
                        # Passenger not in taxi
                        new_passenger_loc = passenger_loc
                
                # Create next state
                next_state = (new_r, new_c, new_passenger_loc, destination)
                next_state_probs[next_state] = 1.0
                
                transitions[(state, action)] = next_state_probs
        
        return transitions
    
    def _build_reward_model(self):
        """Build reward function"""
        rewards = {}
        
        for (state, action), next_states in self.transitions.items():
            taxi_r, taxi_c, passenger_loc, destination = state
            
            for next_state, prob in next_states.items():
                new_r, new_c, new_passenger_loc, new_destination = next_state
                
                # Base reward
                reward = -1  # Cost for each step
                
                # Pickup reward/penalty
                if action == 4:  # pickup
                    if passenger_loc > 0 and (taxi_r, taxi_c) == self.passenger_locations[passenger_loc]:
                        if new_passenger_loc == 0:
                            reward += 5  # Successful pickup
                    else:
                        reward -= 10  # Failed pickup
                
                # Dropoff reward/penalty
                elif action == 5:  # dropoff
                    if passenger_loc == 0 and (taxi_r, taxi_c) == self.destinations[destination]:
                        if new_passenger_loc == destination:
                            reward += 20  # Successful dropoff
                    else:
                        reward -= 10  # Failed dropoff
                
                rewards[(state, action, next_state)] = reward
        
        return rewards
    
    def get_possible_actions(self, state):
        """Get valid actions for a state"""
        return self.actions  # All actions valid in all states
    
    def visualize_policy(self, policy):
        """Visualize policy for different passenger/destination configurations"""
        fig, axes = plt.subplots(4, 4, figsize=(20, 16))
        
        destination_names = {1: 'R', 2: 'G', 3: 'Y', 4: 'B'}
        action_symbols = {
            0: '↑',  # up
            1: '→',  # right
            2: '↓',  # down
            3: '←',  # left
            4: 'P',  # pickup
            5: 'D'   # dropoff
        }
        
        for dest_idx, destination in enumerate(range(1, 5)):
            for pass_idx, passenger_loc in enumerate(range(5)):
                ax = axes[dest_idx, pass_idx]
                
                # Create grid for this configuration
                grid = np.full((self.grid_size, self.grid_size), '', dtype=object)
                
                # Fill grid with actions
                for r in range(self.grid_size):
                    for c in range(self.grid_size):
                        state = (r, c, passenger_loc, destination)
                        
                        if state in policy:
                            action = policy[state]
                            if action is not None:
                                grid[r, c] = action_symbols.get(action, '?')
                
                # Plot grid
                im = ax.imshow(np.ones((self.grid_size, self.grid_size)), 
                              cmap='Blues', vmin=0, vmax=1)
                
                # Add action symbols
                for r in range(self.grid_size):
                    for c in range(self.grid_size):
                        if grid[r, c]:
                            ax.text(c, r, grid[r, c], 
                                   ha='center', va='center', 
                                   fontsize=14, fontweight='bold')
                
                # Mark special locations
                for loc_num, (lr, lc) in self.passenger_locations.items():
                    color = ['red', 'green', 'yellow', 'blue'][loc_num-1]
                    ax.add_patch(plt.Rectangle((lc-0.5, lr-0.5), 1, 1, 
                                              fill=False, edgecolor=color, 
                                              linewidth=3))
                
                # Title
                if passenger_loc == 0:
                    pass_text = "In taxi"
                else:
                    pass_text = f"At {destination_names[passenger_loc]}"
                
                ax.set_title(f"Dest: {destination_names[destination]}, Pass: {pass_text}")
                ax.set_xticks([])
                ax.set_yticks([])
        
        plt.suptitle("Taxi Policy Visualization", fontsize=16, fontweight='bold')
        plt.tight_layout()
        plt.show()

def taxi_value_iteration_project():
    """Complete project: Solve taxi problem with value iteration"""
    print("="*70)
    print("TAXI VALUE ITERATION PROJECT")
    print("="*70)
    
    # Create environment
    print("\n1. Creating Taxi Environment...")
    taxi_env = TaxiWorld(grid_size=4)
    print(f"   Number of states: {len(taxi_env.states):,}")
    print(f"   Number of actions: {len(taxi_env.actions)}")
    
    # Run value iteration
    print("\n2. Running Value Iteration...")
    V, policy = OptimalPolicyFinder.value_iteration(taxi_env, gamma=0.9)
    
    print(f"\n3. Results:")
    print(f"   Optimal value for sample state (0,0,1,4): {V.get((0,0,1,4), 'N/A'):.2f}")
    print(f"   Optimal action for sample state: {policy.get((0,0,1,4), 'N/A')}")
    
    # Visualize
    print("\n4. Visualizing Results...")
    taxi_env.visualize_policy(policy)
    
    # Test policy
    print("\n5. Testing Policy...")
    test_scenarios = [
        ((1, 1, 1, 4), "Taxi at (1,1), passenger at R, destination B"),
        ((3, 2, 0, 2), "Taxi at (3,2), passenger in taxi, destination G"),
        ((0, 3, 3, 1), "Taxi at (0,3), passenger at Y, destination R")
    ]
    
    for state, description in test_scenarios:
        if state in policy:
            action = policy[state]
            action_name = ['up', 'right', 'down', 'left', 'pickup', 'dropoff'][action]
            print(f"   {description}: Action = {action_name}")
        else:
            print(f"   {description}: State not in policy")
    
    return taxi_env, V, policy

# Run the project
if __name__ == "__main__":
    taxi_env, V, policy = taxi_value_iteration_project()

Chapter 4: Dynamic Programming: Planning with a Model

4.1 The Luxury of Having a Model

When you know P(s'|s,a) and R(s,a,s'), you can plan before acting!

class DynamicProgramming:
    """Collection of Dynamic Programming algorithms"""
    
    def __init__(self, mdp):
        self.mdp = mdp
        
    def iterative_policy_evaluation(self, policy, gamma=0.9, theta=1e-4):
        """
        Evaluate a policy through iteration
        
        V_{k+1}(s) = Σ_a π(a|s) Σ_s' P(s'|s,a) [R(s,a,s') + γV_k(s')]
        """
        # Initialize value function
        V = {state: 0 for state in self.mdp.states}
        
        iteration = 0
        while True:
            delta = 0
            new_V = {}
            
            for state in self.mdp.states:
                v = V[state]
                
                # Calculate new value using Bellman expectation equation
                new_v = 0
                valid_actions = self.mdp.get_possible_actions(state)
                
                for action in valid_actions:
                    # Get action probability from policy
                    if state in policy and action in policy[state]:
                        action_prob = policy[state][action]
                    else:
                        action_prob = 0
                    
                    if action_prob == 0:
                        continue
                    
                    # Sum over next states
                    next_state_probs = self.mdp.transitions.get((state, action), {})
                    
                    for next_state, trans_prob in next_state_probs.items():
                        reward = self.mdp.rewards.get((state, action, next_state), 0)
                        new_v += action_prob * trans_prob * (reward + gamma * V[next_state])
                
                new_V[state] = new_v
                delta = max(delta, abs(v - new_v))
            
            # Update value function
            V = new_V
            iteration += 1
            
            # Check convergence
            if delta < theta:
                print(f"Policy evaluation converged in {iteration} iterations (delta={delta:.6f})")
                break
            
            if iteration % 100 == 0:
                print(f"Iteration {iteration}, delta={delta:.6f}")
        
        return V
    
    def policy_improvement(self, V, gamma=0.9):
        """
        Improve policy by acting greedily with respect to V
        
        π'(s) = argmax_a Σ_s' P(s'|s,a) [R(s,a,s') + γV(s')]
        """
        new_policy = {}
        
        for state in self.mdp.states:
            best_action = None
            best_value = float('-inf')
            
            valid_actions = self.mdp.get_possible_actions(state)
            
            for action in valid_actions:
                # Calculate Q-value for this state-action pair
                action_value = 0
                next_state_probs = self.mdp.transitions.get((state, action), {})
                
                for next_state, trans_prob in next_state_probs.items():
                    reward = self.mdp.rewards.get((state, action, next_state), 0)
                    action_value += trans_prob * (reward + gamma * V[next_state])
                
                if action_value > best_value:
                    best_value = action_value
                    best_action = action
            
            if best_action is not None:
                new_policy[state] = {best_action: 1.0}  # Deterministic policy
            else:
                new_policy[state] = {}  # No valid actions
        
        return new_policy
    
    def policy_iteration(self, initial_policy=None, gamma=0.9, theta=1e-4):
        """
        Full policy iteration algorithm
        
        π₀ → Evaluate → Vπ₀ → Improve → π₁ → Evaluate → Vπ₁ → ...
        """
        # Start with initial policy (random if not provided)
        if initial_policy is None:
            policy = self._create_random_policy()
        else:
            policy = initial_policy
        
        iteration = 0
        policy_history = [policy.copy()]
        value_history = []
        
        while True:
            iteration += 1
            print(f"\nPolicy Iteration {iteration}")
            print("-" * 40)
            
            # Policy Evaluation
            print("Step 1: Policy Evaluation...")
            V = self.iterative_policy_evaluation(policy, gamma, theta)
            value_history.append(V.copy())
            
            # Policy Improvement
            print("Step 2: Policy Improvement...")
            new_policy = self.policy_improvement(V, gamma)
            
            # Check if policy has changed
            policy_changed = False
            for state in self.mdp.states:
                if state in policy and state in new_policy:
                    old_best = max(policy[state].items(), key=lambda x: x[1])[0] if policy[state] else None
                    new_best = max(new_policy[state].items(), key=lambda x: x[1])[0] if new_policy[state] else None
                    
                    if old_best != new_best:
                        policy_changed = True
                        break
            
            policy = new_policy
            policy_history.append(policy.copy())
            
            # Check convergence
            if not policy_changed:
                print(f"\n✓ Policy converged after {iteration} iterations!")
                break
        
        return policy, V, policy_history, value_history
    
    def value_iteration(self, gamma=0.9, theta=1e-4):
        """
        Value iteration algorithm
        
        V_{k+1}(s) = max_a Σ_s' P(s'|s,a) [R(s,a,s') + γV_k(s')]
        """
        # Initialize value function
        V = {state: 0 for state in self.mdp.states}
        
        iteration = 0
        value_history = [V.copy()]
        deltas = []
        
        while True:
            delta = 0
            new_V = {}
            
            for state in self.mdp.states:
                v = V[state]
                
                # Find maximum over actions
                max_value = float('-inf')
                valid_actions = self.mdp.get_possible_actions(state)
                
                for action in valid_actions:
                    action_value = 0
                    next_state_probs = self.mdp.transitions.get((state, action), {})
                    
                    for next_state, trans_prob in next_state_probs.items():
                        reward = self.mdp.rewards.get((state, action, next_state), 0)
                        action_value += trans_prob * (reward + gamma * V[next_state])
                    
                    max_value = max(max_value, action_value)
                
                new_V[state] = max_value
                delta = max(delta, abs(v - max_value))
            
            # Update value function
            V = new_V
            value_history.append(V.copy())
            deltas.append(delta)
            
            iteration += 1
            
            # Check convergence
            if delta < theta:
                print(f"Value iteration converged in {iteration} iterations (delta={delta:.6f})")
                break
            
            if iteration % 100 == 0:
                print(f"Iteration {iteration}, delta={delta:.6f}")
        
        # Extract optimal policy
        optimal_policy = self.policy_improvement(V, gamma)
        
        return optimal_policy, V, value_history, deltas
    
    def _create_random_policy(self):
        """Create a random policy for initialization"""
        policy = {}
        
        for state in self.mdp.states:
            valid_actions = self.mdp.get_possible_actions(state)
            
            if valid_actions:
                # Uniform random policy
                action_prob = 1.0 / len(valid_actions)
                policy[state] = {action: action_prob for action in valid_actions}
            else:
                policy[state] = {}  # No valid actions
        
        return policy
    
    def compare_dp_algorithms(self, gamma=0.9):
        """Compare policy iteration and value iteration"""
        import time
        
        print("="*70)
        print("COMPARING DYNAMIC PROGRAMMING ALGORITHMS")
        print("="*70)
        
        # Policy Iteration
        print("\n1. POLICY ITERATION")
        start_time = time.time()
        pi_policy, pi_V, pi_history, pi_values = self.policy_iteration(gamma=gamma)
        pi_time = time.time() - start_time
        
        # Value Iteration
        print("\n2. VALUE ITERATION")
        start_time = time.time()
        vi_policy, vi_V, vi_values, vi_deltas = self.value_iteration(gamma=gamma)
        vi_time = time.time() - start_time
        
        # Results
        print("\n" + "="*70)
        print("RESULTS")
        print("="*70)
        
        print(f"\nPolicy Iteration:")
        print(f"  Time: {pi_time:.3f} seconds")
        print(f"  Iterations: {len(pi_history) - 1}")
        
        print(f"\nValue Iteration:")
        print(f"  Time: {vi_time:.3f} seconds")
        print(f"  Iterations: {len(vi_values) - 1}")
        
        # Compare optimal values
        print(f"\nValue Comparison:")
        sample_states = list(self.mdp.states)[:5]  # First 5 states
        
        for state in sample_states:
            pi_value = pi_V.get(state, 0)
            vi_value = vi_V.get(state, 0)
            diff = abs(pi_value - vi_value)
            
            print(f"  State {state}: PI={pi_value:.4f}, VI={vi_value:.4f}, Diff={diff:.6f}")
        
        # Compare policies
        print(f"\nPolicy Comparison:")
        policy_match = True
        for state in self.mdp.states:
            if state in pi_policy and state in vi_policy:
                pi_best = max(pi_policy[state].items(), key=lambda x: x[1])[0] if pi_policy[state] else None
                vi_best = max(vi_policy[state].items(), key=lambda x: x[1])[0] if vi_policy[state] else None
                
                if pi_best != vi_best:
                    policy_match = False
                    print(f"  State {state}: PI={pi_best}, VI={vi_best}")
        
        if policy_match:
            print("  ✓ Policies match perfectly!")
        else:
            print("  ✗ Policies differ")
        
        return pi_policy, pi_V, vi_policy, vi_V

4.2 Policy Evaluation in Detail

def demonstrate_policy_evaluation():
    """Step-by-step demonstration of policy evaluation"""
    print("="*70)
    print("POLICY EVALUATION DEMONSTRATION")
    print("="*70)
    
    # Create a simple MDP
    class SimpleDemoMDP:
        def __init__(self):
            self.states = ['s1', 's2', 's3']
            self.actions = ['a1', 'a2']
            
            # Transition probabilities
            self.transitions = {
                ('s1', 'a1'): {'s2': 0.7, 's1': 0.3},
                ('s1', 'a2'): {'s3': 1.0},
                ('s2', 'a1'): {'s3': 1.0},
                ('s2', 'a2'): {'s1': 0.5, 's2': 0.5},
                ('s3', 'a1'): {'s3': 1.0},
                ('s3', 'a2'): {'s1': 1.0}
            }
            
            # Rewards
            self.rewards = {
                ('s1', 'a1', 's2'): 5,
                ('s1', 'a1', 's1'): -1,
                ('s1', 'a2', 's3'): 10,
                ('s2', 'a1', 's3'): -2,
                ('s2', 'a2', 's1'): 1,
                ('s2', 'a2', 's2'): -1,
                ('s3', 'a1', 's3'): 0,
                ('s3', 'a2', 's1'): 0
            }
        
        def get_possible_actions(self, state):
            return [a for a in self.actions if (state, a) in self.transitions]
    
    mdp = SimpleDemoMDP()
    dp = DynamicProgramming(mdp)
    
    # Define a policy to evaluate
    policy = {
        's1': {'a1': 0.6, 'a2': 0.4},
        's2': {'a1': 0.3, 'a2': 0.7},
        's3': {'a1': 0.5, 'a2': 0.5}
    }
    
    print("\nInitial Policy:")
    for state, actions in policy.items():
        print(f"  π({state}) = {actions}")
    
    print("\n" + "-"*70)
    print("STEP 1: Initialize Value Function")
    print("-"*70)
    
    V = {state: 0 for state in mdp.states}
    print(f"V₀ = {V}")
    
    print("\n" + "-"*70)
    print("STEP 2: First Iteration (k=1)")
    print("-"*70)
    
    gamma = 0.9
    V1 = {}
    
    for state in mdp.states:
        print(f"\nCalculating V₁({state}):")
        state_value = 0
        
        for action, action_prob in policy[state].items():
            print(f"  Action {action} (probability {action_prob}):")
            
            next_state_probs = mdp.transitions.get((state, action), {})
            
            for next_state, trans_prob in next_state_probs.items():
                reward = mdp.rewards.get((state, action, next_state), 0)
                term = action_prob * trans_prob * (reward + gamma * V[next_state])
                state_value += term
                
                print(f"    P({next_state}|{state},{action}) = {trans_prob}")
                print(f"    R({state},{action},{next_state}) = {reward}")
                print(f"    Contribution: {action_prob} × {trans_prob} × ({reward} + 0.9×{V[next_state]}) = {term:.3f}")
        
        V1[state] = state_value
        print(f"  V₁({state}) = {state_value:.3f}")
    
    print(f"\nV₁ = { {s: f'{v:.3f}' for s, v in V1.items()} }")
    
    print("\n" + "-"*70)
    print("STEP 3: Second Iteration (k=2)")
    print("-"*70)
    
    V2 = {}
    
    for state in mdp.states:
        state_value = 0
        
        for action, action_prob in policy[state].items():
            next_state_probs = mdp.transitions.get((state, action), {})
            
            for next_state, trans_prob in next_state_probs.items():
                reward = mdp.rewards.get((state, action, next_state), 0)
                state_value += action_prob * trans_prob * (reward + gamma * V1[next_state])
        
        V2[state] = state_value
    
    print(f"V₂ = { {s: f'{v:.3f}' for s, v in V2.items()} }")
    
    print("\n" + "-"*70)
    print("STEP 4: Continue Until Convergence")
    print("-"*70)
    
    # Use the DP algorithm
    final_V = dp.iterative_policy_evaluation(policy, gamma=0.9)
    print(f"Final V^π = { {s: f'{v:.3f}' for s, v in final_V.items()} }")
    
    print("\n" + "-"*70)
    print("INTERPRETATION")
    print("-"*70)
    
    print("\nThe value function tells us:")
    for state, value in final_V.items():
        print(f"  Starting from {state} and following policy π,")
        print(f"  the agent can expect {value:.2f} total discounted reward")
    
    return mdp, policy, final_V

# Run the demonstration
mdp, policy, V = demonstrate_policy_evaluation()

4.3 Policy Improvement Theorem

def demonstrate_policy_improvement():
    """Demonstrate the policy improvement theorem"""
    print("="*70)
    print("POLICY IMPROVEMENT THEOREM DEMONSTRATION")
    print("="*70)
    
    # Create a simple MDP
    class ImprovementMDP:
        def __init__(self):
            self.states = ['A', 'B', 'C']
            self.actions = ['left', 'right']
            
            self.transitions = {
                ('A', 'left'): {'B': 1.0},
                ('A', 'right'): {'C': 1.0},
                ('B', 'left'): {'A': 1.0},
                ('B', 'right'): {'C': 1.0},
                ('C', 'left'): {'B': 1.0},
                ('C', 'right'): {'A': 1.0}
            }
            
            self.rewards = {
                ('A', 'left', 'B'): 1,
                ('A', 'right', 'C'): 2,
                ('B', 'left', 'A'): 3,
                ('B', 'right', 'C'): 4,
                ('C', 'left', 'B'): 5,
                ('C', 'right', 'A'): 6
            }
        
        def get_possible_actions(self, state):
            return [a for a in self.actions if (state, a) in self.transitions]
    
    mdp = ImprovementMDP()
    dp = DynamicProgramming(mdp)
    
    # Initial policy: always go left
    π = {
        'A': {'left': 1.0},
        'B': {'left': 1.0},
        'C': {'left': 1.0}
    }
    
    print("\nInitial Policy π (always go left):")
    for state, actions in π.items():
        print(f"  π({state}) = {actions}")
    
    print("\n" + "-"*70)
    print("STEP 1: Evaluate Initial Policy")
    print("-"*70)
    
    gamma = 0.9
    V_π = dp.iterative_policy_evaluation(π, gamma)
    
    print(f"\nValue function V^π:")
    for state, value in V_π.items():
        print(f"  V^π({state}) = {value:.3f}")
    
    print("\n" + "-"*70)
    print("STEP 2: Compute Q^π(s,a) for all state-action pairs")
    print("-"*70)
    
    Q_π = {}
    for state in mdp.states:
        for action in mdp.get_possible_actions(state):
            q_value = 0
            next_state_probs = mdp.transitions.get((state, action), {})
            
            for next_state, trans_prob in next_state_probs.items():
                reward = mdp.rewards.get((state, action, next_state), 0)
                q_value += trans_prob * (reward + gamma * V_π[next_state])
            
            Q_π[(state, action)] = q_value
            
            print(f"  Q^π({state}, {action}) = {q_value:.3f}")
    
    print("\n" + "-"*70)
    print("STEP 3: Create Greedy Policy π'")
    print("-"*70)
    
    π_prime = {}
    for state in mdp.states:
        valid_actions = mdp.get_possible_actions(state)
        
        if valid_actions:
            # Find action with maximum Q-value
            q_values = [Q_π[(state, a)] for a in valid_actions]
            max_q = max(q_values)
            best_actions = [a for a, q in zip(valid_actions, q_values) if q == max_q]
            
            # Deterministic greedy policy
            π_prime[state] = {best_actions[0]: 1.0}
            
            print(f"  At state {state}:")
            print(f"    Q-values: { {a: Q_π[(state, a)]:.3f} for a in valid_actions }")
            print(f"    Best action(s): {best_actions}")
            print(f"    π'({state}) = {best_actions[0]}")
        else:
            π_prime[state] = {}
    
    print("\n" + "-"*70)
    print("STEP 4: Verify Policy Improvement Theorem")
    print("-"*70)
    
    print("\nThe Policy Improvement Theorem states:")
    print("If Q^π(s, π'(s)) ≥ V^π(s) for all s, then V^π'(s) ≥ V^π(s) for all s")
    
    print("\nChecking condition:")
    for state in mdp.states:
        if state in π_prime and π_prime[state]:
            greedy_action = list(π_prime[state].keys())[0]
            Q_greedy = Q_π[(state, greedy_action)]
            V_current = V_π[state]
            
            condition_met = Q_greedy >= V_current
            symbol = "✓" if condition_met else "✗"
            
            print(f"  {symbol} State {state}: Q^π({state}, {greedy_action}) = {Q_greedy:.3f} ≥ V^π({state}) = {V_current:.3f}")
    
    print("\n" + "-"*70)
    print("STEP 5: Evaluate Improved Policy")
    print("-"*70)
    
    V_π_prime = dp.iterative_policy_evaluation(π_prime, gamma)
    
    print(f"\nValue function V^π':")
    for state, value in V_π_prime.items():
        print(f"  V^π'({state}) = {value:.3f}")
    
    print(f"\nValue function V^π:")
    for state, value in V_π.items():
        print(f"  V^π({state}) = {value:.3f}")
    
    print("\nImprovement:")
    for state in mdp.states:
        improvement = V_π_prime[state] - V_π[state]
        symbol = "↑" if improvement > 0 else ("↓" if improvement < 0 else "→")
        print(f"  State {state}: {V_π[state]:.3f} → {V_π_prime[state]:.3f} ({symbol}{abs(improvement):.3f})")
    
    return mdp, π, V_π, π_prime, V_π_prime

4.4 Complete Policy Iteration

def complete_policy_iteration_example():
    """Complete example of policy iteration"""
    print("="*70)
    print("COMPLETE POLICY ITERATION EXAMPLE")
    print("="*70)
    
    # Create a grid world MDP
    class GridMDP:
        def __init__(self, rows=3, cols=3):
            self.rows = rows
            self.cols = cols
            self.states = [(r, c) for r in range(rows) for c in range(cols)]
            
            # Actions: 0=up, 1=right, 2=down, 3=left
            self.actions = [0, 1, 2, 3]
            
            # Special states
            self.goal = (rows-1, cols-1)
            self.trap = (1, 1)
            
            # Build transitions
            self.transitions = self._build_transitions()
            
            # Build rewards
            self.rewards = self._build_rewards()
        
        def _build_transitions(self):
            transitions = {}
            
            for state in self.states:
                r, c = state
                
                for action in self.actions:
                    next_state_probs = {}
                    
                    if state == self.goal or state == self.trap:
                        # Terminal states
                        next_state_probs[state] = 1.0
                    else:
                        # Movement with 80% success, 10% left, 10% right of intended
                        if action == 0:  # up
                            intended = (max(0, r-1), c)
                            left = (r, max(0, c-1))
                            right = (r, min(self.cols-1, c+1))
                        elif action == 1:  # right
                            intended = (r, min(self.cols-1, c+1))
                            left = (max(0, r-1), c)
                            right = (min(self.rows-1, r+1), c)
                        elif action == 2:  # down
                            intended = (min(self.rows-1, r+1), c)
                            left = (r, min(self.cols-1, c+1))
                            right = (r, max(0, c-1))
                        else:  # left
                            intended = (r, max(0, c-1))
                            left = (min(self.rows-1, r+1), c)
                            right = (max(0, r-1), c)
                        
                        next_state_probs[intended] = 0.8
                        next_state_probs[left] = 0.1
                        next_state_probs[right] = 0.1
                    
                    transitions[(state, action)] = next_state_probs
            
            return transitions
        
        def _build_rewards(self):
            rewards = {}
            
            for (state, action), next_states in self.transitions.items():
                for next_state, prob in next_states.items():
                    if next_state == self.goal:
                        reward = 10
                    elif next_state == self.trap:
                        reward = -10
                    else:
                        reward = -1  # Cost for each step
                    
                    rewards[(state, action, next_state)] = reward
            
            return rewards
        
        def get_possible_actions(self, state):
            return self.actions  # All actions always available
        
        def visualize(self, V=None, policy=None):
            """Visualize grid with values and/or policy"""
            import matplotlib.pyplot as plt
            
            fig, ax = plt.subplots(figsize=(10, 8))
            
            # Create grid
            for r in range(self.rows):
                for c in range(self.cols):
                    # Cell color
                    if (r, c) == self.goal:
                        color = 'lightgreen'
                    elif (r, c) == self.trap:
                        color = 'lightcoral'
                    else:
                        color = 'lightblue'
                    
                    rect = plt.Rectangle((c-0.5, r-0.5), 1, 1, 
                                       facecolor=color, edgecolor='black', linewidth=2)
                    ax.add_patch(rect)
                    
                    # Add value if provided
                    if V is not None and (r, c) in V:
                        value = V[(r, c)]
                        ax.text(c, r, f'{value:.1f}', 
                               ha='center', va='center', fontsize=12, fontweight='bold')
                    
                    # Add policy if provided
                    if policy is not None and (r, c) in policy:
                        action = list(policy[(r, c)].keys())[0] if policy[(r, c)] else None
                        if action is not None:
                            action_symbols = {0: '↑', 1: '→', 2: '↓', 3: '←'}
                            symbol = action_symbols.get(action, '?')
                            ax.text(c, r-0.3, symbol, ha='center', va='center', 
                                   fontsize=20, color='red')
            
            ax.set_xlim(-0.5, self.cols-0.5)
            ax.set_ylim(-0.5, self.rows-0.5)
            ax.set_aspect('equal')
            ax.invert_yaxis()  # So (0,0) is at top-left
            ax.set_xticks(range(self.cols))
            ax.set_yticks(range(self.rows))
            ax.set_xlabel('Column')
            ax.set_ylabel('Row')
            ax.set_title('Grid World MDP')
            ax.grid(True, alpha=0.3)
            
            # Legend
            legend_elements = [
                plt.Rectangle((0,0),1,1, facecolor='lightgreen', edgecolor='black', label='Goal (+10)'),
                plt.Rectangle((0,0),1,1, facecolor='lightcoral', edgecolor='black', label='Trap (-10)'),
                plt.Rectangle((0,0),1,1, facecolor='lightblue', edgecolor='black', label='Normal (-1 per step)')
            ]
            ax.legend(handles=legend_elements, loc='upper right')
            
            plt.tight_layout()
            plt.show()
    
    # Create MDP and DP solver
    mdp = GridMDP(rows=3, cols=3)
    dp = DynamicProgramming(mdp)
    
    print("Grid World MDP created:")
    print(f"  States: {mdp.rows} × {mdp.cols} = {len(mdp.states)}")
    print(f"  Actions: {len(mdp.actions)}")
    print(f"  Goal: {mdp.goal} (reward +10)")
    print(f"  Trap: {mdp.trap} (reward -10)")
    print(f"  Step cost: -1")
    
    print("\n" + "-"*70)
    print("INITIAL VISUALIZATION")
    print("-"*70)
    
    mdp.visualize()
    
    print("\n" + "-"*70)
    print("RUNNING POLICY ITERATION")
    print("-"*70)
    
    # Run policy iteration
    final_policy, final_V, policy_history, value_history = dp.policy_iteration(gamma=0.9)
    
    print("\n" + "-"*70)
    print("FINAL RESULTS")
    print("-"*70)
    
    print(f"\nOptimal Value Function:")
    for r in range(mdp.rows):
        row_vals = []
        for c in range(mdp.cols):
            val = final_V.get((r, c), 0)
            row_vals.append(f"{val:6.2f}")
        print("  " + " ".join(row_vals))
    
    print(f"\nOptimal Policy (arrows show best action):")
    mdp.visualize(V=final_V, policy=final_policy)
    
    print("\n" + "-"*70)
    print("POLICY EVOLUTION")
    print("-"*70)
    
    # Show how policy changed during iteration
    action_symbols = {0: '↑', 1: '→', 2: '↓', 3: '←'}
    
    for i, policy in enumerate(policy_history[:4]):  # Show first 4 iterations
        print(f"\nIteration {i}:")
        for r in range(mdp.rows):
            row_actions = []
            for c in range(mdp.cols):
                state = (r, c)
                if state in policy and policy[state]:
                    action = list(policy[state].keys())[0]
                    row_actions.append(action_symbols.get(action, '?'))
                else:
                    row_actions.append('·')
            print("  " + " ".join(row_actions))
    
    print("\n" + "-"*70)
    print("VALUE CONVERGENCE")
    print("-"*70)
    
    # Plot convergence
    import matplotlib.pyplot as plt
    
    # Calculate max value change per iteration
    max_deltas = []
    for i in range(1, len(value_history)):
        max_delta = 0
        for state in mdp.states:
            delta = abs(value_history[i][state] - value_history[i-1][state])
            max_delta = max(max_delta, delta)
        max_deltas.append(max_delta)
    
    plt.figure(figsize=(10, 5))
    plt.plot(max_deltas, 'b-o', linewidth=2, markersize=4)
    plt.yscale('log')
    plt.xlabel('Iteration')
    plt.ylabel('Max Value Change (log scale)')
    plt.title('Policy Iteration Convergence')
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.show()
    
    print(f"\nConverged in {len(policy_history)-1} policy iterations")
    print(f"Final max value change: {max_deltas[-1]:.6f}")
    
    return mdp, dp, final_policy, final_V

# Run the example
grid_mdp, dp_solver, optimal_policy, optimal_values = complete_policy_iteration_example()

4.5 Value Iteration Details

def value_iteration_deep_dive():
    """Deep dive into value iteration algorithm"""
    print("="*70)
    print("VALUE ITERATION DEEP DIVE")
    print("="*70)
    
    # Create a simple MDP for demonstration
    class SimpleMDP:
        def __init__(self):
            self.states = ['A', 'B']
            self.actions = ['stay', 'switch']
            
            self.transitions = {
                ('A', 'stay'): {'A': 1.0},
                ('A', 'switch'): {'B': 1.0},
                ('B', 'stay'): {'B': 1.0},
                ('B', 'switch'): {'A': 1.0}
            }
            
            self.rewards = {
                ('A', 'stay', 'A'): 1,
                ('A', 'switch', 'B'): 3,
                ('B', 'stay', 'B'): 2,
                ('B', 'switch', 'A'): 0
            }
        
        def get_possible_actions(self, state):
            return self.actions
    
    mdp = SimpleMDP()
    dp = DynamicProgramming(mdp)
    
    gamma = 0.9
    
    print("\nMDP Description:")
    print("  States: A, B")
    print("  Actions: stay, switch")
    print("  Discount factor γ = 0.9")
    
    print("\n" + "-"*70)
    print("INITIALIZATION")
    print("-"*70)
    
    # Initialize value function
    V = {state: 0 for state in mdp.states}
    print(f"V₀ = {V}")
    
    print("\n" + "-"*70)
    print("ITERATION 1")
    print("-"*70)
    
    V1 = {}
    for state in mdp.states:
        print(f"\nState {state}:")
        
        # Calculate Q-values for each action
        q_values = {}
        for action in mdp.actions:
            q = 0
            next_state_probs = mdp.transitions.get((state, action), {})
            
            for next_state, prob in next_state_probs.items():
                reward = mdp.rewards.get((state, action, next_state), 0)
                q += prob * (reward + gamma * V[next_state])
            
            q_values[action] = q
            print(f"  Q({state}, {action}) = {q:.3f}")
        
        # Take maximum
        best_action = max(q_values, key=q_values.get)
        best_value = q_values[best_action]
        V1[state] = best_value
        
        print(f"  max_a Q({state}, a) = {best_value:.3f} (action: {best_action})")
    
    print(f"\nV₁ = {V1}")
    
    print("\n" + "-"*70)
    print("ITERATION 2")
    print("-"*70)
    
    V2 = {}
    for state in mdp.states:
        print(f"\nState {state}:")
        
        q_values = {}
        for action in mdp.actions:
            q = 0
            next_state_probs = mdp.transitions.get((state, action), {})
            
            for next_state, prob in next_state_probs.items():
                reward = mdp.rewards.get((state, action, next_state), 0)
                q += prob * (reward + gamma * V1[next_state])
            
            q_values[action] = q
            print(f"  Q({state}, {action}) = {q:.3f}")
        
        best_action = max(q_values, key=q_values.get)
        best_value = q_values[best_action]
        V2[state] = best_value
        
        print(f"  max_a Q({state}, a) = {best_value:.3f} (action: {best_action})")
    
    print(f"\nV₂ = {V2}")
    
    print("\n" + "-"*70)
    print("CONTINUING TO CONVERGENCE")
    print("-"*70)
    
    # Run full value iteration
    optimal_policy, optimal_V, value_history, deltas = dp.value_iteration(gamma=0.9, theta=1e-6)
    
    print(f"\nConverged after {len(value_history)} iterations")
    print(f"Optimal value function: { {s: f'{v:.3f}' for s, v in optimal_V.items()} }")
    
    print(f"\nOptimal policy:")
    for state in mdp.states:
        action = list(optimal_policy[state].keys())[0] if optimal_policy[state] else None
        print(f"  π*({state}) = {action}")
    
    print("\n" + "-"*70)
    print("MATHEMATICAL INSIGHTS")
    print("-"*70)
    
    print("\nThe Bellman Optimality Equation:")
    print("  V*(s) = max_a Σ_s' P(s'|s,a) [R(s,a,s') + γV*(s')]")
    
    print("\nValue iteration update rule:")
    print("  V_{k+1}(s) = max_a Σ_s' P(s'|s,a) [R(s,a,s') + γV_k(s')]")
    
    print("\nKey properties:")
    print("  1. Contraction mapping: Guarantees convergence")
    print("  2. Monotonic improvement: V_{k+1}(s) ≥ V_k(s) for all s")
    print("  3. Finds optimal value function V*")
    
    print("\n" + "-"*70)
    print("CONVERGENCE PLOT")
    print("-"*70)
    
    import matplotlib.pyplot as plt
    
    plt.figure(figsize=(12, 5))
    
    # Plot value evolution
    plt.subplot(1, 2, 1)
    iterations = range(len(value_history))
    
    for state in mdp.states:
        values = [vh[state] for vh in value_history]
        plt.plot(iterations, values, 'o-', linewidth=2, markersize=4, label=f'State {state}')
    
    plt.xlabel('Iteration')
    plt.ylabel('State Value')
    plt.title('Value Iteration Convergence')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # Plot deltas
    plt.subplot(1, 2, 2)
    plt.plot(range(1, len(deltas)+1), deltas, 'r-o', linewidth=2, markersize=4)
    plt.yscale('log')
    plt.xlabel('Iteration')
    plt.ylabel('Max Delta (log scale)')
    plt.title('Convergence Rate')
    plt.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    return mdp, optimal_V, optimal_policy, value_history, deltas

4.6 Chapter Project: Solve the Frozen Lake

def frozen_lake_dp_project():
    """Dynamic Programming solution for Frozen Lake environment"""
    print("="*70)
    print("FROZEN LAKE DYNAMIC PROGRAMMING PROJECT")
    print("="*70)
    
    try:
        import gymnasium as gym
        gym_available = True
    except ImportError:
        print("gymnasium not available. Installing...")
        import subprocess
        import sys
        subprocess.check_call([sys.executable, "-m", "pip", "install", "gymnasium"])
        import gymnasium as gym
        gym_available = True
    
    if not gym_available:
        print("Could not import gymnasium. Using custom implementation.")
        return
    
    print("\n1. Creating Frozen Lake Environment...")
    
    # Create environment
    env = gym.make('FrozenLake-v1', map_name="4x4", is_slippery=True)
    
    print(f"   State space size: {env.observation_space.n}")
    print(f"   Action space size: {env.action_space.n}")
    
    # Extract MDP from environment
    print("\n2. Extracting MDP parameters...")
    
    # Note: In practice, we'd need to estimate P and R from the environment
    # For this demo, we'll create a simplified MDP
    
    class FrozenLakeMDP:
        def __init__(self, env):
            self.env = env
            self.n_states = env.observation_space.n
            self.n_actions = env.action_space.n
            
            # We'll estimate transition probabilities by sampling
            print("   Estimating transition probabilities...")
            self.transitions = self._estimate_transitions(samples_per_state=1000)
            
            print("   Estimating reward function...")
            self.rewards = self._estimate_rewards(samples_per_state=1000)
            
            # States are just integers 0..n_states-1
            self.states = list(range(self.n_states))
            self.actions = list(range(self.n_actions))
        
        def _estimate_transitions(self, samples_per_state=100):
            """Estimate transition probabilities by sampling"""
            transitions = {}
            
            for s in range(self.n_states):
                for a in range(self.n_actions):
                    next_state_counts = {}
                    
                    # Reset to specific state (requires custom reset)
                    # For simplicity, we'll sample from the environment
                    env_copy = gym.make('FrozenLake-v1', map_name="4x4", is_slippery=True)
                    
                    # Sample transitions
                    for _ in range(samples_per_state):
                        # We need to set the state - in real implementation use env.unwrapped
                        # For now, we'll use a simpler approach
                        env_copy.reset()
                        # This is simplified - real implementation would use env.unwrapped.set_state()
                        pass
                    
                    # Simplified: assume deterministic transitions for demo
                    # In real DP, you need the exact model
                    transitions[(s, a)] = {(s+1) % self.n_states: 1.0}
            
            return transitions
        
        def _estimate_rewards(self, samples_per_state=100):
            """Estimate reward function by sampling"""
            rewards = {}
            
            # Simplified: FrozenLake gives reward 1 for reaching goal, 0 otherwise
            for s in range(self.n_states):
                for a in range(self.n_actions):
                    for s_prime in range(self.n_states):
                        # Goal is state 15 in 4x4 map
                        if s_prime == 15:
                            reward = 1.0
                        else:
                            reward = 0.0
                        
                        rewards[(s, a, s_prime)] = reward
            
            return rewards
        
        def get_possible_actions(self, state):
            return self.actions
    
    # Create MDP
    mdp = FrozenLakeMDP(env)
    
    print("\n3. Running Dynamic Programming...")
    
    # Since we don't have exact model, we'll use a simpler approach
    # In real DP, you need the exact transition probabilities
    
    print("   Note: For exact DP solution, we need the complete transition model.")
    print("   In practice, Frozen Lake is slippery (non-deterministic),")
    print("   so we need to know the exact probabilities.")
    
    print("\n   Creating simplified deterministic version...")
    
    # Create a deterministic version for demonstration
    class DeterministicFrozenLake:
        def __init__(self, size=4):
            self.size = size
            self.n_states = size * size
            self.states = list(range(self.n_states))
            self.actions = [0, 1, 2, 3]  # 0: left, 1: down, 2: right, 3: up
            
            # Build deterministic transitions
            self.transitions = self._build_transitions()
            self.rewards = self._build_rewards()
        
        def _build_transitions(self):
            transitions = {}
            
            for s in self.states:
                row = s // self.size
                col = s % self.size
                
                for a in self.actions:
                    next_state_probs = {}
                    
                    if a == 0:  # left
                        new_col = max(0, col - 1)
                        new_state = row * self.size + new_col
                    elif a == 1:  # down
                        new_row = min(self.size - 1, row + 1)
                        new_state = new_row * self.size + col
                    elif a == 2:  # right
                        new_col = min(self.size - 1, col + 1)
                        new_state = row * self.size + new_col
                    else:  # up
                        new_row = max(0, row - 1)
                        new_state = new_row * self.size + col
                    
                    next_state_probs[new_state] = 1.0
                    transitions[(s, a)] = next_state_probs
            
            return transitions
        
        def _build_rewards(self):
            rewards = {}
            goal_state = self.n_states - 1  # Bottom-right corner
            hole_states = [5, 7, 11, 12]  # Example hole positions for 4x4
            
            for (s, a), next_states in self.transitions.items():
                for s_prime, prob in next_states.items():
                    if s_prime == goal_state:
                        reward = 1.0
                    elif s_prime in hole_states:
                        reward = -1.0
                    else:
                        reward = 0.0
                    
                    rewards[(s, a, s_prime)] = reward
            
            return rewards
        
        def get_possible_actions(self, state):
            return self.actions
        
        def visualize_policy(self, policy, title="Policy"):
            """Visualize policy on grid"""
            import matplotlib.pyplot as plt
            import numpy as np
            
            action_symbols = {0: '←', 1: '↓', 2: '→', 3: '↑'}
            
            grid = np.full((self.size, self.size), '', dtype=object)
            
            for s in self.states:
                row = s // self.size
                col = s % self.size
                
                if s in policy and policy[s]:
                    action = list(policy[s].keys())[0]
                    grid[row, col] = action_symbols.get(action, '?')
                else:
                    grid[row, col] = '·'
            
            # Mark goal and holes
            goal_state = self.n_states - 1
            goal_row, goal_col = goal_state // self.size, goal_state % self.size
            grid[goal_row, goal_col] = 'G'
            
            hole_states = [5, 7, 11, 12]
            for hs in hole_states:
                hr, hc = hs // self.size, hs % self.size
                grid[hr, hc] = 'H'
            
            # Start state
            grid[0, 0] = 'S'
            
            # Plot
            fig, ax = plt.subplots(figsize=(8, 8))
            
            # Create grid lines
            for i in range(self.size + 1):
                ax.axhline(i - 0.5, color='black', linewidth=2)
                ax.axvline(i - 0.5, color='black', linewidth=2)
            
            # Add text
            for i in range(self.size):
                for j in range(self.size):
                    ax.text(j, i, grid[i, j], ha='center', va='center', 
                           fontsize=20, fontweight='bold')
            
            ax.set_xlim(-0.5, self.size - 0.5)
            ax.set_ylim(-0.5, self.size - 0.5)
            ax.invert_yaxis()
            ax.set_xticks([])
            ax.set_yticks([])
            ax.set_title(title)
            
            # Add legend
            legend_text = "S: Start, G: Goal, H: Hole, Arrows: Optimal action"
            ax.text(0.5, -0.1, legend_text, transform=ax.transAxes,
                   ha='center', fontsize=10)
            
            plt.tight_layout()
            plt.show()
    
    print("\n4. Creating and solving deterministic version...")
    det_mdp = DeterministicFrozenLake(size=4)
    dp = DynamicProgramming(det_mdp)
    
    # Run value iteration
    optimal_policy, optimal_V, value_history, deltas = dp.value_iteration(gamma=0.9)
    
    print(f"\n5. Results:")
    print(f"   Number of iterations: {len(value_history)}")
    print(f"   Optimal value at start state: {optimal_V.get(0, 0):.3f}")
    
    print("\n6. Visualizing optimal policy...")
    det_mdp.visualize_policy(optimal_policy, title="Optimal Policy (Deterministic)")
    
    print("\n7. Testing policy in actual environment...")
    
    # Test in stochastic environment
    def test_policy_in_env(policy, n_episodes=100):
        env = gym.make('FrozenLake-v1', map_name="4x4", is_slippery=True, render_mode=None)
        
        successes = 0
        total_rewards = []
        
        for episode in range(n_episodes):
            state, _ = env.reset()
            total_reward = 0
            done = False
            
            while not done:
                # Get action from policy
                if state in policy and policy[state]:
                    action = list(policy[state].keys())[0]
                else:
                    action = env.action_space.sample()
                
                # Take action
                state, reward, terminated, truncated, _ = env.step(action)
                done = terminated or truncated
                total_reward += reward
            
            if reward == 1.0:  # Reached goal
                successes += 1
            
            total_rewards.append(total_reward)
        
        success_rate = successes / n_episodes
        avg_reward = sum(total_rewards) / n_episodes
        
        print(f"   Success rate: {success_rate:.1%} ({successes}/{n_episodes})")
        print(f"   Average reward: {avg_reward:.3f}")
        
        return success_rate, avg_reward
    
    print("\n   Testing deterministic policy in slippery environment:")
    success_rate, avg_reward = test_policy_in_env(optimal_policy, n_episodes=1000)
    
    print("\n" + "="*70)
    print("KEY INSIGHTS")
    print("="*70)
    
    print("\n1. Dynamic Programming requires a complete model:")
    print("   - Need exact P(s'|s,a) and R(s,a,s')")
    print("   - Not practical for unknown environments")
    
    print("\n2. Value Iteration vs Policy Iteration:")
    print("   - Value iteration: Updates values directly")
    print("   - Policy iteration: Alternates evaluation and improvement")
    print("   - Both converge to optimal solution with complete model")
    
    print("\n3. Limitations in practice:")
    print("   - Need to know environment dynamics exactly")
    print("   - Doesn't work for large state spaces (curse of dimensionality)")
    print("   - Deterministic solutions may fail in stochastic environments")
    
    print("\n" + "="*70)
    print("NEXT STEPS")
    print("="*70)
    
    print("\nIn next chapters, we'll learn model-free methods:")
    print("1. Monte Carlo: Learn from complete episodes")
    print("2. Temporal Difference: Learn from incomplete episodes")
    print("3. Q-learning: Learn optimal policy without model")
    
    return det_mdp, optimal_policy, optimal_V, success_rate

# Run the project
if __name__ == "__main__":
    frozen_lake_mdp, policy, values, success = frozen_lake_dp_project()

Chapter 4 Key Takeaways:

  • Dynamic Programming requires complete knowledge of the environment
  • Policy Iteration alternates between evaluation and improvement
  • Value Iteration directly updates the value function
  • Both methods guarantee convergence to optimal policy
  • DP is powerful for planning but impractical for large/unknown environments

Reinforcement Learning: From Curiosity to Mastery

A Complete Beginner's Guide

Continuing from Chapter 4...


PART II: CLASSICAL METHODS - LEARNING BY INTERACTION

CHAPTER 5: Monte Carlo Methods: Learning from Experience

5.1 The Essence of Monte Carlo Learning

Analogy: Learning to Bake Without a Recipe Imagine learning to bake cookies through trial and error:

  • DP approach: Read all baking theory first, calculate exact oven times
  • MC approach: Actually bake cookies, taste them, learn what works

Monte Carlo Principle: Learn from complete episodes of experience.

import numpy as np
import matplotlib.pyplot as plt
from collections import defaultdict

class MonteCarloLearning:
    """Monte Carlo methods for RL"""
    
    def __init__(self, env, gamma=0.9):
        self.env = env
        self.gamma = gamma
        
        # Initialize value function and returns
        self.V = defaultdict(float)  # State values
        self.returns = defaultdict(list)  # Returns for each state
        
    def generate_episode(self, policy):
        """Generate an episode following policy"""
        episode = []
        state = self.env.reset()
        done = False
        
        while not done:
            # Get action from policy
            if isinstance(policy, dict):
                # Deterministic policy
                action = policy[state]
            else:
                # Stochastic policy function
                action = policy(state)
            
            # Take action
            next_state, reward, done = self.env.step(action)
            
            # Store transition
            episode.append((state, action, reward))
            state = next_state
        
        return episode
    
    def first_visit_mc_prediction(self, policy, num_episodes=1000):
        """
        First-visit MC prediction
        Average returns following first visit to each state
        """
        for episode_num in range(num_episodes):
            # Generate episode
            episode = self.generate_episode(policy)
            states_in_episode = [step[0] for step in episode]
            rewards = [step[2] for step in episode]
            
            # Calculate returns
            G = 0
            visited_states = set()
            
            # Work backwards through episode
            for t in reversed(range(len(episode))):
                state, action, reward = episode[t]
                
                # Update return
                G = self.gamma * G + reward
                
                # First-visit: only update if state not visited earlier in episode
                if state not in visited_states:
                    visited_states.add(state)
                    self.returns[state].append(G)
                    self.V[state] = np.mean(self.returns[state])
            
            if (episode_num + 1) % 100 == 0:
                print(f"Episode {episode_num + 1}/{num_episodes}")
        
        return self.V
    
    def every_visit_mc_prediction(self, policy, num_episodes=1000):
        """
        Every-visit MC prediction
        Average returns following every visit to each state
        """
        for episode_num in range(num_episodes):
            episode = self.generate_episode(policy)
            rewards = [step[2] for step in episode]
            
            # Calculate returns
            G = 0
            
            # Work backwards
            for t in reversed(range(len(episode))):
                state, action, reward = episode[t]
                
                # Update return
                G = self.gamma * G + reward
                
                # Every-visit: update on every visit
                self.returns[state].append(G)
                self.V[state] = np.mean(self.returns[state])
        
        return self.V
    
    def mc_exploring_starts(self, num_episodes=10000, epsilon=0.1):
        """
        Monte Carlo with Exploring Starts
        For control (finding optimal policy)
        """
        # Initialize Q-function and policy
        Q = defaultdict(lambda: np.zeros(self.env.action_space.n))
        policy = defaultdict(lambda: np.random.randint(self.env.action_space.n))
        
        # Keep track of returns
        returns = defaultdict(list)
        
        for episode_num in range(num_episodes):
            # Exploring start: random initial state-action pair
            state = self.env.reset()
            
            # Generate episode with exploring starts
            episode = []
            done = False
            
            while not done:
                # Epsilon-greedy action selection
                if np.random.random() < epsilon:
                    action = np.random.randint(self.env.action_space.n)
                else:
                    action = np.argmax(Q[state])
                
                next_state, reward, done = self.env.step(action)
                episode.append((state, action, reward))
                state = next_state
            
            # Process episode
            G = 0
            visited_state_actions = set()
            
            # Work backwards through episode
            for t in reversed(range(len(episode))):
                state, action, reward = episode[t]
                G = self.gamma * G + reward
                
                # First-visit for state-action pairs
                if (state, action) not in visited_state_actions:
                    visited_state_actions.add((state, action))
                    returns[(state, action)].append(G)
                    Q[state][action] = np.mean(returns[(state, action)])
                    
                    # Update policy (greedy)
                    policy[state] = np.argmax(Q[state])
        
        return policy, Q

5.2 MC Prediction: Estimating Value Functions

First-Visit vs Every-Visit MC:

def compare_mc_methods():
    """Compare first-visit and every-visit MC"""
    # Simple environment
    class SimpleEnv:
        def __init__(self):
            self.states = ['A', 'B', 'C', 'Terminal']
            self.current_state = 'A'
        
        def reset(self):
            self.current_state = 'A'
            return self.current_state
        
        def step(self, action):
            """Simple deterministic transitions"""
            transitions = {
                'A': {'right': 'B', 'down': 'C'},
                'B': {'right': 'Terminal', 'down': 'C'},
                'C': {'right': 'Terminal', 'up': 'A'},
                'Terminal': {}
            }
            
            if self.current_state == 'Terminal':
                return self.current_state, 0, True
            
            # Simple deterministic policy: always go right
            action = 'right'
            next_state = transitions[self.current_state][action]
            
            # Rewards
            rewards = {
                ('A', 'right', 'B'): 1,
                ('B', 'right', 'Terminal'): 10,
                ('C', 'right', 'Terminal'): 5
            }
            
            reward = rewards.get((self.current_state, action, next_state), 0)
            self.current_state = next_state
            
            done = (next_state == 'Terminal')
            return next_state, reward, done
    
    env = SimpleEnv()
    mc = MonteCarloLearning(env, gamma=0.9)
    
    # Fixed policy: always go right
    policy = {'A': 'right', 'B': 'right', 'C': 'right'}
    
    print("="*60)
    print("COMPARING MONTE CARLO PREDICTION METHODS")
    print("="*60)
    
    # First-visit MC
    print("\n1. First-Visit MC Prediction:")
    V_first_visit = mc.first_visit_mc_prediction(policy, num_episodes=1000)
    print(f"   V(A) = {V_first_visit.get('A', 0):.3f}")
    print(f"   V(B) = {V_first_visit.get('B', 0):.3f}")
    print(f"   V(C) = {V_first_visit.get('C', 0):.3f}")
    
    # Reset for every-visit
    mc.V = defaultdict(float)
    mc.returns = defaultdict(list)
    
    # Every-visit MC
    print("\n2. Every-Visit MC Prediction:")
    V_every_visit = mc.every_visit_mc_prediction(policy, num_episodes=1000)
    print(f"   V(A) = {V_every_visit.get('A', 0):.3f}")
    print(f"   V(B) = {V_every_visit.get('B', 0):.3f}")
    print(f"   V(C) = {V_every_visit.get('C', 0):.3f}")
    
    # Theoretical values (for comparison)
    print("\n3. Theoretical Values (for comparison):")
    print("   From A: A→B (1) then B→Terminal (10)")
    print("   Return = 1 + 0.9×10 = 10.0")
    print("   From B: B→Terminal (10)")
    print("   Return = 10.0")
    print("   From C: C→Terminal (5)")
    print("   Return = 5.0")
    
    return V_first_visit, V_every_visit

# Run comparison
V_first, V_every = compare_mc_methods()

5.3 MC Control: Finding Optimal Policies

On-Policy vs Off-Policy Learning:

class MonteCarloControl:
    """Monte Carlo control methods"""
    
    def __init__(self, env, gamma=0.9):
        self.env = env
        self.gamma = gamma
        self.n_actions = env.action_space.n
        
    def create_epsilon_greedy_policy(self, Q, epsilon):
        """Create epsilon-greedy policy based on Q-values"""
        def policy(state):
            if np.random.random() < epsilon:
                # Explore: random action
                return np.random.randint(self.n_actions)
            else:
                # Exploit: best action
                return np.argmax(Q[state])
        return policy
    
    def on_policy_mc_control(self, num_episodes=10000, epsilon=0.1):
        """
        On-policy MC control with epsilon-greedy policy
        """
        # Initialize
        Q = defaultdict(lambda: np.zeros(self.n_actions))
        returns = defaultdict(list)
        
        # Create initial policy
        policy = self.create_epsilon_greedy_policy(Q, epsilon)
        
        # Track performance
        rewards_history = []
        
        for episode_num in range(num_episodes):
            # Generate episode
            episode = []
            state = self.env.reset()
            done = False
            
            while not done:
                action = policy(state)
                next_state, reward, done = self.env.step(action)
                episode.append((state, action, reward))
                state = next_state
            
            # Calculate returns and update Q-values
            G = 0
            visited_state_actions = set()
            
            # Work backwards through episode
            for t in reversed(range(len(episode))):
                state, action, reward = episode[t]
                G = self.gamma * G + reward
                
                # First-visit MC
                if (state, action) not in visited_state_actions:
                    visited_state_actions.add((state, action))
                    returns[(state, action)].append(G)
                    Q[state][action] = np.mean(returns[(state, action)])
            
            # Track performance
            total_reward = sum([step[2] for step in episode])
            rewards_history.append(total_reward)
            
            # Decay epsilon over time
            epsilon = max(0.01, epsilon * 0.995)
            
            if (episode_num + 1) % 1000 == 0:
                avg_reward = np.mean(rewards_history[-1000:])
                print(f"Episode {episode_num + 1}: Avg reward (last 1000) = {avg_reward:.2f}")
        
        # Extract deterministic policy from Q-values
        deterministic_policy = {}
        for state in Q:
            deterministic_policy[state] = np.argmax(Q[state])
        
        return deterministic_policy, Q, rewards_history
    
    def off_policy_mc_control(self, num_episodes=10000, epsilon=0.1):
        """
        Off-policy MC control using importance sampling
        Target policy: greedy
        Behavior policy: epsilon-greedy
        """
        # Initialize Q-function
        Q = defaultdict(lambda: np.zeros(self.n_actions))
        C = defaultdict(float)  # Cumulative sum of weights
        
        # Behavior policy (epsilon-greedy)
        def behavior_policy(state):
            if np.random.random() < epsilon:
                return np.random.randint(self.n_actions)
            else:
                return np.argmax(Q[state])
        
        # Target policy (greedy)
        def target_policy(state):
            return np.argmax(Q[state])
        
        rewards_history = []
        
        for episode_num in range(num_episodes):
            # Generate episode using behavior policy
            episode = []
            state = self.env.reset()
            done = False
            
            while not done:
                action = behavior_policy(state)
                next_state, reward, done = self.env.step(action)
                episode.append((state, action, reward))
                state = next_state
            
            # Calculate importance sampling weights
            G = 0
            W = 1.0  # Importance sampling ratio
            
            # Work backwards through episode
            for t in reversed(range(len(episode))):
                state, action, reward = episode[t]
                G = self.gamma * G + reward
                
                # Update cumulative sum
                C[(state, action)] += W
                
                # Update Q-value
                Q[state][action] += (W / C[(state, action)]) * (G - Q[state][action])
                
                # If action not greedy under target policy, break
                if action != target_policy(state):
                    break
                
                # Update importance sampling ratio
                # For greedy target policy: probability = 1
                # For epsilon-greedy behavior policy:
                # P(action|state) = 1 - epsilon + epsilon/n_actions for greedy action
                behavior_prob = 1 - epsilon + epsilon/self.n_actions
                W = W * (1.0 / behavior_prob)
            
            # Track performance
            total_reward = sum([step[2] for step in episode])
            rewards_history.append(total_reward)
            
            # Decay epsilon
            epsilon = max(0.01, epsilon * 0.995)
        
        # Extract deterministic policy
        deterministic_policy = {}
        for state in Q:
            deterministic_policy[state] = np.argmax(Q[state])
        
        return deterministic_policy, Q, rewards_history

5.4 Blackjack: A Classic MC Example

class BlackjackEnvironment:
    """Blackjack environment for Monte Carlo learning"""
    
    def __init__(self):
        self.reset()
        
    def reset(self):
        """Reset game"""
        self.player_hand = []
        self.dealer_hand = []
        
        # Deal initial cards
        self.player_hand.append(self._draw_card())
        self.dealer_hand.append(self._draw_card())
        self.player_hand.append(self._draw_card())
        self.dealer_hand.append(self._draw_card())
        
        self.done = False
        return self._get_state()
    
    def _draw_card(self):
        """Draw a card (1-13, face cards = 10)"""
        card = min(10, np.random.randint(1, 14))
        return card
    
    def _get_state(self):
        """Get current state"""
        player_sum = sum(self.player_hand)
        usable_ace = 1 in self.player_hand and player_sum <= 11
        dealer_card = self.dealer_hand[0]  # Only first card is visible
        
        return (player_sum, usable_ace, dealer_card)
    
    def step(self, action):
        """
        Take action
        0: stick (stop)
        1: hit (draw another card)
        """
        if action == 0:  # stick
            self.done = True
            
            # Dealer's turn
            while sum(self.dealer_hand) < 17:
                self.dealer_hand.append(self._draw_card())
            
            # Determine winner
            player_sum = sum(self.player_hand)
            dealer_sum = sum(self.dealer_hand)
            
            if player_sum > 21:  # Player bust
                reward = -1
            elif dealer_sum > 21:  # Dealer bust
                reward = 1
            elif player_sum > dealer_sum:  # Player wins
                reward = 1
            elif player_sum < dealer_sum:  # Dealer wins
                reward = -1
            else:  # Tie
                reward = 0
                
        else:  # hit
            self.player_hand.append(self._draw_card())
            player_sum = sum(self.player_hand)
            
            if player_sum > 21:  # Bust
                self.done = True
                reward = -1
            else:
                reward = 0
        
        return self._get_state(), reward, self.done
    
    def get_possible_actions(self):
        """Get valid actions"""
        return [0, 1]  # Always can stick or hit
    
    def simulate_episode(self, policy):
        """Simulate one episode following policy"""
        state = self.reset()
        episode = []
        done = False
        
        while not done:
            # Get action from policy
            if isinstance(policy, dict):
                action = policy.get(state, 0)  # Default to stick
            else:
                action = policy(state)
            
            # Take action
            next_state, reward, done = self.step(action)
            episode.append((state, action, reward))
            state = next_state
        
        return episode

def blackjack_monte_carlo_experiment():
    """Complete Blackjack MC experiment"""
    print("="*70)
    print("BLACKJACK MONTE CARLO EXPERIMENT")
    print("="*70)
    
    # Create environment
    env = BlackjackEnvironment()
    
    print("\n1. Initializing Monte Carlo Learning...")
    mc = MonteCarloLearning(env, gamma=1.0)  # No discount for episodic tasks
    
    # Define a simple policy: stick on 20 or 21, otherwise hit
    def simple_policy(state):
        player_sum, usable_ace, dealer_card = state
        if player_sum >= 20:
            return 0  # stick
        else:
            return 1  # hit
    
    print("\n2. Running First-Visit MC Prediction...")
    V = mc.first_visit_mc_prediction(simple_policy, num_episodes=50000)
    
    print("\n3. Value Function Results:")
    
    # Extract values for visualization
    player_sums = list(range(12, 22))
    dealer_cards = list(range(1, 11))
    
    # Create heatmap data
    data_no_ace = np.zeros((len(player_sums), len(dealer_cards)))
    data_ace = np.zeros((len(player_sums), len(dealer_cards)))
    
    for i, p_sum in enumerate(player_sums):
        for j, d_card in enumerate(dealer_cards):
            # Without usable ace
            state = (p_sum, False, d_card)
            data_no_ace[i, j] = V.get(state, 0)
            
            # With usable ace
            state = (p_sum, True, d_card)
            data_ace[i, j] = V.get(state, 0)
    
    # Plot heatmaps
    fig, axes = plt.subplots(1, 2, figsize=(15, 6))
    
    # No usable ace
    im1 = axes[0].imshow(data_no_ace, cmap='RdYlGn', aspect='auto')
    axes[0].set_title('Value Function: No Usable Ace')
    axes[0].set_xlabel('Dealer Showing')
    axes[0].set_ylabel('Player Sum')
    axes[0].set_xticks(range(len(dealer_cards)))
    axes[0].set_xticklabels(dealer_cards)
    axes[0].set_yticks(range(len(player_sums)))
    axes[0].set_yticklabels(player_sums)
    plt.colorbar(im1, ax=axes[0])
    
    # Add value annotations
    for i in range(len(player_sums)):
        for j in range(len(dealer_cards)):
            axes[0].text(j, i, f'{data_no_ace[i, j]:.1f}', 
                       ha='center', va='center', color='black')
    
    # With usable ace
    im2 = axes[1].imshow(data_ace, cmap='RdYlGn', aspect='auto')
    axes[1].set_title('Value Function: With Usable Ace')
    axes[1].set_xlabel('Dealer Showing')
    axes[1].set_ylabel('Player Sum')
    axes[1].set_xticks(range(len(dealer_cards)))
    axes[1].set_xticklabels(dealer_cards)
    axes[1].set_yticks(range(len(player_sums)))
    axes[1].set_yticklabels(player_sums)
    plt.colorbar(im2, ax=axes[1])
    
    # Add value annotations
    for i in range(len(player_sums)):
        for j in range(len(dealer_cards)):
            axes[1].text(j, i, f'{data_ace[i, j]:.1f}', 
                       ha='center', va='center', color='black')
    
    plt.tight_layout()
    plt.show()
    
    print("\n4. Finding Optimal Policy with MC Control...")
    
    # Convert environment to standard interface
    class StandardBlackjack:
        def __init__(self):
            self.env = BlackjackEnvironment()
            self.action_space = type('ActionSpace', (), {'n': 2})()
            self.observation_space = None
        
        def reset(self):
            return self.env.reset()
        
        def step(self, action):
            return self.env.step(action)
    
    std_env = StandardBlackjack()
    mc_control = MonteCarloControl(std_env, gamma=1.0)
    
    print("   Running on-policy MC control...")
    policy, Q, rewards = mc_control.on_policy_mc_control(num_episodes=100000, epsilon=0.1)
    
    # Extract optimal policy for visualization
    optimal_policy_no_ace = np.zeros((len(player_sums), len(dealer_cards)))
    optimal_policy_ace = np.zeros((len(player_sums), len(dealer_cards)))
    
    for i, p_sum in enumerate(player_sums):
        for j, d_card in enumerate(dealer_cards):
            # Without usable ace
            state = (p_sum, False, d_card)
            optimal_policy_no_ace[i, j] = policy.get(state, 0)
            
            # With usable ace
            state = (p_sum, True, d_card)
            optimal_policy_ace[i, j] = policy.get(state, 0)
    
    # Plot optimal policies
    fig, axes = plt.subplots(1, 2, figsize=(15, 6))
    
    # No usable ace
    im1 = axes[0].imshow(optimal_policy_no_ace, cmap='coolwarm', aspect='auto', vmin=0, vmax=1)
    axes[0].set_title('Optimal Policy: No Usable Ace (0=Stick, 1=Hit)')
    axes[0].set_xlabel('Dealer Showing')
    axes[0].set_ylabel('Player Sum')
    axes[0].set_xticks(range(len(dealer_cards)))
    axes[0].set_xticklabels(dealer_cards)
    axes[0].set_yticks(range(len(player_sums)))
    axes[0].set_yticklabels(player_sums)
    plt.colorbar(im1, ax=axes[0], ticks=[0, 1])
    
    # With usable ace
    im2 = axes[1].imshow(optimal_policy_ace, cmap='coolwarm', aspect='auto', vmin=0, vmax=1)
    axes[1].set_title('Optimal Policy: With Usable Ace (0=Stick, 1=Hit)')
    axes[1].set_xlabel('Dealer Showing')
    axes[1].set_ylabel('Player Sum')
    axes[1].set_xticks(range(len(dealer_cards)))
    axes[1].set_xticklabels(dealer_cards)
    axes[1].set_yticks(range(len(player_sums)))
    axes[1].set_yticklabels(player_sums)
    plt.colorbar(im2, ax=axes[1], ticks=[0, 1])
    
    plt.tight_layout()
    plt.show()
    
    print("\n5. Performance Analysis:")
    print(f"   Final 1000 episodes average reward: {np.mean(rewards[-1000:]):.3f}")
    print(f"   Best single episode reward: {np.max(rewards)}")
    
    # Plot learning curve
    plt.figure(figsize=(12, 5))
    
    plt.subplot(1, 2, 1)
    plt.plot(rewards, alpha=0.5)
    plt.xlabel('Episode')
    plt.ylabel('Reward')
    plt.title('Learning Curve (Raw)')
    plt.grid(True, alpha=0.3)
    
    plt.subplot(1, 2, 2)
    window_size = 1000
    moving_avg = np.convolve(rewards, np.ones(window_size)/window_size, mode='valid')
    plt.plot(moving_avg)
    plt.xlabel('Episode')
    plt.ylabel('Average Reward (1000-episode window)')
    plt.title('Smoothed Learning Curve')
    plt.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    print("\n" + "="*70)
    print("KEY INSIGHTS FROM BLACKJACK")
    print("="*70)
    
    print("\n1. With usable ace:")
    print("   - Can hit more aggressively (ace can be 1 or 11)")
    print("   - Optimal to hit on lower sums")
    
    print("\n2. Without usable ace:")
    print("   - More conservative strategy")
    print("   - Stick earlier to avoid busting")
    
    print("\n3. Against high dealer cards (7-10):")
    print("   - More aggressive hitting")
    print("   - Need higher total to compete")
    
    print("\n4. Monte Carlo strengths:")
    print("   - Learns from actual experience")
    print("   - No model needed")
    print("   - Handles stochastic environments")
    
    return env, V, policy, Q, rewards

# Run the Blackjack experiment
blackjack_env, V_values, optimal_policy, Q_values, reward_history = blackjack_monte_carlo_experiment()

5.5 Importance Sampling for Off-Policy Learning

def importance_sampling_demo():
    """Demonstrate importance sampling for off-policy MC"""
    print("="*70)
    print("IMPORTANCE SAMPLING DEMONSTRATION")
    print("="*70)
    
    # Simple environment with two actions
    class SimpleImportanceEnv:
        def __init__(self):
            self.states = ['s1', 's2', 's3']
            self.actions = [0, 1]  # 0=left, 1=right
        
        def reset(self):
            self.state = 's1'
            return self.state
        
        def step(self, action):
            transitions = {
                ('s1', 0): ('s2', 1),   # left gives +1
                ('s1', 1): ('s3', 10),  # right gives +10 (better!)
                ('s2', 0): ('s3', 2),
                ('s2', 1): ('s1', 1),
                ('s3', 0): ('s1', 0),
                ('s3', 1): ('s2', 0)
            }
            
            if (self.state, action) in transitions:
                next_state, reward = transitions[(self.state, action)]
            else:
                next_state, reward = self.state, 0
            
            self.state = next_state
            done = False
            return next_state, reward, done
    
    env = SimpleImportanceEnv()
    
    print("\n1. Understanding the Problem:")
    print("   Target policy π: always choose action 1 (right)")
    print("   Behavior policy b: choose action 0 with 0.8 prob, action 1 with 0.2 prob")
    print("   We want to learn Q^π from episodes generated by b")
    
    print("\n2. Importance Sampling Ratio:")
    print("   Weight = Π_{k=t}^{T-1} [π(A_k|S_k) / b(A_k|S_k)]")
    print("   For deterministic π and stochastic b:")
    print("   - If π would choose same action as b: weight increases")
    print("   - If π would choose different action: weight = 0")
    
    print("\n3. Implementation:")
    
    # Target policy (deterministic, always choose action 1)
    def target_policy(state):
        return 1
    
    # Behavior policy (stochastic)
    def behavior_policy(state):
        return np.random.choice([0, 1], p=[0.8, 0.2])
    
    # Generate episode with behavior policy
    def generate_episode_with_policy(policy):
        episode = []
        state = env.reset()
        done = False
        
        while not done and len(episode) < 10:  # Limit steps
            action = policy(state)
            next_state, reward, done = env.step(action)
            episode.append((state, action, reward))
            state = next_state
        
        return episode
    
    print("\n4. Example Episode from Behavior Policy:")
    episode = generate_episode_with_policy(behavior_policy)
    for i, (state, action, reward) in enumerate(episode):
        print(f"   Step {i}: State={state}, Action={action}, Reward={reward}")
    
    print("\n5. Calculating Importance Sampling Weights:")
    G = 0
    weights = []
    cumulative_weights = []
    
    # Work backwards
    for t in reversed(range(len(episode))):
        state, action, reward = episode[t]
        G = 0.9 * G + reward  # gamma = 0.9
        
        # Importance sampling ratio for this step
        if action == target_policy(state):
            # π(action|state) = 1 (deterministic)
            # b(action|state) = 0.2 for action 1, 0.8 for action 0
            behavior_prob = 0.2 if action == 1 else 0.8
            weight = 1.0 / behavior_prob
        else:
            weight = 0  # π would never take this action
        
        weights.insert(0, weight)
        cumulative_weights.insert(0, weight * (cumulative_weights[0] if cumulative_weights else 1))
    
    print("\n6. Results:")
    print("   Episode return G = {:.2f}".format(G))
    print("   Step-by-step weights:")
    for i, (state, action, reward) in enumerate(episode):
        print(f"   Step {i}: State={state}, Action={action}, "
              f"Weight={weights[i]:.2f}, CumWeight={cumulative_weights[i]:.2f}")
    
    print("\n7. Weighted Returns:")
    weighted_return = G * cumulative_weights[-1]
    print(f"   Weighted return = {G:.2f} × {cumulative_weights[-1]:.2f} = {weighted_return:.2f}")
    
    print("\n8. Ordinary Importance Sampling vs Weighted:")
    print("   Ordinary IS: average of weighted returns")
    print("   Weighted IS: weighted average of returns")
    print("   Weighted IS has lower variance but is biased")
    
    return episode, weights, cumulative_weights

# Run importance sampling demo
episode_data, is_weights, cum_weights = importance_sampling_demo()

5.6 Chapter Project: Windy Gridworld

def windy_gridworld_project():
    """MC methods on Windy Gridworld"""
    print("="*70)
    print("WINDY GRIDWORLD PROJECT")
    print("="*70)
    
    class WindyGridworld:
        """Gridworld with wind that pushes agent upward"""
        
        def __init__(self, width=10, height=7):
            self.width = width
            self.height = height
            
            # Start and goal positions
            self.start = (3, 0)
            self.goal = (3, 7)
            
            # Wind strength for each column
            self.wind = [0, 0, 0, 1, 1, 1, 2, 2, 1, 0]
            
            # Actions: 0=up, 1=right, 2=down, 3=left
            self.actions = [0, 1, 2, 3]
            self.action_deltas = {
                0: (-1, 0),  # up
                1: (0, 1),   # right
                2: (1, 0),   # down
                3: (0, -1)   # left
            }
            
            self.reset()
        
        def reset(self):
            """Reset to start position"""
            self.position = list(self.start)
            self.steps = 0
            self.done = False
            return tuple(self.position)
        
        def step(self, action):
            """Take action, considering wind"""
            if self.done:
                return tuple(self.position), 0, True
            
            self.steps += 1
            
            # Move according to action
            dr, dc = self.action_deltas[action]
            new_r = self.position[0] + dr
            new_c = self.position[1] + dc
            
            # Apply wind
            wind_strength = self.wind[self.position[1]]
            new_r -= wind_strength  # Wind blows upward
            
            # Clip to grid bounds
            new_r = max(0, min(self.height - 1, new_r))
            new_c = max(0, min(self.width - 1, new_c))
            
            self.position = [new_r, new_c]
            
            # Check if reached goal
            if tuple(self.position) == self.goal:
                self.done = True
                reward = 0  # No penalty for reaching goal
            else:
                reward = -1  # Penalty for each step
            
            # Limit episode length
            if self.steps >= 100:
                self.done = True
            
            return tuple(self.position), reward, self.done
        
        def render(self):
            """Render current state"""
            grid = [['.' for _ in range(self.width)] for _ in range(self.height)]
            
            # Mark start and goal
            grid[self.start[0]][self.start[1]] = 'S'
            grid[self.goal[0]][self.goal[1]] = 'G'
            
            # Mark agent
            r, c = self.position
            grid[r][c] = 'A'
            
            # Print with wind indicators
            print("Windy Gridworld (wind blows ↑, strength shown below):")
            print("+" + "-" * (2*self.width + 1) + "+")
            
            for r in range(self.height):
                print("| " + " ".join(grid[r]) + " |")
            
            print("+" + "-" * (2*self.width + 1) + "+")
            print("Wind: " + " ".join(str(w) for w in self.wind))
            print(f"Steps: {self.steps}, Position: {self.position}")
    
    print("\n1. Creating Windy Gridworld Environment...")
    env = WindyGridworld()
    
    print("\n2. Testing Environment...")
    state = env.reset()
    env.render()
    
    # Test random actions
    print("\n3. Testing Random Policy for 5 steps:")
    for i in range(5):
        action = np.random.choice(env.actions)
        state, reward, done = env.step(action)
        print(f"   Step {i+1}: Action={action}, Reward={reward}, New state={state}")
        if done:
            break
    
    print("\n4. Implementing Monte Carlo Control...")
    
    # Convert to standard interface
    class StandardWindyGridworld:
        def __init__(self):
            self.env = WindyGridworld()
            self.action_space = type('ActionSpace', (), {'n': 4})()
            self.states = [(r, c) for r in range(env.height) for c in range(env.width)]
        
        def reset(self):
            return self.env.reset()
        
        def step(self, action):
            return self.env.step(action)
    
    std_env = StandardWindyGridworld()
    mc_control = MonteCarloControl(std_env, gamma=0.9)
    
    print("\n5. Training with On-Policy MC Control...")
    policy, Q, rewards = mc_control.on_policy_mc_control(
        num_episodes=2000, epsilon=0.1
    )
    
    print("\n6. Visualizing Learned Policy...")
    
    # Extract policy for visualization
    policy_grid = np.full((env.height, env.width), ' ', dtype=object)
    action_symbols = {0: '↑', 1: '→', 2: '↓', 3: '←'}
    
    for r in range(env.height):
        for c in range(env.width):
            state = (r, c)
            if state in policy:
                action = policy[state]
                policy_grid[r, c] = action_symbols.get(action, '?')
            else:
                policy_grid[r, c] = '.'
    
    # Mark special positions
    policy_grid[env.start] = 'S'
    policy_grid[env.goal] = 'G'
    
    # Plot
    fig, axes = plt.subplots(1, 2, figsize=(15, 6))
    
    # Policy visualization
    ax1 = axes[0]
    im1 = ax1.imshow(np.zeros((env.height, env.width)), cmap='Blues', alpha=0.3)
    
    # Add wind indicators
    for c in range(env.width):
        wind_str = str(env.wind[c])
        ax1.text(c, env.height + 0.5, wind_str, ha='center', va='center', fontweight='bold')
    
    # Add policy arrows
    for r in range(env.height):
        for c in range(env.width):
            ax1.text(c, r, policy_grid[r, c], ha='center', va='center', 
                    fontsize=12, fontweight='bold')
    
    ax1.set_title('Learned Policy (Arrows show optimal action)')
    ax1.set_xlabel('Column')
    ax1.set_ylabel('Row')
    ax1.set_xticks(range(env.width))
    ax1.set_yticks(range(env.height))
    ax1.grid(True, alpha=0.3)
    
    # Learning curve
    ax2 = axes[1]
    window_size = 50
    moving_avg = np.convolve(rewards, np.ones(window_size)/window_size, mode='valid')
    
    ax2.plot(moving_avg, linewidth=2)
    ax2.set_xlabel('Episode')
    ax2.set_ylabel('Average Reward (50-episode window)')
    ax2.set_title('Learning Curve')
    ax2.grid(True, alpha=0.3)
    
    # Add horizontal line at optimal (shortest path ~15 steps = -15 reward)
    optimal_reward = -15
    ax2.axhline(y=optimal_reward, color='r', linestyle='--', alpha=0.5, 
               label=f'Optimal (~{optimal_reward})')
    ax2.legend()
    
    plt.tight_layout()
    plt.show()
    
    print("\n7. Testing Learned Policy...")
    
    def test_policy(policy, num_tests=10):
        """Test policy on environment"""
        test_rewards = []
        steps_to_goal = []
        
        for test in range(num_tests):
            state = env.reset()
            total_reward = 0
            steps = 0
            done = False
            
            while not done:
                if state in policy:
                    action = policy[state]
                else:
                    action = np.random.choice(env.actions)
                
                state, reward, done = env.step(action)
                total_reward += reward
                steps += 1
            
            test_rewards.append(total_reward)
            steps_to_goal.append(steps)
        
        return test_rewards, steps_to_goal
    
    test_rewards, test_steps = test_policy(policy, num_tests=100)
    
    print(f"   Average reward: {np.mean(test_rewards):.2f}")
    print(f"   Average steps to goal: {np.mean(test_steps):.2f}")
    print(f"   Best performance: {np.max(test_rewards)} reward, {np.min(test_steps)} steps")
    print(f"   Worst performance: {np.min(test_rewards)} reward, {np.max(test_steps)} steps")
    
    print("\n8. Comparing with Optimal Path:")
    print("   Without wind, optimal path: 7 steps right = -7 reward")
    print("   With wind, need to account for upward drift")
    print("   Optimal strategy: move down in windy columns to compensate")
    
    print("\n" + "="*70)
    print("MONTE CARLO SUMMARY")
    print("="*70)
    
    print("\nAdvantages of Monte Carlo:")
    print("1. Model-free: Doesn't need P(s'|s,a)")
    print("2. Learns from actual experience")
    print("3. Handles stochastic policies naturally")
    print("4. Simple to implement")
    
    print("\nDisadvantages:")
    print("1. High variance (learns from complete episodes)")
    print("2. Must wait until end of episode to update")
    print("3. Exploring starts can be impractical")
    
    print("\nKey Concepts:")
    print("- First-visit vs every-visit MC")
    print("- Exploring starts for control")
    print("- Importance sampling for off-policy learning")
    print("- ε-greedy policies for exploration")
    
    return env, policy, Q, rewards

# Run Windy Gridworld project
windy_env, windy_policy, windy_Q, windy_rewards = windy_gridworld_project()

Chapter 5 Key Takeaways:

  • Monte Carlo methods learn from complete episodes
  • No model required - learn directly from experience
  • First-visit MC averages returns after first visit to each state
  • Every-visit MC averages returns after every visit
  • On-policy methods learn the policy they're following
  • Off-policy methods learn about a target policy from a behavior policy
  • Importance sampling enables off-policy learning
  • ε-greedy policies balance exploration and exploitation

CHAPTER 6: Temporal-Difference Learning: The RL Breakthrough

6.1 The TD Idea: Learning Guess-from-Guess

Analogy: Predicting the Weather Imagine predicting today's high temperature:

  • Monte Carlo: Wait until midnight, then update based on actual high
  • Temporal Difference: Update at noon based on noon temp and afternoon forecast

The TD Breakthrough: Update estimates based on other estimates!

import numpy as np
import matplotlib.pyplot as plt
from collections import defaultdict, deque
import random

class TemporalDifferenceLearning:
    """Temporal Difference learning algorithms"""
    
    def __init__(self, env, gamma=0.9, alpha=0.1):
        self.env = env
        self.gamma = gamma
        self.alpha = alpha  # Learning rate
        
    def td_prediction(self, policy, num_episodes=1000):
        """
        TD(0) prediction algorithm
        V(s) ← V(s) + α[r + γV(s') - V(s)]
        """
        # Initialize value function
        V = defaultdict(float)
        
        for episode in range(num_episodes):
            state = self.env.reset()
            done = False
            
            while not done:
                # Get action from policy
                if isinstance(policy, dict):
                    action = policy.get(state, 0)
                else:
                    action = policy(state)
                
                # Take action
                next_state, reward, done = self.env.step(action)
                
                # TD update
                td_target = reward + self.gamma * V[next_state] * (not done)
                td_error = td_target - V[state]
                V[state] += self.alpha * td_error
                
                state = next_state
            
            if (episode + 1) % 100 == 0:
                print(f"Episode {episode + 1}/{num_episodes}")
        
        return V
    
    def sarsa(self, num_episodes=5000, epsilon=0.1):
        """
        SARSA algorithm: On-policy TD control
        Q(s,a) ← Q(s,a) + α[r + γQ(s',a') - Q(s,a)]
        """
        # Initialize Q-function
        Q = defaultdict(lambda: np.zeros(self.env.action_space.n))
        
        # Create epsilon-greedy policy
        def epsilon_greedy_policy(state, epsilon):
            if np.random.random() < epsilon:
                return np.random.randint(self.env.action_space.n)
            else:
                return np.argmax(Q[state])
        
        # Track performance
        episode_rewards = []
        episode_lengths = []
        
        for episode in range(num_episodes):
            state = self.env.reset()
            action = epsilon_greedy_policy(state, epsilon)
            total_reward = 0
            steps = 0
            done = False
            
            while not done:
                # Take action
                next_state, reward, done = self.env.step(action)
                total_reward += reward
                steps += 1
                
                # Choose next action
                next_action = epsilon_greedy_policy(next_state, epsilon)
                
                # SARSA update
                td_target = reward + self.gamma * Q[next_state][next_action] * (not done)
                td_error = td_target - Q[state][action]
                Q[state][action] += self.alpha * td_error
                
                # Update state and action
                state = next_state
                action = next_action
            
            episode_rewards.append(total_reward)
            episode_lengths.append(steps)
            
            # Decay epsilon
            epsilon = max(0.01, epsilon * 0.995)
            
            if (episode + 1) % 500 == 0:
                avg_reward = np.mean(episode_rewards[-500:])
                print(f"Episode {episode + 1}: Avg reward = {avg_reward:.2f}")
        
        # Extract deterministic policy
        policy = {}
        for state in Q:
            policy[state] = np.argmax(Q[state])
        
        return policy, Q, episode_rewards, episode_lengths
    
    def q_learning(self, num_episodes=5000, epsilon=0.1):
        """
        Q-learning algorithm: Off-policy TD control
        Q(s,a) ← Q(s,a) + α[r + γ max_a' Q(s',a') - Q(s,a)]
        """
        # Initialize Q-function
        Q = defaultdict(lambda: np.zeros(self.env.action_space.n))
        
        # Track performance
        episode_rewards = []
        episode_lengths = []
        
        for episode in range(num_episodes):
            state = self.env.reset()
            total_reward = 0
            steps = 0
            done = False
            
            while not done:
                # Epsilon-greedy action selection
                if np.random.random() < epsilon:
                    action = np.random.randint(self.env.action_space.n)
                else:
                    action = np.argmax(Q[state])
                
                # Take action
                next_state, reward, done = self.env.step(action)
                total_reward += reward
                steps += 1
                
                # Q-learning update
                if done:
                    td_target = reward
                else:
                    td_target = reward + self.gamma * np.max(Q[next_state])
                
                td_error = td_target - Q[state][action]
                Q[state][action] += self.alpha * td_error
                
                # Update state
                state = next_state
            
            episode_rewards.append(total_reward)
            episode_lengths.append(steps)
            
            # Decay epsilon
            epsilon = max(0.01, epsilon * 0.995)
            
            if (episode + 1) % 500 == 0:
                avg_reward = np.mean(episode_rewards[-500:])
                print(f"Episode {episode + 1}: Avg reward = {avg_reward:.2f}")
        
        # Extract deterministic policy
        policy = {}
        for state in Q:
            policy[state] = np.argmax(Q[state])
        
        return policy, Q, episode_rewards, episode_lengths
    
    def expected_sarsa(self, num_episodes=5000, epsilon=0.1):
        """
        Expected SARSA algorithm
        Q(s,a) ← Q(s,a) + α[r + γ 𝔼[Q(s',a')] - Q(s,a)]
        """
        # Initialize Q-function
        Q = defaultdict(lambda: np.zeros(self.env.action_space.n))
        
        # Track performance
        episode_rewards = []
        episode_lengths = []
        
        for episode in range(num_episodes):
            state = self.env.reset()
            total_reward = 0
            steps = 0
            done = False
            
            while not done:
                # Epsilon-greedy action selection
                if np.random.random() < epsilon:
                    action = np.random.randint(self.env.action_space.n)
                else:
                    action = np.argmax(Q[state])
                
                # Take action
                next_state, reward, done = self.env.step(action)
                total_reward += reward
                steps += 1
                
                # Expected SARSA update
                if done:
                    expected_next_q = 0
                else:
                    # Calculate expected value of next state
                    max_q = np.max(Q[next_state])
                    
                    # Count number of actions with max Q-value
                    n_max_actions = np.sum(Q[next_state] == max_q)
                    
                    # Calculate probabilities for epsilon-greedy policy
                    greedy_prob = 1 - epsilon + epsilon / self.env.action_space.n
                    non_greedy_prob = epsilon / self.env.action_space.n
                    
                    # Calculate expected value
                    expected_next_q = 0
                    for a in range(self.env.action_space.n):
                        if Q[next_state][a] == max_q:
                            prob = greedy_prob / n_max_actions if n_max_actions > 0 else 0
                        else:
                            prob = non_greedy_prob
                        expected_next_q += prob * Q[next_state][a]
                
                td_target = reward + self.gamma * expected_next_q
                td_error = td_target - Q[state][action]
                Q[state][action] += self.alpha * td_error
                
                # Update state
                state = next_state
            
            episode_rewards.append(total_reward)
            episode_lengths.append(steps)
            
            # Decay epsilon
            epsilon = max(0.01, epsilon * 0.995)
        
        # Extract deterministic policy
        policy = {}
        for state in Q:
            policy[state] = np.argmax(Q[state])
        
        return policy, Q, episode_rewards, episode_lengths

6.2 TD(λ): Bridging Monte Carlo and TD(0)

class EligibilityTraces:
    """TD(λ) with eligibility traces"""
    
    def __init__(self, env, gamma=0.9, alpha=0.1, lambda_param=0.7):
        self.env = env
        self.gamma = gamma
        self.alpha = alpha
        self.lambda_param = lambda_param
        
    def td_lambda_prediction(self, policy, num_episodes=1000):
        """
        TD(λ) prediction with eligibility traces
        """
        V = defaultdict(float)  # State values
        e = defaultdict(float)  # Eligibility traces
        
        episode_rewards = []
        
        for episode in range(num_episodes):
            # Reset eligibility traces
            e.clear()
            
            state = self.env.reset()
            total_reward = 0
            done = False
            
            while not done:
                # Get action from policy
                if isinstance(policy, dict):
                    action = policy.get(state, 0)
                else:
                    action = policy(state)
                
                # Take action
                next_state, reward, done = self.env.step(action)
                total_reward += reward
                
                # Calculate TD error
                if done:
                    td_target = reward
                else:
                    td_target = reward + self.gamma * V[next_state]
                
                td_error = td_target - V[state]
                
                # Update eligibility trace for current state
                e[state] = e.get(state, 0) + 1
                
                # Update all states
                for s in list(e.keys()):
                    V[s] += self.alpha * td_error * e[s]
                    e[s] *= self.gamma * self.lambda_param
                
                state = next_state
            
            episode_rewards.append(total_reward)
        
        return V, episode_rewards
    
    def sarsa_lambda(self, num_episodes=2000, epsilon=0.1):
        """
        SARSA(λ) algorithm with eligibility traces
        """
        # Initialize Q-function and eligibility traces
        Q = defaultdict(lambda: np.zeros(self.env.action_space.n))
        E = defaultdict(lambda: np.zeros(self.env.action_space.n))
        
        episode_rewards = []
        episode_lengths = []
        
        for episode in range(num_episodes):
            # Reset eligibility traces
            E.clear()
            
            state = self.env.reset()
            
            # Choose action
            if np.random.random() < epsilon:
                action = np.random.randint(self.env.action_space.n)
            else:
                action = np.argmax(Q[state])
            
            total_reward = 0
            steps = 0
            done = False
            
            while not done:
                # Take action
                next_state, reward, done = self.env.step(action)
                total_reward += reward
                steps += 1
                
                # Choose next action
                if np.random.random() < epsilon:
                    next_action = np.random.randint(self.env.action_space.n)
                else:
                    next_action = np.argmax(Q[next_state])
                
                # Calculate TD error
                if done:
                    td_target = reward
                else:
                    td_target = reward + self.gamma * Q[next_state][next_action]
                
                td_error = td_target - Q[state][action]
                
                # Update eligibility trace
                E[state][action] = E[state][action] + 1
                
                # Update all state-action pairs
                for s in list(E.keys()):
                    for a in range(self.env.action_space.n):
                        Q[s][a] += self.alpha * td_error * E[s][a]
                        E[s][a] *= self.gamma * self.lambda_param
                
                # Move to next state-action pair
                state = next_state
                action = next_action
            
            episode_rewards.append(total_reward)
            episode_lengths.append(steps)
            
            # Decay epsilon
            epsilon = max(0.01, epsilon * 0.995)
            
            if (episode + 1) % 200 == 0:
                avg_reward = np.mean(episode_rewards[-200:])
                print(f"Episode {episode + 1}: Avg reward = {avg_reward:.2f}")
        
        # Extract deterministic policy
        policy = {}
        for state in Q:
            policy[state] = np.argmax(Q[state])
        
        return policy, Q, episode_rewards, episode_lengths

6.3 The Great Comparison: DP, MC, and TD

def compare_rl_algorithms():
    """Comprehensive comparison of RL algorithms"""
    print("="*70)
    print("COMPARING REINFORCEMENT LEARNING ALGORITHMS")
    print("="*70)
    
    # Create a simple grid world for testing
    class ComparisonEnv:
        def __init__(self, size=5):
            self.size = size
            self.goal = (size-1, size-1)
            self.start = (0, 0)
            self.current = self.start
            
            # Actions: 0=up, 1=right, 2=down, 3=left
            self.actions = [0, 1, 2, 3]
            self.action_deltas = {
                0: (-1, 0),
                1: (0, 1),
                2: (1, 0),
                3: (0, -1)
            }
        
        def reset(self):
            self.current = self.start
            return self.current
        
        def step(self, action):
            r, c = self.current
            dr, dc = self.action_deltas[action]
            
            new_r = max(0, min(self.size-1, r + dr))
            new_c = max(0, min(self.size-1, c + dc))
            
            self.current = (new_r, new_c)
            
            # Rewards
            if self.current == self.goal:
                reward = 10
                done = True
            else:
                reward = -1  # Step penalty
                done = False
            
            return self.current, reward, done
        
        @property
        def action_space(self):
            return type('ActionSpace', (), {'n': 4})()
    
    env = ComparisonEnv(size=5)
    
    print("\n1. TEST ENVIRONMENT:")
    print(f"   Grid size: {env.size}×{env.size}")
    print(f"   Start: {env.start}, Goal: {env.goal}")
    print(f"   Step penalty: -1, Goal reward: +10")
    
    # Define a simple policy for prediction
    def simple_policy(state):
        """Move toward goal: right if column < goal column, else down"""
        r, c = state
        goal_r, goal_c = env.goal
        
        if c < goal_c:
            return 1  # right
        elif r < goal_r:
            return 2  # down
        else:
            return 0  # up
    
    print("\n2. ALGORITHM COMPARISON:")
    print("\n   A. Dynamic Programming (Value Iteration)")
    print("      - Requires complete model (P and R)")
    print("      - Updates all states simultaneously")
    print("      - Guaranteed convergence to optimal")
    
    print("\n   B. Monte Carlo Methods")
    print("      - Model-free: learns from episodes")
    print("      - High variance, zero bias")
    print("      - Must wait until end of episode")
    
    print("\n   C. Temporal Difference Learning")
    print("      - Model-free: learns from transitions")
    print("      - Low variance, some bias")
    print("      - Updates after each step")
    print("      - Bootstrapping: learns guess-from-guess")
    
    print("\n3. IMPLEMENTING AND COMPARING:")
    
    # Test TD prediction
    print("\n   Testing TD(0) Prediction...")
    td_learner = TemporalDifferenceLearning(env, gamma=0.9, alpha=0.1)
    V_td = td_learner.td_prediction(simple_policy, num_episodes=1000)
    
    # Test Monte Carlo prediction for comparison
    print("\n   Testing Monte Carlo Prediction...")
    
    class MonteCarloSimple:
        def __init__(self, env, gamma=0.9):
            self.env = env
            self.gamma = gamma
            self.V = defaultdict(float)
            self.returns = defaultdict(list)
        
        def mc_prediction(self, policy, num_episodes=1000):
            for episode in range(num_episodes):
                # Generate episode
                episode_history = []
                state = env.reset()
                done = False
                
                while not done:
                    action = policy(state)
                    next_state, reward, done = env.step(action)
                    episode_history.append((state, action, reward))
                    state = next_state
                
                # Calculate returns
                G = 0
                visited = set()
                
                for t in reversed(range(len(episode_history))):
                    state, action, reward = episode_history[t]
                    G = self.gamma * G + reward
                    
                    if state not in visited:
                        visited.add(state)
                        self.returns[state].append(G)
                        self.V[state] = np.mean(self.returns[state])
            
            return self.V
    
    mc_learner = MonteCarloSimple(env, gamma=0.9)
    V_mc = mc_learner.mc_prediction(simple_policy, num_episodes=1000)
    
    # Compare results
    print("\n4. COMPARISON RESULTS:")
    print("\n   Value estimates for start state (0,0):")
    print(f"   TD(0): {V_td.get((0,0), 0):.3f}")
    print(f"   Monte Carlo: {V_mc.get((0,0), 0):.3f}")
    
    # Theoretical optimal value (for comparison)
    print("\n   Theoretical optimal (shortest path):")
    print("   Shortest path: 8 steps (7 right + 1 down)")
    print("   Reward: 7×(-1) + 10 = 3")
    print("   Discounted: -1 -0.9 -0.81 -0.73 -0.66 -0.59 -0.53 + 10×0.48 = 1.68")
    
    print("\n5. CONTROL ALGORITHMS COMPARISON:")
    
    # Test different control algorithms
    print("\n   Testing SARSA (on-policy TD control)...")
    policy_sarsa, Q_sarsa, rewards_sarsa, lengths_sarsa = td_learner.sarsa(
        num_episodes=2000, epsilon=0.1
    )
    
    print("\n   Testing Q-learning (off-policy TD control)...")
    policy_q, Q_q, rewards_q, lengths_q = td_learner.q_learning(
        num_episodes=2000, epsilon=0.1
    )
    
    print("\n   Testing Expected SARSA...")
    policy_expected, Q_expected, rewards_expected, lengths_expected = td_learner.expected_sarsa(
        num_episodes=2000, epsilon=0.1
    )
    
    # Plot comparison
    plt.figure(figsize=(15, 10))
    
    # Learning curves
    plt.subplot(2, 3, 1)
    window = 50
    sarsa_smooth = np.convolve(rewards_sarsa, np.ones(window)/window, mode='valid')
    q_smooth = np.convolve(rewards_q, np.ones(window)/window, mode='valid')
    expected_smooth = np.convolve(rewards_expected, np.ones(window)/window, mode='valid')
    
    plt.plot(sarsa_smooth, label='SARSA', alpha=0.8)
    plt.plot(q_smooth, label='Q-learning', alpha=0.8)
    plt.plot(expected_smooth, label='Expected SARSA', alpha=0.8)
    plt.xlabel('Episode')
    plt.ylabel('Average Reward (50-episode window)')
    plt.title('Learning Curves Comparison')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # Final performance
    plt.subplot(2, 3, 2)
    final_rewards = [
        np.mean(rewards_sarsa[-100:]),
        np.mean(rewards_q[-100:]),
        np.mean(rewards_expected[-100:])
    ]
    algorithms = ['SARSA', 'Q-learning', 'Expected SARSA']
    colors = ['blue', 'red', 'green']
    
    bars = plt.bar(algorithms, final_rewards, color=colors, alpha=0.7)
    plt.ylabel('Average Reward (last 100 episodes)')
    plt.title('Final Performance Comparison')
    
    # Add value labels on bars
    for bar, reward in zip(bars, final_rewards):
        plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.1,
                f'{reward:.2f}', ha='center', va='bottom')
    
    # Episode lengths
    plt.subplot(2, 3, 3)
    final_lengths = [
        np.mean(lengths_sarsa[-100:]),
        np.mean(lengths_q[-100:]),
        np.mean(lengths_expected[-100:])
    ]
    
    bars = plt.bar(algorithms, final_lengths, color=colors, alpha=0.7)
    plt.ylabel('Average Steps (last 100 episodes)')
    plt.title('Efficiency Comparison')
    
    # Add value labels
    for bar, length in zip(bars, final_lengths):
        plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.1,
                f'{length:.1f}', ha='center', va='bottom')
    
    # Q-value distributions
    plt.subplot(2, 3, 4)
    q_values = []
    for Q_dict, label in [(Q_sarsa, 'SARSA'), (Q_q, 'Q-learning'), (Q_expected, 'Expected SARSA')]:
        values = []
        for state in Q_dict:
            values.extend(Q_dict[state])
        q_values.append(values)
    
    plt.boxplot(q_values, labels=algorithms)
    plt.ylabel('Q-value')
    plt.title('Q-value Distribution')
    plt.grid(True, alpha=0.3)
    
    # Convergence speed
    plt.subplot(2, 3, 5)
    convergence_points = []
    
    for rewards, label in [(rewards_sarsa, 'SARSA'), (rewards_q, 'Q-learning'), (rewards_expected, 'Expected SARSA')]:
        target = final_rewards[algorithms.index(label)] * 0.9  # 90% of final performance
        for i in range(len(rewards)):
            if np.mean(rewards[max(0, i-49):i+1]) >= target:
                convergence_points.append(i)
                break
    
    bars = plt.bar(algorithms, convergence_points, color=colors, alpha=0.7)
    plt.ylabel('Episodes to 90% convergence')
    plt.title('Convergence Speed')
    
    for bar, point in zip(bars, convergence_points):
        plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 10,
                f'{point}', ha='center', va='bottom')
    
    # Policy visualization (heatmap)
    plt.subplot(2, 3, 6)
    
    def create_policy_grid(policy):
        grid = np.zeros((env.size, env.size))
        for r in range(env.size):
            for c in range(env.size):
                state = (r, c)
                if state in policy:
                    grid[r, c] = policy[state]
                else:
                    grid[r, c] = -1
        return grid
    
    policy_grid = create_policy_grid(policy_q)
    im = plt.imshow(policy_grid, cmap='viridis', interpolation='nearest')
    plt.colorbar(im, label='Action (0-3)')
    plt.title('Q-learning Policy (example)')
    plt.xlabel('Column')
    plt.ylabel('Row')
    
    plt.tight_layout()
    plt.show()
    
    print("\n6. KEY DIFFERENCES:")
    print("\n   SARSA (on-policy):")
    print("   - Learns Q-values for the policy being followed")
    print("   - More conservative (accounts for exploration)")
    print("   - Better for stochastic environments")
    
    print("\n   Q-learning (off-policy):")
    print("   - Learns optimal Q-values directly")
    print("   - More aggressive (always assumes optimal action next)")
    print("   - Can be more efficient but may overestimate")
    
    print("\n   Expected SARSA:")
    print("   - Takes expectation over next actions")
    print("   - Reduces variance compared to SARSA")
    print("   - Can be used on-policy or off-policy")
    
    print("\n7. PRACTICAL RECOMMENDATIONS:")
    print("   - Use SARSA when exploration affects next action")
    print("   - Use Q-learning for deterministic environments")
    print("   - Use Expected SARSA to reduce variance")
    print("   - TD methods generally faster than Monte Carlo")
    print("   - Eligibility traces (TD(λ)) can speed up learning")
    
    return {
        'SARSA': (policy_sarsa, rewards_sarsa, lengths_sarsa),
        'Q-learning': (policy_q, rewards_q, lengths_q),
        'Expected SARSA': (policy_expected, rewards_expected, lengths_expected)
    }

# Run the comparison
comparison_results = compare_rl_algorithms()

6.4 n-step TD Methods

class NStepTD:
    """n-step TD methods"""
    
    def __init__(self, env, gamma=0.9, alpha=0.1):
        self.env = env
        self.gamma = gamma
        self.alpha = alpha
    
    def n_step_td_prediction(self, policy, n=3, num_episodes=1000):
        """
        n-step TD prediction
        """
        V = defaultdict(float)
        
        for episode in range(num_episodes):
            # Initialize
            states = [self.env.reset()]
            rewards = [0]
            
            T = float('inf')
            t = 0
            
            while True:
                if t < T:
                    # Take action
                    state = states[t]
                    if isinstance(policy, dict):
                        action = policy.get(state, 0)
                    else:
                        action = policy(state)
                    
                    next_state, reward, done = self.env.step(action)
                    
                    # Store
                    states.append(next_state)
                    rewards.append(reward)
                    
                    if done:
                        T = t + 1
                
                # Update time of interest
                tau = t - n + 1
                
                if tau >= 0:
                    # Calculate n-step return
                    G = 0
                    for i in range(tau + 1, min(tau + n, T) + 1):
                        G += (self.gamma ** (i - tau - 1)) * rewards[i]
                    
                    if tau + n < T:
                        G += (self.gamma ** n) * V[states[tau + n]]
                    
                    # Update value
                    V[states[tau]] += self.alpha * (G - V[states[tau]])
                
                if tau == T - 1:
                    break
                
                t += 1
        
        return V
    
    def n_step_sarsa(self, n=3, num_episodes=2000, epsilon=0.1):
        """
        n-step SARSA algorithm
        """
        Q = defaultdict(lambda: np.zeros(self.env.action_space.n))
        
        episode_rewards = []
        
        for episode in range(num_episodes):
            # Initialize
            states = [self.env.reset()]
            actions = []
            rewards = [0]
            
            # Choose initial action
            state = states[0]
            if np.random.random() < epsilon:
                action = np.random.randint(self.env.action_space.n)
            else:
                action = np.argmax(Q[state])
            actions.append(action)
            
            T = float('inf')
            t = 0
            
            while True:
                if t < T:
                    # Take action
                    next_state, reward, done = self.env.step(actions[t])
                    
                    # Store
                    states.append(next_state)
                    rewards.append(reward)
                    
                    if done:
                        T = t + 1
                    else:
                        # Choose next action
                        if np.random.random() < epsilon:
                            next_action = np.random.randint(self.env.action_space.n)
                        else:
                            next_action = np.argmax(Q[next_state])
                        actions.append(next_action)
                
                # Update time
                tau = t - n + 1
                
                if tau >= 0:
                    # Calculate n-step return
                    G = 0
                    for i in range(tau + 1, min(tau + n, T) + 1):
                        G += (self.gamma ** (i - tau - 1)) * rewards[i]
                    
                    if tau + n < T:
                        state_tau_n = states[tau + n]
                        action_tau_n = actions[tau + n]
                        G += (self.gamma ** n) * Q[state_tau_n][action_tau_n]
                    
                    # Update Q-value
                    state_tau = states[tau]
                    action_tau = actions[tau]
                    Q[state_tau][action_tau] += self.alpha * (G - Q[state_tau][action_tau])
                
                if tau == T - 1:
                    break
                
                t += 1
            
            # Calculate episode reward
            episode_reward = sum(rewards[1:])  # rewards[0] is 0
            episode_rewards.append(episode_reward)
            
            # Decay epsilon
            epsilon = max(0.01, epsilon * 0.995)
        
        # Extract policy
        policy = {}
        for state in Q:
            policy[state] = np.argmax(Q[state])
        
        return policy, Q, episode_rewards
    
    def tree_backup(self, n=3, num_episodes=2000, epsilon=0.1):
        """
        Tree Backup algorithm (n-step off-policy without importance sampling)
        """
        Q = defaultdict(lambda: np.zeros(self.env.action_space.n))
        
        episode_rewards = []
        
        for episode in range(num_episodes):
            # Initialize
            states = [self.env.reset()]
            actions = []
            rewards = [0]
            
            # Choose initial action
            state = states[0]
            if np.random.random() < epsilon:
                action = np.random.randint(self.env.action_space.n)
            else:
                action = np.argmax(Q[state])
            actions.append(action)
            
            T = float('inf')
            t = 0
            
            while True:
                if t < T:
                    # Take action
                    next_state, reward, done = self.env.step(actions[t])
                    
                    # Store
                    states.append(next_state)
                    rewards.append(reward)
                    
                    if done:
                        T = t + 1
                    else:
                        # Choose next action
                        if np.random.random() < epsilon:
                            next_action = np.random.randint(self.env.action_space.n)
                        else:
                            next_action = np.argmax(Q[next_state])
                        actions.append(next_action)
                
                # Update time
                tau = t - n + 1
                
                if tau >= 0:
                    if t + 1 >= T:
                        G = rewards[T]
                    else:
                        # Calculate expected value for next state
                        next_state = states[tau + 1]
                        next_action = actions[tau + 1]
                        
                        # Probability of greedy action
                        max_q = np.max(Q[next_state])
                        n_max = np.sum(Q[next_state] == max_q)
                        greedy_prob = 1 - epsilon + epsilon / n_max if n_max > 0 else 0
                        non_greedy_prob = epsilon / self.env.action_space.n
                        
                        # Calculate expected Q-value
                        expected_q = 0
                        for a in range(self.env.action_space.n):
                            if Q[next_state][a] == max_q:
                                prob = greedy_prob
                            else:
                                prob = non_greedy_prob
                            expected_q += prob * Q[next_state][a]
                        
                        G = rewards[tau + 1] + self.gamma * expected_q
                    
                    # Backup through tree
                    for k in range(min(t, T-1), tau, -1):
                        state_k = states[k]
                        action_k = actions[k]
                        
                        # Calculate expected value for state_k
                        max_q = np.max(Q[state_k])
                        n_max = np.sum(Q[state_k] == max_q)
                        greedy_prob = 1 - epsilon + epsilon / n_max if n_max > 0 else 0
                        non_greedy_prob = epsilon / self.env.action_space.n
                        
                        expected_q = 0
                        for a in range(self.env.action_space.n):
                            if a != action_k:
                                if Q[state_k][a] == max_q:
                                    prob = greedy_prob
                                else:
                                    prob = non_greedy_prob
                                expected_q += prob * Q[state_k][a]
                        
                        G = rewards[k] + self.gamma * expected_q
                    
                    # Update Q-value
                    state_tau = states[tau]
                    action_tau = actions[tau]
                    Q[state_tau][action_tau] += self.alpha * (G - Q[state_tau][action_tau])
                
                if tau == T - 1:
                    break
                
                t += 1
            
            episode_rewards.append(sum(rewards[1:]))
            epsilon = max(0.01, epsilon * 0.995)
        
        policy = {}
        for state in Q:
            policy[state] = np.argmax(Q[state])
        
        return policy, Q, episode_rewards

6.5 Chapter Project: Cliff Walking

def cliff_walking_project():
    """Cliff Walking environment for TD learning comparison"""
    print("="*70)
    print("CLIFF WALKING PROJECT")
    print("="*70)
    
    class CliffWalkingEnv:
        """Cliff Walking environment from Sutton & Barto"""
        
        def __init__(self, width=12, height=4):
            self.width = width
            self.height = height
            
            # Start position (bottom-left)
            self.start = (height-1, 0)
            
            # Goal position (bottom-right)
            self.goal = (height-1, width-1)
            
            # Cliff positions (all cells in bottom row except start and goal)
            self.cliff = [(height-1, c) for c in range(1, width-1)]
            
            # Current position
            self.position = list(self.start)
            
            # Actions: 0=up, 1=right, 2=down, 3=left
            self.actions = [0, 1, 2, 3]
            self.action_deltas = {
                0: (-1, 0),
                1: (0, 1),
                2: (1, 0),
                3: (0, -1)
            }
            
            # For compatibility with TD learning class
            self.action_space = type('ActionSpace', (), {'n': 4})()
        
        def reset(self):
            """Reset to start position"""
            self.position = list(self.start)
            return tuple(self.position)
        
        def step(self, action):
            """Take action"""
            r, c = self.position
            dr, dc = self.action_deltas[action]
            
            # Move
            new_r = max(0, min(self.height-1, r + dr))
            new_c = max(0, min(self.width-1, c + dc))
            
            self.position = [new_r, new_c]
            current_pos = tuple(self.position)
            
            # Check for cliff
            if current_pos in self.cliff:
                # Fall off cliff
                reward = -100
                self.position = list(self.start)  # Reset to start
                done = False  # Not terminal, just reset
            elif current_pos == self.goal:
                # Reached goal
                reward = 0  # No penalty for reaching goal
                done = True
            else:
                # Normal step
                reward = -1  # Step penalty
                done = False
            
            return tuple(self.position), reward, done
        
        def render(self):
            """Render current state"""
            grid = [['.' for _ in range(self.width)] for _ in range(self.height)]
            
            # Mark cliff
            for r, c in self.cliff:
                grid[r][c] = 'C'  # Cliff
            
            # Mark start and goal
            grid[self.start[0]][self.start[1]] = 'S'
            grid[self.goal[0]][self.goal[1]] = 'G'
            
            # Mark agent
            r, c = self.position
            if (r, c) != self.start and (r, c) != self.goal and (r, c) not in self.cliff:
                grid[r][c] = 'A'
            
            # Print
            print("Cliff Walking Environment:")
            print("S = Start, G = Goal, C = Cliff, A = Agent, . = Safe")
            print("+" + "-" * (2*self.width + 1) + "+")
            
            for row in grid:
                print("| " + " ".join(row) + " |")
            
            print("+" + "-" * (2*self.width + 1) + "+")
            print(f"Position: {self.position}")
    
    print("\n1. CREATING CLIFF WALKING ENVIRONMENT...")
    env = CliffWalkingEnv(width=12, height=4)
    
    print("\n2. ENVIRONMENT LAYOUT:")
    env.render()
    
    print("\n3. THE DILEMMA:")
    print("   Safe path: Go up, right across top, then down")
    print("   Risky path: Walk along edge of cliff")
    print("   Optimal depends on learning algorithm!")
    
    print("\n4. TESTING DIFFERENT TD ALGORITHMS...")
    
    # Create TD learner
    td_learner = TemporalDifferenceLearning(env, gamma=1.0, alpha=0.1)
    
    # Test SARSA (on-policy)
    print("\n   A. SARSA (on-policy)...")
    policy_sarsa, Q_sarsa, rewards_sarsa, lengths_sarsa = td_learner.sarsa(
        num_episodes=500, epsilon=0.1
    )
    
    # Test Q-learning (off-policy)
    print("\n   B. Q-learning (off-policy)...")
    policy_q, Q_q, rewards_q, lengths_q = td_learner.q_learning(
        num_episodes=500, epsilon=0.1
    )
    
    # Test Expected SARSA
    print("\n   C. Expected SARSA...")
    policy_expected, Q_expected, rewards_expected, lengths_expected = td_learner.expected_sarsa(
        num_episodes=500, epsilon=0.1
    )
    
    print("\n5. RESULTS ANALYSIS:")
    
    # Calculate statistics
    def calculate_path_stats(policy):
        """Calculate statistics for a given policy"""
        env_test = CliffWalkingEnv(width=12, height=4)
        num_tests = 100
        rewards = []
        cliff_falls = []
        paths = []
        
        for test in range(num_tests):
            state = env_test.reset()
            path = [state]
            total_reward = 0
            cliff_count = 0
            done = False
            
            while not done and len(path) < 100:
                if state in policy:
                    action = policy[state]
                else:
                    action = np.random.randint(4)
                
                state, reward, done = env_test.step(action)
                path.append(state)
                total_reward += reward
                
                if tuple(state) in env_test.cliff:
                    cliff_count += 1
            
            rewards.append(total_reward)
            cliff_falls.append(cliff_count)
            paths.append(path)
        
        return {
            'avg_reward': np.mean(rewards),
            'std_reward': np.std(rewards),
            'avg_cliff_falls': np.mean(cliff_falls),
            'avg_path_length': np.mean([len(p) for p in paths]),
            'paths': paths
        }
    
    stats_sarsa = calculate_path_stats(policy_sarsa)
    stats_q = calculate_path_stats(policy_q)
    stats_expected = calculate_path_stats(policy_expected)
    
    print("\n   Performance Statistics (100 test episodes):")
    print("\n   Algorithm     | Avg Reward | Cliff Falls | Path Length")
    print("   " + "-"*48)
    print(f"   SARSA         | {stats_sarsa['avg_reward']:10.2f} | "
          f"{stats_sarsa['avg_cliff_falls']:11.2f} | {stats_sarsa['avg_path_length']:11.2f}")
    print(f"   Q-learning    | {stats_q['avg_reward']:10.2f} | "
          f"{stats_q['avg_cliff_falls']:11.2f} | {stats_q['avg_path_length']:11.2f}")
    print(f"   Expected SARSA| {stats_expected['avg_reward']:10.2f} | "
          f"{stats_expected['avg_cliff_falls']:11.2f} | {stats_expected['avg_path_length']:11.2f}")
    
    print("\n6. VISUALIZING LEARNED POLICIES...")
    
    # Create policy visualization
    def visualize_policy(policy, title):
        """Visualize policy on cliff walking grid"""
        fig, ax = plt.subplots(figsize=(12, 4))
        
        # Create grid
        grid = np.zeros((env.height, env.width))
        
        # Mark different areas
        for r in range(env.height):
            for c in range(env.width):
                pos = (r, c)
                if pos == env.start:
                    grid[r, c] = 0.8  # Start (light blue)
                elif pos == env.goal:
                    grid[r, c] = 0.6  # Goal (green)
                elif pos in env.cliff:
                    grid[r, c] = 0.2  # Cliff (red)
                else:
                    grid[r, c] = 0.4  # Safe (blue)
        
        # Show grid
        im = ax.imshow(grid, cmap='tab20c', aspect='auto')
        
        # Add policy arrows
        action_symbols = {0: '↑', 1: '→', 2: '↓', 3: '←'}
        
        for r in range(env.height):
            for c in range(env.width):
                pos = (r, c)
                if pos in policy and pos not in env.cliff and pos != env.goal:
                    action = policy[pos]
                    symbol = action_symbols.get(action, '?')
                    
                    # Color based on safety
                    if r == env.height - 1 and c > 0 and c < env.width - 1:
                        color = 'white'  # Cliff edge
                    else:
                        color = 'black'
                    
                    ax.text(c, r, symbol, ha='center', va='center', 
                           fontsize=10, fontweight='bold', color=color)
        
        ax.set_title(title)
        ax.set_xlabel('Column')
        ax.set_ylabel('Row')
        ax.set_xticks(range(env.width))
        ax.set_yticks(range(env.height))
        
        # Add legend
        from matplotlib.patches import Patch
        legend_elements = [
            Patch(facecolor='lightblue', edgecolor='black', label='Start'),
            Patch(facecolor='lightgreen', edgecolor='black', label='Goal'),
            Patch(facecolor='lightcoral', edgecolor='black', label='Cliff'),
            Patch(facecolor='skyblue', edgecolor='black', label='Safe')
        ]
        ax.legend(handles=legend_elements, loc='upper right')
        
        return fig, ax
    
    # Create comparison figure
    fig, axes = plt.subplots(1, 3, figsize=(18, 6))
    
    for idx, (policy, title) in enumerate([
        (policy_sarsa, 'SARSA Policy (On-policy)'),
        (policy_q, 'Q-learning Policy (Off-policy)'),
        (policy_expected, 'Expected SARSA Policy')
    ]):
        ax = axes[idx]
        
        # Create grid
        grid = np.zeros((env.height, env.width))
        
        for r in range(env.height):
            for c in range(env.width):
                pos = (r, c)
                if pos == env.start:
                    grid[r, c] = 0.8
                elif pos == env.goal:
                    grid[r, c] = 0.6
                elif pos in env.cliff:
                    grid[r, c] = 0.2
                else:
                    grid[r, c] = 0.4
        
        ax.imshow(grid, cmap='tab20c', aspect='auto')
        
        # Add policy arrows
        action_symbols = {0: '↑', 1: '→', 2: '↓', 3: '←'}
        
        for r in range(env.height):
            for c in range(env.width):
                pos = (r, c)
                if pos in policy and pos not in env.cliff and pos != env.goal:
                    action = policy[pos]
                    symbol = action_symbols.get(action, '?')
                    color = 'white' if r == env.height - 1 and c > 0 and c < env.width - 1 else 'black'
                    ax.text(c, r, symbol, ha='center', va='center', 
                           fontsize=10, fontweight='bold', color=color)
        
        ax.set_title(title)
        ax.set_xlabel('Column')
        ax.set_ylabel('Row')
        ax.set_xticks(range(env.width))
        ax.set_yticks(range(env.height))
    
    plt.tight_layout()
    plt.show()
    
    print("\n7. LEARNING CURVES COMPARISON...")
    
    # Plot learning curves
    plt.figure(figsize=(15, 5))
    
    # Raw rewards
    plt.subplot(1, 3, 1)
    plt.plot(rewards_sarsa, alpha=0.5, label='SARSA')
    plt.plot(rewards_q, alpha=0.5, label='Q-learning')
    plt.plot(rewards_expected, alpha=0.5, label='Expected SARSA')
    plt.xlabel('Episode')
    plt.ylabel('Reward')
    plt.title('Raw Learning Curves')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # Smoothed rewards
    plt.subplot(1, 3, 2)
    window = 20
    
    sarsa_smooth = np.convolve(rewards_sarsa, np.ones(window)/window, mode='valid')
    q_smooth = np.convolve(rewards_q, np.ones(window)/window, mode='valid')
    expected_smooth = np.convolve(rewards_expected, np.ones(window)/window, mode='valid')
    
    plt.plot(sarsa_smooth, label='SARSA', linewidth=2)
    plt.plot(q_smooth, label='Q-learning', linewidth=2)
    plt.plot(expected_smooth, label='Expected SARSA', linewidth=2)
    plt.xlabel('Episode')
    plt.ylabel('Average Reward (20-episode window)')
    plt.title('Smoothed Learning Curves')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # Path lengths
    plt.subplot(1, 3, 3)
    length_smooth_sarsa = np.convolve(lengths_sarsa, np.ones(window)/window, mode='valid')
    length_smooth_q = np.convolve(lengths_q, np.ones(window)/window, mode='valid')
    length_smooth_expected = np.convolve(lengths_expected, np.ones(window)/window, mode='valid')
    
    plt.plot(length_smooth_sarsa, label='SARSA', linewidth=2)
    plt.plot(length_smooth_q, label='Q-learning', linewidth=2)
    plt.plot(length_smooth_expected, label='Expected SARSA', linewidth=2)
    plt.xlabel('Episode')
    plt.ylabel('Average Path Length')
    plt.title('Convergence of Path Length')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    print("\n8. KEY INSIGHTS FROM CLIFF WALKING:")
    
    print("\n   SARSA (On-policy):")
    print("   - Learns safe path away from cliff")
    print("   - Accounts for exploration (epsilon-greedy)")
    print("   - More conservative but safer")
    
    print("\n   Q-learning (Off-policy):")
    print("   - Learns optimal (shortest) path")
    print("   - May walk along cliff edge")
    print("   - Can fall off cliff during exploration")
    print("   - More efficient but riskier")
    
    print("\n   Expected SARSA:")
    print("   - Compromise between SARSA and Q-learning")
    print("   - Takes expectation over possible next actions")
    print("   - Often best of both worlds")
    
    print("\n9. PRACTICAL IMPLICATIONS:")
    print("   - Use on-policy methods (SARSA) for safety-critical applications")
    print("   - Use off-policy methods (Q-learning) for maximum efficiency")
    print("   - Consider Expected SARSA for balanced performance")
    print("   - The choice depends on exploration strategy and risk tolerance")
    
    return {
        'env': env,
        'SARSA': (policy_sarsa, rewards_sarsa, stats_sarsa),
        'Q-learning': (policy_q, rewards_q, stats_q),
        'Expected SARSA': (policy_expected, rewards_expected, stats_expected)
    }

# Run Cliff Walking project
cliff_results = cliff_walking_project()

Chapter 6 Key Takeaways:

  1. TD Learning Combines MC and DP:

    • Like MC: Model-free, learns from experience
    • Like DP: Bootstraps, updates estimates from other estimates
  2. TD(0) Update:

    V(s) ← V(s) + α[r + γV(s') - V(s)]
    
    • α: Learning rate
    • r + γV(s'): TD target
    • r + γV(s') - V(s): TD error
  3. SARSA vs Q-learning:

    • SARSA (on-policy): Q(s,a) ← Q(s,a) + α[r + γQ(s',a') - Q(s,a)]
    • Q-learning (off-policy): Q(s,a) ← Q(s,a) + α[r + γ max_a' Q(s',a') - Q(s,a)]
    • SARSA learns the policy being followed
    • Q-learning learns the optimal policy directly
  4. Expected SARSA:

    • Takes expectation over next actions
    • Reduces variance compared to SARSA
    • Can be on-policy or off-policy
  5. TD(λ) and Eligibility Traces:

    • λ = 0: Pure TD(0)
    • λ = 1: Monte Carlo
    • 0 < λ < 1: Intermediate, combines MC and TD
    • Eligibility traces credit recent states
  6. n-step Methods:

    • n = 1: TD(0)
    • n = ∞: Monte Carlo
    • Intermediate n: Tradeoff between bias and variance
  7. Practical Considerations:

    • On-policy methods safer for exploration
    • Off-policy methods more efficient
    • Expected SARSA often provides best balance
    • TD methods converge faster than MC in practice

CHAPTER 7: Function Approximation: Handling Complex Worlds

7.1 Why Tabular Methods Fail

The Curse of Dimensionality: Imagine a chess board with 64 squares. Now imagine:

  • Each square can have 6 different pieces × 2 colors = 12 possibilities
  • Total states: 12⁶⁴ ≈ 10⁶⁹
  • More states than atoms in the observable universe!
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import SGDRegressor
from collections import defaultdict

class FunctionApproximationDemo:
    """Demonstrating why we need function approximation"""
    
    def __init__(self):
        self.initialize_examples()
    
    def initialize_examples(self):
        """Initialize examples showing curse of dimensionality"""
        
        print("="*70)
        print("THE CURSE OF DIMENSIONALITY")
        print("="*70)
        
        # Example 1: State space explosion
        print("\n1. STATE SPACE EXPLOSION:")
        print("\n   Consider a robot with n sensors, each with k values:")
        
        for n in [2, 4, 8, 16]:
            states = 10 ** n  # 10 possible values per sensor
            print(f"   {n:2d} sensors: {states:,} states")
            
            if states > 1e9:
                print(f"     (More than 1 billion states!)")
        
        # Example 2: Continuous spaces
        print("\n2. CONTINUOUS STATE SPACES:")
        print("\n   Real-world states are often continuous:")
        print("   - Robot position: (x, y, θ) ∈ ℝ³")
        print("   - Camera image: 640×480×3 ≈ 1 million continuous values")
        print("   - Tabular methods impossible!")
        
        # Example 3: Memory requirements
        print("\n3. MEMORY REQUIREMENTS:")
        print("\n   Tabular Q-learning for Atari games:")
        print("   - State: 210×160×3 = 100,800 pixels")
        print("   - Each pixel: 256 values")
        print("   - Total states: 256¹⁰⁰⁸⁰⁰ ≈ 10²⁴³⁰⁰⁰")
        print("   - Known universe: ~10⁸⁰ atoms")
        print("   - Need smarter representation!")
        
        # Visual demonstration
        self.visualize_curse()
    
    def visualize_curse(self):
        """Visualize the curse of dimensionality"""
        
        # Grid sizes for different dimensions
        dimensions = [1, 2, 3, 4, 5]
        grid_points_per_dim = 10
        
        total_points = []
        for d in dimensions:
            points = grid_points_per_dim ** d
            total_points.append(points)
        
        plt.figure(figsize=(10, 6))
        
        # Plot 1: Total points vs dimensions
        plt.subplot(1, 2, 1)
        plt.plot(dimensions, total_points, 'bo-', linewidth=2, markersize=8)
        plt.yscale('log')
        plt.xlabel('Number of Dimensions')
        plt.ylabel('Total Grid Points (log scale)')
        plt.title('State Space Explosion')
        plt.grid(True, alpha=0.3)
        
        # Annotate
        for d, points in zip(dimensions, total_points):
            plt.text(d, points, f'{points:,}', ha='center', va='bottom')
        
        # Plot 2: Coverage fraction
        plt.subplot(1, 2, 2)
        
        # Fraction of space covered by fixed number of samples
        samples = 10000
        coverage_fractions = []
        
        for d in dimensions:
            # Volume of d-dimensional hypercube
            total_volume = 1.0
            # Fraction covered by samples randomly distributed
            # This is simplified: fraction ≈ samples / (grid_points_per_dim**d)
            fraction = min(1.0, samples / (grid_points_per_dim ** d))
            coverage_fractions.append(fraction)
        
        plt.plot(dimensions, coverage_fractions, 'ro-', linewidth=2, markersize=8)
        plt.xlabel('Number of Dimensions')
        plt.ylabel('Fraction of Space Covered')
        plt.title(f'Coverage with {samples:,} Samples')
        plt.grid(True, alpha=0.3)
        
        plt.tight_layout()
        plt.show()

# Run the demo
demo = FunctionApproximationDemo()

7.2 From Tables to Functions

Core Idea: Instead of storing Q(s,a) in a table, approximate it with a function:

Q(s,a) ≈ f_θ(s,a)

where θ are the function parameters.

class LinearFunctionApproximation:
    """Linear function approximation for value functions"""
    
    def __init__(self, feature_dim, num_actions, learning_rate=0.01):
        """
        Initialize linear function approximator
        
        Args:
            feature_dim: Dimension of feature vector
            num_actions: Number of possible actions
            learning_rate: Learning rate for gradient descent
        """
        self.feature_dim = feature_dim
        self.num_actions = num_actions
        self.learning_rate = learning_rate
        
        # Initialize weights: one weight vector per action
        # θ[a] ∈ ℝ^{feature_dim}
        self.theta = np.random.randn(num_actions, feature_dim) * 0.01
        
        # For tracking
        self.loss_history = []
    
    def extract_features(self, state):
        """
        Extract feature vector from state
        To be implemented by subclasses for specific problems
        """
        # Default: one-hot encoding for discrete states
        if isinstance(state, int):
            features = np.zeros(self.feature_dim)
            if state < self.feature_dim:
                features[state] = 1
            return features
        else:
            # Assume state is already a feature vector
            return np.array(state).flatten()
    
    def predict(self, state):
        """
        Predict Q-values for all actions
        
        Returns:
            Q_values: array of length num_actions
        """
        features = self.extract_features(state)
        # Linear prediction: Q(s,a) = θ[a]·φ(s)
        Q_values = self.theta @ features
        return Q_values
    
    def update(self, state, action, target):
        """
        Update weights using gradient descent
        
        Args:
            state: Current state
            action: Action taken
            target: Target Q-value
        """
        features = self.extract_features(state)
        
        # Current prediction
        prediction = self.theta[action] @ features
        
        # Compute error
        error = target - prediction
        
        # Gradient descent update
        # ∇_θ J(θ) = - (target - θ·φ(s)) φ(s)
        # θ ← θ + α (target - θ·φ(s)) φ(s)
        gradient = features * error
        self.theta[action] += self.learning_rate * gradient
        
        # Track loss
        self.loss_history.append(error ** 2)
        
        return error ** 2  # Return squared error
    
    def sarsa_update(self, state, action, reward, next_state, next_action, 
                    gamma=0.9, done=False):
        """
        SARSA update with function approximation
        """
        features = self.extract_features(state)
        next_features = self.extract_features(next_state)
        
        # Current Q-value
        current_q = self.theta[action] @ features
        
        # Next Q-value
        if done:
            target = reward
        else:
            next_q = self.theta[next_action] @ next_features
            target = reward + gamma * next_q
        
        # Update
        error = target - current_q
        gradient = features * error
        self.theta[action] += self.learning_rate * gradient
        
        self.loss_history.append(error ** 2)
        return error ** 2
    
    def q_learning_update(self, state, action, reward, next_state,
                         gamma=0.9, done=False):
        """
        Q-learning update with function approximation
        """
        features = self.extract_features(state)
        next_features = self.extract_features(next_state)
        
        # Current Q-value
        current_q = self.theta[action] @ features
        
        # Max Q-value for next state
        if done:
            target = reward
        else:
            next_q_values = self.theta @ next_features
            max_next_q = np.max(next_q_values)
            target = reward + gamma * max_next_q
        
        # Update
        error = target - current_q
        gradient = features * error
        self.theta[action] += self.learning_rate * gradient
        
        self.loss_history.append(error ** 2)
        return error ** 2
    
    def get_policy(self, state, epsilon=0.0):
        """
        Get epsilon-greedy policy for a state
        """
        if np.random.random() < epsilon:
            return np.random.randint(self.num_actions)
        else:
            Q_values = self.predict(state)
            return np.argmax(Q_values)
    
    def visualize_weights(self):
        """Visualize the learned weights"""
        plt.figure(figsize=(12, 4))
        
        plt.subplot(1, 3, 1)
        plt.imshow(self.theta, aspect='auto', cmap='RdYlBu')
        plt.colorbar(label='Weight value')
        plt.xlabel('Feature dimension')
        plt.ylabel('Action')
        plt.title('Weight Matrix θ')
        
        plt.subplot(1, 3, 2)
        weight_norms = np.linalg.norm(self.theta, axis=1)
        plt.bar(range(self.num_actions), weight_norms)
        plt.xlabel('Action')
        plt.ylabel('Weight norm')
        plt.title('Weight Norms per Action')
        
        plt.subplot(1, 3, 3)
        if self.loss_history:
            window = min(100, len(self.loss_history) // 10)
            if window > 0:
                smoothed_loss = np.convolve(self.loss_history, 
                                           np.ones(window)/window, 
                                           mode='valid')
                plt.plot(smoothed_loss)
                plt.xlabel('Update step')
                plt.ylabel('Smoothed squared error')
                plt.title('Learning Progress')
                plt.yscale('log')
        
        plt.tight_layout()
        plt.show()

7.3 Feature Engineering: The Art of Representation

class FeatureEngineering:
    """Different feature representations for RL"""
    
    @staticmethod
    def one_hot_encoding(state, n_states):
        """One-hot encoding for discrete states"""
        features = np.zeros(n_states)
        if isinstance(state, (int, np.integer)):
            if state < n_states:
                features[state] = 1
        return features
    
    @staticmethod
    def tile_coding(state, n_tilings=8, n_tiles=8, offset=0.0):
        """
        Tile coding (CMAC) for continuous states
        
        Args:
            state: Continuous state value in [0, 1]
            n_tilings: Number of tilings
            n_tiles: Number of tiles per tiling
            offset: Offset for each tiling
        
        Returns:
            Sparse feature vector
        """
        if isinstance(state, (int, np.integer)):
            # Discrete state: convert to continuous
            state = state / 100.0
        
        # Ensure state is in [0, 1]
        state = max(0.0, min(1.0, state))
        
        # Create features
        features = np.zeros(n_tilings * n_tiles)
        
        for tiling in range(n_tilings):
            # Add offset to each tiling
            offset_state = state + tiling * offset
            
            # Find which tile this state belongs to
            tile_idx = int(offset_state * n_tiles) % n_tiles
            
            # Set feature
            feature_idx = tiling * n_tiles + tile_idx
            if feature_idx < len(features):
                features[feature_idx] = 1
        
        return features
    
    @staticmethod
    def polynomial_features(state, degree=2):
        """
        Polynomial features for continuous states
        
        Args:
            state: Can be scalar or vector
            degree: Maximum polynomial degree
        
        Returns:
            Polynomial feature vector
        """
        state = np.array(state).flatten()
        n_features = len(state)
        
        # Generate all polynomial combinations up to given degree
        from itertools import combinations_with_replacement
        
        features = []
        
        # Bias term (degree 0)
        features.append(1.0)
        
        # Terms of increasing degree
        for d in range(1, degree + 1):
            # All combinations of features with total degree d
            for comb in combinations_with_replacement(range(n_features), d):
                term = 1.0
                for idx in comb:
                    term *= state[idx]
                features.append(term)
        
        return np.array(features)
    
    @staticmethod
    def radial_basis_functions(state, centers, sigma=1.0):
        """
        Radial basis function features
        
        Args:
            state: Input state (scalar or vector)
            centers: Array of RBF centers
            sigma: Width parameter
        
        Returns:
            RBF feature vector
        """
        state = np.array(state).flatten()
        centers = np.array(centers)
        
        if state.ndim == 1:
            state = state.reshape(1, -1)
        
        # Compute distances to all centers
        if centers.ndim == 1:
            centers = centers.reshape(-1, 1)
        
        # For scalar states
        if len(state.shape) == 1 or state.shape[1] == 1:
            distances = np.abs(state - centers.T)
        else:
            # For vector states
            distances = np.linalg.norm(state - centers, axis=1)
        
        # Apply Gaussian kernel
        features = np.exp(-distances**2 / (2 * sigma**2))
        
        return features.flatten()
    
    @staticmethod
    def fourier_features(state, order=3):
        """
        Fourier basis features
        
        Args:
            state: Normalized to [0, 1]
            order: Maximum frequency
        
        Returns:
            Fourier feature vector
        """
        if isinstance(state, (int, np.integer)):
            state = state / 100.0
        
        state = max(0.0, min(1.0, state))
        
        features = []
        
        # Constant term
        features.append(1.0)
        
        # Sin and cos terms
        for k in range(1, order + 1):
            features.append(np.sin(2 * np.pi * k * state))
            features.append(np.cos(2 * np.pi * k * state))
        
        return np.array(features)

def compare_feature_representations():
    """Compare different feature representations"""
    print("="*70)
    print("FEATURE REPRESENTATION COMPARISON")
    print("="*70)
    
    # Generate example states
    n_samples = 100
    states = np.linspace(0, 1, n_samples)
    
    # Create feature representations
    features_dict = {
        'Tile Coding (8x8)': [],
        'Polynomial (degree 3)': [],
        'RBF (10 centers)': [],
        'Fourier (order 3)': []
    }
    
    fe = FeatureEngineering()
    
    # Tile coding
    for state in states:
        features = fe.tile_coding(state, n_tilings=8, n_tiles=8, offset=1/8)
        features_dict['Tile Coding (8x8)'].append(features)
    
    # Polynomial features
    for state in states:
        features = fe.polynomial_features([state], degree=3)
        features_dict['Polynomial (degree 3)'].append(features)
    
    # RBF features
    centers = np.linspace(0, 1, 10)
    for state in states:
        features = fe.radial_basis_functions(state, centers, sigma=0.2)
        features_dict['RBF (10 centers)'].append(features)
    
    # Fourier features
    for state in states:
        features = fe.fourier_features(state, order=3)
        features_dict['Fourier (order 3)'].append(features)
    
    # Visualize
    fig, axes = plt.subplots(2, 2, figsize=(15, 10))
    axes = axes.flatten()
    
    for idx, (title, features_list) in enumerate(features_dict.items()):
        ax = axes[idx]
        
        features_array = np.array(features_list)
        
        # Show feature matrix
        im = ax.imshow(features_array.T, aspect='auto', cmap='viridis',
                      extent=[0, 1, 0, features_array.shape[1]])
        
        ax.set_xlabel('State (normalized)')
        ax.set_ylabel('Feature index')
        ax.set_title(f'{title}\nShape: {features_array.shape}')
        
        # Add colorbar
        plt.colorbar(im, ax=ax)
    
    plt.tight_layout()
    plt.show()
    
    print("\nFEATURE REPRESENTATION PROPERTIES:")
    print("\n1. Tile Coding (CMAC):")
    print("   - Good for coarse coding")
    print("   - Generalization through overlapping tiles")
    print("   - Efficient with sparse representation")
    
    print("\n2. Polynomial Features:")
    print("   - Can approximate smooth functions")
    print("   - Captures interactions between features")
    print("   - Can suffer from curse of dimensionality")
    
    print("\n3. Radial Basis Functions:")
    print("   - Localized representation")
    print("   - Good for interpolation")
    print("   - Center placement is crucial")
    
    print("\n4. Fourier Basis:")
    print("   - Good for periodic functions")
    print("   - Captures frequency information")
    print("   - Can approximate any continuous function")
    
    print("\n5. Neural Networks (next chapter):")
    print("   - Learned features (no manual engineering)")
    print("   - Can handle high-dimensional inputs")
    print("   - Requires more data and computation")
    
    return features_dict

# Run feature comparison
feature_comparison = compare_feature_representations()

7.4 Linear TD with Function Approximation

class LinearTDAgent:
    """TD learning with linear function approximation"""
    
    def __init__(self, env, feature_dim, num_actions, 
                 learning_rate=0.01, gamma=0.9, epsilon=0.1):
        self.env = env
        self.feature_dim = feature_dim
        self.num_actions = num_actions
        self.learning_rate = learning_rate
        self.gamma = gamma
        self.epsilon = epsilon
        
        # Initialize function approximator
        self.approximator = LinearFunctionApproximation(
            feature_dim, num_actions, learning_rate
        )
        
        # For tracking
        self.episode_rewards = []
        self.episode_lengths = []
    
    def extract_features(self, state):
        """Extract features from state - to be overridden"""
        # Default implementation for discrete states
        return FeatureEngineering.one_hot_encoding(state, self.feature_dim)
    
    def train_sarsa(self, num_episodes=1000):
        """Train using SARSA with linear function approximation"""
        
        for episode in range(num_episodes):
            state = self.env.reset()
            features = self.extract_features(state)
            
            # Choose action
            action = self.approximator.get_policy(features, self.epsilon)
            
            total_reward = 0
            steps = 0
            done = False
            
            while not done:
                # Take action
                next_state, reward, done = self.env.step(action)
                next_features = self.extract_features(next_state)
                
                # Choose next action
                next_action = self.approximator.get_policy(next_features, self.epsilon)
                
                # SARSA update
                self.approximator.sarsa_update(
                    features, action, reward, next_features, next_action,
                    self.gamma, done
                )
                
                # Update tracking
                total_reward += reward
                steps += 1
                
                # Move to next state
                state = next_state
                features = next_features
                action = next_action
            
            self.episode_rewards.append(total_reward)
            self.episode_lengths.append(steps)
            
            # Decay epsilon
            self.epsilon = max(0.01, self.epsilon * 0.995)
            
            if (episode + 1) % 100 == 0:
                avg_reward = np.mean(self.episode_rewards[-100:])
                print(f"Episode {episode + 1}: Avg reward = {avg_reward:.2f}")
        
        return self.episode_rewards, self.episode_lengths
    
    def train_q_learning(self, num_episodes=1000):
        """Train using Q-learning with linear function approximation"""
        
        for episode in range(num_episodes):
            state = self.env.reset()
            features = self.extract_features(state)
            
            total_reward = 0
            steps = 0
            done = False
            
            while not done:
                # Choose action
                action = self.approximator.get_policy(features, self.epsilon)
                
                # Take action
                next_state, reward, done = self.env.step(action)
                next_features = self.extract_features(next_state)
                
                # Q-learning update
                self.approximator.q_learning_update(
                    features, action, reward, next_features,
                    self.gamma, done
                )
                
                # Update tracking
                total_reward += reward
                steps += 1
                
                # Move to next state
                state = next_state
                features = next_features
            
            self.episode_rewards.append(total_reward)
            self.episode_lengths.append(steps)
            
            # Decay epsilon
            self.epsilon = max(0.01, self.epsilon * 0.995)
            
            if (episode + 1) % 100 == 0:
                avg_reward = np.mean(self.episode_rewards[-100:])
                print(f"Episode {episode + 1}: Avg reward = {avg_reward:.2f}")
        
        return self.episode_rewards, self.episode_lengths
    
    def evaluate(self, num_episodes=100, render=False):
        """Evaluate the learned policy"""
        total_rewards = []
        
        for episode in range(num_episodes):
            state = self.env.reset()
            features = self.extract_features(state)
            
            total_reward = 0
            done = False
            
            if render:
                self.env.render()
            
            while not done:
                # Greedy action (no exploration)
                action = self.approximator.get_policy(features, epsilon=0.0)
                
                # Take action
                state, reward, done = self.env.step(action)
                features = self.extract_features(state)
                
                total_reward += reward
                
                if render:
                    self.env.render()
            
            total_rewards.append(total_reward)
        
        avg_reward = np.mean(total_rewards)
        std_reward = np.std(total_rewards)
        
        print(f"Evaluation over {num_episodes} episodes:")
        print(f"  Average reward: {avg_reward:.2f} ± {std_reward:.2f}")
        print(f"  Best episode: {np.max(total_rewards):.2f}")
        print(f"  Worst episode: {np.min(total_rewards):.2f}")
        
        return total_rewards
    
    def visualize_learning(self):
        """Visualize learning progress"""
        plt.figure(figsize=(15, 5))
        
        # Plot rewards
        plt.subplot(1, 3, 1)
        window = min(100, len(self.episode_rewards) // 10)
        if window > 0:
            smoothed_rewards = np.convolve(self.episode_rewards, 
                                          np.ones(window)/window, 
                                          mode='valid')
            plt.plot(smoothed_rewards)
            plt.xlabel('Episode')
            plt.ylabel(f'Average Reward ({window}-episode window)')
            plt.title('Learning Curve')
            plt.grid(True, alpha=0.3)
        
        # Plot episode lengths
        plt.subplot(1, 3, 2)
        if window > 0:
            smoothed_lengths = np.convolve(self.episode_lengths, 
                                          np.ones(window)/window, 
                                          mode='valid')
            plt.plot(smoothed_lengths)
            plt.xlabel('Episode')
            plt.ylabel(f'Average Length ({window}-episode window)')
            plt.title('Episode Lengths')
            plt.grid(True, alpha=0.3)
        
        # Plot value function
        plt.subplot(1, 3, 3)
        if hasattr(self.env, 'get_all_states'):
            states = self.env.get_all_states()
            values = []
            
            for state in states:
                features = self.extract_features(state)
                Q_values = self.approximator.predict(features)
                values.append(np.max(Q_values))
            
            plt.plot(states, values, 'b-', linewidth=2)
            plt.xlabel('State')
            plt.ylabel('Max Q-value')
            plt.title('Learned Value Function')
            plt.grid(True, alpha=0.3)
        
        plt.tight_layout()
        plt.show()

7.5 Mountain Car: Continuous State Space Example

def mountain_car_project():
    """Mountain Car with function approximation"""
    print("="*70)
    print("MOUNTAIN CAR PROJECT")
    print("="*70)
    
    class MountainCarEnv:
        """Simplified Mountain Car environment"""
        
        def __init__(self):
            # State: [position, velocity]
            self.min_position = -1.2
            self.max_position = 0.6
            self.max_speed = 0.07
            self.goal_position = 0.5
            
            # Actions: 0=left, 1=neutral, 2=right
            self.actions = [0, 1, 2]
            self.action_space = type('ActionSpace', (), {'n': 3})()
            
            self.reset()
        
        def reset(self):
            """Reset environment"""
            # Start near bottom of hill
            self.position = -0.5
            self.velocity = 0.0
            return self._get_state()
        
        def step(self, action):
            """Take action"""
            # Update velocity
            if action == 0:  # left
                force = -1.0
            elif action == 2:  # right
                force = 1.0
            else:  # neutral
                force = 0.0
            
            # Physics update
            self.velocity += force * 0.001 - 0.0025 * np.cos(3 * self.position)
            self.velocity = np.clip(self.velocity, -self.max_speed, self.max_speed)
            
            self.position += self.velocity
            self.position = np.clip(self.position, self.min_position, self.max_position)
            
            # Reset velocity if hit left boundary
            if self.position == self.min_position and self.velocity < 0:
                self.velocity = 0.0
            
            # Check if done
            done = (self.position >= self.goal_position)
            
            # Reward
            if done:
                reward = 0.0  # Success!
            else:
                reward = -1.0  # Step penalty
            
            return self._get_state(), reward, done
        
        def _get_state(self):
            """Get current state"""
            return np.array([self.position, self.velocity])
        
        def render(self):
            """Simple text render"""
            print(f"Position: {self.position:.3f}, Velocity: {self.velocity:.3f}")
        
        def get_state_bounds(self):
            """Get bounds for state normalization"""
            return {
                'position': (self.min_position, self.max_position),
                'velocity': (-self.max_speed, self.max_speed)
            }
    
    print("\n1. UNDERSTANDING MOUNTAIN CAR:")
    print("\n   The Challenge:")
    print("   - Car starts at bottom of valley")
    print("   - Engine not powerful enough to drive straight up")
    print("   - Must build momentum by swinging back and forth")
    print("   - State: continuous (position, velocity)")
    print("   - Tabular methods impossible!")
    
    env = MountainCarEnv()
    
    print("\n2. CREATING FEATURE REPRESENTATION:")
    
    class MountainCarAgent(LinearTDAgent):
        """Mountain Car agent with tile coding"""
        
        def __init__(self, env, n_tilings=8, n_tiles=8):
            # Calculate feature dimension
            feature_dim = n_tilings * n_tiles * n_tiles  # 2D tile coding
            
            super().__init__(env, feature_dim, env.action_space.n,
                           learning_rate=0.1 / n_tilings,  # Common heuristic
                           gamma=1.0,  # Episodic task
                           epsilon=0.1)
            
            self.n_tilings = n_tilings
            self.n_tiles = n_tiles
            
            # Get state bounds for normalization
            bounds = env.get_state_bounds()
            self.pos_bounds = bounds['position']
            self.vel_bounds = bounds['velocity']
            
            # Create tiling offsets
            self.offsets = []
            for i in range(n_tilings):
                # Different offset for each tiling
                pos_offset = (i * 1.0) / n_tilings
                vel_offset = (i * 1.7) / n_tilings  # Irrational for better coverage
                self.offsets.append((pos_offset, vel_offset))
        
        def extract_features(self, state):
            """2D tile coding for Mountain Car"""
            position, velocity = state
            
            # Normalize to [0, 1]
            pos_norm = (position - self.pos_bounds[0]) / (self.pos_bounds[1] - self.pos_bounds[0])
            vel_norm = (velocity - self.vel_bounds[0]) / (self.vel_bounds[1] - self.vel_bounds[0])
            
            # Initialize feature vector
            features = np.zeros(self.feature_dim)
            
            for tiling in range(self.n_tilings):
                # Add offsets
                pos_offset, vel_offset = self.offsets[tiling]
                pos_off = pos_norm + pos_offset
                vel_off = vel_norm + vel_offset
                
                # Find tile indices
                pos_idx = int(pos_off * self.n_tiles) % self.n_tiles
                vel_idx = int(vel_off * self.n_tiles) % self.n_tiles
                
                # Calculate feature index
                feature_idx = (tiling * self.n_tiles * self.n_tiles + 
                             pos_idx * self.n_tiles + vel_idx)
                
                if feature_idx < self.feature_dim:
                    features[feature_idx] = 1.0
            
            return features
        
        def visualize_value_function(self):
            """Visualize learned value function"""
            # Create grid of states
            pos_values = np.linspace(self.pos_bounds[0], self.pos_bounds[1], 50)
            vel_values = np.linspace(self.vel_bounds[0], self.vel_bounds[1], 50)
            
            values = np.zeros((len(pos_values), len(vel_values)))
            
            for i, pos in enumerate(pos_values):
                for j, vel in enumerate(vel_values):
                    state = np.array([pos, vel])
                    features = self.extract_features(state)
                    Q_values = self.approximator.predict(features)
                    values[i, j] = np.max(Q_values)
            
            # Plot
            plt.figure(figsize=(12, 10))
            
            # 3D surface plot
            from mpl_toolkits.mplot3d import Axes3D
            
            X, Y = np.meshgrid(vel_values, pos_values)
            
            ax = plt.subplot(1, 2, 1, projection='3d')
            surf = ax.plot_surface(X, Y, values, cmap='viridis', 
                                 linewidth=0, antialiased=True)
            ax.set_xlabel('Velocity')
            ax.set_ylabel('Position')
            ax.set_zlabel('Value')
            ax.set_title('3D Value Function')
            plt.colorbar(surf, ax=ax, shrink=0.5)
            
            # 2D heatmap
            ax = plt.subplot(1, 2, 2)
            im = ax.imshow(values, extent=[vel_values[0], vel_values[-1], 
                                         pos_values[0], pos_values[-1]],
                         aspect='auto', cmap='viridis', origin='lower')
            ax.set_xlabel('Velocity')
            ax.set_ylabel('Position')
            ax.set_title('Value Function Heatmap')
            
            # Add goal line
            ax.axhline(y=env.goal_position, color='red', linestyle='--', 
                      alpha=0.5, label='Goal position')
            
            # Add sample trajectories
            ax.legend()
            plt.colorbar(im, ax=ax)
            
            plt.tight_layout()
            plt.show()
        
        def visualize_policy(self):
            """Visualize learned policy"""
            # Create grid of states
            pos_values = np.linspace(self.pos_bounds[0], self.pos_bounds[1], 30)
            vel_values = np.linspace(self.vel_bounds[0], self.vel_bounds[1], 30)
            
            actions_grid = np.zeros((len(pos_values), len(vel_values)))
            
            for i, pos in enumerate(pos_values):
                for j, vel in enumerate(vel_values):
                    state = np.array([pos, vel])
                    features = self.extract_features(state)
                    action = self.approximator.get_policy(features, epsilon=0.0)
                    actions_grid[i, j] = action
            
            # Plot
            plt.figure(figsize=(10, 8))
            
            # Policy heatmap
            plt.imshow(actions_grid, extent=[vel_values[0], vel_values[-1],
                                           pos_values[0], pos_values[-1]],
                     aspect='auto', cmap='coolwarm', origin='lower')
            
            plt.xlabel('Velocity')
            plt.ylabel('Position')
            plt.title('Learned Policy (0=left, 1=neutral, 2=right)')
            
            # Add contour lines for value function
            from scipy import ndimage
            
            # Smooth the policy for contours
            smoothed = ndimage.gaussian_filter(actions_grid, sigma=1.0)
            contour_levels = np.arange(-0.5, 3.5, 0.5)
            plt.contour(vel_values, pos_values, smoothed, levels=contour_levels,
                       colors='black', alpha=0.3, linewidths=0.5)
            
            # Add goal line
            plt.axhline(y=env.goal_position, color='red', linestyle='--',
                       alpha=0.7, label='Goal position')
            
            plt.colorbar(label='Action')
            plt.legend()
            plt.tight_layout()
            plt.show()
    
    print("\n3. TRAINING AGENT WITH TILE CODING...")
    print(f"   Feature dimension: 8 tilings × 8×8 tiles = 512 features")
    print("   Learning rate: α = 0.1/8 = 0.0125 (common heuristic)")
    
    agent = MountainCarAgent(env, n_tilings=8, n_tiles=8)
    
    print("\n4. RUNNING Q-LEARNING...")
    rewards, lengths = agent.train_q_learning(num_episodes=2000)
    
    print("\n5. VISUALIZING RESULTS...")
    
    # Plot learning curve
    plt.figure(figsize=(15, 5))
    
    plt.subplot(1, 3, 1)
    window = 50
    smoothed_rewards = np.convolve(rewards, np.ones(window)/window, mode='valid')
    plt.plot(smoothed_rewards)
    plt.xlabel('Episode')
    plt.ylabel(f'Average Reward ({window}-episode window)')
    plt.title('Learning Curve')
    plt.grid(True, alpha=0.3)
    
    # Add success threshold (reaching goal gives 0 reward, else -1)
    # Success means reward close to 0
    plt.axhline(y=-50, color='red', linestyle='--', alpha=0.5, 
               label='Typical success threshold')
    plt.legend()
    
    plt.subplot(1, 3, 2)
    # Histogram of final performance
    final_rewards = rewards[-200:]  # Last 200 episodes
    plt.hist(final_rewards, bins=20, alpha=0.7, color='blue', edgecolor='black')
    plt.xlabel('Episode Reward')
    plt.ylabel('Frequency')
    plt.title('Final Performance Distribution')
    
    # Mark successful episodes (reward > -100)
    success_rate = np.sum(np.array(final_rewards) > -100) / len(final_rewards)
    plt.axvline(x=-100, color='red', linestyle='--', alpha=0.7,
               label=f'Success rate: {success_rate:.1%}')
    plt.legend()
    
    plt.subplot(1, 3, 3)
    # Episode lengths
    smoothed_lengths = np.convolve(lengths, np.ones(window)/window, mode='valid')
    plt.plot(smoothed_lengths)
    plt.xlabel('Episode')
    plt.ylabel(f'Average Length ({window}-episode window)')
    plt.title('Episode Lengths')
    plt.grid(True, alpha=0.3)
    
    # Optimal length (around 110-130 steps for good policy)
    plt.axhline(y=120, color='green', linestyle='--', alpha=0.5,
               label='Good policy target')
    plt.legend()
    
    plt.tight_layout()
    plt.show()
    
    print("\n6. EVALUATING LEARNED POLICY...")
    eval_rewards = agent.evaluate(num_episodes=100)
    
    success_count = sum(1 for r in eval_rewards if r > -200)  # Didn't timeout
    print(f"\n   Success rate: {success_count}/{len(eval_rewards)} ({success_count/len(eval_rewards):.1%})")
    print(f"   Average steps: {np.mean([abs(r) for r in eval_rewards]):.1f}")
    
    print("\n7. VISUALIZING LEARNED VALUE FUNCTION AND POLICY...")
    agent.visualize_value_function()
    agent.visualize_policy()
    
    print("\n8. DEMONSTRATING SUCCESSFUL EPISODE...")
    
    # Run one episode with visualization
    print("\n   Starting demonstration episode...")
    state = env.reset()
    total_reward = 0
    steps = 0
    done = False
    
    positions = []
    velocities = []
    actions_taken = []
    
    while not done and steps < 200:
        features = agent.extract_features(state)
        action = agent.approximator.get_policy(features, epsilon=0.0)
        
        state, reward, done = env.step(action)
        
        positions.append(state[0])
        velocities.append(state[1])
        actions_taken.append(action)
        
        total_reward += reward
        steps += 1
    
    print(f"   Total reward: {total_reward}")
    print(f"   Steps taken: {steps}")
    print(f"   Reached goal: {state[0] >= env.goal_position}")
    
    # Plot trajectory
    plt.figure(figsize=(12, 4))
    
    plt.subplot(1, 3, 1)
    plt.plot(positions)
    plt.xlabel('Step')
    plt.ylabel('Position')
    plt.title('Position Over Time')
    plt.axhline(y=env.goal_position, color='red', linestyle='--', alpha=0.5)
    plt.grid(True, alpha=0.3)
    
    plt.subplot(1, 3, 2)
    plt.plot(velocities)
    plt.xlabel('Step')
    plt.ylabel('Velocity')
    plt.title('Velocity Over Time')
    plt.grid(True, alpha=0.3)
    
    plt.subplot(1, 3, 3)
    # Phase plot
    plt.plot(positions, velocities, 'b-', alpha=0.7)
    plt.scatter(positions[0], velocities[0], color='green', s=100, 
               label='Start', zorder=5)
    plt.scatter(positions[-1], velocities[-1], color='red', s=100,
               label='End', zorder=5)
    plt.xlabel('Position')
    plt.ylabel('Velocity')
    plt.title('Phase Space Trajectory')
    plt.axvline(x=env.goal_position, color='red', linestyle='--', alpha=0.3)
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    print("\n9. KEY INSIGHTS FROM MOUNTAIN CAR:")
    
    print("\n   Why function approximation works:")
    print("   1. Tile coding generalizes similar states")
    print("   2. Learns to swing back and forth")
    print("   3. Builds momentum strategy")
    
    print("\n   Challenges faced:")
    print("   1. Sparse rewards (only at goal)")
    print("   2. Need to learn complex strategy")
    print("   3. Continuous state space")
    
    print("\n   Solutions demonstrated:")
    print("   1. Tile coding for continuous states")
    print("   2. Step penalty encourages efficiency")
    print("   3. Q-learning for off-policy learning")
    
    return env, agent, rewards

# Run Mountain Car project
mountain_car_env, mountain_car_agent, mountain_car_rewards = mountain_car_project()

7.6 Convergence Issues and Solutions

def convergence_analysis():
    """Analyze convergence issues in function approximation"""
    print("="*70)
    print("CONVERGENCE ANALYSIS IN FUNCTION APPROXIMATION")
    print("="*70)
    
    # Create a simple environment for analysis
    class ConvergenceEnv:
        def __init__(self):
            self.states = list(range(10))
            self.actions = [0, 1]
            self.action_space = type('ActionSpace', (), {'n': 2})()
            
            # Define true optimal values
            self.true_values = {s: np.sin(s/10 * 2*np.pi) for s in self.states}
            
        def reset(self):
            return np.random.choice(self.states)
        
        def step(self, action):
            state = np.random.choice(self.states)  # Random transitions
            reward = self.true_values[state] + np.random.normal(0, 0.1)
            done = np.random.random() < 0.1  # 10% chance of episode ending
            return state, reward, done
    
    env = ConvergenceEnv()
    
    print("\n1. THE CHALLENGE: NON-STATIONARY TARGETS")
    print("\n   In tabular TD:")
    print("   V(s) ← V(s) + α[r + γV(s') - V(s)]")
    print("   Target: r + γV(s')")
    print("   V(s') fixed during update")
    
    print("\n   With function approximation:")
    print("   θ ← θ + α[r + γV_θ(s') - V_θ(s)]∇V_θ(s)")
    print("   Problem: V_θ(s') changes as θ changes!")
    print("   Target depends on parameters being learned")
    print("   Can cause divergence!")
    
    print("\n2. DIVERGENCE EXAMPLE:")
    
    class DivergenceDemo:
        def __init__(self):
            # Simple linear function: y = θx
            self.theta = 1.0
            self.x = 2.0
            self.true_value = 1.0
            
        def update(self, alpha=0.1, gamma=0.9):
            """TD update that can diverge"""
            # Current prediction
            prediction = self.theta * self.x
            
            # Target (depends on current θ!)
            target = self.true_value + gamma * prediction
            
            # Update
            error = target - prediction
            gradient = self.x
            self.theta += alpha * error * gradient
            
            return prediction, target, self.theta
        
        def demonstrate_divergence(self):
            """Show how TD can diverge with function approximation"""
            print("\n   Starting θ = 1.0, x = 2.0, true value = 1.0")
            print("\n   Iteration | Prediction | Target | New θ")
            print("   " + "-"*40)
            
            self.theta = 1.0
            
            for i in range(10):
                pred, target, new_theta = self.update(alpha=0.1, gamma=0.9)
                print(f"   {i+1:9d} | {pred:10.3f} | {target:6.3f} | {new_theta:6.3f}")
                
                if abs(new_theta) > 10:  # Diverged
                    print(f"\n   DIVERGED after {i+1} iterations!")
                    break
    
    demo = DivergenceDemo()
    demo.demonstrate_divergence()
    
    print("\n3. SOLUTIONS FOR CONVERGENCE:")
    
    print("\n   A. Gradient Temporal Difference (GTD):")
    print("   - Uses two timescale updates")
    print("   - Maintains auxiliary weights")
    print("   - Provably convergent")
    
    print("\n   B. Target Networks:")
    print("   - Use separate network for targets")
    print("   - Target network updated slowly")
    print("   - Stabilizes learning (used in DQN)")
    
    print("\n   C. Experience Replay:")
    print("   - Store transitions in memory buffer")
    print("   - Sample random mini-batches")
    print("   - Breaks temporal correlations")
    
    print("\n   D. Proper Step Size Selection:")
    print("   - Smaller learning rates")
    print("   - Adaptive learning rates (Adam, RMSProp)")
    print("   - Learning rate schedules")
    
    print("\n4. IMPLEMENTING GTD:")
    
    class GTDLearner:
        """Gradient Temporal Difference learning"""
        
        def __init__(self, feature_dim, num_actions, alpha=0.01, beta=0.01):
            self.feature_dim = feature_dim
            self.num_actions = num_actions
            
            # Primary weights
            self.theta = np.zeros((num_actions, feature_dim))
            
            # Auxiliary weights (for GTD)
            self.w = np.zeros((num_actions, feature_dim))
            
            self.alpha = alpha  # Learning rate for θ
            self.beta = beta    # Learning rate for w
            
        def extract_features(self, state):
            """Simple one-hot encoding"""
            features = np.zeros(self.feature_dim)
            if isinstance(state, int) and state < self.feature_dim:
                features[state] = 1
            return features
        
        def predict(self, state):
            features = self.extract_features(state)
            return self.theta @ features
        
        def gtd_update(self, state, action, reward, next_state, 
                      next_action, gamma=0.9, done=False):
            """GTD update rule"""
            features = self.extract_features(state)
            next_features = self.extract_features(next_state)
            
            # Current prediction
            delta = reward - self.theta[action] @ features
            
            if not done:
                delta += gamma * (self.theta[next_action] @ next_features)
            
            # GTD updates
            # Update auxiliary weights w
            self.w[action] += self.beta * (delta - self.w[action] @ features) * features
            
            # Update primary weights θ
            self.theta[action] += self.alpha * (
                (features - gamma * next_features) * (self.w[action] @ features)
            )
            
            return delta ** 2
    
    print("\n   GTD Update Equations:")
    print("   δ = r + γθ·φ(s') - θ·φ(s)")
    print("   w ← w + β(δ - w·φ(s))φ(s)")
    print("   θ ← θ + α(φ(s) - γφ(s'))(w·φ(s))")
    
    print("\n5. COMPARING CONVERGENCE:")
    
    # Create test environment
    class TestConvergenceEnv:
        def __init__(self, n_states=20):
            self.n_states = n_states
            self.states = list(range(n_states))
            self.actions = [0, 1]
            self.action_space = type('ActionSpace', (), {'n': 2})()
            
            # Define true optimal Q-values
            self.true_Q = {}
            for s in self.states:
                self.true_Q[s] = np.array([
                    np.sin(s/n_states * 2*np.pi),
                    np.cos(s/n_states * 2*np.pi)
                ])
        
        def reset(self):
            return np.random.choice(self.states)
        
        def step(self, action):
            # Simple deterministic MDP
            state = self.reset()
            
            # Reward = true Q + noise
            reward = self.true_Q[state][action] + np.random.normal(0, 0.1)
            
            # 10% chance episode ends
            done = np.random.random() < 0.1
            
            return state, reward, done
    
    test_env = TestConvergenceEnv(n_states=20)
    
    # Test different algorithms
    algorithms = {
        'TD': LinearFunctionApproximation(20, 2, learning_rate=0.05),
        'GTD': GTDLearner(20, 2, alpha=0.05, beta=0.01)
    }
    
    # Training loop
    num_episodes = 1000
    results = {name: [] for name in algorithms}
    
    for episode in range(num_episodes):
        state = test_env.reset()
        done = False
        
        while not done:
            # Random action for simplicity
            action = np.random.choice([0, 1])
            next_state, reward, done = test_env.step(action)
            next_action = np.random.choice([0, 1])
            
            # Update each algorithm
            for name, algorithm in algorithms.items():
                if name == 'TD':
                    error = algorithm.sarsa_update(
                        state, action, reward, next_state, next_action,
                        gamma=0.9, done=done
                    )
                elif name == 'GTD':
                    error = algorithm.gtd_update(
                        state, action, reward, next_state, next_action,
                        gamma=0.9, done=done
                    )
                
                results[name].append(error)
            
            state = next_state
    
    # Plot results
    plt.figure(figsize=(15, 5))
    
    # Plot 1: Error over time
    plt.subplot(1, 3, 1)
    for name, errors in results.items():
        window = 100
        smoothed = np.convolve(errors, np.ones(window)/window, mode='valid')
        plt.plot(smoothed[:500], label=name, linewidth=2)
    
    plt.xlabel('Update step')
    plt.ylabel('Squared TD error (smoothed)')
    plt.title('Convergence Comparison')
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.yscale('log')
    
    # Plot 2: Final error distribution
    plt.subplot(1, 3, 2)
    final_errors = {name: errors[-1000:] for name, errors in results.items()}
    
    positions = np.arange(len(algorithms))
    means = [np.mean(errors) for errors in final_errors.values()]
    stds = [np.std(errors) for errors in final_errors.values()]
    
    plt.bar(positions, means, yerr=stds, capsize=10, alpha=0.7)
    plt.xticks(positions, list(algorithms.keys()))
    plt.ylabel('Mean squared error (last 1000 updates)')
    plt.title('Final Performance')
    plt.grid(True, alpha=0.3)
    
    # Plot 3: Parameter norm over time
    plt.subplot(1, 3, 3)
    # For demonstration, track parameter norms
    theta_norms = []
    for i in range(0, len(results['TD']), 100):
        if i < len(results['TD']):
            theta_norms.append(np.linalg.norm(algorithms['TD'].theta))
    
    plt.plot(theta_norms, label='TD parameter norm', linewidth=2)
    plt.xlabel('Update step (every 100th)')
    plt.ylabel('Parameter norm ||θ||')
    plt.title('Parameter Stability')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    print("\n6. PRACTICAL ADVICE:")
    print("\n   When function approximation diverges:")
    print("   1. Reduce learning rate")
    print("   2. Use target networks")
    print("   3. Implement experience replay")
    print("   4. Try GTD for guaranteed convergence")
    print("   5. Normalize features")
    
    print("\n   Feature engineering tips:")
    print("   1. Tile coding works well for low-dimensional spaces")
    print("   2. Neural networks for high-dimensional spaces")
    print("   3. Normalize inputs to [-1, 1] or [0, 1]")
    print("   4. Consider feature interactions")
    
    return algorithms, results

# Run convergence analysis
convergence_algorithms, convergence_results = convergence_analysis()

Chapter 7 Key Takeaways:

  1. The Curse of Dimensionality:

    • Tabular methods fail for large/continuous state spaces
    • Need compact representations
  2. Function Approximation:

    • Learn Q(s,a) ≈ f_θ(s,a) instead of table
    • Parameters θ learned from data
    • Generalizes to unseen states
  3. Feature Representations:

    • Tile Coding: Good for low-dimensional, overlapping features
    • Polynomial Features: Captures interactions, can overfit
    • Radial Basis Functions: Localized, good for interpolation
    • Fourier Basis: Periodic functions, universal approximation
    • Neural Networks: Learned features (next chapter)
  4. Linear Function Approximation:

    • Q(s,a) = θ[a]·φ(s)
    • Simple, converges under right conditions
    • Need good feature engineering
  5. Convergence Issues:

    • Non-stationary targets can cause divergence
    • Solutions: GTD, target networks, experience replay
    • Proper step sizes crucial
  6. Mountain Car Example:

    • Continuous state space needs function approximation
    • Tile coding works well for 2D state
    • Learns complex swinging strategy
  7. Practical Considerations:

    • Start with linear methods + good features
    • Monitor for divergence
    • Normalize features
    • Use experience replay for stability

CHAPTER 8: Deep Q-Networks (DQN): A Star is Born

8.1 The Atari Breakthrough

The 2013 Revolution: DeepMind's DQN could play Atari games at human level from pixels alone!

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt
from collections import deque, namedtuple
import random
import cv2

class DQNBreakthrough:
    """Understanding the DQN breakthrough"""
    
    def __init__(self):
        self.history = self.create_timeline()
    
    def create_timeline(self):
        """Create timeline of DQN development"""
        print("="*70)
        print("THE DQN BREAKTHROUGH TIMELINE")
        print("="*70)
        
        timeline = [
            ("2013", "DeepMind publishes first DQN paper", 
             "Playing Atari with Deep Reinforcement Learning"),
            ("2015", "Nature paper: Human-level control", 
             "First AI to learn directly from pixels"),
            ("2016", "Improvements: Double DQN, Dueling DQN", 
             "Addressing overestimation bias"),
            ("2017", "Rainbow DQN", 
             "Combining 7 improvements for state-of-the-art"),
            ("2018", "Distributional DQN", 
             "Learning value distributions instead of expectations"),
            ("2020+", "Modern extensions", 
             "Prioritized replay, noisy nets, multi-step learning")
        ]
        
        for year, title, description in timeline:
            print(f"\n{year}: {title}")
            print(f"     {description}")
        
        return timeline
    
    def atari_challenge(self):
        """Explain the Atari learning challenge"""
        print("\n" + "="*70)
        print("THE ATARI LEARNING CHALLENGE")
        print("="*70)
        
        challenges = [
            ("Input", "210×160 RGB pixels (100,800 values)", 
             "Need to process raw pixels"),
            ("Action Space", "Up to 18 discrete actions",
             "Learn which buttons to press"),
            ("State Space", "~256¹⁰⁰⁸⁰⁰ possible states",
             "More than atoms in universe"),
            ("Rewards", "Sparse and delayed",
             "Score only increases with achievements"),
            ("Generalization", "Learn from pixels alone",
             "No game-specific knowledge")
        ]
        
        print("\nChallenges faced by DQN:")
        for challenge, numbers, description in challenges:
            print(f"\n{challenge:15s}: {numbers}")
            print(f"{'':15s}  {description}")
    
    def dqn_architecture_overview(self):
        """Show DQN architecture"""
        print("\n" + "="*70)
        print("DQN ARCHITECTURE OVERVIEW")
        print("="*70)
        
        architecture = """
        Raw Pixels (210×160×3)
               ↓
        Preprocessing (84×84×4 grayscale)
               ↓
        Convolutional Layers (feature extraction)
               ↓
        Fully Connected Layers
               ↓
        Q-values for each action
        
        Key Innovations:
        1. Experience Replay: Break correlations
        2. Target Network: Stabilize learning
        3. Frame Stacking: Handle partial observability
        """
        
        print(architecture)
        
        # Visualize the architecture
        self.visualize_dqn_architecture()

# Initialize
dqn_intro = DQNBreakthrough()
dqn_intro.atari_challenge()
dqn_intro.dqn_architecture_overview()

8.2 Neural Networks as Q-Function Approximators

class DQNNetwork(nn.Module):
    """Deep Q-Network architecture"""
    
    def __init__(self, input_shape, n_actions):
        """
        Initialize DQN network
        
        Args:
            input_shape: (channels, height, width)
            n_actions: Number of possible actions
        """
        super(DQNNetwork, self).__init__()
        
        # Input shape
        self.input_channels = input_shape[0]
        self.input_height = input_shape[1]
        self.input_width = input_shape[2]
        
        # Convolutional layers (feature extraction)
        self.conv1 = nn.Conv2d(self.input_channels, 32, kernel_size=8, stride=4)
        self.conv2 = nn.Conv2d(32, 64, kernel_size=4, stride=2)
        self.conv3 = nn.Conv2d(64, 64, kernel_size=3, stride=1)
        
        # Calculate flattened size
        conv_output_size = self._get_conv_output(input_shape)
        
        # Fully connected layers
        self.fc1 = nn.Linear(conv_output_size, 512)
        self.fc2 = nn.Linear(512, n_actions)
        
        # Initialize weights
        self._initialize_weights()
    
    def _get_conv_output(self, shape):
        """Calculate output size of convolutional layers"""
        batch_size = 1
        input = torch.autograd.Variable(torch.rand(batch_size, *shape))
        output = self._forward_conv(input)
        return output.view(batch_size, -1).size(1)
    
    def _forward_conv(self, x):
        """Forward pass through convolutional layers"""
        x = F.relu(self.conv1(x))
        x = F.relu(self.conv2(x))
        x = F.relu(self.conv3(x))
        return x
    
    def _initialize_weights(self):
        """Initialize network weights"""
        for module in self.modules():
            if isinstance(module, nn.Conv2d) or isinstance(module, nn.Linear):
                nn.init.kaiming_normal_(module.weight, mode='fan_out', nonlinearity='relu')
                if module.bias is not None:
                    nn.init.constant_(module.bias, 0)
    
    def forward(self, x):
        """Forward pass"""
        # Convolutional layers
        x = self._forward_conv(x)
        
        # Flatten
        x = x.view(x.size(0), -1)
        
        # Fully connected layers
        x = F.relu(self.fc1(x))
        x = self.fc2(x)
        
        return x
    
    def get_action(self, state, epsilon=0.0):
        """
        Get action using epsilon-greedy policy
        
        Args:
            state: Preprocessed state tensor
            epsilon: Exploration rate
        
        Returns:
            action: Integer action index
        """
        if np.random.random() < epsilon:
            # Explore: random action
            return np.random.randint(self.fc2.out_features)
        else:
            # Exploit: best action
            with torch.no_grad():
                q_values = self.forward(state)
                return torch.argmax(q_values).item()

class SimpleDQN(nn.Module):
    """Simplified DQN for non-visual inputs"""
    
    def __init__(self, input_dim, hidden_dim, n_actions):
        """
        Simple feedforward DQN
        
        Args:
            input_dim: Dimension of input state
            hidden_dim: Size of hidden layers
            n_actions: Number of possible actions
        """
        super(SimpleDQN, self).__init__()
        
        self.network = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, n_actions)
        )
        
        # Initialize weights
        self._initialize_weights()
    
    def _initialize_weights(self):
        for layer in self.network:
            if isinstance(layer, nn.Linear):
                nn.init.kaiming_normal_(layer.weight, mode='fan_out', nonlinearity='relu')
                nn.init.constant_(layer.bias, 0)
    
    def forward(self, x):
        return self.network(x)

def visualize_neural_network():
    """Visualize how neural networks approximate Q-functions"""
    
    print("\n" + "="*70)
    print("NEURAL NETWORKS AS FUNCTION APPROXIMATORS")
    print("="*70)
    
    # Create a simple 1D Q-function to approximate
    def true_q_function(state, action):
        """True Q-function we want to learn"""
        x = state[0]
        if action == 0:  # Move left
            return -x**2
        else:  # Move right
            return np.sin(2 * np.pi * x)
    
    # Generate training data
    n_samples = 1000
    states = np.random.uniform(-1, 1, (n_samples, 1))
    actions = np.random.randint(0, 2, n_samples)
    targets = np.array([true_q_function([s], a) for s, a in zip(states, actions)])
    
    # Create neural network
    class QNetwork1D(nn.Module):
        def __init__(self):
            super(QNetwork1D, self).__init__()
            self.net = nn.Sequential(
                nn.Linear(2, 64),  # state (1) + action (1) = 2
                nn.ReLU(),
                nn.Linear(64, 64),
                nn.ReLU(),
                nn.Linear(64, 1)
            )
        
        def forward(self, state, action):
            # One-hot encode action
            action_onehot = F.one_hot(action.long(), num_classes=2).float()
            x = torch.cat([state, action_onehot], dim=1)
            return self.net(x)
    
    # Train the network
    net = QNetwork1D()
    optimizer = optim.Adam(net.parameters(), lr=0.001)
    criterion = nn.MSELoss()
    
    # Convert to tensors
    states_tensor = torch.FloatTensor(states)
    actions_tensor = torch.LongTensor(actions)
    targets_tensor = torch.FloatTensor(targets).unsqueeze(1)
    
    # Training loop
    losses = []
    for epoch in range(1000):
        optimizer.zero_grad()
        predictions = net(states_tensor, actions_tensor)
        loss = criterion(predictions, targets_tensor)
        loss.backward()
        optimizer.step()
        losses.append(loss.item())
    
    # Test the learned function
    test_states = np.linspace(-1, 1, 100).reshape(-1, 1)
    test_states_tensor = torch.FloatTensor(test_states)
    
    with torch.no_grad():
        q_values_action0 = []
        q_values_action1 = []
        
        for state in test_states:
            # Action 0
            state_tensor = torch.FloatTensor(state).unsqueeze(0)
            action0_tensor = torch.LongTensor([0])
            q0 = net(state_tensor, action0_tensor).item()
            q_values_action0.append(q0)
            
            # Action 1
            action1_tensor = torch.LongTensor([1])
            q1 = net(state_tensor, action1_tensor).item()
            q_values_action1.append(q1)
    
    # Plot results
    fig, axes = plt.subplots(2, 2, figsize=(15, 10))
    
    # Plot 1: True vs learned Q-function for action 0
    ax = axes[0, 0]
    true_q0 = [-x**2 for x in test_states.flatten()]
    ax.plot(test_states, true_q0, 'b-', label='True Q(s,0)', linewidth=3, alpha=0.7)
    ax.plot(test_states, q_values_action0, 'r--', label='Learned Q(s,0)', linewidth=2)
    ax.set_xlabel('State')
    ax.set_ylabel('Q-value')
    ax.set_title('Action 0: Move Left')
    ax.legend()
    ax.grid(True, alpha=0.3)
    
    # Plot 2: True vs learned Q-function for action 1
    ax = axes[0, 1]
    true_q1 = [np.sin(2 * np.pi * x) for x in test_states.flatten()]
    ax.plot(test_states, true_q1, 'b-', label='True Q(s,1)', linewidth=3, alpha=0.7)
    ax.plot(test_states, q_values_action1, 'r--', label='Learned Q(s,1)', linewidth=2)
    ax.set_xlabel('State')
    ax.set_ylabel('Q-value')
    ax.set_title('Action 1: Move Right')
    ax.legend()
    ax.grid(True, alpha=0.3)
    
    # Plot 3: Optimal policy
    ax = axes[1, 0]
    optimal_actions = []
    for q0, q1 in zip(q_values_action0, q_values_action1):
        if q0 > q1:
            optimal_actions.append(0)
        else:
            optimal_actions.append(1)
    
    ax.plot(test_states, optimal_actions, 'g-', linewidth=2)
    ax.set_xlabel('State')
    ax.set_ylabel('Optimal Action')
    ax.set_title('Learned Optimal Policy')
    ax.set_yticks([0, 1])
    ax.set_yticklabels(['Left', 'Right'])
    ax.grid(True, alpha=0.3)
    
    # Plot 4: Training loss
    ax = axes[1, 1]
    ax.plot(losses)
    ax.set_xlabel('Training Step')
    ax.set_ylabel('MSE Loss')
    ax.set_title('Training Convergence')
    ax.set_yscale('log')
    ax.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    print("\nKEY INSIGHTS:")
    print("1. Neural networks can approximate complex Q-functions")
    print("2. Single network outputs Q-values for all actions")
    print("3. Network learns smooth approximations")
    print("4. Optimal policy emerges from Q-value comparisons")

# Visualize neural network approximation
visualize_neural_network()

8.3 Experience Replay: Learning from Memory

class ReplayBuffer:
    """Experience replay buffer for DQN"""
    
    def __init__(self, capacity, device='cpu'):
        """
        Initialize replay buffer
        
        Args:
            capacity: Maximum number of transitions to store
            device: Device to store tensors on
        """
        self.capacity = capacity
        self.device = device
        self.buffer = deque(maxlen=capacity)
        
        # Define transition structure
        self.Transition = namedtuple('Transition', 
                                    ('state', 'action', 'reward', 
                                     'next_state', 'done'))
    
    def push(self, state, action, reward, next_state, done):
        """Add a transition to the buffer"""
        transition = self.Transition(state, action, reward, next_state, done)
        self.buffer.append(transition)
    
    def sample(self, batch_size):
        """
        Sample a batch of transitions
        
        Returns:
            tuple of (states, actions, rewards, next_states, dones)
        """
        if len(self.buffer) < batch_size:
            raise ValueError(f"Buffer only has {len(self.buffer)} samples, need {batch_size}")
        
        transitions = random.sample(self.buffer, batch_size)
        batch = self.Transition(*zip(*transitions))
        
        # Convert to tensors
        state_batch = torch.stack(batch.state).to(self.device)
        action_batch = torch.tensor(batch.action, device=self.device).unsqueeze(1)
        reward_batch = torch.tensor(batch.reward, device=self.device, dtype=torch.float32).unsqueeze(1)
        next_state_batch = torch.stack(batch.next_state).to(self.device)
        done_batch = torch.tensor(batch.done, device=self.device, dtype=torch.float32).unsqueeze(1)
        
        return (state_batch, action_batch, reward_batch, 
                next_state_batch, done_batch)
    
    def __len__(self):
        return len(self.buffer)

class PrioritizedReplayBuffer(ReplayBuffer):
    """Prioritized experience replay buffer"""
    
    def __init__(self, capacity, alpha=0.6, beta=0.4, beta_increment=0.001, device='cpu'):
        """
        Initialize prioritized replay buffer
        
        Args:
            capacity: Maximum buffer size
            alpha: Priority exponent (0 = uniform, 1 = fully prioritized)
            beta: Importance sampling exponent
            beta_increment: Beta increment per sample
            device: Device for tensors
        """
        super().__init__(capacity, device)
        self.alpha = alpha
        self.beta = beta
        self.beta_increment = beta_increment
        self.priorities = np.zeros(capacity, dtype=np.float32)
        self.position = 0
        self.max_priority = 1.0
    
    def push(self, state, action, reward, next_state, done):
        """Add transition with maximum priority"""
        # Find position
        if len(self.buffer) < self.capacity:
            self.buffer.append(None)
        
        # Store transition
        transition = self.Transition(state, action, reward, next_state, done)
        self.buffer[self.position] = transition
        
        # Set priority
        self.priorities[self.position] = self.max_priority ** self.alpha
        
        # Update position
        self.position = (self.position + 1) % self.capacity
    
    def sample(self, batch_size):
        """Sample batch with priorities"""
        if len(self.buffer) < batch_size:
            raise ValueError(f"Buffer only has {len(self.buffer)} samples, need {batch_size}")
        
        # Calculate sampling probabilities
        priorities = self.priorities[:len(self.buffer)]
        probabilities = priorities / priorities.sum()
        
        # Sample indices
        indices = np.random.choice(len(self.buffer), batch_size, p=probabilities)
        
        # Get transitions
        transitions = [self.buffer[idx] for idx in indices]
        batch = self.Transition(*zip(*transitions))
        
        # Convert to tensors
        state_batch = torch.stack(batch.state).to(self.device)
        action_batch = torch.tensor(batch.action, device=self.device).unsqueeze(1)
        reward_batch = torch.tensor(batch.reward, device=self.device, dtype=torch.float32).unsqueeze(1)
        next_state_batch = torch.stack(batch.next_state).to(self.device)
        done_batch = torch.tensor(batch.done, device=self.device, dtype=torch.float32).unsqueeze(1)
        
        # Calculate importance sampling weights
        weights = (len(self.buffer) * probabilities[indices]) ** (-self.beta)
        weights = weights / weights.max()  # Normalize
        weight_batch = torch.tensor(weights, device=self.device, dtype=torch.float32).unsqueeze(1)
        
        # Update beta
        self.beta = min(1.0, self.beta + self.beta_increment)
        
        return (state_batch, action_batch, reward_batch, 
                next_state_batch, done_batch, indices, weight_batch)
    
    def update_priorities(self, indices, priorities):
        """Update priorities for sampled transitions"""
        for idx, priority in zip(indices, priorities):
            self.priorities[idx] = (priority + 1e-5) ** self.alpha
            self.max_priority = max(self.max_priority, priority)

def experience_replay_demo():
    """Demonstrate the power of experience replay"""
    
    print("\n" + "="*70)
    print("EXPERIENCE REPLAY: WHY IT MATTERS")
    print("="*70)
    
    # Create a simple environment
    class CorrelatedEnv:
        """Environment that produces highly correlated samples"""
        def __init__(self):
            self.state = 0
            self.states = list(range(100))
        
        def reset(self):
            self.state = 0
            return self.state
        
        def step(self, action):
            # Highly correlated: always move to next state
            self.state = (self.state + 1) % 100
            reward = np.sin(self.state / 10)
            done = False
            return self.state, reward, done
    
    env = CorrelatedEnv()
    
    print("\nTHE PROBLEM: CORRELATED SAMPLES")
    print("Sequential experiences are highly correlated:")
    print("- Breaks i.i.d. assumption of most learning algorithms")
    print("- Can cause unstable learning")
    print("- Network forgets old experiences")
    
    print("\n" + "-"*70)
    print("SOLUTION: EXPERIENCE REPLAY")
    print("-"*70)
    
    print("\nHow it works:")
    print("1. Store experiences (s, a, r, s', done) in replay buffer")
    print("2. Sample random mini-batches from buffer")
    print("3. Breaks temporal correlations")
    print("4. Reuses experiences (data efficiency)")
    
    # Demonstrate correlation
    print("\nDEMONSTRATION:")
    
    # Generate sequential experiences
    sequential_states = []
    state = env.reset()
    for _ in range(20):
        state, reward, done = env.step(0)
        sequential_states.append(state)
    
    print(f"\nSequential states (highly correlated):")
    print(sequential_states)
    
    # Create replay buffer
    buffer = ReplayBuffer(capacity=1000)
    
    # Fill buffer
    state = env.reset()
    for _ in range(100):
        action = np.random.randint(0, 2)
        next_state, reward, done = env.step(action)
        
        # Store as tensor
        state_tensor = torch.FloatTensor([state])
        next_state_tensor = torch.FloatTensor([next_state])
        
        buffer.push(state_tensor, action, reward, next_state_tensor, done)
        state = next_state
    
    # Sample from buffer
    batch_states = []
    for _ in range(5):
        states, _, _, _, _ = buffer.sample(5)
        batch_states.extend(states.numpy().flatten().tolist())
    
    print(f"\nRandom samples from replay buffer (decorrelated):")
    print([int(s) for s in batch_states[:20]])
    
    # Show statistics
    print("\nSTATISTICS:")
    print(f"Sequential correlation: {np.corrcoef(sequential_states[:-1], sequential_states[1:])[0,1]:.3f}")
    
    # Calculate correlation in replay buffer samples
    if len(batch_states) > 1:
        replay_corr = np.corrcoef(batch_states[:-1], batch_states[1:])[0,1]
        print(f"Replay buffer correlation: {replay_corr:.3f}")
    
    # Visualize the difference
    plt.figure(figsize=(12, 5))
    
    # Plot sequential samples
    plt.subplot(1, 2, 1)
    plt.plot(sequential_states, 'b-o', alpha=0.7)
    plt.xlabel('Step')
    plt.ylabel('State')
    plt.title('Sequential Samples (High Correlation)')
    plt.grid(True, alpha=0.3)
    
    # Plot replay buffer samples
    plt.subplot(1, 2, 2)
    plt.plot(batch_states, 'r-o', alpha=0.7)
    plt.xlabel('Sample Index')
    plt.ylabel('State')
    plt.title('Replay Buffer Samples (Low Correlation)')
    plt.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    print("\nBENEFITS OF EXPERIENCE REPLAY:")
    print("1. Breaks temporal correlations")
    print("2. Increases data efficiency (reuse experiences)")
    print("3. Smoothes training distribution")
    print("4. Enables mini-batch learning")
    
    print("\nVARIATIONS:")
    print("1. Prioritized Replay: Sample important transitions more often")
    print("2. Hindsight Experience Replay: Learn from failures")
    print("3. Distributed Replay: Multiple parallel actors")
    
    return buffer

# Run experience replay demo
replay_buffer = experience_replay_demo()

8.4 Target Networks: Stabilizing Learning

class TargetNetworkManager:
    """Manages target network updates"""
    
    def __init__(self, policy_net, target_net, update_method='hard', tau=0.001):
        """
        Initialize target network manager
        
        Args:
            policy_net: The network being trained
            target_net: The network used for targets
            update_method: 'hard' or 'soft' updates
            tau: Soft update parameter (0 < tau ≤ 1)
        """
        self.policy_net = policy_net
        self.target_net = target_net
        self.update_method = update_method
        self.tau = tau
        
        # Initially synchronize target network
        self.hard_update()
    
    def hard_update(self):
        """Hard update: copy all weights"""
        self.target_net.load_state_dict(self.policy_net.state_dict())
    
    def soft_update(self):
        """Soft update: polyak averaging"""
        for target_param, policy_param in zip(self.target_net.parameters(), 
                                            self.policy_net.parameters()):
            target_param.data.copy_(
                self.tau * policy_param.data + (1 - self.tau) * target_param.data
            )
    
    def update(self, step=None):
        """
        Update target network based on method
        
        Args:
            step: Current training step (for scheduled updates)
        """
        if self.update_method == 'hard':
            # Hard update every N steps
            if step is not None and step % 1000 == 0:
                self.hard_update()
        elif self.update_method == 'soft':
            # Soft update every step
            self.soft_update()
        else:
            raise ValueError(f"Unknown update method: {self.update_method}")

def target_network_analysis():
    """Analyze why target networks are needed"""
    
    print("\n" + "="*70)
    print("TARGET NETWORKS: STABILIZING LEARNING")
    print("="*70)
    
    print("\nTHE PROBLEM: MOVING TARGETS")
    print("\nWithout target networks:")
    print("Q(s,a) ← Q(s,a) + α[r + γ max_a' Q(s',a') - Q(s,a)]")
    print("                        ↑")
    print("          This target changes every step!")
    print("\nResult: Unstable learning, can diverge")
    
    print("\n" + "-"*70)
    print("SOLUTION: TARGET NETWORKS")
    print("-"*70)
    
    print("\nWith target networks:")
    print("Q(s,a) ← Q(s,a) + α[r + γ max_a' Q_target(s',a') - Q(s,a)]")
    print("                        ↑")
    print("          Target network changes slowly")
    
    print("\nTwo update methods:")
    print("1. Hard updates: Copy weights every N steps")
    print("2. Soft updates: Polyak averaging: θ_target ← τθ + (1-τ)θ_target")
    
    # Demonstrate the problem
    print("\n" + "-"*70)
    print("DEMONSTRATION")
    print("-"*70)
    
    # Create a simple Q-function learning task
    class MovingTargetEnv:
        def __init__(self):
            self.true_q = 5.0  # True optimal Q-value
            self.current_estimate = 0.0
        
        def step(self):
            """TD update without target network"""
            # Simulate noisy reward
            reward = self.true_q + np.random.normal(0, 1.0)
            
            # TD target using current estimate (problem!)
            target = reward + 0.9 * self.current_estimate
            
            # Update
            error = target - self.current_estimate
            self.current_estimate += 0.1 * error
            
            return self.current_estimate, error
        
        def step_with_target(self, target_estimate):
            """TD update with target network"""
            reward = self.true_q + np.random.normal(0, 1.0)
            target = reward + 0.9 * target_estimate
            error = target - self.current_estimate
            self.current_estimate += 0.1 * error
            return self.current_estimate, error
    
    print("\nSimulating learning WITH and WITHOUT target networks...")
    
    # Without target network
    env1 = MovingTargetEnv()
    estimates_no_target = []
    errors_no_target = []
    
    for i in range(100):
        est, err = env1.step()
        estimates_no_target.append(est)
        errors_no_target.append(err)
    
    # With target network (hard update every 10 steps)
    env2 = MovingTargetEnv()
    estimates_with_target = []
    errors_with_target = []
    target_estimate = 0.0
    
    for i in range(100):
        if i % 10 == 0:  # Hard update
            target_estimate = env2.current_estimate
        
        est, err = env2.step_with_target(target_estimate)
        estimates_with_target.append(est)
        errors_with_target.append(err)
    
    # Plot results
    fig, axes = plt.subplots(2, 2, figsize=(15, 10))
    
    # Plot 1: Estimates over time
    ax = axes[0, 0]
    ax.plot(estimates_no_target, 'r-', label='Without target network', alpha=0.7)
    ax.plot(estimates_with_target, 'b-', label='With target network', alpha=0.7)
    ax.axhline(y=5.0, color='green', linestyle='--', label='True value', alpha=0.5)
    ax.set_xlabel('Update step')
    ax.set_ylabel('Q-value estimate')
    ax.set_title('Learning Stability Comparison')
    ax.legend()
    ax.grid(True, alpha=0.3)
    
    # Plot 2: Errors over time
    ax = axes[0, 1]
    ax.plot(errors_no_target, 'r-', alpha=0.7, label='Without target')
    ax.plot(errors_with_target, 'b-', alpha=0.7, label='With target')
    ax.set_xlabel('Update step')
    ax.set_ylabel('TD error')
    ax.set_title('TD Error Comparison')
    ax.legend()
    ax.grid(True, alpha=0.3)
    
    # Plot 3: Error distribution
    ax = axes[1, 0]
    ax.hist(errors_no_target, bins=20, alpha=0.7, color='red', label='Without target')
    ax.hist(errors_with_target, bins=20, alpha=0.7, color='blue', label='With target')
    ax.set_xlabel('TD error')
    ax.set_ylabel('Frequency')
    ax.set_title('Error Distribution')
    ax.legend()
    
    # Plot 4: Convergence speed
    ax = axes[1, 1]
    
    # Calculate convergence
    def calculate_convergence(estimates, threshold=0.1):
        """Calculate steps to reach within threshold of true value"""
        for i, est in enumerate(estimates):
            if abs(est - 5.0) < threshold:
                return i
        return len(estimates)
    
    conv_no_target = calculate_convergence(estimates_no_target)
    conv_with_target = calculate_convergence(estimates_with_target)
    
    bars = ax.bar(['Without target', 'With target'], 
                  [conv_no_target, conv_with_target],
                  color=['red', 'blue'], alpha=0.7)
    ax.set_ylabel('Steps to converge (within 0.1)')
    ax.set_title('Convergence Speed')
    
    # Add value labels
    for bar, conv in zip(bars, [conv_no_target, conv_with_target]):
        ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 2,
                f'{conv}', ha='center', va='bottom')
    
    plt.tight_layout()
    plt.show()
    
    print("\nKEY FINDINGS:")
    print(f"1. Without target network: Converged in {conv_no_target} steps")
    print(f"2. With target network: Converged in {conv_with_target} steps")
    print(f"3. Target network reduced error variance by {np.var(errors_no_target)/np.var(errors_with_target):.1f}x")
    
    print("\nPRACTICAL ADVICE:")
    print("1. Use τ ≈ 0.001 for soft updates (slow tracking)")
    print("2. Use 1000-10000 steps for hard updates")
    print("3. Target networks essential for deep RL")
    
    return estimates_no_target, estimates_with_target

# Run target network analysis
estimates_without, estimates_with = target_network_analysis()

8.5 Complete DQN Implementation

class DQNAgent:
    """Complete DQN agent implementation"""
    
    def __init__(self, state_dim, action_dim, config=None):
        """
        Initialize DQN agent
        
        Args:
            state_dim: Dimension of state space
            action_dim: Number of possible actions
            config: Configuration dictionary
        """
        # Default configuration
        self.config = {
            'device': 'cuda' if torch.cuda.is_available() else 'cpu',
            'gamma': 0.99,  # Discount factor
            'lr': 1e-4,  # Learning rate
            'buffer_size': 100000,  # Replay buffer size
            'batch_size': 64,  # Training batch size
            'update_target_every': 1000,  # Hard update frequency
            'tau': 0.001,  # Soft update parameter
            'epsilon_start': 1.0,  # Initial exploration
            'epsilon_end': 0.01,  # Final exploration
            'epsilon_decay': 0.995,  # Exploration decay
            'learning_starts': 10000,  # Steps before learning
            'train_frequency': 4,  # Learn every N steps
        }
        
        if config:
            self.config.update(config)
        
        self.device = self.config['device']
        self.action_dim = action_dim
        self.state_dim = state_dim
        
        # Initialize networks
        self.policy_net = SimpleDQN(state_dim, 128, action_dim).to(self.device)
        self.target_net = SimpleDQN(state_dim, 128, action_dim).to(self.device)
        self.target_net.load_state_dict(self.policy_net.state_dict())
        
        # Initialize optimizer
        self.optimizer = optim.Adam(self.policy_net.parameters(), lr=self.config['lr'])
        
        # Initialize replay buffer
        self.memory = ReplayBuffer(self.config['buffer_size'], self.device)
        
        # Training state
        self.steps_done = 0
        self.epsilon = self.config['epsilon_start']
        self.episode_rewards = []
        self.episode_lengths = []
        self.losses = []
        
        # For tracking
        self.current_episode_reward = 0
        self.current_episode_length = 0
    
    def select_action(self, state, training=True):
        """
        Select action using epsilon-greedy policy
        
        Args:
            state: Current state
            training: Whether in training mode
        
        Returns:
            action: Integer action index
        """
        if training and np.random.random() < self.epsilon:
            # Explore
            return np.random.randint(self.action_dim)
        else:
            # Exploit
            with torch.no_grad():
                state_tensor = torch.FloatTensor(state).unsqueeze(0).to(self.device)
                q_values = self.policy_net(state_tensor)
                return torch.argmax(q_values).item()
    
    def store_transition(self, state, action, reward, next_state, done):
        """Store transition in replay buffer"""
        state_tensor = torch.FloatTensor(state).unsqueeze(0).to(self.device)
        next_state_tensor = torch.FloatTensor(next_state).unsqueeze(0).to(self.device)
        self.memory.push(state_tensor, action, reward, next_state_tensor, done)
    
    def update_epsilon(self):
        """Update exploration rate"""
        self.epsilon = max(self.config['epsilon_end'],
                          self.config['epsilon_start'] * 
                          self.config['epsilon_decay'] ** (self.steps_done / 1000))
    
    def compute_td_loss(self, batch):
        """
        Compute TD loss for a batch
        
        Args:
            batch: Tuple of (states, actions, rewards, next_states, dones)
        
        Returns:
            loss: Computed loss
        """
        states, actions, rewards, next_states, dones = batch
        
        # Current Q-values
        current_q = self.policy_net(states).gather(1, actions)
        
        # Next Q-values from target network
        with torch.no_grad():
            next_q = self.target_net(next_states).max(1)[0].unsqueeze(1)
        
        # Compute target
        target = rewards + self.config['gamma'] * next_q * (1 - dones)
        
        # Compute loss
        loss = F.smooth_l1_loss(current_q, target)
        
        return loss
    
    def learn(self):
        """Perform one learning step"""
        if len(self.memory) < self.config['batch_size']:
            return None
        
        # Sample batch
        batch = self.memory.sample(self.config['batch_size'])
        
        # Compute loss
        loss = self.compute_td_loss(batch)
        
        # Optimize
        self.optimizer.zero_grad()
        loss.backward()
        
        # Gradient clipping
        torch.nn.utils.clip_grad_norm_(self.policy_net.parameters(), 10.0)
        
        self.optimizer.step()
        
        # Update target network
        if self.steps_done % self.config['update_target_every'] == 0:
            self.target_net.load_state_dict(self.policy_net.state_dict())
        
        return loss.item()
    
    def train_step(self, state, action, reward, next_state, done):
        """
        Perform one training step (store + learn)
        
        Returns:
            loss: Loss value if learning happened, None otherwise
        """
        # Store transition
        self.store_transition(state, action, reward, next_state, done)
        
        # Update counters
        self.steps_done += 1
        self.current_episode_reward += reward
        self.current_episode_length += 1
        
        # Update epsilon
        self.update_epsilon()
        
        # Learn if ready
        loss = None
        if (self.steps_done > self.config['learning_starts'] and 
            self.steps_done % self.config['train_frequency'] == 0):
            loss = self.learn()
            if loss is not None:
                self.losses.append(loss)
        
        # Handle episode end
        if done:
            self.episode_rewards.append(self.current_episode_reward)
            self.episode_lengths.append(self.current_episode_length)
            self.current_episode_reward = 0
            self.current_episode_length = 0
        
        return loss
    
    def evaluate(self, env, num_episodes=10, render=False):
        """
        Evaluate agent performance
        
        Returns:
            mean_reward: Average reward over episodes
        """
        total_rewards = []
        
        for episode in range(num_episodes):
            state = env.reset()
            done = False
            episode_reward = 0
            
            while not done:
                if render:
                    env.render()
                
                action = self.select_action(state, training=False)
                state, reward, done = env.step(action)
                episode_reward += reward
            
            total_rewards.append(episode_reward)
        
        mean_reward = np.mean(total_rewards)
        std_reward = np.std(total_rewards)
        
        return mean_reward, std_reward, total_rewards
    
    def save(self, path):
        """Save agent state"""
        torch.save({
            'policy_net_state_dict': self.policy_net.state_dict(),
            'target_net_state_dict': self.target_net.state_dict(),
            'optimizer_state_dict': self.optimizer.state_dict(),
            'steps_done': self.steps_done,
            'epsilon': self.epsilon,
            'episode_rewards': self.episode_rewards,
            'losses': self.losses,
            'config': self.config
        }, path)
    
    def load(self, path):
        """Load agent state"""
        checkpoint = torch.load(path, map_location=self.device)
        self.policy_net.load_state_dict(checkpoint['policy_net_state_dict'])
        self.target_net.load_state_dict(checkpoint['target_net_state_dict'])
        self.optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
        self.steps_done = checkpoint['steps_done']
        self.epsilon = checkpoint['epsilon']
        self.episode_rewards = checkpoint['episode_rewards']
        self.losses = checkpoint['losses']
        self.config.update(checkpoint['config'])

def dqn_training_demo():
    """Complete DQN training demonstration"""
    
    print("\n" + "="*70)
    print("COMPLETE DQN TRAINING DEMONSTRATION")
    print("="*70)
    
    # Create a simple environment
    class SimpleGridWorld:
        """Simple 10x10 grid world for DQN demo"""
        
        def __init__(self, size=10):
            self.size = size
            self.goal = (size-1, size-1)
            self.start = (0, 0)
            self.obstacles = [(2, 2), (3, 5), (6, 7), (8, 3)]
            
            # Actions: 0=up, 1=right, 2=down, 3=left
            self.actions = [0, 1, 2, 3]
            self.action_space = type('ActionSpace', (), {'n': 4})()
            
            self.reset()
        
        def reset(self):
            """Reset environment"""
            self.position = list(self.start)
            return self._get_state()
        
        def step(self, action):
            """Take action"""
            r, c = self.position
            
            if action == 0:  # up
                r = max(0, r - 1)
            elif action == 1:  # right
                c = min(self.size - 1, c + 1)
            elif action == 2:  # down
                r = min(self.size - 1, r + 1)
            elif action == 3:  # left
                c = max(0, c - 1)
            
            # Check obstacle
            if (r, c) in self.obstacles:
                # Stay in place, penalty
                reward = -5
                done = False
            else:
                self.position = [r, c]
                
                # Check goal
                if (r, c) == self.goal:
                    reward = 10
                    done = True
                else:
                    reward = -1  # Step penalty
                    done = False
            
            return self._get_state(), reward, done
        
        def _get_state(self):
            """Get state as one-hot encoded position"""
            state = np.zeros(self.size * self.size)
            r, c = self.position
            idx = r * self.size + c
            state[idx] = 1
            return state
        
        def render(self):
            """Simple text render"""
            grid = [['.' for _ in range(self.size)] for _ in range(self.size)]
            
            # Mark obstacles
            for r, c in self.obstacles:
                grid[r][c] = 'X'
            
            # Mark goal
            grid[self.goal[0]][self.goal[1]] = 'G'
            
            # Mark agent
            r, c = self.position
            grid[r][c] = 'A'
            
            print("\nGrid:")
            for row in grid:
                print(' '.join(row))
            print()
    
    print("\n1. INITIALIZING ENVIRONMENT AND AGENT...")
    env = SimpleGridWorld(size=5)  # Smaller for faster training
    state_dim = env.size * env.size  # 25 one-hot states
    action_dim = env.action_space.n  # 4 actions
    
    print(f"   State dimension: {state_dim} (one-hot position)")
    print(f"   Action dimension: {action_dim}")
    print(f"   Goal: Reach (4,4) from (0,0)")
    print(f"   Obstacles: {env.obstacles}")
    
    # Create DQN agent
    agent = DQNAgent(state_dim, action_dim, config={
        'buffer_size': 10000,
        'batch_size': 32,
        'learning_starts': 1000,
        'epsilon_decay': 0.999,
        'update_target_every': 500
    })
    
    print("\n2. TRAINING AGENT...")
    print("   (This will take a moment...)")
    
    num_episodes = 500
    print_progress_every = 50
    
    for episode in range(num_episodes):
        state = env.reset()
        done = False
        episode_losses = []
        
        while not done:
            # Select action
            action = agent.select_action(state, training=True)
            
            # Take action
            next_state, reward, done = env.step(action)
            
            # Train
            loss = agent.train_step(state, action, reward, next_state, done)
            if loss is not None:
                episode_losses.append(loss)
            
            state = next_state
        
        # Print progress
        if (episode + 1) % print_progress_every == 0:
            avg_reward = np.mean(agent.episode_rewards[-print_progress_every:])
            avg_length = np.mean(agent.episode_lengths[-print_progress_every:])
            avg_loss = np.mean(episode_losses) if episode_losses else 0
            
            print(f"   Episode {episode + 1:4d} | "
                  f"Avg Reward: {avg_reward:6.2f} | "
                  f"Avg Length: {avg_length:5.1f} | "
                  f"Epsilon: {agent.epsilon:.3f} | "
                  f"Loss: {avg_loss:.4f}")
    
    print("\n3. VISUALIZING RESULTS...")
    
    # Create comprehensive visualization
    fig, axes = plt.subplots(2, 3, figsize=(15, 10))
    
    # Plot 1: Learning curve (rewards)
    ax = axes[0, 0]
    window = 20
    if len(agent.episode_rewards) > window:
        smoothed_rewards = np.convolve(agent.episode_rewards, 
                                      np.ones(window)/window, 
                                      mode='valid')
        ax.plot(smoothed_rewards)
        ax.set_xlabel('Episode')
        ax.set_ylabel(f'Average Reward ({window}-episode window)')
        ax.set_title('Learning Curve')
        ax.grid(True, alpha=0.3)
        
        # Mark best performance
        best_idx = np.argmax(smoothed_rewards)
        ax.axvline(x=best_idx, color='green', linestyle='--', alpha=0.5,
                  label=f'Best: {smoothed_rewards[best_idx]:.2f}')
        ax.legend()
    
    # Plot 2: Episode lengths
    ax = axes[0, 1]
    if len(agent.episode_lengths) > window:
        smoothed_lengths = np.convolve(agent.episode_lengths,
                                      np.ones(window)/window,
                                      mode='valid')
        ax.plot(smoothed_lengths)
        ax.set_xlabel('Episode')
        ax.set_ylabel(f'Average Length ({window}-episode window)')
        ax.set_title('Episode Lengths')
        ax.grid(True, alpha=0.3)
        
        # Optimal path length (8 steps minimum)
        ax.axhline(y=8, color='red', linestyle='--', alpha=0.5,
                  label='Optimal (8 steps)')
        ax.legend()
    
    # Plot 3: Loss over time
    ax = axes[0, 2]
    if agent.losses:
        loss_window = max(1, len(agent.losses) // 100)
        if loss_window > 1:
            smoothed_losses = np.convolve(agent.losses,
                                         np.ones(loss_window)/loss_window,
                                         mode='valid')
            ax.plot(smoothed_losses)
            ax.set_xlabel('Training Step')
            ax.set_ylabel(f'Smoothed Loss (window={loss_window})')
            ax.set_title('Training Loss')
            ax.set_yscale('log')
            ax.grid(True, alpha=0.3)
    
    # Plot 4: Epsilon decay
    ax = axes[1, 0]
    # Simulate epsilon decay for visualization
    epsilons = []
    steps = np.arange(0, agent.steps_done, 100)
    for step in steps:
        eps = max(agent.config['epsilon_end'],
                 agent.config['epsilon_start'] * 
                 agent.config['epsilon_decay'] ** (step / 1000))
        epsilons.append(eps)
    
    ax.plot(steps, epsilons)
    ax.set_xlabel('Training Steps')
    ax.set_ylabel('Epsilon')
    ax.set_title('Exploration Rate Decay')
    ax.grid(True, alpha=0.3)
    
    # Plot 5: Final policy visualization
    ax = axes[1, 1]
    
    # Create policy grid
    policy_grid = np.zeros((env.size, env.size))
    value_grid = np.zeros((env.size, env.size))
    
    for r in range(env.size):
        for c in range(env.size):
            # Create state
            state = np.zeros(state_dim)
            idx = r * env.size + c
            state[idx] = 1
            
            # Get action and value
            with torch.no_grad():
                state_tensor = torch.FloatTensor(state).unsqueeze(0).to(agent.device)
                q_values = agent.policy_net(state_tensor)
                action = torch.argmax(q_values).item()
                value = torch.max(q_values).item()
            
            policy_grid[r, c] = action
            value_grid[r, c] = value
    
    # Show policy with arrows
    action_symbols = {0: '↑', 1: '→', 2: '↓', 3: '←'}
    
    im = ax.imshow(value_grid, cmap='viridis', alpha=0.7)
    
    for r in range(env.size):
        for c in range(env.size):
            action = int(policy_grid[r, c])
            symbol = action_symbols.get(action, '?')
            ax.text(c, r, symbol, ha='center', va='center', 
                   fontsize=12, fontweight='bold', color='white')
    
    # Mark obstacles
    for r, c in env.obstacles:
        ax.add_patch(plt.Rectangle((c-0.5, r-0.5), 1, 1,
                                 fill=False, edgecolor='red', linewidth=2))
    
    # Mark start and goal
    ax.text(env.start[1], env.start[0], 'S', ha='center', va='center',
           fontsize=14, fontweight='bold', color='white')
    ax.text(env.goal[1], env.goal[0], 'G', ha='center', va='center',
           fontsize=14, fontweight='bold', color='white')
    
    ax.set_title('Learned Policy and Values')
    ax.set_xlabel('Column')
    ax.set_ylabel('Row')
    plt.colorbar(im, ax=ax, label='State Value')
    
    # Plot 6: Q-value distribution
    ax = axes[1, 2]
    
    # Collect all Q-values
    all_q_values = []
    for r in range(env.size):
        for c in range(env.size):
            state = np.zeros(state_dim)
            idx = r * env.size + c
            state[idx] = 1
            
            with torch.no_grad():
                state_tensor = torch.FloatTensor(state).unsqueeze(0).to(agent.device)
                q_values = agent.policy_net(state_tensor).cpu().numpy().flatten()
                all_q_values.extend(q_values)
    
    ax.hist(all_q_values, bins=30, alpha=0.7, edgecolor='black')
    ax.set_xlabel('Q-value')
    ax.set_ylabel('Frequency')
    ax.set_title('Q-value Distribution')
    ax.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    print("\n4. EVALUATING LEARNED AGENT...")
    
    # Run evaluation
    mean_reward, std_reward, eval_rewards = agent.evaluate(env, num_episodes=20)
    
    print(f"\n   Evaluation over 20 episodes:")
    print(f"   Average reward: {mean_reward:.2f} ± {std_reward:.2f}")
    print(f"   Best episode: {np.max(eval_rewards):.2f}")
    print(f"   Worst episode: {np.min(eval_rewards):.2f}")
    
    # Success rate (positive reward means reached goal)
    success_rate = sum(1 for r in eval_rewards if r > 0) / len(eval_rewards)
    print(f"   Success rate: {success_rate:.1%}")
    
    print("\n5. DEMONSTRATING LEARNED BEHAVIOR...")
    
    print("\n   Running demonstration episode:")
    state = env.reset()
    done = False
    total_reward = 0
    steps = 0
    
    positions = []
    
    while not done and steps < 50:
        action = agent.select_action(state, training=False)
        state, reward, done = env.step(action)
        
        # Decode position from one-hot
        pos = np.argmax(state)
        r = pos // env.size
        c = pos % env.size
        positions.append((r, c))
        
        total_reward += reward
        steps += 1
    
    print(f"   Total reward: {total_reward}")
    print(f"   Steps taken: {steps}")
    print(f"   Path: {positions[:10]}...")  # Show first 10 positions
    
    # Visualize path
    plt.figure(figsize=(8, 8))
    
    # Create grid
    for r in range(env.size):
        for c in range(env.size):
            if (r, c) == env.start:
                color = 'lightgreen'
            elif (r, c) == env.goal:
                color = 'lightcoral'
            elif (r, c) in env.obstacles:
                color = 'gray'
            else:
                color = 'white'
            
            plt.gca().add_patch(plt.Rectangle((c-0.5, r-0.5), 1, 1,
                                           facecolor=color, edgecolor='black'))
    
    # Plot path
    if positions:
        positions = np.array(positions)
        plt.plot(positions[:, 1], positions[:, 0], 'b-', linewidth=2, alpha=0.7)
        plt.scatter(positions[:, 1], positions[:, 0], c=range(len(positions)),
                   cmap='viridis', s=50, zorder=5)
        
        # Mark start and end
        plt.scatter([env.start[1]], [env.start[0]], color='green', 
                   s=200, marker='o', label='Start', zorder=10)
        plt.scatter([env.goal[1]], [env.goal[0]], color='red',
                   s=200, marker='*', label='Goal', zorder=10)
    
    plt.xlim(-0.5, env.size - 0.5)
    plt.ylim(-0.5, env.size - 0.5)
    plt.gca().invert_yaxis()  # So (0,0) is top-left
    plt.xlabel('Column')
    plt.ylabel('Row')
    plt.title('Learned Path Demonstration')
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.tight_layout()
    plt.show()
    
    print("\n6. KEY INSIGHTS FROM DQN TRAINING:")
    
    print("\n   What worked well:")
    print("   1. Experience replay stabilized learning")
    print("   2. Target network prevented divergence")
    print("   3. Epsilon decay balanced exploration/exploitation")
    
    print("\n   Challenges observed:")
    print("   1. Needed sufficient exploration early on")
    print("   2. Learning rate crucial for convergence")
    print("   3. Buffer size affects sample diversity")
    
    print("\n   Improvements to try:")
    print("   1. Double DQN: Reduce overestimation bias")
    print("   2. Dueling DQN: Separate value and advantage")
    print("   3. Prioritized replay: Learn from important experiences")
    
    return agent, env

# Run complete DQN training demo
dqn_agent, demo_env = dqn_training_demo()

8.6 Advanced DQN Variants

class AdvancedDQNVariants:
    """Implement advanced DQN variants"""
    
    @staticmethod
    def double_dqn_loss(policy_net, target_net, batch, gamma=0.99):
        """
        Double DQN loss calculation
        
        Reduces overestimation bias by using policy network
        to select actions, target network to evaluate them
        """
        states, actions, rewards, next_states, dones = batch
        
        # Current Q-values
        current_q = policy_net(states).gather(1, actions)
        
        # Double DQN: use policy net to select actions
        with torch.no_grad():
            # Select actions using policy network
            next_actions = policy_net(next_states).argmax(1, keepdim=True)
            
            # Evaluate using target network
            next_q = target_net(next_states).gather(1, next_actions)
            
            # Compute target
            target = rewards + gamma * next_q * (1 - dones)
        
        # Compute loss
        loss = F.smooth_l1_loss(current_q, target)
        
        return loss
    
    @staticmethod
    def dueling_dqn_network(input_dim, hidden_dim, n_actions):
        """
        Dueling DQN network architecture
        
        Separates value and advantage streams:
        Q(s,a) = V(s) + A(s,a) - mean(A(s,:))
        """
        class DuelingDQN(nn.Module):
            def __init__(self, input_dim, hidden_dim, n_actions):
                super(DuelingDQN, self).__init__()
                
                # Shared feature layer
                self.feature = nn.Sequential(
                    nn.Linear(input_dim, hidden_dim),
                    nn.ReLU(),
                    nn.Linear(hidden_dim, hidden_dim),
                    nn.ReLU()
                )
                
                # Value stream
                self.value_stream = nn.Sequential(
                    nn.Linear(hidden_dim, hidden_dim),
                    nn.ReLU(),
                    nn.Linear(hidden_dim, 1)
                )
                
                # Advantage stream
                self.advantage_stream = nn.Sequential(
                    nn.Linear(hidden_dim, hidden_dim),
                    nn.ReLU(),
                    nn.Linear(hidden_dim, n_actions)
                )
            
            def forward(self, x):
                features = self.feature(x)
                
                value = self.value_stream(features)
                advantage = self.advantage_stream(features)
                
                # Combine: Q(s,a) = V(s) + A(s,a) - mean(A(s,:))
                q_values = value + advantage - advantage.mean(1, keepdim=True)
                
                return q_values
        
        return DuelingDQN(input_dim, hidden_dim, n_actions)
    
    @staticmethod
    def noisy_linear(in_features, out_features, sigma_init=0.5):
        """
        Noisy linear layer for exploration
        
        Uses parameterized noise for exploration
        instead of epsilon-greedy
        """
        class NoisyLinear(nn.Module):
            def __init__(self, in_features, out_features, sigma_init):
                super(NoisyLinear, self).__init__()
                
                self.in_features = in_features
                self.out_features = out_features
                
                # Learnable parameters
                self.mu_weight = nn.Parameter(torch.Tensor(out_features, in_features))
                self.sigma_weight = nn.Parameter(torch.Tensor(out_features, in_features))
                self.mu_bias = nn.Parameter(torch.Tensor(out_features))
                self.sigma_bias = nn.Parameter(torch.Tensor(out_features))
                
                # Register noise buffers
                self.register_buffer('epsilon_weight', torch.Tensor(out_features, in_features))
                self.register_buffer('epsilon_bias', torch.Tensor(out_features))
                
                # Initialize
                self.reset_parameters(sigma_init)
                self.reset_noise()
            
            def reset_parameters(self, sigma_init):
                # Initialize mu weights (like normal linear layer)
                bound = 1 / np.sqrt(self.in_features)
                self.mu_weight.data.uniform_(-bound, bound)
                self.mu_bias.data.uniform_(-bound, bound)
                
                # Initialize sigma weights
                self.sigma_weight.data.fill_(sigma_init / np.sqrt(self.in_features))
                self.sigma_bias.data.fill_(sigma_init / np.sqrt(self.out_features))
            
            def reset_noise(self):
                # Sample new noise
                epsilon_in = self._scale_noise(self.in_features)
                epsilon_out = self._scale_noise(self.out_features)
                
                self.epsilon_weight.copy_(epsilon_out.ger(epsilon_in))
                self.epsilon_bias.copy_(epsilon_out)
            
            def _scale_noise(self, size):
                x = torch.randn(size)
                return x.sign().mul(x.abs().sqrt())
            
            def forward(self, x):
                # Noisy weights
                weight = self.mu_weight + self.sigma_weight * self.epsilon_weight
                bias = self.mu_bias + self.sigma_bias * self.epsilon_bias
                
                return F.linear(x, weight, bias)
        
        return NoisyLinear(in_features, out_features, sigma_init)
    
    @staticmethod
    def multi_step_dqn_loss(policy_net, target_net, batch, gamma=0.99, n_steps=3):
        """
        Multi-step DQN loss
        
        Uses n-step returns instead of 1-step returns
        """
        states, actions, rewards, next_states, dones = batch
        
        # Current Q-values
        current_q = policy_net(states).gather(1, actions)
        
        # Compute n-step returns
        with torch.no_grad():
            # For simplicity, assume batch contains n-step transitions
            # In practice, you'd need to store n-step transitions in buffer
            n_step_returns = torch.zeros_like(rewards)
            
            # Bootstrap with target network
            final_next_q = target_net(next_states).max(1, keepdim=True)[0]
            
            # Compute n-step return
            cumulative_gamma = gamma ** n_steps
            n_step_returns = rewards.clone()
            
            for i in range(1, n_steps):
                n_step_returns += (gamma ** i) * rewards.roll(-i, dims=0)
                # Handle episode boundaries
                mask = (dones.roll(-i, dims=0) == 0).float()
                n_step_returns *= mask
            
            n_step_returns += cumulative_gamma * final_next_q * (1 - dones)
        
        # Compute loss
        loss = F.smooth_l1_loss(current_q, n_step_returns)
        
        return loss

def advanced_dqn_comparison():
    """Compare advanced DQN variants"""
    
    print("\n" + "="*70)
    print("ADVANCED DQN VARIANTS COMPARISON")
    print("="*70)
    
    print("\n1. DOUBLE DQN:")
    print("   Problem: Q-learning overestimates Q-values")
    print("   Solution: Use different networks for action selection and evaluation")
    print("   Formula: target = r + γ Q_target(s', argmax_a Q(s',a))")
    
    print("\n2. DUELING DQN:")
    print("   Insight: Value of state and advantage of action can be separated")
    print("   Architecture: Two streams → V(s) and A(s,a)")
    print("   Formula: Q(s,a) = V(s) + A(s,a) - mean_a A(s,a)")
    
    print("\n3. PRIORITIZED EXPERIENCE REPLAY:")
    print("   Insight: Not all experiences are equally important")
    print("   Solution: Sample transitions with probability ∝ TD error")
    print("   Benefit: Faster learning, focus on surprising experiences")
    
    print("\n4. MULTI-STEP LEARNING:")
    print("   Problem: 1-step TD has high bias")
    print("   Solution: Use n-step returns")
    print("   Tradeoff: Between TD (bias) and MC (variance)")
    
    print("\n5. NOISY NETS:")
    print("   Problem: Epsilon-greedy explores randomly")
    print("   Solution: Add parameterized noise to weights")
    print("   Benefit: State-dependent exploration")
    
    print("\n6. DISTRIBUTIONAL DQN:")
    print("   Insight: Learn distribution of returns, not just expectation")
    print("   Architecture: Output probability distribution over returns")
    print("   Benefit: Better risk assessment, richer learning signal")
    
    print("\n7. RAINBOW DQN:")
    print("   Combines all 7 improvements:")
    print("   1. Double DQN")
    print("   2. Dueling architecture")
    print("   3. Prioritized replay")
    print("   4. Multi-step learning")
    print("   5. Noisy nets")
    print("   6. Distributional RL")
    print("   7. N-step returns")
    
    # Create a simple comparison
    class ComparisonEnv:
        def __init__(self):
            self.n_states = 20
            self.actions = [0, 1]
            self.action_space = type('ActionSpace', (), {'n': 2})()
            
        def reset(self):
            return np.random.randint(self.n_states)
        
        def step(self, action):
            state = np.random.randint(self.n_states)
            reward = np.sin(state/self.n_states * 2*np.pi) + np.random.normal(0, 0.1)
            done = np.random.random() < 0.1
            return state, reward, done
    
    env = ComparisonEnv()
    state_dim = 20  # One-hot encoding
    action_dim = 2
    
    # Test different variants
    variants = {
        'Vanilla DQN': SimpleDQN(state_dim, 64, action_dim),
        'Double DQN': SimpleDQN(state_dim, 64, action_dim),
        'Dueling DQN': AdvancedDQNVariants.dueling_dqn_network(state_dim, 64, action_dim)
    }
    
    # Simple training comparison
    num_episodes = 200
    results = {name: [] for name in variants}
    
    for name, network in variants.items():
        print(f"\nTraining {name}...")
        
        optimizer = optim.Adam(network.parameters(), lr=0.001)
        buffer = ReplayBuffer(1000, device='cpu')
        
        episode_rewards = []
        
        for episode in range(num_episodes):
            state = env.reset()
            state_onehot = np.zeros(state_dim)
            state_onehot[state] = 1
            
            done = False
            episode_reward = 0
            
            while not done:
                # Select action
                state_tensor = torch.FloatTensor(state_onehot).unsqueeze(0)
                q_values = network(state_tensor)
                action = torch.argmax(q_values).item() if np.random.random() > 0.1 else np.random.randint(2)
                
                # Take step
                next_state, reward, done = env.step(action)
                next_state_onehot = np.zeros(state_dim)
                next_state_onehot[next_state] = 1
                
                # Store
                buffer.push(torch.FloatTensor(state_onehot).unsqueeze(0),
                          action, reward, 
                          torch.FloatTensor(next_state_onehot).unsqueeze(0),
                          done)
                
                # Learn if buffer has enough samples
                if len(buffer) >= 32:
                    batch = buffer.sample(32)
                    states, actions, rewards, next_states, dones = batch
                    
                    # Compute loss based on variant
                    if name == 'Vanilla DQN' or name == 'Dueling DQN':
                        current_q = network(states).gather(1, actions)
                        with torch.no_grad():
                            next_q = network(next_states).max(1)[0].unsqueeze(1)
                            target = rewards + 0.99 * next_q * (1 - dones)
                    
                    elif name == 'Double DQN':
                        # For Double DQN, we'd use a target network in practice
                        # This is a simplified version
                        current_q = network(states).gather(1, actions)
                        with torch.no_grad():
                            next_actions = network(next_states).argmax(1, keepdim=True)
                            next_q = network(next_states).gather(1, next_actions)
                            target = rewards + 0.99 * next_q * (1 - dones)
                    
                    loss = F.mse_loss(current_q, target)
                    
                    # Optimize
                    optimizer.zero_grad()
                    loss.backward()
                    optimizer.step()
                
                episode_reward += reward
                state_onehot = next_state_onehot
            
            episode_rewards.append(episode_reward)
        
        results[name] = episode_rewards
    
    # Plot comparison
    plt.figure(figsize=(12, 8))
    
    # Plot 1: Learning curves
    plt.subplot(2, 2, 1)
    window = 10
    for name, rewards in results.items():
        if len(rewards) > window:
            smoothed = np.convolve(rewards, np.ones(window)/window, mode='valid')
            plt.plot(smoothed, label=name, linewidth=2)
    
    plt.xlabel('Episode')
    plt.ylabel(f'Average Reward ({window}-episode window)')
    plt.title('Learning Curve Comparison')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # Plot 2: Final performance
    plt.subplot(2, 2, 2)
    final_performance = {name: np.mean(rewards[-20:]) for name, rewards in results.items()}
    
    names = list(final_performance.keys())
    values = list(final_performance.values())
    
    bars = plt.bar(names, values, alpha=0.7)
    plt.ylabel('Average Reward (last 20 episodes)')
    plt.title('Final Performance')
    plt.xticks(rotation=15)
    
    # Add value labels
    for bar, value in zip(bars, values):
        plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.01,
                f'{value:.3f}', ha='center', va='bottom')
    
    # Plot 3: Architecture visualization
    plt.subplot(2, 2, 3)
    
    # Draw simple architecture diagrams
    architectures = {
        'Vanilla DQN': 'State → [FC] → [FC] → Q-values',
        'Double DQN': 'Two networks\nSelect: Policy net\nEvaluate: Target net',
        'Dueling DQN': 'State → Shared → [Value] + [Advantage] → Q-values'
    }
    
    y_pos = 0.8
    for name, desc in architectures.items():
        plt.text(0.1, y_pos, f'{name}:', fontweight='bold', fontsize=10)
        plt.text(0.2, y_pos - 0.1, desc, fontsize=9)
        y_pos -= 0.25
    
    plt.xlim(0, 1)
    plt.ylim(0, 1)
    plt.axis('off')
    plt.title('Architecture Comparison')
    
    # Plot 4: Relative improvement
    plt.subplot(2, 2, 4)
    baseline = final_performance['Vanilla DQN']
    improvements = {}
    
    for name, value in final_performance.items():
        if name != 'Vanilla DQN':
            improvement = (value - baseline) / abs(baseline) * 100
            improvements[name] = improvement
    
    if improvements:
        names = list(improvements.keys())
        values = list(improvements.values())
        colors = ['green' if v > 0 else 'red' for v in values]
        
        bars = plt.bar(names, values, color=colors, alpha=0.7)
        plt.ylabel('Improvement over Vanilla DQN (%)')
        plt.title('Relative Performance Gain')
        plt.axhline(y=0, color='black', linestyle='-', linewidth=0.5)
        
        # Add value labels
        for bar, value in zip(bars, values):
            plt.text(bar.get_x() + bar.get_width()/2, 
                    bar.get_height() + (1 if value > 0 else -3),
                    f'{value:+.1f}%', ha='center', va='bottom' if value > 0 else 'top')
    
    plt.tight_layout()
    plt.show()
    
    print("\nKEY TAKEAWAYS:")
    print("\n1. Vanilla DQN:")
    print("   - Foundation, but has limitations")
    print("   - Suffers from overestimation bias")
    
    print("\n2. Double DQN:")
    print("   - Simple but effective improvement")
    print("   - Reduces overestimation significantly")
    
    print("\n3. Dueling DQN:")
    print("   - Better value function learning")
    print("   - Especially helpful when many actions have similar values")
    
    print("\n4. Rainbow DQN:")
    print("   - State-of-the-art for discrete actions")
    print("   - Combines all improvements synergistically")
    
    print("\nRECOMMENDATIONS:")
    print("1. Start with Double DQN (easiest improvement)")
    print("2. Add Dueling architecture for complex environments")
    print("3. Use Prioritized Replay for sample efficiency")
    print("4. Try Rainbow for maximum performance")
    
    return results

# Run advanced DQN comparison
advanced_results = advanced_dqn_comparison()

Chapter 8 Key Takeaways:

  1. The DQN Breakthrough (2013-2015):

    • First to learn directly from pixels
    • Human-level performance on Atari games
    • Key innovations: Experience replay + Target networks
  2. Neural Networks as Function Approximators:

    • Can handle high-dimensional inputs
    • Learn features automatically (no manual engineering)
    • Universal function approximators
  3. Experience Replay:

    • Stores (s, a, r, s', done) in circular buffer
    • Samples random mini-batches
    • Breaks temporal correlations
    • Increases data efficiency
  4. Target Networks:

    • Separate network for computing targets
    • Updated slowly (hard or soft updates)
    • Stabilizes learning by fixing targets temporarily
  5. Complete DQN Algorithm:

    Initialize replay buffer D, Q-network θ, target network θ'
    for episode = 1 to M:
        Initialize state s
        for t = 1 to T:
            With probability ε select random action a
            Otherwise a = argmax_a Q(s,a; θ)
            Execute a, observe r, s'
            Store (s,a,r,s',done) in D
            Sample random batch from D
            Compute targets using θ'
            Update θ with gradient descent
            Every C steps: θ' ← θ
    
  6. Advanced Variants:

    • Double DQN: Reduces overestimation bias
    • Dueling DQN: Separates value and advantage
    • Prioritized Replay: Samples important transitions
    • Multi-step Learning: Uses n-step returns
    • Noisy Nets: Parameterized noise for exploration
    • Distributional DQN: Learns value distributions
    • Rainbow DQN: Combines all improvements
  7. Practical Implementation Tips:

    • Start with simple environments
    • Monitor loss and reward curves
    • Tune learning rate and exploration schedule
    • Use gradient clipping for stability
    • Visualize learned policies and value functions

CHAPTER 9: Policy Gradients: Direct Action Optimization

9.1 A Different Philosophy: Direct Policy Learning

Analogy: The Sculptor vs The Architect

  • Value-based methods (DQN): Like an architect who calculates the value of each brick, then decides where to place them
  • Policy gradients: Like a sculptor who directly shapes the clay, adjusting based on the final sculpture's beauty
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
import numpy as np
import matplotlib.pyplot as plt
from collections import deque, namedtuple
import random
import gym

class PolicyGradientIntroduction:
    """Introduction to Policy Gradient Methods"""
    
    def __init__(self):
        self.explain_fundamentals()
    
    def explain_fundamentals(self):
        """Explain the fundamental concepts"""
        print("="*70)
        print("POLICY GRADIENTS: DIRECT ACTION OPTIMIZATION")
        print("="*70)
        
        print("\nTHE PHILOSOPHICAL DIFFERENCE:")
        print("\nValue-Based Methods (DQN, Q-learning):")
        print("1. Learn value function Q(s,a)")
        print("2. Policy is implicit: π(s) = argmax_a Q(s,a)")
        print("3. Works well for discrete actions")
        
        print("\nPolicy-Based Methods (Policy Gradients):")
        print("1. Directly learn policy π_θ(a|s)")
        print("2. Outputs action probabilities")
        print("3. Can handle continuous actions naturally")
        
        print("\n" + "-"*70)
        print("WHEN TO USE POLICY GRADIENTS:")
        print("-"*70)
        
        scenarios = [
            ("Continuous action spaces", "Robotics, physics control"),
            ("Stochastic policies needed", "Game theory, adversarial settings"),
            ("Natural exploration", "Learns distribution, not just greedy"),
            ("Simplicity", "Direct optimization of what we care about")
        ]
        
        for scenario, example in scenarios:
            print(f"✓ {scenario:30s}: {example}")
        
        print("\n" + "-"*70)
        print("THE CORE IDEA:")
        print("-"*70)
        
        print("\nInstead of: Learn values → Derive policy")
        print("We do:      Directly optimize policy parameters θ")
        print("\nObjective: Maximize expected return J(θ) = E[Σγ^t r_t]")
        print("Update:    θ ← θ + α ∇_θ J(θ)")
        
        # Mathematical formulation
        print("\n" + "-"*70)
        print("MATHEMATICAL FORMULATION:")
        print("-"*70)
        
        equations = [
            ("Policy", "π_θ(a|s) = probability of action a in state s"),
            ("Objective", "J(θ) = E_{τ∼π_θ}[Σ_{t=0}^T γ^t r_t]"),
            ("Gradient", "∇_θ J(θ) = E_{τ∼π_θ}[Σ_t ∇_θ log π_θ(a_t|s_t) G_t]"),
            ("Update", "θ ← θ + α ∇_θ J(θ)")
        ]
        
        for name, eq in equations:
            print(f"\n{name:12s}: {eq}")
    
    def visualize_policy_vs_value(self):
        """Visualize difference between policy and value functions"""
        
        # Create a simple 1D environment
        states = np.linspace(-2, 2, 100)
        
        # Value function (hypothetical)
        values = -states**2 + 2
        
        # Policy (hypothetical)
        # Mean action increases with state
        policy_means = np.tanh(states)
        policy_stds = 0.3 * np.ones_like(states)
        
        # Plot comparison
        fig, axes = plt.subplots(1, 2, figsize=(12, 5))
        
        # Value function plot
        ax = axes[0]
        ax.plot(states, values, 'b-', linewidth=3, label='V(s)')
        ax.set_xlabel('State s')
        ax.set_ylabel('Value V(s)')
        ax.set_title('Value-Based Approach')
        ax.grid(True, alpha=0.3)
        ax.legend()
        
        # Add greedy policy arrows
        for s, v in zip(states[::20], values[::20]):
            # Greedy action: move toward higher value
            ax.arrow(s, v-0.1, 0.1 if s < 0 else -0.1, 0, 
                    head_width=0.05, head_length=0.1, fc='red', ec='red', alpha=0.5)
        
        # Policy plot
        ax = axes[1]
        
        # Show policy as distribution
        for s, mean, std in zip(states[::10], policy_means[::10], policy_stds[::10]):
            # Plot Gaussian distribution
            actions = np.linspace(-2, 2, 50)
            pdf = np.exp(-0.5 * ((actions - mean) / std) ** 2) / (std * np.sqrt(2 * np.pi))
            ax.plot(actions, pdf * 0.5 + s/5, 'b-', alpha=0.5)
        
        ax.set_xlabel('Action a')
        ax.set_ylabel('State s (offset)')
        ax.set_title('Policy-Based Approach: π(a|s)')
        ax.grid(True, alpha=0.3)
        
        # Add mean action line
        ax.plot(policy_means, states/5, 'r--', linewidth=2, label='Mean action')
        ax.legend()
        
        plt.tight_layout()
        plt.show()
        
        print("\nVISUAL INTERPRETATION:")
        print("Left: Value function - learn value, derive greedy policy")
        print("Right: Policy - directly learn action probabilities")

# Initialize introduction
pg_intro = PolicyGradientIntroduction()
pg_intro.visualize_policy_vs_value()

9.2 The Policy Gradient Theorem

class PolicyGradientTheorem:
    """Understanding and implementing the Policy Gradient Theorem"""
    
    def __init__(self):
        self.explain_theorem()
    
    def explain_theorem(self):
        """Explain the Policy Gradient Theorem"""
        print("="*70)
        print("THE POLICY GRADIENT THEOREM")
        print("="*70)
        
        print("\nTHE PROBLEM:")
        print("How to compute ∇_θ J(θ) when J(θ) depends on:")
        print("1. Action selection (depends on θ)")
        print("2. State distribution (depends on θ)")
        print("3. Rewards (depend on environment)")
        
        print("\n" + "-"*70)
        print("THE BREAKTHROUGH:")
        print("-"*70)
        
        print("\nPolicy Gradient Theorem (Sutton et al., 2000):")
        print("∇_θ J(θ) = E_{τ∼π_θ}[Σ_t ∇_θ log π_θ(a_t|s_t) G_t]")
        
        print("\nWhere:")
        print("• τ = (s_0, a_0, r_0, s_1, a_1, r_1, ...) is a trajectory")
        print("• G_t = Σ_{k=t}^T γ^{k-t} r_k is the return from time t")
        print("• π_θ(a_t|s_t) is the policy probability")
        print("• ∇_θ log π_θ(a_t|s_t) is the score function")
        
        print("\n" + "-"*70)
        print("WHY IT'S AMAZING:")
        print("-"*70)
        
        amazing_facts = [
            ("No need for model", "Works in unknown environments"),
            ("Handles continuous actions", "Natural for robotics"),
            ("Stochastic policies", "Exploration built-in"),
            ("Simple implementation", "Just need log probabilities")
        ]
        
        for fact, reason in amazing_facts:
            print(f"✓ {fact:25s}: {reason}")
    
    def derive_theorem(self):
        """Step-by-step derivation of the theorem"""
        print("\n" + "="*70)
        print("STEP-BY-STEP DERIVATION")
        print("="*70)
        
        steps = [
            ("1. Objective", "J(θ) = Σ_τ P(τ|θ) R(τ)"),
            ("2. Gradient", "∇_θ J(θ) = Σ_τ ∇_θ P(τ|θ) R(τ)"),
            ("3. Log trick", "∇_θ P(τ|θ) = P(τ|θ) ∇_θ log P(τ|θ)"),
            ("4. Trajectory prob", "P(τ|θ) = Π_t π_θ(a_t|s_t) P(s_{t+1}|s_t,a_t)"),
            ("5. Log trajectory", "log P(τ|θ) = Σ_t [log π_θ(a_t|s_t) + log P(s_{t+1}|s_t,a_t)]"),
            ("6. Gradient", "∇_θ log P(τ|θ) = Σ_t ∇_θ log π_θ(a_t|s_t)"),
            ("7. Final form", "∇_θ J(θ) = E_τ[Σ_t ∇_θ log π_θ(a_t|s_t) R(τ)]"),
            ("8. Causality", "∇_θ J(θ) = E_τ[Σ_t ∇_θ log π_θ(a_t|s_t) G_t]")
        ]
        
        for step, equation in steps:
            print(f"\n{step:20s}: {equation}")
        
        print("\nKEY INSIGHTS:")
        print("1. Environment dynamics P(s'|s,a) don't depend on θ")
        print("2. Only need log probabilities of actions")
        print("3. Future rewards don't affect past action gradients")
    
    def simple_implementation(self):
        """Simple implementation of policy gradient"""
        print("\n" + "="*70)
        print("SIMPLE IMPLEMENTATION")
        print("="*70)
        
        class SimplePolicyNetwork(nn.Module):
            """Simple policy network for discrete actions"""
            def __init__(self, input_dim, hidden_dim, output_dim):
                super().__init__()
                self.net = nn.Sequential(
                    nn.Linear(input_dim, hidden_dim),
                    nn.ReLU(),
                    nn.Linear(hidden_dim, output_dim)
                )
            
            def forward(self, x):
                return self.net(x)
            
            def get_action(self, state):
                """Sample action from policy"""
                logits = self.forward(state)
                probs = F.softmax(logits, dim=-1)
                dist = torch.distributions.Categorical(probs)
                action = dist.sample()
                log_prob = dist.log_prob(action)
                return action.item(), log_prob
        
        # Create environment
        class SimpleEnv:
            def __init__(self):
                self.state = 0
                self.states = [0, 1, 2]
                self.actions = [0, 1]
                self.transitions = {
                    0: {0: (1, 1), 1: (2, -1)},  # State 0
                    1: {0: (2, 2), 1: (0, 0)},   # State 1
                    2: {0: (0, -1), 1: (1, 1)}   # State 2
                }
            
            def reset(self):
                self.state = 0
                return torch.tensor([self.state], dtype=torch.float32)
            
            def step(self, action):
                next_state, reward = self.transitions[self.state][action]
                self.state = next_state
                done = (self.state == 2)  # Terminal state
                return (torch.tensor([self.state], dtype=torch.float32), 
                        reward, done)
        
        # Initialize
        env = SimpleEnv()
        policy = SimplePolicyNetwork(1, 32, 2)
        optimizer = optim.Adam(policy.parameters(), lr=0.01)
        
        print("\nTraining loop pseudocode:")
        print("""
        for episode in range(num_episodes):
            state = env.reset()
            rewards = []
            log_probs = []
            
            while not done:
                action, log_prob = policy.get_action(state)
                next_state, reward, done = env.step(action)
                
                rewards.append(reward)
                log_probs.append(log_prob)
                state = next_state
            
            # Compute returns
            returns = compute_returns(rewards, gamma)
            
            # Compute loss
            policy_loss = []
            for log_prob, G in zip(log_probs, returns):
                policy_loss.append(-log_prob * G)  # Negative for gradient ascent
            
            loss = torch.stack(policy_loss).sum()
            
            # Optimize
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
        """)
        
        return policy, env

# Run theorem explanation
pg_theorem = PolicyGradientTheorem()
pg_theorem.derive_theorem()
simple_policy, simple_env = pg_theorem.simple_implementation()

9.3 REINFORCE: The Simplest Policy Gradient Algorithm

class REINFORCEAgent:
    """REINFORCE: Monte Carlo Policy Gradient"""
    
    def __init__(self, state_dim, action_dim, hidden_dim=128, gamma=0.99, lr=0.001):
        """
        Initialize REINFORCE agent
        
        Args:
            state_dim: Dimension of state space
            action_dim: Number of possible actions
            hidden_dim: Size of hidden layers
            gamma: Discount factor
            lr: Learning rate
        """
        self.state_dim = state_dim
        self.action_dim = action_dim
        self.gamma = gamma
        
        # Policy network
        self.policy_net = self._build_policy_network(state_dim, hidden_dim, action_dim)
        self.optimizer = optim.Adam(self.policy_net.parameters(), lr=lr)
        
        # Training history
        self.episode_rewards = []
        self.episode_lengths = []
        self.losses = []
    
    def _build_policy_network(self, state_dim, hidden_dim, action_dim):
        """Build policy network for discrete actions"""
        class PolicyNetwork(nn.Module):
            def __init__(self, state_dim, hidden_dim, action_dim):
                super().__init__()
                self.net = nn.Sequential(
                    nn.Linear(state_dim, hidden_dim),
                    nn.ReLU(),
                    nn.Linear(hidden_dim, hidden_dim),
                    nn.ReLU(),
                    nn.Linear(hidden_dim, action_dim)
                )
            
            def forward(self, x):
                return self.net(x)
            
            def get_action(self, state):
                """Sample action and get log probability"""
                logits = self.forward(state)
                probs = F.softmax(logits, dim=-1)
                dist = torch.distributions.Categorical(probs)
                action = dist.sample()
                log_prob = dist.log_prob(action)
                return action.item(), log_prob, probs.detach()
        
        return PolicyNetwork(state_dim, hidden_dim, action_dim)
    
    def select_action(self, state, training=True):
        """
        Select action using current policy
        
        Args:
            state: Current state
            training: Whether to use stochastic policy
        
        Returns:
            action: Selected action
            log_prob: Log probability of action (if training)
            probs: Action probabilities (for analysis)
        """
        state_tensor = torch.FloatTensor(state).unsqueeze(0)
        
        if training:
            action, log_prob, probs = self.policy_net.get_action(state_tensor)
            return action, log_prob, probs
        else:
            with torch.no_grad():
                logits = self.policy_net(state_tensor)
                probs = F.softmax(logits, dim=-1)
                action = torch.argmax(probs).item()
            return action, None, probs
    
    def compute_returns(self, rewards):
        """
        Compute discounted returns from rewards
        
        Args:
            rewards: List of rewards from episode
        
        Returns:
            returns: Discounted returns for each time step
        """
        returns = []
        G = 0
        for r in reversed(rewards):
            G = r + self.gamma * G
            returns.insert(0, G)
        return returns
    
    def update_policy(self, log_probs, returns):
        """
        Update policy using REINFORCE
        
        Args:
            log_probs: List of log probabilities from episode
            returns: List of returns from episode
        
        Returns:
            loss: Computed loss value
        """
        # Convert to tensors
        log_probs = torch.stack(log_probs)
        returns = torch.FloatTensor(returns)
        
        # Normalize returns (reduces variance)
        returns = (returns - returns.mean()) / (returns.std() + 1e-8)
        
        # Compute loss: -Σ log_prob * G
        policy_loss = []
        for log_prob, G in zip(log_probs, returns):
            policy_loss.append(-log_prob * G)
        
        loss = torch.stack(policy_loss).sum()
        
        # Optimize
        self.optimizer.zero_grad()
        loss.backward()
        self.optimizer.step()
        
        return loss.item()
    
    def train_episode(self, env, max_steps=1000, render=False):
        """
        Train for one episode
        
        Returns:
            episode_reward: Total reward for episode
            episode_length: Length of episode
            loss: Loss value if training happened
        """
        state = env.reset()
        done = False
        episode_reward = 0
        episode_length = 0
        
        # Store episode data
        rewards = []
        log_probs = []
        
        while not done and episode_length < max_steps:
            if render:
                env.render()
            
            # Select action
            action, log_prob, _ = self.select_action(state, training=True)
            
            # Take action
            next_state, reward, done = env.step(action)
            
            # Store data
            rewards.append(reward)
            log_probs.append(log_prob)
            
            # Update
            episode_reward += reward
            episode_length += 1
            state = next_state
        
        # Compute returns and update policy
        returns = self.compute_returns(rewards)
        loss = self.update_policy(log_probs, returns)
        
        # Store history
        self.episode_rewards.append(episode_reward)
        self.episode_lengths.append(episode_length)
        self.losses.append(loss)
        
        return episode_reward, episode_length, loss
    
    def evaluate(self, env, num_episodes=10, render=False):
        """
        Evaluate agent performance
        
        Returns:
            mean_reward: Average reward
            std_reward: Standard deviation
            all_rewards: List of all rewards
        """
        rewards = []
        
        for episode in range(num_episodes):
            state = env.reset()
            done = False
            episode_reward = 0
            
            if render:
                print(f"\nEvaluation Episode {episode + 1}:")
                env.render()
            
            while not done:
                action, _, probs = self.select_action(state, training=False)
                state, reward, done = env.step(action)
                episode_reward += reward
                
                if render:
                    env.render()
                    print(f"Action: {action}, Reward: {reward}, Total: {episode_reward}")
            
            rewards.append(episode_reward)
        
        mean_reward = np.mean(rewards)
        std_reward = np.std(rewards)
        
        return mean_reward, std_reward, rewards
    
    def visualize_training(self):
        """Visualize training progress"""
        if not self.episode_rewards:
            print("No training data to visualize")
            return
        
        fig, axes = plt.subplots(2, 3, figsize=(15, 10))
        
        # Plot 1: Learning curve
        ax = axes[0, 0]
        window = min(50, len(self.episode_rewards) // 10)
        if window > 1:
            smoothed_rewards = np.convolve(self.episode_rewards, 
                                          np.ones(window)/window, 
                                          mode='valid')
            ax.plot(smoothed_rewards)
            ax.set_xlabel('Episode')
            ax.set_ylabel(f'Average Reward ({window}-episode window)')
            ax.set_title('Learning Curve')
            ax.grid(True, alpha=0.3)
            
            # Mark best performance
            best_idx = np.argmax(smoothed_rewards)
            ax.axvline(x=best_idx, color='green', linestyle='--', alpha=0.5,
                      label=f'Best: {smoothed_rewards[best_idx]:.2f}')
            ax.legend()
        
        # Plot 2: Episode lengths
        ax = axes[0, 1]
        if window > 1:
            smoothed_lengths = np.convolve(self.episode_lengths,
                                          np.ones(window)/window,
                                          mode='valid')
            ax.plot(smoothed_lengths)
            ax.set_xlabel('Episode')
            ax.set_ylabel(f'Average Length ({window}-episode window)')
            ax.set_title('Episode Lengths')
            ax.grid(True, alpha=0.3)
        
        # Plot 3: Loss over time
        ax = axes[0, 2]
        if self.losses:
            loss_window = max(1, len(self.losses) // 50)
            if loss_window > 1:
                smoothed_losses = np.convolve(self.losses,
                                             np.ones(loss_window)/loss_window,
                                             mode='valid')
                ax.plot(smoothed_losses)
                ax.set_xlabel('Episode')
                ax.set_ylabel(f'Smoothed Loss (window={loss_window})')
                ax.set_title('Training Loss')
                ax.set_yscale('log')
                ax.grid(True, alpha=0.3)
        
        # Plot 4: Reward distribution
        ax = axes[1, 0]
        if len(self.episode_rewards) > 10:
            ax.hist(self.episode_rewards[-100:], bins=20, alpha=0.7, edgecolor='black')
            ax.set_xlabel('Episode Reward')
            ax.set_ylabel('Frequency')
            ax.set_title('Recent Reward Distribution')
            ax.grid(True, alpha=0.3)
        
        # Plot 5: Policy visualization (if we have a simple env)
        ax = axes[1, 1]
        # This would be environment-specific
        ax.text(0.5, 0.5, 'Policy visualization\n(Environment specific)',
                ha='center', va='center', transform=ax.transAxes, fontsize=12)
        ax.set_title('Learned Policy')
        ax.axis('off')
        
        # Plot 6: Gradient flow analysis
        ax = axes[1, 2]
        if self.losses:
            # Compute gradient magnitudes (simplified)
            grad_norms = []
            for i in range(0, len(self.losses), 10):
                if i < len(self.losses):
                    # Simplified: loss change as proxy for gradient
                    if i > 0:
                        grad_norms.append(abs(self.losses[i] - self.losses[i-1]))
            
            if grad_norms:
                ax.plot(grad_norms)
                ax.set_xlabel('Training Step (every 10th)')
                ax.set_ylabel('Approximate Gradient Norm')
                ax.set_title('Gradient Magnitude Over Time')
                ax.set_yscale('log')
                ax.grid(True, alpha=0.3)
        
        plt.tight_layout()
        plt.show()

def reinforce_demo():
    """Demonstrate REINFORCE algorithm"""
    print("="*70)
    print("REINFORCE DEMONSTRATION")
    print("="*70)
    
    # Create a simple environment
    class SimpleREnv:
        """Simple environment for REINFORCE demo"""
        
        def __init__(self, n_states=5):
            self.n_states = n_states
            self.goal = n_states - 1
            self.state = 0
            
            # Actions: 0=left, 1=right
            self.actions = [0, 1]
            self.action_space = type('ActionSpace', (), {'n': 2})()
            
        def reset(self):
            self.state = 0
            return self._get_state()
        
        def step(self, action):
            if action == 0:  # left
                self.state = max(0, self.state - 1)
            else:  # right
                self.state = min(self.n_states - 1, self.state + 1)
            
            # Rewards
            if self.state == self.goal:
                reward = 10.0
                done = True
            else:
                reward = -0.1  # Small penalty for each step
                done = False
            
            return self._get_state(), reward, done
        
        def _get_state(self):
            """One-hot encoded state"""
            state = np.zeros(self.n_states)
            state[self.state] = 1
            return state
        
        def render(self):
            """Simple visualization"""
            line = ['_'] * self.n_states
            line[self.state] = 'A'
            line[self.goal] = 'G'
            print('[' + ' '.join(line) + ']')
    
    print("\n1. ENVIRONMENT SETUP:")
    print("   Simple chain environment:")
    print("   States: 0-4, Goal: state 4")
    print("   Actions: 0=left, 1=right")
    print("   Reward: +10 at goal, -0.1 per step")
    
    env = SimpleREnv(n_states=5)
    
    print("\n2. AGENT INITIALIZATION:")
    print("   Using REINFORCE (Monte Carlo Policy Gradient)")
    print("   Policy network: 5 → 64 → 64 → 2")
    print("   Learning rate: 0.01, Gamma: 0.99")
    
    agent = REINFORCEAgent(
        state_dim=5,  # One-hot encoded states
        action_dim=2,
        hidden_dim=64,
        gamma=0.99,
        lr=0.01
    )
    
    print("\n3. TRAINING AGENT...")
    print("   (Training for 500 episodes)")
    
    num_episodes = 500
    print_every = 50
    
    for episode in range(num_episodes):
        episode_reward, episode_length, loss = agent.train_episode(env, max_steps=100)
        
        if (episode + 1) % print_every == 0:
            avg_reward = np.mean(agent.episode_rewards[-print_every:])
            avg_length = np.mean(agent.episode_lengths[-print_every:])
            
            print(f"   Episode {episode + 1:4d} | "
                  f"Avg Reward: {avg_reward:6.2f} | "
                  f"Avg Length: {avg_length:5.1f} | "
                  f"Loss: {loss:8.4f}")
    
    print("\n4. VISUALIZING RESULTS...")
    agent.visualize_training()
    
    print("\n5. EVALUATING LEARNED POLICY...")
    mean_reward, std_reward, eval_rewards = agent.evaluate(env, num_episodes=20)
    
    print(f"\n   Evaluation over 20 episodes:")
    print(f"   Average reward: {mean_reward:.2f} ± {std_reward:.2f}")
    print(f"   Best episode: {np.max(eval_rewards):.2f}")
    print(f"   Worst episode: {np.min(eval_rewards):.2f}")
    
    # Success rate (reached goal)
    success_rate = sum(1 for r in eval_rewards if r > 0) / len(eval_rewards)
    print(f"   Success rate: {success_rate:.1%}")
    
    print("\n6. ANALYZING LEARNED POLICY...")
    
    # Visualize policy for all states
    print("\n   Learned action probabilities:")
    states = [np.zeros(5) for _ in range(5)]
    for i in range(5):
        states[i][i] = 1
    
    with torch.no_grad():
        for i, state in enumerate(states):
            state_tensor = torch.FloatTensor(state).unsqueeze(0)
            logits = agent.policy_net.policy_net(state_tensor)
            probs = F.softmax(logits, dim=-1).numpy().flatten()
            
            arrow = '←' if probs[0] > probs[1] else '→'
            print(f"   State {i}: P(left)={probs[0]:.3f}, P(right)={probs[1]:.3f} {arrow}")
    
    print("\n7. DEMONSTRATION EPISODE...")
    print("\n   Running one episode with learned policy:")
    
    state = env.reset()
    done = False
    total_reward = 0
    steps = 0
    
    print("   Initial state:")
    env.render()
    
    while not done and steps < 20:
        action, _, probs = agent.select_action(state, training=False)
        state, reward, done = env.step(action)
        
        total_reward += reward
        steps += 1
        
        print(f"   Step {steps}: Action={'left' if action == 0 else 'right'}, "
              f"Reward={reward:.1f}, Total={total_reward:.1f}")
        env.render()
    
    print(f"\n   Episode finished: Total reward={total_reward:.1f}, Steps={steps}")
    
    print("\n8. KEY INSIGHTS FROM REINFORCE:")
    
    print("\n   Strengths:")
    print("   1. Simple to implement")
    print("   2. Direct policy optimization")
    print("   3. Handles stochastic policies naturally")
    
    print("\n   Weaknesses:")
    print("   1. High variance (Monte Carlo)")
    print("   2. Sample inefficient")
    print("   3. Can be unstable")
    
    print("\n   Improvements (next sections):")
    print("   1. Baseline subtraction (reduce variance)")
    print("   2. Actor-Critic methods")
    print("   3. Trust Region methods")
    
    return agent, env

# Run REINFORCE demo
reinforce_agent, reinforce_env = reinforce_demo()

9.4 Actor-Critic Methods: Combining Value and Policy

class ActorCriticAgent:
    """Actor-Critic method combining policy and value learning"""
    
    def __init__(self, state_dim, action_dim, hidden_dim=128, 
                 gamma=0.99, lr_actor=0.001, lr_critic=0.001):
        """
        Initialize Actor-Critic agent
        
        Args:
            state_dim: Dimension of state space
            action_dim: Number of possible actions
            hidden_dim: Size of hidden layers
            gamma: Discount factor
            lr_actor: Learning rate for actor
            lr_critic: Learning rate for critic
        """
        self.state_dim = state_dim
        self.action_dim = action_dim
        self.gamma = gamma
        
        # Build networks
        self.actor = self._build_actor_network(state_dim, hidden_dim, action_dim)
        self.critic = self._build_critic_network(state_dim, hidden_dim)
        
        # Optimizers
        self.actor_optimizer = optim.Adam(self.actor.parameters(), lr=lr_actor)
        self.critic_optimizer = optim.Adam(self.critic.parameters(), lr=lr_critic)
        
        # Training history
        self.episode_rewards = []
        self.episode_lengths = []
        self.actor_losses = []
        self.critic_losses = []
    
    def _build_actor_network(self, state_dim, hidden_dim, action_dim):
        """Build actor (policy) network"""
        class ActorNetwork(nn.Module):
            def __init__(self, state_dim, hidden_dim, action_dim):
                super().__init__()
                self.net = nn.Sequential(
                    nn.Linear(state_dim, hidden_dim),
                    nn.ReLU(),
                    nn.Linear(hidden_dim, hidden_dim),
                    nn.ReLU(),
                    nn.Linear(hidden_dim, action_dim)
                )
            
            def forward(self, x):
                return self.net(x)
            
            def get_action(self, state):
                """Sample action and get log probability"""
                logits = self.forward(state)
                probs = F.softmax(logits, dim=-1)
                dist = torch.distributions.Categorical(probs)
                action = dist.sample()
                log_prob = dist.log_prob(action)
                return action.item(), log_prob, probs.detach()
        
        return ActorNetwork(state_dim, hidden_dim, action_dim)
    
    def _build_critic_network(self, state_dim, hidden_dim):
        """Build critic (value) network"""
        class CriticNetwork(nn.Module):
            def __init__(self, state_dim, hidden_dim):
                super().__init__()
                self.net = nn.Sequential(
                    nn.Linear(state_dim, hidden_dim),
                    nn.ReLU(),
                    nn.Linear(hidden_dim, hidden_dim),
                    nn.ReLU(),
                    nn.Linear(hidden_dim, 1)
                )
            
            def forward(self, x):
                return self.net(x)
        
        return CriticNetwork(state_dim, hidden_dim)
    
    def select_action(self, state, training=True):
        """
        Select action using actor network
        
        Returns:
            action: Selected action
            log_prob: Log probability (if training)
            value: State value (if training)
            probs: Action probabilities
        """
        state_tensor = torch.FloatTensor(state).unsqueeze(0)
        
        if training:
            action, log_prob, probs = self.actor.get_action(state_tensor)
            value = self.critic(state_tensor)
            return action, log_prob, value, probs
        else:
            with torch.no_grad():
                logits = self.actor(state_tensor)
                probs = F.softmax(logits, dim=-1)
                action = torch.argmax(probs).item()
                value = self.critic(state_tensor)
            return action, None, value, probs
    
    def compute_advantages(self, rewards, values, next_values, dones):
        """
        Compute advantages using TD error
        
        Args:
            rewards: List of rewards
            values: List of value estimates
            next_values: List of next state values
            dones: List of done flags
        
        Returns:
            advantages: Computed advantages
            returns: Estimated returns
        """
        advantages = []
        returns = []
        
        # Compute TD errors
        for t in range(len(rewards)):
            if dones[t]:
                # Terminal state
                td_target = rewards[t]
            else:
                td_target = rewards[t] + self.gamma * next_values[t]
            
            td_error = td_target - values[t]
            advantages.append(td_error)
            
            # Also compute returns for critic training
            G = 0
            for k in range(t, len(rewards)):
                G += (self.gamma ** (k - t)) * rewards[k]
            returns.append(G)
        
        return advantages, returns
    
    def update(self, log_probs, values, advantages, returns):
        """
        Update actor and critic networks
        
        Returns:
            actor_loss: Actor loss value
            critic_loss: Critic loss value
        """
        # Convert to tensors
        log_probs = torch.stack(log_probs)
        values = torch.cat(values).squeeze()
        advantages = torch.FloatTensor(advantages)
        returns = torch.FloatTensor(returns)
        
        # Normalize advantages (reduces variance)
        advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
        
        # Actor loss: -Σ log_prob * advantage
        actor_loss = -(log_probs * advantages).sum()
        
        # Critic loss: MSE between predicted values and returns
        critic_loss = F.mse_loss(values, returns)
        
        # Update actor
        self.actor_optimizer.zero_grad()
        actor_loss.backward(retain_graph=True)
        torch.nn.utils.clip_grad_norm_(self.actor.parameters(), 0.5)
        self.actor_optimizer.step()
        
        # Update critic
        self.critic_optimizer.zero_grad()
        critic_loss.backward()
        torch.nn.utils.clip_grad_norm_(self.critic.parameters(), 0.5)
        self.critic_optimizer.step()
        
        return actor_loss.item(), critic_loss.item()
    
    def train_episode(self, env, max_steps=1000, render=False):
        """
        Train for one episode
        
        Returns:
            episode_reward: Total reward
            episode_length: Episode length
            actor_loss: Actor loss
            critic_loss: Critic loss
        """
        state = env.reset()
        done = False
        episode_reward = 0
        episode_length = 0
        
        # Store episode data
        rewards = []
        log_probs = []
        values = []
        next_values = []
        dones = []
        
        while not done and episode_length < max_steps:
            if render:
                env.render()
            
            # Select action
            action, log_prob, value, _ = self.select_action(state, training=True)
            
            # Take action
            next_state, reward, done = env.step(action)
            
            # Get value of next state
            next_state_tensor = torch.FloatTensor(next_state).unsqueeze(0)
            with torch.no_grad():
                next_value = self.critic(next_state_tensor)
            
            # Store data
            rewards.append(reward)
            log_probs.append(log_prob)
            values.append(value)
            next_values.append(next_value)
            dones.append(done)
            
            # Update
            episode_reward += reward
            episode_length += 1
            state = next_state
        
        # Compute advantages and update
        advantages, returns = self.compute_advantages(rewards, 
                                                     [v.item() for v in values],
                                                     [v.item() for v in next_values],
                                                     dones)
        
        actor_loss, critic_loss = self.update(log_probs, values, advantages, returns)
        
        # Store history
        self.episode_rewards.append(episode_reward)
        self.episode_lengths.append(episode_length)
        self.actor_losses.append(actor_loss)
        self.critic_losses.append(critic_loss)
        
        return episode_reward, episode_length, actor_loss, critic_loss
    
    def visualize_training(self):
        """Visualize training progress"""
        if not self.episode_rewards:
            print("No training data to visualize")
            return
        
        fig, axes = plt.subplots(2, 3, figsize=(15, 10))
        
        # Plot 1: Learning curve
        ax = axes[0, 0]
        window = min(50, len(self.episode_rewards) // 10)
        if window > 1:
            smoothed_rewards = np.convolve(self.episode_rewards, 
                                          np.ones(window)/window, 
                                          mode='valid')
            ax.plot(smoothed_rewards)
            ax.set_xlabel('Episode')
            ax.set_ylabel(f'Average Reward ({window}-episode window)')
            ax.set_title('Learning Curve')
            ax.grid(True, alpha=0.3)
        
        # Plot 2: Loss curves
        ax = axes[0, 1]
        if self.actor_losses and self.critic_losses:
            loss_window = max(1, len(self.actor_losses) // 50)
            if loss_window > 1:
                smoothed_actor = np.convolve(self.actor_losses,
                                           np.ones(loss_window)/loss_window,
                                           mode='valid')
                smoothed_critic = np.convolve(self.critic_losses,
                                            np.ones(loss_window)/loss_window,
                                            mode='valid')
                
                ax.plot(smoothed_actor, label='Actor Loss', alpha=0.7)
                ax.plot(smoothed_critic, label='Critic Loss', alpha=0.7)
                ax.set_xlabel('Episode')
                ax.set_ylabel(f'Smoothed Loss (window={loss_window})')
                ax.set_title('Actor and Critic Losses')
                ax.set_yscale('log')
                ax.legend()
                ax.grid(True, alpha=0.3)
        
        # Plot 3: Value function learning
        ax = axes[0, 2]
        # This would require tracking value estimates
        ax.text(0.5, 0.5, 'Value function analysis\nwould go here',
                ha='center', va='center', transform=ax.transAxes, fontsize=12)
        ax.set_title('Critic Learning')
        ax.axis('off')
        
        # Plot 4: Advantage distribution
        ax = axes[1, 0]
        # Simplified advantage visualization
        if hasattr(self, 'advantages_history'):
            if len(self.advantages_history) > 10:
                recent_advantages = np.concatenate(self.advantages_history[-10:])
                ax.hist(recent_advantages, bins=30, alpha=0.7, edgecolor='black')
                ax.set_xlabel('Advantage')
                ax.set_ylabel('Frequency')
                ax.set_title('Recent Advantage Distribution')
                ax.grid(True, alpha=0.3)
        
        # Plot 5: Policy entropy (exploration)
        ax = axes[1, 1]
        if hasattr(self, 'entropy_history'):
            if len(self.entropy_history) > window:
                smoothed_entropy = np.convolve(self.entropy_history,
                                             np.ones(window)/window,
                                             mode='valid')
                ax.plot(smoothed_entropy)
                ax.set_xlabel('Episode')
                ax.set_ylabel(f'Policy Entropy ({window}-episode window)')
                ax.set_title('Exploration Over Time')
                ax.grid(True, alpha=0.3)
        
        # Plot 6: Gradient analysis
        ax = axes[1, 2]
        if self.actor_losses:
            # Compute approximate gradient norms
            grad_changes = []
            for i in range(1, len(self.actor_losses), 10):
                if i < len(self.actor_losses):
                    grad_changes.append(abs(self.actor_losses[i] - self.actor_losses[i-1]))
            
            if grad_changes:
                ax.plot(grad_changes)
                ax.set_xlabel('Training Step (every 10th)')
                ax.set_ylabel('Loss Change Magnitude')
                ax.set_title('Training Stability')
                ax.set_yscale('log')
                ax.grid(True, alpha=0.3)
        
        plt.tight_layout()
        plt.show()

def actor_critic_demo():
    """Demonstrate Actor-Critic algorithm"""
    print("="*70)
    print("ACTOR-CRITIC DEMONSTRATION")
    print("="*70)
    
    # Create environment
    class ACEnvironment:
        """Environment for Actor-Critic demo"""
        
        def __init__(self, n_states=7):
            self.n_states = n_states
            self.goal = n_states - 1
            self.state = 0
            
            # Actions: 0=left, 1=right
            self.actions = [0, 1]
            self.action_space = type('ActionSpace', (), {'n': 2})()
            
            # Create a more interesting reward structure
            self.reward_map = {
                0: -0.1,  # Starting point
                1: -0.5,  # Pit
                2: 1.0,   # Bonus
                3: -0.2,  # Neutral
                4: -0.8,  # Big pit
                5: 2.0,   # Big bonus
                6: 10.0   # Goal
            }
        
        def reset(self):
            self.state = 0
            return self._get_state()
        
        def step(self, action):
            if action == 0:  # left
                self.state = max(0, self.state - 1)
            else:  # right
                self.state = min(self.n_states - 1, self.state + 1)
            
            # Get reward
            reward = self.reward_map[self.state]
            
            # Check if done
            done = (self.state == self.goal)
            
            return self._get_state(), reward, done
        
        def _get_state(self):
            """One-hot encoded state"""
            state = np.zeros(self.n_states)
            state[self.state] = 1
            return state
        
        def render(self):
            """Visualization"""
            line = ['_'] * self.n_states
            symbols = {0: 'S', 1: 'P', 2: 'B', 3: '_', 4: 'X', 5: '$', 6: 'G'}
            for i in range(self.n_states):
                line[i] = symbols.get(i, '_')
            
            # Add agent
            if 0 <= self.state < self.n_states:
                line[self.state] = 'A'
            
            print('[' + ' '.join(line) + ']')
            print(f"State: {self.state}, Reward: {self.reward_map.get(self.state, 0):.1f}")
    
    print("\n1. ENVIRONMENT SETUP:")
    print("   Complex chain environment:")
    print("   S=Start, P=Pit, B=Bonus, X=Big pit, $=Big bonus, G=Goal")
    print("   Rewards: P=-0.5, B=+1.0, X=-0.8, $=+2.0, G=+10.0")
    
    env = ACEnvironment(n_states=7)
    
    print("\n2. AGENT INITIALIZATION:")
    print("   Using Actor-Critic method")
    print("   Actor network: 7 → 64 → 64 → 2")
    print("   Critic network: 7 → 64 → 64 → 1")
    print("   Learning rates: Actor=0.001, Critic=0.001")
    
    agent = ActorCriticAgent(
        state_dim=7,
        action_dim=2,
        hidden_dim=64,
        gamma=0.99,
        lr_actor=0.001,
        lr_critic=0.001
    )
    
    print("\n3. TRAINING AGENT...")
    print("   (Training for 1000 episodes)")
    
    num_episodes = 1000
    print_every = 100
    
    for episode in range(num_episodes):
        episode_reward, episode_length, actor_loss, critic_loss = agent.train_episode(
            env, max_steps=100
        )
        
        if (episode + 1) % print_every == 0:
            avg_reward = np.mean(agent.episode_rewards[-print_every:])
            avg_length = np.mean(agent.episode_lengths[-print_every:])
            
            print(f"   Episode {episode + 1:4d} | "
                  f"Avg Reward: {avg_reward:6.2f} | "
                  f"Avg Length: {avg_length:5.1f} | "
                  f"Actor Loss: {actor_loss:8.4f} | "
                  f"Critic Loss: {critic_loss:8.4f}")
    
    print("\n4. VISUALIZING RESULTS...")
    agent.visualize_training()
    
    print("\n5. EVALUATING LEARNED POLICY...")
    # Simple evaluation
    test_episodes = 20
    test_rewards = []
    
    for _ in range(test_episodes):
        state = env.reset()
        done = False
        episode_reward = 0
        
        while not done:
            action, _, value, _ = agent.select_action(state, training=False)
            state, reward, done = env.step(action)
            episode_reward += reward
        
        test_rewards.append(episode_reward)
    
    mean_reward = np.mean(test_rewards)
    std_reward = np.std(test_rewards)
    
    print(f"\n   Evaluation over {test_episodes} episodes:")
    print(f"   Average reward: {mean_reward:.2f} ± {std_reward:.2f}")
    print(f"   Best episode: {np.max(test_rewards):.2f}")
    print(f"   Worst episode: {np.min(test_rewards):.2f}")
    
    print("\n6. ANALYZING LEARNED VALUE FUNCTION...")
    
    # Visualize learned values
    states = [np.zeros(7) for _ in range(7)]
    for i in range(7):
        states[i][i] = 1
    
    with torch.no_grad():
        print("\n   State values (learned by critic):")
        for i, state in enumerate(states):
            state_tensor = torch.FloatTensor(state).unsqueeze(0)
            value = agent.critic(state_tensor).item()
            print(f"   State {i}: V(s) = {value:.3f}")
    
    print("\n7. ANALYZING LEARNED POLICY...")
    
    print("\n   Action probabilities and policy:")
    with torch.no_grad():
        for i, state in enumerate(states):
            state_tensor = torch.FloatTensor(state).unsqueeze(0)
            logits = agent.actor(state_tensor)
            probs = F.softmax(logits, dim=-1).numpy().flatten()
            
            # Determine optimal action
            if probs[0] > probs[1]:
                action = "left"
                confidence = probs[0]
            else:
                action = "right"
                confidence = probs[1]
            
            symbol = {0: 'S', 1: 'P', 2: 'B', 3: '_', 4: 'X', 5: '$', 6: 'G'}[i]
            print(f"   {symbol} (State {i}): P(left)={probs[0]:.3f}, "
                  f"P(right)={probs[1]:.3f} → {action} ({confidence:.1%} confident)")
    
    print("\n8. DEMONSTRATION EPISODE...")
    print("\n   Running one episode with learned policy:")
    
    state = env.reset()
    done = False
    total_reward = 0
    steps = 0
    
    print("\n   Initial state:")
    env.render()
    
    while not done and steps < 15:
        action, _, value, probs = agent.select_action(state, training=False)
        state, reward, done = env.step(action)
        
        total_reward += reward
        steps += 1
        
        print(f"\n   Step {steps}:")
        print(f"   Action: {'left' if action == 0 else 'right'}")
        print(f"   P(left)={probs[0][0].item():.3f}, P(right)={probs[0][1].item():.3f}")
        print(f"   Value estimate: {value.item():.3f}")
        print(f"   Reward: {reward:.1f}, Total: {total_reward:.1f}")
        env.render()
    
    print(f"\n   Episode finished: Total reward={total_reward:.1f}, Steps={steps}")
    
    print("\n9. COMPARISON WITH REINFORCE:")
    
    print("\n   Actor-Critic advantages:")
    print("   1. Lower variance (uses value function as baseline)")
    print("   2. Online updates (no need to wait for episode end)")
    print("   3. More stable learning")
    
    print("\n   Implementation details:")
    print("   1. Actor updates policy using advantages")
    print("   2. Critic learns value function")
    print("   3. Advantage = TD error = r + γV(s') - V(s)")
    
    return agent, env

# Run Actor-Critic demo
ac_agent, ac_env = actor_critic_demo()

9.5 Advantages and Generalized Advantage Estimation (GAE)

class AdvantageEstimation:
    """Advanced advantage estimation methods"""
    
    def __init__(self):
        self.explain_advantages()
    
    def explain_advantages(self):
        """Explain different advantage estimation methods"""
        print("="*70)
        print("ADVANTAGE ESTIMATION METHODS")
        print("="*70)
        
        print("\nTHE PROBLEM WITH MONTE CARLO:")
        print("REINFORCE uses: ∇_θ J(θ) = E[Σ_t ∇_θ log π_θ(a_t|s_t) G_t]")
        print("Where G_t = Σ_{k=t}^T γ^{k-t} r_k")
        print("Problem: High variance (full Monte Carlo returns)")
        
        print("\n" + "-"*70)
        print("SOLUTION: USE BASELINES")
        print("-"*70)
        
        print("\nWith baseline b(s_t):")
        print("∇_θ J(θ) = E[Σ_t ∇_θ log π_θ(a_t|s_t) (G_t - b(s_t))]")
        
        print("\nGood baseline choices:")
        print("1. State value V(s_t) ← What Actor-Critic uses!")
        print("2. Average return")
        print("3. Learned baseline network")
        
        print("\n" + "-"*70)
        print("TD ADVANTAGE (ACTOR-CRITIC):")
        print("-"*70)
        
        print("\nTD(0) Advantage:")
        print("A(s_t, a_t) = r_t + γV(s_{t+1}) - V(s_t)")
        print("Pros: Low variance")
        print("Cons: High bias (1-step)")
        
        print("\n" + "-"*70)
        print("N-STEP ADVANTAGE:")
        print("-"*70)
        
        print("\nN-step Advantage:")
        print("A^n(s_t, a_t) = Σ_{k=0}^{n-1} γ^k r_{t+k} + γ^n V(s_{t+n}) - V(s_t)")
        print("Tradeoff: Between TD (bias) and MC (variance)")
    
    def generalized_advantage_estimation(self, rewards, values, next_values, 
                                        dones, gamma=0.99, lambda_param=0.95):
        """
        Generalized Advantage Estimation (GAE)
        
        Args:
            rewards: List of rewards
            values: List of value estimates V(s_t)
            next_values: List of next values V(s_{t+1})
            dones: List of done flags
            gamma: Discount factor
            lambda_param: GAE parameter (0=TD, 1=MC)
        
        Returns:
            advantages: GAE advantages
            returns: Estimated returns
        """
        advantages = []
        returns = []
        
        # Compute TD errors
        td_errors = []
        for t in range(len(rewards)):
            if dones[t]:
                td_target = rewards[t]
            else:
                td_target = rewards[t] + gamma * next_values[t]
            td_error = td_target - values[t]
            td_errors.append(td_error)
        
        # Compute GAE advantages
        advantage = 0
        for t in reversed(range(len(rewards))):
            if dones[t]:
                advantage = 0
            advantage = td_errors[t] + gamma * lambda_param * advantage
            advantages.insert(0, advantage)
        
        # Compute returns for critic training
        for t in range(len(rewards)):
            G = 0
            for k in range(t, len(rewards)):
                G += (gamma ** (k - t)) * rewards[k]
            returns.append(G)
        
        return advantages, returns
    
    def compare_advantage_methods(self):
        """Compare different advantage estimation methods"""
        print("\n" + "="*70)
        print("COMPARING ADVANTAGE ESTIMATION METHODS")
        print("="*70)
        
        # Generate sample trajectory
        np.random.seed(42)
        T = 20  # Episode length
        rewards = np.random.normal(0, 1, T)
        values = np.cumsum(np.random.normal(0, 0.1, T))  # Simulated value estimates
        next_values = np.concatenate([values[1:], [0]])  # Next state values
        dones = [False] * (T-1) + [True]  # Last state is terminal
        
        gamma = 0.9
        lambda_param = 0.95
        
        # Compute different advantages
        # Monte Carlo advantages
        mc_advantages = []
        for t in range(T):
            G = 0
            for k in range(t, T):
                G += (gamma ** (k - t)) * rewards[k]
            mc_advantages.append(G - values[t])
        
        # TD(0) advantages
        td_advantages = []
        for t in range(T):
            if dones[t]:
                td_target = rewards[t]
            else:
                td_target = rewards[t] + gamma * next_values[t]
            td_advantages.append(td_target - values[t])
        
        # GAE advantages
        gae_advantages, _ = self.generalized_advantage_estimation(
            rewards, values, next_values, dones, gamma, lambda_param
        )
        
        # Plot comparison
        fig, axes = plt.subplots(2, 2, figsize=(12, 10))
        
        # Plot 1: Advantages over time
        ax = axes[0, 0]
        time = range(T)
        ax.plot(time, mc_advantages, 'b-', label='Monte Carlo', linewidth=2, alpha=0.7)
        ax.plot(time, td_advantages, 'r-', label='TD(0)', linewidth=2, alpha=0.7)
        ax.plot(time, gae_advantages, 'g-', label=f'GAE(λ={lambda_param})', linewidth=2, alpha=0.7)
        ax.set_xlabel('Time step t')
        ax.set_ylabel('Advantage A(s_t, a_t)')
        ax.set_title('Advantage Estimates Comparison')
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 2: Variance comparison
        ax = axes[0, 1]
        methods = ['Monte Carlo', 'TD(0)', 'GAE']
        variances = [np.var(mc_advantages), np.var(td_advantages), np.var(gae_advantages)]
        
        bars = ax.bar(methods, variances, color=['blue', 'red', 'green'], alpha=0.7)
        ax.set_ylabel('Variance of Advantages')
        ax.set_title('Variance Comparison')
        ax.set_yscale('log')
        
        # Add value labels
        for bar, var in zip(bars, variances):
            ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() * 1.1,
                    f'{var:.3f}', ha='center', va='bottom')
        
        # Plot 3: Bias-Variance tradeoff
        ax = axes[1, 0]
        
        # Simulate different lambda values
        lambda_values = np.linspace(0, 1, 11)
        variances = []
        
        for lam in lambda_values:
            advantages, _ = self.generalized_advantage_estimation(
                rewards, values, next_values, dones, gamma, lam
            )
            variances.append(np.var(advantages))
        
        ax.plot(lambda_values, variances, 'b-o', linewidth=2, markersize=6)
        ax.set_xlabel('λ parameter')
        ax.set_ylabel('Variance of Advantages')
        ax.set_title('Bias-Variance Tradeoff (λ=0:TD, λ=1:MC)')
        ax.grid(True, alpha=0.3)
        
        # Mark special points
        ax.scatter([0, 1], [variances[0], variances[-1]], 
                  color=['red', 'green'], s=100, zorder=5)
        ax.text(0.05, variances[0], 'TD(0)\n(low variance,\nhigh bias)', 
               ha='left', va='bottom')
        ax.text(0.95, variances[-1], 'Monte Carlo\n(high variance,\nlow bias)', 
               ha='right', va='top')
        
        # Plot 4: Mathematical formulas
        ax = axes[1, 1]
        ax.axis('off')
        
        formulas = [
            "Monte Carlo Advantage:",
            "A^{MC}(s_t) = G_t - V(s_t)",
            "",
            "TD(0) Advantage:",
            "A^{TD}(s_t) = r_t + γV(s_{t+1}) - V(s_t)",
            "",
            "N-step Advantage:",
            "A^{n}(s_t) = Σ_{k=0}^{n-1} γ^k r_{t+k}",
            "    + γ^n V(s_{t+n}) - V(s_t)",
            "",
            "GAE Advantage:",
            "A^{GAE}(s_t) = Σ_{l=0}^{∞} (γλ)^l δ_{t+l}",
            "where δ_t = r_t + γV(s_{t+1}) - V(s_t)"
        ]
        
        y_pos = 0.95
        for formula in formulas:
            if '=' in formula or 'where' in formula:
                fontsize = 10
            else:
                fontsize = 11
                y_pos -= 0.05
            
            ax.text(0.05, y_pos, formula, fontsize=fontsize,
                   verticalalignment='top', transform=ax.transAxes)
            y_pos -= 0.08
        
        ax.set_title('Mathematical Formulas')
        
        plt.tight_layout()
        plt.show()
        
        print("\nKEY INSIGHTS:")
        print("\n1. Monte Carlo (λ=1):")
        print("   - Unbiased estimator")
        print("   - High variance")
        print("   - Uses full returns")
        
        print("\n2. TD(0) (λ=0):")
        print("   - Biased estimator")
        print("   - Low variance")
        print("   - Uses 1-step returns")
        
        print("\n3. GAE (0<λ<1):")
        print("   - Smooth interpolation between TD and MC")
        print("   - Adjustable bias-variance tradeoff")
        print("   - Typically λ=0.95 works well")
        
        print("\nPRACTICAL RECOMMENDATIONS:")
        print("1. Start with λ=0.95 for most problems")
        print("2. Use GAE for stable learning")
        print("3. Monitor advantage variance during training")
        print("4. Adjust λ based on environment stochasticity")
        
        return mc_advantages, td_advantages, gae_advantages

# Run advantage estimation comparison
mc_adv, td_adv, gae_adv = AdvantageEstimation().compare_advantage_methods()

9.6 Chapter Project: Lunar Lander with Policy Gradients

def lunar_lander_project():
    """Lunar Lander with Policy Gradient methods"""
    print("="*70)
    print("LUNAR LANDER PROJECT")
    print("="*70)
    
    try:
        import gymnasium as gym
        gym_available = True
    except ImportError:
        print("gymnasium not available. Creating custom environment...")
        gym_available = False
    
    if gym_available:
        # Use actual LunarLander environment
        print("\n1. INITIALIZING LUNAR LANDER ENVIRONMENT...")
        env = gym.make('LunarLander-v2', render_mode='rgb_array')
        
        print(f"   State dimension: {env.observation_space.shape[0]}")
        print(f"   Action space: {env.action_space.n} discrete actions")
        print("\n   Actions: 0=do nothing, 1=left engine, 2=main engine, 3=right engine")
        print("   Goal: Land softly between flags")
        print("   Reward: 100-140 for landing, -100 for crashing, -0.3/frame for main engine")
        
        # Create a wrapper for our training loop
        class GymEnvWrapper:
            def __init__(self, gym_env):
                self.env = gym_env
                self.state_dim = gym_env.observation_space.shape[0]
                self.action_dim = gym_env.action_space.n
            
            def reset(self):
                state, _ = self.env.reset()
                return state
            
            def step(self, action):
                state, reward, terminated, truncated, _ = self.env.step(action)
                done = terminated or truncated
                return state, reward, done
            
            def render(self):
                return self.env.render()
        
        wrapped_env = GymEnvWrapper(env)
        state_dim = wrapped_env.state_dim
        action_dim = wrapped_env.action_dim
        
    else:
        # Create a simplified Lunar Lander environment
        print("\n1. CREATING SIMPLIFIED LUNAR LANDER ENVIRONMENT...")
        
        class SimpleLunarLander:
            def __init__(self):
                # State: [x, y, vx, vy, angle, angular_velocity, left_leg, right_leg]
                self.state_dim = 8
                self.action_dim = 4  # 0: none, 1: left, 2: main, 3: right
                
                # Initial state
                self.reset()
            
            def reset(self):
                # Start at random position
                self.x = np.random.uniform(-0.5, 0.5)
                self.y = np.random.uniform(1.0, 1.5)
                self.vx = np.random.uniform(-0.2, 0.2)
                self.vy = np.random.uniform(-0.5, 0)
                self.angle = np.random.uniform(-0.2, 0.2)
                self.angular_velocity = np.random.uniform(-0.1, 0.1)
                self.left_leg = 0
                self.right_leg = 0
                self.steps = 0
                
                return self._get_state()
            
            def step(self, action):
                # Simplified physics
                dt = 0.05
                g = -1.5  # Gravity
                
                # Apply action
                if action == 1:  # left engine
                    self.vx += 0.1 * dt
                    self.vy += 0.5 * dt
                    self.angular_velocity += 0.1 * dt
                elif action == 2:  # main engine
                    self.vy += 1.0 * dt
                elif action == 3:  # right engine
                    self.vx -= 0.1 * dt
                    self.vy += 0.5 * dt
                    self.angular_velocity -= 0.1 * dt
                
                # Update physics
                self.x += self.vx * dt
                self.y += self.vy * dt + 0.5 * g * dt**2
                self.vy += g * dt
                self.angle += self.angular_velocity * dt
                
                # Keep angle in reasonable range
                self.angle = np.clip(self.angle, -np.pi/2, np.pi/2)
                
                # Update leg contact
                if self.y <= 0.1 and abs(self.angle) < 0.2:
                    if self.x < -0.1:
                        self.left_leg = 1
                    elif self.x > 0.1:
                        self.right_leg = 1
                
                # Calculate reward
                reward = self._calculate_reward(action)
                
                # Check if done
                self.steps += 1
                done = (self.y <= 0) or (self.steps >= 200)
                
                return self._get_state(), reward, done
            
            def _calculate_reward(self, action):
                # Base reward for being alive
                reward = 0.1
                
                # Penalty for using main engine
                if action == 2:
                    reward -= 0.3
                
                # Reward for being upright
                reward += 0.1 * (1.0 - abs(self.angle) / (np.pi/2))
                
                # Reward for being near center
                reward += 0.1 * (1.0 - abs(self.x) / 0.5)
                
                # Reward for low speed
                speed = np.sqrt(self.vx**2 + self.vy**2)
                reward += 0.1 * (1.0 - min(speed, 2.0) / 2.0)
                
                # Landing bonus
                if self.y <= 0:
                    if abs(self.angle) < 0.2 and abs(self.x) < 0.1 and speed < 0.5:
                        reward += 100  # Successful landing
                        if self.left_leg and self.right_leg:
                            reward += 40  # Both legs touched
                    else:
                        reward -= 100  # Crash
                
                return reward
            
            def _get_state(self):
                return np.array([
                    self.x, self.y, self.vx, self.vy,
                    self.angle, self.angular_velocity,
                    self.left_leg, self.right_leg
                ])
            
            def render(self):
                """Simple text render"""
                print(f"Pos: ({self.x:.2f}, {self.y:.2f}), "
                      f"Vel: ({self.vx:.2f}, {self.vy:.2f}), "
                      f"Angle: {np.degrees(self.angle):.1f}°, "
                      f"Steps: {self.steps}")
        
        wrapped_env = SimpleLunarLander()
        state_dim = wrapped_env.state_dim
        action_dim = wrapped_env.action_dim
    
    print("\n2. IMPLEMENTING ADVANCED POLICY GRADIENT AGENT...")
    
    class AdvancedPGAgent:
        """Advanced Policy Gradient agent with GAE and entropy regularization"""
        
        def __init__(self, state_dim, action_dim, hidden_dim=256, 
                    gamma=0.99, gae_lambda=0.95, lr=3e-4,
                    entropy_coef=0.01, value_coef=0.5, max_grad_norm=0.5):
            """
            Initialize advanced PG agent
            
            Args:
                state_dim: Dimension of state space
                action_dim: Number of possible actions
                hidden_dim: Size of hidden layers
                gamma: Discount factor
                gae_lambda: GAE parameter
                lr: Learning rate
                entropy_coef: Entropy regularization coefficient
                value_coef: Value loss coefficient
                max_grad_norm: Gradient clipping norm
            """
            self.state_dim = state_dim
            self.action_dim = action_dim
            self.gamma = gamma
            self.gae_lambda = gae_lambda
            self.entropy_coef = entropy_coef
            self.value_coef = value_coef
            self.max_grad_norm = max_grad_norm
            
            # Build networks
            self.actor = self._build_actor(state_dim, hidden_dim, action_dim)
            self.critic = self._build_critic(state_dim, hidden_dim)
            
            # Single optimizer for both networks
            parameters = list(self.actor.parameters()) + list(self.critic.parameters())
            self.optimizer = optim.Adam(parameters, lr=lr)
            
            # Training history
            self.episode_rewards = []
            self.episode_lengths = []
            self.losses = []
            self.entropies = []
            self.value_losses = []
            self.policy_losses = []
            
            # For GAE computation
            self.states = []
            self.actions = []
            self.rewards = []
            self.values = []
            self.next_values = []
            self.dones = []
            self.log_probs = []
        
        def _build_actor(self, state_dim, hidden_dim, action_dim):
            """Build actor network"""
            return nn.Sequential(
                nn.Linear(state_dim, hidden_dim),
                nn.Tanh(),
                nn.Linear(hidden_dim, hidden_dim),
                nn.Tanh(),
                nn.Linear(hidden_dim, action_dim)
            )
        
        def _build_critic(self, state_dim, hidden_dim):
            """Build critic network"""
            return nn.Sequential(
                nn.Linear(state_dim, hidden_dim),
                nn.Tanh(),
                nn.Linear(hidden_dim, hidden_dim),
                nn.Tanh(),
                nn.Linear(hidden_dim, 1)
            )
        
        def select_action(self, state, training=True):
            """
            Select action using current policy
            
            Returns:
                action: Selected action
                log_prob: Log probability
                value: State value
                entropy: Policy entropy
            """
            state_tensor = torch.FloatTensor(state).unsqueeze(0)
            
            # Get action probabilities
            logits = self.actor(state_tensor)
            probs = F.softmax(logits, dim=-1)
            dist = torch.distributions.Categorical(probs)
            
            # Sample action
            if training:
                action = dist.sample()
            else:
                action = torch.argmax(probs)
            
            log_prob = dist.log_prob(action)
            entropy = dist.entropy()
            value = self.critic(state_tensor)
            
            return action.item(), log_prob, value, entropy.item()
        
        def store_transition(self, state, action, reward, next_state, 
                           log_prob, value, next_value, done):
            """Store transition for GAE computation"""
            self.states.append(state)
            self.actions.append(action)
            self.rewards.append(reward)
            self.values.append(value.item())
            self.next_values.append(next_value.item())
            self.dones.append(done)
            self.log_probs.append(log_prob)
        
        def compute_gae(self):
            """Compute Generalized Advantage Estimation"""
            advantages = []
            returns = []
            
            # Compute TD errors
            td_errors = []
            for t in range(len(self.rewards)):
                if self.dones[t]:
                    td_target = self.rewards[t]
                else:
                    td_target = self.rewards[t] + self.gamma * self.next_values[t]
                td_error = td_target - self.values[t]
                td_errors.append(td_error)
            
            # Compute GAE advantages
            advantage = 0
            for t in reversed(range(len(self.rewards))):
                if self.dones[t]:
                    advantage = 0
                advantage = td_errors[t] + self.gamma * self.gae_lambda * advantage
                advantages.insert(0, advantage)
            
            # Compute returns
            for t in range(len(self.rewards)):
                G = 0
                for k in range(t, len(self.rewards)):
                    G += (self.gamma ** (k - t)) * self.rewards[k]
                returns.append(G)
            
            return advantages, returns
        
        def update(self):
            """Update networks using stored transitions"""
            if not self.states:
                return 0, 0, 0
            
            # Compute advantages and returns
            advantages, returns = self.compute_gae()
            
            # Convert to tensors
            states_tensor = torch.FloatTensor(np.array(self.states))
            actions_tensor = torch.LongTensor(self.actions)
            log_probs_tensor = torch.stack(self.log_probs)
            advantages_tensor = torch.FloatTensor(advantages)
            returns_tensor = torch.FloatTensor(returns)
            
            # Normalize advantages
            advantages_tensor = (advantages_tensor - advantages_tensor.mean()) / (advantages_tensor.std() + 1e-8)
            
            # Get current policy distribution
            current_logits = self.actor(states_tensor)
            current_probs = F.softmax(current_logits, dim=-1)
            current_dist = torch.distributions.Categorical(current_probs)
            
            # Get current log probabilities
            current_log_probs = current_dist.log_prob(actions_tensor)
            
            # Policy loss
            ratio = torch.exp(current_log_probs - log_probs_tensor.detach())
            policy_loss = -(ratio * advantages_tensor).mean()
            
            # Value loss
            values = self.critic(states_tensor).squeeze()
            value_loss = F.mse_loss(values, returns_tensor)
            
            # Entropy bonus
            entropy = current_dist.entropy().mean()
            
            # Total loss
            loss = (policy_loss 
                   + self.value_coef * value_loss 
                   - self.entropy_coef * entropy)
            
            # Optimize
            self.optimizer.zero_grad()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(self.actor.parameters(), self.max_grad_norm)
            torch.nn.utils.clip_grad_norm_(self.critic.parameters(), self.max_grad_norm)
            self.optimizer.step()
            
            # Clear buffers
            self.clear_buffers()
            
            return loss.item(), policy_loss.item(), value_loss.item(), entropy.item()
        
        def clear_buffers(self):
            """Clear stored transitions"""
            self.states.clear()
            self.actions.clear()
            self.rewards.clear()
            self.values.clear()
            self.next_values.clear()
            self.dones.clear()
            self.log_probs.clear()
        
        def train_episode(self, env, max_steps=1000, render=False):
            """
            Train for one episode
            
            Returns:
                episode_reward: Total reward
                episode_length: Episode length
                loss: Training loss
            """
            state = env.reset()
            done = False
            episode_reward = 0
            episode_length = 0
            
            while not done and episode_length < max_steps:
                if render:
                    env.render()
                
                # Select action
                action, log_prob, value, entropy = self.select_action(state, training=True)
                
                # Take action
                next_state, reward, done = env.step(action)
                
                # Get next state value
                next_state_tensor = torch.FloatTensor(next_state).unsqueeze(0)
                with torch.no_grad():
                    next_value = self.critic(next_state_tensor)
                
                # Store transition
                self.store_transition(state, action, reward, next_state,
                                    log_prob, value, next_value, done)
                
                # Update
                episode_reward += reward
                episode_length += 1
                state = next_state
            
            # Update policy
            loss, policy_loss, value_loss, entropy_val = self.update()
            
            # Store history
            self.episode_rewards.append(episode_reward)
            self.episode_lengths.append(episode_length)
            self.losses.append(loss)
            self.policy_losses.append(policy_loss)
            self.value_losses.append(value_loss)
            self.entropies.append(entropy_val)
            
            return episode_reward, episode_length, loss
        
        def evaluate(self, env, num_episodes=5, render=False):
            """
            Evaluate agent performance
            
            Returns:
                mean_reward: Average reward
                std_reward: Standard deviation
                all_rewards: List of rewards
            """
            rewards = []
            
            for episode in range(num_episodes):
                state = env.reset()
                done = False
                episode_reward = 0
                
                if render:
                    print(f"\nEvaluation Episode {episode + 1}:")
                
                while not done:
                    action, _, _, _ = self.select_action(state, training=False)
                    state, reward, done = env.step(action)
                    episode_reward += reward
                    
                    if render:
                        env.render()
                
                rewards.append(episode_reward)
                
                if render:
                    print(f"  Episode reward: {episode_reward:.1f}")
            
            mean_reward = np.mean(rewards)
            std_reward = np.std(rewards)
            
            return mean_reward, std_reward, rewards
    
    print("\n3. INITIALIZING ADVANCED PG AGENT...")
    print(f"   State dimension: {state_dim}")
    print(f"   Action dimension: {action_dim}")
    print(f"   Network: {state_dim} → 256 → 256 → {action_dim}")
    print(f"   GAE λ: 0.95, Entropy coefficient: 0.01")
    print(f"   Learning rate: 3e-4, Max grad norm: 0.5")
    
    agent = AdvancedPGAgent(
        state_dim=state_dim,
        action_dim=action_dim,
        hidden_dim=256,
        gamma=0.99,
        gae_lambda=0.95,
        lr=3e-4,
        entropy_coef=0.01,
        value_coef=0.5,
        max_grad_norm=0.5
    )
    
    print("\n4. TRAINING AGENT...")
    print("   (Training for 2000 episodes)")
    
    num_episodes = 2000
    print_every = 100
    best_reward = -float('inf')
    
    for episode in range(num_episodes):
        episode_reward, episode_length, loss = agent.train_episode(
            wrapped_env, max_steps=500
        )
        
        # Track best reward
        if episode_reward > best_reward:
            best_reward = episode_reward
        
        # Print progress
        if (episode + 1) % print_every == 0:
            avg_reward = np.mean(agent.episode_rewards[-print_every:])
            avg_length = np.mean(agent.episode_lengths[-print_every:])
            avg_loss = np.mean(agent.losses[-print_every:])
            
            print(f"   Episode {episode + 1:4d} | "
                  f"Avg Reward: {avg_reward:7.1f} | "
                  f"Avg Length: {avg_length:5.1f} | "
                  f"Loss: {avg_loss:8.4f} | "
                  f"Best: {best_reward:7.1f}")
    
    print("\n5. VISUALIZING RESULTS...")
    
    # Create comprehensive visualization
    fig, axes = plt.subplots(3, 3, figsize=(15, 12))
    
    # Plot 1: Learning curve
    ax = axes[0, 0]
    window = 50
    if len(agent.episode_rewards) > window:
        smoothed_rewards = np.convolve(agent.episode_rewards, 
                                      np.ones(window)/window, 
                                      mode='valid')
        ax.plot(smoothed_rewards, linewidth=2)
        ax.set_xlabel('Episode')
        ax.set_ylabel(f'Average Reward ({window}-episode window)')
        ax.set_title('Learning Curve')
        ax.grid(True, alpha=0.3)
        
        # Mark success threshold (typically > 200 for LunarLander)
        ax.axhline(y=200, color='green', linestyle='--', alpha=0.5,
                  label='Success threshold (~200)')
        ax.legend()
    
    # Plot 2: Episode lengths
    ax = axes[0, 1]
    if len(agent.episode_lengths) > window:
        smoothed_lengths = np.convolve(agent.episode_lengths,
                                      np.ones(window)/window,
                                      mode='valid')
        ax.plot(smoothed_lengths, linewidth=2)
        ax.set_xlabel('Episode')
        ax.set_ylabel(f'Average Length ({window}-episode window)')
        ax.set_title('Episode Lengths')
        ax.grid(True, alpha=0.3)
    
    # Plot 3: Loss components
    ax = axes[0, 2]
    if agent.losses and agent.policy_losses and agent.value_losses:
        loss_window = max(1, len(agent.losses) // 50)
        if loss_window > 1:
            smoothed_total = np.convolve(agent.losses,
                                        np.ones(loss_window)/loss_window,
                                        mode='valid')
            smoothed_policy = np.convolve(agent.policy_losses,
                                         np.ones(loss_window)/loss_window,
                                         mode='valid')
            smoothed_value = np.convolve(agent.value_losses,
                                        np.ones(loss_window)/loss_window,
                                        mode='valid')
            
            ax.plot(smoothed_total, label='Total Loss', linewidth=2)
            ax.plot(smoothed_policy, label='Policy Loss', linewidth=2, alpha=0.7)
            ax.plot(smoothed_value, label='Value Loss', linewidth=2, alpha=0.7)
            ax.set_xlabel('Episode')
            ax.set_ylabel(f'Smoothed Loss (window={loss_window})')
            ax.set_title('Loss Components')
            ax.set_yscale('log')
            ax.legend()
            ax.grid(True, alpha=0.3)
    
    # Plot 4: Entropy over time
    ax = axes[1, 0]
    if agent.entropies:
        entropy_window = max(1, len(agent.entropies) // 20)
        if entropy_window > 1:
            smoothed_entropy = np.convolve(agent.entropies,
                                          np.ones(entropy_window)/entropy_window,
                                          mode='valid')
            ax.plot(smoothed_entropy, linewidth=2, color='purple')
            ax.set_xlabel('Episode')
            ax.set_ylabel(f'Policy Entropy (window={entropy_window})')
            ax.set_title('Exploration Over Time')
            ax.grid(True, alpha=0.3)
            
            # Theoretical maximum entropy for discrete actions
            max_entropy = np.log(action_dim)
            ax.axhline(y=max_entropy, color='red', linestyle='--', alpha=0.5,
                      label=f'Max entropy: {max_entropy:.2f}')
            ax.legend()
    
    # Plot 5: Reward distribution
    ax = axes[1, 1]
    if len(agent.episode_rewards) > 100:
        recent_rewards = agent.episode_rewards[-100:]
        ax.hist(recent_rewards, bins=20, alpha=0.7, edgecolor='black')
        ax.set_xlabel('Episode Reward')
        ax.set_ylabel('Frequency')
        ax.set_title('Recent Reward Distribution')
        ax.grid(True, alpha=0.3)
        
        # Add mean line
        mean_reward = np.mean(recent_rewards)
        ax.axvline(x=mean_reward, color='red', linestyle='--', alpha=0.7,
                  label=f'Mean: {mean_reward:.1f}')
        ax.legend()
    
    # Plot 6: Learning stages
    ax = axes[1, 2]
    # Divide training into stages
    if len(agent.episode_rewards) > 300:
        stage_size = len(agent.episode_rewards) // 3
        stages = ['Early', 'Middle', 'Late']
        stage_rewards = []
        
        for i in range(3):
            start = i * stage_size
            end = (i + 1) * stage_size if i < 2 else len(agent.episode_rewards)
            stage_rewards.append(agent.episode_rewards[start:end])
        
        # Create box plot
        bp = ax.boxplot(stage_rewards, labels=stages, patch_artist=True)
        
        # Color boxes
        colors = ['lightblue', 'lightgreen', 'lightcoral']
        for patch, color in zip(bp['boxes'], colors):
            patch.set_facecolor(color)
        
        ax.set_ylabel('Episode Reward')
        ax.set_title('Learning Progress by Stage')
        ax.grid(True, alpha=0.3)
    
    # Plot 7: Action distribution (last episode)
    ax = axes[2, 0]
    # This would require tracking actions during evaluation
    ax.text(0.5, 0.5, 'Action distribution analysis\nwould require tracking actions',
            ha='center', va='center', transform=ax.transAxes, fontsize=10)
    ax.set_title('Action Distribution')
    ax.axis('off')
    
    # Plot 8: Value function learning
    ax = axes[2, 1]
    # Simplified value function analysis
    if hasattr(agent, 'value_history'):
        ax.text(0.5, 0.5, 'Value function learning curve\nwould be shown here',
                ha='center', va='center', transform=ax.transAxes, fontsize=10)
    else:
        ax.text(0.5, 0.5, 'Value function metrics\nnot tracked in this demo',
                ha='center', va='center', transform=ax.transAxes, fontsize=10)
    ax.set_title('Critic Learning')
    ax.axis('off')
    
    # Plot 9: Success rate over time
    ax = axes[2, 2]
    if len(agent.episode_rewards) > window:
        # Define success as reward > 200 (for LunarLander)
        success_threshold = 200
        success_rates = []
        
        for i in range(0, len(agent.episode_rewards) - window + 1, window):
            window_rewards = agent.episode_rewards[i:i + window]
            success_rate = sum(1 for r in window_rewards if r > success_threshold) / len(window_rewards)
            success_rates.append(success_rate)
        
        if success_rates:
            ax.plot(range(0, len(agent.episode_rewards), window)[:len(success_rates)], 
                   success_rates, 'o-', linewidth=2, markersize=6)
            ax.set_xlabel('Episode')
            ax.set_ylabel('Success Rate')
            ax.set_title(f'Success Rate (reward > {success_threshold})')
            ax.set_ylim(-0.05, 1.05)
            ax.grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.show()
    
    print("\n6. EVALUATING LEARNED AGENT...")
    
    # Run evaluation
    eval_episodes = 10
    print(f"\n   Running evaluation for {eval_episodes} episodes...")
    
    mean_reward, std_reward, eval_rewards = agent.evaluate(
        wrapped_env, num_episodes=eval_episodes, render=False
    )
    
    print(f"\n   Evaluation Results:")
    print(f"   Average reward: {mean_reward:.1f} ± {std_reward:.1f}")
    print(f"   Best episode: {np.max(eval_rewards):.1f}")
    print(f"   Worst episode: {np.min(eval_rewards):.1f}")
    
    # Success rate
    success_threshold = 200
    success_count = sum(1 for r in eval_rewards if r > success_threshold)
    print(f"   Success rate (reward > {success_threshold}): {success_count}/{eval_episodes} ({success_count/eval_episodes:.1%})")
    
    print("\n7. KEY INSIGHTS FROM LUNAR LANDER PROJECT:")
    
    print("\n   What Policy Gradients learned:")
    print("   1. Balance thrusters to control descent")
    print("   2. Soft landing requires careful timing")
    print("   3. Need to manage fuel (main engine penalty)")
    
    print("\n   Challenges observed:")
    print("   1. High-dimensional continuous state space")
    print("   2. Sparse rewards (only at landing)")
    print("   3. Need for exploration in early training")
    
    print("\n   Advanced techniques used:")
    print("   1. Generalized Advantage Estimation (GAE)")
    print("   2. Entropy regularization for exploration")
    print("   3. Value function as baseline (Actor-Critic)")
    print("   4. Gradient clipping for stability")
    
    print("\n   Comparison with value-based methods:")
    print("   1. Policy gradients handle continuous actions naturally")
    print("   2. Can learn stochastic policies")
    print("   3. Direct optimization of what we care about")
    print("   4. Often more sample-efficient for complex tasks")
    
    print("\n8. NEXT STEPS:")
    print("   For even better performance:")
    print("   1. Use PPO (Proximal Policy Optimization)")
    print("   2. Try TRPO (Trust Region Policy Optimization)")
    print("   3. Implement parallel environments")
    print("   4. Add reward shaping")
    
    return agent, wrapped_env

# Run Lunar Lander project
lunar_agent, lunar_env = lunar_lander_project()

Chapter 9 Key Takeaways:

  1. Policy Gradient Philosophy:

    • Directly optimize policy parameters θ
    • Output action probabilities π_θ(a|s)
    • Natural for continuous/stochastic actions
  2. Policy Gradient Theorem:

    • ∇_θ J(θ) = E[Σ_t ∇_θ log π_θ(a_t|s_t) G_t]
    • Only need log probabilities of actions taken
    • Environment dynamics don't matter (model-free)
  3. REINFORCE Algorithm:

    • Monte Carlo policy gradient
    • Simple but high variance
    • Collect full episode, then update
    • Baseline subtraction reduces variance
  4. Actor-Critic Methods:

    • Actor: Learns policy π_θ(a|s)
    • Critic: Learns value function V_φ(s)
    • Advantages: A(s,a) = r + γV(s') - V(s)
    • Lower variance than REINFORCE
  5. Generalized Advantage Estimation (GAE):

    • A^{GAE} = Σ_{l=0}^{∞} (γλ)^l δ_{t+l}
    • λ=0: TD(0) (low variance, high bias)
    • λ=1: Monte Carlo (high variance, low bias)
    • 0<λ<1: Interpolation (adjustable tradeoff)
  6. Advanced Techniques:

    • Entropy regularization: Encourages exploration
    • Gradient clipping: Prevents large updates
    • Value function clipping (PPO)
    • Trust regions (TRPO)
  7. Lunar Lander Project:

    • Demonstrated policy gradients on complex task
    • Used GAE for stable learning
    • Entropy regularization for exploration
    • Showed practical implementation details
  8. Comparison with Value-Based Methods:

    • Policy Gradients: Direct, stochastic, continuous actions
    • Value-Based: Indirect, often deterministic, discrete actions
    • Actor-Critic: Best of both worlds
  9. Practical Implementation Tips:

    • Start with REINFORCE for understanding
    • Use Actor-Critic for better performance
    • Implement GAE with λ=0.95
    • Add entropy regularization
    • Monitor learning curves closely
    • Tune learning rates carefully

I'll provide you with the complete, continuous Chapter 10. Let me write it as a single, cohesive chapter:

# **CHAPTER 10: The Frontier & Putting It All Together**

#### **10.1 Beyond the Basics: Modern RL Algorithms**

```python
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import matplotlib.pyplot as plt
from collections import defaultdict, deque
import random
from IPython.display import display, HTML

class RLFrontier:
    """Exploring the cutting edge of Reinforcement Learning"""
    
    def __init__(self):
        self.setup_visualization()
    
    def setup_visualization(self):
        """Setup visualization for the frontier"""
        print("="*80)
        print("CHAPTER 10: THE REINFORCEMENT LEARNING FRONTIER")
        print("="*80)
        print("\nWelcome to the final chapter! Here we explore:")
        print("• Modern RL algorithms that power today's AI systems")
        print("• Real-world challenges and practical solutions")
        print("• Multi-agent systems and collaborative AI")
        print("• Meta-learning: teaching AI to learn faster")
        print("• RLHF: How ChatGPT and similar models are trained")
        print("• The future of RL and your role in it")
        
        self.timeline_data = self.create_modern_rl_timeline()
        
    def create_modern_rl_timeline(self):
        """Create timeline of modern RL developments"""
        timeline = [
            (2015, "DQN (Nature)", "Deep Q-Networks achieve human-level Atari play"),
            (2016, "A3C", "Asynchronous Advantage Actor-Critic"),
            (2017, "PPO", "Proximal Policy Optimization (simpler than TRPO)"),
            (2017, "Rainbow DQN", "Combines 7 DQN improvements"),
            (2018, "Soft Actor-Critic (SAC)", "Maximum entropy RL for robotics"),
            (2018, "IMPALA", "Massively parallel RL"),
            (2019, "MuZero", "Model-based + Model-free, learns dynamics"),
            (2020, "Dreamer", "World models for sample efficiency"),
            (2021, "Decision Transformer", "RL as sequence modeling"),
            (2022, "ChatGPT/RLHF", "Reinforcement Learning from Human Feedback"),
            (2023, "AlphaDev", "Discovering faster sorting algorithms"),
            (2024, "Sora/Gemini", "Video generation and multimodal RL")
        ]
        
        print("\nMODERN RL TIMELINE:")
        print("-"*80)
        
        for year, name, description in timeline:
            print(f"{year}: {name:20s} - {description}")
        
        return timeline
    
    def visualize_algorithm_landscape(self):
        """Visualize the RL algorithm landscape"""
        
        algorithms = {
            "Value-Based": ["Q-learning", "DQN", "Double DQN", "Rainbow", "C51"],
            "Policy-Based": ["REINFORCE", "TRPO", "PPO", "A3C", "DDPG"],
            "Actor-Critic": ["A2C", "A3C", "SAC", "TD3", "SAC"],
            "Model-Based": ["Dyna", "MBMF", "MuZero", "Dreamer", "PlaNet"],
            "Hierarchical": ["HIRO", "HAC", "Option-Critic", "MAXQ"],
            "Multi-Agent": ["MADDPG", "QMIX", "COMA", "VDN"],
            "Inverse RL": ["MaxEnt IRL", "GAIL", "AIRL", "FAIRL"],
            "Meta-RL": ["MAML", "RL²", "PEARL", "VariBAD"]
        }
        
        # Create radar chart
        categories = list(algorithms.keys())
        N = len(categories)
        
        # Assign scores for maturity/complexity
        scores = {
            "Value-Based": 9,
            "Policy-Based": 8,
            "Actor-Critic": 9,
            "Model-Based": 7,
            "Hierarchical": 6,
            "Multi-Agent": 7,
            "Inverse RL": 5,
            "Meta-RL": 4
        }
        
        values = [scores[cat] for cat in categories]
        values += values[:1]  # Close the radar chart
        
        angles = np.linspace(0, 2 * np.pi, N, endpoint=False).tolist()
        angles += angles[:1]
        
        fig, ax = plt.subplots(figsize=(10, 10), subplot_kw=dict(projection='polar'))
        
        # Draw the plot
        ax.plot(angles, values, 'o-', linewidth=2, label='Maturity/Complexity')
        ax.fill(angles, values, alpha=0.25)
        
        # Add labels
        ax.set_xticks(angles[:-1])
        ax.set_xticklabels(categories, fontsize=10)
        ax.set_ylim(0, 10)
        ax.set_yticks([2, 4, 6, 8, 10])
        ax.set_yticklabels(['2', '4', '6', '8', '10'], fontsize=8)
        ax.grid(True)
        
        # Add title
        ax.set_title('RL Algorithm Landscape (Maturity & Complexity)', 
                    size=15, weight='bold', ha='center', pad=20)
        
        plt.tight_layout()
        plt.show()
        
        # Print key insights
        print("\n" + "="*80)
        print("KEY INSIGHTS FROM RL LANDSCAPE:")
        print("="*80)
        
        insights = [
            ("Value-Based", "Most mature, good for discrete actions"),
            ("Actor-Critic", "Best balance for continuous control"),
            ("Model-Based", "Most sample-efficient, but complex"),
            ("Multi-Agent", "Growing importance for real-world systems"),
            ("Meta-RL", "Cutting edge, learns to learn quickly")
        ]
        
        for category, insight in insights:
            print(f"• {category:15s}: {insight}")
    
    def explain_modern_algorithms(self):
        """Explain key modern algorithms"""
        print("\n" + "="*80)
        print("MODERN RL ALGORITHMS EXPLAINED")
        print("="*80)
        
        algorithms = [
            {
                "name": "PPO (Proximal Policy Optimization)",
                "key_idea": "Clipped objective prevents too-large policy updates",
                "formula": "L^CLIP(θ) = E[min(r_t(θ)A_t, clip(r_t(θ), 1-ε, 1+ε)A_t)]",
                "strengths": ["Simple", "Stable", "Good performance"],
                "use_cases": ["Robotics", "Games", "Continuous control"]
            },
            {
                "name": "SAC (Soft Actor-Critic)",
                "key_idea": "Maximum entropy RL - maximize reward + entropy",
                "formula": "J(π) = Σ E[r(s,a) + αH(π(·|s))]",
                "strengths": ["Sample efficient", "Stable", "Automated temp tuning"],
                "use_cases": ["Robotics", "Autonomous vehicles"]
            },
            {
                "name": "TD3 (Twin Delayed DDPG)",
                "key_idea": "Addresses overestimation in actor-critic",
                "formula": "Two critics + delayed policy updates + target smoothing",
                "strengths": ["Reduces overestimation", "More stable than DDPG"],
                "use_cases": ["Continuous control with deterministic policies"]
            },
            {
                "name": "MuZero",
                "key_idea": "Learns model, uses it for planning",
                "formula": "Learns: dynamics, reward, value functions",
                "strengths": ["Model-based efficiency", "No environment knowledge"],
                "use_cases": ["Games", "Planning problems"]
            },
            {
                "name": "Decision Transformer",
                "key_idea": "RL as sequence modeling problem",
                "formula": "P(a_t | s_t, a_{<t}, r_{<t}) - Transformer architecture",
                "strengths": ["Leverages transformer power", "Offline RL friendly"],
                "use_cases": ["Language tasks", "Recommendation systems"]
            }
        ]
        
        for i, algo in enumerate(algorithms, 1):
            print(f"\n{i}. {algo['name']}")
            print(f"   Key Idea: {algo['key_idea']}")
            print(f"   Formula: {algo['formula']}")
            print(f"   Strengths: {', '.join(algo['strengths'])}")
            print(f"   Use Cases: {', '.join(algo['use_cases'])}")
            print("-"*60)

# Initialize frontier exploration
frontier = RLFrontier()
frontier.visualize_algorithm_landscape()
frontier.explain_modern_algorithms()

10.2 Challenges of Real-World RL

class RealWorldChallenges:
    """Understanding real-world RL challenges and solutions"""
    
    def __init__(self):
        self.challenges = self.identify_challenges()
    
    def identify_challenges(self):
        """Identify key real-world RL challenges"""
        print("\n" + "="*80)
        print("10.2 REAL-WORLD RL CHALLENGES")
        print("="*80)
        
        print("\nRL research often focuses on ideal conditions, but real-world")
        print("applications face unique challenges:")
        
        challenges = [
            {
                "name": "Sample Efficiency",
                "description": "Real-world interactions are expensive/slow",
                "example": "Robot needs thousands of trials to learn simple task",
                "solutions": ["Model-based RL", "Sim-to-real", "Offline RL", "Meta-learning"]
            },
            {
                "name": "Safety & Constraints",
                "description": "Cannot explore unsafe actions in real world",
                "example": "Self-driving car can't crash during learning",
                "solutions": ["Safe exploration", "Constraint optimization", "Human oversight"]
            },
            {
                "name": "Reward Specification",
                "description": "Hard to design reward functions that capture intent",
                "example": "Reward hacking - agent finds loopholes",
                "solutions": ["Inverse RL", "Preference learning", "Multi-objective RL"]
            },
            {
                "name": "Non-stationarity",
                "description": "Real world changes while agent learns",
                "example": "Environment dynamics change over time",
                "solutions": ["Continual learning", "Adaptation", "Meta-RL"]
            },
            {
                "name": "Partial Observability",
                "description": "Agent only sees part of true state",
                "example": "Robot sees limited camera view",
                "solutions": ["Memory networks", "Recurrent policies", "State estimation"]
            },
            {
                "name": "Multi-agent Coordination",
                "description": "Multiple agents learning simultaneously",
                "example": "Autonomous vehicles coordinating",
                "solutions": ["Centralized training", "Communication protocols", "Game theory"]
            },
            {
                "name": "Sim-to-Real Gap",
                "description": "Simulators don't match reality perfectly",
                "example": "Physics simulators have approximations",
                "solutions": ["Domain randomization", "System identification", "Adaptation"]
            },
            {
                "name": "Scalability",
                "description": "Algorithms don't scale to complex real problems",
                "example": "High-dimensional state/action spaces",
                "solutions": ["Hierarchical RL", "Skill learning", "Modular architectures"]
            }
        ]
        
        for i, challenge in enumerate(challenges, 1):
            print(f"\n{i}. {challenge['name'].upper()}")
            print(f"   Description: {challenge['description']}")
            print(f"   Example: {challenge['example']}")
            print(f"   Solutions: {', '.join(challenge['solutions'])}")
        
        return challenges
    
    def demonstrate_safety_challenge(self):
        """Demonstrate safety challenge in RL"""
        print("\n" + "="*80)
        print("SAFETY CHALLENGE DEMONSTRATION")
        print("="*80)
        
        class UnsafeGridWorld:
            """Grid world with unsafe states"""
            def __init__(self, size=5):
                self.size = size
                self.goal = (4, 4)
                self.unsafe = [(1, 1), (2, 2), (3, 1), (4, 0)]
                self.state = (0, 0)
                
                self.actions = [(0, 1), (1, 0), (0, -1), (-1, 0)]  # right, down, left, up
                
            def reset(self):
                self.state = (0, 0)
                return self.state
                
            def step(self, action):
                r, c = self.state
                dr, dc = self.actions[action]
                
                new_r = max(0, min(self.size-1, r + dr))
                new_c = max(0, min(self.size-1, c + dc))
                
                self.state = (new_r, new_c)
                
                # Check unsafe state
                if self.state in self.unsafe:
                    reward = -100  # Large penalty for unsafe state
                    done = True
                elif self.state == self.goal:
                    reward = 10
                    done = True
                else:
                    reward = -0.1  # Small penalty for each step
                    done = False
                
                return self.state, reward, done
                
            def render(self):
                grid = [['.' for _ in range(self.size)] for _ in range(self.size)]
                
                for r, c in self.unsafe:
                    grid[r][c] = 'X'
                
                grid[self.goal[0]][self.goal[1]] = 'G'
                
                r, c = self.state
                grid[r][c] = 'A'
                
                for row in grid:
                    print(' '.join(row))
                print()
        
        print("\n1. UNSAFE GRID WORLD:")
        print("   X = Unsafe state (-100 reward)")
        print("   G = Goal (+10 reward)")
        print("   . = Normal state (-0.1 per step)")
        
        env = UnsafeGridWorld()
        print("\n   Initial state:")
        env.render()
        
        print("\n2. NAIVE RL APPROACH (without safety):")
        print("   Risk: Agent might explore unsafe states during training")
        print("   Consequence: Catastrophic failure in real world")
        
        print("\n3. SAFE RL SOLUTIONS:")
        
        solutions = [
            ("Constrained Policy Optimization", 
             "Maximize reward subject to safety constraints"),
            ("Shielded Reinforcement Learning",
             "External safety module prevents unsafe actions"),
            ("Risk-Sensitive RL",
             "Optimize conditional value at risk (CVaR)"),
            ("Human-in-the-loop",
             "Human prevents unsafe actions during training")
        ]
        
        for name, description in solutions:
            print(f"   • {name}: {description}")
        
        # Demonstrate safe vs unsafe policies
        print("\n4. POLICY COMPARISON:")
        
        # Unsafe policy (shortest path through unsafe states)
        unsafe_path = [(0,0), (1,0), (2,0), (3,0), (4,0), (4,1), (4,2), (4,3), (4,4)]
        
        # Safe policy (avoids unsafe states)
        safe_path = [(0,0), (0,1), (0,2), (0,3), (0,4), 
                    (1,4), (2,4), (3,4), (4,4)]
        
        print(f"\n   Unsafe policy (shortest path):")
        print(f"   Length: {len(unsafe_path)} steps")
        print(f"   Risk: Goes through unsafe state (4,0)")
        
        print(f"\n   Safe policy (avoids unsafe states):")
        print(f"   Length: {len(safe_path)} steps")
        print(f"   Risk: No unsafe states")
        
        # Visualize paths
        fig, axes = plt.subplots(1, 2, figsize=(12, 5))
        
        for idx, (path, title) in enumerate([(unsafe_path, "Unsafe Policy"),
                                            (safe_path, "Safe Policy")]):
            ax = axes[idx]
            
            # Create grid
            for r in range(5):
                for c in range(5):
                    if (r, c) in env.unsafe:
                        color = 'red'
                    elif (r, c) == env.goal:
                        color = 'green'
                    elif (r, c) == (0, 0):
                        color = 'blue'
                    else:
                        color = 'white'
                    
                    ax.add_patch(plt.Rectangle((c-0.5, r-0.5), 1, 1,
                                              facecolor=color, alpha=0.3,
                                              edgecolor='black'))
            
            # Plot path
            if path:
                path_array = np.array(path)
                ax.plot(path_array[:, 1], path_array[:, 0], 'b-o', 
                       linewidth=2, markersize=8, alpha=0.7)
                ax.scatter([path_array[0, 1]], [path_array[0, 0]], 
                          color='blue', s=200, marker='o', label='Start')
                ax.scatter([path_array[-1, 1]], [path_array[-1, 0]], 
                          color='green', s=200, marker='*', label='Goal')
            
            ax.set_xlim(-0.5, 4.5)
            ax.set_ylim(-0.5, 4.5)
            ax.invert_yaxis()
            ax.set_aspect('equal')
            ax.set_title(title, fontsize=14, fontweight='bold')
            ax.set_xlabel('Column')
            ax.set_ylabel('Row')
            ax.legend()
            ax.grid(True, alpha=0.3)
        
        plt.tight_layout()
        plt.show()
        
        print("\n5. KEY TAKEAWAYS:")
        print("   • Safety must be considered from the beginning")
        print("   • Unsafe exploration is unacceptable in real systems")
        print("   • Constrained optimization is often necessary")
        print("   • Human oversight can bridge the gap during learning")
    
    def demonstrate_sim2real(self):
        """Demonstrate sim-to-real transfer challenge"""
        print("\n" + "="*80)
        print("SIM-TO-REAL TRANSFER CHALLENGE")
        print("="*80)
        
        print("\nTHE PROBLEM:")
        print("Simulators are imperfect models of reality:")
        print("• Approximate physics")
        print("• Simplified sensor models")
        print("• Missing environmental factors")
        
        print("\n" + "-"*80)
        print("DOMAIN RANDOMIZATION SOLUTION:")
        print("-"*80)
        
        print("\nIdea: Train in many randomized simulations")
        print("      so agent learns robust policy")
        
        # Simulate domain randomization
        np.random.seed(42)
        
        # Parameters that vary between sim and real
        parameters = {
            'friction': {'sim': 0.8, 'real': 1.2},
            'mass': {'sim': 1.0, 'real': 1.3},
            'sensor_noise': {'sim': 0.01, 'real': 0.05},
            'actuator_delay': {'sim': 0.0, 'real': 0.02},
            'wind': {'sim': 0.0, 'real': 0.5}
        }
        
        print("\nParameter variations:")
        for param, values in parameters.items():
            print(f"  {param:15s}: Sim={values['sim']:.3f}, Real={values['real']:.3f}")
        
        # Demonstrate domain randomization
        print("\n" + "-"*80)
        print("DOMAIN RANDOMIZATION TRAINING:")
        print("-"*80)
        
        num_training_episodes = 5
        randomization_ranges = {
            'friction': (0.5, 1.5),
            'mass': (0.7, 1.5),
            'sensor_noise': (0.0, 0.1),
            'actuator_delay': (0.0, 0.05),
            'wind': (0.0, 1.0)
        }
        
        print("\nTraining with randomized parameters:")
        for i in range(num_training_episodes):
            episode_params = {}
            for param, (low, high) in randomization_ranges.items():
                episode_params[param] = np.random.uniform(low, high)
            
            param_str = ', '.join([f"{k}:{v:.2f}" for k, v in episode_params.items()])
            print(f"  Episode {i+1}: {param_str}")
        
        # Visualize the concept
        fig, axes = plt.subplots(2, 3, figsize=(15, 10))
        
        # Plot 1: Parameter distributions
        ax = axes[0, 0]
        params = list(randomization_ranges.keys())
        sim_values = [parameters[p]['sim'] for p in params]
        real_values = [parameters[p]['real'] for p in params]
        
        x = np.arange(len(params))
        width = 0.35
        
        ax.bar(x - width/2, sim_values, width, label='Simulator', alpha=0.7)
        ax.bar(x + width/2, real_values, width, label='Reality', alpha=0.7)
        
        ax.set_xlabel('Parameter')
        ax.set_ylabel('Value')
        ax.set_title('Simulator vs Reality Parameters')
        ax.set_xticks(x)
        ax.set_xticklabels(params, rotation=45)
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 2: Domain randomization ranges
        ax = axes[0, 1]
        for i, (param, (low, high)) in enumerate(randomization_ranges.items()):
            ax.plot([low, high], [i, i], 'o-', linewidth=3, markersize=8)
            ax.scatter([parameters[param]['sim']], [i], color='blue', s=100, 
                      label='Sim' if i == 0 else '', zorder=5)
            ax.scatter([parameters[param]['real']], [i], color='red', s=100,
                      label='Real' if i == 0 else '', zorder=5)
        
        ax.set_yticks(range(len(params)))
        ax.set_yticklabels(params)
        ax.set_xlabel('Parameter Value')
        ax.set_title('Domain Randomization Ranges')
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 3: Performance comparison
        ax = axes[0, 2]
        methods = ['No Rand', 'Limited Rand', 'Full Rand']
        sim_performance = [95, 85, 75]  # Performance in simulator
        real_performance = [30, 65, 90]  # Performance in reality
        
        x = np.arange(len(methods))
        ax.bar(x - 0.2, sim_performance, 0.4, label='Simulator', alpha=0.7)
        ax.bar(x + 0.2, real_performance, 0.4, label='Reality', alpha=0.7)
        
        ax.set_xlabel('Training Method')
        ax.set_ylabel('Success Rate (%)')
        ax.set_title('Sim-to-Real Transfer Performance')
        ax.set_xticks(x)
        ax.set_xticklabels(methods)
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 4: Robustness visualization
        ax = axes[1, 0]
        
        # Simulate policy robustness
        np.random.seed(42)
        n_samples = 100
        
        # Non-robust policy
        non_robust_rewards = np.random.normal(80, 20, n_samples)
        non_robust_rewards = np.clip(non_robust_rewards, 0, 100)
        
        # Robust policy (domain randomization)
        robust_rewards = np.random.normal(85, 10, n_samples)
        robust_rewards = np.clip(robust_rewards, 0, 100)
        
        ax.boxplot([non_robust_rewards, robust_rewards], 
                  labels=['No Randomization', 'With Randomization'])
        ax.set_ylabel('Reward')
        ax.set_title('Policy Robustness to Parameter Variations')
        ax.grid(True, alpha=0.3)
        
        # Plot 5: Adaptation over time
        ax = axes[1, 1]
        
        # Simulate adaptation process
        episodes = np.arange(1, 101)
        
        # Without adaptation
        no_adapt = 30 + 70 * (1 - np.exp(-episodes / 50))
        
        # With adaptation
        with_adapt = 50 + 50 * (1 - np.exp(-episodes / 20))
        
        ax.plot(episodes, no_adapt, 'r-', label='No Adaptation', linewidth=2)
        ax.plot(episodes, with_adapt, 'g-', label='With Adaptation', linewidth=2)
        
        ax.set_xlabel('Episode')
        ax.set_ylabel('Real-World Performance')
        ax.set_title('Adaptation to Reality Over Time')
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 6: System diagram
        ax = axes[1, 2]
        ax.axis('off')
        
        # Draw system diagram
        components = [
            ("Simulator\n(Domain Randomization)", (0.3, 0.7)),
            ("RL Agent\n(Training)", (0.3, 0.5)),
            ("Policy\n(Deployment)", (0.3, 0.3)),
            ("Real World\n(Testing)", (0.7, 0.5))
        ]
        
        # Draw boxes
        for text, (x, y) in components:
            ax.add_patch(plt.Rectangle((x-0.15, y-0.1), 0.3, 0.2,
                                      facecolor='lightblue', alpha=0.5,
                                      edgecolor='black'))
            ax.text(x, y, text, ha='center', va='center', fontsize=9)
        
        # Draw arrows
        arrows = [
            ((0.3, 0.6), (0.3, 0.4)),  # Training to Policy
            ((0.3, 0.3), (0.7, 0.5)),  # Policy to Real World
            ((0.7, 0.45), (0.3, 0.55)),  # Feedback loop
        ]
        
        for (x1, y1), (x2, y2) in arrows:
            ax.annotate('', xy=(x2, y2), xytext=(x1, y1),
                       arrowprops=dict(arrowstyle='->', lw=2))
        
        ax.set_xlim(0, 1)
        ax.set_ylim(0, 1)
        ax.set_title('Sim-to-Real Pipeline', fontsize=12)
        
        plt.tight_layout()
        plt.show()
        
        print("\nKEY INSIGHTS:")
        print("1. Domain randomization creates robust policies")
        print("2. Tradeoff: Lower simulator performance for better real-world transfer")
        print("3. Adaptive policies can improve during real-world deployment")
        print("4. System identification helps bridge the sim-to-real gap")

# Run real-world challenges demonstration
print("\n" + "="*80)
print("DEMONSTRATING REAL-WORLD CHALLENGES")
print("="*80)
real_world = RealWorldChallenges()
real_world.demonstrate_safety_challenge()
real_world.demonstrate_sim2real()

10.3 Multi-Agent Reinforcement Learning

class MultiAgentRL:
    """Multi-Agent Reinforcement Learning"""
    
    def __init__(self):
        print("\n" + "="*80)
        print("10.3 MULTI-AGENT REINFORCEMENT LEARNING")
        print("="*80)
        self.introduce_marl()
    
    def introduce_marl(self):
        """Introduce Multi-Agent RL concepts"""
        print("\nWHY MULTI-AGENT RL MATTERS:")
        print("Real-world systems often involve multiple autonomous agents:")
        print("• Autonomous vehicles on roads")
        print("• Robots in warehouses")
        print("• Trading algorithms in markets")
        print("• Network routing algorithms")
        
        print("\n" + "-"*80)
        print("KEY CHALLENGES:")
        print("-"*80)
        
        challenges = [
            ("Non-stationarity", "Other agents are learning too"),
            ("Credit assignment", "Who gets credit for team success?"),
            ("Communication", "How should agents share information?"),
            ("Coordination", "Avoiding conflicts, achieving cooperation"),
            ("Scalability", "State space grows exponentially with agents")
        ]
        
        for challenge, description in challenges:
            print(f"• {challenge:20s}: {description}")
    
    def demonstrate_coordination_games(self):
        """Demonstrate coordination problems in multi-agent systems"""
        print("\n" + "="*80)
        print("COORDINATION GAMES DEMONSTRATION")
        print("="*80)
        
        # Define classic coordination games
        games = {
            "Prisoner's Dilemma": {
                "payoffs": np.array([[[3,3], [0,5]], 
                                   [[5,0], [1,1]]]),
                "description": "Individual rationality vs collective optimal"
            },
            "Stag Hunt": {
                "payoffs": np.array([[[4,4], [0,3]], 
                                   [[3,0], [2,2]]]),
                "description": "Risk dominance vs payoff dominance"
            },
            "Battle of the Sexes": {
                "payoffs": np.array([[[2,1], [0,0]], 
                                   [[0,0], [1,2]]]),
                "description": "Coordination with conflicting preferences"
            },
            "Pure Coordination": {
                "payoffs": np.array([[[1,1], [0,0]], 
                                   [[0,0], [1,1]]]),
                "description": "Simple coordination problem"
            }
        }
        
        print("\nCLASSIC COORDINATION GAMES:")
        for game_name, game_info in games.items():
            print(f"\n{game_name}:")
            print(f"  {game_info['description']}")
            print(f"  Payoff matrix (row=Agent1, col=Agent2):")
            print(f"  {game_info['payoffs'][0,0]} | {game_info['payoffs'][0,1]}")
            print(f"  {game_info['payoffs'][1,0]} | {game_info['payoffs'][1,1]}")
        
        # Simulate learning in these games
        print("\n" + "-"*80)
        print("LEARNING DYNAMICS IN COORDINATION GAMES")
        print("-"*80)
        
        class MultiAgentLearning:
            """Simple multi-agent Q-learning"""
            
            def __init__(self, payoff_matrix, learning_rate=0.1, discount=0.9, epsilon=0.1):
                self.payoff_matrix = payoff_matrix
                self.lr = learning_rate
                self.gamma = discount
                self.epsilon = epsilon
                
                # Initialize Q-tables for each agent
                self.q1 = np.zeros((2, 2))  # Agent 1: Q(state, action)
                self.q2 = np.zeros((2, 2))  # Agent 2: Q(state, action)
                
                # Keep history
                self.actions_history = []
                self.rewards_history = []
            
            def select_action(self, agent, state):
                """Epsilon-greedy action selection"""
                if np.random.random() < self.epsilon:
                    return np.random.randint(2)
                else:
                    if agent == 1:
                        return np.argmax(self.q1[state])
                    else:
                        return np.argmax(self.q2[state])
            
            def learn_step(self):
                """One learning step"""
                # Agents choose actions
                a1 = self.select_action(1, 0)
                a2 = self.select_action(2, 0)
                
                # Get rewards
                r1 = self.payoff_matrix[a1, a2, 0]
                r2 = self.payoff_matrix[a1, a2, 1]
                
                # Q-learning updates
                # For simplicity, assume next state is always 0
                next_state = 0
                
                # Agent 1 update
                best_next_q1 = np.max(self.q1[next_state])
                td_error1 = r1 + self.gamma * best_next_q1 - self.q1[0, a1]
                self.q1[0, a1] += self.lr * td_error1
                
                # Agent 2 update
                best_next_q2 = np.max(self.q2[next_state])
                td_error2 = r2 + self.gamma * best_next_q2 - self.q2[0, a2]
                self.q2[0, a2] += self.lr * td_error2
                
                # Store history
                self.actions_history.append((a1, a2))
                self.rewards_history.append((r1, r2))
                
                return (a1, a2), (r1, r2)
        
        # Run learning for different games
        fig, axes = plt.subplots(2, 2, figsize=(12, 10))
        axes = axes.flatten()
        
        for idx, (game_name, game_info) in enumerate(games.items()):
            ax = axes[idx]
            
            # Run learning
            learner = MultiAgentLearning(game_info['payoffs'])
            n_steps = 1000
            
            action_counts = np.zeros((2, 2))  # Count (a1, a2) pairs
            
            for step in range(n_steps):
                (a1, a2), _ = learner.learn_step()
                action_counts[a1, a2] += 1
            
            # Plot action distribution
            im = ax.imshow(action_counts / n_steps, cmap='YlOrRd', vmin=0, vmax=1)
            
            # Add text annotations
            for i in range(2):
                for j in range(2):
                    prob = action_counts[i, j] / n_steps
                    color = 'white' if prob > 0.5 else 'black'
                    ax.text(j, i, f'{prob:.2f}', ha='center', va='center', 
                           color=color, fontsize=12, fontweight='bold')
            
            ax.set_xticks([0, 1])
            ax.set_yticks([0, 1])
            ax.set_xticklabels(['C', 'D'] if game_name == "Prisoner's Dilemma" else ['A', 'B'])
            ax.set_yticklabels(['C', 'D'] if game_name == "Prisoner's Dilemma" else ['A', 'B'])
            ax.set_xlabel('Agent 2 Action')
            ax.set_ylabel('Agent 1 Action')
            ax.set_title(f'{game_name}\nFinal Action Distribution')
            
            # Add payoff information
            payoff_text = f'Payoffs:\n'
            for i in range(2):
                for j in range(2):
                    payoff_text += f'({game_info["payoffs"][i,j,0]},{game_info["payoffs"][i,j,1]}) '
                payoff_text += '\n'
            
            ax.text(1.5, 0.5, payoff_text, transform=ax.transAxes, 
                   fontsize=8, verticalalignment='center')
        
        plt.colorbar(im, ax=axes[-1])
        plt.tight_layout()
        plt.show()
        
        print("\nOBSERVED LEARNING DYNAMICS:")
        print("1. Prisoner's Dilemma: Often converges to (Defect, Defect) - suboptimal")
        print("2. Stag Hunt: Can get stuck in risk-dominant equilibrium")
        print("3. Battle of Sexes: May oscillate or need communication")
        print("4. Pure Coordination: Usually finds coordination but which one?")
        
        print("\n" + "-"*80)
        print("SOLUTIONS FOR MULTI-AGENT COORDINATION:")
        print("-"*80)
        
        solutions = [
            ("Centralized Training", "Train agents together with shared information"),
            ("Communication Protocols", "Agents learn to send/receive messages"),
            ("Credit Assignment", "Properly assign credit in team settings"),
            ("Opponent Modeling", "Agents learn models of other agents"),
            ("Mechanism Design", "Design rewards to encourage cooperation")
        ]
        
        for solution, description in solutions:
            print(f"• {solution:25s}: {description}")
    
    def demonstrate_maddpg(self):
        """Demonstrate MADDPG algorithm"""
        print("\n" + "="*80)
        print("MADDPG: MULTI-AGENT DDPG")
        print("="*80)
        
        print("\nMADDPG (Multi-Agent Deep Deterministic Policy Gradient)")
        print("Key Idea: Centralized training with decentralized execution")
        print("         Each agent has its own actor, but critics see all agents' info")
        
        class MADDPGComponents:
            """Visualize MADDPG architecture"""
            
            def __init__(self, n_agents=3, state_dim=4, action_dim=2):
                self.n_agents = n_agents
                self.state_dim = state_dim
                self.action_dim = action_dim
                
            def visualize_architecture(self):
                """Visualize MADDPG architecture"""
                fig, axes = plt.subplots(1, 2, figsize=(15, 6))
                
                # Left: Centralized Critic during training
                ax1 = axes[0]
                ax1.set_title('CENTRALIZED TRAINING PHASE', fontsize=14, fontweight='bold')
                ax1.axis('off')
                
                # Draw agents
                agent_y_positions = [0.8, 0.5, 0.2]
                agent_colors = ['#FF6B6B', '#4ECDC4', '#FFD166']
                
                for i, (y, color) in enumerate(zip(agent_y_positions, agent_colors)):
                    # Actor network
                    ax1.add_patch(plt.Rectangle((0.1, y-0.05), 0.2, 0.1,
                                               facecolor=color, alpha=0.7,
                                               edgecolor='black'))
                    ax1.text(0.2, y, f'Actor {i+1}\nπ_i', 
                            ha='center', va='center', fontsize=10)
                    
                    # Input arrows
                    ax1.annotate('', xy=(0.1, y), xytext=(0.05, y),
                                arrowprops=dict(arrowstyle='->', lw=1))
                    ax1.text(0.025, y, f's_i', ha='right', va='center')
                    
                    # Output arrows
                    ax1.annotate('', xy=(0.3, y), xytext=(0.35, y),
                                arrowprops=dict(arrowstyle='->', lw=1))
                    ax1.text(0.375, y, f'a_i', ha='left', va='center')
                
                # Centralized critic
                ax1.add_patch(plt.Rectangle((0.4, 0.3), 0.2, 0.4,
                                           facecolor='#06D6A0', alpha=0.7,
                                           edgecolor='black'))
                ax1.text(0.5, 0.5, 'Centralized Critic\nQ(s, a₁, a₂, a₃)',
                        ha='center', va='center', fontsize=10, fontweight='bold')
                
                # Inputs to critic
                ax1.text(0.3, 0.8, 's₁', ha='right', va='center')
                ax1.text(0.3, 0.5, 's₂', ha='right', va='center')
                ax1.text(0.3, 0.2, 's₃', ha='right', va='center')
                
                ax1.text(0.35, 0.8, 'a₁', ha='right', va='center')
                ax1.text(0.35, 0.5, 'a₂', ha='right', va='center')
                ax1.text(0.35, 0.2, 'a₃', ha='right', va='center')
                
                # Arrows to critic
                for y in agent_y_positions:
                    ax1.annotate('', xy=(0.4, y), xytext=(0.35, y),
                                arrowprops=dict(arrowstyle='->', lw=1))
                
                # Output from critic
                ax1.annotate('', xy=(0.65, 0.5), xytext=(0.6, 0.5),
                            arrowprops=dict(arrowstyle='->', lw=2))
                ax1.text(0.7, 0.5, 'Q-value\n(criticizes all)', 
                        ha='left', va='center')
                
                # Right: Decentralized execution
                ax2 = axes[1]
                ax2.set_title('DECENTRALIZED EXECUTION PHASE', fontsize=14, fontweight='bold')
                ax2.axis('off')
                
                for i, (y, color) in enumerate(zip(agent_y_positions, agent_colors)):
                    # Actor network (deployed)
                    ax2.add_patch(plt.Rectangle((0.3, y-0.05), 0.2, 0.1,
                                               facecolor=color, alpha=0.7,
                                               edgecolor='black'))
                    ax2.text(0.4, y, f'Actor {i+1}\nπ_i', 
                            ha='center', va='center', fontsize=10)
                    
                    # Only local observation
                    ax2.annotate('', xy=(0.3, y), xytext=(0.1, y),
                                arrowprops=dict(arrowstyle='->', lw=1))
                    ax2.text(0.05, y, f'Local obs\ns_i', ha='right', va='center')
                    
                    # Action output
                    ax2.annotate('', xy=(0.5, y), xytext=(0.7, y),
                                arrowprops=dict(arrowstyle='->', lw=1))
                    ax2.text(0.75, y, f'Action\na_i', ha='left', va='center')
                
                plt.tight_layout()
                plt.show()
                
                print("\nMADDPG ALGORITHM STEPS:")
                steps = [
                    ("1. Centralized Critic", "Each agent's critic sees all states and actions"),
                    ("2. Policy Gradient", "Actors updated using centralized critic"),
                    ("3. Target Networks", "Slow-updating target networks for stability"),
                    ("4. Experience Replay", "Store transitions from all agents"),
                    ("5. Decentralized Execution", "Each agent uses only its own actor")
                ]
                
                for step, description in steps:
                    print(f"{step:25s}: {description}")
        
        # Show MADDPG architecture
        maddpg_viz = MADDPGComponents()
        maddpg_viz.visualize_architecture()
        
        # Demonstrate MADDPG benefits
        print("\n" + "="*80)
        print("MADDPG BENEFITS AND RESULTS")
        print("="*80)
        
        # Simulated results comparison
        fig, axes = plt.subplots(2, 2, figsize=(12, 10))
        
        # Plot 1: Learning curves comparison
        ax = axes[0, 0]
        episodes = np.arange(1000)
        
        # Simulated learning curves
        independent_ddpg = 0.3 * (1 - np.exp(-episodes / 200)) + 0.1 * np.random.randn(1000)
        independent_ddpg = np.cumsum(np.clip(independent_ddpg, 0, None))
        
        maddpg_performance = 0.5 * (1 - np.exp(-episodes / 100)) + 0.05 * np.random.randn(1000)
        maddpg_performance = np.cumsum(np.clip(maddpg_performance, 0, None))
        
        ax.plot(episodes, independent_ddpg, 'r-', label='Independent DDPG', alpha=0.7)
        ax.plot(episodes, maddpg_performance, 'g-', label='MADDPG', alpha=0.7)
        ax.fill_between(episodes, independent_ddpg*0.9, independent_ddpg*1.1, alpha=0.2, color='red')
        ax.fill_between(episodes, maddpg_performance*0.95, maddpg_performance*1.05, alpha=0.2, color='green')
        
        ax.set_xlabel('Episode')
        ax.set_ylabel('Cumulative Reward')
        ax.set_title('Learning Curve Comparison')
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 2: Cooperation metrics
        ax = axes[0, 1]
        
        cooperation_scenarios = ['Predator-Prey', 'Multi-Walker', 'Cooperative Navigation']
        independent_scores = [0.4, 0.3, 0.35]
        maddpg_scores = [0.85, 0.75, 0.9]
        
        x = np.arange(len(cooperation_scenarios))
        width = 0.35
        
        ax.bar(x - width/2, independent_scores, width, label='Independent', alpha=0.7)
        ax.bar(x + width/2, maddpg_scores, width, label='MADDPG', alpha=0.7)
        
        ax.set_xlabel('Environment')
        ax.set_ylabel('Cooperation Score')
        ax.set_title('Coordination Performance')
        ax.set_xticks(x)
        ax.set_xticklabels(cooperation_scenarios, rotation=15)
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 3: Scalability analysis
        ax = axes[1, 0]
        
        n_agents = [2, 4, 8, 16]
        independent_times = [100, 250, 800, 3000]
        maddpg_times = [120, 180, 300, 600]
        
        ax.plot(n_agents, independent_times, 'ro-', label='Independent DDPG', markersize=8)
        ax.plot(n_agents, maddpg_times, 'go-', label='MADDPG', markersize=8)
        
        ax.set_xlabel('Number of Agents')
        ax.set_ylabel('Training Time (relative)')
        ax.set_title('Scalability Analysis')
        ax.set_xscale('log')
        ax.set_yscale('log')
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 4: Communication patterns
        ax = axes[1, 1]
        ax.axis('off')
        
        # Draw communication patterns
        communication_types = [
            ("No Communication", 0.1, 'red'),
            ("Broadcast", 0.4, 'orange'),
            ("Learned Protocols", 0.7, 'yellow'),
            ("Attention-Based", 0.9, 'green')
        ]
        
        for i, (name, score, color) in enumerate(communication_types):
            y = 0.8 - i * 0.2
            ax.add_patch(plt.Rectangle((0.2, y-0.05), score, 0.1,
                                      facecolor=color, alpha=0.7,
                                      edgecolor='black'))
            ax.text(0.15, y, name, ha='right', va='center', fontsize=10)
            ax.text(0.2 + score + 0.05, y, f'{score:.1f}', 
                   ha='left', va='center', fontsize=10)
        
        ax.text(0.5, 0.9, 'Communication Effectiveness', 
               ha='center', va='center', fontsize=12, fontweight='bold')
        ax.set_xlim(0, 1)
        ax.set_ylim(0, 1)
        
        plt.tight_layout()
        plt.show()
        
        print("\nKEY ADVANCES IN MULTI-AGENT RL:")
        print("-"*80)
        
        advances = [
            ("QMIX", "Value decomposition for cooperative tasks"),
            ("COMA", "Counterfactual multi-agent policy gradients"),
            ("CommNet", "Learned communication between agents"),
            ("TarMAC", "Targeted multi-agent communication"),
            ("LOLA", "Learning with opponent-learning awareness"),
            ("PBT", "Population-based training for diverse strategies")
        ]
        
        for advance, description in advances:
            print(f"• {advance:15s}: {description}")

# Run multi-agent RL demonstration
print("\n" + "="*80)
print("DEMONSTRATING MULTI-AGENT RL")
print("="*80)
multi_agent = MultiAgentRL()
multi_agent.demonstrate_coordination_games()
multi_agent.demonstrate_maddpg()

10.4 Meta-Reinforcement Learning

class MetaReinforcementLearning:
    """Meta-Learning: Learning to Learn"""
    
    def __init__(self):
        print("\n" + "="*80)
        print("10.4 META-REINFORCEMENT LEARNING")
        print("="*80)
        self.introduce_meta_rl()
    
    def introduce_meta_rl(self):
        """Introduce Meta-Reinforcement Learning"""
        print("\nTHE CORE IDEA:")
        print("Instead of learning a specific task...")
        print("Learn HOW TO LEARN new tasks quickly!")
        
        print("\n" + "-"*80)
        print("ANALOGY:")
        print("-"*80)
        print("Standard RL: Learning to play a specific video game")
        print("Meta-RL: Learning how to quickly learn ANY new video game")
        
        print("\n" + "-"*80)
        print("KEY CONCEPTS:")
        print("-"*80)
        
        concepts = [
            ("Task Distribution", "Set of related tasks to meta-learn from"),
            ("Inner Loop", "Fast adaptation to a new task (few-shot learning)"),
            ("Outer Loop", "Slow learning of adaptation algorithm"),
            ("Meta-Objective", "Performance after adaptation to new tasks"),
            ("Context", "Information about current task")
        ]
        
        for concept, description in concepts:
            print(f"• {concept:20s}: {description}")
    
    def demonstrate_maml(self):
        """Demonstrate Model-Agnostic Meta-Learning (MAML)"""
        print("\n" + "="*80)
        print("MAML: MODEL-AGNOSTIC META-LEARNING")
        print("="*80)
        
        print("\nMAML ALGORITHM:")
        print("1. Sample batch of tasks from task distribution")
        print("2. For each task:")
        print("   a. Compute gradient on task's data")
        print("   b. Take one gradient step (inner loop)")
        print("3. Update parameters to improve performance AFTER adaptation")
        print("4. Repeat")
        
        class MAMLVisualizer:
            """Visualize MAML optimization"""
            
            def __init__(self):
                self.task_functions = self.create_task_functions()
                
            def create_task_functions(self):
                """Create different tasks (sine waves with different phases)"""
                tasks = []
                phases = [0, np.pi/2, np.pi, 3*np.pi/2]
                
                for phase in phases:
                    def task(x, phase=phase):
                        return np.sin(x + phase)
                    tasks.append(task)
                
                return tasks
            
            def visualize_maml_optimization(self):
                """Visualize MAML optimization process"""
                fig, axes = plt.subplots(2, 2, figsize=(12, 10))
                
                # Generate data
                np.random.seed(42)
                x = np.linspace(-5, 5, 100)
                
                # Plot 1: Task distribution
                ax = axes[0, 0]
                for i, task_func in enumerate(self.task_functions):
                    y = task_func(x)
                    ax.plot(x, y, label=f'Task {i+1}', alpha=0.7)
                
                ax.set_xlabel('x')
                ax.set_ylabel('y')
                ax.set_title('Task Distribution (Different Sine Waves)')
                ax.legend()
                ax.grid(True, alpha=0.3)
                
                # Plot 2: Initial model
                ax = axes[0, 1]
                
                # Initial model (poor fit for all tasks)
                initial_model = lambda x: 0.5 * np.sin(x)
                y_initial = initial_model(x)
                
                ax.plot(x, y_initial, 'k--', linewidth=2, label='Initial Model')
                
                # Show adaptation for one task
                task_idx = 0
                task_data_x = np.array([-4, -2, 0, 2, 4])
                task_data_y = self.task_functions[task_idx](task_data_x)
                
                ax.scatter(task_data_x, task_data_y, color='red', s=50, 
                          label='Task Data', zorder=5)
                
                # One gradient step adaptation
                # Simplified: move toward task data
                adapted_model = lambda x: 0.5 * np.sin(x) + 0.3 * np.sin(x + np.pi/2)
                y_adapted = adapted_model(x)
                
                ax.plot(x, y_adapted, 'g-', linewidth=2, label='After 1-step Adaptation')
                ax.set_title('Fast Adaptation (Inner Loop)')
                ax.legend()
                ax.grid(True, alpha=0.3)
                
                # Plot 3: Meta-update visualization
                ax = axes[1, 0]
                
                # Create loss landscape visualization
                theta1 = np.linspace(-2, 2, 100)
                theta2 = np.linspace(-2, 2, 100)
                Theta1, Theta2 = np.meshgrid(theta1, theta2)
                
                # Simplified loss function
                Loss = np.zeros_like(Theta1)
                for i in range(Theta1.shape[0]):
                    for j in range(Theta2.shape[1]):
                        # Loss after adaptation to different tasks
                        losses = []
                        for task_func in self.task_functions:
                            # Simulated adaptation
                            adapted_theta1 = Theta1[i, j] + 0.1 * np.random.randn()
                            adapted_theta2 = Theta2[i, j] + 0.1 * np.random.randn()
                            
                            # Simplified loss
                            loss = (adapted_theta1 - 0.5)**2 + (adapted_theta2 - 0.3)**2
                            losses.append(loss)
                        
                        Loss[i, j] = np.mean(losses)
                
                # Plot loss landscape
                contour = ax.contourf(Theta1, Theta2, Loss, levels=20, cmap='viridis', alpha=0.8)
                plt.colorbar(contour, ax=ax)
                
                # Plot optimization path
                path_theta1 = [1.5, 1.0, 0.7, 0.5]
                path_theta2 = [1.5, 1.0, 0.8, 0.3]
                
                ax.plot(path_theta1, path_theta2, 'ro-', linewidth=2, markersize=8)
                ax.scatter(path_theta1[0], path_theta2[0], color='yellow', s=200, 
                          marker='*', label='Start', zorder=5)
                ax.scatter(path_theta1[-1], path_theta2[-1], color='red', s=200,
                          marker='X', label='MAML Optimum', zorder=5)
                
                ax.set_xlabel('Parameter θ₁')
                ax.set_ylabel('Parameter θ₂')
                ax.set_title('Meta-Learning Loss Landscape')
                ax.legend()
                ax.grid(True, alpha=0.3)
                
                # Plot 4: Few-shot learning performance
                ax = axes[1, 1]
                
                k_shots = [1, 5, 10, 20, 50]
                standard_rl = [0.2, 0.4, 0.6, 0.75, 0.9]
                meta_rl = [0.6, 0.85, 0.95, 0.98, 0.99]
                pretrained = [0.8, 0.9, 0.95, 0.97, 0.98]
                
                ax.plot(k_shots, standard_rl, 'ro-', label='Standard RL', markersize=8)
                ax.plot(k_shots, meta_rl, 'go-', label='Meta-RL (MAML)', markersize=8)
                ax.plot(k_shots, pretrained, 'bo-', label='Pretrained + Fine-tune', markersize=8)
                
                ax.set_xlabel('Number of Training Examples (k-shot)')
                ax.set_ylabel('Test Performance')
                ax.set_title('Few-Shot Learning Comparison')
                ax.set_xscale('log')
                ax.legend()
                ax.grid(True, alpha=0.3)
                
                plt.tight_layout()
                plt.show()
                
                print("\nMAML MATHEMATICAL FORMULATION:")
                print("-"*80)
                print("Inner loop (task adaptation):")
                print("  θ_i' = θ - α ∇_θ L_i(f_θ)")
                print("\nOuter loop (meta-update):")
                print("  θ ← θ - β ∇_θ Σ_i L_i(f_{θ_i'})")
                print("\nWhere:")
                print("  α: Inner learning rate (fast adaptation)")
                print("  β: Outer learning rate (meta-learning rate)")
                print("  L_i: Loss for task i")
                print("  θ_i': Task-specific adapted parameters")
        
        # Show MAML visualization
        maml_viz = MAMLVisualizer()
        maml_viz.visualize_maml_optimization()
        
        print("\n" + "="*80)
        print("META-RL APPLICATIONS")
        print("="*80)
        
        applications = [
            ("Robotics", "Quickly adapt to new objects/environments"),
            ("Personalization", "Adapt to individual user preferences"),
            ("Continual Learning", "Learn sequences of related tasks"),
            ("Sim-to-Real", "Adapt quickly to real-world variations"),
            ("Game Playing", "Quickly learn new games or levels")
        ]
        
        print("\nREAL-WORLD APPLICATIONS:")
        for app, description in applications:
            print(f"• {app:20s}: {description}")
        
        print("\n" + "-"*80)
        print("META-RL ALGORITHMS:")
        print("-"*80)
        
        algorithms = [
            ("RL²", "Recurrent model that learns learning algorithm"),
            ("PEARL", "Probabilistic embeddings for actor-critic meta-learning"),
            ("VariBAD", "Variational Bayes-Adaptive Deep RL"),
            ("CAVIA", "Fast adaptation via context parameters"),
            ("ProMP", "Proximal meta-policy search")
        ]
        
        for algo, description in algorithms:
            print(f"• {algo:15s}: {description}")

# Run meta-RL demonstration
print("\n" + "="*80)
print("DEMONSTRATING META-REINFORCEMENT LEARNING")
print("="*80)
meta_rl = MetaReinforcementLearning()
meta_rl.demonstrate_maml()

10.5 Reinforcement Learning from Human Feedback (RLHF)

class RLHFExplainer:
    """Reinforcement Learning from Human Feedback"""
    
    def __init__(self):
        print("\n" + "="*80)
        print("10.5 REINFORCEMENT LEARNING FROM HUMAN FEEDBACK (RLHF)")
        print("="*80)
        self.introduce_rlhf()
    
    def introduce_rlhf(self):
        """Introduce RLHF"""
        print("\nTHE RLHF REVOLUTION:")
        print("How ChatGPT, Claude, and other LLMs were trained!")
        
        print("\n" + "-"*80)
        print("WHY RLHF?")
        print("-"*80)
        print("1. Reward functions are hard to specify for complex tasks")
        print("2. Humans have nuanced preferences hard to capture in rewards")
        print("3. Alignment: Make AI systems helpful, harmless, honest")
        print("4. Natural interface: Humans provide feedback naturally")
    
    def demonstrate_rlhf_pipeline(self):
        """Demonstrate the RLHF pipeline"""
        print("\n" + "="*80)
        print("RLHF PIPELINE FOR LANGUAGE MODELS")
        print("="*80)
        
        # Create visualization
        fig, axes = plt.subplots(2, 3, figsize=(15, 10))
        
        # Step 1: Supervised Fine-Tuning
        ax = axes[0, 0]
        ax.axis('off')
        
        ax.text(0.5, 0.8, 'STEP 1:', ha='center', va='center', 
               fontsize=12, fontweight='bold')
        ax.text(0.5, 0.7, 'Supervised Fine-Tuning', ha='center', va='center',
               fontsize=11)
        
        # Draw SFT process
        ax.add_patch(plt.Rectangle((0.2, 0.4), 0.3, 0.2,
                                  facecolor='lightblue', alpha=0.7,
                                  edgecolor='black'))
        ax.text(0.35, 0.5, 'Pre-trained\nLLM', ha='center', va='center')
        
        ax.annotate('', xy=(0.5, 0.5), xytext=(0.65, 0.5),
                   arrowprops=dict(arrowstyle='->', lw=1))
        
        ax.add_patch(plt.Rectangle((0.65, 0.4), 0.3, 0.2,
                                  facecolor='lightgreen', alpha=0.7,
                                  edgecolor='black'))
        ax.text(0.8, 0.5, 'SFT Model', ha='center', va='center')
        
        ax.text(0.35, 0.3, 'Human-written\ndemonstrations', 
               ha='center', va='center', fontsize=9)
        
        # Step 2: Reward Model Training
        ax = axes[0, 1]
        ax.axis('off')
        
        ax.text(0.5, 0.8, 'STEP 2:', ha='center', va='center',
               fontsize=12, fontweight='bold')
        ax.text(0.5, 0.7, 'Reward Model Training', ha='center', va='center',
               fontsize=11)
        
        # Draw comparison data collection
        ax.text(0.3, 0.6, 'Prompt:', ha='right', va='center')
        ax.text(0.35, 0.6, '"Explain RLHF"', ha='left', va='center',
               fontsize=9, style='italic')
        
        ax.text(0.3, 0.5, 'Response A:', ha='right', va='center')
        ax.text(0.35, 0.5, 'Good explanation...', ha='left', va='center',
               fontsize=8, style='italic', color='green')
        
        ax.text(0.3, 0.4, 'Response B:', ha='right', va='center')
        ax.text(0.35, 0.4, 'Bad explanation...', ha='left', va='center',
               fontsize=8, style='italic', color='red')
        
        ax.text(0.6, 0.45, 'Human\nPreference\n(A > B)', 
               ha='center', va='center', fontsize=9,
               bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.5))
        
        ax.annotate('', xy=(0.7, 0.45), xytext=(0.8, 0.45),
                   arrowprops=dict(arrowstyle='->', lw=1))
        
        ax.add_patch(plt.Rectangle((0.8, 0.35), 0.15, 0.2,
                                  facecolor='orange', alpha=0.7,
                                  edgecolor='black'))
        ax.text(0.875, 0.45, 'Reward\nModel', ha='center', va='center',
               fontsize=9)
        
        # Step 3: RL Fine-Tuning
        ax = axes[0, 2]
        ax.axis('off')
        
        ax.text(0.5, 0.8, 'STEP 3:', ha='center', va='center',
               fontsize=12, fontweight='bold')
        ax.text(0.5, 0.7, 'RL Fine-Tuning (PPO)', ha='center', va='center',
               fontsize=11)
        
        # Draw RL loop
        ax.add_patch(plt.Rectangle((0.2, 0.4), 0.2, 0.2,
                                  facecolor='lightgreen', alpha=0.7,
                                  edgecolor='black'))
        ax.text(0.3, 0.5, 'Policy\n(LLM)', ha='center', va='center',
               fontsize=9)
        
        ax.text(0.1, 0.5, 'Prompt', ha='right', va='center')
        ax.annotate('', xy=(0.2, 0.5), xytext=(0.1, 0.5),
                   arrowprops=dict(arrowstyle='->', lw=1))
        
        ax.text(0.3, 0.3, 'Response', ha='center', va='center')
        ax.annotate('', xy=(0.3, 0.4), xytext=(0.3, 0.35),
                   arrowprops=dict(arrowstyle='->', lw=1))
        
        ax.add_patch(plt.Rectangle((0.5, 0.4), 0.2, 0.2,
                                  facecolor='orange', alpha=0.7,
                                  edgecolor='black'))
        ax.text(0.6, 0.5, 'Reward\nModel', ha='center', va='center',
               fontsize=9)
        
        ax.text(0.6, 0.3, 'Reward', ha='center', va='center')
        ax.annotate('', xy=(0.6, 0.4), xytext=(0.6, 0.35),
                   arrowprops=dict(arrowstyle='->', lw=1))
        
        ax.annotate('', xy=(0.7, 0.5), xytext=(0.5, 0.5),
                   arrowprops=dict(arrowstyle='->', lw=1, color='red'))
        ax.text(0.6, 0.6, 'Update Policy\nvia PPO', ha='center', va='center',
               fontsize=8, color='red')
        
        # Plot 4: Reward model architecture
        ax = axes[1, 0]
        
        # Simulate reward model scores
        np.random.seed(42)
        n_samples = 50
        
        good_responses = np.random.normal(0.8, 0.1, n_samples)
        bad_responses = np.random.normal(0.3, 0.2, n_samples)
        
        positions = np.arange(n_samples)
        
        ax.scatter(positions, good_responses, alpha=0.6, 
                  label='Preferred responses', color='green')
        ax.scatter(positions, bad_responses, alpha=0.6,
                  label='Dispreferred responses', color='red')
        
        ax.axhline(y=0.5, color='black', linestyle='--', alpha=0.5)
        ax.text(n_samples/2, 0.52, 'Decision Boundary', 
               ha='center', va='bottom', fontsize=9)
        
        ax.set_xlabel('Sample Index')
        ax.set_ylabel('Reward Model Score')
        ax.set_title('Reward Model Discrimination')
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 5: KL divergence constraint
        ax = axes[1, 1]
        
        # Simulate KL divergence during training
        episodes = np.arange(100)
        kl_divergence = 0.1 + 0.5 * np.exp(-episodes / 20) + 0.05 * np.random.randn(100)
        
        ax.plot(episodes, kl_divergence, 'b-', linewidth=2)
        ax.axhline(y=0.15, color='red', linestyle='--', alpha=0.7, 
                  label='KL constraint')
        ax.fill_between(episodes, 0, 0.15, alpha=0.1, color='red')
        
        ax.set_xlabel('Training Step')
        ax.set_ylabel('KL Divergence')
        ax.set_title('KL Constraint in RLHF')
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 6: Human preference alignment
        ax = axes[1, 2]
        
        aspects = ['Helpfulness', 'Harmlessness', 'Honesty', 'Coherence']
        before_rlhf = [0.6, 0.5, 0.7, 0.8]
        after_rlhf = [0.9, 0.85, 0.88, 0.85]
        
        x = np.arange(len(aspects))
        width = 0.35
        
        ax.bar(x - width/2, before_rlhf, width, label='Before RLHF', alpha=0.7)
        ax.bar(x + width/2, after_rlhf, width, label='After RLHF', alpha=0.7)
        
        ax.set_xlabel('Alignment Aspect')
        ax.set_ylabel('Human Preference Score')
        ax.set_title('RLHF Alignment Improvement')
        ax.set_xticks(x)
        ax.set_xticklabels(aspects)
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        plt.tight_layout()
        plt.show()
        
        print("\n" + "="*80)
        print("RLHF KEY COMPONENTS")
        print("="*80)
        
        components = [
            ("Reward Modeling", "Learn human preferences from comparisons"),
            ("KL Penalty", "Prevent policy from drifting too far from original"),
            ("PPO Algorithm", "Optimize policy with reward model feedback"),
            ("Iterative Refinement", "Collect more data, retrain, repeat")
        ]
        
        for component, description in components:
            print(f"• {component:20s}: {description}")
        
        print("\n" + "-"*80)
        print("RLHF CHALLENGES:")
        print("-"*80)
        
        challenges = [
            ("Scalability", "Need massive human preference data"),
            ("Bias", "Human preferences contain biases"),
            ("Reward Hacking", "Policy learns to exploit reward model"),
            ("Overoptimization", "Goodhart's law: metric ≠ objective")
        ]
        
        for challenge, description in challenges:
            print(f"• {challenge:20s}: {description}")
        
        print("\n" + "-"*80)
        print("ADVANCES BEYOND RLHF:")
        print("-"*80)
        
        advances = [
            ("Constitutional AI", "AI critiques and improves itself"),
            ("Direct Preference Optimization", "Simpler alternative to RLHF"),
            ("RAFT", "Rejection sampling fine-tuning"),
            ("STaR", "Self-taught reasoner")
        ]
        
        for advance, description in advances:
            print(f"• {advance:25s}: {description}")

# Run RLHF demonstration
print("\n" + "="*80)
print("DEMONSTRATING REINFORCEMENT LEARNING FROM HUMAN FEEDBACK")
print("="*80)
rlhf = RLHFExplainer()
rlhf.demonstrate_rlhf_pipeline()

10.6 The Future of Reinforcement Learning

class RLFuture:
    """The Future of Reinforcement Learning"""
    
    def __init__(self):
        print("\n" + "="*80)
        print("10.6 THE FUTURE OF REINFORCEMENT LEARNING")
        print("="*80)
        self.explore_future_directions()
    
    def explore_future_directions(self):
        """Explore future directions in RL"""
        print("\nGRAND CHALLENGES:")
        print("-"*80)
        
        challenges = [
            ("Artificial General Intelligence", "Human-level learning and reasoning"),
            ("Real-World Autonomy", "Robots that learn complex skills"),
            ("Scientific Discovery", "AI scientists making breakthroughs"),
            ("Personal AI Assistants", "Agents that understand and help individuals"),
            ("AI Alignment", "Ensuring AI systems are safe and beneficial")
        ]
        
        for i, (challenge, description) in enumerate(challenges, 1):
            print(f"{i}. {challenge}:")
            print(f"   {description}")
        
        print("\n" + "="*80)
        print("EMERGING RESEARCH DIRECTIONS")
        print("="*80)
        
        # Create future roadmap visualization
        fig, axes = plt.subplots(2, 2, figsize=(14, 10))
        
        # Plot 1: Research trends timeline
        ax = axes[0, 0]
        
        research_areas = [
            ("Foundation Models\nfor RL", 2024, 2030),
            ("World Models\n& Planning", 2023, 2028),
            ("Causal RL", 2025, 2032),
            ("Neuro-Symbolic RL", 2024, 2031),
            ("Lifelong RL", 2023, 2035),
            ("Embodied AI", 2024, 2033)
        ]
        
        current_year = 2024
        years = np.arange(2023, 2036)
        
        for i, (area, start, end) in enumerate(research_areas):
            y_pos = len(research_areas) - i - 1
            ax.plot([start, end], [y_pos, y_pos], 'o-', linewidth=3, markersize=8)
            ax.text(end + 0.2, y_pos, area, va='center', fontsize=9)
        
        ax.axvline(x=current_year, color='red', linestyle='--', alpha=0.7)
        ax.text(current_year, len(research_areas) - 0.5, 'Now', 
               ha='center', color='red', fontweight='bold')
        
        ax.set_xlabel('Year')
        ax.set_title('RL Research Roadmap')
        ax.set_yticks([])
        ax.grid(True, alpha=0.3, axis='x')
        
        # Plot 2: Capability progression
        ax = axes[0, 1]
        
        capabilities = [
            "Game Playing\n(Chess, Go)",
            "Video Games\n(Atari, Dota)",
            "Robotics\n(Manipulation)",
            "Autonomous\nDriving",
            "Scientific\nDiscovery",
            "AGI"
        ]
        
        difficulty = [1, 3, 5, 7, 9, 10]
        current_level = [1, 0.8, 0.5, 0.3, 0.1, 0.01]
        
        x = np.arange(len(capabilities))
        
        ax.bar(x, difficulty, alpha=0.3, color='gray', label='Theoretical Difficulty')
        ax.bar(x, [d * c for d, c in zip(difficulty, current_level)], 
              alpha=0.7, color='blue', label='Current Progress')
        
        ax.set_xlabel('Domain')
        ax.set_ylabel('Progress / Difficulty')
        ax.set_title('RL Capability Progression')
        ax.set_xticks(x)
        ax.set_xticklabels(capabilities, rotation=45, ha='right')
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        # Plot 3: Technology readiness levels
        ax = axes[1, 0]
        
        technologies = [
            ("Model-Based RL", 6),
            ("Multi-Agent RL", 5),
            ("Meta-RL", 4),
            ("Offline RL", 7),
            ("Hierarchical RL", 5),
            ("Inverse RL", 4),
            ("RLHF", 8)
        ]
        
        tech_names = [t[0] for t in technologies]
        trl_levels = [t[1] for t in technologies]
        
        x = np.arange(len(technologies))
        
        colors = plt.cm.RdYlGn(trl_levels / 9)
        bars = ax.barh(x, trl_levels, color=colors)
        
        ax.set_xlabel('Technology Readiness Level (1-9)')
        ax.set_title('RL Technology Readiness')
        ax.set_yticks(x)
        ax.set_yticklabels(tech_names)
        ax.set_xlim(0, 9)
        
        # Add TRL annotations
        trl_descriptions = {
            1: 'Basic principles',
            3: 'Proof of concept',
            5: 'Lab validation',
            7: 'System prototype',
            9: 'Mission proven'
        }
        
        for level, desc in trl_descriptions.items():
            ax.axvline(x=level, color='gray', linestyle=':', alpha=0.5)
            ax.text(level, -0.5, desc, ha='center', fontsize=8, rotation=45)
        
        # Plot 4: Societal impact areas
        ax = axes[1, 1]
        
        impact_areas = [
            'Healthcare\n& Medicine',
            'Education\n& Tutoring',
            'Climate\n& Energy',
            'Transportation\n& Logistics',
            'Manufacturing\n& Robotics',
            'Finance\n& Economics',
            'Art\n& Creativity'
        ]
        
        impact_scores = [9, 8, 9, 8, 7, 6, 5]
        timeframe = [5, 3, 10, 5, 4, 3, 2]
        
        scatter = ax.scatter(timeframe, impact_scores, s=200, alpha=0.6,
                           c=timeframe, cmap='viridis')
        
        for i, area in enumerate(impact_areas):
            ax.annotate(area, (timeframe[i], impact_scores[i]),
                       xytext=(5, 5), textcoords='offset points',
                       fontsize=9, ha='left')
        
        ax.set_xlabel('Time to Impact (years)')
        ax.set_ylabel('Potential Impact (1-10)')
        ax.set_title('Societal Impact of RL')
        ax.grid(True, alpha=0.3)
        
        plt.colorbar(scatter, ax=ax, label='Years to Impact')
        
        plt.tight_layout()
        plt.show()
        
        print("\n" + "="*80)
        print("KEY TECHNICAL BREAKTHROUGHS NEEDED")
        print("="*80)
        
        breakthroughs = [
            ("Sample Efficiency", "Learn complex tasks with human-level data"),
            ("Compositionality", "Combine learned skills in novel ways"),
            ("Causal Understanding", "Reason about cause and effect"),
            ("Common Sense", "Background knowledge about the world"),
            ("Theory of Mind", "Understand other agents' mental states"),
            ("Safe Exploration", "Learn without catastrophic failures")
        ]
        
        for breakthrough, description in breakthroughs:
            print(f"• {breakthrough:25s}: {description}")
        
        print("\n" + "="*80)
        print("PRACTICAL ADVICE FOR RL PRACTITIONERS")
        print("="*80)
        
        advice = [
            ("Start Simple", "Begin with well-understood algorithms (PPO, SAC)"),
            ("Understand the Basics", "Master fundamentals before cutting-edge"),
            ("Reproduce Papers", "Build intuition by implementing from scratch"),
            ("Join Communities", "RL is fast-moving - stay connected"),
            ("Work on Real Problems", "Theory needs practical validation"),
            ("Consider Ethics", "Think about societal impact from day one")
        ]
        
        for item, description in advice:
            print(f"• {item:25s}: {description}")

# Explore the future of RL
print("\n" + "="*80)
print("EXPLORING THE FUTURE OF REINFORCEMENT LEARNING")
print("="*80)
future = RLFuture()

10.7 Putting It All Together: Complete RL System

class CompleteRLSystem:
    """Complete RL System Design Pattern"""
    
    def __init__(self):
        print("\n" + "="*80)
        print("10.7 PUTTING IT ALL TOGETHER: COMPLETE RL SYSTEM")
        print("="*80)
        self.design_complete_system()
    
    def design_complete_system(self):
        """Design a complete RL system from scratch"""
        print("\nA production-ready RL system has many components:")
        
        class ProductionRLSystem:
            """Blueprint for production RL system"""
            
            def __init__(self):
                self.components = self.define_system_components()
                
            def define_system_components(self):
                """Define all components of a production RL system"""
                components = {
                    "Data Pipeline": [
                        "Experience collection",
                        "Replay buffers",
                        "Data augmentation",
                        "Feature engineering",
                        "Data validation"
                    ],
                    "Model Architecture": [
                        "Network design",
                        "Normalization layers",
                        "Regularization",
                        "Model checkpointing",
                        "Version control"
                    ],
                    "Training System": [
                        "Distributed training",
                        "Hyperparameter tuning",
                        "Curriculum learning",
                        "Monitoring & logging",
                        "Early stopping"
                    ],
                    "Evaluation Framework": [
                        "Test environments",
                        "A/B testing",
                        "Statistical significance",
                        "Performance metrics",
                        "Safety validation"
                    ],
                    "Deployment System": [
                        "Model serving",
                        "Rollout policies",
                        "Online learning",
                        "Fail-safe mechanisms",
                        "Rollback procedures"
                    ],
                    "Monitoring & Ops": [
                        "Performance monitoring",
                        "Drift detection",
                        "Alerting systems",
                        "Resource management",
                        "Cost optimization"
                    ]
                }
                return components
            
            def visualize_system_architecture(self):
                """Visualize complete system architecture"""
                fig, ax = plt.subplots(figsize=(14, 8))
                ax.axis('off')
                
                # Draw system architecture
                system_layers = [
                    ("Environment\n& Simulators", 0.1, 0.9, '#FFB74D'),
                    ("Data Collection\nPipeline", 0.3, 0.9, '#4FC3F7'),
                    ("Training\nCluster", 0.5, 0.9, '#81C784'),
                    ("Model Registry\n& Versioning", 0.7, 0.9, '#FF8A65'),
                    ("Serving\nInfrastructure", 0.9, 0.9, '#9575CD')
                ]
                
                # Draw monitoring and ops layer
                ax.add_patch(plt.Rectangle((0.05, 0.05), 0.9, 0.1,
                                          facecolor='#90A4AE', alpha=0.3,
                                          edgecolor='black'))
                ax.text(0.5, 0.1, 'MONITORING & OPERATIONS\n'
                       'Logging | Alerting | Metrics | Dashboards',
                       ha='center', va='center', fontsize=10)
                
                # Draw main components
                for name, x, y, color in system_layers:
                    # Component box
                    ax.add_patch(plt.Rectangle((x-0.08, y-0.08), 0.16, 0.16,
                                              facecolor=color, alpha=0.8,
                                              edgecolor='black', linewidth=2))
                    ax.text(x, y, name, ha='center', va='center', 
                           fontsize=9, fontweight='bold')
                    
                    # Draw connections to monitoring
                    ax.annotate('', xy=(x, 0.15), xytext=(x, y-0.08),
                               arrowprops=dict(arrowstyle='->', 
                                             connectionstyle="arc3,rad=-0.2",
                                             alpha=0.5))
                
                # Draw data flow arrows between components
                for i in range(len(system_layers)-1):
                    x1, y1 = system_layers[i][1], system_layers[i][2] - 0.08
                    x2, y2 = system_layers[i+1][1] - 0.08, system_layers[i+1][2]
                    
                    ax.annotate('', xy=(x2, y2), xytext=(x1, y1),
                               arrowprops=dict(arrowstyle='->', 
                                             shrinkA=10, shrinkB=10,
                                             connectionstyle="arc3,rad=0.2",
                                             alpha=0.7, linewidth=2))
                
                # Add feedback loop
                ax.annotate('', xy=(0.1, 0.82), xytext=(0.9, 0.82),
                           arrowprops=dict(arrowstyle='->',
                                         connectionstyle="arc3,rad=-0.5",
                                         alpha=0.5, linestyle='--',
                                         color='red'))
                ax.text(0.5, 0.85, 'Feedback Loop\n(Policy updates)',
                       ha='center', va='center', color='red', fontsize=9)
                
                ax.set_xlim(0, 1)
                ax.set_ylim(0, 1)
                ax.set_title('Production RL System Architecture', 
                           fontsize=14, fontweight='bold', pad=20)
                
                plt.show()
                
                print("\n" + "="*80)
                print("SYSTEM COMPONENTS DETAIL")
                print("="*80)
                
                for component, subcomponents in self.components.items():
                    print(f"\n{component}:")
                    for sub in subcomponents:
                        print(f"  • {sub}")
            
            def demonstrate_mlops_pipeline(self):
                """Demonstrate MLOps pipeline for RL"""
                print("\n" + "="*80)
                print("MLOPS FOR RL: CONTINUOUS TRAINING & DEPLOYMENT")
                print("="*80)
                
                # Create pipeline visualization
                fig, axes = plt.subplots(2, 1, figsize=(14, 8))
                
                # Pipeline diagram
                ax = axes[0]
                ax.axis('off')
                
                pipeline_stages = [
                    ("Data\nCollection", 0.1, 0.5, '#E3F2FD'),
                    ("Data\nValidation", 0.25, 0.5, '#F3E5F5'),
                    ("Feature\nEngineering", 0.4, 0.5, '#E8F5E8'),
                    ("Model\nTraining", 0.55, 0.5, '#FFF3E0'),
                    ("Model\nEvaluation", 0.7, 0.5, '#FFEBEE'),
                    ("Model\nDeployment", 0.85, 0.5, '#E8EAF6')
                ]
                
                for i, (stage, x, y, color) in enumerate(pipeline_stages):
                    # Stage box
                    ax.add_patch(plt.Rectangle((x-0.07, y-0.07), 0.14, 0.14,
                                              facecolor=color, alpha=0.8,
                                              edgecolor='black', linewidth=2))
                    ax.text(x, y, stage, ha='center', va='center',
                           fontsize=9, fontweight='bold')
                    
                    # Stage number
                    ax.text(x, y+0.1, f'Stage {i+1}', 
                           ha='center', va='center', fontsize=8)
                    
                    # Connect stages
                    if i < len(pipeline_stages) - 1:
                        next_x = pipeline_stages[i+1][1] - 0.07
                        ax.annotate('', xy=(next_x, y), xytext=(x+0.07, y),
                                   arrowprops=dict(arrowstyle='->', lw=1.5))
                
                # Add trigger sources
                triggers = [
                    ("New Data\nAvailable", 0.1, 0.8),
                    ("Model Drift\nDetected", 0.4, 0.8),
                    ("Code\nChanges", 0.55, 0.8),
                    ("Scheduled\nRetraining", 0.85, 0.8)
                ]
                
                for trigger, x, y in triggers:
                    ax.text(x, y, trigger, ha='center', va='center',
                           fontsize=8, style='italic',
                           bbox=dict(boxstyle='round', facecolor='yellow', alpha=0.5))
                    ax.annotate('', xy=(x, y-0.05), xytext=(x, 0.57),
                               arrowprops=dict(arrowstyle='->', 
                                             linestyle=':', alpha=0.7))
                
                ax.set_xlim(0, 1)
                ax.set_ylim(0, 1)
                ax.set_title('Continuous RL Training Pipeline', 
                           fontsize=12, fontweight='bold')
                
                # Monitoring dashboard
                ax = axes[1]
                
                # Simulate monitoring metrics
                time_points = np.arange(100)
                
                # Performance metrics
                reward_metric = 0.7 + 0.3 * np.sin(time_points/10) + 0.05 * np.random.randn(100)
                safety_metric = 0.9 - 0.1 * np.sin(time_points/15) + 0.02 * np.random.randn(100)
                efficiency_metric = 0.8 + 0.15 * np.cos(time_points/20) + 0.03 * np.random.randn(100)
                
                # Plot metrics
                ax.plot(time_points, reward_metric, 'b-', label='Average Reward', linewidth=2)
                ax.plot(time_points, safety_metric, 'g-', label='Safety Score', linewidth=2)
                ax.plot(time_points, efficiency_metric, 'r-', label='Efficiency', linewidth=2)
                
                # Add deployment markers
                deployment_times = [20, 45, 70]
                for deploy_time in deployment_times:
                    ax.axvline(x=deploy_time, color='purple', linestyle='--', alpha=0.7)
                    ax.text(deploy_time, 0.95, 'Deploy', rotation=90, 
                           ha='right', va='top', color='purple')
                
                ax.set_xlabel('Time (days)')
                ax.set_ylabel('Metric Value')
                ax.set_title('RL System Monitoring Dashboard')
                ax.legend()
                ax.grid(True, alpha=0.3)
                ax.set_ylim(0, 1)
                
                plt.tight_layout()
                plt.show()
                
                print("\nBEST PRACTICES FOR PRODUCTION RL:")
                print("-"*80)
                
                practices = [
                    ("Version Everything", "Code, data, models, hyperparameters"),
                    ("Automate Testing", "Unit tests, integration tests, stress tests"),
                    ("Monitor Continuously", "Performance, data drift, resource usage"),
                    ("Plan for Failures", "Rollback strategies, safe exploration"),
                    ("Document Thoroughly", "System design, decisions, limitations"),
                    ("Start Small", "Pilot projects before full deployment")
                ]
                
                for practice, details in practices:
                    print(f"• {practice:25s}: {details}")
            
            def create_final_checklist(self):
                """Create final checklist for RL projects"""
                print("\n" + "="*80)
                print("RL PROJECT CHECKLIST")
                print("="*80)
                
                checklist = {
                    "Problem Formulation": [
                        "Clearly define the MDP (states, actions, rewards)",
                        "Consider partial observability",
                        "Design appropriate reward function",
                        "Identify constraints and safety requirements"
                    ],
                    "Algorithm Selection": [
                        "Match algorithm to problem characteristics",
                        "Consider sample efficiency requirements",
                        "Evaluate computational constraints",
                        "Plan for exploration strategy"
                    ],
                    "Implementation": [
                        "Start with simple baselines",
                        "Implement proper logging and visualization",
                        "Use version control from day one",
                        "Write modular, testable code"
                    ],
                    "Experimentation": [
                        "Systematic hyperparameter tuning",
                        "Statistical significance testing",
                        "Ablation studies",
                        "Compare against appropriate baselines"
                    ],
                    "Deployment": [
                        "Robustness testing",
                        "Performance benchmarking",
                        "Safety validation",
                        "Monitoring infrastructure"
                    ],
                    "Ethics & Safety": [
                        "Consider potential misuse",
                        "Plan for failure modes",
                        "Document limitations",
                        "Establish governance procedures"
                    ]
                }
                
                for category, items in checklist.items():
                    print(f"\n{category.upper()}:")
                    for item in items:
                        print(f"  ☐ {item}")
        
        # Create and demonstrate complete system
        system = ProductionRLSystem()
        system.visualize_system_architecture()
        system.demonstrate_mlops_pipeline()
        system.create_final_checklist()
        
        print("\n" + "="*80)
        print("THE RL JOURNEY: FROM THEORY TO PRACTICE")
        print("="*80)
        
        journey_stages = [
            ("Chapter 1-3: Foundations", "MDPs, Dynamic Programming, Monte Carlo"),
            ("Chapter 4-5: Value-Based Methods", "Q-learning, DQN, Deep RL"),
            ("Chapter 6: Policy-Based Methods", "REINFORCE, Policy Gradients"),
            ("Chapter 7: Actor-Critic Methods", "A2C, A3C, PPO, SAC"),
            ("Chapter 8: Advanced Topics", "Exploration, Transfer Learning, Hierarchical RL"),
            ("Chapter 9: Deep Dive", "Transformers, Attention, Advanced Architectures"),
            ("Chapter 10: The Frontier", "Modern Algorithms, Real-World Challenges, Future")
        ]
        
        print("\nYour learning journey through this book:")
        for stage, description in journey_stages:
            print(f"• {stage:30s}: {description}")
        
        print("\n" + "="*80)
        print("FINAL WORDS OF ENCOURAGEMENT")
        print("="*80)
        
        final_thoughts = [
            "Reinforcement Learning is one of the most exciting fields in AI.",
            "You're learning a technology that's shaping the future.",
            "The challenges are significant, but so are the opportunities.",
            "Every expert was once where you are now.",
            "Your unique perspective could lead to the next breakthrough.",
            "Go build, experiment, learn, and contribute!"
        ]
        
        for thought in final_thoughts:
            print(f"• {thought}")
        
        print("\n" + "="*80)
        print("🚀 THE ADVENTURE CONTINUES... 🚀")
        print("="*80)

# Create complete RL system
print("\n" + "="*80)
print("BUILDING COMPLETE RL SYSTEMS")
print("="*80)
complete_system = CompleteRLSystem()

print("\n" + "="*80)
print("CHAPTER 10 COMPLETE!")
print("="*80)
print("\nCongratulations on completing this comprehensive journey through")
print("Reinforcement Learning! You now have:")
print("\n• A deep understanding of RL fundamentals")
print("• Practical experience with modern algorithms")
print("• Awareness of real-world challenges")
print("• Vision for the future of the field")
print("\nThe frontier awaits your contributions!")
print("="*80)

This is the complete Chapter 10. It's structured as:

  1. 10.1 Modern RL Algorithms - Overview of cutting-edge algorithms
  2. 10.2 Real-World Challenges - Practical issues and solutions
  3. 10.3 Multi-Agent RL - Systems with multiple interacting agents
  4. 10.4 Meta-RL - Learning to learn quickly
  5. 10.5 RLHF - How modern AI systems are trained
  6. 10.6 Future of RL - Where the field is heading
  7. 10.7 Complete Systems - Building production-ready RL systems

Each section includes:

  • Theoretical explanations
  • Visualizations
  • Code demonstrations
  • Practical insights
  • Key takeaways

The chapter builds from fundamentals to frontier research, ending with practical advice for building real-world systems.

Clone this wiki locally