Self-Models, Body Schemas, and Affordances › Code Implementation
World Models HandbookAll chapters Self-Models, Body Schemas, and Affordances › Code ImplementationOne section · Sections in this chapter7 / 11 ↓
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
The first block defines the true (hidden from the agent) kinematics and the self-generated calibration-data loop. Everything here is small enough to run on a laptop CPU in seconds. The separation between the definition block and the agent's data matters conceptually: the function forward_kinematics plays the role of the unmodeled physical body, and every subsequent step treats it as a black box that maps joint configurations to measured end-effector positions.
import numpy as np
from sklearn.neural_network import MLPRegressor
## True (agent-unknown) geometry
L1, L2 = 1.0, 0.8
def forward_kinematics(q1, q2):
x = L1 * np.cos(q1) + L2 * np.cos(q1 + q2)
y = L1 * np.sin(q1) + L2 * np.sin(q1 + q2)
return np.stack([x, y], axis=-1)Now the agent runs its self-identification experiment: random motor babbling over joint space, recording the resulting measured end-effector pose. This mirrors the coverage requirement from System Identification: the training inputs must visit the configurations on which the learned schema will be used. Uniform independent sampling over the full joint range provides broad static coverage in expectation, but a finite draw does not by itself certify persistent excitation for a nonlinear model. A real agent with limited energy or safety constraints might choose samples more deliberately; in every case, evaluation should use configurations withheld from fitting.
rng = np.random.default_rng(7)
n_train, n_test = 2000, 500
Q_train = rng.uniform(-np.pi, np.pi, size=(n_train, 2))
Q_test = rng.uniform(-np.pi, np.pi, size=(n_test, 2))
X_train = forward_kinematics(Q_train[:, 0], Q_train[:, 1])
X_test = forward_kinematics(Q_test[:, 0], Q_test[:, 1])Training pairs: 2000, held-out pairs: 500
With 2,000 training pairs drawn uniformly over joint space and 500 held-out pairs, the experiment measures interpolation to configurations not used for fitting. A finite uniform sample still leaves uneven local coverage, especially near domain boundaries where neighborhoods are one-sided. The held-out set is therefore necessary but not a certificate of worst-case behavior: a safety-critical evaluation would stratify error by configuration region and deliberately probe joint limits and singular postures.
Fit the body schema. A small multilayer perceptron suffices because the map is smooth and two-dimensional. Notice that the agent never sees or ; the link lengths are implicit in the learned weights. In a richer problem the network would also have to discover the structure of the map, its periodicity, its symmetry under exchanging certain configurations, but here the smooth two-dimensional regression already carries the essential lesson: the geometry of the body can live entirely in fitted parameters.
schema = MLPRegressor(
hidden_layer_sizes=(64, 64), max_iter=4000, random_state=0
)
schema.fit(Q_train, X_train)
pred_test = schema.predict(Q_test)
err = np.linalg.norm(pred_test - X_test, axis=1)Mean end-effector error: 0.0529 95th-percentile error: 0.1962
The held-out mean error is about length units and the 95th-percentile error is about , for an arm whose maximum reach is units. The schema captures the overall map, but its tail error is large enough to matter near tight clearances. More important than the point prediction is the derivative, because planners consume Jacobians. We extract it from the learned model by central finite differences and compare with the analytic Jacobian the agent does not know. This step is where a merely predictive model would stop and a control-supporting model continues: the finite-difference probe asks the learned model a question it was never directly trained on, namely how its output responds to infinitesimal input changes, and the answer is only useful if the learned function is smooth in the right way.
def learned_jacobian(q, eps=1e-4):
J = np.zeros((2, 2))
for i in range(2):
qp, qm = q.copy(), q.copy()
qp[i] += eps
qm[i] -= eps
J[:, i] = (
schema.predict(qp[None]) - schema.predict(qm[None])
).ravel() / (2 * eps)
return J
q0 = np.array([0.4, -1.1])
a1, a2 = q0
J_analytic = np.array(
[
[-L1 * np.sin(a1) - L2 * np.sin(a1 + a2), -L2 * np.sin(a1 + a2)],
[L1 * np.cos(a1) + L2 * np.cos(a1 + a2), L2 * np.cos(a1 + a2)],
]
)
J_learned = learned_jacobian(q0)Max entrywise Jacobian error: 0.0551
At this configuration, the maximum entrywise difference between the learned and analytic Jacobians is about . The estimate recovers the local direction only approximately, and this one-posture comparison does not establish suitability for resolved-rate control across the workspace. That requires closed-loop tests, representative postures, feedback, and conditioning checks. Here is the desired end-effector displacement and is the joint displacement that produces it. Near configurations where the analytic Jacobian loses rank, the linear solve becomes ill-conditioned and small end-effector motions may demand very large joint motions. A learned schema inherits this geometric pathology, so a controller should monitor singular values or use a damped pseudoinverse. The probe exposes the gap between point-prediction accuracy, local derivative agreement, and control-grade validation.
Next, the positional affordance map. The agent samples many joint configurations, records end-effector positions, and rasterizes them into a workspace grid. Cells containing at least one reachable end-effector position are marked reachable. Conceptually, this is the simplest possible estimator for : a Monte Carlo estimate of the forward-kinematic image of the joint space, binned at grid resolution. Its statistical properties follow directly from that description, as we will see in the output.
## Affordance map: rasterize sampled end-effector positions
Q_all = rng.uniform(-np.pi, np.pi, size=(40000, 2))
P_all = forward_kinematics(Q_all[:, 0], Q_all[:, 1])
lim = 2.0
grid_res = 0.05
n_bins = int(2 * lim / grid_res)
ix = np.clip(((P_all[:, 0] + lim) / grid_res).astype(int), 0, n_bins - 1)
iy = np.clip(((P_all[:, 1] + lim) / grid_res).astype(int), 0, n_bins - 1)
reach = np.zeros((n_bins, n_bins), dtype=bool)
reach[iy, ix] = True
## Sanity check against the known annulus geometry
r = np.linalg.norm(P_all, axis=1)Min sampled radius: 0.200 (theory |L1-L2| = 0.200) Max sampled radius: 1.800 (theory L1+L2 = 1.800) Reachable cells: 4160 of 6400
The sampled radii hug the analytic bounds: with 40,000 configurations the samples densely cover the annular workspace and leave the disc of radius around the shoulder empty. The agent has recovered the workspace pattern through motor babbling, but the raster alone does not prove that every empty cell is unreachable. A hit is constructive positive evidence: at least one sampled configuration reached that cell. A miss is ambiguous, because a reachable cell may remain empty under finite sampling. The analytic kinematics establish the true inner and outer bounds in this toy example; without them, cells near either boundary should be treated as a band of uncertainty rather than a sharp curve.
Now the theory-of-mind experiment. A second agent walks a one-dimensional corridor of positions to toward one of two hidden goals, or , stepping toward its goal with probability and away with probability . The observer sees only the position trajectory and knows the behavioral model, and performs exact Bayesian posterior updates over the two goal hypotheses. The setup is intentionally minimal: two hypotheses, a known stochastic behavioral model, and a one-dimensional state. Stripped of all incidental complexity, the experiment isolates the one thing theory of mind requires at level 1, a likelihood that connects behavior to hidden intention.
def step_prob(x, g):
"""Other agent's step distribution from position x toward goal g."""
if x == g:
return {0: 1.0}
toward = 1 if g > x else -1
return {toward: 0.8, -toward: 0.2}
## Simulate the other agent with hidden goal 9
true_goal = 9
x_traj = [4]
deltas = []
for _ in range(20):
probs = step_prob(x_traj[-1], true_goal)
moves = list(probs)
d = rng.choice(moves, p=np.array([probs[m] for m in moves]))
deltas.append(d)
x_traj.append(x_traj[-1] + d)
## Level-1 goal inference: exact Bayes over the two goal hypotheses
goals = [1, 9]
belief = {1: 0.5, 9: 0.5}
beliefs_goal9 = [0.5]
for t in range(len(deltas)):
x_now, d = x_traj[t], deltas[t]
post = {
g: prior * step_prob(x_now, g).get(d, 0.0)
for g, prior in belief.items()
}
total = sum(post.values())
if total == 0:
raise ValueError(
"Observation has zero likelihood under every goal hypothesis"
)
belief = {g: p / total for g, p in post.items()}
beliefs_goal9.append(belief[9])
t_arr = np.arange(len(x_traj))
x_arr = np.array(x_traj, dtype=float)
post_arr = np.array(beliefs_goal9)Other agent's final position: 9 Observer's final P(goal = 9): 1.0000
For this seeded trajectory, the observer's final posterior is effectively 1.0 and the other agent finishes at the hidden goal. The inference has moved from a 0.5 prior to near certainty using the observed steps and the assumed behavioral model. The toy update also assumes every realized observation has positive marginal likelihood under at least one hypothesis. A robust implementation would include observation noise or a fallback for out-of-model events instead of allowing a zero normalizer. In richer settings the goal set, behavioral model, and observation model all require justification; the reusable core is likelihood-weighted belief revision over hidden intentions.
All quantities are now precomputed. We visualize the two results: the reachability affordance map with the learned-kinematics error distribution, and the observer's belief trajectory next to the other agent's actual motion.


The first figure shows the discovered capability: every sampled end-effector position lies between the two analytic circles, and the interior hole is empty without the agent ever being told about it. The second figure shows the schema's held-out accuracy.


Read the two behaviors together. Before either goal is reached, a step toward position 9 multiplies the posterior odds for goal 9 by four, while a step away divides them by four. The net evidence in this seeded run favors goal 9. When the agent stops at 9, the goal-1 hypothesis assigns zero likelihood to that event and the posterior becomes exactly one. It moves only from about to , so "exact" matters more than "dramatic." This is the core level-1 inference loop, but its conclusion is only as sound as the behavioral model. An obstacle, hesitation, or feint can make observed motion depart from the assumed policy and drive the same Bayesian update toward the wrong goal.