Differentiable Persistence Landscape Layer / landscape_experiment.py
Failed on benchmark
1import json, math, os, random
2import numpy as np
3import torch
4from torch import nn
5
6SEED = 2016
7np.random.seed(SEED); random.seed(SEED); torch.manual_seed(SEED)
8
9
10def tents(diagram, grid):
11 d = np.asarray(diagram, dtype=float).reshape(-1, 2)
12 b, death = d[:, 0:1], d[:, 1:2]
13 return np.maximum(0.0, np.minimum(grid[None, :] - b, death - grid[None, :]))
14
15
16def landscape(diagram, grid, K):
17 v = tents(diagram, grid)
18 if len(v) == 0:
19 return np.zeros((K, len(grid)))
20 v = np.sort(v, axis=0)[::-1]
21 out = np.zeros((K, len(grid)))
22 out[:min(K, len(v))] = v[:K]
23 return out
24
25
26def lp_grid(a, b, p=2, dt=0.002):
27 # Common grid over the supports; sufficiently fine for this sanity check.
28 lo = min([x[0] for x in a] + [x[0] for x in b]) - .01
29 hi = max([x[1] for x in a] + [x[1] for x in b]) + .01
30 g = np.arange(lo, hi + dt/2, dt)
31 return (np.sum(np.abs(landscape(a, g, max(len(a),len(b))) -
32 landscape(b, g, max(len(a),len(b))))**p) * dt) ** (1/p)
33
34
35def tent_distance(x, y, dt=0.001):
36 lo = min(x[0], y[0]) - .01; hi = max(x[1], y[1]) + .01
37 g = np.arange(lo, hi + dt/2, dt)
38 return np.sqrt(np.sum(np.abs(tents([x], g)[0] - tents([y], g)[0])**2) * dt)
39
40
41def matching_bound(a, b, dt=0.001):
42 # Equal-cardinality matched-pair W_2^triangle upper bound (the chosen matching).
43 return math.sqrt(sum(tent_distance(x, y, dt)**2 for x, y in zip(a, b)))
44
45
46class PersistenceLandscape(nn.Module):
47 def __init__(self, t_min=0., t_max=1., n_grid=32, K=3):
48 super().__init__()
49 self.K, self.n_grid = K, n_grid
50 self.register_buffer('grid', torch.linspace(t_min, t_max, n_grid))
51 def forward(self, diagrams, mask=None):
52 # diagrams [B,N,2], mask [B,N], padded rows are ignored.
53 b = diagrams[..., 0:1]; d = diagrams[..., 1:2]
54 t = self.grid.view(1, 1, -1)
55 v = torch.relu(torch.minimum(t-b, d-t))
56 if mask is not None:
57 v = v.masked_fill(~mask[..., None], -1e9)
58 vals, _ = torch.topk(v, k=min(self.K, v.shape[1]), dim=1)
59 if vals.shape[1] < self.K:
60 vals = torch.cat([vals, torch.zeros(*vals.shape[:1], self.K-vals.shape[1], vals.shape[2], device=vals.device)], 1)
61 return vals.clamp_min(0.)
62
63
64def make_data(n, N=6, noise=0., seed=0):
65 rng=np.random.default_rng(seed); ds=[]; ys=[]
66 for i in range(n):
67 y=int(i % 2); center=.34 if y==0 else .66
68 births=np.clip(rng.normal(center, .09, N), .03, .82)
69 pers=np.clip(rng.normal(.22, .05, N), .04, .38)
70 deaths=np.minimum(births+pers, .97)
71 ds.append(np.stack([births,deaths],1)); ys.append(y)
72 ds=np.asarray(ds)
73 if noise: ds=np.stack([np.clip(ds[:,:,0]+rng.normal(0,noise,ds.shape[:2]),0,.9), np.clip(ds[:,:,1]+rng.normal(0,noise,ds.shape[:2]),.05,1)],2); ds[:,:,1]=np.maximum(ds[:,:,1],ds[:,:,0]+.01)
74 return ds.astype('float32'), np.asarray(ys, dtype='int64')
75
76
77def train_eval(kind, train, ytr, test, yte, epochs=80):
78 device='cuda' if torch.cuda.is_available() else 'cpu'
79 try:
80 if kind=='landscape':
81 layer=PersistenceLandscape(.0,1.,32,3); Xtr=layer(torch.tensor(train)).flatten(1); Xte=layer(torch.tensor(test)).flatten(1)
82 else:
83 Xtr=torch.tensor(train).flatten(1); Xte=torch.tensor(test).flatten(1)
84 model=nn.Sequential(nn.Linear(Xtr.shape[1],32),nn.ReLU(),nn.Linear(32,2)).to(device)
85 Xtr,Xte=Xtr.to(device),Xte.to(device); yt=torch.tensor(ytr,device=device); yv=torch.tensor(yte,device=device)
86 opt=torch.optim.Adam(model.parameters(),lr=.01)
87 for _ in range(epochs):
88 opt.zero_grad(); loss=nn.functional.cross_entropy(model(Xtr),yt); loss.backward(); opt.step()
89 with torch.no_grad(): acc=(model(Xte).argmax(1)==yv).float().mean().item()
90 return acc
91 except Exception:
92 # CUDA OOM or driver issues: rerun on CPU.
93 torch.cuda.empty_cache() if torch.cuda.is_available() else None
94 old=torch.cuda.is_available; torch.cuda.is_available=lambda:False
95 try: return train_eval(kind,train,ytr,test,yte,epochs)
96 finally: torch.cuda.is_available=old
97
98
99def main():
100 # Mechanism verification: predictions are (P1) one point saturates the bound,
101 # (P2) every multi-point ratio is <=1, and (P3) larger noise cannot amplify
102 # landscape perturbation beyond the chosen matching bound.
103 grid=np.linspace(0,1,2001); rows=[]
104 a=[(.15,.55)];
105 for shift in [.002,.01,.03,.08]:
106 b=[(a[0][0]+shift,a[0][1]+shift)]
107 lhs=np.linalg.norm(landscape(a,grid,3)-landscape(b,grid,3))*math.sqrt(1/2000)
108 rhs=tent_distance(a[0],b[0]); rows.append({'single_shift':shift,'landscape_norm':lhs,'bound':rhs,'ratio':lhs/rhs})
109 rng=np.random.default_rng(4); ratios=[]
110 for _ in range(100):
111 A=[]; B=[]
112 for j in range(5):
113 x=float(rng.uniform(.05,.75)); p=float(rng.uniform(.08,.3)); A.append((x,x+p))
114 dx=float(rng.normal(0,.04)); dp=float(rng.normal(0,.03)); z=max(.01,min(.9,x+dx)); B.append((z,min(.99,z+max(.02,p+dp))))
115 ratios.append(lp_grid(A,B)/matching_bound(A,B))
116 # P3: piecewise-linear tents integrated on a uniform grid should converge
117 # to the continuous L2 norm as dt shrinks (first-order rectangle estimate).
118 ca=[(.13,.57),(.31,.76),(.61,.91)]; cb=[(.16,.55),(.28,.79),(.65,.88)]
119 ref=lp_grid(ca, cb, dt=0.0001)
120 discretization=[]
121 for dt in [.02,.01,.005,.0025,.00125]:
122 val=lp_grid(ca, cb, dt=dt)
123 discretization.append({'dt':dt,'grid_norm':val,'abs_error':abs(val-ref),'error_over_dt':abs(val-ref)/dt})
124 # Autograd prediction: away from max/tent kinks, the layer has finite gradients.
125 q=torch.tensor([[[.18,.52],[.43,.81]]],dtype=torch.float32,requires_grad=True)
126 qout=PersistenceLandscape(.0,1.,64,2)(q)
127 qout.sum().backward()
128 grad_ok=bool(torch.isfinite(q.grad).all() and q.grad.abs().sum()>0)
129 prediction={'single_point_equality':rows,'multi_point_max_ratio':float(max(ratios)),
130 'multi_point_mean_ratio':float(np.mean(ratios)),'predicted_bound':1.0,
131 'grid_convergence':{'reference_dt':0.0001,'sweep':discretization,
132 'prediction':'error decreases as dt decreases'},
133 'autograd_finite_nonzero':grad_ok}
134 tr,ytr=make_data(160,seed=10); te,yte=make_data(80,seed=11)
135 clean={'padded_birth_death':train_eval('raw',tr,ytr,te,yte),'landscape':train_eval('landscape',tr,ytr,te,yte)}
136 noisy=[]
137 for sigma in [.01,.03,.07]:
138 nt,ny=make_data(80,noise=sigma,seed=20); noisy.append({'sigma':sigma,'padded_birth_death':train_eval('raw',tr,ytr,nt,ny),'landscape':train_eval('landscape',tr,ytr,nt,ny)})
139 result={'prediction_checks':prediction,'clean_accuracy':clean,'noise_accuracy':noisy}
140 with open('results.json','w') as f: json.dump(result,f,indent=2)
141 print(json.dumps(result,indent=2))
142if __name__=='__main__': main()