diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8d31f9d..0985075 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,7 @@ on: push: branches: [main] pull_request: - branches: [main, cmd_line] + branches: [main, cmd_line, tests_and_CI] workflow_dispatch: concurrency: diff --git a/flcore/models/xgb/client.py b/flcore/models/xgb/client.py index 65bcc2d..307b78b 100644 --- a/flcore/models/xgb/client.py +++ b/flcore/models/xgb/client.py @@ -144,8 +144,11 @@ def fit( } # If multiclass objective, ensure num_class is correctly set from local data + n_classes = len(np.unique(self.local_data['y_train'])) + if self.xgb_params.get("objective") == "binary:logistic" and n_classes > 2: + self.xgb_params["objective"] = "multi:softmax" + self.xgb_params["eval_metric"] = "mlogloss" if self.xgb_params.get("objective", "").startswith("multi"): - n_classes = len(np.unique(self.local_data['y_train'])) if n_classes >= 2: self.xgb_params["num_class"] = n_classes print(f"[Client] Multiclass detected: num_class={n_classes}") diff --git a/flcore/models/xgb/server.py b/flcore/models/xgb/server.py index dbe0327..ce33564 100644 --- a/flcore/models/xgb/server.py +++ b/flcore/models/xgb/server.py @@ -11,6 +11,7 @@ import xgboost as xgb from flwr.common import ( + FitIns, ArrayRecord, ConfigRecord, Message, @@ -174,6 +175,34 @@ def initialize_parameters(self, client_manager): empty = np.frombuffer(b"", dtype=np.uint8) return ndarrays_to_parameters([empty]) + # ------------------------------------------------------ + # CONFIGURE FIT + # ------------------------------------------------------ + + def configure_fit(self, server_round, parameters, client_manager): + """Send each client its final learning rate: eta / number of clients.""" + # Without scaling, the training becomes unstable with more clients + instructions = super().configure_fit(server_round, parameters, client_manager) + num_clients = len(instructions) + if num_clients == 0: + return instructions + + base_eta = float(self.xgb_params.get("eta", 0.1)) + final_eta = base_eta / num_clients + print( + f"[Round {server_round}] eta: {base_eta} / {num_clients} clients " + f"= {final_eta}" + ) + + # One FitIns per client, each carrying its own final eta + return [ + ( + client, + FitIns(fit_ins.parameters, {**fit_ins.config, "eta": final_eta}), + ) + for client, fit_ins in instructions + ] + # ------------------------------------------------------ # AGGREGATE FIT (CRITICAL FIX) # ------------------------------------------------------ @@ -233,8 +262,12 @@ def aggregate_fit( total_examples = sum(fit_res.num_examples for _, fit_res in results) for _, fit_res in results: - if "n_out" in fit_res.metrics: - self.xgb_params["num_class"] = int(fit_res.metrics["n_out"]) + n_out = int(fit_res.metrics.get("n_out", 0)) + if self.xgb_params["objective"] == "binary:logistic" and n_out > 2: + self.xgb_params["objective"] = "multi:softmax" + self.xgb_params["eval_metric"] = "mlogloss" + if self.xgb_params["objective"].startswith("multi"): + self.xgb_params["num_class"] = n_out for key, value in fit_res.metrics.items(): if not isinstance(value, (int, float)): @@ -361,7 +394,7 @@ def get_server_and_strategy(config: dict) -> FedXgbFullyFederated: "colsample_bytree": 0.8, } - if task == "binary": + if task in ("binary", "classification"): xgb_params["objective"] = "binary:logistic" xgb_params["eval_metric"] = "auc" @@ -377,7 +410,7 @@ def get_server_and_strategy(config: dict) -> FedXgbFullyFederated: xgb_params["eval_metric"] = "rmse" train_method = xgb_config.get("train_method", "bagging") # 'bagging' or 'cyclic' - num_local_rounds = xgb_config.get("tree_num", 100) // config.get("num_rounds", 10) # Trees per round + num_local_rounds = 1 # Always train one tree per round print("\n" + "=" * 60) diff --git a/requirements.txt b/requirements.txt index dff296c..c80f4f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ scikit-learn==1.3.2 torch==2.3.1 torchmetrics==1.4.0 tqdm==4.65.0 -xgboost==1.7.5 +xgboost==2.1.4 pdfkit==1.0.0 scikit-survival==0.22.2 fpboost==0.1.0 diff --git a/tests/test_federated_training.py b/tests/test_federated_training.py index dcd02b2..ed23aa7 100644 --- a/tests/test_federated_training.py +++ b/tests/test_federated_training.py @@ -51,7 +51,6 @@ def case(model, scenario, client_extra=(), id_suffix="", bug=None): "lsvc/svm, but SGDClassifier needs one intercept per class (multiclass)" ) SVR_KERNEL_BUG = "SVR with a non-linear kernel has no fit_intercept / coef_" -XGB_BUG = "xgb server needs iteration_indptr (XGBoost >= 2.0, 1.7.5 is pinned)" SURVIVAL_TIME_BUG = "survival client fit() uses `time` without importing it" CASES = [] @@ -98,7 +97,7 @@ def case(model, scenario, client_extra=(), id_suffix="", bug=None): case("weighted_random_forest", "binary", bug=wrf_bug), case("weighted_random_forest", "multiclass", bug=wrf_bug), ] -CASES += [case("xgb", s, bug=XGB_BUG) for s in ("binary", "multiclass", "regression")] +CASES += [case("xgb", s) for s in ("binary", "multiclass", "regression")] CASES += [case("nn", s) for s in ("binary", "multiclass", "regression")] # Survival