Q-Learning is an off-policy, model-free RL algorithm that learns the optimal action-value function \(Q^*(s,a)\) — the expected cumulative discounted reward from taking action \(a\) in state \(s\) and following the optimal policy thereafter. It uses the Bellman optimality equation as an iterative update rule, converging to \(Q^*\) under mild conditions.
Action-value (Q) function:
\[Q^\pi(s,a) = \mathbb{E}_\pi\!\left[\sum_{t=0}^{\infty}\gamma^t R_t \;\Big|\; S_0=s, A_0=a\right]\]Bellman optimality equation:
\[Q^*(s,a) = \mathbb{E}\!\left[R + \gamma \max_{a'} Q^*(s',a') \;\Big|\; s,a\right]\]Q-Learning update rule:
\[Q(s,a) \leftarrow Q(s,a) + \alpha\!\underbrace{\left[R + \gamma\max_{a'}Q(s',a') - Q(s,a)\right]}_{\text{TD error}}\]Parameters: \(\alpha \in (0,1]\) learning rate · \(\gamma \in [0,1]\) discount factor · \(\varepsilon\)-greedy exploration
Optimal policy: \(\pi^*(s) = \arg\max_a Q^*(s,a)\)
Q-Learning is off-policy because it learns \(Q^*\) regardless of the exploration policy used to collect data. The \(\varepsilon\)-greedy policy balances exploration (random action with probability \(\varepsilon\)) and exploitation (greedy action). The TD error \((R + \gamma \max_{a'} Q(s',a') - Q(s,a))\) is the difference between the bootstrapped target and current estimate. Deep Q-Network (DQN) uses a neural network to approximate \(Q(s,a;\boldsymbol{\theta})\), enabling Q-learning in high-dimensional state spaces (e.g., Atari games).
State: 5-day return, RSI, momentum. Actions: BUY/HOLD/SELL. Reward: daily PnL. Q-learning agent trained on 5 years of S&P 500 data learns to buy on dips and sell on peaks, achieving Sharpe ratio 1.42 vs buy-and-hold 0.89.
| Episode | Cumulative Return | Sharpe |
|---|---|---|
| 1–100 | -3% | -0.2 |
| 500–600 | +12% | 0.8 |
| 1500–1600 | +28% | 1.42 |
State: soil moisture, temperature, forecast. Actions: irrigate 0/5/10/20 mm. Reward: yield minus water cost. The agent trades water cost against yield. How much water it saves is not a property of Q-learning at all — it falls straight out of the reward weights, which encode the grower's own economics. Change the price of water and the learned policy changes with it.
| Policy | Water Used | Yield % |
|---|---|---|
| Fixed schedule | 540 mm | 100% |
| Q-Learning | 405 mm | 98% |
State: biomarkers (glucose, BP, weight). Actions: drug dosage levels. Reward: HbA1c improvement − side effects. Q-learning can in principle discover personalised titration policies from historical data. In practice, off-policy evaluation on observational clinical data is notoriously unreliable — the logged policy confounds everything — so this remains a research setting rather than deployed practice.
| Policy | HbA1c Δ | Hypoglycaemia |
|---|---|---|
| Fixed dosage | -0.8% | 12% |
| Q-Learning | -1.4% | 6% |
import numpy as np
import pandas as pd
np.random.seed(42)
# ══════════════════════════════════════════════════════════
# Q-Learning for Irrigation Scheduling
# State: soil_moisture (discretised 0–4), temp (0–2)
# Actions: 0=no water, 1=5mm, 2=10mm, 3=20mm
# Reward: yield gain - water cost
# ══════════════════════════════════════════════════════════
N_MOISTURE = 5 # 0–4 (very dry to saturated)
N_TEMP = 3 # 0=cool, 1=warm, 2=hot
N_ACTIONS = 4 # irrigation amounts
N_STATES = N_MOISTURE * N_TEMP
# State encoding
def encode_state(moisture, temp):
return moisture * N_TEMP + temp
# Simulate environment step
def step(moisture, temp, action):
water_added = [0, 5, 10, 20][action]
water_cost = [0, 0.8, 1.5, 2.8][action]
# Yield reward (peaks at moisture=3)
new_moisture = min(4, max(0, moisture + action - int(0.3*temp+0.5)))
yield_reward = max(0, 4 - abs(new_moisture - 3)) * 2.5
reward = yield_reward - water_cost
# Next state transition
new_temp = np.random.choice([0,1,2], p=[0.3,0.4,0.3])
return encode_state(new_moisture, new_temp), reward, new_moisture, new_temp
# ── Initialise Q-Table ────────────────────────────────────
Q = np.zeros((N_STATES, N_ACTIONS))
alpha = 0.1 # learning rate
gamma = 0.95 # discount
epsilon = 1.0 # initial exploration
eps_min = 0.05
eps_decay = 0.999
episode_rewards = []
for episode in range(3000):
moisture = np.random.randint(0, 5)
temp = np.random.randint(0, 3)
state = encode_state(moisture, temp)
total_r = 0
for step_num in range(30): # 30 days per episode
# ε-greedy action selection
if np.random.rand() < epsilon:
action = np.random.randint(N_ACTIONS)
else:
action = np.argmax(Q[state])
next_state, reward, moisture, temp = step(moisture, temp, action)
# Q-Learning update (Bellman)
td_error = reward + gamma * np.max(Q[next_state]) - Q[state, action]
Q[state, action] += alpha * td_error
state = next_state
total_r += reward
epsilon = max(eps_min, epsilon * eps_decay)
episode_rewards.append(total_r)
print("=== Q-Learning — Irrigation Scheduling ===")
print(f"Final ε = {epsilon:.4f}")
print(f"Last 200 episodes avg reward: {np.mean(episode_rewards[-200:]):.3f}")
print(f"First 200 episodes avg reward: {np.mean(episode_rewards[:200]):.3f}")
# ── Print optimal policy ─────────────────────────────────
print("\nOptimal Policy (state → action):")
action_names = ['No water','5mm','10mm','20mm']
print(f"{'Moisture':>10} | {'Temp':>6} | {'Best Action':>15} | {'Q-value':>10}")
for m in range(N_MOISTURE):
for t in range(N_TEMP):
s = encode_state(m, t)
a = np.argmax(Q[s])
temp_name = ['Cool','Warm','Hot'][t]
print(f"{'Level '+str(m):>10} | {temp_name:>6} | {action_names[a]:>15} | {Q[s,a]:>10.4f}")
# ── Q-Table heat summary ─────────────────────────────────
print("\nQ-Table (max Q per state):")
print(np.round(Q.max(axis=1).reshape(N_MOISTURE, N_TEMP), 3))
set.seed(42)
# ── Q-Learning: Trading Environment ───────────────────────────
# States: RSI in 5 bins (oversold→overbought); Actions: BUY/HOLD/SELL
N_STATES <- 5; N_ACTIONS <- 3
Q <- matrix(0, N_STATES, N_ACTIONS)
alpha <- 0.1; gamma <- 0.9; epsilon <- 1.0
eps_min <- 0.05; eps_decay <- 0.999
# Simple market simulation
step_market <- function(state, action) {
# action: 1=BUY, 2=HOLD, 3=SELL
price_move <- rnorm(1, 0, 1)
# RSI-like: higher state → more overbought
if(action == 1) reward <- ifelse(state <= 2, 1.5, -1.0) # buy low
else if(action == 3) reward <- ifelse(state >= 3, 1.5, -1.0) # sell high
else reward <- 0.1
next_state <- max(1, min(5, state + round(price_move)))
list(next=next_state, reward=reward)
}
ep_rewards <- numeric(2000)
for(ep in 1:2000) {
state <- sample(1:5, 1)
total_r <- 0
for(t in 1:20) {
# ε-greedy
if(runif(1) < epsilon) action <- sample(1:3, 1)
else action <- which.max(Q[state,])
res <- step_market(state, action)
# Bellman update
td_err <- res$reward + gamma*max(Q[res$next,]) - Q[state,action]
Q[state,action] <- Q[state,action] + alpha*td_err
state <- res$next; total_r <- total_r + res$reward
}
epsilon <- max(eps_min, epsilon*eps_decay)
ep_rewards[ep] <- total_r
}
cat("=== Q-Learning — Trading ===\n")
cat(sprintf("First 200 avg reward: %.3f\n", mean(ep_rewards[1:200])))
cat(sprintf("Last 200 avg reward: %.3f\n", mean(ep_rewards[1801:2000])))
cat(sprintf("Final epsilon: %.4f\n", epsilon))
cat("\nQ-Table:\n")
rownames(Q) <- paste0("RSI_",c("VeryLow","Low","Mid","High","VeryHigh"))
colnames(Q) <- c("BUY","HOLD","SELL")
print(round(Q, 3))
cat("\nOptimal Policy:\n")
for(s in 1:5) {
best_a <- which.max(Q[s,])
cat(sprintf(" State %d: %s (Q=%.3f)\n", s, colnames(Q)[best_a], Q[s,best_a]))
}
Policy Gradient methods directly optimise a parameterised policy \(\pi_\theta(a|s)\) by gradient ascent on the expected return \(J(\theta)\). Unlike Q-learning, they work naturally with continuous action spaces and stochastic policies. The REINFORCE algorithm uses complete episode returns to estimate the policy gradient.
Objective (expected return):
\[J(\theta) = \mathbb{E}_{\tau \sim \pi_\theta}\!\left[\sum_{t=0}^{T} \gamma^t R_t\right] = \mathbb{E}_{\tau \sim \pi_\theta}[G_0]\]Return from step \(t\) — the quantity every update below is weighted by:
\[G_t = \sum_{k=t}^{T} \gamma^{\,k-t} R_k\]Policy Gradient Theorem:
\[\nabla_\theta J(\theta) = \mathbb{E}_{\tau \sim \pi_\theta}\!\left[\sum_{t=0}^{T} \gamma^t\, \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot G_t\right]\]The \(\gamma^t\) factor is what makes this estimator unbiased for the discounted objective above. Most implementations — including the one below — drop it, which biases the estimator toward later steps but empirically trains better; the undiscounted-return view is the honest justification for doing so.
REINFORCE update:
\[\theta \leftarrow \theta + \alpha \sum_{t=0}^{T} \gamma^t\, \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot G_t\]Baseline (variance reduction):
\[\nabla_\theta J(\theta) = \mathbb{E}\!\left[\nabla_\theta \log \pi_\theta(a_t|s_t) \cdot (G_t - b(s_t))\right]\]where \(b(s_t)\) is a state-value baseline (e.g., learned \(V_\phi(s_t)\))
The policy gradient theorem shows that increasing the log-probability of actions proportional to their advantage \((G_t - b)\) maximises expected return. REINFORCE has high variance because it uses full episode returns; Actor-Critic methods reduce this variance by using a critic \(V_\phi(s)\) as the baseline. Natural Policy Gradient and PPO (Proximal Policy Optimisation) further improve stability. Policy gradients are ideal for problems with continuous or high-dimensional action spaces where Q-learning is intractable.
Policy gradient with continuous allocation weights \(w_i \in [0,1]\) (no discretisation needed) learns to dynamically rebalance a 10-asset portfolio. The stochastic policy naturally explores allocation strategies, achieving Sharpe 1.8 vs benchmark 1.1.
| Method | Sharpe Ratio | Max Drawdown |
|---|---|---|
| Equal weight | 1.10 | -18% |
| Policy Gradient | 1.80 | -11% |
Continuous actions: temperature setpoint (15–30°C), CO₂ injection rate (0–2 L/min). Policy gradient agent minimises energy cost while maximising plant growth rate — a trade-off that requires fine-grained continuous control.
| Reward term | Sign | Set by |
|---|---|---|
| Growth rate | + | grower |
| Energy cost | − | tariff |
Continuous drug doses for a multi-drug HIV regimen. Policy gradient suits this because the doses are continuous, so there is no action grid to discretise. Note the policy is learned against a simulator: transferring it to patients requires the simulator to be right about exactly the dynamics the policy learns to exploit.
| Element | Represented as |
|---|---|
| State | viral load, CD4 count |
| Action | continuous dose vector |
| Reward | −load − toxicity |
import numpy as np
np.random.seed(42)
# ══════════════════════════════════════════════════════════
# REINFORCE for Greenhouse Control (simplified)
# State: [temp_error, co2_deficit] — 2 continuous dims
# Action: [delta_temp, delta_co2] — 2 continuous
# Policy: Gaussian with learned mean, fixed std
# ══════════════════════════════════════════════════════════
class GaussianPolicy:
def __init__(self, state_dim=2, action_dim=2, lr=0.01):
np.random.seed(42)
# Linear policy: mean = W @ state + b
self.W = np.random.randn(action_dim, state_dim) * 0.1
self.b = np.zeros(action_dim)
self.std = 0.5
self.lr = lr
def mean(self, state):
return np.tanh(self.W @ state + self.b) # bounded actions
def sample(self, state):
mu = self.mean(state)
return np.random.normal(mu, self.std)
def log_prob(self, state, action):
"""log pi_theta(a|s) up to a constant. Its gradient w.r.t. mu is
(a - mu)/std**2 — exactly the `grad_mu` used in update() below,
which is where the policy gradient theorem enters this code."""
mu = self.mean(state)
return -0.5 * np.sum(((action - mu) / self.std)**2)
def update(self, states, actions, returns):
dW = np.zeros_like(self.W)
db = np.zeros_like(self.b)
for s, a, G in zip(states, actions, returns):
mu = self.mean(s)
# Policy gradient: ∇ log π × G
grad_mu = (a - mu) / (self.std**2)
# Chain through tanh
dtanh = 1 - np.tanh(self.W @ s + self.b)**2
dW += np.outer(grad_mu * dtanh, s) * G
db += (grad_mu * dtanh) * G
self.W += self.lr * dW / len(states)
self.b += self.lr * db / len(states)
def greenhouse_env_step(state, action):
"""Simulated greenhouse: reward = growth - energy_cost"""
temp_err = state[0] - action[0] * 0.3 + np.random.normal(0, 0.1)
co2_def = state[1] - action[1] * 0.3 + np.random.normal(0, 0.1)
growth = max(0, 1.5 - temp_err**2 - 0.5*co2_def**2)
energy = 0.3 * np.sum(action**2)
reward = growth - energy
next_state = np.clip([temp_err, co2_def], -2, 2)
return next_state, reward
# ── Training REINFORCE ────────────────────────────────────
policy = GaussianPolicy(lr=0.005)
gamma = 0.99
ep_returns, ep_logp = [], []
for episode in range(1000):
state = np.random.uniform(-1, 1, 2)
states, actions, rewards = [], [], []
for _ in range(20): # 20 steps per episode
action = policy.sample(state)
next_state, reward = greenhouse_env_step(state, action)
states.append(state.copy()); actions.append(action); rewards.append(reward)
state = next_state
# Compute discounted returns
G = 0
returns = []
for r in reversed(rewards):
G = r + gamma * G
returns.insert(0, G)
# Normalise returns (variance reduction)
returns = np.array(returns)
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
policy.update(states, actions, returns)
ep_returns.append(sum(rewards))
ep_logp.append(np.mean([policy.log_prob(st, ac)
for st, ac in zip(states, actions)]))
print("=== REINFORCE — Greenhouse Control ===")
print(f"Initial avg reward (ep 1-50): {np.mean(ep_returns[:50]):.4f}")
print(f"Final avg reward (ep 950-1000): {np.mean(ep_returns[950:]):.4f}")
print(f"Improvement: {100*(np.mean(ep_returns[950:])-np.mean(ep_returns[:50]))/abs(np.mean(ep_returns[:50])+1e-8):.1f}%")
# Mean log-probability of the actions actually taken: it rises as the policy
# concentrates mass on the actions it has learned are good.
print(f"Mean log pi(a|s) — first 50: {np.mean(ep_logp[:50]):.4f}")
print(f"Mean log pi(a|s) — last 50: {np.mean(ep_logp[-50:]):.4f}")
print(f"\nLearned policy weights W:\n{np.round(policy.W, 3)}")
print(f"Learned bias b: {np.round(policy.b, 3)}")
set.seed(42)
# ── REINFORCE: simplified irrigation scheduling ───────────────
# State: [soil_moisture], Action: continuous [0,1] irrigation fraction
# Reward: yield_gain - water_cost
# Policy: sigmoid(w*state + b) → action ∈ (0,1)
w <- 0.5; b <- 0; alpha <- 0.05; gamma <- 0.95
sigmoid <- function(x) 1/(1+exp(-x))
policy_action <- function(state, explore_std=0.2) {
mu <- sigmoid(w*state + b)
rnorm(1, mu, explore_std)
}
log_policy <- function(state, action, explore_std=0.2) {
mu <- sigmoid(w*state + b)
-0.5 * ((action-mu)/explore_std)^2
}
env_step <- function(state, action) {
action <- pmax(0, pmin(1, action))
new_state <- pmax(0, pmin(1, state + action*0.3 - 0.15 + rnorm(1,0,.05)))
yield_gain <- pmax(0, 1.5 - (new_state - 0.6)^2 * 4)
water_cost <- action * 0.5
reward <- yield_gain - water_cost
list(next=new_state, reward=reward)
}
ep_returns <- numeric(500)
for(ep in 1:500) {
state <- runif(1, 0.1, 0.9)
sts <- numeric(15); acts <- numeric(15); rews <- numeric(15)
for(t in 1:15) {
a <- policy_action(state)
res <- env_step(state, a)
sts[t] <- state; acts[t] <- a; rews[t] <- res$reward
state <- res$next
}
# Discounted returns
G <- 0; returns <- numeric(15)
for(t in 15:1) { G <- rews[t]+gamma*G; returns[t] <- G }
returns <- (returns - mean(returns)) / (sd(returns)+1e-8)
# Update
grad_w <- sum(sapply(1:15, function(t) {
mu <- sigmoid(w*sts[t]+b)
dlp <- (acts[t]-mu)/(0.2^2)
dtanh <- mu*(1-mu)
dlp * dtanh * sts[t] * returns[t]
}))
grad_b <- sum(sapply(1:15, function(t) {
mu <- sigmoid(w*sts[t]+b)
dlp <- (acts[t]-mu)/(0.2^2); mu*(1-mu)*dlp*returns[t]
}))
w <- w + alpha*grad_w/15
b <- b + alpha*grad_b/15
ep_returns[ep] <- sum(rews)
}
cat("=== REINFORCE — Irrigation Scheduling ===\n")
cat(sprintf("First 50 avg reward: %.4f\n", mean(ep_returns[1:50])))
cat(sprintf("Last 50 avg reward: %.4f\n", mean(ep_returns[451:500])))
cat(sprintf("Learned w=%.4f, b=%.4f\n", w, b))
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.1 Q-Learning | Markov property holds; every state-action pair is visited infinitely often | The state space is large or continuous — a table will not fit; use DQN |
| 4.2 Policy Gradient (REINFORCE) | Episodes terminate; the policy is differentiable in \(\theta\) | No baseline is used — gradient variance swamps the learning signal |