What it actually does
Q-learning asks "what would this be worth if I acted optimally from here?" and answers a question about a policy nobody is following, because the agent is still exploring. SARSA asks the question the agent can actually act on: "what is this worth given that I will keep behaving the way I am behaving, mistakes included?" The change is one symbol in the update — the next action is the one that will be taken rather than the best available — and the consequence is a genuinely different value function. An agent learning under SARSA discovers that standing next to a cliff is dangerous, because sometimes it does fall off, and it routes around the edge. Q-learning learns the shortest path along the edge and then keeps falling off it, because the values it learned assume an optimality its own exploration contradicts. Neither is wrong: SARSA is correct about the policy being run and Q-learning is correct about a policy that would be run if exploration stopped. Which one you want depends on whether exploration is free, and the answer to that is usually no.
- The behaviour policy and the policy being evaluated are the same — the defining assumption, and the one a replay buffer or a log breaks
- The environment is a Markov decision process, since the update bootstraps off the next state's value
- State and action spaces are enumerable, because a value is stored per pair and the improvement step ranges over all of them
- Every state-action pair is visited infinitely often, which exploration must deliver rather than be assumed to
- Exploration will continue during operation — which is what makes the epsilon-soft value function the relevant one rather than a compromise
Fixed-point condition
Compare it against the Bellman optimality equation and one operator has changed: a max over next actions has become an expectation under the policy. That is the on-policy/off-policy distinction in its entirety, and every difference between SARSA and Q-learning is downstream of it. Three consequences are worth holding onto. First, the fixed point depends on epsilon: SARSA converges to the value of the epsilon-soft policy it is running, so a policy that will sometimes act randomly is evaluated as a policy that will sometimes act randomly, which is why it prefers routes with room for error. That is the correct answer to the question an exploring agent should be asking, and it is a different question from the one Q-learning answers. Second, anneal epsilon toward zero and the two converge to the same place, because an epsilon-soft policy approaching greedy has a value function approaching the optimal one — so the disagreement is a statement about the learning process rather than about the destination. Third, the target uses the action that WILL be taken, which means the update cannot be formed until that action is chosen, and that chosen action must be the one executed. Implementations that sample an action for the update and then sample again to act are running something that is neither SARSA nor Q-learning, and the bug is invisible in every metric except the resulting behaviour.
- the action actually taken next, sampled from the behaviour policy — this single symbol is the whole difference from Q-learning
- the value of the policy being followed, exploration included, rather than of the greedy policy
- an epsilon-soft behaviour policy; the fixed point moves with epsilon, which is a feature rather than a defect
- the discount, setting the horizon over which exploration mistakes are counted
How it is minimized
Stochastic approximation on the on-policy TD error, with the next action drawn from the same policy that is being improved
- the value of the action actually taken next — a sample from the policy, and therefore a source of variance
- Expected SARSA's target: the same expectation computed exactly instead of sampled
- the step size; decaying to converge, constant to track a policy that is still changing
- the exploration rate, which sets both how much is explored and which policy is being evaluated
The update is one line and the interesting engineering is in the two variants around it. Expected SARSA replaces the sampled next action with the exact expectation under the policy, which removes the variance introduced by sampling a prime while leaving the bias untouched — strictly less noisy for the same computation up to a sum over actions, and in practice it tolerates a larger step size because of it. It also unifies the family: with a greedy target policy the expectation collapses to a max and Expected SARSA becomes Q-learning, so the two methods are endpoints of one parameterization rather than rivals. That framing is worth carrying, because it makes the real choice visible — not "SARSA or Q-learning" but "which policy should the target be evaluated under, and is it the one being executed". Eligibility traces extend the method the same way they extend TD, propagating one error back across the recent trajectory, and the on-policy setting makes them simpler than their off-policy counterparts because no importance correction is required. The cost of all of this is the same thing that gives it its advantages: being on-policy means the data must come from the current policy, so there is no replay buffer, no learning from logs, and no reuse of experience after the policy moves. That single limitation is why Q-learning rather than SARSA is what deep reinforcement learning was built on.
epsilon
Exploration rate, and also part of the definition of what is being learned — the fixed point is the value of the epsilon-soft policy, so changing it changes the answer (1.0 annealed toward 0.01)
step size (alpha)
Decaying satisfies Robbins-Monro and converges; constant tracks a policy that is still improving, which is the normal case in control (0.05 to 0.5)
discount (gamma)
The horizon over which exploration mistakes are counted, which is why a longer horizon makes SARSA more conservative than Q-learning rather than equally so (0.9 to 0.99)
lambda
Trace decay for SARSA(lambda). On-policy traces need no importance correction, which makes them simpler here than in any off-policy method (0.7 to 0.95)
expected vs sampled target
Expected SARSA removes the variance of sampling the next action for the cost of a sum over actions. Almost always worth it when the action set is small (expected, when |A| is small)
initial Q values
Optimistic initialization drives systematic early exploration for free, and interacts with epsilon rather than substituting for it (0, or above the largest plausible return)
SARSA converges to the optimal action-value function with probability one under two conditions together: the Robbins-Monro conditions on the step size, and GLIE — greedy in the limit with infinite exploration, which in practice means annealing epsilon as roughly one over the visit count. Hold epsilon fixed, as most implementations do, and it converges instead to the action-value function of the optimal epsilon-soft policy. That is not a failure: it is the correct value of the policy actually being run, and it is what makes the method safe around irreversible mistakes. It does mean that comparing a fixed-epsilon SARSA run against a Q-learning run and calling one better is comparing answers to two different questions. The characteristic failures are elsewhere. The most common is an implementation bug rather than an algorithmic one: sampling the next action to form the target and then sampling again to act breaks the on-policy identity, and the result is neither SARSA nor Q-learning and looks fine in every metric except the behaviour. A constant step size means the estimate tracks rather than converges, which is usually right and makes "converged" the wrong word. And under function approximation the tabular guarantee weakens to a bounded-error result at best — though SARSA is notably better behaved here than Q-learning, because on-policy sampling removes one leg of the deadly triad and the divergence that comes with it.
O(1) per step for the sampled target: one lookup for the current pair, one for the next, one arithmetic update. Expected SARSA is O(|A|) per step for the expectation, which is nothing for the small action sets it suits and prohibitive for large ones. SARSA(lambda) adds a trace pass, O(|S|·|A|) naively and O(k) with a cutoff. Memory is O(|S|·|A|) for the table, doubled if traces are kept. The binding cost is transitions rather than compute, and on-policy learning cannot reuse them: every policy change invalidates the experience collected before it, which is the practical price of the method.
How, where, and why it is used
Time-Series Forecasting
Not applicableThere is no action to evaluate and no policy to be on, so the one thing distinguishing this method from every other value estimator has nothing to attach to; forecasting becomes relevant only once a prediction feeds a decision, which is the control-and-operations case.
Anomaly Detection
Not applicableDetection scores observations without acting, so no behaviour policy exists to be on-policy with respect to; a large TD error here reports an under-learned state-action pair, which is a statement about the agent rather than about the data.
Optimization
Primary fitImprove a policy by evaluating it as it actually behaves — exploration included — rather than as it would behave if exploration stopped. The target uses the action that will be taken, which makes the learned values an honest account of the policy being run.
- Sequential decisions where exploration carries real cost, so the value of a state must account for the mistakes that will be made in it
- On-policy against off-policy as a design decision with visible consequences rather than a technicality
- Expected SARSA as the parameterization that contains both this and Q-learning, which reframes the choice as "under which policy is the target evaluated"
- Variance reduction by computing an expectation instead of sampling it, which is the same move that appears throughout estimation
The most instructive pairing in this category, because SARSA and Q-learning differ by one symbol and disagree substantively, which makes the on-policy question concrete rather than definitional. Three lessons transfer. The first is that an estimator should evaluate the thing you will actually do: Q-learning's values are correct about a hypothetical greedy agent and an exploring agent acting on them will underperform its own predictions, which is a mismatch that recurs whenever a model is fitted to an idealization of the system it will control. The second is Expected SARSA's framing — replacing a sampled expectation with a computed one costs a sum and removes variance for free, and doing so here reveals that the two methods are endpoints of a single family rather than alternatives. The third is the cost: on-policy learning cannot reuse experience, so every policy update discards the data collected before it, and that single property is why the deep reinforcement-learning literature is built on Q-learning instead. The honest summary is that SARSA is the better answer when exploration is expensive and the worse one when data is.
- Use the action actually executed in the target; sampling one for the update and another to act is neither method and fails silently
- Anneal epsilon if the optimal policy is the goal, since fixed epsilon converges to the best epsilon-soft policy instead
- Prefer Expected SARSA whenever the action set is small enough to sum over, since it removes variance for no bias
- Compare against Q-learning on the same problem rather than choosing on principle; the disagreement between them is informative about the task
Online return during learning, not just the final policy's return — SARSA's advantage is precisely that it performs better while still exploring, and an evaluation that only scores the greedy policy at the end measures the one thing the method does not optimize. Report both: the greedy-policy return says whether learning succeeded, and the behaviour-policy return says whether it was safe to run.
- Re-sampling the next action between the update and the step, which quietly breaks the on-policy identity
- Comparing a fixed-epsilon SARSA run against Q-learning as though they answered the same question
- Evaluating only the greedy policy, which discards the entire advantage of the method
- Reaching for a replay buffer, which invalidates the on-policy assumption without any error appearing
Control & Operations
Primary fitLearn a control policy from live interaction where exploration carries physical or financial cost, and value states by what will actually happen under the exploring policy rather than by what would happen under perfect play. The result routes away from configurations where a random action would be expensive.
- Process control near an operating envelope, where the cost of an exploratory action is not symmetric with its benefit
- Robotics and physical systems, where a random action next to a hard constraint is a real incident rather than a lost step
- Dispatch and scheduling under live operation, where the policy cannot be taken offline to be improved
- Any setting with an irreversible failure mode, where the value of a state must include the probability of stumbling into it
The strongest applied fit for this method specifically, and the argument is about safety rather than accuracy. An operating policy that is still learning will take exploratory actions, and in a physical system some of those are expensive — so a value function that assumes they will not be taken is systematically wrong about exactly the states where being wrong matters. SARSA prices them in, which is why it prefers a margin from the constraint boundary and why its online performance during learning is better than Q-learning's in precisely these environments. Two cautions. It is not a safety mechanism: it accounts for exploration in the values, it does not prevent a bad action, and a hard interlock is still required for anything genuinely dangerous. And because it is on-policy, it cannot learn from the historical logs most operations teams already have, which frequently makes the decision for them — an off-policy method with importance correction is the only way to use that data, whatever its other drawbacks.
- Include the distance to any constraint boundary in the state, since that is what the method will learn to keep
- Keep epsilon fixed and low deliberately when exploration remains live, and report that the learned values are the epsilon-soft ones
- Use a constant step size because the process drifts, then describe the estimate as tracking rather than converged
- Pair with a hard interlock; this method prices exploration into the values and does not prevent any individual action
Cumulative realized cost during learning, which is the quantity the method actually improves, alongside the greedy policy's simulated performance. Count constraint violations separately as hard failures rather than averaging them into the return, since a policy that is better on average and violates a limit is not better.
- Treating on-policy value estimation as a safety guarantee rather than a bias in the right direction
- Attempting to warm-start from historical logs, which the on-policy assumption forbids without correction
- Reporting only the greedy policy, which hides the online cost the method exists to reduce
- A discount chosen short for convergence speed, which also shortens how far ahead exploration risk is counted
Recommendation & Ranking
AdaptedLearn a session policy from live traffic where the serving policy is the one being evaluated, so the value of a state accounts for the exploration the system is actually performing. Applicable once the action set has been narrowed by candidate generation to something enumerable.
- Live on-policy learning on a surface that is already exploring deliberately
- Small action sets after candidate generation, where Expected SARSA's sum over actions is affordable
- Settings where exploration has a user-visible cost, so the value of a state should include the poor recommendations that will be made in it
- Session-level control where the serving policy and the learning policy are the same system
A narrow fit, and being clear about why is most of the value. The attraction is real: recommendation surfaces explore continuously, and a value function that prices that exploration in is a more honest account of what the system delivers than one assuming greedy play. What rules it out in most cases is the data. On-policy learning cannot use logged impressions from a previous policy, and logged impressions are what the domain has — years of them, which off-policy methods with importance correction can exploit and this cannot. The action space is the second obstacle: SARSA needs a value per state-action pair and a catalogue is not enumerable, so candidate generation has to come first. Where both obstacles are absent — a small slate of strategies, live traffic, deliberate exploration already in place — it is a reasonable choice, and its bias toward the policy actually being served is a genuine advantage over an off-policy estimate that assumes the ranker stops exploring.
- Narrow the action set with candidate generation before anything else; a catalogue-sized action space has no tabular value function
- Confirm the learning policy and the serving policy are the same object, since the method assumes it and nothing checks it
- Prefer Expected SARSA once the action set is small, which removes the variance of sampling the served item
- Do not plan to warm-start from logs; that requires an off-policy method and the correction it implies
Online tests decide. During learning, track the realized session value under the serving policy rather than the greedy policy's estimate, because the serving policy is what users experience and is what this method optimizes.
- Warm-starting from logged impressions, which breaks the on-policy assumption silently
- An action space that was never narrowed, leaving a value table that cannot be populated
- Judging by the greedy policy's offline estimate, which ignores the exploration users are actually receiving
- Assuming the serving policy and the learning policy stay identical through a deployment that updates one of them independently
What it costs to run
The cheapest per step in this category: two lookups and an arithmetic update, or O(|A|) for the expected variant. The cost that binds is transitions, and on-policy learning makes them expensive in a specific way — experience collected under a previous policy cannot be reused, so every improvement discards its own history and the sample requirement is set by how often the policy changes rather than by how much data exists. Illustrative, not a measured benchmark.
An argmax over the action row of a table: microseconds, fully inspectable, and identical to Q-learning at serving time. What differs is what the numbers mean — these are the values of the exploring policy, so reading them as the optimal policy's values overstates what a greedy deployment will achieve.
Continuous by construction, since the method learns from the policy it is running. With a constant step size there is no retrain event and the estimate tracks indefinitely; with a decaying one it converges and stops responding to change. On a live system the first is almost always right, and it means the deployment has no cadence so much as a permanent learner.
- Online return under the behaviour policy, which is the quantity this method improves and the one a greedy-only evaluation cannot see
- Gap between the behaviour-policy return and the greedy-policy return, which is the current cost of exploration and should shrink as epsilon anneals
- Epsilon and the visit-count schedule together, since the fixed point is defined by epsilon and a stalled anneal means the target moved and stopped
- TD-error distribution, whose mean should sit near zero once the policy has stabilized — a sustained departure means the environment moved
- State-action coverage, because an unvisited pair has no estimate and the improvement step will still rank it
- The action used in the target must be the action executed. Sampling one for the update and another to act is neither SARSA nor Q-learning, and it looks correct in every metric except the resulting behaviour
- With a fixed epsilon this converges to the value of the epsilon-soft policy, not the optimal one. That is the right answer to the right question and it is routinely reported as though it were the optimal value
- On-policy learning forbids a replay buffer. Adding one because it improved sample efficiency silently changes what is being learned, with no error anywhere
- Historical logs cannot be used without importance correction, which frequently decides the method choice before any of its advantages are considered
- Epsilon must actually be annealed at serving, or the deployed policy keeps taking random actions in production — the same trap as Q-learning, with the added subtlety that the values were learned assuming it
- SARSA is better behaved than Q-learning under function approximation because on-policy sampling removes one leg of the deadly triad, and that is a reason to prefer it that rarely appears in the comparison
- Its conservatism is a bias, not a safety mechanism. It prices exploration into the values and prevents no individual action, so anything genuinely dangerous still needs an interlock
Pros and cons, in context
Learns the value of the policy it is actually running, exploration included
The values are an honest account of what the agent will achieve rather than of what an idealized greedy agent would. That is the whole method, and it is why online performance during learning is better than Q-learning's in environments with costly mistakes
Safer around irreversible failures, because the risk of a random action is priced into nearby states
Not a safety mechanism but a bias in the right direction: it keeps a margin from constraint boundaries because it has learned that it sometimes stumbles. Anything genuinely dangerous still needs an interlock
Expected SARSA removes the variance of sampling the next action for the cost of a sum
Same bias, less noise, and it tolerates a larger step size as a result. It also unifies the family — a greedy target policy turns it into Q-learning — which reframes the choice as which policy the target evaluates
Better behaved than Q-learning under function approximation
On-policy sampling removes one leg of the deadly triad, so the divergence that afflicts off-policy bootstrapping with approximation does not arise. This is a real advantage and it is rarely mentioned in the comparison
Converges to the optimal policy under GLIE, so the conservatism is a property of the learning process rather than of the destination
Anneal epsilon and SARSA and Q-learning agree. The disagreement people observe is between a fixed-epsilon run and an off-policy one, which is a comparison of two different questions
On-policy learning cannot reuse experience
Every policy change invalidates the data collected before it: no replay buffer, no learning from logs, no experience reuse. This single limitation is why deep reinforcement learning was built on Q-learning instead
With a fixed epsilon it converges to the value of the epsilon-soft policy, not the optimal one
Correct, and routinely reported as though it were the optimal value. The gap is proportional to epsilon and it is the reason a fixed-epsilon SARSA run is not comparable to a Q-learning run
The on-policy identity is easy to break by accident
Sampling the next action for the target and then sampling again to act produces a method that is neither SARSA nor Q-learning. Nothing errors, no metric moves, and only the behaviour differs
The table does not generalize and does not scale
Every state-action pair learned independently, memory as the product of the two spaces. The same wall Q-learning hits, without the off-policy property that made the deep replacement possible
Expected SARSA costs a sum over actions
Negligible for a handful of actions and prohibitive for a large set, which is exactly where the variance it removes would have mattered most
Sample-inefficient, and made more so by being on-policy
It needs many visits to every pair and cannot amortize them across policy versions. In practice this means a simulator, and a simulator that can be stepped fast enough to keep up with a policy that changes constantly
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""Tabular SARSA - the on-policy update, transcribed.
One thing to watch above all others: a' is chosen BEFORE the update and then
executed. Drawing it twice - once to form the target, once to act - is neither
SARSA nor Q-learning, and nothing in any metric will tell you.
"""
import random
def epsilon_greedy(q_row, n_actions, epsilon):
"""The behaviour policy. It is also the policy being evaluated - that
identity is the whole method."""
if random.random() < epsilon:
return random.randrange(n_actions)
return max(range(n_actions), key=lambda a: q_row[a])
def train(env, n_states, n_actions, episodes=5_000, alpha=0.1, gamma=0.99, epsilon=0.1):
# Q[s][a] = expected discounted return from taking a in s and then
# continuing to behave epsilon-greedily. Not optimally. Epsilon-greedily.
Q = [[0.0] * n_actions for _ in range(n_states)]
for _ in range(episodes):
state = env.reset()
# The first action is drawn before the loop: every iteration needs an
# action already in hand, because the update it forms depends on it.
action = epsilon_greedy(Q[state], n_actions, epsilon)
done = False
while not done:
next_state, reward, done = env.step(action)
# Draw the next action NOW, from the same policy. This value is the
# a' inside the target AND the action executed on the next pass of
# the loop. One draw, used for both. Two draws is the classic bug.
next_action = 0 if done else epsilon_greedy(Q[next_state], n_actions, epsilon)
# Q[s'][a'], not max over a of Q[s'][a]. That single substitution
# is the entire difference from Q-learning, and it is why this
# learns the value of a policy that sometimes acts at random.
bootstrap = 0.0 if done else Q[next_state][next_action]
td_target = reward + gamma * bootstrap
Q[state][action] += alpha * (td_target - Q[state][action])
state, action = next_state, next_action
return Q
def expected_sarsa_target(q_row, n_actions, epsilon, gamma, reward):
"""Expected SARSA: compute the expectation instead of sampling it.
Epsilon-greedy puts epsilon/|A| on every action plus 1 - epsilon on the
greedy one. Summing that out by hand removes the variance of the draw and
leaves the bias untouched. Set epsilon to 0 here and the sum collapses to a
max - which is Q-learning. The two methods are endpoints of one family.
"""
greedy = max(range(n_actions), key=lambda a: q_row[a])
expectation = 0.0
for a in range(n_actions):
prob = epsilon / n_actions
if a == greedy:
prob += 1.0 - epsilon
expectation += prob * q_row[a]
return reward + gamma * expectationO(1) per step for the sampled target, O(|A|) for the expected one. Python lists throughout.
How this language actually wants it written.
"""Tabular SARSA - typed, annealed, with the on-policy invariant asserted."""
from dataclasses import dataclass, field
from typing import Protocol
import numpy as np
from numpy.typing import NDArray
QTable = NDArray[np.float64]
class Environment(Protocol):
def reset(self) -> int: ...
def step(self, action: int) -> tuple[int, float, bool]: ...
@dataclass(frozen=True)
class SarsaConfig:
n_states: int
n_actions: int
alpha: float = 0.1
gamma: float = 0.99
epsilon_start: float = 1.0
epsilon_end: float = 0.01
epsilon_decay_episodes: int = 1_000
# Sampled target vs. the exact expectation. Expected SARSA is strictly
# less noisy for the cost of a sum over actions, so it is the default
# whenever the action set is small enough to sum over.
expected: bool = True
def __post_init__(self) -> None:
if not 0.0 <= self.gamma < 1.0:
raise ValueError(f"gamma must be in [0, 1), got {self.gamma}")
if not 0.0 < self.alpha <= 1.0:
raise ValueError(f"alpha must be in (0, 1], got {self.alpha}")
if self.n_states < 1 or self.n_actions < 1:
raise ValueError("state and action spaces must be non-empty")
@dataclass
class TrainingResult:
q_table: QTable
# SARSA's advantage is online performance WHILE exploring, so the return
# under the behaviour policy is the quantity that matters. Recording only
# the greedy policy's return measures the one thing this does not optimize.
behaviour_returns: list[float] = field(default_factory=list)
greedy_returns: list[float] = field(default_factory=list)
mean_abs_td_error: list[float] = field(default_factory=list)
@property
def exploration_cost(self) -> float:
"""Gap between what the greedy policy would earn and what the agent
actually earns while still exploring. It should shrink as epsilon
anneals; if it does not, the anneal has stalled."""
if not self.behaviour_returns or not self.greedy_returns:
return 0.0
window = min(50, len(self.behaviour_returns), len(self.greedy_returns))
return float(
np.mean(self.greedy_returns[-window:]) - np.mean(self.behaviour_returns[-window:])
)
def _policy_probabilities(q_row: NDArray[np.float64], epsilon: float) -> NDArray[np.float64]:
"""Epsilon-greedy as an explicit distribution.
Writing the policy down as probabilities rather than as a sampling
procedure is what makes Expected SARSA one line, and it makes the
on-policy claim auditable: this is the distribution the target integrates
over and it must be the one the agent samples from.
"""
n_actions = q_row.shape[0]
probabilities = np.full(n_actions, epsilon / n_actions, dtype=np.float64)
probabilities[int(np.argmax(q_row))] += 1.0 - epsilon
return probabilities
def _anneal(episode: int, config: SarsaConfig) -> float:
progress = min(1.0, episode / config.epsilon_decay_episodes)
return config.epsilon_start + progress * (config.epsilon_end - config.epsilon_start)
def train(env: Environment, config: SarsaConfig, episodes: int = 5_000) -> TrainingResult:
q = np.zeros((config.n_states, config.n_actions), dtype=np.float64)
rng = np.random.default_rng(0)
result = TrainingResult(q_table=q)
for episode in range(episodes):
# Fixed epsilon converges to the value of the epsilon-soft policy, not
# the optimal one. Both are correct answers; only one of them is the
# answer people usually report.
epsilon = _anneal(episode, config)
state = env.reset()
probabilities = _policy_probabilities(q[state], epsilon)
action = int(rng.choice(config.n_actions, p=probabilities))
done = False
behaviour_return = 0.0
td_errors: list[float] = []
while not done:
next_state, reward, done = env.step(action)
if done:
# No future past a terminal. Bootstrapping past one is the
# single most common bug in any TD implementation.
bootstrap = 0.0
next_action = 0
else:
next_probabilities = _policy_probabilities(q[next_state], epsilon)
# Draw once. This action is both the a' in the target and the
# action executed next iteration - the on-policy invariant.
next_action = int(rng.choice(config.n_actions, p=next_probabilities))
bootstrap = (
float(next_probabilities @ q[next_state])
if config.expected
else float(q[next_state, next_action])
)
td_error = reward + config.gamma * bootstrap - q[state, action]
q[state, action] += config.alpha * td_error
behaviour_return += reward
td_errors.append(abs(td_error))
state, action = next_state, next_action
result.behaviour_returns.append(behaviour_return)
result.mean_abs_td_error.append(float(np.mean(td_errors)) if td_errors else 0.0)
return resultWhat changed: The policy is written down as an explicit probability vector rather than as a sampling procedure, which is what makes Expected SARSA a single dot product and — more importantly — makes the on-policy claim auditable: the distribution the target integrates over is visibly the one the agent samples from. Epsilon is annealed rather than fixed, so the fixed point walks toward the optimal policy instead of stopping at the epsilon-soft one. And the result records the behaviour-policy return alongside the greedy one, because the gap between them is the exploration cost this method exists to reduce and a greedy-only evaluation cannot see it.
O(|A|) per step for the expected target; the table is one contiguous array.
Faster, and the justification for each change.
"""Expected SARSA - vectorized across environments stepping in lockstep.
Note what is absent: a replay buffer. On-policy learning cannot reuse
experience collected under a previous policy, so the only parallelism
available is breadth - many environments running the CURRENT policy at once -
rather than depth into stored history. That constraint is the method, not an
oversight, and it is the reason deep RL was built on off-policy methods.
"""
import numpy as np
from numpy.typing import NDArray
QTable = NDArray[np.float64]
def expected_bootstrap(
q: QTable,
next_states: NDArray[np.intp],
epsilon: float,
out: NDArray[np.float64],
) -> NDArray[np.float64]:
"""Expectation under epsilon-greedy, in closed form, for a whole batch.
The sum over actions never needs to be built explicitly. Epsilon-greedy is
a uniform mixture plus a point mass on the greedy action, so its
expectation is exactly epsilon * mean(row) + (1 - epsilon) * max(row) -
two reductions instead of a weighted sum, and no probability vector is
materialized. Setting epsilon to 0 leaves max(row), which is the
Q-learning target; the family really is one parameterization.
"""
rows = q[next_states] # one gather, (batch, |A|)
np.max(rows, axis=1, out=out) # greedy component, in place
out *= 1.0 - epsilon
out += epsilon * rows.mean(axis=1) # uniform component
return out
def run_vectorized(
envs,
n_states: int,
n_actions: int,
steps: int = 100_000,
alpha: float = 0.1,
gamma: float = 0.99,
epsilon: float = 0.1,
) -> QTable:
rng = np.random.default_rng(0)
n_envs = len(envs)
q = np.zeros((n_states, n_actions), dtype=np.float64)
# Scratch buffers allocated once and reused every step, so the hot loop
# allocates nothing at all.
bootstrap = np.empty(n_envs, dtype=np.float64)
actions = np.empty(n_envs, dtype=np.intp)
states = np.array([env.reset() for env in envs], dtype=np.intp)
actions[:] = select_actions(q, states, epsilon, n_actions, rng)
for _ in range(steps // n_envs):
results = [env.step(int(a)) for env, a in zip(envs, actions)]
next_states = np.fromiter((r[0] for r in results), dtype=np.intp, count=n_envs)
rewards = np.fromiter((r[1] for r in results), dtype=np.float64, count=n_envs)
dones = np.fromiter((r[2] for r in results), dtype=np.bool_, count=n_envs)
expected_bootstrap(q, next_states, epsilon, bootstrap)
bootstrap[dones] = 0.0
targets = rewards + gamma * bootstrap
deltas = alpha * (targets - q[states, actions])
# np.add.at, not q[states, actions] += deltas: when two environments
# visit the same (s, a) in one batch, plain fancy-index assignment
# keeps only the last write and silently drops the others.
np.add.at(q, (states, actions), deltas)
# Choosing the next actions AFTER the update rather than before is
# deliberate: it keeps the behaviour policy synchronized with the table
# the agent is about to act on, which is what on-policy means once the
# updates are batched.
states = np.where(dones, [env.reset() for env in envs], next_states)
actions[:] = select_actions(q, states, epsilon, n_actions, rng)
return q
def select_actions(
q: QTable,
states: NDArray[np.intp],
epsilon: float,
n_actions: int,
rng: np.random.Generator,
) -> NDArray[np.intp]:
"""Epsilon-greedy for every environment in one pass - one RNG draw for the
explore mask, one argmax over the gathered rows, one select."""
explore = rng.random(states.shape[0]) < epsilon
greedy = np.argmax(q[states], axis=1)
return np.where(explore, rng.integers(n_actions, size=states.shape[0]), greedy).astype(np.intp)What changed: Two changes. The expectation over actions is folded into closed form — epsilon-greedy is a uniform mixture plus a point mass, so its expectation is epsilon·mean + (1−epsilon)·max, two reductions with no probability vector materialized and no per-action loop. And many environments step in lockstep under the current policy, which is the only parallelism an on-policy method is allowed: there is deliberately no replay buffer, because reusing experience from a previous policy would quietly change what is being learned with nothing erroring.
Fuse adjacent operations so intermediates are never materialized
The weighted sum over actions collapses to a mean and a max, so the |A|-length probability vector and the elementwise product it multiplies are never built.
Trade-off: The closed form is specific to epsilon-greedy — a softmax or any other policy loses it and has to build the probability vector again.
Batch work to amortize interpreter overhead
Per-step work is a gather and an arithmetic update, so a single-environment loop is almost entirely Python overhead; stepping n environments in lockstep amortizes it across the batch.
Trade-off: Environments must be steppable in lockstep and similar in episode length, and the batched update means the policy is one step stale relative to a strictly sequential SARSA run.
Pre-allocate output arrays and use in-place operations
The bootstrap and action buffers are allocated once and written in place with the out= parameter, so the hot loop performs no allocation.
Trade-off: np.add.at is far slower per element than plain fancy-index assignment — accepted deliberately, because the faster form silently drops duplicate state-action updates.
One batched update per n_envs steps; the expectation costs two reductions. Illustrative, not a measured benchmark.