In the previous entries we explored how Monte Carlo Policy Iteration was able to learn directly from raw experience without requiring a model of the environment. This made Monte Carlo the first model-free episodic method we encountered: we act, collect trajectories, then use those trajectories to improve our value estimates and our policy. Unlike Dynamic Programming methods, which required a known transition model, Monte Carlo methods only needed samples from experience. But there was a catch: Monte Carlo methods require full episodes before learning anything. We must wait until a trajectory completes, compute the return: $$ G_t = R_{t+1} + \gamma R_{t+2} + \gamma^2 R_{t+3} + \dots $$ and only then update our value function.

But do we really need to wait for an entire episode?

Monte Carlo learning implicitly assumes that to get a good estimate of the long-term return, we need to look all the way to the end of the episode. But we also spent a lot of time establishing something important:
If the problem satisfies the Markov property, the next state and reward depend only on the current state and action.
If that is true (or true enough), then perhaps we can get away with not waiting until the end of the episode. Maybe we can learn during the episode — one transition at a time — instead of only after it. That idea leads to Temporal Difference (TD) learning. MarkovProperty

From Monte Carlo Updates to Temporal Difference Updates

Monte Carlo policy evaluation updated the value of a state using: $$ V(s_t) \leftarrow V(s_t) + \alpha \big[ G_t - V(s_t) \big], $$ where \(G_t\) is computed using full-episode returns. But if we want to learn every time step, we need a way to replace \(G_t\) with something available immediately. Note that: $$ G_t = R_{t+1} + \gamma G_{t+1}, $$ and the first part — the immediate reward — is always known after a single transition. For the rest of the return, we don’t have the true value… but we do have our current estimate of the next state’s value. Temporal Difference learning simply uses that: $$ G_t \approx R_{t+1} + \gamma V(s_{t+1}). $$ Plugging this approximation into the Monte Carlo update gives: $$ V(s_t) \leftarrow V(s_t) + \alpha \big[ R_{t+1} + \gamma V(s_{t+1}) - V(s_t) \big]. $$ The term: $$ \delta_t = R_{t+1} + \gamma V(s_{t+1}) - V(s_t) $$ is called the Temporal Difference error. It measures how “surprised” we are by what actually happened relative to what we previously believed. This learning procedure is called TD(0) because it uses only a single step of lookahead. It learns online, incrementally, and does not require a model. td(0)

A sampled Bellman backup and its source of bias

Fix the current value table while taking an expectation over the next transition. Under policy \(\pi\), $$\mathbb E[\delta_t\mid S_t=s]=(T^\pi V)(s)-V(s).$$ A TD error is therefore a noisy estimate of the current Bellman residual. It need not have mean zero until the table reaches the fixed point. Monte Carlo instead samples a complete return whose expectation is \(V^\pi(s)\), independent of the current table.

For a nonterminal transition, suppose \(V(s)=2\), \(V(s')=4\), \(r=1\), and \(\gamma=0.9\). The TD target is 4.6 and the error is 2.6; with learning rate 0.1, the new prediction is 2.26. If the observed remainder of the episode later totals a discounted return of 7, MC would have updated toward 7. TD can learn sooner, but its target depends on the accuracy of the successor estimate. The familiar bias–variance trade-off concerns that target, not a promise that TD is better on every task.

From State-Values to Action-Values

So far we discussed updating the state value function \(V(s)\). But acting optimally requires reasoning about actions, so TD learning is commonly applied to the action-value function: $$ Q(s,a). $$ Once we work with \(Q(s,a)\), we can fold the policy directly into the update rule. This is where SARSA appears as a natural TD control algorithm.

Several steps connect TD back to Monte Carlo

We need not choose only between one transition and a whole episode. If \(n\) steps are available before termination, an n-step return is $$G_t^{(n)}=\sum_{k=0}^{n-1}\gamma^kR_{t+k+1}+\gamma^nV(S_{t+n}).$$ If the episode terminates sooner, stop the reward sum there and omit the bootstrap. Setting \(n=1\) recovers TD(0); extending to the episode's end recovers the Monte Carlo return. Longer targets depend on more observed rewards and on a more distant, more heavily discounted estimate.

For a fixed table and a segment containing \(n\) nonterminal steps, the errors telescope: $$G_t^{(n)}-V(S_t)=\sum_{k=0}^{n-1}\gamma^k\delta_{t+k}.$$ Each intermediate value appears once positively and once negatively. This identity explains how later prediction errors can assign credit to earlier states. Online table changes complicate the exact equality, so it is best understood first with values held fixed over the calculation.

SARSA: On-Policy Temporal Difference Learning

SARSA is named after the sequence it uses in its update: (State, Action, Reward, Next State, Next Action) At each time step:
  • We are in state \(s_t\).
  • We pick action \(a_t\) using our current (usually ε-greedy) policy.
  • We observe reward \(R_{t+1}\) and next state \(s_{t+1}\).
  • We pick the next action \(a_{t+1}\) from the same policy.
  • We update \(Q(s_t,a_t)\) toward the one-step TD target that uses \(Q(s_{t+1}, a_{t+1})\).
The SARSA update is: $$ Q(s_t,a_t) \leftarrow Q(s_t,a_t) + \alpha \Big[ R_{t+1} + \gamma Q(s_{t+1}, a_{t+1}) - Q(s_t,a_t) \Big]. $$ Because both the behavior policy (used to generate data) and the target policy (used in the update) are the same, SARSA is an on-policy method. It learns how good it is to follow its actual exploration strategy. In environments where exploratory actions can be risky, SARSA often converges to safer policies, because it “bakes in” the consequences of exploration.

Averaging over the next action gives Expected SARSA

SARSA samples the next action from its policy and uses that action's Q-value. Expected SARSA averages over the same policy instead: $$y_{\mathrm{ES}}=r+\gamma(1-d)\sum_a\pi(a\mid s')Q(s',a).$$ Here \(d=1\) means true termination. This removes the randomness of sampling the next action conditional on \(s'\), while randomness in the reward and successor state remains.

For next-state values 5 and 1 with epsilon-greedy \(\varepsilon=0.2\), the two probabilities are 0.9 and 0.1. With \(r=0\) and \(\gamma=0.9\), SARSA's sampled target is 4.5 or 0.9; Expected SARSA's target is 4.14. Q-learning instead uses 4.5 regardless of which action exploration actually chooses. This difference explains why SARSA can value a route according to the risks of its continuing exploration, while Q-learning evaluates a greedy continuation.

Q-learning: learning a greedy target policy

Q-learning has a similar structure to SARSA, but makes a key change: instead of updating toward the value of the actual next action \(a_{t+1}\), it updates toward the value of the best possible next action: $$ Q(s_t,a_t) \leftarrow Q(s_t,a_t) + \alpha \Big[ R_{t+1} + \gamma \max_{a'} Q(s_{t+1}, a') - Q(s_t,a_t) \Big]. $$ This makes Q-learning an off-policy TD control algorithm:
  • The behavior policy can be ε-greedy (or anything that explores enough).
  • The target in the update always assumes a greedy policy in the future (via the max over actions).
Q-learning differs from SARSA in its target policy: it updates as if the agent will behave greedily in the next state, even if exploration actually chooses something else during training. Below we walk through a complete tabular Q-learning implementation for the CartPole environment, broken into pieces to show how the code instantiates the Q-learning update.

What the tabular convergence claim requires

In a finite discounted MDP with bounded rewards, tabular Q-learning converges under suitable sampling and step-size conditions: every state-action pair must be visited infinitely often, and its own step sizes satisfy $$\sum_n\alpha_n(s,a)=\infty,\qquad\sum_n\alpha_n(s,a)^2<\infty.$$ The first condition permits correction of initial errors; the second damps accumulated noise. A constant learning rate, a finite run, and a coarse observation digitizer do not meet this theorem merely because the code contains a max operator.

Q-learning is off-policy because its target uses a greedy action even when its behaviour explores. This is separate from optimism through deliberately high initial values or confidence bonuses. A noisy maximum can also overestimate values; that statistical effect motivates Double DQN later in the series. None of these uses of optimism implies that a Q-learning controller always takes a safe or high-reward action during training.

Q-learning tutorial: CartPole control in Python

We will work with a tabular Q-learning agent. That means we need:
  • categorical (discrete) states,
  • a table \(Q(s,a)\) for each discrete state–action pair,
  • an exploration strategy (ε-greedy), and
  • a training loop over many episodes.

Episode boundaries belong in the target

The terminal target is just the reward: $$y=r+\gamma(1-d)\max_aQ(s',a).$$ Bootstrapping after genuine failure adds a continuation that the task does not have. Conversely, an external collection time limit may stop a rollout while the underlying task could continue. The loop then resets, but the target can still bootstrap from the final observation. Keep the rollout-ending flag distinct from the true-termination flag used in the equation.

State aggregation introduces another limitation. The digitizer maps an observation to a bin \(\bar s=\phi(s)\), so different velocities or angles can share one Q entry. If their next-bin distributions differ, the aggregate process need not be exactly Markov. The ten edge values in each dimension below produce eleven possible indices, including the two outer intervals, for \(11^4\times2=29,282\) Q entries. This representation saves computation by treating observations as equivalent; it does not prove that the equivalence preserves the optimal policy.

Gymnasium: termination, truncation, and bootstrap targets

1. Imports and entry point: how the simulation runs

Imports and __main__ block

import gymnasium as gym
import matplotlib.pyplot as plt
import numpy as np

def plot_learning_curve(scores, x):
    running_avg = np.zeros(len(scores))
    for i in range(len(running_avg)):
        running_avg[i] = np.mean(scores[max(0, i-100):(i+1)])
    plt.plot(x, running_avg)
    plt.title('Running average of previous 100 scores')
    plt.savefig('qlearning_control_cartpole.png')


if __name__ == '__main__':
    env = gym.make('CartPole-v1')
    n_games = 50000
    eps_dec = 2 / n_games

    digitizer = CartPoleStateDigitizer()
    agent = Agent(
        alpha=0.01,
        gamma=0.99,
        n_actions=2,
        epsilon=1.0,
        eps_min=0.01,
        eps_dec=eps_dec,
        state_space=digitizer.states
    )

    scores = []

    for i in range(n_games):
        observation, _ = env.reset()
        done = False
        score = 0
        state = digitizer.digitize(observation)

        while not done:
            action = agent.choose_action(state)
            observation_, reward, terminated, truncated, info = env.step(action)
            done = terminated or truncated
            state_ = digitizer.digitize(observation_)
            agent.learn(state, action, reward, state_, terminated)
            state = state_
            score += reward

        if i % 5000 == 0:
            print('episode', i, 'score %.1f' % score, 'epsilon %.2f' % agent.epsilon)

        agent.epsilon_decrement()
        scores.append(score)

    x = [i + 1 for i in range(n_games)]
    plot_learning_curve(scores, x)
The flow is:
  1. Create the CartPole-v1 environment.
  2. Set the number of training episodes (n_games = 50000).
  3. Set the rate at which we decay ε (eps_dec), going from 1.0 down to around 0.01 over training.
  4. Instantiate the CartPoleStateDigitizer (to discretize continuous observations).
  5. Instantiate the Agent with learning rate alpha, discount gamma, etc.
  6. For each episode:
    • Reset the env, digitize the initial observation into a discrete state.
    • Loop until episode termination:
      • Choose an action via ε-greedy policy.
      • Step the environment, get next observation and reward.
      • Digitize the new observation into next state.
      • Call agent.learn(state, action, reward, state_, terminated) to apply the Q-learning update.
      • Move to the next state and accumulate episode reward.
    • Periodically print episode stats and current ε.
    • Decay ε via agent.epsilon_decrement().
    • Store each episode’s total score.
  7. After training, plot the running average score over the last 100 episodes.
Everything else in the script exists to support this main loop. Next we look at how we turn continuous CartPole observations into discrete states.

2. Digitizing CartPole state: making tabular Q-learning possible

CartPole observations are continuous: \((x, \dot x, \theta, \dot\theta)\). A tabular Q-learner needs discrete state indices. The CartPoleStateDigitizer class discretizes each dimension into bins and returns a tuple of bin indices.

CartPoleStateDigitizer

class CartPoleStateDigitizer():
    def __init__(self, bounds=(2.4, 4, 0.209, 4), n_bins=10):
        self.position_space = np.linspace(-1 * bounds[0], bounds[0], n_bins)
        self.velocity_space = np.linspace(-1 * bounds[1], bounds[1], n_bins)
        self.pole_angle_space = np.linspace(-1 * bounds[2], bounds[2], n_bins)
        self.pole_velocity_space = np.linspace(-1 * bounds[3], bounds[3], n_bins)
        self.states = self.get_state_space()

    def get_state_space(self):
        states = []
        for i in range(len(self.position_space) + 1):
            for j in range(len(self.velocity_space) + 1):
                for k in range(len(self.pole_angle_space) + 1):
                    for l in range(len(self.pole_velocity_space) + 1):
                        states.append((i, j, k, l))
        return states

    def digitize(self, observation):
        x, x_dot, theta, theta_dot = observation
        cart_x = int(np.digitize(x, self.position_space))
        cart_xdot = int(np.digitize(x_dot, self.velocity_space))
        pole_theta = int(np.digitize(theta, self.pole_angle_space))
        pole_thetadot = int(np.digitize(theta_dot, self.pole_velocity_space))
        return (cart_x, cart_xdot, pole_theta, pole_thetadot)
Key points:
  • Bin edges: each physical quantity is sliced into n_bins equally spaced edges; digitize also includes intervals outside those bounds.
  • State space: self.states enumerates all tuples \((i,j,k,l)\) of bin indices; this is the discrete state space used to initialize the Q-table.
  • digitize: np.digitize maps each real-valued observation component to an integer bin index. The result is a categorical state suitable for tabular Q-learning.
Conceptually, we are approximating the continuous MDP with a finite MDP by clustering similar observations into the same discrete state.

3. The Agent: Q-table, ε-greedy policy, and the Q-learning update

The Agent encapsulates the Q-learning logic: it holds the Q-table, chooses actions ε-greedily, and applies the update: $$ Q(s_t,a_t) \leftarrow Q(s_t,a_t) + \alpha \Big[ R_{t+1} + \gamma \max_{a'} Q(s_{t+1}, a') - Q(s_t,a_t) \Big]. $$

Agent class

class Agent():
    def __init__(self, alpha, gamma, n_actions, state_space,
                 epsilon=1, eps_dec=5e-4, eps_min=0.01):
        self.alpha = alpha
        self.gamma = gamma
        self.state_space = state_space
        self.actions = [i for i in range(n_actions)]
        self.epsilon = epsilon
        self.eps_dec = eps_dec
        self.eps_min = eps_min
        self.Q = {}
        self.init_Q()

    def init_Q(self):
        for state in self.state_space:
            for action in self.actions:
                self.Q[(state, action)] = 0.0

    def max_action(self, state):
        actions = np.array([self.Q[(state, a)] for a in self.actions])
        action = np.argmax(actions)
        return action

    def choose_action(self, state):
        if np.random.random() < self.epsilon:
            return np.random.choice(self.actions)
        return self.max_action(state)

    def epsilon_decrement(self):
        if self.epsilon > self.eps_min:
            self.epsilon = max(self.eps_min, self.epsilon - self.eps_dec)
        else:
            self.epsilon = self.eps_min

    def learn(self, state, action, reward, state_, terminated):
        a_max = self.max_action(state_)
        q_next = reward + self.gamma * (not terminated) * self.Q[(state_, a_max)]
        self.Q[(state, action)] += self.alpha * (q_next - self.Q[(state, action)])
Mapping this to the Q-learning equation:
  • Q-table: self.Q[(state, action)] represents \(Q(s,a)\).
  • Action space: self.actions is the set of possible actions \(\mathcal{A}\).
  • Greedy action: max_action(state) computes \(\arg\max_a Q(s,a)\).
  • ε-greedy policy: choose_action:
    • With probability ε, pick a random action (exploration).
    • Otherwise, pick the greedy action (exploitation).
  • Annealing ε: epsilon_decrement slowly reduces ε from 1.0 down to eps_min, shifting from exploration to exploitation.
  • Learning step: learn:
    • a_max = self.max_action(state_) computes \(\arg\max_{a'} Q(s_{t+1}, a')\).
    • q_next = reward + self.gamma * (not terminated) * self.Q[(state_, a_max)] computes the target \(R_{t+1}+\gamma(1-d_t)\max_{a'}Q(s_{t+1},a')\), with zero continuation after termination.
    • self.Q[(state, action)] += self.alpha * (q_next - self.Q[(state, action)]) applies the Q-learning update.
If we wanted this to implement SARSA instead, the only conceptual change would be in learn: we would pass the actual next action \(a_{t+1}\) into learn and update toward \(R_{t+1} + \gamma Q(s_{t+1}, a_{t+1})\) instead of using \(\max_{a'} Q(s_{t+1}, a')\). The existing figure below records the earlier course run. The corrected example above uses CartPole-v1 and explicit terminal masking; this historical curve is not a rerun of that updated code: Q-learning CartPole Learning Curve

Summary

  • Monte Carlo methods learn only after full episodes; TD methods learn every time step.
  • SARSA is an on-policy TD control algorithm: $$Q(s_t,a_t) \leftarrow Q(s_t,a_t) + \alpha [R_{t+1} + \gamma Q(s_{t+1},a_{t+1}) - Q(s_t,a_t)].$$
  • Q-learning is an off-policy TD control algorithm: $$Q(s_t,a_t) \leftarrow Q(s_t,a_t) + \alpha [R_{t+1} + \gamma \max_{a'}Q(s_{t+1},a') - Q(s_t,a_t)].$$
  • Tabular Q-learning requires discrete states; we used a digitizer to approximate CartPole’s continuous dynamics with a finite MDP.
  • The Q-learning update is implemented in Agent.learn, directly matching the mathematical form.
From here, the natural next step is to replace the Q-table with a function approximator (e.g., a neural network), leading to Deep Q-Networks (DQN) and beyond.