Conservative Q-Learning (CQL)
CQL trains offline values that refuse to brag about actions the dataset never tried. It pushes unseen actions down and dataset actions up, so the learned values sit safely below the truth.
Why Does This Exist?
Offline RL learns from a fixed log with no fresh tries allowed. Standard off-policy updates still take a maximum over next actions:
That maximum is the trap. A network with random initialization assigns some value to actions nobody ever took, and one of those guesses is always high. The max picks the hallucination, the next update treats it as ground truth, and values snowball until the deployed policy chases actions that never worked.
Behaviour-policed methods fight this by modelling the logging policy and staying near it, but density models in high dimensions add their own errors. CQL (Kumar et al., 2020) acts on values directly: add a regularizer that depresses unseen actions and props up logged ones, hard enough to guarantee a lower bound on the true values.
This page covers that regularizer and its gradient dynamics. For the shift problem itself see distributional shift in offline RL. For the batch setup see offline batch RL. For the policy-constraint alternative see batch-constrained Q-learning.
You need one setup idea: an offline dataset holds only the actions some past behaviour tried, and anything else is out of distribution with no measured return.
Think of It Like This
A cautious engineer rating a bridge from old logs
Two engineers must certify an old bridge using only a ledger of past crossings.
The first fits a curve through the logged vans and buses, then reads off the untested 60-ton truck row: the curve says it is the safest load yet. The first real truck collapses the bridge.
The second engineer rates every logged weight from measurements and stamps every untested weight far below the weakest logged one. The certificate is pessimistic on purpose, and no truck gets approved on a guess.
Map it point for point: the ledger is the dataset, vehicle weights are actions, the curve is the unconstrained Q-network, and the pessimistic stamp is the CQL penalty pushing unseen actions down.
Where it stops: bridges are static, while RL stitches sequences, so CQL must preserve Bellman backups along logged transitions instead of stamping everything down equally.
How It Actually Works
CQL adds a regularizer with weight to the usual Bellman squared error:
The practical form uses a log-sum-exp over actions minus the dataset mean:
In continuous spaces the sum is sampled, half uniformly and half from the current policy, with importance weights.
Why the gradient targets spikes
Differentiate with respect to one :
Every action feels downward pressure through its softmax share , and only logged actions get the upward refund . The tallest spike owns most of , so it attracts most of the punishment. Logged actions roughly cancel and stay anchored to Bellman targets. Unseen actions get pure downward push.
With large enough, Kumar et al. prove the learned values lower-bound the truth pointwise, . The policy cannot profit from imagination.
Worked example with 3 actions
State has actions . Only appears in the dataset, so . Current values are , with a dangerous mirage at . Bellman target for is . Set and step size .
Exponentials: , , , sum , so . The regularizer is .
Softmax shares: . The mirage owns 87 percent of the penalty mass. Gradients are . Negative on means push up; positive elsewhere means push down. The Bellman gradient on is .
One gradient step:
- :
- :
- :
After five steps values reach . The mirage drops below the supported action, so the greedy pick flips to . The diagram draws these before and after bars.
Code
This snippet computes the same softmax shares, gradients, and five update steps with only the standard library.
import mathfrom typing import List
def softmax(q: List[float]) -> List[float]: """Numerically stable softmax over a list of Q-values.""" top = max(q) exps = [math.exp(v - top) for v in q] total = sum(exps) return [v / total for v in exps]
q = [3.0, 5.0, 1.0]mu = softmax(q)print([round(v, 4) for v in mu])# -> [0.1173, 0.8668, 0.0159]grad = [mu[0] - 1.0, mu[1], mu[2]]print([round(v, 4) for v in grad])# -> [-0.8827, 0.8668, 0.0159]
qq = list(q)for _ in range(5): m = softmax(qq) g = [m[0] - 1.0 + (qq[0] - 3.0), m[1], m[2]] qq = [qq[i] - 0.5 * g[i] for i in range(3)]print([round(v, 2) for v in qq])# -> [3.56, 3.37, 0.94]print(qq[0] > qq[1])# -> TrueWatch Out For
Alpha so high the agent just copies the log
Crank to 50 and the penalty buries the Bellman error. Every deviation from logged actions looks terrible, so the policy hugs demonstrations including their mistakes and detours. You lose stitching, which is the whole point of offline RL: combining good segments from different trips into one better route.
Symptom: training looks safe but evaluation never beats the behaviour log. Fix: keep in on standard benchmarks, or use a Lagrange variant that auto-tunes against a fixed penalty budget.
Alpha so low the mirage survives
Set near zero and the update is plain DQN on a fixed log. The tallest unseen spike keeps winning the max, values inflate, and deployment rediscovers the crash the penalty was meant to stop.
Symptom: offline Q-values climb far past any logged return while logged-action errors stay small. Fix: watch the gap between log-sum-exp and dataset mean during training. If it grows while returns do not, raise before trusting the policy.
The Quick Version
- CQL adds a log-sum-exp penalty over actions minus the dataset action mean to the Bellman loss.
- Its gradient pushes each action down by its softmax share and refunds only logged actions, so spikes attract the most punishment.
- With values , shares are and one step moves values to .
- After five steps values reach , so the greedy pick returns to the supported action.
- Large enough alpha guarantees a pointwise lower bound on true values; tune it in to keep trajectory stitching alive.