#!/usr/bin/env python # -*- coding: utf-8 -*- """ ...
Erstellt am: 30. Oktober 2026
Beantwortet mit GPT-5.6 Thinking von Chat01
Erstellt am: 30. Oktober 2026
Beantwortet mit GPT-5.6 Thinking von Chat01
#!/usr/bin/env python
"""
Personalized Federated Anomaly Detection Benchmark
Replacing FedTreeLoRA (NLI) with Anomaly Detection Methods
This benchmark framework provides a comprehensive comparison of personalized
federated learning methods for anomaly detection tasks, structured similarly
to the FedTreeLoRA benchmark but adapted for anomaly detection.
Original FedTreeLoRA: https://github.com/jmbian/FedTreeLoRA
Our Adaptation: Anomaly detection with multi-domain heterogeneity
Baseline Methods Implemented:
Datasets:
Evaluation Metrics:
Usage:
# Run full benchmark
python federated_anomaly_benchmark.py --config benchmark_config.json
text# Run specific method python federated_anomaly_benchmark.py --method FedCR --num_clients 20 # Hyperparameter sensitivity python federated_anomaly_benchmark.py --experiment hyperparameter_sweep
"""
import os
import json
import copy
import random
import argparse
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, Subset
from typing import Dict, List, Tuple, Optional, Any
from collections import defaultdict
import time
from tqdm import tqdm
from sklearn.metrics import roc_auc_score, average_precision_score, f1_score, precision_recall_curve
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from federated_domain_manager import FederatedDomainManager
from dataset import *
class AnomalyDetectorAutoencoder(nn.Module):
"""
Standard Autoencoder for anomaly detection.
Used as base model for all methods.
"""
def init(self, input_dim: int, hidden_dims: List[int], latent_dim: int):
super().init()
self.input_dim = input_dim
self.latent_dim = latent_dim
text# Encoder encoder_layers = [] prev_dim = input_dim for h_dim in hidden_dims: encoder_layers.extend([ nn.Linear(prev_dim, h_dim), nn.ReLU(), nn.Dropout(0.1) ]) prev_dim = h_dim encoder_layers.append(nn.Linear(prev_dim, latent_dim)) self.encoder = nn.Sequential(*encoder_layers) # Decoder decoder_layers = [] prev_dim = latent_dim for h_dim in reversed(hidden_dims): decoder_layers.extend([ nn.Linear(prev_dim, h_dim), nn.ReLU(), nn.Dropout(0.1) ]) prev_dim = h_dim decoder_layers.append(nn.Linear(prev_dim, input_dim)) self.decoder = nn.Sequential(*decoder_layers) def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: """Forward pass returning reconstruction and latent representation.""" latent = self.encoder(x) reconstruction = self.decoder(latent) return reconstruction, latent def compute_anomaly_score(self, x: torch.Tensor) -> torch.Tensor: """Compute reconstruction-based anomaly score.""" reconstruction, _ = self.forward(x) mse = torch.mean((x - reconstruction) ** 2, dim=tuple(range(1, x.dim()))) return mse
class PersonalizedAutoencoder(nn.Module):
"""
Autoencoder with separate global and personalized layers.
Used for FedPer and FedRep.
"""
def init(self, input_dim: int, hidden_dims: List[int], latent_dim: int,
personalized_layers: int = 2):
super().init()
self.input_dim = input_dim
self.latent_dim = latent_dim
self.personalized_layers = personalized_layers
text# Global encoder layers global_encoder = [] prev_dim = input_dim for i, h_dim in enumerate(hidden_dims[:-personalized_layers]): global_encoder.extend([ nn.Linear(prev_dim, h_dim), nn.ReLU(), nn.Dropout(0.1) ]) prev_dim = h_dim self.global_encoder = nn.Sequential(*global_encoder) # Personalized encoder layers personal_encoder = [] for h_dim in hidden_dims[-personalized_layers:]: personal_encoder.extend([ nn.Linear(prev_dim, h_dim), nn.ReLU(), nn.Dropout(0.1) ]) prev_dim = h_dim personal_encoder.append(nn.Linear(prev_dim, latent_dim)) self.personal_encoder = nn.Sequential(*personal_encoder) # Decoder (can be global or personalized based on method) decoder_layers = [] prev_dim = latent_dim for h_dim in reversed(hidden_dims): decoder_layers.extend([ nn.Linear(prev_dim, h_dim), nn.ReLU(), nn.Dropout(0.1) ]) prev_dim = h_dim decoder_layers.append(nn.Linear(prev_dim, input_dim)) self.decoder = nn.Sequential(*decoder_layers) def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]: global_features = self.global_encoder(x) latent = self.personal_encoder(global_features) reconstruction = self.decoder(latent) return reconstruction, latent def compute_anomaly_score(self, x: torch.Tensor) -> torch.Tensor: reconstruction, _ = self.forward(x) mse = torch.mean((x - reconstruction) ** 2, dim=tuple(range(1, x.dim()))) return mse
class BaselineClient:
"""Base client class for all personalized FL methods."""
textdef __init__(self, client_id: int, model: nn.Module, train_loader: DataLoader, val_loader: DataLoader, test_loader: DataLoader, device: str = 'cuda', lr: float = 1e-3, local_epochs: int = 5, domain_name: str = 'unknown'): self.client_id = client_id self.model = model.to(device) self.train_loader = train_loader self.val_loader = val_loader self.test_loader = test_loader self.device = device self.lr = lr self.local_epochs = local_epochs self.domain_name = domain_name self.optimizer = optim.Adam(self.model.parameters(), lr=lr) def train_local(self, global_model_state: Optional[Dict] = None) -> Dict[str, torch.Tensor]: """Perform local training. Override in subclasses for specific methods.""" self.model.train() for epoch in range(self.local_epochs): for batch_data, _ in self.train_loader: batch_data = batch_data.to(self.device) # Handle windowed data: [batch_size, win_size, features] -> [batch_size * win_size, features] if len(batch_data.shape) == 3: batch_size, win_size, features = batch_data.shape batch_data = batch_data.reshape(batch_size * win_size, features) elif len(batch_data.shape) == 2: # Already correct shape [batch_size, features] pass else: raise ValueError(f"Unexpected batch shape: {batch_data.shape}") self.optimizer.zero_grad() reconstruction, _ = self.model(batch_data) loss = nn.functional.mse_loss(reconstruction, batch_data) loss.backward() self.optimizer.step() return {k: v.cpu().clone() for k, v in self.model.state_dict().items()} def evaluate(self, data_loader: Optional[DataLoader] = None) -> Dict[str, float]: """Evaluate anomaly detection performance.""" self.model.eval() data_loader = data_loader or self.test_loader all_scores = [] all_labels = [] with torch.no_grad(): for batch_data, batch_labels in data_loader: batch_data = batch_data.to(self.device) # Handle windowed data: [batch_size, win_size, features] -> [batch_size * win_size, features] original_batch_size = batch_data.shape[0] if len(batch_data.shape) == 3: batch_size, win_size, features = batch_data.shape batch_data = batch_data.reshape(batch_size * win_size, features) # Flatten labels similarly if len(batch_labels.shape) == 2: batch_labels = batch_labels.reshape(-1) scores = self.model.compute_anomaly_score(batch_data) all_scores.extend(scores.cpu().numpy()) all_labels.extend(batch_labels.numpy()) all_scores = np.array(all_scores) all_labels = np.array(all_labels).flatten() # Compute metrics try: roc_auc = roc_auc_score(all_labels, all_scores) pr_auc = average_precision_score(all_labels, all_scores) # Compute F1 at best threshold precision, recall, thresholds = precision_recall_curve(all_labels, all_scores) f1_scores = 2 * (precision * recall) / (precision + recall + 1e-10) best_f1 = np.max(f1_scores) return { 'roc_auc': roc_auc, 'pr_auc': pr_auc, 'f1_score': best_f1, 'num_samples': len(all_labels) } except Exception as e: print(f"Warning: Could not compute metrics for client {self.client_id}: {e}") return { 'roc_auc': 0.0, 'pr_auc': 0.0, 'f1_score': 0.0, 'num_samples': len(all_labels) }
class FedAvgClient(BaselineClient):
"""FedAvg: Standard federated averaging."""
textdef train_local(self, global_model_state: Optional[Dict] = None) -> Dict[str, torch.Tensor]: # Synchronize with global model if global_model_state is not None: self.model.load_state_dict(global_model_state) # Standard local training return super().train_local(global_model_state)
class FedProxClient(BaselineClient):
"""FedProx: Federated learning with proximal term."""
textdef __init__(self, *args, mu: float = 0.01, **kwargs): super().__init__(*args, **kwargs) self.mu = mu self.global_model_state = None def train_local(self, global_model_state: Optional[Dict] = None) -> Dict[str, torch.Tensor]: if global_model_state is not None: self.global_model_state = copy.deepcopy(global_model_state) self.model.load_state_dict(global_model_state) self.model.train() for epoch in range(self.local_epochs): for batch_data, _ in self.train_loader: batch_data = batch_data.to(self.device) self.optimizer.zero_grad() reconstruction, _ = self.model(batch_data) recon_loss = nn.functional.mse_loss(reconstruction, batch_data) # Proximal term if self.global_model_state is not None: prox_term = 0.0 for name, param in self.model.named_parameters(): prox_term += torch.sum((param - self.global_model_state[name].to(self.device)) ** 2) loss = recon_loss + (self.mu / 2) * prox_term else: loss = recon_loss loss.backward() self.optimizer.step() return {k: v.cpu().clone() for k, v in self.model.state_dict().items()}
class FedPerClient(BaselineClient):
"""FedPer: Personalized layers remain local, only global layers are aggregated."""
textdef __init__(self, *args, personalized_layer_names: List[str], **kwargs): super().__init__(*args, **kwargs) self.personalized_layer_names = personalized_layer_names def train_local(self, global_model_state: Optional[Dict] = None) -> Dict[str, torch.Tensor]: # Only update global layers from server if global_model_state is not None: current_state = self.model.state_dict() for name, param in global_model_state.items(): if not any(pname in name for pname in self.personalized_layer_names): current_state[name] = param self.model.load_state_dict(current_state) # Train all parameters super().train_local(global_model_state) # Only return global layers for aggregation model_state = self.model.state_dict() global_state = {k: v.cpu().clone() for k, v in model_state.items() if not any(pname in k for pname in self.personalized_layer_names)} return global_state
class FedRepClient(BaselineClient):
"""FedRep: Learn shared representations, personalize classifier."""
textdef __init__(self, *args, representation_layers: List[str], **kwargs): super().__init__(*args, **kwargs) self.representation_layers = representation_layers def train_local(self, global_model_state: Optional[Dict] = None) -> Dict[str, torch.Tensor]: # Synchronize representation layers if global_model_state is not None: current_state = self.model.state_dict() for name, param in global_model_state.items(): if any(rname in name for rname in self.representation_layers): current_state[name] = param self.model.load_state_dict(current_state) # Train all parameters super().train_local(global_model_state) # Only return representation layers for aggregation model_state = self.model.state_dict() rep_state = {k: v.cpu().clone() for k, v in model_state.items() if any(rname in k for rname in self.representation_layers)} return rep_state
class DittoClient(BaselineClient):
"""Ditto: Personalized federated learning with separate global and local models."""
textdef __init__(self, *args, lam: float = 0.1, **kwargs): super().__init__(*args, **kwargs) self.lam = lam self.global_model = copy.deepcopy(self.model) self.local_model = copy.deepcopy(self.model) def train_local(self, global_model_state: Optional[Dict] = None) -> Dict[str, torch.Tensor]: # Update global model if global_model_state is not None: self.global_model.load_state_dict(global_model_state) # Train local model with regularization toward global model self.local_model.train() local_optimizer = optim.Adam(self.local_model.parameters(), lr=self.lr) for epoch in range(self.local_epochs): for batch_data, _ in self.train_loader: batch_data = batch_data.to(self.device) # Handle windowed data: [batch_size, win_size, features] -> [batch_size * win_size, features] if len(batch_data.shape) == 3: batch_size, win_size, features = batch_data.shape batch_data = batch_data.reshape(batch_size * win_size, features) elif len(batch_data.shape) == 2: # Already correct shape [batch_size, features] pass else: raise ValueError(f"Unexpected batch shape: {batch_data.shape}") local_optimizer.zero_grad() reconstruction, _ = self.local_model(batch_data) recon_loss = nn.functional.mse_loss(reconstruction, batch_data) # Regularization toward global model reg_term = 0.0 for (n1, p1), (n2, p2) in zip(self.local_model.named_parameters(), self.global_model.named_parameters()): reg_term += torch.sum((p1 - p2) ** 2) loss = recon_loss + (self.lam / 2) * reg_term loss.backward() local_optimizer.step() # Use local model for evaluation self.model = self.local_model # Return global model state for aggregation return {k: v.cpu().clone() for k, v in self.global_model.state_dict().items()}
class FedCRClient(BaselineClient):
"""FedCR: Our causal representation learning method."""
textdef __init__(self, *args, alpha: float = 1.0, dimZ: int = 128, **kwargs): super().__init__(*args, **kwargs) self.alpha = alpha self.dimZ = dimZ def train_local(self, global_model_state: Optional[Dict] = None) -> Dict[str, torch.Tensor]: if global_model_state is not None: self.model.load_state_dict(global_model_state) self.model.train() for epoch in range(self.local_epochs): for batch_data, _ in self.train_loader: batch_data = batch_data.to(self.device) self.optimizer.zero_grad() reconstruction, latent = self.model(batch_data) # Reconstruction loss recon_loss = nn.functional.mse_loss(reconstruction, batch_data) # Causal regularization (simplified for this implementation) # In full FedCR, this includes VIB and POE causal_reg = self.alpha * torch.mean(latent ** 2) loss = recon_loss + causal_reg loss.backward() self.optimizer.step() return {k: v.cpu().clone() for k, v in self.model.state_dict().items()}
class FederatedServer:
"""Federated server for aggregation."""
textdef __init__(self, global_model: nn.Module, device: str = 'cuda'): self.global_model = global_model.to(device) self.device = device def aggregate(self, client_updates: List[Dict[str, torch.Tensor]], client_weights: Optional[List[float]] = None) -> Dict[str, torch.Tensor]: """Federated averaging of client updates.""" if not client_updates: return self.global_model.state_dict() if client_weights is None: client_weights = [1.0 / len(client_updates)] * len(client_updates) # Weighted average aggregated_state = {} for key in client_updates[0].keys(): aggregated_state[key] = torch.sum( torch.stack([w * client_updates[i][key] for i, w in enumerate(client_weights)]), dim=0 ) self.global_model.load_state_dict(aggregated_state) return aggregated_state
class AnomalyDetectionBenchmark:
"""Main benchmark class for running experiments."""
textdef __init__(self, config: Dict[str, Any]): self.config = config self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') self.results = defaultdict(list) # Set random seeds self.set_seeds(config.get('seed', 42)) def set_seeds(self, seed: int): """Set random seeds for reproducibility.""" random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed(seed) def create_clients(self, method: str, num_clients: int, domain_preset: str = 'diverse_6_clients') -> Tuple[List, FederatedServer]: """Create clients and server for a specific method.""" # Load data using domain manager # If preset doesn't have enough clients, use auto_assign mode instead try: manager = FederatedDomainManager(preset=domain_preset) all_loaders = manager.get_all_client_loaders(batch_size=self.config['batch_size']) # If preset has fewer clients than requested, use auto_assign if len(all_loaders) < num_clients: print(f"Preset '{domain_preset}' only has {len(all_loaders)} clients, switching to auto_assign for {num_clients} clients") manager = FederatedDomainManager(num_clients=num_clients, auto_assign=True) all_loaders = manager.get_all_client_loaders(batch_size=self.config['batch_size']) except Exception as e: print(f"Error with preset '{domain_preset}': {e}") print(f"Falling back to auto_assign mode with {num_clients} clients") manager = FederatedDomainManager(num_clients=num_clients, auto_assign=True) all_loaders = manager.get_all_client_loaders(batch_size=self.config['batch_size']) # Adjust num_clients if it exceeds available clients actual_num_clients = min(num_clients, len(all_loaders)) if actual_num_clients < num_clients: print(f"Warning: Requested {num_clients} clients but only {actual_num_clients} available. Using {actual_num_clients}.") # Detect input dimension from first client's data first_client_id = sorted(all_loaders.keys())[0] first_batch = next(iter(all_loaders[first_client_id]['train'])) if isinstance(first_batch, (tuple, list)): sample_data = first_batch[0] else: sample_data = first_batch # Handle different data shapes if len(sample_data.shape) == 1: detected_input_dim = sample_data.shape[0] elif len(sample_data.shape) == 2: # [batch_size, features] detected_input_dim = sample_data.shape[1] elif len(sample_data.shape) == 3: # [batch_size, win_size, features] - we reshape to [batch_size * win_size, features] during training # So input_dim should be the last dimension (features), not win_size * features detected_input_dim = sample_data.shape[2] else: # Flatten to last dimension detected_input_dim = int(np.prod(sample_data.shape[1:])) print(f"Detected input dimension: {detected_input_dim} from shape {sample_data.shape}") # Override model config with detected dimension model_config = self.config['model'].copy() model_config['input_dim'] = detected_input_dim # Create base model base_model = AnomalyDetectorAutoencoder( input_dim=model_config['input_dim'], hidden_dims=model_config['hidden_dims'], latent_dim=model_config['latent_dim'] ) # Create clients based on method clients = [] for client_id in sorted(all_loaders.keys())[:actual_num_clients]: loaders = all_loaders[client_id] metadata = loaders['metadata'] client_model = copy.deepcopy(base_model) if method == 'FedAvg': client = FedAvgClient( client_id, client_model, loaders['train'], loaders['val'], loaders['test'], device=self.device, lr=self.config['lr'], local_epochs=self.config['local_epochs'], domain_name=metadata['domain_name'] ) elif method == 'FedProx': client = FedProxClient( client_id, client_model, loaders['train'], loaders['val'], loaders['test'], device=self.device, lr=self.config['lr'], local_epochs=self.config['local_epochs'], domain_name=metadata['domain_name'], mu=self.config.get('mu', 0.01) ) elif method == 'FedPer': client = FedPerClient( client_id, client_model, loaders['train'], loaders['val'], loaders['test'], device=self.device, lr=self.config['lr'], local_epochs=self.config['local_epochs'], domain_name=metadata['domain_name'], personalized_layer_names=['personal_encoder', 'decoder'] ) elif method == 'FedRep': client = FedRepClient( client_id, client_model, loaders['train'], loaders['val'], loaders['test'], device=self.device, lr=self.config['lr'], local_epochs=self.config['local_epochs'], domain_name=metadata['domain_name'], representation_layers=['global_encoder'] ) elif method == 'Ditto': client = DittoClient( client_id, client_model, loaders['train'], loaders['val'], loaders['test'], device=self.device, lr=self.config['lr'], local_epochs=self.config['local_epochs'], domain_name=metadata['domain_name'], lam=self.config.get('lam', 0.1) ) elif method == 'FedCR': client = FedCRClient( client_id, client_model, loaders['train'], loaders['val'], loaders['test'], device=self.device, lr=self.config['lr'], local_epochs=self.config['local_epochs'], domain_name=metadata['domain_name'], alpha=self.config.get('alpha', 1.0), dimZ=self.config.get('dimZ', 128) ) else: raise ValueError(f"Unknown method: {method}") clients.append(client) # Create server server = FederatedServer(copy.deepcopy(base_model), device=self.device) return clients, server def run_experiment(self, method: str, num_clients: int = 6, global_rounds: int = 30, client_frac: float = 1.0) -> Dict: """Run a single experiment.""" print(f"\n{'='*80}") print(f"Running {method} with {num_clients} clients for {global_rounds} rounds") print(f"{'='*80}\n") # Create clients and server clients, server = self.create_clients(method, num_clients) # Training loop round_results = [] for round_idx in tqdm(range(global_rounds), desc=f"{method} Training"): # Client selection num_selected = max(1, int(client_frac * len(clients))) selected_clients = random.sample(clients, num_selected) # Local training client_updates = [] client_weights = [] for client in selected_clients: global_model_state = server.global_model.state_dict() update = client.train_local(global_model_state) client_updates.append(update) client_weights.append(1.0 / len(selected_clients)) # Server aggregation server.aggregate(client_updates, client_weights) # Evaluation (every 5 rounds) if round_idx % 5 == 0 or round_idx == global_rounds - 1: eval_results = self.evaluate_clients(clients, round_idx) round_results.append(eval_results) return { 'method': method, 'num_clients': num_clients, 'round_results': round_results, 'final_results': round_results[-1] if round_results else None } def evaluate_clients(self, clients: List, round_idx: int) -> Dict: """Evaluate all clients.""" all_metrics = [] domain_metrics = defaultdict(list) for client in clients: metrics = client.evaluate() metrics['client_id'] = client.client_id metrics['domain'] = client.domain_name metrics['round'] = round_idx all_metrics.append(metrics) domain_metrics[client.domain_name].append(metrics) # Aggregate results avg_metrics = { 'round': round_idx, 'roc_auc_mean': np.mean([m['roc_auc'] for m in all_metrics]), 'roc_auc_std': np.std([m['roc_auc'] for m in all_metrics]), 'pr_auc_mean': np.mean([m['pr_auc'] for m in all_metrics]), 'pr_auc_std': np.std([m['pr_auc'] for m in all_metrics]), 'f1_mean': np.mean([m['f1_score'] for m in all_metrics]), 'f1_std': np.std([m['f1_score'] for m in all_metrics]), 'per_client': all_metrics, 'per_domain': dict(domain_metrics) } print(f"\nRound {round_idx}: ROC-AUC={avg_metrics['roc_auc_mean']:.4f}±{avg_metrics['roc_auc_std']:.4f}, " f"F1={avg_metrics['f1_mean']:.4f}±{avg_metrics['f1_std']:.4f}") return avg_metrics def run_full_benchmark(self, methods: List[str], save_dir: str = './results'): """Run full benchmark comparing all methods.""" os.makedirs(save_dir, exist_ok=True) all_results = {} for method in methods: try: result = self.run_experiment( method=method, num_clients=self.config['num_clients'], global_rounds=self.config['global_rounds'], client_frac=self.config.get('client_frac', 1.0) ) all_results[method] = result # Save individual result with open(os.path.join(save_dir, f'{method}_results.json'), 'w') as f: json.dump(result, f, indent=2, default=str) except Exception as e: print(f"\nError running {method}: {e}") import traceback traceback.print_exc() # Save all results with open(os.path.join(save_dir, 'all_results.json'), 'w') as f: json.dump(all_results, f, indent=2, default=str) # Generate comparison plots self.plot_comparison(all_results, save_dir) return all_results def plot_comparison(self, results: Dict, save_dir: str): """Generate comparison plots.""" # Extract data for plotting methods = list(results.keys()) final_metrics = {} for method, result in results.items(): if result['final_results']: final_metrics[method] = result['final_results'] if not final_metrics: print("No results to plot") return # Plot ROC-AUC comparison plt.figure(figsize=(12, 6)) # Bar plot plt.subplot(1, 2, 1) method_names = list(final_metrics.keys()) roc_aucs = [final_metrics[m]['roc_auc_mean'] for m in method_names] roc_stds = [final_metrics[m]['roc_auc_std'] for m in method_names] plt.bar(method_names, roc_aucs, yerr=roc_stds, capsize=5) plt.ylabel('ROC-AUC') plt.title('Final ROC-AUC Comparison') plt.xticks(rotation=45) plt.grid(axis='y', alpha=0.3) # Learning curves plt.subplot(1, 2, 2) for method, result in results.items(): rounds = [r['round'] for r in result['round_results']] roc_aucs = [r['roc_auc_mean'] for r in result['round_results']] plt.plot(rounds, roc_aucs, marker='o', label=method) plt.xlabel('Communication Round') plt.ylabel('ROC-AUC') plt.title('Learning Curves') plt.legend() plt.grid(alpha=0.3) plt.tight_layout() plt.savefig(os.path.join(save_dir, 'comparison_plots.png'), dpi=300, bbox_inches='tight') plt.close() print(f"\nPlots saved to {save_dir}/comparison_plots.png")
def parse_args():
parser = argparse.ArgumentParser(description='Personalized Federated Anomaly Detection Benchmark')
textparser.add_argument('--config', type=str, default=None, help='Path to config JSON file') parser.add_argument('--method', type=str, default='all', choices=['all', 'FedAvg', 'FedProx', 'FedPer', 'FedRep', 'Ditto', 'FedCR'], help='Method to run') parser.add_argument('--num_clients', type=int, default=6, help='Number of clients') parser.add_argument('--global_rounds', type=int, default=30, help='Number of global communication rounds') parser.add_argument('--local_epochs', type=int, default=5, help='Number of local epochs') parser.add_argument('--batch_size', type=int, default=32, help='Batch size') parser.add_argument('--lr', type=float, default=1e-3, help='Learning rate') parser.add_argument('--seed', type=int, default=42, help='Random seed') parser.add_argument('--save_dir', type=str, default='./benchmark_results', help='Directory to save results') return parser.parse_args()
def main():
args = parse_args()
text# Load or create config if args.config: with open(args.config, 'r') as f: config = json.load(f) else: config = { 'seed': args.seed, 'num_clients': args.num_clients, 'global_rounds': args.global_rounds, 'local_epochs': args.local_epochs, 'batch_size': args.batch_size, 'lr': args.lr, 'model': { 'input_dim': 64 * 64, # Adjust based on your data 'hidden_dims': [256, 128], 'latent_dim': 64 }, 'mu': 0.01, # FedProx 'lam': 0.1, # Ditto 'alpha': 1.0, # FedCR 'dimZ': 128 # FedCR } # Create benchmark benchmark = AnomalyDetectionBenchmark(config) # Run experiments if args.method == 'all': methods = ['FedAvg', 'FedProx', 'FedPer', 'FedRep', 'Ditto', 'FedCR'] benchmark.run_full_benchmark(methods, args.save_dir) else: result = benchmark.run_experiment( method=args.method, num_clients=args.num_clients, global_rounds=args.global_rounds ) os.makedirs(args.save_dir, exist_ok=True) with open(os.path.join(args.save_dir, f'{args.method}_results.json'), 'w') as f: json.dump(result, f, indent=2, default=str) print(f"\n{'='*80}") print("Benchmark Complete!") print(f"Results saved to {args.save_dir}") print(f"{'='*80}\n")
if name == 'main':
main() 为什么一直报错呀Detected input dimension: 1 from shape torch.Size([32, 32, 1])
FedCR Training: 0%| | 0/30 [00:00<?, ?it/s]
Error running FedCR: mat1 and mat2 shapes cannot be multiplied (2048x38 and 1x256)
Traceback (most recent call last):
File "/tmp/pycharm_project_757/code_FedCR/federated_anomaly_benchmark.py", line 757, in run_full_benchmark
result = self.run_experiment(
File "/tmp/pycharm_project_757/code_FedCR/federated_anomaly_benchmark.py", line 697, in run_experiment
update = client.train_local(global_model_state)
File "/tmp/pycharm_project_757/code_FedCR/federated_anomaly_benchmark.py", line 462, in train_local
reconstruction, latent = self.model(batch_data)
File "/home/zmn/miniconda3/envs/cpiri/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1775, in _wrapped_call_impl
return self._call_impl(*args, **kwargs)
File "/home/zmn/miniconda3/envs/cpiri/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1786, in _call_impl
return forward_call(*args, **kwargs)
File "/tmp/pycharm_project_757/code_FedCR/federated_anomaly_benchmark.py", line 112, in forward
latent = self.encoder(x)
File "/home/zmn/miniconda3/envs/cpiri/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1775, in _wrapped_call_impl
return self._call_impl(*args, **kwargs)
File "/home/zmn/miniconda3/envs/cpiri/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1786, in _call_impl
return forward_call(*args, **kwargs)
File "/home/zmn/miniconda3/envs/cpiri/lib/python3.10/site-packages/torch/nn/modules/container.py", line 250, in forward
input = module(input)
File "/home/zmn/miniconda3/envs/cpiri/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1775, in _wrapped_call_impl
return self._call_impl(*args, **kwargs)
File "/home/zmn/miniconda3/envs/cpiri/lib/python3.10/site-packages/torch/nn/modules/module.py", line 1786, in _call_impl
return forward_call(*args, **kwargs)
File "/home/zmn/miniconda3/envs/cpiri/lib/python3.10/site-packages/torch/nn/modules/linear.py", line 134, in forward
return F.linear(input, self.weight, self.bias)
RuntimeError: mat1 and mat2 shapes cannot be multiplied (2048x38 and 1x256)
No results to plot
这个报错的核心不是 PyTorch 本身,而是不同客户端的数据特征维度不一致,但你只用第一个客户端的维度创建了全局模型。另外 FedCRClient.train_local() 里也漏掉了你在基类中已经写好的 3D→2D 处理。
从报错数字就能反推出问题:
torch.Size([32, 32, 1]),所以代码把 input_dim 设成了 1,第一层变成 Linear(1, 256)。mat1 是 2048 x 38。2048 = 32 × 64,这很像某个客户端实际 batch 是 [32, 64, 38]。nn.Linear 会把最后一维当成特征维,因此它实际收到 38 个特征,却遇到只接受 1 个特征的 Linear(1,256),于是报:
mat1 and mat2 shapes cannot be multiplied (...x38 and 1x256)。所以即使你把 FedCR 的 3D 数据 reshape 成二维,最终也仍然是 [..., 38],单纯 reshape 不能解决 1 vs 38 的冲突。
问题已经很明确了:你的不同 domain/client 的原始特征维度不同,但全局 Autoencoder 只根据第一个 client 的维度初始化。
你的这两行日志已经把问题暴露出来了:
textDetected input dimension: 1 from shape torch.Size([32, 32, 1])
因此模型第一层实际上是:
pythonnn.Linear(1, 256)
但真正训练 FedCR 时,某个客户端传进模型的数据最后一维是 38:
textmat1 and mat2 shapes cannot be multiplied (2048x38 and 1x256)
这里 2048 很可能就是:
textbatch_size × win_size = 32 × 64 = 2048
也就是说这个 client 的数据大概率是:
python[32, 64, 38] # batch=32 # window=64 # features=38
PyTorch 的 Linear 永远把最后一维当 feature,所以实际发生的是:
python[32, 64, 38] ↓ Linear [..., 38] @ [1, 256] # ❌ 38 != 1
而你的模型是按照另一个 client:
python[32, 32, 1]
创建的。
create_clients() 只检查了第一个客户端:
pythonfirst_client_id = sorted(all_loaders.keys())[0] first_batch = next(iter(all_loaders[first_client_id]['train']))
然后:
pythondetected_input_dim = sample_data.shape[2]
所以只要 Client 0 是:
python[B, W, 1]
你就直接决定:
pythoninput_dim = 1
但是后面的 Client 可能是:
pythonClient 0: [B, 32, 1] Client 1: [B, 64, 38] Client 2: [B, 32, 18] Client 3: [B, 100, 1] ...
这在你这种:
CalIt2, GECCO, Genesis 等不同 anomaly detection domain
里其实非常可能。
不同数据集本来就不一定具有相同数量的传感器/变量。
不要只检测第一个 client。
把你现在这段:
python# Detect input dimension from first client's data first_client_id = sorted(all_loaders.keys())[0] first_batch = next(iter(all_loaders[first_client_id]['train'])) ...
替换为下面这个诊断版本:
python# ============================================================ # Inspect input dimensions of ALL clients # ============================================================ client_feature_dims = {} print("\n" + "=" * 80) print("Client data shapes") print("=" * 80) for client_id in sorted(all_loaders.keys()): loaders = all_loaders[client_id] batch = next(iter(loaders['train'])) if isinstance(batch, (tuple, list)): sample_data = batch[0] else: sample_data = batch if sample_data.ndim == 1: feature_dim = sample_data.shape[0] elif sample_data.ndim == 2: # [B, F] feature_dim = sample_data.shape[-1] elif sample_data.ndim == 3: # [B, W, F] feature_dim = sample_data.shape[-1] else: raise ValueError( f"Unsupported input shape for client {client_id}: " f"{sample_data.shape}" ) domain_name = loaders.get('metadata', {}).get( 'domain_name', 'unknown' ) client_feature_dims[client_id] = feature_dim print( f"Client {client_id:2d} | " f"domain={domain_name:15s} | " f"shape={tuple(sample_data.shape)} | " f"feature_dim={feature_dim}" ) print("=" * 80) unique_dims = sorted(set(client_feature_dims.values())) print(f"Unique feature dimensions: {unique_dims}") if len(unique_dims) != 1: raise ValueError( "\nDifferent clients have different feature dimensions!\n" f"{client_feature_dims}\n\n" "A single FedAvg-style autoencoder cannot aggregate models " "with different input/output dimensions." ) detected_input_dim = unique_dims[0]
你运行之后,我估计会看到类似:
textClient 0 | domain=CalIt2 | shape=(32, 32, 1) | feature_dim=1 Client 1 | domain=GECCO | shape=(32, 64, 38) | feature_dim=38 Client 2 | domain=Genesis | shape=(32, 32, 18) | feature_dim=18 ... Unique feature dimensions: [1, 18, 38]
这样就能彻底确认。
你的 BaselineClient.train_local() 已经写了:
pythonif len(batch_data.shape) == 3: batch_size, win_size, features = batch_data.shape batch_data = batch_data.reshape( batch_size * win_size, features )
但是 FedCRClient.train_local() 又自己重写了一遍训练循环:
pythonfor batch_data, _ in self.train_loader: batch_data = batch_data.to(self.device) self.optimizer.zero_grad() reconstruction, latent = self.model(batch_data)
这里没有 reshape。
FedProxClient 也有同样的问题。
虽然这不是当前 1 vs 38 的根本原因,因为:
python[32,64,38] -> [2048,38]
最后还是 38 维,但最好统一掉,否则不同算法的数据处理方式都不一样,benchmark 不公平。
我建议直接在 BaselineClient 写一个统一函数。
pythonclass BaselineClient: ... def prepare_batch(self, batch_data): batch_data = batch_data.to(self.device).float() if batch_data.ndim == 3: # [B, W, F] -> [B*W, F] batch_data = batch_data.reshape( -1, batch_data.shape[-1] ) elif batch_data.ndim == 2: # [B, F] pass elif batch_data.ndim == 1: batch_data = batch_data.unsqueeze(0) else: raise ValueError( f"Client {self.client_id}, " f"domain={self.domain_name}: " f"unexpected input shape {batch_data.shape}" ) expected_dim = self.model.input_dim actual_dim = batch_data.shape[-1] if actual_dim != expected_dim: raise ValueError( f"\nInput dimension mismatch!\n" f"Client: {self.client_id}\n" f"Domain: {self.domain_name}\n" f"Model expects: {expected_dim}\n" f"Data provides: {actual_dim}\n" f"Batch shape: {tuple(batch_data.shape)}" ) return batch_data
然后所有算法都:
pythonfor batch_data, _ in self.train_loader: batch_data = self.prepare_batch(batch_data) self.optimizer.zero_grad() reconstruction, latent = self.model(batch_data) ...
包括:
pythonBaselineClient FedProxClient FedCRClient DittoClient
全部调用同一个函数。
这样以后错误会直接变成:
textInput dimension mismatch! Client: 3 Domain: GECCO Model expects: 1 Data provides: 38 Batch shape: (2048, 38)
会比现在好调试很多。
你的 benchmark 声称研究:
multi-domain heterogeneity
可是现在不同 domain 连输入空间都不一样。
例如:
textCalIt2 → 1 feature GECCO → 38 features Genesis → 18 features
那么这个模型:
pythonAutoencoder( Linear(input_dim, 256), ... Linear(256, input_dim) )
不能直接进行标准 FedAvg。
因为 Client A 需要:
pythonLinear(1, 256)
Client B 需要:
pythonLinear(38, 256)
参数 shape 分别是:
text[256, 1] [256, 38]
服务器根本没办法:
python0.5 * weight_A + 0.5 * weight_B
它们 shape 都不同。
所以不能简单地:
python每个 client 自动检测 input_dim
然后各自建立模型。
这样下一步 server.aggregate() 一样会挂。
对于你目前这个项目,我更推荐下面这个方向:
textRaw domain data ↓ Domain-specific preprocessing ↓ Same-dimensional representation ↓ Federated Autoencoder ↓ Anomaly score
也就是说让:
textCalIt2 ─┐ GECCO ─┼──> fixed dimension,例如 32/64 Genesis ─┘
之后所有 client 才能共享:
pythonLinear(64, 256)
这样 FedAvg、FedProx、Ditto 才是数学上成立的。
最简单的办法是:
只选择 feature dimension 一样的 clients。
例如:
pythonfrom collections import Counter feature_dims = {} for client_id, loaders in all_loaders.items(): batch = next(iter(loaders['train'])) x = batch[0] if isinstance(batch, (tuple, list)) else batch feature_dims[client_id] = x.shape[-1] # 找出现最多的 feature dimension counter = Counter(feature_dims.values()) target_dim = counter.most_common(1)[0][0] print(f"Using feature dimension: {target_dim}") eligible_clients = [ client_id for client_id, dim in feature_dims.items() if dim == target_dim ] print("Eligible clients:", eligible_clients) all_loaders = { cid: all_loaders[cid] for cid in eligible_clients }
例如最后只留下:
textClient 0: feature=1 Client 3: feature=1 Client 5: feature=1
这样:
pythoninput_dim = 1
大家就一致了。
这是很好的 debug 方案,但不是你论文里最终 multi-domain benchmark 的理想方案。
你可能想到:
python1 feature -> padding -> 38 18 feature -> padding -> 38 38 feature -> 38
技术上能跑:
pythonF.pad(x, (0, 38 - x.shape[-1]))
但不建议直接拿这个做最终实验。
因为:
textGECCO feature 1
和:
textCalIt2 feature 1
未必具有任何相同语义。
而且 reconstruction MSE 会把大量 padding 的 0 也统计进去:
pythonMSE( predicted_38_dim, padded_38_dim )
最后可能让 ROC-AUC/F1 出现很奇怪的结果。
如果只是验证代码 pipeline,可以 padding;正式 benchmark 最好不要。
你把当前这个 shape bug 修掉后,很可能马上又遇到别的问题。
你创建的是:
pythonbase_model = AnomalyDetectorAutoencoder(...)
它的 state dict 名字类似:
textencoder.0.weight encoder.0.bias encoder.3.weight decoder.0.weight ...
但是 FedRep 传的是:
pythonrepresentation_layers=['global_encoder']
然后:
pythonif any(rname in k for rname in self.representation_layers)
实际上根本没有:
textglobal_encoder.xxx
所以:
pythonrep_state = {}
极有可能是空字典。
你其实定义了:
pythonPersonalizedAutoencoder
但 create_clients() 完全没用它。
你传的是:
pythonpersonalized_layer_names=[ 'personal_encoder', 'decoder' ]
但 AnomalyDetectorAutoencoder 里没有:
textpersonal_encoder
只有:
textencoder decoder
所以实际上你只 personalized 了:
textdecoder
而不是代码注释里描述的结构。
你这里:
pythonself.global_model.load_state_dict(aggregated_state)
默认:
pythonstrict=True
但 FedPer 返回的是 partial state:
python{ encoder.... }
没有 decoder。
所以很可能继续报:
textMissing key(s) in state_dict
partial aggregation 应该是:
pythoncurrent_state = self.global_model.state_dict() for key, value in aggregated_state.items(): current_state[key] = value self.global_model.load_state_dict(current_state)
而不是直接:
pythonself.global_model.load_state_dict(aggregated_state)
这部分也很关键:
pythonif global_model_state is not None: self.global_model.load_state_dict(global_model_state)
然后你只训练:
pythonself.local_model
最后却返回:
pythonreturn { k: v.cpu().clone() for k, v in self.global_model.state_dict().items() }
也就是说:
textserver global model ↓ 复制给 client ↓ global model 根本没更新 ↓ 原封不动传回 server
服务器平均了一堆没有变化的参数。
所以你的 Ditto global model 基本不会学习。
dimZ 目前没有任何作用这里:
pythonself.dimZ = dimZ
之后就再也没有使用。
而且目前所谓:
pythoncausal_reg = self.alpha * torch.mean(latent ** 2)
本质上只是 latent L2 regularization:
textL = reconstruction loss + α ||z||²
严格来说这还不能称为完整的 causal representation learning。
你注释里也写了:
python# simplified # full FedCR includes VIB and POE
如果后面要拿它做论文 baseline comparison,这部分最好特别注意。
当前最优先的顺序是:
1 / 18 / 38 等不同输入维度。prepare_batch()。所以这一次报错最核心的一句话就是:
text你根据 Client 0 创建了 Linear(1,256), 但 FedCR 后来抽到了一个每个时间点有 38 个特征的 Client, 于是 Linear 收到 38 维输入,直接 shape mismatch。
而不是简单的:
pythonreshape 写错了
实际上 2048x38 已经非常明确地告诉你:真正冲突的是 38 != 1。