Skip to content

Commit 3c5ddba

Browse files
Add new percentile aggregator and tests for comparing it with Byzantine (#1052)
* Add new percentile aggregator and tests for comparing it with Byzantine * Fix Byzantine (paper-like) and compare the usecases with percentile * Update discojs/src/aggregator/percentile_clipping.ts Co-authored-by: Julien Vignoud <33122365+JulienVig@users.noreply.github.com> * Update discojs/src/aggregator/byzantine.ts Co-authored-by: Julien Vignoud <33122365+JulienVig@users.noreply.github.com> * formatting --------- Co-authored-by: Julien Vignoud <33122365+JulienVig@users.noreply.github.com>
1 parent 7f47b34 commit 3c5ddba

7 files changed

Lines changed: 1016 additions & 38 deletions

File tree

discojs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@xenova/transformers": "2.17.2",
2727
"chalk": "4.1.2",
2828
"debug": "catalog:",
29+
"fast-check": "3.23.2",
2930
"immutable": "catalog:",
3031
"isomorphic-ws": "5.0.0",
3132
"simple-peer": "9.11.1",

discojs/src/aggregator/byzantine.spec.ts

Lines changed: 250 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Set } from "immutable";
22
import { describe, expect, it } from "vitest";
3+
import fc from "fast-check";
34

45
import { WeightsContainer } from "../index.js";
56
import { ByzantineRobustAggregator } from "./byzantine.js";
@@ -39,8 +40,8 @@ describe("ByzantineRobustAggregator", () => {
3940
expect(arr).to.deep.equal([[2], [3]]);
4041
});
4142

42-
it("clips a single outlier with small radius", async () => {
43-
const agg = new ByzantineRobustAggregator(0, 3, "absolute", 1.0, 1, 0);
43+
it("reduces influence of a single outlier", async () => {
44+
const agg = new ByzantineRobustAggregator(0, 3, "absolute", 1.0, 10, 0);
4445
const [c1, c2, bad] = ["c1", "c2", "bad"];
4546
agg.setNodes(Set.of(c1, c2, bad));
4647

@@ -51,43 +52,67 @@ describe("ByzantineRobustAggregator", () => {
5152

5253
const out = await p;
5354
const arr = await WSIntoArrays(out);
54-
expect(arr[0][0]).to.be.closeTo(1, 1e-6);
55+
56+
const result = arr[0][0];
57+
const mean = (1 + 1 + 100) / 3;
58+
59+
expect(Math.abs(result - 1)).to.be.lessThan(Math.abs(mean - 1));
5560
});
5661

57-
it("applies multiple clipping iterations (maxIterations > 1)", async () => {
58-
const agg = new ByzantineRobustAggregator(0, 2, "absolute", 1.0, 3, 0);
59-
const [c1, bad] = ["c1", "bad"];
60-
agg.setNodes(Set.of(c1, bad));
62+
it("multiple iterations improve the estimate", async () => {
63+
const [c1, c2, bad] = ["c1", "c2", "bad"];
6164

62-
const p = agg.getPromiseForAggregation();
63-
agg.add(c1, WeightsContainer.of([0]), 0);
64-
agg.add(bad, WeightsContainer.of([10]), 0);
65+
const agg1 = new ByzantineRobustAggregator(0, 3, "absolute", 1.0, 1, 0);
66+
agg1.setNodes(Set.of(c1, c2, bad));
6567

66-
const out = await p;
67-
const arr = await WSIntoArrays(out);
68-
expect(arr[0][0]).to.be.lessThan(1); // clipped closer to 0
68+
const p1 = agg1.getPromiseForAggregation();
69+
agg1.add(c1, WeightsContainer.of([0]), 0);
70+
agg1.add(c2, WeightsContainer.of([0]), 0);
71+
agg1.add(bad, WeightsContainer.of([10]), 0);
72+
const out1 = await p1;
73+
const arr1 = await WSIntoArrays(out1);
74+
75+
const agg3 = new ByzantineRobustAggregator(0, 3, "absolute", 1.0, 3, 0);
76+
agg3.setNodes(Set.of(c1, c2, bad));
77+
78+
const p3 = agg3.getPromiseForAggregation();
79+
agg3.add(c1, WeightsContainer.of([0]), 0);
80+
agg3.add(c2, WeightsContainer.of([0]), 0);
81+
agg3.add(bad, WeightsContainer.of([10]), 0);
82+
const out3 = await p3;
83+
const arr3 = await WSIntoArrays(out3);
84+
85+
const honest = 0;
86+
87+
expect(Math.abs(arr3[0][0] - honest)).to.be.lessThanOrEqual(
88+
Math.abs(arr1[0][0] - honest),
89+
);
6990
});
7091

7192
it("uses momentum when beta > 0", async () => {
7293
const agg = new ByzantineRobustAggregator(0, 2, "absolute", 1e6, 1, 0.5);
7394
const [c1, c2] = ["c1", "c2"];
7495
agg.setNodes(Set.of(c1, c2));
7596

97+
// Round 1
7698
const p1 = agg.getPromiseForAggregation();
7799
agg.add(c1, WeightsContainer.of([2]), 0);
78100
agg.add(c2, WeightsContainer.of([2]), 0);
79101
const out1 = await p1;
80102
const arr1 = await WSIntoArrays(out1);
81-
expect(arr1[0][0]).to.equal(2);
82103

104+
// m₀ = (1 - β) * g = 1
105+
expect(arr1[0][0]).to.be.closeTo(1, 1e-6);
106+
107+
// Round 2
83108
const p2 = agg.getPromiseForAggregation();
84109
agg.add(c1, WeightsContainer.of([4]), 1);
85110
agg.add(c2, WeightsContainer.of([4]), 1);
86111
const out2 = await p2;
87112
const arr2 = await WSIntoArrays(out2);
88113

89-
// With momentum = 0.5, result = 0.5 * prev + 0.5 * current = 3.0
90-
expect(arr2[0][0]).to.be.closeTo(3, 1e-6);
114+
// m₁ = 0.5*4 + 0.5*1 = 2.5 → avg = 2.5
115+
expect(arr2[0][0]).to.be.closeTo(2.5, 1e-6);
91116
});
92117

93118
it("respects roundCutoff — ignores old contributions", async () => {
@@ -108,4 +133,213 @@ describe("ByzantineRobustAggregator", () => {
108133
const arr2 = await WSIntoArrays(out2);
109134
expect(arr2[0][0]).to.equal(20);
110135
});
136+
137+
it("remains robust with 30% Byzantine clients", async () => {
138+
const honest = Array(7).fill(1);
139+
const byzantine = Array(3).fill(100);
140+
141+
const agg = new ByzantineRobustAggregator(0, 10, "absolute", 1.0, 5, 0);
142+
const ids = [...honest, ...byzantine].map((_, i) => `c${i}`);
143+
agg.setNodes(Set(ids));
144+
145+
const p = agg.getPromiseForAggregation();
146+
honest.forEach((v, i) => agg.add(`c${i}`, WeightsContainer.of([v]), 0));
147+
byzantine.forEach((v, i) =>
148+
agg.add(`c${i + honest.length}`, WeightsContainer.of([v]), 0),
149+
);
150+
151+
const out = await p;
152+
const arr = await WSIntoArrays(out);
153+
154+
const honestMean = honest.reduce((a, b) => a + b, 0) / honest.length;
155+
const rawMean =
156+
[...honest, ...byzantine].reduce((a, b) => a + b, 0) /
157+
(honest.length + byzantine.length);
158+
159+
expect(Math.abs(arr[0][0] - honestMean)).to.be.lessThan(
160+
Math.abs(rawMean - honestMean),
161+
);
162+
});
163+
164+
it("moves closer to the honest signal under constant input", async () => {
165+
const honest = 1;
166+
167+
const agg = new ByzantineRobustAggregator(0, 4, "absolute", 1.0, 3, 0);
168+
agg.setNodes(Set(["a", "b", "c", "d"]));
169+
170+
const p = agg.getPromiseForAggregation();
171+
agg.add("a", WeightsContainer.of([1]), 0);
172+
agg.add("b", WeightsContainer.of([1]), 0);
173+
agg.add("c", WeightsContainer.of([1]), 0);
174+
agg.add("d", WeightsContainer.of([10]), 0);
175+
176+
const out = await p;
177+
const v = (await out.weights[0].data())[0];
178+
179+
const mean = (1 + 1 + 1 + 10) / 4;
180+
181+
expect(Math.abs(v - honest)).to.be.lessThan(Math.abs(mean - honest));
182+
});
183+
184+
it("does not significantly worsen deviation compared to mean", async () => {
185+
const clipRadius = 1.0;
186+
187+
await fc.assert(
188+
fc.asyncProperty(
189+
fc
190+
.array(
191+
fc.double({
192+
min: -1,
193+
max: 1,
194+
noNaN: true,
195+
noDefaultInfinity: true,
196+
}),
197+
{ minLength: 3, maxLength: 10 },
198+
)
199+
// avoid degenerate constant arrays (no signal)
200+
.filter((arr) => arr.some((v) => Math.abs(v - arr[0]) > 1e-8)),
201+
202+
async (honest) => {
203+
const n = honest.length + 1;
204+
205+
// clean aggregation
206+
const aggClean = new ByzantineRobustAggregator(
207+
0,
208+
honest.length,
209+
"absolute",
210+
clipRadius,
211+
1,
212+
0,
213+
);
214+
const honestIds = honest.map((_, i) => `h${i}`);
215+
aggClean.setNodes(Set(honestIds));
216+
217+
const pClean = aggClean.getPromiseForAggregation();
218+
honest.forEach((v, i) =>
219+
aggClean.add(`h${i}`, WeightsContainer.of([v]), 0),
220+
);
221+
const cleanOut = await pClean;
222+
const clean = (await cleanOut.weights[0].data())[0];
223+
224+
// aggregation with Byzantine
225+
const aggByz = new ByzantineRobustAggregator(
226+
0,
227+
n,
228+
"absolute",
229+
clipRadius,
230+
1,
231+
0,
232+
);
233+
const ids = honestIds.concat("byz");
234+
aggByz.setNodes(Set(ids));
235+
236+
const pByz = aggByz.getPromiseForAggregation();
237+
honest.forEach((v, i) =>
238+
aggByz.add(`h${i}`, WeightsContainer.of([v]), 0),
239+
);
240+
aggByz.add("byz", WeightsContainer.of([1e9]), 0);
241+
242+
const byzOut = await pByz;
243+
const byz = (await byzOut.weights[0].data())[0];
244+
245+
const deviation = Math.abs(byz - clean);
246+
const mean = [...honest, 1e9].reduce((a, b) => a + b, 0) / n;
247+
const baseline = Math.abs(mean - clean);
248+
249+
// combined tolerance (absolute + relative)
250+
const ABS_EPS = 1e-6;
251+
const REL_EPS = 1e-6;
252+
253+
expect(deviation).toBeLessThanOrEqual(
254+
baseline * (1 + REL_EPS) + ABS_EPS,
255+
);
256+
},
257+
),
258+
{ numRuns: 500 },
259+
);
260+
});
261+
262+
it("is invariant to client ordering", async () => {
263+
const values = [0, 1, 100];
264+
const ids1 = ["a", "b", "c"];
265+
const ids2 = ["c", "a", "b"];
266+
267+
const run = async (ids: string[]) => {
268+
const agg = new ByzantineRobustAggregator(0, 3, "absolute", 1.0, 3, 0);
269+
agg.setNodes(Set(ids));
270+
const p = agg.getPromiseForAggregation();
271+
ids.forEach((id, i) => agg.add(id, WeightsContainer.of([values[i]]), 0));
272+
return (await (await p).weights[0].data())[0];
273+
};
274+
275+
const out1 = await run(ids1);
276+
const out2 = await run(ids2);
277+
278+
expect(out1).to.be.closeTo(out2, 1e-6);
279+
});
280+
281+
it("is idempotent when all inputs are identical and within clipping radius", async () => {
282+
const agg = new ByzantineRobustAggregator(0, 5, "absolute", 10.0, 5, 0);
283+
const ids = ["a", "b", "c", "d", "e"];
284+
agg.setNodes(Set(ids));
285+
286+
const p = agg.getPromiseForAggregation();
287+
ids.forEach((id) => agg.add(id, WeightsContainer.of([3.14]), 0));
288+
const out = await p;
289+
290+
const v = (await out.weights[0].data())[0];
291+
expect(v).to.be.closeTo(3.14, 1e-6);
292+
});
293+
294+
it("limits bias under symmetric Byzantine attacks", async () => {
295+
const agg = new ByzantineRobustAggregator(0, 4, "absolute", 1.0, 3, 0);
296+
agg.setNodes(Set(["h1", "h2", "b1", "b2"]));
297+
298+
const p = agg.getPromiseForAggregation();
299+
agg.add("h1", WeightsContainer.of([1]), 0);
300+
agg.add("h2", WeightsContainer.of([1]), 0);
301+
agg.add("b1", WeightsContainer.of([100]), 0);
302+
agg.add("b2", WeightsContainer.of([-100]), 0);
303+
304+
const out = await p;
305+
const v = (await out.weights[0].data())[0];
306+
307+
expect(Math.abs(v - 1)).to.be.lessThan(
308+
Math.abs((1 + 1 + 100 - 100) / 4 - 1),
309+
);
310+
});
311+
312+
it("reduces influence of extreme outliers", async () => {
313+
const agg = new ByzantineRobustAggregator(0, 4, "absolute", 1.0, 3, 0);
314+
agg.setNodes(Set(["a", "b", "c", "d"]));
315+
316+
const p = agg.getPromiseForAggregation();
317+
agg.add("a", WeightsContainer.of([0]), 0);
318+
agg.add("b", WeightsContainer.of([0.5]), 0);
319+
agg.add("c", WeightsContainer.of([1]), 0);
320+
agg.add("d", WeightsContainer.of([100]), 0);
321+
322+
const out = await p;
323+
const v = (await out.weights[0].data())[0];
324+
325+
const mean = (0 + 0.5 + 1 + 100) / 4;
326+
const honestCenter = (0 + 0.5 + 1) / 3;
327+
328+
expect(Math.abs(v - honestCenter)).to.be.lessThan(
329+
Math.abs(mean - honestCenter),
330+
);
331+
});
332+
333+
it("reset state when starting fresh aggregator", async () => {
334+
const run = async () => {
335+
const agg = new ByzantineRobustAggregator(0, 2, "absolute", 1.0, 3, 0.9);
336+
agg.setNodes(Set(["a", "b"]));
337+
const p = agg.getPromiseForAggregation();
338+
agg.add("a", WeightsContainer.of([1]), 0);
339+
agg.add("b", WeightsContainer.of([1]), 0);
340+
return (await (await p).weights[0].data())[0];
341+
};
342+
343+
expect(await run()).to.be.closeTo(await run(), 1e-6);
344+
});
111345
});

0 commit comments

Comments
 (0)