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
Five hypotheses, ordered by what they cost to test A five-row triage table for a diverging federated round. Cohort collapse, gate storms and modulus overflow are all diagnosable from free protocol metadata. Clipping saturation and direction reversal each cost a single differentially private scalar. None requires seeing client data, which is the point: the common causes are all protocol-level. Five hypotheses, ordered by what they cost to test protocol metadata first; data-level explanations last evidence cost usual cause Cohort collapse return rate halved free connectivity / rollout Gate storm >30% rejected free poisoning or infra Clipping saturated >90% clipped one DP scalar bound too tight Direction reversal cosine < −0.2 one DP scalar sign or LR error Modulus overflow norm out of range free quantisation
Work top to bottom. In production incidents the cause is overwhelmingly protocol-level, and every row here is answerable without a single client's data.

Reference Implementation

python
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

python
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()
An incident, as it appears in free metadata Two curves over twelve rounds. The client return rate is stable near ninety-five percent until round six, where it collapses to twenty-eight percent for two rounds before recovering. The cosine similarity between consecutive aggregates tracks it almost exactly, showing that the model's direction became unstable because the cohort did — not because of anything in the data. An incident, as it appears in free metadata a rollout at round 6 collapses the cohort and the aggregate with it 2.5 5 7.5 10 0 50 100 round % / cosine ×100 client return rate cosine with previous aggregate
Both series are protocol metadata and cost nothing. Together they identify the round, the cause and the recovery without any client's data being involved.

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.
Sizing the modulus so a wrapped sum is impossible A bar chart of spare modulus bits above the magnitude the aggregate can reach, for five modulus widths. A 24-bit modulus has a negative margin and will wrap; 32 bits leaves a small margin; 48 bits is comfortable. A wrapped sum does not raise — it decodes to a well-formed but arbitrary gradient. Sizing the modulus so a wrapped sum is impossible 500 clients, unit clipping, fixed-point scale 1,000 0 10 20 30 spare bits 5.1 24-bit 9.1 28-bit 13.1 32-bit 21.1 40-bit 29.1 48-bit
Compute the required magnitude from 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.

Up one level: Federated Evaluation and Monitoring · Section: Federated Learning Workflows for Geospatial Data.