Sufficient State and State Abstraction › Code Implementation

Michael BrenndoerferJune 14, 20269 min read
World Models HandbookAll chapters
Part 3 of 12Representing Agents and Worlds
Chapter 14Sufficient State and State Abstraction › Code Implementation
Reading section 7 of 10 · Code Implementation · 9 minSection 7 of 10 · 9 min

Book contents

Section 7 of 10 · 9 min read

Sufficient State and State Abstraction › Code ImplementationOne section · Sections in this chapter7 / 10 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

We will implement the lamp gridworld, compute its exact bisimulation partition by partition refinement, verify that the optimal value function is constant on classes, and then use two small supervised probes to compare forecasting with value estimation. The exact partition supplies the control certificate; the probes measure, on a fixed dataset, how much the lamp feature contributes to each prediction target.

As throughout the book, we keep computation and rendering separate: all fitting and simulation happen first, and the visualization cells only draw from precomputed results.

In[8]:
Code
import numpy as np

## Environment structure: position in 0..4, lamp bit in {0, 1}
n_pos, n_lamp = 5, 2
lamp_flip = 0.3
actions = [-1, 0, +1]
n_actions = len(actions)

states = [(p, l) for p in range(n_pos) for l in range(n_lamp)]
state_index = {s: i for i, s in enumerate(states)}
n_states = len(states)


def reward(s):
    return 1.0 if s[0] == n_pos - 1 else 0.0


def transitions(s, a):
    """Outcome distribution for taking action a in state s."""
    next_pos = min(max(s[0] + a, 0), n_pos - 1)
    return [
        (1 - lamp_flip, (next_pos, s[1])),
        (lamp_flip, (next_pos, 1 - s[1])),
    ]

The state space has ten elements: five positions times two lamp values. The transition function is exact, so we can run value iteration to get the ground-truth optimal values. This ground truth is what lets us check the preservation theorem numerically rather than taking it on faith.

In[9]:
Code
gamma = 0.9
V = np.zeros(n_states)

for _ in range(500):
    Q = np.zeros((n_states, n_actions))
    for i, s in enumerate(states):
        for k, a in enumerate(actions):
            Q[i, k] = reward(s) + gamma * sum(
                p * V[state_index[s2]] for p, s2 in transitions(s, a)
            )
    V_new = Q.max(axis=1)
    if np.max(np.abs(V_new - V)) < 1e-12:
        V = V_new
        break
    V = V_new

Q = np.array(
    [
        [
            reward(s)
            + gamma * sum(p * V[state_index[s2]] for p, s2 in transitions(s, a))
            for k, a in enumerate(actions)
        ]
        for i, s in enumerate(states)
    ]
)
greedy = Q.argmax(axis=1)

Next, the partition refinement algorithm. We start from blocks keyed by reward and repeatedly split any block whose members differ in the block-distribution of their successors, for any action. The implementation applies to finite tabular MDPs whose reward and aggregated-probability signatures can be compared exactly. With probabilities produced by different floating-point arithmetic paths, a numerical tolerance or canonicalization policy is required.

In[10]:
Code
def coarsest_bisimulation(states, actions, trans_fn, reward_fn):
    """Exact partition refinement for a finite, exactly comparable MDP table."""
    by_reward = {}
    for s in states:
        reward_signature = tuple(reward_fn(s, a) for a in actions)
        by_reward.setdefault(reward_signature, []).append(s)
    partition = [tuple(block) for block in by_reward.values()]

    def block_of(s):
        return next(i for i, b in enumerate(partition) if s in b)

    while True:
        new_partition = []
        for block in partition:
            subgroups = {}
            for s in block:
                sig = []
                for a in actions:
                    dist = {}
                    for p, s2 in trans_fn(s, a):
                        j = block_of(s2)
                        dist[j] = dist.get(j, 0.0) + p
                    sig.append(tuple(sorted(dist.items())))
                subgroups.setdefault(tuple(sig), []).append(s)
            new_partition.extend(tuple(g) for g in subgroups.values())

        if len(new_partition) == len(partition):
            return partition
        partition = new_partition


partition = coarsest_bisimulation(
    states,
    actions,
    transitions,
    lambda s, a: reward(s),
)
class_of = {}
for c, block in enumerate(partition):
    for s in block:
        class_of[s] = c
Out[11]:
Console
States: 10, bisimulation classes: 5
class 0: members [(0, 0), (0, 1)], V in [6.5610, 6.5610], spread 0.00e+00
class 1: members [(1, 0), (1, 1)], V in [7.2900, 7.2900], spread 0.00e+00
class 2: members [(2, 0), (2, 1)], V in [8.1000, 8.1000], spread 0.00e+00
class 3: members [(3, 0), (3, 1)], V in [9.0000, 9.0000], spread 0.00e+00
class 4: members [(4, 0), (4, 1)], V in [10.0000, 10.0000], spread 0.00e+00

Max within-class value spread: 0.00e+00

The partition collapsed ten states into five classes, and the optimal value function is numerically constant within every class. That is the preservation theorem made concrete: the two-element lamp dimension carries no control information, and bisimulation removes it without changing any optimal decision. Note also the structure of the classes: each contains one position and both lamp values, exactly matching the analytic derivation.

Now the quantitative contrast between the two objectives. We sample a seeded dataset of transitions and fit two small probes on two versions of the representation: one with the lamp feature included and one without. A logistic probe for next-lamp prediction tests predictive content, and a linear probe tests whether the feature helps estimate optimal value. The reported scores are in-sample diagnostics on this synthetic dataset, not generalization estimates or standalone certificates of control sufficiency.

In[12]:
Code
from sklearn.linear_model import LinearRegression, LogisticRegression

rng = np.random.default_rng(0)
n_samples = 4000

X_pos = np.zeros((n_samples, n_pos))
X_lamp = np.zeros((n_samples, 1))
y_next_lamp = np.zeros(n_samples)
y_value = np.zeros(n_samples)

for n in range(n_samples):
    s = states[rng.integers(n_states)]
    a = actions[rng.integers(n_actions)]
    next_pos = min(max(s[0] + a, 0), n_pos - 1)
    next_lamp = s[1] if rng.random() > lamp_flip else 1 - s[1]
    X_pos[n, s[0]] = 1.0
    X_lamp[n, 0] = s[1]
    y_next_lamp[n] = next_lamp
    y_value[n] = V[state_index[s]]

## Noisy value targets: probes must estimate V from finite data, and an
## exactly interpolable target (R^2 = 1.0 for both probes) would make the
## control-side comparison vacuous.
y_value += rng.normal(0.0, 0.15 * y_value.std(), size=y_value.shape)

X_with = np.hstack([X_pos, X_lamp])
X_without = X_pos

acc_with = (
    LogisticRegression(max_iter=200)
    .fit(X_with, y_next_lamp)
    .score(X_with, y_next_lamp)
)
acc_without = (
    LogisticRegression(max_iter=200)
    .fit(X_without, y_next_lamp)
    .score(X_without, y_next_lamp)
)

r2_with = LinearRegression().fit(X_with, y_value).score(X_with, y_value)
r2_without = (
    LinearRegression().fit(X_without, y_value).score(X_without, y_value)
)
Out[13]:
Console
Next-lamp prediction accuracy, with lamp feature:    0.704
Next-lamp prediction accuracy, without lamp feature: 0.519
Value prediction R^2, with lamp feature:    0.9784
Value prediction R^2, without lamp feature: 0.9784

On this seeded sample, knowing the lamp bit raises in-sample next-lamp accuracy from 0.5190.519 to 0.7040.704, close to the theoretical comparison between a balanced coin flip and the Bayes-optimal 0.70.7. The in-sample value-probe score is R2=0.9784R^2=0.9784 with either representation because the lamp contributes nothing to value. The same feature and dataset therefore produce different conclusions for the two consumers.

Finally, two figures. First, the value function plotted separately for each lamp value, showing the curves coincide exactly, alongside the bisimulation class structure itself. Then a bar chart of the probe scores.

In[14]:
Code
V_by_pos = np.array(
    [[V[state_index[(p, l)]] for p in range(n_pos)] for l in range(n_lamp)]
)

class_grid = np.zeros((n_lamp, n_pos), dtype=int)
for s, c in class_of.items():
    class_grid[s[1], s[0]] = c

These two arrays hold the per-lamp value curves and the class index for every (position, lamp) cell, precomputed so the figure cells only contain presentation logic.

Out[15]:
Visualization
Two overlapping value curves versus position, one per lamp value.
Optimal value against corridor position, plotted separately for each lamp value. The two curves coincide, confirming the lamp carries no control information.
Grid of class indices showing merged lamp variants per position.
Bisimulation classes over the joint (position, lamp) state space. Each class merges the two lamp variants of a position.
Out[16]:
Visualization
Bar chart showing lamp feature improves prediction accuracy but not value prediction.
Probe accuracy for predicting the next lamp bit, and R-squared for predicting the optimal value, from representations with and without the lamp feature. The lamp helps prediction but not value estimation.

Read the two probes together. The left pair of bars shows that the lamp feature carries real predictive information; removing it drops prediction to a coin flip. The right pair shows that value estimation is untouched by its removal. An abstraction guided purely by prediction error would keep the lamp; an abstraction justified by bisimulation discards it with proof that no optimal decision changes. The full pipeline, environment construction, exact quotient, and probes, is small enough to modify: try adding a second, slower nuisance variable, or a lamp that occasionally blocks movement, and watch which classes split. The blocking variant is the more instructive of the two: the moment the lamp acquires any causal influence on reachable states or rewards, the class structure shatters, and a representation that had learned lamp-invariance from the benign version will be confidently, silently wrong on the new one. That is the augmentation wager from the invariance section, played out in miniature.