Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Predicting a review label answers one question about a request. Choosing what to do next raises another: how will this action change the evidence, delay, and outcome? At 09:00, access-change request REQ-10234 lands on the routing desk under fictional policy P-7. The router could approve immediately, request supporting evidence, or escalate to a human reviewer. Compare the futures those choices create.
The earlier tree-model lesson fitted completed-case labels from static snapshots; supervised labels can arrive much later than predictions. Here, requesting evidence changes the next observation. Reinforcement learning (RL) learns decisions from rewards and their consequences.[1] Experience can come from interaction, simulation, or historical logs. The agent chooses routing actions; the environment includes the requester, verification service, and reviewers. We optimize expected cumulative return under a declared reward and discount, rather than only the next reward.
Teaching scores below are synthetic reward units, not dollar amounts or live production parameters. The unsafe approval option stays in our simulator to expose failure patterns. Real authorization layers must enforce hard invariants through deterministic guardrails rather than relying on a learned penalty to prevent policy violations.
From one label to an episode
REQ-10234 is a stale access-change request. Security policy P-7 requires human review unless strong secondary evidence arrives. Three opening actions are available:
Before checking the mathematical totals, pause and predict: if evidence definitely arrives, does the fastest action win, or can a small upfront delay pay for a higher-value outcome?
| Initial action | Immediate effect | Reward now | Later best outcome |
|---|---|---|---|
auto_approve | Closes an unsupported required-review request | -120 | Terminal policy violation |
request_evidence | Asks for evidence before approving | -4 | Evidence supports safe automatic approval |
human_review | Enters the reviewer queue immediately | -18 | Reviewer inspects credentials and approves |
An episode starts at new_request and terminates at closed. Suppose evidence arrives with absolute certainty, giving these reward sequences:
| Route | Reward sequence | Concrete meaning |
|---|---|---|
| Approve immediately | [-120] | Fast, but violates compliance rules |
| Request evidence, then approval | [-4, +70] | Brief inquiry delay, supported resolution |
| Review, then approve | [-18, +80] | Reviewer handling cost, verified resolution |
Without discounting, evidence earns while human review earns . Now discount rewards observed one step later by a factor of . The evidence route earns . Human review earns . Immediate approval still incurs . The evidence route leads by five points when evidence arrives reliably.
The discounted return accumulates rewards across time steps with increasing powers of :
Here is received after action from state . The discount factor is in our model.[1] Two roles need distinguishing:
- A bounded continuing return: With and , . Undiscounted continuing sums may diverge; average-reward formulations are another legitimate objective.
- A chosen temporal preference: Discounting downweights later positive rewards and later penalties. It can favor earlier resolution, but can also make a delayed violation look cheaper. Uncertain dynamics need their own model or robustness analysis.
Every branch here terminates within three transitions, so would also give finite returns. Each transition represents an operational step, not necessarily an equal duration: a constant per-step discount doesn't price hours consistently if step lengths differ. We model abandonment explicitly below. A discount has an equivalent survival interpretation only under suitable independent termination with zero reward on termination; it can't replace our action-dependent abandonment penalty.
The Python script applies those weights directly to each path:
gamma = 0.90
routes = {
"auto_approve": [-120],
"request_evidence": [-4, 70],
"human_review": [-18, 80],
}
def discounted_return(rewards):
return sum((gamma**step) * reward for step, reward in enumerate(rewards))
assert discounted_return([-4, 70]) == 59.0
assert discounted_return([-18, 80]) == 54.0
for action, rewards in routes.items():
print(f"{action:16} return={discounted_return(rewards):7.1f}")auto_approve return= -120.0
request_evidence return= 59.0
human_review return= 54.0That five-point advantage relies on evidence arriving every time. If the next observation depends on the action just taken, which facts does the router have to remember?
Define the MDP
The router has to remember whether verified evidence has arrived, because that fact changes both the next state and the reward of auto_approve. A Markov decision process (MDP) keeps that memory in a 5-tuple :
- States : The set of all valid environment configurations. captures what's known at decision time .
- Actions : The set of choices available in state .
- Transition dynamics : The conditional probability distribution .
- Reward function : The expected immediate reward .
- Discount factor : The decay rate for future rewards.
Sequential interaction between the agent and environment repeats until termination:

Under the Markov assumption, the current state and action contain all information needed to predict the next state and reward distribution:
The past adds nothing to this distribution once the state and action are known.[1] If elapsed wait affects abandonment, omitting it can make our chosen representation non-Markov. An MDP environment may expose only partial observations to the agent; model that as a POMDP or use a sufficient history or belief representation. Dropping a field doesn't itself change the underlying environment.
Begin with a deterministic environment where transitions occur with probability :
| State | Available action | Next state | Reward |
|---|---|---|---|
new_request | auto_approve | closed | -120 |
new_request | request_evidence | evidence_ready | -4 |
new_request | human_review | review_queue | -18 |
evidence_ready | auto_approve | closed | +70 |
evidence_ready | human_review | review_queue | -10 |
review_queue | approve_request | closed | +80 |
Evidence changes whether auto_approve is permitted under policy P-7, so new_request and evidence_ready must be distinct states.
The graph uses shortened action labels: evidence means request_evidence, review means human_review, and auto means auto_approve. Each edge carries its immediate reward.

Why are new_request and evidence_ready separate states?
Answer
The presence of verified credentials alters the legality and reward of taking auto_approve. In new_request, approving violates policy P-7 and triggers a compliance penalty. In evidence_ready, the exact same action closes a verified request safely.
The graph shows where each action lands and what it pays immediately. It doesn't yet say which state is worth being in once later rewards are discounted.
Value and Bellman backups
A state's worth depends on which action comes next. A policy defines how the agent behaves. A deterministic policy maps each state directly to a single action: . A stochastic policy outputs an action distribution: .
Two foundational value functions measure performance under a policy :
- State-value function : The expected return starting from state and following policy thereafter:
- Action-value function : The expected return starting from state , taking action , and following policy thereafter:
The connection between state values and action values is defined by the Bellman expectation equations. We decompose the return into immediate reward plus the discounted value of the successor state:
When seeking optimal control, we aren't satisfied with an arbitrary policy. The optimal policy achieves the highest possible return across all states: for all . This produces the Bellman optimality equations:
The backward calculation updating a state's value from its successors is called a Bellman backup. Starting from terminal outcomes and walking backward:
- Terminal state
closedhas no future transitions: . review_queuehas one action (approve_request): .evidence_readycompares auto-approval against human review:new_requestcompares all three opening options:
The backup tree shows how action values feed into the root state:

The script performs this exact comparison and extracts the greedy action:
gamma = 0.90
successor_value = {
"closed": 0.0,
"evidence_ready": 70.0,
"review_queue": 80.0,
}
start_actions = {
"auto_approve": (-120, "closed"),
"request_evidence": (-4, "evidence_ready"),
"human_review": (-18, "review_queue"),
}
scores = {
action: reward + gamma * successor_value[next_state]
for action, (reward, next_state) in start_actions.items()
}
assert scores["request_evidence"] == 59.0
assert scores["human_review"] == 54.0
for action, score in scores.items():
print(f"Q(new_request, {action:16}) = {score:7.1f}")
print("greedy action =", max(scores, key=scores.get))Q(new_request, auto_approve ) = -120.0
Q(new_request, request_evidence) = 59.0
Q(new_request, human_review ) = 54.0
greedy action = request_evidenceAt evidence_ready, why isn't the +80 reviewer reward enough to make human_review win?
Answer
Reaching that +80 requires paying -10 upfront and discounting the +80 to 72. That branch totals 62, whereas approving with existing evidence pays 70 immediately. Bellman backups compare complete discounted returns across entire trajectories, not isolated maximum rewards.
One backup was enough because this graph is tiny, every path ends, and the successor values were already known. What fills those values in when you can't write every backup by hand?
Dynamic programming: value iteration vs policy iteration
Repeated Bellman backups fill them in, once the transitions and expected rewards are known. For a finite discounted MDP, that sweep is dynamic programming, and it can compute an optimal policy. Two standard algorithms are:
- Policy Iteration: Operates in two distinct alternating phases:
- Policy Evaluation: Solve the Bellman expectation equations for a fixed policy , using a linear solve or iterative evaluation. Practical iterations use a tolerance.
- Policy Improvement: Extract a strictly better or equal policy greedily: . Exact evaluation and greedy improvement give . There are finitely many deterministic policies, ; stochastic policies aren't a finite set. With consistent tie handling, exact policy iteration terminates at an optimum. Approximate evaluation needs its own error control rather than an automatic exact guarantee.
- Value Iteration: Avoids waiting for complete policy evaluation. Instead, it collapses evaluation and improvement into a single step by directly applying the Bellman optimality operator on every sweep:
For this finite model with bounded rewards and , the Bellman optimality operator contracts under the infinity norm: . Exact repeated backups converge geometrically to a unique from any finite initialization. Optimal actions can still tie, so uniqueness of the value doesn't imply a unique optimal policy.
Watch how credit propagates backward across three sweeps:
gamma = 0.90
states = ["new_request", "evidence_ready", "review_queue", "closed"]
actions = {
"new_request": ["auto_approve", "request_evidence", "human_review"],
"evidence_ready": ["auto_approve", "human_review"],
"review_queue": ["approve_request"],
}
transitions = {
("new_request", "auto_approve"): [(1.0, "closed", -120)],
("new_request", "request_evidence"): [(1.0, "evidence_ready", -4)],
("new_request", "human_review"): [(1.0, "review_queue", -18)],
("evidence_ready", "auto_approve"): [(1.0, "closed", 70)],
("evidence_ready", "human_review"): [(1.0, "review_queue", -10)],
("review_queue", "approve_request"): [(1.0, "closed", 80)],
}
def action_value(state, action, values):
return sum(
probability * (reward + gamma * values[next_state])
for probability, next_state, reward in transitions[(state, action)]
)
values = {state: 0.0 for state in states}
for sweep in range(3):
updated = values.copy()
for state in actions:
updated[state] = max(
action_value(state, action, values) for action in actions[state]
)
values = updated
print(
f"sweep {sweep + 1}: "
f"new={values['new_request']:5.1f} "
f"evidence={values['evidence_ready']:5.1f} "
f"review={values['review_queue']:5.1f}"
)
policy = {
state: max(actions[state], key=lambda action: action_value(state, action, values))
for state in actions
}
assert values["new_request"] == 59.0
assert policy["new_request"] == "request_evidence"
print("policy =", policy)sweep 1: new= -4.0 evidence= 70.0 review= 80.0
sweep 2: new= 59.0 evidence= 70.0 review= 80.0
sweep 3: new= 59.0 evidence= 70.0 review= 80.0
policy = {'new_request': 'request_evidence', 'evidence_ready': 'auto_approve', 'review_queue': 'approve_request'}On sweep 1, all successor estimates start at zero, so new_request sees only immediate costs (). On sweep 2, the newly calculated values of evidence_ready () and review_queue () propagate backward, lifting new_request to . On sweep 3, values stabilize completely.
Value iteration solves planning when dynamics are known. What happens when our assumption of certain evidence arrival breaks down?
Transition risk can reverse a decision
Real users don't always cooperate. When asked for additional authentication credentials, some users close the tab and abandon the flow. Add an abandonment branch: with probability , the requester walks away. The ticket closes unresolved, incurring a penalty alongside the initial dispatch cost, totaling :
Action from new_request | Transition probability | Next state | Immediate reward |
|---|---|---|---|
request_evidence | 0.75 | evidence_ready | -4 |
request_evidence | 0.25 | closed (abandoned) | -54 |
Under this model, the expected counts in 100 independent requests are 75 arrivals and 25 abandonments; realized counts vary. We charge abandonment on the opening transition. If it occurred after a separate waiting step, its timing and discounted return would need changing. Weight the current branches by their probabilities:
Direct human review remains deterministic: . Comparing the two choices under risk:
The expected Bellman backup flips the optimal action from request_evidence to human_review!
We can solve for the exact indifference threshold. Let be the abandonment probability. The expected return for requesting evidence as a function of is:
Equating this expected return to human review's constant value of :
Above abandonment, human review has higher expected return under these rewards and timings. This isn't a universal product threshold.
The stochastic backup script reflects this calculation:
gamma = 0.90
values = {"closed": 0.0, "evidence_ready": 70.0, "review_queue": 80.0}
transitions = {
"request_evidence": [
(0.75, "evidence_ready", -4),
(0.25, "closed", -54),
],
"human_review": [(1.0, "review_queue", -18)],
}
def expected_action_value(action):
return sum(
probability * (reward + gamma * values[next_state])
for probability, next_state, reward in transitions[action]
)
assert expected_action_value("request_evidence") == 30.75
assert expected_action_value("human_review") == 54.0
for action in transitions:
print(f"{action:16} expected return={expected_action_value(action):5.2f}")
print("risk-aware action =", max(transitions, key=expected_action_value))request_evidence expected return=30.75
human_review expected return=54.00
risk-aware action = human_reviewThe expectation isn't a return any single ticket achieves. Check a sample mean from 10,000 simulated episodes against it. One seeded run can check the simulator's arithmetic without proving convergence or validating real abandonment rates:
import random
gamma = 0.90
rng = random.Random(7)
def request_evidence_return():
if rng.random() < 0.75:
return -4 + gamma * 70
return -54
evidence_returns = [request_evidence_return() for _ in range(10_000)]
evidence_mean = sum(evidence_returns) / len(evidence_returns)
abandoned = sum(value < 0 for value in evidence_returns)
assert abs(evidence_mean - 30.75) < 1.0
print(f"request_evidence mean={evidence_mean:5.2f}")
print(f"human_review mean={-18 + gamma * 80:5.2f}")
print("abandoned evidence routes =", abandoned)request_evidence mean=30.73
human_review mean=54.00
abandoned evidence routes = 2502Unknown dynamics leave two choices: estimate a model and plan with it, or learn values or policies directly from sampled transitions. Model-free learning takes the latter route; unknown probabilities don't force it.
Model-free learning: temporal difference updates
Our model-free learner receives sampled experience tuples rather than a transition table.
Two primary approaches address this challenge:
- Monte Carlo (MC) methods: Update value estimates using the actual realized total return observed at the end of the episode:
- Target: For a fixed policy and correctly sampled completed episodes, has conditional expectation . That doesn't make an arbitrarily initialized, finite-data value estimate unbiased.
- Noise: The target includes all later randomness; long stochastic episodes can produce high variance, while deterministic returns can have none.
- Timing: Standard episodic MC waits for the completed return. A truncated discounted rollout can approximate a continuing return, but introduces tail error; appending a value estimate makes a bootstrapped target.
- Temporal Difference (TD) learning: Bootstraps future return using the agent's current estimate at the next state :
where the TD error is defined as:
The target replaces the unobserved tail of the episode.
- Target: Bootstrapping from an inaccurate successor value can introduce target bias. With exact , the one-step target has conditional expectation .
- Noise: Replacing a sampled tail with its exact conditional expectation reduces target variance. A learned approximation adds its own errors, so TD doesn't have universally lower finite-data variance or error than MC.
- Benefit: Updates immediately on every time step without waiting for episode termination.
For control tasks where we must select actions without a model, we learn action values rather than state values . Two algorithms frame the core design choice:
- Q-Learning (Watkins, 1989 [2]): An off-policy TD control algorithm. It updates toward the greedy maximum in the next state: Because the target uses , Q-learning targets the optimal action-value function even while the agent follows an exploratory behavior policy (such as -greedy). A greedy action choice from those values defines the target policy.
- SARSA (Sutton & Barto [1]): An on-policy TD control algorithm. It uses the transition : Here is the action actually selected by the current behavior policy. SARSA learns the value of the policy being executed, incorporating the cost of exploratory mistakes.
For a stationary finite MDP with bounded rewards and , tabular Q-learning converges to almost surely when every state-action pair is updated infinitely often and its own visit-indexed step sizes satisfy and .[2][1] This theorem describes asymptotic behavior under those conditions. A finite run provides a sample trajectory; neural Q-functions require separate convergence analysis.
To ensure exploration, we apply epsilon-greedy exploration: with probability , choose an action uniformly at random; with probability , select the highest-scoring action.
The simulator's step function below contains the transition dynamics and rewards; the learner receives only sampled outcomes:
import random
gamma = 0.90
epsilon = 0.15
rng = random.Random(42)
actions = {
"new_request": ["auto_approve", "request_evidence", "human_review"],
"evidence_ready": ["auto_approve", "human_review"],
"review_queue": ["approve_request"],
}
def step(state, action):
if state not in actions or action not in actions[state]:
raise ValueError("invalid state-action pair")
if state == "new_request":
if action == "auto_approve":
return "closed", -120
if action == "human_review":
return "review_queue", -18
if rng.random() < 0.75:
return "evidence_ready", -4
return "closed", -54
if state == "evidence_ready":
return ("closed", 70) if action == "auto_approve" else ("review_queue", -10)
return "closed", 80
q = {
state: {action: 0.0 for action in available_actions}
for state, available_actions in actions.items()
}
visits = {
state: {action: 0 for action in available_actions}
for state, available_actions in actions.items()
}
for _ in range(20_000):
state = "new_request"
while state != "closed":
available_actions = actions[state]
if rng.random() < epsilon:
action = rng.choice(available_actions)
else:
action = max(available_actions, key=lambda candidate: q[state][candidate])
next_state, reward = step(state, action)
future = 0.0 if next_state == "closed" else max(q[next_state].values())
target = reward + gamma * future
visits[state][action] += 1
learning_rate = visits[state][action] ** -0.6
q[state][action] += learning_rate * (target - q[state][action])
state = next_state
start = q["new_request"]
assert max(start, key=start.get) == "human_review"
assert 20.0 < start["request_evidence"] < 40.0
for action, value in start.items():
print(f"Q(new_request, {action:16}) = {value:6.1f}")
print("learned start action =", max(start, key=start.get))Q(new_request, auto_approve ) = -120.0
Q(new_request, request_evidence) = 30.5
Q(new_request, human_review ) = 54.0
learned start action = human_reviewAfter this seeded run, request_evidence is estimated near , close to the analytic . The convergence theorem is asymptotic and assumes conditions such as step-size sums and repeated visits. Our visit-indexed rate satisfies the two step-size sums; constant exploration supplies repeated visits in this reachable toy graph, while the seeded run reports one finite trajectory. The greedy extracted policy chooses review, but the training behavior still explores. Once review is the unique greedy opening action, it is selected with probability 0.85 + 0.15/3 = 0.90; each other action still receives probability 0.05. Random exploration can also select the greedy action, so its probability isn't just 1 - epsilon. The simulator accepts only declared state-action pairs; its unsafe opening action remains solely for this experiment.
A stopped rollout isn't always a terminal request
The lab sets future value to zero when the ticket reaches closed. An external collection time limit is different: the ticket may still have a valuable future. Gymnasium distinguishes terminated from truncated; a one-step bootstrap masks genuine termination, not merely truncation.[3]
reward, gamma, next_value = -18.0, 0.90, 80.0
for label, terminated, truncated in (
("external time limit", False, True),
("terminal outcome", True, False),
):
stopped = terminated or truncated
correct = reward + gamma * (not terminated) * next_value
erased_tail = reward + gamma * (not stopped) * next_value
print(f"{label}: target={correct:.1f}; stop-masked target={erased_tail:.1f}")external time limit: target=54.0; stop-masked target=-18.0
terminal outcome: target=-18.0; stop-masked target=-18.0The same observed reward produces different targets because the future differs. A deadline built into the task can be a genuine terminal condition. Include remaining time in the state when it affects decisions. Use the actual final observation for a truncated bootstrap, rather than a reset observation from an automatically reset environment.
Those updates only revise actions the learner actually tries. What happens to an action that stays at its starting zero?
Exploration and action coverage
With zero initialization, deterministic tie handling, and no exploration, a positive learned action value can keep an untried zero-valued action from being selected. A stochastic return or optimistic initialization could change that behavior. Replace each whole path with its true expected value and watch human_review receive zero visits:
import random
true_returns = {
"auto_approve": -120.0,
"request_evidence": 30.75,
"human_review": 54.0,
}
action_order = list(true_returns)
def learn(epsilon, seed):
rng = random.Random(seed)
estimates = {action: 0.0 for action in action_order}
visits = {action: 0 for action in action_order}
for _ in range(400):
if rng.random() < epsilon:
action = rng.choice(action_order)
else:
action = max(action_order, key=lambda candidate: estimates[candidate])
visits[action] += 1
estimates[action] += (true_returns[action] - estimates[action]) / visits[action]
selected = max(action_order, key=lambda candidate: estimates[candidate])
return visits, selected
greedy_visits, greedy_selected = learn(0.00, seed=5)
explore_visits, explore_selected = learn(0.10, seed=1)
assert greedy_selected == "request_evidence"
assert greedy_visits["human_review"] == 0
assert explore_selected == "human_review"
for epsilon, visits, selected in (
(0.00, greedy_visits, greedy_selected),
(0.10, explore_visits, explore_selected),
):
print(f"epsilon={epsilon:.2f} visits={visits} selected={selected}")epsilon=0.00 visits={'auto_approve': 1, 'request_evidence': 399, 'human_review': 0} selected=request_evidence
epsilon=0.10 visits={'auto_approve': 17, 'request_evidence': 28, 'human_review': 355} selected=human_reviewWith , human_review receives zero visits. The agent remains blind to the superior return and commits forever to request_evidence. With , exploration samples all actions, discovers review's higher value, and concentrates out of decisions on the true optimum.
In production access systems, you can't freely explore dangerous actions on live traffic. Instead, restrict the action space through policy guardrails prior to model evaluation, or conduct exploration inside high-fidelity staging environments.
In tabular Q-learning, why does the update use the maximum next-state Q-value rather than the action the behavior policy actually took?
Answer
Q-learning is off-policy: its target uses the maximum next-state action value rather than the exploratory next action. That defines an optimal-control target while exploratory behavior gathers data. It doesn't remove sampling noise, insufficient coverage, or maximization bias from finite estimates.
Reward specification is part of the system
Reinforcement learning agents optimize the numeric signal they receive, not the unspoken intent in an engineering spec. When rewards measure only convenient proxies, agents find unexpected loopholes.
Consider what happens if an optimization team rewards fast ticket closure:
actions = ["auto_approve", "request_evidence", "human_review"]
speed_only = {
"auto_approve": 100,
"request_evidence": 45,
"human_review": 15,
}
outcome_aware = {
"auto_approve": -120,
"request_evidence": 30.75,
"human_review": 54.0,
}
for name, reward in [("speed_only", speed_only), ("outcome_aware", outcome_aware)]:
choice = max(actions, key=lambda action: reward[action])
print(f"{name:13} chooses {choice:16} reward={reward[choice]:4}")speed_only chooses auto_approve reward= 100
outcome_aware chooses human_review reward=54.0The speed-only reward selects auto_approve. It maximizes the turnaround number by approving a request that policy P-7 forbids. That is Goodhart's Law in this table: once the proxy is the target, it stops tracking the outcome you meant. The compliance boundary belongs in the execution filter, not only in the reward.
Policy gradients and the LLM bridge
Tabular methods store a separate value for each state-action pair. A language-model state is a token prefix, and there are too many prefixes to store a table. A parameterized policy lets the network weights output the action probabilities directly.
For an episode ending at , define from a fixed initial-state distribution. Assume differentiable policies and environment dynamics that don't depend directly on . The trajectory score identity and reward-to-go argument give:[1]
Sampling this expectation gives REINFORCE updates.[4] We don't need a transition table, but do need experience from the appropriate policy. The outer measures time from the episode's start; discounts only from the current step. It disappears for an undiscounted episodic objective with .
Check that distinction with a two-step episode: the opening action is fixed, the second action is Bernoulli with probability , and it earns reward 1 only when action 1 is selected. Thus . At , which derivative should REINFORCE reproduce?
from math import exp
gamma, theta = 0.90, 0.0
def probability(parameter):
return 1 / (1 + exp(-parameter))
p = probability(theta)
second_step_gradient = sum(
action_probability * (action - p) * action
for action, action_probability in ((0, 1 - p), (1, p))
)
weighted_gradient = gamma * second_step_gradient
delta = 1e-5
finite_difference = gamma * (
probability(theta + delta) - probability(theta - delta)
) / (2 * delta)
assert abs(weighted_gradient - finite_difference) < 1e-9
print(f"with the time weight: {weighted_gradient:.4f}")
print(f"without the time weight: {second_step_gradient:.4f}")
print(f"objective finite difference: {finite_difference:.4f}")with the time weight: 0.2250
without the time weight: 0.2500
objective finite difference: 0.2250A baseline changes variance, not the intended gradient
Subtract an action-independent, state-dependent baseline , often an estimate of . The true advantage is:
The sampled quantity estimates that advantage when ; it isn't the exact action value. Because , an action-independent baseline leaves the score estimator's expectation unchanged.[1][5] Treat it as a fixed weight when differentiating the actor loss. A baseline fitted using the same action's return needs care; action independence isn't automatic.
Variance reduction depends on the baseline. In a one-step Bernoulli policy with and reward equal to the selected action, compare no baseline, the exact value baseline, and a badly chosen baseline:
for baseline in (0.0, 0.5, 10.0):
gradients = [(action - 0.5) * (action - baseline) for action in (0, 1)]
mean_gradient = sum(gradients) / 2
variance = sum((value - mean_gradient)**2 for value in gradients) / 2
print(f"baseline={baseline:.1f}: mean={mean_gradient:.4f}; variance={variance:.4f}")baseline=0.0: mean=0.2500; variance=0.0625
baseline=0.5: mean=0.2500; variance=0.0000
baseline=10.0: mean=0.2500; variance=22.5625All three have the correct mean derivative, but one makes sampling much noisier. The exact value baseline removes variance in this tiny example; it isn't universally the variance-minimizing baseline for every parameterization.
Return to the router. Freeze all downstream choices at the model's optimal actions and optimize only the opening softmax policy. The script sums over its three known action values exactly and checks the derivative against finite differences. It computes an opening-policy gradient; a whole-policy REINFORCE trainer would sample trajectories and update all policy decisions:
import math
actions = ["auto_approve", "request_evidence", "human_review"]
action_values = [-120.0, 30.75, 54.0]
logits = [0.0, 0.0, 0.0]
def softmax(values):
peak = max(values)
weights = [math.exp(value - peak) for value in values]
total = sum(weights)
return [weight / total for weight in weights]
probabilities = softmax(logits)
baseline = sum(p * value for p, value in zip(probabilities, action_values))
advantages = [value - baseline for value in action_values]
n = len(actions)
gradient = [0.0] * n
for i, advantage in enumerate(advantages):
for j in range(n):
score = (1.0 if i == j else 0.0) - probabilities[j]
gradient[j] += probabilities[i] * score * advantage
def objective(candidate_logits):
return sum(p * value for p, value in zip(softmax(candidate_logits), action_values))
delta = 1e-5
for j in range(n):
plus, minus = logits.copy(), logits.copy()
plus[j] += delta
minus[j] -= delta
finite_difference = (objective(plus) - objective(minus)) / (2 * delta)
assert abs(gradient[j] - finite_difference) < 1e-6
updated = softmax([logit + 0.03 * value for logit, value in zip(logits, gradient)])
assert abs(updated[0] - 0.089) < 0.001
assert abs(updated[2] - 0.508) < 0.001
for action, before, after in zip(actions, probabilities, updated):
print(f"{action:16} before={before:.3f} after={after:.3f}")
print("exact gradient matches finite differences")auto_approve before=0.333 after=0.089
request_evidence before=0.333 after=0.403
human_review before=0.333 after=0.508
exact gradient matches finite differencesThe gradient step pushes the human review probability from to , while driving the policy-violating auto-approval down to .
For a text-only completion with no external tool interaction, token generation has a useful MDP representation:
- State : The prompt plus generated tokens, ; is just the prompt.
- Action : The next token , including an end-of-sequence token. Vocabulary size depends on the model.
- Transition dynamics: Deterministic token appending: .
- Reward : Typically a sparse scalar assigned at sequence completion by a trained reward model or an automated verifier (such as unit test execution or mathematical proof checks).
Tool-using agents also have external observations and actions; appending a token doesn't describe all their environment dynamics. Prompt difficulty, the reward's units, termination, and token-length weighting remain part of the training objective.
In classical RLHF (Ouyang et al., 2022 [6]), Proximal Policy Optimization (PPO, Schulman et al., 2017 [7]) optimizes the language model:
- An actor network generates candidate completions.
- A separate critic network predicts expected reward from partial token sequences, providing token-level baselines for generalized advantage estimation (GAE).
- A frozen reference model penalizes large divergence from the initial supervised model using a token-level Kullback-Leibler (KL) penalty: .
The human-preference reward model is a fourth role, scoring completions. Architectures can share weights across these roles or implement them with separate networks. A separate large critic adds weights, training state, compute, and communication; total memory depends on the chosen architecture.
PPO's clipped surrogate compares the current policy with the policy that sampled the rollout. That older sampling policy and the frozen reference serve different purposes. Clipping discourages some large probability-ratio changes; it doesn't impose a hard KL bound or guarantee improvement under repeated optimization.[7]
DeepSeek introduced Group Relative Policy Optimization (GRPO) in DeepSeekMath (2024) and used it in DeepSeek-R1 (2025). The original recipe provides a critic-free example, rather than a complete description of every current GRPO variant:[8][9]
- Eliminating the critic: GRPO doesn't train a separate value network .
- Group sampling: For each input prompt , the policy samples a group of independent completions: .
- Group-relative score: Each completion receives a reward . DeepSeekMath describes learned outcome and process reward models; R1's reasoning RL uses rule-based accuracy and format rewards. For outcome supervision, normalize the completion scores across the group, with a small numerical stabilizer:
- Objective: Use a PPO-style clipped token-ratio surrogate and a reference-policy KL term. In the original outcome-supervision recipe, each token in a completion receives the same group-relative score. Removing the value model saves its resources; rollout lengths, group size, and other models still determine total cost.
If every completion has the same reward, the normalized reward component is zero; a KL term may still contribute an update. This empirical group-relative statistic differs from the exact advantage. Its group mean includes the sample being updated, and its standard deviation rescales prompt contributions. State which variant, normalization, and length weighting you use when estimating policy gradients.
The bridge is reward-based probability updates over sequences. A better surrogate score doesn't itself prove stronger reasoning, a better verifier, or useful behavior on held-out tasks.
What a policy scorecard can and can't establish
Use an oracle scorecard to check four constructed starting cases against our known environment equations. Each rule below chooses an action in those two starting states; the sole approve_request action at review_queue is fixed. The chosen mixture of two new_request and two evidence_ready cases is part of the calculation, not representative-traffic evidence:
action_values = {
"new_request": {
"auto_approve": -120,
"request_evidence": 30.75,
"human_review": 54.0,
},
"evidence_ready": {"auto_approve": 70, "human_review": 62.0},
}
cases = ["new_request", "new_request", "evidence_ready", "evidence_ready"]
policies = {
"always_auto": lambda state: "auto_approve",
"always_review": lambda state: "human_review",
"risk_aware": lambda state: (
"human_review" if state == "new_request" else "auto_approve"
),
}
for name, policy in policies.items():
returns = [action_values[state][policy(state)] for state in cases]
unsupported = sum(
state == "new_request" and policy(state) == "auto_approve"
for state in cases
)
mean_return = sum(returns) / len(returns)
if name == "risk_aware":
assert mean_return == 62.0
assert unsupported == 0
if name == "always_review":
assert mean_return == 58.0
print(
f"{name:13} expected mean={mean_return:5.1f} unsupported_approvals={unsupported}"
)always_auto expected mean=-25.0 unsupported_approvals=2
always_review expected mean= 58.0 unsupported_approvals=0
risk_aware expected mean= 62.0 unsupported_approvals=0The oracle scorecard knows counterfactual values because we specified the environment equations. In real operations, historical logs record only the action chosen and its resulting outcome. Evaluating an alternate policy from logs collected by a behavior policy requires off-policy evaluation (OPE):
- Importance sampling: Under common dynamics and a common initial-state distribution, an entire episode's return uses the trajectory weight . Per-decision estimators weight each reward by the cumulative product through the action that produced it, not merely that step's ratio.[1]
- Coverage requirements: If for an action the target chooses in a reachable state, importance sampling lacks support. A positive propensity with no observed visits is different: theoretical support can hold while a finite log still contains no usable samples for that choice.
- Unmeasured confounding: Private information that affects both action selection and outcomes can invalidate causal evaluation from the recorded states and propensities. This is separate from whether a state representation is Markov.
For two logged decisions with behavior probabilities 0.5, 0.5 and target probabilities 0.8, 0.6, the trajectory weight is (0.8/0.5)*(0.6/0.5)=1.92. Using only the last ratio, 1.2, misses how the first choice changes trajectory frequency. Products can have high variance; a large log doesn't guarantee enough effective samples. Model-based and doubly robust OPE offer other tradeoffs, but don't identify unsupported actions without additional assumptions or evidence.
Record the actual logging propensities, relevant decision-time information, and the desired starting-case distribution. Verify coverage and temporal relevance; a chronological split alone doesn't repair confounding or a misspecified environment.
Stress-test the assumptions
Score a route, back up a state, and notice when a log has no coverage for the action you want to evaluate. Each check below moves one assumption: the abandonment rate, the legal actions, or the actions present in the log.
Change the abandonment probability in stochastic-transition-backup.py. At what exact probability does human review overtake requesting evidence?
Answer
At abandonment probability p, the expected return of requesting evidence is (1-p)59 + p(-54) = 59 - 113p. Setting this equal to review's fixed return of 54 gives 59 - 113p = 54, yielding p = 5/113 ≈ 4.42%. Below this threshold, evidence wins. Above it, direct human review wins.
Remove auto_approve from new_request in the Q-learning lab while leaving it available at evidence_ready. What changes in learning and execution?
Answer
With the lab's Q-table and counters rebuilt from the restricted action dictionary, that state-action entry is absent, rather than a zero-valued available choice. Random exploration also uses the restricted list. The simulator must reject invalid actions, and a real execution layer must enforce the same rule independently; a larger penalty only changes an incentive.
A production log contains 50,000 cases routed to human review, but zero evidence requests. Can an offline evaluation measure how an evidence-seeking policy would perform?
Answer
Not from those episodes alone without extra assumptions or evidence. No evidence-request outcomes were observed. If the logging policy prohibited that action, importance sampling also lacks theoretical support; if it allowed the action with tiny probability, this finite log still supplies no samples for that choice. A time split can't create the missing outcomes.