MEDfl 2.0.10.dev10__tar.gz → 2.0.10.dev11__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 (70) hide show
  1. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/client.py +24 -10
  2. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/holdout.py +68 -38
  3. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/xgboost/client.py +16 -9
  4. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/PKG-INFO +1 -1
  5. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/medfl.egg-info/PKG-INFO +1 -1
  6. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/setup.py +1 -1
  7. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/LICENSE +0 -0
  8. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/__init__.py +0 -0
  9. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/client.py +0 -0
  10. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/dynamicModal.py +0 -0
  11. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/federated_dataset.py +0 -0
  12. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/flpipeline.py +0 -0
  13. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/model.py +0 -0
  14. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/params.yaml +0 -0
  15. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/params_optimiser.py +0 -0
  16. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/plot.py +0 -0
  17. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/server.py +0 -0
  18. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/shap/__init__.py +0 -0
  19. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/shap/aggregation.py +0 -0
  20. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/shap/config.py +0 -0
  21. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/shap/explainer.py +0 -0
  22. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/shap/xgboost_explainer.py +0 -0
  23. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/strategy.py +0 -0
  24. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/utils.py +0 -0
  25. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
  26. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/xgboost/client.py +0 -0
  27. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/xgboost/config.py +0 -0
  28. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/xgboost/data.py +0 -0
  29. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
  30. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
  31. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/xgboost/server.py +0 -0
  32. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/LearningManager/xgboost/strategy.py +0 -0
  33. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/NetManager/__init__.py +0 -0
  34. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/NetManager/database_connector.py +0 -0
  35. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/NetManager/dataset.py +0 -0
  36. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/NetManager/flsetup.py +0 -0
  37. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/NetManager/net_helper.py +0 -0
  38. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/NetManager/net_manager_queries.py +0 -0
  39. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/NetManager/network.py +0 -0
  40. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/NetManager/node.py +0 -0
  41. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/__init__.py +0 -0
  42. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/__init__.py +0 -0
  43. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/identity.py +0 -0
  44. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/model.py +0 -0
  45. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/server.py +0 -0
  46. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/strategy.py +0 -0
  47. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/xgboost/__init__.py +0 -0
  48. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/xgboost/config.py +0 -0
  49. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/xgboost/data.py +0 -0
  50. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/xgboost/metrics.py +0 -0
  51. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/xgboost/serialization.py +0 -0
  52. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/rw/xgboost/strategy.py +0 -0
  53. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/scripts/__init__.py +0 -0
  54. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/scripts/base.py +0 -0
  55. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/MEDfl/scripts/create_db.py +0 -0
  56. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/README.md +0 -0
  57. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/alembic/__init__.py +0 -0
  58. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/alembic/env.py +0 -0
  59. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/medfl.egg-info/SOURCES.txt +0 -0
  60. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/medfl.egg-info/dependency_links.txt +0 -0
  61. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/medfl.egg-info/requires.txt +0 -0
  62. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/medfl.egg-info/top_level.txt +0 -0
  63. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/setup.cfg +0 -0
  64. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/tests/test_dataset.py +0 -0
  65. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/tests/test_federated_dataset.py +0 -0
  66. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/tests/test_flpipeline.py +0 -0
  67. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/tests/test_flsetup.py +0 -0
  68. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/tests/test_network.py +0 -0
  69. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/tests/test_node.py +0 -0
  70. {medfl-2.0.10.dev10 → medfl-2.0.10.dev11}/tests/test_rw_holdout.py +0 -0
@@ -263,6 +263,14 @@ class FlowerClient(fl.client.NumPyClient):
263
263
  X_train, y_train = X_trval, y_trval
264
264
  X_val, y_val = np.empty((0, X_all.shape[1])), np.empty((0,))
265
265
 
266
+ # the whole learning set, for the optimized holdout threshold. Kept as arrays: with DP, Opacus replaces
267
+ # the train loader by a Poisson-sampling one that does not go over every training row
268
+ self._learning_splits = {
269
+ "train": (X_train, y_train),
270
+ "validation": (X_val, y_val),
271
+ "test": (X_test, y_test),
272
+ }
273
+
266
274
  # ---------- build loaders
267
275
  batch_size = self.dp_config.batch_size if self.dp_config else 32
268
276
  self.train_loader = self._mk_loader(X_train, y_train, batch_size, shuffle=True)
@@ -611,7 +619,7 @@ class FlowerClient(fl.client.NumPyClient):
611
619
  predict=self._predict_probabilities,
612
620
  client_id=client_name(),
613
621
  drop_missing_features=True,
614
- validation=self._validation_predictions,
622
+ validation=self._learning_set_predictions,
615
623
  )
616
624
  )
617
625
 
@@ -626,15 +634,21 @@ class FlowerClient(fl.client.NumPyClient):
626
634
  logits = self.model(torch.tensor(X, dtype=torch.float32))
627
635
  return torch.sigmoid(logits.reshape(-1)).cpu().numpy()
628
636
 
629
- def _validation_predictions(self):
630
- """(y_true, y_prob) of the validation split, for the optimized holdout threshold."""
631
- if self.val_loader is None:
632
- return np.empty((0,)), np.empty((0,))
633
- y_parts, prob_parts = [], []
634
- for X_batch, y_batch in self.val_loader:
635
- y_parts.append(y_batch.cpu().numpy().reshape(-1))
636
- prob_parts.append(self._predict_probabilities(X_batch.cpu().numpy()))
637
- return np.concatenate(y_parts), np.concatenate(prob_parts)
637
+ def _learning_set_predictions(self):
638
+ """
639
+ (y_true, y_prob, rows per split) of the whole learning set (train + validation + test splits),
640
+ for the optimized holdout threshold.
641
+ """
642
+ y_parts, prob_parts, splits = [], [], {}
643
+ for split, (X, y) in getattr(self, "_learning_splits", {}).items():
644
+ y = np.asarray(y, dtype=float).reshape(-1)
645
+ splits[split] = int(len(y))
646
+ if len(y):
647
+ y_parts.append(y)
648
+ prob_parts.append(self._predict_probabilities(np.asarray(X, dtype=np.float32)))
649
+ if not y_parts:
650
+ return np.empty((0,)), np.empty((0,)), splits
651
+ return np.concatenate(y_parts), np.concatenate(prob_parts), splits
638
652
 
639
653
  def get_properties(self, config):
640
654
  hostname = client_name()
@@ -19,10 +19,12 @@ data: the server features in the server order, the raw values (the
19
19
  real-world clients do not impute or scale), and a binary 0/1 target.
20
20
 
21
21
  Optimized threshold (threshold_source="optimized"): in the same final
22
- round, every client also sends the two score histograms of its validation
23
- split (never used for training). The server sums them and picks the
24
- threshold among the histogram bin edges (Youden's J or a target
25
- sensitivity). At a bin edge, the histograms give exact confusion matrices,
22
+ round, every client also sends the two score histograms of its whole
23
+ learning set (train + validation + test splits, LEARNING_SET). The server
24
+ sums them and picks the threshold among the histogram bin edges (Youden's
25
+ J or a target sensitivity). The training rows were used to fit the model,
26
+ so the learning-set metrics at that threshold are optimistic: the holdout
27
+ gives the unbiased estimate. At a bin edge, the histograms give exact confusion matrices,
26
28
  so the holdout results are then recomputed from the holdout histograms
27
29
  at that threshold. The holdout histograms are never used to choose it.
28
30
  """
@@ -46,6 +48,10 @@ HOLDOUT_FAILED = "failed"
46
48
  HOLDOUT_UNSUPPORTED = "unsupported_task"
47
49
 
48
50
  THRESHOLD_SOURCES = ("manual", "optimized")
51
+ # data the clients use for the optimized threshold (sent back in their summary, so a client running an
52
+ # older MEDfl that only sends its validation split is detected)
53
+ LEARNING_SET = "learning_set"
54
+ LEARNING_SET_SPLITS = ("train", "validation", "test")
49
55
  # criterion key -> label shown in the results
50
56
  THRESHOLD_CRITERIA = {"youden": "Youden's J", "target_sensitivity": "Target sensitivity"}
51
57
 
@@ -70,13 +76,14 @@ class HoldoutConfig:
70
76
  include_client_results: bool = True
71
77
 
72
78
  # "manual": the threshold above. "optimized": chosen on the clients'
73
- # validation splits, at a histogram bin edge (resolution 1 / num_bins)
79
+ # learning sets (train + validation + test), at a histogram bin edge
80
+ # (resolution 1 / num_bins)
74
81
  threshold_source: str = "manual"
75
82
 
76
83
  # "youden" or "target_sensitivity" (optimized threshold only)
77
84
  threshold_criterion: str = "youden"
78
85
 
79
- # minimal pooled validation sensitivity of the target_sensitivity criterion
86
+ # minimal pooled learning-set sensitivity of the target_sensitivity criterion
80
87
  target_sensitivity: float = 0.9
81
88
 
82
89
  def validate(self) -> None:
@@ -115,7 +122,7 @@ class HoldoutConfig:
115
122
  "holdout_threshold": float(self.threshold),
116
123
  "holdout_num_bins": int(self.num_bins),
117
124
  "holdout_minimum_samples": int(self.minimum_samples),
118
- # the clients also send their validation histograms
125
+ # the clients also send their learning-set histograms
119
126
  "threshold_optimization_enabled": self.threshold_source == "optimized",
120
127
  }
121
128
 
@@ -285,11 +292,13 @@ def local_validation_summary(
285
292
  num_bins: int,
286
293
  minimum_samples: int,
287
294
  client_id: str,
295
+ splits: Optional[Dict[str, int]] = None,
288
296
  ) -> Dict[str, Any]:
289
297
  """
290
- Summary of one client's validation split for the optimized threshold,
291
- safe to send to the server: counts and the two score histograms (same
292
- bins as the holdout). Below minimum_samples rows, only the counts are sent.
298
+ Summary of one client's learning set (train + validation + test) for the
299
+ optimized threshold, safe to send to the server: counts and the two score
300
+ histograms (same bins as the holdout). Below minimum_samples rows, only
301
+ the counts are sent. splits gives the number of rows of each split.
293
302
  """
294
303
  y_true = np.asarray(y_true, dtype=float).reshape(-1)
295
304
  y_prob = np.asarray(y_prob, dtype=float).reshape(-1)
@@ -301,7 +310,7 @@ def local_validation_summary(
301
310
  classes = set(np.unique(y_true).tolist())
302
311
  if not classes.issubset({0.0, 1.0}):
303
312
  raise ValueError(
304
- f"The validation target must be binary (0/1), found {len(classes)} "
313
+ f"The learning-set target must be binary (0/1), found {len(classes)} "
305
314
  f"values: {sorted(classes)[:10]}"
306
315
  )
307
316
 
@@ -309,11 +318,13 @@ def local_validation_summary(
309
318
  positives = int(y_true.sum())
310
319
  summary = {
311
320
  "client_id": client_id,
321
+ "data": LEARNING_SET,
312
322
  "dataset": {
313
323
  "rows": rows,
314
324
  "positives": positives,
315
325
  "negatives": rows - positives,
316
326
  "rows_without_prediction": int((~predicted).sum()),
327
+ "splits": {key: int(value) for key, value in (splits or {}).items()},
317
328
  },
318
329
  }
319
330
 
@@ -321,7 +332,7 @@ def local_validation_summary(
321
332
  return {
322
333
  **summary,
323
334
  "status": HOLDOUT_MISSING,
324
- "reason": "No validation rows (validation fraction of 0?)",
335
+ "reason": "No learning-set rows",
325
336
  }
326
337
 
327
338
  if rows < int(minimum_samples):
@@ -329,7 +340,7 @@ def local_validation_summary(
329
340
  **summary,
330
341
  "status": HOLDOUT_SKIPPED,
331
342
  "reason": (
332
- f"{rows} validation row(s), fewer than the minimum of "
343
+ f"{rows} learning-set row(s), fewer than the minimum of "
333
344
  f"{int(minimum_samples)}"
334
345
  ),
335
346
  }
@@ -347,35 +358,38 @@ def local_validation_summary(
347
358
 
348
359
  def run_local_validation(
349
360
  config: Dict[str, Any],
350
- validation: Optional[Callable[[], Tuple[np.ndarray, np.ndarray]]],
361
+ validation: Optional[Callable[[], Tuple[np.ndarray, np.ndarray, Dict[str, int]]]],
351
362
  client_id: str,
352
363
  ) -> Dict[str, Any]:
353
364
  """
354
365
  Entries to add to the Flower evaluate metrics for the optimized
355
366
  threshold: threshold_validation_status, then threshold_validation
356
- (JSON) or threshold_validation_error.
367
+ (JSON) or threshold_validation_error. (The metric names are kept from
368
+ the validation-only versions; the data is the learning set.)
357
369
 
358
- validation returns (y_true, y_prob) of the client's validation split
359
- with the final global model.
370
+ validation returns (y_true, y_prob, splits) of the client's whole
371
+ learning set (train + validation + test) with the final global model,
372
+ splits being the number of rows of each split.
360
373
  """
361
374
  if validation is None:
362
375
  return {
363
376
  "threshold_validation_status": HOLDOUT_MISSING,
364
- "threshold_validation_error": "This client cannot send validation data",
377
+ "threshold_validation_error": "This client cannot send learning-set data",
365
378
  }
366
379
 
367
380
  try:
368
- y_true, y_prob = validation()
381
+ y_true, y_prob, splits = validation()
369
382
  summary = local_validation_summary(
370
383
  y_true=y_true,
371
384
  y_prob=y_prob,
372
385
  num_bins=int(config.get("holdout_num_bins", 100)),
373
386
  minimum_samples=int(config.get("holdout_minimum_samples", 10)),
374
387
  client_id=client_id,
388
+ splits=splits,
375
389
  )
376
390
  print(
377
- f"[Client/Holdout] Validation for the threshold: {summary['status']} | "
378
- f"client={client_id} | rows={summary['dataset']['rows']}",
391
+ f"[Client/Holdout] Learning set for the threshold: {summary['status']} | "
392
+ f"client={client_id} | rows={summary['dataset']['rows']} | splits={splits}",
379
393
  flush=True,
380
394
  )
381
395
  return {
@@ -383,7 +397,7 @@ def run_local_validation(
383
397
  "threshold_validation": json.dumps(summary, allow_nan=False),
384
398
  }
385
399
  except Exception as exc:
386
- print(f"[Client/Holdout] Validation for the threshold failed: {exc}", flush=True)
400
+ print(f"[Client/Holdout] Learning set for the threshold failed: {exc}", flush=True)
387
401
  return {
388
402
  "threshold_validation_status": HOLDOUT_FAILED,
389
403
  "threshold_validation_error": str(exc),
@@ -398,7 +412,7 @@ def run_local_holdout(
398
412
  predict: Callable[[np.ndarray], np.ndarray],
399
413
  client_id: str,
400
414
  drop_missing_features: bool,
401
- validation: Optional[Callable[[], Tuple[np.ndarray, np.ndarray]]] = None,
415
+ validation: Optional[Callable[[], Tuple[np.ndarray, np.ndarray, Dict[str, int]]]] = None,
402
416
  ) -> Dict[str, Any]:
403
417
  """
404
418
  Run the holdout test on a client and return the entries to add to the
@@ -408,9 +422,9 @@ def run_local_holdout(
408
422
  predict maps the holdout features to positive-class probabilities with
409
423
  the final global model.
410
424
 
411
- When the server optimizes the threshold, the validation summary is
425
+ When the server optimizes the threshold, the learning-set summary is
412
426
  added too (see run_local_validation), even without a holdout file:
413
- the client validation data still helps choose the threshold.
427
+ the client learning set still helps choose the threshold.
414
428
  """
415
429
  entries = {}
416
430
  if bool(config.get("threshold_optimization_enabled", False)):
@@ -534,7 +548,7 @@ def select_threshold_from_histograms(
534
548
  positives, negatives = int(pos_hist.sum()), int(neg_hist.sum())
535
549
  if positives == 0 or negatives == 0:
536
550
  raise ValueError(
537
- f"The validation data has a single class ({positives} positive, "
551
+ f"The learning-set data has a single class ({positives} positive, "
538
552
  f"{negatives} negative): the threshold cannot be optimized"
539
553
  )
540
554
 
@@ -558,7 +572,7 @@ def select_threshold_from_histograms(
558
572
  reached = np.flatnonzero(sensitivity >= target - 1e-12)
559
573
  if not len(reached):
560
574
  raise ValueError(
561
- f"No threshold reaches a validation sensitivity of {target}: "
575
+ f"No threshold reaches a learning-set sensitivity of {target}: "
562
576
  f"{int(pos_hist[0])} of the {positives} positive case(s) score below "
563
577
  f"the lowest candidate threshold {1 / num_bins:g}. Increase the number "
564
578
  f"of bins or lower the target sensitivity"
@@ -572,9 +586,10 @@ def select_threshold_from_histograms(
572
586
 
573
587
  def optimize_federated_threshold(results, holdout_config: HoldoutConfig) -> Dict[str, Any]:
574
588
  """
575
- Choose the threshold on the pooled validation histograms of the final
576
- round. Returns the selection with status "completed" (threshold, edge,
577
- validation metrics) or "failed" (error), and the excluded clients.
589
+ Choose the threshold on the pooled learning-set (train + validation +
590
+ test) histograms of the final round. Returns the selection with status
591
+ "completed" (threshold, edge, learning-set metrics) or "failed" (error),
592
+ and the excluded clients.
578
593
  """
579
594
  completed, skipped, missing, failed = [], [], [], []
580
595
 
@@ -586,7 +601,7 @@ def optimize_federated_threshold(results, holdout_config: HoldoutConfig) -> Dict
586
601
  if status is None:
587
602
  missing.append({
588
603
  "client_id": name,
589
- "error": "No validation data sent (the client MEDfl version may not support the optimized threshold)",
604
+ "error": "No learning-set data sent (the client MEDfl version may not support the optimized threshold)",
590
605
  })
591
606
  continue
592
607
 
@@ -595,9 +610,17 @@ def optimize_federated_threshold(results, holdout_config: HoldoutConfig) -> Dict
595
610
  try:
596
611
  summary = json.loads(metrics["threshold_validation"])
597
612
  except Exception as exc:
598
- failed.append({"client_id": name, "error": f"Invalid validation JSON: {exc}"})
613
+ failed.append({"client_id": name, "error": f"Invalid learning-set JSON: {exc}"})
599
614
  continue
600
615
 
616
+ if summary and summary.get("data") != LEARNING_SET:
617
+ # an older client sends only its validation split: never mix it with learning sets
618
+ failed.append({
619
+ "client_id": summary.get("client_id", name),
620
+ "error": "This client sent only its validation split: update MEDfl on it to use its whole learning set",
621
+ })
622
+ continue
623
+
601
624
  if status == HOLDOUT_COMPLETED and summary:
602
625
  if int(summary.get("num_bins", -1)) != int(holdout_config.num_bins):
603
626
  failed.append({
@@ -620,7 +643,7 @@ def optimize_federated_threshold(results, holdout_config: HoldoutConfig) -> Dict
620
643
  else:
621
644
  failed.append({
622
645
  "client_id": name,
623
- "error": metrics.get("threshold_validation_error", "No validation result returned"),
646
+ "error": metrics.get("threshold_validation_error", "No learning-set result returned"),
624
647
  })
625
648
 
626
649
  criterion = holdout_config.threshold_criterion
@@ -630,7 +653,8 @@ def optimize_federated_threshold(results, holdout_config: HoldoutConfig) -> Dict
630
653
  "target_sensitivity": (
631
654
  float(holdout_config.target_sensitivity) if criterion == "target_sensitivity" else None
632
655
  ),
633
- "method": f"pooled {int(holdout_config.num_bins)}-bin validation score histograms",
656
+ "data": LEARNING_SET,
657
+ "method": f"pooled {int(holdout_config.num_bins)}-bin learning-set (train + validation + test) score histograms",
634
658
  "num_bins": int(holdout_config.num_bins),
635
659
  # the threshold is a bin edge
636
660
  "resolution": 1.0 / int(holdout_config.num_bins),
@@ -640,16 +664,20 @@ def optimize_federated_threshold(results, holdout_config: HoldoutConfig) -> Dict
640
664
  }
641
665
 
642
666
  if not completed:
643
- return {**selection, "status": "failed", "error": "No client returned validation data"}
667
+ return {**selection, "status": "failed", "error": "No client returned learning-set data"}
644
668
 
645
669
  pos_hist = np.sum([summary["pos_hist"] for summary in completed], axis=0)
646
670
  neg_hist = np.sum([summary["neg_hist"] for summary in completed], axis=0)
647
671
  positives, negatives = int(pos_hist.sum()), int(neg_hist.sum())
648
- selection["validation"] = {
672
+ selection["learning_set"] = {
649
673
  "clients": len(completed),
650
674
  "rows": positives + negatives,
651
675
  "positives": positives,
652
676
  "negatives": negatives,
677
+ "splits": {
678
+ split: int(sum(summary["dataset"].get("splits", {}).get(split, 0) for summary in completed))
679
+ for split in LEARNING_SET_SPLITS
680
+ },
653
681
  }
654
682
 
655
683
  try:
@@ -660,7 +688,8 @@ def optimize_federated_threshold(results, holdout_config: HoldoutConfig) -> Dict
660
688
  return {**selection, "status": "failed", "error": str(exc)}
661
689
 
662
690
  confusion = histogram_confusion(pos_hist, neg_hist, edge)
663
- auc, _ = pooled_roc_auc(pos_hist, neg_hist)
691
+ # learning-set ROC curve: its points are the bin edges, so the chosen threshold is one of them
692
+ auc, roc = pooled_roc_auc(pos_hist, neg_hist)
664
693
  return {
665
694
  **selection,
666
695
  "status": "completed",
@@ -668,6 +697,7 @@ def optimize_federated_threshold(results, holdout_config: HoldoutConfig) -> Dict
668
697
  "edge": edge,
669
698
  "metrics": {"auc": auc, **confusion_metrics(confusion)},
670
699
  "confusion_matrix": confusion,
700
+ "roc_curve": roc,
671
701
  }
672
702
 
673
703
 
@@ -746,7 +776,7 @@ def aggregate_federated_holdout(
746
776
  }
747
777
 
748
778
  if holdout_config.threshold_source == "optimized":
749
- # chosen on the validation histograms only, the holdout ones are not read here
779
+ # chosen on the learning-set histograms only, the holdout ones are not read here
750
780
  selection = optimize_federated_threshold(results, holdout_config)
751
781
  result["threshold_selection"] = selection
752
782
  if selection["status"] != "completed":
@@ -590,7 +590,7 @@ class XGBoostFlowerClient(fl.client.Client):
590
590
  predict=self._predict_probabilities,
591
591
  client_id=client_name(),
592
592
  drop_missing_features=False,
593
- validation=self._validation_predictions,
593
+ validation=self._learning_set_predictions,
594
594
  )
595
595
  )
596
596
 
@@ -609,14 +609,21 @@ class XGBoostFlowerClient(fl.client.Client):
609
609
  )
610
610
  return np.asarray(self.booster.predict(matrix), dtype=float)
611
611
 
612
- def _validation_predictions(self) -> Tuple[np.ndarray, np.ndarray]:
613
- """(y_true, y_prob) of the validation split, for the optimized holdout threshold."""
614
- if self.dval is None or int(self.metadata.get("num_val", 0)) == 0:
615
- return np.empty((0,)), np.empty((0,))
616
- return (
617
- np.asarray(self.dval.get_label(), dtype=float),
618
- np.asarray(self.booster.predict(self.dval), dtype=float),
619
- )
612
+ def _learning_set_predictions(self) -> Tuple[np.ndarray, np.ndarray, Dict[str, int]]:
613
+ """
614
+ (y_true, y_prob, rows per split) of the whole learning set (train + validation + test splits),
615
+ for the optimized holdout threshold.
616
+ """
617
+ y_parts, prob_parts, splits = [], [], {}
618
+ for split, matrix in (("train", self.dtrain), ("validation", self.dval), ("test", self.dtest)):
619
+ rows = 0 if matrix is None else int(matrix.num_row())
620
+ splits[split] = rows
621
+ if rows:
622
+ y_parts.append(np.asarray(matrix.get_label(), dtype=float))
623
+ prob_parts.append(np.asarray(self.booster.predict(matrix), dtype=float))
624
+ if not y_parts:
625
+ return np.empty((0,)), np.empty((0,)), splits
626
+ return np.concatenate(y_parts), np.concatenate(prob_parts), splits
620
627
 
621
628
  def get_properties(self, ins: GetPropertiesIns) -> GetPropertiesRes:
622
629
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: medfl
3
- Version: 2.0.10.dev10
3
+ Version: 2.0.10.dev11
4
4
  Summary: Python Open-source package for simulating federated learning and differential privacy
5
5
  Home-page: https://github.com/MEDomics-UdeS/MEDfl
6
6
  Author: MEDomics consortium
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: medfl
3
- Version: 2.0.10.dev10
3
+ Version: 2.0.10.dev11
4
4
  Summary: Python Open-source package for simulating federated learning and differential privacy
5
5
  Home-page: https://github.com/MEDomics-UdeS/MEDfl
6
6
  Author: MEDomics consortium
@@ -14,7 +14,7 @@ with open('requirements.txt') as f:
14
14
 
15
15
  setup(
16
16
  name="medfl",
17
- version="2.0.10.dev10",
17
+ version="2.0.10.dev11",
18
18
  author="MEDomics consortium",
19
19
  author_email="medomics.info@gmail.com",
20
20
  description="Python Open-source package for simulating federated learning and differential privacy",
File without changes
File without changes
File without changes