Skip to content
AI360Xpert
Beta

Tile Coding (CMAC) in RL

Tile coding overlays multiple slightly shifted grid patterns across continuous space, translating coordinates into a sparse set of active tiles that combine broad generalization with pinpoint resolution.

Tile coding partitions continuous space across multiple offset grid tilings, activating exactly one binary tile per tiling to enable O(m) linear inference.
Tile coding partitions continuous space across multiple offset grid tilings, activating exactly one binary tile per tiling to enable O(m) linear inference.

Why Does This Exist?

When reinforcement learning agents operate in continuous state spaces—such as a robotic arm moving through 3D coordinates or a self-balancing cart-pole—tabular methods fail because no continuous state is ever visited twice.

A naive workaround is to discretize the state space into a single coarse grid. However, a single grid suffers from two critical flaws:

  1. Severe quantization error: All continuous points within the same grid cell are treated as identical, producing stair-step, piecewise-constant value functions with harsh discontinuities at cell borders.
  2. The curse of resolution vs. generalization: If cells are large, the agent cannot make fine-grained control distinctions; if cells are tiny, memory requirements explode exponentially (KdK^d) and learning generalizes virtually zero distance.

Introduced by James Albus in 1975 under the name CMAC (Cerebellar Model Articulation Controller) and popularized in reinforcement learning by Richard Sutton, tile coding elegantly resolves this trade-off. By superimposing multiple overlapping grid partitions (tilings) shifted by small asymmetric offsets, the intersection of active tiles achieves high spatial resolution while retaining the broad, smooth generalization of large tiles—all while producing sparse binary feature vectors that compute in O(m)O(m) integer time.

Think of It Like This

The Draftsman's Offset Grid Stencils

Imagine an artist trying to identify the precise coordinates of a pinhole on a drafting canvas using grid stencils:

  1. A Single Coarse Stencil: She lays down one stencil with 2-inch square cutouts. The pinhole falls into cell (row 3, col 2). This tells her the pinhole's general neighborhood, but she has zero idea whether it is sitting near the top-left or bottom-right corner of that 2-inch box.
  2. Multiple Offset Stencils (Tile Coding): Instead of making the stencil squares microscopic (which would require millions of tiny cutouts), she lays down four identical stencils, each shifted slightly in a different direction:
    • Stencil 1 is aligned at the canvas origin.
    • Stencil 2 is nudged half an inch right and a quarter inch down.
    • Stencil 3 is nudged an inch right and three-quarters down.
    • Stencil 4 is nudged an inch and a half right and half an inch down.

Now, she marks which box contains the pinhole on each of the four sheets. When all four stencils are stacked together, the overlapping intersection of the four marked boxes forms a tiny sub-box just a fraction of an inch wide!

Where the analogy stops: Real drafting stencils physically occlude light. In tile coding, the active tiles are represented digitally as indices in a sparse binary feature vector x(s)∈{0,1}d\mathbf{x}(s) \in \{0, 1\}^d, where exactly one feature per tiling equals 11 and all others are 00.

How It Actually Works

Overlapping Tilings and Asymmetric Offsets

Let the continuous state space be S⊂Rk\mathcal{S} \subset \mathbb{R}^k. A tiling is an exhaustive partitioning of the state space into non-overlapping geometric hyper-rectangles called tiles.

Tile coding uses mm distinct tilings. Each tiling has identical tile dimensions (width wjw_j along dimension jj), but each tiling is displaced relative to the others by an offset vector oi\mathbf{o}_i:

oi=(oi,1,oi,2,…,oi,k),i∈{0,1,…,m−1}\mathbf{o}_i = \left( o_{i, 1}, o_{i, 2}, \dots, o_{i, k} \right), \quad i \in \{0, 1, \dots, m-1\}

For any continuous state s=(s1,s2,…,sk)s = (s_1, s_2, \dots, s_k), the integer coordinate of the active tile in tiling ii along dimension jj is calculated via floor division:

coordi,j(s)=⌊sj−oi,jwj⌋\text{coord}_{i, j}(s) = \left\lfloor \frac{s_j - o_{i, j}}{w_j} \right\rfloor

To prevent unnatural diagonal alignment artifacts, offsets must be chosen asymmetrically. If tilings are shifted uniformly along diagonals (oi=(iΔ,iΔ)o_i = (i \Delta, i \Delta)), the intersections form diagonal elongated strips, causing the value function to generalize unnaturally along the 45-degree axis. Sutton and Barto recommend displacing tilings using odd-integer displacement vectors:

oi=imw⊙(1,3,5,…,2k−1)(modw)\mathbf{o}_i = \frac{i}{m} \mathbf{w} \odot (1, 3, 5, \dots, 2k-1) \pmod{\mathbf{w}}

This asymmetric spacing ensures that the overlapping intersection regions are nearly isotropic (square/circular), providing balanced generalization across all spatial directions.

Sparsity and O(m) Computational Efficiency

Because each tiling is an exhaustive partition, any continuous state ss falls into exactly one tile per tiling. For a system with mm tilings:

  • The binary feature vector x(s)∈{0,1}d\mathbf{x}(s) \in \{0, 1\}^d contains millions of possible tile dimensions.
  • However, exactly mm features are non-zero (∥x(s)∥1=m\|\mathbf{x}(s)\|_1 = m and xj∈{0,1}x_j \in \{0, 1\}).
  • All remaining d−md - m features are strictly 00.

This extreme sparsity transforms linear value inference from an expensive vector multiplication into a fast integer lookup:

v^(s,w)=w⊤x(s)=∑j=1dwjxj(s)=∑i=0m−1wtilei(s)\hat{v}(s, \mathbf{w}) = \mathbf{w}^\top \mathbf{x}(s) = \sum_{j=1}^d w_j x_j(s) = \sum_{i=0}^{m-1} w_{\text{tile}_i(s)}

Rather than executing dd floating-point multiplications, computing v^(s,w)\hat{v}(s, \mathbf{w}) requires only mm memory lookups and additions (O(m)O(m) complexity), completely independent of total state-space resolution dd.

Proportional Generalization and Tile Hashing

  1. Controlled Generalization: Two continuous states ss and s′s' generalize in direct proportion to their spatial proximity. If ss and s′s' share kk active tiles across the mm tilings, their feature dot product is: x(s)⊤x(s′)=k\mathbf{x}(s)^\top \mathbf{x}(s') = k Updating state ss shifts the value of state s′s' by exactly the fraction km\frac{k}{m}. Points separated by more than one tile width share zero tiles (k=0k=0) and undergo zero cross-talk.

  2. Consistent Tile Hashing: In multi-dimensional spaces, the total number of tiles across all tilings can easily reach billions. To cap memory usage, multi-dimensional tile coordinates (i,c1,c2,…,ck)(i, c_1, c_2, \dots, c_k) are mapped to a fixed-size weight table of length NN using a uniform pseudo-random hash function: index=hash(i,c1,c2,…,ck)(modN)\text{index} = \text{hash}(i, c_1, c_2, \dots, c_k) \pmod N Because tilings are offset and states are sparse, occasional hash collisions are spread uniformly throughout the state space, behaving as harmless pseudo-random noise that averages out during learning.

Worked numerical calculation

Consider a 2-dimensional continuous state s=(x,y)=(3.6,7.2)s = (x, y) = (3.6, 7.2).

  • Number of tilings: m=4m = 4.
  • Tile width along both axes: W=2.0W = 2.0.
  • Fundamental offset step: Δ=W/m=2.0/4=0.5\Delta = W / m = 2.0 / 4 = 0.5.
  • Asymmetric offsets for the 4 tilings:
    • Tiling 0: o0=(0.0,0.0)\mathbf{o}_0 = (0.0, 0.0)
    • Tiling 1: o1=(0.5,1.5)\mathbf{o}_1 = (0.5, 1.5)
    • Tiling 2: o2=(1.0,1.0)\mathbf{o}_2 = (1.0, 1.0)
    • Tiling 3: o3=(1.5,0.5)\mathbf{o}_3 = (1.5, 0.5)

Compute active tile integer coordinates cx=⌊(x−ox)/W⌋,  cy=⌊(y−oy)/W⌋c_x = \lfloor (x - o_x) / W \rfloor, \; c_y = \lfloor (y - o_y) / W \rfloor:

  1. Tiling 0: cx=⌊(3.6−0.0)/2.0⌋=⌊1.80⌋=1c_x = \lfloor (3.6 - 0.0) / 2.0 \rfloor = \lfloor 1.80 \rfloor = \mathbf{1} cy=⌊(7.2−0.0)/2.0⌋=⌊3.60⌋=3c_y = \lfloor (7.2 - 0.0) / 2.0 \rfloor = \lfloor 3.60 \rfloor = \mathbf{3} Active Tile: (Tiling 0, Tile [1, 3])

  2. Tiling 1: cx=⌊(3.6−0.5)/2.0⌋=⌊3.1/2.0⌋=⌊1.55⌋=1c_x = \lfloor (3.6 - 0.5) / 2.0 \rfloor = \lfloor 3.1 / 2.0 \rfloor = \lfloor 1.55 \rfloor = \mathbf{1} cy=⌊(7.2−1.5)/2.0⌋=⌊5.7/2.0⌋=⌊2.85⌋=2c_y = \lfloor (7.2 - 1.5) / 2.0 \rfloor = \lfloor 5.7 / 2.0 \rfloor = \lfloor 2.85 \rfloor = \mathbf{2} Active Tile: (Tiling 1, Tile [1, 2])

  3. Tiling 2: cx=⌊(3.6−1.0)/2.0⌋=⌊2.6/2.0⌋=⌊1.30⌋=1c_x = \lfloor (3.6 - 1.0) / 2.0 \rfloor = \lfloor 2.6 / 2.0 \rfloor = \lfloor 1.30 \rfloor = \mathbf{1} cy=⌊(7.2−1.0)/2.0⌋=⌊6.2/2.0⌋=⌊3.10⌋=3c_y = \lfloor (7.2 - 1.0) / 2.0 \rfloor = \lfloor 6.2 / 2.0 \rfloor = \lfloor 3.10 \rfloor = \mathbf{3} Active Tile: (Tiling 2, Tile [1, 3])

  4. Tiling 3: cx=⌊(3.6−1.5)/2.0⌋=⌊2.1/2.0⌋=⌊1.05⌋=1c_x = \lfloor (3.6 - 1.5) / 2.0 \rfloor = \lfloor 2.1 / 2.0 \rfloor = \lfloor 1.05 \rfloor = \mathbf{1} cy=⌊(7.2−0.5)/2.0⌋=⌊6.7/2.0⌋=⌊3.35⌋=3c_y = \lfloor (7.2 - 0.5) / 2.0 \rfloor = \lfloor 6.7 / 2.0 \rfloor = \lfloor 3.35 \rfloor = \mathbf{3} Active Tile: (Tiling 3, Tile [1, 3])

Suppose the current learned weights associated with these four active tiles are:

  • w(0,1,3)=1.20w(0, 1, 3) = 1.20
  • w(1,1,2)=0.80w(1, 1, 2) = 0.80
  • w(2,1,3)=1.50w(2, 1, 3) = 1.50
  • w(3,1,3)=1.00w(3, 1, 3) = 1.00

The estimated linear state value is the direct sum of the m=4m=4 active weights: v^(s,w)=1.20+0.80+1.50+1.00=4.50\hat{v}(s, \mathbf{w}) = 1.20 + 0.80 + 1.50 + 1.00 = \mathbf{4.50}

Computing this prediction required exactly four additions, delivering high-acuity localization with minimal processor overhead.

Code

import mathfrom typing import Dict, List, Set, Tuple

class TileCoder2D:    """2D Tile Coder (CMAC) using asymmetric offsets and linear weight indexing."""
    def __init__(        self,        num_tilings: int = 4,        tile_width: float = 2.0,        offsets: List[Tuple[float, float]] = None,    ):        self.num_tilings = num_tilings        self.tile_width = tile_width
        # Asymmetric offsets to prevent diagonal correlation artifacts        if offsets is None:            self.offsets = [                (0.0, 0.0),                (0.5, 1.5),                (1.0, 1.0),                (1.5, 0.5),            ]        else:            assert len(offsets) == num_tilings            self.offsets = offsets
    def get_active_tiles(self, x: float, y: float) -> List[Tuple[int, int, int]]:        """Return the active (tiling_idx, coord_x, coord_y) for continuous point (x, y)."""        active_tiles: List[Tuple[int, int, int]] = []        for tiling_idx, (ox, oy) in enumerate(self.offsets):            cx = math.floor((x - ox) / self.tile_width)            cy = math.floor((y - oy) / self.tile_width)            active_tiles.append((tiling_idx, cx, cy))        return active_tiles
    def predict(        self, x: float, y: float, weights: Dict[Tuple[int, int, int], float]    ) -> float:        """Compute linear prediction as sum of m active tile weights in O(m) time."""        active_tiles = self.get_active_tiles(x, y)        return sum(weights.get(tile, 0.0) for tile in active_tiles)
    def update(        self,        x: float,        y: float,        target: float,        weights: Dict[Tuple[int, int, int], float],        alpha: float = 0.1,    ) -> float:        """Perform linear semi-gradient update: w_i <- w_i + (alpha / m) * (target - v_hat)."""        active_tiles = self.get_active_tiles(x, y)        v_hat = sum(weights.get(tile, 0.0) for tile in active_tiles)        error = target - v_hat
        # Step size normalized by number of active tilings m        step_size = alpha / self.num_tilings        for tile in active_tiles:            weights[tile] = weights.get(tile, 0.0) + step_size * error
        return error

if __name__ == "__main__":    tc = TileCoder2D(num_tilings=4, tile_width=2.0)    query_x, query_y = 3.6, 7.2
    # 1. Active Tile Indexing Verification    active = tc.get_active_tiles(query_x, query_y)    print("=== Tile Coding (CMAC) Numerical Verification ===")    print(f"Continuous State Point: ({query_x}, {query_y})")    print(f"Active Tile Indices across {tc.num_tilings} tilings:")    for tile in active:        print(f"  Tiling {tile[0]}: Tile Coordinate [{tile[1]}, {tile[2]}]")
    expected_active = [(0, 1, 3), (1, 1, 2), (2, 1, 3), (3, 1, 3)]    assert (        active == expected_active    ), f"Expected {expected_active}, got {active}"
    # 2. Value Prediction Verification with Preset Weights    preset_weights: Dict[Tuple[int, int, int], float] = {        (0, 1, 3): 1.20,        (1, 1, 2): 0.80,        (2, 1, 3): 1.50,        (3, 1, 3): 1.00,    }    v_val = tc.predict(query_x, query_y, preset_weights)    print(f"\nCalculated Linear Value v_hat: {v_val:.2f}")    assert math.isclose(v_val, 4.50), f"Expected 4.50, got {v_val}"
    # 3. Spatial Generalization Verification    close_point = (3.7, 7.3)    far_point = (6.5, 11.0)
    set_original: Set[Tuple[int, int, int]] = set(active)    set_close: Set[Tuple[int, int, int]] = set(        tc.get_active_tiles(*close_point)    )    set_far: Set[Tuple[int, int, int]] = set(tc.get_active_tiles(*far_point))
    overlap_close = len(set_original.intersection(set_close))    overlap_far = len(set_original.intersection(set_far))
    print("\nGeneralization Analysis:")    print(        f"  Nearby point {close_point} shares: {overlap_close}/4 tiles ({overlap_close / 4:.0%} overlap)"    )    print(        f"  Distant point {far_point} shares: {overlap_far}/4 tiles ({overlap_far / 4:.0%} overlap)"    )
    assert (        overlap_close >= 3    ), "Close continuous points must share significant tile overlap!"    assert (        overlap_far == 0    ), "Far continuous points must share zero tile overlap!"
    print("\nAll tile coding mathematical assertions passed successfully.")
# Expected Output:# === Tile Coding (CMAC) Numerical Verification ===# Continuous State Point: (3.6, 7.2)# Active Tile Indices across 4 tilings:#   Tiling 0: Tile Coordinate [1, 3]#   Tiling 1: Tile Coordinate [1, 2]#   Tiling 2: Tile Coordinate [1, 3]#   Tiling 3: Tile Coordinate [1, 3]## Calculated Linear Value v_hat: 4.50## Generalization Analysis:#   Nearby point (3.7, 7.3) shares: 4/4 tiles (100% overlap)#   Distant point (6.5, 11.0) shares: 0/4 tiles (0% overlap)## All tile coding mathematical assertions passed successfully.

Watch Out For

Symmetrical Offsets and Diagonal Bias Grooves

A frequent pitfall when building custom tile coders is choosing uniform symmetrical tiling offsets, such as shifting each successive tiling by equal increments along every axis: oi=(iwm,  iwm)\mathbf{o}_i = \left( i \frac{w}{m}, \; i \frac{w}{m} \right)

When offsets are symmetrical:

  • The sub-tile intersection shapes elongate into narrow diagonal slivers oriented along the 45-degree axis.
  • The agent generalizes strongly along the diagonal while generalizing poorly along orthogonal directions.
  • Learning develops artificial "grooves," causing erratic policy performance when negotiating curved trajectories.

The Fix:

  1. Use Odd-Integer Asymmetric Vectors: Follow the canonical Sutton-Barto offset construction where coordinate displacements follow (1,3,5,…,2k−1)(1, 3, 5, \dots, 2k-1), creating isotropic, nearly circular receptive fields.
  2. Dimension-Specific Tile Widths: Tune tile widths wjw_j independently per dimension to reflect physical sensitivity (e.g., narrow tiles for sensitive angle sensors and wide tiles for cart position).
  3. Choose m≥8m \ge 8 Tilings: Using too few tilings (m<4m < 4) causes coarse stair-step value approximations; using m∈[8,32]m \in [8, 32] yields smooth, quasi-continuous function approximation.

The Quick Version

  • Multiple offset grids: Tile coding overlays mm complete partitionings (tilings) of continuous state space, displaced by asymmetric offsets.
  • Sparse binary representation: Exactly mm tiles are active at any continuous point; the feature vector is purely binary with ∥x(s)∥1=m\|\mathbf{x}(s)\|_1 = m.
  • Lightning-fast O(m)O(m) inference: Value estimation evaluates as a simple sum of mm active weights, completely bypassing matrix multiplications.
  • Isotropic generalization: Two states sharing kk active tiles generalize with exact weight fraction km\frac{k}{m}, combining broad coverage with pinpoint spatial resolution.