Skip to the content

Algorithms on This Page

4.3 Dyna-Q 4.4 Value Iteration (Dynamic Programming)

4.3  Dyna-Q

Model-BasedPlanning
DEFINITION

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.

4.3.1  Mathematical Foundation

FORMULAE

Dyna-Q Algorithm per time step:

  1. Take action \(a\) in state \(s\), observe \(r, s'\)
  2. Q-update (direct RL): \(Q(s,a) \leftarrow Q(s,a) + \alpha[r + \gamma\max_{a'}Q(s',a') - Q(s,a)]\)
  3. Model update: \(M(s,a) \leftarrow (r, s')\)
  4. For \(n\) planning steps: sample \((s,a)\) from past, use \(M(s,a)\) for Q-update

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

4.3.2  How It Works

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).

4.3.3  Assumptions and Failure Modes

ASSUMES
  • The environment is stationary enough for the learned model to stay valid
BREAKS WHEN
  • The environment shifts — planning confidently propagates a stale model
  • The model stores one draw instead of an expectation in a stochastic environment
  • \(n\) is large and the model is wrong — more planning makes it worse, not better

4.3.4  Worked Examples

FINANCE

🤖 Trading with Market Model

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).

MethodDays to convergeSharpe
Q-Learning4001.4
Dyna-Q (n=20)221.4
AGRICULTURE

🌱 Multi-Season Crop Planning

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.

MethodReal seasonsYield %
Q-Learning15+18%
Dyna-Q3+17%
MEDICINE

🔬 Clinical Trial Optimisation

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.

MethodReal patientsOptimal dose found
Standard 3+33070% accurate
Dyna-Q1289% accurate

4.3.5  Code

Dyna-Q
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])))

4.4  Value Iteration (Dynamic Programming)

Model-BasedExact
DEFINITION

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^*\).

4.4.1  Mathematical Foundation

FORMULAE

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

4.4.2  How It Works

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.

4.4.3  Assumptions and Failure Modes

ASSUMES
  • The full MDP is known: transition probabilities and rewards
  • \(\gamma < 1\), finite states and actions
BREAKS WHEN
  • The model is unknown or estimated — errors compound through the Bellman backup
  • The state space is large — sweeps cost \(O(|S|^2|A|)\) each
  • Transition rows do not sum to 1 — silently renormalising hides the bug

4.4.4  Worked Examples

FINANCE

🏦 Optimal Loan Pricing MDP

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 TierOptimal RateExpected PnL
A (low risk)6.2%+$1,850
C (medium)9.8%+$2,100
E (high risk)14.5%+$900
AGRICULTURE

📅 Seasonal Harvest Timing MDP

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.

StateOptimal Action
Mature + Rain forecastHarvest NOW
Immature + SunnyWait 1 week
Near-mature + RainFungicide
MEDICINE

💊 Treatment Sequence Optimisation

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).

StageResponseOptimal Rx
1GoodWatchful wait
2PoorTargeted Rx
3AnyChemo

4.4.5  Code

Value Iteration
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]))
}

At a Glance

The same information as the assumption blocks above, side by side — this is the comparison that decides which method to reach for.

AlgorithmAssumesBreaks when
4.3 Dyna-QThe environment is stationary enough for the learned model to stay validThe environment shifts — planning confidently propagates a stale model
4.4 Value Iteration (Dynamic Programming)The full MDP is known: transition probabilities and rewardsThe model is unknown or estimated — errors compound through the Bellman backup