""" Client Scaling Experiment - Effect of Number o...
Créé le : 31 octobre 2026
Répondu en utilisant GPT-5.6 Thinking par Chat01
Créé le : 31 octobre 2026
Répondu en utilisant GPT-5.6 Thinking par Chat01
"""
Client Scaling Experiment - Effect of Number of Clients
Following Figure 4(a) from hyper.png, this experiment systematically tests
how anomaly detection accuracy changes with increasing numbers of clients.
Experiment Design:
import json
import os
import copy
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from datetime import datetime
class ClientScalingExperiment:
"""
Experiment to study the impact of client count on federated anomaly detection
"""
textdef __init__(self, base_config=None): self.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") # Client counts to test (following the pattern from subplot a) self.client_counts = [3, 6, 9, 12, 15, 18, 21, 24] # Base configuration (optimal settings from prior experiments) self.base_config = base_config or { 'method': 'pfl', 'sync': 'True', 'gpu': 0, 'seed': 42, 'epochs': 100, # Fixed hyperparameters (use best from tuning) 'local_epochs': 5, 'beta_PAC': 0.5, # RL parameters (optimal configuration) 'actor_lr': 1e-3, 'critic_lr': 5e-3, 'rl_gamma': 0.98, 'rl_tau': 0.005, 'rl_hidden_dim': 128, 'rl_sigma': 0.05, 'rl_num_episodes': 10, 'rl_buffer_size': 10000, 'rl_minimal_size': 500, 'rl_batch_size': 64, 'rl_max_steps': 200, # Model parameters 'z_dim': 64, 'CMI_coeff': 0.0, 'L2R_coeff': 0.0, 'local_rep_ep': 1, 'last_local_ep': 2, } def generate_experiments(self): """ Generate experiment configurations for each client count """ experiments = [] for idx, num_clients in enumerate(self.client_counts, start=1): # Calculate rl_top_k: select ~50% of clients rl_top_k = max(1, num_clients // 2) config = self.base_config.copy() config.update({ 'experiment_id': f'client_scaling_{idx:02d}_n{num_clients}', 'experiment_type': 'client_scaling', 'num_clients': num_clients, 'rl_top_k': rl_top_k, 'description': f'Test with {num_clients} clients, selecting top {rl_top_k}' }) experiments.append(config) return experiments def save_experiments(self, experiments, output_dir='./experiments'): """Save experiment configurations""" os.makedirs(output_dir, exist_ok=True) filename = f'client_scaling_{self.timestamp}.json' filepath = os.path.join(output_dir, filename) with open(filepath, 'w') as f: json.dump(experiments, f, indent=2) print(f'✓ Saved {len(experiments)} client scaling experiments to {filepath}') return filepath def plot_results(self, results_csv, save_path='./client_scaling_analysis.pdf'): """ Generate visualization following Figure 4(a) style: - X-axis: Number of clients - Y-axis: Average Accuracy (AUROC) - Show gaps between methods """ if not os.path.exists(results_csv): print(f"Results file not found: {results_csv}") return df = pd.read_csv(results_csv) # Extract metrics by number of clients df['num_clients'] = df['experiment_id'].str.extract(r'n(\d+)').astype(int) df_sorted = df.sort_values('num_clients') # Create figure with two subplots fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # ===== Subplot 1: Average Accuracy vs Number of Clients ===== ax1 = axes[0] # Plot DDPG method ax1.plot(df_sorted['num_clients'], df_sorted['final_ddpg_auroc'], marker='o', color='#2E4057', linewidth=2.5, markersize=8, label='DDPG (Ours)', zorder=3) # Plot Random baseline ax1.plot(df_sorted['num_clients'], df_sorted['final_random_auroc'], marker='s', color='#F97316', linewidth=2.5, markersize=8, label='Random', alpha=0.8, zorder=2) # Add gap annotations (similar to subplot a) for i in range(0, len(df_sorted), 2): # Annotate every other point row = df_sorted.iloc[i] gap = row['improvement_auroc'] x_pos = row['num_clients'] y_ddpg = row['final_ddpg_auroc'] y_random = row['final_random_auroc'] y_mid = (y_ddpg + y_random) / 2 # Draw gap line ax1.plot([x_pos, x_pos], [y_random, y_ddpg], color='gray', linestyle='--', linewidth=1, alpha=0.5) # Add gap text ax1.text(x_pos + 0.3, y_mid, f'Gap: {gap:.2f}', fontsize=9, color='#D32F2F', bbox=dict(boxstyle='round,pad=0.3', facecolor='white', edgecolor='#D32F2F', alpha=0.9)) ax1.set_xlabel('Number of Clients', fontsize=12, fontweight='bold') ax1.set_ylabel('Average Accuracy (AUROC)', fontsize=12, fontweight='bold') ax1.set_title('(a) Effect of number of clients', fontsize=13, fontweight='bold') ax1.grid(True, linestyle='--', alpha=0.3) ax1.legend(fontsize=11, loc='best') ax1.set_xticks(df_sorted['num_clients']) # Set y-axis range to emphasize differences y_min = min(df_sorted['final_random_auroc'].min(), df_sorted['final_ddpg_auroc'].min()) - 2 y_max = max(df_sorted['final_random_auroc'].max(), df_sorted['final_ddpg_auroc'].max()) + 2 ax1.set_ylim(y_min, y_max) # ===== Subplot 2: Convergence Speed ===== ax2 = axes[1] # Plot training loss for different client counts # We'll show 3, 12, and 24 clients as representatives representative_clients = [3, 12, 24] colors = ['#1E88E5', '#43A047', '#E53935'] for n_clients, color in zip(representative_clients, colors): subset = df_sorted[df_sorted['num_clients'] == n_clients] if not subset.empty: ax2.plot([0, 100], [subset['final_ddpg_auroc'].values[0] - 5, subset['final_ddpg_auroc'].values[0]], marker='o', color=color, linewidth=2, label=f'{n_clients} clients') ax2.set_xlabel('Communication Round', fontsize=12, fontweight='bold') ax2.set_ylabel('Train Loss', fontsize=12, fontweight='bold') ax2.set_title('(b) Convergence', fontsize=13, fontweight='bold') ax2.grid(True, linestyle='--', alpha=0.3) ax2.legend(fontsize=11, loc='best') fig.tight_layout() fig.savefig(save_path, dpi=300, format='pdf', bbox_inches='tight') plt.close(fig) print(f'✓ Saved analysis plot to {save_path}') def generate_summary_table(self, results_csv, save_path='./client_scaling_table.csv'): """ Generate a summary table showing: - Number of clients - DDPG AUROC - Random AUROC - Improvement (Gap) - Relative improvement (%) """ if not os.path.exists(results_csv): print(f"Results file not found: {results_csv}") return df = pd.read_csv(results_csv) df['num_clients'] = df['experiment_id'].str.extract(r'n(\d+)').astype(int) df = df.sort_values('num_clients') summary = pd.DataFrame({ 'Num_Clients': df['num_clients'], 'DDPG_AUROC': df['final_ddpg_auroc'].round(2), 'Random_AUROC': df['final_random_auroc'].round(2), 'Gap_AUROC': df['improvement_auroc'].round(2), 'Relative_Improvement_%': ( (df['improvement_auroc'] / df['final_random_auroc']) * 100 ).round(2), 'DDPG_AUPRC': df['final_ddpg_auprc'].round(2), 'Random_AUPRC': df['final_random_auprc'].round(2), 'Gap_AUPRC': df['improvement_auprc'].round(2), }) summary.to_csv(save_path, index=False) print(f'✓ Saved summary table to {save_path}') # Print summary to console print("\n" + "="*80) print("CLIENT SCALING EXPERIMENT SUMMARY") print("="*80) print(summary.to_string(index=False)) print("="*80) # Statistical analysis print("\nKey Findings:") print(f" • Average Gap (AUROC): {summary['Gap_AUROC'].mean():.2f}") print(f" • Max Gap at {summary.loc[summary['Gap_AUROC'].idxmax(), 'Num_Clients']} clients: " f"{summary['Gap_AUROC'].max():.2f}") print(f" • Min Gap at {summary.loc[summary['Gap_AUROC'].idxmin(), 'Num_Clients']} clients: " f"{summary['Gap_AUROC'].min():.2f}") # Trend analysis if len(summary) >= 2: slope = np.polyfit(summary['Num_Clients'], summary['Gap_AUROC'], 1)[0] trend = "increasing" if slope > 0 else "decreasing" print(f" • Trend: Gap is {trend} with more clients (slope: {slope:.3f})") print("="*80 + "\n") return summary
def main():
"""
Main execution: Generate and prepare client scaling experiment
"""
print("="*80)
print("CLIENT SCALING EXPERIMENT - Effect of Number of Clients")
print("="*80)
print("\nFollowing Figure 4(a) design pattern:")
print(" • Test client counts: 3, 6, 9, 12, 15, 18, 21, 24")
print(" • Compare DDPG vs Random client selection")
print(" • Measure performance gap at each scale")
print(" • Analyze convergence and scalability trends")
text# Initialize experiment exp = ClientScalingExperiment() # Generate configurations experiments = exp.generate_experiments() print(f"\n✓ Generated {len(experiments)} experiment configurations") print("\nExperiment configurations:") for e in experiments: print(f" • {e['experiment_id']}: {e['num_clients']} clients, " f"select top {e['rl_top_k']}") # Save configurations config_path = exp.save_experiments(experiments) # Print execution instructions print("\n" + "="*80) print("NEXT STEPS") print("="*80) print("\n1. Run the experiments:") print(f" python experiment_runner.py --config {config_path}") print("\n2. Or run a quick test (first 3 configs):") print(f" python experiment_runner.py --config {config_path} --end_idx 3") print("\n3. After completion, analyze results:") print(" python -c \"from client_scaling_experiment import ClientScalingExperiment; " "exp = ClientScalingExperiment(); " "exp.plot_results('experiment_results/client_scaling_*/experiment_summary.csv')\"") print("\n4. View the generated plot:") print(" Open: ./client_scaling_analysis.pdf") print("\n" + "="*80) print(f"Configuration saved: {config_path}") print("="*80 + "\n")
if name == 'main':
main() 去掉DDPG那个,只保留随机挑选的那个算法名字为PFLAD做可拓展性实验
可以。你的实验目标改成:只保留随机客户端选择机制,并将整个方法统一命名为 PFLAD,用来研究客户端数量增加时的可扩展性(scalability)。
相应地,我建议把原来的 DDPG vs Random 对比、improvement_*、Gap、所有 RL 超参数全部删除。因为现在只有 PFLAD 一个方法,图更适合展示 PFLAD 的 AUROC/AUPRC 随客户端数量变化的趋势。另外你原来 (b) Convergence 那张图实际上是用最终 AUROC 人工连成两点,并不是真正的 training loss,所以我也一起改掉了。
下面是整理后的完整版本:
python""" PFLAD Client Scaling Experiment - Effect of Number of Clients Scalability experiment for PFLAD with random client selection. Experiment Design: - Client counts: 3, 6, 9, 12, 15, 18, 21, 24 - Algorithm: PFLAD - Client selection: Random - Participation ratio: approximately 50% - Metrics: AUROC, AUPRC - Goal: Evaluate the scalability of PFLAD as the number of clients increases """ import json import os import numpy as np import pandas as pd import matplotlib.pyplot as plt from datetime import datetime class PFLADClientScalingExperiment: """ Scalability experiment for PFLAD. This experiment studies how anomaly detection performance changes as the total number of federated clients increases. PFLAD uses random client selection in each communication round. """ def __init__(self, base_config=None): self.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") # Number of federated clients self.client_counts = [3, 6, 9, 12, 15, 18, 21, 24] # Base configuration self.base_config = base_config or { # Algorithm 'algorithm': 'PFLAD', 'method': 'pfl', # Client selection strategy 'selection_strategy': 'random', # General settings 'sync': 'True', 'gpu': 0, 'seed': 42, 'epochs': 100, # Local training 'local_epochs': 5, 'beta_PAC': 0.5, # Model parameters 'z_dim': 64, 'CMI_coeff': 0.0, 'L2R_coeff': 0.0, 'local_rep_ep': 1, 'last_local_ep': 2, } def generate_experiments(self): """ Generate scalability experiment configurations. Approximately 50% of clients are randomly selected in each communication round. """ experiments = [] for idx, num_clients in enumerate(self.client_counts, start=1): # Randomly select approximately 50% of clients num_selected_clients = max(1, num_clients // 2) config = self.base_config.copy() config.update({ 'experiment_id': f'pflad_scaling_{idx:02d}_n{num_clients}', 'experiment_type': 'client_scaling', # Total number of clients 'num_clients': num_clients, # Number of randomly selected clients per round 'num_selected_clients': num_selected_clients, # Participation ratio 'participation_ratio': ( num_selected_clients / num_clients ), 'description': ( f'PFLAD scalability experiment with ' f'{num_clients} clients; randomly selecting ' f'{num_selected_clients} clients per round' ) }) experiments.append(config) return experiments def save_experiments( self, experiments, output_dir='./experiments' ): """ Save experiment configurations to JSON. """ os.makedirs(output_dir, exist_ok=True) filename = f'pflad_client_scaling_{self.timestamp}.json' filepath = os.path.join(output_dir, filename) with open(filepath, 'w') as f: json.dump(experiments, f, indent=2) print( f'✓ Saved {len(experiments)} PFLAD scalability ' f'experiments to {filepath}' ) return filepath def plot_results( self, results_csv, save_path='./pflad_client_scaling_analysis.pdf' ): """ Plot PFLAD scalability results. Subplot (a): AUROC vs Number of Clients Subplot (b): AUPRC vs Number of Clients """ if not os.path.exists(results_csv): print(f"Results file not found: {results_csv}") return df = pd.read_csv(results_csv) # ---------------------------------------------------------- # Extract number of clients # ---------------------------------------------------------- if 'num_clients' not in df.columns: df['num_clients'] = ( df['experiment_id'] .str.extract(r'n(\d+)') .astype(int) ) df = df.sort_values('num_clients') # ---------------------------------------------------------- # Support different result column names # ---------------------------------------------------------- if 'final_pflad_auroc' in df.columns: auroc_col = 'final_pflad_auroc' elif 'final_random_auroc' in df.columns: # Compatibility with old experiment runner auroc_col = 'final_random_auroc' elif 'final_auroc' in df.columns: auroc_col = 'final_auroc' else: raise ValueError( "Cannot find AUROC column. Expected one of: " "'final_pflad_auroc', 'final_random_auroc', " "'final_auroc'." ) if 'final_pflad_auprc' in df.columns: auprc_col = 'final_pflad_auprc' elif 'final_random_auprc' in df.columns: # Compatibility with old experiment runner auprc_col = 'final_random_auprc' elif 'final_auprc' in df.columns: auprc_col = 'final_auprc' else: raise ValueError( "Cannot find AUPRC column. Expected one of: " "'final_pflad_auprc', 'final_random_auprc', " "'final_auprc'." ) # ---------------------------------------------------------- # Create plots # ---------------------------------------------------------- fig, axes = plt.subplots(1, 2, figsize=(13, 5)) # ========================================================== # (a) AUROC scalability # ========================================================== ax1 = axes[0] ax1.plot( df['num_clients'], df[auroc_col], marker='o', linewidth=2.5, markersize=8, label='PFLAD' ) ax1.set_xlabel( 'Number of Clients', fontsize=12, fontweight='bold' ) ax1.set_ylabel( 'AUROC', fontsize=12, fontweight='bold' ) ax1.set_title( '(a) Scalability in AUROC', fontsize=13, fontweight='bold' ) ax1.set_xticks(df['num_clients']) ax1.grid( True, linestyle='--', alpha=0.3 ) ax1.legend( fontsize=11, loc='best' ) # Add value annotations for _, row in df.iterrows(): ax1.annotate( f"{row[auroc_col]:.2f}", ( row['num_clients'], row[auroc_col] ), textcoords='offset points', xytext=(0, 8), ha='center', fontsize=8 ) # ========================================================== # (b) AUPRC scalability # ========================================================== ax2 = axes[1] ax2.plot( df['num_clients'], df[auprc_col], marker='s', linewidth=2.5, markersize=8, label='PFLAD' ) ax2.set_xlabel( 'Number of Clients', fontsize=12, fontweight='bold' ) ax2.set_ylabel( 'AUPRC', fontsize=12, fontweight='bold' ) ax2.set_title( '(b) Scalability in AUPRC', fontsize=13, fontweight='bold' ) ax2.set_xticks(df['num_clients']) ax2.grid( True, linestyle='--', alpha=0.3 ) ax2.legend( fontsize=11, loc='best' ) # Add value annotations for _, row in df.iterrows(): ax2.annotate( f"{row[auprc_col]:.2f}", ( row['num_clients'], row[auprc_col] ), textcoords='offset points', xytext=(0, 8), ha='center', fontsize=8 ) fig.tight_layout() fig.savefig( save_path, dpi=300, format='pdf', bbox_inches='tight' ) plt.close(fig) print(f'✓ Saved scalability analysis plot to {save_path}') def generate_summary_table( self, results_csv, save_path='./pflad_client_scaling_table.csv' ): """ Generate summary table for PFLAD scalability experiment. Columns: - Number of clients - Number of selected clients - Participation ratio - AUROC - AUPRC """ if not os.path.exists(results_csv): print(f"Results file not found: {results_csv}") return df = pd.read_csv(results_csv) # ---------------------------------------------------------- # Extract number of clients # ---------------------------------------------------------- if 'num_clients' not in df.columns: df['num_clients'] = ( df['experiment_id'] .str.extract(r'n(\d+)') .astype(int) ) df = df.sort_values('num_clients') # ---------------------------------------------------------- # Find AUROC column # ---------------------------------------------------------- if 'final_pflad_auroc' in df.columns: auroc_col = 'final_pflad_auroc' elif 'final_random_auroc' in df.columns: auroc_col = 'final_random_auroc' elif 'final_auroc' in df.columns: auroc_col = 'final_auroc' else: raise ValueError("AUROC column not found.") # ---------------------------------------------------------- # Find AUPRC column # ---------------------------------------------------------- if 'final_pflad_auprc' in df.columns: auprc_col = 'final_pflad_auprc' elif 'final_random_auprc' in df.columns: auprc_col = 'final_random_auprc' elif 'final_auprc' in df.columns: auprc_col = 'final_auprc' else: raise ValueError("AUPRC column not found.") # ---------------------------------------------------------- # Number of selected clients # ---------------------------------------------------------- if 'num_selected_clients' not in df.columns: df['num_selected_clients'] = ( df['num_clients'] // 2 ).clip(lower=1) df['participation_ratio'] = ( df['num_selected_clients'] / df['num_clients'] ) # ---------------------------------------------------------- # Summary # ---------------------------------------------------------- summary = pd.DataFrame({ 'Num_Clients': df['num_clients'], 'Selected_Clients': df['num_selected_clients'], 'Participation_Ratio': df['participation_ratio'].round(3), 'PFLAD_AUROC': df[auroc_col].round(4), 'PFLAD_AUPRC': df[auprc_col].round(4), }) summary.to_csv( save_path, index=False ) print(f'✓ Saved summary table to {save_path}') # ---------------------------------------------------------- # Print summary # ---------------------------------------------------------- print("\n" + "=" * 80) print("PFLAD CLIENT SCALING EXPERIMENT SUMMARY") print("=" * 80) print(summary.to_string(index=False)) print("=" * 80) # ---------------------------------------------------------- # Scalability analysis # ---------------------------------------------------------- print("\nScalability Analysis:") print( f" • Average AUROC: " f"{summary['PFLAD_AUROC'].mean():.4f}" ) print( f" • Average AUPRC: " f"{summary['PFLAD_AUPRC'].mean():.4f}" ) # Performance change from smallest to largest scale first_auroc = summary.iloc[0]['PFLAD_AUROC'] last_auroc = summary.iloc[-1]['PFLAD_AUROC'] auroc_change = last_auroc - first_auroc first_auprc = summary.iloc[0]['PFLAD_AUPRC'] last_auprc = summary.iloc[-1]['PFLAD_AUPRC'] auprc_change = last_auprc - first_auprc print( f" • AUROC change " f"({summary.iloc[0]['Num_Clients']} → " f"{summary.iloc[-1]['Num_Clients']} clients): " f"{auroc_change:+.4f}" ) print( f" • AUPRC change " f"({summary.iloc[0]['Num_Clients']} → " f"{summary.iloc[-1]['Num_Clients']} clients): " f"{auprc_change:+.4f}" ) # ---------------------------------------------------------- # Linear trend # ---------------------------------------------------------- if len(summary) >= 2: auroc_slope = np.polyfit( summary['Num_Clients'], summary['PFLAD_AUROC'], 1 )[0] auprc_slope = np.polyfit( summary['Num_Clients'], summary['PFLAD_AUPRC'], 1 )[0] auroc_trend = ( "increasing" if auroc_slope > 0 else "decreasing" ) auprc_trend = ( "increasing" if auprc_slope > 0 else "decreasing" ) print( f" • AUROC trend: {auroc_trend} " f"(slope = {auroc_slope:.6f})" ) print( f" • AUPRC trend: {auprc_trend} " f"(slope = {auprc_slope:.6f})" ) print("=" * 80 + "\n") return summary def main(): """ Generate PFLAD client scaling experiment configurations. """ print("=" * 80) print("PFLAD CLIENT SCALING EXPERIMENT") print("Effect of Number of Clients") print("=" * 80) print("\nExperiment design:") print( " • Number of clients: " "3, 6, 9, 12, 15, 18, 21, 24" ) print( " • Algorithm: PFLAD" ) print( " • Client selection: Random" ) print( " • Participation: approximately 50% " "of clients per round" ) print( " • Metrics: AUROC and AUPRC" ) print( " • Goal: Evaluate scalability as " "the federation size increases" ) # -------------------------------------------------------------- # Initialize experiment # -------------------------------------------------------------- exp = PFLADClientScalingExperiment() # -------------------------------------------------------------- # Generate configurations # -------------------------------------------------------------- experiments = exp.generate_experiments() print( f"\n✓ Generated {len(experiments)} " f"PFLAD scalability configurations" ) print("\nExperiment configurations:") for e in experiments: print( f" • {e['experiment_id']}: " f"{e['num_clients']} clients, " f"randomly select " f"{e['num_selected_clients']} clients " f"({e['participation_ratio'] * 100:.1f}%)" ) # -------------------------------------------------------------- # Save configurations # -------------------------------------------------------------- config_path = exp.save_experiments( experiments ) # -------------------------------------------------------------- # Instructions # -------------------------------------------------------------- print("\n" + "=" * 80) print("NEXT STEPS") print("=" * 80) print("\n1. Run all scalability experiments:") print( f" python experiment_runner.py " f"--config {config_path}" ) print("\n2. Run a quick test with first 3 settings:") print( f" python experiment_runner.py " f"--config {config_path} " f"--end_idx 3" ) print("\n3. Analyze the results:") print( " python -c \"" "from client_scaling_experiment " "import PFLADClientScalingExperiment; " "exp=PFLADClientScalingExperiment(); " "exp.plot_results(" "'experiment_results/client_scaling_summary.csv'); " "exp.generate_summary_table(" "'experiment_results/client_scaling_summary.csv')" "\"" ) print("\n4. Output files:") print( " ./pflad_client_scaling_analysis.pdf" ) print( " ./pflad_client_scaling_table.csv" ) print("\n" + "=" * 80) print( f"Configuration saved: {config_path}" ) print("=" * 80 + "\n") if __name__ == '__main__': main()
这里有几个关键改动:
actor_lr、critic_lr、rl_gamma、rl_tau、rl_sigma、rl_buffer_size 等全部没必要再出现。algorithm = PFLAD,selection_strategy = random。rl_top_k 改成更合理的 num_selected_clients。DDPG - Random 的 Gap_AUROC / Gap_AUPRC,因为现在不存在两个算法。final_random_auroc / final_random_auprc 的兼容,因此你现有 runner 暂时没有完全改名时,分析脚本仍然能读取。还有一个需要注意的地方:你的 experiment_runner.py 里如果现在仍然是读取 rl_top_k、运行 DDPG 和 Random 两套实验,那么runner 也必须同步改。最终推荐让结果 CSV 直接输出:
textexperiment_id num_clients num_selected_clients final_pflad_auroc final_pflad_auprc
这样整套实验从配置、运行到论文图表都会统一叫 PFLAD,不会再出现 DDPG/Random baseline 的痕迹。