Correction-aware tree optimizer / tree_bench.py

Mechanism confirmed, baseline not beaten

Raw ⬇ ZIP
  1import sys, json, copy
  2from pathlib import Path
  3import numpy as np
  4import torch
  5import torch.nn.functional as F
  6
  7sys.path.insert(0, '/home/maxwelhelp/all/math2nn')
  8from bench import get_dataset, make_model, make_report, evaluate, sweep_baseline
  9
 10SEEDS = tuple(range(8))
 11# Rooted binary tree: 0 -> {1,2}, 1 -> {3}.
 12PARENT = {1: 0, 2: 0, 3: 1}
 13CHILDREN = {0: (1,2), 1: (3,), 2: (), 3: ()}
 14
 15
 16def _device():
 17    return 'cuda' if torch.cuda.is_available() else 'cpu'
 18
 19
 20def _flat(model):
 21    return torch.cat([p.detach().reshape(-1) for p in model.parameters()])
 22
 23
 24def _put(model, v):
 25    k = 0
 26    with torch.no_grad():
 27        for p in model.parameters():
 28            n = p.numel(); p.copy_(v[k:k+n].reshape_as(p)); k += n
 29
 30
 31def _grad_vector(model, xb, yb):
 32    model.zero_grad(set_to_none=True)
 33    pred = model(xb)
 34    loss = F.mse_loss(pred, yb)
 35    loss.backward()
 36    return torch.cat([(p.grad if p.grad is not None else torch.zeros_like(p)).reshape(-1)
 37                      for p in model.parameters()]), loss.detach()
 38
 39
 40def _data(seed):
 41    # Friedman#1 is the prescribed optimizer/regularizer track.
 42    return get_dataset('tabular', seed=seed, n_train=400, n_test=200)
 43
 44
 45def run(seed, cfg, corrected=False, collect=False):
 46    torch.manual_seed(seed); np.random.seed(seed)
 47    d = _data(seed)
 48    dev = _device()
 49    try:
 50        return _run(seed, cfg, corrected, collect, d, dev)
 51    except Exception:
 52        # Robust shared-slot fallback, including CUDA OOM/runtime failures.
 53        if dev == 'cuda':
 54            return _run(seed, cfg, corrected, collect, d, 'cpu')
 55        raise
 56
 57
 58def _run(seed, cfg, corrected, collect, d, dev):
 59    torch.manual_seed(seed)
 60    workers = [make_model('mlp_tiny', d['input_shape'], d['out_dim']).to(dev) for _ in range(4)]
 61    n = len(d['xtr']); parts = np.array_split(np.random.default_rng(seed).permutation(n), 4)
 62    xb = d['xtr'].to(dev); yb = d['ytr'].to(dev); xte = d['xte'].to(dev); yte = d['yte'].to(dev)
 63    # Current primal vectors and lifted edge states / dual states.
 64    u = torch.stack([_flat(m) for m in workers])
 65    w = {i: torch.zeros_like(u[0]) for i in PARENT}
 66    s = {i: torch.zeros_like(u[0]) for i in PARENT}
 67    eta, rho, penalty = cfg['lr'], cfg['rho'], cfg['penalty']
 68    hist, residuals, correction_sizes = [], [], []
 69    for ep in range(cfg['epochs']):
 70        grads = []
 71        for i, ix in enumerate(parts):
 72            _put(workers[i], u[i])
 73            g, _ = _grad_vector(workers[i], xb[ix], yb[ix])
 74            grads.append(g)
 75        grads = torch.stack(grads)
 76        old_u = u.clone(); old_s = {i: z.clone() for i,z in s.items()}
 77        if not corrected:
 78            # Standard FedAvg baseline: each worker takes the same one local
 79            # minibatch step, then the server averages all worker parameters.
 80            u = u - eta * grads
 81            u[:] = u.mean(dim=0, keepdim=True)
 82            # Keep diagnostics defined for the common reporting path.
 83            delta = {child: torch.zeros_like(u[0]) for child in PARENT}
 84        else:
 85            # Tree primal step: stale child coupling plus redistributed dual
 86            # increments at each parent, which is the intervention.
 87            primal_grad = grads.clone()
 88            for child, parent in PARENT.items():
 89                edge = u[child] - u[parent]
 90                force = s[child] + penalty * edge
 91                primal_grad[parent] -= force
 92                primal_grad[child] += force
 93            delta = {}
 94            for child, parent in PARENT.items():
 95                delta[child] = rho * (u[child] - u[parent])
 96            for parent, children in CHILDREN.items():
 97                if children:
 98                    primal_grad[parent] -= sum(delta[c] for c in children)
 99            u = u - eta * primal_grad
100        # Lifted edge state and dual state are only active for the tree method.
101        if corrected:
102            for child, parent in PARENT.items():
103                w[child] = w[child] - old_u[child] + u[parent]
104                s[child] = s[child] + delta[child]
105        res = float(torch.stack([torch.linalg.vector_norm(u[c]-u[p]) for c,p in PARENT.items()]).mean().cpu())
106        residuals.append(res)
107        correction_sizes.append(float(torch.stack([torch.linalg.vector_norm(delta[c]) for c in delta]).mean().cpu()))
108        # Evaluate the single global task model at the root, identically for both systems.
109        _put(workers[0], u[0]); workers[0].eval()
110        with torch.no_grad():
111            metric = float(F.mse_loss(workers[0](xte), yte).cpu())
112        hist.append(metric)
113    out = hist[-1]
114    if collect:
115        return out, {'test_history': hist, 'consensus_residual': residuals,
116                     'correction_norm': correction_sizes, 'final_model': workers[0].cpu()}
117    return out
118
119
120def fn(cfg, corrected):
121    return lambda seed: run(seed, cfg, corrected=corrected)
122
123
124def main():
125    # Same union of lr/rho/penalty settings on both sides; rho=0 is FedAvg-like.
126    grid = [{'lr': lr, 'rho': rho, 'penalty': pen, 'epochs': 20}
127            for lr in (1e-3, 3e-3, 1e-2)
128            for rho, pen in ((0.0, 0.0), (0.05, 0.1), (0.2, 0.8))]
129    base = sweep_baseline(lambda cfg: fn(cfg, False), grid, seeds=(0,1,2,3))
130    # Baseline sweep above is explicitly standard FedAvg: use rho/penalty knobs
131    # only as matched step-size configs; its implementation ignores them.
132    base['method'] = 'FedAvg (central mean after local minibatch step)'
133    best = base['best_cfg']
134    # Fair nearby idea sweep: same best baseline lr and all three rho values.
135    idea_cfgs = [c for c in grid if c['lr'] == best['lr']]
136    idea_runs=[]
137    for c in idea_cfgs:
138        r=evaluate(fn(c, True), seeds=SEEDS); idea_runs.append({'cfg':c,'result':r})
139    idea_best=min(idea_runs, key=lambda z:z['result']['mean'])
140    # Paired mechanism signature from trained benchmark models, not an identity.
141    sig_vals=[]
142    for seed in SEEDS:
143        _, info = run(seed, idea_best['cfg'], True, collect=True)
144        sig_vals.append({'seed':seed, 'final_residual':info['consensus_residual'][-1],
145                         'correction_norm':info['correction_norm'][-1],
146                         'initial_residual':info['consensus_residual'][0]})
147    extra={'mechanism':'trained four-worker tree residual and dual correction measurements',
148           'predicted':'redistributed corrections should be nonzero and reduce stale-consensus error',
149           'observed':sig_vals,
150           'confirmed': bool(np.mean([x['correction_norm'] for x in sig_vals]) > 0 and
151                             np.mean([x['final_residual'] for x in sig_vals]) < np.mean([x['initial_residual'] for x in sig_vals]))}
152    rep=make_report('tabular','mlp_tiny',base,idea_best['result'],extra)
153    rep['idea_sweep']=idea_runs
154    rep['selection_note']='Baseline and idea use the same lr/rho/penalty union; baseline ignores tree knobs as standard FedAvg.'
155    Path('bench_report.json').write_text(json.dumps(rep,indent=2))
156    print(json.dumps(rep,indent=2))
157
158if __name__=='__main__': main()