VKVlad KulikovPublic / lectures
RL for LLM1 / 13

Module 1. Introduction: Reinforcement Learning for LLMs

Read the explanations and code in the existing notebooks, or download the files. Code does not run on this site.

Download course files (.zip) 382.1 KB

The archive preserves the source folder structure, both languages, and supporting files.

Module 1 · Minimal RL Mechanics

Main module notebook

Download .ipynb module-01-rl-basics.en.ipynb · 18.5 KB

Show notebook text

Module 1 · Minimal RL Mechanics

This notebook is the hands-on companion to Module 1. It does not attempt to reproduce a large RLHF pipeline. Its purpose is more basic and more important: to make the first course objects executable — policy as a distribution, numerically stable softmax, entropy, trajectories, returns, and Monte Carlo evaluation.

Profile: Tier A — CPU, NumPy, Matplotlib.
Reproducibility: fixed seeds and deterministic helper functions.
Expected runtime: under one minute on an ordinary CPU.
Link to the lecture: the figures in §1.4 are built by the module figure generator; this notebook exposes the same mechanics in editable form.

The notebook is self-contained. The companion Modern LLMs course exposes the same next-token distribution inside a complete Transformer, but no knowledge of that course is required here.

0. Environment setup

Uncomment the installation line in Colab. Skip it in a local environment where numpy and matplotlib are already installed.

Python
# Uncomment the installation line when needed in Colab.
# %pip -q install numpy matplotlib
from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

import matplotlib.pyplot as plt
import numpy as np

OUTPUT_DIR = Path("outputs/module-01/en")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

np.set_printoptions(precision=6, suppress=True)
SEED = 7
rng = np.random.default_rng(SEED)

1. Return: accumulated future reward

For a finite trajectory we use

G0=r1+γr2+γ2r3+⋯ .G_0 = r_1 + \gamma r_2 + \gamma^2 r_3 + \cdots.

The implementation is deliberately short, but it validates its inputs. In RL, an indexing error or an unnoticed nan can look like “unstable training” even when the bug is in trajectory accounting.

Python
def discounted_return(rewards: list[float], gamma: float = 1.0) -> float:
    """Compute G_0 by backward recursion for a non-empty finite sequence."""
    reward_array = np.asarray(rewards, dtype=float)
    if reward_array.ndim != 1 or reward_array.size == 0:
        raise ValueError("rewards must be a non-empty 1D sequence")
    if not np.isfinite(reward_array).all():
        raise ValueError("rewards must be finite")
    if not np.isfinite(gamma) or not 0.0 <= gamma <= 1.0:
        raise ValueError("gamma must be finite and lie in [0, 1]")
    total = 0.0
    for reward in reward_array[::-1]:
        total = float(reward) + gamma * total
    return float(total)


assert discounted_return([0, 0, 1], gamma=1.0) == 1.0
assert np.isclose(discounted_return([1, 1, 1], gamma=0.9), 1 + 0.9 + 0.81)

discounted_return([0, 0, 0, 1], gamma=0.95)

2. Stable softmax and entropy

For an LLM, the next-token policy is a probability distribution. Softmax should be evaluated stably: subtract the maximum scaled logit before exponentiation. Entropy measures the spread of that distribution; it is not, by itself, a measure of answer quality.

Python
def softmax_with_temperature(logits: np.ndarray, temperature: float = 1.0) -> np.ndarray:
    """Numerically stable softmax(logits / temperature)."""
    logits = np.asarray(logits, dtype=float)
    if logits.ndim != 1 or logits.size == 0:
        raise ValueError("logits must be a non-empty one-dimensional array")
    if not np.all(np.isfinite(logits)):
        raise ValueError("logits must be finite")
    if not np.isfinite(temperature) or temperature <= 0:
        raise ValueError("temperature must be positive and finite")
    scaled = logits / float(temperature)
    scaled = scaled - np.max(scaled)
    weights = np.exp(scaled)
    return weights / np.sum(weights)


def categorical_entropy(probs: np.ndarray) -> float:
    """Entropy in nats for a categorical distribution."""
    probs = np.asarray(probs, dtype=float)
    if probs.ndim != 1 or probs.size == 0:
        raise ValueError("probs must be a non-empty one-dimensional array")
    if np.any(probs < 0) or not np.all(np.isfinite(probs)):
        raise ValueError("probs must be finite and non-negative")
    if not np.isclose(probs.sum(), 1.0, atol=1e-8):
        raise ValueError("probs must sum to 1")
    positive = probs > 0
    return float(-np.sum(probs[positive] * np.log(probs[positive])))


logits = np.array([2.0, 1.0, 0.0, -1.0])
for temperature in [0.25, 0.5, 1.0, 2.0, 4.0]:
    probs = softmax_with_temperature(logits, temperature)
    print(f"T={temperature:>4}: probabilities={probs}, H={categorical_entropy(probs):.4f}")

# An implementation without max subtraction would overflow on this example.
p = softmax_with_temperature(np.array([1000.0, 999.0, 998.0]), temperature=1.0)
assert np.all(np.isfinite(p)) and np.isclose(p.sum(), 1.0)

3. The temperature curve

For fixed finite logits, with no top-k, top-p, or other filtering, the entropy of a softmax policy is non-decreasing in temperature. The next plot is not an external benchmark; it is a numerical check of the formula for one fixed logit vector.

Python
temperatures = np.geomspace(0.1, 12.0, 400)
entropies = np.array([
    categorical_entropy(softmax_with_temperature(logits, t))
    for t in temperatures
])

fig, ax = plt.subplots(figsize=(8.5, 4.8))
ax.plot(temperatures, entropies, linewidth=2)
ax.axhline(np.log(len(logits)), linestyle="--", linewidth=1.2,
           label="maximum entropy ln K")
ax.set_xscale("log")
ax.set_xlabel("temperature T")
ax.set_ylabel("entropy H(pi_T), nats")
ax.set_title("Entropy of a pure softmax policy for fixed logits")
ax.legend()
fig.tight_layout()
fig.savefig(OUTPUT_DIR / "temperature_entropy.png", dpi=160)
plt.show()

4. The corridor environment

We now build a small environment. It is not a toy for its own sake: the corridor makes states, actions, terminal states, rewards, trajectories, and stopping rules visible. Later modules replace the five cells with tokens, complete responses, candidate groups, and tool-using environments, but the trajectory bookkeeping remains the same.

Python
@dataclass(frozen=True)
class CorridorConfig:
    n_states: int = 5
    start: int = 2
    left_terminal: int = 0
    right_terminal: int = 4
    max_steps: int = 20


def corridor_step(
    state: int,
    action: int,
    cfg: CorridorConfig = CorridorConfig(),
) -> tuple[int, float, bool]:
    """Execute one transition in the five-cell corridor."""
    if action not in (0, 1):
        raise ValueError("action must be 0 (left) or 1 (right)")
    if state in (cfg.left_terminal, cfg.right_terminal):
        return state, 0.0, True
    next_state = state - 1 if action == 0 else state + 1
    done = next_state in (cfg.left_terminal, cfg.right_terminal)
    reward = 1.0 if next_state == cfg.right_terminal else 0.0
    return next_state, reward, done


def random_policy(_: int, rng: np.random.Generator) -> int:
    """Select left or right with equal probability."""
    return int(rng.integers(0, 2))


def run_episode(
    rng: np.random.Generator,
    cfg: CorridorConfig = CorridorConfig(),
) -> dict:
    """Collect one trajectory, including its stopping rule and return."""
    state = cfg.start
    states = [state]
    actions: list[int] = []
    rewards: list[float] = []
    done = False
    for _ in range(cfg.max_steps):
        action = random_policy(state, rng)
        next_state, reward, done = corridor_step(state, action, cfg)
        actions.append(action)
        rewards.append(reward)
        states.append(next_state)
        state = next_state
        if done:
            break
    return {
        "states": states,
        "actions": actions,
        "rewards": rewards,
        "done": done,
        "return": discounted_return(rewards, gamma=1.0),
    }


for i in range(5):
    episode = run_episode(np.random.default_rng(SEED + i))
    print(
        f"episode {i}: states={episode['states']}, "
        f"actions={episode['actions']}, return={episode['return']}"
    )
Python
def plot_corridor_episode(
    episode: dict,
    cfg: CorridorConfig = CorridorConfig(),
) -> None:
    """Display the cells and the first transitions of one trajectory."""
    fig, ax = plt.subplots(figsize=(8.8, 2.4))
    ax.set_xlim(-0.5, cfg.n_states - 0.5)
    ax.set_ylim(-0.35, 1.2)
    ax.axis("off")

    for state in range(cfg.n_states):
        if state == cfg.left_terminal:
            face, edge, label = "#F8E2D2", "#D55E00", "terminal\n0"
        elif state == cfg.right_terminal:
            face, edge, label = "#FFF0C8", "#E69F00", "terminal\n+1"
        elif state == cfg.start:
            face, edge, label = "#D9EDF8", "#0072B2", "start\n2"
        else:
            face, edge, label = "#E6F4EF", "#009E73", str(state)
        box = plt.Rectangle(
            (state - 0.42, 0.22), 0.84, 0.52,
            facecolor=face, edgecolor=edge, linewidth=2,
        )
        ax.add_patch(box)
        ax.text(state, 0.48, label, ha="center", va="center", fontsize=11)

    states = episode["states"]
    for t, (start, end) in enumerate(zip(states[:-1], states[1:])):
        y = 0.96 + 0.04 * (t % 2)
        ax.annotate(
            "", xy=(end, y), xytext=(start, y),
            arrowprops={"arrowstyle": "->", "linewidth": 1.6},
        )
    ax.set_title(
        f"sample episode: return={episode['return']}, "
        f"steps={len(episode['actions'])}"
    )
    fig.tight_layout()
    fig.savefig(OUTPUT_DIR / "corridor_episode.png", dpi=160)
    plt.show()


plot_corridor_episode(run_episode(np.random.default_rng(SEED)))

5. Exact value and Monte Carlo estimation

Without a length cap, the probability of eventually reaching the right terminal from the center is 0.50.5. The code, however, truncates the episode at max_steps=20, so the experiment must be compared with the finite protocol:

V20π(2)=Pr⁡(T4≤20)=10232048.V_{20}^{\pi}(2)=\Pr(T_4\le 20)=\frac{1023}{2048}.

Monte Carlo evaluation estimates this expectation by averaging independent episodes. A finite estimate may lie above or below the exact value; its noise shrinks on the order of 1/N1/\sqrt{N}.

Python
def exact_corridor_value(max_steps: int = 20) -> float:
    """Compute the exact probability of reaching the right terminal within a horizon."""
    if not isinstance(max_steps, int) or isinstance(max_steps, bool) or max_steps <= 0:
        raise ValueError("max_steps must be a positive integer")
    values = np.zeros(5, dtype=float)
    values[4] = 1.0
    for _ in range(max_steps):
        updated = values.copy()
        for state in (1, 2, 3):
            updated[state] = 0.5 * values[state - 1] + 0.5 * values[state + 1]
        updated[0] = 0.0
        updated[4] = 1.0
        values = updated
    return float(values[2])


def monte_carlo_value(
    n_episodes: int,
    seed: int = SEED,
    cfg: CorridorConfig = CorridorConfig(),
) -> tuple[float, np.ndarray]:
    """Estimate the value by averaging returns from independent episodes."""
    if not isinstance(n_episodes, int) or isinstance(n_episodes, bool) or n_episodes <= 0:
        raise ValueError("n_episodes must be a positive integer")
    local_rng = np.random.default_rng(seed)
    returns = np.array(
        [run_episode(local_rng, cfg)["return"] for _ in range(n_episodes)],
        dtype=float,
    )
    return float(returns.mean()), returns


estimate_1000, returns_1000 = monte_carlo_value(1000, seed=SEED)
exact = exact_corridor_value(20)
print(f"Monte Carlo estimate at N=1000: {estimate_1000:.3f}")
print(f"Exact value at horizon 20: {exact:.12f}")
print(f"Approximate standard error: {np.sqrt(exact * (1 - exact) / 1000):.4f}")

# The Monte Carlo estimate changes with SEED; test its domain, not one draw.
assert np.isfinite(estimate_1000) and 0.0 <= estimate_1000 <= 1.0
assert np.isclose(exact, 1023 / 2048)
Python
n = 5000
_, returns = monte_carlo_value(n, seed=SEED)
ns = np.arange(1, n + 1)
running = np.cumsum(returns) / ns
exact = exact_corridor_value(20)
standard_error = np.sqrt(exact * (1 - exact) / ns)

fig, ax = plt.subplots(figsize=(8.8, 4.8))
ax.fill_between(
    ns,
    exact - 2 * standard_error,
    exact + 2 * standard_error,
    alpha=0.35,
    label="approximate ±2 standard errors",
)
ax.plot(ns, running, linewidth=1.5, label="running Monte Carlo estimate")
ax.axhline(exact, linestyle="--", linewidth=1.8,
           label="exact finite-horizon value")
ax.set_xscale("log")
ax.set_ylim(0.42, 0.58)
ax.set_xlabel("number of episodes N")
ax.set_ylabel("value estimate")
ax.set_title("Monte Carlo evaluation of the corridor policy")
ax.legend()
fig.tight_layout()
fig.savefig(OUTPUT_DIR / "corridor_mc_convergence.png", dpi=160)
plt.show()

6. Key takeaways from the notebook

  • A policy is a probability distribution, not the single action sampled from it.
  • Softmax must be computed with numerical stability in mind.
  • Entropy describes probability spread; it does not replace quality evaluation.
  • A trajectory has exact bookkeeping: states, actions, rewards, a stopping rule, and a return.
  • Monte Carlo estimates are random and must be compared with the exact protocol actually implemented.
  • The code and plots use seed=7. This makes the particular run reproducible, but it does not remove the statistical randomness of the method.

In Module 2, the corridor becomes a small MDP with Bellman equations. In Modules 6–9, the same ideas return at the level of token log-probabilities, KL, DPO, PPO, GRPO, and RLVR.