Code: data and style used in this chapter
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from scipy import stats
from statly import style
style.use()
rng = np.random.default_rng(1)In reinforcement learning nobody gives the right answers. An agent tries actions, receives rewards, and learns which actions pay off, balancing the urge to use what it already knows against the need to try things that might be better.
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from scipy import stats
from statly import style
style.use()
rng = np.random.default_rng(1)Supervised learning (Part 6) learns from examples labelled with the right answer. Reinforcement learning learns from consequences. An agent observes the state of its environment, chooses an action, receives a reward and a new state, and repeats. Its goal is a policy, a rule for choosing actions, that collects as much reward as possible over time. Game-playing programs, robot control and the final tuning stage of language models (Chapter 8.3) all work this way.
fig, ax = plt.subplots(figsize=(7, 2.6))
for x, name, color in ((0, "agent\n(policy)", style.BLUE), (4, "environment", style.GREEN)):
ax.add_patch(plt.Rectangle((x - 0.9, -0.5), 1.8, 1, color=color, alpha=0.35))
ax.text(x, 0, name, ha="center", va="center", fontsize=10)
ax.annotate(
"",
xy=(3.1, 0.25),
xytext=(0.9, 0.25),
arrowprops=dict(arrowstyle="->", color=style.INK, lw=1.5),
)
ax.text(2, 0.42, "action", ha="center", fontsize=9)
ax.annotate(
"",
xy=(0.9, -0.25),
xytext=(3.1, -0.25),
arrowprops=dict(arrowstyle="->", color=style.INK, lw=1.5),
)
ax.text(2, -0.6, "reward and new state", ha="center", fontsize=9)
ax.set(xlim=(-1.2, 5.2), ylim=(-0.9, 0.8))
ax.set_axis_off()
plt.show()
The simplest case has no states, just repeated choices. Three versions of a web page have unknown click rates; each visitor sees one version and clicks or not. Always showing the version that has done best so far (exploiting) can lock in an early lucky winner; showing versions at random (exploring) wastes visitors on poor versions forever. Good strategies explore less as they learn more.
RATES = np.array([0.04, 0.05, 0.06])
VISITORS, RUNS = 10_000, 200
def run_bandit(strategy: str, seed: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Cumulative regret, and click and show counts per version, for one simulated run."""
local = np.random.default_rng(seed)
clicks, shows = np.zeros(3), np.zeros(3)
regret = np.empty(VISITORS)
lost = 0.0
for t in range(VISITORS):
if strategy == "random":
arm = local.integers(3)
elif strategy == "epsilon":
arm = (
local.integers(3)
if local.uniform() < 0.1 or shows.min() == 0
else int(np.argmax(clicks / shows))
)
else:
arm = int(np.argmax(local.beta(1 + clicks, 1 + shows - clicks)))
clicks[arm] += local.uniform() < RATES[arm]
shows[arm] += 1
lost += RATES.max() - RATES[arm]
regret[t] = lost
return regret, clicks, shows
STRATEGIES = {"random": style.GREY, "epsilon": style.GOLD, "thompson": style.BLUE}
NAMES = {"random": "random choice", "epsilon": "ε-greedy", "thompson": "Thompson sampling"}
fig, axes = style.subplots(2, height=3.2, width=10.5)
for strategy, color in STRATEGIES.items():
regrets = np.mean([run_bandit(strategy, s)[0] for s in range(RUNS)], axis=0)
axes[0].plot(np.arange(1, VISITORS + 1), regrets, color=color, lw=2, label=NAMES[strategy])
axes[0].legend(fontsize=8)
axes[0].set(xlabel="visitors", ylabel="clicks lost (regret)")
_, clicks, shows = run_bandit("thompson", 0)
grid = np.linspace(0.02, 0.09, 400)
for k, color in enumerate((style.CORAL, style.GOLD, style.GREEN)):
posterior = stats.beta(1 + clicks[k], 1 + shows[k] - clicks[k])
axes[1].plot(
grid,
posterior.pdf(grid),
color=color,
lw=2,
label=f"true {RATES[k]:.0%}, shown {int(shows[k]):,}",
)
axes[1].xaxis.set_major_formatter(style.PERCENT)
axes[1].legend(fontsize=8)
axes[1].set(xlabel="click rate", ylabel="how plausible", yticks=[])
fig.tight_layout()
plt.show()
Bandits are an alternative to the fixed-size A/B test of Chapter 5.1: they lose fewer clicks while learning, at the price of a less clean comparison at the end.
With states, an action’s worth depends on where it leads, and rewards may come only much later. The value of taking action a in state s, written Q(s, a), is the expected total of future rewards, with rewards further in the future discounted by a factor γ < 1 per step. The values satisfy the Bellman equation: the value of an action is its immediate reward plus the discounted value of the best action in the state it leads to,
Q-learning estimates these values by experience: after each step, nudge the current estimate towards the reward received plus the discounted best estimate for the new state,
and act mostly greedily on the current estimates, with some exploration.
ROWS, COLS = 5, 6
WALLS = {(1, 1), (2, 1), (3, 3), (1, 4)}
GOAL, PIT, START = (0, 5), (2, 4), (4, 0)
MOVES = [(-1, 0), (1, 0), (0, -1), (0, 1)] # up, down, left, right
ARROWS = ["↑", "↓", "←", "→"]
def step(state: tuple[int, int], action: int) -> tuple[tuple[int, int], float, bool]:
"""Move if possible; return the new state, the reward and whether the episode ended."""
r, c = state[0] + MOVES[action][0], state[1] + MOVES[action][1]
if not (0 <= r < ROWS and 0 <= c < COLS) or (r, c) in WALLS:
r, c = state
if (r, c) == GOAL:
return (r, c), 1.0, True
if (r, c) == PIT:
return (r, c), -1.0, True
return (r, c), -0.01, False
def q_learning(
episodes: int = 3000, alpha: float = 0.2, gamma: float = 0.95, epsilon: float = 0.1
) -> tuple[np.ndarray, list[int]]:
"""Tabular Q-learning with ε-greedy exploration."""
local = np.random.default_rng(0)
q = np.zeros((ROWS, COLS, 4))
lengths = []
for _ in range(episodes):
state, done, steps = START, False, 0
while not done and steps < 200:
action = (
local.integers(4) if local.uniform() < epsilon else int(np.argmax(q[state]))
)
new_state, reward, done = step(state, action)
target = reward + (0 if done else gamma * q[new_state].max())
q[state][action] += alpha * (target - q[state][action])
state, steps = new_state, steps + 1
lengths.append(steps)
return q, lengths
q, lengths = q_learning()
values = q.max(axis=2)
fig, axes = style.subplots(2, height=3.4, width=10.5)
shown = np.where(
np.isin(np.arange(ROWS * COLS), [r * COLS + c for r, c in WALLS]).reshape(ROWS, COLS),
np.nan,
values,
)
axes[0].imshow(shown, cmap="Blues", vmin=-0.3, vmax=1.8)
for r in range(ROWS):
for c in range(COLS):
if (r, c) in WALLS:
axes[0].add_patch(plt.Rectangle((c - 0.5, r - 0.5), 1, 1, color=style.GREY))
elif (r, c) == GOAL:
axes[0].text(c, r, "G", ha="center", va="center", fontsize=12, fontweight="bold")
elif (r, c) == PIT:
axes[0].add_patch(
plt.Rectangle((c - 0.5, r - 0.5), 1, 1, color=style.CORAL, alpha=0.7)
)
else:
label = ARROWS[int(np.argmax(q[r, c]))] + ("\nS" if (r, c) == START else "")
axes[0].text(c, r, label, ha="center", va="center", fontsize=12, color="black")
axes[0].set(xticks=[], yticks=[], title="Learned values and policy")
axes[0].grid(False)
blocks = np.array(lengths).reshape(-1, 50).mean(axis=1)
axes[1].plot(np.arange(len(blocks)) * 50 + 25, blocks, color=style.BLUE, lw=2)
axes[1].set(xlabel="episode", ylabel="steps to finish", title="Learning")
fig.tight_layout()
plt.show()
A table of values works for a few dozen states. A board game or a robot has far too many states to list, so deep reinforcement learning replaces the table by a neural network (Chapter 8.1) that takes a state and outputs values or action probabilities, and generalises from states it has seen to similar ones. Policy gradient methods learn the policy directly, nudging the probability of each action in proportion to how much better than expected the following rewards turned out.
In reinforcement learning from human feedback, used to tune language models (Chapter 8.3), the “environment” is a reward model trained to predict which of two answers people prefer, and the policy is the language model itself.
Reinforcement learning is powerful and brittle: rewards that are sparse or poorly specified lead to slow learning or to agents that exploit loopholes in the reward instead of doing the intended task, and results can vary a lot between random seeds.
Next: Part 9, data that come in order, through time and until an event.