Dyna-Q combines direct RL (real experience) with model-based planning (simulated experience). After each real interaction, it updates the Q-table (like Q-learning) and trains an environment model \(M(s,a) \to (r,s')\). Then it performs \(n\) planning steps — simulated Q-learning updates using the model — allowing it to learn much faster than pure model-free RL.
Dyna-Q Algorithm per time step:
Model (tabular): \(M(s,a) = (r_{\text{avg}},\, s'_{\text{most common}})\)
Sample efficiency gain: Each real step produces \(1 + n\) Q-updates
Dyna-Q+: Adds exploration bonus \(\kappa\sqrt{\tau(s,a)}\) where \(\tau\) = time since \((s,a)\) was visited
The model \(M\) stores observed \((s,a) \to (r,s')\) transitions, enabling "mental simulation" between real interactions. With \(n=50\) planning steps, Dyna-Q can learn as fast as having 50× more real experience. The tradeoff: if the environment model is wrong (non-stationary environment), planning propagates errors. Dyna-Q+ maintains recency bonuses to re-explore stale state-action pairs. Model-based RL is especially beneficial in real-world scenarios where data collection is expensive (robotics, clinical trials, precision agriculture).
Dyna-Q builds a model of market microstructure and uses it for planning between market sessions. With \(n=20\) planning steps, it reaches the same trading performance as Q-learning in 1/20th of the real trading days (critical: market data is expensive).
| Method | Days to converge | Sharpe |
|---|---|---|
| Q-Learning | 400 | 1.4 |
| Dyna-Q (n=20) | 22 | 1.4 |
Real crop seasons are expensive (one per year). Dyna-Q learns the crop–weather–yield relationship from historical data and plans across many simulated seasons between real ones. The catch is the one Dyna-Q always carries: planning is only as good as the learned model, and a model fitted to a shifting climate goes stale faster than it can be refitted.
| Method | Real seasons | Yield % |
|---|---|---|
| Q-Learning | 15 | +18% |
| Dyna-Q | 3 | +17% |
Each real patient trial is costly and time-limited. Dyna-Q learns a patient response model from early data and simulates thousands of virtual patients. This informs adaptive dose-finding (Phase 1/2 trials) safely with fewer real subjects.
| Method | Real patients | Optimal dose found |
|---|---|---|
| Standard 3+3 | 30 | 70% accurate |
| Dyna-Q | 12 | 89% accurate |
import numpy as np
from collections import defaultdict, Counter
np.random.seed(42)
# ══════════════════════════════════════════════════════════
# Dyna-Q for Crop Planting Strategy
# States: soil_quality (0–4), season (0–3) → 20 states
# Actions: crop choice 0=wheat,1=maize,2=rice,3=fallow
# ══════════════════════════════════════════════════════════
N_SOIL = 5; N_SEASON = 4; N_ACTIONS = 4
N_STATES = N_SOIL * N_SEASON
def encode(soil, season): return soil * N_SEASON + season
def decode(s): return divmod(s, N_SEASON)
# True environment
YIELD = np.array([ # [soil][action]
[2.5, 2.0, 1.5, 0.5],
[3.2, 2.8, 2.0, 0.5],
[4.0, 3.5, 2.8, 0.5],
[4.8, 4.2, 3.5, 0.5],
[5.5, 5.0, 4.2, 0.5]
])
SOIL_EFFECT = [0.1, 0.05, 0.0, -0.1] # soil impact of crop
def env_step(state, action):
soil, season = decode(state)
yield_val = YIELD[soil, action] * (1 + np.random.normal(0, 0.1))
new_soil = int(np.clip(soil + SOIL_EFFECT[action] * 10 + np.random.randint(-1,2), 0, 4))
new_season = (season + 1) % N_SEASON
return encode(new_soil, new_season), yield_val
def run_agent(n_plan=0, n_episodes=200):
Q = np.zeros((N_STATES, N_ACTIONS))
# The environment is stochastic, so a model storing the LAST observed
# transition is a single noisy draw. Store M(s,a) = (r_avg, s'_most_common)
# instead, matching the definition in the maths box above.
m_count = defaultdict(int) # (s,a) -> times visited
m_rew = defaultdict(float) # (s,a) -> running mean reward
m_next = defaultdict(Counter) # (s,a) -> counts over observed s'
alpha = 0.1; gamma = 0.95; epsilon = 0.3
ep_returns = []
for ep in range(n_episodes):
state = np.random.randint(N_STATES)
total_r = 0
for _ in range(16): # 4 seasons × 4 years
# ε-greedy
if np.random.rand() < epsilon: action = np.random.randint(N_ACTIONS)
else: action = np.argmax(Q[state])
next_state, reward = env_step(state, action)
# Direct Q-update
Q[state,action] += alpha*(reward + gamma*np.max(Q[next_state]) - Q[state,action])
# Model update: incremental mean reward + successor histogram
key = (state, action)
m_count[key] += 1
m_rew[key] += (reward - m_rew[key]) / m_count[key]
m_next[key][next_state] += 1
# Planning: one visited (s,a) pair is enough to begin
if n_plan > 0 and m_count:
keys = list(m_count)
for i in np.random.choice(len(keys), n_plan, replace=True):
ps, pa = keys[i]
pr = m_rew[(ps,pa)] # r_avg
pns = m_next[(ps,pa)].most_common(1)[0][0] # s'_most_common
Q[ps,pa] += alpha*(pr + gamma*np.max(Q[pns]) - Q[ps,pa])
state = next_state; total_r += reward
ep_returns.append(total_r)
return Q, ep_returns
# ── Compare: Q-learning (n=0) vs Dyna-Q (n=20) ───────────────
Q_base, rew_base = run_agent(n_plan=0, n_episodes=200)
Q_dyna, rew_dyna = run_agent(n_plan=20, n_episodes=200)
print("=== Dyna-Q vs Q-Learning — Crop Planning ===")
print(f"Q-Learning first 20 ep: {np.mean(rew_base[:20]):.3f}")
print(f"Q-Learning last 20 ep: {np.mean(rew_base[-20:]):.3f}")
print(f"Dyna-Q (n=20) first 20: {np.mean(rew_dyna[:20]):.3f}")
print(f"Dyna-Q (n=20) last 20: {np.mean(rew_dyna[-20:]):.3f}")
print("\nOptimal crop by soil quality (Dyna-Q):")
crops = ['Wheat','Maize','Rice','Fallow']
for soil in range(N_SOIL):
best_actions = [np.argmax(Q_dyna[encode(soil, s)]) for s in range(N_SEASON)]
print(f" Soil {soil}: {[crops[a] for a in best_actions]}")
set.seed(42)
# ── Dyna-Q: Irrigation Scheduling ─────────────────────────────
N_S <- 5; N_A <- 4 # 5 moisture levels, 4 actions
Q <- matrix(0, N_S, N_A)
M <- list() # environment model
alpha <- 0.1; gamma <- 0.95; epsilon <- 0.3
n_plan <- 20 # planning steps
env_step <- function(state, action) {
water <- c(0, 5, 10, 20)[action]
new_st <- max(1, min(5, state + as.integer(water/5) - 1 + sample(-1:1,1)))
yield_r <- max(0, 4 - (new_st-3)^2) * 2.5
water_c <- c(0, 0.8, 1.5, 2.8)[action]
list(next=new_st, reward=yield_r - water_c)
}
run_dyna <- function(n_plan=20, n_ep=300) {
Q <- matrix(0, N_S, N_A); M <- list()
returns <- numeric(n_ep)
for(ep in 1:n_ep) {
s <- sample(1:N_S, 1); total_r <- 0
for(t in 1:20) {
# ε-greedy
a <- if(runif(1) < epsilon) sample(1:N_A,1) else which.max(Q[s,])
res <- env_step(s, a)
# Q-update
Q[s,a] <- Q[s,a] + alpha*(res$reward + gamma*max(Q[res$next,]) - Q[s,a])
# Model: running mean reward + all observed successors, so that
# M(s,a) = (r_avg, s'_most_common) as in the maths box above
key <- paste(s,a)
m <- M[[key]]
if(is.null(m)) m <- list(n=0, r=0, ns=integer(0))
m$n <- m$n + 1
m$r <- m$r + (res$reward - m$r)/m$n
m$ns <- c(m$ns, res$next)
M[[key]] <- m
# Planning: one visited (s,a) pair is enough to begin
if(n_plan > 0 && length(M) > 0) {
keys <- sample(names(M), n_plan, replace=TRUE)
for(k in keys) {
parts <- as.integer(strsplit(k," ")[[1]])
ps <- parts[1]; pa <- parts[2]
tr <- M[[k]]
pns <- as.integer(names(which.max(table(tr$ns))))
Q[ps,pa] <- Q[ps,pa] + alpha*(tr$r + gamma*max(Q[pns,]) - Q[ps,pa])
}
}
s <- res$next; total_r <- total_r + res$reward
}
returns[ep] <- total_r
}
list(Q=Q, returns=returns)
}
res_q <- run_dyna(n_plan=0, n_ep=300)
res_dyna <- run_dyna(n_plan=20, n_ep=300)
cat("=== Dyna-Q vs Q-Learning ===\n")
cat(sprintf("Q-Learning first 30 ep: %.3f\n", mean(res_q$returns[1:30])))
cat(sprintf("Q-Learning last 30 ep: %.3f\n", mean(res_q$returns[271:300])))
cat(sprintf("Dyna-Q first 30 ep: %.3f\n", mean(res_dyna$returns[1:30])))
cat(sprintf("Dyna-Q last 30 ep: %.3f\n", mean(res_dyna$returns[271:300])))
Value Iteration is a dynamic programming algorithm that computes the optimal state-value function \(V^*(s)\) by repeatedly applying the Bellman optimality operator until convergence. It requires a complete known model of the environment (transition probabilities \(P(s'|s,a)\) and rewards \(R(s,a,s')\)). The optimal policy is then extracted greedily from \(V^*\).
Bellman Optimality Equation (state values):
\[V^*(s) = \max_a \sum_{s'} P(s'|s,a)\bigl[R(s,a,s') + \gamma\, V^*(s')\bigr]\]Value Iteration update:
\[V_{k+1}(s) = \max_a \sum_{s'} P(s'|s,a)\bigl[R(s,a,s') + \gamma\, V_k(s')\bigr]\]Convergence criterion: \(\max_s |V_{k+1}(s) - V_k(s)| < \theta\)
Optimal policy extraction:
\[\pi^*(s) = \arg\max_a \sum_{s'} P(s'|s,a)\bigl[R(s,a,s') + \gamma\, V^*(s')\bigr]\]Contraction property: Bellman operator is a \(\gamma\)-contraction, guaranteeing convergence
Value Iteration is guaranteed to converge to \(V^*\) for any initialisation when \(\gamma < 1\) and the MDP has finite states and actions. Policy Iteration (alternating between policy evaluation and greedy improvement) often converges in fewer iterations. Value Iteration is used when the MDP model is known exactly — e.g., in supply chain optimisation, game theory, and operations research. For large state spaces, function approximation replaces exact tabular representation.
States: customer credit tier (1–5). Actions: interest rate offered (5%–15%). Transitions: customer accepts/declines based on rate. Bank's known demand model enables Value Iteration to compute the optimal rate for each tier, maximising expected profit.
| Credit Tier | Optimal Rate | Expected PnL |
|---|---|---|
| A (low risk) | 6.2% | +$1,850 |
| C (medium) | 9.8% | +$2,100 |
| E (high risk) | 14.5% | +$900 |
States: crop maturity × weather forecast. Actions: harvest now, wait 1 week, apply fungicide. Known weather transition model enables value iteration to compute the harvest timing policy that maximises grain quality and minimises spoilage losses.
| State | Optimal Action |
|---|---|
| Mature + Rain forecast | Harvest NOW |
| Immature + Sunny | Wait 1 week |
| Near-mature + Rain | Fungicide |
States: disease stage (1–4) × response (good/poor). Actions: chemotherapy, targeted therapy, watchful waiting. Known transition probabilities from RCT data; value iteration computes the optimal treatment sequence maximising quality-adjusted life years (QALYs).
| Stage | Response | Optimal Rx |
|---|---|---|
| 1 | Good | Watchful wait |
| 2 | Poor | Targeted Rx |
| 3 | Any | Chemo |
import numpy as np
np.random.seed(42)
# ══════════════════════════════════════════════════════════
# Value Iteration for Medical Treatment MDP
# States: disease stage (0–3) × response (0=good,1=poor) → 8 states
# Actions: 0=watch, 1=targeted, 2=chemo
# ══════════════════════════════════════════════════════════
N_STAGE = 4 # stages 0–3
N_RESPONSE = 2 # 0=good, 1=poor
N_STATES = N_STAGE * N_RESPONSE
N_ACTIONS = 3
def encode(stage, resp): return stage * N_RESPONSE + resp
def decode(s): return divmod(s, N_RESPONSE)
gamma = 0.95
TERMINAL = encode(3, 1) # advanced + poor response = terminal state
# ── Transition and Reward matrices ───────────────────────────
# P[s,a,s'] = transition probability
P = np.zeros((N_STATES, N_ACTIONS, N_STATES))
R = np.zeros((N_STATES, N_ACTIONS))
def fill_transitions():
"""Build P and R so every row of P sums to 1 BY CONSTRUCTION.
Renormalising a row afterwards would silently absorb any modelling
mistake, so we assert instead — in a worked MDP the probabilities
should be right before they are used, not made right afterwards.
"""
for s in range(N_STATES):
stage, resp = decode(s)
if s == TERMINAL:
P[s, :, s] = 1.0; continue
for a in range(N_ACTIONS):
# Reward = QALYs gained; p_prog = disease advances a stage
# p_improve / p_worsen = response flips
if a == 0: # watchful wait
R[s,a] = 0.8 if resp == 0 else 0.3
p_prog = 0.10 + 0.20*resp
p_improve = 0.10
p_worsen = 0.15
elif a == 1: # targeted therapy
R[s,a] = 1.2 if resp == 0 else 0.7
p_prog = 0.05 + 0.10*resp
p_improve = 0.30
p_worsen = 0.05
else: # chemo
R[s,a] = 0.9 if resp == 0 else 1.0
p_prog = 0.04 + 0.05*resp
p_improve = 0.30
p_worsen = 0.08
# Only one response transition is reachable from a given state:
# a good responder can worsen, a poor responder can improve.
p_flip = p_worsen if resp == 0 else p_improve
p_stay = 1.0 - p_prog - p_flip
assert p_stay >= 0, f"negative stay probability at s={s}, a={a}"
next_stage = min(stage + 1, N_STAGE - 1)
P[s,a, encode(next_stage, resp)] += p_prog # stage advances
P[s,a, encode(stage, 1 - resp)] += p_flip # response flips
P[s,a, encode(stage, resp)] += p_stay # no change
assert np.allclose(P.sum(axis=2), 1.0), "every transition row must sum to 1"
fill_transitions()
# ── Value Iteration ───────────────────────────────────────
V = np.zeros(N_STATES)
theta = 1e-8
iters = 0
while True:
delta = 0
for s in range(N_STATES):
if s == TERMINAL: continue
v_old = V[s]
V[s] = max(sum(P[s,a,sp] * (R[s,a] + gamma*V[sp])
for sp in range(N_STATES))
for a in range(N_ACTIONS))
delta = max(delta, abs(V[s] - v_old))
iters += 1
if delta < theta: break
# ── Extract Optimal Policy ────────────────────────────────
actions = ['WatchWait', 'Targeted', 'Chemo']
policy = np.argmax([[sum(P[s,a,sp]*(R[s,a]+gamma*V[sp])
for sp in range(N_STATES))
for a in range(N_ACTIONS)]
for s in range(N_STATES)], axis=1)
print(f"=== Value Iteration — Treatment MDP ===")
print(f"Converged in {iters} iterations")
print(f"\nOptimal Policy:")
for s in range(N_STATES-1):
stage, resp = decode(s)
resp_str = 'Good' if resp == 0 else 'Poor'
print(f" Stage {stage}, {resp_str:4s}: {actions[policy[s]]:12s} V={V[s]:.4f}")
# ── Value Iteration: Loan Pricing MDP ─────────────────────────
# States: credit tiers 1–5
# Actions: interest rates 5%–15% in 1% steps (11 options)
# Reward: expected profit = accepted_prob × margin
N_S <- 5; N_A <- 11
rates <- seq(0.05, 0.15, by=0.01)
# Acceptance probability: lower rate → higher acceptance
# Tier 1 (safest) can afford high rates; Tier 5 least so
accept_prob <- function(tier, rate_idx) {
base_rate <- 0.04 + tier*0.015 # break-even rate
sensitivity <- 5 + tier*2
1 / (1 + exp(sensitivity*(rates[rate_idx] - base_rate - 0.03)))
}
# Reward = expected_profit per customer
reward <- function(tier, rate_idx) {
margin <- rates[rate_idx] - 0.03 - tier*0.008 # minus default risk
accept_prob(tier, rate_idx) * margin * 10000 # normalised to $
}
# In this problem: no state transitions (static pricing MDP)
# Value iteration = maximise immediate reward
V <- numeric(N_S)
policy <- numeric(N_S)
gamma <- 0.9
# State transitions: tier can change based on repayment
P <- matrix(0, N_S, N_S)
for(t in 1:N_S) {
P[t,max(1,t-1)] <- 0.3 # improve tier on repayment
P[t,t] <- 0.5
P[t,min(5,t+1)] <- 0.2 # worsen tier
}
# Value iteration
for(i in 1:200) {
V_new <- numeric(N_S)
for(s in 1:N_S) {
V_new[s] <- max(sapply(1:N_A, function(a) {
r <- reward(s, a)
r + gamma * sum(P[s,] * V)
}))
policy[s] <- which.max(sapply(1:N_A, function(a) {
reward(s, a) + gamma * sum(P[s,] * V)
}))
}
if(max(abs(V_new - V)) < 1e-8) { cat(sprintf("Converged at iter %d\n",i)); break }
V <- V_new
}
cat("\n=== Value Iteration — Loan Pricing ===\n")
cat("Tier | Optimal Rate | V*(s) $/customer\n")
cat("-----|--------------|------------------\n")
for(s in 1:N_S) {
cat(sprintf(" %d | %.0f%% | $%.2f\n",
s, rates[policy[s]]*100, V[s]))
}
The same information as the assumption blocks above, side by side — this is the comparison that decides which method to reach for.
| Algorithm | Assumes | Breaks when |
|---|---|---|
| 4.3 Dyna-Q | The environment is stationary enough for the learned model to stay valid | The environment shifts — planning confidently propagates a stale model |
| 4.4 Value Iteration (Dynamic Programming) | The full MDP is known: transition probabilities and rewards | The model is unknown or estimated — errors compound through the Bellman backup |