June 7, 2026
I audited my own results and they didn't survive
The midsem numbers looked great. Then I ran a research audit on my own pipeline and found six bugs holding the results up.
June 7 was two sessions in one day. In the morning I finished the midsem deliverables: a full 50-round simulation, held-out evaluation, five figures, the paper sections filled with real numbers, a slide deck. The headline was clean — Clustered FL beat FedAvg by 13.2 points on held-out facilities. I could have presented it as-is.
Instead I spent the afternoon auditing my own code. Most of that headline did not survive.
The aggregation wasn’t aggregating
_aggregate_xgb() in simulation.py did this:
max(client_results.values(), key=lambda x: x[1])[0]. It was supposed to
combine eight facilities’ models into one. But every facility has exactly
657 training rows, so the “weight” being maxed over was identical across
all of them. max() just returned whichever client Python happened to
iterate last. No averaging. No federation. The “FedAvg” baseline I had
been comparing against was one arbitrary facility’s model.
FedProx was worse in a quieter way. The client stored self.mu — the
proximal term that is the whole point of FedProx — and never once used it
in fit(). FedProx was byte-for-byte identical to FedAvg across all 50
rounds. Presenting it as a third comparison strategy was not a bug so much
as a lie I hadn’t noticed telling.
And the tree budget: warm-start added local_epochs * 10 trees per round,
so the federated models grew to 2,600 trees by round 50 while the local
baseline had 100. Federation was “winning” partly because I had handed it
26x the model capacity.
The data was clinically impossible
Two schema bugs would have made a Long-Term Care clinician reject the
dataset on sight. mds_adl_summary — which in MDS 3.0 is definitionally
the sum of four ADL subscores — was being generated independently by
CTGAN, giving it near-zero correlation with its own components.
nursing_hours_lpn was missing from NON_IID_SPEC entirely, so
Independent Living facilities got ~5.3 LPN hours per resident per day
against a real CMS benchmark near 0.7 — more nursing than Memory Care,
which is backwards.
The DP sweep had its own tell: torch.manual_seed() was called once at
import, so each epsilon run inherited different RNG state, and ε=10 scored
worse than ε=5. Larger privacy budget, worse model — a direct violation
of the theory the whole section rests on.
The number that survived
The fix sprint was mechanical once the audit was honest: real
prediction-consensus aggregation, a fixed 100-tree budget, per-call
seeding, mds_adl_summary recomputed as the ADL sum post-CTGAN, five
features added to NON_IID_SPEC. I regenerated the data and reran
everything. New held-out result: CFL 0.9677 vs FedAvg 0.9057.
Then the hardest disclosure. The IL cluster has exactly one training facility, because facilities 8 and 9 are held out. So CFL’s “federation” in that cluster was never federation — it was zero-shot transfer from a single facility to two unseen ones. That has to go in the paper as a limitation, not stay buried as a flattering result.
Sixteen of seventeen validity checks pass now. The midsem is behind me. The number is smaller and I trust it.
Next: a held-out design that isn’t rigged in CFL’s favor, and real MIMIC-IV instead of a synthetic proxy.