import numpy as np import torch from scipy.linalg import solve from scipy.sparse.csgraph import shortest_path import time, json SEED=417 def directed_dist(A): D=shortest_path(A, directed=True, unweighted=True) if not np.isfinite(D).all(): raise ValueError('graph is not strongly connected') return D def curvature(D, eps=1e-6, clip=5.): n=len(D) # np.linalg.pinv implements the specified Moore-Penrose SVD cutoff convention mo=np.linalg.pinv(D, rcond=eps) @ (n*np.ones(n)) mi=np.linalg.pinv(D.T, rcond=eps) @ (n*np.ones(n)) scale=(np.abs(mo).sum()+np.abs(mi).sum())/(2*n)+1e-6 z=np.stack([np.clip(mo/scale,-clip,clip), np.clip(mi/scale,-clip,clip)],1) return mo,mi,z,scale def graph(n=96, p=0.10, seed=0): rng=np.random.default_rng(seed) y=np.repeat(np.arange(2), n//2) rng.shuffle(y) # Group 0 has relatively strong outgoing connections; group 1 receives them. P=np.full((2,2),p) P[0,1]=.22; P[1,0]=.035; P[0,0]=.10; P[1,1]=.10 A=np.zeros((n,n),dtype=np.float32) for i in range(n): for j in range(n): if i!=j: A[i,j]=rng.random()j; incoming and outgoing directed aggregations. out=A@x/(A.sum(1,keepdims=True)+1e-6) inc=A.T@x/(A.T.sum(1,keepdims=True)+1e-6) h=torch.relu(self.lin(torch.cat([x,out,inc],1))) return self.out(h) def run_one(seed, use_curv, labeled_per_class=5): torch.manual_seed(seed); np.random.seed(seed) A,y,D=graph(seed=seed) n=len(y); rng=np.random.default_rng(seed+88) # Features deliberately carry no class information; degrees are the standard local control. X=rng.normal(0,1,(n,4)).astype(np.float32) if use_curv: _,_,z,_=curvature(D) X=np.concatenate([X,z.astype(np.float32)],1) At=torch.tensor(A); Xt=torch.tensor(X); yt=torch.tensor(y,dtype=torch.long) train=[] for c in [0,1]: train.extend(rng.choice(np.where(y==c)[0],labeled_per_class,replace=False)) train=torch.tensor(train); test=torch.tensor([i for i in range(n) if i not in set(train.tolist())]) model=DirectedNet(X.shape[1]); opt=torch.optim.Adam(model.parameters(),lr=.02,weight_decay=1e-4) for _ in range(250): opt.zero_grad(); logits=model(Xt,At); loss=torch.nn.functional.cross_entropy(logits[train],yt[train]); loss.backward(); opt.step() with torch.no_grad(): logits=model(Xt,At); pred=logits.argmax(1); prob=logits.softmax(1)[:,1] acc=(pred[test]==yt[test]).float().mean().item() # Brier score is a simple calibration metric (lower is better). brier=((prob[test]-(yt[test]==1).float())**2).mean().item() return acc,brier def main(): t=time.perf_counter(); check=math_check(); prep=time.perf_counter()-t rows=[] for k in [5,10]: for mode in [False,True]: r=[run_one(100+s,mode,k) for s in range(5)] rows.append({'labels_per_class':k,'curvature':mode,'accuracy_mean':float(np.mean([x[0] for x in r])),'accuracy_std':float(np.std([x[0] for x in r])),'brier_mean':float(np.mean([x[1] for x in r]))}) print(json.dumps({'math_check':check,'math_check_seconds':prep,'results':rows},indent=2)) if __name__=='__main__': main()