MEDfl 2.0.10.dev7__tar.gz → 2.0.10.dev8__tar.gz

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 (69) hide show
  1. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/server.py +1 -1
  2. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/client.py +35 -0
  3. medfl-2.0.10.dev8/MEDfl/rw/holdout.py +495 -0
  4. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/server.py +37 -0
  5. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/strategy.py +39 -6
  6. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/client.py +51 -0
  7. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/strategy.py +46 -9
  8. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/PKG-INFO +1 -1
  9. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/medfl.egg-info/PKG-INFO +1 -1
  10. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/medfl.egg-info/SOURCES.txt +3 -1
  11. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/setup.py +1 -1
  12. medfl-2.0.10.dev8/tests/test_rw_holdout.py +171 -0
  13. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/LICENSE +0 -0
  14. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/__init__.py +0 -0
  15. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/client.py +0 -0
  16. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/dynamicModal.py +0 -0
  17. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/federated_dataset.py +0 -0
  18. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/flpipeline.py +0 -0
  19. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/model.py +0 -0
  20. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/params.yaml +0 -0
  21. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/params_optimiser.py +0 -0
  22. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/plot.py +0 -0
  23. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/server.py +0 -0
  24. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/shap/__init__.py +0 -0
  25. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/shap/aggregation.py +0 -0
  26. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/shap/config.py +0 -0
  27. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/shap/explainer.py +0 -0
  28. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/shap/xgboost_explainer.py +0 -0
  29. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/strategy.py +0 -0
  30. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/utils.py +0 -0
  31. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
  32. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/client.py +0 -0
  33. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/config.py +0 -0
  34. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/data.py +0 -0
  35. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
  36. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
  37. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/strategy.py +0 -0
  38. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/NetManager/__init__.py +0 -0
  39. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/NetManager/database_connector.py +0 -0
  40. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/NetManager/dataset.py +0 -0
  41. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/NetManager/flsetup.py +0 -0
  42. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/NetManager/net_helper.py +0 -0
  43. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/NetManager/net_manager_queries.py +0 -0
  44. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/NetManager/network.py +0 -0
  45. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/NetManager/node.py +0 -0
  46. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/__init__.py +0 -0
  47. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/__init__.py +0 -0
  48. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/model.py +0 -0
  49. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/__init__.py +0 -0
  50. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/config.py +0 -0
  51. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/data.py +0 -0
  52. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/metrics.py +0 -0
  53. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/serialization.py +0 -0
  54. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/scripts/__init__.py +0 -0
  55. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/scripts/base.py +0 -0
  56. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/MEDfl/scripts/create_db.py +0 -0
  57. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/README.md +0 -0
  58. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/alembic/__init__.py +0 -0
  59. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/alembic/env.py +0 -0
  60. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/medfl.egg-info/dependency_links.txt +0 -0
  61. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/medfl.egg-info/requires.txt +0 -0
  62. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/medfl.egg-info/top_level.txt +0 -0
  63. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/setup.cfg +0 -0
  64. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/tests/test_dataset.py +0 -0
  65. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/tests/test_federated_dataset.py +0 -0
  66. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/tests/test_flpipeline.py +0 -0
  67. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/tests/test_flsetup.py +0 -0
  68. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/tests/test_network.py +0 -0
  69. {medfl-2.0.10.dev7 → medfl-2.0.10.dev8}/tests/test_node.py +0 -0
@@ -743,4 +743,4 @@ class XGBoostSimulationServer:
743
743
  flush=True,
744
744
  )
745
745
 
746
- return self.history
746
+ return self.history
@@ -12,6 +12,7 @@ from sklearn.metrics import accuracy_score, roc_auc_score
12
12
  from sklearn.model_selection import train_test_split
13
13
  from MEDfl.rw.model import Net # votre définition de modèle
14
14
  from MEDfl.LearningManager.shap import LocalPyTorchSHAPExplainer
15
+ from MEDfl.rw.holdout import run_local_holdout
15
16
  import socket
16
17
  import platform
17
18
  import psutil
@@ -77,6 +78,10 @@ class FlowerClient(fl.client.NumPyClient):
77
78
  Comma-separated list of IDs (or line numbers) to use as test set.
78
79
  seed : int, optional
79
80
  Random seed used for splits. Default is ``42``.
81
+ holdout_path : str, optional
82
+ Path to a local holdout CSV, never used for training. When the server
83
+ enables the federated holdout test, the final global model is tested
84
+ on it after the last round. Default is ``None`` (no holdout).
80
85
  """
81
86
  def __init__(
82
87
  self,
@@ -89,6 +94,7 @@ class FlowerClient(fl.client.NumPyClient):
89
94
  id_col=None,
90
95
  test_ids=None,
91
96
  seed=42,
97
+ holdout_path=None,
92
98
  ):
93
99
  self.server_address = server_address
94
100
  self.dp_config = dp_config
@@ -97,6 +103,8 @@ class FlowerClient(fl.client.NumPyClient):
97
103
  self.id_col = id_col
98
104
  self.test_ids = test_ids
99
105
  self.seed = seed
106
+ # read only when the server asks for the holdout test (features/target come from the server)
107
+ self.holdout_path = holdout_path
100
108
 
101
109
  # Load the CSV once; actual column selection happens on first fit using server config
102
110
  self._df = pd.read_csv(data_path)
@@ -585,10 +593,37 @@ class FlowerClient(fl.client.NumPyClient):
585
593
  flush=True,
586
594
  )
587
595
 
596
+ # ---------------------------------------------------------------
597
+ # Final-round federated holdout test
598
+ #
599
+ # Same final global parameters as SHAP above, tested on the local
600
+ # holdout file. Rows with missing feature values are dropped since
601
+ # the network cannot predict them.
602
+ # ---------------------------------------------------------------
603
+ if bool(config.get("holdout_enabled", False)):
604
+ metrics.update(
605
+ run_local_holdout(
606
+ config=config,
607
+ holdout_path=self.holdout_path,
608
+ features=self.effective_features,
609
+ target=self.effective_target,
610
+ predict=self._predict_probabilities,
611
+ client_id=socket.gethostname(),
612
+ drop_missing_features=True,
613
+ )
614
+ )
615
+
588
616
  print(f"Evaluation metrics: {metrics}")
589
617
 
590
618
  return float(avg_loss), len(self.test_loader.dataset), metrics
591
619
 
620
+ def _predict_probabilities(self, X):
621
+ """Positive-class probabilities of the current model for a feature array."""
622
+ self.model.eval()
623
+ with torch.no_grad():
624
+ logits = self.model(torch.tensor(X, dtype=torch.float32))
625
+ return torch.sigmoid(logits.reshape(-1)).cpu().numpy()
626
+
592
627
  def get_properties(self, config):
593
628
  hostname = socket.gethostname()
594
629
  os_type = platform.system()
@@ -0,0 +1,495 @@
1
+ """
2
+ Federated holdout test for MEDfl real-world pipelines.
3
+
4
+ After the last round, every client tests the final global model on its own
5
+ local holdout CSV (a file never used for training). Clients only send
6
+ aggregate numbers back to the server:
7
+
8
+ - the confusion matrix at the decision threshold,
9
+ - their local metrics,
10
+ - two histograms of the predicted probabilities (positives / negatives).
11
+
12
+ The server sums the confusion matrices to get exact pooled metrics
13
+ (sensitivity, specificity, PPV, NPV, F1, accuracy) and sums the histograms
14
+ to rebuild a pooled ROC curve and AUC. The pooled AUC is exact up to the
15
+ histogram bin width: two scores falling in the same bin count as a tie.
16
+
17
+ The holdout goes through the same preprocessing as the client training
18
+ data: the server features in the server order, the raw values (the
19
+ real-world clients do not impute or scale), and a binary 0/1 target.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ from dataclasses import dataclass
27
+ from typing import Any, Callable, Dict, List, Optional, Tuple
28
+
29
+ import numpy as np
30
+ import pandas as pd
31
+ from sklearn.metrics import roc_auc_score
32
+
33
+ # Statuses a client can report in metrics["holdout_status"]
34
+ HOLDOUT_COMPLETED = "completed"
35
+ HOLDOUT_SKIPPED = "skipped" # fewer rows than minimum_samples
36
+ HOLDOUT_MISSING = "missing" # no holdout file on this client
37
+ HOLDOUT_FAILED = "failed"
38
+ HOLDOUT_UNSUPPORTED = "unsupported_task"
39
+
40
+
41
+ @dataclass
42
+ class HoldoutConfig:
43
+ """
44
+ Configuration for the optional post-training federated holdout test.
45
+ """
46
+
47
+ enabled: bool = False
48
+
49
+ # A prediction is positive when its probability is >= threshold
50
+ threshold: float = 0.5
51
+
52
+ # Number of probability bins used to rebuild the pooled ROC curve / AUC
53
+ num_bins: int = 100
54
+
55
+ # Clients with fewer holdout rows do not send results
56
+ minimum_samples: int = 10
57
+
58
+ include_client_results: bool = True
59
+
60
+ def validate(self) -> None:
61
+ if not isinstance(self.enabled, bool):
62
+ raise TypeError("Holdout enabled must be a boolean")
63
+
64
+ if not 0.0 < float(self.threshold) < 1.0:
65
+ raise ValueError("Holdout threshold must be between 0 and 1")
66
+
67
+ if int(self.num_bins) < 2:
68
+ raise ValueError("Holdout num_bins must be at least 2")
69
+
70
+ if int(self.minimum_samples) <= 0:
71
+ raise ValueError("Holdout minimum_samples must be greater than zero")
72
+
73
+ def to_evaluate_config(self) -> Dict[str, Any]:
74
+ """
75
+ Keys added to the evaluate config of the final round.
76
+ Flower configs are Scalar-only.
77
+ """
78
+ return {
79
+ "holdout_enabled": True,
80
+ "holdout_threshold": float(self.threshold),
81
+ "holdout_num_bins": int(self.num_bins),
82
+ "holdout_minimum_samples": int(self.minimum_samples),
83
+ }
84
+
85
+
86
+ # ----------------------------------------------------------------------
87
+ # Client side
88
+ # ----------------------------------------------------------------------
89
+
90
+
91
+ def load_holdout(
92
+ path: str,
93
+ features: List[str],
94
+ target: str,
95
+ drop_missing_features: bool,
96
+ ) -> Tuple[np.ndarray, np.ndarray, Dict[str, Any]]:
97
+ """
98
+ Read a holdout CSV with the training schema.
99
+
100
+ Rows without a target are dropped. Rows with a missing feature value are
101
+ dropped when drop_missing_features is True (the neural network cannot
102
+ predict them); XGBoost keeps them since it handles missing values.
103
+
104
+ Returns (X, y, dataset information).
105
+ """
106
+ data = pd.read_csv(path)
107
+
108
+ if target not in data.columns:
109
+ raise ValueError(
110
+ f"The holdout dataset has no '{target}' column (the model target)"
111
+ )
112
+
113
+ missing = [column for column in features if column not in data.columns]
114
+ if missing:
115
+ raise ValueError(
116
+ f"The holdout dataset is missing {len(missing)} training "
117
+ f"column(s): {', '.join(missing)}"
118
+ )
119
+
120
+ X = data[features].apply(pd.to_numeric, errors="coerce")
121
+ non_numeric = [
122
+ column
123
+ for column in features
124
+ if data[column].notna().any() and X[column].isna().all()
125
+ ]
126
+ if non_numeric:
127
+ raise ValueError(
128
+ f"These holdout columns are not numeric: {', '.join(non_numeric)}"
129
+ )
130
+
131
+ y = pd.to_numeric(data[target], errors="coerce")
132
+ has_target = y.notna().values
133
+ has_features = X.notna().all(axis=1).values
134
+
135
+ keep = has_target & has_features if drop_missing_features else has_target
136
+ X, y = X.values[keep], y.values[keep].astype(float)
137
+
138
+ classes = set(np.unique(y).tolist())
139
+ if not classes.issubset({0.0, 1.0}):
140
+ raise ValueError(
141
+ f"The target '{target}' must be binary (0/1) in the holdout "
142
+ f"dataset, found {len(classes)} values: {sorted(classes)[:10]}"
143
+ )
144
+
145
+ info = {
146
+ "name": os.path.basename(path),
147
+ "rows_in_file": int(len(data)),
148
+ "rows_without_target": int((~has_target).sum()),
149
+ "rows_with_missing_features": int(
150
+ (has_target & ~has_features).sum()
151
+ ),
152
+ "rows_missing_features_dropped": bool(drop_missing_features),
153
+ }
154
+ return X.astype(np.float32), y, info
155
+
156
+
157
+ def _ratio(numerator: float, denominator: float) -> Optional[float]:
158
+ """None when undefined (e.g. no positive case), so JSON stays valid."""
159
+ return float(numerator) / float(denominator) if denominator else None
160
+
161
+
162
+ def _confusion(y_true: np.ndarray, y_prob: np.ndarray, threshold: float) -> Dict[str, int]:
163
+ y_pred = (y_prob >= threshold).astype(float)
164
+ return {
165
+ "TP": int(((y_pred == 1) & (y_true == 1)).sum()),
166
+ "FP": int(((y_pred == 1) & (y_true == 0)).sum()),
167
+ "FN": int(((y_pred == 0) & (y_true == 1)).sum()),
168
+ "TN": int(((y_pred == 0) & (y_true == 0)).sum()),
169
+ }
170
+
171
+
172
+ def confusion_metrics(confusion: Dict[str, int]) -> Dict[str, Optional[float]]:
173
+ """Threshold metrics from a confusion matrix (same definitions as the simulation holdout)."""
174
+ tp, fp, fn, tn = (confusion[key] for key in ("TP", "FP", "FN", "TN"))
175
+ return {
176
+ "accuracy": _ratio(tp + tn, tp + tn + fp + fn),
177
+ "sensitivity": _ratio(tp, tp + fn),
178
+ "specificity": _ratio(tn, tn + fp),
179
+ "ppv": _ratio(tp, tp + fp),
180
+ "npv": _ratio(tn, tn + fn),
181
+ # same value as 2 * sensitivity * ppv / (sensitivity + ppv), and 0 when there is no true positive
182
+ "f1": _ratio(2 * tp, 2 * tp + fp + fn),
183
+ }
184
+
185
+
186
+ def _bin_edges(num_bins: int) -> np.ndarray:
187
+ return np.linspace(0.0, 1.0, int(num_bins) + 1)
188
+
189
+
190
+ def local_holdout_summary(
191
+ y_true: np.ndarray,
192
+ y_prob: np.ndarray,
193
+ threshold: float,
194
+ num_bins: int,
195
+ minimum_samples: int,
196
+ client_id: str,
197
+ dataset: Dict[str, Any],
198
+ ) -> Dict[str, Any]:
199
+ """
200
+ Summary of one client's holdout test, safe to send to the server.
201
+ Below minimum_samples rows, only the row count is sent.
202
+ """
203
+ rows = int(len(y_true))
204
+ positives = int(y_true.sum())
205
+ summary = {
206
+ "client_id": client_id,
207
+ "dataset": {**dataset, "rows": rows, "positives": positives, "negatives": rows - positives},
208
+ }
209
+
210
+ if rows < int(minimum_samples):
211
+ return {
212
+ **summary,
213
+ "status": HOLDOUT_SKIPPED,
214
+ "reason": (
215
+ f"{rows} usable holdout row(s), fewer than the minimum of "
216
+ f"{int(minimum_samples)}"
217
+ ),
218
+ }
219
+
220
+ y_prob = np.clip(np.asarray(y_prob, dtype=float), 0.0, 1.0)
221
+ confusion = _confusion(y_true, y_prob, threshold)
222
+ edges = _bin_edges(num_bins)
223
+
224
+ metrics = {
225
+ "auc": (
226
+ float(roc_auc_score(y_true, y_prob))
227
+ if 0 < positives < rows
228
+ else None
229
+ ),
230
+ **confusion_metrics(confusion),
231
+ }
232
+
233
+ return {
234
+ **summary,
235
+ "status": HOLDOUT_COMPLETED,
236
+ "threshold": float(threshold),
237
+ "num_bins": int(num_bins),
238
+ "metrics": metrics,
239
+ "confusion_matrix": confusion,
240
+ "pos_hist": np.histogram(y_prob[y_true == 1], bins=edges)[0].astype(int).tolist(),
241
+ "neg_hist": np.histogram(y_prob[y_true == 0], bins=edges)[0].astype(int).tolist(),
242
+ }
243
+
244
+
245
+ def run_local_holdout(
246
+ config: Dict[str, Any],
247
+ holdout_path: Optional[str],
248
+ features: List[str],
249
+ target: str,
250
+ predict: Callable[[np.ndarray], np.ndarray],
251
+ client_id: str,
252
+ drop_missing_features: bool,
253
+ ) -> Dict[str, Any]:
254
+ """
255
+ Run the holdout test on a client and return the entries to add to the
256
+ Flower evaluate metrics: holdout_status, then holdout_result (JSON) or
257
+ holdout_error.
258
+
259
+ predict maps the holdout features to positive-class probabilities with
260
+ the final global model.
261
+ """
262
+ if not holdout_path or not os.path.exists(holdout_path):
263
+ print(f"[Client/Holdout] No holdout file on this client ({holdout_path})", flush=True)
264
+ return {
265
+ "holdout_status": HOLDOUT_MISSING,
266
+ "holdout_client_id": client_id,
267
+ "holdout_error": "No holdout file on this client",
268
+ }
269
+
270
+ try:
271
+ X, y, dataset = load_holdout(holdout_path, features, target, drop_missing_features)
272
+ probabilities = predict(X) if len(y) else np.empty((0,))
273
+ summary = local_holdout_summary(
274
+ y_true=y,
275
+ y_prob=probabilities,
276
+ threshold=float(config.get("holdout_threshold", 0.5)),
277
+ num_bins=int(config.get("holdout_num_bins", 100)),
278
+ minimum_samples=int(config.get("holdout_minimum_samples", 10)),
279
+ client_id=client_id,
280
+ dataset=dataset,
281
+ )
282
+ print(
283
+ f"[Client/Holdout] {summary['status']} | client={client_id} | "
284
+ f"rows={summary['dataset']['rows']} | metrics={summary.get('metrics')}",
285
+ flush=True,
286
+ )
287
+ return {
288
+ "holdout_status": summary["status"],
289
+ "holdout_client_id": client_id,
290
+ "holdout_result": json.dumps(summary, allow_nan=False),
291
+ }
292
+ except Exception as exc:
293
+ print(f"[Client/Holdout] Holdout test failed: {exc}", flush=True)
294
+ return {
295
+ "holdout_status": HOLDOUT_FAILED,
296
+ "holdout_client_id": client_id,
297
+ "holdout_error": str(exc),
298
+ }
299
+
300
+
301
+ # ----------------------------------------------------------------------
302
+ # Server side
303
+ # ----------------------------------------------------------------------
304
+
305
+
306
+ def pooled_roc_auc(pos_hist: np.ndarray, neg_hist: np.ndarray) -> Tuple[Optional[float], Optional[Dict[str, List[float]]]]:
307
+ """
308
+ ROC curve and AUC from score histograms.
309
+
310
+ The thresholds are the bin edges. The AUC is the Mann-Whitney statistic
311
+ where two scores in the same bin count as a tie (half a pair).
312
+ """
313
+ positives, negatives = pos_hist.sum(), neg_hist.sum()
314
+ if positives == 0 or negatives == 0:
315
+ return None, None
316
+
317
+ # positives in strictly higher bins than each bin
318
+ positives_above = np.concatenate([np.cumsum(pos_hist[::-1])[::-1][1:], [0]])
319
+ auc = float((neg_hist * (positives_above + 0.5 * pos_hist)).sum() / (positives * negatives))
320
+
321
+ # highest threshold first, starting at (0, 0)
322
+ tpr = np.concatenate([[0.0], np.cumsum(pos_hist[::-1]) / positives])
323
+ fpr = np.concatenate([[0.0], np.cumsum(neg_hist[::-1]) / negatives])
324
+ return auc, {"fpr": [float(v) for v in fpr], "tpr": [float(v) for v in tpr]}
325
+
326
+
327
+ def _client_name(client_proxy) -> str:
328
+ return str(getattr(client_proxy, "cid", client_proxy))
329
+
330
+
331
+ def aggregate_federated_holdout(
332
+ results,
333
+ failures,
334
+ holdout_config: HoldoutConfig,
335
+ backend: str,
336
+ ) -> Dict[str, Any]:
337
+ """
338
+ Aggregate the clients' holdout summaries of the final round.
339
+
340
+ Args:
341
+ results: Flower evaluate results, a list of (client_proxy, EvaluateRes)
342
+ failures: Flower evaluate failures
343
+ holdout_config: the server HoldoutConfig
344
+ backend: "pytorch" or "xgboost"
345
+
346
+ The "pooled" entry has the same keys as the simulation holdout results
347
+ (dataset, threshold, metrics, confusion_matrix, roc_curve).
348
+ """
349
+ completed, skipped, missing, failed = [], [], [], []
350
+
351
+ for client_proxy, eval_res in results:
352
+ metrics = eval_res.metrics or {}
353
+ status = metrics.get("holdout_status")
354
+ # the client hostname when the client sent it, else the Flower client id
355
+ name = str(metrics.get("holdout_client_id") or _client_name(client_proxy))
356
+
357
+ summary = None
358
+ if metrics.get("holdout_result"):
359
+ try:
360
+ summary = json.loads(metrics["holdout_result"])
361
+ except Exception as exc:
362
+ failed.append({"client_id": name, "error": f"Invalid holdout JSON: {exc}"})
363
+ continue
364
+
365
+ if status == HOLDOUT_COMPLETED and summary:
366
+ if int(summary.get("num_bins", -1)) != int(holdout_config.num_bins):
367
+ failed.append({
368
+ "client_id": summary.get("client_id", name),
369
+ "error": f"Used {summary.get('num_bins')} histogram bins instead of {holdout_config.num_bins}",
370
+ })
371
+ continue
372
+ completed.append(summary)
373
+ elif status == HOLDOUT_SKIPPED and summary:
374
+ skipped.append({
375
+ "client_id": summary.get("client_id", name),
376
+ "rows": summary["dataset"]["rows"],
377
+ "reason": summary.get("reason"),
378
+ })
379
+ elif status == HOLDOUT_MISSING:
380
+ missing.append({"client_id": name, "error": metrics.get("holdout_error")})
381
+ else:
382
+ failed.append({
383
+ "client_id": name,
384
+ "error": metrics.get("holdout_error", "No holdout result returned"),
385
+ })
386
+
387
+ for failure in failures or []:
388
+ if isinstance(failure, BaseException):
389
+ failed.append({"client_id": None, "error": str(failure)})
390
+ else:
391
+ failed.append({"client_id": _client_name(failure[0]), "error": "Evaluation failed"})
392
+
393
+ result: Dict[str, Any] = {
394
+ "backend": backend,
395
+ "threshold": float(holdout_config.threshold),
396
+ "num_bins": int(holdout_config.num_bins),
397
+ "minimum_samples": int(holdout_config.minimum_samples),
398
+ "participating_clients": len(completed),
399
+ "skipped_clients": skipped,
400
+ "missing_clients": missing,
401
+ "failed_clients": failed,
402
+ }
403
+
404
+ if not completed:
405
+ return {
406
+ **result,
407
+ "status": "failed",
408
+ "error": "No client returned a holdout result",
409
+ }
410
+
411
+ confusion = {
412
+ key: int(sum(summary["confusion_matrix"][key] for summary in completed))
413
+ for key in ("TP", "FP", "FN", "TN")
414
+ }
415
+ pos_hist = np.sum([summary["pos_hist"] for summary in completed], axis=0)
416
+ neg_hist = np.sum([summary["neg_hist"] for summary in completed], axis=0)
417
+ auc, roc = pooled_roc_auc(pos_hist, neg_hist)
418
+
419
+ rows = sum(summary["dataset"]["rows"] for summary in completed)
420
+ positives = sum(summary["dataset"]["positives"] for summary in completed)
421
+
422
+ weighted = [
423
+ (summary["dataset"]["rows"], summary["metrics"]["auc"])
424
+ for summary in completed
425
+ if summary["metrics"].get("auc") is not None
426
+ ]
427
+ auc_weighted_mean = (
428
+ sum(n * value for n, value in weighted) / sum(n for n, _ in weighted)
429
+ if weighted
430
+ else None
431
+ )
432
+
433
+ result.update({
434
+ "status": "completed",
435
+ "pooled": {
436
+ "dataset": {
437
+ "name": f"Federated holdout ({len(completed)} client(s))",
438
+ "rows": int(rows),
439
+ "positives": int(positives),
440
+ "negatives": int(rows - positives),
441
+ "rows_without_target": int(
442
+ sum(summary["dataset"]["rows_without_target"] for summary in completed)
443
+ ),
444
+ },
445
+ "threshold": float(holdout_config.threshold),
446
+ "metrics": {"auc": auc, **confusion_metrics(confusion)},
447
+ "confusion_matrix": confusion,
448
+ "roc_curve": roc,
449
+ },
450
+ "auc_method": f"pooled from {int(holdout_config.num_bins)}-bin score histograms",
451
+ "auc_weighted_mean": auc_weighted_mean,
452
+ })
453
+
454
+ if holdout_config.include_client_results:
455
+ result["clients"] = [
456
+ {
457
+ "client_id": summary["client_id"],
458
+ "dataset": summary["dataset"],
459
+ "metrics": summary["metrics"],
460
+ "confusion_matrix": summary["confusion_matrix"],
461
+ }
462
+ for summary in completed
463
+ ]
464
+
465
+ return result
466
+
467
+
468
+ # The app reads the result from this log line (the XGBoost server answers
469
+ # its caller before training ends)
470
+ HOLDOUT_LOG_PREFIX = "[Server/Holdout] FEDERATED_HOLDOUT_RESULT "
471
+
472
+
473
+ def finalize_federated_holdout(
474
+ results,
475
+ failures,
476
+ holdout_config: HoldoutConfig,
477
+ backend: str,
478
+ ) -> Dict[str, Any]:
479
+ """
480
+ Aggregate the final-round holdout results without ever stopping the
481
+ server, then print them on one line prefixed by HOLDOUT_LOG_PREFIX.
482
+ """
483
+ try:
484
+ result = aggregate_federated_holdout(results, failures, holdout_config, backend)
485
+ except Exception as exc:
486
+ result = {"status": "failed", "backend": backend, "error": f"Holdout aggregation failed: {exc}"}
487
+
488
+ print(
489
+ f"[Server/Holdout] Federated holdout {result['status']} | "
490
+ f"clients={result.get('participating_clients', 0)} | "
491
+ f"metrics={(result.get('pooled') or {}).get('metrics')}",
492
+ flush=True,
493
+ )
494
+ print(HOLDOUT_LOG_PREFIX + json.dumps(result, allow_nan=False, default=str), flush=True)
495
+ return result
@@ -3,6 +3,7 @@ from flwr.server.strategy import FedAvg
3
3
  from flwr.server.server import ServerConfig
4
4
  from MEDfl.rw.strategy import Strategy
5
5
  from MEDfl.LearningManager.shap import SHAPConfig
6
+ from MEDfl.rw.holdout import HoldoutConfig
6
7
  import asyncio
7
8
  from flwr.server.client_manager import ClientManager
8
9
  from flwr.server.client_proxy import ClientProxy
@@ -35,6 +36,7 @@ class FederatedServer:
35
36
  strategy=None,
36
37
  certificates=None,
37
38
  shap_config=None,
39
+ holdout_config=None,
38
40
  ):
39
41
  """
40
42
  Initialize the FederatedServer.
@@ -45,6 +47,9 @@ class FederatedServer:
45
47
  num_rounds (int): Number of federated learning rounds to execute.
46
48
  strategy (Optional[Strategy]): Optional custom strategy wrapper.
47
49
  certificates (Optional[Any]): Optional TLS certificates.
50
+ shap_config (Optional[SHAPConfig]): Optional final-round federated SHAP.
51
+ holdout_config (Optional[HoldoutConfig]): Optional final-round federated
52
+ holdout test. When None, the strategy's own holdout_config is kept.
48
53
  """
49
54
  # Server address and configuration
50
55
  self.server_address = f"{host}:{port}"
@@ -62,6 +67,16 @@ class FederatedServer:
62
67
  # The Strategy owns the final-round SHAP orchestration.
63
68
  self.strategy_wrapper.shap_config = self.shap_config
64
69
 
70
+ # The Strategy also owns the final-round holdout test.
71
+ if holdout_config is not None:
72
+ holdout_config.validate()
73
+ self.strategy_wrapper.holdout_config = holdout_config
74
+ self.holdout_config = getattr(
75
+ self.strategy_wrapper,
76
+ "holdout_config",
77
+ None,
78
+ ) or HoldoutConfig(enabled=False)
79
+
65
80
  # Keep the strategy's final-round marker synchronized with
66
81
  # the actual Flower ServerConfig.
67
82
  self.strategy_wrapper.total_rounds = int(num_rounds)
@@ -134,6 +149,14 @@ class FederatedServer:
134
149
  }
135
150
  self.local_shap_results = []
136
151
 
152
+ self.federated_holdout_result = {
153
+ "status": (
154
+ "pending"
155
+ if self.holdout_config.enabled
156
+ else "disabled"
157
+ )
158
+ }
159
+
137
160
 
138
161
  def start(self):
139
162
  """
@@ -169,6 +192,20 @@ class FederatedServer:
169
192
  flush=True,
170
193
  )
171
194
 
195
+ # The strategy aggregates the holdout test during the final evaluation.
196
+ self.federated_holdout_result = getattr(
197
+ self.strategy_wrapper,
198
+ "federated_holdout_result",
199
+ self.federated_holdout_result,
200
+ )
201
+
202
+ if self.holdout_config.enabled:
203
+ print(
204
+ "[Server/Holdout] Final federated holdout status: "
205
+ f"{self.federated_holdout_result.get('status')}",
206
+ flush=True,
207
+ )
208
+
172
209
 
173
210
  class TrackingClientManager(fl.server.client_manager.SimpleClientManager):
174
211
  """