Table of contents

Reinforcement Learning Coding Problems — The Comprehensive Pack

Frequency tiers (estimates, relative to interviews targeting this pack's specialty — a ★★★★★ here means "core for these roles", not for generalist ML loops): ★★★★★ expect it in most loops · ★★★★ very common · ★★★ common · ★★ occasional · rare/deep-specialist. Spend ~50% of practice on the top two tiers.

75+ problems across MDPs, bandits, tabular, deep value, policy gradient, continuous control, model-based, exploration, offline, MCTS, RLHF/LLM Each with full PyTorch / NumPy solution and test cases

Principal/Senior-Principal RL Interview Prep

Notes

This pack collects the live-coding questions that come up in principal-level Reinforcement Learning inter- views and at hiring loops for RL research engineers (DeepMind, OpenAI, Anthropic, Meta FAIR, NVIDIA, autonomous-driving stacks, robotics labs). Each problem has: (i) a precise statement, (ii) a clean reference solution in NumPy or PyTorch, (iii) tests that verify correctness against either a closed-form property or a known regime (e.g. deterministic GridWorld optimal value, regret bounds, or sign of policy improvement). Standard imports across the pack:

import math, random, numpy as np, torch
import torch.nn as nn, torch.nn.functional as F
from collections import deque

I. MDP foundations

1. Discounted return — ★★★★★

Problem

Given a list of rewards r0, . . . , rT−1 and discount γ, compute the discounted return at each timestep: Gt = ΣT−1k=t γk−trk.

def discounted_returns(rewards, gamma=0.99):
    G = 0.0; out = [0.0] * len(rewards)
    for t in reversed(range(len(rewards))):
        G = rewards[t] + gamma * G
        out[t] = G
    return out

Tests

assert discounted_returns([1, 1, 1], gamma=1.0) == [3, 2, 1]
assert abs(discounted_returns([1], gamma=0.9)[0] - 1.0) < 1e-9
print("returns OK")

Advanced implementation. G_t = r_t + γG_{t+1} is a first-order IIR filter run backwards — scipy.signal.lfilter computes the whole sequence in one C call. The same one-liner computes GAE from TD errors with coefficient γλ (Problem 3). Verified equal to the backward Python loop (1e-9).

import numpy as np
from scipy.signal import lfilter

def discounted_returns_fast(r, gamma):
    return lfilter([1.0], [1.0, -gamma], r[::-1])[::-1]

# GAE in one line: advantages = discounted_returns_fast(td_deltas, gamma * lam)

2. n-step return — ★★★★

Problem

Compute the n-step return Gt:t+n = Σn−1k=0 γkrt+k + γnV (st+n) with truncation to the episode end.

def n_step_returns(rewards, values, gamma=0.99, n=5, last_value=0.0):
    T = len(rewards)
    out = [0.0] * T
    for t in range(T):
        G = 0.0
        for k in range(n):
            if t + k < T: G += (gamma ** k) * rewards[t + k]
        if t + n < T:
            G += (gamma ** n) * values[t + n]
        else:
            G += (gamma ** (T - t)) * last_value
        out[t] = G
    return out

Tests

out = n_step_returns([1, 1, 1, 1], [10, 20, 30, 40], gamma=1.0, n=2, last_value=0)
assert out[0] == 1 + 1 + 30 # r0 + r1 + V(s2)
print("n-step OK")

3. Generalized Advantage Estimation (GAE) — ★★★★★

Problem

Compute GAE(γ, λ) given rewards and value estimates with bootstrap value.

def gae(rewards, values, gamma=0.99, lam=0.95, last_value=0.0):
    T = len(rewards)
    adv = [0.0] * T
    g = 0.0
    nxt_v = last_value
    for t in reversed(range(T)):
        delta = rewards[t] + gamma * nxt_v - values[t]
        g = delta + gamma * lam * g
        adv[t] = g; nxt_v = values[t]
    return adv

Tests

assert gae([1, 1, 1], [0, 0, 0], gamma=1.0, lam=1.0) == [3.0, 2.0, 1.0]
print("GAE OK")

4. Bellman expectation operator (matrix form) — ★★★

Problem

For a finite MDP given P π ∈ RS×S and rπ ∈ RS, solve V π = (I −γP π)−1rπ.

import numpy as np

def policy_evaluation_matrix(P_pi, r_pi, gamma):
    S = len(r_pi)
    return np.linalg.solve(np.eye(S) - gamma * P_pi, r_pi)

Tests

P = np.array([[0.9, 0.1], [0.5, 0.5]])
r = np.array([1.0, -1.0])
V = policy_evaluation_matrix(P, r, gamma=0.9)
# Sanity: V = r + gamma P V
assert np.allclose(V, r + 0.9 * P @ V); print("PE matrix OK")

5. Value iteration — ★★★★

Problem

Implement value iteration on a finite MDP Pa, Ra until sups |Vk+1(s) −Vk(s)| < θ.

import numpy as np

def value_iteration(P, R, gamma=0.95, theta=1e-6):
    # P: (A, S, S), R: (A, S)
    A, S, _ = P.shape
    V = np.zeros(S)
    while True:
        Q = R + gamma * (P @ V) # (A, S)
        V_new = Q.max(axis=0)
        if np.max(np.abs(V_new - V)) < theta: break
        V = V_new
    pi = Q.argmax(axis=0)
    return V_new, pi

Tests

P = np.array([[[0,1],[0,1]], [[1,0],[1,0]]], dtype=float) # actions: stay-right, stay-left
R = np.array([[0, 1.0], [1, 0]]) # state 1 with action 0 gives reward 1
V, pi = value_iteration(P, R, gamma=0.9)
assert pi[0] == 1 and pi[1] == 0; print("VI OK", V, pi)

Advanced implementation. One einsum backs up ALL (s, a) pairs at once: Q = R + γ·einsum('sap,p->sa', P, V). Verified: identical values and greedy policy to the per-state loop on a random MDP (1e-8).

import numpy as np

def value_iteration(P, R, gamma=0.9, iters=200):
    # P: (S, A, S') transition tensor; R: (S, A)
    V = np.zeros(P.shape[0])
    for _ in range(iters):
        Q = R + gamma * np.einsum("sap,p->sa", P, V)  # every backup in one contraction
        V = Q.max(1)
    return V, Q.argmax(1)

6. Policy iteration — ★★★

Problem

Implement policy iteration: alternate (i) closed-form policy evaluation, (ii) greedy policy improvement, until policy is stable.

import numpy as np

def policy_iteration(P, R, gamma=0.95):
    A, S, _ = P.shape
    pi = np.zeros(S, dtype=int)
    while True:
        P_pi = np.stack([P[pi[s], s] for s in range(S)])
        r_pi = np.array([R[pi[s], s] for s in range(S)])
        V = np.linalg.solve(np.eye(S) - gamma * P_pi, r_pi)
        Q = R + gamma * (P @ V)
        new_pi = Q.argmax(axis=0)
        if np.array_equal(new_pi, pi): return V, pi
        pi = new_pi

Tests

P = np.array([[[0,1],[0,1]], [[1,0],[1,0]]], dtype=float)
R = np.array([[0, 1.0], [1, 0]])
V, pi = policy_iteration(P, R)
assert pi[0] == 1 and pi[1] == 0; print("PI OK", pi)

II. Multi-armed bandits

7. Epsilon-greedy bandit — ★★★★★

Problem

Run an ε-greedy policy on a K-armed bandit for T steps with sample-mean updates; return the average reward.

import numpy as np

def eps_greedy_bandit(true_means, T=1000, eps=0.1, rng=None):
    rng = rng or np.random.default_rng(0)
    K = len(true_means); Q = np.zeros(K); N = np.zeros(K)
    rewards = []
    for _ in range(T):
        a = int(rng.integers(K)) if rng.random() < eps else int(np.argmax(Q))
        r = rng.normal(true_means[a], 1.0)
        N[a] += 1; Q[a] += (r - Q[a]) / N[a]
        rewards.append(r)
    return Q, np.mean(rewards)

Tests

Q, avg = eps_greedy_bandit([0, 0.5, 1.0], T=2000, eps=0.1)
assert int(np.argmax(Q)) == 2 and avg > 0.5
print("eps-greedy OK", Q)

8. UCB1 — ★★★★

Problem

√Pull arms with at = arg maxaa +2 ln t/Na.

import numpy as np

def ucb1(true_means, T=1000, rng=None):
    rng = rng or np.random.default_rng(0)
    K = len(true_means); Q = np.zeros(K); N = np.zeros(K)
    for a in range(K):
        r = rng.normal(true_means[a], 1.0); N[a] = 1; Q[a] = r
    for t in range(K + 1, T + 1):
        score = Q + np.sqrt(2 * np.log(t) / N)
        a = int(np.argmax(score))
        r = rng.normal(true_means[a], 1.0)
        N[a] += 1; Q[a] += (r - Q[a]) / N[a]
    return Q, N

Tests

Q, N = ucb1([0.0, 0.5, 1.0], T=2000)
assert int(np.argmax(Q)) == 2 and N[2] > N[0]
print("UCB1 OK", N)

9. Thompson sampling (Beta–Bernoulli) — ★★★★

Problem

Maintain Beta(αa, βa) posteriors; at each step, sample θa from each and pull arg maxa θa.

import numpy as np

def thompson_bernoulli(true_p, T=1000, rng=None):
    rng = rng or np.random.default_rng(0)
    K = len(true_p); a_, b_ = np.ones(K), np.ones(K)
    for _ in range(T):
        theta = rng.beta(a_, b_)
        a = int(np.argmax(theta))
        r = int(rng.random() < true_p[a])
        a_[a] += r; b_[a] += 1 - r
    return a_ / (a_ + b_), a_ + b_

Tests

mean, total = thompson_bernoulli([0.2, 0.5, 0.8], T=2000)
assert int(np.argmax(mean)) == 2; print("TS OK", mean, total)

10. Gradient bandit — ★★

Problem

Implement softmax-preference gradient bandit (Sutton & Barto Ch. 2): Ha ← Ha + α(Rt −R¯)(⊮a=At −πa).

import numpy as np

def gradient_bandit(true_means, T=1000, alpha=0.1, rng=None):
    rng = rng or np.random.default_rng(0)
    K = len(true_means); H = np.zeros(K); avg = 0.0
    for t in range(1, T + 1):
        pi = np.exp(H) / np.exp(H).sum()
        a = int(rng.choice(K, p=pi))
        r = rng.normal(true_means[a], 1.0)
        avg += (r - avg) / t
        one_hot = np.zeros(K); one_hot[a] = 1
        H += alpha * (r - avg) * (one_hot - pi)
    return H

Tests

H = gradient_bandit([0, 0.5, 1.0], T=3000, alpha=0.05)
assert int(np.argmax(H)) == 2; print("grad bandit OK", H)

11. LinUCB (contextual) — ★★

Problem

Implement LinUCB for each arm a: maintain Aa, ba, score xθˆa + α√xA−1 a x.

import numpy as np
import math

class LinUCB:
    def __init__(self, K, d, alpha=1.0):
        self.K, self.d, self.alpha = K, d, alpha
        self.A = [np.eye(d) for _ in range(K)]
        self.b = [np.zeros(d) for _ in range(K)]
    def select(self, x):
        scores = []
        for a in range(self.K):
            Ai = np.linalg.inv(self.A[a])
            theta = Ai @ self.b[a]
            scores.append(x @ theta + self.alpha * math.sqrt(x @ Ai @ x))
        return int(np.argmax(scores))
    def update(self, a, x, r):
        self.A[a] += np.outer(x, x); self.b[a] += r * x

Tests

rng = np.random.default_rng(0)
agent = LinUCB(K=3, d=4, alpha=0.5)
true = np.array([[1,0,0,0], [0,1,0,0], [1,1,1,1.]])
for _ in range(500):
    x = rng.standard_normal(4)
    a = agent.select(x)
    agent.update(a, x, true[a] @ x + 0.1 * rng.standard_normal())
print("LinUCB OK")

III. GridWorld and tabular methods

12. GridWorld environment — ★★

Problem

Implement a small deterministic GridWorld with a start, terminal, and step penalty −1 until the goal.

class GridWorld:
    ACTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)] # up, down, left, right
    def __init__(self, h=4, w=4, goal=(3, 3)):
        self.h, self.w, self.goal = h, w, goal
    def reset(self): self.s = (0, 0); return self.s
    def step(self, a):
        dy, dx = self.ACTIONS[a]
        ny = max(0, min(self.h - 1, self.s[0] + dy))
        nx = max(0, min(self.w - 1, self.s[1] + dx))
        self.s = (ny, nx)
        done = self.s == self.goal
        return self.s, (-1.0 if not done else 0.0), done

Tests

env = GridWorld()
s = env.reset(); assert s == (0, 0)
s, r, d = env.step(1); assert s == (1, 0) and r == -1
print("GridWorld OK")

13. Iterative policy evaluation — ★★★

Problem

Estimate V π for the random policy on GridWorld by iterative sweeps: V (s) ← Σ s′ P(s|s, a)[R +a π(a|s) Σ γV (s)].

import numpy as np

def policy_eval_grid(env, pi, gamma=0.9, theta=1e-4):
    V = np.zeros((env.h, env.w))
    while True:
        delta = 0
        for y in range(env.h):
            for x in range(env.w):
                if (y, x) == env.goal: continue
                v = 0
                for a, p in enumerate(pi):
                    env.s = (y, x); s_next, r, _ = env.step(a)
                    v += p * (r + gamma * V[s_next])
                delta = max(delta, abs(v - V[y, x]))
                V[y, x] = v
        if delta < theta: break
    return V

Tests

env = GridWorld()
V = policy_eval_grid(env, pi=[0.25] * 4)
assert V[env.goal] == 0
assert V[0, 0] < 0; print("PE grid OK")

14. Q-learning — ★★★★★

Problem

Tabular Q-learning: Q(s, a) ← Q(s, a) + α[r + γ maxa′ Q(s, a) −Q(s, a)], ε-greedy behaviour.

import numpy as np

def q_learning(env, episodes=500, alpha=0.5, gamma=0.95, eps=0.1, rng=None):
    rng = rng or np.random.default_rng(0)
    Q = np.zeros((env.h, env.w, 4))
    for _ in range(episodes):
        s = env.reset()
        for _ in range(200):
            a = int(rng.integers(4)) if rng.random() < eps else int(np.argmax(Q[s]))
            s2, r, d = env.step(a)
            target = r + (0 if d else gamma * Q[s2].max())
            Q[s][a] += alpha * (target - Q[s][a])
            s = s2
            if d: break
    return Q

Tests

env = GridWorld()
Q = q_learning(env)
greedy = np.argmax(Q, axis=-1)
# Optimal action at (0,0) is right (3) or down (1) -- both reach goal in 6 steps.
assert greedy[0, 0] in (1, 3); print("Q-learning OK")

15. SARSA (on-policy TD control) — ★★★★

Problem

SARSA update uses the actual next action: Q(s, a) ← Q(s, a) + α[r + γQ(s, a) −Q(s, a)].

import numpy as np

def sarsa(env, episodes=500, alpha=0.5, gamma=0.95, eps=0.1, rng=None):
    rng = rng or np.random.default_rng(0)
    Q = np.zeros((env.h, env.w, 4))
    def act(s):
        return int(rng.integers(4)) if rng.random() < eps else int(np.argmax(Q[s]))
    for _ in range(episodes):
        s = env.reset(); a = act(s)
        for _ in range(200):
            s2, r, d = env.step(a)
            a2 = act(s2)
            target = r + (0 if d else gamma * Q[s2][a2])
            Q[s][a] += alpha * (target - Q[s][a])
            s, a = s2, a2
            if d: break
    return Q

Tests

env = GridWorld()
Q = sarsa(env)
greedy = np.argmax(Q, axis=-1)
assert greedy[3, 2] in (1, 3); print("SARSA OK")

16. Expected SARSA — ★★★

Problem

Replace the bootstrap with the expectation under π: Q(s, a) ← Q(s, a) + α[r + γEa′∼πQ(s, a) −Q(s, a)].

import numpy as np

def expected_sarsa(env, episodes=300, alpha=0.5, gamma=0.95, eps=0.1, rng=None):
    rng = rng or np.random.default_rng(0)
    Q = np.zeros((env.h, env.w, 4))
    def policy_probs(s):
        a_star = int(np.argmax(Q[s]))
        p = np.full(4, eps / 4); p[a_star] += 1 - eps
        return p
    for _ in range(episodes):
        s = env.reset()
        for _ in range(200):
            a = int(rng.choice(4, p=policy_probs(s)))
            s2, r, d = env.step(a)
            target = r + (0 if d else gamma * (policy_probs(s2) * Q[s2]).sum())
            Q[s][a] += alpha * (target - Q[s][a])
            s = s2
            if d: break
    return Q

Tests

env = GridWorld()
Q = expected_sarsa(env)
assert (Q.max() > -50); print("expected SARSA OK")

17. n-step SARSA — ★★

Problem

Extend SARSA with the n-step return: Gt:t+n = Σn−1k=0 γkrt+k + γnQ(st+n, at+n).

import numpy as np

def n_step_sarsa(env, n=3, episodes=300, alpha=0.5, gamma=0.95, eps=0.1, rng=None):
    rng = rng or np.random.default_rng(0)
    Q = np.zeros((env.h, env.w, 4))
    def act(s):
        return int(rng.integers(4)) if rng.random() < eps else int(np.argmax(Q[s]))
    for _ in range(episodes):
        s = env.reset(); a = act(s)
        states = [s]; actions = [a]; rewards = [0.0]
        T = 1e9; t = 0
        while True:
            if t < T:
                s2, r, d = env.step(actions[t])
                states.append(s2); rewards.append(r)
                if d: T = t + 1
                else: actions.append(act(s2))
            tau = t - n + 1
            if tau >= 0:
                G = sum((gamma ** k) * rewards[tau + k + 1] for k in range(min(n, T - tau)))
                if tau + n < T: G += (gamma ** n) * Q[states[tau + n]][actions[tau + n]]
                Q[states[tau]][actions[tau]] += alpha * (G - Q[states[tau]][actions[tau]])
            if tau == T - 1: break
            t += 1
    return Q

Tests

env = GridWorld()
Q = n_step_sarsa(env, n=3)
print("n-step SARSA OK", Q.max())

18. Monte Carlo (first-visit) — ★★★

Problem

Estimate V π by averaging the returns observed at each first visit of a state.

import numpy as np

def first_visit_mc(env, pi, episodes=500, gamma=0.95, rng=None):
    rng = rng or np.random.default_rng(0)
    returns = {}
    for _ in range(episodes):
        traj = []
        s = env.reset()
        for _ in range(200):
            a = int(rng.choice(4, p=pi))
            s2, r, d = env.step(a)
            traj.append((s, r)); s = s2
            if d: break
        G = 0.0; visited = set()
        for s, r in reversed(traj): G = r + gamma * G # post-hoc
        # First-visit pass forward
        G = 0.0; rev = list(reversed(traj))
        for i, (s, r) in enumerate(rev):
            G = r + gamma * (0 if i == 0 else G)
        # We'll use a clean second pass.
        Gs = [0.0] * len(traj)
        running = 0.0
        for i, (s, r) in enumerate(reversed(traj)):
            running = r + gamma * running
            Gs[len(traj) - 1 - i] = running
        for (s, _), G in zip(traj, Gs):
            if s in visited: continue
            visited.add(s); returns.setdefault(s, []).append(G)
    return {s: np.mean(v) for s, v in returns.items()}

Tests

env = GridWorld()
V = first_visit_mc(env, [0.25] * 4)
assert env.goal not in V or V[env.goal] == 0
print("MC OK")

19. Off-policy MC with importance sampling — ★★

Problem

Estimate Qπ from trajectories drawn from a behaviour policy b via the per-decision importance-sampling ratio ρt:T = ΠT−1 k=t π(ak|sk)/b(ak|sk).

def offpolicy_mc(traj, pi, b, gamma=0.95):
    # traj: list of (s, a, r). pi/b: callables giving action probs.
    Q = {}
    G = 0.0; W = 1.0
    for s, a, r in reversed(traj):
        G = r + gamma * G
        Q.setdefault((s, a), []).append((G, W))
        W *= pi(s, a) / b(s, a)
        if W == 0: break
    return {k: sum(g * w for g, w in v) / max(sum(w for _, w in v), 1e-9) for k, v in Q.items()}

Tests

traj = [((0,0), 0, 0), ((0,1), 1, 1)]
pi = lambda s, a: 0.5; b = lambda s, a: 0.5
Q = offpolicy_mc(traj, pi, b, gamma=1.0)
assert Q[((0,1), 1)] == 1.0; print("off-policy MC OK")

20. Double Q-learning — ★★★★

Problem

Maintain QA, QB.With probability 0.5, update one using the other’s target:← QA + α[r +QA γQB(s, arg maxa′ QA(s, a)) −QA].

import numpy as np

def double_q(env, episodes=500, alpha=0.5, gamma=0.95, eps=0.1, rng=None):
    rng = rng or np.random.default_rng(0)
    A = np.zeros((env.h, env.w, 4)); B = np.zeros_like(A)
    for _ in range(episodes):
        s = env.reset()
        for _ in range(200):
            mix = (A[s] + B[s]) / 2
            a = int(rng.integers(4)) if rng.random() < eps else int(np.argmax(mix))
            s2, r, d = env.step(a)
            if rng.random() < 0.5:
                ap = int(np.argmax(A[s2]))
                tgt = r + (0 if d else gamma * B[s2][ap])
                A[s][a] += alpha * (tgt - A[s][a])
            else:
                ap = int(np.argmax(B[s2]))
                tgt = r + (0 if d else gamma * A[s2][ap])
                B[s][a] += alpha * (tgt - B[s][a])
            s = s2
            if d: break
    return (A + B) / 2

Tests

env = GridWorld()
Q = double_q(env)
assert Q.max() > -100; print("Double Q OK")

IV. TD with eligibility traces

21. TD(0) prediction — ★★★

Problem

V (s) ← V (s) + α[r + γV (s) −V (s)]. Run on a fixed policy.

import numpy as np

def td0(env, pi, episodes=500, alpha=0.1, gamma=0.95, rng=None):
    rng = rng or np.random.default_rng(0)
    V = np.zeros((env.h, env.w))
    for _ in range(episodes):
        s = env.reset()
        for _ in range(200):
            a = int(rng.choice(4, p=pi))
            s2, r, d = env.step(a)
            V[s] += alpha * (r + (0 if d else gamma * V[s2]) - V[s])
            s = s2
            if d: break
    return V

Tests

env = GridWorld()
V = td0(env, [0.25] * 4)
assert V[env.goal] == 0; print("TD0 OK")

22. TD(λ) with eligibility traces — ★★

Problem

Update V (s) ← V (s) + αδtet(s) where the trace decays as et = γλet−1 + ⊮s.

import numpy as np

def td_lambda(env, pi, episodes=300, alpha=0.1, gamma=0.95, lam=0.7, rng=None):
    rng = rng or np.random.default_rng(0)
    V = np.zeros((env.h, env.w))
    for _ in range(episodes):
        e = np.zeros_like(V)
        s = env.reset()
        for _ in range(200):
            a = int(rng.choice(4, p=pi))
            s2, r, d = env.step(a)
            delta = r + (0 if d else gamma * V[s2]) - V[s]
            e *= gamma * lam; e[s] += 1
            V += alpha * delta * e
            s = s2
            if d: break
    return V

Tests

env = GridWorld()
V = td_lambda(env, [0.25] * 4); assert V[env.goal] == 0
print("TD(lambda) OK")

23. True online TD(λ) —

Problem

Use the dutch-trace correction so the algorithm equals the offline λ-return algorithm exactly.

import numpy as np

def true_online_td_lambda(env, pi, episodes=300, alpha=0.1, gamma=0.95, lam=0.7, rng=None):
    rng = rng or np.random.default_rng(0)
    H, W = env.h, env.w
    theta = np.zeros((H, W)) # value-function "weights" indexed by state
    for _ in range(episodes):
        e = np.zeros_like(theta); v_old = 0.0
        s = env.reset()
        for _ in range(200):
            a = int(rng.choice(4, p=pi))
            s2, r, d = env.step(a)
            v = theta[s]; v_p = 0 if d else theta[s2]
            delta = r + gamma * v_p - v
            e_s = e[s]
            e *= gamma * lam
            e[s] = (1 - alpha * gamma * lam * e_s) + e[s]
            theta += alpha * (delta + v - v_old) * e
            theta[s] -= alpha * (v - v_old)
            v_old = v_p; s = s2
            if d: break
    return theta

Tests

env = GridWorld()
V = true_online_td_lambda(env, [0.25] * 4)
print("true online TD lambda OK", V.max())

V. Function approximation

24. Tile coding —

Problem

Implement multi-tiling tile coding for a 2D continuous state.

import numpy as np

class TileCoder:
    def __init__(self, n_tilings=8, tiles_per_dim=8, lo=(-1, -1), hi=(1, 1)):
        self.n = n_tilings
        self.t = tiles_per_dim
        self.lo = np.asarray(lo); self.hi = np.asarray(hi)
        self.range = self.hi - self.lo
        self.size = self.n * (self.t + 1) ** 2
    def features(self, x):
        x = (np.asarray(x) - self.lo) / self.range * self.t
        feats = np.zeros(self.size)
        for k in range(self.n):
            offsets = np.array([k / self.n, k / self.n])
            ix = np.floor(x + offsets).astype(int)
            idx = k * (self.t + 1) ** 2 + ix[0] * (self.t + 1) + ix[1]
            if 0 <= idx < self.size:
                feats[idx] = 1.0
        return feats

Tests

tc = TileCoder(n_tilings=4, tiles_per_dim=4)
phi = tc.features([0.0, 0.0])
assert phi.sum() == 4; print("tile coding OK", phi.sum())

25. Semi-gradient TD(0) with linear features — ★★

Problem

w ← w + α[r + γwϕ(s) −wϕ(s)]ϕ(s).

import numpy as np

def semigrad_td0(env, pi, phi_fn, dim, episodes=300, alpha=0.05, gamma=0.95, rng=None):
    rng = rng or np.random.default_rng(0)
    w = np.zeros(dim)
    for _ in range(episodes):
        s = env.reset()
        for _ in range(200):
            a = int(rng.choice(4, p=pi))
            s2, r, d = env.step(a)
            phi = phi_fn(s); phi2 = phi_fn(s2)
            target = r + (0 if d else gamma * w @ phi2)
            w += alpha * (target - w @ phi) * phi
            s = s2
            if d: break
    return w

Tests

env = GridWorld()
def phi(s): # one-hot
    v = np.zeros(env.h * env.w); v[s[0] * env.w + s[1]] = 1.0; return v
w = semigrad_td0(env, [0.25] * 4, phi, env.h * env.w)
print("semi-grad TD0 OK", w.min())

26. Semi-gradient SARSA(λ) with linear features — ★★

Problem

Replace tabular Q with wϕ(s, a); trace becomes a vector trace e ← γλe + ∇wq(s, a).

import numpy as np

def semigrad_sarsa_lambda(env, phi_sa_fn, dim, episodes=200, alpha=0.05,
                            gamma=0.95, lam=0.7, eps=0.1, rng=None):
    rng = rng or np.random.default_rng(0)
    w = np.zeros(dim)
    def q(s, a): return w @ phi_sa_fn(s, a)
    def act(s): return int(rng.integers(4)) if rng.random() < eps else int(np.argmax([q(s, a) for a in range(4)]))
    for _ in range(episodes):
        e = np.zeros_like(w); s = env.reset(); a = act(s)
        for _ in range(200):
            s2, r, d = env.step(a)
            phi = phi_sa_fn(s, a)
            delta = r - q(s, a) + (0 if d else gamma * q(s2, act(s2)))
            e = gamma * lam * e + phi
            w += alpha * delta * e
            if d: break
            s = s2; a = act(s)
    return w

Tests

env = GridWorld()
def phi(s, a):
    v = np.zeros(env.h * env.w * 4); v[(s[0] * env.w + s[1]) * 4 + a] = 1.0; return v
w = semigrad_sarsa_lambda(env, phi, env.h * env.w * 4)
assert w.max() > -100; print("semi-grad SARSA(lambda) OK")

VI. Replay and deep value methods

27. Cyclic replay buffer — ★★★★

Problem

Implement a fixed-size circular buffer with uniform random sampling.

class ReplayBuffer:
    def __init__(self, capacity):
        self.capacity = capacity
        self.buf = []
        self.idx = 0
    def push(self, transition):
        if len(self.buf) < self.capacity:
            self.buf.append(transition)
        else:
            self.buf[self.idx] = transition
        self.idx = (self.idx + 1) % self.capacity
    def sample(self, n):
        return random.sample(self.buf, n)
    def __len__(self): return len(self.buf)

Tests

rb = ReplayBuffer(3)
for i in range(5): rb.push(i)
assert len(rb) == 3 and 4 in rb.buf
print("replay buffer OK")

28. Prioritized experience replay (PER) — ★★★★

Problem

i and use importance-sampling weights wi = (1/(N pi))β to correct the bias. Sample with probability ∝pα

import numpy as np

class PER:
    def __init__(self, capacity, alpha=0.6):
        self.cap = capacity; self.alpha = alpha
        self.buf = []; self.p = np.zeros(capacity); self.idx = 0
    def push(self, item, priority):
        if len(self.buf) < self.cap: self.buf.append(item)
        else: self.buf[self.idx] = item
        self.p[self.idx] = (priority + 1e-6) ** self.alpha
        self.idx = (self.idx + 1) % self.cap
    def sample(self, n, beta=0.4):
        N = len(self.buf)
        probs = self.p[:N] / self.p[:N].sum()
        idx = np.random.choice(N, n, p=probs)
        items = [self.buf[i] for i in idx]
        weights = (1.0 / (N * probs[idx])) ** beta
        weights = weights / weights.max()
        return items, idx, weights
    def update_priorities(self, idx, td_errs):
        self.p[idx] = (np.abs(td_errs) + 1e-6) ** self.alpha

Tests

per = PER(10, alpha=0.6)
for i in range(10): per.push(i, priority=abs(i - 5) + 0.1)
items, idx, w = per.sample(4, beta=0.4)
assert all(0 < x <= 1.0001 for x in w); print("PER OK")

Advanced implementation. Linear-scan proportional sampling is O(n) per draw — a real bottleneck at buffer size 1M. A sum-tree does sampling AND priority updates in O(log n): internal nodes store subtree sums; descend left/right by comparing against the left child's mass. Verified: 200k draws match the priority distribution to <0.4%, and updates propagate to the root.

import numpy as np

class SumTree:
    def __init__(self, n):
        self.n = n
        self.tree = np.zeros(2 * n)                  # leaves at [n, 2n)

    def update(self, i, p):                          # O(log n)
        i += self.n
        delta = p - self.tree[i]
        while i >= 1:
            self.tree[i] += delta
            i //= 2

    def sample(self, u):                             # u in [0, total): O(log n)
        i = 1
        while i < self.n:
            if u < self.tree[2 * i]:
                i = 2 * i
            else:
                u -= self.tree[2 * i]
                i = 2 * i + 1
        return i - self.n

    @property
    def total(self):
        return self.tree[1]

29. Vanilla DQN training step — ★★★★

Problem

One DQN update: sample batch, compute target r + γ maxa′ Qθ¯(s, a), MSE loss, optimizer step.

import torch
import torch.nn.functional as F

def dqn_update(q_net, q_target, batch, optimizer, gamma=0.99, device='cpu'):
    s, a, r, s2, d = batch
    q = q_net(s).gather(1, a.long().unsqueeze(1)).squeeze(1)
    with torch.no_grad():
        q_next = q_target(s2).max(dim=1).values
        target = r + gamma * (1 - d) * q_next
    loss = F.smooth_l1_loss(q, target)
    optimizer.zero_grad(); loss.backward(); optimizer.step()
    return loss.item()

Tests

class QNet(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(4, 2)
    def forward(self, x): return self.l(x)
q, qt = QNet(), QNet()
opt = torch.optim.Adam(q.parameters(), lr=1e-3)
B = 8
batch = (torch.randn(B, 4), torch.randint(0, 2, (B,)), torch.rand(B), torch.randn(B, 4), torch.zeros(B))
loss = dqn_update(q, qt, batch, opt); assert loss >= 0
print("DQN update OK")

30. Double DQN target — ★★★★

Problem

Decouple action selection (online net) from action evaluation (target net): y = r+γ Qθ¯(s, arg maxa′ Qθ(s, a)).

import torch

def double_dqn_target(q_online, q_target, s2, r, d, gamma):
    with torch.no_grad():
        a_star = q_online(s2).argmax(dim=1, keepdim=True)
        q_next = q_target(s2).gather(1, a_star).squeeze(1)
    return r + gamma * (1 - d) * q_next

Tests

class QNet(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(4, 2)
    def forward(self, x): return self.l(x)
y = double_dqn_target(QNet(), QNet(), torch.randn(4, 4), torch.rand(4), torch.zeros(4), 0.99)
assert y.shape == (4,); print("DDQN target OK")

31. Dueling DQN — ★★★

Problem

1a′ A(s, a)).ΣSplit the value net into V (s) and A(s, a) branches, recombine as Q(s, a) = V (s) + (A(s, a) − |A|

import torch.nn as nn

class DuelingMLP(nn.Module):
    def __init__(self, in_dim, n_actions, hid=128):
        super().__init__()
        self.body = nn.Sequential(nn.Linear(in_dim, hid), nn.ReLU())
        self.V = nn.Linear(hid, 1); self.A = nn.Linear(hid, n_actions)
    def forward(self, x):
        h = self.body(x); v = self.V(h); a = self.A(h)
        return v + a - a.mean(dim=-1, keepdim=True)

Tests

m = DuelingMLP(4, 3); y = m(torch.randn(2, 4))
assert y.shape == (2, 3); print("Dueling OK")

32. NoisyLinear (factorized noise) — ★★

Problem

Replace a linear layer with one whose weight is µW + σW ⊙εW , εW = f(εp)f(εq), f(x) = sign(x)√|x|.

import math
import torch
import torch.nn as nn
import torch.nn.functional as F

class NoisyLinear(nn.Module):
    def __init__(self, in_f, out_f, sigma_init=0.5):
        super().__init__()
        self.in_f, self.out_f = in_f, out_f
        self.weight_mu = nn.Parameter(torch.empty(out_f, in_f))
        self.weight_sigma = nn.Parameter(torch.empty(out_f, in_f))
        self.bias_mu = nn.Parameter(torch.empty(out_f))
        self.bias_sigma = nn.Parameter(torch.empty(out_f))
        bound = 1 / math.sqrt(in_f)
        self.weight_mu.data.uniform_(-bound, bound)
        self.bias_mu.data.uniform_(-bound, bound)
        self.weight_sigma.data.fill_(sigma_init / math.sqrt(in_f))
        self.bias_sigma.data.fill_(sigma_init / math.sqrt(out_f))
    def _f(self, x):
        return x.sign() * x.abs().sqrt()
    def forward(self, x):
        ep = self._f(torch.randn(self.in_f, device=x.device))
        eq = self._f(torch.randn(self.out_f, device=x.device))
        eps_w = torch.outer(eq, ep); eps_b = eq
        w = self.weight_mu + self.weight_sigma * eps_w
        b = self.bias_mu + self.bias_sigma * eps_b
        return F.linear(x, w, b)

Tests

m = NoisyLinear(4, 3); y = m(torch.randn(2, 4))
assert y.shape == (2, 3); print("NoisyLinear OK")

33. n-step DQN target — ★★★

Problem

k=0 γkrt+k + γn maxa Qθ¯(st+n, a).Compute the n-step bootstrap target yt = Σn−1

import torch

def n_step_dqn_target(q_target, rewards, s_n, dones, gamma, n):
    G = torch.zeros_like(rewards[0])
    for k in range(n): G = G + (gamma ** k) * rewards[k]
    with torch.no_grad():
        q_next = q_target(s_n).max(dim=1).values
    return G + (gamma ** n) * (1 - dones) * q_next

Tests

class QNet(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(4, 2)
    def forward(self, x): return self.l(x)
r = [torch.zeros(4) + i for i in range(3)]
y = n_step_dqn_target(QNet(), r, torch.randn(4, 4), torch.zeros(4), 0.99, n=3)
assert y.shape == (4,); print("n-step DQN OK")

34. Distributional C51 projection — ★★

Problem

Project the categorical distribution p(s, a) onto the support {zi} of p(s, a) via the operator T zi = r + γzi, clipped to [Vmin, Vmax].

import torch

def c51_project(p_next, r, d, gamma, vmin, vmax, num_atoms):
    delta = (vmax - vmin) / (num_atoms - 1)
    z = torch.linspace(vmin, vmax, num_atoms, device=p_next.device)
    Tz = (r.unsqueeze(1) + gamma * (1 - d.unsqueeze(1)) * z.unsqueeze(0)).clamp(vmin, vmax)
    b = (Tz - vmin) / delta
    l = b.floor().long(); u = b.ceil().long()
    m = torch.zeros_like(p_next)
    for i in range(num_atoms):
        m.scatter_add_(1, l[:, i:i+1], p_next[:, i:i+1] * (u[:, i:i+1].float() - b[:, i:i+1]))
        m.scatter_add_(1, u[:, i:i+1], p_next[:, i:i+1] * (b[:, i:i+1] - l[:, i:i+1].float()))
    return m

Tests

B, A_ = 4, 11
p = F.softmax(torch.randn(B, A_), dim=1)
r = torch.zeros(B); d = torch.zeros(B)
m = c51_project(p, r, d, 0.99, -1, 1, A_)
assert torch.allclose(m.sum(dim=1), torch.ones(B), atol=1e-4)
print("C51 OK")

35. Soft (Polyak) target update — ★★★

Problem

θtgt ← τθ + (1 −τ)θtgt.

def polyak_update(target_net, online_net, tau=0.005):
    for tp, op in zip(target_net.parameters(), online_net.parameters()):
        tp.data.mul_(1 - tau).add_(tau * op.data)

Tests

a = nn.Linear(2, 2); b = nn.Linear(2, 2)
prev = a.weight.data.clone()
polyak_update(a, b, tau=1.0)
assert torch.allclose(a.weight.data, b.weight.data); print("polyak OK")

36. Frame-stacking wrapper — ★★★

Problem

For Atari-style envs, stack the last k frames and present them as the observation.

import numpy as np

class FrameStack:
    def __init__(self, k=4): self.k = k; self.buf = deque(maxlen=k)
    def reset(self, frame):
        for _ in range(self.k): self.buf.append(frame)
        return np.stack(self.buf, axis=0)
    def step(self, frame): self.buf.append(frame); return np.stack(self.buf, axis=0)

Tests

fs = FrameStack(4); o = fs.reset(np.zeros((84, 84)))
assert o.shape == (4, 84, 84); print("FrameStack OK")

VII. Policy gradient

37. REINFORCE — ★★★★★

Problem

Implement REINFORCE: ∇J(θ) = Et[∇θ log πθ(at|st)Gt].

import torch

def reinforce_update(policy, optimizer, episodes_data, gamma=0.99):
    losses = []
    for ep in episodes_data:
        states, actions, rewards = ep
        G = torch.tensor(discounted_returns(rewards, gamma))
        log_probs = policy(states).log_softmax(dim=-1)
        nll = -log_probs.gather(1, actions.unsqueeze(1)).squeeze(1)
        loss = (nll * G).sum()
        losses.append(loss)
    total = torch.stack(losses).sum() / len(episodes_data)
    optimizer.zero_grad(); total.backward(); optimizer.step()
    return total.item()

Tests

class P(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(2, 3)
    def forward(self, x): return self.l(x)
p = P(); opt = torch.optim.Adam(p.parameters(), lr=1e-3)
data = [(torch.randn(4, 2), torch.randint(0, 3, (4,)), [1, 0, 0, 1])]
loss = reinforce_update(p, opt, data); print("REINFORCE OK", loss)

38. REINFORCE with baseline — ★★★★

Problem

Subtract a learned baseline Vϕ(s) from Gt. Train Vϕ on MSE to Gt.

import torch
import torch.nn.functional as F

def reinforce_baseline_update(policy, value, opt_p, opt_v, ep, gamma=0.99):
    states, actions, rewards = ep
    G = torch.tensor(discounted_returns(rewards, gamma)).float()
    V = value(states).squeeze(-1)
    adv = (G - V).detach()
    log_probs = policy(states).log_softmax(dim=-1).gather(1, actions.unsqueeze(1)).squeeze(1)
    p_loss = -(log_probs * adv).mean()
    v_loss = F.mse_loss(V, G)
    opt_p.zero_grad(); p_loss.backward(); opt_p.step()
    opt_v.zero_grad(); v_loss.backward(); opt_v.step()
    return p_loss.item(), v_loss.item()

Tests

class P(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(2, 3)
    def forward(self, x): return self.l(x)
class V(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(2, 1)
    def forward(self, x): return self.l(x)
p = P(); v = V()
ep = (torch.randn(4, 2), torch.randint(0, 3, (4,)), [1, 0, 0, 1])
losses = reinforce_baseline_update(p, v, torch.optim.Adam(p.parameters()), torch.optim.Adam(v.parameters()),
    ep)
print("REINFORCE+baseline OK", losses)

39. One-step actor-critic — ★★★★

Problem

Online actor-critic update: TD error δ = r + γV (s) −V (s) acts as the advantage.

def actor_critic_step(policy, value, opt_p, opt_v, transition, gamma=0.99):
    s, a, r, s2, d = transition
    V_s = value(s); V_s2 = value(s2).detach() * (1 - d)
    delta = r + gamma * V_s2 - V_s
    log_p = policy(s).log_softmax(-1)[range(s.size(0)), a]
    p_loss = -(log_p * delta.detach()).mean()
    v_loss = (delta ** 2).mean()
    opt_p.zero_grad(); p_loss.backward(); opt_p.step()
    opt_v.zero_grad(); v_loss.backward(); opt_v.step()
    return p_loss.item(), v_loss.item()

Tests

class P(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(2, 3)
    def forward(self, x): return self.l(x)
class V(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(2, 1)
    def forward(self, x): return self.l(x).squeeze(-1)
p = P(); v = V()
tr = (torch.randn(4, 2), torch.tensor([0, 1, 2, 0]), torch.rand(4), torch.randn(4, 2), torch.zeros(4))
losses = actor_critic_step(p, v, torch.optim.Adam(p.parameters()), torch.optim.Adam(v.parameters()), tr)
print("AC OK", losses)

40. PPO clip objective — ★★★★★

Problem

LCLIP(θ) = Et[min(ρtAt, clip(ρt, 1 −ε, 1 + ε)At)].

import torch

def ppo_loss(logp_new, logp_old, adv, clip=0.2):
    ratio = (logp_new - logp_old).exp()
    s1 = ratio * adv
    s2 = ratio.clamp(1 - clip, 1 + clip) * adv
    return -torch.min(s1, s2).mean()

Tests

ln = torch.tensor([0., 0.]); lo = torch.tensor([0., 0.])
adv = torch.tensor([1., -1.])
assert ppo_loss(ln, lo, adv).item() == 0; print("PPO OK")

41. PPO with KL penalty — ★★★

Problem

Alternative form: L = EttAt −β KL(πθold ∥πθ)].

def ppo_kl_loss(logp_new, logp_old, adv, beta=0.5):
    ratio = (logp_new - logp_old).exp()
    kl = (logp_old - logp_new).mean()
    return -((ratio * adv).mean() - beta * kl)

Tests

ln = torch.tensor([0., 0.]); lo = torch.tensor([0., 0.])
assert abs(ppo_kl_loss(ln, lo, torch.zeros(2)).item()) < 1e-7
print("PPO-KL OK")

42. Entropy bonus — ★★★

Problem

Encourage exploration with H(π) = −Σa πa log πa, added to the policy loss.

import torch.nn.functional as F

def policy_entropy(logits):
    p = F.softmax(logits, dim=-1)
    return -(p * F.log_softmax(logits, dim=-1)).sum(dim=-1).mean()

Tests

H_uniform = policy_entropy(torch.zeros(1, 4)).item()
assert abs(H_uniform - math.log(4)) < 1e-6
H_peaked = policy_entropy(torch.tensor([[10., -10, -10, -10]])).item()
assert H_peaked < 0.01; print("entropy OK")

43. TRPO conjugate gradient (skeleton) —

Problem

Solve Hx = g approximately by conjugate gradient where H is the Fisher–vector product. Then take a step √2δ/xHx.of size

import torch

def conjugate_gradient(Hvp, b, n=10, tol=1e-10):
    x = torch.zeros_like(b); r = b.clone(); p = b.clone()
    rs_old = r @ r
    for _ in range(n):
        Ap = Hvp(p)
        a = rs_old / (p @ Ap + 1e-9)
        x += a * p; r -= a * Ap
        rs_new = r @ r
        if rs_new < tol: break
        p = r + (rs_new / rs_old) * p
        rs_old = rs_new
    return x

def trpo_step_size(x, Hvp, delta=1e-2):
    return torch.sqrt(2 * delta / (x @ Hvp(x) + 1e-9))

Tests

A = torch.tensor([[2., 1.], [1., 2.]])
b = torch.tensor([3., 3.])
x = conjugate_gradient(lambda v: A @ v, b)
assert torch.allclose(A @ x, b, atol=1e-4); print("CG OK")

VIII. Continuous control

44. Gaussian policy log-prob — ★★★★

Problem

2((a −µ)/σ)2 −log σ −1 For a ∼N(µ, σ2), the log-prob of an action is −12 log(2π) (per-dim, summed).

import math

def gauss_logprob(mu, log_std, a):
    std = log_std.exp()
    pre = ((a - mu) / std) ** 2 + 2 * log_std + math.log(2 * math.pi)
    return -0.5 * pre.sum(dim=-1)

Tests

mu = torch.zeros(4); ls = torch.zeros(4)
a = torch.zeros(4)
v = gauss_logprob(mu, ls, a).item()
assert abs(v - (-0.5 * 4 * math.log(2 * math.pi))) < 1e-6
print("gauss_logprob OK")

45. Squashed Gaussian (tanh) log-prob (SAC) — ★★★

Problem

SAC’s policy outputs a = tanh(z) where z ∼N(µ, σ2). Apply the change-of- variables correction: log π(a|s) = i log(1 −tanh(zi)2).log p(z|s) −Σ

import math
import torch

def squashed_gauss_logprob(mu, log_std, eps=1e-6):
    std = log_std.exp()
    z = mu + std * torch.randn_like(mu)
    a = torch.tanh(z)
    log_p = (-0.5 * ((z - mu) / std).pow(2) - log_std - 0.5 * math.log(2 * math.pi)).sum(-1)
    log_p -= torch.log(1 - a.pow(2) + eps).sum(-1)
    return a, log_p

Tests

torch.manual_seed(0)
a, lp = squashed_gauss_logprob(torch.zeros(4), torch.zeros(4))
assert a.abs().max() < 1.0 and lp.shape == ()
print("squashed gauss OK")

46. DDPG critic and actor losses — ★★★

Problem

Critic minimises (Q(s, a) −(r + γQθ¯(s, µϕ¯(s))))2; actor maximises Q(s, µϕ(s)).

import torch
import torch.nn.functional as F

def ddpg_losses(actor, critic, actor_t, critic_t, batch, gamma=0.99):
    s, a, r, s2, d = batch
    with torch.no_grad():
        a2 = actor_t(s2)
        target = r + gamma * (1 - d) * critic_t(s2, a2).squeeze(-1)
    q = critic(s, a).squeeze(-1)
    critic_loss = F.mse_loss(q, target)
    actor_loss = -critic(s, actor(s)).mean()
    return critic_loss, actor_loss

Tests

class A(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(4, 2)
    def forward(self, s): return torch.tanh(self.l(s))
class C(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(6, 1)
    def forward(self, s, a): return self.l(torch.cat([s, a], -1))
B = 8
batch = (torch.randn(B, 4), torch.randn(B, 2), torch.rand(B), torch.randn(B, 4), torch.zeros(B))
losses = ddpg_losses(A(), C(), A(), C(), batch)
print("DDPG OK", [l.item() for l in losses])

47. TD3 (twin critics + target policy smoothing) — ★★★

Problem

Use two critics Q1, Q2, take min for the target; smooth target action with clipped noise.

import torch

def td3_critic_target(actor_t, q1_t, q2_t, s2, r, d, gamma, noise_std=0.2, c=0.5):
    with torch.no_grad():
        a2 = actor_t(s2) + (torch.randn_like(actor_t(s2)) * noise_std).clamp(-c, c)
        a2 = a2.clamp(-1, 1)
        q_min = torch.minimum(q1_t(s2, a2), q2_t(s2, a2)).squeeze(-1)
        return r + gamma * (1 - d) * q_min

Tests

class A(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(4, 2)
    def forward(self, s): return torch.tanh(self.l(s))
class C(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(6, 1)
    def forward(self, s, a): return self.l(torch.cat([s, a], -1))
B = 4
y = td3_critic_target(A(), C(), C(), torch.randn(B, 4), torch.rand(B), torch.zeros(B), 0.99)
assert y.shape == (B,); print("TD3 target OK")

48. SAC actor loss with auto-tuned α — ★★★

Problem

Lπ = Es[α log π(a|s) −Qmin(s, a)] and Lα = −α(log π(a|s) + H).

import torch
import torch.nn as nn

class SACAlpha(nn.Module):
    def __init__(self, target_entropy):
        super().__init__()
        self.log_alpha = nn.Parameter(torch.zeros(1))
        self.target_entropy = target_entropy
    @property
    def alpha(self): return self.log_alpha.exp()

def sac_actor_loss(policy, q_min, s, alpha):
    a, log_p = policy(s)
    return (alpha.detach() * log_p - q_min(s, a)).mean(), log_p

def sac_alpha_loss(log_p, alpha_module):
    return -(alpha_module.log_alpha * (log_p + alpha_module.target_entropy).detach()).mean()

Tests

class P(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(4, 4)
    def forward(self, s):
        h = self.l(s); mu, ls = h.chunk(2, -1)
        return squashed_gauss_logprob(mu, ls.clamp(-5, 2))
class C(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(6, 1)
    def forward(self, s, a): return self.l(torch.cat([s, a], -1)).squeeze(-1)
def q_min(s, a): return torch.minimum(C()(s, a), C()(s, a))
alpha_mod = SACAlpha(target_entropy=-2.0)
loss, lp = sac_actor_loss(P(), q_min, torch.randn(4, 4), alpha_mod.alpha)
print("SAC actor OK", loss.item())

IX. Model-based and planning

49. Random shooting MPC — ★★

Problem

Sample N random action sequences of horizon H, simulate them through a learned model, return the first action of the best sequence.

import numpy as np

def random_shooting(model, s0, action_dim, H=10, N=200, rng=None):
    rng = rng or np.random.default_rng(0)
    actions = rng.uniform(-1, 1, (N, H, action_dim)).astype(np.float32)
    states = np.broadcast_to(s0, (N, len(s0))).copy()
    rewards = np.zeros(N)
    for t in range(H):
        states, r = model(states, actions[:, t])
        rewards += r
    return actions[int(np.argmax(rewards)), 0]

Tests

def model(s, a): # toy: reward = -|a|, dynamics = s + a
    return s + a, -np.abs(a).sum(-1)
a = random_shooting(model, np.zeros(2), action_dim=2, H=3, N=50)
print("MPC random shoot OK", a)

50. Cross-entropy method (CEM) — ★★

Problem

Iteratively fit a Gaussian to the elite fraction of sampled actions; refine.

import numpy as np

def cem(model, s0, action_dim, H=10, N=200, elite=0.1, iters=5, rng=None):
    rng = rng or np.random.default_rng(0)
    mu = np.zeros((H, action_dim)); sigma = np.ones_like(mu)
    for _ in range(iters):
        actions = rng.normal(mu, sigma, size=(N, H, action_dim))
        states = np.broadcast_to(s0, (N, len(s0))).copy()
        rewards = np.zeros(N)
        for t in range(H):
            states, r = model(states, actions[:, t])
            rewards += r
        idx = rewards.argsort()[-int(N * elite):]
        elites = actions[idx]
        mu = elites.mean(axis=0); sigma = elites.std(axis=0) + 1e-6
    return mu[0]

Tests

def model(s, a): return s + a, -np.abs(a).sum(-1)
a = cem(model, np.zeros(2), action_dim=2, H=3, N=100)
print("CEM OK", a)

51. World-model latent rollout (Dreamer-style) — ★★

Problem

Roll out H steps in latent space using an RSSM-like step zt+1 = f(zt, at), and decode rewards rt = R(zt).

def latent_rollout(z0, policy, dyn, reward_fn, H):
    z = z0; total = 0.0
    for _ in range(H):
        a = policy(z); z = dyn(z, a); total = total + reward_fn(z)
    return total

Tests

dyn = lambda z, a: z + a
pol = lambda z: torch.zeros_like(z)
rew = lambda z: -z.norm()
total = latent_rollout(torch.ones(4), pol, dyn, rew, H=5).item()
print("rollout OK", total)

52. Discrete LQR (one Riccati step) —

Problem

For dynamics xt+1 = Axt+But with cost xt Qxt+ut Rut, compute the optimal gain K = (R+BPB)−1BPA for given P.

import numpy as np

def lqr_gain(A, B, Q, R, P):
    return np.linalg.solve(R + B.T @ P @ B, B.T @ P @ A)

def riccati_iter(A, B, Q, R, iters=200):
    P = Q.copy()
    for _ in range(iters):
        K = lqr_gain(A, B, Q, R, P)
        P = Q + A.T @ P @ A - A.T @ P @ B @ K
    return P, K

Tests

A = np.array([[1, 1.0], [0, 1]])
B = np.array([[0], [1.0]])
Q = np.eye(2); R = np.array([[1.0]])
P, K = riccati_iter(A, B, Q, R)
assert np.linalg.eigvals(A - B @ K).max() < 1; print("LQR stable OK")

X. Exploration

53. Intrinsic Curiosity Module (ICM) — ★★

Problem

ϕˆ(st+1) −ϕ(st+1)Compute the intrinsic reward ri = ηwhere ϕˆ is a forward model. 2

def icm_intrinsic(phi_s, phi_s2, fwd_model, action, eta=0.1):
    pred = fwd_model(phi_s, action)
    return eta * 0.5 * ((pred - phi_s2.detach()) ** 2).sum(-1)

Tests

phi = torch.randn(4, 8); phi2 = torch.randn(4, 8)
def fm(z, a): return z + a
out = icm_intrinsic(phi, phi2, fm, torch.zeros(4, 8))
assert out.shape == (4,); print("ICM OK")

54. Random Network Distillation (RND) — ★★

Problem

2 f ˆ(s) −f(s)where f is a random fixed network and fˆ a learned predictor. Bonus =

import torch

def rnd_bonus(target_net, predictor, s):
    with torch.no_grad():
        t = target_net(s)
    p = predictor(s)
    return ((p - t) ** 2).sum(-1)

Tests

class N(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(4, 8)
    def forward(self, x): return self.l(x)
b = rnd_bonus(N(), N(), torch.randn(4, 4))
assert b.shape == (4,); print("RND OK")

55. Count-based exploration via hashing — ★★

Problem

√Hash s into a 1D bucket via SimHash and reward 1/N(s) where N is the visit count.

import numpy as np
import math

class SimHashCount:
    def __init__(self, dim, k=32):
        self.A = np.random.randn(k, dim)
        self.counts = {}
    def visit(self, s):
        h = tuple(np.sign(self.A @ s).astype(int).tolist())
        self.counts[h] = self.counts.get(h, 0) + 1
        return 1.0 / math.sqrt(self.counts[h])

Tests

sh = SimHashCount(4)
b1 = sh.visit(np.ones(4)); b2 = sh.visit(np.ones(4)); b3 = sh.visit(-np.ones(4))
assert b1 == 1.0 and b2 < b1 and b3 == 1.0; print("simhash OK")

XI. Imitation learning

56. Behavior Cloning — ★★★

Problem

Train a policy by supervised learning on (state, expert-action) pairs.

import torch.nn.functional as F

def bc_loss_discrete(policy, states, actions):
    return F.cross_entropy(policy(states), actions)

def bc_loss_continuous(policy, states, actions):
    return F.mse_loss(policy(states), actions)

Tests

pol = nn.Linear(4, 3)
loss = bc_loss_discrete(pol, torch.randn(8, 4), torch.randint(0, 3, (8,)))
assert loss > 0; print("BC OK")

57. DAGGER step — ★★

Problem

Roll out the current policy, query the expert at every visited state, append (state, expert-action) to the dataset, and retrain.

import torch

def dagger_step(policy, expert, env, dataset, episodes=5):
    for _ in range(episodes):
        s = env.reset()
        for _ in range(200):
            with torch.no_grad():
                a = policy(torch.tensor(s, dtype=torch.float32)).argmax().item()
            a_e = expert(s)
            dataset.append((s, a_e))
            s, _, d = env.step(a)
            if d: break
    return dataset

Tests

class TinyP(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(2, 4)
    def forward(self, x): return self.l(x)
env = GridWorld()
def expert(s): return 1 # always go down
def state_to_vec(s): return np.array(s, dtype=np.float32)
class WrappedEnv:
    def __init__(self): self.e = GridWorld()
    def reset(self): return state_to_vec(self.e.reset())
    def step(self, a): s, r, d = self.e.step(a); return state_to_vec(s), r, d
ds = dagger_step(TinyP(), expert, WrappedEnv(), [], episodes=2)
assert len(ds) > 0; print("DAGGER OK", len(ds))

58. GAIL discriminator + reward — ★★

Problem

Train a discriminator to distinguish expert vs. policy state-actions; use −log D(s, a) as the reward signal.

import torch
import torch.nn.functional as F

def gail_disc_loss(D, expert_sa, policy_sa):
    d_e = D(expert_sa); d_p = D(policy_sa)
    return F.binary_cross_entropy_with_logits(d_e, torch.ones_like(d_e)) + \
            F.binary_cross_entropy_with_logits(d_p, torch.zeros_like(d_p))

def gail_reward(D, sa):
    with torch.no_grad():
        return -F.logsigmoid(-D(sa)) # equivalent to -log(1 - D)

Tests

class D(nn.Module):
    def __init__(self): super().__init__(); self.l = nn.Linear(4, 1)
    def forward(self, x): return self.l(x).squeeze(-1)
loss = gail_disc_loss(D(), torch.randn(4, 4), torch.randn(4, 4))
assert loss > 0; print("GAIL OK")

XII. Offline RL

59. CQL conservative regularizer — ★★

Problem

a exp Q(s, a)−Ea∼πβQ(s, a)) to the standard Q loss, pushing down OODConservative Q-learning adds α(log Σ actions.

import torch

def cql_regularizer(q_all, q_data, alpha=1.0):
    # q_all: (B, A) Q for all actions; q_data: (B,) Q at the dataset action
    return alpha * (torch.logsumexp(q_all, dim=1) - q_data).mean()

Tests

qa = torch.randn(8, 4); qd = qa[range(8), torch.randint(0, 4, (8,))]
out = cql_regularizer(qa, qd); assert out > 0; print("CQL OK")

60. Implicit Q-Learning (IQL) expectile regression — ★★

Problem

Train the value V via expectile loss Lτ(u) = |τ −⊮u<0| u2 with τ ∈ (0.5, 1).

import torch

def expectile_loss(diff, tau=0.7):
    weight = torch.where(diff > 0, tau, 1 - tau)
    return (weight * diff ** 2).mean()

Tests

diff = torch.tensor([-1., 1.])
loss = expectile_loss(diff, tau=0.9)
# Bigger penalty on the positive side because of larger weight
assert loss > 0; print("IQL expectile OK", loss.item())

61. Advantage-weighted regression (AWR) — ★★

Problem

Update the policy by maximising E(s,a)∼D[log π(a|s) · exp(A(s, a)/β)].

def awr_loss(policy, states, actions, advantages, beta=1.0, max_w=20.0):
    weights = (advantages / beta).exp().clamp(max=max_w)
    log_p = policy(states).log_softmax(-1).gather(1, actions.unsqueeze(1)).squeeze(1)
    return -(weights.detach() * log_p).mean()

Tests

pol = nn.Linear(4, 3)
loss = awr_loss(pol, torch.randn(8, 4), torch.randint(0, 3, (8,)), torch.randn(8))
assert loss != 0; print("AWR OK")

XIII. Multi-agent and planning

62. MCTS / UCT — ★★★

Problem

Implement a minimal UCT-based MCTS for a deterministic finite-state game: select with UCB, expand, rollout (random), backpropagate.

import math

class Node:
    def __init__(self, parent=None):
        self.parent = parent; self.children = {}
        self.N = 0; self.W = 0.0
    def Q(self): return self.W / max(1, self.N)

def uct_select(node, c=1.4):
    best, best_score = None, -1e18
    for a, ch in node.children.items():
        score = ch.Q() + c * math.sqrt(math.log(node.N + 1) / max(1, ch.N))
        if score > best_score: best, best_score = a, score
    return best, node.children[best]

def mcts_run(root, env, sim, n_iters=100):
    for _ in range(n_iters):
        node = root; e = env.clone()
        # SELECT
        while node.children and not e.is_terminal():
            a, node = uct_select(node)
            e.step(a)
        # EXPAND
        if not e.is_terminal():
            for a in e.legal_actions(): node.children[a] = Node(parent=node)
            if node.children:
                a = random.choice(list(node.children))
                node = node.children[a]; e.step(a)
        # ROLLOUT
        ret = sim(e)
        # BACKPROP
        while node:
            node.N += 1; node.W += ret; node = node.parent
    return max(root.children.items(), key=lambda kv: kv[1].N)[0]

Tests

class Toy:
    def __init__(self): self.s = 0
    def clone(self):
        t = Toy(); t.s = self.s; return t
    def is_terminal(self): return self.s >= 3
    def legal_actions(self): return [0, 1]
    def step(self, a): self.s += 1
def sim(e):
    while not e.is_terminal(): e.step(random.choice(e.legal_actions()))
    return 1.0
root = Node()
for a in [0, 1]: root.children[a] = Node(parent=root)
chosen = mcts_run(root, Toy(), sim, n_iters=200)
print("MCTS OK", chosen)

63. Self-play training step — ★★

Problem

Skeleton of an AlphaZero-style step: collect MCTS visit counts as targets; train policy network to match the MCTS distribution and value network to match the game outcome.

import torch.nn.functional as F

def az_loss(policy_logits, value, mcts_pi, outcome):
    p_loss = F.cross_entropy(policy_logits, mcts_pi) # mcts_pi can be soft labels
    v_loss = F.mse_loss(value.squeeze(-1), outcome)
    return p_loss + v_loss

Tests

loss = az_loss(torch.randn(2, 5), torch.randn(2, 1), torch.tensor([0, 3]), torch.tensor([1.0, -1.0]))
assert loss > 0; print("AZ loss OK")

64. Independent Q-learning (IQL) for MARL — ★★

Problem

Each agent runs Q-learning treating others as part of the environment.

def iql_step(Q_list, transitions, alpha=0.5, gamma=0.95):
    # Q_list: list of per-agent Q tables; transitions: list of (s_a, a, r, s_a')
    for Q, tr in zip(Q_list, transitions):
        s, a, r, s2 = tr
        Q[s][a] += alpha * (r + gamma * Q[s2].max() - Q[s][a])

Tests

Q = [np.zeros((4, 2)) for _ in range(2)]
iql_step(Q, [(0, 1, 1.0, 1), (0, 0, 0.5, 1)])
assert Q[0][0, 1] > 0; print("IQL MARL OK")

XIV. RLHF and LLM-specific RL

65. KL penalty to a reference policy — ★★★★

Problem

For an LM policy πθ and a frozen reference πref, the KL is Σt(log πθ −log πref) at the sampled tokens.

import torch.nn.functional as F

def lm_kl(logits_pi, logits_ref, tokens):
    log_p = F.log_softmax(logits_pi, dim=-1).gather(-1, tokens.unsqueeze(-1)).squeeze(-1)
    log_r = F.log_softmax(logits_ref, dim=-1).gather(-1, tokens.unsqueeze(-1)).squeeze(-1)
    return (log_p - log_r).sum(dim=-1).mean()

Tests

B, T, V = 2, 3, 5
lp = torch.randn(B, T, V); lr = lp.detach() + 0.01
toks = torch.randint(0, V, (B, T))
out = lm_kl(lp, lr, toks); assert out.item() != 0; print("LM KL OK")

66. PPO loss for LMs (with masks) — ★★★★

Problem

Per-token PPO clip with response masking (prompt tokens don’t contribute to the loss).

import torch

def ppo_lm_loss(logp_new, logp_old, adv, response_mask, clip=0.2):
    ratio = (logp_new - logp_old).exp()
    s1 = ratio * adv
    s2 = ratio.clamp(1 - clip, 1 + clip) * adv
    pg = -torch.min(s1, s2)
    return (pg * response_mask).sum() / response_mask.sum().clamp(min=1)

Tests

B, T = 2, 4
lp_n = torch.zeros(B, T); lp_o = torch.zeros(B, T); adv = torch.ones(B, T)
mask = torch.tensor([[0, 0, 1, 1], [0, 1, 1, 1.]])
loss = ppo_lm_loss(lp_n, lp_o, adv, mask)
assert torch.isclose(loss, torch.tensor(-1.0)); print("LM PPO OK")

67. Direct Preference Optimization (DPO) loss — ★★★★

Problem

Givenchosen/rejectedresponseswithlog-probsfrompolicyandreference,DPOminimises −log σ(β(log πθ(yw) −log πref(yw)) −β(log πθ(yl) −log πref(yl))).

import torch.nn.functional as F

def dpo_loss(pi_logp_w, pi_logp_l, ref_logp_w, ref_logp_l, beta=0.1):
    pi_diff = pi_logp_w - pi_logp_l
    ref_diff = ref_logp_w - ref_logp_l
    return -F.logsigmoid(beta * (pi_diff - ref_diff)).mean()

Tests

loss = dpo_loss(torch.tensor(0.5), torch.tensor(-0.5), torch.tensor(0.0), torch.tensor(0.0))
assert loss < math.log(2); print("DPO OK", loss.item())

68. GRPO advantage and update (DeepSeek) — ★★★

Problem

Group Relative Policy Optimization: sample G responses per prompt, set advantage Ai = (ri−mean(r))/std(r), apply PPO clip without a critic.

import torch

def grpo_advantages(rewards, eps=1e-8):
    mu = rewards.mean(); sd = rewards.std()
    return (rewards - mu) / (sd + eps)

def grpo_loss(logp_new, logp_old, adv, response_mask, kl_to_ref, beta_kl=0.04, clip=0.2):
    ratio = (logp_new - logp_old).exp()
    s1 = ratio * adv.unsqueeze(-1)
    s2 = ratio.clamp(1 - clip, 1 + clip) * adv.unsqueeze(-1)
    pg = -torch.min(s1, s2)
    pg = (pg * response_mask).sum() / response_mask.sum().clamp(min=1)
    return pg + beta_kl * kl_to_ref

Tests

A = grpo_advantages(torch.tensor([1., 2., 3., 4.]))
assert abs(A.mean().item()) < 1e-6
print("GRPO adv OK", A)

69. RLOO leave-one-out advantage — ★★★

Problem

1ΣFor G responses with rewards ri, the leave-one-out baseline is bi =j̸=i rj, advantage Ai = ri −bi. G−1

def rloo_advantages(rewards):
    G = rewards.size(0)
    total = rewards.sum()
    return rewards - (total - rewards) / max(1, G - 1)

Tests

A = rloo_advantages(torch.tensor([1., 2., 3., 4.]))
# For uniform shift, advantages should sum to 0 in expectation
assert abs(A.sum().item() - 0.0) < 1e-6 or len(A) == 4
print("RLOO OK", A)

70. KTO loss (Kahneman–Tversky Optimization) — ★★

Problem

KTO: chosen samples push log-ratio above a desired margin; rejected samples push it below. L = E[wd σ(−d)] where d depends on whether the sample is desirable.

import torch

def kto_loss(pi_logp, ref_logp, desirable, beta=0.1, lam_d=1.0, lam_u=1.0, ref_kl=0.0):
    log_ratio = pi_logp - ref_logp
    z = beta * (log_ratio - ref_kl)
    desired = lam_d * (1 - torch.sigmoid(z)) # for chosen
    undesired = lam_u * (1 - torch.sigmoid(-z)) # for rejected
    return torch.where(desirable.bool(), desired, undesired).mean()

Tests

loss = kto_loss(torch.tensor([1., -1.]), torch.tensor([0., 0.]), torch.tensor([1, 0]))
assert loss > 0; print("KTO OK", loss.item())

71. Reward model: pairwise preference loss — ★★★★

Problem

Train a reward model rϕ to maximise the probability that the chosen response is rated higher than the rejected one: −log σ(r(yw) −r(yl)).

import torch.nn.functional as F

def pairwise_pref_loss(r_chosen, r_rejected):
    return -F.logsigmoid(r_chosen - r_rejected).mean()

Tests

loss = pairwise_pref_loss(torch.tensor([1., 1.]), torch.tensor([0., 0.]))
assert loss < 0.5; print("pref RM OK", loss.item())

72. Best-of-N decoding — ★★★

Problem

Sample N candidate responses and keep the one with the highest reward-model score.

import numpy as np

def best_of_n(samples, reward_fn):
    scores = [reward_fn(s) for s in samples]
    return samples[int(np.argmax(scores))], max(scores)

Tests

samples = ["a", "abc", "abcdef"]
def rew(x): return len(x)
best, score = best_of_n(samples, rew); assert best == "abcdef"
print("BoN OK", best, score)

XV. Engineering and utilities

73. Reward normalization (running mean/std) — ★★★★

Problem

Normalise rewards online with Welford’s algorithm; subtract running mean (or only divide by std, as PPO/IM- PALA do).

import math

class RunningStats:
    def __init__(self):
        self.n = 0; self.mean = 0.0; self.M2 = 0.0
    def update(self, x):
        self.n += 1; d = x - self.mean
        self.mean += d / self.n
        self.M2 += d * (x - self.mean)
    @property
    def std(self): return math.sqrt(self.M2 / max(1, self.n - 1)) if self.n > 1 else 1.0
    def normalize(self, x): return x / max(self.std, 1e-8)

Tests

s = RunningStats()
for r in [1, 2, 3, 4]: s.update(r)
assert s.std > 1.0; print("running stats OK", s.std)

74. Observation normalization — ★★★

Problem

For each observation feature, maintain running mean/var and normalise to zero-mean, unit-variance.

import numpy as np

class ObsNorm:
    def __init__(self, dim, clip=10.0):
        self.mean = np.zeros(dim); self.var = np.ones(dim); self.n = 1e-4; self.clip = clip
    def update(self, x):
        b = len(x); xm = x.mean(0); xv = x.var(0)
        delta = xm - self.mean
        tot = self.n + b
        self.mean += delta * b / tot
        m_a = self.var * self.n; m_b = xv * b
        M2 = m_a + m_b + delta ** 2 * self.n * b / tot
        self.var = M2 / tot
        self.n = tot
    def normalize(self, x):
        return np.clip((x - self.mean) / np.sqrt(self.var + 1e-8), -self.clip, self.clip)

Tests

o = ObsNorm(2)
o.update(np.random.randn(100, 2))
y = o.normalize(np.random.randn(50, 2))
assert (np.abs(y) < 10).all(); print("obsnorm OK")

75. Generalised epsilon decay schedule — ★★★

Problem

Implement linear and exponential epsilon decay between ε0 and εmin over T steps.

import math

def eps_linear(t, T, eps0=1.0, eps_min=0.05):
    return max(eps_min, eps0 - (eps0 - eps_min) * t / T)

def eps_exp(t, half_life, eps0=1.0, eps_min=0.05):
    return eps_min + (eps0 - eps_min) * math.exp(-t / half_life)

Tests

assert eps_linear(0, 100) == 1.0 and eps_linear(100, 100) == 0.05
assert eps_exp(0, 100) == 1.0; print("eps OK")

76. Episode rollout collector — ★★★

Problem

Run the policy for one episode and return (states, actions, rewards, dones).

def collect_episode(env, policy_fn, max_steps=500):
    s = env.reset(); states, actions, rewards, dones = [], [], [], []
    for _ in range(max_steps):
        a = policy_fn(s)
        s2, r, d = env.step(a)
        states.append(s); actions.append(a); rewards.append(r); dones.append(d)
        s = s2
        if d: break
    return states, actions, rewards, dones

Tests

env = GridWorld()
ss, aa, rr, dd = collect_episode(env, lambda s: 1)
assert len(ss) == len(aa) == len(rr) == len(dd); print("rollout OK")

77. Vectorised environment fan-out — ★★★

Problem

Step N copies of an environment in parallel; reset finished envs.

class VecGridWorld:
    def __init__(self, N=8):
        self.envs = [GridWorld() for _ in range(N)]
    def reset(self):
        return [e.reset() for e in self.envs]
    def step(self, actions):
        out = []
        for e, a in zip(self.envs, actions):
            s, r, d = e.step(a)
            if d: e.reset()
            out.append((s, r, d))
        return out

Tests

vec = VecGridWorld(4)
vec.reset()
out = vec.step([1, 1, 1, 3])
assert len(out) == 4; print("vec env OK")

78. Generalised return calculator (returns + GAE in one pass) — ★★★★

Problem

Given rewards, values, dones, bootstrap values, return Gt (discounted) and GAE advantages.

def returns_and_gae(rewards, values, dones, last_v, gamma=0.99, lam=0.95):
    T = len(rewards); adv = [0.0] * T; G = [0.0] * T
    g = 0.0; nxt_v = last_v
    for t in reversed(range(T)):
        nonterminal = 1.0 - dones[t]
        delta = rewards[t] + gamma * nxt_v * nonterminal - values[t]
        g = delta + gamma * lam * nonterminal * g
        adv[t] = g
        G[t] = adv[t] + values[t]
        nxt_v = values[t]
    return G, adv

Tests

G, A = returns_and_gae([1, 1, 1], [0, 0, 0], [0, 0, 1], 0.0, gamma=1.0, lam=1.0)
assert G == [3.0, 2.0, 1.0]; print("returns+GAE OK")

79. Soft entropy bonus + value-function clipping (PPO-style) — ★★

Problem

PPO trick: clip the value function update too, taking the max of unclipped and clipped MSE losses.

import torch

def value_clipped_loss(v_new, v_old, returns, clip=0.2):
    v_clip = v_old + (v_new - v_old).clamp(-clip, clip)
    l1 = (v_new - returns) ** 2
    l2 = (v_clip - returns) ** 2
    return torch.maximum(l1, l2).mean()

Tests

v0 = torch.tensor([0., 0.]); v1 = torch.tensor([1.0, 1.0]); R = torch.tensor([2.0, 2.0])
loss = value_clipped_loss(v1, v0, R)
assert loss > 0; print("vclip OK")

80. Episode-aware advantage normalization — ★★★

Problem

Normalize advantages over the entire batch (mean / std) for stable PPO updates.

def normalize_adv(adv):
    return (adv - adv.mean()) / (adv.std() + 1e-8)

Tests

A = normalize_adv(torch.randn(32))
assert abs(A.mean().item()) < 1e-6 and abs(A.std().item() - 1.0) < 1e-2
print("adv norm OK")

81. Determinism / seeding helper — ★★

Problem

Set seeds across NumPy, PyTorch, and Python random to make a run reproducible.

import numpy as np
import torch

def set_seed(s):
    random.seed(s); np.random.seed(s); torch.manual_seed(s); torch.cuda.manual_seed_all(s)
    torch.backends.cudnn.deterministic = True
    torch.backends.cudnn.benchmark = False

Tests

set_seed(0); a = torch.randn(1).item()
set_seed(0); b = torch.randn(1).item()
assert a == b; print("seed OK")

82. Closing tips for the live RL coding interview

Notes

Survival tactics for live RL coding: