Unexplained Topology Distillation / pce_experiment.py
Mechanism confirmed, baseline not beaten
1import json, math, random
2import numpy as np
3import torch
4
5SEED = 137
6random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
7
8def pce_components(teacher, student, sigma=0.35, eps=1e-8):
9 # teacher/student: [n,2] birth-death coordinates
10 dist2 = ((teacher[:, None, :] - student[None, :, :]) ** 2).sum(-1)
11 sim = torch.exp(-dist2 / (2 * sigma * sigma))
12 a = sim.max(dim=1).values
13 persistence = (teacher[:, 1] - teacher[:, 0]).clamp_min(0)
14 p = persistence / (persistence.sum() + eps)
15 explained = p * a
16 unexplained = 1.0 - explained.sum()
17 loss = -(p * torch.log(p * a + eps)).sum()
18 entropy = -(p * torch.log(p + eps)).sum()
19 return loss, entropy, unexplained, a, p
20
21def chamfer_loss(teacher, student):
22 d = torch.cdist(teacher, student).pow(2)
23 return d.min(1).values.mean() + d.min(0).values.mean()
24
25def run(method, teacher, init, steps=500, lr=0.04):
26 student = torch.nn.Parameter(init.clone())
27 opt = torch.optim.Adam([student], lr=lr)
28 history=[]
29 for step in range(steps):
30 opt.zero_grad()
31 if method == 'pce':
32 loss, _, _, _, _ = pce_components(teacher, student)
33 # The entropy term is constant, but retaining it matches the proposed excess objective.
34 objective = loss
35 else:
36 objective = chamfer_loss(teacher, student)
37 objective.backward(); opt.step()
38 if step in (0, 99, 249, 499):
39 with torch.no_grad():
40 pc = pce_components(teacher, student)
41 history.append((step + 1, float(pc[2]), float(pc[0]), float(objective)))
42 with torch.no_grad():
43 loss, ent, unexplained, a, p = pce_components(teacher, student)
44 return student.detach(), history, (loss.item(), ent.item(), unexplained.item(), a.numpy(), p.numpy())
45
46def main():
47 # Four teacher events with unequal importance and only two student events.
48 teacher = torch.tensor([[0.10,0.90], [0.18,0.52], [0.62,0.96], [0.38,0.48]], dtype=torch.float32)
49 init = torch.tensor([[0.25,0.25], [0.75,0.75]], dtype=torch.float32)
50
51 # Core numerical verification: valid measure and excess identity for random diagrams.
52 torch.manual_seed(11)
53 T = torch.rand(7,2); T[:,1] = T[:,0] + torch.rand(7)
54 S = torch.rand(3,2); S[:,1] = S[:,0] + torch.rand(3)
55 l,e,u,a,p = pce_components(T,S,sigma=.27)
56 identity_error = abs((l-e).item()) # overwritten below; calculate stated excess exactly
57 excess_error = abs((l-e).item() - (-(p*torch.log(a+1e-8)).sum()).item())
58 validity = float((p >= 0).all() and (a >= 0).all() and (a <= 1+1e-6).all() and (u >= -1e-6).item() and (u <= 1+1e-6).item())
59
60 pce_student, pce_hist, pce_final = run('pce', teacher, init)
61 cham_student, cham_hist, cham_final = run('chamfer', teacher, init)
62 # Repeated starts test whether the directional signal survives initialization.
63 repeat = []
64 for seed in (3, 19, 71):
65 g = torch.Generator().manual_seed(seed)
66 start = torch.rand((2, 2), generator=g) * 0.8 + 0.1
67 ps = run('pce', teacher, start)[2]
68 cs = run('chamfer', teacher, start)[2]
69 repeat.append({'seed': seed, 'pce_unexplained': ps[2],
70 'chamfer_unexplained': cs[2],
71 'pce_excess': ps[0]-ps[1],
72 'chamfer_excess': cs[0]-cs[1]})
73 out = {
74 'seed': SEED, 'teacher_points': teacher.tolist(), 'student_count': 2,
75 'repeated_starts': repeat,
76 'core_check': {'probability_valid': bool(validity), 'excess_identity_abs_error': excess_error,
77 'unexplained_mass': u.item(), 'p_sum': p.sum().item(), 'explained_sum': (p*a).sum().item()},
78 'pce': {'student_points': pce_student.tolist(), 'trajectory': pce_hist,
79 'final_loss': pce_final[0], 'teacher_entropy': pce_final[1],
80 'final_excess': pce_final[0]-pce_final[1], 'unexplained_mass': pce_final[2],
81 'explanation_scores': pce_final[3].tolist()},
82 'chamfer_control': {'student_points': cham_student.tolist(), 'trajectory': cham_hist,
83 'pce_loss_evaluated': cham_final[0], 'teacher_entropy': cham_final[1],
84 'final_excess': cham_final[0]-cham_final[1], 'unexplained_mass': cham_final[2],
85 'explanation_scores': cham_final[3].tolist()},
86 }
87 with open('results.json','w') as f: json.dump(out,f,indent=2)
88 print(json.dumps(out,indent=2))
89
90if __name__ == '__main__': main()