fedmesh 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fedmesh/__init__.py +90 -0
- fedmesh/cli.py +377 -0
- fedmesh/dp.py +163 -0
- fedmesh/masking.py +265 -0
- fedmesh/mesh.py +159 -0
- fedmesh/party.py +154 -0
- fedmesh/py.typed +0 -0
- fedmesh/sampler.py +118 -0
- fedmesh/testing.py +84 -0
- fedmesh/trust.py +217 -0
- fedmesh-0.1.0.dist-info/METADATA +158 -0
- fedmesh-0.1.0.dist-info/RECORD +16 -0
- fedmesh-0.1.0.dist-info/WHEEL +5 -0
- fedmesh-0.1.0.dist-info/entry_points.txt +2 -0
- fedmesh-0.1.0.dist-info/licenses/LICENSE +202 -0
- fedmesh-0.1.0.dist-info/top_level.txt +1 -0
fedmesh/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""fedmesh: privacy-preserving federated training across organisations.
|
|
2
|
+
|
|
3
|
+
fedmesh coordinates training runs that span several organisations, each of
|
|
4
|
+
which keeps its data local. It is built on iroh-tinker's public surface:
|
|
5
|
+
``LocalTransport``/``IrohTransport`` for the wire, ``CompressedTransport``
|
|
6
|
+
for bandwidth, and ``JobManager``/``LaunchSpec`` for the launch.
|
|
7
|
+
|
|
8
|
+
Two things make it different from a plain multi-node job:
|
|
9
|
+
|
|
10
|
+
* **No raw gradient leaves a party.** Contributions are masked with pairwise
|
|
11
|
+
secrets before they cross the wire, and the masks cancel in the aggregate.
|
|
12
|
+
* **No single party holds a global secret.** Every pair of parties derives
|
|
13
|
+
its own secret, so compromising the coordinator does not open the other
|
|
14
|
+
pairs' channels.
|
|
15
|
+
|
|
16
|
+
The guarantees, and their limits, are written down in ``docs/threat-model.md``.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
from .dp import (
|
|
23
|
+
DPConfig,
|
|
24
|
+
PrivacyAccountant,
|
|
25
|
+
PrivacyBudgetError,
|
|
26
|
+
add_gaussian_noise,
|
|
27
|
+
clip_by_l2_norm,
|
|
28
|
+
epsilon_for_gaussian_mechanism,
|
|
29
|
+
)
|
|
30
|
+
from .masking import (
|
|
31
|
+
InsecureTransportError,
|
|
32
|
+
MaskedTransport,
|
|
33
|
+
MaskRegistry,
|
|
34
|
+
broadcast_within_party,
|
|
35
|
+
exchange_party_sums,
|
|
36
|
+
is_lossy_transport,
|
|
37
|
+
)
|
|
38
|
+
from .mesh import Mesh, MeshError, PartyManifest
|
|
39
|
+
from .party import Party, PartyError, pairwise_secrets
|
|
40
|
+
from .sampler import Shard, ShardAudit, ShardPolicy, audit_shards
|
|
41
|
+
from .trust import (
|
|
42
|
+
PairwiseSecrets,
|
|
43
|
+
PartyKeyPair,
|
|
44
|
+
TrustError,
|
|
45
|
+
derive_pairwise_secret,
|
|
46
|
+
hkdf_expand,
|
|
47
|
+
hkdf_extract,
|
|
48
|
+
sign_ticket,
|
|
49
|
+
verify_ticket,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"__version__",
|
|
54
|
+
# trust
|
|
55
|
+
"PartyKeyPair",
|
|
56
|
+
"PairwiseSecrets",
|
|
57
|
+
"TrustError",
|
|
58
|
+
"derive_pairwise_secret",
|
|
59
|
+
"hkdf_expand",
|
|
60
|
+
"hkdf_extract",
|
|
61
|
+
"sign_ticket",
|
|
62
|
+
"verify_ticket",
|
|
63
|
+
# mesh
|
|
64
|
+
"Mesh",
|
|
65
|
+
"MeshError",
|
|
66
|
+
"PartyManifest",
|
|
67
|
+
# party
|
|
68
|
+
"Party",
|
|
69
|
+
"PartyError",
|
|
70
|
+
"pairwise_secrets",
|
|
71
|
+
# masking
|
|
72
|
+
"MaskedTransport",
|
|
73
|
+
"MaskRegistry",
|
|
74
|
+
"InsecureTransportError",
|
|
75
|
+
"broadcast_within_party",
|
|
76
|
+
"exchange_party_sums",
|
|
77
|
+
"is_lossy_transport",
|
|
78
|
+
# differential privacy
|
|
79
|
+
"DPConfig",
|
|
80
|
+
"PrivacyAccountant",
|
|
81
|
+
"PrivacyBudgetError",
|
|
82
|
+
"add_gaussian_noise",
|
|
83
|
+
"clip_by_l2_norm",
|
|
84
|
+
"epsilon_for_gaussian_mechanism",
|
|
85
|
+
# sampling
|
|
86
|
+
"Shard",
|
|
87
|
+
"ShardAudit",
|
|
88
|
+
"ShardPolicy",
|
|
89
|
+
"audit_shards",
|
|
90
|
+
]
|
fedmesh/cli.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""``fedmesh`` command line interface.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
|
|
5
|
+
``init`` create a party's key pair and namespace
|
|
6
|
+
``manifest`` print this party's manifest
|
|
7
|
+
``mesh`` combine manifests into a mesh and elect the coordinator
|
|
8
|
+
``shards`` show which party owns which dataset rows
|
|
9
|
+
``dp`` project a differential privacy budget
|
|
10
|
+
``spike`` run the compression/masking spike and print the numbers
|
|
11
|
+
|
|
12
|
+
Every command supports ``--json``.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import stat
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .dp import DPConfig, PrivacyAccountant
|
|
25
|
+
from .masking import MaskRegistry
|
|
26
|
+
from .mesh import Mesh
|
|
27
|
+
from .party import Party
|
|
28
|
+
from .sampler import ShardPolicy
|
|
29
|
+
from .trust import PartyKeyPair
|
|
30
|
+
|
|
31
|
+
__all__ = ["build_parser", "main"]
|
|
32
|
+
|
|
33
|
+
KEY_FILE = "party_key.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write_private(
|
|
37
|
+
path: Path,
|
|
38
|
+
party_id: str,
|
|
39
|
+
keypair: PartyKeyPair,
|
|
40
|
+
ranks: int,
|
|
41
|
+
rank_offset: int = 0,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Write a party's private key with owner-only permissions."""
|
|
44
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
payload = {
|
|
46
|
+
"party_id": party_id,
|
|
47
|
+
"ranks": int(ranks),
|
|
48
|
+
"rank_offset": int(rank_offset),
|
|
49
|
+
"private_exponent": str(keypair.private_exponent),
|
|
50
|
+
"public_key": keypair.public_bytes().hex(),
|
|
51
|
+
}
|
|
52
|
+
path.write_text(json.dumps(payload, indent=2) + "\n")
|
|
53
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_key(path: Path) -> PartyKeyPair:
|
|
57
|
+
raw = json.loads(path.read_text())
|
|
58
|
+
return PartyKeyPair.from_private(
|
|
59
|
+
str(raw["party_id"]), int(str(raw["private_exponent"]))
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_party(directory: Path, party_id: str | None = None) -> Party:
|
|
64
|
+
key_path = directory / KEY_FILE
|
|
65
|
+
if not key_path.exists():
|
|
66
|
+
raise FileNotFoundError(f"no party key at {key_path}; run 'fedmesh init' first")
|
|
67
|
+
keypair = _read_key(key_path)
|
|
68
|
+
raw_ranks = json.loads(key_path.read_text()).get("ranks", 1)
|
|
69
|
+
if party_id is not None and party_id != keypair.party_id:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"key at {key_path} belongs to {keypair.party_id!r}, not {party_id!r}"
|
|
72
|
+
)
|
|
73
|
+
offset = int(json.loads(key_path.read_text()).get("rank_offset", 0))
|
|
74
|
+
ranks = tuple(range(offset, offset + int(raw_ranks)))
|
|
75
|
+
return Party(
|
|
76
|
+
party_id=keypair.party_id,
|
|
77
|
+
keypair=keypair,
|
|
78
|
+
ranks=ranks,
|
|
79
|
+
rendezvous_uri=str(directory),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
84
|
+
parser = argparse.ArgumentParser(
|
|
85
|
+
prog="fedmesh",
|
|
86
|
+
description="Privacy-preserving federated training across organisations.",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
89
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
90
|
+
|
|
91
|
+
init = sub.add_parser("init", help="create a party key pair")
|
|
92
|
+
init.add_argument("--party-id", required=True, help="the party's identifier")
|
|
93
|
+
init.add_argument("--dir", required=True, help="party directory")
|
|
94
|
+
init.add_argument("--ranks", type=int, default=1, help="number of ranks")
|
|
95
|
+
init.add_argument(
|
|
96
|
+
"--rank-offset",
|
|
97
|
+
type=int,
|
|
98
|
+
default=0,
|
|
99
|
+
help="the party's first global rank in the mesh",
|
|
100
|
+
)
|
|
101
|
+
init.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
102
|
+
|
|
103
|
+
manifest = sub.add_parser("manifest", help="print this party's manifest")
|
|
104
|
+
manifest.add_argument("--dir", required=True, help="party directory")
|
|
105
|
+
manifest.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
106
|
+
|
|
107
|
+
mesh = sub.add_parser("mesh", help="combine manifests into a mesh")
|
|
108
|
+
mesh.add_argument("manifests", nargs="+", help="manifest JSON files")
|
|
109
|
+
mesh.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
110
|
+
|
|
111
|
+
shards = sub.add_parser("shards", help="show the shard assignment")
|
|
112
|
+
shards.add_argument("--parties", required=True, help="comma separated party ids")
|
|
113
|
+
shards.add_argument("--rows", type=int, default=1000, help="number of dataset rows")
|
|
114
|
+
shards.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
115
|
+
|
|
116
|
+
dp = sub.add_parser("dp", help="project a privacy budget")
|
|
117
|
+
dp.add_argument("--epsilon", type=float, default=8.0, help="target epsilon")
|
|
118
|
+
dp.add_argument("--delta", type=float, default=1e-5, help="target delta")
|
|
119
|
+
dp.add_argument("--noise", type=float, default=10.0, help="noise multiplier")
|
|
120
|
+
dp.add_argument("--clip", type=float, default=1.0, help="clipping norm")
|
|
121
|
+
dp.add_argument("--steps", type=int, default=10, help="number of steps to project")
|
|
122
|
+
dp.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
123
|
+
|
|
124
|
+
spike = sub.add_parser("spike", help="run the compression/masking spike")
|
|
125
|
+
spike.add_argument("--json", action="store_true", help="emit JSON on stdout")
|
|
126
|
+
|
|
127
|
+
return parser
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
131
|
+
directory = Path(args.dir)
|
|
132
|
+
key_path = directory / KEY_FILE
|
|
133
|
+
if key_path.exists():
|
|
134
|
+
raise FileExistsError(f"party key already exists at {key_path}")
|
|
135
|
+
keypair = PartyKeyPair.generate(args.party_id)
|
|
136
|
+
_write_private(key_path, args.party_id, keypair, args.ranks, args.rank_offset)
|
|
137
|
+
party = Party(
|
|
138
|
+
party_id=args.party_id,
|
|
139
|
+
keypair=keypair,
|
|
140
|
+
ranks=tuple(range(args.ranks)),
|
|
141
|
+
rendezvous_uri=str(directory),
|
|
142
|
+
)
|
|
143
|
+
payload = {
|
|
144
|
+
"party_id": party.party_id,
|
|
145
|
+
"ranks": list(party.ranks),
|
|
146
|
+
"public_key": party.public_key().hex(),
|
|
147
|
+
"key_file": str(key_path),
|
|
148
|
+
}
|
|
149
|
+
if args.json:
|
|
150
|
+
_emit_json(payload)
|
|
151
|
+
return 0
|
|
152
|
+
print(f"party {payload['party_id']} initialised with {len(party.ranks)} rank(s)")
|
|
153
|
+
print(f"key file: {key_path} (mode 600)")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _cmd_manifest(args: argparse.Namespace) -> int:
|
|
158
|
+
party = _load_party(Path(args.dir))
|
|
159
|
+
manifest = party.manifest().to_dict()
|
|
160
|
+
if args.json:
|
|
161
|
+
_emit_json(manifest)
|
|
162
|
+
else:
|
|
163
|
+
print(json.dumps(manifest, indent=2))
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _cmd_mesh(args: argparse.Namespace) -> int:
|
|
168
|
+
mesh = Mesh()
|
|
169
|
+
for path in args.manifests:
|
|
170
|
+
raw = json.loads(Path(path).read_text())
|
|
171
|
+
for entry in raw.get("parties", [raw]):
|
|
172
|
+
from .mesh import PartyManifest
|
|
173
|
+
|
|
174
|
+
mesh.add(PartyManifest.from_dict(entry))
|
|
175
|
+
mesh.validate()
|
|
176
|
+
payload = mesh.to_dict()
|
|
177
|
+
if args.json:
|
|
178
|
+
_emit_json(payload)
|
|
179
|
+
return 0
|
|
180
|
+
print(f"parties: {len(mesh.parties)} world size: {mesh.world_size()}")
|
|
181
|
+
print(f"coordinator: {mesh.coordinator()}")
|
|
182
|
+
print(f"election proof: {mesh.election_proof()}")
|
|
183
|
+
for party in mesh.party_ids():
|
|
184
|
+
ranks = mesh.ranks_of(party)
|
|
185
|
+
print(f" {party:<12} ranks {list(ranks)}")
|
|
186
|
+
return 0
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _cmd_shards(args: argparse.Namespace) -> int:
|
|
190
|
+
party_ids = [p.strip() for p in args.parties.split(",") if p.strip()]
|
|
191
|
+
policy = ShardPolicy(party_ids)
|
|
192
|
+
shards = policy.assign(args.rows)
|
|
193
|
+
payload = {
|
|
194
|
+
party: {
|
|
195
|
+
"rows": len(shard),
|
|
196
|
+
"indices": list(shard.indices[:20]),
|
|
197
|
+
"truncated": len(shard.indices) > 20,
|
|
198
|
+
}
|
|
199
|
+
for party, shard in sorted(shards.items())
|
|
200
|
+
}
|
|
201
|
+
if args.json:
|
|
202
|
+
_emit_json(payload)
|
|
203
|
+
return 0
|
|
204
|
+
for party, entry in payload.items():
|
|
205
|
+
print(f" {party:<12} {entry['rows']:>6} rows")
|
|
206
|
+
print(f"total: {args.rows} rows, one owner per row")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _cmd_dp(args: argparse.Namespace) -> int:
|
|
211
|
+
config = DPConfig(
|
|
212
|
+
clip_norm=args.clip,
|
|
213
|
+
noise_multiplier=args.noise,
|
|
214
|
+
target_epsilon=args.epsilon,
|
|
215
|
+
target_delta=args.delta,
|
|
216
|
+
)
|
|
217
|
+
accountant = PrivacyAccountant(args.epsilon, args.delta)
|
|
218
|
+
steps = 0
|
|
219
|
+
try:
|
|
220
|
+
for _ in range(args.steps):
|
|
221
|
+
accountant.spend_step(config)
|
|
222
|
+
steps += 1
|
|
223
|
+
except Exception as exc: # noqa: BLE001 - the budget error is the answer
|
|
224
|
+
payload = {
|
|
225
|
+
"config": {
|
|
226
|
+
"clip_norm": config.clip_norm,
|
|
227
|
+
"noise_multiplier": config.noise_multiplier,
|
|
228
|
+
"target_epsilon": config.target_epsilon,
|
|
229
|
+
"target_delta": config.target_delta,
|
|
230
|
+
},
|
|
231
|
+
"steps_completed": steps,
|
|
232
|
+
"budget_exhausted": True,
|
|
233
|
+
"reason": str(exc),
|
|
234
|
+
"accountant": accountant.to_dict(),
|
|
235
|
+
}
|
|
236
|
+
if args.json:
|
|
237
|
+
_emit_json(payload)
|
|
238
|
+
else:
|
|
239
|
+
print(f"budget exhausted after {steps} step(s): {exc}")
|
|
240
|
+
return 1
|
|
241
|
+
payload = {
|
|
242
|
+
"config": {
|
|
243
|
+
"clip_norm": config.clip_norm,
|
|
244
|
+
"noise_multiplier": config.noise_multiplier,
|
|
245
|
+
"target_epsilon": config.target_epsilon,
|
|
246
|
+
"target_delta": config.target_delta,
|
|
247
|
+
},
|
|
248
|
+
"steps_completed": steps,
|
|
249
|
+
"budget_exhausted": False,
|
|
250
|
+
"accountant": accountant.to_dict(),
|
|
251
|
+
}
|
|
252
|
+
if args.json:
|
|
253
|
+
_emit_json(payload)
|
|
254
|
+
return 0
|
|
255
|
+
print(
|
|
256
|
+
f"{steps} step(s) within budget: epsilon {accountant.epsilon:.4f} of "
|
|
257
|
+
f"{accountant.target_epsilon}, delta {accountant.delta:.3e}"
|
|
258
|
+
)
|
|
259
|
+
return 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _cmd_spike(args: argparse.Namespace) -> int:
|
|
263
|
+
"""Run the P2.1 spike: masks cancel exactly, and not through compression."""
|
|
264
|
+
import numpy as np
|
|
265
|
+
|
|
266
|
+
from .party import pairwise_secrets
|
|
267
|
+
|
|
268
|
+
size = 1024
|
|
269
|
+
party_ids = ("alpha", "beta", "gamma")
|
|
270
|
+
parties: dict[str, Party] = {}
|
|
271
|
+
for index, party_id in enumerate(party_ids):
|
|
272
|
+
parties[party_id] = Party(
|
|
273
|
+
party_id=party_id,
|
|
274
|
+
keypair=PartyKeyPair.generate(party_id, seed=b"fedmesh-spike"),
|
|
275
|
+
ranks=(index,),
|
|
276
|
+
)
|
|
277
|
+
for i, first in enumerate(party_ids):
|
|
278
|
+
for second in party_ids[i + 1 :]:
|
|
279
|
+
parties[first].establish(second, parties[second].public_key())
|
|
280
|
+
parties[second].establish(first, parties[first].public_key())
|
|
281
|
+
registry = MaskRegistry(
|
|
282
|
+
party_ids=party_ids,
|
|
283
|
+
secrets=pairwise_secrets(parties),
|
|
284
|
+
size=size,
|
|
285
|
+
scale=1.0,
|
|
286
|
+
)
|
|
287
|
+
masks = registry.all_masks()
|
|
288
|
+
rng = np.random.default_rng(17)
|
|
289
|
+
local_sums = {
|
|
290
|
+
index: rng.normal(0.0, 1.0, size=size).astype(np.float32)
|
|
291
|
+
for index in range(len(party_ids))
|
|
292
|
+
}
|
|
293
|
+
truth = sum(local_sums.values())
|
|
294
|
+
|
|
295
|
+
from iroh_tinker.comm.transport import (
|
|
296
|
+
CompressedTransport,
|
|
297
|
+
LocalNetwork,
|
|
298
|
+
Transport,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
from .masking import MaskedTransport, exchange_party_sums
|
|
302
|
+
|
|
303
|
+
def run(compress: bool) -> np.ndarray:
|
|
304
|
+
network = LocalNetwork(len(party_ids))
|
|
305
|
+
transports: dict[int, MaskedTransport] = {}
|
|
306
|
+
for rank in range(len(party_ids)):
|
|
307
|
+
base: Transport = network.transport(rank)
|
|
308
|
+
if compress:
|
|
309
|
+
base = CompressedTransport(base, scheme="8bit", block_size=128)
|
|
310
|
+
transports[rank] = MaskedTransport(
|
|
311
|
+
base,
|
|
312
|
+
party_id=party_ids[rank],
|
|
313
|
+
party_of_rank=list(party_ids),
|
|
314
|
+
mask=masks[party_ids[rank]],
|
|
315
|
+
allow_lossy=True,
|
|
316
|
+
)
|
|
317
|
+
results = exchange_party_sums(
|
|
318
|
+
transports=transports,
|
|
319
|
+
party_of_rank=list(party_ids),
|
|
320
|
+
local_sums=local_sums,
|
|
321
|
+
masks=masks,
|
|
322
|
+
)
|
|
323
|
+
return results[0]
|
|
324
|
+
|
|
325
|
+
def relative(value: np.ndarray) -> float:
|
|
326
|
+
error = float(np.linalg.norm(value - truth))
|
|
327
|
+
return error / max(float(np.linalg.norm(truth)), 1e-9)
|
|
328
|
+
|
|
329
|
+
exact_error = relative(run(compress=False))
|
|
330
|
+
lossy_error = relative(run(compress=True))
|
|
331
|
+
payload = {
|
|
332
|
+
"exact_relative_error": exact_error,
|
|
333
|
+
"lossy_relative_error": lossy_error,
|
|
334
|
+
"lossy_is_worse_by": lossy_error / max(exact_error, 1e-18),
|
|
335
|
+
"decision": "the masked path must be lossless",
|
|
336
|
+
}
|
|
337
|
+
if args.json:
|
|
338
|
+
_emit_json(payload)
|
|
339
|
+
return 0
|
|
340
|
+
print(f"exact fp32 wire : relative error {exact_error:.3e}")
|
|
341
|
+
print(f"8-bit wire : relative error {lossy_error:.3e}")
|
|
342
|
+
print(f"lossy is {payload['lossy_is_worse_by']:.0f}x worse -> {payload['decision']}")
|
|
343
|
+
return 0
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _emit_json(payload: Any) -> None:
|
|
347
|
+
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
351
|
+
parser = build_parser()
|
|
352
|
+
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
353
|
+
try:
|
|
354
|
+
if args.command == "init":
|
|
355
|
+
return _cmd_init(args)
|
|
356
|
+
if args.command == "manifest":
|
|
357
|
+
return _cmd_manifest(args)
|
|
358
|
+
if args.command == "mesh":
|
|
359
|
+
return _cmd_mesh(args)
|
|
360
|
+
if args.command == "shards":
|
|
361
|
+
return _cmd_shards(args)
|
|
362
|
+
if args.command == "dp":
|
|
363
|
+
return _cmd_dp(args)
|
|
364
|
+
if args.command == "spike":
|
|
365
|
+
return _cmd_spike(args)
|
|
366
|
+
except (FileExistsError, FileNotFoundError, ValueError, RuntimeError) as exc:
|
|
367
|
+
if getattr(args, "json", False):
|
|
368
|
+
_emit_json({"error": str(exc)})
|
|
369
|
+
else:
|
|
370
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
371
|
+
return 2
|
|
372
|
+
parser.error(f"unknown command {args.command!r}")
|
|
373
|
+
return 2
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
if __name__ == "__main__": # pragma: no cover
|
|
377
|
+
raise SystemExit(main())
|
fedmesh/dp.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Differential privacy for the federated update.
|
|
2
|
+
|
|
3
|
+
fedmesh does not implement DP in the first version of the training loop; this
|
|
4
|
+
module provides the pieces a training run needs and, importantly, a budget
|
|
5
|
+
accountant that *refuses* to go over budget.
|
|
6
|
+
|
|
7
|
+
The privacy guarantee is the standard Gaussian mechanism: per-example
|
|
8
|
+
gradients are clipped to a bounded L2 norm and Gaussian noise proportional to
|
|
9
|
+
that norm is added before the update leaves the party. The accountant uses
|
|
10
|
+
the basic (not the tighter Rényi) composition bound, so the reported epsilon
|
|
11
|
+
is an upper bound.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DPConfig",
|
|
23
|
+
"PrivacyAccountant",
|
|
24
|
+
"PrivacyBudgetError",
|
|
25
|
+
"add_gaussian_noise",
|
|
26
|
+
"clip_by_l2_norm",
|
|
27
|
+
"epsilon_for_gaussian_mechanism",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PrivacyBudgetError(RuntimeError):
|
|
32
|
+
"""Raised when a step would exceed the privacy budget."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class DPConfig:
|
|
37
|
+
"""Parameters of the differential privacy mechanism."""
|
|
38
|
+
|
|
39
|
+
clip_norm: float = 1.0
|
|
40
|
+
noise_multiplier: float = 10.0
|
|
41
|
+
target_epsilon: float = 8.0
|
|
42
|
+
target_delta: float = 1e-5
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
if self.clip_norm <= 0:
|
|
46
|
+
raise ValueError("clip_norm must be positive")
|
|
47
|
+
if self.noise_multiplier <= 0:
|
|
48
|
+
raise ValueError("noise_multiplier must be positive")
|
|
49
|
+
if not 0 < self.target_delta < 1:
|
|
50
|
+
raise ValueError("target_delta must be in (0, 1)")
|
|
51
|
+
if self.target_epsilon <= 0:
|
|
52
|
+
raise ValueError("target_epsilon must be positive")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def clip_by_l2_norm(vector: np.ndarray, max_norm: float) -> np.ndarray:
|
|
56
|
+
"""Clip a vector to ``max_norm`` in L2, preserving direction."""
|
|
57
|
+
flat = np.asarray(vector, dtype=np.float64).reshape(-1)
|
|
58
|
+
norm = float(np.linalg.norm(flat))
|
|
59
|
+
if norm <= max_norm or norm == 0.0:
|
|
60
|
+
return flat.astype(np.float32)
|
|
61
|
+
return (flat * (max_norm / norm)).astype(np.float32)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_gaussian_noise(
|
|
65
|
+
vector: np.ndarray,
|
|
66
|
+
noise_multiplier: float,
|
|
67
|
+
rng: np.random.Generator,
|
|
68
|
+
) -> np.ndarray:
|
|
69
|
+
"""Add Gaussian noise with the given multiplier of the sensitivity."""
|
|
70
|
+
flat = np.asarray(vector, dtype=np.float64).reshape(-1)
|
|
71
|
+
noise = rng.normal(0.0, noise_multiplier, size=flat.shape)
|
|
72
|
+
return (flat + noise).astype(np.float32)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def epsilon_for_gaussian_mechanism(
|
|
76
|
+
noise_multiplier: float,
|
|
77
|
+
clip_norm: float,
|
|
78
|
+
delta: float,
|
|
79
|
+
) -> float:
|
|
80
|
+
"""The basic (upper bound) epsilon of the Gaussian mechanism.
|
|
81
|
+
|
|
82
|
+
Uses the standard bound ``eps = C * sqrt(2 * ln(1.25 / delta)) / sigma``
|
|
83
|
+
for the Gaussian mechanism with sensitivity ``C`` and noise ``sigma *
|
|
84
|
+
C``. It is not the tightest bound available, but it is the one that is
|
|
85
|
+
easy to audit and it never understates the privacy loss.
|
|
86
|
+
"""
|
|
87
|
+
if not 0 < delta < 1:
|
|
88
|
+
raise ValueError("delta must be in (0, 1)")
|
|
89
|
+
if noise_multiplier <= 0:
|
|
90
|
+
raise ValueError("noise_multiplier must be positive")
|
|
91
|
+
return clip_norm * math.sqrt(2.0 * math.log(1.25 / delta)) / noise_multiplier
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class PrivacyAccountant:
|
|
96
|
+
"""Tracks the privacy budget spent by a run.
|
|
97
|
+
|
|
98
|
+
Basic composition: epsilon adds up, delta combines as
|
|
99
|
+
``1 - prod(1 - delta_i)``.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
target_epsilon: float
|
|
103
|
+
target_delta: float
|
|
104
|
+
_epsilon: float = 0.0
|
|
105
|
+
_delta_product: float = 1.0
|
|
106
|
+
steps: int = 0
|
|
107
|
+
history: list[dict[str, Any]] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
def __post_init__(self) -> None:
|
|
110
|
+
if self.target_epsilon <= 0:
|
|
111
|
+
raise ValueError("target_epsilon must be positive")
|
|
112
|
+
if not 0 < self.target_delta < 1:
|
|
113
|
+
raise ValueError("target_delta must be in (0, 1)")
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def epsilon(self) -> float:
|
|
117
|
+
return self._epsilon
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def delta(self) -> float:
|
|
121
|
+
return 1.0 - self._delta_product
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def remaining(self) -> float:
|
|
125
|
+
return max(0.0, self.target_epsilon - self._epsilon)
|
|
126
|
+
|
|
127
|
+
def spend(self, epsilon: float, delta: float) -> None:
|
|
128
|
+
"""Record a step, refusing to exceed the budget."""
|
|
129
|
+
if epsilon < 0 or delta < 0:
|
|
130
|
+
raise ValueError("epsilon and delta must be non-negative")
|
|
131
|
+
if epsilon + self._epsilon > self.target_epsilon + 1e-12:
|
|
132
|
+
raise PrivacyBudgetError(
|
|
133
|
+
f"step would spend epsilon {epsilon:.4f} on top of "
|
|
134
|
+
f"{self._epsilon:.4f}, exceeding the budget {self.target_epsilon:.4f}"
|
|
135
|
+
)
|
|
136
|
+
if self.delta + delta > self.target_delta + 1e-15:
|
|
137
|
+
raise PrivacyBudgetError(
|
|
138
|
+
f"step would spend delta {delta:.3e} on top of {self.delta:.3e}, "
|
|
139
|
+
f"exceeding the budget {self.target_delta:.3e}"
|
|
140
|
+
)
|
|
141
|
+
self._epsilon += epsilon
|
|
142
|
+
self._delta_product *= 1.0 - delta
|
|
143
|
+
self.steps += 1
|
|
144
|
+
self.history.append({"step": self.steps, "epsilon": epsilon, "delta": delta})
|
|
145
|
+
|
|
146
|
+
def spend_step(self, config: DPConfig) -> float:
|
|
147
|
+
"""Spend one step of the Gaussian mechanism described by ``config``."""
|
|
148
|
+
epsilon = epsilon_for_gaussian_mechanism(
|
|
149
|
+
config.noise_multiplier, config.clip_norm, config.target_delta
|
|
150
|
+
)
|
|
151
|
+
self.spend(epsilon, config.target_delta)
|
|
152
|
+
return epsilon
|
|
153
|
+
|
|
154
|
+
def to_dict(self) -> dict[str, Any]:
|
|
155
|
+
return {
|
|
156
|
+
"target_epsilon": self.target_epsilon,
|
|
157
|
+
"target_delta": self.target_delta,
|
|
158
|
+
"epsilon": self.epsilon,
|
|
159
|
+
"delta": self.delta,
|
|
160
|
+
"steps": self.steps,
|
|
161
|
+
"remaining_epsilon": self.remaining,
|
|
162
|
+
"history": list(self.history),
|
|
163
|
+
}
|