Defenses (PPFL techniques)¶
Defenses are hook plugins (fltest/defenses/) that subclass PPFLBaseClass. Declare them
in a config:
defenses:
- {name: <defense>, params: {...}}
Three flavors compose through the same hooks:
- Client-side perturbation acts at
after_client_trainon one client's update. - Robust aggregation acts at
before_aggregateby replacing the set of updates the backend will average. - Secure aggregation blinds each update so the server sees only the sum.
secure_aggregationmasks client-side and lets FedAvg cancel the masks;mpc_aggregationsimulates the whole fixed-point protocol atbefore_aggregate.
Catalog¶
| Name | Type | Hook | Key params |
|---|---|---|---|
gradient_noise |
DP-style clip + Gaussian noise | after_client_train |
clip_norm (1.0), sigma (0.01) |
norm_clip |
update-norm clipping | after_client_train |
clip_norm (1.0) |
krum |
robust aggregation (select) | before_aggregate |
num_byzantine (1) |
trimmed_mean |
robust aggregation (coordinate trim) | before_aggregate |
trim (1) |
median |
robust aggregation (coordinate median) | before_aggregate |
— |
secure_aggregation |
pairwise masking, float | after_client_train |
mask_scale (1.0), seed (0) |
mpc_aggregation |
pairwise masking, fixed point | before_aggregate |
quant_bits (16), modulus (2^32), dropout_rate (0.0) |
fldetector |
history-based client filtering | before_round, before_aggregate, after_aggregate |
window_size (10), start_round (50), max_clusters (10), gap_samples (20) |
How each works¶
gradient_noise — clips the client's update delta (relative to the current global
model) to clip_norm, then adds N(0, sigma²). The user-space analogue of DP-SGD's
per-update clipping + noise. Sweep sigma to chart the privacy/utility trade-off (the
project's Pitfall-4).
norm_clip — clips the update delta's L2 norm to clip_norm without noise. Limits the
magnitude a malicious client can inject (mitigates scaled poisoning / sign-flip).
krum — selects the single client update closest to its n − f − 2 nearest neighbours
(the most "agreed upon"), robust to up to f = num_byzantine adversaries.
trimmed_mean — for each coordinate, drops the trim largest and smallest values
across clients, then averages the rest.
median — coordinate-wise median across client updates. Simple and strong against a
Byzantine minority.
Secure aggregation¶
Both variants build pairwise masks in the style of Bonawitz et al. (CCS 2017). Every pair of participants derives one shared pseudo-random vector, the lower-indexed party adds it and the higher-indexed party subtracts it. The masks therefore telescope to zero once every participant's contribution is summed.
Each mask is a pure function of (seed, round, client pair, layer), so both parties derive it
independently. That is what lets the Flower backend use them at all, since its client hooks
run in separate Ray workers.
What a single-process simulation can and cannot show
The masks come from a seed this process derives for both parties. There is no key agreement, no threshold secret sharing, and no dropout recovery, so nothing here demonstrates cryptographic security. What it does reproduce faithfully is the simulated adversary view: what an honest-but-curious server actually receives. Claims of the form "under masking, attack X no longer succeeds" are supported; claims of the form "secure aggregation protects Y" are not.
Neither defense touches integer entries of the state_dict — BatchNorm's
num_batches_tracked, a Hugging Face model's position_ids. A float mask cast back to
int64 truncates, so the halves stop cancelling and the aggregate drifts silently. Those
entries carry counters rather than learned information, so they pass through unmasked.
Both defenses also assume the participant set is fixed for the round — masks cancel only
across exactly the set that produced them. FLTest's backends use full participation, and
secure_aggregation records secagg_participant_mismatch if a round ever aggregates a
different number of updates rather than letting the residue pass silently.
secure_aggregation — float masking, no quantization. The client uploads
x_i + m_i / n_i; FedAvg's sum_i (n_i / N) * x_i then cancels the masks exactly. This is
the variant for privacy comparisons: the mask is in place at before_aggregate, which is
where the dlg attack with source: shared_update reads the uploaded update.
One consequence decides whether a configuration hides anything at all. The on-the-wire mask
has standard deviation mask_scale * sqrt(P - 1) / n_i, so mask_scale is relative to the
client's shard size rather than to the parameter scale. A client with 5000 samples needs a
mask_scale three orders of magnitude above one with 5. Every round records
secagg_mask_to_update_ratio; a ratio near or below 1 means the masking is cosmetic.
Cancellation is exact in real arithmetic, but the wire format is float32, so a large
mask_scale leaves a rounding residue of roughly 1e-8 * mask_scale / 50 per coordinate.
That is the trade-off the parameter buys: stronger blinding, slightly noisier aggregate. For a
variant with no rounding to argue about, use mpc_aggregation.
mpc_aggregation — fixed-point masking in Z_modulus, which is how deployed secure
aggregation actually works. It carries three failure modes a float simulation hides:
| Failure mode | Trigger | What to watch |
|---|---|---|
| Quantization error | too few quant_bits |
mpc_agg_max_abs_error rises above the floor |
| Overflow | modulus too small for sum_i n_i * x_i |
mpc_overflow_rate > 0; wraps silently |
| Masks not cancelling | dropout_rate > 0 |
error jumps by orders of magnitude |
The whole protocol runs at before_aggregate, where the server holds every client's update.
That placement is what lets the defense compute plain FedAvg alongside the protocol's output
and record the exact gap as mpc_agg_max_abs_error — a strict numeric oracle rather than an
accuracy threshold. The cost is that the client-side view is not simulated: an attack reading
ctx.updates_and_weights still sees plaintext, because attacks attach before defenses on the
same hook. Use secure_aggregation for adversary-view experiments and this one for
arithmetic correctness.
dropout_rate drops clients after they have masked. FLTest does not implement the threshold
secret sharing that repairs this, so a run with dropouts produces an aggregate that is
knowingly wrong. The parameter exists to measure that failure mode rather than to survive it.
Checking that the masks actually cancel¶
Masking is either lossless or broken, and an accuracy threshold is too coarse to tell the
difference. The secagg_lossless metamorphic relation is an exact-equality oracle: the
masks are supposed to cancel whatever they are, so changing the mask seed must leave the global
model bit-identical.
testing:
metamorphic:
- {relation: secagg_lossless, parameter: defense.seed, values: [1, 2, 3],
metric: gm_weight_sum, tolerance: 0.0}
Use gm_weight_sum, which fingerprints the model directly — accuracy rounds two different
models to the same number. The pitfall checker flags a secure-aggregation config that has no
such relation (P4_untested_secagg).
Masking and robust aggregation are mutually exclusive
secure_aggregation hides individual updates; krum / trimmed_mean / median need to
compare them client-by-client. A real server cannot do both. This simulation lets it,
because the server holds plaintext either way — so the pitfall checker flags the
combination (P4_secagg_vs_robust) rather than letting a config claim a defense stack
nobody can deploy.
Worked example: secure aggregation vs. gradient inversion¶
examples/configs/secure_agg.yaml runs dlg with source: shared_update, which is an
honest-but-curious server inverting the update it received. Three arms differ only in what
the client uploads:
fltest run examples/configs/secure_agg.yaml
Compare reconstruction_mse across none, gradient_noise, and secure_agg; higher is a
worse reconstruction, i.e. a better defense. Check secagg_mask_to_update_ratio first — if it
is not comfortably above 1, the arm proves nothing and mask_scale needs raising.
Worked example: what the finite ring costs¶
examples/configs/mpc_aggregation.yaml runs the same MNIST job five ways, with one arm per
failure mode. Start with the static check, which reads the parameters and trains nothing.
fltest pitfalls examples/configs/mpc_aggregation.yaml
Three arms are flagged before a single round runs. For example, the small_ring arm draws
MPC ring too small for its precision, which reports that quant_bits=16 with
modulus=4096 can represent only |value| < 0.0312.
fltest run examples/configs/mpc_aggregation.yaml
| run | accuracy | loss | mpc-err |
mpc-ovf |
mpc-drops |
|---|---|---|---|---|---|
mpc_ok |
0.8662 | 0.4633 | 0.0000 | 0.0000 | 0.0000 |
exact (plain FedAvg) |
0.8662 | 0.4630 | - | - | - |
low_precision |
0.8652 | 0.4630 | 0.0000 | 0.0000 | 0.0000 |
small_ring |
0.1064 | 2.3026 | 0.0301 | 1.0000 | 0.0000 |
dropouts |
0.1416 | 2609.69 | 11.1569 | 0.0156 | 2.0000 |
The mpc_ok arm matches plain FedAvg to four decimals, which is what a correct protocol
must produce. The two loud arms fail differently. small_ring wraps every value it
aggregates, so accuracy sits at chance while the aggregate stays finite and plausible.
dropouts leaves two clients' pairwise masks in the sum, and that residue pushes the loss
to 2609.69.
low_precision is the arm that argues for running the static check. Its accuracy of 0.8652
sits within rounding error of the correct 0.8662, so no accuracy threshold would catch it.
The parameter check does, because quant_bits=4 leaves four fractional bits for updates
that need more.
Finally, test the property that no accuracy number can express.
fltest metamorphic examples/configs/mpc_aggregation.yaml
The secagg_lossless relation redraws the masks under seeds 1, 2, and 3 and requires the
aggregate to come out identical. The healthy arm reports spread=0 against a tolerance of
exactly zero.
fldetector — compares each client's model delta with a limited-memory BFGS
prediction from earlier rounds. It scores inconsistencies over window_size rounds and
uses gap statistics and two-cluster k-means to identify the high-score group. Identified
clients are removed from the current aggregation and excluded from later rounds. This is
an online variant: unlike the FLDetector paper,
it does not restart training after detection. It requires unique stable client IDs and
full client participation until detection; custom Flower clients must report their cid.
The default start_round=50 follows the paper's warm-up choice, so shorter experiments
should lower it. At least window_size + 2 rounds are needed to form the history.
To combine detection with a robust rule, place it first:
defenses:
- {name: fldetector, params: {window_size: 10, start_round: 50}}
- {name: median}
fldetector_scores, fldetector_detected_clients, and
fldetector_detected_count are recorded in the detection round's metrics. The detector
fails explicitly if submission IDs are missing or an earlier hook changes the update
list's alignment. It is not available on NVFlare.
Worked example¶
examples/configs/fldetector.yaml gives two of eight clients a sign-flip attack and runs
the same setup three ways:
fltest run examples/configs/fldetector.yaml
| run | final accuracy | per-round accuracy |
|---|---|---|
| undefended | 0.0938 | 0.119 → 0.109 → 0.094 → 0.094 → 0.094 → 0.094 → 0.094 → 0.094 |
median |
0.9023 | 0.763 → 0.856 → 0.870 → 0.878 → 0.881 → 0.889 → 0.900 → 0.902 |
fldetector |
0.8809 | 0.119 → 0.109 → 0.094 → 0.094 → 0.791 → 0.855 → 0.856 → 0.881 |
The traces show what separates the two defenses. Median suppresses the attack from the
first round and never lets the model collapse. FLDetector has no history yet, so the model
collapses to chance and stays there. At round 5 it flags clients [0, 1], which are exactly
the two attackers, excludes them, and recovers.
Pick median when you only need the model to survive. Pick FLDetector when you need to know who attacked, at the cost of the rounds it takes to find out.
Backend support
Client-side and robust-aggregation defenses run on the reference and Flower backends. NVFlare runs clients in separate processes, so it does not apply client-side hooks (it's used for cross-framework parity of vanilla FedAvg).
Worked example: defeating a backdoor¶
examples/configs/defense_robust.yaml — two of six clients run a strong backdoor;
median aggregation rejects the poisoned updates:
| Defense | attack_success_rate | accuracy |
|---|---|---|
| none | 0.80 | 0.90 |
median |
0.03 | 0.90 |
norm_clip (clip_norm 0.5) |
0.67 | 0.86 |
attacks: [{name: backdoor, params: {infection_rate: 0.8, patch_size: 5}, target_clients: [0, 1]}]
defenses: [{name: median}]
metrics: [accuracy, loss, per_client]
Sweep a defense parameter (metamorphic)¶
defenses: [{name: gradient_noise, params: {clip_norm: 1.0, sigma: 0.05}}]
testing:
metamorphic:
- {relation: dp_noise, parameter: defense.sigma, values: [0.0, 0.05, 0.1, 0.2], metric: accuracy}
More noise should not increase accuracy (utility non-increasing). See Metamorphic testing.
To add your own defense, see Port your attacks & defenses.