Equivariant Shared-Mechanism World Model / experiment.py
Mechanism confirmed, baseline not beaten
1import json, random
2from pathlib import Path
3import numpy as np
4import torch
5from torch import nn
6
7SEED = 3145
8random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
9try:
10 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
11except Exception:
12 device = torch.device('cpu')
13
14# C4 acts on both 2D input blocks (state and action) and on the 2D output.
15def R(k, device=None):
16 t = float(k % 4) * np.pi / 2
17 c, s = np.cos(t), np.sin(t)
18 return torch.tensor([[c, -s], [s, c]], dtype=torch.float32, device=device)
19
20def transform(z, k):
21 q = R(k, z.device)
22 return torch.cat([z[:, :2] @ q.T, z[:, 2:] @ q.T], dim=1)
23
24def transform_y(y, k):
25 return y @ R(k, y.device).T
26
27def true_base(z):
28 x, a = z[:, :2], z[:, 2:]
29 # Nonlinear but exactly O(2)-equivariant: scalar invariants multiply vectors.
30 s = (x*x).sum(1, keepdim=True) + 0.35*(a*a).sum(1, keepdim=True)
31 return (0.65 + 0.12*torch.tanh(s))*x + (0.42 - 0.08*torch.sigmoid(s))*a
32
33def make_data(n, ks):
34 z0 = torch.randn(n, 4) * torch.tensor([2.0, 0.35, 1.5, 0.25])
35 # Every orientation is the same embedded local mechanism under C4.
36 kvals = torch.tensor(np.random.choice(ks, n), dtype=torch.long)
37 zs, ys = [], []
38 for z, k in zip(z0, kvals):
39 zz = transform(z[None], int(k)).squeeze(0)
40 yy = transform_y(true_base(z[None]), int(k)).squeeze(0)
41 zs.append(zz); ys.append(yy)
42 return torch.stack(zs), torch.stack(ys)
43
44class MLP(nn.Module):
45 def __init__(self, hidden=48):
46 super().__init__()
47 self.net = nn.Sequential(nn.Linear(4, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), nn.Linear(hidden, 2))
48 def forward(self, z): return self.net(z)
49
50def eq_penalty(model, z):
51 # Exact sampled analogue of E_h ||F(rho_in(h)z)-rho_out(h)F(z)||^2.
52 y = model(z)
53 vals = []
54 for k in range(4):
55 vals.append((model(transform(z, k)) - transform_y(y, k)).pow(2).mean())
56 return torch.stack(vals).mean()
57
58def fit(lam, train_z, train_y, epochs=450):
59 model = MLP().to(device)
60 opt = torch.optim.Adam(model.parameters(), lr=3e-3, weight_decay=1e-5)
61 z, y = train_z.to(device), train_y.to(device)
62 for _ in range(epochs):
63 opt.zero_grad()
64 pred = model(z)
65 loss = (pred-y).pow(2).mean() + lam*eq_penalty(model, z)
66 loss.backward(); opt.step()
67 return model
68
69def mse(model, z, y):
70 with torch.no_grad(): return float((model(z.to(device))-y.to(device)).pow(2).mean().cpu())
71
72def main():
73 # Structural sanity checks corresponding to the occurrence symmetry: C4 composition,
74 # invertibility, and exact equivariance of the generated shared mechanism.
75 z = torch.randn(64, 4)
76 composition_err = max(float((transform(transform(z, a), b)-transform(z, (a+b)%4)).abs().max()) for a in range(4) for b in range(4))
77 mechanism_err = max(float((true_base(transform(z, k))-transform_y(true_base(z), k)).abs().max()) for k in range(4))
78 linear_good = nn.Linear(4, 2, bias=False)
79 with torch.no_grad():
80 linear_good.weight.copy_(torch.tensor([[1.,0.,0.5,0.],[0.,1.,0.,0.5]]))
81 penalty_good = float(eq_penalty(linear_good, z))
82 bad = nn.Linear(4, 2)
83 with torch.no_grad(): bad.weight.copy_(torch.tensor([[1.7,0.2,-.4,.3],[.1,.3,.8,-.2]])); bad.bias.fill_(.4)
84 penalty_bad = float(eq_penalty(bad, z))
85
86 # Train on three orientations and test on a held-out fourth occurrence symmetry.
87 train_z, train_y = make_data(768, [0,1,2])
88 test_id_z, test_id_y = make_data(384, [0,1,2])
89 test_ood_z, test_ood_y = make_data(384, [3])
90 baseline = fit(0.0, train_z, train_y)
91 equiv = fit(1.0, train_z, train_y)
92 results = {
93 'seed': SEED, 'device': str(device),
94 'math_check': {'c4_composition_max_abs': composition_err,
95 'true_mechanism_equivariance_max_abs': mechanism_err,
96 'equivariance_penalty_exact_linear': penalty_good,
97 'equivariance_penalty_non_equivariant_linear': penalty_bad},
98 'params_each_model': sum(p.numel() for p in baseline.parameters()),
99 'baseline': {'train_mse': mse(baseline, train_z, train_y), 'in_distribution_mse': mse(baseline, test_id_z, test_id_y), 'heldout_rotation_mse': mse(baseline, test_ood_z, test_ood_y), 'heldout_equivariance_penalty': float(eq_penalty(baseline, test_ood_z.to(device)).cpu())},
100 'equivariant_shared': {'train_mse': mse(equiv, train_z, train_y), 'in_distribution_mse': mse(equiv, test_id_z, test_id_y), 'heldout_rotation_mse': mse(equiv, test_ood_z, test_ood_y), 'heldout_equivariance_penalty': float(eq_penalty(equiv, test_ood_z.to(device)).cpu())}
101 }
102 Path('results.json').write_text(json.dumps(results, indent=2))
103 print(json.dumps(results, indent=2))
104
105if __name__ == '__main__':
106 try:
107 main()
108 except Exception as e:
109 if str(device) == 'cuda':
110 print('CUDA failed; rerun on CPU:', repr(e))
111 device = torch.device('cpu'); main()
112 else: raise