From 08b18f068498b62e88951169db0588d758587ce3 Mon Sep 17 00:00:00 2001 From: faildeny Date: Thu, 13 Nov 2025 13:11:40 +0100 Subject: [PATCH 01/29] Add support for Diabetes dataset --- config.yaml | 10 +- flcore/datasets.py | 431 +++++++++++++++++++++++++++++++++++++++++---- requirements.txt | 1 + 3 files changed, 401 insertions(+), 41 deletions(-) diff --git a/config.yaml b/config.yaml index 4c561dc..9ee8e31 100644 --- a/config.yaml +++ b/config.yaml @@ -10,7 +10,7 @@ ################################################################################ ############## Dataset type to use -# Possible values: , kaggle_hf, mnist, dt4h_format +# Possible values: , kaggle_hf, diabetes, mnist, dt4h_format dataset: dt4h_format #custom #libsvm @@ -33,7 +33,7 @@ train_size: 0.7 # ****** * * * * * * * * * * * * * * * * * * * * ******************* ############## Number of clients (data centers) to use for training -num_clients: 1 +num_clients: 3 ############## Model type # Possible values: logistic_regression, lsvc, elastic_net, random_forest, weighted_random_forest, xgb @@ -43,7 +43,7 @@ model: random_forest #random_forest ############## Training length -num_rounds: 50 +num_rounds: 5 ############## Metric to select the best model # Possible values: accuracy, balanced_accuracy, f1, precision, recall @@ -87,6 +87,8 @@ smoothWeights: linear_models: n_features: 9 +n_features: 9 + # Random Forest random_forest: balanced_rf: true @@ -101,7 +103,7 @@ xgb: batch_size: 32 num_iterations: 100 task_type: BINARY - tree_num: 500 + tree_num: 10 held_out_center_id: -1 diff --git a/flcore/datasets.py b/flcore/datasets.py index 699c4a0..d7f4e84 100644 --- a/flcore/datasets.py +++ b/flcore/datasets.py @@ -12,10 +12,13 @@ import pandas as pd from sklearn.datasets import load_svmlight_file -from sklearn.preprocessing import OrdinalEncoder, MinMaxScaler,StandardScaler +from sklearn.preprocessing import OrdinalEncoder, LabelEncoder, MinMaxScaler, StandardScaler from sklearn.model_selection import KFold, StratifiedShuffleSplit, train_test_split from sklearn.utils import shuffle -from sklearn.feature_selection import SelectKBest, f_classif +from sklearn.feature_selection import SelectKBest, f_classif, mutual_info_classif +from sklearn.ensemble import RandomForestClassifier + +from ucimlrepo import fetch_ucirepo from flcore.models.xgb.utils import TreeDataset, do_fl_partitioning, get_dataloader @@ -23,6 +26,273 @@ XY = Tuple[np.ndarray, np.ndarray] Dataset = Tuple[XY, XY] +def calculate_preprocessing_params(subset_data, subset_target, n_features=None, feature_selection_method='mutual_info'): + """ + Calculate preprocessing parameters based on a subset of data (reference center) + + Args: + subset_data: DataFrame containing the subset data + subset_target: Series containing the target variable + n_features: Number of features to select (None for all features) + feature_selection_method: Method for feature selection ('mutual_info', 'f_classif', 'random_forest') + + Returns: + dict: Preprocessing parameters (imputation values, mean, std, label_encoders, feature_selector) + """ + data_copy = subset_data.copy() + target_copy = subset_target.copy() + + # Calculate imputation parameters + imputation_params = {} + label_encoders = {} + + for column in data_copy.columns: + # Handle missing values + if data_copy[column].isna().any(): + if data_copy[column].dtype in ['float64', 'int64']: + imputation_params[column] = data_copy[column].median() + else: + imputation_params[column] = data_copy[column].mode()[0] if not data_copy[column].mode().empty else 0 + + # Store label encoders for categorical variables + if data_copy[column].dtype == 'object': + le = LabelEncoder() + # Fit on non-null values only + non_null_data = data_copy[column].dropna() + if len(non_null_data) > 0: + # Add 'unknown' category for unseen labels + classes = np.append(non_null_data.astype(str).unique(), 'unknown') + le.fit(classes) + label_encoders[column] = le + + # Calculate normalization parameters for ALL columns (after conversion to numerical) + numeric_data = data_copy.copy() + + # Temporarily convert categorical to numerical for normalization parameter calculation + for column in numeric_data.columns: + if numeric_data[column].dtype == 'object': + # Use simple integer encoding for parameter calculation + numeric_data[column] = pd.Categorical(numeric_data[column]).codes + # Handle missing values temporarily for parameter calculation + if column in imputation_params: + numeric_data[column].fillna(imputation_params[column], inplace=True) + + # Convert all to numeric + numeric_data = numeric_data.apply(pd.to_numeric, errors='coerce') + + # Calculate normalization parameters + normalization_params = { + 'mean': numeric_data.mean().to_dict(), + 'std': numeric_data.std().to_dict() + } + + # Handle zero standard deviation + for col, std_val in normalization_params['std'].items(): + if std_val == 0 or np.isnan(std_val): + normalization_params['std'][col] = 1.0 + + # Feature Selection + feature_selector = None + selected_features = None + feature_scores = None + + if n_features is not None and n_features < len(numeric_data.columns): + # Prepare data for feature selection + X_temp = numeric_data.fillna(numeric_data.median()) + y_temp = target_copy + + # Handle any remaining NaN values + X_temp = X_temp.fillna(0) + + if feature_selection_method == 'mutual_info': + selector = SelectKBest(score_func=mutual_info_classif, k=min(n_features, X_temp.shape[1])) + elif feature_selection_method == 'f_classif': + selector = SelectKBest(score_func=f_classif, k=min(n_features, X_temp.shape[1])) + elif feature_selection_method == 'random_forest': + # Use Random Forest feature importance + rf = RandomForestClassifier(n_estimators=100, random_state=42) + rf.fit(X_temp, y_temp) + importances = rf.feature_importances_ + indices = np.argsort(importances)[::-1] + selected_indices = indices[:min(n_features, len(indices))] + + # Create a custom selector object + class CustomSelector: + def __init__(self, selected_indices, feature_names): + self.selected_indices = selected_indices + self.feature_names = feature_names + self.scores_ = importances + + def transform(self, X): + if isinstance(X, pd.DataFrame): + return X.iloc[:, self.selected_indices] + else: + return X[:, self.selected_indices] + + def get_support(self, indices=False): + if indices: + return self.selected_indices + else: + mask = np.zeros(len(self.feature_names), dtype=bool) + mask[self.selected_indices] = True + return mask + + selector = CustomSelector(selected_indices, numeric_data.columns.tolist()) + feature_scores = importances + else: + raise ValueError("feature_selection_method must be 'mutual_info', 'f_classif', or 'random_forest'") + + if feature_selection_method != 'random_forest': + selector.fit(X_temp, y_temp) + feature_scores = selector.scores_ + + feature_selector = selector + selected_features = numeric_data.columns[selector.get_support()].tolist() + + print(f"Feature selection: Selected {len(selected_features)} most informative features") + if feature_scores is not None: + # Print top feature scores + feature_importance = pd.DataFrame({ + 'feature': numeric_data.columns, + 'score': feature_scores + }).sort_values('score', ascending=False) + print("Top 5 features:") + for i, (_, row) in enumerate(feature_importance.head().iterrows()): + print(f" {i+1}. {row['feature']}: {row['score']:.4f}") + + return { + 'imputation': imputation_params, + 'normalization': normalization_params, + 'label_encoders': label_encoders, + 'feature_selector': feature_selector, + 'selected_features': selected_features, + 'n_features': n_features + } + +def apply_preprocessing(subset_data, preprocessing_params): + """ + Apply preprocessing to a subset using pre-calculated parameters from reference center + + Args: + subset_data: DataFrame to preprocess + preprocessing_params: dict from calculate_preprocessing_params + + Returns: + tuple: (preprocessed_data, feature_names) + """ + data_copy = subset_data.copy() + + # Step 1: Handle missing values using reference center parameters + for column in data_copy.columns: + if column in preprocessing_params['imputation']: + missing_mask = data_copy[column].isna() + if missing_mask.any(): + data_copy.loc[missing_mask, column] = preprocessing_params['imputation'][column] + + # Step 2: Convert all features to numerical using reference center label encoders + for column in data_copy.columns: + if column in preprocessing_params['label_encoders']: + le = preprocessing_params['label_encoders'][column] + # Convert to string and handle unseen labels + encoded_values = [] + for val in data_copy[column]: + if pd.isna(val): + encoded_values.append(-1) # Special value for missing + else: + str_val = str(val) + if str_val in le.classes_: + encoded_values.append(le.transform([str_val])[0]) + else: + # Map unseen labels to 'unknown' class + encoded_values.append(le.transform(['unknown'])[0]) + data_copy[column] = encoded_values + elif data_copy[column].dtype == 'object': + # Fallback: use categorical codes for any remaining object columns + data_copy[column] = pd.Categorical(data_copy[column]).codes + + # Ensure all data is numerical + data_copy = data_copy.apply(pd.to_numeric, errors='coerce') + + # Step 3: Normalize ALL features using reference center parameters + normalization_params = preprocessing_params['normalization'] + for column in data_copy.columns: + if column in normalization_params['mean']: + mean_val = normalization_params['mean'][column] + std_val = normalization_params['std'][column] + data_copy[column] = (data_copy[column] - mean_val) / std_val + + # Step 4: Apply feature selection if enabled + if preprocessing_params['feature_selector'] is not None: + selector = preprocessing_params['feature_selector'] + data_copy = pd.DataFrame(selector.transform(data_copy), + columns=preprocessing_params['selected_features']) + + return data_copy, data_copy.columns.tolist() + +def partition_data_dirichlet(labels, num_centers, alpha=1.0): + """ + Partition data among centers using Dirichlet distribution + """ + unique_labels = np.unique(labels) + n_samples = len(labels) + n_classes = len(unique_labels) + + # Create assignment matrix + center_indices = [[] for _ in range(num_centers)] + + # For each class, distribute samples to centers using Dirichlet distribution + for class_idx in unique_labels: + class_mask = (labels == class_idx) + class_indices = np.where(class_mask)[0] + n_class_samples = len(class_indices) + + if n_class_samples > 0: + # Generate Dirichlet distribution for this class + proportions = np.random.dirichlet(np.repeat(alpha, num_centers)) + proportions = proportions / proportions.sum() + + # Calculate number of samples for each center + center_samples = (proportions * n_class_samples).astype(int) + + # Adjust for rounding errors + diff = n_class_samples - center_samples.sum() + if diff > 0: + center_samples[np.random.choice(num_centers, diff, replace=True)] += 1 + + # Shuffle and assign indices + np.random.shuffle(class_indices) + ptr = 0 + for center_id in range(num_centers): + if center_samples[center_id] > 0: + center_indices[center_id].extend( + class_indices[ptr:ptr + center_samples[center_id]] + ) + ptr += center_samples[center_id] + + # Shuffle indices within each center + for center_id in range(num_centers): + np.random.shuffle(center_indices[center_id]) + + return center_indices + +def select_reference_center(all_center_data, method='largest'): + """ + Select which center to use for calculating preprocessing parameters + """ + if method == 'largest': + center_sizes = [len(X) for X, y in all_center_data] + reference_center_id = np.argmax(center_sizes) + print(f"Selected largest center (ID: {reference_center_id}) with {center_sizes[reference_center_id]} samples") + + elif method == 'random': + reference_center_id = np.random.randint(0, len(all_center_data)) + print(f"Selected random center (ID: {reference_center_id})") + + else: + raise ValueError("Method must be 'largest' or 'random'") + + return reference_center_id + def load_mnist(center_id=None, num_splits=5): """Loads the MNIST dataset using OpenML. @@ -343,7 +613,7 @@ def get_preprocessing_params(data): for feature in transformers_dict: if feature == 'ST_Slope': # Change value of last row to 'Down' to avoid error as it is missing in some splits - X_train[feature].iloc[-1] = 'Down' + X_train.loc[X_train.index[-1], feature] = 'Down' transformers_dict[feature].fit(X_train[feature].values.reshape(-1, 1)) else: transformers_dict[feature].fit(X_train[feature].values.reshape(-1, 1)) @@ -358,7 +628,9 @@ def preprocess_data(data, column_transformer): target = df1['HeartDisease'] for feature in column_transformer: - features[feature] = column_transformer[feature].transform(features[feature].values.reshape(-1, 1)) + features.loc[:, feature] = column_transformer[feature].transform(features[feature].values.reshape(-1, 1)) + + features = features.infer_objects() X_train, X_test, y_train, y_test = train_test_split(features, target, test_size = 0.20, random_state = seed, stratify=target) @@ -369,39 +641,6 @@ def preprocess_data(data, column_transformer): (X_train, y_train), (X_test, y_test) = preprocess_data(data, preprocessing_params) - # n_females = len(X_train[X_train['Sex'] == 0]) - # print(f'n_females{n_females}') - # n_males = len(X_train[X_train['Sex'] == 1]) - # print(f'n_males{n_males}') - # print(len(X_train)) - # Get indexes of rows with men (Sex == 0) - n_females = len(X_train[X_train['Sex'] == 0]) - n_males = len(X_train[X_train['Sex'] == 1]) - print(f'Center {center_id} of size {len(X_train)} with n_females {n_females} and n_males {n_males} in training set') - - if center_id == 0: - men_indexes = X_train.index[X_train['Sex'] == 1] - female_indexes = X_train.index[X_train['Sex'] == 0] - # print(len(female_indexes)) - n_females_to_drop = int(len(female_indexes)*0.9) - female_indexes = female_indexes[:n_females_to_drop] - copy_male_indexes = men_indexes[:n_females_to_drop] - # print(len(female_indexes)) - X_train = X_train.drop(index=female_indexes) - y_train = y_train.drop(index=female_indexes) - # print(len(X_train)) - # print(f'Adding males {len(copy_male_indexes)}') - X_train = pd.concat([X_train, X_train.loc[copy_male_indexes]]) - y_train = pd.concat([y_train, y_train.loc[copy_male_indexes]]) - - if center_id == 2 or center_id == -1: - X_train = pd.concat([X_train, X_train, X_train, X_train]) - y_train = pd.concat([y_train, y_train, y_train, y_train]) - - n_females = len(X_train[X_train['Sex'] == 0]) - n_males = len(X_train[X_train['Sex'] == 1]) - print(f'Center {center_id} of size {len(X_train)} with n_females {n_females} and n_males {n_males} in training set') - # xx return (X_train, y_train), (X_test, y_test) @@ -639,6 +878,122 @@ def load_dt4h(config,id): y_test = data_target[int(dat_len*config["train_size"]):].iloc[:, 0] return (X_train, y_train), (X_test, y_test) +def load_diabetes(center_id, config): + """ + Load and preprocess diabetes dataset for federated learning with feature selection + + Args: + center_id: Identifier for the federated node + num_centers: Total number of federated centers + alpha: Dirichlet concentration parameter for data partitioning + reference_method: How to select reference center ('largest' or 'random') + global_preprocessing_params: Precomputed parameters (if None, will calculate) + n_features: Number of features to select (None for all features) + feature_selection_method: Method for feature selection + + Returns: + tuple: ((X_train, y_train), (X_test, y_test), preprocessing_params) + """ + num_centers = config.get("num_clients", 5) + alpha = config.get("dirichlet_alpha", 1.0) + reference_method = config.get("reference_center_method", "largest") + global_preprocessing_params = None + n_features = config.get("n_features", 20) + feature_selection_method = config.get("feature_selection_method", "mutual_info") + + # Load the dataset + cdc_diabetes_health_indicators = fetch_ucirepo(id=891) + + # Get features and target + X = cdc_diabetes_health_indicators.data.features + y = cdc_diabetes_health_indicators.data.targets + + # convert y to a pandas Series for easier handling + y = pd.Series(y.values.flatten()) + + # Use fraction of data for faster testing (optional) + fraction = 0.02 + X = X.sample(frac=fraction, random_state=42).reset_index(drop=True) + y = y.loc[X.index].reset_index(drop=True) + + # Set random seed for reproducible partitioning + np.random.seed(42) + + # Convert target to binary classification if needed + if y.nunique() > 2: + y_binary = (y > y.median()).astype(int) + else: + y_binary = y + + # Partition data using Dirichlet distribution + all_center_indices = partition_data_dirichlet(y_binary.values, num_centers, alpha) + + # Get all center data for reference selection + all_center_data = [] + for i in range(num_centers): + if i < len(all_center_indices) and len(all_center_indices[i]) > 0: + X_center = X.iloc[all_center_indices[i]] + all_center_data.append((X_center, y_binary.iloc[all_center_indices[i]])) + else: + all_center_data.append((pd.DataFrame(), pd.Series())) + + # Calculate or use global preprocessing parameters + if global_preprocessing_params is None: + # Select reference center and calculate parameters + reference_center_id = select_reference_center(all_center_data, reference_method) + X_reference = all_center_data[reference_center_id][0] + y_reference = all_center_data[reference_center_id][1] + + if len(X_reference) == 0: + # Fallback: use full dataset if reference center is empty + X_reference = X + y_reference = y_binary + print("Warning: Reference center empty, using full dataset for preprocessing parameters") + + global_preprocessing_params = calculate_preprocessing_params( + X_reference, y_reference, n_features, feature_selection_method + ) + print("Calculated new global preprocessing parameters with feature selection") + + if center_id: + # Get indices for the requested center + if center_id >= len(all_center_indices) or len(all_center_indices[center_id]) == 0: + raise ValueError(f"Center ID {center_id} has no data assigned") + + center_indices = all_center_indices[center_id] + X_center = X.iloc[center_indices].reset_index(drop=True) + y_center = y.iloc[center_indices].reset_index(drop=True) + else: + # Use full dataset if no center_id specified + X_center = X + y_center = y + + # Split into train/test for this center + if len(X_center) > 1: + X_train, X_test, y_train, y_test = train_test_split( + X_center, y_center, test_size=0.2, random_state=42, stratify=y_center + ) + else: + X_train, y_train = X_center, y_center + X_test, y_test = X_center.iloc[:0], y_center.iloc[:0] + + # Apply GLOBAL preprocessing parameters to both train and test sets + X_train_processed, feature_names = apply_preprocessing(X_train, global_preprocessing_params) + X_test_processed, _ = apply_preprocessing(X_test, global_preprocessing_params) + + # Convert targets to numpy arrays + # y_train_processed = y_train.values + # y_test_processed = y_test.values + + # # Print center statistics + # print(f"Center {center_id}/{num_centers} (alpha={alpha}):") + # print(f" Samples: {len(X_center)} (Train: {len(X_train_processed)}, Test: {len(X_test_processed)})") + # print(f" Features: {X_train_processed.shape[1]}/{len(X.columns)} selected") + # print(f" Data range: [{X_train_processed.min():.3f}, {X_train_processed.max():.3f}]") + # print(f" Normalized stats - Mean: {X_train_processed.mean():.4f}, Std: {X_train_processed.std():.4f}") + + return (X_train_processed, y_train), (X_test_processed, y_test) + def cvd_to_torch(config): pass @@ -695,6 +1050,8 @@ def load_dataset(config, id=None): return load_ukbb_cvd(config["data_path"], id, config) elif config["dataset"] == "kaggle_hf": return load_kaggle_hf(config["data_path"], id, config) + elif config["dataset"] == "diabetes": + return load_diabetes(id, config) elif config["dataset"] == "libsvm": return load_libsvm(config, id) elif config["dataset"] == "dt4h_format": diff --git a/requirements.txt b/requirements.txt index 13078ec..fc7ee35 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,5 +11,6 @@ scikit_learn==1.2.2 torch==2.0.1 torchmetrics==0.11.4 tqdm==4.65.0 +ucimlrepo==0.0.7 xgboost==1.7.5 pdfkit==1.0.0 From 4d0bc62524acc9355e2ecba80ae22b89c0029a0a Mon Sep 17 00:00:00 2001 From: faildeny Date: Thu, 13 Nov 2025 13:16:48 +0100 Subject: [PATCH 02/29] Add Balanced Random Forest selection as separate model --- flcore/client_selector.py | 2 +- .../models/random_forest/FedCustomAggregator.py | 17 ----------------- flcore/models/random_forest/client.py | 10 +++++++--- flcore/models/random_forest/server.py | 2 +- flcore/models/weighted_random_forest/client.py | 2 +- flcore/models/weighted_random_forest/server.py | 2 +- flcore/server_selector.py | 2 +- 7 files changed, 12 insertions(+), 25 deletions(-) diff --git a/flcore/client_selector.py b/flcore/client_selector.py index 76fa3d5..3f92915 100644 --- a/flcore/client_selector.py +++ b/flcore/client_selector.py @@ -11,7 +11,7 @@ def get_model_client(config, data, client_id): if model in ("logistic_regression", "elastic_net", "lsvc"): client = linear_models.client.get_client(config,data,client_id) - elif model == "random_forest": + elif model in ("random_forest", "balanced_random_forest"): client = random_forest.client.get_client(config,data,client_id) elif model == "weighted_random_forest": diff --git a/flcore/models/random_forest/FedCustomAggregator.py b/flcore/models/random_forest/FedCustomAggregator.py index 0da2e6b..adb8842 100644 --- a/flcore/models/random_forest/FedCustomAggregator.py +++ b/flcore/models/random_forest/FedCustomAggregator.py @@ -153,14 +153,6 @@ def aggregate_fit( self.time_server_round = time.time() print(f"Elapsed time: {elapsed_time} for round {server_round}") metrics_aggregated['training_time [s]'] = self.accum_time - - filename = 'server_results.txt' - with open( - filename, - "a", - ) as f: - f.write(f"Accumulated Time: {self.accum_time} for round {server_round}\n") - return parameters_aggregated, metrics_aggregated @@ -194,15 +186,6 @@ def aggregate_evaluate( elif server_round == 1: # Only log this warning once log(WARNING, "No evaluate_metrics_aggregation_fn provided") - # filename = 'server_results.txt' - # with open( - # filename, - # "a", - # ) as f: - # f.write(f"Accuracy: {metrics_aggregated['accuracy']} \n") - # f.write(f"Sensitivity: {metrics_aggregated['sensitivity']} \n") - # f.write(f"Specificity: {metrics_aggregated['specificity']} \n") - return loss_aggregated, metrics_aggregated diff --git a/flcore/models/random_forest/client.py b/flcore/models/random_forest/client.py index 52e07cb..7c464f7 100644 --- a/flcore/models/random_forest/client.py +++ b/flcore/models/random_forest/client.py @@ -30,8 +30,9 @@ def __init__(self, data,client_id,config): # Load data (self.X_train, self.y_train), (self.X_test, self.y_test) = data self.splits_nested = datasets.split_partitions(n_folds_out,0.2, seed, self.X_train, self.y_train) - self.bal_RF = config['random_forest']['balanced_rf'] - self.model = utils.get_model(self.bal_RF) + self.bal_RF = True if config['model'] == 'balanced_random_forest' else False + self.model = utils.get_model(self.bal_RF) + self.round_time = 0 # Setting initial parameters, akin to model.compile for keras models utils.set_initial_params_client(self.model,self.X_train, self.y_train) def get_parameters(self, ins: GetParametersIns): # , config type: ignore @@ -64,6 +65,7 @@ def fit(self, ins: FitIns): # , parameters, config type: ignore #To implement the center dropout, we need the execution time start_time = time.time() self.model.fit(X_train_2, y_train_2) + elapsed_time = (time.time() - start_time) #accuracy = model.score( X_test, y_test ) # accuracy,specificity,sensitivity,balanced_accuracy, precision, F1_score = \ # measurements_metrics(self.model,X_val, y_val) @@ -76,8 +78,8 @@ def fit(self, ins: FitIns): # , parameters, config type: ignore # print(f"precision in fit: {precision}") # print(f"F1_score in fit: {F1_score}") - elapsed_time = (time.time() - start_time) metrics["running_time"] = elapsed_time + self.round_time = elapsed_time print(f"num_client {self.client_id} has an elapsed time {elapsed_time}") @@ -108,6 +110,8 @@ def evaluate(self, ins: EvaluateIns): # , parameters, config type: ignore # measurements_metrics(self.model,self.X_test, self.y_test) y_pred = self.model.predict(self.X_test) metrics = calculate_metrics(self.y_test, y_pred) + metrics["round_time [s]"] = self.round_time + metrics["client_id"] = self.client_id # print(f"Accuracy client in evaluate: {accuracy}") # print(f"Sensitivity client in evaluate: {sensitivity}") # print(f"Specificity client in evaluate: {specificity}") diff --git a/flcore/models/random_forest/server.py b/flcore/models/random_forest/server.py index acbfd1b..06b538c 100644 --- a/flcore/models/random_forest/server.py +++ b/flcore/models/random_forest/server.py @@ -33,7 +33,7 @@ def fit_round( server_round: int ) -> Dict: def get_server_and_strategy(config): - bal_RF = config['random_forest']['balanced_rf'] + bal_RF = True if config['model'] == 'balanced_random_forest' else False model = get_model(bal_RF) utils.set_initial_params_server( model) diff --git a/flcore/models/weighted_random_forest/client.py b/flcore/models/weighted_random_forest/client.py index 74fa60e..bd7b801 100644 --- a/flcore/models/weighted_random_forest/client.py +++ b/flcore/models/weighted_random_forest/client.py @@ -94,7 +94,7 @@ def __init__(self, data,client_id,config): # Load data (self.X_train, self.y_train), (self.X_test, self.y_test) = data self.splits_nested = datasets.split_partitions(n_folds_out,0.2, seed, self.X_train, self.y_train) - self.bal_RF = config['weighted_random_forest']['balanced_rf'] + self.bal_RF = True if config['model'] == 'balanced_random_forest' else False self.model = utils.get_model(self.bal_RF) # Setting initial parameters, akin to model.compile for keras models utils.set_initial_params_client(self.model,self.X_train, self.y_train) diff --git a/flcore/models/weighted_random_forest/server.py b/flcore/models/weighted_random_forest/server.py index 877b871..20539c2 100644 --- a/flcore/models/weighted_random_forest/server.py +++ b/flcore/models/weighted_random_forest/server.py @@ -32,7 +32,7 @@ def fit_round( server_round: int ) -> Dict: def get_server_and_strategy(config): - bal_RF = config['weighted_random_forest']['balanced_rf'] + bal_RF = True if config['model'] == 'balanced_random_forest' else False model = get_model(bal_RF) utils.set_initial_params_server( model) diff --git a/flcore/server_selector.py b/flcore/server_selector.py index 3ba5a06..8c5e010 100644 --- a/flcore/server_selector.py +++ b/flcore/server_selector.py @@ -13,7 +13,7 @@ def get_model_server_and_strategy(config, data=None): server, strategy = linear_models_server.get_server_and_strategy( config ) - elif model == "random_forest": + elif model in ("random_forest", "balanced_random_forest"): server, strategy = random_forest_server.get_server_and_strategy( config ) From 5aefbd6ddde9d76fc62b6a9b9f1fc59eba4a679a Mon Sep 17 00:00:00 2001 From: faildeny Date: Thu, 13 Nov 2025 13:17:28 +0100 Subject: [PATCH 03/29] Minor xgb fix for results compatibility --- flcore/models/xgb/fed_custom_strategy.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/flcore/models/xgb/fed_custom_strategy.py b/flcore/models/xgb/fed_custom_strategy.py index 20dbe55..9f74f4d 100644 --- a/flcore/models/xgb/fed_custom_strategy.py +++ b/flcore/models/xgb/fed_custom_strategy.py @@ -143,4 +143,10 @@ def aggregate_fit( elif server_round == 1: # Only log this warning once log(WARNING, "No fit_metrics_aggregation_fn provided") + elapsed_time = (time.time() - self.time_server_round) + self.accum_time = self.accum_time+ elapsed_time + self.time_server_round = time.time() + print(f"Elapsed time: {elapsed_time} for round {server_round}") + metrics_aggregated['training_time [s]'] = self.accum_time + return [parameters_aggregated, trees_aggregated], metrics_aggregated \ No newline at end of file From 4da5e2ed23502b984e1f690fa6644704f3fd6c99 Mon Sep 17 00:00:00 2001 From: faildeny Date: Thu, 13 Nov 2025 13:18:24 +0100 Subject: [PATCH 04/29] Update tests with Diabetes dataset and unlock models testing --- tests/test_models.py | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 669f1d0..cd67f79 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -12,12 +12,18 @@ LOGGING_LEVEL = logging.INFO # WARNING # logging.INFO model_names = [ -# "logistic_regression", -# "elastic_net", -# "lsvc", + "logistic_regression", + "elastic_net", + "lsvc", "random_forest", + "balanced_random_forest", # "weighted_random_forest", - # "xgb" + "xgb" + ] + +datasets = [ + "kaggle_hf", + "diabetes", ] def free_port(port): @@ -39,12 +45,17 @@ def setup_class(self): @pytest.mark.parametrize( "model_name", - model_names + model_names, + ) + @pytest.mark.parametrize( + "dataset_name", + datasets, ) def test_get_model_client( - self, model_name + self, model_name, dataset_name ): self.config["model"] = model_name + self.config["dataset"] = dataset_name from flcore.client_selector import get_model_client from flcore.datasets import load_dataset @@ -57,22 +68,27 @@ def test_get_model_client( @pytest.mark.parametrize( "model_name", - model_names + model_names, ) - def test_run(self, model_name): + @pytest.mark.parametrize( + "dataset_name", + datasets, + ) + def test_run(self, model_name, dataset_name): self.config["model"] = model_name + self.config["dataset"] = dataset_name with open("config.yaml", "r") as f: config = yaml.safe_load(f) config = self.config - with open("config.yaml", "w") as f: + with open("tmp_test_config.yaml", "w") as f: yaml.dump(config, f) free_port(config["local_port"]) run_log = open("run.log", "w") - run_process = subprocess.Popen("python run.py", shell=True, stdout=run_log, stderr=run_log) + run_process = subprocess.Popen("python run.py tmp_test_config.yaml", shell=True, stdout=run_log, stderr=run_log) timer = Timer(180, run_process.kill) try: @@ -85,5 +101,8 @@ def test_run(self, model_name): run_log.close() run_log = open("run.log", "r") print(run_log.read()) + + # Delete temporary config file + os.remove("tmp_test_config.yaml") assert run_process.returncode == 0 From 891c7d75b2a542c0cd39f349e9b0890728f326ce Mon Sep 17 00:00:00 2001 From: faildeny Date: Thu, 13 Nov 2025 13:30:06 +0100 Subject: [PATCH 05/29] Fix for config independent tests --- tests/test_models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_models.py b/tests/test_models.py index cd67f79..feb2cc2 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -17,7 +17,7 @@ "lsvc", "random_forest", "balanced_random_forest", - # "weighted_random_forest", + # # "weighted_random_forest", "xgb" ] @@ -55,6 +55,7 @@ def test_get_model_client( self, model_name, dataset_name ): self.config["model"] = model_name + self.config['data_path'] = 'dataset/' self.config["dataset"] = dataset_name from flcore.client_selector import get_model_client From df5d480ea3a6a5749838ee7aecfd013b34c06fe1 Mon Sep 17 00:00:00 2001 From: faildeny Date: Thu, 13 Nov 2025 16:48:15 +0100 Subject: [PATCH 06/29] Update github actions to meet latest github system changes --- .github/workflows/python-ci.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 12460f1..0ef35fc 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -26,12 +26,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.ref }} # ${{ github.event.pull_request.head.sha }} - name: Setup Python 3.10 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.10" cache: 'pip' @@ -70,13 +70,13 @@ jobs: steps: - name: Checkout to latest changes - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ needs.formatting.outputs.new_sha }} fetch-depth: 0 - name: Set up Python 3.10 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.10" cache: 'pip' @@ -94,13 +94,13 @@ jobs: steps: - name: Checkout to latest changes - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ needs.formatting.outputs.new_sha }} fetch-depth: 0 - name: Set up Python 3.10 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.10" cache: 'pip' @@ -125,13 +125,13 @@ jobs: steps: - name: Checkout to latest changes - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ needs.formatting.outputs.new_sha }} fetch-depth: 0 - name: Set up Python 3.10 - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.10" cache: 'pip' From 3e3a0ef174def6d94dc6938c9646d39b02c9f788 Mon Sep 17 00:00:00 2001 From: faildeny Date: Tue, 3 Feb 2026 17:25:31 +0100 Subject: [PATCH 07/29] Add AUROC calculation and switch to predict_proba in models --- flcore/metrics.py | 31 ++++++++++++--------- flcore/models/linear_models/client.py | 23 ++++++++-------- flcore/models/linear_models/server.py | 4 +-- flcore/models/linear_models/utils.py | 36 ++++++++++++++++--------- flcore/models/random_forest/client.py | 8 +++--- flcore/models/xgb/client.py | 39 ++++++++++++++++++--------- 6 files changed, 86 insertions(+), 55 deletions(-) diff --git a/flcore/metrics.py b/flcore/metrics.py index 7788f61..5de33bc 100644 --- a/flcore/metrics.py +++ b/flcore/metrics.py @@ -8,6 +8,7 @@ BinaryPrecision, BinaryRecall, BinarySpecificity, + BinaryAUROC, ) from torchmetrics.functional.classification.precision_recall import ( @@ -43,17 +44,18 @@ def compute(self) -> Tensor: return (recall + specificity) / 2 -def get_metrics_collection(task_type="binary", device="cpu"): +def get_metrics_collection(task_type="binary", device="cpu", threshold=0.5): if task_type.lower() == "binary": return MetricCollection( { - "accuracy": BinaryAccuracy().to(device), - "precision": BinaryPrecision().to(device), - "recall": BinaryRecall().to(device), - "specificity": BinarySpecificity().to(device), - "f1": BinaryF1Score().to(device), - "balanced_accuracy": BinaryBalancedAccuracy().to(device), + "accuracy": BinaryAccuracy(threshold=threshold).to(device), + "precision": BinaryPrecision(threshold=threshold).to(device), + "recall": BinaryRecall(threshold=threshold).to(device), + "specificity": BinarySpecificity(threshold=threshold).to(device), + "f1": BinaryF1Score(threshold=threshold).to(device), + "balanced_accuracy": BinaryBalancedAccuracy(threshold=threshold).to(device), + "auroc": BinaryAUROC().to(device), } ) elif task_type.lower() == "reg": @@ -61,13 +63,18 @@ def get_metrics_collection(task_type="binary", device="cpu"): "mse": MeanSquaredError().to(device), }) -def calculate_metrics(y_true, y_pred, task_type="binary"): - metrics_collection = get_metrics_collection(task_type) + +def calculate_metrics(y_true, y_pred_proba, task_type="binary", threshold=0.5): + metrics_collection = get_metrics_collection(task_type, threshold=threshold) if not torch.is_tensor(y_true): y_true = torch.tensor(y_true.tolist()) - if not torch.is_tensor(y_pred): - y_pred = torch.tensor(y_pred.tolist()) - metrics_collection.update(y_pred, y_true) + if not torch.is_tensor(y_pred_proba): + y_pred_proba = torch.tensor(y_pred_proba.tolist()) + + # Extract probabilities for the positive class + y_pred_proba = y_pred_proba[:, 1] + + metrics_collection.update(y_pred_proba, y_true) metrics = metrics_collection.compute() metrics = {k: v.item() for k, v in metrics.items()} diff --git a/flcore/models/linear_models/client.py b/flcore/models/linear_models/client.py index b7561be..a4cd1ac 100644 --- a/flcore/models/linear_models/client.py +++ b/flcore/models/linear_models/client.py @@ -44,7 +44,7 @@ def __init__(self, data,client_id,config): self.first_round = True self.personalize = True # Setting initial parameters, akin to model.compile for keras models - utils.set_initial_params(self.model,self.n_features) + utils.set_initial_params(self.model, (self.X_train, self.y_train), self.n_features) def get_parameters(self, config): # type: ignore #compute the feature selection @@ -67,9 +67,8 @@ def fit(self, parameters, config): # type: ignore self.model.fit(self.X_train, self.y_train) # self.model.fit(self.X_train.loc[:, parameters[2].astype(bool)], self.y_train) # y_pred = self.model.predict(self.X_test.loc[:, parameters[2].astype(bool)]) - y_pred = self.model.predict(self.X_test) - - metrics = calculate_metrics(self.y_test, y_pred) + y_pred_proba = self.model.predict_proba(self.X_test) + metrics = calculate_metrics(self.y_test, y_pred_proba) print(f"Client {self.client_id} Evaluation just after local training: {metrics['balanced_accuracy']}") # Add 'personalized' to the metrics to identify them metrics = {f"personalized {key}": metrics[key] for key in metrics} @@ -81,10 +80,10 @@ def fit(self, parameters, config): # type: ignore if self.first_round: local_model = utils.get_model(self.model_name, local=True) - utils.set_initial_params(local_model,self.n_features) + # utils.set_initial_params(local_model,self.n_features) local_model.fit(self.X_train, self.y_train) - y_pred = local_model.predict(self.X_test) - local_metrics = calculate_metrics(self.y_test, y_pred) + y_pred_proba = local_model.predict_proba(self.X_test) + local_metrics = calculate_metrics(self.y_test, y_pred_proba) #Add 'local' to the metrics to identify them local_metrics = {f"local {key}": local_metrics[key] for key in local_metrics} metrics.update(local_metrics) @@ -96,10 +95,10 @@ def evaluate(self, parameters, config): # type: ignore utils.set_model_params(self.model, parameters) # Calculate validation set metrics - y_pred = self.model.predict(self.X_val) - val_metrics = calculate_metrics(self.y_val, y_pred) + y_pred_proba = self.model.predict_proba(self.X_val) + val_metrics = calculate_metrics(self.y_val, y_pred_proba) - y_pred = self.model.predict(self.X_test) + y_pred_proba = self.model.predict_proba(self.X_test) # y_pred = self.model.predict(self.X_test.loc[:, parameters[2].astype(bool)]) if(isinstance(self.model, SGDClassifier)): @@ -107,7 +106,7 @@ def evaluate(self, parameters, config): # type: ignore else: loss = log_loss(self.y_test, self.model.predict_proba(self.X_test), labels=[0, 1]) - metrics = calculate_metrics(self.y_test, y_pred) + metrics = calculate_metrics(self.y_test, y_pred_proba) metrics["round_time [s]"] = self.round_time metrics["client_id"] = self.client_id @@ -119,7 +118,7 @@ def evaluate(self, parameters, config): # type: ignore metrics.update(val_metrics) - return loss, len(y_pred), metrics + return loss, len(y_pred_proba), metrics def get_client(config,data,client_id) -> fl.client.Client: diff --git a/flcore/models/linear_models/server.py b/flcore/models/linear_models/server.py index 9204430..a49da28 100644 --- a/flcore/models/linear_models/server.py +++ b/flcore/models/linear_models/server.py @@ -138,9 +138,9 @@ def evaluate_held_out( def get_server_and_strategy(config): model_type = config['model'] - model = get_model(model_type) + # model = get_model(model_type) n_features = config['linear_models']['n_features'] - utils.set_initial_params(model, n_features) + # utils.set_initial_params(model, n_features) # Pass parameters to the Strategy for server-side parameter initialization #strategy = fl.server.strategy.FedAvg( diff --git a/flcore/models/linear_models/utils.py b/flcore/models/linear_models/utils.py index cdc36c9..512642e 100644 --- a/flcore/models/linear_models/utils.py +++ b/flcore/models/linear_models/utils.py @@ -20,14 +20,24 @@ def get_model(model_name, local=False): case "lsvc": #Linear classifiers (SVM, logistic regression, etc.) with SGD training. #If we use hinge, it implements SVM - model = SGDClassifier(max_iter=max_iter,n_iter_no_change=1000,average=True,random_state=42,class_weight= "balanced",warm_start=True,fit_intercept=True,loss="hinge", learning_rate='optimal') + model = SGDClassifier( + max_iter=max_iter, + n_iter_no_change=1000, + average=True, + # random_state=42, + class_weight= "balanced", + warm_start=True, + fit_intercept=True, + loss="hinge", + learning_rate='optimal' + ) case "logistic_regression": model = LogisticRegression( penalty="l2", #max_iter=1, # local epoch ==>> it doesn't work max_iter=max_iter, # local epoch warm_start=True, # prevent refreshing weights when fitting - random_state=42, + # random_state=42, class_weight= "balanced" #For unbalanced ) case "elastic_net": @@ -38,7 +48,7 @@ def get_model(model_name, local=False): #max_iter=1, # local epoch ==>> it doesn't work max_iter=max_iter, # local epoch warm_start=True, # prevent refreshing weights when fitting - random_state=42, + # random_state=42, class_weight= "balanced" #For unbalanced ) @@ -73,7 +83,7 @@ def set_model_params( return model -def set_initial_params(model: LinearClassifier,n_features): +def set_initial_params(model: LinearClassifier, data, n_features): """Sets initial parameters as zeros Required since model params are uninitialized until model.fit is called. But server asks for initial parameters from clients at launch. Refer @@ -82,16 +92,18 @@ def set_initial_params(model: LinearClassifier,n_features): """ n_classes = 2 # MNIST has 10 classes #n_features = 9 # Number of features in dataset + + model.fit(data[0], data[1]) model.classes_ = np.array([i for i in range(n_classes)]) - if(isinstance(model,SGDClassifier)==True): - model.coef_ = np.zeros((1, n_features)) - if model.fit_intercept: - model.intercept_ = 0 - else: - model.coef_ = np.zeros((n_classes, n_features)) - if model.fit_intercept: - model.intercept_ = np.zeros((n_classes,)) + # if(isinstance(model,SGDClassifier)==True): + # model.coef_ = np.zeros((1, n_features)) + # if model.fit_intercept: + # model.intercept_ = 0 + # else: + # model.coef_ = np.zeros((n_classes, n_features)) + # if model.fit_intercept: + # model.intercept_ = np.zeros((n_classes,)) #Evaluate in the aggregations evaluation with diff --git a/flcore/models/random_forest/client.py b/flcore/models/random_forest/client.py index 7c464f7..e4e1595 100644 --- a/flcore/models/random_forest/client.py +++ b/flcore/models/random_forest/client.py @@ -69,8 +69,8 @@ def fit(self, ins: FitIns): # , parameters, config type: ignore #accuracy = model.score( X_test, y_test ) # accuracy,specificity,sensitivity,balanced_accuracy, precision, F1_score = \ # measurements_metrics(self.model,X_val, y_val) - y_pred = self.model.predict(X_val) - metrics = calculate_metrics(y_val, y_pred) + y_pred_proba = self.model.predict_proba(X_val) + metrics = calculate_metrics(y_val, y_pred_proba) # print(f"Accuracy client in fit: {accuracy}") # print(f"Sensitivity client in fit: {sensitivity}") # print(f"Specificity client in fit: {specificity}") @@ -108,8 +108,8 @@ def evaluate(self, ins: EvaluateIns): # , parameters, config type: ignore loss = log_loss(self.y_test, y_pred_prob) # accuracy,specificity,sensitivity,balanced_accuracy, precision, F1_score = \ # measurements_metrics(self.model,self.X_test, self.y_test) - y_pred = self.model.predict(self.X_test) - metrics = calculate_metrics(self.y_test, y_pred) + # y_pred = self.model.predict(self.X_test) + metrics = calculate_metrics(self.y_test, y_pred_prob) metrics["round_time [s]"] = self.round_time metrics["client_id"] = self.client_id # print(f"Accuracy client in evaluate: {accuracy}") diff --git a/flcore/models/xgb/client.py b/flcore/models/xgb/client.py index 6bcbc1a..d2e358e 100644 --- a/flcore/models/xgb/client.py +++ b/flcore/models/xgb/client.py @@ -125,6 +125,10 @@ def fit(self, fit_params: FitIns) -> FitRes: print("Client " + self.cid + ": recieved", len(aggregated_trees), "trees") else: print("Client " + self.cid + ": only had its own tree") + + # Don't prepare dataloaders if they number of clients didn't change + # if type(aggregated_trees) is list and len(aggregated_trees) != self.client_num or self.trainloader is None: + self.trainloader = tree_encoding_loader( self.trainloader_original, batch_size, @@ -139,6 +143,8 @@ def fit(self, fit_params: FitIns) -> FitRes: self.client_tree_num, self.client_num, ) + # else: + # print("Client " + self.cid + ": reusing existing dataloaders") # num_iterations = None special behaviour: train(...) runs for a single epoch, however many updates it may be num_iterations = num_iterations or len(self.trainloader) @@ -235,25 +241,32 @@ def get_client(config, data, client_id) -> fl.client.Client: client_tree_num = config["xgb"]["tree_num"] // client_num batch_size = "whole" cid = str(client_id) + #measure time for client data loading + time_start = time.time() trainset = TreeDataset(np.array(X_train, copy=True), np.array(y_train, copy=True)) valset = TreeDataset(np.array(X_test, copy=True), np.array(y_test, copy=True)) + time_end = time.time() + print(f"Client {cid}: Data loading time: {time_end - time_start} seconds") + time_start = time.time() trainloader = get_dataloader(trainset, "train", batch_size) valloader = get_dataloader(valset, "test", batch_size) + time_end = time.time() + print(f"Client {cid}: Dataloader creation time: {time_end - time_start} seconds") - metrics = train_test(data, client_tree_num) - from flcore import datasets - if client_id == 1: - cross_id = 2 - else: - cross_id = 1 - _, (X_test, y_test) = datasets.load_dataset(config, cross_id) + # metrics = train_test(data, client_tree_num) + # from flcore import datasets + # if client_id == 1: + # cross_id = 2 + # else: + # cross_id = 1 + # _, (X_test, y_test) = datasets.load_dataset(config, cross_id) - data = (X_train, y_train), (X_test, y_test) - metrics_cross = train_test(data, client_tree_num) - print("Client " + cid + " non-federated training results:") - print(metrics) - print("Cross testing model on client " + str(cross_id) + ":") - print(metrics_cross) + # data = (X_train, y_train), (X_test, y_test) + # metrics_cross = train_test(data, client_tree_num) + # print("Client " + cid + " non-federated training results:") + # print(metrics) + # print("Cross testing model on client " + str(cross_id) + ":") + # print(metrics_cross) client = FL_Client( task_type, From fa7191d3cd413ad975656f8323780cc61a5de4c0 Mon Sep 17 00:00:00 2001 From: faildeny Date: Tue, 3 Feb 2026 17:26:32 +0100 Subject: [PATCH 08/29] Improve Random Forest hyperparameters --- flcore/models/random_forest/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flcore/models/random_forest/utils.py b/flcore/models/random_forest/utils.py index 026c294..426e9f7 100644 --- a/flcore/models/random_forest/utils.py +++ b/flcore/models/random_forest/utils.py @@ -23,9 +23,9 @@ def get_model(bal_RF): if(bal_RF == True): - model = BalancedRandomForestClassifier(n_estimators=100,random_state=42) + model = BalancedRandomForestClassifier(n_estimators=300,max_depth=10) else: - model = RandomForestClassifier(n_estimators=100,class_weight= "balanced",max_depth=2,random_state=42) + model = RandomForestClassifier(n_estimators=300,max_depth=10,class_weight= "balanced_subsample") return model From 157fd8c6b58d12a3f48fe59a0e701411a0427ff3 Mon Sep 17 00:00:00 2001 From: faildeny Date: Tue, 3 Feb 2026 17:27:21 +0100 Subject: [PATCH 09/29] Minor changes with report generation --- flcore/compile_results.py | 48 +++++++++++++++++--------------- flcore/report/generate_report.py | 3 +- server.py | 13 +++++++-- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/flcore/compile_results.py b/flcore/compile_results.py index 8270d9b..4e7f0c3 100644 --- a/flcore/compile_results.py +++ b/flcore/compile_results.py @@ -21,6 +21,8 @@ def compile_results(experiment_dir: str): elif config['dataset'] == 'kaggle_hf': center_names = ['Cleveland', 'Hungary', 'VA', 'Switzerland'] + else: + center_names = [f"center_{i+1}" for i in range(config['num_clients'])] writer = open(f"{experiment_dir}/metrics.txt", "w") @@ -47,7 +49,8 @@ def compile_results(experiment_dir: str): # Read history.yaml history = yaml.safe_load(open(os.path.join(fold_dir, "history.yaml"), "r")) - selection_metric = 'val '+ config['checkpoint_selection_metric'] + # selection_metric = 'val '+ config['checkpoint_selection_metric'] + selection_metric = config['checkpoint_selection_metric'] best_round= int(np.argmax(history['metrics_distributed'][selection_metric])) # client_order = history['metrics_distributed']['per client client_id'][best_round] client_order = history['metrics_distributed']['per client n samples'][best_round] @@ -98,7 +101,8 @@ def compile_results(experiment_dir: str): fit_metrics[metric] = np.vstack((fit_metrics[metric], values_history[best_round])) - execution_stats = ['client_id', 'round_time [s]', 'n samples', 'training_time [s]'] + # execution_stats = ['client_id', 'round_time [s]', 'n samples', 'training_time [s]'] + execution_stats = ['client_id', 'round_time [s]', 'n samples'] # Calculate mean and std for per client metrics writer.write(f"{'Evaluation':.^100} \n\n") writer.write(f"\n{'Test set:'} \n") @@ -161,25 +165,25 @@ def compile_results(experiment_dir: str): centralized_metrics[metric] = held_out_metrics[metric] held_out_metrics.pop(metric, None) - writer.write(f"\n{'Held out set evaluation':.^100} \n\n") - for metric in held_out_metrics: - center = int(held_out_metrics['client_id'][0]) - center = center_names[center]+' (held out)' - mean = np.average(held_out_metrics[metric]) - std = np.std(held_out_metrics[metric]) - - writer.write(f"{metric:<30}: {mean:<6.3f} ±{std:<6.3f}\n") - if center not in csv_dict: - csv_dict[center] = {} - csv_dict[center][metric] = mean - csv_dict[center][metric+'_std'] = std - - # Calculate mean and std for centralized metrics - writer.write(f"\n{'Centralized evaluation':.^100} \n\n") - for metric in centralized_metrics: - mean = np.average(centralized_metrics[metric]) - std = np.std(centralized_metrics[metric]) - writer.write(f"{metric:<30}: {mean:<6.3f} ±{std:<6.3f}\n") + # writer.write(f"\n{'Held out set evaluation':.^100} \n\n") + # for metric in held_out_metrics: + # center = int(held_out_metrics['client_id'][0]) + # center = center_names[center]+' (held out)' + # mean = np.average(held_out_metrics[metric]) + # std = np.std(held_out_metrics[metric]) + + # writer.write(f"{metric:<30}: {mean:<6.3f} ±{std:<6.3f}\n") + # if center not in csv_dict: + # csv_dict[center] = {} + # csv_dict[center][metric] = mean + # csv_dict[center][metric+'_std'] = std + + # # Calculate mean and std for centralized metrics + # writer.write(f"\n{'Centralized evaluation':.^100} \n\n") + # for metric in centralized_metrics: + # mean = np.average(centralized_metrics[metric]) + # std = np.std(centralized_metrics[metric]) + # writer.write(f"{metric:<30}: {mean:<6.3f} ±{std:<6.3f}\n") writer.close() @@ -194,7 +198,7 @@ def compile_results(experiment_dir: str): # Write to csv df.to_csv(f"{experiment_dir}/per_center_results.csv", index=True) - generate_report(experiment_dir) + # generate_report(experiment_dir) if __name__ == "__main__": diff --git a/flcore/report/generate_report.py b/flcore/report/generate_report.py index 1c92777..45e88f9 100644 --- a/flcore/report/generate_report.py +++ b/flcore/report/generate_report.py @@ -27,7 +27,8 @@ def generate_report(experiment_path: str): df = df.rename(columns={"Unnamed: 0": "center"}) # Convert metrics columns to 2 decimal places df = df.round(2) - colors = ['#FF6666', '#FF9999', '#FF3333', '#CC0000', '#990000', '#B22222', '#FF0044', '#960018'] + colors = ['#FF6666', '#FF9999', '#FF3333', '#CC0000', '#990000', '#B22222', '#FF0044', '#960018', '#FF0000', + '#B22222'] # print(df.head()) diff --git a/server.py b/server.py index 0b9784a..5149c1e 100644 --- a/server.py +++ b/server.py @@ -103,8 +103,17 @@ def check_config(config): # selection_metric = 'val ' + config['checkpoint_selection_metric'] selection_metric = config['checkpoint_selection_metric'] # Get index of tuple of the best round - best_round = int(numpy.argmax([round[1] for round in history.metrics_distributed[selection_metric]])) - training_time = history.metrics_distributed_fit['training_time [s]'][-1][1] + # best_round = int(numpy.argmax([round[1] for round in history.metrics_distributed[selection_metric]])) + # Use the last round as final checkpoint, since no validation set is used + best_round = -1 + print(history) + # check if history has attribute metrics_distributed_fit + if hasattr(history, 'metrics_distributed_fit') and 'training_time [s]' in history.metrics_distributed_fit: + # check if training_time is in metrics_distributed_fit + training_time = history.metrics_distributed_fit['training_time [s]'][-1][1] + else: + training_time = 0.0 + f.write(f"Total training time: {training_time:.2f} [s] \n") f.write(f"Best checkpoint based on {selection_metric} after round: {best_round}\n\n") print(f"Best checkpoint based on {selection_metric} after round: {best_round}\n\n") From 185789a5ced6057004e952f4a40e41a7a1bf96de Mon Sep 17 00:00:00 2001 From: faildeny Date: Tue, 3 Feb 2026 17:29:14 +0100 Subject: [PATCH 10/29] Move to one commonm federated preprocessing function for public datasets. --- config.yaml | 45 ++- flcore/datasets.py | 969 +++++++++++++++++++++++++-------------------- 2 files changed, 575 insertions(+), 439 deletions(-) diff --git a/config.yaml b/config.yaml index 9ee8e31..1319554 100644 --- a/config.yaml +++ b/config.yaml @@ -11,7 +11,9 @@ ############## Dataset type to use # Possible values: , kaggle_hf, diabetes, mnist, dt4h_format -dataset: dt4h_format +dataset: kaggle_hf +# dataset: ukbb_cvd +# dataset: diabetes #custom #libsvm #kaggle_hf @@ -33,30 +35,54 @@ train_size: 0.7 # ****** * * * * * * * * * * * * * * * * * * * * ******************* ############## Number of clients (data centers) to use for training -num_clients: 3 +num_clients: 4 ############## Model type # Possible values: logistic_regression, lsvc, elastic_net, random_forest, weighted_random_forest, xgb # See README.md for a full list of supported models -model: random_forest +# model: xgb +model: logistic_regression +# model: random_forest #logistic_regression #random_forest ############## Training length -num_rounds: 5 +num_rounds: 10 ############## Metric to select the best model # Possible values: accuracy, balanced_accuracy, f1, precision, recall -checkpoint_selection_metric: precision +# checkpoint_selection_metric: precision +checkpoint_selection_metric: balanced_accuracy #balanced_accuracy ############## Experiment logging experiment: - name: experiment_1 + name: experiment_kaggle_standard log_path: logs debug: true +################################################################################ +# Federated Data Preprocessing +################################################################################ + +# Strategy to calculate data preprocessing parameters between clients. +# It covers missing data imputation, label encoding, normalization and feature selection +# It can be one of: + # "reference" - use reference center to calculate all parameters (largest or random) + # "equal_aggregate" - aggregate parameters from all clients based on mean and voting disregarding center size + # "weighted_aggregate" - aggregate parameters from all clients based on weighted mean and voting + +# data_preprocessing_method: "equal_aggregate" +data_preprocessing_method: "reference" + +# Toggle data normalization (Standard scaler) based on largest center (global) or local client +data_normalization: "local" + +# Determine target for feature selection number +n_features: Null + + ################################################################################ # Aggregation methods ################################################################################ @@ -87,7 +113,8 @@ smoothWeights: linear_models: n_features: 9 -n_features: 9 + +dirichlet_alpha: Null # Random Forest random_forest: @@ -103,7 +130,7 @@ xgb: batch_size: 32 num_iterations: 100 task_type: BINARY - tree_num: 10 + tree_num: 300 held_out_center_id: -1 @@ -115,6 +142,6 @@ seed: 42 local_port: 8081 -data_path: dataset/icrc-dataset/ +data_path: dataset/ production_mode: False # Turn on to use environment variables such as data path, server address, certificates etc. diff --git a/flcore/datasets.py b/flcore/datasets.py index d7f4e84..f82a776 100644 --- a/flcore/datasets.py +++ b/flcore/datasets.py @@ -19,6 +19,7 @@ from sklearn.ensemble import RandomForestClassifier from ucimlrepo import fetch_ucirepo +import pickle from flcore.models.xgb.utils import TreeDataset, do_fl_partitioning, get_dataloader @@ -96,69 +97,70 @@ def calculate_preprocessing_params(subset_data, subset_target, n_features=None, selected_features = None feature_scores = None - if n_features is not None and n_features < len(numeric_data.columns): - # Prepare data for feature selection - X_temp = numeric_data.fillna(numeric_data.median()) - y_temp = target_copy - - # Handle any remaining NaN values - X_temp = X_temp.fillna(0) - - if feature_selection_method == 'mutual_info': - selector = SelectKBest(score_func=mutual_info_classif, k=min(n_features, X_temp.shape[1])) - elif feature_selection_method == 'f_classif': - selector = SelectKBest(score_func=f_classif, k=min(n_features, X_temp.shape[1])) - elif feature_selection_method == 'random_forest': - # Use Random Forest feature importance - rf = RandomForestClassifier(n_estimators=100, random_state=42) - rf.fit(X_temp, y_temp) - importances = rf.feature_importances_ - indices = np.argsort(importances)[::-1] - selected_indices = indices[:min(n_features, len(indices))] + if n_features is not None: + if n_features < len(numeric_data.columns): + # Prepare data for feature selection + X_temp = numeric_data.fillna(numeric_data.median()) + y_temp = target_copy - # Create a custom selector object - class CustomSelector: - def __init__(self, selected_indices, feature_names): - self.selected_indices = selected_indices - self.feature_names = feature_names - self.scores_ = importances - - def transform(self, X): - if isinstance(X, pd.DataFrame): - return X.iloc[:, self.selected_indices] - else: - return X[:, self.selected_indices] + # Handle any remaining NaN values + X_temp = X_temp.fillna(0) + + if feature_selection_method == 'mutual_info': + selector = SelectKBest(score_func=mutual_info_classif, k=min(n_features, X_temp.shape[1])) + elif feature_selection_method == 'f_classif': + selector = SelectKBest(score_func=f_classif, k=min(n_features, X_temp.shape[1])) + elif feature_selection_method == 'random_forest': + # Use Random Forest feature importance + rf = RandomForestClassifier(n_estimators=100, random_state=42) + rf.fit(X_temp, y_temp) + importances = rf.feature_importances_ + indices = np.argsort(importances)[::-1] + selected_indices = indices[:min(n_features, len(indices))] + + # Create a custom selector object + class CustomSelector: + def __init__(self, selected_indices, feature_names): + self.selected_indices = selected_indices + self.feature_names = feature_names + self.scores_ = importances - def get_support(self, indices=False): - if indices: - return self.selected_indices - else: - mask = np.zeros(len(self.feature_names), dtype=bool) - mask[self.selected_indices] = True - return mask + def transform(self, X): + if isinstance(X, pd.DataFrame): + return X.iloc[:, self.selected_indices] + else: + return X[:, self.selected_indices] + + def get_support(self, indices=False): + if indices: + return self.selected_indices + else: + mask = np.zeros(len(self.feature_names), dtype=bool) + mask[self.selected_indices] = True + return mask + + selector = CustomSelector(selected_indices, numeric_data.columns.tolist()) + feature_scores = importances + else: + raise ValueError("feature_selection_method must be 'mutual_info', 'f_classif', or 'random_forest'") - selector = CustomSelector(selected_indices, numeric_data.columns.tolist()) - feature_scores = importances - else: - raise ValueError("feature_selection_method must be 'mutual_info', 'f_classif', or 'random_forest'") - - if feature_selection_method != 'random_forest': - selector.fit(X_temp, y_temp) - feature_scores = selector.scores_ - - feature_selector = selector - selected_features = numeric_data.columns[selector.get_support()].tolist() - - print(f"Feature selection: Selected {len(selected_features)} most informative features") - if feature_scores is not None: - # Print top feature scores - feature_importance = pd.DataFrame({ - 'feature': numeric_data.columns, - 'score': feature_scores - }).sort_values('score', ascending=False) - print("Top 5 features:") - for i, (_, row) in enumerate(feature_importance.head().iterrows()): - print(f" {i+1}. {row['feature']}: {row['score']:.4f}") + if feature_selection_method != 'random_forest': + selector.fit(X_temp, y_temp) + feature_scores = selector.scores_ + + feature_selector = selector + selected_features = numeric_data.columns[selector.get_support()].tolist() + + print(f"Feature selection: Selected {len(selected_features)} most informative features") + if feature_scores is not None: + # Print top feature scores + feature_importance = pd.DataFrame({ + 'feature': numeric_data.columns, + 'score': feature_scores + }).sort_values('score', ascending=False) + print("Top 5 features:") + for i, (_, row) in enumerate(feature_importance.head().iterrows()): + print(f" {i+1}. {row['feature']}: {row['score']:.4f}") return { 'imputation': imputation_params, @@ -169,7 +171,7 @@ def get_support(self, indices=False): 'n_features': n_features } -def apply_preprocessing(subset_data, preprocessing_params): +def apply_preprocessing(subset_data, preprocessing_params, normalization="global"): """ Apply preprocessing to a subset using pre-calculated parameters from reference center @@ -213,13 +215,26 @@ def apply_preprocessing(subset_data, preprocessing_params): # Ensure all data is numerical data_copy = data_copy.apply(pd.to_numeric, errors='coerce') - # Step 3: Normalize ALL features using reference center parameters - normalization_params = preprocessing_params['normalization'] - for column in data_copy.columns: - if column in normalization_params['mean']: - mean_val = normalization_params['mean'][column] - std_val = normalization_params['std'][column] + # Step 3: Normalize ALL features using global parameters if enabled + if normalization == "global": + normalization_params = preprocessing_params['normalization'] + for column in data_copy.columns: + if column in normalization_params['mean']: + mean_val = normalization_params['mean'][column] + std_val = normalization_params['std'][column] + data_copy[column] = (data_copy[column] - mean_val) / std_val + # print("Applied global normalization during preprocessing.") + elif normalization == "local": + # Calculate local normalization parameters + local_mean = data_copy.mean() + local_std = data_copy.std() + for column in data_copy.columns: + mean_val = local_mean[column] + std_val = local_std[column] if local_std[column] != 0 else 1.0 data_copy[column] = (data_copy[column] - mean_val) / std_val + # print("Applied local normalization during preprocessing.") + elif normalization is not None: + raise ValueError("Data normalization method must be 'global', 'local', or None") # Step 4: Apply feature selection if enabled if preprocessing_params['feature_selector'] is not None: @@ -236,6 +251,18 @@ def partition_data_dirichlet(labels, num_centers, alpha=1.0): unique_labels = np.unique(labels) n_samples = len(labels) n_classes = len(unique_labels) + + if not alpha: + alpha = -1.0 + + if alpha <= 0: + # IID partitioning + shuffled_indices = np.random.permutation(n_samples) + center_indices = np.array_split(shuffled_indices, num_centers) + center_indices = [indices.tolist() for indices in center_indices] + # check lengths of each center + center_lengths = [len(indices) for indices in center_indices] + return center_indices # Create assignment matrix center_indices = [[] for _ in range(num_centers)] @@ -287,13 +314,362 @@ def select_reference_center(all_center_data, method='largest'): elif method == 'random': reference_center_id = np.random.randint(0, len(all_center_data)) print(f"Selected random center (ID: {reference_center_id})") - else: raise ValueError("Method must be 'largest' or 'random'") return reference_center_id +def aggregate_preprocessing_params(preprocessing_params_list, center_sizes, method='weighted_aggregate'): + """ + Aggregate preprocessing parameters from multiple centers using weighted aggregation. + + Args: + preprocessing_params_list: List of preprocessing parameter dictionaries from each center + center_sizes: List of center sizes (number of samples) + + Returns: + dict: Aggregated preprocessing parameters + """ + if not preprocessing_params_list: + raise ValueError("preprocessing_params_list cannot be empty") + + if "equal" in method: + # Equal weights + center_sizes = [1 for _ in center_sizes] + print("Using equal weights for aggregation of preprocessing parameters.") + + total_size = sum(center_sizes) + weights = [size / total_size for size in center_sizes] + + aggregated = { + 'imputation': {}, + 'normalization': {'mean': {}, 'std': {}}, + 'label_encoders': {}, + 'feature_selector': None, + 'selected_features': [], + 'n_features': preprocessing_params_list[0]['n_features'] # Assume same for all + } + + # Collect all columns + all_columns = set() + for params in preprocessing_params_list: + all_columns.update(params['imputation'].keys()) + all_columns.update(params['normalization']['mean'].keys()) + all_columns.update(params['label_encoders'].keys()) + + # Aggregate imputation + for col in all_columns: + numeric_values = [] + categorical_values = [] + weights_num = [] + weights_cat = [] + for params, weight in zip(preprocessing_params_list, weights): + if col in params['imputation']: + value = params['imputation'][col] + if isinstance(value, (int, float)) and not pd.isna(value): + numeric_values.append(value) + weights_num.append(weight) + else: + categorical_values.append(value) + weights_cat.append(weight) + + if numeric_values: + # Weighted mean for numeric + aggregated['imputation'][col] = sum(v * w for v, w in zip(numeric_values, weights_num)) / sum(weights_num) + elif categorical_values: + # Most frequent for categorical (simple mode) + from collections import Counter + counter = Counter(categorical_values) + aggregated['imputation'][col] = counter.most_common(1)[0][0] + + # Aggregate normalization + for col in all_columns: + means = [] + stds = [] + weights_norm = [] + for params, weight in zip(preprocessing_params_list, weights): + if col in params['normalization']['mean']: + means.append(params['normalization']['mean'][col]) + stds.append(params['normalization']['std'][col]) + weights_norm.append(weight) + + if means: + global_mean = sum(m * w for m, w in zip(means, weights_norm)) / sum(weights_norm) + aggregated['normalization']['mean'][col] = global_mean + + # Calculate global std: sqrt( sum(w_i * var_i) + sum(w_i * (mean_i - global_mean)^2) ) + variances = [s ** 2 for s in stds] + weighted_var_sum = sum(v * w for v, w in zip(variances, weights_norm)) + mean_diff_sq = [(m - global_mean) ** 2 for m in means] + weighted_mean_var = sum(md * w for md, w in zip(mean_diff_sq, weights_norm)) + global_var = weighted_var_sum + weighted_mean_var + global_std = np.sqrt(global_var) if global_var > 0 else 1.0 + aggregated['normalization']['std'][col] = global_std + + # For label_encoders, take from the largest center (simplest approach) + max_size_idx = center_sizes.index(max(center_sizes)) + aggregated['label_encoders'] = preprocessing_params_list[max_size_idx]['label_encoders'].copy() + + # Aggregate selected_features by frequency + if preprocessing_params_list[0]['selected_features']: + from collections import Counter + feature_counts = Counter() + for params, weight in zip(preprocessing_params_list, weights): + for feature in params['selected_features']: + feature_counts[feature] += weight + + # Select top n_features most frequent + n_features = aggregated['n_features'] + if n_features: + selected = [feat for feat, _ in feature_counts.most_common(n_features)] + aggregated['selected_features'] = selected + + return aggregated +def prepare_dataset_old(X, y, center_id, config, center_indices=None): + """ + Load and preprocess raw dataset for federated learning with feature selection + + This function will extract the following config values: + center_id: Identifier for the federated node + num_centers: Total number of federated centers + alpha: Dirichlet concentration parameter for data partitioning + reference_method: How to select reference center ('largest' or 'random') + global_preprocessing_params: Precomputed parameters (if None, will calculate) + n_features: Number of features to select (None for all features) + feature_selection_method: Method for feature selection + + Returns: + tuple: X_train, y_train, X_test, y_test + """ + + num_centers = config.get("num_clients", 5) + alpha = config.get("dirichlet_alpha", 1.0) + reference_method = config.get("reference_center_method", "largest") + global_preprocessing_params = None + n_features = config.get("n_features", 20) + feature_selection_method = config.get("feature_selection_method", "mutual_info") + normalization_method = config.get("data_normalization", "global") + + np.random.seed(42) + + # Convert target to binary classification if needed + if y.nunique() > 2: + y_binary = (y > y.median()).astype(int) + else: + y_binary = y + + if not center_indices: + # Partition data using Dirichlet distribution + all_center_indices = partition_data_dirichlet(y_binary.values, num_centers, alpha) + else: + all_center_indices = center_indices + + # Get all center data for reference selection + all_center_data = [] + for i in range(num_centers): + if i < len(all_center_indices) and len(all_center_indices[i]) > 0: + X_center = X.iloc[all_center_indices[i]] + all_center_data.append((X_center, y_binary.iloc[all_center_indices[i]])) + else: + all_center_data.append((pd.DataFrame(), pd.Series())) + + # Calculate or use global preprocessing parameters + if global_preprocessing_params is None: + if aggregation_method == 'reference': + # Select reference center and calculate parameters + reference_center_id = select_reference_center(all_center_data, reference_method) + X_reference = all_center_data[reference_center_id][0] + y_reference = all_center_data[reference_center_id][1] + + if len(X_reference) == 0: + # Fallback: use full dataset if reference center is empty + X_reference = X + y_reference = y_binary + print("Warning: Reference center empty, using full dataset for preprocessing parameters") + + global_preprocessing_params = calculate_preprocessing_params( + X_reference, y_reference, n_features=n_features, feature_selection_method=feature_selection_method + ) + print("Calculated global preprocessing parameters using reference center") + elif aggregation_method == 'weighted_aggregate': + # Calculate parameters for each center and aggregate + preprocessing_params_list = [] + center_sizes = [] + for X_center, y_center in all_center_data: + if len(X_center) > 0: + params = calculate_preprocessing_params( + X_center, y_center, n_features=n_features, feature_selection_method=feature_selection_method + ) + preprocessing_params_list.append(params) + center_sizes.append(len(X_center)) + + if preprocessing_params_list: + global_preprocessing_params = aggregate_preprocessing_params(preprocessing_params_list, center_sizes) + print("Calculated global preprocessing parameters using weighted aggregation") + else: + # Fallback + global_preprocessing_params = calculate_preprocessing_params( + X, y_binary, n_features=n_features, feature_selection_method=feature_selection_method + ) + print("Warning: No valid centers, using full dataset for preprocessing parameters") + else: + raise ValueError("aggregation_method must be 'reference' or 'weighted_aggregate'") + + if center_id is not None: + # Get indices for the requested center + if center_id >= len(all_center_indices) or len(all_center_indices[center_id]) == 0: + raise ValueError(f"Center ID {center_id} has no data assigned") + + center_indices = all_center_indices[center_id] + X_center = X.iloc[center_indices].reset_index(drop=True) + y_center = y.iloc[center_indices].reset_index(drop=True) + else: + # Use full dataset if no center_id specified + X_center = X + y_center = y + + # Split into train/test for this center + if len(X_center) > 1: + X_train, X_test, y_train, y_test = train_test_split( + X_center, y_center, test_size=0.2, random_state=42, stratify=y_center + ) + else: + X_train, y_train = X_center, y_center + X_test, y_test = X_center.iloc[:0], y_center.iloc[:0] + + # Apply GLOBAL preprocessing parameters to both train and test sets + X_train_processed, feature_names = apply_preprocessing(X_train, global_preprocessing_params, normalization=normalization_method) + X_test_processed, _ = apply_preprocessing(X_test, global_preprocessing_params, normalization=normalization_method) + + # shuffle the training data + X_train_processed, y_train = shuffle(X_train_processed, y_train) + + return X_train_processed, y_train, X_test_processed, y_test + +def prepare_dataset(X, y, center_id, config, center_indices=None): + """ + Load and preprocess raw dataset for federated learning with feature selection + + This function will extract the following config values: + center_id: Identifier for the federated node + num_centers: Total number of federated centers + alpha: Dirichlet concentration parameter for data partitioning + reference_method: How to select reference center ('largest' or 'random') + aggregation_method: How to aggregate preprocessing params ('reference' or 'weighted_aggregate') + global_preprocessing_params: Precomputed parameters (if None, will calculate) + n_features: Number of features to select (None for all features) + feature_selection_method: Method for feature selection + + Returns: + tuple: X_train, y_train, X_test, y_test + """ + + num_centers = config.get("num_clients", 5) + alpha = config.get("dirichlet_alpha", 1.0) + reference_method = config.get("reference_center_method", "largest") + preprocessing_method = config.get("data_preprocessing_method", "reference") + global_preprocessing_params = None + n_features = config.get("n_features", 20) + feature_selection_method = config.get("feature_selection_method", "mutual_info") + normalization_method = config.get("data_normalization", "global") + + np.random.seed(42) + + # Convert target to binary classification if needed + if y.nunique() > 2: + y_binary = (y > y.median()).astype(int) + else: + y_binary = y + + if not center_indices: + # Partition data using Dirichlet distribution + all_center_indices = partition_data_dirichlet(y_binary.values, num_centers, alpha) + else: + all_center_indices = center_indices + + # Get all center data for reference selection + all_center_data = [] + for i in range(num_centers): + if i < len(all_center_indices) and len(all_center_indices[i]) > 0: + X_center = X.iloc[all_center_indices[i]] + all_center_data.append((X_center, y_binary.iloc[all_center_indices[i]])) + else: + all_center_data.append((pd.DataFrame(), pd.Series())) + + # Calculate or use global preprocessing parameters + if global_preprocessing_params is None: + if preprocessing_method == 'reference': + # Select reference center and calculate parameters + reference_center_id = select_reference_center(all_center_data, reference_method) + X_reference = all_center_data[reference_center_id][0] + y_reference = all_center_data[reference_center_id][1] + + if len(X_reference) == 0: + # Fallback: use full dataset if reference center is empty + X_reference = X + y_reference = y_binary + print("Warning: Reference center empty, using full dataset for preprocessing parameters") + + global_preprocessing_params = calculate_preprocessing_params( + X_reference, y_reference, n_features=n_features, feature_selection_method=feature_selection_method + ) + elif "aggregate" in preprocessing_method: + # Calculate parameters for each center and aggregate + preprocessing_params_list = [] + center_sizes = [] + for X_center, y_center in all_center_data: + if len(X_center) > 0: + params = calculate_preprocessing_params( + X_center, y_center, n_features=n_features, feature_selection_method=feature_selection_method + ) + preprocessing_params_list.append(params) + center_sizes.append(len(X_center)) + + if preprocessing_params_list: + global_preprocessing_params = aggregate_preprocessing_params(preprocessing_params_list, center_sizes, method=preprocessing_method) + else: + # Fallback + global_preprocessing_params = calculate_preprocessing_params( + X, y_binary, n_features=n_features, feature_selection_method=feature_selection_method + ) + print("Warning: No valid centers, using full dataset for preprocessing parameters") + else: + raise ValueError("aggregation_method must be 'reference', 'equal_aggregate' or 'weighted_aggregate'") + + print("Calculated global preprocessing parameters using", preprocessing_method) + + if center_id is not None: + # Get indices for the requested center + if center_id >= len(all_center_indices) or len(all_center_indices[center_id]) == 0: + raise ValueError(f"Center ID {center_id} has no data assigned") + + center_indices = all_center_indices[center_id] + X_center = X.iloc[center_indices].reset_index(drop=True) + y_center = y.iloc[center_indices].reset_index(drop=True) + else: + # Use full dataset if no center_id specified + X_center = X + y_center = y + + # Split into train/test for this center + if len(X_center) > 1: + X_train, X_test, y_train, y_test = train_test_split( + X_center, y_center, test_size=0.2, random_state=42, stratify=y_center + ) + else: + X_train, y_train = X_center, y_center + X_test, y_test = X_center.iloc[:0], y_center.iloc[:0] + + # Apply GLOBAL preprocessing parameters to both train and test sets + X_train_processed, feature_names = apply_preprocessing(X_train, global_preprocessing_params, normalization=normalization_method) + X_test_processed, _ = apply_preprocessing(X_test, global_preprocessing_params, normalization=normalization_method) + + # shuffle the training data + X_train_processed, y_train = shuffle(X_train_processed, y_train) + + return X_train_processed, y_train, X_test_processed, y_test + def load_mnist(center_id=None, num_splits=5): """Loads the MNIST dataset using OpenML. OpenML dataset link: https://www.openml.org/d/554 @@ -337,109 +713,51 @@ def load_mnist(center_id=None, num_splits=5): return (x_train, y_train), (x_test, y_test) - -def load_cvd(data_path, center_id=None) -> Dataset: +def load_cvd(data_path, center_id, config) -> Dataset: id = center_id - if center_id == 1: - file_name = data_path+'data_center1.csv' - elif center_id == 2: - file_name = data_path+'data_center2.csv' - elif center_id == 3: - file_name = data_path+'data_center3.csv' - else: - file_name = data_path+'data_center3.csv' - - if id == None: - # id = 'All' - data_centers = ['All'] - else: - data_centers = [id] - - X_train_list, y_train_list = [], [] - X_test_list, y_test_list = [], [] - test_index_list = [] - train_index_list = [] - for id in data_centers: - # file_name = os.path.join(data_path, f"data_center{id}.csv") - # file_name = os.path.join(data_path, file_name) + code_id = "f_eid" + code_outcome = "Eval" - code_id = "f_eid" - code_outcome = "Eval" + data = pd.read_csv(os.path.join(data_path, "data_centerAll.csv")) + X_data = data.drop([code_id, code_outcome], axis=1) + y_data = data[code_outcome] - data = pd.read_csv(file_name) - X_data = data.drop([code_id, code_outcome], axis=1) - y_data = data[code_outcome] - f_eid = data[code_id] + X_train_processed, y_train, X_test_processed, y_test = prepare_dataset(X_data, y_data, center_id, config) - # Split the data - sss = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=None) - train_index, test_index = next(sss.split(X_data, y_data)) - X_test = X_data.iloc[test_index, :] - X_train = X_data.iloc[train_index, :] - y_test, y_train = y_data.iloc[test_index], y_data.iloc[train_index] - # We save the names - f_eid.iloc[test_index] - f_eid.iloc[train_index] - - X_train_list.append(X_train) - y_train_list.append(y_train) - X_test_list.append(X_test) - y_test_list.append(y_test) - train_index_list.append(train_index) - test_index_list.append(test_index) - - X_train = pd.concat(X_train_list) - y_train = pd.concat(y_train_list) - X_test = pd.concat(X_test_list) - y_test = pd.concat(y_test_list) - train_index = np.concatenate(train_index_list) - test_index = np.concatenate(test_index_list) - - # Verify set difference, data centers overlap - # print(len(train_index.tolist())) - # print(len(test_index.tolist())) - # train_set = set(train_index.tolist()) - # test_set = set(test_index.tolist()) - # diff = train_set.intersection(test_set) - # print(len(train_set)) - # print(len(test_set)) - # print( len(diff) ) - # print(f"SUBSET {id}") - # train_unique = np.unique(y_train, return_counts=True) - # test_unique = np.unique(y_test, return_counts=True) - # train_max_acc = train_unique[1][0]/len(y_train) - # test_max_acc = test_unique[1][0]/len(y_test) - # print(np.unique(y_train, return_counts=True)) - # print(np.unique(y_test, return_counts=True)) - # print(train_max_acc) - # print(test_max_acc) - - return (X_train, y_train), (X_test, y_test) + return (X_train_processed, y_train), (X_test_processed, y_test) def load_ukbb_cvd(data_path, center_id, config) -> Dataset: + """ + Load UKBB CVD mortality dataset + + Args: + data_path: Path to the dataset + center_id: ID of the center to load + config: Configuration dictionary - seed = config["seed"] + """ data_path = os.path.join(data_path, "CVDMortalityData.csv") data = pd.read_csv(data_path) - # print(len(data)) - center_key = 'f.54.0.0' patient_key = 'f.eid' label_key = 'label' - # center_id = None - # center_id = 1 - preprocessing_data = data.loc[(data[center_key] == 1)] - # center_id = None - if center_id is not None: - center_id = center_id - if center_id == 19: - center_id = 21 - elif center_id == 21: - center_id = 19 - data = data.loc[(data[center_key] == center_id)] + #Create a list of lists for each center_key with row indexes from that center + center_keys = sorted(list(data[center_key].unique())) + # convert to list of ints + center_keys = set(int(center) for center in center_keys) + center_indices = [] + for center in center_keys: + center_indices.append(data.loc[(data[center_key] == center)].index.tolist()) + + X = data.drop([label_key, center_key, patient_key], axis=1) + y = data[label_key] + + X_train, y_train, X_test, y_test = prepare_dataset(X, y, center_id, config, center_indices) + + # print("Center ", center_id, "with ", len(X_train), " samples, of which positive samples are ", len(X_train.loc[y_train == 1])) # center_names = ['Bristol', 'Newcastle', 'Oxford', 'Stockport (pilot)', 'Reading', # 'Middlesborough', 'Leeds', 'Liverpool', 'Nottingham', 'Glasgow', 'Croydon', @@ -452,197 +770,55 @@ def load_ukbb_cvd(data_path, center_id, config) -> Dataset: # center_dict = list(center_dict.values()) # print(center_dict) - # xx - - # for i in range(0, 23): - # center_data = data.loc[(data[center_key] == i)] - # print(f'Center ID: {i} {center_dict[i]} with {len(center_data)} samples of which positive samples are {len(center_data.loc[center_data[label_key] == 1])})') - # xx - # features = data.drop([label_key, center_key, patient_key], axis=1) - # target = data[label_key] - - # print(len(data)) - # print(features.head()) - # print(f'Center ID: {center_id} with {len(data)} samples of which positive samples are {len(data.loc[data[label_key] == 1])})') - # print(target.head()) - - def get_preprocessing_params(preprocessing_data): - - data = preprocessing_data - features = data.drop([label_key, center_key, patient_key], axis=1) - target = data[label_key] - X_train, X_test, y_train, y_test = train_test_split(features, target, test_size = 0.20, random_state = seed, stratify=target) - - n_features = 40 - fs = SelectKBest(f_classif, k=n_features).fit(X_train, y_train) - index_features = fs.get_support() - X_train = X_train.iloc[:, index_features] - - # print(X_train.head()) - - # Get the unique values of the categorical features - col = list(X_train.columns) - categorical_features = [] - numerical_features = [] - for i in col: - if len(X_train[i].unique()) > 24: - numerical_features.append(i) - # else: - # categorical_features.append(i) - - transformers_dict = {} - - for i in categorical_features: - transformers_dict[i] = OrdinalEncoder() - for i in numerical_features: - transformers_dict[i] = StandardScaler() - - # df1 = data.copy(deep = True) - - for feature in transformers_dict: - transformers_dict[feature].fit(X_train[feature].values.reshape(-1, 1)) - - return index_features, transformers_dict - - - index_features, transformers_dict = get_preprocessing_params(preprocessing_data) - - def preprocess_data(data, index_features, column_transformer): - # Scale the data using the precomputed parameters - data = data.copy(deep = True) - features = data.drop([label_key, center_key, patient_key], axis=1) - features = features.iloc[:, index_features] - target = data[label_key] - - for feature in column_transformer: - features[feature] = column_transformer[feature].transform(features[feature].values.reshape(-1, 1)) - - X_train, X_test, y_train, y_test = train_test_split(features, target, test_size = 0.20, random_state = seed, stratify=target) - - return X_train, X_test, y_train, y_test - - X_train, X_test, y_train, y_test = preprocess_data(data, index_features, transformers_dict) - - # print shapes of the data - # print(X_train.shape) - # print(X_test.shape) - # print(y_train.shape) - # print(y_test.shape) - - # features = features.iloc[:, index_features] - - # X_train, X_test, y_train, y_test = train_test_split(features, target, test_size = 0.20, random_state = None, stratify=target) - - # print(features.head()) - - print(f'Center ID: {center_id} with {len(data)} samples of which positive samples are {len(data.loc[data[label_key] == 1])})') - - return (X_train, y_train), (X_test, y_test) - def load_kaggle_hf(data_path, center_id, config) -> Dataset: - id = center_id - seed = config["seed"] + """ + Load Kaggle Heart Failure dataset for federated learning using prepare_dataset + + Args: + data_path: Path to the dataset + center_id: ID of the center (0: cleveland, 1: hungarian, 2: va, 3: switzerland, None: all) + config: Configuration dictionary + + Returns: + tuple: ((X_train, y_train), (X_test, y_test)) + """ - if id == -1: - id = 'switzerland' - elif id == 1: - id = 'hungarian' - elif id == 2: - id = 'va' - elif id == 0: - id = 'cleveland' - elif id == None: - pass - else: - raise ValueError(f"Invalid center id: {id}") - - # elif id == 5: - # id = 'cleveland' - file_name = os.path.join(data_path, "kaggle_hf.csv") data = pd.read_csv(file_name) - - scaling_data = data.loc[(data['data_center'] == 'hungarian')] - # scaling_data = data - - if id is not None: - data = data.loc[(data['data_center'] == id)] - - # print('Categorical Features :',*categorical_features) - # print('Numerical Features :',*numerical_features) - - def get_preprocessing_params(data): - - # Get the unique values of the categorical features - col = list(data.columns) - categorical_features = [] - numerical_features = [] - for i in col: - if len(data[i].unique()) > 6: - numerical_features.append(i) - else: - categorical_features.append(i) - - transformers_dict = {} - - categorical_features.pop(categorical_features.index('HeartDisease')) - if 'RestingBP' in numerical_features: - numerical_features.pop(numerical_features.index('RestingBP')) - elif 'RestingBP' in categorical_features: - categorical_features.pop(categorical_features.index('RestingBP')) - categorical_features.pop(categorical_features.index('RestingECG')) - categorical_features.pop(categorical_features.index('data_center')) - numerical_features.pop(numerical_features.index('Oldpeak')) - min_max_scaling_features = ['Oldpeak'] - - for i in categorical_features: - transformers_dict[i] = OrdinalEncoder() - for i in numerical_features: - transformers_dict[i] = StandardScaler() - for i in min_max_scaling_features: - transformers_dict[i] = MinMaxScaler() - - df1 = data.copy(deep = True) - - target = df1['HeartDisease'] - X_train, X_test, y_train, y_test = train_test_split(df1, target, test_size = 0.20, random_state = seed) - - for feature in transformers_dict: - if feature == 'ST_Slope': - # Change value of last row to 'Down' to avoid error as it is missing in some splits - X_train.loc[X_train.index[-1], feature] = 'Down' - transformers_dict[feature].fit(X_train[feature].values.reshape(-1, 1)) - else: - transformers_dict[feature].fit(X_train[feature].values.reshape(-1, 1)) - - return transformers_dict - + # Define centers + centers = ['cleveland', 'hungarian', 'va', 'switzerland'] - def preprocess_data(data, column_transformer): - # Scale the data using the precomputed parameters - df1 = data.copy(deep = True) - features = df1[df1.columns.drop(['HeartDisease','RestingBP','RestingECG', 'data_center'])] - target = df1['HeartDisease'] - - for feature in column_transformer: - features.loc[:, feature] = column_transformer[feature].transform(features[feature].values.reshape(-1, 1)) - - features = features.infer_objects() - - X_train, X_test, y_train, y_test = train_test_split(features, target, test_size = 0.20, random_state = seed, stratify=target) - - return (X_train, y_train), (X_test, y_test) + # Map center_id to index + center_id_mapped = None + if center_id is not None: + if center_id == 0: + center_id_mapped = 0 # cleveland + elif center_id == 1: + center_id_mapped = 1 # hungarian + elif center_id == 2: + center_id_mapped = 2 # va + elif center_id == 3: + center_id_mapped = 3 # switzerland + else: + # print(f"Invalid center id: {center_id}", type(center_id)) + raise ValueError(f"Invalid center id: {center_id}") - - preprocessing_params = get_preprocessing_params(scaling_data) - - (X_train, y_train), (X_test, y_test) = preprocess_data(data, preprocessing_params) - - return (X_train, y_train), (X_test, y_test) - + # Create center_indices + center_indices = [] + for center in centers: + indices = data.loc[data['data_center'] == center].index.tolist() + center_indices.append(indices) + + # Prepare X and y + X = data.drop(['HeartDisease', 'data_center'], axis=1) + y = data['HeartDisease'] + + X_train_processed, y_train, X_test_processed, y_test = prepare_dataset(X, y, center_id_mapped, config, center_indices) + + return (X_train_processed, y_train), (X_test_processed, y_test) def load_libsvm(config, center_id=None, task_type="BINARY"): # ## Manually download and load the tabular dataset from LIBSVM data @@ -894,104 +1070,37 @@ def load_diabetes(center_id, config): Returns: tuple: ((X_train, y_train), (X_test, y_test), preprocessing_params) """ - num_centers = config.get("num_clients", 5) - alpha = config.get("dirichlet_alpha", 1.0) - reference_method = config.get("reference_center_method", "largest") - global_preprocessing_params = None - n_features = config.get("n_features", 20) - feature_selection_method = config.get("feature_selection_method", "mutual_info") - # Load the dataset - cdc_diabetes_health_indicators = fetch_ucirepo(id=891) - + dataset_file = "dataset/cdc_diabetes_health_indicators.pkl" + if os.path.exists(dataset_file): + # Load from pickle + with open(dataset_file, 'rb') as f: + cdc_diabetes_health_indicators = pickle.load(f) + else: + # Download the dataset + cdc_diabetes_health_indicators = fetch_ucirepo(id=891).data + # save as pickle for faster loading next time + dataset = {"features": cdc_diabetes_health_indicators.features, "targets": cdc_diabetes_health_indicators.targets} + with open(dataset_file, 'wb') as f: + pickle.dump(dataset, f) + # Get features and target - X = cdc_diabetes_health_indicators.data.features - y = cdc_diabetes_health_indicators.data.targets - + X = cdc_diabetes_health_indicators['features'] + y = cdc_diabetes_health_indicators['targets'] + # convert y to a pandas Series for easier handling y = pd.Series(y.values.flatten()) - # Use fraction of data for faster testing (optional) - fraction = 0.02 - X = X.sample(frac=fraction, random_state=42).reset_index(drop=True) - y = y.loc[X.index].reset_index(drop=True) - - # Set random seed for reproducible partitioning - np.random.seed(42) - - # Convert target to binary classification if needed - if y.nunique() > 2: - y_binary = (y > y.median()).astype(int) - else: - y_binary = y - - # Partition data using Dirichlet distribution - all_center_indices = partition_data_dirichlet(y_binary.values, num_centers, alpha) - - # Get all center data for reference selection - all_center_data = [] - for i in range(num_centers): - if i < len(all_center_indices) and len(all_center_indices[i]) > 0: - X_center = X.iloc[all_center_indices[i]] - all_center_data.append((X_center, y_binary.iloc[all_center_indices[i]])) - else: - all_center_data.append((pd.DataFrame(), pd.Series())) - - # Calculate or use global preprocessing parameters - if global_preprocessing_params is None: - # Select reference center and calculate parameters - reference_center_id = select_reference_center(all_center_data, reference_method) - X_reference = all_center_data[reference_center_id][0] - y_reference = all_center_data[reference_center_id][1] - - if len(X_reference) == 0: - # Fallback: use full dataset if reference center is empty - X_reference = X - y_reference = y_binary - print("Warning: Reference center empty, using full dataset for preprocessing parameters") - - global_preprocessing_params = calculate_preprocessing_params( - X_reference, y_reference, n_features, feature_selection_method - ) - print("Calculated new global preprocessing parameters with feature selection") + # # # # Use fraction of data for faster testing (optional) + if not config['num_clients'] == 1: + fraction = 1.0 + # Sample indices first, then select from both X and y + sampled_indices = X.sample(frac=fraction, random_state=42).index + X = X.loc[sampled_indices].reset_index(drop=True) + y = y.loc[sampled_indices].reset_index(drop=True) - if center_id: - # Get indices for the requested center - if center_id >= len(all_center_indices) or len(all_center_indices[center_id]) == 0: - raise ValueError(f"Center ID {center_id} has no data assigned") - - center_indices = all_center_indices[center_id] - X_center = X.iloc[center_indices].reset_index(drop=True) - y_center = y.iloc[center_indices].reset_index(drop=True) - else: - # Use full dataset if no center_id specified - X_center = X - y_center = y + X_train_processed, y_train, X_test_processed, y_test = prepare_dataset(X, y, center_id, config) - # Split into train/test for this center - if len(X_center) > 1: - X_train, X_test, y_train, y_test = train_test_split( - X_center, y_center, test_size=0.2, random_state=42, stratify=y_center - ) - else: - X_train, y_train = X_center, y_center - X_test, y_test = X_center.iloc[:0], y_center.iloc[:0] - - # Apply GLOBAL preprocessing parameters to both train and test sets - X_train_processed, feature_names = apply_preprocessing(X_train, global_preprocessing_params) - X_test_processed, _ = apply_preprocessing(X_test, global_preprocessing_params) - - # Convert targets to numpy arrays - # y_train_processed = y_train.values - # y_test_processed = y_test.values - - # # Print center statistics - # print(f"Center {center_id}/{num_centers} (alpha={alpha}):") - # print(f" Samples: {len(X_center)} (Train: {len(X_train_processed)}, Test: {len(X_test_processed)})") - # print(f" Features: {X_train_processed.shape[1]}/{len(X.columns)} selected") - # print(f" Data range: [{X_train_processed.min():.3f}, {X_train_processed.max():.3f}]") - # print(f" Normalized stats - Mean: {X_train_processed.mean():.4f}, Std: {X_train_processed.std():.4f}") - return (X_train_processed, y_train), (X_test_processed, y_test) @@ -1045,7 +1154,7 @@ def load_dataset(config, id=None): if config["dataset"] == "mnist": return load_mnist(id, config["num_clients"]) elif config["dataset"] == "cvd": - return load_cvd(config["data_path"], id) + return load_cvd(config["data_path"], id, config) elif config["dataset"] == "ukbb_cvd": return load_ukbb_cvd(config["data_path"], id, config) elif config["dataset"] == "kaggle_hf": From af74c35798608163a2723f8f09d4538cfa432257 Mon Sep 17 00:00:00 2001 From: faildeny Date: Tue, 3 Feb 2026 17:30:26 +0100 Subject: [PATCH 11/29] Add scripts for automated benchmarking --- benchmark.py | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++ repeated.py | 45 +++++++++++++++- 2 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 benchmark.py diff --git a/benchmark.py b/benchmark.py new file mode 100644 index 0000000..3f78ef0 --- /dev/null +++ b/benchmark.py @@ -0,0 +1,142 @@ +import subprocess +import time +import os +import yaml +import sys +from itertools import product + +experiment_name = "experiment_all_10percent" +benchmark_dir = "benchmark_results" + + +model_names = [ + "logistic_regression", + "elastic_net", + "lsvc", + "random_forest", + "balanced_random_forest", + # # "weighted_random_forest", + "xgb" + ] + +datasets = [ + # "kaggle_hf", + "diabetes", + # "ukbb_cvd", + # "cvd" + ] + +num_clients = [ + 3, + 5, + 10, + 20 +] + +dirichlet_alpha = [ + None, + # 1.0, + # 0.7 +] + +data_normalization = ["global"] +n_features = [None] + +# Normalization experiment +# experiment_name = "normalization" +# benchmark_dir = "benchmark_results_normalization" +# model_names = ["logistic_regression"] +# datasets = ["diabetes", "ukbb_cvd"] +# num_clients = [10] +# dirichlet_alpha = [0.7, None] +# data_normalization = ["global", "local", None] + +# Feature selection experiment +experiment_name = "feature_selection" +benchmark_dir = "benchmark_results_feature_selection" +model_names = ["balanced_random_forest"] +datasets = ["ukbb_cvd"] +num_clients = [5,10] +dirichlet_alpha = [0.7, None] +data_normalization = ["global"] +n_features = [10, 20, 35, 40, None] + +os.makedirs(benchmark_dir, exist_ok=True) + +with open("config.yaml", "r") as f: + config = yaml.safe_load(f) + + +config_path = os.path.join(benchmark_dir, "config.yaml") +log_file_path = os.path.join(benchmark_dir, "run_log.txt") + +with open(config_path, "w") as f: + yaml.dump(config, f) + +config['data_path'] = 'dataset/' +config['experiment']['log_path'] = benchmark_dir + +start_time = time.time() + +# Flatten the nested loops into a single iterator +parameters = product(datasets, num_clients, dirichlet_alpha, model_names, data_normalization, n_features) + +try: + for ds_name, n_client, alpha, m_name, norm, n_feat in parameters: + print(f"Running benchmark: {ds_name}, {m_name}, clients: {n_client}, alpha: {alpha}, normalization: {norm}, features: {n_feat}") + + # Update config dictionary + config.update({ + 'model': m_name, + 'dataset': ds_name, + 'num_clients': n_client, + 'dirichlet_alpha': alpha, + 'data_normalization': norm, + 'n_features': n_feat + }) + if "forest" in m_name: + config['num_rounds'] = 1 # Set number of jobs for parallel processing + + config['experiment']['name'] = f"{experiment_name}_{ds_name}_{m_name}_c{n_client}_a{alpha}_norm{norm}_feat{n_feat}" + + with open(config_path, "w") as f: + yaml.dump(config, f) + + # subprocess.run is cleaner for synchronous execution + # Use a list for the command to avoid shell=True security/cleanup issues + cmd = f"python repeated.py {config_path} | tee {log_file_path}" + subprocess.run(cmd, shell=True, check=True) + +except KeyboardInterrupt: + print("\nBenchmark interrupted by user. Exiting...") + sys.exit(1) + + + +# # Run benchmark experiments +# # Iterate over datasets and models +# for dataset_name in datasets: +# for num_client in num_clients: +# for alpha in dirichlet_alpha: +# for model_name in model_names: +# print(f"Running benchmark for dataset: {dataset_name}, model: {model_name}") +# config['experiment']['name'] = f"{experiment_name}_{dataset_name}_{model_name}_clients_{num_client}_alpha_{alpha}" +# config['model'] = model_name +# config['dataset'] = dataset_name +# config['num_clients'] = num_client +# config['dirichlet_alpha'] = alpha + +# with open(config_path, "w") as f: +# yaml.dump(config, f) + +# try: +# run_process = subprocess.Popen(f"python repeated.py {config_path} | tee {log_file_path}", shell=True) +# run_process.wait() + +# except KeyboardInterrupt: +# run_process.terminate() +# run_process.wait() +# break + +total_time = time.time() - start_time +print("Benchmark experiments finished in", total_time/60, " minutes") diff --git a/repeated.py b/repeated.py index 567870e..6a226d3 100644 --- a/repeated.py +++ b/repeated.py @@ -1,13 +1,19 @@ import subprocess import time import os +import sys import yaml -with open("config.yaml", "r") as f: +if len(sys.argv) == 2: + config_path = sys.argv[1] +else: + config_path = "config.yaml" + +with open(config_path, "r") as f: config = yaml.safe_load(f) -repetitions = 4 +repetitions = 5 experiment_name = config['experiment']['name'] config['experiment']['log_path'] = os.path.join(config['experiment']['log_path'], config['experiment']['name']) @@ -21,6 +27,12 @@ config_path = os.path.join(config['experiment']['log_path'], "config.yaml") log_file_path = os.path.join(config['experiment']['log_path'], config['experiment']['name'], "run_log.txt") os.makedirs(os.path.join(config['experiment']['log_path'], config['experiment']['name']), exist_ok=True) + + # Kill any existing process using the same port + if 'local_port' in config: + kill_command = f"lsof -ti tcp:{config['local_port']} | xargs kill -9" + subprocess.run(kill_command, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + with open(config_path, "w") as f: yaml.dump(config, f) try: @@ -35,6 +47,35 @@ with open(config_path, "w") as f: yaml.dump(config, f) + +# processes = [] +# try: +# for i in range(repetitions): +# print(f"Experiment run {i + 1}") +# config['experiment']['name'] = 'run_' + str(i + 1) +# config['seed'] = i + 10 +# config['local_port'] = 8081 + i +# config_path = os.path.join(config['experiment']['log_path'], config['experiment']['name'], "config.yaml") +# log_file_path = os.path.join(config['experiment']['log_path'], config['experiment']['name'], "run_log.txt") +# os.makedirs(os.path.join(config['experiment']['log_path'], config['experiment']['name']), exist_ok=True) +# with open(config_path, "w") as f: +# yaml.dump(config, f) +# run_process = subprocess.Popen(f"python run.py {config_path} | tee {log_file_path}", shell=True) +# # run_process.wait() +# processes.append(run_process) + +# for run_process in processes: +# run_process.wait() + +# except KeyboardInterrupt: +# run_process.terminate() +# run_process.wait() + +# config['experiment']['name'] = experiment_name +# config_path = os.path.join(config['experiment']['log_path'], "config.yaml") +# with open(config_path, "w") as f: +# yaml.dump(config, f) + run_process = subprocess.Popen(f"python flcore/compile_results.py {config['experiment']['log_path']}", shell=True) run_process.wait() From 5094455d2557489d059776a0661e103e91c4c74e Mon Sep 17 00:00:00 2001 From: faildeny Date: Tue, 3 Feb 2026 17:31:09 +0100 Subject: [PATCH 12/29] Add notebook for results visualisation. --- plots.ipynb | 732 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 732 insertions(+) create mode 100644 plots.ipynb diff --git a/plots.ipynb b/plots.ipynb new file mode 100644 index 0000000..7078efd --- /dev/null +++ b/plots.ipynb @@ -0,0 +1,732 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "4c815c0e", + "metadata": {}, + "source": [ + "## Select data to load based on keywords in experiment name" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f05d536", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import os\n", + "\n", + "logs_dir = \"logs\"\n", + "logs_dir = \"benchmark_results\"\n", + "\n", + "experiment_name = \"experiment_1percent\"\n", + "# experiment_name = \"experiment_good\"\n", + "# experiment_name = \"experiment_small\"\n", + "dataset_name = \"diabetes\"\n", + "# dataset_name = \"kaggle_hf\"\n", + "\n", + "results_file = \"per_center_results.csv\"\n", + "\n", + "keywords = [\n", + " experiment_name,\n", + " dataset_name,\n", + " # \"logistic_regression\",\n", + " # \"forest\",\n", + " # \"c10\"\n", + " # \"a0.7\"\n", + " # \"a1.0\"\n", + " \"aNone\"\n", + " ]\n", + "\n", + "# Normalization experiment\n", + "experiment_name = \"normalization\"\n", + "logs_dir = \"benchmark_results_normalization\"\n", + "model_names = [\"logistic_regression\"]\n", + "datasets = [\"diabetes\"]\n", + "num_clients = [10]\n", + "dirichlet_alpha = [\"None\"]\n", + "data_normalization = [\"global\", \"local\", None]\n", + "keywords = [experiment_name]\n", + "\n", + "def load_data(logs_dir, experiment_name, keywords, results_file=\"per_center_results.csv\"):\n", + " data = {}\n", + "\n", + " # iterate over all directories in logs_dir with names containing all the keywords\n", + " dirs = [d for d in os.listdir(logs_dir) if all(keyword in d for keyword in keywords)]\n", + " for d in dirs: \n", + " model_name = d\n", + " model_name = model_name.replace(experiment_name+\"_\", \"\")\n", + " # model_name = model_name.replace(experiment_name+\"_\"+dataset_name+\"_\", \"\")\n", + " model_name = model_name.replace(\"_\", \" \")\n", + " model_name = model_name.title()\n", + " model_name = model_name.replace(\"none\", \"N\")\n", + " # Find position of _c keyword\n", + " pos = model_name.find(\" C\")\n", + " # if pos != -1:\n", + " # if not model_name[pos+3].isdigit():\n", + " # model_name = model_name[:pos+2] + \"0\" + model_name[pos+2:]\n", + " #remove non-capital letters\n", + " # model_name = ''.join(c for c in model_name if c.isupper() or c == ' ' or c.isdigit())\n", + "\n", + " full_path = os.path.join(logs_dir, d)\n", + " metrics_file = os.path.join(full_path, results_file)\n", + " if os.path.isfile(metrics_file):\n", + " df = pd.read_csv(metrics_file)\n", + " data[model_name] = df\n", + "\n", + " print(\"Found \", len(data), \" experiments\")\n", + "\n", + " # Sort data by model_name\n", + " data = dict(sorted(data.items()))\n", + "\n", + " # for model_name, df in data.items():\n", + " # print(model_name)\n", + " \n", + " return data" + ] + }, + { + "cell_type": "markdown", + "id": "0389d57d", + "metadata": {}, + "source": [ + "### Print metric values" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "29bb08b0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Logistic Regression C10 AN NormN FeatN: 0.6632\n", + "Logistic Regression C10 AN Normglobal FeatN: 0.7546\n", + "Logistic Regression C10 AN Normlocal FeatN: 0.7586\n" + ] + } + ], + "source": [ + "metric = \"balanced_accuracy\"\n", + "# metric = \"accuracy\"\n", + "results = []\n", + "#print average metric across all centers for each model\n", + "for model_name, df in data.items():\n", + " #weighted average by number of samples in each center\n", + " total_samples = df[\"n samples\"].sum()\n", + " weighted_sum = (df[metric] * df[\"n samples\"]).sum()\n", + " avg_metric = weighted_sum / total_samples\n", + " results.append(f\"{model_name}: {avg_metric:.4f}\")\n", + " # print(f\"{model_name}: {avg_metric:.4f}\")\n", + "\n", + "# Sort results alphabetically by model name\n", + "results.sort()\n", + "for result in results:\n", + " print(result)" + ] + }, + { + "cell_type": "markdown", + "id": "7893ad6c", + "metadata": {}, + "source": [ + "# Bar plot for all imported models" + ] + }, + { + "cell_type": "code", + "execution_count": 49, + "id": "905d8cfa", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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HWHTTURERERERKQjbLqJiIiIiIiIdIRNNxEREREREZGOsOkmIiIiIiIi0hE23UREREREREQ6wqabiIiIiIiISEfYdBMRERERERHpCJtuIiIiIiIiIh1h001ERERERESkI2y6iYiIiIiIiHSETTcRERERERGRjrDpJiIiIiIiItIRNt1EREREREREOsKmm4iIiIiIiEhH2HQTERERERER6QibbiIiIiIiIiIdYdNNREREREREpCNsuomIiIiIiIh0hE03ERERERERkY6w6SYiIiIiIiLSEVN9F0Ca1Z16oOyfdPvhMn26qAXdyvT5iIiIiIiIyhL3dBMRERERERHpCPd0ExERacAjjoiIiOi/4p5uIiIiIiIiIh1h001ERERERESkI2y6iYiIiIiIiHSETTcRERERERGRjrDpJiIiIiIiItIRjl5O5RJHFCYiIiIiovKAe7qJiIiIiIiIdIRNNxEREREREZGOsOkmIiIiIiIi0hE23UREREREREQ6wqabiIiIiIiISEfYdBMRERERERHpCC8ZRkRk4PRyCT6gTC/Dx0vwERERkb5wTzcRERERERGRjrDpJiIiIiIiItIRNt1EREREREREOqLXc7rj4uIwc+ZMXL16FZaWlggKCsLnn38OY2P1bQEfffQRLl++rDZNLpfjs88+w+jRozFkyBBcu3ZN7XH16tXDvn37yuR1EBERERERERVHb023EAKjR49G/fr1ERISguTkZIwYMQKVK1fGhx9+qDbvunXr1G5nZGSgW7du6NSpkzRt7ty5CAoKKpPaiYiIiIiIiEpCb4eXh4WFITIyEtOnT4e9vT1cXFwwYsQIbN269V8fu2TJEnTu3Blubm5lUCkRERERERHRy9Hbnu7w8HA4OzvDwcFBmubh4YGoqChkZ2fDxsam2Mc9ePAA+/fvx9GjR9WmHzx4EGvWrEFqaiq8vb0xY8YM1KlTR+PzKxQKKBSKUnkt9PL4HmhWHrJR1Vgeai1rzObVwvdBs/KQDdcnzZiNZsymeMxFM2ajGbMpXknz0FvTnZaWBnt7e7VpqttpaWkam+7Vq1ejb9++cHR0lKa5uLjA0tISCxYsgLGxMYKDgzFixAj8+eefMDc3L3Y5d+7cKaVXQv/FP//8o+8SXlnlKZuwsDB9l/DKYjavhvK0PpW18pQN1yfNmI1mzKZ4zEUzZqMZs3k5emu6jYyMtH5MSkoKDh06hAMHDqhNnzVrltrtOXPmoFmzZrh8+TJatWpV7LJcXV1hZWWldQ1lavthfVegc76+vi/3QGbzSlAoFAgLC4OXlxdMTEz0Xc4rpVxlw/VJM2bzSihX61MZYzaaMZviMRfNmI1mzKZ4ubm5JdqZq7em29HREenp6WrT0tLSpPuKc+LECbz55puoXbv2C5dtY2MDBwcHJCUlaZzHxMSEH5hXAN8DzcpTNlyfNGM2rwa+B5qVp2y4PmnGbDRjNsVjLpoxG82YjbqSZqG3gdS8vLyQkJAgNdoAEBoaivr168Pa2rrYx5w9exb+/v5q07KzszFr1iykpKRI09LS0pCWloZatWrppngiIiIiIiKiEtBb0+3u7g5vb28EBwcjMzMTkZGRWLt2LQYNGgQA6NKlC65cuaL2mIiICNSvX19tmo2NDUJDQzFv3jxkZWUhPT0ds2fPhru7O/z8/Mrs9RARERERERE9T29NNwAsXboUWVlZaN26NT788EP0798fAwcOBAA8fPgQubm5avMnJSWpjXausmLFCshkMnTo0AFvv/02hBBYtWoVjI31+vKIiIiIiIjIwOntnG4AqF69OtauXVvsfZGRkUWmXb9+vdh5a9SogRUrVpRqbURERERERET/FXcFExEREREREekIm24iIiIiIiIiHWHTTURERERERKQjbLqJiIiIiIiIdIRNNxEREREREZGOsOkmIiIiIiIi0hE23UREREREREQ6wqabiIiIiIiISEfYdBMRERERERHpiNZNd3R0tC7qICIiIiIiInrtaN10d+nSBf369cOmTZuQmpqqi5qIiIiIiIiIXgtaN90nTpxAt27dcOTIEbRt2xbDhw/Hvn37kJeXp4v6iIiIiIiIiMotrZvuGjVq4P3338fmzZtx6tQpdOrUCXv37kWbNm0wadIkXLhwQRd1EhEREREREZU7/2kgNVtbW9ja2sLa2hpyuRwPHz7EjBkz8O677yImJqa0aiQiIiIiIiIql0y1fYAQAufOncP+/ftx/Phx2NnZoUePHhg3bhxcXFygVCqxZMkSjB8/Hrt27dJFzURERERERETlgtZNd0BAAHJzc9G5c2esWLECzZs3h5GRkXS/sbExxo4di40bN5ZqoURERERERETljdZN9+TJk9G5c2dYWlpqXqipKQ4fPvyfCiMiIiIiIiIq77Q+pzswMBDTp09HSEiING3Lli34/PPPkZGRIU2rXr166VRIREREREREVE5pvad75syZyM3NhYuLizQtICAAFy5cwOzZs/HDDz+UaoFEpJ26Uw+U/ZNuL7sjW6IWdCuz5yIiIiIi+q+0brrPnTuHkJAQtcPLa9eujQULFiAwMLBUiyMiIqJXEzfwacZsiIjoWVofXm5iYoK0tLQi05OSkmBs/J+uQEZERERERET0WtF6T3efPn3w0UcfYcCAAXB2doYQAlFRUdi6dSuCgoJ0USMRERERERFRuaR10z1p0iQ4Oztj586diImJAQDUqlULH330EQYOHFjqBRIRERERERGVV1o33cbGxhg0aBAGDRpU5L7Tp0+jXbt2pVEXERERERERUbmnddMNAGlpabh79y4KCgqkaYmJiZg3bx6uXr1aasURERERERERlWdaN93Hjh3DpEmTIJPJYGRkBCEEAMDOzo7ndBMRERERERE9Q+vhxpcsWYLZs2cjNDQUZmZmuH37Nnbt2oVGjRqhf//+uqiRiIiIiIiIqFzSuulOSEhA7969YW5uDiMjIxgZGaFhw4b48ssv8eWXX+qiRiIiIiIiIqJySevDyytVqoTw8HA0bNgQlSpVQmRkJNzc3FCtWjXcvXtXFzUSEf1ndace0M8Tbz9cZk8VtaBbmT0XEREREZWM1k334MGD0a9fP1y4cAHt2rXDqFGj0LlzZ4SGhsLNzU0XNRIRERERERGVS1o33R988AE8PT1ha2uLKVOmoGLFirh58yZcXV0xcuRIXdRIREREREREVC5p1XQrlUocOHAAPXr0AABYWFhgzJgxOimMiIiIiIiIqLzTaiA1Y2NjBAcHIz8/X1f1EBEREREREb02tD68fOLEifjqq6/Qs2dP1KhRA6am6ouoV69eqRVHREREREREVJ5p3XTPnDkTAHDgwP9GAjYyMoIQAkZGRrh9+3bpVUdERERERERUjmnddJ84cUIXdRARERERERG9drRuup2dnXVRBxEREREREdFrR+umu3379jAyMir2PoVCgdOnT//XmoiIiIiIiIheC1o33R9//LHabSEEHj16hOPHj2PIkCGlVhgRERERERFRead1092/f/9ipw8cOBDTp0/HgAED/nNRRERERERERK8Dra7T/SLVq1dHdHR0aS2OiIiIiIiIqNzTek/32bNni0wrLCzElStXYGxcaj08ERERERERUbmnddM9fPjwItMsLCxQt25dzJo1S6tlxcXFYebMmbh69SosLS0RFBSEzz//vEjz/tFHH+Hy5ctq0+RyOT777DOMHj0aMpkM33zzDQ4fPozCwkK0bt0as2bNgqOjo7Yvj4iIiIiIiKjUaN10R0RElMoTCyEwevRo1K9fHyEhIUhOTsaIESNQuXJlfPjhh2rzrlu3Tu12RkYGunXrhk6dOgEAFi5ciGvXrmHnzp2wtrbG1KlTMW3aNKxevbpUaiUiIiIiIiJ6GS91PPi2bdtw69Yt6fbp06exbds2rZYRFhaGyMhITJ8+Hfb29nBxccGIESOwdevWf33skiVL0LlzZ7i5uUEul2P37t0YP348atWqBUdHR0yZMgWnTp1CYmKi1q+NiIiIiIiIqLRo3XQvWbIEq1atglwul6ZZWlri559/xtKlS0u8nPDwcDg7O8PBwUGa5uHhgaioKGRnZ2t83IMHD7B//36MHj0aABATE4Ps7Gx4eHhI87i4uMDS0lJtwwARERERERFRWdP68PIdO3bgjz/+gLOzszTN398fGzZsQL9+/TBu3LgSLSctLQ329vZq01S309LSYGNjU+zjVq9ejb59+0rna6elpak9VsXOzg6pqakan1+hUEChUJSoVtIdvgeaMZviMRfNmI1mzEYzZlM85qJZeclGVWd5qbesMBfNmI1mzKZ4Jc1D66Y7JycHFStWLDLd1tYWOTk5JV6OkZGRtk+NlJQUHDp0CAcOHCjRcl503507d7R+fip9//zzj75LeGUxm+IxF82YjWbMRjNmUzzmoll5yyYsLEzfJbySmItmzEYzZvNytG66W7VqhalTp+LTTz+Fs7MzlEoloqOjsXLlSrRq1arEy3F0dER6erraNNVea02jjp84cQJvvvkmateurbYcAEhPT4eVlRWAp4O0paeno1KlShqf39XVVZr/lbX9sL4r0DlfX9+XeyCz0ew1z4a5aMZsNGM2mjGb4r10LgCzeUUoFAqEhYXBy8sLJiYm+i7nlcFcNGM2mjGb4uXm5pZoZ67WTfecOXMwe/ZsBAUFQQghTe/UqZNWlwzz8vJCQkIC0tLSpD3noaGhqF+/PqytrYt9zNmzZ+Hv7682rVatWnBwcMCtW7dQo0YNAEBkZCQKCwvh6emp8flNTEz4gXkF8D3QjNkUj7loxmw0YzaaMZviMRfNyls2/M1XPOaiGbPRjNmoK2kWWjfdjo6OWLp0KTIzMxEfHw8AcHZ2hp2dnVbLcXd3h7e3N4KDgzFz5kw8evQIa9euxaeffgoA6NKlC4KDg9GkSRPpMREREWjbtq3ackxMTNCvXz8sWbIEDRo0gJWVFebPn4+33noLlStX1vblEREREREREZUarZtu4Oklwzw8PKQRw0+fPo3ExES89957Wi1n6dKlmDFjBlq3bg1ra2sMHDgQAwcOBAA8fPgQubm5avMnJSWpjXauMmbMGOTk5CAoKAgKhQKBgYFa7XUnIiIiIiIi0gWtm+4lS5Zg7969WLJkiTTN0tISv/zyCx4/flzi0csBoHr16li7dm2x90VGRhaZdv369WLnNTc3x4wZMzBjxowSPzcRERERERGRrml9ne4dO3Zg8+bN8PHxkaapLhm2ffv2Ui2OiIiIiIiIqDzTuukurUuGEREREREREb3utG66VZcMi4iIQFZWFjIyMhAaGopJkyZpdckwIiIiIiIioted3i4ZRkRERERERPS6K9VLhj3bhBMREREREREZOq0PL1exs7ODu7s73N3dkZaWhh9++KHINbSJiIiIiIiIDNlLXacbAPLz83Ho0CHs3LkTV69ehYeHBz755JPSrI2IiIiIiIioXNO66b5x4wZ27NiBQ4cOwdbWFklJSVi3bh1atGihi/qIiIiIiIiIyq0SH16+bt06dO/eHR999BHkcjlWrlyJkydPwszMDDVr1tRljURERERERETlUon3dH/33Xfo1q0bNm3aVOx1uomIiIiIiIhIXYn3dM+ePRsxMTFo3749Pv/8c/z1119QKBS6rI2IiIiIiIioXCvxnu733nsP7733HiIjI7Fjxw588cUXMDU1RWFhIR4+fIhatWrpsk4iIiIiIiKickfrS4a5ubnhq6++wpkzZzBt2jQ0bdoUI0eORO/evbFp0yZd1EhERERERERULr30dbrNzc3RrVs3rF+/HseOHUO7du2wbt260qyNiIiIiIiIqFx76ab7WTVr1sT48eNx8uTJ0lgcERERERER0WuhVJpuFSMjo9JcHBEREREREVG5VqpNNxERERERERH9D5tuIiIiIiIiIh0p0SXD2rdvX6JDx+VyOUJCQv5zUURERERERESvgxI13R9//LH075SUFGzbtg3t27dHnTp1oFAo8PDhQ5w5cwbDhg3TWaFERERERERE5U2Jmu7+/ftL/x42bBiWLl0KX19ftXmuXLmCH3/8EUOHDi3VAomIiIiIiIjKK63P6b527RoaNmxYZLq3tzeuX79eKkURERERERERvQ60brpr166N5cuXIysrS5qWnZ2NlStXombNmqVaHBEREREREVF5VqLDy581Z84cjBs3Dj///DNsbGwAPG267e3tsXLlylIvkIiIiIiIiKi80rrp9vHxwcmTJ3Hz5k08fvwYBQUFqFq1Knx8fGBhYaGLGomIiIiIiIjKJa2bbgAwNjaGsbExjIyM0L17dwCATCYr1cKIiIiIiIiIyjutz+mOjY1Fjx49MGjQIEycOBEAEB8fj8DAQISHh5d6gURERERERETlldZN99y5c9G2bVtcvnwZRkZGAABnZ2d8/PHHCA4OLvUCiYiIiIiIiMorrZvuGzduYOzYsTA3N5eabgAYPHgwbt++XarFEREREREREZVnWjfdRkZGyMzMLDI9JiaGA6kRERERERERPUPrprtr166YMGECLly4ACEEwsPDsXv3bnzyySfo1q2bLmokIiIiIiIiKpe0Hr186tSpWLlyJcaPH4+CggIEBQXBwcEB7733Hj777DNd1EhERERERERULmnddJubm2PChAmYMGECMjMzYWxsDBsbG13URkRERERERFSuaX14eUFBARYvXowrV67Azs4ONjY22L9/P3744QcUFhbqokYiIiIiIiKicknrpjs4OBhnzpyBnZ2dNM3FxQWXLl3iJcOIiIiIiIiInqF1033s2DH88ssvcHV1laY1bNgQq1atwtGjR0u1OCIiIiIiIqLyTOumWy6Xq12f+9npPLyciIiIiIiI6H+0HkitU6dO+PTTTzFs2DA4OztDqVQiKioKv/zyCzp37qyLGomIiIiIiIjKJa2b7hkzZmDZsmWYNm0aMjIyAAB2dnZ45513MHbs2FIvkIiIiIiIiKi80rrprlChAiZPnozJkycjMzMTANQGVSMiIiIiIiKip7RuugEgIiICDx8+hEwmK3Jf7969/2tNRERERERERK8FrZvu7777DuvWrYOtrS0sLCyK3M+mm4iIiIiIiOgprZvunTt3Yu3atWjTpo0u6iEiIiIiIiJ6bWh9yTBTU1O0bNmyVJ48Li4Ow4YNg6+vL1q0aIGFCxdCqVQWO+/9+/cxaNAg+Pj4oF27dtiwYYN035AhQ+Dh4QEvLy/pv549e5ZKjUREREREREQvS+um+8MPP8T69ev/8xMLITB69GhUrFgRISEh2Lx5Mw4dOoRff/21yLwymQwff/wxevXqhUuXLuHbb7/FH3/8gfv370vzzJ07F2FhYdJ/+/bt+881EhEREREREf0XWh9efu3aNVy/fh2//voratSoAWNj9b5969atJVpOWFgYIiMjsWHDBtjb28Pe3h4jRozAhg0b8OGHH6rNe+jQIdSrVw/9+vUDAPj7++PQoUPalk5ERERERERUprRuul1dXdGwYcP//MTh4eFwdnaGg4ODNM3DwwNRUVHIzs6GjY2NNP3KlSuoV68exo4di3PnzqFatWoYPXo0unbtKs1z8OBBrFmzBqmpqfD29saMGTNQp06d/1wnERERERER0cvSuukeP368xvtKupcbANLS0mBvb682TXU7LS1Nrel+/PgxQkNDsWjRInz33Xc4cOAAPv/8c9SrVw/u7u5wcXGBpaUlFixYAGNjYwQHB2PEiBH4888/YW5uXuzzKxQKKBSKEtdLusH3QDNmUzzmohmz0YzZaMZsisdcNCsv2ajqLC/1lhXmohmz0YzZFK+kebzUdbrv3LmDW7duoaCgQJqWmJiIDRs2oH///iVahpGRUYmfTy6Xo127dtKI6e+88w62bduGgwcPwt3dHbNmzVKbf86cOWjWrBkuX76MVq1aaXwNpH///POPvkt4ZTGb4jEXzZiNZsxGM2ZTPOaiWXnLJiwsTN8lvJKYi2bMRjNm83K0brp///13zJ07F5UqVUJycjKqV6+OpKQk1KhRA6NHjy7xchwdHZGenq42LS0tTbrvWfb29rC1tVWb5uzsjOTk5GKXbWNjAwcHByQlJWl8fldXV1hZWZW4Xr3YfljfFeicr6/vyz2Q2Wj2mmfDXDRjNpoxG82YTfFeOheA2bwiFAoFwsLC4OXlBRMTE32X88pgLpoxG82YTfFyc3NLtDNX66b7l19+wfr16+Hv7w9vb2+cOnUKKSkp+Prrr+Ht7V3i5Xh5eSEhIQFpaWmoWLEiACA0NBT169eHtbW12rweHh44efKk2rT4+Hi0bt0a2dnZWLRoEcaMGYNKlSoBeNq8p6WloVatWhqf38TEhB+YVwDfA82YTfGYi2bMRjNmoxmzKR5z0ay8ZcPffMVjLpoxG82YjbqSZqH1JcNSUlLg7++v9iSVKlXCrFmzMHv27BIvx93dHd7e3ggODkZmZiYiIyOxdu1aDBo0CADQpUsXXLlyBQDQu3dvREZGYuvWrZDJZNi3bx9u3bqFnj17wsbGBqGhoZg3bx6ysrKQnp6O2bNnw93dHX5+ftq+PCIiIiIiIqJSo3XT7eTkhNOnTwMAqlSpgsuXLwMALCwsEBcXp9Wyli5diqysLLRu3Roffvgh+vfvj4EDBwIAHj58iNzcXABA1apVsXbtWmzduhXNmjXDTz/9hB9//BG1a9cGAKxYsQIymQwdOnTA22+/DSEEVq1aVeRyZkRERERERERlSevDy0eNGoXPPvsMf//9N7p164ZPP/0U/v7+iIyMROPGjbVaVvXq1bF27dpi74uMjFS73bRpU+zZs6fYeWvUqIEVK1Zo9dxEREREREREuqZ1092zZ080btwYtra2GDduHGrVqoWbN2/C29sbAwYM0EWNREREREREROXSS10yzNnZWfp3UFAQgoKCSq0gIiIiIiIiotdFiZru9957r8TX1d66det/KoiIiIiIiIjodVGiprt169a6roOIiIiIiIjotVOipnv06NElWtjixYv/UzFEREREREREr5OXOqf79OnTuHnzJgoKCqRpiYmJOH78OCZMmFBqxRERERERERGVZ1o33cuXL8e6devg5uaG0NBQNGrUCA8ePEDVqlUxd+5cXdRIREREREREVC4Za/uAHTt2YPv27di6dStMTU2xefNmnD59GvXr14ep6UvtOCciIiIiIiJ6LWnddGdmZqJ+/foAABMTEyiVSpibm2PGjBlYtGhRqRdIREREREREVF5p3XTXq1cPv/32G5RKJZycnHDixAkAQHZ2NpKTk0u9QCIiIiIiIqLySuvjwSdOnIixY8eiV69eGDhwIMaPHw9XV1fExcUhMDBQFzUSERERERERlUtaN90BAQE4d+4cLC0tMXjwYLz55pu4efMmnJyc0LlzZ13USERERERERFQuvdTIZ5aWltK/fX194ePjgwoVKpRaUURERERERESvA63O6T58+DDWrFmD+/fvAwBmz54NX19fNG7cGJ988gmysrJ0UiQRERERERFReVTipnvt2rX48ssvcfToUQwcOBDr1q3D3bt3sWnTJmzYsAF5eXlYunSpLmslIiIiIiIiKldKfHj5rl27sGbNGjRr1gynTp3C2LFjsW/fPtSrVw8A8M0332DIkCGYPn26zoolIiIiIiIiKk9KvKf7yZMnaNasGQCgdevWUCqVUsMNAM7OzkhJSSn9ComIiIiIiIjKqRI33QqFQvq3qakpTE1fagw2IiIiIiIiIoOhVedcWFgIIYTG20RERERERET0PyVuumUyGby9vaXbQgi120RERERERESkrsRN98aNG3VZBxEREREREdFrp8RNt2oQNSIiIiIiIiIqmRIPpEZERERERERE2mHTTURERERERKQjbLqJiIiIiIiIdOQ/Nd3p6emlVAYRERERERHR60frpjsvLw+zZ8+Gn58fAgICADxtvkeNGoW0tLRSL5CIiIiIiIiovNK66Z4/fz6ioqLw008/wdj46cPNzMxgbW2NOXPmlHqBREREREREROVViS8ZpvLXX39h165dcHR0hJGREQDA2toaM2fORMeOHUu9QCIiIiIiIqLySus93RkZGbCxsSkyXalUorCwsFSKIiIiIiIiInodaN10+/v74/vvv0dBQYE0LT4+Hl999RX8/f1LtTgiIiIiIiKi8kzrpnvmzJkIDQ1Fo0aNIJPJ0KhRI3Ts2BGpqamYOXOmLmokIiIiIiIiKpe0PqfbyckJv//+OyIiIhAXFwcjIyPUrl0bb775pi7qIyIiIiIiIiq3tG66Y2JiYGpqCjs7OzRs2FCanpCQAGNjY1SpUgUmJialWiQRERERERFReaR10925c2dp1PLiGBsbIyAgAHPnzkXVqlX/U3FERERERERE5ZnWTfdPP/2EZcuWoV+/fvDw8ICxsTFu3ryJnTt3YuTIkahQoQLWrVuH4OBgLFu2TBc1ExEREREREZULWjfdixcvxvLly+Hs7CxNa9CgAfz9/TF58mT8/vvvaNiwITp37lyqhRIRERERERGVN1qPXh4dHQ1bW9si0x0cHBAZGQkAMDIygkKh+O/VEREREREREZVjWu/p9vPzw6effooPPvgANWvWBAA8fvwYmzZtgru7O+RyOcaMGYMWLVqUerFERERERERE5YnWTfeCBQswffp0TJgwAYWFhQAAExMTNG7cGN9++y1MTU3h7OyML774otSLJSIiIiIiIipPtG66K1eujNWrV0OhUCAlJQVCCDg6OsLMzAwREREAgG+++abUCyUiIiIiIiIq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9OgRjh8/juPHj+Pq1avw8PDA5MmTS7M2IiIiIiIionJN66Z75cqVOHHiBCIiIuDp6Ym3334b8+fPR40aNXRRHxEREREREVG5pXXTHRISgu7du2PFihVFGu3MzEzY2dmVWnFERERERERE5ZnWTfe2bduKTLtw4QK2b9+OEydO4MaNG6VSGBEREREREVF599LndCckJGDXrl3YvXs3kpKSEBgYiOXLl5dmbURERERERETlmlZNd0FBAY4fP47t27fj0qVL8PHxwZMnT7B9+3Y0aNBAVzUSERERERERlUslbrrnzp2LP//8Ew4ODujRowfmzJmDWrVqwc/PD9bW1rqskYiIiIiIiKhcKnHT/dtvv6Fbt24YP348atWqpcuaiIiIiIiIiF4LxiWd8eeff4ZCoUD37t3Rv39//P7770hPT9dhaURERERERETlW4n3dAcEBCAgIABpaWnYu3cvtmzZgm+++QZKpRJ///03nJycYGr60uOyEREREREREb12SrynW6VixYr44IMPsH//fmzevBl9+vTB/Pnz0aZNGyxYsEAXNRIRERERERGVS/9p17Svry98fX3x1Vdf4cCBA9i5c2dp1UVERERERERU7pXK8eBWVlbo27cv+vbtWxqLIyIiIiIiInotaH14ORERERERERGVDJtuIiIiIiIiIh1h001ERERERESkI2y6iYiIiIiIiHSETTcRERERERGRjrDpJiIiIiIiItIRNt1EREREREREOsKmm4iIiIiIiEhH2HQTERERERER6QibbiIiIiIiIiIdYdNNREREREREpCNsuomIiIiIiIh0hE03ERERERERkY6w6SYiIiIiIiLSETbdRERERERERDrCppuIiIiIiIhIR9h0ExEREREREekIm24iIiIiIiIiHWHTTURERERERKQjpvp88ri4OMycORNXr16FpaUlgoKC8Pnnn8PYWH1bwPLly/Hjjz/C1FS93FOnTqFy5coYMmQIrl27pva4evXqYd++fWXyOoiIiIiIiIiKo7emWwiB0aNHo379+ggJCUFycjJGjBiBypUr48MPPywyf69evbBgwQKNy5s7dy6CgoJ0WTIRERERERGRVvR2eHlYWBgiIyMxffp02Nvbw8XFBSNGjMDWrVv1VRIRERERERFRqdJb0x0eHg5nZ2c4ODhI0zw8PBAVFYXs7Owi80dGRqJv375o3Lgx+vTpg7Nnz6rdf/DgQbz11lto2rQphg0bhujoaF2/BCIiIiIiIqIX0tvh5WlpabC3t1ebprqdlpYGGxsbaXr16tVRq1YtjBs3Dk5OTti2bRtGjRqFvXv3wsXFBS4uLrC0tMSCBQtgbGyM4OBgjBgxAn/++SfMzc2LfX6FQgGFQqG7F0glwvdAM2ZTPOaiGbPRjNloxmyKx1w0Ky/ZqOosL/WWFeaiGbPRjNkUr6R56K3pNjIyKvG8ffv2Rd++faXbH3zwAf7880/s27cPEyZMwKxZs9TmnzNnDpo1a4bLly+jVatWxS7zzp07L1U3la5//vlH3yW8sphN8ZiLZsxGM2ajGbMpHnPRrLxlExYWpu8SXknMRTNmoxmzeTl6a7odHR2Rnp6uNi0tLU2679/UrFkTSUlJxd5nY2MDBwcHjfcDgKurK6ysrEpesD5sP6zvCnTO19f35R7IbDR7zbNhLpoxG82YjWbMpngvnQvAbF4RCoUCYWFh8PLygomJib7LeWUwF82YjWbMpni5ubkl2pmrt6bby8sLCQkJSEtLQ8WKFQEAoaGhqF+/PqytrdXmXbVqFRo3boxmzZpJ0x4+fIguXbogOzsbixYtwpgxY1CpUiUAT5v3tLQ01KpVS+Pzm5iY8APzCuB7oBmzKR5z0YzZaMZsNGM2xWMumpW3bPibr3jMRTNmoxmzUVfSLPQ2kJq7uzu8vb0RHByMzMxMREZGYu3atRg0aBAAoEuXLrhy5QoAIDMzE3PnzkVsbCxkMhnWrVuHmJgYBAUFwcbGBqGhoZg3bx6ysrKQnp6O2bNnw93dHX5+fvp6eURERERERET629MNAEuXLsWMGTPQunVrWFtbY+DAgRg4cCCAp3uyc3NzAQATJkyAQqHAgAEDkJeXBzc3N2zYsAHVqlUDAKxYsQLz5s1Dhw4dYGJigmbNmmHVqlUwNtbbNgUiIiIiIiIi/Tbd1atXx9q1a4u9LzIyUvq3ubk5pk2bhmnTphU7b40aNbBixQqd1EhERERERET0srgrmIiIiIiIiEhH2HQTERERERER6QibbiIiIiIiIiIdYdNNREREREREpCNsuomIiIiIiIh0hE03ERERERERkY6w6SYiIiIiIiLSETbdRERERERERDrCppuIiIiIiIhIR9h0ExEREREREekIm24iIiIiIiIiHWHTTURERERERKQjbLqJiIiIiIiIdIRNNxEREREREZGOsOkmIiIiIiIi0hE23UREREREREQ6wqabiIiIiIiISEfYdBMRERERERHpCJtuIiIiIiIiIh0x1XcBRERERPT6qzv1gH6eePvhMnuqqAXdyuy5iKj84J5uIiIiIiIiIh1h001ERERERESkI2y6iYiIiIiIiHSETTcRERERERGRjrDpJiIiIiIiItIRNt1EREREREREOsKmm4iIiIiIiEhH2HQTERERERER6QibbiIiIiIiIiIdYdNNREREREREpCNsuomIiIiIiIh0hE03ERERERERkY6w6SYiIiIiIiLSETbdRERERERERDrCppuIiIiIiIhIR0z1XQARERERkSGrO/VA2T/p9sNl+nRRC7qV6fMRvUq4p5uIiIiIiIhIR9h0ExEREREREekIm24iIiIiIiIiHWHTTURERERERKQjbLqJiIiIiIiIdIRNNxEREREREZGOsOkmIiIiIiIi0hG9Nt1xcXEYNmwYfH190aJFCyxcuBBKpbLIfMuXL4e7uzu8vLzU/ktOTgYAyGQyzJgxA82aNYOfnx/Gjh2L1NTUsn45RERERERERGr01nQLITB69GhUrFgRISEh2Lx5Mw4dOoRff/212Pl79eqFsLAwtf8qV64MAFi4cCGuXbuGnTt34sSJE8jPz8e0adPK8uUQERERERERFaG3pjssLAyRkZGYPn067O3t4eLighEjRmDr1q1aLUcul2P37t0YP348atWqBUdHR0yZMgWnTp1CYmKijqonIiIiIiIi+nd6a7rDw8Ph7OwMBwcHaZqHhweioqKQnZ1dZP7IyEj07dsXjRs3Rp8+fXD27FkAQExMDLKzs+Hh4SHN6+LiAktLS9y6dUvnr4OIiIiIiIhIE1N9PXFaWhrs7e3Vpqlup6WlwcbGRppevXp11KpVC+PGjYOTkxO2bduGUaNGYe/evUhPT1d7rIqdnV2x53WrzhnPycmBQqEozZdU6uo56O3tKTNZWVkv9Thmo9nrng1z0YzZaMZsNGM2xXvZXABmo8nrngvAbF7kv6xTZUXVJ2RnZ8PYmONNP4vZFC8/Px8Aih2X7FlGQghRFgU9b/Xq1Th27Bh27twpTYuOjkbnzp1x/Phx1KpV64WPf/fdd9GqVSu0bdsWAwYMwPXr12FlZSXd36ZNG4wbNw7vvPOO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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Make a bar plot comparing the models based on the average metric\n", + "import matplotlib.pyplot as plt\n", + "model_names = []\n", + "avg_metrics = []\n", + "for model_name, df in data.items():\n", + " total_samples = df[\"n samples\"].sum()\n", + " weighted_sum = (df[metric] * df[\"n samples\"]).sum()\n", + " avg_metric = weighted_sum / total_samples\n", + " \n", + " model_names.append(model_name)\n", + " avg_metrics.append(avg_metric)\n", + "\n", + "# Sort models by names\n", + "model_names, avg_metrics = zip(*sorted(zip(model_names, avg_metrics)))\n", + "\n", + "plt.figure(figsize=(10, 6))\n", + "plt.bar(model_names, avg_metrics)\n", + "plt.ylabel(f'Average {metric.replace(\"_\", \" \").title()}')\n", + "plt.title(f'Comparison of Models based on Average {metric.replace(\"_\", \" \").title()}')\n", + "#start y-axis from 0.5\n", + "plt.ylim(bottom=0.5)\n", + "plt.xticks(rotation=85)\n", + "plt.tight_layout()\n", + "plt.show() \n", + "\n" + ] + }, + { + "cell_type": "markdown", + "id": "110609b3", + "metadata": {}, + "source": [ + "# Box Plots: Number of Clients \n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "66277bb4", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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3jz961//UmZmpiRp/fr1GjJkiA4fPqzbb79dUVFReumllwo1V+izzz6rBQsWqEyZMnr88ccVFRWlxYsXq0uXLsrOzlaDBg1Uu3ZtSVK5cuXUrVs3VatWrcCxvv76a/Xr1087d+5U48aNVbt2bf3000/q3Lmz5Y1rUSUnJ0uSvLy8FBoaKkkaN26cRo8eraNHj6pFixaqWrWqFi5cqN69e1u2W7hwod577z2dPHlSd955p8qXL68XXnghX6M9z7Zt27RgwQI9/PDDqlq1qsxms55++ml98sknMgxDbdq0kZeXl959911Nnjy50I+fJA0ePFgffvihfHx8FB8fr7vvvls//PCDOnfufMW53F566SWNHz9ex48ft8wxt3nzZj377LMFzhP/+uuv648//lCDBg2UlZWlxYsXX/d59wEA7uPvv/9Wt27dtGrVKkVGRurhhx/WgQMHNHjwYP3www+SLk3T0q1bNy1btkyRkZF6/PHHVa5cOX366aeWs8ObN2+uyMhISVJkZKS6deum8uXLF7jPyZMna9iwYTp06JBatGihW2+9VcuWLVPnzp116tQpq3Vr1qypJk2aKDU1VTNmzLB5P3788Uc999xz+vXXX1WvXj01atRIGzZsUNeuXXX27FmVL19e8fHxlvXj4+PVvHlzux47AACul9zcXL3wwgtKT09XvXr1lJqaqo8++sgy1aokvfzyy5o6darOnTun1q1bq1SpUpozZ46GDh1qWee9997TuHHjdOrUKbVp00ZlypTRsmXL9MILLyg3N9dqn/98f1yQ2267TZJ0+vRptW3bVlOmTNGWLVuUmZmpsLAwmUwmy7qRkZG64447JP1v+hbp0ofgubm5uuWWW3T33XdLkn799VdJ0u23366SJUvm2+8dd9yhp556SpUrVy7S4wi4OqZzAewwduxYZWZmKi4uTrNmzZLJZNKJEyf04IMP6o8//tCiRYvUuXNnffzxx5IuvdlMSEiQp6envvvuO7344otXHD85OVm7du2SJE2bNk1hYWGSpJkzZ8owDKWmpqp169Y6ePCg/vvf/1pN4ZJ3JncewzAsDduePXvqpZdeknSpEfzTTz/p008/1bBhwwp933Nzc7V3717Nnj1bkvTggw/K29tbZ86csdzfiRMnqmHDhpKkjh07avPmzdq0aZPq1aunefPmSbp0ttnUqVMlSbNnz9a4ceMK3N+RI0e0dOlSywuBH374QYmJifL399cXX3yh0NBQpaSkqHHjxvrwww/Vo0cPZWZmXvXxK1WqlNatWydJGjlypOVr7QkJCTp79qzOnz9v2e5yGzdu1Pfffy9J+vDDDxUbGyvpf9PbjB8/Xm3atJGHx/8+qwwNDdW0adNkMplUoUIFzZo1SytXrsz3dTkAwI1pw4YNysjIyLc8JiamwK+HHz9+XM2bN5eXl5def/11eXp6ytvbWwsWLNCKFSssrzdOnjypgIAAzZ49Wx4eHsrNzdXEiRMVFBSkzMxM/etf/9LOnTu1b98+xcTEWF4r5L0JznP+/HnNmjVL0qUPfp944gkZhqFOnTopKSlJX3/9tdW0LYZhaMCAAVqzZo0++eQTm835iRMnyjAM9ejRw/L6491339WMGTP06aefqm/fvurdu7flm3m9e/dmTnQAgFupWbOm3njjDUnSgAEDtHz5cq1cuVLdu3fX7t27Le8d58+fr4iICGVnZ+uhhx7S119/rd69eys8PFweHh564okn1LRpUzVu3Fh///234uLi9Pvvv+vQoUOKiIiw7O+f748L8thjj2np0qXavn27Dh8+rPfff1+S5O3trfvuu0/PPvus7rrrLsv6rVu31u7du/Xjjz9aToDLm8qlZcuWlqb733//LenSSXzAzYQmOnCNLly4oG3btkm69LWmvEApX768YmNjtWnTJm3evFmdO3e2TJfStGlTy5zhzZs3l5+fny5cuGBzH4GBgSpdurROnz6t9u3bq2nTpoqNjVX79u0L/MT3Sg4cOKCTJ09Kkpo0aWJZ/u677xZ6jOnTpxf4VbG6detaGvCJiYmWr4r98MMPWrNmjSRZpi1JTEzUnXfeaTnz/aGHHrKM065dO5tN9IiICKsXCHmPfYkSJazmWfPx8dG5c+f0xx9/qEaNGoV6/CIiIrRr1y49//zzeuCBBxQbG6smTZpc8UXBhg0bJEmVKlWyNNClSy8uPvvsM506dUoHDhywnPknSW3atLH8O7n77rs1a9asfGf1AQBuXImJiUpMTMy3/NFHHy2wiV6nTh1VrFhR33//vSZMmKDs7GzLV6zz3sCWL19evr6+Sk9PV5s2bXT//fcrNjZWvXr1KvLFSn/77TfLt+jyXiuYTCYlJCTY3CYqKkpt2rTR4sWL9f7772vUqFFWt6elpSkpKUmStHfvXo0cOVLSpSloJBX4eAAA4G7atm1r+fvdd9+t5cuXW97r5b139fb21meffWZZL++94Y4dOxQeHq4XX3xR69at044dO7Rhw4Z805Re3kT/5/vjgvj6+uqTTz7RsmXLtGLFCm3dulXnz59Xdna21qxZo/Xr12vmzJmWKVpbtWqlcePGadeuXTp58qRKliyp9evXS/rfVC6X1202m4v+QAFujCY6cI1SU1MtX6n6Z0M77+e8+UHzpgS5/IxmDw8PhYSEXLGJ7u3trdmzZ+vNN9/U9u3b9fHHH+vjjz+Wj4+PunXrpldeeaXQ9eZNuyJJwcHBhd7ucjExMapTp44kafv27dqxY4eqVKmijz76SF5el36dnD9/3rJ+QW+6T548qeTkZMsLgssfuyt9MPDPs8Hz9pOcnGw58/1yJ06cUExMTKEev/fff1/Dhg3T+vXrtWDBAi1YsECenp5q06aN3n77bas53vLkPZ62jr2kfPPilypVyvJ3Pz8/Scr3tTwAwI3rueeeK9KFRbdu3aqnn35aWVlZNtcpXbq0pk+frlGjRmnv3r36448/JEkBAQHq16+fnnrqqULv71pfK/Tr10/Lly/X4sWL9cwzz1jddvm1P3766ad82544caLQ+wEAwFVd/n71n+/18t67ZmdnF/je9eTJk8rNzVWfPn2splK53OUN9X/u70q8vLzUrl07tWvXToZh6I8//tCSJUs0Z84c5eTkaPbs2ZYmerly5XT33Xdr8+bN+vHHH1WhQgVlZGSoatWqlqleJKlChQqWuoGbCU104BoFBwdbvjKdd8GOPHk/5wVbaGioTp06ZXXRrdzc3KtehEuSatSooYSEBP3999/avn27NmzYoK+++kqzZ89WzZo11bJly0LXm+fyN8np6ek6f/68vLy8VLp06SuOcd9991ne/B85ckSPPPKIDh06pDlz5li+3h0SEmJZf8uWLQW+Cb/84imXPwZXmn/88mlRLr8/t99+u7755hub2xXm8atUqZI+/PBDpaSkaPv27dq8ebMSEhL09ddfKzIyUj179sw3bl6z/J81nz592vL3y5vmAAAU1bvvvqusrCxFR0dr6tSpKlu2rMaPH2+ZciVP/fr1tXTpUh09elTbt2/XmjVrtGzZMo0ePVqxsbGWa6dczeWZnZKSYpmaJTU1VRkZGfLx8SnwTXvFihXVqVMnzZs3TxMmTJC/v79l2pqgoCCZTCYZhqEpU6aoWbNm1/pwAADglvLyNSgoSFu3bi1wnY0bN1oa6GPHjlXLli1lGIZlutF/+uf744KcOHFCf/zxh+644w6VKlVKJpNJ1atX18svv6y0tDR9/vnnOnbsmNU2bdq00ebNm7V27VrL1GqXn4UuSffee68WLFhgmWamSpUqVrfv3LlTI0eOVOfOndWqVatC1Qq4A/4lA9fIz8/PMn/Yt99+a/lk+OjRo5arV+fNB169enVJl87Ayvs0esWKFVc8C12S9u3bp3fffVdz585V2bJl1bx5c7355pu67777LPuS/vd1qsvP9vqnqlWrWt74Xv7p9htvvKH777/f8vXqwgoPD7c0l6dMmaLDhw9LkqKjoy1nbv/888+W9T/77DPNnTtX+/btk4+Pj2699VZJ0n/+8x/LOgVdjNOWvMd+3759+uuvvyRJGRkZmjp1qj799FOdP3++UI/fyZMn9f777+u9995TaGiomjRpokGDBlmuUm7r4q95n9YfO3ZM27dvtyz/9ttvJV2a5uWfLyYAACiKvA+a69Spo7Jly+rixYuWs7nzPpD+73//q7Fjx2rx4sWqVKmSWrdurfHjx1umE8t7c5z3WqGgOdnzxMTEWDI8L58Nw1CvXr10//33a86cOTa3fe655xQQEKBVq1ZZfVju7++vGjVqSJLlGiSStGbNGs2ePVsbN260qu9qNQIA4G7y3rueP39e//3vfyVdOqlu1qxZmj9/vk6ePGn1LeYmTZrIx8dHK1assCy7PFsL4+LFi2rXrp169Oih8ePHW53JbhiGjhw5Ikn53rM2b95c3t7e2rx5c4FTuUhSs2bNVLlyZRmGoVGjRll9Yy4lJUVvvPGGtm3bpi+++IIGOm4onIkOXMHnn3+u5cuX51t+33336c0339Qrr7yirl276ueff1aXLl106623as2aNcrOzlZsbKwlbDp37qz169frv//9rzp16qSIiAj9/PPPCg4OVmpqqs39BwYGav78+bpw4YK2bNmiW265RSdPntS6devk6+urxo0bS/rfBT12796tgQMH6pFHHpG/v7/VWJ6enurfv7+GDRumjz76SMeOHbO8Gff19dWzzz5b5MenV69e+uabb3T48GHLuGFhYerSpYvmzp2r1157TatWrVJycrLWr1+vMmXKqFWrVpbHZNSoUfr+++/19NNPKzQ0VDt37iz0vhs3bqzo6Gjt2LFDHTp0UMOGDbVz507t3btX9erVU+fOnZWRkXHVxy8kJEQLFy7U33//rcTERFWtWlUpKSn64Ycf5OHhYTVn++XuvfdePfjgg/rhhx/Uq1cvNWvWTCdOnNCGDRvk6empwYMHWzUEAACwdWFR6dIFuv8pJiZGf/75pxYtWqQLFy5o+/btqlq1qv7880/t2rVLb7zxhp544gl9/PHHMplMWrdunUJDQ3Xo0CH9+eefCgsL0z333CNJKlu2rKRLH+i/9tpr6tChQ779hYWFqXv37po5c6bGjBmj3377TSdOnND27dsVFhamrl272rxvYWFheuaZZ/T+++9bro2Sp3fv3urTp48SEhJ07NgxBQUF6ccff7R8jVy6NC2Nl5eXcnJy9OqrryouLq5IU98AAGCvq73/v1a333675b1jz5491aRJEx06dEjbt29XtWrV1L59e9WqVcuSgy+88ILKli2rTZs2qW7dutq8ebMmTZpUpDnIfXx8NGjQIA0ePFiLFi3Szp07FRMTI5PJpP/+97/au3evfH191adPH6vtgoOD1ahRI61atUppaWmKjo7O12j38fHRpEmT9Mwzz2j16tVq3ry57rvvPmVlZWnDhg06e/asKleurHfeeeeaHzPAFfGREHAF586d0+HDh/P9ybtASO3atfX555+rcePGlrnF/P399dxzz2nOnDmWs7maNWum1157TWXLltXu3bu1b98+TZo0yTJXWkFzbkuXmuOffPKJGjdurK1btyohIUHbtm1To0aNNHfuXMsZ7q1atVLDhg3l7e2tn3/+2eY0MR07dtSECRN0xx13aPXq1dq0aZPi4uL02Wef6fbbby/y4+Pj46MhQ4ZIutQYyDuTfNCgQXrllVdUvnx5ff/999qxY4cefvhhffbZZypTpowkqWvXrurVq5dKliypX3/9VadPn7ZcLfxKj0keT09PzZkzRx07dpRhGPrmm2+UkpKip59+WlOnTpXJZCrU4+fr66vPPvtMLVu2VFJSkhYsWKD169erdu3amj59uuWM84K899576t+/v8LCwrR06VLt2LFDDRs21Lx58/TAAw8U+fEEANzYEhMTLdfn+Oef3bt351v/5Zdftkx/snr1aj344IOaNGmS5UP6TZs2qVatWpo1a5buuusu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A87lytlNEx0AAAAAAAAAABv4/hAAAAAAAAAAADbQRAcAAAAAAAAAwAaa6AAAAAAAAAAA2EATHQAAAAAAAAAAG2iiAwAAAAAAAABgA010AAAAAAAAAABsoIkOAAAAAAAAAIANNNEBAAAAAAAAALCBJjpwE+jatavGjx/vtP3v27dPzZs3V+3atXXmzJlrGuPo0aOKiorSvn37JEnR0dFav369I8sEAMBlkN0AALgXshu4sdFEB66zpk2bqlGjRsrIyLBavmnTJjVt2tRJVRWvhQsXKjAwUL/++qtKlSpV4Dr79u3TgAEDdN9996l27dpq2rSpRowYoZSUlALX37Fjhxo0aOCQ+j766CPl5OQ4ZCwAwI2H7Ca7AQDuhewmuwFHo4kOOMHFixc1depUZ5dRZIZhKDc3t8jbnTt3TpUrV5aXl1eBt//+++9q3769ypcvryVLlmj79u2aPn26/vzzT3Xq1EmZmZn2lm7T2bNnNXbsWJnN5mLbBwDA/ZHd1shuAICrI7utkd2AfWiiA07Qt29fffrppzpw4ECBt//zK1SSNH78eHXt2lWStGHDBt15551atWqVGjdurNjYWE2cOFG7du1S69atFRsbq/79+1t9ypuZmamXXnpJsbGxat68udatW2e57fjx43ruuecUGxurRo0aaejQoUpPT5d06ZP62NhYzZ8/X3feeae2bduWr97c3FxNmTJFDz74oO666y517NhRiYmJkqR///vfWrx4sVasWKHo6GidPn063/ZvvfWWGjZsqEGDBql06dLy8PBQ9erVNWXKFNWpU0d///13vm2ioqK0du1aSZdeHL311luqV6+e6tatqx49eujw4cOSpJycHEVFRWnlypXq2LGj6tSpo7Zt2yopKUmnT59Wo0aNZBiG7r77bi1atEinT59W7969Va9ePd1555166qmndOTIkSsfUADADY/stkZ2AwBcHdltjewG7EMTHXCCatWqqUOHDhoxYsQ1be/p6akLFy5o48aNWrFihYYNG6bp06dr+vTpmjdvnhYuXKj//Oc/VoG9ZMkStW7dWps2bVLbtm3Vv39/paWlSZJeeuklVapUSRs2bNDXX3+tQ4cO6Z133rFsm52drUOHDumXX37RXXfdla+eTz/9VF9++aUmT56sDRs2qFmzZnrqqad09uxZvfPOO2rbtq1atGihHTt2qHTp0lbbnjlzRtu2bbO8ULlcQECARo8ercqVK1/x8ZgyZYr27t2rJUuWaO3atapevbpeeOEF5ebmWj6FnzNnjsaOHatffvlFwcHBmjRpkkqXLq0PP/xQkrR161bFx8dr0qRJCgkJ0dq1a7V+/XrdeuutGjt2bCGPDADgRkV2/w/ZDQBwB2T3/5DdgP1oogNO0rdvXyUlJemHH364pu1zc3PVpUsX+fr6qkmTJjIMQw888IDCwsJUrVo1VapUSYcOHbKsHx0drSZNmsjHx0fdu3dXVlaWtm/frj179igxMVGvvPKK/Pz8VKpUKfXt21dLliyxbJudna0OHTqoRIkSMplM+Wr58ssv1alTJ0VFRalEiRJ6+umn5ePjo9WrV1/1fuR92hwREXFNj4MkJSQk6Pnnn1e5cuXk6+urF198UYcPH9bOnTst67Ru3VpVqlSRr6+vHnjgAZtnI5w5c0Y+Pj7y8fGRn5+fhg4dqsmTJ19zbQCAGwfZfQnZDQBwF2T3JWQ3YL+CJ0oCUOwCAwP18ssva/To0YqLi7umMcqXLy9J8vX1lSSVK1fOcpuvr68uXrxo+fnWW2+1/N3Pz08hISE6efKkMjMzZTabdffdd1uNbTabdfbsWcvPFSpUsFnH0aNHVaVKFcvPHh4eqlixoo4ePXrV++Dp6WnZ37U4d+6cUlJS9Oyzz1q90MjNzdVff/2lmJgYSVKlSpUst5UoUUJZWVkFjtevXz/17NlTa9asUVxcnB5++GHVr1//mmoDANxYyO5LyG4AgLsguy8huwH70UQHnKhdu3ZasGCBZsyYoXvvvfeK6xqGkW+Zh4fHFX++2m0+Pj4ymUzy9/fX9u3br7h/b2/vK95ekII+Pf+nSpUqycPDQ3/++afVi5HCyrtfn3/+uaKjo+2qRZJuv/12rVq1Sj///LPWrl2rvn376oknntArr7xS5NoAADcespvsBgC4F7Kb7AYcgelcACcbOnSo5s6da3URjbxPuLOzsy3LTpw4Ydd+Lh8/PT1dKSkpKleunCpXrqyMjAyr29PS0pScnFzosStXrqyDBw9afs7JydHRo0cVHh5+1W1LliypevXqWeZIu1xmZqbi4+P166+/2tw+KChIoaGh2rt3r9XywnwaX5CUlBR5e3uradOmGj58uKZNm6aEhIRrGgsAcGMiu8luAIB7IbvJbsBeNNEBJ6tRo4batWuniRMnWpaFhYUpODjYEmJ79+7Vpk2b7NrP9u3btX79el28eFEfffSRQkJCFBsbq+rVqys2NlajRo1ScnKyUlNTNWzYMA0aNKjQYz/++OP6/PPP9ccffygzM1MzZsyQYRhq2rRpobYfMmSIduzYoaFDh+rkyZMyDEN79uxRjx495OXldcVPuiWpY8eOmjFjhvbt26fs7GzNnTtXjz/+uC5cuHDVfee9cNq/f7/S0tL0xBNPaNasWcrKylJOTo527txZqBclAICbB9lNdgMA3AvZTXYD9qKJDriAF198UTk5OZafPTw8NGzYMM2aNUsPPfSQpkyZoo4dO1qtUxTZ2dlq3769FixYoLp16+rbb7/VxIkT5ePjI0maMGGCcnNz1bRpUzVt2lTZ2dkaM2ZMocfv2LGjHnnkET355JNq0KCBfvnlF3388ccKDg4u1PbVqlXTl19+qczMTD322GOqU6eO+vXrp7vuukvz5s2z1GnLCy+8oAYNGqhz58665557tGLFCs2aNUt+fn5X3XeNGjUUGxurTp066csvv9SkSZO0bt061a9fX/fee6/WrFmj8ePHF+p+AABuHmQ32Q0AcC9kN9kN2MNkFDThEwAAAAAAAAAA4Ex0AAAAAAAAAABsoYkOAAAAAAAAAIANNNEBAAAAAAAAALCBJjoAAAAAAAAAADbQRAcAAAAAAAAAwAaa6AAAAAAAAAAA2EATHQAAAAAAAAAAG2iiAwAAAAAAAABgA010AAAAAAAAAABsoIkOAAAAAAAAAIANNNEBAAAAAAAAALCBJjoAAAAAAAAAADbQRAcAAAAAAAAAwAaa6AAAAAAAAAAA2ODl7AKAG1nTpk117NixfMv9/f0VFRWljh07ql27dk6pafTo0YqPj7+u+y6oDlu6deum119//TpWBAC4XgrKAG9vb5UrV041atTQCy+8oDvuuKNIY7766qv6+uuv9eijj2rMmDGOLPe6KEz9H3zwgSZPnpxvube3t8qXL6+mTZuqd+/eCgkJKe5y89VUt25dzZ8//7rt11YdtgQFBWnr1q3XsSIAwI0iMTFRTzzxhHJzczVlyhQ1a9bM6rYOHTrIMAxNmzZNTZs2lST95z//0cKFC7Vz506dO3dOoaGhqlSpklq3bq327dvLx8fHMkZxvC4C4Hg00YHrICYmRnXq1JEkGYahvf/H3n3HR1Hnfxx/bxqkbUJCDx2UQwgQpIhUQQVRRHNSBLGAIP5EFFBRDhERRUROULCgIuX0sBxNBER6KNICBlCCRy8CISSE9GQzvz9yWVnJQsJu2N3wej4ePo7Mzn73s5nbvGc+O/OdAwe0detW7dq1S+fOndOTTz7p2gJd6NLfzaVatmx5/Yu5iqysLN1+++266667PLJBAwDu5tIMyMrK0s8//6yffvpJGzdu1H/+8x/VrVvXtQW6qYCAAD300EPWn5OSkrR69WrNmTNHW7du1XfffSdfX18XVug6f/3dFChbtqwLqrm6V199Vd98843i4+NdXQoAwI7GjRurf//+mjNnjt566y21bdtWZcuWVV5ensaNGyfDMNSpUyd16tRJhmHolVde0cKFC+Xl5aXWrVurWrVqOnLkiLUHsHTpUn322WcKDAy87HXceb+IzMKNjiY6cB3cfvvtGj58uM2y119/XV999ZVmzpypJ554Qt7e3i6qzrUK+924q9WrVys1NdXVZQBAqfHXDEhNTdUdd9yhlJQULV68WCNGjHBhde4rODj4squ14uLi1LNnT+3fv19r167V3Xff7aLqXKuw3427ys7O1o8//ujqMgAARfD8889r1apVOnnypD755BM999xzmj9/vvbt2yd/f3+NGTNGkvTVV19p4cKF8vX11SeffKI2bdpYx9iwYYP+7//+T7Gxsfr88881bNgwm9dw5/0iMgtgTnTAZQrC9MKFCzp//rwkKSMjQ++9957uvvtuNWnSRB07dtRrr72m5ORk6/Nefvll1a9fXx988IGWLVumrl27qlGjRrr//vu1Z88em9dYuHCh7r77bkVGRqpHjx7auHFjobWcP39e48ePV8eOHdWoUSO1bt1azz//vP773/9a19m6davq16+vTp066dChQ+rTp48aN26se+65R5s2bVJCQoIGDhyoJk2a6I477lBMTIzTflfbtm3TwIED1bx5czVq1EhdunTR+++/r6ysrMt+L1OnTtWrr76qqKgoLVmyRJJ05swZjRo1Sp07d1ZkZKTuueceffvttzavceTIEY0cOVIdOnRQZGSk7rjjDo0fP14pKSmSpP79+1t3aBYuXKj69etr69atTnuPAAApKChI1atXlyRdvHjRurwo+ViYEydO6IUXXlCHDh3UuHFjde3aVZ999pny8vKs63Tq1En169fXli1bNHXqVLVt21aNGzfWkCFDlJiYaDPejz/+qJ49e6pJkyZq3bq1hgwZot9++81mnV27dunJJ59UmzZt1KRJEz388MPauXOnzToFl4U3btxYnTp10hdffHEtvy4bjRs3ltlslpSfaQVWrVqlPn36qEWLFmrRooX69+9vU8+l+X7y5EkNHDhQTZs2VevWrTVz5kyb1zh8+LAGDBigJk2aqG3btpoyZYosFkuh9Xz77beKjo5WkyZN1LRpU/Xs2VOLFi2yWafgd79582ZNmDBBzZs3V6tWrfTOO+9YL5lv3bq1oqKi9Oabb9p9reIqzn7PHXfcodWrV6tDhw4aMGCAJCkvL0+zZ8/WAw88oKioKLVu3Vpjxoyx7jNIUm5urj766CPde++9ioqKUqtWrTRw4EDrlDILFixQZGSkLly4IEmqX7++Xn75Zae8PwCA8wUEBOi1116TJH322Wf65ZdfNHXqVEnS008/rYiICEmyZnqvXr1sGuiS1L59e40ePVrTp0/XkCFDrvqa9vaLJOmnn35S3759FRUVpcaNG+v+++/X7NmzbfZxpKIdS5NZQNHQRAdcJCkpSZLk4+Oj0NBQSdI//vEPffzxx8rKytIDDzwgPz8/zZ8/v9CA2rhxoyZNmqSoqCiVL19e8fHxeuqpp5SZmSlJ2rJli15++WUdPXpUzZo1U7NmzTRq1KjLGg4pKSnq3bu3vvzyS3l5een+++9XxYoVtXz5cvXs2dPmgFLKb/q/8MILqlGjhsqVK6dDhw5p5MiReuGFFxQYGKiIiAidOnVKzz//vNLS0hz+Pa1atUqPP/64Nm7cqIYNG+ree+/V+fPnNWPGDA0ZMkSGYdis/8MPP2jLli3q3r27qlSpotTUVPXt21eLFi1ScHCwevToodTUVI0ZM0YLFiyQlH+p3KOPPqqlS5eqbt26euihh1SpUiV9+eWXGjx4sCSpS5cu1svn6tatq0cffVSVK1d2+P0BAP6UmpqqY8eOScpvChcoTj4WyMrK0mOPPabvv/9eFSpUUI8ePZSQkKDJkydrzpw5l60/bdo0rVu3Trfffru8vLy0du1amzOaFy5cqGHDhmnv3r3q2LGjmjRporVr16pv377WrNy7d68effRRxcTEqEGDBurSpYv27dunAQMGWNe5cOGCBgwYoN27dysiIkKdOnXSV1995fCXz5mZmUpPT5ckhYeHS8o/423o0KH65Zdf1KFDBzVr1kzbtm3ToEGDdOrUKZvnX7hwQU8//bSCgoIUGRmp8+fPa8qUKVq5cqWk/APsJ598Ups2bVK5cuXUtWtXbdiw4bIvpSVp0qRJGjNmjA4cOKBOnTqpXbt22rt3r0aNGlXovOXvvfeefv/9d91yyy1KTk7W559/rhEjRmjFihVq1qyZ0tPTNXfuXOuX444o7n7PxYsXNW7cOLVs2VK33XabJGny5MmaOHGiTpw4oa5du6pOnTr69ttv9cwzz1ifN2XKFE2dOlWZmZm6//771a5dO23dulWPP/64fv/9d9WrV09dunSxrv/oo49e1mwBALiXDh066L777lN2drb69++vCxcuqE6dOtYvWf/44w8dP35ckuz+Te/bt6/uuusumznR7bG3X/Svf/1LQ4cOVWxsrFq1aqW77rpLR44c0cSJE232XYp6LE1mAUXDdC7AdZaXl6cDBw7os88+kyTddddd8vX1lcViUWhoqHr37q3o6Gg1bdpUu3btUp8+fbRhwwZlZmbazOe5f/9+rVixQlWqVNHBgwfVrVs3JSYmKjY2Vrfffru1QdCsWTPNnj1bJpNJ7du3v+wb7y+++ELHjh1TeHi4Fi9erODgYOXk5Oihhx7S/v37NX36dOs37FJ+kPfr109///vftX37dj3yyCNKSkpSzZo1NX78eP3xxx+64447lJqaqtjYWLVr1+6af1eGYeitt96SxWJR7969NX78eEl/XrK+efNmbdiwQR06dLA+JyEhQWvWrFFYWJgkafbs2Tpx4oQiIiL0zTffyM/PT4cPH1bXrl01ffp0RUdH6/fff9eZM2cUGBiozz77TF5eXsrLy9PUqVMVHByszMxMPfLII9q7d68OHjyoxo0be8yl4gDgzjZv3mxt/BbM/Zmamqr7779f3bt3l6Ri52OBP/74w3qAN2LECOsNvf75z3/qxx9/1BNPPGGzflZWlr799lv5+voqKipK48aN07p165SdnS1fX1+99957kqRBgwZZL6ceMWKE1q5dqy+//FKvvfaaZsyYoezsbN13332aMmWKJOnWW2/V2LFj9dlnn+ntt9/WggULdPHiRQUEBGj+/PkKCQnR008/rc6dO1/z7zEpKUnTpk1Tbm6uAgICdMcdd0jKP+O6V69eqlOnjh5//HFJUteuXXX48GHF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kvLvtVGnTh3r2VLFzcfGjRtr/fr1iomJ0csvv6zff/9dFStWlLe3txISEvTSSy/plVdeKVKd3t7eeu6556yZffLkSWVnZ2vt2rUqW7asnnrqKUnSU089pXXr1mnDhg3q27evIiIitG7dOqWkpOjtt9+WlJ9LM2bMUGZmpnr16qWWLVtqw4YNqlq1qo4ePXpNv8datWppwIAB+vjjjzVjxgx17dpVNWvWVPXq1XX8+HG98847WrNmjdatW6f27dtr1apVWrx4scLDw9WgQYMivcZdd92lChUqKCEhQU888YTat2+vnTt3KiQkxGa/okaNGnr00Uc1e/ZsvfLKK1q/fr3S0tKs23XkyJHWuc5dwdH9nrCwMPXr18/6/lavXq2kpCRt2rRJFSpU0L333qugoCD99NNP+vXXX7V3715FRkYqLS3N+gVMwRzrlSpVso47ZMgQderU6bIb3gIAXC89PV2vv/66pPyru2655RZJUkREhJ566ilNnTpVH3zwge6991717t1bv/zyixYsWKChQ4eqVatWql27tpKTk7V9+3brlVmFXVFXlP0iSXr++ef11ltvaerUqdq3b598fHysXwY//vjjql69uiQV+ViazAKKhjPRARfw8/PTmDFjJOUH5aJFi9SiRQu9+OKLqlSpkrZu3aqcnBx9/vnneuaZZxQaGqo9e/YUOk+nPZ06ddIrr7yiihUrat++fTp48KCmTZt22V3CQ0JCNH/+fPXq1UsZGRnWedYeeOABfffdd4XerO16uuuuuzRr1iy1bNlSO3fu1LJly1S1alW9+OKLl93QrTBBQUH66quvdN999+nChQtasmSJLBaLhg8fbr1DeaNGjfTpp5/q1ltv1YYNG/T111/rwIED6tatm+bNm6cKFSpIkvr27aumTZvKMAxt2LDhsrP6AQDFExcXp7lz52ru3LmaN2+eNm3apHr16umVV17RjBkzJOma83HgwIF68MEHFRAQoDVr1qhhw4Z677339MQTT6hMmTLaunWrzU2/rqZPnz6aMmWKbrnlFq1bt05bt25Vu3bt9NVXX+lvf/ubpPyzlOfMmaNWrVrpt99+0/Lly1WtWjVNmzZNDz74oCSpfPny+uijj3TzzTfr9OnT2rZtm5555hmbG5Zei6effloRERHKysrS2LFjJeXf+DQyMlJnz55VbGys/vGPf+iNN97Q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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Federated Learning Benchmark Summary - Box Plot Analysis\n", + "================================================================================\n", + "\n", + "Logistic Regression:\n", + "----------------------------------------\n", + " 3 clients: 0.7577 ± 0.0196 [0.7390, 0.7780]\n", + " 5 clients: 0.7694 ± 0.0413 [0.6970, 0.7960]\n", + " 10 clients: 0.7258 ± 0.0275 [0.6920, 0.7750]\n", + " 20 clients: 0.7255 ± 0.0637 [0.5980, 0.8580]\n", + " Performance degradation (3→20 clients): 4.25%\n", + "\n", + "ElasticNet:\n", + "----------------------------------------\n", + " 3 clients: nan ± nan [nan, nan]\n", + " 5 clients: nan ± nan [nan, nan]\n", + " 10 clients: nan ± nan [nan, nan]\n", + " 20 clients: nan ± nan [nan, nan]\n", + " Performance degradation (3→20 clients): nan%\n", + "\n", + "Linear SVC:\n", + "----------------------------------------\n", + " 3 clients: nan ± nan [nan, nan]\n", + " 5 clients: nan ± nan [nan, nan]\n", + " 10 clients: nan ± nan [nan, nan]\n", + " 20 clients: nan ± nan [nan, nan]\n", + " Performance degradation (3→20 clients): nan%\n", + "\n", + "Random Forest:\n", + "----------------------------------------\n", + " 3 clients: 0.5263 ± 0.0106 [0.5150, 0.5360]\n", + " 5 clients: 0.5116 ± 0.0135 [0.4980, 0.5260]\n", + " 10 clients: 0.5000 ± 0.0000 [0.5000, 0.5000]\n", + " 20 clients: 0.5000 ± 0.0000 [0.5000, 0.5000]\n", + " Performance degradation (3→20 clients): 5.00%\n", + "\n", + "Balanced Random Forest:\n", + "----------------------------------------\n", + " 3 clients: 0.7713 ± 0.0156 [0.7570, 0.7880]\n", + " 5 clients: 0.7636 ± 0.0297 [0.7190, 0.8010]\n", + " 10 clients: 0.7269 ± 0.0254 [0.6940, 0.7800]\n", + " 20 clients: 0.7177 ± 0.0685 [0.5650, 0.8360]\n", + " Performance degradation (3→20 clients): 6.95%\n", + "\n", + "XGBoost:\n", + "----------------------------------------\n", + " 3 clients: nan ± nan [nan, nan]\n", + " 5 clients: nan ± nan [nan, nan]\n", + " 10 clients: nan ± nan [nan, nan]\n", + " 20 clients: nan ± nan [nan, nan]\n", + " Performance degradation (3→20 clients): nan%\n", + "\n", + "================================================================================\n", + "COMPARATIVE ANALYSIS:\n", + "================================================================================\n", + "Best at 3 clients: Balanced Random Forest (balanced_accuracy: 0.7713)\n", + "Best at 5 clients: Logistic Regression (balanced_accuracy: 0.7694)\n", + "Best at 10 clients: Balanced Random Forest (balanced_accuracy: 0.7269)\n", + "Best at 20 clients: Logistic Regression (balanced_accuracy: 0.7255)\n", + "\n", + "Overall best model: Logistic Regression (Avg balanced_accuracy: 0.7339)\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import pandas as pd\n", + "from sklearn.metrics import roc_auc_score\n", + "\n", + "# Set style for academic paper\n", + "plt.style.use('seaborn-v0_8-whitegrid')\n", + "# plt.rcParams['font.family'] = 'serif'\n", + "plt.rcParams['font.size'] = 10\n", + "\n", + "# Define models and client configurations (performance decreases with more clients)\n", + "models = ['Logistic Regression', 'ElasticNet', 'Linear SVC', 'Random Forest', 'Balanced Random Forest', 'XGBoost']\n", + "clients = [3, 5, 10, 20] # Only these client numbers\n", + "# clients = [3, 5, 10] # Only these client numbers\n", + "\n", + "extracted_data = []\n", + "metric = \"balanced_accuracy\"\n", + "for model_name, df in data.items():\n", + " model = model_name.split(\" C\")[0]\n", + " if model == \"Elastic Net\":\n", + " model = \"ElasticNet\"\n", + " if model == \"Lsvc\":\n", + " model = \"Linear SVC\"\n", + " num_clients = int(model_name.split(\" C\")[-1][:2])\n", + " alpha = model_name.split(\" A\")[-1]\n", + " metric_scores = df[metric].values\n", + " for center, score in enumerate(metric_scores):\n", + " extracted_data.append({\n", + " 'model': model,\n", + " 'run': center, # Placeholder, as run info is not available\n", + " 'n_clients': num_clients,\n", + " 'alpha': alpha,\n", + " metric: score\n", + " })\n", + "\n", + "# Convert to DataFrame\n", + "df = pd.DataFrame(extracted_data)\n", + "\n", + "# print(df)\n", + "\n", + "# Create 3x3 subplot grid\n", + "fig, axes = plt.subplots(2, 3, figsize=(15, 10))\n", + "axes = axes.flatten()\n", + "\n", + "# Define colors for each model\n", + "colors = {\n", + " 'Logistic Regression': '#1f77b4',\n", + " 'ElasticNet': '#ff7f0e', \n", + " 'Linear SVC': '#2ca02c',\n", + " 'Random Forest': '#d62728',\n", + " 'Balanced Random Forest': '#8c564b',\n", + " 'XGBoost': '#9467bd',\n", + " 'MLP': '#8c564b'\n", + "}\n", + "\n", + "# Prepare data for boxplot\n", + "# boxplot_data = []\n", + "# client_labels = []\n", + "x_positions = clients\n", + "\n", + "# for client_idx, client in enumerate(clients):\n", + "# client_data = model_data[model_data['n_clients'] == client][metric]\n", + "# if len(client_data) > 0:\n", + "# boxplot_data.append(client_data)\n", + "# # Use actual client number as x-position\n", + "# client_labels.append(f'{client}')\n", + " \n", + "# print(box_positions)\n", + "# x\n", + "\n", + "# Plot box plots for each model in separate subplots\n", + "for i, model in enumerate(models):\n", + " if i < len(axes): # Ensure we don't exceed subplot count\n", + " ax = axes[i]\n", + " model_data = df[df['model'] == model]\n", + " \n", + " # Prepare data for boxplot\n", + " boxplot_data = []\n", + " client_labels = []\n", + " box_positions = []\n", + "\n", + " for client_idx, client in enumerate(clients):\n", + " client_data = model_data[model_data['n_clients'] == client][metric]\n", + " boxplot_data.append(client_data)\n", + " box_positions.append(x_positions[client_idx])\n", + " client_labels.append(f'{client}')\n", + " \n", + " \n", + " # print(f\"Model: {model}\")\n", + " # print(\"Box data:\", boxplot_data)\n", + " \n", + " \n", + " # Create box plot with custom positions\n", + " # Adjust width relative to the x-axis scale\n", + " # Base width on the smallest gap between client numbers\n", + " min_gap = min([x_positions[i+1] - x_positions[i] for i in range(len(x_positions)-1)])\n", + " box_width = min_gap * 0.9 # Adjust this factor to control box width\n", + " \n", + " box_plots = ax.boxplot(boxplot_data, positions=box_positions, \n", + " widths=box_width, patch_artist=True,\n", + " showmeans=False, \n", + " meanprops={'marker':'o', 'markerfacecolor':'white', \n", + " 'markeredgecolor':'black'})\n", + " # Color the boxes\n", + " for patch in box_plots['boxes']:\n", + " patch.set_facecolor(colors[model])\n", + " patch.set_alpha(0.7)\n", + " \n", + " # Customize box plot elements\n", + " for element in ['whiskers', 'caps', 'medians']:\n", + " for line in box_plots[element]:\n", + " line.set_color('black')\n", + " line.set_linewidth(1.5)\n", + "\n", + " # Set x-ticks to client numbers\n", + " ax.set_xticks(box_positions)\n", + " ax.set_xticklabels(client_labels)\n", + " \n", + " # Set subplot title and labels\n", + " ax.set_title(f'{model}', fontsize=12, fontweight='bold')\n", + " ax.set_xlabel('Number of Clients', fontsize=10)\n", + " metric_formatted = metric.replace(\"_\", \" \").title()\n", + " ax.set_ylabel(metric_formatted, fontsize=10)\n", + " \n", + " # Set consistent y-axis across all subplots\n", + " # ax.set_ylim(0.5, 0.78)\n", + " ax.set_ylim(0.4, 0.85)\n", + "\n", + " # Set x-axis limits with some padding\n", + " ax.set_xlim(min(box_positions) - min_gap * 0.5, \n", + " max(box_positions) + min_gap * 0.5)\n", + " \n", + " # Add grid\n", + " ax.grid(True, alpha=0.3, axis='y')\n", + " \n", + " # Add trend annotation\n", + " means = [np.mean(client_data) for client_data in boxplot_data]\n", + " trend = means[0] - means[-1] # Performance drop from 3 to 20 clients\n", + " \n", + " # Add performance degradation annotation\n", + " ax.text(0.02, 0.98, f'Δ: -{trend:.3f}', transform=ax.transAxes, \n", + " fontsize=9, verticalalignment='top',\n", + " bbox=dict(boxstyle='round', facecolor='lightgray', alpha=0.8))\n", + "\n", + "# Remove empty subplot if we have 6 models in 3x3 grid\n", + "if len(models) < len(axes):\n", + " for i in range(len(models), len(axes)):\n", + " fig.delaxes(axes[i])\n", + "\n", + "# Add overall title\n", + "# fig.suptitle('Federated Learning Benchmark: Model Performance Distribution vs Number of Clients\\n'\n", + " # 'Box Plots Showing Performance Degradation with Increasing Clients', \n", + " # fontsize=14, fontweight='bold', y=0.98)\n", + "\n", + "plt.tight_layout()\n", + "plt.subplots_adjust(top=0.93)\n", + "plt.show()\n", + "\n", + "# Print detailed statistics for the paper\n", + "print(\"Federated Learning Benchmark Summary - Box Plot Analysis\")\n", + "print(\"=\" * 80)\n", + "\n", + "for model in models:\n", + " print(f\"\\n{model}:\")\n", + " print(\"-\" * 40)\n", + " model_data = df[df['model'] == model]\n", + " \n", + " for client in clients:\n", + " client_data = model_data[model_data['n_clients'] == client][metric]\n", + " mean_auc = client_data.mean()\n", + " std_auc = client_data.std()\n", + " min_auc = client_data.min()\n", + " max_auc = client_data.max()\n", + " \n", + " print(f\" {client:2d} clients: {mean_auc:.4f} ± {std_auc:.4f} \"\n", + " f\"[{min_auc:.4f}, {max_auc:.4f}]\")\n", + " \n", + " # Calculate overall degradation\n", + " perf_3 = model_data[model_data['n_clients'] == 3][metric].mean()\n", + " perf_20 = model_data[model_data['n_clients'] == 20][metric].mean()\n", + " degradation = ((perf_3 - perf_20) / perf_3) * 100\n", + " print(f\" Performance degradation (3→20 clients): {degradation:.2f}%\")\n", + "\n", + "# Comparative analysis\n", + "print(\"\\n\" + \"=\" * 80)\n", + "print(\"COMPARATIVE ANALYSIS:\")\n", + "print(\"=\" * 80)\n", + "\n", + "# Find best performing model at each client count\n", + "for client in clients:\n", + " client_data = df[df['n_clients'] == client]\n", + " best_model = None\n", + " best_auc = 0\n", + " \n", + " for model in models:\n", + " model_auc = client_data[client_data['model'] == model][metric].mean()\n", + " if model_auc > best_auc:\n", + " best_auc = model_auc\n", + " best_model = model\n", + "\n", + " print(f\"Best at {client:2d} clients: {best_model} ({metric}: {best_auc:.4f})\")\n", + "\n", + "# Overall best model\n", + "overall_means = df.groupby('model')[metric].mean()\n", + "best_overall_model = overall_means.idxmax()\n", + "best_overall_auc = overall_means.max()\n", + "\n", + "print(f\"\\nOverall best model: {best_overall_model} (Avg {metric}: {best_overall_auc:.4f})\")" + ] + }, + { + "cell_type": "markdown", + "id": "30c417a3", + "metadata": {}, + "source": [ + "# Table: Normalization impact" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ec9feea2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Weighted Average Metrics Table:\n", + "\n", + "Model Balanced Accuracy Auroc\n", + "Diabetes Logistic Regression C10 A0.7 NormN FeatN 0.654 ± 0.021 0.729 ± 0.034\n", + "Diabetes Logistic Regression C10 A0.7 Normglobal FeatN 0.745 ± 0.041 0.803 ± 0.049\n", + "Diabetes Logistic Regression C10 A0.7 Normlocal FeatN 0.730 ± 0.024 0.801 ± 0.056\n", + "Diabetes Logistic Regression C10 AN NormN FeatN 0.665 ± 0.017 0.725 ± 0.014\n", + "Diabetes Logistic Regression C10 AN Normglobal FeatN 0.755 ± 0.011 0.829 ± 0.009\n", + "Diabetes Logistic Regression C10 AN Normlocal FeatN 0.759 ± 0.011 0.830 ± 0.010\n", + "Ukbb Cvd Logistic Regression C10 A0.7 NormN FeatN 0.515 ± 0.009 0.529 ± 0.027\n", + "Ukbb Cvd Logistic Regression C10 A0.7 Normglobal FeatN 0.742 ± 0.035 0.814 ± 0.024\n", + "Ukbb Cvd Logistic Regression C10 A0.7 Normlocal FeatN 0.746 ± 0.037 0.818 ± 0.021\n", + "Ukbb Cvd Logistic Regression C10 AN NormN FeatN 0.518 ± 0.008 0.530 ± 0.024\n", + "Ukbb Cvd Logistic Regression C10 AN Normglobal FeatN 0.741 ± 0.034 0.814 ± 0.023\n", + "Ukbb Cvd Logistic Regression C10 AN Normlocal FeatN 0.746 ± 0.036 0.818 ± 0.021\n", + "\n", + "LaTeX Table:\n", + "\n", + "\\begin{tabular}{lcc}\n", + "Model & Balanced Accuracy & Auroc \\\\\n", + "\\hline\n", + "Diabetes Logistic Regression C10 A0.7 NormN FeatN & 0.654 $\\pm$ 0.021 & 0.729 $\\pm$ 0.034 \\\\\n", + "Diabetes Logistic Regression C10 A0.7 Normglobal FeatN & 0.745 $\\pm$ 0.041 & 0.803 $\\pm$ 0.049 \\\\\n", + "Diabetes Logistic Regression C10 A0.7 Normlocal FeatN & 0.730 $\\pm$ 0.024 & 0.801 $\\pm$ 0.056 \\\\\n", + "Diabetes Logistic Regression C10 AN NormN FeatN & 0.665 $\\pm$ 0.017 & 0.725 $\\pm$ 0.014 \\\\\n", + "Diabetes Logistic Regression C10 AN Normglobal FeatN & 0.755 $\\pm$ 0.011 & 0.829 $\\pm$ 0.009 \\\\\n", + "Diabetes Logistic Regression C10 AN Normlocal FeatN & 0.759 $\\pm$ 0.011 & 0.830 $\\pm$ 0.010 \\\\\n", + "Ukbb Cvd Logistic Regression C10 A0.7 NormN FeatN & 0.515 $\\pm$ 0.009 & 0.529 $\\pm$ 0.027 \\\\\n", + "Ukbb Cvd Logistic Regression C10 A0.7 Normglobal FeatN & 0.742 $\\pm$ 0.035 & 0.814 $\\pm$ 0.024 \\\\\n", + "Ukbb Cvd Logistic Regression C10 A0.7 Normlocal FeatN & 0.746 $\\pm$ 0.037 & 0.818 $\\pm$ 0.021 \\\\\n", + "Ukbb Cvd Logistic Regression C10 AN NormN FeatN & 0.518 $\\pm$ 0.008 & 0.530 $\\pm$ 0.024 \\\\\n", + "Ukbb Cvd Logistic Regression C10 AN Normglobal FeatN & 0.741 $\\pm$ 0.034 & 0.814 $\\pm$ 0.023 \\\\\n", + "Ukbb Cvd Logistic Regression C10 AN Normlocal FeatN & 0.746 $\\pm$ 0.036 & 0.818 $\\pm$ 0.021 \\\\\n", + "\\end{tabular}\n" + ] + } + ], + "source": [ + "# Normalization experiment\n", + "experiment_name = \"normalization\"\n", + "logs_dir = \"benchmark_results_normalization\"\n", + "model_names = [\"logistic_regression\"]\n", + "datasets = [\"diabetes\"]\n", + "num_clients = [10]\n", + "dirichlet_alpha = [\"None\"]\n", + "data_normalization = [\"global\", \"local\", None]\n", + "keywords = [experiment_name]\n", + "data = load_data(logs_dir, experiment_name, keywords, results_file=\"per_center_results.csv\")\n", + "\n", + "# Write a code to extract the following metrics, calculate weighted averages and standard deviations and create a table with rows as models and columns as metrics in latex format\n", + "metrics_to_extract = [\"balanced_accuracy\", \"auroc\"]\n", + "table_data = {}\n", + "for model_name, df in data.items():\n", + " # model = model_name.split(\" Norm\")[1]\n", + " model = model_name\n", + " total_samples = df[\"n samples\"].sum()\n", + " table_data[model] = {}\n", + " for metric in metrics_to_extract:\n", + " weighted_sum = (df[metric] * df[\"n samples\"]).sum()\n", + " avg_metric = weighted_sum / total_samples\n", + " std_metric = ( ((df[metric] - avg_metric)**2 * df[\"n samples\"]).sum() / total_samples )**0.5\n", + " table_data[model][metric] = (avg_metric, std_metric)\n", + "\n", + "# Print nicely formatted table\n", + "print(\"\\nWeighted Average Metrics Table:\\n\")\n", + "header = \"Model\".ljust(30)\n", + "for metric in metrics_to_extract:\n", + " header += f\"{metric.replace('_', ' ').title():>30}\"\n", + "print(header)\n", + "for model, metrics in table_data.items():\n", + " row = model.ljust(30)\n", + " for metric in metrics_to_extract:\n", + " avg, std = metrics[metric]\n", + " row += f\"{avg:.3f} ± {std:.3f}\".rjust(30)\n", + " print(row)\n", + "\n", + "\n", + "# Create latex table\n", + "latex_table = \"\\\\begin{tabular}{l\" + \"c\" * len(metrics_to_extract) + \"}\\n\"\n", + "latex_table += \"Model\"\n", + "for metric in metrics_to_extract:\n", + " latex_table += f\" & {metric.replace('_', ' ').title()}\"\n", + "latex_table += \" \\\\\\\\\\n\\\\hline\\n\"\n", + "for model, metrics in table_data.items():\n", + " latex_table += model\n", + " for metric in metrics_to_extract:\n", + " avg, std = metrics[metric]\n", + " latex_table += f\" & {avg:.3f} $\\\\pm$ {std:.3f}\"\n", + " latex_table += \" \\\\\\\\\\n\"\n", + "latex_table += \"\\\\end{tabular}\"\n", + "print(\"\\nLaTeX Table:\\n\")\n", + "print(latex_table)\n" + ] + }, + { + "cell_type": "markdown", + "id": "35133b50", + "metadata": {}, + "source": [ + "# Table: Feature Selection" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "add792d5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Found 6 experiments\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat10\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat20\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat35\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat40\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal FeatN\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat10\n", + "\n", + "Weighted Average Metrics Table:\n", + "\n", + "Model Balanced Accuracy Auroc Round Time [S]\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat10 0.757 ± 0.024 0.818 ± 0.022 0.966 ± 0.199\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat20 0.742 ± 0.028 0.823 ± 0.027 1.032 ± 0.212\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat35 0.750 ± 0.027 0.825 ± 0.025 1.098 ± 0.226\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat40 0.747 ± 0.018 0.825 ± 0.025 1.128 ± 0.235\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal FeatN 0.750 ± 0.031 0.824 ± 0.027 1.146 ± 0.240\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat10 0.755 ± 0.023 0.819 ± 0.022 0.983 ± 0.199\n", + "\n", + "LaTeX Table:\n", + "\n", + "\\begin{tabular}{lccc}\n", + "Model & Balanced Accuracy & Auroc & Round Time [S] \\\\\n", + "\\hline\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat10 & 0.757 $\\pm$ 0.024 & 0.818 $\\pm$ 0.022 & 0.966 $\\pm$ 0.199 \\\\\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat20 & 0.742 $\\pm$ 0.028 & 0.823 $\\pm$ 0.027 & 1.032 $\\pm$ 0.212 \\\\\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat35 & 0.750 $\\pm$ 0.027 & 0.825 $\\pm$ 0.025 & 1.098 $\\pm$ 0.226 \\\\\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat40 & 0.747 $\\pm$ 0.018 & 0.825 $\\pm$ 0.025 & 1.128 $\\pm$ 0.235 \\\\\n", + "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal FeatN & 0.750 $\\pm$ 0.031 & 0.824 $\\pm$ 0.027 & 1.146 $\\pm$ 0.240 \\\\\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat10 & 0.755 $\\pm$ 0.023 & 0.819 $\\pm$ 0.022 & 0.983 $\\pm$ 0.199 \\\\\n", + "\\end{tabular}\n" + ] + } + ], + "source": [ + "# Feature selection experiment\n", + "experiment_name = \"feature_selection\"\n", + "benchmark_dir = \"benchmark_results_feature_selection\"\n", + "model_names = [\"balanced_random_forest\"]\n", + "datasets = [\"ukbb_cvd\"]\n", + "num_clients = [5,10]\n", + "dirichlet_alpha = [0.7, None]\n", + "data_normalization = [\"global\"]\n", + "n_features = [10, 20, 35, 40, None]\n", + "keywords = [experiment_name]\n", + "\n", + "data = load_data(benchmark_dir, experiment_name, keywords)\n", + "# Write a code to extract the following metrics, calculate weighted averages and standard deviations and create a table with rows as models and columns as metrics in latex format\n", + "metrics_to_extract = [\"balanced_accuracy\", \"auroc\", \"round_time [s]\"]\n", + "table_data = {}\n", + "for model_name, df in data.items():\n", + " # model = model_name.split(\" Norm\")[1]\n", + " model = model_name\n", + " total_samples = df[\"n samples\"].sum()\n", + " table_data[model] = {}\n", + " for metric in metrics_to_extract:\n", + " weighted_sum = (df[metric] * df[\"n samples\"]).sum()\n", + " avg_metric = weighted_sum / total_samples\n", + " std_metric = ( ((df[metric] - avg_metric)**2 * df[\"n samples\"]).sum() / total_samples )**0.5\n", + " table_data[model][metric] = (avg_metric, std_metric)\n", + "\n", + "# Print nicely formatted table\n", + "print(\"\\nWeighted Average Metrics Table:\\n\")\n", + "header = \"Model\".ljust(30)\n", + "for metric in metrics_to_extract:\n", + " header += f\"{metric.replace('_', ' ').title():>30}\"\n", + "print(header)\n", + "for model, metrics in table_data.items():\n", + " row = model.ljust(30)\n", + " for metric in metrics_to_extract:\n", + " avg, std = metrics[metric]\n", + " row += f\"{avg:.3f} ± {std:.3f}\".rjust(30)\n", + " print(row)\n", + "\n", + "\n", + "# Create latex table\n", + "latex_table = \"\\\\begin{tabular}{l\" + \"c\" * len(metrics_to_extract) + \"}\\n\"\n", + "latex_table += \"Model\"\n", + "for metric in metrics_to_extract:\n", + " latex_table += f\" & {metric.replace('_', ' ').title()}\"\n", + "latex_table += \" \\\\\\\\\\n\\\\hline\\n\"\n", + "for model, metrics in table_data.items():\n", + " latex_table += model\n", + " for metric in metrics_to_extract:\n", + " avg, std = metrics[metric]\n", + " latex_table += f\" & {avg:.3f} $\\\\pm$ {std:.3f}\"\n", + " latex_table += \" \\\\\\\\\\n\"\n", + "latex_table += \"\\\\end{tabular}\"\n", + "print(\"\\nLaTeX Table:\\n\")\n", + "print(latex_table)\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "flc", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.19" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 6678ff3ebae308e8fbdb5629e2b8663448c770f4 Mon Sep 17 00:00:00 2001 From: faildeny Date: Wed, 4 Feb 2026 12:59:51 +0100 Subject: [PATCH 13/29] Fix for AUROC in LSVC models since they do not output probabilites --- flcore/metrics.py | 5 +++-- flcore/models/linear_models/client.py | 21 +++++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/flcore/metrics.py b/flcore/metrics.py index 5de33bc..ad33faf 100644 --- a/flcore/metrics.py +++ b/flcore/metrics.py @@ -71,8 +71,9 @@ def calculate_metrics(y_true, y_pred_proba, task_type="binary", threshold=0.5): if not torch.is_tensor(y_pred_proba): y_pred_proba = torch.tensor(y_pred_proba.tolist()) - # Extract probabilities for the positive class - y_pred_proba = y_pred_proba[:, 1] + # Extract probabilities for the positive class if shape>1 + if y_pred_proba.ndim > 1: + y_pred_proba = y_pred_proba[:, 1] metrics_collection.update(y_pred_proba, y_true) diff --git a/flcore/models/linear_models/client.py b/flcore/models/linear_models/client.py index a4cd1ac..e73fb87 100644 --- a/flcore/models/linear_models/client.py +++ b/flcore/models/linear_models/client.py @@ -67,7 +67,11 @@ def fit(self, parameters, config): # type: ignore self.model.fit(self.X_train, self.y_train) # self.model.fit(self.X_train.loc[:, parameters[2].astype(bool)], self.y_train) # y_pred = self.model.predict(self.X_test.loc[:, parameters[2].astype(bool)]) - y_pred_proba = self.model.predict_proba(self.X_test) + # If LSVC is used, use decision_function instead of predict_proba + if self.model_name == 'lsvc': + y_pred_proba = self.model.decision_function(self.X_test) + else: + y_pred_proba = self.model.predict_proba(self.X_test) metrics = calculate_metrics(self.y_test, y_pred_proba) print(f"Client {self.client_id} Evaluation just after local training: {metrics['balanced_accuracy']}") # Add 'personalized' to the metrics to identify them @@ -82,7 +86,10 @@ def fit(self, parameters, config): # type: ignore local_model = utils.get_model(self.model_name, local=True) # utils.set_initial_params(local_model,self.n_features) local_model.fit(self.X_train, self.y_train) - y_pred_proba = local_model.predict_proba(self.X_test) + if self.model_name == 'lsvc': + y_pred_proba = self.model.decision_function(self.X_test) + else: + y_pred_proba = self.model.predict_proba(self.X_test) local_metrics = calculate_metrics(self.y_test, y_pred_proba) #Add 'local' to the metrics to identify them local_metrics = {f"local {key}": local_metrics[key] for key in local_metrics} @@ -95,10 +102,16 @@ def evaluate(self, parameters, config): # type: ignore utils.set_model_params(self.model, parameters) # Calculate validation set metrics - y_pred_proba = self.model.predict_proba(self.X_val) + if self.model_name == 'lsvc': + y_pred_proba = self.model.decision_function(self.X_val) + else: + y_pred_proba = self.model.predict_proba(self.X_val) val_metrics = calculate_metrics(self.y_val, y_pred_proba) - y_pred_proba = self.model.predict_proba(self.X_test) + if self.model_name == 'lsvc': + y_pred_proba = self.model.decision_function(self.X_test) + else: + y_pred_proba = self.model.predict_proba(self.X_test) # y_pred = self.model.predict(self.X_test.loc[:, parameters[2].astype(bool)]) if(isinstance(self.model, SGDClassifier)): From 848f627325720eaed76d60c362baa2ef3ec4208c Mon Sep 17 00:00:00 2001 From: faildeny Date: Wed, 4 Feb 2026 13:00:46 +0100 Subject: [PATCH 14/29] Add much faster tests with lower config parameters --- flcore/models/random_forest/aggregatorRF.py | 2 +- flcore/models/random_forest/client.py | 2 +- flcore/models/random_forest/server.py | 2 +- flcore/models/random_forest/utils.py | 6 +++--- tests/test_models.py | 8 +++++++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/flcore/models/random_forest/aggregatorRF.py b/flcore/models/random_forest/aggregatorRF.py index a55b8b8..b059309 100644 --- a/flcore/models/random_forest/aggregatorRF.py +++ b/flcore/models/random_forest/aggregatorRF.py @@ -117,8 +117,8 @@ def aggregateRF_withprevious(rfs,previous_estimators,bal_RF): #weigth, we transform into probability /sum(weights) #and random choice select according to probability distribution def aggregateRFwithSizeCenterProbs(rfs,bal_RF,smoothing_method,smoothing_strenght): - rfa= get_model(bal_RF) numberTreesperclient = int(len(rfs[0][0][0])) + rfa= get_model(bal_RF, numberTreesperclient) number_Clients = len(rfs) random_select =int(numberTreesperclient/number_Clients) list_classifiers = [] diff --git a/flcore/models/random_forest/client.py b/flcore/models/random_forest/client.py index e4e1595..ef1e758 100644 --- a/flcore/models/random_forest/client.py +++ b/flcore/models/random_forest/client.py @@ -31,7 +31,7 @@ def __init__(self, data,client_id,config): (self.X_train, self.y_train), (self.X_test, self.y_test) = data self.splits_nested = datasets.split_partitions(n_folds_out,0.2, seed, self.X_train, self.y_train) self.bal_RF = True if config['model'] == 'balanced_random_forest' else False - self.model = utils.get_model(self.bal_RF) + self.model = utils.get_model(self.bal_RF, config['random_forest']['tree_num']) self.round_time = 0 # Setting initial parameters, akin to model.compile for keras models utils.set_initial_params_client(self.model,self.X_train, self.y_train) diff --git a/flcore/models/random_forest/server.py b/flcore/models/random_forest/server.py index 06b538c..e863a52 100644 --- a/flcore/models/random_forest/server.py +++ b/flcore/models/random_forest/server.py @@ -34,7 +34,7 @@ def fit_round( server_round: int ) -> Dict: def get_server_and_strategy(config): bal_RF = True if config['model'] == 'balanced_random_forest' else False - model = get_model(bal_RF) + model = get_model(bal_RF, config['random_forest']['tree_num']) utils.set_initial_params_server( model) # Pass parameters to the Strategy for server-side parameter initialization diff --git a/flcore/models/random_forest/utils.py b/flcore/models/random_forest/utils.py index 426e9f7..1170122 100644 --- a/flcore/models/random_forest/utils.py +++ b/flcore/models/random_forest/utils.py @@ -21,11 +21,11 @@ from typing import cast -def get_model(bal_RF): +def get_model(bal_RF, tree_num) -> RandomForestClassifier: if(bal_RF == True): - model = BalancedRandomForestClassifier(n_estimators=300,max_depth=10) + model = BalancedRandomForestClassifier(n_estimators=tree_num,max_depth=10) else: - model = RandomForestClassifier(n_estimators=300,max_depth=10,class_weight= "balanced_subsample") + model = RandomForestClassifier(n_estimators=tree_num,max_depth=10,class_weight= "balanced_subsample") return model diff --git a/tests/test_models.py b/tests/test_models.py index feb2cc2..3a02568 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -40,7 +40,13 @@ def setup_class(self): with open("config.yaml", "r") as f: self.config = yaml.safe_load(f) - self.num_clients = 3 + self.config["num_clients"] = 3 + self.config["num_rounds"] = 2 + + # To speed up tests, reduce number of trees in xgboost and random forest + self.config["random_forest"]["tree_num"] = 5 + self.config["xgb"]["tree_num"] = 5 + self.config["xgb"]["num_iterations"] = 2 @pytest.mark.parametrize( From 2aadb58d3a33e2f7cecfa5aab9df23cceaf23752 Mon Sep 17 00:00:00 2001 From: faildeny Date: Wed, 4 Feb 2026 13:02:14 +0100 Subject: [PATCH 15/29] Add tree number parameter in config for RF --- config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/config.yaml b/config.yaml index 1319554..448da96 100644 --- a/config.yaml +++ b/config.yaml @@ -119,6 +119,7 @@ dirichlet_alpha: Null # Random Forest random_forest: balanced_rf: true + tree_num: 300 # Weighted Random Forest weighted_random_forest: From c4935c0a2a13644194c602e3c89668fe0b361d1d Mon Sep 17 00:00:00 2001 From: faildeny Date: Wed, 4 Feb 2026 13:25:56 +0100 Subject: [PATCH 16/29] Update preprocessing aggregation method in config --- config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.yaml b/config.yaml index 448da96..d17bc02 100644 --- a/config.yaml +++ b/config.yaml @@ -73,8 +73,8 @@ experiment: # "equal_aggregate" - aggregate parameters from all clients based on mean and voting disregarding center size # "weighted_aggregate" - aggregate parameters from all clients based on weighted mean and voting -# data_preprocessing_method: "equal_aggregate" -data_preprocessing_method: "reference" +data_preprocessing_method: "equal_aggregate" +# data_preprocessing_method: "reference" # Toggle data normalization (Standard scaler) based on largest center (global) or local client data_normalization: "local" From 7d6504511c932ffb8f7fa870aa79568f6a68bab3 Mon Sep 17 00:00:00 2001 From: faildeny Date: Wed, 4 Feb 2026 14:24:38 +0100 Subject: [PATCH 17/29] Remove legacy dataset preparation function --- flcore/datasets.py | 121 --------------------------------------------- 1 file changed, 121 deletions(-) diff --git a/flcore/datasets.py b/flcore/datasets.py index f82a776..32a5ed9 100644 --- a/flcore/datasets.py +++ b/flcore/datasets.py @@ -426,127 +426,6 @@ def aggregate_preprocessing_params(preprocessing_params_list, center_sizes, meth return aggregated -def prepare_dataset_old(X, y, center_id, config, center_indices=None): - """ - Load and preprocess raw dataset for federated learning with feature selection - - This function will extract the following config values: - center_id: Identifier for the federated node - num_centers: Total number of federated centers - alpha: Dirichlet concentration parameter for data partitioning - reference_method: How to select reference center ('largest' or 'random') - global_preprocessing_params: Precomputed parameters (if None, will calculate) - n_features: Number of features to select (None for all features) - feature_selection_method: Method for feature selection - - Returns: - tuple: X_train, y_train, X_test, y_test - """ - - num_centers = config.get("num_clients", 5) - alpha = config.get("dirichlet_alpha", 1.0) - reference_method = config.get("reference_center_method", "largest") - global_preprocessing_params = None - n_features = config.get("n_features", 20) - feature_selection_method = config.get("feature_selection_method", "mutual_info") - normalization_method = config.get("data_normalization", "global") - - np.random.seed(42) - - # Convert target to binary classification if needed - if y.nunique() > 2: - y_binary = (y > y.median()).astype(int) - else: - y_binary = y - - if not center_indices: - # Partition data using Dirichlet distribution - all_center_indices = partition_data_dirichlet(y_binary.values, num_centers, alpha) - else: - all_center_indices = center_indices - - # Get all center data for reference selection - all_center_data = [] - for i in range(num_centers): - if i < len(all_center_indices) and len(all_center_indices[i]) > 0: - X_center = X.iloc[all_center_indices[i]] - all_center_data.append((X_center, y_binary.iloc[all_center_indices[i]])) - else: - all_center_data.append((pd.DataFrame(), pd.Series())) - - # Calculate or use global preprocessing parameters - if global_preprocessing_params is None: - if aggregation_method == 'reference': - # Select reference center and calculate parameters - reference_center_id = select_reference_center(all_center_data, reference_method) - X_reference = all_center_data[reference_center_id][0] - y_reference = all_center_data[reference_center_id][1] - - if len(X_reference) == 0: - # Fallback: use full dataset if reference center is empty - X_reference = X - y_reference = y_binary - print("Warning: Reference center empty, using full dataset for preprocessing parameters") - - global_preprocessing_params = calculate_preprocessing_params( - X_reference, y_reference, n_features=n_features, feature_selection_method=feature_selection_method - ) - print("Calculated global preprocessing parameters using reference center") - elif aggregation_method == 'weighted_aggregate': - # Calculate parameters for each center and aggregate - preprocessing_params_list = [] - center_sizes = [] - for X_center, y_center in all_center_data: - if len(X_center) > 0: - params = calculate_preprocessing_params( - X_center, y_center, n_features=n_features, feature_selection_method=feature_selection_method - ) - preprocessing_params_list.append(params) - center_sizes.append(len(X_center)) - - if preprocessing_params_list: - global_preprocessing_params = aggregate_preprocessing_params(preprocessing_params_list, center_sizes) - print("Calculated global preprocessing parameters using weighted aggregation") - else: - # Fallback - global_preprocessing_params = calculate_preprocessing_params( - X, y_binary, n_features=n_features, feature_selection_method=feature_selection_method - ) - print("Warning: No valid centers, using full dataset for preprocessing parameters") - else: - raise ValueError("aggregation_method must be 'reference' or 'weighted_aggregate'") - - if center_id is not None: - # Get indices for the requested center - if center_id >= len(all_center_indices) or len(all_center_indices[center_id]) == 0: - raise ValueError(f"Center ID {center_id} has no data assigned") - - center_indices = all_center_indices[center_id] - X_center = X.iloc[center_indices].reset_index(drop=True) - y_center = y.iloc[center_indices].reset_index(drop=True) - else: - # Use full dataset if no center_id specified - X_center = X - y_center = y - - # Split into train/test for this center - if len(X_center) > 1: - X_train, X_test, y_train, y_test = train_test_split( - X_center, y_center, test_size=0.2, random_state=42, stratify=y_center - ) - else: - X_train, y_train = X_center, y_center - X_test, y_test = X_center.iloc[:0], y_center.iloc[:0] - - # Apply GLOBAL preprocessing parameters to both train and test sets - X_train_processed, feature_names = apply_preprocessing(X_train, global_preprocessing_params, normalization=normalization_method) - X_test_processed, _ = apply_preprocessing(X_test, global_preprocessing_params, normalization=normalization_method) - - # shuffle the training data - X_train_processed, y_train = shuffle(X_train_processed, y_train) - - return X_train_processed, y_train, X_test_processed, y_test - def prepare_dataset(X, y, center_id, config, center_indices=None): """ Load and preprocess raw dataset for federated learning with feature selection From 50994038b0344377b9134f2287fd2a110884a1cf Mon Sep 17 00:00:00 2001 From: faildeny Date: Thu, 5 Feb 2026 17:55:47 +0100 Subject: [PATCH 18/29] Add minimum num of samples in dirichlet partitioning --- flcore/datasets.py | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/flcore/datasets.py b/flcore/datasets.py index 32a5ed9..3d699b6 100644 --- a/flcore/datasets.py +++ b/flcore/datasets.py @@ -244,9 +244,15 @@ def apply_preprocessing(subset_data, preprocessing_params, normalization="global return data_copy, data_copy.columns.tolist() -def partition_data_dirichlet(labels, num_centers, alpha=1.0): +def partition_data_dirichlet(labels, num_centers, alpha=1.0, min_samples_per_class=10): """ Partition data among centers using Dirichlet distribution + + Args: + labels: Array of class labels + num_centers: Number of centers to partition into + alpha: Dirichlet concentration parameter + min_samples_per_class: Minimum number of samples per class per center """ unique_labels = np.unique(labels) n_samples = len(labels) @@ -281,10 +287,32 @@ def partition_data_dirichlet(labels, num_centers, alpha=1.0): # Calculate number of samples for each center center_samples = (proportions * n_class_samples).astype(int) - # Adjust for rounding errors - diff = n_class_samples - center_samples.sum() + # Ensure minimum samples per class per center + for i in range(num_centers): + if center_samples[i] < min_samples_per_class: + center_samples[i] = min(min_samples_per_class, n_class_samples // num_centers) + + # Adjust for rounding errors and minimum constraints + total_assigned = center_samples.sum() + diff = n_class_samples - total_assigned if diff > 0: - center_samples[np.random.choice(num_centers, diff, replace=True)] += 1 + # Distribute remaining samples + available_centers = [i for i in range(num_centers) if center_samples[i] < n_class_samples] + if available_centers: + additions = np.random.choice(available_centers, diff, replace=True) + for i in additions: + center_samples[i] += 1 + elif diff < 0: + # Remove excess samples + excess_centers = np.argsort(center_samples)[::-1] # Sort by size descending + for i in excess_centers: + if diff >= 0: + break + can_remove = center_samples[i] - min_samples_per_class + if can_remove > 0: + remove = min(can_remove, -diff) + center_samples[i] -= remove + diff += remove # Shuffle and assign indices np.random.shuffle(class_indices) @@ -448,6 +476,7 @@ def prepare_dataset(X, y, center_id, config, center_indices=None): alpha = config.get("dirichlet_alpha", 1.0) reference_method = config.get("reference_center_method", "largest") preprocessing_method = config.get("data_preprocessing_method", "reference") + min_samples_per_class = config.get("min_samples_per_class", 10) global_preprocessing_params = None n_features = config.get("n_features", 20) feature_selection_method = config.get("feature_selection_method", "mutual_info") @@ -463,7 +492,7 @@ def prepare_dataset(X, y, center_id, config, center_indices=None): if not center_indices: # Partition data using Dirichlet distribution - all_center_indices = partition_data_dirichlet(y_binary.values, num_centers, alpha) + all_center_indices = partition_data_dirichlet(y_binary.values, num_centers, alpha, min_samples_per_class) else: all_center_indices = center_indices @@ -475,7 +504,7 @@ def prepare_dataset(X, y, center_id, config, center_indices=None): all_center_data.append((X_center, y_binary.iloc[all_center_indices[i]])) else: all_center_data.append((pd.DataFrame(), pd.Series())) - + # Calculate or use global preprocessing parameters if global_preprocessing_params is None: if preprocessing_method == 'reference': From 9005e97d14e0bcadd6dc640bc4277dc57b37697a Mon Sep 17 00:00:00 2001 From: faildeny Date: Thu, 5 Feb 2026 18:00:58 +0100 Subject: [PATCH 19/29] Add threshold fine tuning on validation set for all models --- config.yaml | 2 +- flcore/metrics.py | 26 +++++++-- flcore/models/linear_models/client.py | 21 +++++--- flcore/models/random_forest/client.py | 39 +++++++++++--- flcore/models/xgb/client.py | 58 +++++++++++++++----- flcore/models/xgb/cnn.py | 76 +++++++++++++++------------ 6 files changed, 158 insertions(+), 64 deletions(-) diff --git a/config.yaml b/config.yaml index d17bc02..917fdc1 100644 --- a/config.yaml +++ b/config.yaml @@ -77,7 +77,7 @@ data_preprocessing_method: "equal_aggregate" # data_preprocessing_method: "reference" # Toggle data normalization (Standard scaler) based on largest center (global) or local client -data_normalization: "local" +data_normalization: "global" # Determine target for feature selection number n_features: Null diff --git a/flcore/metrics.py b/flcore/metrics.py index ad33faf..9bbcb89 100644 --- a/flcore/metrics.py +++ b/flcore/metrics.py @@ -67,12 +67,18 @@ def get_metrics_collection(task_type="binary", device="cpu", threshold=0.5): def calculate_metrics(y_true, y_pred_proba, task_type="binary", threshold=0.5): metrics_collection = get_metrics_collection(task_type, threshold=threshold) if not torch.is_tensor(y_true): - y_true = torch.tensor(y_true.tolist()) + if isinstance(y_true, list): + y_true = torch.cat(y_true) + else: + y_true = torch.tensor(y_true.tolist()) if not torch.is_tensor(y_pred_proba): - y_pred_proba = torch.tensor(y_pred_proba.tolist()) + if isinstance(y_pred_proba, list): + y_pred_proba = torch.cat(y_pred_proba) + else: + y_pred_proba = torch.tensor(y_pred_proba.tolist()) # Extract probabilities for the positive class if shape>1 - if y_pred_proba.ndim > 1: + if y_pred_proba.ndim > 1 and y_pred_proba.shape[1] > 1: y_pred_proba = y_pred_proba[:, 1] metrics_collection.update(y_pred_proba, y_true) @@ -97,4 +103,16 @@ def metrics_aggregation_fn(distributed_metrics): metrics['per client n samples'] = [res[0] for res in distributed_metrics] - return metrics \ No newline at end of file + return metrics + +def find_best_threshold(y_true, y_pred_proba, metric="balanced_accuracy"): + best_threshold = 0.5 + best_metric_value = 0.0 + + for threshold in np.arange(0.0, 1.01, 0.01): + metrics = calculate_metrics(y_true, y_pred_proba, threshold=threshold) + if metrics[metric] > best_metric_value: + best_metric_value = metrics[metric] + best_threshold = threshold + + return best_threshold diff --git a/flcore/models/linear_models/client.py b/flcore/models/linear_models/client.py index e73fb87..06a6eed 100644 --- a/flcore/models/linear_models/client.py +++ b/flcore/models/linear_models/client.py @@ -9,7 +9,7 @@ import flwr as fl from sklearn.metrics import log_loss from flcore.performance import measurements_metrics, get_metrics -from flcore.metrics import calculate_metrics +from flcore.metrics import calculate_metrics, find_best_threshold import time import pandas as pd from sklearn.preprocessing import StandardScaler @@ -86,11 +86,17 @@ def fit(self, parameters, config): # type: ignore local_model = utils.get_model(self.model_name, local=True) # utils.set_initial_params(local_model,self.n_features) local_model.fit(self.X_train, self.y_train) + # Calculate validation set metrics + if self.model_name == 'lsvc': + y_pred_proba = self.model.decision_function(self.X_val) + else: + y_pred_proba = self.model.predict_proba(self.X_val) + best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") if self.model_name == 'lsvc': y_pred_proba = self.model.decision_function(self.X_test) else: y_pred_proba = self.model.predict_proba(self.X_test) - local_metrics = calculate_metrics(self.y_test, y_pred_proba) + local_metrics = calculate_metrics(self.y_test, y_pred_proba, threshold=best_threshold) #Add 'local' to the metrics to identify them local_metrics = {f"local {key}": local_metrics[key] for key in local_metrics} metrics.update(local_metrics) @@ -106,7 +112,8 @@ def evaluate(self, parameters, config): # type: ignore y_pred_proba = self.model.decision_function(self.X_val) else: y_pred_proba = self.model.predict_proba(self.X_val) - val_metrics = calculate_metrics(self.y_val, y_pred_proba) + best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") + val_metrics = calculate_metrics(self.y_val, y_pred_proba, threshold=best_threshold) if self.model_name == 'lsvc': y_pred_proba = self.model.decision_function(self.X_test) @@ -119,13 +126,13 @@ def evaluate(self, parameters, config): # type: ignore else: loss = log_loss(self.y_test, self.model.predict_proba(self.X_test), labels=[0, 1]) - metrics = calculate_metrics(self.y_test, y_pred_proba) + metrics = calculate_metrics(self.y_test, y_pred_proba, threshold=best_threshold) + metrics_not_tuned = calculate_metrics(self.y_test, y_pred_proba, threshold=0.5) + metrics_not_tuned = {f"not tuned {key}": metrics_not_tuned[key] for key in metrics_not_tuned} + metrics.update(metrics_not_tuned) metrics["round_time [s]"] = self.round_time metrics["client_id"] = self.client_id - print(f"Client {self.client_id} Evaluation after aggregated model: {metrics['balanced_accuracy']}") - - # Add validation metrics to the evaluation metrics with a prefix val_metrics = {f"val {key}": val_metrics[key] for key in val_metrics} metrics.update(val_metrics) diff --git a/flcore/models/random_forest/client.py b/flcore/models/random_forest/client.py index ef1e758..307096c 100644 --- a/flcore/models/random_forest/client.py +++ b/flcore/models/random_forest/client.py @@ -7,7 +7,7 @@ from flcore.serialization_funs import serialize_RF, deserialize_RF import flcore.models.random_forest.utils as utils from flcore.performance import measurements_metrics -from flcore.metrics import calculate_metrics +from flcore.metrics import calculate_metrics, find_best_threshold from flwr.common import ( Code, EvaluateIns, @@ -32,7 +32,9 @@ def __init__(self, data,client_id,config): self.splits_nested = datasets.split_partitions(n_folds_out,0.2, seed, self.X_train, self.y_train) self.bal_RF = True if config['model'] == 'balanced_random_forest' else False self.model = utils.get_model(self.bal_RF, config['random_forest']['tree_num']) - self.round_time = 0 + self.round_time = 0 + self.tree_num = config['random_forest']['tree_num'] + self.first_round = True # Setting initial parameters, akin to model.compile for keras models utils.set_initial_params_client(self.model,self.X_train, self.y_train) def get_parameters(self, ins: GetParametersIns): # , config type: ignore @@ -59,9 +61,9 @@ def fit(self, ins: FitIns): # , parameters, config type: ignore warnings.simplefilter("ignore") train_idx, val_idx = next(self.splits_nested) X_train_2 = self.X_train.iloc[train_idx, :] - X_val = self.X_train.iloc[val_idx,:] + self.X_val = self.X_train.iloc[val_idx,:] y_train_2 = self.y_train.iloc[train_idx] - y_val = self.y_train.iloc[val_idx] + self.y_val = self.y_train.iloc[val_idx] #To implement the center dropout, we need the execution time start_time = time.time() self.model.fit(X_train_2, y_train_2) @@ -69,8 +71,8 @@ def fit(self, ins: FitIns): # , parameters, config type: ignore #accuracy = model.score( X_test, y_test ) # accuracy,specificity,sensitivity,balanced_accuracy, precision, F1_score = \ # measurements_metrics(self.model,X_val, y_val) - y_pred_proba = self.model.predict_proba(X_val) - metrics = calculate_metrics(y_val, y_pred_proba) + y_pred_proba = self.model.predict_proba(self.X_val) + metrics = calculate_metrics(self.y_val, y_pred_proba) # print(f"Accuracy client in fit: {accuracy}") # print(f"Sensitivity client in fit: {sensitivity}") # print(f"Specificity client in fit: {specificity}") @@ -85,6 +87,21 @@ def fit(self, ins: FitIns): # , parameters, config type: ignore print(f"Training finished for round {ins.config['server_round']}") + if self.first_round: + local_model = utils.get_model(self.bal_RF, self.tree_num) + # utils.set_initial_params(local_model,self.n_features) + local_model.fit(self.X_train, self.y_train) + + y_pred_proba = self.model.predict_proba(self.X_val) + best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") + + y_pred_proba = local_model.predict_proba(self.X_test) + local_metrics = calculate_metrics(self.y_test, y_pred_proba, threshold=best_threshold) + #Add 'local' to the metrics to identify them + local_metrics = {f"local {key}": local_metrics[key] for key in local_metrics} + metrics.update(local_metrics) + self.first_round = False + # Serialize to send it to the server params = utils.get_model_parameters(self.model) parameters_updated = serialize_RF(params) @@ -104,12 +121,20 @@ def evaluate(self, ins: EvaluateIns): # , parameters, config type: ignore #Deserialize to get the real parameters parameters = deserialize_RF(parameters) utils.set_model_params(self.model, parameters) + # Get threshold based on validation set + y_pred_proba = self.model.predict_proba(self.X_val) + best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") + # Get validation metrics + val_metrics = calculate_metrics(self.y_val, y_pred_proba, threshold=best_threshold) + val_metrics = {f"val {key}": val_metrics[key] for key in val_metrics} + y_pred_prob = self.model.predict_proba(self.X_test) loss = log_loss(self.y_test, y_pred_prob) # accuracy,specificity,sensitivity,balanced_accuracy, precision, F1_score = \ # measurements_metrics(self.model,self.X_test, self.y_test) # y_pred = self.model.predict(self.X_test) - metrics = calculate_metrics(self.y_test, y_pred_prob) + metrics = calculate_metrics(self.y_test, y_pred_prob, threshold=best_threshold) + metrics.update(val_metrics) metrics["round_time [s]"] = self.round_time metrics["client_id"] = self.client_id # print(f"Accuracy client in evaluate: {accuracy}") diff --git a/flcore/models/xgb/client.py b/flcore/models/xgb/client.py index d2e358e..4e9f492 100644 --- a/flcore/models/xgb/client.py +++ b/flcore/models/xgb/client.py @@ -22,6 +22,7 @@ from flwr.common.typing import Parameters from torch.utils.data import DataLoader from xgboost import XGBClassifier, XGBRegressor +from sklearn.model_selection import KFold, StratifiedShuffleSplit, train_test_split from flcore.models.xgb.cnn import CNN, test, train from flcore.models.xgb.utils import ( @@ -34,14 +35,15 @@ tree_encoding_loader, train_test ) +from flcore.metrics import calculate_metrics, find_best_threshold + class FL_Client(fl.client.Client): def __init__( self, task_type: str, - trainloader: DataLoader, - valloader: DataLoader, + data, client_tree_num: int, client_num: int, cid: str, @@ -52,9 +54,6 @@ def __init__( """ self.task_type = task_type self.cid = cid - self.tree = construct_tree_from_loader(trainloader, client_tree_num, task_type) - self.trainloader_original = trainloader - self.valloader_original = valloader self.trainloader = None self.valloader = None self.client_tree_num = client_tree_num @@ -66,13 +65,25 @@ def __init__( "task_type": self.task_type, } self.tmp_dir = "" - # instantiate model self.net = CNN(client_num=client_num, client_tree_num=client_tree_num) - # determine device self.device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") self.round_time = -1 + self.first_round = True + batch_size = "whole" + + (self.X_train, self.y_train), (self.X_test, self.y_test) = data + + self.X_train, self.X_val, self.y_train, self.y_val = train_test_split(self.X_train, self.y_train, test_size=0.2, random_state=42, stratify=self.y_train) + + trainset = TreeDataset(np.array(self.X_train, copy=True), np.array(self.y_train, copy=True)) + valset = TreeDataset(np.array(self.X_val, copy=True), np.array(self.y_val, copy=True)) + testset = TreeDataset(np.array(self.X_test, copy=True), np.array(self.y_test, copy=True)) + self.trainloader_original = get_dataloader(trainset, "train", batch_size) + self.valloader_original = get_dataloader(valset, "test", batch_size) + self.testloader_original = get_dataloader(testset, "test", batch_size) + self.tree = construct_tree_from_loader(self.trainloader_original, client_tree_num, task_type) def get_properties(self, ins: GetPropertiesIns) -> GetPropertiesRes: return GetPropertiesRes(properties=self.properties) @@ -126,7 +137,7 @@ def fit(self, fit_params: FitIns) -> FitRes: else: print("Client " + self.cid + ": only had its own tree") - # Don't prepare dataloaders if they number of clients didn't change + # Don't prepare dataloaders if their number of clients didn't change # if type(aggregated_trees) is list and len(aggregated_trees) != self.client_num or self.trainloader is None: self.trainloader = tree_encoding_loader( @@ -143,6 +154,13 @@ def fit(self, fit_params: FitIns) -> FitRes: self.client_tree_num, self.client_num, ) + self.testloader = tree_encoding_loader( + self.testloader_original, + batch_size, + aggregated_trees, + self.client_tree_num, + self.client_num, + ) # else: # print("Client " + self.cid + ": reusing existing dataloaders") @@ -166,6 +184,22 @@ def fit(self, fit_params: FitIns) -> FitRes: ) self.round_time = (time.time() - start_time) + metrics = {} + + if self.first_round: + #Get best threshold based on validation set + y_pred_proba_val = self.tree.predict_proba(self.X_val, device=self.device) + best_threshold = find_best_threshold(self.y_val, y_pred_proba_val, metric="balanced_accuracy") + y_pred_proba = self.tree.predict_proba(self.X_test) + local_metrics = calculate_metrics(self.y_test, y_pred_proba, threshold=best_threshold) + #Add 'local' to the metrics to identify them + local_metrics = {f"local {key}": local_metrics[key] for key in local_metrics} + metrics.update(local_metrics) + self.first_round = False + + metrics.update({ + "running_time": self.round_time, + "train_loss": train_loss}) # Return training information: model, number of examples processed and metrics if self.task_type == "BINARY": @@ -174,7 +208,7 @@ def fit(self, fit_params: FitIns) -> FitRes: # parameters=self.get_parameters(fit_params.config), parameters=self.get_parameters(fit_params.config).parameters, num_examples=num_examples, - metrics={"loss": train_loss, "accuracy": train_result, "running_time":self.round_time}, + metrics=metrics, ) elif self.task_type == "REG": return FitRes( @@ -200,8 +234,9 @@ def evaluate(self, eval_params: EvaluateIns) -> EvaluateRes: loss, result, num_examples = test( self.task_type, self.net, - self.valloader, + self.testloader, device=self.device, + valloader=self.valloader, log_progress=self.log_progress, ) @@ -270,8 +305,7 @@ def get_client(config, data, client_id) -> fl.client.Client: client = FL_Client( task_type, - trainloader, - valloader, + data, client_tree_num, client_num, cid, diff --git a/flcore/models/xgb/cnn.py b/flcore/models/xgb/cnn.py index 849efc3..3a5331b 100644 --- a/flcore/models/xgb/cnn.py +++ b/flcore/models/xgb/cnn.py @@ -13,7 +13,7 @@ from sklearn.metrics import accuracy_score, mean_squared_error from torch.utils.data import DataLoader from torchmetrics import Accuracy, MeanSquaredError -from flcore.metrics import get_metrics_collection +from flcore.metrics import calculate_metrics, find_best_threshold from tqdm import tqdm @@ -147,6 +147,7 @@ def test( net: CNN, testloader: DataLoader, device: torch.device, + valloader: DataLoader = None, log_progress: bool = True, ) -> Tuple[float, float, int]: """Evaluates the network on test data.""" @@ -157,39 +158,48 @@ def test( elif task_type == "REG": criterion = nn.MSELoss() - total_loss, total_result, n_samples = 0.0, 0.0, 0 - metrics = get_metrics_collection() net.eval() - with torch.no_grad(): - pbar = tqdm(testloader, desc="TEST") if log_progress else testloader - for data in pbar: - tree_outputs, labels = data[0].to(device), data[1].to(device) - outputs = net(tree_outputs) - - # Collected testing loss and accuracy statistics - total_loss += criterion(outputs, labels).item() - n_samples += labels.size(0) - num_classes = np.unique(labels.cpu().numpy()).size - - y_pred = outputs.cpu() - y_true = labels.cpu() - metrics.update(y_pred, y_true) - - # if task_type == "BINARY" or task_type == "MULTICLASS": - # if task_type == "MULTICLASS": - # raise NotImplementedError() - - # # acc = Accuracy(task=task_type.lower())( - # # outputs.cpu(), labels.type(torch.int).cpu()) - # # total_result += acc * labels.size(0) - # elif task_type == "REG": - # mse = MeanSquaredError()(outputs.cpu(), labels.type(torch.int).cpu()) - # total_result += mse * labels.size(0) - - metrics = metrics.compute() - metrics = {k: v.item() for k, v in metrics.items()} - - # total_result = total_result.item() + + # Collect predictions and true labels for the entire test set, to compute metrics at the end of the epoch + + def get_pred_proba(dataloader): + y_pred_list = [] + y_true_list = [] + total_loss, total_result, n_samples = 0.0, 0.0, 0 + with torch.no_grad(): + pbar = tqdm(dataloader, desc="TEST") if log_progress else dataloader + for data in pbar: + tree_outputs, labels = data[0].to(device), data[1].to(device) + outputs = net(tree_outputs) + # Collected testing loss and accuracy statistics + total_loss += criterion(outputs, labels).item() + n_samples += labels.size(0) + num_classes = np.unique(labels.cpu().numpy()).size + + y_pred = outputs.cpu() + y_true = labels.cpu() + y_pred_list.append(y_pred) + y_true_list.append(y_true) + + return y_true_list, y_pred_list, total_loss, n_samples + + metrics = {} + if valloader is not None: + y_true_val, y_pred_proba_val, val_loss, val_n_samples = get_pred_proba(valloader) + best_threshold = find_best_threshold(y_true_val, y_pred_proba_val, metric="balanced_accuracy") + metrics_val = calculate_metrics(y_true_val, y_pred_proba_val, task_type=task_type, threshold=best_threshold) + metrics_val = {f"val {key}": metrics_val[key] for key in metrics_val} + metrics.update(metrics_val) + else: + best_threshold = 0.5 + + # Add validation metrics to the evaluation metrics with a prefix + y_true, y_pred_proba, total_loss, n_samples = get_pred_proba(testloader) + metrics_test = calculate_metrics(y_true, y_pred_proba, task_type=task_type, threshold=best_threshold) + metrics_not_tuned = calculate_metrics(y_true, y_pred_proba, task_type=task_type, threshold=0.5) + metrics_not_tuned = {f"not tuned {key}": metrics_not_tuned[key] for key in metrics_not_tuned} + metrics.update(metrics_test) + metrics.update(metrics_not_tuned) if log_progress: print("\n") From 67f20da50e3ea28c3edd83941f9ed8e02b926293 Mon Sep 17 00:00:00 2001 From: faildeny Date: Thu, 5 Feb 2026 18:01:30 +0100 Subject: [PATCH 20/29] Fix missing metrics from XGBoost model in distributed fit --- flcore/models/xgb/server.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/flcore/models/xgb/server.py b/flcore/models/xgb/server.py index 046fc2d..4b5a748 100644 --- a/flcore/models/xgb/server.py +++ b/flcore/models/xgb/server.py @@ -98,10 +98,13 @@ def fit(self, num_rounds: int, timeout: Optional[float]) -> History: for current_round in range(1, num_rounds + 1): # Train model and replace previous global model res_fit = self.fit_round(server_round=current_round, timeout=timeout) - if res_fit: - parameters_prime, _, _ = res_fit # fit_metrics_aggregated + if res_fit is not None: + parameters_prime, fit_metrics, _ = res_fit # fit_metrics_aggregated if parameters_prime: self.parameters = parameters_prime + history.add_metrics_distributed_fit( + server_round=current_round, metrics=fit_metrics + ) # Evaluate model using strategy implementation res_cen = self.strategy.evaluate(current_round, parameters=self.parameters) From 183ab758b5f41beb875327cfdb38f1455e73d67e Mon Sep 17 00:00:00 2001 From: faildeny Date: Mon, 9 Feb 2026 12:00:07 +0100 Subject: [PATCH 21/29] Fix xgb training with device argument --- flcore/models/xgb/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flcore/models/xgb/client.py b/flcore/models/xgb/client.py index 4e9f492..fc9ae6b 100644 --- a/flcore/models/xgb/client.py +++ b/flcore/models/xgb/client.py @@ -188,7 +188,7 @@ def fit(self, fit_params: FitIns) -> FitRes: if self.first_round: #Get best threshold based on validation set - y_pred_proba_val = self.tree.predict_proba(self.X_val, device=self.device) + y_pred_proba_val = self.tree.predict_proba(self.X_val) best_threshold = find_best_threshold(self.y_val, y_pred_proba_val, metric="balanced_accuracy") y_pred_proba = self.tree.predict_proba(self.X_test) local_metrics = calculate_metrics(self.y_test, y_pred_proba, threshold=best_threshold) From d594349b87535645737045afee76a9f66dd6a504 Mon Sep 17 00:00:00 2001 From: faildeny Date: Mon, 9 Feb 2026 16:12:20 +0100 Subject: [PATCH 22/29] Add usage of seed from config for reproducibility --- flcore/compile_results.py | 23 +++++++++++++++-------- flcore/datasets.py | 8 ++------ flcore/models/linear_models/client.py | 10 +++++++--- flcore/models/random_forest/client.py | 22 +++++----------------- flcore/models/random_forest/server.py | 1 + flcore/models/xgb/client.py | 6 ++++-- server.py | 10 +++++----- 7 files changed, 39 insertions(+), 41 deletions(-) diff --git a/flcore/compile_results.py b/flcore/compile_results.py index 4e7f0c3..4f8d7b8 100644 --- a/flcore/compile_results.py +++ b/flcore/compile_results.py @@ -8,6 +8,7 @@ def compile_results(experiment_dir: str): + print(f"Compiling results for experiment in {experiment_dir}") per_client_metrics = {} held_out_metrics = {} fit_metrics = {} @@ -49,9 +50,11 @@ def compile_results(experiment_dir: str): # Read history.yaml history = yaml.safe_load(open(os.path.join(fold_dir, "history.yaml"), "r")) - # selection_metric = 'val '+ config['checkpoint_selection_metric'] - selection_metric = config['checkpoint_selection_metric'] + selection_metric = 'val '+ config['checkpoint_selection_metric'] + # selection_metric = config['checkpoint_selection_metric'] best_round= int(np.argmax(history['metrics_distributed'][selection_metric])) + # best_round = -1 + print(f"Best round for {directory} based on {selection_metric}: {best_round}") # client_order = history['metrics_distributed']['per client client_id'][best_round] client_order = history['metrics_distributed']['per client n samples'][best_round] for logs in history.keys(): @@ -128,12 +131,16 @@ def compile_results(experiment_dir: str): writer.write(f"\n{'Federated finetuned locally:'} \n") personalized_section = True - # Calculate general mean and std - mean = np.average(per_client_metrics[metric]) - # Calculate std of the average metric between experiment runs - std = np.std(np.mean(per_client_metrics[metric], axis=1)) - per_client_mean = np.around(np.mean(per_client_metrics[metric], axis=0), 3) - per_client_std = np.around(np.std(per_client_metrics[metric], axis=0), 3) + # Calculate general weighted mean and std + # Weighted by number of samples in each client + weights = np.array(per_client_metrics['n samples'][0]) + per_client_mean = np.mean(per_client_metrics[metric], axis=0) + per_client_std = np.std(per_client_metrics[metric], axis=0) + mean = np.average(per_client_mean, weights=weights) + std = np.sqrt(np.average((per_client_mean - mean) ** 2, weights=weights)) + # Round per client mean and std to 3 decimals + per_client_mean = np.around(per_client_mean, 3) + per_client_std = np.around(per_client_std, 3) if metric not in execution_stats: writer.write(f"{metric:<30}: {mean:<6.3f} ±{std:<6.3f} \t\t\t|| Per client {metric} {per_client_mean} ({per_client_std})\n".replace("\n", "")+"\n") for i, _ in enumerate(per_client_mean): diff --git a/flcore/datasets.py b/flcore/datasets.py index 3d699b6..d0c16ed 100644 --- a/flcore/datasets.py +++ b/flcore/datasets.py @@ -482,7 +482,7 @@ def prepare_dataset(X, y, center_id, config, center_indices=None): feature_selection_method = config.get("feature_selection_method", "mutual_info") normalization_method = config.get("data_normalization", "global") - np.random.seed(42) + np.random.seed(42) # For reproducibility of partitioning and reference selection # Convert target to binary classification if needed if y.nunique() > 2: @@ -563,7 +563,7 @@ def prepare_dataset(X, y, center_id, config, center_indices=None): # Split into train/test for this center if len(X_center) > 1: X_train, X_test, y_train, y_test = train_test_split( - X_center, y_center, test_size=0.2, random_state=42, stratify=y_center + X_center, y_center, test_size=0.2, random_state=config['seed'], stratify=y_center ) else: X_train, y_train = X_center, y_center @@ -573,9 +573,6 @@ def prepare_dataset(X, y, center_id, config, center_indices=None): X_train_processed, feature_names = apply_preprocessing(X_train, global_preprocessing_params, normalization=normalization_method) X_test_processed, _ = apply_preprocessing(X_test, global_preprocessing_params, normalization=normalization_method) - # shuffle the training data - X_train_processed, y_train = shuffle(X_train_processed, y_train) - return X_train_processed, y_train, X_test_processed, y_test def load_mnist(center_id=None, num_splits=5): @@ -711,7 +708,6 @@ def load_kaggle_hf(data_path, center_id, config) -> Dataset: elif center_id == 3: center_id_mapped = 3 # switzerland else: - # print(f"Invalid center id: {center_id}", type(center_id)) raise ValueError(f"Invalid center id: {center_id}") # Create center_indices diff --git a/flcore/models/linear_models/client.py b/flcore/models/linear_models/client.py index 06a6eed..b0dd88b 100644 --- a/flcore/models/linear_models/client.py +++ b/flcore/models/linear_models/client.py @@ -25,7 +25,7 @@ def __init__(self, data,client_id,config): (self.X_train, self.y_train), (self.X_test, self.y_test) = data # Create train and validation split - self.X_train, self.X_val, self.y_train, self.y_val = train_test_split(self.X_train, self.y_train, test_size=0.2, random_state=42, stratify=self.y_train) + self.X_train, self.X_val, self.y_train, self.y_val = train_test_split(self.X_train, self.y_train, test_size=0.2, random_state=config['seed'], stratify=self.y_train) # #Only use the standardScaler to the continous variables # scaled_features_train = StandardScaler().fit_transform(self.X_train.values) @@ -68,12 +68,16 @@ def fit(self, parameters, config): # type: ignore # self.model.fit(self.X_train.loc[:, parameters[2].astype(bool)], self.y_train) # y_pred = self.model.predict(self.X_test.loc[:, parameters[2].astype(bool)]) # If LSVC is used, use decision_function instead of predict_proba + if self.model_name == 'lsvc': + y_pred_proba = self.model.decision_function(self.X_val) + else: + y_pred_proba = self.model.predict_proba(self.X_val) + best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") if self.model_name == 'lsvc': y_pred_proba = self.model.decision_function(self.X_test) else: y_pred_proba = self.model.predict_proba(self.X_test) - metrics = calculate_metrics(self.y_test, y_pred_proba) - print(f"Client {self.client_id} Evaluation just after local training: {metrics['balanced_accuracy']}") + metrics = calculate_metrics(self.y_test, y_pred_proba, threshold=best_threshold) # Add 'personalized' to the metrics to identify them metrics = {f"personalized {key}": metrics[key] for key in metrics} self.round_time = (time.time() - start_time) diff --git a/flcore/models/random_forest/client.py b/flcore/models/random_forest/client.py index 307096c..e53984b 100644 --- a/flcore/models/random_forest/client.py +++ b/flcore/models/random_forest/client.py @@ -26,10 +26,9 @@ class MnistClient(fl.client.Client): def __init__(self, data,client_id,config): self.client_id = client_id n_folds_out= config['num_rounds'] - seed=42 # Load data (self.X_train, self.y_train), (self.X_test, self.y_test) = data - self.splits_nested = datasets.split_partitions(n_folds_out,0.2, seed, self.X_train, self.y_train) + self.splits_nested = datasets.split_partitions(n_folds_out,0.2, config['seed'], self.X_train, self.y_train) self.bal_RF = True if config['model'] == 'balanced_random_forest' else False self.model = utils.get_model(self.bal_RF, config['random_forest']['tree_num']) self.round_time = 0 @@ -60,37 +59,26 @@ def fit(self, ins: FitIns): # , parameters, config type: ignore with warnings.catch_warnings(): warnings.simplefilter("ignore") train_idx, val_idx = next(self.splits_nested) - X_train_2 = self.X_train.iloc[train_idx, :] + self.X_train_2 = self.X_train.iloc[train_idx, :] self.X_val = self.X_train.iloc[val_idx,:] - y_train_2 = self.y_train.iloc[train_idx] + self.y_train_2 = self.y_train.iloc[train_idx] self.y_val = self.y_train.iloc[val_idx] #To implement the center dropout, we need the execution time start_time = time.time() - self.model.fit(X_train_2, y_train_2) + self.model.fit(self.X_train_2, self.y_train_2) elapsed_time = (time.time() - start_time) - #accuracy = model.score( X_test, y_test ) - # accuracy,specificity,sensitivity,balanced_accuracy, precision, F1_score = \ - # measurements_metrics(self.model,X_val, y_val) y_pred_proba = self.model.predict_proba(self.X_val) metrics = calculate_metrics(self.y_val, y_pred_proba) - # print(f"Accuracy client in fit: {accuracy}") - # print(f"Sensitivity client in fit: {sensitivity}") - # print(f"Specificity client in fit: {specificity}") - # print(f"Balanced_accuracy in fit: {balanced_accuracy}") - # print(f"precision in fit: {precision}") - # print(f"F1_score in fit: {F1_score}") metrics["running_time"] = elapsed_time self.round_time = elapsed_time - print(f"num_client {self.client_id} has an elapsed time {elapsed_time}") - print(f"Training finished for round {ins.config['server_round']}") if self.first_round: local_model = utils.get_model(self.bal_RF, self.tree_num) # utils.set_initial_params(local_model,self.n_features) - local_model.fit(self.X_train, self.y_train) + local_model.fit(self.X_train_2, self.y_train_2) y_pred_proba = self.model.predict_proba(self.X_val) best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") diff --git a/flcore/models/random_forest/server.py b/flcore/models/random_forest/server.py index e863a52..97a1373 100644 --- a/flcore/models/random_forest/server.py +++ b/flcore/models/random_forest/server.py @@ -46,6 +46,7 @@ def get_server_and_strategy(config): min_evaluate_clients = config['num_clients'], #enable evaluate_fn if we have data to evaluate in the server #evaluate_fn = utils_RF.get_evaluate_fn( model ), #no data in server + fit_metrics_aggregation_fn=metrics_aggregation_fn, evaluate_metrics_aggregation_fn = metrics_aggregation_fn, on_fit_config_fn = fit_round ) diff --git a/flcore/models/xgb/client.py b/flcore/models/xgb/client.py index fc9ae6b..515f94b 100644 --- a/flcore/models/xgb/client.py +++ b/flcore/models/xgb/client.py @@ -47,6 +47,7 @@ def __init__( client_tree_num: int, client_num: int, cid: str, + config, log_progress: bool = False, ): """ @@ -75,7 +76,7 @@ def __init__( (self.X_train, self.y_train), (self.X_test, self.y_test) = data - self.X_train, self.X_val, self.y_train, self.y_val = train_test_split(self.X_train, self.y_train, test_size=0.2, random_state=42, stratify=self.y_train) + self.X_train, self.X_val, self.y_train, self.y_val = train_test_split(self.X_train, self.y_train, test_size=0.2, random_state=config['seed'], stratify=self.y_train) trainset = TreeDataset(np.array(self.X_train, copy=True), np.array(self.y_train, copy=True)) valset = TreeDataset(np.array(self.X_val, copy=True), np.array(self.y_val, copy=True)) @@ -309,6 +310,7 @@ def get_client(config, data, client_id) -> fl.client.Client: client_tree_num, client_num, cid, - log_progress=False, + config, + log_progress=False ) return client diff --git a/server.py b/server.py index 5149c1e..24f5ec6 100644 --- a/server.py +++ b/server.py @@ -93,7 +93,7 @@ def check_config(config): # filename = os.path.join( checkpoint_dir, 'final_model.pt' ) # joblib.dump(model, filename) # Save the history as a yaml file - print(history) + # print(history) with open(experiment_dir / "metrics.txt", "w") as f: f.write(f"Results of the experiment {config['experiment']['name']}\n") f.write(f"Model: {config['model']}\n") @@ -101,12 +101,12 @@ def check_config(config): f.write(f"Number of clients: {config['num_clients']}\n") # selection_metric = 'val ' + config['checkpoint_selection_metric'] - selection_metric = config['checkpoint_selection_metric'] + selection_metric = "val " + config['checkpoint_selection_metric'] # Get index of tuple of the best round - # best_round = int(numpy.argmax([round[1] for round in history.metrics_distributed[selection_metric]])) + best_round = int(numpy.argmax([round[1] for round in history.metrics_distributed[selection_metric]])) # Use the last round as final checkpoint, since no validation set is used - best_round = -1 - print(history) + # best_round = -1 + # print(history) # check if history has attribute metrics_distributed_fit if hasattr(history, 'metrics_distributed_fit') and 'training_time [s]' in history.metrics_distributed_fit: # check if training_time is in metrics_distributed_fit From 3aca0546ce5a5999bcdfe78a2fa6f4e2850fefba Mon Sep 17 00:00:00 2001 From: faildeny Date: Tue, 10 Feb 2026 11:48:08 +0100 Subject: [PATCH 23/29] Update benchmarking parameters --- benchmark.py | 33 ++- plots.ipynb | 702 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 628 insertions(+), 107 deletions(-) diff --git a/benchmark.py b/benchmark.py index 3f78ef0..f5e8dc9 100644 --- a/benchmark.py +++ b/benchmark.py @@ -51,15 +51,32 @@ # dirichlet_alpha = [0.7, None] # data_normalization = ["global", "local", None] -# Feature selection experiment -experiment_name = "feature_selection" -benchmark_dir = "benchmark_results_feature_selection" -model_names = ["balanced_random_forest"] -datasets = ["ukbb_cvd"] -num_clients = [5,10] -dirichlet_alpha = [0.7, None] +# # Feature selection experiment +# experiment_name = "feature_selection" +# benchmark_dir = "benchmark_results_feature_selection" +# model_names = ["balanced_random_forest"] +# datasets = ["ukbb_cvd"] +# num_clients = [5,10] +# dirichlet_alpha = [0.7, None] +# data_normalization = ["global"] +# n_features = [10, 20, 35, 40, None] + +# # Number of Clients ablation experiment +experiment_name = "num_clients_ablation" +benchmark_dir = "benchmark_results_num_clients_ablation" +model_names = [ + "logistic_regression", + "elastic_net", + "lsvc", + "random_forest", + "balanced_random_forest", + "xgb" + ] +datasets = ["diabetes"] +num_clients = [3,5,10,20] +dirichlet_alpha = [0.7, 1.0, None] data_normalization = ["global"] -n_features = [10, 20, 35, 40, None] +n_features = [None] os.makedirs(benchmark_dir, exist_ok=True) diff --git a/plots.ipynb b/plots.ipynb index 7078efd..a5c008d 100644 --- a/plots.ipynb +++ b/plots.ipynb @@ -10,7 +10,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "9f05d536", "metadata": {}, "outputs": [], @@ -20,15 +20,12 @@ "\n", "logs_dir = \"logs\"\n", "logs_dir = \"benchmark_results\"\n", - "\n", "experiment_name = \"experiment_1percent\"\n", "# experiment_name = \"experiment_good\"\n", "# experiment_name = \"experiment_small\"\n", "dataset_name = \"diabetes\"\n", "# dataset_name = \"kaggle_hf\"\n", - "\n", "results_file = \"per_center_results.csv\"\n", - "\n", "keywords = [\n", " experiment_name,\n", " dataset_name,\n", @@ -40,16 +37,6 @@ " \"aNone\"\n", " ]\n", "\n", - "# Normalization experiment\n", - "experiment_name = \"normalization\"\n", - "logs_dir = \"benchmark_results_normalization\"\n", - "model_names = [\"logistic_regression\"]\n", - "datasets = [\"diabetes\"]\n", - "num_clients = [10]\n", - "dirichlet_alpha = [\"None\"]\n", - "data_normalization = [\"global\", \"local\", None]\n", - "keywords = [experiment_name]\n", - "\n", "def load_data(logs_dir, experiment_name, keywords, results_file=\"per_center_results.csv\"):\n", " data = {}\n", "\n", @@ -57,8 +44,8 @@ " dirs = [d for d in os.listdir(logs_dir) if all(keyword in d for keyword in keywords)]\n", " for d in dirs: \n", " model_name = d\n", - " model_name = model_name.replace(experiment_name+\"_\", \"\")\n", - " # model_name = model_name.replace(experiment_name+\"_\"+dataset_name+\"_\", \"\")\n", + " # model_name = model_name.replace(experiment_name+\"_\", \"\")\n", + " model_name = model_name.replace(experiment_name+\"_\"+dataset_name+\"_\", \"\")\n", " model_name = model_name.replace(\"_\", \" \")\n", " model_name = model_name.title()\n", " model_name = model_name.replace(\"none\", \"N\")\n", @@ -97,37 +84,27 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 3, "id": "29bb08b0", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Logistic Regression C10 AN NormN FeatN: 0.6632\n", - "Logistic Regression C10 AN Normglobal FeatN: 0.7546\n", - "Logistic Regression C10 AN Normlocal FeatN: 0.7586\n" - ] - } - ], + "outputs": [], "source": [ - "metric = \"balanced_accuracy\"\n", - "# metric = \"accuracy\"\n", - "results = []\n", - "#print average metric across all centers for each model\n", - "for model_name, df in data.items():\n", - " #weighted average by number of samples in each center\n", - " total_samples = df[\"n samples\"].sum()\n", - " weighted_sum = (df[metric] * df[\"n samples\"]).sum()\n", - " avg_metric = weighted_sum / total_samples\n", - " results.append(f\"{model_name}: {avg_metric:.4f}\")\n", - " # print(f\"{model_name}: {avg_metric:.4f}\")\n", + "# metric = \"balanced_accuracy\"\n", + "# # metric = \"accuracy\"\n", + "# results = []\n", + "# #print average metric across all centers for each model\n", + "# for model_name, df in data.items():\n", + "# #weighted average by number of samples in each center\n", + "# total_samples = df[\"n samples\"].sum()\n", + "# weighted_sum = (df[metric] * df[\"n samples\"]).sum()\n", + "# avg_metric = weighted_sum / total_samples\n", + "# results.append(f\"{model_name}: {avg_metric:.4f}\")\n", + "# # print(f\"{model_name}: {avg_metric:.4f}\")\n", "\n", - "# Sort results alphabetically by model name\n", - "results.sort()\n", - "for result in results:\n", - " print(result)" + "# # Sort results alphabetically by model name\n", + "# results.sort()\n", + "# for result in results:\n", + "# print(result)" ] }, { @@ -191,15 +168,531 @@ "# Box Plots: Number of Clients \n" ] }, + { + "cell_type": "code", + "execution_count": 10, + "id": "07bd40b0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Found 20 experiments\n" + ] + } + ], + "source": [ + "# Feature selection experiment\n", + "# experiment_name = \"experiment_good\"\n", + "# experiment_name = \"experiment_all_10percent\"\n", + "experiment_name = \"num_clients_ablation\"\n", + "benchmark_dir = \"benchmark_results_num_clients_ablation\"\n", + "model_names = [\"balanced_random_forest\"]\n", + "datasets = [\"diabetes\"]\n", + "# num_clients = [5,10]\n", + "dirichlet_alpha = [\"0.7\"]\n", + "# dirichlet_alpha = [\"aNone\"]\n", + "keywords = [experiment_name] + datasets + dirichlet_alpha\n", + "\n", + "data = load_data(benchmark_dir, experiment_name, keywords)" + ] + }, { "cell_type": "code", "execution_count": null, "id": "66277bb4", "metadata": {}, "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Logistic Regression\n", + " model run n_clients alpha \n", + "76 Logistic Regression 0 10 0.7 Normglobal FeatN \\\n", + "77 Logistic Regression 1 10 0.7 Normglobal FeatN \n", + "78 Logistic Regression 2 10 0.7 Normglobal FeatN \n", + "79 Logistic Regression 3 10 0.7 Normglobal FeatN \n", + "80 Logistic Regression 4 10 0.7 Normglobal FeatN \n", + "81 Logistic Regression 5 10 0.7 Normglobal FeatN \n", + "82 Logistic Regression 6 10 0.7 Normglobal FeatN \n", + "83 Logistic Regression 7 10 0.7 Normglobal FeatN \n", + "84 Logistic Regression 8 10 0.7 Normglobal FeatN \n", + "85 Logistic Regression 9 10 0.7 Normglobal FeatN \n", + "86 Logistic Regression 0 20 0.7 Normglobal FeatN \n", + "87 Logistic Regression 1 20 0.7 Normglobal FeatN \n", + "88 Logistic Regression 2 20 0.7 Normglobal FeatN \n", + "89 Logistic Regression 3 20 0.7 Normglobal FeatN \n", + "90 Logistic Regression 4 20 0.7 Normglobal FeatN \n", + "91 Logistic Regression 5 20 0.7 Normglobal FeatN \n", + "92 Logistic Regression 6 20 0.7 Normglobal FeatN \n", + "93 Logistic Regression 7 20 0.7 Normglobal FeatN \n", + "94 Logistic Regression 8 20 0.7 Normglobal FeatN \n", + "95 Logistic Regression 9 20 0.7 Normglobal FeatN \n", + "96 Logistic Regression 10 20 0.7 Normglobal FeatN \n", + "97 Logistic Regression 11 20 0.7 Normglobal FeatN \n", + "98 Logistic Regression 12 20 0.7 Normglobal FeatN \n", + "99 Logistic Regression 13 20 0.7 Normglobal FeatN \n", + "100 Logistic Regression 14 20 0.7 Normglobal FeatN \n", + "101 Logistic Regression 15 20 0.7 Normglobal FeatN \n", + "102 Logistic Regression 16 20 0.7 Normglobal FeatN \n", + "103 Logistic Regression 17 20 0.7 Normglobal FeatN \n", + "104 Logistic Regression 18 20 0.7 Normglobal FeatN \n", + "105 Logistic Regression 19 20 0.7 Normglobal FeatN \n", + "106 Logistic Regression 0 3 0.7 Normglobal FeatN \n", + "107 Logistic Regression 1 3 0.7 Normglobal FeatN \n", + "108 Logistic Regression 2 3 0.7 Normglobal FeatN \n", + "109 Logistic Regression 0 5 0.7 Normglobal FeatN \n", + "110 Logistic Regression 1 5 0.7 Normglobal FeatN \n", + "111 Logistic Regression 2 5 0.7 Normglobal FeatN \n", + "112 Logistic Regression 3 5 0.7 Normglobal FeatN \n", + "113 Logistic Regression 4 5 0.7 Normglobal FeatN \n", + "\n", + " balanced_accuracy \n", + "76 0.759 \n", + "77 0.752 \n", + "78 0.783 \n", + "79 0.741 \n", + "80 0.724 \n", + "81 0.748 \n", + "82 0.742 \n", + "83 0.753 \n", + "84 0.741 \n", + "85 0.741 \n", + "86 0.651 \n", + "87 0.669 \n", + "88 0.755 \n", + "89 0.796 \n", + "90 0.759 \n", + "91 0.749 \n", + "92 0.629 \n", + "93 0.756 \n", + "94 0.758 \n", + "95 0.726 \n", + "96 0.746 \n", + "97 0.741 \n", + "98 0.745 \n", + "99 0.717 \n", + "100 0.728 \n", + "101 0.735 \n", + "102 0.681 \n", + "103 0.742 \n", + "104 0.740 \n", + "105 0.746 \n", + "106 0.739 \n", + "107 0.745 \n", + "108 0.748 \n", + "109 0.738 \n", + "110 0.756 \n", + "111 0.730 \n", + "112 0.741 \n", + "113 0.746 \n", + "Logistic Regression [106 0.739\n", + "107 0.745\n", + "108 0.748\n", + "Name: balanced_accuracy, dtype: float64, 109 0.738\n", + "110 0.756\n", + "111 0.730\n", + "112 0.741\n", + "113 0.746\n", + "Name: balanced_accuracy, dtype: float64, 76 0.759\n", + "77 0.752\n", + "78 0.783\n", + "79 0.741\n", + "80 0.724\n", + "81 0.748\n", + "82 0.742\n", + "83 0.753\n", + "84 0.741\n", + "85 0.741\n", + "Name: balanced_accuracy, dtype: float64, 86 0.651\n", + "87 0.669\n", + "88 0.755\n", + "89 0.796\n", + "90 0.759\n", + "91 0.749\n", + "92 0.629\n", + "93 0.756\n", + "94 0.758\n", + "95 0.726\n", + "96 0.746\n", + "97 0.741\n", + "98 0.745\n", + "99 0.717\n", + "100 0.728\n", + "101 0.735\n", + "102 0.681\n", + "103 0.742\n", + "104 0.740\n", + "105 0.746\n", + "Name: balanced_accuracy, dtype: float64]\n", + "ElasticNet\n", + " model run n_clients alpha balanced_accuracy\n", + "38 ElasticNet 0 10 0.7 Normglobal FeatN 0.765\n", + "39 ElasticNet 1 10 0.7 Normglobal FeatN 0.700\n", + "40 ElasticNet 2 10 0.7 Normglobal FeatN 0.767\n", + "41 ElasticNet 3 10 0.7 Normglobal FeatN 0.745\n", + "42 ElasticNet 4 10 0.7 Normglobal FeatN 0.734\n", + "43 ElasticNet 5 10 0.7 Normglobal FeatN 0.741\n", + "44 ElasticNet 6 10 0.7 Normglobal FeatN 0.747\n", + "45 ElasticNet 7 10 0.7 Normglobal FeatN 0.758\n", + "46 ElasticNet 8 10 0.7 Normglobal FeatN 0.743\n", + "47 ElasticNet 9 10 0.7 Normglobal FeatN 0.743\n", + "48 ElasticNet 0 20 0.7 Normglobal FeatN 0.680\n", + "49 ElasticNet 1 20 0.7 Normglobal FeatN 0.651\n", + "50 ElasticNet 2 20 0.7 Normglobal FeatN 0.755\n", + "51 ElasticNet 3 20 0.7 Normglobal FeatN 0.727\n", + "52 ElasticNet 4 20 0.7 Normglobal FeatN 0.749\n", + "53 ElasticNet 5 20 0.7 Normglobal FeatN 0.742\n", + "54 ElasticNet 6 20 0.7 Normglobal FeatN 0.661\n", + "55 ElasticNet 7 20 0.7 Normglobal FeatN 0.738\n", + "56 ElasticNet 8 20 0.7 Normglobal FeatN 0.749\n", + "57 ElasticNet 9 20 0.7 Normglobal FeatN 0.720\n", + "58 ElasticNet 10 20 0.7 Normglobal FeatN 0.743\n", + "59 ElasticNet 11 20 0.7 Normglobal FeatN 0.736\n", + "60 ElasticNet 12 20 0.7 Normglobal FeatN 0.730\n", + "61 ElasticNet 13 20 0.7 Normglobal FeatN 0.695\n", + "62 ElasticNet 14 20 0.7 Normglobal FeatN 0.718\n", + "63 ElasticNet 15 20 0.7 Normglobal FeatN 0.735\n", + "64 ElasticNet 16 20 0.7 Normglobal FeatN 0.698\n", + "65 ElasticNet 17 20 0.7 Normglobal FeatN 0.732\n", + "66 ElasticNet 18 20 0.7 Normglobal FeatN 0.733\n", + "67 ElasticNet 19 20 0.7 Normglobal FeatN 0.747\n", + "68 ElasticNet 0 3 0.7 Normglobal FeatN 0.743\n", + "69 ElasticNet 1 3 0.7 Normglobal FeatN 0.744\n", + "70 ElasticNet 2 3 0.7 Normglobal FeatN 0.748\n", + "71 ElasticNet 0 5 0.7 Normglobal FeatN 0.741\n", + "72 ElasticNet 1 5 0.7 Normglobal FeatN 0.770\n", + "73 ElasticNet 2 5 0.7 Normglobal FeatN 0.727\n", + "74 ElasticNet 3 5 0.7 Normglobal FeatN 0.742\n", + "75 ElasticNet 4 5 0.7 Normglobal FeatN 0.746\n", + "ElasticNet [68 0.743\n", + "69 0.744\n", + "70 0.748\n", + "Name: balanced_accuracy, dtype: float64, 71 0.741\n", + "72 0.770\n", + "73 0.727\n", + "74 0.742\n", + "75 0.746\n", + "Name: balanced_accuracy, dtype: float64, 38 0.765\n", + "39 0.700\n", + "40 0.767\n", + "41 0.745\n", + "42 0.734\n", + "43 0.741\n", + "44 0.747\n", + "45 0.758\n", + "46 0.743\n", + "47 0.743\n", + "Name: balanced_accuracy, dtype: float64, 48 0.680\n", + "49 0.651\n", + "50 0.755\n", + "51 0.727\n", + "52 0.749\n", + "53 0.742\n", + "54 0.661\n", + "55 0.738\n", + "56 0.749\n", + "57 0.720\n", + "58 0.743\n", + "59 0.736\n", + "60 0.730\n", + "61 0.695\n", + "62 0.718\n", + "63 0.735\n", + "64 0.698\n", + "65 0.732\n", + "66 0.733\n", + "67 0.747\n", + "Name: balanced_accuracy, dtype: float64]\n", + "Linear SVC\n", + " model run n_clients alpha balanced_accuracy\n", + "114 Linear SVC 0 10 0.7 Normglobal FeatN 0.754\n", + "115 Linear SVC 1 10 0.7 Normglobal FeatN 0.738\n", + "116 Linear SVC 2 10 0.7 Normglobal FeatN 0.779\n", + "117 Linear SVC 3 10 0.7 Normglobal FeatN 0.747\n", + "118 Linear SVC 4 10 0.7 Normglobal FeatN 0.750\n", + "119 Linear SVC 5 10 0.7 Normglobal FeatN 0.746\n", + "120 Linear SVC 6 10 0.7 Normglobal FeatN 0.744\n", + "121 Linear SVC 7 10 0.7 Normglobal FeatN 0.757\n", + "122 Linear SVC 8 10 0.7 Normglobal FeatN 0.746\n", + "123 Linear SVC 9 10 0.7 Normglobal FeatN 0.746\n", + "124 Linear SVC 0 20 0.7 Normglobal FeatN 0.641\n", + "125 Linear SVC 1 20 0.7 Normglobal FeatN 0.692\n", + "126 Linear SVC 2 20 0.7 Normglobal FeatN 0.742\n", + "127 Linear SVC 3 20 0.7 Normglobal FeatN 0.779\n", + "128 Linear SVC 4 20 0.7 Normglobal FeatN 0.750\n", + "129 Linear SVC 5 20 0.7 Normglobal FeatN 0.728\n", + "130 Linear SVC 6 20 0.7 Normglobal FeatN 0.592\n", + "131 Linear SVC 7 20 0.7 Normglobal FeatN 0.747\n", + "132 Linear SVC 8 20 0.7 Normglobal FeatN 0.755\n", + "133 Linear SVC 9 20 0.7 Normglobal FeatN 0.725\n", + "134 Linear SVC 10 20 0.7 Normglobal FeatN 0.742\n", + "135 Linear SVC 11 20 0.7 Normglobal FeatN 0.742\n", + "136 Linear SVC 12 20 0.7 Normglobal FeatN 0.764\n", + "137 Linear SVC 13 20 0.7 Normglobal FeatN 0.744\n", + "138 Linear SVC 14 20 0.7 Normglobal FeatN 0.727\n", + "139 Linear SVC 15 20 0.7 Normglobal FeatN 0.732\n", + "140 Linear SVC 16 20 0.7 Normglobal FeatN 0.708\n", + "141 Linear SVC 17 20 0.7 Normglobal FeatN 0.745\n", + "142 Linear SVC 18 20 0.7 Normglobal FeatN 0.741\n", + "143 Linear SVC 19 20 0.7 Normglobal FeatN 0.742\n", + "144 Linear SVC 0 3 0.7 Normglobal FeatN 0.729\n", + "145 Linear SVC 1 3 0.7 Normglobal FeatN 0.750\n", + "146 Linear SVC 2 3 0.7 Normglobal FeatN 0.752\n", + "147 Linear SVC 0 5 0.7 Normglobal FeatN 0.731\n", + "148 Linear SVC 1 5 0.7 Normglobal FeatN 0.768\n", + "149 Linear SVC 2 5 0.7 Normglobal FeatN 0.732\n", + "150 Linear SVC 3 5 0.7 Normglobal FeatN 0.749\n", + "151 Linear SVC 4 5 0.7 Normglobal FeatN 0.750\n", + "Linear SVC [144 0.729\n", + "145 0.750\n", + "146 0.752\n", + "Name: balanced_accuracy, dtype: float64, 147 0.731\n", + "148 0.768\n", + "149 0.732\n", + "150 0.749\n", + "151 0.750\n", + "Name: balanced_accuracy, dtype: float64, 114 0.754\n", + "115 0.738\n", + "116 0.779\n", + "117 0.747\n", + "118 0.750\n", + "119 0.746\n", + "120 0.744\n", + "121 0.757\n", + "122 0.746\n", + "123 0.746\n", + "Name: balanced_accuracy, dtype: float64, 124 0.641\n", + "125 0.692\n", + "126 0.742\n", + "127 0.779\n", + "128 0.750\n", + "129 0.728\n", + "130 0.592\n", + "131 0.747\n", + "132 0.755\n", + "133 0.725\n", + "134 0.742\n", + "135 0.742\n", + "136 0.764\n", + "137 0.744\n", + "138 0.727\n", + "139 0.732\n", + "140 0.708\n", + "141 0.745\n", + "142 0.741\n", + "143 0.742\n", + "Name: balanced_accuracy, dtype: float64]\n", + "Random Forest\n", + " model run n_clients alpha balanced_accuracy\n", + "152 Random Forest 0 10 0.7 Normglobal FeatN 0.771\n", + "153 Random Forest 1 10 0.7 Normglobal FeatN 0.816\n", + "154 Random Forest 2 10 0.7 Normglobal FeatN 0.749\n", + "155 Random Forest 3 10 0.7 Normglobal FeatN 0.743\n", + "156 Random Forest 4 10 0.7 Normglobal FeatN 0.761\n", + "157 Random Forest 5 10 0.7 Normglobal FeatN 0.732\n", + "158 Random Forest 6 10 0.7 Normglobal FeatN 0.750\n", + "159 Random Forest 7 10 0.7 Normglobal FeatN 0.747\n", + "160 Random Forest 8 10 0.7 Normglobal FeatN 0.744\n", + "161 Random Forest 9 10 0.7 Normglobal FeatN 0.747\n", + "162 Random Forest 0 20 0.7 Normglobal FeatN 0.737\n", + "163 Random Forest 1 20 0.7 Normglobal FeatN 0.700\n", + "164 Random Forest 2 20 0.7 Normglobal FeatN 0.742\n", + "165 Random Forest 3 20 0.7 Normglobal FeatN 0.800\n", + "166 Random Forest 4 20 0.7 Normglobal FeatN 0.761\n", + "167 Random Forest 5 20 0.7 Normglobal FeatN 0.762\n", + "168 Random Forest 6 20 0.7 Normglobal FeatN 0.610\n", + "169 Random Forest 7 20 0.7 Normglobal FeatN 0.734\n", + "170 Random Forest 8 20 0.7 Normglobal FeatN 0.756\n", + "171 Random Forest 9 20 0.7 Normglobal FeatN 0.753\n", + "172 Random Forest 10 20 0.7 Normglobal FeatN 0.756\n", + "173 Random Forest 11 20 0.7 Normglobal FeatN 0.737\n", + "174 Random Forest 12 20 0.7 Normglobal FeatN 0.755\n", + "175 Random Forest 13 20 0.7 Normglobal FeatN 0.713\n", + "176 Random Forest 14 20 0.7 Normglobal FeatN 0.717\n", + "177 Random Forest 15 20 0.7 Normglobal FeatN 0.739\n", + "178 Random Forest 16 20 0.7 Normglobal FeatN 0.714\n", + "179 Random Forest 17 20 0.7 Normglobal FeatN 0.746\n", + "180 Random Forest 18 20 0.7 Normglobal FeatN 0.743\n", + "181 Random Forest 19 20 0.7 Normglobal FeatN 0.751\n", + "182 Random Forest 0 3 0.7 Normglobal FeatN 0.737\n", + "183 Random Forest 1 3 0.7 Normglobal FeatN 0.750\n", + "184 Random Forest 2 3 0.7 Normglobal FeatN 0.753\n", + "185 Random Forest 0 5 0.7 Normglobal FeatN 0.744\n", + "186 Random Forest 1 5 0.7 Normglobal FeatN 0.771\n", + "187 Random Forest 2 5 0.7 Normglobal FeatN 0.739\n", + "188 Random Forest 3 5 0.7 Normglobal FeatN 0.747\n", + "189 Random Forest 4 5 0.7 Normglobal FeatN 0.747\n", + "Random Forest [182 0.737\n", + "183 0.750\n", + "184 0.753\n", + "Name: balanced_accuracy, dtype: float64, 185 0.744\n", + "186 0.771\n", + "187 0.739\n", + "188 0.747\n", + "189 0.747\n", + "Name: balanced_accuracy, dtype: float64, 152 0.771\n", + "153 0.816\n", + "154 0.749\n", + "155 0.743\n", + "156 0.761\n", + "157 0.732\n", + "158 0.750\n", + "159 0.747\n", + "160 0.744\n", + "161 0.747\n", + "Name: balanced_accuracy, dtype: float64, 162 0.737\n", + "163 0.700\n", + "164 0.742\n", + "165 0.800\n", + "166 0.761\n", + "167 0.762\n", + "168 0.610\n", + "169 0.734\n", + "170 0.756\n", + "171 0.753\n", + "172 0.756\n", + "173 0.737\n", + "174 0.755\n", + "175 0.713\n", + "176 0.717\n", + "177 0.739\n", + "178 0.714\n", + "179 0.746\n", + "180 0.743\n", + "181 0.751\n", + "Name: balanced_accuracy, dtype: float64]\n", + "Balanced Random Forest\n", + " model run n_clients alpha \n", + "0 Balanced Random Forest 0 10 0.7 Normglobal FeatN \\\n", + "1 Balanced Random Forest 1 10 0.7 Normglobal FeatN \n", + "2 Balanced Random Forest 2 10 0.7 Normglobal FeatN \n", + "3 Balanced Random Forest 3 10 0.7 Normglobal FeatN \n", + "4 Balanced Random Forest 4 10 0.7 Normglobal FeatN \n", + "5 Balanced Random Forest 5 10 0.7 Normglobal FeatN \n", + "6 Balanced Random Forest 6 10 0.7 Normglobal FeatN \n", + "7 Balanced Random Forest 7 10 0.7 Normglobal FeatN \n", + "8 Balanced Random Forest 8 10 0.7 Normglobal FeatN \n", + "9 Balanced Random Forest 9 10 0.7 Normglobal FeatN \n", + "10 Balanced Random Forest 0 20 0.7 Normglobal FeatN \n", + "11 Balanced Random Forest 1 20 0.7 Normglobal FeatN \n", + "12 Balanced Random Forest 2 20 0.7 Normglobal FeatN \n", + "13 Balanced Random Forest 3 20 0.7 Normglobal FeatN \n", + "14 Balanced Random Forest 4 20 0.7 Normglobal FeatN \n", + "15 Balanced Random Forest 5 20 0.7 Normglobal FeatN \n", + "16 Balanced Random Forest 6 20 0.7 Normglobal FeatN \n", + "17 Balanced Random Forest 7 20 0.7 Normglobal FeatN \n", + "18 Balanced Random Forest 8 20 0.7 Normglobal FeatN \n", + "19 Balanced Random Forest 9 20 0.7 Normglobal FeatN \n", + "20 Balanced Random Forest 10 20 0.7 Normglobal FeatN \n", + "21 Balanced Random Forest 11 20 0.7 Normglobal FeatN \n", + "22 Balanced Random Forest 12 20 0.7 Normglobal FeatN \n", + "23 Balanced Random Forest 13 20 0.7 Normglobal FeatN \n", + "24 Balanced Random Forest 14 20 0.7 Normglobal FeatN \n", + "25 Balanced Random Forest 15 20 0.7 Normglobal FeatN \n", + "26 Balanced Random Forest 16 20 0.7 Normglobal FeatN \n", + "27 Balanced Random Forest 17 20 0.7 Normglobal FeatN \n", + "28 Balanced Random Forest 18 20 0.7 Normglobal FeatN \n", + "29 Balanced Random Forest 19 20 0.7 Normglobal FeatN \n", + "30 Balanced Random Forest 0 3 0.7 Normglobal FeatN \n", + "31 Balanced Random Forest 1 3 0.7 Normglobal FeatN \n", + "32 Balanced Random Forest 2 3 0.7 Normglobal FeatN \n", + "33 Balanced Random Forest 0 5 0.7 Normglobal FeatN \n", + "34 Balanced Random Forest 1 5 0.7 Normglobal FeatN \n", + "35 Balanced Random Forest 2 5 0.7 Normglobal FeatN \n", + "36 Balanced Random Forest 3 5 0.7 Normglobal FeatN \n", + "37 Balanced Random Forest 4 5 0.7 Normglobal FeatN \n", + "\n", + " balanced_accuracy \n", + "0 0.769 \n", + "1 0.740 \n", + "2 0.767 \n", + "3 0.747 \n", + "4 0.742 \n", + "5 0.739 \n", + "6 0.755 \n", + "7 0.745 \n", + "8 0.749 \n", + "9 0.748 \n", + "10 0.713 \n", + "11 0.691 \n", + "12 0.740 \n", + "13 0.768 \n", + "14 0.756 \n", + "15 0.759 \n", + "16 0.628 \n", + "17 0.756 \n", + "18 0.755 \n", + "19 0.763 \n", + "20 0.758 \n", + "21 0.741 \n", + "22 0.756 \n", + "23 0.716 \n", + "24 0.725 \n", + "25 0.737 \n", + "26 0.707 \n", + "27 0.750 \n", + "28 0.746 \n", + "29 0.751 \n", + "30 0.741 \n", + "31 0.752 \n", + "32 0.755 \n", + "33 0.744 \n", + "34 0.769 \n", + "35 0.739 \n", + "36 0.746 \n", + "37 0.747 \n", + "Balanced Random Forest [30 0.741\n", + "31 0.752\n", + "32 0.755\n", + "Name: balanced_accuracy, dtype: float64, 33 0.744\n", + "34 0.769\n", + "35 0.739\n", + "36 0.746\n", + "37 0.747\n", + "Name: balanced_accuracy, dtype: float64, 0 0.769\n", + "1 0.740\n", + "2 0.767\n", + "3 0.747\n", + "4 0.742\n", + "5 0.739\n", + "6 0.755\n", + "7 0.745\n", + "8 0.749\n", + "9 0.748\n", + "Name: balanced_accuracy, dtype: float64, 10 0.713\n", + "11 0.691\n", + "12 0.740\n", + "13 0.768\n", + "14 0.756\n", + "15 0.759\n", + "16 0.628\n", + "17 0.756\n", + "18 0.755\n", + "19 0.763\n", + "20 0.758\n", + "21 0.741\n", + "22 0.756\n", + "23 0.716\n", + "24 0.725\n", + "25 0.737\n", + "26 0.707\n", + "27 0.750\n", + "28 0.746\n", + "29 0.751\n", + "Name: balanced_accuracy, dtype: float64]\n", + "XGBoost\n", + "Empty DataFrame\n", + "Columns: [model, run, n_clients, alpha, balanced_accuracy]\n", + "Index: []\n", + "XGBoost [Series([], Name: balanced_accuracy, dtype: float64), Series([], Name: balanced_accuracy, dtype: float64), Series([], Name: balanced_accuracy, dtype: float64), Series([], Name: balanced_accuracy, dtype: float64)]\n" + ] + }, { "data": { - "image/png": "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3jz961//UmZmpiRp/fr1GjJkiA4fPqzbb79dUVFReumllwo1V+izzz6rBQsWqEyZMnr88ccVFRWlxYsXq0uXLsrOzlaDBg1Uu3ZtSVK5cuXUrVs3VatWrcCxvv76a/Xr1087d+5U48aNVbt2bf3000/q3Lmz5Y1rUSUnJ0uSvLy8FBoaKkkaN26cRo8eraNHj6pFixaqWrWqFi5cqN69e1u2W7hwod577z2dPHlSd955p8qXL68XXnghX6M9z7Zt27RgwQI9/PDDqlq1qsxms55++ml98sknMgxDbdq0kZeXl959911Nnjy50I+fJA0ePFgffvihfHx8FB8fr7vvvls//PCDOnfufMW53F566SWNHz9ex48ft8wxt3nzZj377LMFzhP/+uuv648//lCDBg2UlZWlxYsXX/d59wEA7uPvv/9Wt27dtGrVKkVGRurhhx/WgQMHNHjwYP3www+SLk3T0q1bNy1btkyRkZF6/PHHVa5cOX366aeWs8ObN2+uyMhISVJkZKS6deum8uXLF7jPyZMna9iwYTp06JBatGihW2+9VcuWLVPnzp116tQpq3Vr1qypJk2aKDU1VTNmzLB5P3788Uc999xz+vXXX1WvXj01atRIGzZsUNeuXXX27FmVL19e8fHxlvXj4+PVvHlzux47AACul9zcXL3wwgtKT09XvXr1lJqaqo8++sgy1aokvfzyy5o6darOnTun1q1bq1SpUpozZ46GDh1qWee9997TuHHjdOrUKbVp00ZlypTRsmXL9MILLyg3N9dqn/98f1yQ2267TZJ0+vRptW3bVlOmTNGWLVuUmZmpsLAwmUwmy7qRkZG64447JP1v+hbp0ofgubm5uuWWW3T33XdLkn799VdJ0u23366SJUvm2+8dd9yhp556SpUrVy7S4wi4OqZzAewwduxYZWZmKi4uTrNmzZLJZNKJEyf04IMP6o8//tCiRYvUuXNnffzxx5IuvdlMSEiQp6envvvuO7344otXHD85OVm7du2SJE2bNk1hYWGSpJkzZ8owDKWmpqp169Y6ePCg/vvf/1pN4ZJ3JncewzAsDduePXvqpZdeknSpEfzTTz/p008/1bBhwwp933Nzc7V3717Nnj1bkvTggw/K29tbZ86csdzfiRMnqmHDhpKkjh07avPmzdq0aZPq1aunefPmSbp0ttnUqVMlSbNnz9a4ceMK3N+RI0e0dOlSywuBH374QYmJifL399cXX3yh0NBQpaSkqHHjxvrwww/Vo0cPZWZmXvXxK1WqlNatWydJGjlypOVr7QkJCTp79qzOnz9v2e5yGzdu1Pfffy9J+vDDDxUbGyvpf9PbjB8/Xm3atJGHx/8+qwwNDdW0adNkMplUoUIFzZo1SytXrsz3dTkAwI1pw4YNysjIyLc8JiamwK+HHz9+XM2bN5eXl5def/11eXp6ytvbWwsWLNCKFSssrzdOnjypgIAAzZ49Wx4eHsrNzdXEiRMVFBSkzMxM/etf/9LOnTu1b98+xcTEWF4r5L0JznP+/HnNmjVL0qUPfp944gkZhqFOnTopKSlJX3/9tdW0LYZhaMCAAVqzZo0++eQTm835iRMnyjAM9ejRw/L6491339WMGTP06aefqm/fvurdu7flm3m9e/dmTnQAgFupWbOm3njjDUnSgAEDtHz5cq1cuVLdu3fX7t27Le8d58+fr4iICGVnZ+uhhx7S119/rd69eys8PFweHh564okn1LRpUzVu3Fh///234uLi9Pvvv+vQoUOKiIiw7O+f748L8thjj2np0qXavn27Dh8+rPfff1+S5O3trfvuu0/PPvus7rrrLsv6rVu31u7du/Xjjz9aToDLm8qlZcuWlqb733//LenSSXzAzYQmOnCNLly4oG3btkm69LWmvEApX768YmNjtWnTJm3evFmdO3e2TJfStGlTy5zhzZs3l5+fny5cuGBzH4GBgSpdurROnz6t9u3bq2nTpoqNjVX79u0L/MT3Sg4cOKCTJ09Kkpo0aWJZ/u677xZ6jOnTpxf4VbG6detaGvCJiYmWr4r98MMPWrNmjSRZpi1JTEzUnXfeaTnz/aGHHrKM065dO5tN9IiICKsXCHmPfYkSJazmWfPx8dG5c+f0xx9/qEaNGoV6/CIiIrRr1y49//zzeuCBBxQbG6smTZpc8UXBhg0bJEmVKlWyNNClSy8uPvvsM506dUoHDhywnPknSW3atLH8O7n77rs1a9asfGf1AQBuXImJiUpMTMy3/NFHHy2wiV6nTh1VrFhR33//vSZMmKDs7GzLV6zz3sCWL19evr6+Sk9PV5s2bXT//fcrNjZWvXr1KvLFSn/77TfLt+jyXiuYTCYlJCTY3CYqKkpt2rTR4sWL9f7772vUqFFWt6elpSkpKUmStHfvXo0cOVLSpSloJBX4eAAA4G7atm1r+fvdd9+t5cuXW97r5b139fb21meffWZZL++94Y4dOxQeHq4XX3xR69at044dO7Rhw4Z805Re3kT/5/vjgvj6+uqTTz7RsmXLtGLFCm3dulXnz59Xdna21qxZo/Xr12vmzJmWKVpbtWqlcePGadeuXTp58qRKliyp9evXS/rfVC6X1202m4v+QAFujCY6cI1SU1MtX6n6Z0M77+e8+UHzpgS5/IxmDw8PhYSEXLGJ7u3trdmzZ+vNN9/U9u3b9fHHH+vjjz+Wj4+PunXrpldeeaXQ9eZNuyJJwcHBhd7ucjExMapTp44kafv27dqxY4eqVKmijz76SF5el36dnD9/3rJ+QW+6T548qeTkZMsLgssfuyt9MPDPs8Hz9pOcnGw58/1yJ06cUExMTKEev/fff1/Dhg3T+vXrtWDBAi1YsECenp5q06aN3n77bas53vLkPZ62jr2kfPPilypVyvJ3Pz8/Scr3tTwAwI3rueeeK9KFRbdu3aqnn35aWVlZNtcpXbq0pk+frlGjRmnv3r36448/JEkBAQHq16+fnnrqqULv71pfK/Tr10/Lly/X4sWL9cwzz1jddvm1P3766ad82544caLQ+wEAwFVd/n71n+/18t67ZmdnF/je9eTJk8rNzVWfPn2splK53OUN9X/u70q8vLzUrl07tWvXToZh6I8//tCSJUs0Z84c5eTkaPbs2ZYmerly5XT33Xdr8+bN+vHHH1WhQgVlZGSoatWqlqleJKlChQqWuoGbCU104BoFBwdbvjKdd8GOPHk/5wVbaGioTp06ZXXRrdzc3KtehEuSatSooYSEBP3999/avn27NmzYoK+++kqzZ89WzZo11bJly0LXm+fyN8np6ek6f/68vLy8VLp06SuOcd9991ne/B85ckSPPPKIDh06pDlz5li+3h0SEmJZf8uWLQW+Cb/84imXPwZXmn/88mlRLr8/t99+u7755hub2xXm8atUqZI+/PBDpaSkaPv27dq8ebMSEhL09ddfKzIyUj179sw3bl6z/J81nz592vL3y5vmAAAU1bvvvqusrCxFR0dr6tSpKlu2rMaPH2+ZciVP/fr1tXTpUh09elTbt2/XmjVrtGzZMo0ePVqxsbGWa6dczeWZnZKSYpmaJTU1VRkZGfLx8SnwTXvFihXVqVMnzZs3TxMmTJC/v79l2pqgoCCZTCYZhqEpU6aoWbNm1/pwAADglvLyNSgoSFu3bi1wnY0bN1oa6GPHjlXLli1lGIZlutF/+uf744KcOHFCf/zxh+644w6VKlVKJpNJ1atX18svv6y0tDR9/vnnOnbsmNU2bdq00ebNm7V27VrL1GqXn4UuSffee68WLFhgmWamSpUqVrfv3LlTI0eOVOfOndWqVatC1Qq4A/4lA9fIz8/PMn/Yt99+a/lk+OjRo5arV+fNB169enVJl87Ayvs0esWKFVc8C12S9u3bp3fffVdz585V2bJl1bx5c7355pu67777LPuS/vd1qsvP9vqnqlWrWt74Xv7p9htvvKH777/f8vXqwgoPD7c0l6dMmaLDhw9LkqKjoy1nbv/888+W9T/77DPNnTtX+/btk4+Pj2699VZJ0n/+8x/LOgVdjNOWvMd+3759+uuvvyRJGRkZmjp1qj799FOdP3++UI/fyZMn9f777+u9995TaGiomjRpokGDBlmuUm7r4q95n9YfO3ZM27dvtyz/9ttvJV2a5uWfLyYAACiKvA+a69Spo7Jly+rixYuWs7nzPpD+73//q7Fjx2rx4sWqVKmSWrdurfHjx1umE8t7c5z3WqGgOdnzxMTEWDI8L58Nw1CvXr10//33a86cOTa3fe655xQQEKBVq1ZZfVju7++vGjVqSJLlGiSStGbNGs2ePVsbN260qu9qNQIA4G7y3rueP39e//3vfyVdOqlu1qxZmj9/vk6ePGn1LeYmTZrIx8dHK1assCy7PFsL4+LFi2rXrp169Oih8ePHW53JbhiGjhw5Ikn53rM2b95c3t7e2rx5c4FTuUhSs2bNVLlyZRmGoVGjRll9Yy4lJUVvvPGGtm3bpi+++IIGOm4onIkOXMHnn3+u5cuX51t+33336c0339Qrr7yirl276ueff1aXLl106623as2aNcrOzlZsbKwlbDp37qz169frv//9rzp16qSIiAj9/PPPCg4OVmpqqs39BwYGav78+bpw4YK2bNmiW265RSdPntS6devk6+urxo0bS/rfBT12796tgQMH6pFHHpG/v7/VWJ6enurfv7+GDRumjz76SMeOHbO8Gff19dWzzz5b5MenV69e+uabb3T48GHLuGFhYerSpYvmzp2r1157TatWrVJycrLWr1+vMmXKqFWrVpbHZNSoUfr+++/19NNPKzQ0VDt37iz0vhs3bqzo6Gjt2LFDHTp0UMOGDbVz507t3btX9erVU+fOnZWRkXHVxy8kJEQLFy7U33//rcTERFWtWlUpKSn64Ycf5OHhYTVn++XuvfdePfjgg/rhhx/Uq1cvNWvWTCdOnNCGDRvk6empwYMHWzUEAACwdWFR6dIFuv8pJiZGf/75pxYtWqQLFy5o+/btqlq1qv7880/t2rVLb7zxhp544gl9/PHHMplMWrdunUJDQ3Xo0CH9+eefCgsL0z333CNJKlu2rKRLH+i/9tpr6tChQ779hYWFqXv37po5c6bGjBmj3377TSdOnND27dsVFhamrl272rxvYWFheuaZZ/T+++9bro2Sp3fv3urTp48SEhJ07NgxBQUF6ccff7R8jVy6NC2Nl5eXcnJy9OqrryouLq5IU98AAGCvq73/v1a333675b1jz5491aRJEx06dEjbt29XtWrV1L59e9WqVcuSgy+88ILKli2rTZs2qW7dutq8ebMmTZpUpDnIfXx8NGjQIA0ePFiLFi3Szp07FRMTI5PJpP/+97/au3evfH191adPH6vtgoOD1ahRI61atUppaWmKjo7O12j38fHRpEmT9Mwzz2j16tVq3ry57rvvPmVlZWnDhg06e/asKleurHfeeeeaHzPAFfGREHAF586d0+HDh/P9ybtASO3atfX555+rcePGlrnF/P399dxzz2nOnDmWs7maNWum1157TWXLltXu3bu1b98+TZo0yTJXWkFzbkuXmuOffPKJGjdurK1btyohIUHbtm1To0aNNHfuXMsZ7q1atVLDhg3l7e2tn3/+2eY0MR07dtSECRN0xx13aPXq1dq0aZPi4uL02Wef6fbbby/y4+Pj46MhQ4ZIutQYyDuTfNCgQXrllVdUvnx5ff/999qxY4cefvhhffbZZypTpowkqWvXrurVq5dKliypX3/9VadPn7ZcLfxKj0keT09PzZkzRx07dpRhGPrmm2+UkpKip59+WlOnTpXJZCrU4+fr66vPPvtMLVu2VFJSkhYsWKD169erdu3amj59uuWM84K899576t+/v8LCwrR06VLt2LFDDRs21Lx58/TAAw8U+fEEANzYEhMTLdfn+Oef3bt351v/5Zdftkx/snr1aj344IOaNGmS5UP6TZs2qVatWpo1a5buuusu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A87lytlNEx0AAAAAAAAAABv4/hAAAAAAAAAAADbQRAcAAAAAAAAAwAaa6AAAAAAAAAAA2EATHQAAAAAAAAAAG2iiAwAAAAAAAABgA010AAAAAAAAAABsoIkOAAAAAAAAAIANNNEBAAAAAAAAALCBJjpwE+jatavGjx/vtP3v27dPzZs3V+3atXXmzJlrGuPo0aOKiorSvn37JEnR0dFav369I8sEAMBlkN0AALgXshu4sdFEB66zpk2bqlGjRsrIyLBavmnTJjVt2tRJVRWvhQsXKjAwUL/++qtKlSpV4Dr79u3TgAEDdN9996l27dpq2rSpRowYoZSUlALX37Fjhxo0aOCQ+j766CPl5OQ4ZCwAwI2H7Ca7AQDuhewmuwFHo4kOOMHFixc1depUZ5dRZIZhKDc3t8jbnTt3TpUrV5aXl1eBt//+++9q3769ypcvryVLlmj79u2aPn26/vzzT3Xq1EmZmZn2lm7T2bNnNXbsWJnN5mLbBwDA/ZHd1shuAICrI7utkd2AfWiiA07Qt29fffrppzpw4ECBt//zK1SSNH78eHXt2lWStGHDBt15551atWqVGjdurNjYWE2cOFG7du1S69atFRsbq/79+1t9ypuZmamXXnpJsbGxat68udatW2e57fjx43ruuecUGxurRo0aaejQoUpPT5d06ZP62NhYzZ8/X3feeae2bduWr97c3FxNmTJFDz74oO666y517NhRiYmJkqR///vfWrx4sVasWKHo6GidPn063/ZvvfWWGjZsqEGDBql06dLy8PBQ9erVNWXKFNWpU0d///13vm2ioqK0du1aSZdeHL311luqV6+e6tatqx49eujw4cOSpJycHEVFRWnlypXq2LGj6tSpo7Zt2yopKUmnT59Wo0aNZBiG7r77bi1atEinT59W7969Va9ePd1555166qmndOTIkSsfUADADY/stkZ2AwBcHdltjewG7EMTHXCCatWqqUOHDhoxYsQ1be/p6akLFy5o48aNWrFihYYNG6bp06dr+vTpmjdvnhYuXKj//Oc/VoG9ZMkStW7dWps2bVLbtm3Vv39/paWlSZJeeuklVapUSRs2bNDXX3+tQ4cO6Z133rFsm52drUOHDumXX37RXXfdla+eTz/9VF9++aUmT56sDRs2qFmzZnrqqad09uxZvfPOO2rbtq1atGihHTt2qHTp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R1Hnfxx/bxqkbUJCDx2UQwgQpIhUQQVRRHNSBLGAIP5EFFBRDhERRUROULCgIuX0sBxNBER6KNICBlCCRy8CISSE9GQzvz9yWVnJQsJu2N3wej4ePo7Mzn73s5nbvGc+O/OdAwe0detW7dq1S+fOndOTTz7p2gJd6NLfzaVatmx5/Yu5iqysLN1+++266667PLJBAwDu5tIMyMrK0s8//6yffvpJGzdu1H/+8x/VrVvXtQW6qYCAAD300EPWn5OSkrR69WrNmTNHW7du1XfffSdfX18XVug6f/3dFChbtqwLqrm6V199Vd98843i4+NdXQoAwI7GjRurf//+mjNnjt566y21bdtWZcuWVV5ensaNGyfDMNSpUyd16tRJhmHolVde0cKFC+Xl5aXWrVurWrVqOnLkiLUHsHTpUn322WcKDAy87HXceb+IzMKNjiY6cB3cfvvtGj58uM2y119/XV999ZVmzpypJ554Qt7e3i6qzrUK+924q9WrVys1NdXVZQBAqfHXDEhNTdUdd9yhlJQULV68WCNGjHBhde4rODj4squ14uLi1LNnT+3fv19r167V3Xff7aLqXKuw3427ys7O1o8//ujqMgAARfD8889r1apVOnnypD755BM999xzmj9/vvbt2yd/f3+NGTNGkvTVV19p4cKF8vX11SeffKI2bdpYx9iwYYP+7//+T7Gxsfr88881bNgwm9dw5/0iMgtgTnTAZQrC9MKFCzp//rwkKSMjQ++9957uvvtuNWnSRB07dtRrr72m5ORk6/Nefvll1a9fXx988IGWLVumrl27qlGjRrr//vu1Z88em9dYuHCh7r77bkVGRqpHjx7auHFjobWcP39e48ePV8eOHdWoUSO1bt1azz//vP773/9a19m6davq16+vTp066dChQ+rTp48aN26se+65R5s2bVJCQoIGDhyoJk2a6I477lBMTIzTflfbtm3TwIED1bx5czVq1EhdunTR+++/r6ysrMt+L1OnTtWrr76qqKgoLVmyRJJ05swZjRo1Sp07d1ZkZKTuueceffvttzavceTIEY0cOVIdOnRQZGSk7rjjDo0fP14pKSmSpP79+1t3aBYuXKj69etr69atTnuPAAApKChI1atXlyRdvHjRurwo+ViYEydO6IUXXlCHDh3UuHFjde3aVZ999pny8vKs63Tq1En169fXli1bNHXqVLVt21aNGzfWkCFDlJiYaDPejz/+qJ49e6pJkyZq3bq1hgwZot9++81mnV27dunJJ59UmzZt1KRJEz388MPauXOnzToFl4U3btxYnTp10hdffHEtvy4bjRs3ltlslpSfaQVWrVqlPn36qEWLFmrRooX69+9vU8+l+X7y5EkNHDhQTZs2VevWrTVz5kyb1zh8+LAGDBigJk2aqG3btpoyZYosFkuh9Xz77beKjo5WkyZN1LRpU/Xs2VOLFi2yWafgd79582ZNmDBBzZs3V6tWrfTOO+9YL5lv3bq1oqKi9Oabb9p9reIqzn7PHXfcodWrV6tDhw4aMGCAJCkvL0+zZ8/WAw88oKioKLVu3Vpjxoyx7jNIUm5urj766CPde++9ioqKUqtWrTRw4EDrlDILFixQZGSkLly4IEmqX7++Xn75Zae8PwCA8wUEBOi1116TJH322Wf65ZdfNHXqVEnS008/rYiICEmyZnqvXr1sGuiS1L59e40ePVrTp0/XkCFDrvqa9vaLJOmnn35S3759FRUVpcaNG+v+++/X7NmzbfZxpKIdS5NZQNHQRAdcJCkpSZLk4+Oj0NBQSdI//vEPffzxx8rKytIDDzwgPz8/zZ8/v9CA2rhxoyZNmqSoqCiVL19e8fHxeuqpp5SZmSlJ2rJli15++WUdPXpUzZo1U7NmzTRq1KjLGg4pKSnq3bu3vvzyS3l5een+++9XxYoVtXz5cvXs2dPmgFLKb/q/8MILqlGjhsqVK6dDhw5p5MiReuGFFxQYGKiIiAidOnVKzz//vNLS0hz+Pa1atUqPP/64Nm7cqIYNG+ree+/V+fPnNWPGDA0ZMkSGYdis/8MPP2jLli3q3r27qlSpotTUVPXt21eLFi1ScHCwevToodTUVI0ZM0YLFiyQlH+p3KOPPqqlS5eqbt26euihh1SpUiV9+eWXGjx4sCSpS5cu1svn6tatq0cffVSVK1d2+P0BAP6UmpqqY8eOScpvChcoTj4WyMrK0mOPPabvv/9eFSpUUI8ePZSQkKDJkydrzpw5l60/bdo0rVu3Trfffru8vLy0du1amzOaFy5cqGHDhmnv3r3q2LGjmjRporVr16pv377WrNy7d68effRRxcTEqEGDBurSpYv27dunAQMGWNe5cOGCBgwYoN27dysiIkKdOnXSV1995fCXz5mZmUpPT5ckhYeHS8o/423o0KH65Zdf1KFDBzVr1kzbtm3ToEGDdOrUKZvnX7hwQU8//bSCgoIUGRmp8+fPa8qUKVq5cqWk/APsJ598Ups2bVK5cuXUtWtXbdiw4bIvpSVp0qRJGjNmjA4cOKBOnTqpXbt22rt3r0aNGlXovOXvvfeefv/9d91yyy1KTk7W559/rhEjRmjFihVq1qyZ0tPTNXfuXOuX444o7n7PxYsXNW7cOLVs2VK33XabJGny5MmaOHGiTpw4oa5du6pOnTr69ttv9cwzz1ifN2XKFE2dOlWZmZm6//771a5dO23dulWPP/64fv/9d9WrV09dunSxrv/oo49e1mwBALiXDh066L777lN2drb69++vCxcuqE6dOtYvWf/44w8dP35ckuz+Te/bt6/uuusumznR7bG3X/Svf/1LQ4cOVWxsrFq1aqW77rpLR44c0cSJE232XYp6LE1mAUXDdC7AdZaXl6cDBw7os88+kyTddddd8vX1lcViUWhoqHr37q3o6Gg1bdpUu3btUp8+fbRhwwZlZmbazOe5f/9+rVixQlWqVNHBgwfVrVs3JSYmKjY2Vrfffru1QdCsWTPNnj1bJpNJ7du3v+wb7y+++ELHjh1TeHi4Fi9erODgYOXk5Oihhx7S/v37NX36dOs37FJ+kPfr109///vftX37dj3yyCNKSkpSzZo1NX78eP3xxx+64447lJqaqtjYWLVr1+6af1eGYeitt96SxWJR7969NX78eEl/XrK+efNmbdiwQR06dLA+JyEhQWvWrFFYWJgkafbs2Tpx4oQiIiL0zTffyM/PT4cPH1bXrl01ffp0RUdH6/fff9eZM2cUGBiozz77TF5eXsrLy9PUqVMVHByszMxMPfLII9q7d68OHjyoxo0be8yl4gDgzjZv3mxt/BbM/Zmamqr7779f3bt3l6Ri52OBP/74w3qAN2LECOsNvf75z3/qxx9/1BNPPGGzflZWlr799lv5+voqKipK48aN07p165SdnS1fX1+99957kqRBgwZZL6ceMWKE1q5dqy+//FKvvfaaZsyYoezsbN13332aMmWKJOnWW2/V2LFj9dlnn+ntt9/WggULdPHiRQUEBGj+/PkKCQnR008/rc6dO1/z7zEpKUnTpk1Tbm6uAgICdMcdd0jKP+O6V69eqlOnjh5//HFJUteuXXX48GHF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kvLvtVGnTh3r2VLFzcfGjRtr/fr1iomJ0csvv6zff/9dFStWlLe3txISEvTSSy/plVdeKVKd3t7eeu6556yZffLkSWVnZ2vt2rUqW7asnnrqKUnSU089pXXr1mnDhg3q27evIiIitG7dOqWkpOjtt9+WlJ9LM2bMUGZmpnr16qWWLVtqw4YNqlq1qo4ePXpNv8datWppwIAB+vjjjzVjxgx17dpVNWvWVPXq1XX8+HG98847WrNmjdatW6f27dtr1apVWrx4scLDw9WgQYMivcZdd92lChUqKCEhQU888YTat2+vnTt3KiQkxGa/okaNGnr00Uc1e/ZsvfLKK1q/fr3S0tKs23XkyJHWuc5dwdH9nrCwMPXr18/6/lavXq2kpCRt2rRJFSpU0L333qugoCD99NNP+vXXX7V3715FRkYqLS3N+gVMwRzrlSpVso47ZMgQderU6bIb3gIAXC89PV2vv/66pPyru2655RZJUkREhJ566ilNnTpVH3zwge6991717t1bv/zyixYsWKChQ4eqVatWql27tpKTk7V9+3brlVmFXVFXlP0iSXr++ef11ltvaerUqdq3b598fHysXwY//vjjql69uiQV+ViazAKKhjPRARfw8/PTmDFjJOUH5aJFi9SiRQu9+OKLqlSpkrZu3aqcnBx9/vnneuaZZxQaGqo9e/YUOk+nPZ06ddIrr7yiihUrat++fTp48KCmTZt22V3CQ0JCNH/+fPXq1UsZGRnWedYeeOABfffdd4XerO16uuuuuzRr1iy1bNlSO3fu1LJly1S1alW9+OKLl93QrTBBQUH66quvdN999+nChQtasmSJLBaLhg8fbr1DeaNGjfTpp5/q1ltv1YYNG/T111/rwIED6tatm+bNm6cKFSpIkvr27aumTZvKMAxt2LDhsrP6AQDFExcXp7lz52ru3LmaN2+eNm3apHr16umVV17RjBkzJOma83HgwIF68MEHFRAQoDVr1qhhw4Z677339MQTT6hMmTLaunWrzU2/rqZPnz6aMmWKbrnlFq1bt05bt25Vu3bt9NVXX+lvf/ubpPyzlOfMmaNWrVrpt99+0/Lly1WtWjVNmzZNDz74oCSpfPny+uijj3TzzTfr9OnT2rZtm5555hmbG5Zei6effloRERHKysrS2LFjJeXf+DQyMlJnz55VbGys/vGPf+iNN97Q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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAABdEAAAO7CAYAAAC76s0MAAAAOXRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjcuMSwgaHR0cHM6Ly9tYXRwbG90bGliLm9yZy/bCgiHAAAACXBIWXMAAA9hAAAPYQGoP6dpAAEAAElEQVR4nOzde0BUZf7H8c8w4IW7mJq6GqhlroJYm2KKYlpaodJE1lr6WyurLbuJZbp2L82Syrab3csuVkRUrJlloaCYlZaiSTcp73kBuSnCzPn94TLrKCOMDMwMvF+77Mo5zzzznTnMfOd85znPYzIMwxAAAAAAAAAAADiOn6cDAAAAAAAAAADAW1FEBwAAAAAAAADACYroAAAAAAAAAAA4QREdAAAAAAAAAAAnKKIDAAAAAAAAAOAERXQAAAAAAAAAAJygiA4AAAAAAAAAgBMU0QEAAAAAAAAAcIIiOgAAAAAAAAAATlBEB2pw3nnnqWfPnnriiSca9X7T09PVs2dPnXfeefXu6+uvv1bPnj3Vs2fPevdV/Xwc+9OvXz9dccUVysjIqPd9eLu77rpLPXv21F133eXpUAAAPsZZHj36Z9u2bZowYYJ69uypf//7340S18l+7qjOiT179tT7779/3P6jHxMAAN6uruf/vnhOuH37ds2ZM0ejRo1Sv3791KdPHyUkJOiOO+7Q1q1bJUk//PCDPXfn5uYe18dvv/1m35+VlWXfvnXrVt1333264IILFBMTo5iYGF144YV66qmntH///sZ6iECj8fd0AAD+p0ePHpo4caLCwsJcvu3IkSPVvn17LVy4UJJ06qmnauLEiW6NLyYmRrGxsZIkwzD0008/6euvv9a6deu0d+9eXXvttW69P28yaNAghYSEKCYmxtOhAAB81NF59FjBwcENet8VFRU699xzdf755+uRRx6RVL/PHdX+/e9/a/To0WrVqlW9Y3z++ef1xBNPaNmyZfrLX/5S7/4AAHAnXzsn3LVrly699FIVFhYqKipKF198sQ4fPqysrCx9/PHH+vrrr5WRkaG+ffuqa9eu+uOPP7Rs2TINHDjQoZ9ly5ZJksLDwzVo0CBJRwbt/fOf/1RZWZn+8pe/aMyYMSorK9PKlSv1zDPP6OOPP9Ybb7yhTp06NfrjBhoKRXTAi1R/e+uq9evXq6CgQO3bt7dvO+200/Svf/3LneHp3HPP1e233+6w7f7779fbb7+tF154QZMmTZLZbHbrfXqL0aNHa/To0Z4OAwDgw2rKo41l2bJlKi0tddh2sp87qvn5+Wn37t164403dN1119U3RH3yySf17gMAgIbia+eE77//vgoLC9WpUyd9/PHHatGihaQjI8gvvPBCHThwQCtWrFBSUpISExP17LPP6ssvv9SsWbMc+vnyyy8lSaNGjVJAQIDKy8s1depUlZWVadSoUZo3b54CAgIkSQcOHNCkSZO0ceNGPfzww3rmmWca90EDDYjpXIB62rx5s26++WYNGDBAffr00XnnnafZs2erqKjIod3777+v888/X9HR0UpKStLq1at12WWXqWfPnkpPT5dU82XVe/bs0T333KPzzjtP0dHRGjx4sKZPn66dO3dKOnJJ2WWXXSZJWrNmjb0/Z9O5fPbZZ7rsssvUt29fDRw4UDfccIN+/PHHk3781d9EHzhwwH7Jls1m02uvvaakpCT169dPAwcO1KxZs1RcXOxw2+eff15DhgxRTEyM/v73v2vz5s0699xz1bNnT3399deSjoxw69mzp6ZPn65///vfOvvss/X8889LkkpKSvTggw9q5MiRiomJ0fDhw7VgwQIZhlHn50+SSktL9eijj2rkyJH25+Xmm2/WTz/9ZG9T06V7VVVVevHFF5WYmKjo6GidddZZmjBhgsMlbtL/Lmv/9ddfdd9992nAgAHq16+fpk+frrKyspN+7gEATV9+fr5uvPFGDR48WLGxsRozZow++OADhzYFBQVKSUnR0KFDFR0drWHDhumBBx6w590JEybYi/cffvihPc86m85l0aJFGj16tKKjoxUfH6+UlBT7Jd9HS0hIkCS9+OKLOnDgwAkfR1ZWliZMmGDPgddcc41++eUXSf+bgq769+HDh2vChAmuP1kAADSgY88Jt23bpp49e6pXr17av3+/br/9dp111lk655xz9Mgjj6iqqsp+28OHD2v+/Pm6+OKL1bdvX8XHx2vu3Lk6fPiwvY3NZtNLL72kiy++WLGxsRo8eLBSUlK0Y8cOe5sTnR8fa9++ffb7PjqWLl26aMWKFfr++++VlJQkSUpMTJR0ZPqXzZs329vu379f33//vUObxYsXa+/evQoICNB9991nL6BLUlhYmB566CHNmDFD06dPd/k5BrwZRXSgHtavX6/LL79cS5cuVdeuXTV69GgdPnxYr7/+uq666iodOnRIkrRy5UrNmjVLf/zxh84880z17NlTU6dOrdNcoddff73effddtWvXTsnJyerZs6cyMjJ05ZVXqrKyUoMGDVLfvn0lSR06dNDEiRPVo0ePGvv68MMPdcsttygvL08JCQnq27evvvrqK40fP95+4uqqwsJCSZK/v7/Cw8MlSY899pjmzJmjbdu2adSoUerWrZvef/993XTTTfbbvf/++3riiSe0e/dunXXWWTr11FN14403Hldor7Z27Vq9++67uvDCC9WtWzdZrVZdffXVevPNN2UYhsaMGSN/f389/vjjevrpp+v8/EnSzJkz9fLLL6tFixayWCz629/+ps8//1zjx48/4VxuU6dO1bx587Rjxw77HHNr1qzR9ddfX+M88f/617/0888/a9CgQaqoqFBGRkajz7sPAPAdf/75pyZOnKhly5ape/fuuvDCC7VlyxbNnDlTn3/+uaQj07RMnDhRmZmZ6t69u5KTk9WhQwe99dZb9tHhI0eOVPfu3SVJ3bt318SJE3XqqafWeJ9PP/207r33Xv3+++8aNWqUIiMjlZmZqfHjx2vPnj0ObXv37q1hw4apuLhYCxYscPo4vvzyS91www367rvvNGDAAA0ZMkSrVq3ShAkTtH//fp166qmyWCz29haLRSNHjqzXcwcAQGOx2Wy68cYbVVZWpgEDBqi4uFivvvqqfapVSZo2bZqeffZZHThwQKNHj1bbtm31yiuv6J577rG3eeKJJ/TYY49pz549GjNmjNq1a6fMzEzdeOONstlsDvd57PlxTU4//XRJ0t69ezV27Fg988wz+uabb3To0CFFRETIZDLZ23bv3l1//etfJf1v+hbpyJfgNptNHTt21N/+9jdJ0nfffSdJOvPMM9WmTZvj7vevf/2r/vGPf6hr164uPY+At2M6F6Ae5s6dq0OHDik+Pl4vvviiTCaTdu3apfPPP18///yz0tPTNX78eL3xxhuSjpxsLlq0SGazWZ9++qluu+22E/ZfWFiojRs3SpKee+45RURESJJeeOEFGYah4uJijR49WgUFBfrhhx8cpnCpHsldzTAMe8F28uTJmjp1qqQjheCvvvpKb731lu699946P3abzaaffvpJL730kiTp/PPPV0BAgPbt22d/vE8++aQGDx4sSbriiiu0Zs0aff311xowYIBef/11SUdGmz377LOSpJdeekmPPfZYjfe3detWffLJJ/YPAp9//rnWr1+vwMBAvffeewoPD1dRUZESEhL08ssv69prr9WhQ4dqff7atm2r7OxsSdLDDz9sv6x90aJF2r9/v0pKSuy3O1pubq4+++wzSdLLL7+sfv36Sfrf9Dbz5s3TmDFj5Of3v+8qw8PD9dxzz8lkMqlTp0568cUXtXTp0uMulwMANE2rVq1SeXn5cdtjYmJqvDx8x44dGjlypPz9/fWvf/1LZrNZAQEBevfdd7VkyRL7543du3crKChIL730kvz8/GSz2fTkk08qJCREhw4d0lVXXaW8vDz9+uuviomJsX9WqD4JrlZSUqIXX3xR0pEvfi+//HIZhqG///3vys/P14cffugwbYthGLr99tu1fPlyvfnmm06L808++aQMw9C1115r//zx+OOPa8GCBXrrrbd0880366abbrJfmXfTTTcxJzoAwKf07t1bd999tyTp9ttv1+LFi7V06VJNmjRJmzZtsp87Lly4UFFRUaqsrNQFF1ygDz/8UDfddJO6dOkiPz8/XX755TrvvPOUkJCgP//8U/Hx8frxxx/1+++/Kyoqyn5/x54f1+TSSy/VJ598onXr1umPP/7QU089JUkKCAjQueeeq+uvv15nn322vf3o0aO1adMmffnll/YBcNVTuVx00UX2ovuff/4p6cggPqA5oYgOnKSDBw9q7dq1ko5c1lSdUE499VT169dPX3/9tdasWaPx48fbp0s577zz7HOGjxw5Uq1bt9bBgwed3kdwcLBOOeUU7d27V5dddpnOO+889evXT5dddlmN3/ieyJYtW7R7925J0rBhw+zbH3/88Tr38fzzz9d4qVj//v3tBfj169fbLxX7/PPPtXz5ckmyT1uyfv16nXXWWfaR7xdccIG9n6SkJKdF9KioKIcPCNXPfcuWLR3mWWvRooUOHDign3/+Wb169arT8xcVFaWNGzfqn//8p4YPH65+/fpp2LBhJ/xQsGrVKknSX/7yF3sBXTry4eLtt9/Wnj17tGXLFvvIP0kaM2aM/e/kb3/7m1588cXjRvUBAJqu9evXa/369cdtv+SSS2ososfGxqpz58767LPPlJqaqsrKSvsl1tUnsKeeeqpatWqlsrIyjRkzRkOHDlW/fv103XXXubxY6ffff2+/iq76s4LJZNKiRYuc3qZnz54aM2aMMjIy9NRTT2n27NkO+0tLS5Wfny9J+umnn/Twww9LOjIFjaQanw8AAHzN2LFj7f/+29/+psWLF9vP9arPXQMCAvT222/b21WfG27YsEFdunTRbbfdpuzsbG3YsEGrVq06bprSo4vox54f16RVq1Z68803lZmZqSVLlujbb79VSUmJKisrtXz5cq1cuVIvvPCCfYrWiy++WI899pg2btyo3bt3q02bNlq5cqWk/03lcnTcVqvV9ScK8GEU0YGTVFxcbL+k6tiCdvXv1fODVk8JcvSIZj8/P4WFhZ2wiB4QEKCXXnpJ999/v9atW6c33nhDb7zxhlq0aKGJEyfqjjvuqHO81dOuSFJoaGidb3e0mJgYxcbGSpLWrVunDRs26LTTTtOrr74qf/8jbyclJSX29jWddO/evVuFhYX2DwRHP3cn+mLg2NHg1fdTWFhoH/l+tF27dikmJqZOz99TTz2le++9VytXrtS7776rd999V2azWWPGjNGDDz7oMMdbtern09mxl3TcvPht27a1/7t169aSdNxleQCApuuGG25waWHRb7/9VldffbUqKiqctjnllFP0/PPPa/bs2frpp5/0888/S5KCgoJ0yy236B//+Eed7+9kPyvccsstWrx4sTIyMnTNNdc47Dt67Y+vvvrquNvu2rWrzvcDAIC3Ovp89dhzvepz18rKyhrPXXfv3i2bzaYpU6Y4TKVytKML6sfe34n4+/srKSlJSUlJMgxDP//8sz7++GO98sorqqqq0ksvvWQvonfo0EF/+9vftGbNGn355Zfq1KmTysvL1a1bN/tUL5LUqVMne9xAc0IRHThJoaGh9kumqxfsqFb9e3ViCw8P1549exwW3bLZbLUuwiVJvXr10qJFi/Tnn39q3bp1WrVqlT744AO99NJL6t27ty666KI6x1vt6JPksrIylZSUyN/fX6eccsoJ+zj33HPtJ/9bt25VYmKifv/9d73yyiv2y7vDwsLs7b/55psaT8KPXjzl6OfgRPOPHz0tytGP58wzz9RHH33k9HZ1ef7+8pe/6OWXX1ZRUZHWrVunNWvWaNGiRfrwww/VvXt3TZ48+bh+q4vlx8a8d+9e+7+PLpoDAOCqxx9/XBUVFYqOjtazzz6r9u3ba968efYpV6oNHDhQn3zyibZt26Z169Zp+fLlyszM1Jw5c9SvXz/72im1OTpnFxUV2admKS4uVnl5uVq0aFHjSXvnzp3197//Xa+//rpSU1MVGBhon7YmJCREJpNJhmHomWee0YgRI0726QAAwCdV59eQkBB9++23NbbJzc21F9Dnzp2riy66SIZh2KcbPdax58c12bVrl37++Wf99a9/Vdu2bWUymXTGGWdo2rRpKi0t1TvvvKPt27c73GbMmDFas2aNVqxYYZ9a7ehR6JIUFxend9991z7NzGmnneawPy8vTw8//LDGjx+viy++uE6xAr6Av2TgJLVu3do+f9h//vMf+zfD27Zts69eXT0f+BlnnCHpyAis6m+jlyxZcsJR6JL066+/6vHHH9drr72m9u3ba+TIkbr//vt17rnn2u9L+t/lVEeP9jpWt27d7Ce+R3+7fffdd2vo0KH2y6vrqkuXLvbi8jPPPKM//vhDkhQdHW0fuZ2Tk2Nv//bbb+u1117Tr7/+qhYtWigyMlKS9MUXX9jb1LQYpzPVz/2vv/6qnTt3SpLKy8v17LPP6q233lJJSUmdnr/du3frqaee0hNPPKHw8HANGzZM06dPt69S7mzx1+pv67dv365169bZt//nP/+RdGSal2M/TAAA4IrqL5pjY2PVvn17HT582D6au/oL6R9++EFz585VRkaG/vKXv2j06NGaN2+efTqx6pPj6s8KNc3JXi0mJsaew6vzs2EYuu666zR06FC98sorTm97ww03KCgoSMuWLXP4sjwwMFC9evWSJPsaJJK0fPlyvfTSS8rNzXWIr7YYAQDwNdXnriUlJfrhhx8kHRlU9+KLL2rhwoXavXu3w1XMw4YNU4sWLbRkyRL7tqNza10cPnxYSUlJuvbaazVv3jyHkeyGYWjr1q2SdNw568iRIxUQEKA1a9bUOJWLJI0YMUJdu3aVYRiaPXu2wxVzRUVFuvvuu7V27Vq99957FNDRpDASHTiBd955R4sXLz5u+7nnnqv7779fd9xxhyZMmKCcnBxdeeWVioyM1PLly1VZWal+/frZk8348eO1cuVK/fDDD/r73/+uqKgo5eTkKDQ0VMXFxU7vPzg4WAsXLtTBgwf1zTffqGPHjtq9e7eys7PVqlUrJSQkSPrfgh6bNm1SSkqKEhMTFRgY6NCX2WzWrbfeqnvvvVevvvqqtm/fbj8Zb9Wqla6//nqXn5/rrrtOH330kf744w97vxEREbryyiv12muvacaMGVq2bJkKCwu1cuVKtWvXThdffLH9OZk9e7Y+++wzXX311QoPD1deXl6d7zshIUHR0dHasGGDxo0bp8GDBysvL08//fSTBgwYoPHjx6u8vLzW5y8sLEzvv/++/vzzT61fv17dunVTUVGRPv/8c/n5+TnM2X60uLg4nX/++fr888913XXXacSIEdq1a5dWrVols9msmTNnOhQEAABwtrCodGSB7mPFxMTol19+UXp6ug4ePKh169apW7du+uWXX7Rx40bdfffduvzyy/XGG2/IZDIpOztb4eHh+v333/XLL78oIiJC55xzjiSpffv2ko58oT9jxgyNGzfuuPuLiIjQpEmT9MILL+iRRx7R999/r127dmndunWKiIjQhAkTnD62iIgIXXPNNXrqqafsa6NUu+mmmzRlyhQtWrRI27dvV0hIiL788kv7ZeTSkWlp/P39VVVVpbvuukvx8fEuTX0DAEB91Xb+f7LOPPNM+7nj5MmTNWzYMP3+++9at26devToocsuu0x9+vSx58Ebb7xR7du319dff63+/ftrzZo1mj9/vktzkLdo0ULTp0/XzJkzlZ6erry8PMXExMhkMumHH37QTz/9pFatWmnKlCkOtwsNDdWQIUO0bNkylZaWKjo6+rhCe4sWLTR//nxdc801ysrK0siRI3XuueeqoqJCq1at0v79+9W1a1c9+uijJ/2cAd6Ir4SAEzhw4ID++OOP436qFwjp27ev3nnnHSUkJNjnFgsMDNQNN9ygV155xT6aa8SIEZoxY4bat2+vTZs26ddff9X8+fPtc6XVNOe2dKQ4/uabbyohIUHffvutFi1apLVr12rIkCF67bXX7CPcL774Yg0ePFgBAQHKyclxOk3MFVdcodTUVP31r39VVlaWvv76a8XHx+vtt9/WmWee6fLz06JFC82aNUvSkcJA9Ujy6dOn64477tCpp56qzz77TBs2bNCFF16ot99+W+3atZMkTZgwQdddd53atGmj7777Tnv37rWvFn6i56Sa2WzWK6+8oiuuuEKGYeijjz5SUVGRrr76aj377LMymUx1ev5atWqlt99+WxdddJHy8/P17rvvauXKlerbt6+ef/55+4jzmjzxxBO69dZbFRERoU8++UQbNmzQ4MGD9frrr2v48OEuP58AgKZt/fr19vU5jv3ZtGnTce2nTZtmn/4kKytL559/vubPn2//kv7rr79Wnz599OKLL+rss8/WihUr9O677+qnn37SRRddpIULF9rz7vjx4xUbGyvDMLRixQr7AqLHmjp1qmbNmqXTTjtNS5Ys0ebNmzVq1CgtWrTohAtuS9KkSZNqnBpuxIgRevbZZ9W3b1+tWbNGy5YtU+/evfXyyy9r4MCBko4sFH7HHXcoPDxcv/zyi30xUgAAGktt5//18fjjj9sX/c7MzFRBQYHGjRun119/Xa1atVKXLl308MMPq0uXLsrLy9POnTv14osv6rbbblP79u31yy+/uBzHJZdcooULFyoxMVFFRUXKyMhQRkaGysrKlJSUpPfff7/GKd+OXuz82FHo1f76178qMzNT11xzjQIDA7V48WItXbpUbdq00a233qoPPvhAHTt2dO1JArycyTh2dQIAbrdjxw79/vvvMplMiouLkyTt3LlT5513nmw2m95//32nc501VQUFBdq+fbuCg4Ptifu7777T+PHjZTKZlJOTU+sc7QAAAAAAAEBDYzoXoBFs2rRJN910k0wmkwYPHqyOHTsqOztbNptN55xzTrMroEvSihUr9PDDDysgIMA+rUr1/KtjxoyhgA4AAAAAAACvwEh0oJEsXbpUr776qn755RdVVlaqU6dOio+P180336zg4GBPh+cR7733nhYtWqSCggJJUufOnTVq1ChNnjxZLVq08GxwAAAAAAAAgCiiAwAAAAAAAADglEcXFt22bZuuueYaxcbGauDAgXrsscdks9mOa2ez2TR//nwNGzZM/fr10+jRo7VkyRL7/gkTJqh3796Kjo62/4wZM6YxHwoAAM0CuRsAAN9C7gYAoP48Nie6YRiaMmWKevTooeXLl2vv3r2aPHmyTjnlFE2aNMmh7dtvv620tDS98cYbOu2007RixQrddNNNioqKUs+ePSVJDz74oCwWiyceCgAAzQK5GwAA30LuBgDAPTw2En3Dhg3Kz8/XrFmzFBYWpu7du2vy5MlatGjRcW1//PFHnXXWWYqKipKfn58SEhIUGhqqzZs3eyByAACaJ3I3AAC+hdwNAIB7eGwk+qZNm9S5c2eFh4fbt/Xu3VsFBQUqLS11WGgxISFB9957rzZv3qwePXooKytLFRUV6t+/v73N4sWLtWDBAu3fv18xMTG65557dNpppx13v1VVVTpw4IBatmwpPz+PzmYDAMAJ2Ww2VVRUKCwsTP7+HkvZduRuAABOjNx9BLkbAOAr6pq7PZbVCwsLFRYW5rCt+vfCwkKHZH7++edr06ZNGjt2rCSpdevWmjt3rjp27ChJ6t69u1q3bq1HHnlEfn5+euihhzR58mRlZmaqRYsWDvdx4MABFRQUNOAjAwDAvSIjI9W2bVtPh0HuBgCgjsjd5G4AgG+pLXd7rIhuMpnq3DYjI0MfffSRMjIy1L17d+Xm5mrq1Knq2LGjYmJidN999zm0f+CBB9S/f3998803GjRokMO+li1bSjryxLRq1arej+NoVqtVP//8s04//XSZzWa39t1QfDFmOOIYAp7VkK/BQ4cOqaCgwJ67PI3c7R18MWY44hgCnkXurhm5u+H4YsxwxDEEPMsbcrfHiugREREqKipy2FZYWGjfd7SFCxdq3Lhx6tWrlyRp6NChGjBggDIyMhQTE3Nc38HBwQoPD9eePXuO21d9KVnr1q0VGBjojodiZ7VaJUlBQUE+86bqizHDEccQ8KyGfA1Wn/h6y2XQ5G7v4IsxwxHHEPAscje5u7H5YsxwxDEEPMsbcrfHiujR0dHasWOHCgsL1aZNG0nS+vXr1aNHDwUFBTm0NQxDNpvNYVtVVZX8/PxUWlqqefPm6eabb7YPuS8sLFRhYaG6dOnicly//fabNm/erMrKSpdva7PZ9Ntvv+mPP/7wmg9NJpNJoaGhOvvssxUSEuLpcAAAPozc3ThMJpPatm2rs88+2+2j9wAAzQu5u3Fw3g0ATZ/Hiui9evVSTEyMHnroId17773auXOnXnjhBd14442SpFGjRumhhx7S3/72Nw0bNkxpaWk6//zz1a1bN61Zs0a5ubmaOHGigoODtX79es2ePVv33XefrFar7r//fvXq1Uv9+vWrczx79uzR7bffro0bNx73wcEVlZWVCggIOOnbNwSTyaQWLVro//7v/3TjjTe6dEkfAADVyN2Nx2QyKSgoSLfeeqvGjRvn6XAAAD6K3N14OO8GgKbNo8uFz58/X/fcc4/i4+MVFBSk8ePHa/z48ZKkLVu2qLy8XJJ0ww03qKqqStdff73279+vTp066b777tPgwYMlSU8//bRmz56t4cOHy2w2q3///nruuefq/K20YRi66aabtHfvXt1xxx2KiYk56ZFf5eXlbr9crT5sNpv27dunpUuX6sUXX9Qpp5yiyy+/3NNhAQB8FLm74dlsNu3atUsfffSR5syZo1NPPVVDhgzxdFgAAB9F7m54nHcDQNNnMgzD8HQQjam8vFw//vijevXqZU+6Gzdu1FVXXaVZs2apb9++9e7fm5L50ebNm6cDBw7onXfesW+zWq36/vvvFRsby7xePopjCHhWQ74Ga8pZzVFzzd2GYeiuu+5SZGSkUlNT7dt53/d9HEPAs8jdDa+55m6J8+6mimMIeJY35G7vmEDMw/Ly8mQymRQdHe3pUBpUbGys8vPzT2reOQAAvElzyN0mk0kxMTHasGGDp0MBAKDemkPuljjvBoCmyqPTuXiLiooKtWzZ0ullaI899phycnL0xhtvKCws7KTuY9++fZo/f74KCgrk7++vcePGadSoUTW2feutt7R06VIZhqHevXvr1ltvtV/mtn//fs2bN0+///673nrrLYfbffPNN3rppZdktVrVtm1bpaSkqH379vb91X1UVFR43fxxAAC4wpdy99tvv62vvvpKktShQwfdfPPN6tChgyoqKrRgwQJt2LBBhmGoW7dumjJlikJDQ+39BgYGqqKi4qTiBwDAm/hS7nZ23v3UU09p48aN9t+tVqt2796tTz75xL6N824AaJoYif5fzhb9KC0t1erVq9WjRw99+eWXJ93/U089pa5du+qNN97QI488ooULF+rXX389rl12draysrL09NNP67XXXpMkvfHGG5KkvXv3avr06erWrdtxt/vzzz/1zDPP6L777tNLL72kfv362U/Ya3uMAAD4Il/I3Z9//rlWrlyp+fPn68UXX1TXrl31/PPPS5LeeecdlZSU6Pnnn9eCBQtks9m0cOHCk44XAABv5wu5+0Tn3bfccosWLFhg/7nwwguPK9Jz3g0ATRNF9FpkZWXp9NNPV2Jior744guHfZ988oleeumlWvsoLy/X2rVrdemll0qS2rdvr3PPPVcrVqw4rm12drYuuOAChYSEyM/PT2PHjtXy5cslSX5+fnrwwQfVv3//42735ZdfaujQoerYsaMk6YorrmAhEwBAs+RNubtbt266/fbb7XPrnXXWWdq2bZsk6eyzz9bVV18ts9kss9ms2NhYbd++vV6PHQAAX+RNuftE591H279/vzIzMzVx4sS6PkwAgA+jiF6LpUuXavjw4Ro4cKB2796tn3/+2b5v9OjRuvbaa2vtY8eOHWrZsqXatGlj39axY0dt3br1uLbbt29Xp06d7L936tRJRUVFKikpUUREhE499dQa7+O3336Tn5+fZs6cqcmTJ2vu3Lk6cOCAKw8VAIAmwZtyd/fu3dWjRw/7vtzcXPXu3VuSFB0dbf/yu7i4WCtWrFBcXJzrDxgAAB/nTbn7ROfdR3vrrbd00UUXKSQkpNa2AADfRxH9BH799Vft2LFD8fHxatWqlYYMGXLct+J1cejQIbVo0cJhW4sWLXTo0KFa21b/u7b5UEtLS7Vu3TpNnz5dzz77rPz8/PTvf//b5VgBAPBl3py7Fy9erG+//Vb/+Mc/HLbfdddduuqqq9SpUyddfPHFLscKAIAv8+bc7UxRUZFyc3OVmJjocpwAAN9EEf0Eli5dqsGDB9sXBhkxYoSysrJqXWU7Pz9f119/va6//nqlpqaqdevWKi8vd2hTVlam1q1bH3fbY9uWlZVJ+t/iJM4EBwdryJAhCgsLU0BAgC655BKtXbtWhmHU6bECANAUeGvufuutt/TRRx9p7ty5Cg8Pd7j9I488ovfee08mk0mPPfaYS48XAABf5625+0Q+//xz9e/fv8a+AQBNk7+nA/BWlZWVWr58ue6++277tr/+9a8KCwtTbm6uhgwZ4vS2PXv21IIFC+y/Hzx4UDabTX/++afat28vSdq2bZu6du163G27dOniMB/qtm3b1LZtWwUHB58w3o4dO6q0tNT+u8lkktlsZlETAECz4a25+6233tLatWv12GOPKTQ01N5u5cqV6tmzp0455RS1atVKF110ke66666TfwIAAPAx3pq7a/Ptt99qzJgxdWoLAGgaGInuxKpVqxQSEmKft7TaiBEj9Pnnn7vUV+vWrTVgwABlZGRIOjJX2zfffKNhw4Yd1zYhIUHLli1TSUmJrFarMjIydN5559V6H8OHD9eXX36p/fv3S5KWLFmis846y6U4AQDwZd6Yuzdt2qQvv/xSDzzwgEMBXTqyqNmbb74pq9Uq6ch86d27d3cpTgAAfJk35u7aGIah/Px8RUVFuRQfAMC3MRLdiXXr1qmoqEjXX3+9w/aKigrt27dP0pFVwnfv3l2nRU6mTJmixx9/XBMnTlRAQIBuuOEG+zfir732msLCwnTJJZdowIAB+v333zVlyhQZhqF+/frpyiuvlCQtW7ZM7733nioqKlRcXGyPbcGCBeratauuvPJK3XnnnTIMQ5GRkZoyZYo7nxIAALyaN+bujz/+WKWlpZo6dapD3/Pnz9c///lPPfvss7r++utlMpl06qmn6vbbb3fHUwEAgE/wxtx9ovNu6chi4JWVlcdNzwYAaNooojtx22236bbbbjthm9GjR9e5v7CwMN1///017jt2gbFx48Zp3Lhxx7UbPny4hg8f7vQ+zj//fJ1//vl1jgkAgKbEG3P3iaZnadWqlWbMmFHneAAAaGq8MXfXdt4dFham//znP3WOCQDQNDCdCwAAAAAAAAAATlBEBwAAAAAAAADACYrokgICAnT48GEZhuHpUBpURUWFJKlFixYejgQAgPppTrk7ICDA02EAAFBvzSl3S5x3A0BTQxFd0hlnnKGqqir99NNPng6lQW3atEldu3YlmQMAfF5zyt09e/b0dBgAANRbc8rdnHcDQNNDEV1Sv3791LlzZz333HP67bffmtw34xUVFfrss8+0fPlyJSYmejocAADqrann7rKyMr3//vvatGmTLr74Yk+HAwBAvTX13M15NwA0bf6eDsAb+Pn56ZlnntE///lP3XnnnQoKClLLli1d7scwDFVWViogIEAmk6kBInWdYRgqKSmR1WpVYmKiJk2a5OmQAACot6acu202m4qLiyVJ1157rS688EIPRwQAQP015dzNeTcANH0U0f8rMjJSn3zyib755hvl5+fr8OHDLvdhs9m0detWdenSRX5+3jPIPywsTAMHDlTXrl09HQoAAG7TVHO3yWRS27ZtNXjwYLVv397T4QAA4DZNNXdLnHcDQFNHEf0o/v7+GjhwoAYOHHhSt7darfr+++8VGxsrs9ns5ugAAMCxyN0AAPgWcjcAwBd5z9e2AAAAAAAAAAB4GYroAAAAAAAAAAA4QREdAAAAAAAAAAAnKKIDAAAAAAAAAOAERXQAAAAAAAAAAJygiA4AAAAAAAAAgBMU0QEAAAAAAAAAcIIiOgAAAAAAAAAATlBEBwAAAAAAAADACYroAAAAAAAAAAA4QREdAAAAAAAAAAAnKKIDAAAAAAAAAOAERXQAAAAAAAAAAJygiA4AAAAAAAAAgBMU0QEAAAAAAAAAcIIiOgAAAAAAAAAATlBEBwAAAAAAAADACYroAAAAAAAAAAA4QREdAAAA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srIkSYcPH3bkw7Hy+++/F9vYxcUda4Y1XkPAuYrzPZiVlWWVi85CdrsWd6wZ1ngNAeciu8nukuaONcMaryHgXM7Mbqc10U0mk93r5uTkKDo6Wm3btpUkPfDAA/r888/13XffqX379oW6j6CgINWpU0fly5eXh4dTr6sKAMBV5ebmKisrK98HVmchuwEAuDqy+zKyGwDgLuzNbqc10UNCQpSammq1LCUlxXLblYKCghQQEGC1LDQ0VGfOnLGsm5qaKl9fX0mXL56SmpqqSpUq5btfLy+vApcDAOCKXOEotjxkNwAA10Z2k90AAPdiT3Y77Svh8PBwnThxwhLgkpSYmKj69evLz8/Pat1GjRppz549Vsv++OMPhYaGqmbNmgoODra6PSkpSdnZ2WrcuHHxPggAAMoQshsAAPdCdgMA4BhOa6I3bNhQERERmjp1qtLS0pSUlKSFCxeqf//+kqQuXbrol19+kST16NFDSUlJWrp0qbKysvT1119rz549uu++++Tp6anevXtrzpw5OnbsmM6ePavp06erc+fOuuGGG5z18AAAKHXIbgAA3AvZDQCAY5gMR18quxBOnjypiRMnavPmzfLz81O/fv00YsQISVKDBg307rvvWuZj27p1q1555RUdOnRItWrV0rPPPmu57dKlS3r11Vf1zTffyGw2q3379po8eXK+U9EAAEDRkN0AALgXshsAgKJzahO9NNm3b59effVV7d69W15eXmrRooX++c9/qkqVKs4uzaYGDRqoXLlyVheC6d27t1588UUnVoWrSUhI0Lhx49SiRQu98cYbVrd9++23+te//qUTJ06odu3aGj9+vFq3bu2kSoHS6fjx43rllVe0bds2eXp6KioqSv/85z8VFBSk3377TS+99JL27t2r4OBgDRo0SIMGDXJ2ybgKshslgewGnIvsLl3IbpQEshtwLlfNbi6T7QCXLl3S4MGDdfvtt2vDhg367rvvlJycrMmTJzu7tGtatWqVdu3aZfmPIHdd7777rqZOnaratWvnu2337t0aN26cnn76aW3dulUPP/ywnnzySZ08edIJlQKl1+OPP67g4GD9/PPP+uqrr3TgwAG99tprunDhgoYOHapmzZpp48aN+te//qW33npL33//vbNLhg1kN0oC2Q04H9ldepDdKAlkN+B8rprdNNEd4MKFC3rmmWc0bNgw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+vTpo2effbbQtQEASh+ym+wGALgXspvsBhyB6VwAJ5s4caIWLVpkdRGNvG+4s7OzLctOnjxZpPu5cvyMjAylpqaqatWqqlWrljIzM61uT09PV0pKit1j16pVS4cPH7b8nJOTo+PHj6tmzZrX3LZixYpq0aKFZY60K128eFExMTHatm2bze0DAgIUHBys/fv3Wy2359v4gqSmpqpcuXLq0KGDJk+erLfffltLly69rrEAAKUT2U12AwDcC9lNdgNFRRMdcLKGDRuqR48emjNnjmVZSEiIAgMDLSG2f/9+bd68uUj3s2PHDq1fv16XLl3Shx9+qKCgIEVGRurmm29WZGSkpk2bppSUFKWlpWnSpEkaN26c3WP37NlT//73v/X777/r4sWLWrBggQzDUIcOHezafsKECdq1a5cmTpyoU6dOyTAM7du3T48++qi8vLyu+k23JPXt21cLFizQgQMHlJ2drUWLFqlnz566cOHCNe87b8fp4MGDSk9PV58+ffTuu+8qKytLOTk52r17t107JQCAsoPsJrsBAO6F7Ca7gaKiiQ64gFGjRiknJ8fys4eHhyZNmqR3331XnTp10vz589W3b1+rdQojOztbvXr10meffabmzZvr22+/1Zw5c+Tt7S1Jmj17tnJzc9WhQwd16NBB2dnZevXVV+0ev2/fvuratasefvhhtW7dWps2bdJHH32kwMBAu7avX7++YmNjdfHiRT3wwANq2rSpRo4cqVtvvVWLFy+21GnLE088odatW6tfv366/fbbtWrVKr377rvy8fG55n03bNhQkZGRevDBBxUbG6u5c+cqISFBrVq1UsuWLbVmzRrNmjXLrscBACg7yG6yGwDgXshushsoCpNR0IRPAAAAAAAAAACAI9EBAAAAAAAAALCFJjoAAAAAAAAAADbQRAcAAAAAAAAAwAaa6AAAAAAAAAAA2EATHQAAAAAAAAAAG2iiAwAAAAAAAABgA010AAAAAAAAAABsoIkOAAAAAAAAAIANNNEBAAAAAAAAALCBJjoAAAAAAAAAADbQRAcAAAAAAAAAwAaa6AAAAAAAAAAA2EATHQAAAAAAAAAAG2iiAwAAAAAAAABgg5ezCwBKsw4dOuiPP/7It9zX11cNGjRQ37591aNHD6fUNH36dMXExJTofRdUhy0DBw7UP//5zxKsCABQUgrKgHLlyqlq1apq2LChnnjiCd1yyy2FGvP555/X8uXLdf/99+vVV191ZLklwp7633zzTc2bNy/f8nLlyunGG29Uhw4d9OSTTyooKKi4y81XU/PmzbVkyZISu19bddgSEBCgX375pQQrAgCUFomJierTp49yc3M1f/583XnnnVa39e7dW4Zh6O2331aHDh0kST/++KO++OIL7d69W+fOnVNwcLBq1Kihbt26qVevXvL29raMURz7RQAcjyY6UAIiIiLUtGlTSZJhGNq/f782b96sHTt26MyZM3r00UedW6ATXfncXKl58+YlX8w1ZGVl6Y477tBdd93llg0aAHA1V2ZAVlaWNm3apB9++EHr1q3Tl19+qXr16jm3QBfl6+urnj17Wn5OSUnR6tWrtXjxYm3evFmxsbEqV66cEyt0nr8/N3kqVKjghGqu7cUXX9Tnn3+upKQkZ5cCALAhIiJCAwYM0OLFizVt2jS1adNGFSpUUG5uriZPnizDMNShQwd16NBBhmFo/PjxWr58uTw8PNSqVSvVqFFDhw8ftvQAVqxYoffee09+fn757seV94vILJR1NNGBEnDHHXfomWeesVr20ksv6dNPP9XChQs1aNAgeXp6Oqk65yrouXFVq1evVnp6urPLAIBS4+8ZkJ6ervbt2ystLU1fffWVRo8e7cTqXFdAQEC+s7USExPVq1cv7du3Tz///LM6derkpOqcq6DnxlVdunRJ//nPf5xdBgDADqNGjdKPP/6oP/74QwsWLNDTTz+tpUuXas+ePfLx8dGECRMkSZ9++qmWL1+ucuXKacGCBWrdurVljLVr1+qJJ57Q9u3b9f7772vkyJFW9+HK+0VkFsCc6IDT5IXpuXPnlJycLEm6cOGC3njjDXXq1ElNmjRRdHS0Jk2apNTUVMt2zz//vBo0aKA333xT3333nbp06aLGjRvrvvvu065du6zuY/ny5erUqZPCw8PVvXt3rVu3rsBakpOTNWXKFEVHR6tx48Zq1aqVRo0apf/+97+WdTZv3qwGDRqoQ4cOOnjwoPr27auIiAjdfffdWr9+vU6fPq0hQ4aoSZMmat++vRISEhz2XG3ZskVDhgzRbbfdpsaNG6tz587617/+paysrHzPy5w5c/Tiiy8qMjJSX3/9tSTp1KlTGjdunDp27Kjw8HDdfffd+uKLL6zu4/DhwxozZozatWun8PBwtW/fXlOmTFFaWpokacCAAZYdmuXLl6tBgwbavHmzwx4jAEDy9/dXzZo1JUnnz5+3LLcnHwty/PhxjR07Vu3atVNERIS6dOmi9957T7m5uZZ1OnTooAYNGmjjxo2aM2eO2rRpo4iICA0fPlxnz561Gu8///mPevXqpSZNmqhVq1YaPny4fvvtN6t1duzYoUcffVStW7dWkyZN9OCDD2rbtm1W6+SdFh4REaEOHTroww8/vJ6ny0pERIQCAwMlXc60PD/++KP69u2r22+/XbfffrsGDBhgVc+V+f7HH39oyJAhatq0qVq1aqWFCxda3cehQ4c0ePBgNWnSRG3atNHs2bNlNpsLrOeLL75QTEyMmjRpoqZNm6pXr16Ki4uzWifvud+wYYOmTp2q2267TS1atNBrr71mOWW+VatWioyM1CuvvGLzvgqrMPs97du31+rVq9WuXTsNHjxYkpSbm6tFixapR48eioyMVKtWrTRhwgTLPoMk5eTk6O2339a9996ryMhItWjRQkOGDLFMKbNs2TKFh4fr3LlzkqQGDRro+eefd8jjAwA4nq+vryZNmiRJeu+99/Trr79qzpw5kqTHH39coaGhkmTJ9N69e1s10CWpbdu2euGFFzRv3jwNHz78mvdpa79Ikn744Qf169dPkZGRioiI0H333adFixZZ7eNI9n2WJrMA+9BEB5wkJSVFkuTl5aXg4GBJ0j//+U+98847ysrKUo8ePeTt7a2lS5cWGFDr1q3TjBkzFBkZqRtuuEFJSUkaNmyYLl68KEnauHGjnn/+eR05ckTNmjVTs2bNNG7cuHwNh7S0NPXp00effPKJPDw8dN9996lKlSpauXKlevXqZfWBUrrc9B87dqxq1aqlihUr6uDBgxozZozGjh0rPz8/hYaG6sSJExo1apQyMjKK/Dz9+OOPeuSRR7Ru3To1atRI9957r5KTkzV//nwNHz5chmFYrf/tt99q48aN6tatm6pVq6b09HT169dPcXFxCggIUPfu3ZWenq4JEyZo2bJlki6fKjdw4ECtWLFC9erVU8+ePVW1alV98skneuyxxyRJnTt3tpw+V69ePQ0cOFA33nhjkR8fAOD/paen6+jRo5IuN4XzFCYf82RlZenhhx/WN998o8qVK6t79+46ffq0Zs6cqcWLF+dbf+7cuYqPj9cdd9whDw8P/fzzz1ZHNC9fvlwjR47U7t27FR0drSZNmujnn39Wv379LFm5e/duDRw4UAkJCWrYsKE6d+6sPXv2aPDgwZZ1zp07p8GDB2vnzp0KDQ1Vhw4d9Omnnxb5y+eLFy8qMzNTklSpUiVJl494GzFihH799Ve1a9dOzZo105YtWzR06FCdOHHCavtz587p8ccfl7+/v8LDw5WcnKzZs2fr+++/l3T5A/ajjz6q9evXq2LFiurSpYvWrl2b70tpSZoxY4YmTJig/fv3q0OHDoqKitLu3bs1bty4Auctf+ONN/T777/rlltuUWpqqt5//32NHj1aq1atUrNmzZSZmamPPvrI8uV4URR2v+f8+fOaPHmymjdvrpYtW0qSZs6cqenTp+v48ePq0qWL6tatqy+++EJPPvmkZbvZs2drzpw5unjxou677z5FRUVp8+bNeuSRR/T777+rfv366ty5s2X9gQMH5mu2AABcS7t27dS1a1ddunRJAwYM0Llz51S3bl3Ll6x//vmnjh07Jkk2/6b369dPd911l9Wc6LbY2i/6+OOPNWLECG3fvl0tWrTQXXfdpcOHD2v69OlW+y72fpYmswD7MJ0LUMJyc3O1f/9+vffee5Kku+66S+XKlZPZbFZwcLD69OmjmJgYNW3aVDt27FDfvn21du1aXbx40Wo+z3379mnVqlWqVq2aDhw4oHvuuUdnz57V9u3bdccdd1gaBM2aNdOiRYtkMpnUtm3bfN94f/jhhzp69KgqVaqkr776SgEBAcrOzlbPnj21b98+zZs3z/INu3Q5yPv3768HHnhAW7du1UMPPaSUlBTVrl1bU6ZM0Z9//qn27dsrPT1d27dvV1RU1HU/V4ZhaNq0aTKbzerTp4+mTJki6f9PWd+wYYPWrl2rdu3aWbY5ffq0fvrpJ4WEhEiSFi1apOPHjys0NFSff/65vL29dejQIXXp0kXz5s1TTEyMfv/9d506dUp+fn5677335OHhodzcXM2ZM0cBAQG6ePGiHnroIe3evVsHDhxQRESE25wqDgCubMOGDZbGb97cn+np6brvvvvUrVs3SSp0Pub5888/LR/wRo8ebbmg1+uvv67//Oc/GjRokNX6WVlZ+uKLL1SuXDlFRkZq8uTJio+P16VLl1SuXDm98cYbkqShQ4daTqcePXq0fv75Z33yySeaNGmS5s+fr0uXLqlr166aPXu2JOnWW2/VxIkT9d577+nVV1/VsmXLdP78efn6+mrp0qUKCgrS448/ro4dO17385iSkqK5c+cqJydHvr6+at++vaTLR1z37t1bdevW1SOPPCJJ6tKliw4dOqSEhAT16dPHMkZ6erp69OihwYMHyzAM9e3bVzt37tT333+vTp066aefftLx48dlMpn0wQcfqG7dusrKytJdd91lVcuRI0csR+HNmDFD9957r6TLR+3NnDlTCxcu1IABA6wuflqhQgUtWrRIhmGoS5cuOnLkiDZu3KiffvpJfn5+euSRR7Rx40YlJCTo/vvvv+7nSSr8fs/58+f1zDPPqH///pKks2fP6qOPPpIky5kLktS3b19t2bJFmzdvVosWLSxn/40dO1Z33323pMtH3h84cECXLl1SRESE+vfvbzk1nv0KAHAP48eP16pVqyxHco8ZM8ZyHZJTp05Z1qtevXqhx7Znvyg9Pd2yjzF69GjLQV8rV67UqFGjtGzZMj366KOqW7eu3Z+lySzAPjTRgRLwzjvv6J133sm3vHnz5pZTwjw9PfXCCy9o9erVWrt2rb799lvL/Ntms1lnz561nCKWt221atUkXT4yumLFikpJSdFff/0lSZbTyzt27CiTySRJio6Olq+vryWYpctBLV3+Vj0gIEDS5SuB33XXXdq3b5+2bNmSr+68q5GHh4dblrVt21aSVK1aNYWEhOjs2bP5ToMvzHMzffp0NW3a1HKV8rydBunyt/A1atTQ8ePHtWXLFqsm+m233WZpoEvS9u3bJUkeHh6aOXOmZbmnp6f++OMPnT17VjfeeKMqVKigjIwM3XfffWrXrp0iIyP12GOPyd/f/5qPAQBwfRITE5WYmGi1rHLlygoJCVFaWppCQkIKnY956tSpo2effVYrV67U+++/r4sXL+rAgQOSZMnKK91zzz2WD8G33XabpMtf5p49e1YXLlywfDDOa1BL0uuvv241Rl7mnDp1Sq+88ook6cyZM5bHKkl79+6VJN1+++2WRnKlSpXUvHlzrVmzxq7n7dSpU2rQoEG+5SEhIZoxY4YlB3v06KF//OMfSkhI0PTp05Wbm2vZBzh9+nS+7bt37y5JMplMatasmXbu3GlZL6/uevXqqW7dupKk8uXLq2PHjvr0008tY2zcuFGGYcjLy0tdunSxLL/nnns0c+ZMZWVlaefOnVbZnbevYjKZdMstt+jIkSO67bbbLBdca9SokTZu3GjXfoWt56Z58+ZasmTJde33XHn0XWJionJyciRdPpU+7zXLO/suMTFRLVq0UJ06dbR//369+OKLWrNmjSIjI3XHHXfonnvuueZjAAC4rh9//NGSA9LlLMj7fJz3uVuS1RRkGRkZatasWb6x/n6BTnv2i3bs2GHJ8q5du1rW69y5s7y8vJSTk6PNmzfLZDLZ/VmazALsQxMdKAFXXmV7x44d2rVrl2rXrq0PP/xQXl6X34YXLlzQwIED84Vmnr9PW5J3qnYeX19fpaSkWOZAy/ugeWUT2GQyKTAw0KqJnjetTMWKFa3Gy/s5b96zK+V96L/yyL+8D6JXLv/7fGwFufK5uVL9+vUttdmq7/jx4/nqu7KBLv3/3HHHjh2zHDl2pZMnT6pRo0Z65513NG3aNO3fv1+///67JMnPz08jR460HL0HAHCs4cOHW643kZubq2PHjmn69OlatGiR1q9fr+XLlysnJ6dQ+Zjn0KFD6tu37zXnTc9zZa76+PhY/m02m63yKG/e8YLkNfe3bt2qrVu3Wt128uRJSbJcB+XvX9JeeWT2tfj6+qpnz56SLufc8uXLJV2ekqZ58+aW9d5//3299tprBY5R0PN25XPg6+sr6f+z3N66856rwMBAq4umX5njf8/uK5/TvH2I692vuPK5uVLt2rWt6ivMfs+V+xZXzkm7dOnSfOvmfdkydepUeXp66ocfftDy5cstr1F0dLRmz57Nl/QA4IZOnz5tOQq8e/fu+uqrr/TVV1+pV69euu2226ym+zxx4oQaN24s6fKXtQMHDpR0OSdsXaDTnv0iW5+RPTw8FBgYqOTkZJ07d65Qn6XJLMA+NNGBEnDlVbaPHTumrl276siRI/rggw8sp1+tWLFCiYmJMplM+vDDD3X77bfryJEj1/3tb3BwsE6fPm350CtdbgT8vZlQsWJFHTlyxGo96f+b8H9vSjva369AfqWDBw9a1VO/fv189f39ywQPD+tLPeR9MO/YsaPeeustm3W0atVK33zzjY4fP64dO3ZozZo1WrFihaZPn67IyEg1adKkcA8MAFAoHh4eql27tp588kn9/PPP+v3333XgwAHt2rXruvLxrbfeUmpqqkJDQ7Vo0SLVrFlTS5cu1eTJkwtd25VN3is/lGZkZOj8+fPy8vLSDTfcYPnwOn78eJtfwOZdB8VW7tojICDA6lTq06dPa926dXr55Ze1fPlyeXl56eLFi5YpaPr27atnn31W/v7+6t27t3799Ve776uwdV/ZjM7JybEcLJB3RL6UP7sd6e/Pzd9dz37PlfsWV35psHXrVptfqgQFBWnOnDlKT0/Xr7/+qm3btunf//634uPjNWvWrOv6PQQAONcrr7yitLQ0RUZGasaMGcrIyNCPP/6oKVOmaPny5apatapq166tI0eOaMWKFerUqZMkydvb25JNmzdvttlEv5Kt/aIrG+Jnz55VjRo1JF2+dkleU7xSpUr51rvaZ2kyC7APFxYFSljNmjU1dOhQSdL8+fMtFwrJCzx/f3+1bNlSXl5eVuF66dKlQt1P3qnMP//8s+XIrRUrVlguPJonb77Y+Ph4yxF0ly5dslxILG+uT2cICwuznKL/7bffWpZv27bNckG0a13Q5NZbb5V0+QyAvFOtT58+rfnz52vp0qXKycnRr7/+qhkzZiguLk41atRQt27dNGvWLMuFRPNOg8s7Pe/KI/kBAI515YUdvb29rzsf87Zr0KCBatWqJcMw9MMPP1x1G1vq1q1raa6uXr3asvzFF19Uu3btLFO35GXO+vXrLets375dCxcu1I8//mipR7qcS3mN3GPHjhU4jYi9XnzxRXl7e2v//v16//33JV3OquzsbEmXs9zf319HjhyxTPd2vfsVR48e1f79+yVdPio7b38hT96FWc1ms1atWmVZvmLFCkmXz/Iq6Ay0klLU/Z7w8HDLtD95c8hK0qeffqpFixbpwIEDSk9P11tvvaUpU6bI19dXrVu31siRIzVkyBBJ0vHjxyVZn/bviIuxAwCKz5o1a7Ry5Up5eHho4sSJMplMeuGFF+Tj46OkpCR9/PHHkmS5yOiPP/5oyf482dnZWrt2baHu9+/7RZGRkZazxa78jPzdd9/JbDbLw8NDrVq1svuzNJkF2I8j0QEneOyxx/TVV1/p6NGjmjRpkj788EPLkc7nz5/X8OHDZTKZdPDgQTVo0EBJSUmaMmWKRo0aZfd99O/fX+vWrVNiYqL69eunWrVqaf369QoODrY6Gv3hhx/W119/rWPHjikmJkbNmzfXr7/+qt9//10VK1bUiBEjHPzo7WcymTR+/HiNHDlSn332mf7880+FhIRYmiCdO3e2Om29IDExMVq0aJH++OMPxcTEqFmzZvrll1909OhRxcTEqG/fvvL09NRHH30kk8mkhIQEBQcH68iRI/rvf/+rkJAQ3X777ZKkKlWqSLr8xcT48ePVu3dvRUZGFu+TAACl2JUX0DIMQ2fOnNFPP/0k6fK1NurWrWs5Wqqw+RgREaE1a9YoISFBzz//vH7//XdVqVJFnp6eOn36tJ577jmNHz/erjo9PT319NNPWzL7jz/+0KVLl/Tzzz+rQoUKGjZsmCRp2LBhio+P19q1a9WvXz+FhoYqPj5eaWlpevXVVyVdzqX58+fr4sWL6t27t5o3b661a9eqevXqOnLkyHU9j3Xq1NHgwYP1zjvvaP78+erSpYtq166tmjVr6tixY3rttdf0008/KT4+Xm3bttWPP/6or776SpUqVVLDhg3tuo+77rpLlStX1unTpzVo0CC1bdtW27ZtU1BQkNV+Ra1atTRw4EAtWrRI48eP15o1a5SRkWF5XceMGWOZ69wZirrfExISov79+1se3+rVq5WSkqL169ercuXKuvfee+Xv768ffvhBe/fu1e7duxUeHq6MjAzLFzB5c6xXrVrVMu7w4cPVoUOHfBe8BQA4X2Zmpl566SVJl8/uuuWWWyRJoaGhGjZsmObMmaM333xT9957r/r06aNff/1Vy5Yt04gRI9SiRQuFhYUpNTVVW7dutZyZVdAZdfbsF0nSqFGjNG3aNM2ZM0d79uyRl5eX5cvgRx55RDVr1pQkuz9Lk1mAfTgSHXACb29vTZgwQdLloIyLi9Ptt9+uZ599VlWrVtXmzZuVnZ2t999/X08++aSCg4O1a9euAufptKVDhw4aP368qlSpoj179ujAgQOaO3duvquEBwUFaenSperdu7cuXLhgmWetR48eio2NLfBibSXprrvu0gcffKDmzZtr27Zt+u6771S9enU9++yz+S7oVhB/f399+umn6tq1q86dO6evv/5aZrNZzzzzjOUK5Y0bN9a7776rW2+9VWvXrtVnn32m/fv365577tGSJUtUuXJlSVK/fv3UtGlTGYahtWvX5juqHwBQOImJifroo4/00UcfacmSJVq/fr3q16+v8ePHa/78+ZJ03fk4ZMgQ3X///fL19dVPP/2kRo0a6Y033tCgQYNUvnx5bd682eqiX9fSt29fzZ49W7fccovi4+O1efNmRUVF6dNPP9U//vEP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", "text/plain": [ "
" ] @@ -216,43 +709,43 @@ "\n", "Logistic Regression:\n", "----------------------------------------\n", - " 3 clients: 0.7577 ± 0.0196 [0.7390, 0.7780]\n", - " 5 clients: 0.7694 ± 0.0413 [0.6970, 0.7960]\n", - " 10 clients: 0.7258 ± 0.0275 [0.6920, 0.7750]\n", - " 20 clients: 0.7255 ± 0.0637 [0.5980, 0.8580]\n", - " Performance degradation (3→20 clients): 4.25%\n", + " 3 clients: 0.7440 ± 0.0046 [0.7390, 0.7480]\n", + " 5 clients: 0.7422 ± 0.0097 [0.7300, 0.7560]\n", + " 10 clients: 0.7484 ± 0.0154 [0.7240, 0.7830]\n", + " 20 clients: 0.7285 ± 0.0407 [0.6290, 0.7960]\n", + " Performance degradation (3→20 clients): 2.09%\n", "\n", "ElasticNet:\n", "----------------------------------------\n", - " 3 clients: nan ± nan [nan, nan]\n", - " 5 clients: nan ± nan [nan, nan]\n", - " 10 clients: nan ± nan [nan, nan]\n", - " 20 clients: nan ± nan [nan, nan]\n", - " Performance degradation (3→20 clients): nan%\n", + " 3 clients: 0.7450 ± 0.0026 [0.7430, 0.7480]\n", + " 5 clients: 0.7452 ± 0.0156 [0.7270, 0.7700]\n", + " 10 clients: 0.7443 ± 0.0189 [0.7000, 0.7670]\n", + " 20 clients: 0.7219 ± 0.0297 [0.6510, 0.7550]\n", + " Performance degradation (3→20 clients): 3.09%\n", "\n", "Linear SVC:\n", "----------------------------------------\n", - " 3 clients: nan ± nan [nan, nan]\n", - " 5 clients: nan ± nan [nan, nan]\n", - " 10 clients: nan ± nan [nan, nan]\n", - " 20 clients: nan ± nan [nan, nan]\n", - " Performance degradation (3→20 clients): nan%\n", + " 3 clients: 0.7437 ± 0.0127 [0.7290, 0.7520]\n", + " 5 clients: 0.7460 ± 0.0152 [0.7310, 0.7680]\n", + " 10 clients: 0.7507 ± 0.0112 [0.7380, 0.7790]\n", + " 20 clients: 0.7269 ± 0.0428 [0.5920, 0.7790]\n", + " Performance degradation (3→20 clients): 2.25%\n", "\n", "Random Forest:\n", "----------------------------------------\n", - " 3 clients: 0.5263 ± 0.0106 [0.5150, 0.5360]\n", - " 5 clients: 0.5116 ± 0.0135 [0.4980, 0.5260]\n", - " 10 clients: 0.5000 ± 0.0000 [0.5000, 0.5000]\n", - " 20 clients: 0.5000 ± 0.0000 [0.5000, 0.5000]\n", - " Performance degradation (3→20 clients): 5.00%\n", + " 3 clients: 0.7467 ± 0.0085 [0.7370, 0.7530]\n", + " 5 clients: 0.7496 ± 0.0124 [0.7390, 0.7710]\n", + " 10 clients: 0.7560 ± 0.0235 [0.7320, 0.8160]\n", + " 20 clients: 0.7363 ± 0.0369 [0.6100, 0.8000]\n", + " Performance degradation (3→20 clients): 1.39%\n", "\n", "Balanced Random Forest:\n", "----------------------------------------\n", - " 3 clients: 0.7713 ± 0.0156 [0.7570, 0.7880]\n", - " 5 clients: 0.7636 ± 0.0297 [0.7190, 0.8010]\n", - " 10 clients: 0.7269 ± 0.0254 [0.6940, 0.7800]\n", - " 20 clients: 0.7177 ± 0.0685 [0.5650, 0.8360]\n", - " Performance degradation (3→20 clients): 6.95%\n", + " 3 clients: 0.7493 ± 0.0074 [0.7410, 0.7550]\n", + " 5 clients: 0.7490 ± 0.0116 [0.7390, 0.7690]\n", + " 10 clients: 0.7501 ± 0.0105 [0.7390, 0.7690]\n", + " 20 clients: 0.7358 ± 0.0328 [0.6280, 0.7680]\n", + " Performance degradation (3→20 clients): 1.81%\n", "\n", "XGBoost:\n", "----------------------------------------\n", @@ -265,12 +758,12 @@ "================================================================================\n", "COMPARATIVE ANALYSIS:\n", "================================================================================\n", - "Best at 3 clients: Balanced Random Forest (balanced_accuracy: 0.7713)\n", - "Best at 5 clients: Logistic Regression (balanced_accuracy: 0.7694)\n", - "Best at 10 clients: Balanced Random Forest (balanced_accuracy: 0.7269)\n", - "Best at 20 clients: Logistic Regression (balanced_accuracy: 0.7255)\n", + "Best at 3 clients: Balanced Random Forest (balanced_accuracy: 0.7493)\n", + "Best at 5 clients: Random Forest (balanced_accuracy: 0.7496)\n", + "Best at 10 clients: Random Forest (balanced_accuracy: 0.7560)\n", + "Best at 20 clients: Random Forest (balanced_accuracy: 0.7363)\n", "\n", - "Overall best model: Logistic Regression (Avg balanced_accuracy: 0.7339)\n" + "Overall best model: Random Forest (Avg balanced_accuracy: 0.7441)\n" ] } ], @@ -291,7 +784,9 @@ "# clients = [3, 5, 10] # Only these client numbers\n", "\n", "extracted_data = []\n", - "metric = \"balanced_accuracy\"\n", + "# metric = \"auroc\" \n", + "metric = \"local balanced_accuracy\" \n", + "# metric = \"balanced_accuracy\" \n", "for model_name, df in data.items():\n", " model = model_name.split(\" C\")[0]\n", " if model == \"Elastic Net\":\n", @@ -309,7 +804,7 @@ " 'alpha': alpha,\n", " metric: score\n", " })\n", - "\n", + " \n", "# Convert to DataFrame\n", "df = pd.DataFrame(extracted_data)\n", "\n", @@ -330,27 +825,15 @@ " 'MLP': '#8c564b'\n", "}\n", "\n", - "# Prepare data for boxplot\n", - "# boxplot_data = []\n", - "# client_labels = []\n", "x_positions = clients\n", "\n", - "# for client_idx, client in enumerate(clients):\n", - "# client_data = model_data[model_data['n_clients'] == client][metric]\n", - "# if len(client_data) > 0:\n", - "# boxplot_data.append(client_data)\n", - "# # Use actual client number as x-position\n", - "# client_labels.append(f'{client}')\n", - " \n", - "# print(box_positions)\n", - "# x\n", - "\n", "# Plot box plots for each model in separate subplots\n", "for i, model in enumerate(models):\n", " if i < len(axes): # Ensure we don't exceed subplot count\n", " ax = axes[i]\n", " model_data = df[df['model'] == model]\n", - " \n", + " print(model)\n", + " print(model_data)\n", " # Prepare data for boxplot\n", " boxplot_data = []\n", " client_labels = []\n", @@ -393,6 +876,7 @@ " ax.set_xticks(box_positions)\n", " ax.set_xticklabels(client_labels)\n", " \n", + " print(model, boxplot_data)\n", " # Set subplot title and labels\n", " ax.set_title(f'{model}', fontsize=12, fontweight='bold')\n", " ax.set_xlabel('Number of Clients', fontsize=10)\n", @@ -401,7 +885,7 @@ " \n", " # Set consistent y-axis across all subplots\n", " # ax.set_ylim(0.5, 0.78)\n", - " ax.set_ylim(0.4, 0.85)\n", + " ax.set_ylim(0.6, 0.85)\n", "\n", " # Set x-axis limits with some padding\n", " ax.set_xlim(min(box_positions) - min_gap * 0.5, \n", @@ -608,7 +1092,7 @@ }, { "cell_type": "code", - "execution_count": 34, + "execution_count": 37, "id": "add792d5", "metadata": {}, "outputs": [ @@ -616,35 +1100,55 @@ "name": "stdout", "output_type": "stream", "text": [ - "Found 6 experiments\n", - "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat10\n", - "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat20\n", - "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat35\n", - "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat40\n", - "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal FeatN\n", - "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat10\n", + "Found 19 experiments\n", "\n", "Weighted Average Metrics Table:\n", "\n", "Model Balanced Accuracy Auroc Round Time [S]\n", + "Ukbb Cvd Balanced Random Forest C10 A0.7 Normglobal Feat10 0.749 ± 0.029 0.817 ± 0.027 1.425 ± 0.274\n", + "Ukbb Cvd Balanced Random Forest C10 A0.7 Normglobal Feat20 0.759 ± 0.021 0.829 ± 0.024 1.501 ± 0.275\n", + "Ukbb Cvd Balanced Random Forest C10 A0.7 Normglobal Feat35 0.754 ± 0.024 0.829 ± 0.024 1.591 ± 0.292\n", + "Ukbb Cvd Balanced Random Forest C10 A0.7 Normglobal Feat40 0.753 ± 0.022 0.827 ± 0.024 1.638 ± 0.346\n", + "Ukbb Cvd Balanced Random Forest C10 A0.7 Normglobal FeatN 0.754 ± 0.027 0.826 ± 0.024 1.636 ± 0.321\n", + "Ukbb Cvd Balanced Random Forest C10 AN Normglobal Feat10 0.749 ± 0.031 0.817 ± 0.026 1.416 ± 0.276\n", + "Ukbb Cvd Balanced Random Forest C10 AN Normglobal Feat20 0.758 ± 0.022 0.828 ± 0.025 1.511 ± 0.303\n", + "Ukbb Cvd Balanced Random Forest C10 AN Normglobal Feat35 0.755 ± 0.027 0.828 ± 0.025 1.582 ± 0.339\n", + "Ukbb Cvd Balanced Random Forest C10 AN Normglobal Feat40 0.752 ± 0.025 0.826 ± 0.023 1.624 ± 0.311\n", "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat10 0.757 ± 0.024 0.818 ± 0.022 0.966 ± 0.199\n", "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat20 0.742 ± 0.028 0.823 ± 0.027 1.032 ± 0.212\n", "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat35 0.750 ± 0.027 0.825 ± 0.025 1.098 ± 0.226\n", "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat40 0.747 ± 0.018 0.825 ± 0.025 1.128 ± 0.235\n", "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal FeatN 0.750 ± 0.031 0.824 ± 0.027 1.146 ± 0.240\n", "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat10 0.755 ± 0.023 0.819 ± 0.022 0.983 ± 0.199\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat20 0.742 ± 0.028 0.823 ± 0.026 1.035 ± 0.216\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat35 0.750 ± 0.030 0.824 ± 0.024 1.075 ± 0.225\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat40 0.747 ± 0.023 0.824 ± 0.025 1.106 ± 0.233\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal FeatN 0.747 ± 0.032 0.823 ± 0.027 1.120 ± 0.236\n", "\n", "LaTeX Table:\n", "\n", "\\begin{tabular}{lccc}\n", "Model & Balanced Accuracy & Auroc & Round Time [S] \\\\\n", "\\hline\n", + "Ukbb Cvd Balanced Random Forest C10 A0.7 Normglobal Feat10 & 0.749 $\\pm$ 0.029 & 0.817 $\\pm$ 0.027 & 1.425 $\\pm$ 0.274 \\\\\n", + "Ukbb Cvd Balanced Random Forest C10 A0.7 Normglobal Feat20 & 0.759 $\\pm$ 0.021 & 0.829 $\\pm$ 0.024 & 1.501 $\\pm$ 0.275 \\\\\n", + "Ukbb Cvd Balanced Random Forest C10 A0.7 Normglobal Feat35 & 0.754 $\\pm$ 0.024 & 0.829 $\\pm$ 0.024 & 1.591 $\\pm$ 0.292 \\\\\n", + "Ukbb Cvd Balanced Random Forest C10 A0.7 Normglobal Feat40 & 0.753 $\\pm$ 0.022 & 0.827 $\\pm$ 0.024 & 1.638 $\\pm$ 0.346 \\\\\n", + "Ukbb Cvd Balanced Random Forest C10 A0.7 Normglobal FeatN & 0.754 $\\pm$ 0.027 & 0.826 $\\pm$ 0.024 & 1.636 $\\pm$ 0.321 \\\\\n", + "Ukbb Cvd Balanced Random Forest C10 AN Normglobal Feat10 & 0.749 $\\pm$ 0.031 & 0.817 $\\pm$ 0.026 & 1.416 $\\pm$ 0.276 \\\\\n", + "Ukbb Cvd Balanced Random Forest C10 AN Normglobal Feat20 & 0.758 $\\pm$ 0.022 & 0.828 $\\pm$ 0.025 & 1.511 $\\pm$ 0.303 \\\\\n", + "Ukbb Cvd Balanced Random Forest C10 AN Normglobal Feat35 & 0.755 $\\pm$ 0.027 & 0.828 $\\pm$ 0.025 & 1.582 $\\pm$ 0.339 \\\\\n", + "Ukbb Cvd Balanced Random Forest C10 AN Normglobal Feat40 & 0.752 $\\pm$ 0.025 & 0.826 $\\pm$ 0.023 & 1.624 $\\pm$ 0.311 \\\\\n", "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat10 & 0.757 $\\pm$ 0.024 & 0.818 $\\pm$ 0.022 & 0.966 $\\pm$ 0.199 \\\\\n", "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat20 & 0.742 $\\pm$ 0.028 & 0.823 $\\pm$ 0.027 & 1.032 $\\pm$ 0.212 \\\\\n", "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat35 & 0.750 $\\pm$ 0.027 & 0.825 $\\pm$ 0.025 & 1.098 $\\pm$ 0.226 \\\\\n", "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal Feat40 & 0.747 $\\pm$ 0.018 & 0.825 $\\pm$ 0.025 & 1.128 $\\pm$ 0.235 \\\\\n", "Ukbb Cvd Balanced Random Forest C5 A0.7 Normglobal FeatN & 0.750 $\\pm$ 0.031 & 0.824 $\\pm$ 0.027 & 1.146 $\\pm$ 0.240 \\\\\n", "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat10 & 0.755 $\\pm$ 0.023 & 0.819 $\\pm$ 0.022 & 0.983 $\\pm$ 0.199 \\\\\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat20 & 0.742 $\\pm$ 0.028 & 0.823 $\\pm$ 0.026 & 1.035 $\\pm$ 0.216 \\\\\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat35 & 0.750 $\\pm$ 0.030 & 0.824 $\\pm$ 0.024 & 1.075 $\\pm$ 0.225 \\\\\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal Feat40 & 0.747 $\\pm$ 0.023 & 0.824 $\\pm$ 0.025 & 1.106 $\\pm$ 0.233 \\\\\n", + "Ukbb Cvd Balanced Random Forest C5 AN Normglobal FeatN & 0.747 $\\pm$ 0.032 & 0.823 $\\pm$ 0.027 & 1.120 $\\pm$ 0.236 \\\\\n", "\\end{tabular}\n" ] } From ad77488123e65615fded264f1a723c52a6d48fdf Mon Sep 17 00:00:00 2001 From: faildeny Date: Tue, 10 Feb 2026 13:40:51 +0100 Subject: [PATCH 24/29] Rename old XGBoost implementation to xgblr --- flcore/client_selector.py | 6 +++--- flcore/datasets.py | 2 +- flcore/models/xgb/__init__.py | 4 ---- flcore/models/xgblr/__init__.py | 4 ++++ flcore/models/{xgb => xgblr}/client.py | 4 ++-- flcore/models/{xgb => xgblr}/cnn.py | 0 flcore/models/{xgb => xgblr}/fed_custom_strategy.py | 0 flcore/models/{xgb => xgblr}/server.py | 8 ++++---- flcore/models/{xgb => xgblr}/utils.py | 0 flcore/server_selector.py | 6 +++--- 10 files changed, 17 insertions(+), 17 deletions(-) delete mode 100644 flcore/models/xgb/__init__.py create mode 100644 flcore/models/xgblr/__init__.py rename flcore/models/{xgb => xgblr}/client.py (99%) rename flcore/models/{xgb => xgblr}/cnn.py (100%) rename flcore/models/{xgb => xgblr}/fed_custom_strategy.py (100%) rename flcore/models/{xgb => xgblr}/server.py (99%) rename flcore/models/{xgb => xgblr}/utils.py (100%) diff --git a/flcore/client_selector.py b/flcore/client_selector.py index 3f92915..c0c616b 100644 --- a/flcore/client_selector.py +++ b/flcore/client_selector.py @@ -1,7 +1,7 @@ import numpy as np import flcore.models.linear_models as linear_models -import flcore.models.xgb as xgb +import flcore.models.xgblr as xgblr import flcore.models.random_forest as random_forest import flcore.models.weighted_random_forest as weighted_random_forest @@ -17,8 +17,8 @@ def get_model_client(config, data, client_id): elif model == "weighted_random_forest": client = weighted_random_forest.client.get_client(config,data,client_id) - elif model == "xgb": - client = xgb.client.get_client(config, data, client_id) + elif model == "xgblr": + client = xgblr.client.get_client(config, data, client_id) else: raise ValueError(f"Unknown model: {model}") diff --git a/flcore/datasets.py b/flcore/datasets.py index d0c16ed..68d048f 100644 --- a/flcore/datasets.py +++ b/flcore/datasets.py @@ -22,7 +22,7 @@ import pickle -from flcore.models.xgb.utils import TreeDataset, do_fl_partitioning, get_dataloader +from flcore.models.xgblr.utils import TreeDataset, do_fl_partitioning, get_dataloader XY = Tuple[np.ndarray, np.ndarray] Dataset = Tuple[XY, XY] diff --git a/flcore/models/xgb/__init__.py b/flcore/models/xgb/__init__.py deleted file mode 100644 index 034de7d..0000000 --- a/flcore/models/xgb/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import flcore.models.xgb.client -import flcore.models.xgb.server -import flcore.models.xgb.fed_custom_strategy -import flcore.models.xgb.utils diff --git a/flcore/models/xgblr/__init__.py b/flcore/models/xgblr/__init__.py new file mode 100644 index 0000000..478cd6d --- /dev/null +++ b/flcore/models/xgblr/__init__.py @@ -0,0 +1,4 @@ +import flcore.models.xgblr.client +import flcore.models.xgblr.server +import flcore.models.xgblr.fed_custom_strategy +import flcore.models.xgblr.utils diff --git a/flcore/models/xgb/client.py b/flcore/models/xgblr/client.py similarity index 99% rename from flcore/models/xgb/client.py rename to flcore/models/xgblr/client.py index 515f94b..197e1a9 100644 --- a/flcore/models/xgb/client.py +++ b/flcore/models/xgblr/client.py @@ -24,8 +24,8 @@ from xgboost import XGBClassifier, XGBRegressor from sklearn.model_selection import KFold, StratifiedShuffleSplit, train_test_split -from flcore.models.xgb.cnn import CNN, test, train -from flcore.models.xgb.utils import ( +from flcore.models.xgblr.cnn import CNN, test, train +from flcore.models.xgblr.utils import ( NumpyEncoder, TreeDataset, construct_tree_from_loader, diff --git a/flcore/models/xgb/cnn.py b/flcore/models/xgblr/cnn.py similarity index 100% rename from flcore/models/xgb/cnn.py rename to flcore/models/xgblr/cnn.py diff --git a/flcore/models/xgb/fed_custom_strategy.py b/flcore/models/xgblr/fed_custom_strategy.py similarity index 100% rename from flcore/models/xgb/fed_custom_strategy.py rename to flcore/models/xgblr/fed_custom_strategy.py diff --git a/flcore/models/xgb/server.py b/flcore/models/xgblr/server.py similarity index 99% rename from flcore/models/xgb/server.py rename to flcore/models/xgblr/server.py index 4b5a748..156844b 100644 --- a/flcore/models/xgb/server.py +++ b/flcore/models/xgblr/server.py @@ -30,10 +30,10 @@ from xgboost import XGBClassifier, XGBRegressor from flcore.metrics import metrics_aggregation_fn -from flcore.models.xgb.client import FL_Client -from flcore.models.xgb.fed_custom_strategy import FedCustomStrategy -from flcore.models.xgb.cnn import CNN, test -from flcore.models.xgb.utils import ( +from flcore.models.xgblr.client import FL_Client +from flcore.models.xgblr.fed_custom_strategy import FedCustomStrategy +from flcore.models.xgblr.cnn import CNN, test +from flcore.models.xgblr.utils import ( TreeDataset, construct_tree, do_fl_partitioning, diff --git a/flcore/models/xgb/utils.py b/flcore/models/xgblr/utils.py similarity index 100% rename from flcore/models/xgb/utils.py rename to flcore/models/xgblr/utils.py diff --git a/flcore/server_selector.py b/flcore/server_selector.py index 8c5e010..dbcc26e 100644 --- a/flcore/server_selector.py +++ b/flcore/server_selector.py @@ -1,6 +1,6 @@ #import flcore.models.logistic_regression.server as logistic_regression_server #import flcore.models.logistic_regression.server as logistic_regression_server -import flcore.models.xgb.server as xgb_server +import flcore.models.xgblr.server as xgblr_server import flcore.models.random_forest.server as random_forest_server import flcore.models.linear_models.server as linear_models_server import flcore.models.weighted_random_forest.server as weighted_random_forest_server @@ -22,8 +22,8 @@ def get_model_server_and_strategy(config, data=None): config ) - elif model == "xgb": - server, strategy = xgb_server.get_server_and_strategy(config, data) + elif model == "xgblr": + server, strategy = xgblr_server.get_server_and_strategy(config, data) else: raise ValueError(f"Unknown model: {model}") From 90a42cdce623513ada32b6820c4e0159068dd3f4 Mon Sep 17 00:00:00 2001 From: faildeny Date: Tue, 10 Feb 2026 14:18:42 +0100 Subject: [PATCH 25/29] Rename old XGBoost implementation to xgblr tests --- flcore/models/xgblr/client.py | 4 ++-- flcore/models/xgblr/server.py | 6 +++--- tests/test_models.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/flcore/models/xgblr/client.py b/flcore/models/xgblr/client.py index 197e1a9..2a1d65a 100644 --- a/flcore/models/xgblr/client.py +++ b/flcore/models/xgblr/client.py @@ -272,9 +272,9 @@ def evaluate(self, eval_params: EvaluateIns) -> EvaluateRes: def get_client(config, data, client_id) -> fl.client.Client: (X_train, y_train), (X_test, y_test) = data - task_type = config["xgb"]["task_type"] + task_type = config["xgblr"]["task_type"] client_num = config["num_clients"] - client_tree_num = config["xgb"]["tree_num"] // client_num + client_tree_num = config["xgblr"]["tree_num"] // client_num batch_size = "whole" cid = str(client_id) #measure time for client data loading diff --git a/flcore/models/xgblr/server.py b/flcore/models/xgblr/server.py index 156844b..4312d5d 100644 --- a/flcore/models/xgblr/server.py +++ b/flcore/models/xgblr/server.py @@ -410,15 +410,15 @@ def get_server_and_strategy( # The number of clients participated in the federated learning client_num = config["num_clients"] # The number of XGBoost trees in the tree ensemble that will be built for each client - client_tree_num = config["xgb"]["tree_num"] // client_num + client_tree_num = config["xgblr"]["tree_num"] // client_num num_rounds = config["num_rounds"] client_pool_size = client_num - num_iterations = config["xgb"]["num_iterations"] + num_iterations = config["xgblr"]["num_iterations"] fraction_fit = 1.0 min_fit_clients = client_num - batch_size = config["xgb"]["batch_size"] + batch_size = config["xgblr"]["batch_size"] val_ratio = 0.1 # DATASET = "CVD" diff --git a/tests/test_models.py b/tests/test_models.py index 3a02568..f5969f7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -18,7 +18,7 @@ "random_forest", "balanced_random_forest", # # "weighted_random_forest", - "xgb" + "xgblr" ] datasets = [ @@ -45,8 +45,8 @@ def setup_class(self): # To speed up tests, reduce number of trees in xgboost and random forest self.config["random_forest"]["tree_num"] = 5 - self.config["xgb"]["tree_num"] = 5 - self.config["xgb"]["num_iterations"] = 2 + self.config["xgblr"]["tree_num"] = 5 + self.config["xgblr"]["num_iterations"] = 2 @pytest.mark.parametrize( From 5e80ec53643914639085e42ee4d4135c7c1e2bfa Mon Sep 17 00:00:00 2001 From: faildeny Date: Tue, 10 Feb 2026 14:19:35 +0100 Subject: [PATCH 26/29] Update gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 135e228..4d85b65 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ results/ data/ logs/ external/ +benchmark*/ +*.png +*.csv other/ # C extensions *.so From 32f9966d59e6495161fb07c59516d5df0ec33ea7 Mon Sep 17 00:00:00 2001 From: faildeny Date: Mon, 23 Feb 2026 12:04:32 +0100 Subject: [PATCH 27/29] Add new XGBoost with correct aggregation and modified Eta mechanism from NVFLARE --- config.yaml | 38 ++- flcore/client_selector.py | 4 + flcore/models/xgb/__init__.py | 9 + flcore/models/xgb/client.py | 433 +++++++++++++++++++++++++++++++++ flcore/models/xgb/server.py | 440 ++++++++++++++++++++++++++++++++++ flcore/server_selector.py | 4 +- requirements.txt | 5 +- run.py | 4 +- 8 files changed, 920 insertions(+), 17 deletions(-) create mode 100644 flcore/models/xgb/__init__.py create mode 100644 flcore/models/xgb/client.py create mode 100644 flcore/models/xgb/server.py diff --git a/config.yaml b/config.yaml index 917fdc1..7b971a6 100644 --- a/config.yaml +++ b/config.yaml @@ -11,12 +11,12 @@ ############## Dataset type to use # Possible values: , kaggle_hf, diabetes, mnist, dt4h_format -dataset: kaggle_hf +# dataset: kaggle_hf # dataset: ukbb_cvd -# dataset: diabetes +dataset: diabetes #custom #libsvm -#kaggle_hf +# kaggle_hf # ****** * * * * * * * * * * * * * * * * * * * * ******************* # New variables @@ -35,19 +35,20 @@ train_size: 0.7 # ****** * * * * * * * * * * * * * * * * * * * * ******************* ############## Number of clients (data centers) to use for training -num_clients: 4 +num_clients: 50 ############## Model type # Possible values: logistic_regression, lsvc, elastic_net, random_forest, weighted_random_forest, xgb # See README.md for a full list of supported models -# model: xgb -model: logistic_regression +model: xgb +# model: logistic_regression +# model: lsvc # model: random_forest -#logistic_regression +#logistic_regressionpyte #random_forest ############## Training length -num_rounds: 10 +num_rounds: 25 ############## Metric to select the best model # Possible values: accuracy, balanced_accuracy, f1, precision, recall @@ -56,8 +57,10 @@ checkpoint_selection_metric: balanced_accuracy #balanced_accuracy ############## Experiment logging +experiment_dir: logs experiment: - name: experiment_kaggle_standard + # name: experiment_diabetes_lr_checks + name: experiment_diabetes_xgb_checks_50_adaptive_lrx log_path: logs debug: true @@ -110,11 +113,12 @@ smoothWeights: # Kaggle dataset has 9 features # UKBB dataset has 40 features -linear_models: - n_features: 9 +# linear_models: +# n_features: 9 -dirichlet_alpha: Null +# dirichlet_alpha: Null +dirichlet_alpha: 0.7 # Random Forest random_forest: @@ -128,6 +132,14 @@ weighted_random_forest: # XGBoost xgb: + learning_rate: 0.2 + max_depth: 2 + num_parallel_tree: 2 + task_type: BINARY + tree_num: 300 + +# XGBoost Learning rate method +xgblr: batch_size: 32 num_iterations: 100 task_type: BINARY @@ -141,7 +153,7 @@ held_out_center_id: -1 seed: 42 -local_port: 8081 +local_port: 8050 data_path: dataset/ diff --git a/flcore/client_selector.py b/flcore/client_selector.py index c0c616b..361a41f 100644 --- a/flcore/client_selector.py +++ b/flcore/client_selector.py @@ -1,6 +1,7 @@ import numpy as np import flcore.models.linear_models as linear_models +import flcore.models.xgb as xgb import flcore.models.xgblr as xgblr import flcore.models.random_forest as random_forest import flcore.models.weighted_random_forest as weighted_random_forest @@ -17,6 +18,9 @@ def get_model_client(config, data, client_id): elif model == "weighted_random_forest": client = weighted_random_forest.client.get_client(config,data,client_id) + elif model == "xgb": + client = xgb.client.get_client(config, data, client_id) + elif model == "xgblr": client = xgblr.client.get_client(config, data, client_id) diff --git a/flcore/models/xgb/__init__.py b/flcore/models/xgb/__init__.py new file mode 100644 index 0000000..98e3452 --- /dev/null +++ b/flcore/models/xgb/__init__.py @@ -0,0 +1,9 @@ +# ********* * * * * * * * * * * * * * * * * * * +# XGBoost +# Author: Iratxe Moya +# Date: January 2026 +# Project: DT4H +# ********* * * * * * * * * * * * * * * * * * * + +import flcore.models.xgb.client +import flcore.models.xgb.server \ No newline at end of file diff --git a/flcore/models/xgb/client.py b/flcore/models/xgb/client.py new file mode 100644 index 0000000..0fc47b9 --- /dev/null +++ b/flcore/models/xgb/client.py @@ -0,0 +1,433 @@ +# ********* * * * * * * * * * * * * * * * * * * +# XGBoost +# Author: Iratxe Moya +# Date: January 2026 +# Project: DT4H +# ********* * * * * * * * * * * * * * * * * * * + +import os +import time +from typing import Dict, Tuple, List +import flwr as fl +from flwr.common import NDArrays, Scalar +from sklearn.model_selection import train_test_split +import xgboost as xgb +import numpy as np +from pathlib import Path +from flcore.metrics import calculate_metrics, find_best_threshold + + +class XGBoostClient(fl.client.NumPyClient): + """Flower client for federated XGBoost training. + + Supports two training methods: + - bagging: Each client trains new trees, server combines all trees + - cyclic: Each client refines the global model sequentially + """ + + def __init__( + self, + local_data: Dict, + client_id: int, + saving_path: str = "logs/sandbox/", + config: Dict = None + ): + """ + Initialize XGBoost client. + + Args: + local_data: Dictionary containing: + - X_train: Training features + - y_train: Training labels + - X_test: Test features + - y_test: Test labels + saving_path: Path to save local models and logs + """ + self.client_id = client_id + self.local_data = local_data + self.saving_path = Path(saving_path) + self.saving_path.mkdir(parents=True, exist_ok=True) + self.config = config + # Create models directory + models_dir = self.saving_path / "models" + models_dir.mkdir(exist_ok=True) + + # Local model + self.bst = None + self.xgb_params = {} + self.dtrain = None + self.dtest = None + self.label_encoder = None # For categorical target encoding + + self.round_time = None + + # Prepare data + self._prepare_data() + + print(f"[Client] Initialized") + print(f"[Client] Training samples: {len(self.local_data['X_train'])}") + print(f"[Client] Test samples: {len(self.local_data['X_test'])}") + + def _prepare_data(self): + """Convert data to DMatrix format for XGBoost.""" + + X_train = self.local_data['X_train'] + y_train = self.local_data['y_train'] + X_test = self.local_data['X_test'] + y_test = self.local_data['y_test'] + + X_train, X_val, y_train, y_val = train_test_split(X_train, y_train, test_size=0.2, random_state=self.config['seed'], stratify=y_train) + + self.local_data['X_train'] = X_train + self.local_data['y_train'] = y_train + self.local_data['X_val'] = X_val + self.local_data['y_val'] = y_val + + # Handle categorical labels (for multiclass classification) + # XGBoost requires numeric labels, not strings + if hasattr(y_train, 'dtype') and y_train.dtype == 'object': + print(f"[Client] Detected categorical labels, encoding...") + from sklearn.preprocessing import LabelEncoder + + self.label_encoder = LabelEncoder() + y_train = self.label_encoder.fit_transform(y_train) + y_test = self.label_encoder.transform(y_test) + y_val = self.label_encoder.transform(y_val) + + # Update local_data with encoded labels + self.local_data['y_train'] = y_train + self.local_data['y_test'] = y_test + self.local_data['y_val'] = y_val + + print(f"[Client] Label mapping: {dict(enumerate(self.label_encoder.classes_))}") + print(f"[Client] Encoded labels - Train: {np.unique(y_train)}, Test: {np.unique(y_test)}") + else: + self.label_encoder = None + + # Create DMatrix objects + self.dtrain = xgb.DMatrix(X_train, label=y_train) + self.dtest = xgb.DMatrix(X_test, label=y_test) + self.dval = xgb.DMatrix(X_val, label=y_val) + + print(f"[Client] Data prepared as DMatrix") + + def get_parameters(self, config: Dict[str, Scalar] = None) -> NDArrays: + """Return current model parameters.""" + if self.bst is None: + # Return empty parameters if no model yet + return [np.array([], dtype=np.uint8)] + + # Serialize model + model_bytes = self.bst.save_raw("json") + return [np.frombuffer(model_bytes, dtype=np.uint8)] + + def set_parameters(self, parameters: NDArrays): + """Set model parameters from server.""" + if len(parameters) == 0 or len(parameters[0]) == 0: + # No parameters to load (first round) + self.bst = None + return + + # Load model from bytes + model_bytes = bytearray(parameters[0].tobytes()) + self.bst = xgb.Booster(params=self.xgb_params) + self.bst.load_model(model_bytes) + + print(f"[Client] Loaded global model with {self.bst.num_boosted_rounds()} trees") + + def fit( + self, + parameters: NDArrays, + config: Dict[str, Scalar] + ) -> Tuple[NDArrays, int, Dict[str, Scalar]]: + """Train the model on local data. + + Args: + parameters: Model parameters from server + config: Training configuration from server + + Returns: + Tuple of (updated_parameters, num_examples, metrics) + """ + + # Extract config + server_round = int(config.get("server_round", 1)) + num_local_rounds = int(config.get("num_local_rounds", 5)) + train_method = config.get("train_method", "bagging") + + # Update XGBoost parameters from config + self.xgb_params = { + k: v for k, v in config.items() + if k not in ["server_round", "num_local_rounds", "train_method"] + } + print(f"\n[Client] === Round {server_round} - FIT ===") + print(f"[Client] Method: {train_method}") + print(f"[Client] Local rounds: {num_local_rounds}") + start_time = time.time() + # Prepare metrics + metrics = {} + if server_round == 1: + # First round: train from scratch + print(f"[Client] Training from scratch...") + self.bst = xgb.train( + self.xgb_params, + self.dtrain, + num_boost_round=num_local_rounds, + ) + # Train the model for total num_local_rounds to get a local training score + local_xgb_params = self.xgb_params.copy() + # Modify learning rate to 0.2 for local training + local_xgb_params['eta'] = self.config['xgb']['learning_rate'] + local_bst = xgb.train( + local_xgb_params, + self.dtrain, + num_boost_round=num_local_rounds*self.config.get("num_rounds", 1), + ) + # Get validation score, find best threshold and calculate test metrics + y_val_pred = local_bst.predict(self.dval) + y_val_true = self.local_data['y_val'] + best_threshold = find_best_threshold(y_val_true, y_val_pred) + # Get test metrics and add to metrics with 'local' prefix + y_test_pred = local_bst.predict(self.dtest) + y_test_true = self.local_data['y_test'] + local_metrics = calculate_metrics(y_test_true, y_test_pred, threshold=best_threshold) + metrics.update({f"local {key}": local_metrics[key] for key in local_metrics}) + else: + # Subsequent rounds: load global model and continue training + self.set_parameters(parameters) + + if self.bst is None: + # Fallback: train from scratch if loading failed + print(f"[Client] Warning: Could not load model, training from scratch") + self.bst = xgb.train( + self.xgb_params, + self.dtrain, + num_boost_round=num_local_rounds, + ) + else: + # Continue training + print(f"[Client] Continuing training from global model...") + initial_trees = self.bst.num_boosted_rounds() + + # Update trees based on local training data + for i in range(num_local_rounds): + self.bst.update(self.dtrain, self.bst.num_boosted_rounds()) + + final_trees = self.bst.num_boosted_rounds() + print(f"[Client] Trained {final_trees - initial_trees} new trees (total: {final_trees})") + + print(f"[Client] Trained {self.bst.num_boosted_rounds()} boosting rounds with num parallel trees: {self.xgb_params.get('num_parallel_tree', 1)}") + + # For bagging: return only the last N trees + # For cyclic: return the entire model + if train_method == "bagging": + # Extract only the newly trained trees + num_trees = self.bst.num_boosted_rounds() + if num_trees > num_local_rounds: + # Slice to get last num_local_rounds trees + model_to_send = self.bst[num_trees - num_local_rounds : num_trees] + print(f"[Client] Sending last {num_local_rounds} trees (bagging mode)") + else: + model_to_send = self.bst + print(f"[Client] Sending all {num_trees} trees") + else: + # Cyclic: send entire model + model_to_send = self.bst + print(f"[Client] Sending entire model (cyclic mode)") + + # Serialize model + model_bytes = model_to_send.save_raw("json") + model_array = np.frombuffer(model_bytes, dtype=np.uint8) + + # Get number of training examples + num_examples = len(self.local_data['X_train']) + + metrics['num_examples'] = num_examples + metrics['num_trees'] = self.bst.num_boosted_rounds() + + + # Save local model + local_model_path = self.saving_path / "models" / f"xgboost_client__round_{server_round}.json" + self.bst.save_model(str(local_model_path)) + print(f"[Client] Saved local model to {local_model_path}") + + self.round_time = (time.time() - start_time) + + return [model_array], num_examples, metrics + + def evaluate( + self, + parameters: NDArrays, + config: Dict[str, Scalar] + ) -> Tuple[float, int, Dict[str, Scalar]]: + """Evaluate the global model on local test data. + + Args: + parameters: Model parameters from server + config: Evaluation configuration from server + + Returns: + Tuple of (loss, num_examples, metrics) + """ + + server_round = int(config.get("server_round", 0)) + + print(f"\n[Client] === Round {server_round} - EVALUATE ===") + + # Update XGBoost parameters + self.xgb_params = { + k: v for k, v in config.items() + if k not in ["server_round"] + } + + # Load global model + self.set_parameters(parameters) + + if self.bst is None: + print(f"[Client] Warning: No model to evaluate") + return 0.0, 0, {} + + # Evaluate on test set + eval_results = self.bst.eval_set( + evals=[(self.dtest, "test")], + iteration=self.bst.num_boosted_rounds() - 1, + ) + + print(f"[Client] Evaluation results: {eval_results}") + + # Parse evaluation results + # Format: "[0]\ttest-auc:0.85123" + metrics = {} + try: + parts = eval_results.split("\t") + for part in parts[1:]: # Skip the iteration number + metric_name, metric_value = part.split(":") + metric_name = metric_name.replace("test-", "") + metrics[metric_name] = float(metric_value) + except Exception as e: + print(f"[Client] Warning: Could not parse metrics: {e}") + + # Get valiidation prediction for threshold finding and additional metrics + y_val_pred = self.bst.predict(self.dval) + y_val_true = self.local_data['y_val'] + best_threshold = find_best_threshold(y_val_true, y_val_pred) + metrics_val = calculate_metrics(y_val_true, y_val_pred, threshold=best_threshold) + metrics.update({f"val {key}": metrics_val[key] for key in metrics_val}) + + + # Get predictions for additional metrics + y_pred = self.bst.predict(self.dtest) + y_true = self.local_data['y_test'] + + # Determine task type from objective + objective = self.xgb_params.get("objective", "") + + # Calculate additional metrics based on task type + if objective.startswith("binary"): + # Binary classification + from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score + + general_metrics = calculate_metrics(y_true, y_pred, threshold=best_threshold) + metrics.update(general_metrics) + # Add n samples to metrics + metrics['n samples'] = len(y_true) + metrics['client_id'] = self.client_id + metrics['round_time [s]'] = self.round_time + # Loss is 1 - AUC for binary + primary_metric = metrics.get('auc', 0) + loss = 1 - primary_metric + + elif objective.startswith("multi"): + # Multiclass classification + from sklearn.metrics import accuracy_score, f1_score + + # y_pred is already the predicted class (not probabilities) + y_pred_class = y_pred.astype(int) + metrics['accuracy'] = float(accuracy_score(y_true, y_pred_class)) + metrics['f1_macro'] = float(f1_score(y_true, y_pred_class, average='macro', zero_division=0)) + metrics['f1_weighted'] = float(f1_score(y_true, y_pred_class, average='weighted', zero_division=0)) + + # Loss is mlogloss (already calculated by XGBoost) + loss = metrics.get('mlogloss', 1.0) + + elif objective.startswith("reg"): + # Regression + from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score + + metrics['mse'] = float(mean_squared_error(y_true, y_pred)) + metrics['mae'] = float(mean_absolute_error(y_true, y_pred)) + metrics['r2'] = float(r2_score(y_true, y_pred)) + + # Loss is RMSE (primary metric for regression) + loss = metrics.get('rmse', metrics['mse'] ** 0.5) + else: + # Unknown task, use default loss + loss = 1.0 + + num_examples = len(self.local_data['X_test']) + + print(f"[Client] Metrics: {metrics}") + print(f"[Client] Loss: {loss:.4f}") + + return loss, num_examples, metrics + + +def get_numpy(X_train, y_train, X_test, y_test, time_col=None, event_col=None) -> Dict: + """Convert data to dictionary format expected by client. + + Args: + X_train: Training features (numpy array or pandas DataFrame) + y_train: Training labels + X_test: Test features + y_test: Test labels + time_col: Optional time column for survival analysis + event_col: Optional event column for survival analysis + + Returns: + Dictionary with X_train, y_train, X_test, y_test + """ + + # Convert to numpy if needed + if hasattr(X_train, 'values'): # pandas DataFrame + X_train = X_train.values + if hasattr(y_train, 'values'): # pandas Series + y_train = y_train.values + if hasattr(X_test, 'values'): + X_test = X_test.values + if hasattr(y_test, 'values'): + y_test = y_test.values + + return { + 'X_train': X_train, + 'y_train': y_train, + 'X_test': X_test, + 'y_test': y_test, + 'num_examples': len(X_train), + } + + +def get_client(config: Dict, data: Tuple, client_id: int) -> fl.client.Client: + """Create and return XGBoost federated learning client. + + Args: + config: Configuration dictionary containing experiment settings + data: Tuple of ((X_train, y_train), (X_test, y_test), time_col, event_col) + + Returns: + Initialized XGBoostClient + """ + + (X_train, y_train), (X_test, y_test) = data + + # Convert to format expected by client + local_data = get_numpy(X_train, y_train, X_test, y_test) + + # Create client + client = XGBoostClient( + local_data=local_data, + client_id=client_id, + saving_path=config.get("experiment_dir", "logs/sandbox/"), + config=config + ) + + return client \ No newline at end of file diff --git a/flcore/models/xgb/server.py b/flcore/models/xgb/server.py new file mode 100644 index 0000000..c83be5e --- /dev/null +++ b/flcore/models/xgb/server.py @@ -0,0 +1,440 @@ +""" +Fully Federated XGBoost - Flower Message-Based Server +""" + +import json +from logging import WARNING, log +import os +from pathlib import Path +from typing import Tuple, Dict, List, Optional, Callable, Iterable, Any + +import numpy as np +import xgboost as xgb + +from flwr.common import ( + # ArrayRecord, + # ConfigRecord, + # Message, + # MetricRecord, + # RecordDict, + Parameters, + FitRes, + EvaluateRes, + Scalar, + parameters_to_ndarrays, + ndarrays_to_parameters, +) +# from flwr.server import Grid +from flwr.server.strategy import FedAvg +from flwr.server.client_proxy import ClientProxy + +from flcore.metrics import metrics_aggregation_fn + + + +# ========================================================== +# BAGGING AGGREGATION (Tree-Level JSON Merge) +# ========================================================== + +def _get_tree_nums(xgb_model_org: bytes): + """Extract total tree numbers from XGBoost JSON model.""" + bst = json.loads(bytearray(xgb_model_org)) + model = bst["learner"]["gradient_booster"]["model"] + tree_num = int(model["gbtree_model_param"]["num_trees"]) + paral_tree_num = int(model["gbtree_model_param"]["num_parallel_tree"]) + return tree_num, paral_tree_num + + + +# def aggregate_metricrecords( +# records: list[RecordDict], weighting_metric_name: str +# ) -> MetricRecord: +# """Perform weighted aggregation all MetricRecords using a specific key.""" +# # Retrieve weighting factor from MetricRecord +# weights: list[float] = [] +# for record in records: +# # Get the first (and only) MetricRecord in the record +# metricrecord = next(iter(record.metric_records.values())) +# # Because replies have been checked for consistency, +# # we can safely cast the weighting factor to float +# w = cast(float, metricrecord[weighting_metric_name]) +# weights.append(w) + +# # Average +# total_weight = sum(weights) +# weight_factors = [w / total_weight for w in weights] + +# aggregated_metrics = MetricRecord() +# for record, weight in zip(records, weight_factors, strict=True): +# for record_item in record.metric_records.values(): +# # aggregate in-place +# for key, value in record_item.items(): +# if key == weighting_metric_name: +# # We exclude the weighting key from the aggregated MetricRecord +# continue +# if key not in aggregated_metrics: +# if isinstance(value, list): +# aggregated_metrics[key] = [v * weight for v in value] +# else: +# aggregated_metrics[key] = value * weight +# else: +# if isinstance(value, list): +# current_list = cast(list[float], aggregated_metrics[key]) +# aggregated_metrics[key] = [ +# curr + val * weight +# for curr, val in zip(current_list, value, strict=True) +# ] +# else: +# current_value = cast(float, aggregated_metrics[key]) +# aggregated_metrics[key] = current_value + value * weight + +# return aggregated_metrics + +def aggregate_bagging( + bst_prev_org: bytes, + bst_curr_org: bytes, +) -> bytes: + """Conduct bagging aggregation for given trees.""" + if bst_prev_org == b"": + return bst_curr_org + + # Get the tree numbers + tree_num_prev, _ = _get_tree_nums(bst_prev_org) + _, paral_tree_num_curr = _get_tree_nums(bst_curr_org) + + + bst_prev = json.loads(bytearray(bst_prev_org)) + bst_curr = json.loads(bytearray(bst_curr_org)) + + previous_model = bst_prev["learner"]["gradient_booster"]["model"] + previous_model["gbtree_model_param"]["num_trees"] = str( + tree_num_prev + paral_tree_num_curr + ) + iteration_indptr = previous_model["iteration_indptr"] + previous_model["iteration_indptr"].append( + iteration_indptr[-1] + paral_tree_num_curr + ) + + # Aggregate new trees + trees_curr = bst_curr["learner"]["gradient_booster"]["model"]["trees"] + for tree_count in range(paral_tree_num_curr): + trees_curr[tree_count]["id"] = tree_num_prev + tree_count + previous_model["trees"].append(trees_curr[tree_count]) + previous_model["tree_info"].append(0) + + + print("Previous tree params", previous_model["gbtree_model_param"]) + # Current trees number in trees curr: + print("Current tree params", len(bst_curr["learner"]["gradient_booster"]["model"]["trees"])) + print("Parallel trees num: ", paral_tree_num_curr) + # Total trees after aggregation: + print("Total tree params", len(bst_prev["learner"]["gradient_booster"]["model"]['trees'])) + + bst_prev_bytes = bytes(json.dumps(bst_prev), "utf-8") + + return bst_prev_bytes + + +# ========================================================== +# STRATEGY +# ========================================================== + +class FedXgbFullyFederated(FedAvg): + """Fully federated XGBoost strategy (bagging or cyclic).""" + + def __init__( + self, + num_local_rounds: int = 5, + xgb_params: Dict = None, + saving_path: str = "./sandbox", + min_fit_clients: int = 1, + min_evaluate_clients: int = 1, + min_available_clients: int = 1, + evaluate_fn: Optional[Callable] = None, + on_fit_config_fn: Optional[Callable] = None, + on_evaluate_config_fn: Optional[Callable] = None, + train_method: str = "bagging", + fraction_train=1.0, + fraction_evaluate=1.0, + + # --> INHERITED + **kwargs, + ): + super().__init__( + min_fit_clients=min_fit_clients, + min_evaluate_clients=min_evaluate_clients, + min_available_clients=min_available_clients, + evaluate_fn=evaluate_fn, + on_fit_config_fn=on_fit_config_fn, + on_evaluate_config_fn=on_evaluate_config_fn, + **kwargs + ) + + self.train_method = train_method + self.xgb_params = xgb_params or {} + self.saving_path = Path(saving_path) + self.saving_path.mkdir(parents=True, exist_ok=True) + + self.current_model: Optional[bytes] = b"" + + print(f"[FedXgb] Training method: {train_method}") + print(f"[FedXgb] XGBoost params: {self.xgb_params}") + + # ------------------------------------------------------ + # INITIALIZE + # ------------------------------------------------------ + + def initialize_parameters(self, client_manager): + """Start with empty model.""" + empty = np.frombuffer(b"", dtype=np.uint8) + return ndarrays_to_parameters([empty]) + + # ------------------------------------------------------ + # AGGREGATE FIT (CRITICAL FIX) + # ------------------------------------------------------ + + def aggregate_fit( + self, + server_round: int, + results: List[Tuple[ClientProxy, FitRes]], + failures: List, + ) -> Tuple[Optional[Parameters], Dict[str, Scalar]]: + + if not results: + return None, {} + + print(f"\n[Round {server_round}] Aggregating {len(results)} clients") + + models: List[bytes] = [] + + for _, fit_res in results: + ndarrays = parameters_to_ndarrays(fit_res.parameters) + model_bytes = ndarrays[0].tobytes() + + if model_bytes: + models.append(model_bytes) + + if not models: + return None, {} + + # ----------------------------------- + # BAGGING + # ----------------------------------- + if self.train_method == "bagging": + + combined = self.current_model + + for m in models: + combined = aggregate_bagging(combined, m) + + # ----------------------------------- + # CYCLIC + # ----------------------------------- + else: + combined = models[-1] + + self.current_model = combined + + # Save checkpoint + self._save_checkpoint(combined, server_round) + + # Convert back to Parameters + aggregated_params = ndarrays_to_parameters( + [np.frombuffer(combined, dtype=np.uint8)] + ) + + # Aggregate metrics + metrics_aggregated = {} + total_examples = sum([fit_res.num_examples for _, fit_res in results]) + + # Aggregate custom metrics if aggregation fn was provided + if self.evaluate_metrics_aggregation_fn: + eval_metrics = [(res.num_examples, res.metrics) for _, res in results] + metrics_aggregated = self.evaluate_metrics_aggregation_fn(eval_metrics) + elif server_round == 1: # Only log this warning once + log(WARNING, "No evaluate_metrics_aggregation_fn provided") + else: + for client_proxy, fit_res in results: + for key, value in fit_res.metrics.items(): + # Skip non-numeric metrics (like client_id) + if not isinstance(value, (int, float)): + continue + + if key not in metrics_aggregated: + metrics_aggregated[key] = 0 + # Weighted average by number of examples + metrics_aggregated[key] += value * fit_res.num_examples / total_examples + + print(f"[Round {server_round}] Aggregation done.") + + return aggregated_params, metrics_aggregated + + # ------------------------------------------------------ + # EVALUATION + # ------------------------------------------------------ + + def aggregate_evaluate( + self, + server_round: int, + results: List[Tuple[ClientProxy, EvaluateRes]], + failures: List, + ) -> Tuple[Optional[float], Dict[str, Scalar]]: + + if not results: + return None, {} + + total_examples = sum(eval_res.num_examples for _, eval_res in results) + + total_loss = sum( + eval_res.loss * eval_res.num_examples + for _, eval_res in results + ) + + avg_loss = total_loss / total_examples + + # Aggregate metrics with weighted average + metrics_aggregated = {} + total_examples = sum([eval_res.num_examples for _, eval_res in results]) + + # Aggregate custom metrics if aggregation fn was provided + if self.evaluate_metrics_aggregation_fn: + eval_metrics = [(res.num_examples, res.metrics) for _, res in results] + metrics_aggregated = self.evaluate_metrics_aggregation_fn(eval_metrics) + elif server_round == 1: # Only log this warning once + log(WARNING, "No evaluate_metrics_aggregation_fn provided") + else: + for _, eval_res in results: + for key, value in eval_res.metrics.items(): + # Skip non-numeric metrics (like client_id) + if not isinstance(value, (int, float)): + continue + + if key not in metrics_aggregated: + metrics_aggregated[key] = 0 + metrics_aggregated[key] += value * eval_res.num_examples / total_examples + + print(f"[Round {server_round}] Eval loss: {avg_loss:.4f}") + + return avg_loss, metrics_aggregated + + # ------------------------------------------------------ + # CHECKPOINT + # ------------------------------------------------------ + + def _save_checkpoint(self, model_bytes: bytes, round_num: int): + + if not model_bytes: + return + + checkpoint_dir = self.saving_path / "checkpoints" + checkpoint_dir.mkdir(exist_ok=True) + + bst = xgb.Booster(params=self.xgb_params) + bst.load_model(bytearray(model_bytes)) + + model_path = checkpoint_dir / f"xgboost_round_{round_num}.json" + bst.save_model(str(model_path)) + + print(f"[Checkpoint] Saved {model_path}") + + +def get_fit_config_fn( + num_local_rounds: int, + train_method: str, + xgb_params: Dict, +) -> Callable[[int], Dict[str, Any]]: + """Return a function that returns training configuration.""" + + def fit_config(server_round: int) -> Dict[str, Any]: + config = { + "server_round": server_round, + "num_local_rounds": num_local_rounds, + "train_method": train_method, + } + # Add XGBoost parameters + config.update(xgb_params) + return config + + return fit_config + +def get_evaluate_config_fn(xgb_params: Dict) -> Callable[[int], Dict[str, Any]]: + """Return a function that returns evaluation configuration.""" + + def evaluate_config(server_round: int) -> Dict[str, Any]: + config = { + "server_round": server_round, + } + config.update(xgb_params) + return config + + return evaluate_config + +# ========================================================== +# SERVER FACTORY +# ========================================================== + +def get_server_and_strategy(config: dict) -> FedXgbFullyFederated: + """Create strategy from config dictionary.""" + + os.makedirs(config["experiment_dir"], exist_ok=True) + + task = config.get("task", "binary").lower() + xgb_config = config.get("xgb", {}) + + xgb_params = { + "eta": xgb_config.get("learning_rate") / config.get("num_clients"), + "max_depth": xgb_config.get("max_depth", 6), + "tree_method": "hist", + "subsample": 0.8, + "colsample_bytree": 0.8, + "num_parallel_tree": xgb_config.get("num_parallel_tree", 1), + } + + if task == "binary": + xgb_params["objective"] = "binary:logistic" + xgb_params["eval_metric"] = "auc" + + elif task == "multiclass": + n_out = config.get("n_out") + if n_out is None or n_out < 2: + raise ValueError("For multiclass you must provide n_out >= 2") + xgb_params["objective"] = "multi:softmax" + xgb_params["eval_metric"] = "mlogloss" + xgb_params["num_class"] = n_out + + elif task == "regression": + xgb_params["objective"] = "reg:squarederror" + 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 + + + print("\n" + "=" * 60) + print("Federated XGBoost Configuration") + print("=" * 60) + print("Task:", task.upper()) + print("Train method:", xgb_config.get("train_method", "bagging")) + print("Rounds:", config.get("num_rounds")) + print("Clients:", config.get("num_clients")) + print("XGBoost params:", xgb_params) + print("=" * 60 + "\n") + + strategy = FedXgbFullyFederated( + train_method=train_method, + num_local_rounds=num_local_rounds, + xgb_params=xgb_params, + saving_path=config['experiment_dir'], + min_fit_clients=config.get('min_fit_clients', config['num_clients']), + min_evaluate_clients=config.get('min_evaluate_clients', config['num_clients']), + min_available_clients=config.get('min_available_clients', config['num_clients']), + on_fit_config_fn=get_fit_config_fn(num_local_rounds, train_method, xgb_params), + on_evaluate_config_fn=get_evaluate_config_fn(xgb_params), + fraction_train=1.0, + fraction_evaluate=1.0, + fit_metrics_aggregation_fn=metrics_aggregation_fn, + evaluate_metrics_aggregation_fn=metrics_aggregation_fn + ) + + return None, strategy diff --git a/flcore/server_selector.py b/flcore/server_selector.py index dbcc26e..37df516 100644 --- a/flcore/server_selector.py +++ b/flcore/server_selector.py @@ -1,5 +1,6 @@ #import flcore.models.logistic_regression.server as logistic_regression_server #import flcore.models.logistic_regression.server as logistic_regression_server +import flcore.models.xgb.server as xgb_server import flcore.models.xgblr.server as xgblr_server import flcore.models.random_forest.server as random_forest_server import flcore.models.linear_models.server as linear_models_server @@ -21,7 +22,8 @@ def get_model_server_and_strategy(config, data=None): server, strategy = weighted_random_forest_server.get_server_and_strategy( config ) - + elif model == "xgb": + server, strategy = xgb_server.get_server_and_strategy(config) elif model == "xgblr": server, strategy = xgblr_server.get_server_and_strategy(config, data) else: diff --git a/requirements.txt b/requirements.txt index fc7ee35..768eacd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,9 +8,10 @@ openml==0.13.1 pandas==2.0.1 PyYAML==6.0.1 scikit_learn==1.2.2 -torch==2.0.1 +# torch==2.0.1 +torch @ https://download.pytorch.org/whl/cpu/torch-2.0.1%2Bcpu-cp310-cp310-linux_x86_64.whl torchmetrics==0.11.4 tqdm==4.65.0 ucimlrepo==0.0.7 -xgboost==1.7.5 +xgboost==3.0.0 pdfkit==1.0.0 diff --git a/run.py b/run.py index 1198842..7da1277 100644 --- a/run.py +++ b/run.py @@ -22,7 +22,9 @@ # Break when "ready" is printed for line in server_process.stderr: print(line, end='') # process line here - if "Requesting initial parameters" in line: + # if "Requesting initial parameters" in line: + # if "FL starting" in line: + if "Starting Flower server" in line: break From 12f4a4fd6a35aa571a60b6f4864bddca6da9d54b Mon Sep 17 00:00:00 2001 From: faildeny Date: Mon, 23 Feb 2026 12:05:39 +0100 Subject: [PATCH 28/29] Updates to metrics and linear models --- benchmark.py | 99 +++++++++++++-------------- flcore/datasets.py | 8 ++- flcore/metrics.py | 3 + flcore/models/linear_models/client.py | 4 +- flcore/models/linear_models/server.py | 5 -- flcore/models/linear_models/utils.py | 6 +- 6 files changed, 64 insertions(+), 61 deletions(-) diff --git a/benchmark.py b/benchmark.py index f5e8dc9..f638325 100644 --- a/benchmark.py +++ b/benchmark.py @@ -5,50 +5,17 @@ import sys from itertools import product -experiment_name = "experiment_all_10percent" -benchmark_dir = "benchmark_results" - - -model_names = [ - "logistic_regression", - "elastic_net", - "lsvc", - "random_forest", - "balanced_random_forest", - # # "weighted_random_forest", - "xgb" - ] - -datasets = [ - # "kaggle_hf", - "diabetes", - # "ukbb_cvd", - # "cvd" - ] - -num_clients = [ - 3, - 5, - 10, - 20 -] - -dirichlet_alpha = [ - None, - # 1.0, - # 0.7 -] data_normalization = ["global"] n_features = [None] -# Normalization experiment +# # Normalization experiment # experiment_name = "normalization" -# benchmark_dir = "benchmark_results_normalization" +# benchmark_dir = "benchmark_results_normalization_3_datasets" # model_names = ["logistic_regression"] -# datasets = ["diabetes", "ukbb_cvd"] +# datasets = ["kaggle_hf", "diabetes", "ukbb_cvd"] # num_clients = [10] -# dirichlet_alpha = [0.7, None] +# dirichlet_alpha = [0.7] # data_normalization = ["global", "local", None] # # Feature selection experiment @@ -61,20 +28,41 @@ # data_normalization = ["global"] # n_features = [10, 20, 35, 40, None] -# # Number of Clients ablation experiment -experiment_name = "num_clients_ablation" -benchmark_dir = "benchmark_results_num_clients_ablation" +# # # Number of Clients ablation experiment +# experiment_name = "num_clients_ablation" +# benchmark_dir = "benchmark_results_num_clients_ablation_correct_fixed_xgb_and_seed_42" +# model_names = [ +# "logistic_regression", +# "elastic_net", +# "lsvc", +# "random_forest", +# "balanced_random_forest", +# "xgb" +# ] +# datasets = ["diabetes"] +# num_clients = [1,3,5,10,20,50] +# # num_clients = [20] +# # num_clients = [1, 50] +# # dirichlet_alpha = [0.7, None] +# dirichlet_alpha = [0.7] +# data_normalization = ["global"] +# n_features = [None] + +# # General benchmark experiment +experiment_name = "general" +benchmark_dir = "benchmark_results_general_xgb" model_names = [ - "logistic_regression", - "elastic_net", - "lsvc", - "random_forest", - "balanced_random_forest", +# "logistic_regression", +# "elastic_net", +# "lsvc", + # "random_forest", + # "balanced_random_forest", "xgb" ] -datasets = ["diabetes"] -num_clients = [3,5,10,20] -dirichlet_alpha = [0.7, 1.0, None] +datasets = ["kaggle_hf", "ukbb_cvd"] +# datasets = ["ukbb_cvd"] +num_clients = [10] +dirichlet_alpha = [0.7] data_normalization = ["global"] n_features = [None] @@ -102,6 +90,17 @@ for ds_name, n_client, alpha, m_name, norm, n_feat in parameters: print(f"Running benchmark: {ds_name}, {m_name}, clients: {n_client}, alpha: {alpha}, normalization: {norm}, features: {n_feat}") + if "kaggle_hf" in ds_name: n_client = 4 + if "ukbb_cvd" in ds_name: n_client = 20 + # if "diabetes" in ds_name: n_client = 10 + if "forest" in m_name: + config['num_rounds'] = 1 + elif "xgb" in m_name: + config['num_rounds'] = 60 + # config['num_rounds'] = config['xgb']['tree_num'] // config['num_clients'] + print("Running xgb for: ", config['num_rounds'], " rounds") + else: + config['num_rounds'] = 10 # Update config dictionary config.update({ 'model': m_name, @@ -111,8 +110,8 @@ 'data_normalization': norm, 'n_features': n_feat }) - if "forest" in m_name: - config['num_rounds'] = 1 # Set number of jobs for parallel processing + + # Set number of jobs for parallel processing config['experiment']['name'] = f"{experiment_name}_{ds_name}_{m_name}_c{n_client}_a{alpha}_norm{norm}_feat{n_feat}" diff --git a/flcore/datasets.py b/flcore/datasets.py index 68d048f..74268ca 100644 --- a/flcore/datasets.py +++ b/flcore/datasets.py @@ -482,7 +482,13 @@ def prepare_dataset(X, y, center_id, config, center_indices=None): feature_selection_method = config.get("feature_selection_method", "mutual_info") normalization_method = config.get("data_normalization", "global") - np.random.seed(42) # For reproducibility of partitioning and reference selection + # np.random.seed(42) + # # For reproducibility of partitioning and reference selection + seed = 42 + # if num_centers == 20: + # seed = 46 + np.random.seed(seed) # For reproducibility of partitioning and reference selection + # np.random.seed(config['seed']) # For reproducibility of partitioning and reference selection # Convert target to binary classification if needed if y.nunique() > 2: diff --git a/flcore/metrics.py b/flcore/metrics.py index 9bbcb89..1115aa3 100644 --- a/flcore/metrics.py +++ b/flcore/metrics.py @@ -85,6 +85,9 @@ def calculate_metrics(y_true, y_pred_proba, task_type="binary", threshold=0.5): metrics = metrics_collection.compute() metrics = {k: v.item() for k, v in metrics.items()} + # Add n positives and n negatives to the metrics for better interpretability + metrics["n positives"] = (y_true == 1).sum().item() + metrics["n negatives"] = (y_true == 0).sum().item() return metrics diff --git a/flcore/models/linear_models/client.py b/flcore/models/linear_models/client.py index b0dd88b..8586a2a 100644 --- a/flcore/models/linear_models/client.py +++ b/flcore/models/linear_models/client.py @@ -38,13 +38,12 @@ def __init__(self, data,client_id,config): # self.X_test = scaled_features_df self.model_name = config['model'] - self.n_features = config['linear_models']['n_features'] self.model = utils.get_model(self.model_name) self.round_time = 0 self.first_round = True self.personalize = True # Setting initial parameters, akin to model.compile for keras models - utils.set_initial_params(self.model, (self.X_train, self.y_train), self.n_features) + utils.set_initial_params(self.model, (self.X_train, self.y_train)) def get_parameters(self, config): # type: ignore #compute the feature selection @@ -89,6 +88,7 @@ def fit(self, parameters, config): # type: ignore if self.first_round: local_model = utils.get_model(self.model_name, local=True) # utils.set_initial_params(local_model,self.n_features) + print("Training local model for comparison") local_model.fit(self.X_train, self.y_train) # Calculate validation set metrics if self.model_name == 'lsvc': diff --git a/flcore/models/linear_models/server.py b/flcore/models/linear_models/server.py index a49da28..9a8700f 100644 --- a/flcore/models/linear_models/server.py +++ b/flcore/models/linear_models/server.py @@ -137,11 +137,6 @@ def evaluate_held_out( def get_server_and_strategy(config): - model_type = config['model'] - # model = get_model(model_type) - n_features = config['linear_models']['n_features'] - # utils.set_initial_params(model, n_features) - # Pass parameters to the Strategy for server-side parameter initialization #strategy = fl.server.strategy.FedAvg( strategy = FedCustom( diff --git a/flcore/models/linear_models/utils.py b/flcore/models/linear_models/utils.py index 512642e..b0d2c04 100644 --- a/flcore/models/linear_models/utils.py +++ b/flcore/models/linear_models/utils.py @@ -12,9 +12,9 @@ def get_model(model_name, local=False): if local: - max_iter = 100000 + max_iter = 1000 else: - max_iter = 1 + max_iter = 10 match model_name: case "lsvc": @@ -83,7 +83,7 @@ def set_model_params( return model -def set_initial_params(model: LinearClassifier, data, n_features): +def set_initial_params(model: LinearClassifier, data): """Sets initial parameters as zeros Required since model params are uninitialized until model.fit is called. But server asks for initial parameters from clients at launch. Refer From 32eb2b725709751d2608b3d7adb641a6e5a679cc Mon Sep 17 00:00:00 2001 From: faildeny Date: Thu, 2 Apr 2026 11:10:00 +0200 Subject: [PATCH 29/29] Add fairness metrics computation --- benchmark.py | 76 ++++++++----- flcore/compile_results.py | 11 +- flcore/datasets.py | 21 +++- flcore/metrics.py | 151 ++++++++++++++++++++++++-- flcore/models/linear_models/client.py | 122 +++++++++++++-------- flcore/models/linear_models/utils.py | 34 ++++-- flcore/models/random_forest/client.py | 41 ++++++- flcore/models/xgb/client.py | 52 +++++++-- flcore/models/xgb/server.py | 55 ---------- tests/test_models.py | 4 +- 10 files changed, 394 insertions(+), 173 deletions(-) diff --git a/benchmark.py b/benchmark.py index f638325..067ae82 100644 --- a/benchmark.py +++ b/benchmark.py @@ -28,43 +28,58 @@ # data_normalization = ["global"] # n_features = [10, 20, 35, 40, None] -# # # Number of Clients ablation experiment -# experiment_name = "num_clients_ablation" -# benchmark_dir = "benchmark_results_num_clients_ablation_correct_fixed_xgb_and_seed_42" +# Number of Clients ablation experiment +experiment_name = "num_clients_ablation" +benchmark_dir = "benchmark_results_num_clients_ablation_lsvc_elasticnet" +model_names = [ +# "logistic_regression", + "elastic_net", + "lsvc", + # "random_forest", + # "balanced_random_forest", + # "xgb" + ] +datasets = ["diabetes"] +num_clients = [1,3,5,10,20,50] +dirichlet_alpha = [0.7, None] +# dirichlet_alpha = [0.7] +data_normalization = ["global"] +n_features = [None] + +# # General benchmark experiment +# experiment_name = "general" +# benchmark_dir = "benchmark_results_general_local_fixed" # model_names = [ # "logistic_regression", -# "elastic_net", -# "lsvc", -# "random_forest", +# # "elastic_net", +# # "lsvc", +# # "random_forest", # "balanced_random_forest", # "xgb" # ] -# datasets = ["diabetes"] -# num_clients = [1,3,5,10,20,50] -# # num_clients = [20] -# # num_clients = [1, 50] -# # dirichlet_alpha = [0.7, None] +# datasets = ["kaggle_hf", "diabetes", "ukbb_cvd"] +# # datasets = ["ukbb_cvd"] +# num_clients = [10] # dirichlet_alpha = [0.7] # data_normalization = ["global"] # n_features = [None] -# # General benchmark experiment -experiment_name = "general" -benchmark_dir = "benchmark_results_general_xgb" -model_names = [ -# "logistic_regression", -# "elastic_net", -# "lsvc", - # "random_forest", - # "balanced_random_forest", - "xgb" - ] -datasets = ["kaggle_hf", "ukbb_cvd"] -# datasets = ["ukbb_cvd"] -num_clients = [10] -dirichlet_alpha = [0.7] -data_normalization = ["global"] -n_features = [None] +# Fairness benchmark experiment +# experiment_name = "fairness" +# benchmark_dir = "benchmark_results_fairness_10_clients" +# model_names = [ +# # "logistic_regression", +# # "elastic_net", +# # "lsvc", +# # "random_forest", +# # "balanced_random_forest", +# "xgb" +# ] +# datasets = ["diabetes"] +# num_clients = [10] +# dirichlet_alpha = [0.7, None] +# data_normalization = ["global"] +# n_features = [None] os.makedirs(benchmark_dir, exist_ok=True) @@ -81,6 +96,11 @@ config['data_path'] = 'dataset/' config['experiment']['log_path'] = benchmark_dir +if "fairness" in experiment_name: + config['parititon_by_attribute'] = "Sex" +else: + config['parititon_by_attribute'] = None + start_time = time.time() # Flatten the nested loops into a single iterator diff --git a/flcore/compile_results.py b/flcore/compile_results.py index 4f8d7b8..69945d5 100644 --- a/flcore/compile_results.py +++ b/flcore/compile_results.py @@ -56,7 +56,8 @@ def compile_results(experiment_dir: str): # best_round = -1 print(f"Best round for {directory} based on {selection_metric}: {best_round}") # client_order = history['metrics_distributed']['per client client_id'][best_round] - client_order = history['metrics_distributed']['per client n samples'][best_round] + client_order = history['metrics_distributed']['per client client_id'][best_round] + local_client_order = history['metrics_distributed_fit']['per client client_id'][0] for logs in history.keys(): if isinstance(history[logs], dict): for metric in history[logs]: @@ -66,10 +67,12 @@ def compile_results(experiment_dir: str): continue if 'local' in metric: values = values_history[0] + ids, values = zip(*sorted(zip(local_client_order, values), key=lambda x: x[0])) else: values = values_history[best_round] - # sort by key client_id in the metrics dict - ids, values = zip(*sorted(zip(client_order, values), key=lambda x: x[0])) + # sort by key client_id in the metrics dict + ids, values = zip(*sorted(zip(client_order, values), key=lambda x: x[0])) + metric = metric.replace("per client ", "") if metric not in per_client_metrics: @@ -98,6 +101,8 @@ def compile_results(experiment_dir: str): else: fit_metrics[metric] = np.vstack((fit_metrics[metric], values_history[-1])) else: + if "id" in metric: + continue if metric not in fit_metrics: fit_metrics[metric] = np.array(values_history[best_round]) else: diff --git a/flcore/datasets.py b/flcore/datasets.py index 74268ca..e8993c3 100644 --- a/flcore/datasets.py +++ b/flcore/datasets.py @@ -477,6 +477,7 @@ def prepare_dataset(X, y, center_id, config, center_indices=None): reference_method = config.get("reference_center_method", "largest") preprocessing_method = config.get("data_preprocessing_method", "reference") min_samples_per_class = config.get("min_samples_per_class", 10) + partition_by_attribute = config.get("parititon_by_attribute", None) global_preprocessing_params = None n_features = config.get("n_features", 20) feature_selection_method = config.get("feature_selection_method", "mutual_info") @@ -498,7 +499,25 @@ def prepare_dataset(X, y, center_id, config, center_indices=None): if not center_indices: # Partition data using Dirichlet distribution - all_center_indices = partition_data_dirichlet(y_binary.values, num_centers, alpha, min_samples_per_class) + if partition_by_attribute is not None: + if isinstance(partition_by_attribute, int): + if partition_by_attribute < 0 or partition_by_attribute >= X.shape[1]: + raise ValueError( + f"Invalid parititon_by_attribute index: {partition_by_attribute}" + ) + partition_labels = X.iloc[:, partition_by_attribute] + else: + if partition_by_attribute not in X.columns: + raise ValueError( + f"parititon_by_attribute column not found: {partition_by_attribute}" + ) + partition_labels = X[partition_by_attribute] + else: + partition_labels = y + + all_center_indices = partition_data_dirichlet( + np.asarray(partition_labels), num_centers, alpha, min_samples_per_class + ) else: all_center_indices = center_indices diff --git a/flcore/metrics.py b/flcore/metrics.py index 1115aa3..4c62c1d 100644 --- a/flcore/metrics.py +++ b/flcore/metrics.py @@ -1,6 +1,7 @@ import numpy as np import torch from torch import Tensor +from typing import Any, Optional from torchmetrics import MetricCollection from torchmetrics.classification import ( BinaryAccuracy, @@ -64,18 +65,131 @@ def get_metrics_collection(task_type="binary", device="cpu", threshold=0.5): }) -def calculate_metrics(y_true, y_pred_proba, task_type="binary", threshold=0.5): +def _to_tensor(values): + if torch.is_tensor(values): + return values + if isinstance(values, list): + return torch.cat(values) + if hasattr(values, "tolist"): + return torch.tensor(values.tolist()) + return torch.tensor(values) + + +def _extract_attribute_values(X: Any, attribute: Any): + if X is None: + return None + + if hasattr(X, "loc") and hasattr(X, "columns"): + if attribute in X.columns: + return np.asarray(X[attribute]) + return None + + array = np.asarray(X) + if array.ndim < 2: + return None + + if isinstance(attribute, int) and 0 <= attribute < array.shape[1]: + return array[:, attribute] + + return None + + +def _group_rates(y_true_group: Tensor, y_pred_group: Tensor, threshold: float): + y_pred_labels = (y_pred_group >= threshold).int() + y_true_group = y_true_group.int() + + tp = ((y_pred_labels == 1) & (y_true_group == 1)).sum().item() + tn = ((y_pred_labels == 0) & (y_true_group == 0)).sum().item() + fp = ((y_pred_labels == 1) & (y_true_group == 0)).sum().item() + fn = ((y_pred_labels == 0) & (y_true_group == 1)).sum().item() + + tpr = tp / (tp + fn) if (tp + fn) > 0 else 0.0 + fpr = fp / (fp + tn) if (fp + tn) > 0 else 0.0 + + return tpr, fpr + + +def _normalize_key_part(value: Any): + return str(value).strip().replace(" ", "_") + + +def _group_label(index: int): + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + if 0 <= index < len(alphabet): + return alphabet[index] + return f"G{index + 1}" + + +def calculate_fairness_metrics( + y_true, + y_pred_proba, + X, + fairness_attributes, + task_type="binary", + threshold=0.5, +): + fairness_results = {} + + y_true_tensor = _to_tensor(y_true) + y_pred_tensor = _to_tensor(y_pred_proba) + if y_pred_tensor.ndim > 1 and y_pred_tensor.shape[1] > 1: + y_pred_tensor = y_pred_tensor[:, 1] + + for attribute in fairness_attributes: + attribute_values = _extract_attribute_values(X, attribute) + if attribute_values is None: + continue + + attribute_values = np.asarray(attribute_values) + unique_groups = np.unique(attribute_values) + + group_rates = [] + normalized_attribute = _normalize_key_part(attribute) + + for group_idx, group in enumerate(unique_groups): + group_mask = torch.tensor(attribute_values == group, dtype=torch.bool) + if group_mask.numel() != y_true_tensor.numel() or group_mask.sum().item() == 0: + continue + + group_y_true = y_true_tensor[group_mask] + group_y_pred = y_pred_tensor[group_mask] + group_metrics = calculate_metrics( + group_y_true, + group_y_pred, + task_type=task_type, + threshold=threshold, + ) + + normalized_group = _group_label(group_idx) + for metric_name, metric_value in group_metrics.items(): + normalized_metric = _normalize_key_part(metric_name) + fairness_results[ + f"{normalized_attribute}_{normalized_group}_{normalized_metric}" + ] = metric_value + + if task_type.lower() == "binary": + group_rates.append(_group_rates(group_y_true, group_y_pred, threshold)) + + if task_type.lower() == "binary" and len(group_rates) == 2: + tpr_diff = abs(group_rates[0][0] - group_rates[1][0]) + fpr_diff = abs(group_rates[0][1] - group_rates[1][1]) + fairness_results[f"{normalized_attribute}_EOP"] = tpr_diff + fairness_results[f"{normalized_attribute}_EOD"] = max(tpr_diff, fpr_diff) + + return fairness_results + + +def calculate_metrics( + y_true, + y_pred_proba, + task_type="binary", + threshold=0.5, + X: Optional[Any] = None, + fairness_attributes: Optional[list] = None, +): metrics_collection = get_metrics_collection(task_type, threshold=threshold) - if not torch.is_tensor(y_true): - if isinstance(y_true, list): - y_true = torch.cat(y_true) - else: - y_true = torch.tensor(y_true.tolist()) - if not torch.is_tensor(y_pred_proba): - if isinstance(y_pred_proba, list): - y_pred_proba = torch.cat(y_pred_proba) - else: - y_pred_proba = torch.tensor(y_pred_proba.tolist()) + y_true = _to_tensor(y_true) + y_pred_proba = _to_tensor(y_pred_proba) # Extract probabilities for the positive class if shape>1 if y_pred_proba.ndim > 1 and y_pred_proba.shape[1] > 1: @@ -89,10 +203,23 @@ def calculate_metrics(y_true, y_pred_proba, task_type="binary", threshold=0.5): metrics["n positives"] = (y_true == 1).sum().item() metrics["n negatives"] = (y_true == 0).sum().item() + if X is not None and fairness_attributes: + fairness_metrics = calculate_fairness_metrics( + y_true=y_true, + y_pred_proba=y_pred_proba, + X=X, + fairness_attributes=fairness_attributes, + task_type=task_type, + threshold=threshold, + ) + + #Extract fairness metrics and add them to the main metrics dictionary + metrics.update(fairness_metrics) + return metrics def metrics_aggregation_fn(distributed_metrics): - print(distributed_metrics[0][1].keys()) + # print(distributed_metrics[0][1].keys()) keys_names = distributed_metrics[0][1].keys() keys_names = list(keys_names) diff --git a/flcore/models/linear_models/client.py b/flcore/models/linear_models/client.py index 8586a2a..d03b030 100644 --- a/flcore/models/linear_models/client.py +++ b/flcore/models/linear_models/client.py @@ -1,6 +1,7 @@ from sklearn.linear_model import SGDClassifier from sklearn.metrics import log_loss +import numpy as np import time from sklearn.feature_selection import SelectKBest, f_classif from sklearn.model_selection import KFold, StratifiedShuffleSplit, train_test_split @@ -26,24 +27,33 @@ def __init__(self, data,client_id,config): # Create train and validation split self.X_train, self.X_val, self.y_train, self.y_val = train_test_split(self.X_train, self.y_train, test_size=0.2, random_state=config['seed'], stratify=self.y_train) - - # #Only use the standardScaler to the continous variables - # scaled_features_train = StandardScaler().fit_transform(self.X_train.values) - # scaled_features_train = pd.DataFrame(scaled_features_train, index=self.X_train.index, columns=self.X_train.columns) - # self.X_train = scaled_features_train - - # #Only use the standardScaler to the continous variables. - # scaled_features = StandardScaler().fit_transform(self.X_test.values) - # scaled_features_df = pd.DataFrame(scaled_features, index=self.X_test.index, columns=self.X_test.columns) - # self.X_test = scaled_features_df self.model_name = config['model'] - self.model = utils.get_model(self.model_name) + self.config = config + self.model = utils.get_model(self.model_name, self.config) self.round_time = 0 self.first_round = True self.personalize = True + self.use_fedprox = str(config.get("strategy", "FedAvg")).lower() == "fedprox" + self.fedprox_mu = float(config.get("fedprox_mu", 0.02)) + self.fairness_attribute = config.get("parititon_by_attribute", None) + if self.fairness_attribute is None: + self.fairness_attribute = config.get("partition_by_attribute", None) + self.fairness_attributes = ( + [self.fairness_attribute] if self.fairness_attribute is not None else None + ) # Setting initial parameters, akin to model.compile for keras models utils.set_initial_params(self.model, (self.X_train, self.y_train)) + + def _predict_scores(self, X): + if hasattr(self.model, "predict_proba"): + return self.model.predict_proba(X) + return self.model.decision_function(X) + + def _get_fairness_kwargs(self, X_subset): + if self.fairness_attributes is None: + return {} + return {"X": X_subset, "fairness_attributes": self.fairness_attributes} def get_parameters(self, config): # type: ignore #compute the feature selection @@ -56,27 +66,40 @@ def get_parameters(self, config): # type: ignore return utils.get_model_parameters(self.model) def fit(self, parameters, config): # type: ignore - + global_params = [np.copy(param) for param in parameters] utils.set_model_params(self.model, parameters) # Ignore convergence failure due to low local epochs with warnings.catch_warnings(): warnings.simplefilter("ignore") #To implement the center dropout, we need the execution time start_time = time.time() - self.model.fit(self.X_train, self.y_train) + + if self.use_fedprox and self.fedprox_mu > 0.0 and isinstance(self.model, SGDClassifier): + local_steps = 10 + for _ in range(local_steps): + self.model.partial_fit(self.X_train, self.y_train) + # FedProx proximal correction on model parameters + self.model.coef_ = self.model.coef_ - self.fedprox_mu * (self.model.coef_ - global_params[0]) + if self.model.fit_intercept: + self.model.intercept_ = self.model.intercept_ - self.fedprox_mu * ( + self.model.intercept_ - global_params[1] + ) + print("Training with FedProx, mu =", self.fedprox_mu) + else: + print("Training without FedProx") + self.model.fit(self.X_train, self.y_train) + # self.model.fit(self.X_train.loc[:, parameters[2].astype(bool)], self.y_train) # y_pred = self.model.predict(self.X_test.loc[:, parameters[2].astype(bool)]) - # If LSVC is used, use decision_function instead of predict_proba - if self.model_name == 'lsvc': - y_pred_proba = self.model.decision_function(self.X_val) - else: - y_pred_proba = self.model.predict_proba(self.X_val) + y_pred_proba = self._predict_scores(self.X_val) best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") - if self.model_name == 'lsvc': - y_pred_proba = self.model.decision_function(self.X_test) - else: - y_pred_proba = self.model.predict_proba(self.X_test) - metrics = calculate_metrics(self.y_test, y_pred_proba, threshold=best_threshold) + y_pred_proba = self._predict_scores(self.X_test) + metrics = calculate_metrics( + self.y_test, + y_pred_proba, + threshold=best_threshold, + **self._get_fairness_kwargs(self.X_test), + ) # Add 'personalized' to the metrics to identify them metrics = {f"personalized {key}": metrics[key] for key in metrics} self.round_time = (time.time() - start_time) @@ -86,24 +109,24 @@ def fit(self, parameters, config): # type: ignore print(f"Training finished for round {config['server_round']}") if self.first_round: - local_model = utils.get_model(self.model_name, local=True) + local_model = utils.get_model(self.model_name, self.config, local=True) # utils.set_initial_params(local_model,self.n_features) print("Training local model for comparison") local_model.fit(self.X_train, self.y_train) # Calculate validation set metrics - if self.model_name == 'lsvc': - y_pred_proba = self.model.decision_function(self.X_val) - else: - y_pred_proba = self.model.predict_proba(self.X_val) + y_pred_proba = self._predict_scores(self.X_val) best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") - if self.model_name == 'lsvc': - y_pred_proba = self.model.decision_function(self.X_test) - else: - y_pred_proba = self.model.predict_proba(self.X_test) - local_metrics = calculate_metrics(self.y_test, y_pred_proba, threshold=best_threshold) + y_pred_proba = self._predict_scores(self.X_test) + local_metrics = calculate_metrics( + self.y_test, + y_pred_proba, + threshold=best_threshold, + **self._get_fairness_kwargs(self.X_test), + ) #Add 'local' to the metrics to identify them local_metrics = {f"local {key}": local_metrics[key] for key in local_metrics} metrics.update(local_metrics) + metrics["client_id"] = self.client_id self.first_round = False return utils.get_model_parameters(self.model), len(self.X_train), metrics @@ -112,17 +135,16 @@ def evaluate(self, parameters, config): # type: ignore utils.set_model_params(self.model, parameters) # Calculate validation set metrics - if self.model_name == 'lsvc': - y_pred_proba = self.model.decision_function(self.X_val) - else: - y_pred_proba = self.model.predict_proba(self.X_val) + y_pred_proba = self._predict_scores(self.X_val) best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") - val_metrics = calculate_metrics(self.y_val, y_pred_proba, threshold=best_threshold) - - if self.model_name == 'lsvc': - y_pred_proba = self.model.decision_function(self.X_test) - else: - y_pred_proba = self.model.predict_proba(self.X_test) + val_metrics = calculate_metrics( + self.y_val, + y_pred_proba, + threshold=best_threshold, + **self._get_fairness_kwargs(self.X_val), + ) + + y_pred_proba = self._predict_scores(self.X_test) # y_pred = self.model.predict(self.X_test.loc[:, parameters[2].astype(bool)]) if(isinstance(self.model, SGDClassifier)): @@ -130,8 +152,18 @@ def evaluate(self, parameters, config): # type: ignore else: loss = log_loss(self.y_test, self.model.predict_proba(self.X_test), labels=[0, 1]) - metrics = calculate_metrics(self.y_test, y_pred_proba, threshold=best_threshold) - metrics_not_tuned = calculate_metrics(self.y_test, y_pred_proba, threshold=0.5) + metrics = calculate_metrics( + self.y_test, + y_pred_proba, + threshold=best_threshold, + **self._get_fairness_kwargs(self.X_test), + ) + metrics_not_tuned = calculate_metrics( + self.y_test, + y_pred_proba, + threshold=0.5, + **self._get_fairness_kwargs(self.X_test), + ) metrics_not_tuned = {f"not tuned {key}": metrics_not_tuned[key] for key in metrics_not_tuned} metrics.update(metrics_not_tuned) metrics["round_time [s]"] = self.round_time diff --git a/flcore/models/linear_models/utils.py b/flcore/models/linear_models/utils.py index b0d2c04..6d08480 100644 --- a/flcore/models/linear_models/utils.py +++ b/flcore/models/linear_models/utils.py @@ -9,13 +9,17 @@ XYList = List[XY] -def get_model(model_name, local=False): +def get_model(model_name, config, local=False): + + # Adjust learning rate for SGDClassifier based on config + eta = config["learning_rate"] if local: max_iter = 1000 else: - max_iter = 10 - + max_iter = 1 + eta = eta / config["num_clients"] + match model_name: case "lsvc": #Linear classifiers (SVM, logistic regression, etc.) with SGD training. @@ -26,10 +30,12 @@ def get_model(model_name, local=False): average=True, # random_state=42, class_weight= "balanced", + learning_rate="constant", + eta0=eta, warm_start=True, fit_intercept=True, loss="hinge", - learning_rate='optimal' + # learning_rate='optimal' ) case "logistic_regression": model = LogisticRegression( @@ -41,15 +47,19 @@ def get_model(model_name, local=False): class_weight= "balanced" #For unbalanced ) case "elastic_net": - model = LogisticRegression( - l1_ratio=0.5,#necessary param for elasticnet otherwise error - penalty="elasticnet", - solver='saga', #necessary param for elasticnet otherwise error - #max_iter=1, # local epoch ==>> it doesn't work - max_iter=max_iter, # local epoch - warm_start=True, # prevent refreshing weights when fitting + model = SGDClassifier( + max_iter=max_iter, + n_iter_no_change=1000, + average=True, # random_state=42, - class_weight= "balanced" #For unbalanced + class_weight="balanced", + learning_rate="constant", + eta0=eta, + warm_start=True, + fit_intercept=True, + loss="log_loss", + penalty="elasticnet", + l1_ratio=0.5, ) diff --git a/flcore/models/random_forest/client.py b/flcore/models/random_forest/client.py index e53984b..bba09c0 100644 --- a/flcore/models/random_forest/client.py +++ b/flcore/models/random_forest/client.py @@ -34,8 +34,20 @@ def __init__(self, data,client_id,config): self.round_time = 0 self.tree_num = config['random_forest']['tree_num'] self.first_round = True + self.fairness_attribute = config.get("parititon_by_attribute", None) + if self.fairness_attribute is None: + self.fairness_attribute = config.get("partition_by_attribute", None) + self.fairness_attributes = ( + [self.fairness_attribute] if self.fairness_attribute is not None else None + ) # Setting initial parameters, akin to model.compile for keras models utils.set_initial_params_client(self.model,self.X_train, self.y_train) + + def _get_fairness_kwargs(self, X_subset): + if self.fairness_attributes is None: + return {} + return {"X": X_subset, "fairness_attributes": self.fairness_attributes} + def get_parameters(self, ins: GetParametersIns): # , config type: ignore params = utils.get_model_parameters(self.model) @@ -68,7 +80,11 @@ def fit(self, ins: FitIns): # , parameters, config type: ignore self.model.fit(self.X_train_2, self.y_train_2) elapsed_time = (time.time() - start_time) y_pred_proba = self.model.predict_proba(self.X_val) - metrics = calculate_metrics(self.y_val, y_pred_proba) + metrics = calculate_metrics( + self.y_val, + y_pred_proba, + **self._get_fairness_kwargs(self.X_val), + ) metrics["running_time"] = elapsed_time self.round_time = elapsed_time @@ -84,11 +100,18 @@ def fit(self, ins: FitIns): # , parameters, config type: ignore best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") y_pred_proba = local_model.predict_proba(self.X_test) - local_metrics = calculate_metrics(self.y_test, y_pred_proba, threshold=best_threshold) + local_metrics = calculate_metrics( + self.y_test, + y_pred_proba, + threshold=best_threshold, + **self._get_fairness_kwargs(self.X_test), + ) #Add 'local' to the metrics to identify them local_metrics = {f"local {key}": local_metrics[key] for key in local_metrics} metrics.update(local_metrics) self.first_round = False + + metrics["client_id"] = self.client_id # Serialize to send it to the server params = utils.get_model_parameters(self.model) @@ -113,7 +136,12 @@ def evaluate(self, ins: EvaluateIns): # , parameters, config type: ignore y_pred_proba = self.model.predict_proba(self.X_val) best_threshold = find_best_threshold(self.y_val, y_pred_proba, metric="balanced_accuracy") # Get validation metrics - val_metrics = calculate_metrics(self.y_val, y_pred_proba, threshold=best_threshold) + val_metrics = calculate_metrics( + self.y_val, + y_pred_proba, + threshold=best_threshold, + **self._get_fairness_kwargs(self.X_val), + ) val_metrics = {f"val {key}": val_metrics[key] for key in val_metrics} y_pred_prob = self.model.predict_proba(self.X_test) @@ -121,7 +149,12 @@ def evaluate(self, ins: EvaluateIns): # , parameters, config type: ignore # accuracy,specificity,sensitivity,balanced_accuracy, precision, F1_score = \ # measurements_metrics(self.model,self.X_test, self.y_test) # y_pred = self.model.predict(self.X_test) - metrics = calculate_metrics(self.y_test, y_pred_prob, threshold=best_threshold) + metrics = calculate_metrics( + self.y_test, + y_pred_prob, + threshold=best_threshold, + **self._get_fairness_kwargs(self.X_test), + ) metrics.update(val_metrics) metrics["round_time [s]"] = self.round_time metrics["client_id"] = self.client_id diff --git a/flcore/models/xgb/client.py b/flcore/models/xgb/client.py index 0fc47b9..8dfda45 100644 --- a/flcore/models/xgb/client.py +++ b/flcore/models/xgb/client.py @@ -60,6 +60,12 @@ def __init__( self.label_encoder = None # For categorical target encoding self.round_time = None + self.fairness_attribute = config.get("parititon_by_attribute", None) + if self.fairness_attribute is None: + self.fairness_attribute = config.get("partition_by_attribute", None) + self.fairness_attributes = ( + [self.fairness_attribute] if self.fairness_attribute is not None else None + ) # Prepare data self._prepare_data() @@ -67,6 +73,11 @@ def __init__( print(f"[Client] Initialized") print(f"[Client] Training samples: {len(self.local_data['X_train'])}") print(f"[Client] Test samples: {len(self.local_data['X_test'])}") + + def _get_fairness_kwargs(self, X_subset): + if self.fairness_attributes is None: + return {} + return {"X": X_subset, "fairness_attributes": self.fairness_attributes} def _prepare_data(self): """Convert data to DMatrix format for XGBoost.""" @@ -190,7 +201,12 @@ def fit( # Get test metrics and add to metrics with 'local' prefix y_test_pred = local_bst.predict(self.dtest) y_test_true = self.local_data['y_test'] - local_metrics = calculate_metrics(y_test_true, y_test_pred, threshold=best_threshold) + local_metrics = calculate_metrics( + y_test_true, + y_test_pred, + threshold=best_threshold, + **self._get_fairness_kwargs(self.local_data['X_test']), + ) metrics.update({f"local {key}": local_metrics[key] for key in local_metrics}) else: # Subsequent rounds: load global model and continue training @@ -244,6 +260,8 @@ def fit( metrics['num_examples'] = num_examples metrics['num_trees'] = self.bst.num_boosted_rounds() + metrics["client_id"] = self.client_id + # Save local model @@ -311,7 +329,12 @@ def evaluate( y_val_pred = self.bst.predict(self.dval) y_val_true = self.local_data['y_val'] best_threshold = find_best_threshold(y_val_true, y_val_pred) - metrics_val = calculate_metrics(y_val_true, y_val_pred, threshold=best_threshold) + metrics_val = calculate_metrics( + y_val_true, + y_val_pred, + threshold=best_threshold, + **self._get_fairness_kwargs(self.local_data['X_val']), + ) metrics.update({f"val {key}": metrics_val[key] for key in metrics_val}) @@ -327,7 +350,12 @@ def evaluate( # Binary classification from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score - general_metrics = calculate_metrics(y_true, y_pred, threshold=best_threshold) + general_metrics = calculate_metrics( + y_true, + y_pred, + threshold=best_threshold, + **self._get_fairness_kwargs(self.local_data['X_test']), + ) metrics.update(general_metrics) # Add n samples to metrics metrics['n samples'] = len(y_true) @@ -387,15 +415,15 @@ def get_numpy(X_train, y_train, X_test, y_test, time_col=None, event_col=None) - Dictionary with X_train, y_train, X_test, y_test """ - # Convert to numpy if needed - if hasattr(X_train, 'values'): # pandas DataFrame - X_train = X_train.values - if hasattr(y_train, 'values'): # pandas Series - y_train = y_train.values - if hasattr(X_test, 'values'): - X_test = X_test.values - if hasattr(y_test, 'values'): - y_test = y_test.values + # # Convert to numpy if needed + # if hasattr(X_train, 'values'): # pandas DataFrame + # X_train = X_train.values + # if hasattr(y_train, 'values'): # pandas Series + # y_train = y_train.values + # if hasattr(X_test, 'values'): + # X_test = X_test.values + # if hasattr(y_test, 'values'): + # y_test = y_test.values return { 'X_train': X_train, diff --git a/flcore/models/xgb/server.py b/flcore/models/xgb/server.py index c83be5e..a4cea49 100644 --- a/flcore/models/xgb/server.py +++ b/flcore/models/xgb/server.py @@ -12,11 +12,6 @@ import xgboost as xgb from flwr.common import ( - # ArrayRecord, - # ConfigRecord, - # Message, - # MetricRecord, - # RecordDict, Parameters, FitRes, EvaluateRes, @@ -44,52 +39,6 @@ def _get_tree_nums(xgb_model_org: bytes): paral_tree_num = int(model["gbtree_model_param"]["num_parallel_tree"]) return tree_num, paral_tree_num - - -# def aggregate_metricrecords( -# records: list[RecordDict], weighting_metric_name: str -# ) -> MetricRecord: -# """Perform weighted aggregation all MetricRecords using a specific key.""" -# # Retrieve weighting factor from MetricRecord -# weights: list[float] = [] -# for record in records: -# # Get the first (and only) MetricRecord in the record -# metricrecord = next(iter(record.metric_records.values())) -# # Because replies have been checked for consistency, -# # we can safely cast the weighting factor to float -# w = cast(float, metricrecord[weighting_metric_name]) -# weights.append(w) - -# # Average -# total_weight = sum(weights) -# weight_factors = [w / total_weight for w in weights] - -# aggregated_metrics = MetricRecord() -# for record, weight in zip(records, weight_factors, strict=True): -# for record_item in record.metric_records.values(): -# # aggregate in-place -# for key, value in record_item.items(): -# if key == weighting_metric_name: -# # We exclude the weighting key from the aggregated MetricRecord -# continue -# if key not in aggregated_metrics: -# if isinstance(value, list): -# aggregated_metrics[key] = [v * weight for v in value] -# else: -# aggregated_metrics[key] = value * weight -# else: -# if isinstance(value, list): -# current_list = cast(list[float], aggregated_metrics[key]) -# aggregated_metrics[key] = [ -# curr + val * weight -# for curr, val in zip(current_list, value, strict=True) -# ] -# else: -# current_value = cast(float, aggregated_metrics[key]) -# aggregated_metrics[key] = current_value + value * weight - -# return aggregated_metrics - def aggregate_bagging( bst_prev_org: bytes, bst_curr_org: bytes, @@ -189,10 +138,6 @@ def initialize_parameters(self, client_manager): empty = np.frombuffer(b"", dtype=np.uint8) return ndarrays_to_parameters([empty]) - # ------------------------------------------------------ - # AGGREGATE FIT (CRITICAL FIX) - # ------------------------------------------------------ - def aggregate_fit( self, server_round: int, diff --git a/tests/test_models.py b/tests/test_models.py index f5969f7..82b8871 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -18,7 +18,7 @@ "random_forest", "balanced_random_forest", # # "weighted_random_forest", - "xgblr" + "xgb" ] datasets = [ @@ -48,6 +48,8 @@ def setup_class(self): self.config["xgblr"]["tree_num"] = 5 self.config["xgblr"]["num_iterations"] = 2 + self.config["xgb"]["tree_num"] = 5 + @pytest.mark.parametrize( "model_name",