Staleness-Aware Weighting for Late Updates

In an asynchronous federated deployment an update arrives computed against a model version that no longer exists. Discarding it wastes the client’s battery and, worse, systematically discards the slowest regions — which in a geospatial fleet means the rural ones. Accepting it unweighted lets a gradient computed twenty rounds ago pull the model backwards. The weighting function between those two failures is the whole design, and it is a smaller and more consequential piece of code than most teams expect. This page builds it, under async execution patterns in Federated Learning Workflows for Geospatial Data.

Parameter Configuration and Calibration

  • half_life — rounds until an update’s weight halves. The single most important knob. Derive it from the observed lag distribution rather than choosing a round number: a half-life at the median lag means the median client contributes at half weight, which is usually too aggressive. Anchoring at the 75th percentile of the lag distribution is a defensible default.
  • max_staleness — the hard gate. Beyond it the update is dropped regardless of weight. Set it where the weight has fallen below the point of usefulness — typically three half-lives, at 12.5% — so the gate is a consequence of the decay rather than an independent parameter.
  • weight_floor — the minimum weight a surviving update receives. Without a floor, the slowest clients contribute epsilon-weight updates that cost as much bandwidth and battery as full ones. With a floor, slow regions retain a real voice at the cost of some staleness tolerance.
  • quality_terms — what multiplies the staleness factor. Sample count, GNSS quality, and the cosine alignment against the current aggregate. These multiply rather than add, so a fresh but low-quality update and a stale but excellent one can end up comparable — which is the intended behaviour.
Lag (rounds) Weight at half-life 5 Weight at half-life 2 Consequence
0 1.00 1.00 fresh, full weight
3 0.66 0.35 typical urban late arrival
8 0.33 0.06 rural node on a slow link
15 0.12 0.005 at or past the gate
Effective participation under four half-lives A bar chart of effective participation for four staleness half-lives, computed against a measured lag distribution. A two-round half-life realises only about half the cohort's statistical power, because most late arrivals are heavily discounted. A twenty-round half-life realises nearly ninety percent, at the cost of admitting much staler gradients. Effective participation under four half-lives weighted by the fleet's measured lag distribution 0 50 100 % of the cohort's statistical power 59 half-life 2 76 half-life 5 86 half-life 10 92 half-life 20
A round advertising 800 participants at 50% effective participation has the power of 400. Every variance estimate that uses the headline number is wrong.

Reference Implementation

python
from __future__ import annotations

import math
from dataclasses import dataclass
from typing import Sequence


@dataclass(frozen=True)
class Update:
    """One client update as it arrives at the aggregator."""

    client_id: str
    generated_at_round: int
    samples: int
    gnss_pdop: float
    cosine_to_aggregate: float
    l2_norm: float


@dataclass(frozen=True)
class WeightingPolicy:
    half_life: float = 5.0
    max_staleness: int = 15
    weight_floor: float = 0.05
    cosine_floor: float = -0.1
    clip_norm: float = 1.0

    def staleness_factor(self, lag: int) -> float:
        """Exponential decay in the round lag."""
        return 0.5 ** (lag / self.half_life)

    def quality_factor(self, update: Update) -> float:
        """Sample count and GNSS geometry, both bounded so neither dominates."""
        sample_term = min(1.0, math.log1p(update.samples) / math.log1p(500.0))
        pdop_term = min(1.0, 1.5 / max(1.0, update.gnss_pdop))
        return sample_term * pdop_term

    def weight(self, update: Update, current_round: int) -> float:
        """Final aggregation weight, or 0.0 if the update must be rejected.

        Rejection has two independent causes, and they are worth distinguishing in
        metrics: staleness rejection is an infrastructure signal, cosine rejection
        is a poisoning signal, and conflating them hides both.
        """
        lag = current_round - update.generated_at_round
        if lag < 0:
            raise ValueError("an update cannot be newer than the current round")
        if lag > self.max_staleness:
            return 0.0
        if update.cosine_to_aggregate < self.cosine_floor:
            return 0.0
        raw = self.staleness_factor(lag) * self.quality_factor(update)
        return max(self.weight_floor, raw)


def aggregate(
    updates: Sequence[Update], policy: WeightingPolicy, current_round: int
) -> tuple[float, dict[str, int]]:
    """Weighted mean of update norms, plus a rejection breakdown by cause.

    Returning the breakdown alongside the aggregate is deliberate: a change in the
    ratio between the two rejection causes is the earliest available signal that
    something has changed in the fleet.
    """
    total_w = 0.0
    total = 0.0
    reasons = {"stale": 0, "cosine": 0, "accepted": 0}
    for u in updates:
        lag = current_round - u.generated_at_round
        if lag > policy.max_staleness:
            reasons["stale"] += 1
            continue
        if u.cosine_to_aggregate < policy.cosine_floor:
            reasons["cosine"] += 1
            continue
        w = policy.weight(u, current_round)
        clipped = min(u.l2_norm, policy.clip_norm)
        total += w * clipped
        total_w += w
        reasons["accepted"] += 1
    return (total / total_w if total_w else 0.0), reasons


def effective_participation(
    updates: Sequence[Update], policy: WeightingPolicy, current_round: int
) -> float:
    """Sum of weights divided by the client count — how much of the fleet counted.

    A round with 800 returning clients and an effective participation of 0.2 is a
    round with the statistical power of 160 clients, and the variance of the
    aggregate should be read against that number rather than the headline one.
    """
    if not updates:
        return 0.0
    return sum(policy.weight(u, current_round) for u in updates) / len(updates)

Validation Checkpoint

python
def _validate() -> None:
    policy = WeightingPolicy(half_life=5.0, max_staleness=15)

    fresh = Update("u1", 100, samples=400, gnss_pdop=1.2,
                   cosine_to_aggregate=0.8, l2_norm=0.9)
    stale = Update("u2", 92, samples=400, gnss_pdop=1.2,
                   cosine_to_aggregate=0.8, l2_norm=0.9)
    ancient = Update("u3", 80, samples=400, gnss_pdop=1.2,
                     cosine_to_aggregate=0.8, l2_norm=0.9)
    hostile = Update("u4", 100, samples=400, gnss_pdop=1.2,
                     cosine_to_aggregate=-0.7, l2_norm=0.9)

    # 1. Weight must decay monotonically with lag.
    assert policy.weight(fresh, 100) > policy.weight(stale, 100)

    # 2. The half-life must be exactly that: half the weight after `half_life` rounds.
    five_late = Update("u5", 95, 400, 1.2, 0.8, 0.9)
    assert abs(policy.weight(five_late, 100) / policy.weight(fresh, 100) - 0.5) < 1e-9

    # 3. Beyond max_staleness the update is dropped, not merely down-weighted.
    assert policy.weight(ancient, 100) == 0.0

    # 4. An anti-aligned update is rejected regardless of freshness.
    assert policy.weight(hostile, 100) == 0.0

    # 5. The rejection breakdown must separate the two causes.
    _, reasons = aggregate([fresh, stale, ancient, hostile], policy, 100)
    assert reasons == {"stale": 1, "cosine": 1, "accepted": 2}, reasons

    # 6. Effective participation must fall when the fleet is mostly stale.
    slow_fleet = [Update(f"s{i}", 88, 400, 1.2, 0.8, 0.9) for i in range(20)]
    fast_fleet = [Update(f"f{i}", 100, 400, 1.2, 0.8, 0.9) for i in range(20)]
    assert effective_participation(slow_fleet, policy, 100) < 0.4
    assert effective_participation(fast_fleet, policy, 100) > 0.9

    # 7. The floor must keep slow-but-valid clients contributing something.
    barely = Update("u6", 87, 400, 1.2, 0.8, 0.9)
    assert policy.weight(barely, 100) >= policy.weight_floor

    print("staleness weighting: all assertions passed")


_validate()
The half-life is a regional inclusion policy A grouped bar chart of the aggregation weight each region's median update receives under two half-lives. With a two-round half-life the remote region's median update counts for about four percent, effectively excluding it. With an eight-round half-life it counts for about forty-six percent, which is the difference between a national model and an urban one. The half-life is a regional inclusion policy median arrival lag per region 0 50 100 % weight at the region's median lag 100 100 urban 71 92 suburban 50 84 exurban 18 65 rural 4 46 remote half-life 2 rounds half-life 8 rounds
Choosing the half-life without looking at the regional lag distribution silently decides which regions are in the model.

Assertion 6 is the metric worth putting on a dashboard. A round advertising 800 participants with an effective participation of 0.2 has the statistical power of 160, and every variance estimate that uses the headline number is wrong by a factor of two.

Incident Response and Edge Cases

  • Rural regions have vanished from the aggregate. Effective participation for those regions has collapsed. Raise the half-life, raise the floor, or move those clients to a semi-synchronous schedule with a longer deadline — and measure regional participation, not just the global figure.
  • A client’s update is newer than the current round. Clock or bookkeeping error; the code raises rather than accepting it, because a negative lag silently becomes a weight above one.
  • The cosine rejection rate spikes. Either a poisoning attempt or a genuine distribution shift that has made honest updates look anti-aligned. Check whether the rejections cluster geographically: an attacker’s clients rarely respect regional boundaries, and a real shift almost always does.
  • Convergence is slow despite high participation. Weights may be nearly uniform because the half-life is too long, so ancient updates are pulling the model backwards. Shorten it and watch the effective participation — the aim is a distribution of weights, not a single value.
  • The floor is doing all the work. If most surviving updates sit exactly at the floor, the decay has already made them irrelevant and the floor is re-admitting them at an arbitrary weight. Either lower the floor and accept losing them, or shorten max_staleness so the decision is explicit.
Two rejection causes with two different diagnoses Two series of rejected updates per round. Staleness rejections spike at rounds five and six, the signature of a connectivity incident. Cosine rejections spike separately at rounds eight and nine, the signature of anti-aligned updates. Conflating the two into a single rejection count would hide both. Two rejection causes with two different diagnoses updates refused per round, by gate 2 4 6 8 10 0 20 40 60 round updates rejected staleness rejections cosine rejections
Separate the counters. A staleness spike is an infrastructure page; a cosine spike is a security one, and they need opposite responses.

Frequently Asked Questions

Why exponential decay rather than a linear ramp?

Because gradient relevance decays multiplicatively as the model moves: each round of drift invalidates a fraction of what the gradient encoded, not a fixed amount. Exponential decay also gives a single interpretable parameter — the half-life — that can be read off the lag distribution.

Should the staleness gate be a hard drop or an ever-shrinking weight?

A hard drop, placed where the weight has already become negligible. A pure decay with no gate keeps the aggregator carrying updates that cannot move the model, which costs memory and makes the accounting of who contributed harder to state.

Does down-weighting a client reduce its privacy cost?

No. The client computed and transmitted an update, so it spent its budget regardless of the weight the server later assigned. This is an argument for gating early on the client side — a device that knows it is past the staleness bound should not compute the update at all.

How do quality terms interact with the staleness factor?

Multiplicatively, so a stale-but-excellent update and a fresh-but-poor one can land at comparable weights. That is intended: both signals are about how much you should trust the update, and neither should be able to veto the other on its own.

Up one level: Async Execution Patterns · Section: Federated Learning Workflows for Geospatial Data.