Coarse Coding in RL
Coarse coding covers continuous states with overlapping shapes, and each state is named by the set of shapes that contain it. The shape of those regions decides how far each lesson spreads.
Why Does This Exist?
Linear value functions are fast and stable, but they can only draw flat planes over raw coordinates:
Give the model raw and it cannot form peaks, valleys, or cliffs. Chop space into hard non-overlapping cells and the opposite problem appears: neighbouring cells share nothing, so every cell must be visited before it knows anything.
Coarse coding sits between those failures. Cover space with overlapping regions called receptive fields, and let each region own one binary feature that is 1 inside and 0 outside. One state lights up several features at once, and one weight update reaches every state that shares a field.
This page covers binary overlapping fields and how their geometry sets generalization. For the broader feature menu see feature construction. For guaranteed full coverage with offset grids see tile coding. For smooth graded fields instead of hard edges see radial basis functions.
You need one idea first: a linear model predicts by weighting its features, and gradient descent changes only the weights of features that are active.
Think of It Like This
Phone towers that locate you by overlap
Your phone sits somewhere in a city covered by broadcast circles from cell towers. At your corner it hears towers 3, 7, and 12. Your location code is the list of towers you hear, not your exact coordinates.
When tower 7 gets repaired, everyone who hears tower 7 benefits, even people standing blocks away on streets nobody measured. Two people who share two towers share more signal than two people who share one.
Map it point for point: the city is the state space, tower circles are receptive fields, the heard-tower list is the binary feature vector, and the repair spreading to strangers is value generalizing to unvisited states.
Where it stops: tower signal fades smoothly with distance, while classic coarse coding is binary, inside or outside. Smooth the edges and you get radial basis functions.
How It Actually Works
Let through be regions over continuous states. Feature is an indicator:
Value is the sum of weights of covering fields:
With a target (a return or a TD target) and error , the linear update touches only active fields:
Inactive weights do not move. That is the whole engine: shared fields carry the lesson elsewhere.
Why shape sets generalization
Large circles share members across long distances, so learning is fast but blurry. Small circles barely overlap, so detail is crisp but every patch must be visited. Ellipses stretched along one axis generalize far along that axis and stay sharp along the other, which suits tasks like mountain car where position changes slowly and velocity matters sharply.
Worked example with 5 circular fields
Fields: center radius , center radius , center radius , center radius , center radius . Weights .
Query . Squared distances to centers: to (inside, since ), to (inside), to (inside, since ), to (outside), to (outside). So and:
Target gives error . With step size , each active weight rises by : new weights , new prediction .
Now the unvisited neighbour . It sits in (distance squared exactly ), in (), outside (), and in (). So . Before the update its value was ; after, it is . Nobody visited , yet it gained through the shared fields and . The diagram draws this exact handoff.
Code
The snippet below builds the five fields, runs the update at , and shows the neighbour inheriting .
import mathfrom typing import List, Tuple
def inside(point: Tuple[float, float], center: Tuple[float, float], r: float) -> bool: """Binary receptive field: 1 when the point is within radius r.""" return (point[0] - center[0]) ** 2 + (point[1] - center[1]) ** 2 <= r ** 2
centers = [(2.0, 3.0), (2.5, 3.2), (2.8, 2.7), (3.2, 3.5), (1.0, 1.0)]radii = [1.0, 1.0, 0.8, 0.8, 0.5]w = [1.5, 2.0, -0.5, 0.8, -1.2]
s = (2.5, 3.0)feat = [1 if inside(s, c, r) else 0 for c, r in zip(centers, radii)]print(feat)# -> [1, 1, 1, 0, 0]pred = sum(wi * fi for wi, fi in zip(w, feat))print(round(pred, 2))# -> 3.0
delta = 4.0 - predw = [wi + 0.1 * delta * fi for wi, fi in zip(w, feat)]print([round(v, 2) for v in w])# -> [1.6, 2.1, -0.4, 0.8, -1.2]
adj = (2.8, 3.6)feat_adj = [1 if inside(adj, c, r) else 0 for c, r in zip(centers, radii)]print(feat_adj)# -> [1, 1, 0, 1, 0]print(round(sum(wi * fi for wi, fi in zip(w, feat_adj)), 2))# -> 4.5Watch Out For
Dead zones where no field fires
Scatter fields by hand and gaps appear where nothing covers the state. There the feature vector is all zeros, the prediction is 0, and the gradient is 0, so even a huge error updates nothing.
Symptom: the agent visits a region repeatedly and never improves there. Fix: check coverage on a grid of probe states before training, and prefer structured layouts such as offset tilings that guarantee every state activates the same number of fields.
Circles on axes with different scales
A circle treats along every axis as equal. Physical states rarely agree: mountain-car position spans while velocity spans . A circle sized for position swallows the whole velocity range, and the agent stops telling speeds apart.
Symptom: values look flat along one axis no matter how long you train. Fix: normalize each state variable first, or stretch fields into ellipses whose radii match each axis scale.
The Quick Version
- Coarse coding turns a continuous state into a sparse binary vector, one bit per overlapping receptive field.
- Value is the sum of weights of the active fields, and each update moves only those weights.
- Field geometry picks the tradeoff: large fields blur and learn fast, small fields sharpen slowly, ellipses split the difference by axis.
- In the worked case, activates fields 1, 2, and 3, predicts 3.0, and moves to 3.3 after one update.
- The unvisited neighbour shares fields 1 and 2, so it inherits a 0.2 gain with zero visits.