Correction-aware tree optimizer / tree_optimizer_experiment.py
Mechanism confirmed, baseline not beaten
1import json
2import math
3from pathlib import Path
4import numpy as np
5
6SEED = 17
7TREE = {0: [1, 2], 1: [3], 2: []}
8PARENT = {1: 0, 2: 0, 3: 1}
9N = 4
10
11
12def correction_terms(u, s, rho):
13 """One dual update and the exact parent corrections -sum(delta s)."""
14 s_new = s.copy()
15 for child, parent in PARENT.items():
16 s_new[child] += rho * (u[child] - u[parent])
17 corr = np.zeros(N)
18 for parent, children in TREE.items():
19 corr[parent] = -sum(s_new[ch] - s[ch] for ch in children)
20 return s_new, corr
21
22
23def tree_step(u, s, targets, rho, eta, penalty, corrected=True):
24 # Jacobi primal step. Each node reads old neighbors; the root/parents can
25 # additionally read child dual increments through the correction channel.
26 s_new, corr = correction_terms(u, s, rho)
27 grad = u - targets
28 for child, parent in PARENT.items():
29 d = u[child] - u[parent]
30 grad[parent] -= s[child] + penalty * d
31 grad[child] += s[child] + penalty * d
32 if corrected:
33 # At a parent, -delta s is precisely the paper's correction term.
34 grad += corr
35 unew = u - eta * grad
36 return unew, s_new, corr
37
38
39def corrected_tree(targets, rounds=160, rho=.20, eta=.16, penalty=.8, corrected=True):
40 u = np.zeros(N)
41 s = np.zeros(N)
42 losses, residuals, bytes_sent = [], [], 0
43 for _ in range(rounds):
44 u, s, _ = tree_step(u, s, targets, rho, eta, penalty, corrected)
45 loss = .5 * np.sum((u-targets)**2) + .5 * penalty * sum((u[ch]-u[p])**2 for ch,p in PARENT.items())
46 losses.append(float(loss))
47 residuals.append(float(np.sqrt(np.mean([(u[ch]-u[p])**2 for ch,p in PARENT.items()]))))
48 # Each edge sends a scalar state and a scalar correction in this toy.
49 bytes_sent += 2 * len(PARENT) * 8
50 return u, losses, residuals, bytes_sent
51
52
53def fedavg(targets, rounds=160, eta=.16, local_steps=1):
54 u = np.zeros(N)
55 losses, residuals = [], []
56 for _ in range(rounds):
57 proposals = []
58 for x in u:
59 z = x
60 for _ in range(local_steps):
61 z -= eta * (z - targets[len(proposals)])
62 proposals.append(z)
63 mean = np.mean(proposals)
64 u[:] = mean
65 loss = .5 * np.sum((u-targets)**2) + .5*.8*sum((u[ch]-u[p])**2 for ch,p in PARENT.items())
66 losses.append(float(loss))
67 residuals.append(float(np.sqrt(np.mean([(u[ch]-u[p])**2 for ch,p in PARENT.items()]))))
68 # four workers upload and root broadcasts a scalar each round
69 return u, losses, residuals, rounds * 2 * N * 8
70
71
72def math_checks():
73 # Prediction 1: correction conservation error is numerical zero for every rho.
74 u = np.array([.2, 1.3, -.7, .4])
75 s = np.array([0., .8, -.2, .5])
76 conservation = []
77 for rho in [.01, .1, .4, .9]:
78 sn, corr = correction_terms(u, s, rho)
79 expected = np.array([-(sn[1]-s[1])-(sn[2]-s[2]),
80 -(sn[3]-s[3]), 0., 0.])
81 conservation.append(float(abs(corr[0]-expected[0]) + abs(corr[1]-expected[1])))
82
83 # Prediction 2: fixed-state correction norm is exactly linear in rho.
84 norms = []
85 for rho in [.05, .10, .20, .40]:
86 _, c = correction_terms(u, s, rho)
87 norms.append(float(np.linalg.norm(c)))
88 ratios = [norms[i]/norms[0] for i in range(4)]
89 predicted_ratios = [1., 2., 4., 8.]
90
91 # Prediction 3: stale parent dual error is the correction magnitude and
92 # therefore has zero intercept and linear rho scaling.
93 stale = norms
94 slope = float(np.dot([.05,.1,.2,.4], stale) / np.dot([.05,.1,.2,.4], [.05,.1,.2,.4]))
95 intercept = float(np.mean(stale) - slope*np.mean([.05,.1,.2,.4]))
96 return {
97 "conservation_abs_errors": conservation,
98 "correction_norms": norms,
99 "rho_ratio_observed": ratios,
100 "rho_ratio_predicted": predicted_ratios,
101 "stale_error_linear_fit_slope": slope,
102 "stale_error_linear_fit_intercept": intercept,
103 "max_conservation_error": max(conservation),
104 }
105
106
107def main():
108 np.random.seed(SEED)
109 targets = np.array([-2.0, 1.0, 2.5, -1.0])
110 checks = math_checks()
111 sweep = []
112 for rho in [.0, .05, .10, .20, .40]:
113 uc, lc, rc, bc = corrected_tree(targets, rho=rho, corrected=True)
114 us, ls, rs, bs = corrected_tree(targets, rho=rho, corrected=False)
115 sweep.append({
116 "rho": rho,
117 "corrected_final_loss": lc[-1], "stale_final_loss": ls[-1],
118 "corrected_final_residual": rc[-1], "stale_final_residual": rs[-1],
119 "corrected_loss_40": lc[39], "stale_loss_40": ls[39],
120 })
121 uc, lc, rc, bc = corrected_tree(targets, rho=.20, corrected=True)
122 uf, lf, rf, bf = fedavg(targets)
123 result = {
124 "seed": SEED, "targets": targets.tolist(), "math_checks": checks,
125 "rho_sweep": sweep,
126 "mini_experiment": {
127 "tree_correction": {"final_loss": lc[-1], "final_residual": rc[-1], "bytes": bc},
128 "fedavg": {"final_loss": lf[-1], "final_residual": rf[-1], "bytes": bf},
129 "stale_tree_rho_0.2": {"final_loss": corrected_tree(targets, rho=.2, corrected=False)[1][-1],
130 "final_residual": corrected_tree(targets, rho=.2, corrected=False)[2][-1],
131 "bytes": bc},
132 },
133 "interpretation": "Checks test algebraic correction conservation and rho scaling; optimization comparison is a small scalar quadratic proxy, not a neural-network benchmark."
134 }
135 Path("results.json").write_text(json.dumps(result, indent=2))
136 print(json.dumps(result, indent=2))
137
138if __name__ == "__main__":
139 main()