MEDfl 2.0.10.dev6__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.
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/server.py +1 -1
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/client.py +35 -0
- medfl-2.0.10.dev8/MEDfl/rw/holdout.py +495 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/server.py +69 -6
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/strategy.py +39 -6
- medfl-2.0.10.dev8/MEDfl/rw/xgboost/client.py +721 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/strategy.py +327 -10
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/PKG-INFO +1 -1
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/medfl.egg-info/PKG-INFO +1 -1
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/medfl.egg-info/SOURCES.txt +3 -1
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/setup.py +1 -1
- medfl-2.0.10.dev8/tests/test_rw_holdout.py +171 -0
- medfl-2.0.10.dev6/MEDfl/rw/xgboost/client.py +0 -379
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/LICENSE +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/client.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/dynamicModal.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/federated_dataset.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/flpipeline.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/model.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/params.yaml +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/params_optimiser.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/plot.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/server.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/shap/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/shap/aggregation.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/shap/config.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/shap/explainer.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/shap/xgboost_explainer.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/strategy.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/utils.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/client.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/config.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/data.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/metrics.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/serialization.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/LearningManager/xgboost/strategy.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/NetManager/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/NetManager/database_connector.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/NetManager/dataset.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/NetManager/flsetup.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/NetManager/net_helper.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/NetManager/net_manager_queries.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/NetManager/network.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/NetManager/node.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/model.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/config.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/data.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/metrics.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/rw/xgboost/serialization.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/scripts/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/scripts/base.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/MEDfl/scripts/create_db.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/README.md +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/alembic/__init__.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/alembic/env.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/medfl.egg-info/dependency_links.txt +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/medfl.egg-info/requires.txt +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/medfl.egg-info/top_level.txt +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/setup.cfg +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/tests/test_dataset.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/tests/test_federated_dataset.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/tests/test_flpipeline.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/tests/test_flsetup.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/tests/test_network.py +0 -0
- {medfl-2.0.10.dev6 → medfl-2.0.10.dev8}/tests/test_node.py +0 -0
|
@@ -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
|