rigfl 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.
Files changed (53) hide show
  1. rigfl/__init__.py +8 -0
  2. rigfl/algorithms/__init__.py +1 -0
  3. rigfl/algorithms/fedavg.py +164 -0
  4. rigfl/algorithms/feddes.py +380 -0
  5. rigfl/algorithms/fedgh.py +106 -0
  6. rigfl/algorithms/fedkd.py +143 -0
  7. rigfl/algorithms/fedproto.py +147 -0
  8. rigfl/algorithms/fedprox.py +42 -0
  9. rigfl/algorithms/fedtgp.py +157 -0
  10. rigfl/algorithms/fml.py +83 -0
  11. rigfl/algorithms/global_ensemble.py +52 -0
  12. rigfl/algorithms/lgfedavg.py +70 -0
  13. rigfl/algorithms/local.py +47 -0
  14. rigfl/core/__init__.py +27 -0
  15. rigfl/core/adapters.py +76 -0
  16. rigfl/core/config.py +17 -0
  17. rigfl/core/interfaces.py +115 -0
  18. rigfl/core/model.py +52 -0
  19. rigfl/core/round.py +399 -0
  20. rigfl/data/README.md +107 -0
  21. rigfl/data/__init__.py +61 -0
  22. rigfl/data/biosilo.py +56 -0
  23. rigfl/data/builder.py +147 -0
  24. rigfl/data/config.py +265 -0
  25. rigfl/data/flower.py +608 -0
  26. rigfl/data/generate.py +38 -0
  27. rigfl/data/partitions.py +254 -0
  28. rigfl/eval/__init__.py +14 -0
  29. rigfl/eval/metrics.py +240 -0
  30. rigfl/eval/protocol.py +112 -0
  31. rigfl/eval/report.py +269 -0
  32. rigfl/eval/selection.py +274 -0
  33. rigfl/experiment/__init__.py +11 -0
  34. rigfl/experiment/artifacts.py +396 -0
  35. rigfl/experiment/collect.py +380 -0
  36. rigfl/experiment/config.py +160 -0
  37. rigfl/experiment/device.py +23 -0
  38. rigfl/experiment/env.py +110 -0
  39. rigfl/experiment/launch.py +444 -0
  40. rigfl/experiment/registry.py +160 -0
  41. rigfl/experiment/run.py +402 -0
  42. rigfl/experiment/tracking.py +78 -0
  43. rigfl/experiment/tuning.py +887 -0
  44. rigfl/models/__init__.py +1 -0
  45. rigfl/models/cifar.py +185 -0
  46. rigfl/models/eicu.py +96 -0
  47. rigfl/models/registry.py +120 -0
  48. rigfl/prediction.py +120 -0
  49. rigfl-0.1.0.dist-info/METADATA +357 -0
  50. rigfl-0.1.0.dist-info/RECORD +53 -0
  51. rigfl-0.1.0.dist-info/WHEEL +5 -0
  52. rigfl-0.1.0.dist-info/licenses/LICENSE +21 -0
  53. rigfl-0.1.0.dist-info/top_level.txt +1 -0
rigfl/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """RigFL — a modular framework for rigorous federated learning experimentation."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("rigfl")
7
+ except PackageNotFoundError:
8
+ __version__ = "0+unknown"
@@ -0,0 +1 @@
1
+ """Federated algorithms. Each file is one self-contained algorithm."""
@@ -0,0 +1,164 @@
1
+ """FedAvg -- homogeneous full-model federated averaging.
2
+
3
+ Every client starts a round from the same global ``ClientModel``, trains it with
4
+ local SGD, and uploads a detached state snapshot plus its local sample count.
5
+ The server averages floating-point state by sample count. Non-floating buffers
6
+ cannot be meaningfully averaged, so they are copied from the largest upload
7
+ (ties follow client/upload order).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ from dataclasses import dataclass
14
+ from typing import Mapping
15
+
16
+ import torch
17
+ import torch.nn.functional as F
18
+ from pydantic import Field
19
+
20
+ from rigfl.core.config import AlgorithmConfig
21
+ from rigfl.core.interfaces import Algorithm
22
+ from rigfl.prediction import Predictions
23
+
24
+
25
+ class FedAvgConfig(AlgorithmConfig):
26
+ local_epochs: int = Field(1, ge=1)
27
+ lr: float = Field(0.01, gt=0)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ModelUpload:
32
+ """A complete locally trained model state and its aggregation weight."""
33
+
34
+ state: dict[str, torch.Tensor]
35
+ num_samples: int
36
+
37
+
38
+ def clone_state_dict(state: Mapping[str, torch.Tensor]) -> dict[str, torch.Tensor]:
39
+ """A detached snapshot: later client training cannot mutate the upload."""
40
+ return {name: value.detach().clone() for name, value in state.items()}
41
+
42
+
43
+ def validate_state_structure(local, global_state: Mapping[str, torch.Tensor], *,
44
+ algorithm: str, client_id=None) -> None:
45
+ """Reject parameter/buffer layouts that full-model FL cannot synchronize."""
46
+ local_state = local.state_dict() if hasattr(local, "state_dict") else local
47
+ local_keys, global_keys = set(local_state), set(global_state)
48
+ where = f" client {client_id}" if client_id is not None else ""
49
+ prefix = f"{algorithm} requires homogeneous client models;{where}"
50
+ if local_keys != global_keys:
51
+ missing = sorted(global_keys - local_keys)
52
+ extra = sorted(local_keys - global_keys)
53
+ detail = []
54
+ if missing:
55
+ detail.append(f"missing keys {missing}")
56
+ if extra:
57
+ detail.append(f"extra keys {extra}")
58
+ raise ValueError(f"{prefix} has an incompatible state structure ({'; '.join(detail)}).")
59
+ for name, reference in global_state.items():
60
+ value = local_state[name]
61
+ if value.shape != reference.shape:
62
+ raise ValueError(
63
+ f"{prefix} state {name!r} has shape {tuple(value.shape)}, expected "
64
+ f"{tuple(reference.shape)}."
65
+ )
66
+ if value.dtype != reference.dtype:
67
+ raise ValueError(
68
+ f"{prefix} state {name!r} has dtype {value.dtype}, expected "
69
+ f"{reference.dtype}."
70
+ )
71
+
72
+
73
+ def weighted_average_states(uploads: list[ModelUpload], *, device,
74
+ algorithm: str = "FedAvg") -> dict[str, torch.Tensor]:
75
+ """Sample-count-weighted floating state plus deterministic integer buffers."""
76
+ if not uploads:
77
+ raise ValueError("FedAvg aggregation requires at least one client upload.")
78
+ if any(upload.num_samples < 0 for upload in uploads):
79
+ raise ValueError("FedAvg upload sample counts must be non-negative.")
80
+ total = sum(upload.num_samples for upload in uploads)
81
+ if total <= 0:
82
+ raise ValueError("FedAvg aggregation requires at least one training sample.")
83
+
84
+ reference = uploads[0].state
85
+ for upload in uploads[1:]:
86
+ validate_state_structure(upload.state, reference, algorithm=f"{algorithm} upload")
87
+
88
+ # There is no arithmetic mean for integer/categorical module state. Selecting
89
+ # the largest-weight contributor preserves dtype and a real client value.
90
+ source = max(enumerate(uploads), key=lambda item: (item[1].num_samples, -item[0]))[1]
91
+ averaged: dict[str, torch.Tensor] = {}
92
+ for name, first in reference.items():
93
+ if torch.is_floating_point(first) or torch.is_complex(first):
94
+ if torch.is_complex(first):
95
+ work_dtype = torch.complex128 if first.dtype == torch.complex128 else torch.complex64
96
+ else:
97
+ work_dtype = torch.float64 if first.dtype == torch.float64 else torch.float32
98
+ value = torch.zeros(first.shape, dtype=work_dtype, device=device)
99
+ for upload in uploads:
100
+ value.add_(upload.state[name].to(device=device, dtype=work_dtype),
101
+ alpha=upload.num_samples / total)
102
+ averaged[name] = value.to(dtype=first.dtype)
103
+ else:
104
+ averaged[name] = source.state[name].to(device=device).clone()
105
+ return averaged
106
+
107
+
108
+ class FedAvg(Algorithm):
109
+ """Traditional FedAvg over the complete homogeneous client model."""
110
+
111
+ algorithm_name = "FedAvg"
112
+
113
+ def __init__(self, config: FedAvgConfig, model_template):
114
+ super().__init__(config)
115
+ if model_template is None:
116
+ raise ValueError("FedAvg requires an initial homogeneous client model template.")
117
+ self.model_template = copy.deepcopy(model_template).cpu()
118
+
119
+ @classmethod
120
+ def from_config(cls, config, *, model_template=None, **resources):
121
+ return cls(config, model_template)
122
+
123
+ def init_globals(self):
124
+ return copy.deepcopy(self.model_template)
125
+
126
+ def _round_reference(self, model) -> dict[str, torch.Tensor] | None:
127
+ return None
128
+
129
+ def _training_loss(self, model, logits, labels, reference) -> torch.Tensor:
130
+ return F.cross_entropy(logits, labels)
131
+
132
+ def local_train(self, client, global_model) -> ModelUpload:
133
+ model, loader = client.model, client.train_loader
134
+ global_state = global_model.state_dict()
135
+ validate_state_structure(model, global_state, algorithm=self.algorithm_name,
136
+ client_id=client.client_id)
137
+ model.load_state_dict(global_state)
138
+ model.to(self.device)
139
+ model.train()
140
+ reference = self._round_reference(model)
141
+ optimizer = torch.optim.SGD(model.parameters(), lr=self.config.lr)
142
+ for _ in range(self.config.local_epochs):
143
+ for x, y in loader:
144
+ x, y = x.to(self.device), y.to(self.device)
145
+ loss = self._training_loss(model, model(x), y, reference)
146
+ optimizer.zero_grad()
147
+ loss.backward()
148
+ optimizer.step()
149
+ return ModelUpload(clone_state_dict(model.state_dict()), len(loader.dataset))
150
+
151
+ def aggregate(self, uploads: list[ModelUpload], global_model):
152
+ averaged = weighted_average_states(
153
+ uploads, device=self.device, algorithm=self.algorithm_name
154
+ )
155
+ validate_state_structure(averaged, global_model.state_dict(),
156
+ algorithm=self.algorithm_name)
157
+ global_model.to(self.device)
158
+ global_model.load_state_dict(averaged)
159
+ return global_model
160
+
161
+ @torch.no_grad()
162
+ def predict(self, client, x, global_model) -> Predictions:
163
+ global_model.to(x.device)
164
+ return Predictions.from_logits(global_model(x))
@@ -0,0 +1,380 @@
1
+ """FedDES -- Federated Diverse Ensemble Selection (Mueller & Street).
2
+
3
+ FedDES uses RigFL's peer-to-peer one-shot lifecycle:
4
+ prepare each client trains its local classifier pool.
5
+ one_shot_communication the local pools are shared with every client once.
6
+ local_computation each client independently builds its graph and
7
+ trains its complete GNN meta-learner.
8
+
9
+ There is no iterative local-training/server-aggregation loop. Each GNN retains
10
+ the epoch selected by its own validation split, and RigFL evaluates those final
11
+ per-client models once.
12
+
13
+ Per-client GraphRoute state is stored in ``ctx.client_state``. GraphRoute remains
14
+ an optional dependency for users running other algorithms.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import copy
20
+ from pathlib import Path
21
+ from typing import Callable, Literal
22
+
23
+ import torch
24
+ from torch.utils.data import DataLoader, Dataset
25
+
26
+ from pydantic import Field
27
+
28
+ from rigfl.core.config import AlgorithmConfig
29
+ from rigfl.core.interfaces import Algorithm, LocalSelection, OneShotContext
30
+ from rigfl.prediction import Predictions
31
+ from rigfl.data.builder import _collate # multi-input-safe collate (works for single-input too)
32
+
33
+ FEDDES_PREPROCESSING_KEY = "rigfl-feddes-multitensor-collation-v1"
34
+
35
+
36
+ class FedDESConfig(AlgorithmConfig):
37
+ # FedDES trains base classifiers + a GNN meta-learner; it does not use the
38
+ # local_epochs/lr settings used by algorithms with a client training loop.
39
+ gnn_arch: Literal["gat", "graph_gps", "mlp"] = "gat"
40
+ base_lr: float = Field(5e-4, gt=0)
41
+ base_epochs: int = Field(100, ge=1)
42
+ graph_k: int = Field(5, ge=1)
43
+ hidden_dim: int = Field(128, ge=1)
44
+ gnn_epochs: int = Field(500, ge=1)
45
+ gnn_patience: int = Field(50, ge=1)
46
+ calibrate: bool = True
47
+ # OOF stacking produces training meta-labels from models that did not see the
48
+ # corresponding rows. In-sample mode is cheaper but measures training fit.
49
+ base_split_mode: Literal["oof_stacking", "in_sample"] = "oof_stacking"
50
+ base_oof_folds: int = Field(3, ge=2)
51
+ cache_dir: str = "pool_cache" # reuse trained base pools across graph/GNN sweeps ("" disables)
52
+
53
+
54
+ class FedDES(Algorithm):
55
+ def __init__(self, config: FedDESConfig,
56
+ base_factories: list[torch.nn.Module | Callable[[], torch.nn.Module]],
57
+ num_classes: int, *, data_id: str | None = None,
58
+ model_ids: list[str] | None = None, seed: int = 0,
59
+ validation_fraction: float = 0.2):
60
+ super().__init__(config)
61
+ sources = list(base_factories)
62
+ # Configuration resolves to actual model templates. A few low-level test
63
+ # integrations still supply constructors, so materialize those once and
64
+ # use the resulting templates for both identity and isolated training.
65
+ self.base_models = tuple(
66
+ source if isinstance(source, torch.nn.Module) else source()
67
+ for source in sources
68
+ )
69
+ self.base_factories = [
70
+ lambda template=model: copy.deepcopy(template)
71
+ for model in self.base_models
72
+ ]
73
+ self.num_classes = num_classes
74
+ self.gnn_arch = config.gnn_arch
75
+ self.base_lr, self.base_epochs = config.base_lr, config.base_epochs
76
+ self.graph_k, self.hidden_dim = config.graph_k, config.hidden_dim
77
+ self.calibrate = config.calibrate
78
+ self.base_split_mode = config.base_split_mode
79
+ self.base_oof_folds = config.base_oof_folds
80
+ self.cache_dir = config.cache_dir or None
81
+ self.data_id, self.seed = data_id, seed
82
+ self.validation_fraction = validation_fraction
83
+ self.model_ids = model_ids
84
+ if self.cache_dir and not self.data_id:
85
+ raise ValueError("FedDES pool reuse requires a stable data_id.")
86
+ if self.cache_dir and not self.model_ids:
87
+ raise ValueError("FedDES pool reuse requires stable model_ids.")
88
+ if self.model_ids and len(self.model_ids) != len(self.base_models):
89
+ raise ValueError("model_ids must name every base model in order.")
90
+ if self.model_ids is None:
91
+ self.model_ids = [f"model_{i}" for i in range(len(base_factories))]
92
+ self.gnn_epochs, self.gnn_patience = config.gnn_epochs, config.gnn_patience
93
+
94
+ @classmethod
95
+ def from_config(cls, config, *, experiment, base_pool=None,
96
+ model_input_spec=None, **resources):
97
+ from rigfl.core.adapters import LearnedProjection
98
+ from rigfl.experiment.config import fingerprint
99
+ from rigfl.models.registry import (instantiate_models,
100
+ resolve_model_architectures)
101
+
102
+ if experiment.scheme in {"generated", "natural"}:
103
+ data_id = f"{experiment.dataset}-{experiment.partition}"
104
+ else:
105
+ partition_settings = {
106
+ "dataset": experiment.dataset,
107
+ "num_clients": experiment.num_clients,
108
+ "alpha": experiment.alpha,
109
+ "seed": experiment.seed,
110
+ "train_per_client": experiment.train_per_client,
111
+ "test_per_client": experiment.test_per_client,
112
+ "val_frac": experiment.val_frac,
113
+ }
114
+ data_id = (
115
+ f"{experiment.dataset}-{fingerprint(partition_settings)}")
116
+
117
+ input_kind = model_input_spec["input_kind"] if model_input_spec else (
118
+ "temporal" if experiment.scheme == "natural" else "image")
119
+ model_ids = resolve_model_architectures(
120
+ architecture_family=experiment.model_architecture_family,
121
+ architectures=experiment.model_architectures,
122
+ input_kind=input_kind,
123
+ )
124
+ if base_pool is None:
125
+ if model_input_spec is None:
126
+ if experiment.scheme == "natural":
127
+ raise ValueError(
128
+ "FedDES temporal models require model_input_spec with "
129
+ "n_ts, n_static, and seq_len."
130
+ )
131
+ model_input_spec = {
132
+ "input_kind": "image", "shape": (3, 32, 32)
133
+ }
134
+ base_pool = instantiate_models(
135
+ model_ids,
136
+ num_classes=experiment.num_classes,
137
+ input_spec=model_input_spec,
138
+ shared_dim=experiment.shared_dim,
139
+ adapter=lambda native, shared: LearnedProjection(native, shared),
140
+ )
141
+ return cls(
142
+ config,
143
+ base_pool,
144
+ experiment.num_classes,
145
+ data_id=data_id,
146
+ model_ids=model_ids,
147
+ seed=experiment.seed,
148
+ validation_fraction=experiment.val_frac,
149
+ )
150
+
151
+ def prepare(self, model, train_loader, ctx: OneShotContext):
152
+ """Train one client's local base pool and return its outgoing payload."""
153
+ del model
154
+ if ctx.validation_loader is None:
155
+ raise ValueError("FedDES requires each client's official validation split.")
156
+ st = ctx.client_state
157
+ st["train_dataset"] = train_loader.dataset
158
+ st["validation_dataset"] = ctx.validation_loader.dataset
159
+ st["local_pool"] = self._train_or_load_pool(
160
+ st["train_dataset"], st["validation_dataset"],
161
+ ctx.device, ctx.client_id)
162
+ return st["local_pool"]
163
+
164
+ def one_shot_communication(self, outgoing: list):
165
+ """Share the ordered union of all local pools with every client once."""
166
+ shared_pool = self._union_pools(outgoing)
167
+ return [shared_pool for _ in outgoing]
168
+
169
+ # ── decision space: pool probabilities [N,M*C] + per-classifier hard preds [N,M] ──
170
+ def _splice_oof(self, tr_logits, pool, st, client_id):
171
+ """Replace this client's own columns with their out-of-fold logits.
172
+
173
+ The global pool is every client's classifiers concatenated, and only the
174
+ slice this client contributed was fit on tr_ds -- the others already
175
+ predict it out-of-sample, since the rows are not their data. Explicit
176
+ client/model IDs locate the local columns in the ordered global pool.
177
+ """
178
+ local_pool = st.get("local_pool")
179
+ oof = None if local_pool is None else local_pool.load_oof()
180
+ if oof is None:
181
+ return tr_logits
182
+ prefix = f"client_{client_id}/"
183
+ cols = [j for j, model_id in enumerate(pool.model_ids)
184
+ if model_id.startswith(prefix)]
185
+ if len(cols) != oof.shape[1] or oof.shape[0] != tr_logits.shape[0]:
186
+ print(f"[FedDES][warn] cannot place OOF logits (matched {len(cols)} of "
187
+ f"{oof.shape[1]} own classifiers, {oof.shape[0]} vs "
188
+ f"{tr_logits.shape[0]} rows); using in-sample meta-labels.")
189
+ return tr_logits
190
+ tr_logits[:, cols, :] = oof.to(tr_logits.device, tr_logits.dtype)
191
+ return tr_logits
192
+
193
+ # ── base-pool artifact reuse (train once; reuse across graph/GNN sweeps) ──
194
+ def _pool_fp(self) -> str:
195
+ """Fingerprint the ordered local pool and its complete training policy."""
196
+ from graphroute.pool_cache import fingerprint_model, fingerprint_pool
197
+ from rigfl.experiment.env import _package
198
+ template_fingerprints = [
199
+ fingerprint_model(model) for model in self.base_models
200
+ ]
201
+ return fingerprint_pool(
202
+ model_ids=self.model_ids,
203
+ model_fingerprints=template_fingerprints,
204
+ base_config={
205
+ "task": "classification", "num_classes": self.num_classes,
206
+ "split_mode": self.base_split_mode,
207
+ "oof_folds": self.base_oof_folds,
208
+ "lr": self.base_lr, "epochs": self.base_epochs,
209
+ "batch_size": 64, "patience": 20, "optimizer": "Adam",
210
+ "weight_decay": 5e-4, "weighted_by_class": True,
211
+ "es_metric": "val_loss", "inner_val_ratio": 0.2,
212
+ "client_validation_fraction": self.validation_fraction,
213
+ "seed_policy": "experiment_seed_plus_client_id",
214
+ "preprocessing": FEDDES_PREPROCESSING_KEY,
215
+ },
216
+ seed=self.seed,
217
+ code_identity={"graphroute": _package("graphroute"),
218
+ "rigfl": _package("rigfl")})
219
+
220
+ def _train(self, tr_ds, va_ds, device, client_id):
221
+ """Return the trained models and optional out-of-fold logits."""
222
+ from graphroute.run import seed_everything
223
+ seed_everything(self.seed + int(client_id))
224
+ if self.base_split_mode == "oof_stacking":
225
+ from graphroute.pool import train_pool_oof
226
+ models, oof_logits, _ = train_pool_oof(
227
+ self.base_factories, tr_ds, va_ds, device,
228
+ n_folds=self.base_oof_folds, num_classes=self.num_classes,
229
+ lr=self.base_lr, max_epochs=self.base_epochs,
230
+ seed=self.seed + int(client_id), collate_fn=_collate)
231
+ return models, oof_logits # [N_tr, M_local, C], row i unseen by its predictor
232
+ from graphroute.pool import train_pool
233
+ return train_pool(self.base_factories, tr_ds, va_ds, device,
234
+ num_classes=self.num_classes, lr=self.base_lr, max_epochs=self.base_epochs,
235
+ collate_fn=_collate), None # keep multi-input (ts, static) as a MultiTensor
236
+
237
+ def _train_or_load_pool(self, tr_ds, va_ds, device, client_id):
238
+ """Load or train this client's pool for reuse across graph/GNN sweeps."""
239
+ def train():
240
+ return self._train(tr_ds, va_ds, device, client_id)
241
+
242
+ if not self.cache_dir:
243
+ from graphroute.pool_cache import in_memory_pool
244
+ models, oof = train()
245
+ artifact = in_memory_pool(
246
+ models, model_ids=self.model_ids,
247
+ fingerprint_value=self._pool_fp())
248
+ artifact.oof_logits = oof
249
+ return artifact
250
+ from pathlib import Path
251
+
252
+ from graphroute.pool_cache import cached_pool
253
+ fp = self._pool_fp()
254
+ print(f"[FedDES] base pool {fp} (client {client_id})")
255
+ directory = (Path(self.cache_dir) / self.data_id / f"pool_{fp}"
256
+ / "clients" / f"client_{client_id}")
257
+ return cached_pool(
258
+ directory, self.base_factories, train,
259
+ fingerprint_value=fp, model_ids=self.model_ids,
260
+ require_oof=self.base_split_mode == "oof_stacking",
261
+ data_id=self.data_id)
262
+
263
+ def _graphroute_config(self, client_id, device):
264
+ from graphroute.config import GraphRouteConfig
265
+ return GraphRouteConfig(
266
+ task="classification", loss_target="meta_labels",
267
+ dataset=self.data_id or "federated-client",
268
+ num_classes=self.num_classes, seed=self.seed + int(client_id),
269
+ device=device.type,
270
+ base={"split_mode": ("oof_stacking" if self.base_split_mode == "oof_stacking"
271
+ else "split_train")},
272
+ graph={"k": self.graph_k, "pool_calibrate": self.calibrate},
273
+ gnn={"arch": self.gnn_arch, "hidden_dim": self.hidden_dim,
274
+ "epochs": self.gnn_epochs, "patience": self.gnn_patience,
275
+ "es_metric": "val_acc",
276
+ "ens_combination_mode": "hard_weighted_voting",
277
+ "voting_weight_space": "sig"})
278
+
279
+ # ── post-communication local computation: train one complete local GNN ──
280
+ def local_computation(self, model, pool, loader, ctx: OneShotContext):
281
+ del model, loader
282
+ device, st = ctx.device, ctx.client_state
283
+ if pool is None:
284
+ raise RuntimeError("FedDES communication did not provide a classifier pool.")
285
+ train_dataset = st["train_dataset"]
286
+ validation_dataset = st["validation_dataset"]
287
+ tr_loader = DataLoader(train_dataset, 256, shuffle=False, collate_fn=_collate)
288
+ output_dir = (pool.directory / "outputs" / f"client_{ctx.client_id}"
289
+ if pool.directory is not None else None)
290
+ client_pool = pool.for_data(output_directory=output_dir)
291
+ train_logits = client_pool.cached_outputs(
292
+ self._output_name("train"),
293
+ tr_loader, device, task="classification",
294
+ transform=lambda value: self._splice_oof(
295
+ value, pool, st, ctx.client_id))
296
+ client_pool = client_pool.for_data(
297
+ output_directory=output_dir, training_outputs=train_logits)
298
+
299
+ from graphroute.run import fit_graphroute
300
+ st["graphroute_model"] = fit_graphroute(
301
+ self._graphroute_config(ctx.client_id, device), train_dataset,
302
+ validation_set=validation_dataset, pool=client_pool,
303
+ collate_fn=_collate)
304
+ training = st["graphroute_model"].history
305
+ return LocalSelection(
306
+ selected_step=int(training["best_epoch"]),
307
+ metric="accuracy",
308
+ validation_value=float(training["best_metric"]),
309
+ )
310
+
311
+ @staticmethod
312
+ def _output_name(split: str) -> str:
313
+ return f"{split}_logits"
314
+
315
+ def _union_pools(self, uploads: list):
316
+ """Combine the prepared client pools in deterministic client/model order."""
317
+ from graphroute.pool_cache import PoolArtifact, fingerprint
318
+ model_ids, factories, paths, models = [], [], [], []
319
+ can_hold_models = all(artifact.models is not None for artifact in uploads)
320
+ for cid, artifact in enumerate(uploads):
321
+ model_ids.extend(f"client_{cid}/{name}" for name in artifact.model_ids)
322
+ factories.extend(artifact.model_factories)
323
+ paths.extend(artifact.model_paths)
324
+ if can_hold_models:
325
+ models.extend(artifact.models)
326
+ root = (None if not self.cache_dir else
327
+ Path(self.cache_dir) / self.data_id / f"pool_{self._pool_fp()}")
328
+ shared_fingerprint = fingerprint({"ordered_members": model_ids,
329
+ "local_pool": self._pool_fp()})
330
+ if root is not None:
331
+ _write_manifest(root, shared_fingerprint, model_ids, self.data_id)
332
+ return PoolArtifact(
333
+ fingerprint=shared_fingerprint,
334
+ model_ids=tuple(model_ids), model_factories=tuple(factories),
335
+ model_paths=tuple(paths), directory=root,
336
+ output_directory=None, models=models if can_hold_models else None)
337
+
338
+ # prediction: connect the query batch into the train graph, run the GNN, ensemble-select
339
+ def predict(self, client, x, shared) -> Predictions:
340
+ st = client.state
341
+ if "graphroute_model" not in st:
342
+ raise RuntimeError(
343
+ "FedDES cannot predict before its local GNN has been trained.")
344
+
345
+ predicted = st["graphroute_model"].predict(
346
+ _BatchDataset(x), split="batch", cache_outputs=False)
347
+ return Predictions.from_probabilities(
348
+ predicted["probabilities"], labels=predicted["predictions"])
349
+
350
+ # ── small helpers ────────────────────────────────────────────────────────────
351
+ class _BatchDataset(Dataset):
352
+ """Wrap a query batch as a (labelless) Dataset so it goes through the pool.
353
+ Handles single-input ``x`` (a tensor) and multi-input ``x`` (a MultiTensor /
354
+ tuple of tensors, e.g. eICU's ``(ts, static)``) -- indexing samples, not fields."""
355
+ def __init__(self, x):
356
+ self.x = x
357
+ self.multi = isinstance(x, tuple) # MultiTensor is a tuple subclass
358
+ def __len__(self):
359
+ return len(self.x[0]) if self.multi else len(self.x)
360
+ def __getitem__(self, i):
361
+ return (tuple(f[i] for f in self.x) if self.multi else self.x[i]), 0
362
+
363
+
364
+ def _write_manifest(directory: Path, pool_fingerprint: str,
365
+ model_ids: list[str], data_id: str) -> None:
366
+ """Record the deterministic order of the federated pool."""
367
+ import json
368
+ import os
369
+
370
+ directory.mkdir(parents=True, exist_ok=True)
371
+ path = directory / "manifest.json"
372
+ value = {"schema_version": 2, "data_id": data_id,
373
+ "fingerprint": pool_fingerprint, "model_ids": model_ids}
374
+ if path.exists():
375
+ if json.loads(path.read_text()) != value:
376
+ raise RuntimeError(f"FedDES pool manifest does not match {path}")
377
+ return
378
+ tmp = path.with_name(path.name + f".{os.getpid()}.tmp")
379
+ tmp.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n")
380
+ os.replace(tmp, path)
@@ -0,0 +1,106 @@
1
+ """FedGH -- Federated Global Header (Yi et al., ACM MM 2023).
2
+
3
+ Clients keep their feature extractors but share a global classifier head. Each
4
+ round, the server broadcasts the head; each client installs it,
5
+ fine-tunes locally, and uploads per-class prototypes (mean representations);
6
+ the server then *trains* the head on those (prototype -> label) pairs and keeps
7
+ it for the next round. Predictions use the head.
8
+
9
+ Server-side header training follows Algorithm 1 / Eq. 4: the optimizer updates
10
+ the header from uploaded (representation, label) pairs. See DEVIATIONS.md.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import defaultdict
16
+
17
+ import torch
18
+ import torch.nn as nn
19
+ import torch.nn.functional as F
20
+
21
+ from pydantic import Field
22
+
23
+ from rigfl.core.config import AlgorithmConfig
24
+ from rigfl.core.interfaces import Algorithm
25
+ from rigfl.prediction import Predictions
26
+
27
+ Prototypes = dict[int, torch.Tensor]
28
+
29
+
30
+ class FedGHConfig(AlgorithmConfig):
31
+ local_epochs: int = Field(1, ge=1)
32
+ lr: float = Field(0.01, gt=0)
33
+ server_epochs: int = Field(1, ge=1)
34
+ server_lr: float = Field(0.01, gt=0)
35
+
36
+
37
+ class FedGH(Algorithm):
38
+ def __init__(self, config: FedGHConfig, shared_dim: int, num_classes: int):
39
+ super().__init__(config)
40
+ self.shared_dim = shared_dim
41
+ self.num_classes = num_classes
42
+
43
+ @classmethod
44
+ def from_config(cls, config, *, experiment, **resources):
45
+ return cls(config, experiment.shared_dim, experiment.num_classes)
46
+
47
+ def init_globals(self) -> nn.Linear:
48
+ return nn.Linear(self.shared_dim, self.num_classes) # the shared, server-trained head
49
+
50
+ def local_train(self, client, global_head: nn.Linear) -> Prototypes:
51
+ model, loader = client.model, client.train_loader
52
+ device = self.device
53
+ model.head.load_state_dict(global_head.state_dict()) # install the global head
54
+ model.to(device)
55
+ model.train()
56
+ optimizer = torch.optim.SGD(model.parameters(), lr=self.config.lr)
57
+
58
+ for _ in range(self.config.local_epochs):
59
+ for x, y in loader:
60
+ x, y = x.to(device), y.to(device)
61
+ loss = F.cross_entropy(model(x), y)
62
+ optimizer.zero_grad()
63
+ loss.backward()
64
+ optimizer.step()
65
+
66
+ return self._local_prototypes(model, loader, device)
67
+
68
+ @torch.no_grad()
69
+ def _local_prototypes(self, model, loader, device) -> Prototypes:
70
+ model.eval()
71
+ total: dict[int, torch.Tensor] = {}
72
+ count: dict[int, int] = defaultdict(int)
73
+ for x, y in loader:
74
+ x, y = x.to(device), y.to(device)
75
+ for r, label in zip(model.rep(x), y):
76
+ c = label.item()
77
+ total[c] = r.clone() if c not in total else total[c] + r
78
+ count[c] += 1
79
+ return {c: total[c] / count[c] for c in total}
80
+
81
+ def aggregate(self, uploads: list[Prototypes],
82
+ global_head: nn.Linear) -> nn.Linear:
83
+ device = self.device
84
+ pairs = [(proto, c) for protos in uploads for c, proto in protos.items()]
85
+ global_head = global_head.to(device)
86
+ global_head.train()
87
+ optimizer = torch.optim.SGD(
88
+ global_head.parameters(), lr=self.config.server_lr)
89
+
90
+ for _ in range(self.config.server_epochs):
91
+ for proto, c in pairs:
92
+ logit = global_head(proto.unsqueeze(0).to(device))
93
+ loss = F.cross_entropy(logit, torch.tensor([c], device=device))
94
+ optimizer.zero_grad()
95
+ loss.backward()
96
+ optimizer.step()
97
+
98
+ return global_head
99
+
100
+ @torch.no_grad()
101
+ def predict(self, client, x, global_head: nn.Linear) -> Predictions:
102
+ model = client.model
103
+ # Local backbone + the server-trained head: the model that actually
104
+ # predicts, and so the one whose logits become the distribution.
105
+ model.head.load_state_dict(global_head.state_dict())
106
+ return Predictions.from_logits(model(x))