File size: 8,637 Bytes
ea3a71e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | """FedDPO / DecDPO on DistilGPT-2 (82M) with real SHP preference pairs,
non-IID split across N=5 clients by SHP domain (the paper's heterogeneity).
Design notes that make the theorems actually testable:
* Each client's batch sequence is fixed by its own seed, so with FULL
participation (S=N) the whole round is deterministic. Corollary 5.2 predicts
the partial-participation variance term vanishes at S=N -- here that becomes
an EXACT zero-variance check, not a noisy approximation.
* Client sampling uses a separate RNG, so S<N runs differ only in WHICH
clients are sampled -- isolating the 1/S term from data-order noise.
"""
import json, time, copy, itertools
import numpy as np, torch, torch.nn.functional as F
from transformers import AutoTokenizer, AutoModelForCausalLM
from dpo_real import batch_logps, dpo_loss, DEV, MODEL, BETA
N_CLIENTS, E_DEF, R_DEF, BS = 5, 2, 40, 4
RES = {}
def build_clients(n_per_domain=90):
from datasets import load_dataset
ds = load_dataset("stanfordnlp/SHP", split="train", streaming=True)
tok = AutoTokenizer.from_pretrained(MODEL); tok.pad_token = tok.eos_token
doms, want = {}, N_CLIENTS
for r in itertools.islice(ds, 400000):
d = r["domain"]
if d not in doms and len(doms) >= want: continue
b = doms.setdefault(d, [])
if len(b) >= n_per_domain:
if all(len(v) >= n_per_domain for v in doms.values()) and len(doms) >= want: break
continue
a, bb = r["human_ref_A"], r["human_ref_B"]
w, l = (a, bb) if r["labels"] == 1 else (bb, a)
p = "Q: " + r["history"][:400] + "\nA:"
pw = tok(p + " " + w[:400], truncation=True, max_length=128)["input_ids"]
pl = tok(p + " " + l[:400], truncation=True, max_length=128)["input_ids"]
npr = len(tok(p, truncation=True, max_length=128)["input_ids"])
if len(pw) > npr + 2 and len(pl) > npr + 2:
b.append({"w": pw, "l": pl, "np": npr})
names = sorted(doms)[:N_CLIENTS]
return [doms[d] for d in names], names, tok
def flat(m): return torch.cat([p.detach().reshape(-1) for p in m.parameters()])
def setflat(m, v):
i = 0
for p in m.parameters():
n = p.numel(); p.data.copy_(v[i:i+n].view_as(p)); i += n
def local_train(model, ref, data, E, lr, pad, rng):
opt = torch.optim.SGD(model.parameters(), lr=lr)
for _ in range(E):
idx = rng.integers(0, len(data), size=BS)
loss, _ = dpo_loss(model, ref, [data[i] for i in idx], pad)
opt.zero_grad(); loss.backward(); opt.step()
def evaluate(model, ref, clients, pad, nb=3):
tot, acc, k = 0.0, 0.0, 0
with torch.no_grad():
for d in clients:
for j in range(nb):
l, a = dpo_loss(model, ref, d[j*BS:(j+1)*BS], pad)
tot += l.item(); acc += a; k += 1
return tot/k, acc/k
def fed_run(base, ref, clients, tok, S, R=R_DEF, E=E_DEF, lr=2e-5, q_max=0, seed=0):
m = copy.deepcopy(base).to(DEV)
th = flat(m).clone()
samp = np.random.default_rng(10_000 + seed)
crngs = [np.random.default_rng(777 + i) for i in range(len(clients))] # fixed per client
dly = np.random.default_rng(50_000 + seed)
buf = {}
for r in range(R):
sel = samp.choice(len(clients), size=S, replace=False) if S < len(clients) else np.arange(len(clients))
for i in sel:
setflat(m, th)
local_train(m, ref, clients[i], E, lr, tok.pad_token_id, crngs[i])
d = flat(m) - th
k = r + (int(dly.integers(0, q_max+1)) if q_max else 0)
buf.setdefault(k, []).append(d)
ds = buf.pop(r, [])
if ds: th = th + torch.stack(ds).mean(0)
setflat(m, th)
return m, th
def metropolis(adj):
n = adj.shape[0]; deg = adj.sum(1); W = np.zeros((n, n))
for i in range(n):
for j in range(n):
if i != j and adj[i, j]: W[i, j] = 1.0/(1+max(deg[i], deg[j]))
W[i, i] = 1 - W[i].sum()
return W, float(np.sort(np.abs(np.linalg.eigvals(W)))[::-1][1])
def dec_run(base, ref, clients, tok, W, R=R_DEF, E=E_DEF, lr=2e-5):
n = len(clients)
m = copy.deepcopy(base).to(DEV)
TH = torch.stack([flat(m).clone() for _ in range(n)])
crngs = [np.random.default_rng(777+i) for i in range(n)]
Wt = torch.tensor(W, dtype=TH.dtype, device=TH.device)
for r in range(R):
new = []
for i in range(n):
setflat(m, TH[i]); local_train(m, ref, clients[i], E, lr, tok.pad_token_id, crngs[i])
new.append(flat(m).clone())
TH = Wt @ torch.stack(new)
cons = float(torch.norm(TH - TH.mean(0, keepdim=True), dim=1).mean())
setflat(m, TH.mean(0))
return m, cons
def main():
t0 = time.time()
clients, names, tok = build_clients()
print("clients:", [(n, len(d)) for n, d in zip(names, clients)], flush=True)
base = AutoModelForCausalLM.from_pretrained(MODEL)
ref = AutoModelForCausalLM.from_pretrained(MODEL).to(DEV).eval()
for p in ref.parameters(): p.requires_grad_(False)
pad = tok.pad_token_id
l0, a0 = evaluate(base.to(DEV), ref, clients, pad)
RES["init"] = {"loss": l0, "acc": a0, "clients": dict(zip(names, [len(d) for d in clients]))}
print("init loss=%.4f acc=%.3f" % (l0, a0), flush=True)
# ---- Claim 2: participation sweep, variance of the global model ----
rows = []
for S in (1, 2, 3, 5):
ths, ls = [], []
for s in range(3):
m, th = fed_run(base, ref, clients, tok, S, seed=s)
l, a = evaluate(m, ref, clients, pad); ths.append(th); ls.append(l)
print(" S=%d seed=%d loss=%.4f acc=%.3f (%.0fs)" % (S, s, l, a, time.time()-t0), flush=True)
TH = torch.stack(ths)
var = float((TH - TH.mean(0, keepdim=True)).pow(2).sum(1).mean())
fpc = (N_CLIENTS-S)/(S*(N_CLIENTS-1))
rows.append({"S": S, "param_variance": var, "fpc": round(fpc, 5),
"ratio": (var/fpc if fpc > 0 else None),
"loss_mean": float(np.mean(ls)), "loss_sd": float(np.std(ls))})
print(" S=%d Var=%.4e fpc=%.4f loss=%.4f+-%.4f" % (S, var, fpc, np.mean(ls), np.std(ls)), flush=True)
RES["claim2_participation"] = {"rows": rows}
json.dump(RES, open("fed_real_results.json", "w"), indent=1)
# ---- Claim 3: staleness ----
rows = []
for q in (0, 2, 5):
ls = []
for s in range(2):
m, _ = fed_run(base, ref, clients, tok, N_CLIENTS, q_max=q, seed=s)
l, a = evaluate(m, ref, clients, pad); ls.append(l)
rows.append({"q_max": q, "loss_mean": float(np.mean(ls)), "loss_sd": float(np.std(ls))})
print(" q_max=%d loss=%.5f+-%.5f (%.0fs)" % (q, np.mean(ls), np.std(ls), time.time()-t0), flush=True)
RES["claim3_staleness"] = {"rows": rows,
"monotone": all(rows[i+1]["loss_mean"] >= rows[i]["loss_mean"]-1e-9 for i in range(len(rows)-1))}
json.dump(RES, open("fed_real_results.json", "w"), indent=1)
# ---- Claim 5: topology ----
n = N_CLIENTS
tops = {}
ring = np.zeros((n, n), int)
for i in range(n): ring[i, (i+1) % n] = ring[(i+1) % n, i] = 1
tops["ring"] = ring
star = np.zeros((n, n), int); star[0, 1:] = star[1:, 0] = 1; tops["star"] = star
path = np.zeros((n, n), int)
for i in range(n-1): path[i, i+1] = path[i+1, i] = 1
tops["path"] = path
tops["complete"] = np.ones((n, n), int) - np.eye(n, dtype=int)
rows = []
for nm, adj in tops.items():
W, rho = metropolis(adj)
m, cons = dec_run(base, ref, clients, tok, W)
l, a = evaluate(m, ref, clients, pad)
rows.append({"topology": nm, "rho": round(rho, 4),
"one_over_1_minus_rho2": round(1/(1-rho**2), 3),
"consensus_error": cons, "loss": l, "acc": a})
print(" %-9s rho=%.4f cons=%.4e loss=%.4f (%.0fs)" % (nm, rho, cons, l, time.time()-t0), flush=True)
x = np.log([r["one_over_1_minus_rho2"] for r in rows if r["consensus_error"] > 1e-9])
y = np.log([r["consensus_error"] for r in rows if r["consensus_error"] > 1e-9])
if len(x) > 2:
sl, ic = np.polyfit(x, y, 1)
RES["claim5_topology"] = {"rows": rows, "loglog_slope": round(float(sl), 4),
"r2": round(float(1-np.var(y-(sl*x+ic))/np.var(y)), 4),
"rank_corr_positive": bool(np.corrcoef(x, y)[0, 1] > 0)}
else:
RES["claim5_topology"] = {"rows": rows}
RES["wall_clock_s"] = time.time()-t0
json.dump(RES, open("fed_real_results.json", "w"), indent=1)
print("DONE %.0fs" % (time.time()-t0), flush=True)
if __name__ == "__main__":
main()
|