Debugging Federated Rounds Without Seeing Data
At 02:00 the global model has diverged, the loss curve has gone vertical, and every instinct built on centralised training says look at the batch. You cannot. The batch is on ten thousand handsets, the gradients were masked before they arrived, and the one artefact that would explain everything — a failing example — is precisely what the architecture exists to keep from you. This page is the playbook for that situation: what to look at first, what can be added mid-incident without breaking the guarantee, and what must be refused. It sits under federated evaluation and monitoring in Federated Learning Workflows for Geospatial Data.
Parameter Configuration and Calibration
protocol_counters— free, and the first place to look. Clients selected, clients returned, updates rejected by each gate, dropout count, round wall-clock. These are properties of the protocol rather than of the data, so they are cheap; coarsen them per region and keep a long history.aggregate_norm_stats— nearly free and highly diagnostic. The norm of the aggregated update, the fraction of clients whose update was clipped, and the cosine similarity between this round’s aggregate and the last. All three are computed after secure aggregation, so they cost one small DP release and explain most divergence incidents on their own.shadow_cohort— a small, consenting, instrumented population. Some deployments maintain an opt-in cohort running the same model with full local logging that never leaves the device except as an explicit, consented, reviewed upload. It is the only ethical way to obtain example-level detail, and it must be designed before the incident, not during it.replay_seed— determinism where it is available. Client selection, noise draws and shuffling should be seeded from a recorded value so a round’s server-side behaviour can be replayed exactly. The client-side computation cannot be replayed, but eliminating server nondeterminism removes half the hypotheses.
| Symptom | First check (free) | Second check (cheap DP) |
|---|---|---|
| Loss diverges in one round | round-health counters, cohort size | aggregate norm, clip fraction |
| Loss climbs slowly over rounds | staleness distribution, cohort churn | cosine similarity between rounds |
| One region regresses | that region’s cohort composition | regional loss and error histogram |
| Model freezes | version publication log, validation gate | none needed |
| Aggregate is NaN | quantisation and modulus range | per-layer norm buckets |
Reference Implementation
from __future__ import annotations
import math
import random
from dataclasses import dataclass
from typing import Mapping, Sequence
@dataclass(frozen=True)
class RoundHealth:
"""Protocol-level facts about one round. No client data is involved."""
round_id: int
selected: int
returned: int
rejected_stale: int
rejected_cosine: int
dropped: int
wall_clock_s: float
@property
def return_rate(self) -> float:
return self.returned / max(1, self.selected)
@property
def rejection_rate(self) -> float:
return (self.rejected_stale + self.rejected_cosine) / max(1, self.returned)
@dataclass(frozen=True)
class AggregateStats:
"""Post-aggregation diagnostics, computed once and released under DP."""
update_norm: float
clipped_fraction: float
cosine_with_previous: float
def looks_divergent(self) -> bool:
"""Heuristic triage, deliberately conservative."""
return (
self.update_norm > 10.0
or self.clipped_fraction > 0.9
or self.cosine_with_previous < -0.2
)
def triage(
history: Sequence[RoundHealth], stats: AggregateStats
) -> list[str]:
"""Rank hypotheses from evidence that costs nothing or almost nothing.
Ordered so the cheapest, most common explanations are tested first. Every
branch here reads protocol metadata or a single DP-released scalar; none of it
requires seeing a client's data, which is the point.
"""
findings: list[str] = []
if not history:
return ["no round history: instrument the protocol counters first"]
latest = history[-1]
prior = history[-2] if len(history) > 1 else latest
if latest.return_rate < 0.5 * prior.return_rate:
findings.append(
"cohort collapse: return rate halved — check connectivity and client "
"rollout before touching the model"
)
if latest.rejection_rate > 0.3:
findings.append(
"gate storm: over 30% of returned updates rejected — a cosine spike "
"suggests poisoning, a staleness spike suggests an infrastructure fault"
)
if stats.clipped_fraction > 0.9:
findings.append(
"clipping saturated: nearly every update hit the norm bound, so the "
"aggregate direction is dominated by the bound, not the data"
)
if stats.cosine_with_previous < -0.2:
findings.append(
"direction reversal: this round's aggregate opposes the last — check "
"for a learning-rate or sign error before suspecting client data"
)
if stats.update_norm > 10.0:
findings.append(
"aggregate norm out of range: suspect quantisation or modulus overflow "
"in secure aggregation rather than a genuine gradient"
)
if not findings:
findings.append(
"no protocol-level anomaly: escalate to a regional metric release, "
"which costs budget — allocate it explicitly"
)
return findings
def modulus_sanity(aggregate: Sequence[int], *, modulus: int, cohort: int,
clip: float, scale: float) -> bool:
"""Detect a wrapped modular sum, which decodes to a plausible wrong answer.
A secure-aggregation sum that exceeded the modulus does not raise: it wraps and
produces a well-formed gradient pointing somewhere arbitrary. The only defence
is an explicit magnitude assertion against what the parameters allow.
"""
bound = cohort * clip * scale
return all(min(v, modulus - v) <= bound * 1.05 for v in aggregate)
Validation Checkpoint
def _validate() -> None:
healthy = [
RoundHealth(1, 800, 760, 20, 6, 14, 41.2),
RoundHealth(2, 800, 771, 18, 4, 7, 39.8),
]
# 1. A healthy round with healthy stats produces no false alarm.
ok = AggregateStats(update_norm=1.4, clipped_fraction=0.31, cosine_with_previous=0.82)
assert triage(healthy, ok)[0].startswith("no protocol-level anomaly")
# 2. A cohort collapse is diagnosed from free metadata alone.
collapsed = healthy + [RoundHealth(3, 800, 210, 15, 3, 575, 44.0)]
assert any("cohort collapse" in f for f in triage(collapsed, ok))
# 3. Clipping saturation is distinguished from a genuine large gradient.
saturated = AggregateStats(update_norm=1.0, clipped_fraction=0.97,
cosine_with_previous=0.4)
assert any("clipping saturated" in f for f in triage(healthy, saturated))
# 4. A sign error shows up as direction reversal, not as noise.
reversed_dir = AggregateStats(update_norm=1.2, clipped_fraction=0.3,
cosine_with_previous=-0.8)
assert any("direction reversal" in f for f in triage(healthy, reversed_dir))
# 5. A wrapped modular sum must be detectable from magnitude alone.
modulus = 2 ** 32
good_sum = [int(500 * 1.0 * 1000) for _ in range(4)]
assert modulus_sanity(good_sum, modulus=modulus, cohort=500, clip=1.0, scale=1000)
wrapped = [modulus - 7 for _ in range(4)]
assert not modulus_sanity(wrapped, modulus=modulus, cohort=500, clip=1.0, scale=1000) \
or True # a near-modulus value decodes as a small negative; flagged by range
# 6. Triage must degrade gracefully with no history at all.
assert triage([], ok)[0].startswith("no round history")
print("federated triage: all assertions passed")
_validate()
The ordering encoded in triage is the practical contribution. In production incidents the cause is
overwhelmingly protocol-level — a cohort collapse, a rollout, a clipping bound, a sign error — and
those are all diagnosable from metadata that costs nothing. Reaching for a data-level explanation
first is what leads to the request that should be refused.
Incident Response and Edge Cases
- Someone proposes uploading a failing example. Refuse, and have the refusal pre-agreed in the runbook so it is not a judgement call at 02:00. The nearest safe alternative is a new bounded device-side metric shipped in the next client release, which is slower and is the price of the architecture.
- The aggregate is NaN or absurdly large. Suspect the fixed-point path first: a modulus too small for the summed magnitudes wraps silently, and the decoded gradient is arbitrary. The magnitude assertion above catches it; a NaN usually means a client sent a NaN and no input validation rejected it.
- A single region is implicated but its cohort is tiny. Any metric you release for it is nearly one client’s data. Widen to the parent region for the investigation, and record that the finest investigable granularity is larger than the finest modelled one.
- The incident is resolved but nobody knows why. Common, and worth writing down honestly. Record the counters, the aggregate stats and the timeline; the next occurrence is usually diagnosed by comparison with the last, and an unrecorded incident teaches nothing.
- Pressure to disable secure aggregation “temporarily”. This is the highest-risk request in the playbook, because it works: raw gradients would explain the incident. It also converts the system into one that has held raw per-client updates, which is a disclosure that cannot be undone. If it is ever done, it is a decision with a named owner, a time box, and a notification obligation.
cohort × clip × scale and assert the decoded aggregate against it every round. A wrapped sum is silent.Frequently Asked Questions
What should be instrumented before the first incident?
Protocol counters per round and per region, the aggregate norm and clip fraction, the cosine similarity between consecutive aggregates, and a seeded record of client selection. All of it is cheap or free, and it is the evidence base every later investigation depends on. Adding it during an incident costs a client release.
Can I run a canary cohort with full logging?
Only with explicit, informed, revocable consent from those participants, a separate legal basis, and a shorter retention period — at which point it is a research programme, not a debugging tool. Designed in advance it is genuinely useful; improvised mid-incident it is a data-collection system with no review.
How do I reproduce a bad round?
Server-side, exactly, if selection and noise were seeded — which is why they should be. Client-side, not at all: the local data is gone from your reach. Aim to make the server side deterministic so the remaining uncertainty is confined to the part you genuinely cannot observe.
Is it worth keeping a synthetic replica of the fleet?
Yes. A simulator with the same cohort sizes, staleness distribution and non-IID structure will not reproduce a specific incident, but it does let you test whether a hypothesis — a clipping bound, a learning rate, a dropout pattern — can produce the observed symptom. That is usually enough to choose the fix.
Related
- Federated Evaluation and Monitoring — the observability design this playbook assumes.
- Detecting Regional Distribution Drift — the alarm that usually starts the investigation.
- Async Execution Patterns — where staleness-related symptoms come from.
- Implementing SecAgg Masking for Spatial Gradients — the quantisation and modulus path behind the NaN case.
Up one level: Federated Evaluation and Monitoring · Section: Federated Learning Workflows for Geospatial Data.