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.
- rigfl/__init__.py +8 -0
- rigfl/algorithms/__init__.py +1 -0
- rigfl/algorithms/fedavg.py +164 -0
- rigfl/algorithms/feddes.py +380 -0
- rigfl/algorithms/fedgh.py +106 -0
- rigfl/algorithms/fedkd.py +143 -0
- rigfl/algorithms/fedproto.py +147 -0
- rigfl/algorithms/fedprox.py +42 -0
- rigfl/algorithms/fedtgp.py +157 -0
- rigfl/algorithms/fml.py +83 -0
- rigfl/algorithms/global_ensemble.py +52 -0
- rigfl/algorithms/lgfedavg.py +70 -0
- rigfl/algorithms/local.py +47 -0
- rigfl/core/__init__.py +27 -0
- rigfl/core/adapters.py +76 -0
- rigfl/core/config.py +17 -0
- rigfl/core/interfaces.py +115 -0
- rigfl/core/model.py +52 -0
- rigfl/core/round.py +399 -0
- rigfl/data/README.md +107 -0
- rigfl/data/__init__.py +61 -0
- rigfl/data/biosilo.py +56 -0
- rigfl/data/builder.py +147 -0
- rigfl/data/config.py +265 -0
- rigfl/data/flower.py +608 -0
- rigfl/data/generate.py +38 -0
- rigfl/data/partitions.py +254 -0
- rigfl/eval/__init__.py +14 -0
- rigfl/eval/metrics.py +240 -0
- rigfl/eval/protocol.py +112 -0
- rigfl/eval/report.py +269 -0
- rigfl/eval/selection.py +274 -0
- rigfl/experiment/__init__.py +11 -0
- rigfl/experiment/artifacts.py +396 -0
- rigfl/experiment/collect.py +380 -0
- rigfl/experiment/config.py +160 -0
- rigfl/experiment/device.py +23 -0
- rigfl/experiment/env.py +110 -0
- rigfl/experiment/launch.py +444 -0
- rigfl/experiment/registry.py +160 -0
- rigfl/experiment/run.py +402 -0
- rigfl/experiment/tracking.py +78 -0
- rigfl/experiment/tuning.py +887 -0
- rigfl/models/__init__.py +1 -0
- rigfl/models/cifar.py +185 -0
- rigfl/models/eicu.py +96 -0
- rigfl/models/registry.py +120 -0
- rigfl/prediction.py +120 -0
- rigfl-0.1.0.dist-info/METADATA +357 -0
- rigfl-0.1.0.dist-info/RECORD +53 -0
- rigfl-0.1.0.dist-info/WHEEL +5 -0
- rigfl-0.1.0.dist-info/licenses/LICENSE +21 -0
- rigfl-0.1.0.dist-info/top_level.txt +1 -0
rigfl/__init__.py
ADDED
|
@@ -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))
|