Influence-Adaptive Strategic Quantization / experiment.py
Mechanism confirmed, baseline not beaten
1import json, math, random
2import numpy as np
3
4SEED = 996
5np.random.seed(SEED); random.seed(SEED)
6
7def sat(u): return np.clip(u, -1.0, 1.0)
8
9def xi_law(a, kappa=2.0, eps=0.05, xmin=1.0, xmax=8.0):
10 return np.clip(kappa/(eps + np.asarray(a)), xmin, xmax)
11
12def midpoint_quantize(x, L):
13 # Uniform interval quantizer on [-1,1], midpoint reconstruction.
14 edges = np.linspace(-1, 1, int(L)+1)
15 idx = np.clip(np.searchsorted(edges, x, side='right')-1, 0, L-1)
16 return (edges[idx] + edges[idx+1])/2
17
18def math_checks():
19 # Prediction 1: saturation begins exactly at |x|=1/xi.
20 xis = [1.25, 2., 4., 7.]
21 threshold_err = []
22 for z in xis:
23 x = np.linspace(0, 1, 200001)
24 y = sat(z*x)
25 first = x[np.argmax(y >= 1-1e-10)]
26 threshold_err.append(abs(first - 1/z))
27 # Prediction 2: unsaturated local slope is xi and decreases with influence.
28 aa = np.linspace(0.05, 2.0, 20)
29 zz = xi_law(aa)
30 slopes = []
31 for z in zz:
32 x = np.linspace(-0.05, 0.05, 101)
33 slopes.append(np.polyfit(x, sat(z*x), 1)[0])
34 slope_err = float(np.max(np.abs(np.asarray(slopes)-zz)))
35 monotone = bool(np.all(np.diff(zz) <= 1e-12))
36 # Prediction 3: increasing strategic pressure reduces adaptive credible bins.
37 # The proposed design maps xi to a variable number of bins, capped at binary.
38 kappas = np.linspace(.5, 8., 16)
39 a = .30
40 bins = [max(2, int(np.rint(8/xi_law(a, kappa=k)))) for k in kappas]
41 occupied = []
42 x = np.linspace(-1, 1, 20001)
43 for k, L in zip(kappas, bins):
44 z = xi_law(a, kappa=k)
45 occupied.append(len(np.unique(midpoint_quantize(sat(z*x), L))))
46 return {
47 'threshold_max_abs_error': float(max(threshold_err)),
48 'threshold_observed': [float(1/z) for z in xis],
49 'threshold_errors': [float(v) for v in threshold_err],
50 'influence_xi_at_low_high_a': [float(zz[0]), float(zz[-1])],
51 'influence_xi_monotone_decreasing': monotone,
52 'slope_max_abs_error_vs_xi': slope_err,
53 'kappas': [float(v) for v in kappas], 'adaptive_bins': bins,
54 'occupied_bins': occupied,
55 'binary_reached': bool(min(bins)==2 and occupied[-1] <= 2),
56 'formula_bounds_ok': bool(np.all(np.abs(sat(zz*x[:len(zz)])) <= 1+1e-12))
57 }
58
59def gnn_experiment():
60 try:
61 import torch
62 torch.manual_seed(SEED)
63 device = 'cuda' if torch.cuda.is_available() else 'cpu'
64 try:
65 if device == 'cuda': torch.cuda.empty_cache()
66 except Exception: device='cpu'
67 n=96; d=12; c=2
68 # Homophilic SBM, undirected with self loops, row normalized.
69 y=torch.arange(n, device=device)%2
70 rng=np.random.default_rng(SEED)
71 A=np.zeros((n,n),dtype=np.float32)
72 for i in range(n):
73 for j in range(i+1,n):
74 p=.24 if int(y[i])==int(y[j]) else .035
75 if rng.random()<p: A[i,j]=A[j,i]=1
76 A += np.eye(n,dtype=np.float32)
77 A=A/A.sum(1,keepdims=True)
78 At=torch.tensor(A,device=device)
79 X=torch.randn(n,d,device=device)
80 # Class signal in first coordinate, intentionally modest.
81 X[:,0] += (2*y.float()-1)*.75
82 perm=torch.randperm(n,device=device); tr=perm[:60]; va=perm[60:]
83 class Base(torch.nn.Module):
84 def __init__(self):
85 super().__init__(); self.w1=torch.nn.Linear(d,16); self.w2=torch.nn.Linear(16,c)
86 def forward(self):
87 h=torch.relu(At @ self.w1(X)); return self.w2(At @ h)
88 # Scalar strategic channel replaces the scalar message, then broadcasts through Wm.
89 class Strategic(torch.nn.Module):
90 def __init__(self, quant=False):
91 super().__init__(); self.w0=torch.nn.Linear(d,16); self.vm=torch.nn.Parameter(torch.randn(d)*.15)
92 self.wm=torch.nn.Linear(1,16,bias=False); self.out=torch.nn.Linear(16,c); self.quant=quant
93 def forward(self, eval_quant=False):
94 h=torch.relu(self.w0(X)); raw=torch.tanh(X @ self.vm)
95 # audience statistic: normalized incoming influence (column mass)
96 influence=At.sum(0)
97 xi=torch.clamp(2.0/(.05+influence),1.,8.)
98 msg=torch.clamp(xi*raw,-1.,1.)
99 if self.quant and eval_quant:
100 # adaptive resolution; low influence -> larger xi -> fewer bins
101 L=torch.clamp(torch.round(8./xi),min=2,max=8).long()
102 vals=[]
103 for i in range(n): vals.append(midpoint_quantize(msg[i].detach().cpu().numpy(),int(L[i])))
104 msg=torch.tensor(np.asarray(vals),device=device,dtype=X.dtype)
105 h=torch.relu(h + At @ self.wm(msg[:,None]))
106 return self.out(h), raw, msg, xi
107 def train_model(model, strategic=False, quant=False):
108 opt=torch.optim.Adam(model.parameters(),lr=.025,weight_decay=1e-3)
109 for step in range(180):
110 opt.zero_grad()
111 out,*_=model() if strategic else (model(),)
112 loss=torch.nn.functional.cross_entropy(out[tr],y[tr]); loss.backward(); opt.step()
113 with torch.no_grad():
114 if strategic: out,raw,msg,xi=model(eval_quant=quant)
115 else: out=model(); raw=msg=xi=None
116 acc=(out[va].argmax(1)==y[va]).float().mean().item()
117 return acc, raw, msg, xi
118 b=Base().to(device); bacc,*_=train_model(b)
119 s=Strategic(False).to(device); sacc,sraw,smsg,sxi=train_model(s,True,False)
120 q=Strategic(True).to(device); qacc,qraw,qmsg,qxi=train_model(q,True,True)
121 with torch.no_grad():
122 satur=np.abs((sxi*sraw).cpu().numpy())>=1-1e-5
123 bits=np.log2(np.maximum(2,np.rint(8/sxi.cpu().numpy())))
124 ent=None
125 # communication summary uses adaptive assigned bits, not tensor storage.
126 return {'device':device,'nodes':n,'train_steps':180,
127 'validation_accuracy':{'baseline_gcn':bacc,'strategic_clipped':sacc,'strategic_adaptive_quantized':qacc},
128 'strategic_saturation_fraction':float(satur.mean()),
129 'mean_adaptive_bits':float(bits.mean()),'min_max_xi':[float(sxi.min()),float(sxi.max())],
130 'bounded_message_max_abs':float(torch.max(torch.abs(smsg)).item()),
131 'mean_influence':float(sxi.numel() and (At.sum(0).mean()).item())}
132 except Exception as e:
133 return {'error':repr(e),'fallback':'math checks still valid'}
134
135def main():
136 result={'seed':SEED,'math_checks':math_checks(),'gnn_experiment':gnn_experiment()}
137 print(json.dumps(result,indent=2,sort_keys=True))
138if __name__=='__main__': main()