-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclient.py
More file actions
3909 lines (3410 loc) · 226 KB
/
Copy pathclient.py
File metadata and controls
3909 lines (3410 loc) · 226 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# client.py
# Provides the Client class for federated learning clients, including benign and attacker clients.
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import copy
import numpy as np
from typing import Dict, List, Optional, Tuple, Any
from tqdm import tqdm
from models import VGAE
from torch.nn.utils import stateless
# ============================================================================
# CRITICAL: Functional call wrapper for LoRA gradient preservation
# ============================================================================
# Purpose: Use torch.func.functional_call to preserve gradients when injecting
# LoRA parameters into the model forward pass. This ensures the
# computational graph remains intact from proxy_param to loss.
#
# API Note: torch.func.functional_call (PyTorch 2.0+) takes (params, buffers) tuple
# stateless.functional_call (PyTorch < 2.0) only takes params dict
# ============================================================================
try:
from torch.func import functional_call as _torch_func_call
def functional_call(model, params_buffers, args=(), kwargs=None):
"""
Wrapper for torch.func.functional_call (PyTorch 2.0+).
Args:
model: PyTorch model
params_buffers: Tuple of (params_dict, buffers_dict)
args: Positional arguments for forward pass
kwargs: Keyword arguments for forward pass
Returns:
Model output with preserved gradients
"""
params, buffers = params_buffers
return _torch_func_call(model, (params, buffers), args=args, kwargs=kwargs or {})
except ImportError:
# Fallback for older PyTorch versions (< 2.0)
from torch.nn.utils.stateless import functional_call as _stateless_call
def functional_call(model, params_buffers, args=(), kwargs=None):
"""
Fallback wrapper for torch.nn.utils.stateless.functional_call (PyTorch < 2.0).
Note: stateless.functional_call doesn't support buffers parameter, so we only
pass params. This is acceptable because buffers are typically constant
and don't need to be injected for gradient preservation.
"""
params, buffers = params_buffers
return _stateless_call(model, params, args=args, kwargs=kwargs or {})
# Client class for federated learning
class Client:
def __init__(self, client_id: int, model: nn.Module, data_loader, lr, local_epochs, alpha):
"""
Initialize a federated learning client.
Args:
client_id: Unique identifier for the client
model: The neural network model (will be deep copied)
data_loader: DataLoader for local training data
lr: Learning rate for local training (must be provided, no default)
local_epochs: Number of local training epochs per round (must be provided, no default)
alpha: Proximal regularization coefficient μ (FedProx standard: min_w F_k(w) + (μ/2) * ||w - w_t||²)
Note: α corresponds to μ in FedProx paper (Li et al., 2020)
Note: All parameters must be explicitly provided. Default values are removed to prevent
inconsistencies with config settings. See main.py for proper usage.
Memory optimization: Model is kept in CPU by default to save GPU memory.
It will be moved to GPU only during training (for benign clients) or proxy loss calculation (for attackers).
"""
self.client_id = client_id
self.model = copy.deepcopy(model)
# Keep model in CPU initially to save GPU memory
# Will be moved to GPU only when needed (training or proxy loss calculation)
self.data_loader = data_loader
self.lr = lr
self.local_epochs = local_epochs
self.alpha = alpha # Regularization coefficient α ∈ [0,1] from paper formula (1)
# CRITICAL: Use explicit cuda:0 instead of 'cuda' to ensure device consistency
# This prevents issues where 'cuda' and 'cuda:0' are treated as different devices
if torch.cuda.is_available():
self.device = torch.device('cuda:0')
else:
self.device = torch.device('cpu')
# Do NOT move model to GPU here - will be moved on-demand
self.optimizer = None # Will be created when needed
self.current_round = 0
self.is_attacker = False
self._model_on_gpu = False # Track if model is currently on GPU
def reset_optimizer(self):
"""Reset the optimizer. Only valid when model is on GPU."""
if self._model_on_gpu:
# Only optimize trainable parameters (important for LoRA)
trainable_params = [p for p in self.model.parameters() if p.requires_grad]
self.optimizer = optim.Adam(trainable_params, lr=self.lr)
else:
self.optimizer = None
def set_round(self, round_num: int):
"""Set the current training round."""
self.current_round = round_num
def get_model_update(self, initial_params: torch.Tensor) -> torch.Tensor:
"""
Calculate the model update (Current - Initial).
Args:
initial_params: Initial model parameters (flattened)
Returns:
Model update tensor (flattened, on CPU)
Note: Works on both CPU and GPU models. Returns CPU tensor to save GPU memory.
"""
current_params = self.model.get_flat_params()
# Ensure both tensors are on the same device before subtraction
# initial_params is on CPU, so move current_params to CPU
if current_params.device.type == 'cuda':
current_params = current_params.cpu()
# Ensure initial_params is also on CPU (should already be, but double-check)
if initial_params.device.type == 'cuda':
initial_params = initial_params.cpu()
update = current_params - initial_params
return update
def local_train(self, epochs=None) -> torch.Tensor:
"""Base local training method (to be overridden)."""
raise NotImplementedError
# BenignClient class for benign clients
class BenignClient(Client):
def __init__(self, client_id: int, model: nn.Module, data_loader, lr, local_epochs, alpha,
data_indices=None, grad_clip_norm=1.0):
super().__init__(client_id, model, data_loader, lr, local_epochs, alpha)
# Track assigned data indices for proper aggregation weighting
self.data_indices = data_indices or []
self.grad_clip_norm = grad_clip_norm
def prepare_for_round(self, round_num: int):
"""Benign clients do not require special preparation."""
self.set_round(round_num)
def local_train(self, epochs=None) -> torch.Tensor:
"""
Perform local training with FedProx proximal regularization.
Standard FedProx formula: min_w F_k(w) + (μ/2) * ||w - w_t||²
where μ is the proximal regularization coefficient (self.alpha).
"""
if epochs is None:
epochs = self.local_epochs
# Move model to GPU for training
if not self._model_on_gpu:
self.model.to(self.device)
self._model_on_gpu = True
# Create optimizer when model is on GPU
# Only optimize trainable parameters (important for LoRA)
if self.optimizer is None:
trainable_params = [p for p in self.model.parameters() if p.requires_grad]
self.optimizer = optim.Adam(trainable_params, lr=self.lr)
self.model.train()
# Get initial params and move to CPU to save GPU memory
initial_params = self.model.get_flat_params().clone().cpu()
# Proximal regularization coefficient (FedProx standard: μ)
# Standard formula: min_w F_k(w) + (μ/2) * ||w - w_t||²
# Note: α (self.alpha) corresponds to μ in FedProx paper
mu = self.alpha
for epoch in range(epochs):
epoch_loss = 0
num_batches = 0
pbar = tqdm(self.data_loader,
desc=f'Client {self.client_id} - Epoch {epoch + 1}/{epochs}',
leave=False)
for batch in pbar:
input_ids = batch['input_ids'].to(self.device)
attention_mask = batch['attention_mask'].to(self.device)
labels = batch['labels'].to(self.device)
outputs = self.model(input_ids, attention_mask)
# NewsClassifierModel returns logits directly
logits = outputs
ce_loss = nn.CrossEntropyLoss()(logits, labels)
# Add proximal regularization term (FedProx standard formula: (μ/2) * ||w - w_t||²)
# Standard FedProx: min_w F_k(w) + (μ/2) * ||w - w_t||²
# This ensures gradient is μ * (w - w_t) without extra 2x factor
# Move initial_params to GPU temporarily for computation
# CRITICAL: requires_grad=True to preserve gradients for backward pass
current_params = self.model.get_flat_params(requires_grad=True)
initial_params_gpu = initial_params.to(self.device)
proximal_term = (mu / 2.0) * torch.norm(current_params - initial_params_gpu) ** 2
initial_params_gpu = None # Release GPU reference
loss = ce_loss + proximal_term
if not torch.isfinite(loss).item():
# Skip batch on nan/inf to avoid corrupting model (e.g. Pythia-160m can be unstable)
import warnings
warnings.warn(
f"[Client {self.client_id}] Skipping batch: loss={loss.item()} (non-finite). "
"Consider lowering client_lr or grad_clip_norm for decoder models (e.g. Pythia-160m)."
)
pbar.set_postfix({'loss': 'nan(skip)'})
continue
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=self.grad_clip_norm)
self.optimizer.step()
epoch_loss += loss.item()
num_batches += 1
pbar.set_postfix({'loss': loss.item()})
# Calculate update (will be on CPU)
update = self.get_model_update(initial_params)
# Move model back to CPU to free GPU memory
self.model.cpu()
self._model_on_gpu = False
# Delete optimizer to free its GPU memory (Adam states)
del self.optimizer
self.optimizer = None
torch.cuda.empty_cache() # Clear CUDA cache
return update
def receive_benign_updates(self, updates: List[torch.Tensor]):
# Benign clients do not use this method
pass
# AttackerClient class for clients that perform attacks
class AttackerClient(Client):
def __init__(self, client_id: int, model: nn.Module, data_manager,
data_indices, lr, local_epochs, alpha,
dim_reduction_size=10000,
vgae_epochs=20, vgae_lr=0.01, graph_threshold=0.5,
proxy_step=0.1,
claimed_data_size=1.0,
proxy_sample_size=512,
proxy_max_batches_opt=2,
proxy_max_batches_eval=4,
vgae_hidden_dim=32,
vgae_latent_dim=16,
vgae_dropout=0.0,
vgae_kl_weight=0.1,
proxy_steps=20,
grad_clip_norm=1.0,
proxy_grad_clip_norm=None,
early_stop_constraint_stability_steps=3,
use_proxy_data=True):
"""
Initialize an attacker client with VGAE-based camouflage capabilities.
Args:
client_id: Unique identifier for the client
model: The neural network model (will be deep copied)
data_manager: DataManager instance for managing attacker data
data_indices: List of data indices assigned to this client
lr: Learning rate for local training (must be provided, no default)
local_epochs: Number of local training epochs per round (must be provided, no default)
alpha: Proximal regularization coefficient μ (FedProx standard: (μ/2) * ||w - w_t||²)
Note: α corresponds to μ in FedProx paper (Li et al., 2020)
dim_reduction_size: Dimensionality for feature reduction (default: 10000)
vgae_epochs: Number of epochs for VGAE training (default: 20)
vgae_lr: Learning rate for VGAE optimizer (default: 0.01)
graph_threshold: Threshold for graph adjacency matrix binarization (default: 0.5)
proxy_step: Step size for gradient-free ascent toward global-loss proxy (default: 0.1)
claimed_data_size: Reported data size D'_j(t) for weighted aggregation (default: 1.0)
proxy_sample_size: Number of samples in proxy dataset for F(w'_g) estimation (default: 512)
proxy_max_batches_opt: Max batches for proxy loss in optimization loop (default: 2)
proxy_max_batches_eval: Max batches for proxy loss in final evaluation (default: 4)
vgae_hidden_dim: VGAE hidden layer dimension (default: 32, per paper)
vgae_latent_dim: VGAE latent space dimension (default: 16, per paper)
vgae_dropout: VGAE dropout rate (default: 0.0)
vgae_kl_weight: Weight for KL divergence term in VGAE loss (default: 0.1)
proxy_steps: Number of optimization steps for attack objective (default: 20)
grad_clip_norm: Kept for compatibility; proxy step uses proxy_grad_clip_norm if set.
proxy_grad_clip_norm: Gradient clipping for AugMP proxy parameter update only (default: None = use grad_clip_norm). Separate from benign client training.
use_proxy_data: If True, use proxy set to estimate F(w'_g); if False, no data access (constraint-only optimization) (default: True)
Note: lr, local_epochs, and alpha must be explicitly provided to ensure consistency
with config settings. Other parameters have defaults but should be set via config in main.py.
"""
self.data_manager = data_manager
self.data_indices = data_indices
# Store parameters first (before using them)
self.dim_reduction_size = dim_reduction_size
self.vgae_epochs = vgae_epochs
self.vgae_lr = vgae_lr
self.graph_threshold = graph_threshold
self.proxy_step = proxy_step
self.claimed_data_size = claimed_data_size # For weighted aggregation (paper: D'(t))
self.proxy_sample_size = proxy_sample_size
self.proxy_max_batches_opt = proxy_max_batches_opt
self.proxy_max_batches_eval = proxy_max_batches_eval
self.vgae_hidden_dim = vgae_hidden_dim
self.vgae_latent_dim = vgae_latent_dim
self.vgae_dropout = vgae_dropout
self.vgae_kl_weight = vgae_kl_weight
self.proxy_steps = proxy_steps
self.grad_clip_norm = grad_clip_norm
self.proxy_grad_clip_norm = proxy_grad_clip_norm if proxy_grad_clip_norm is not None else grad_clip_norm
self.early_stop_constraint_stability_steps = early_stop_constraint_stability_steps
self.use_proxy_data = use_proxy_data
dummy_loader = data_manager.get_empty_loader()
super().__init__(client_id, model, dummy_loader, lr, local_epochs, alpha)
self.is_attacker = True
# VGAE components
self.vgae = None
self.vgae_optimizer = None
self.benign_updates = []
self.benign_update_client_ids = [] # Track client_id for each benign update to enable weighted average calculation
self.feature_indices = None
# Other attackers' updates (for coordinated optimization)
self.other_attacker_updates = []
self.other_attacker_client_ids = []
self.other_attacker_data_sizes = {} # {client_id: claimed_data_size}
if use_proxy_data:
self.proxy_loader = data_manager.get_proxy_eval_loader(sample_size=self.proxy_sample_size)
else:
self.proxy_loader = None # No data access; optimization will use constraint terms only
# Formula 4 constraints parameters
self.dist_bound = None # Distance threshold for constraint (4b): d(w'_j(t), w'_g(t)) ≤ dist_bound
self.global_model_params = None # Store global model params for constraint (4b) (will be on GPU when needed)
# Paper Formula (2): w'_g(t) = Σ_{i=1}^I (D_i(t)/D(t)) β'_{i,j}(t) w_i(t) + (D'_j(t)/D(t)) w'_j(t)
self.total_data_size = None # D(t): Total data size for aggregation weight calculation
self.benign_data_sizes = {} # {client_id: D_i(t)}: Data sizes for each benign client
# Manual cosine similarity bounds (None = use benign min/max)
self.sim_bound_low = None # If set, use as lower bound; else benign min
self.sim_bound_up = None # If set, use as upper bound; else benign mean
# Lagrangian dual variables (λ(t) from paper)
# Initialized in set_lagrangian_params
self.lambda_dist = None # λ_dist(t): Lagrangian multiplier for distance constraint (4b)
self.use_lagrangian_dual = False # Whether to use Lagrangian Dual mechanism
self.lambda_dist_lr = 0.01 # Learning rate for λ_dist(t) update
# Save initial values for reset in prepare_for_round
self.lambda_dist_init = None # Save initial λ_dist value for reset in prepare_for_round
# Cosine similarity constraint parameters (TWO-SIDED with TWO multipliers)
self.use_cosine_similarity_constraint = False # Whether to use cosine similarity constraints
self.lambda_sim_low = None # λ_sim_low(t): Lagrangian multiplier for lower bound constraint
self.lambda_sim_up = None # λ_sim_up(t): Lagrangian multiplier for upper bound constraint
self.lambda_sim_low_lr = 0.01 # Learning rate for λ_sim_low(t) update
self.lambda_sim_up_lr = 0.01 # Learning rate for λ_sim_up(t) update
self.lambda_sim_low_init = None # Save initial λ_sim_low value for reset
self.lambda_sim_up_init = None # Save initial λ_sim_up value for reset
# ============================================================
# Augmented Lagrangian (ALM) penalty parameters (ρ)
# Standard ALM uses: L_aug = f(x) + Σ_i [ λ_i g_i(x) + (ρ_i/2) g_i(x)^2 ]
# where g_i(x) ≤ 0 are inequality constraints.
# ============================================================
self.use_augmented_lagrangian = False # Whether to use Augmented Lagrangian Method (ALM)
self.lambda_update_mode = "classic" # "classic": λ += lr * g ; "alm": λ += ρ * g
# ρ variables (kept on same device as optimization)
self.rho_dist = None
self.rho_sim_low = None
self.rho_sim_up = None
# Save initial values for reset in prepare_for_round
self.rho_dist_init = None
self.rho_sim_low_init = None
self.rho_sim_up_init = None
# Adaptive ρ update (monotone increase) parameters
self.rho_adaptive = True
self.rho_theta = 0.5 # If violation does not decrease enough: σ_k > theta * σ_{k-1} => increase ρ
self.rho_increase_factor = 2.0
self.rho_min = 1e-3
self.rho_max = 1e3
# Track previous constraint violations for adaptive ρ update (per-round)
self._prev_violation_dist = None
self._prev_violation_sim_low = None
self._prev_violation_sim_up = None
# Get model parameter count (works on CPU model)
self._flat_numel = int(self.model.get_flat_params().numel()) # Convert to Python int
# ===== CRITICAL: LoRA functional_call cache for gradient preservation =====
# These will be initialized in _init_functional_param_cache() when needed
self.lora_param_names: List[str] = [] # Ordered list of LoRA parameter names
self.lora_param_shapes: Dict[str, torch.Size] = {} # Shape for each LoRA param
self.lora_param_numels: Dict[str, int] = {} # Numel for each LoRA param
self.lora_param_slices: Dict[str, slice] = {} # Slice in flat tensor for each LoRA param
self.base_params: Dict[str, torch.Tensor] = {} # Frozen base parameters (detached)
self.base_buffers: Dict[str, torch.Tensor] = {} # Buffers (detached)
self._functional_cache_initialized = False # Cache initialization flag
# ============================================================================
# Validate and adjust dim_reduction_size for LoRA mode
# In LoRA mode, if dim_reduction_size > actual LoRA params, use all LoRA params
# Rationale: When LoRA params are already small, using all of them is more reasonable
# than further reducing, as it preserves information and the computation is still feasible.
use_lora = hasattr(self.model, 'use_lora') and self.model.use_lora
if use_lora:
actual_lora_params = self._flat_numel
if dim_reduction_size > actual_lora_params:
# Auto-adjust: use all LoRA params (no further reduction needed)
# When LoRA params are already small, using all of them is reasonable
# and preserves more information for VGAE training
print(f" [Attacker {self.client_id}] Info: dim_reduction_size ({dim_reduction_size}) > LoRA params ({actual_lora_params})")
print(f" [Attacker {self.client_id}] Auto-adjusting dim_reduction_size to {actual_lora_params} (using all LoRA params)")
self.dim_reduction_size = actual_lora_params
elif dim_reduction_size == actual_lora_params:
# Use all parameters (no reduction), which is fine
pass
else:
# dim_reduction_size < actual_lora_params, which is the normal case (with reduction)
pass
def prepare_for_round(self, round_num: int):
"""
Prepare for a new training round.
Modification 1: Reset λ and ρ to initial values at the start of each round
to prevent numerical instability from cross-round accumulation.
"""
self.set_round(round_num)
# Data-agnostic attacker keeps an empty loader
self.data_loader = self.data_manager.get_empty_loader()
# ===== CRITICAL: Reset functional cache for new round =====
# Model structure may change between rounds, so cache must be reset
self._functional_cache_initialized = False
self.lora_param_names = []
self.lora_param_shapes = {}
self.lora_param_numels = {}
self.lora_param_slices = {}
self.base_params = {}
self.base_buffers = {}
# ============================================================
# Note: d_T is used only as fallback when distance prediction is disabled or no history
# Modification 1: Reset Lagrangian multipliers at the start of each round
# Reason: Prevent λ from accumulating across rounds, which causes numerical instability and optimization imbalance
# Reset distance constraint multiplier
if self.use_lagrangian_dual and self.lambda_dist_init is not None:
self.lambda_dist = torch.tensor(self.lambda_dist_init, requires_grad=False)
# Reset cosine similarity constraint multipliers (TWO multipliers for two-sided constraint)
if self.use_cosine_similarity_constraint and self.lambda_sim_low_init is not None:
self.lambda_sim_low = torch.tensor(self.lambda_sim_low_init, requires_grad=False)
if self.use_cosine_similarity_constraint and self.lambda_sim_up_init is not None:
self.lambda_sim_up = torch.tensor(self.lambda_sim_up_init, requires_grad=False)
# Reset Augmented Lagrangian penalty parameters (ρ) at the start of each round
if self.use_lagrangian_dual and self.use_augmented_lagrangian:
if self.rho_dist_init is not None:
self.rho_dist = torch.tensor(self.rho_dist_init, requires_grad=False)
if self.use_cosine_similarity_constraint:
if self.rho_sim_low_init is not None:
self.rho_sim_low = torch.tensor(self.rho_sim_low_init, requires_grad=False)
if self.rho_sim_up_init is not None:
self.rho_sim_up = torch.tensor(self.rho_sim_up_init, requires_grad=False)
# Reset per-round violation history (avoid cross-round coupling)
self._prev_violation_dist = None
self._prev_violation_sim_low = None
self._prev_violation_sim_up = None
def receive_benign_updates(self, updates: List[torch.Tensor], client_ids: Optional[List[int]] = None):
"""
Receive updates from benign clients.
Args:
updates: List of benign client updates
client_ids: Optional list of client IDs corresponding to each update.
If None, indices will be used as client IDs (fallback for backward compatibility)
"""
# Store detached copies on CPU to save GPU memory
# Updates will be moved to GPU only when needed for VGAE processing
self.benign_updates = [u.detach().clone().cpu() for u in updates]
# Store corresponding client IDs for weighted average calculation
if client_ids is not None:
self.benign_update_client_ids = client_ids.copy()
else:
# Fallback: use indices as client IDs (for backward compatibility)
# Note: This may not be accurate, but allows code to work without server changes
self.benign_update_client_ids = list(range(len(updates)))
def receive_attacker_updates(self, updates: List[torch.Tensor], client_ids: List[int], data_sizes: Dict[int, float] = None):
"""
Receive updates from other attackers that have already completed optimization.
These will be used in distance calculation to match Phase 4's reference point.
Args:
updates: List of attacker update tensors (already optimized)
client_ids: List of attacker client IDs
data_sizes: Dictionary mapping client_id to claimed_data_size (optional)
"""
# Store detached copies on CPU to save GPU memory
self.other_attacker_updates = [u.detach().clone().cpu() for u in updates]
self.other_attacker_client_ids = client_ids.copy() if client_ids else []
# Store data sizes for weighted aggregation
if data_sizes is not None:
self.other_attacker_data_sizes = data_sizes.copy()
else:
# Fallback: use current attacker's claimed size as estimate
self.other_attacker_data_sizes = {cid: float(self.claimed_data_size) for cid in client_ids}
def _select_benign_subset(self) -> List[torch.Tensor]:
"""
Select a subset of benign updates (β selection) using 0-1 Knapsack optimization.
Paper formulation (Equation 9):
β'_{i,j}(t)^* = argmin_{β'_{i,j}(t)} Σ_{i=1}^I β'_{i,j}(t) d(w_i(t), w̄_i(t))
s.t. Σ_{i=1}^I β'_{i,j}(t) d(w_i(t), w̄_i(t)) ≤ Γ
β'_{i,j}(t) ∈ {0,1}
This is a 0-1 Knapsack problem: minimize sum of selected distances
subject to sum ≤ capacity (Γ).
Note: Since we want to minimize the sum and the constraint is also on the sum,
the optimal solution is to select as many items as possible while staying within capacity.
We use a greedy approach to find an approximate optimal selection.
Returns:
List of selected benign updates (on CPU to save GPU memory)
"""
if not self.benign_updates:
return []
# Compute distances from weighted mean for all benign updates
# Paper definition: w̄_i(t) = Σ_{i=1}^I (D_i(t)/D(t)) w_i(t) (weighted mean, not simple mean)
# Move to GPU only for computation, then back to CPU
benign_updates_gpu = [u.to(self.device) for u in self.benign_updates]
benign_stack = torch.stack([u.detach() for u in benign_updates_gpu])
# Compute weighted mean: w̄_i(t) = Σ (D_i/D) w_i(t)
if self.total_data_size is not None and len(self.benign_data_sizes) > 0 and len(self.benign_update_client_ids) > 0:
D_total = float(self.total_data_size)
benign_mean = torch.zeros_like(benign_stack[0])
for idx, benign_update in enumerate(self.benign_updates):
if idx < len(self.benign_update_client_ids):
client_id = self.benign_update_client_ids[idx]
D_i = self.benign_data_sizes.get(client_id, 1.0)
weight = D_i / D_total
else:
# Fallback: use equal weight if client_id not available
weight = 1.0 / len(self.benign_updates)
benign_mean = benign_mean + weight * benign_update.to(self.device)
else:
# Fallback: use simple mean if data sizes not available
benign_mean = benign_stack.mean(dim=0)
distances = torch.norm(benign_stack - benign_mean, dim=1).cpu().numpy()
# Clean up GPU references immediately
del benign_updates_gpu, benign_stack, benign_mean
torch.cuda.empty_cache()
# Always return all benign updates
return self.benign_updates
def _get_selected_benign_indices(self) -> List[int]:
"""
Get indices of selected benign updates (β selection).
This is a helper method to avoid tensor comparison issues.
"""
if not self.benign_updates:
return []
# Compute distances from weighted mean for all benign updates
# Paper definition: w̄_i(t) = Σ_{i=1}^I (D_i(t)/D(t)) w_i(t) (weighted mean, not simple mean)
# Move to GPU only for computation, then back to CPU
benign_updates_gpu = [u.to(self.device) for u in self.benign_updates]
benign_stack = torch.stack([u.detach() for u in benign_updates_gpu])
# Compute weighted mean: w̄_i(t) = Σ (D_i/D) w_i(t)
if self.total_data_size is not None and len(self.benign_data_sizes) > 0 and len(self.benign_update_client_ids) > 0:
D_total = float(self.total_data_size)
benign_mean = torch.zeros_like(benign_stack[0])
for idx, benign_update in enumerate(self.benign_updates):
if idx < len(self.benign_update_client_ids):
client_id = self.benign_update_client_ids[idx]
D_i = self.benign_data_sizes.get(client_id, 1.0)
weight = D_i / D_total
else:
# Fallback: use equal weight if client_id not available
weight = 1.0 / len(self.benign_updates)
benign_mean = benign_mean + weight * benign_update.to(self.device)
else:
# Fallback: use simple mean if data sizes not available
benign_mean = benign_stack.mean(dim=0)
distances = torch.norm(benign_stack - benign_mean, dim=1).cpu().numpy()
# Clean up GPU references immediately
del benign_updates_gpu, benign_stack, benign_mean
torch.cuda.empty_cache()
# Always return all indices
return list(range(len(self.benign_updates)))
def local_train(self, epochs=None) -> torch.Tensor:
"""
Attacker does not perform local training (data-agnostic attack).
Attackers are not assigned local data, so they return zero update.
The actual attack is generated in camouflage_update using VGAE+GSP.
Returns:
Zero update tensor on CPU (to save GPU memory)
"""
# Attackers don't have local data, return zero update
# Model is on CPU, so initial_params is on CPU
initial_params = self.model.get_flat_params().clone()
return torch.zeros_like(initial_params) # Already on CPU
def _get_reduced_features(self, updates: List[torch.Tensor], fix_indices=True) -> torch.Tensor:
"""
Helper function to reduce dimensionality of updates.
Selects indices based on update magnitude (importance) to prioritize parameters
that change most in normal training, simulating realistic training patterns.
Selection strategy:
1. Calculate update magnitudes (absolute mean across all updates)
2. Select top-K important parameters (2x dim_reduction_size for diversity pool)
3. Randomly select from important pool using client_id for diversity
This approach:
- Prioritizes important parameters (classifier, attention layers)
- Maintains diversity among different attackers
- Simulates normal training patterns (updates important params more)
- Aligns with FedLLM standard practices (FedPipe, FedLEASE)
Args:
updates: List of update tensors to reduce
fix_indices: If True, reuse existing feature_indices; if False, generate new ones
Returns:
Stacked reduced features tensor of shape (I, M) where I=num_updates, M=dim_reduction_size
"""
stacked_updates = torch.stack(updates)
# Ensure stacked_updates has valid shape
if len(stacked_updates.shape) < 2:
raise ValueError(f"[Attacker {self.client_id}] stacked_updates must be 2D, got shape {stacked_updates.shape}")
shape_dim = stacked_updates.shape[1]
if shape_dim is None:
raise ValueError(f"[Attacker {self.client_id}] stacked_updates.shape[1] is None")
try:
total_dim = int(shape_dim) # Convert to Python int
except (TypeError, ValueError) as e:
raise ValueError(f"[Attacker {self.client_id}] Cannot convert shape[1]={shape_dim} to int: {e}")
# If update dimension is smaller than reduction target, skip reduction
if total_dim <= self.dim_reduction_size:
return stacked_updates
# Fix feature indices at the start of each attack round to ensure training consistency within the round
if self.feature_indices is None or not fix_indices:
# Importance-based selection: prioritize parameters with larger update magnitudes
# This simulates normal training patterns where important parameters change more
import hashlib
# Validate inputs
if self.client_id is None:
raise ValueError(f"client_id is None for attacker")
if total_dim is None or total_dim <= 0:
raise ValueError(f"total_dim is None or invalid: {total_dim}")
# Step 1: Calculate update magnitudes (importance scores)
# Use absolute mean across all benign updates to identify important parameters
# Parameters with larger magnitudes are more important (change more in normal training)
update_magnitudes = torch.abs(stacked_updates).mean(dim=0) # (total_dim,)
# Step 2: Select top-K important parameters (2x for diversity pool)
# Select 2x dim_reduction_size to create a pool, then randomly select from pool
# This balances importance with diversity among different attackers
top_k = min(self.dim_reduction_size * 2, total_dim)
_, top_indices_tensor = torch.topk(update_magnitudes, k=top_k)
top_indices = top_indices_tensor.cpu().numpy() # Convert to numpy for random selection
# Step 3: Within top-K, use client_id for diversity (different attackers select different params)
# This ensures different attackers choose different parameters from the important pool
seed_str = f"{self.client_id}_{top_k}"
seed = int(hashlib.md5(seed_str.encode()).hexdigest()[:8], 16) % (2**31)
np_rng = np.random.RandomState(seed)
# Ensure we don't select more than available (safety check)
num_to_select = min(self.dim_reduction_size, len(top_indices))
permuted = np_rng.permutation(len(top_indices))[:num_to_select]
selected_indices = top_indices[permuted]
# Step 4: Create feature_indices tensor on the same device as stacked_updates
# Ensure device consistency for index_select operation
target_device = stacked_updates.device
self.feature_indices = torch.tensor(selected_indices, dtype=torch.long, device=target_device)
# Select features
reduced_features = torch.index_select(stacked_updates, 1, self.feature_indices)
return reduced_features
def _flat_to_param_dict(self, flat_params: torch.Tensor, skip_dim_check: bool = False) -> Dict[str, torch.Tensor]:
"""
Convert flat tensor to param dict for stateless.functional_call.
In LoRA mode, only sets LoRA parameters (trainable parameters).
In full fine-tuning mode, sets all parameters.
Important: Handles PEFT model parameter name compatibility.
PEFT models have nested structure (base_model.model.*), and stateless.functional_call
may need specific parameter name formats.
Args:
flat_params: Flattened parameter tensor (LoRA params in LoRA mode, all params in full mode)
skip_dim_check: If True, skip dimension check (for performance in loops)
Returns:
Dictionary mapping parameter names to tensors, compatible with stateless.functional_call
"""
param_dict = {}
offset = 0
flat_params = flat_params.view(-1) # Ensure 1D (O(1), just view change)
total_numel = int(flat_params.numel()) # Convert to Python int
# Check if model is in LoRA mode
use_lora = hasattr(self.model, 'use_lora') and self.model.use_lora
# Build a mapping from parameter objects to their names
# This is more efficient than searching each time
param_to_name = {}
for name, param in self.model.named_parameters():
# In LoRA mode, only track trainable parameters
if use_lora:
if param.requires_grad:
param_to_name[param] = name
else:
# Full fine-tuning: track all parameters
param_to_name[param] = name
# Iterate through parameters in the same order as get_flat_params
for param in self.model.parameters():
# In LoRA mode, skip non-trainable parameters
if use_lora and not param.requires_grad:
continue
# Get parameter name from pre-built mapping
param_name = param_to_name.get(param)
if param_name is None:
# Parameter not in mapping (shouldn't happen, but handle gracefully)
continue
numel = int(param.numel()) # Convert to Python int
if not skip_dim_check and offset + numel > total_numel:
# Dimension mismatch: return empty dict to avoid errors
print(f" [Attacker {self.client_id}] Param dict dimension mismatch: offset {offset} + numel {numel} > total {total_numel}")
return {}
# For PEFT models, stateless.functional_call expects parameter names
# that match the actual model structure. The names from named_parameters()
# should already be correct, but we verify compatibility.
param_value = flat_params[offset:offset + numel].view_as(param)
# Ensure param_value is on the same device as param
# This is important when model is on GPU but flat_params might be on different device
if param_value.device != param.device:
param_value = param_value.to(param.device)
# Handle PEFT model parameter names (base_model.model.* format)
# stateless.functional_call should work with the names as-is from named_parameters()
# But if we're working with a PEFT-wrapped model, ensure the name is correct
if use_lora and hasattr(self.model, 'model') and hasattr(self.model.model, 'base_model'):
# This is a PEFT model - parameter names should already include base_model.model prefix
# from named_parameters(), so use as-is
param_dict[param_name] = param_value
else:
# Standard model or direct PEFT model access
param_dict[param_name] = param_value
offset += numel
# Verify we used all parameters
if not skip_dim_check and offset != total_numel:
print(f" [Attacker {self.client_id}] Param dict size mismatch: used {offset} params, provided {total_numel}")
# This could indicate a serious problem - log warning but continue
return param_dict
def _device_matches(self, device1, device2):
"""
Check if two devices are the same, handling 'cuda' vs 'cuda:0' equivalence.
Args:
device1: First device
device2: Second device
Returns:
True if devices are the same, False otherwise
"""
# Convert to string and normalize
d1_str = str(device1)
d2_str = str(device2)
# Normalize 'cuda' to 'cuda:0'
if d1_str == 'cuda':
d1_str = 'cuda:0'
if d2_str == 'cuda':
d2_str = 'cuda:0'
return d1_str == d2_str
def _ensure_model_on_device(self, module, device):
"""
Recursively ensure ALL parameters and buffers of a module are on the specified device.
This is critical for PEFT models with nested structures.
Args:
module: The module to move
device: Target device (will be normalized to 'cuda:0' if it's 'cuda')
"""
# Normalize device: always use 'cuda:0' instead of 'cuda' for consistency
device_str = str(device)
if device_str == 'cuda':
target_device = torch.device('cuda:0')
elif device_str.startswith('cuda'):
target_device = torch.device(device_str if ':' in device_str else 'cuda:0')
else:
target_device = device
# Use named_parameters to get all parameters including nested ones
for name, param in module.named_parameters(recurse=False):
if not self._device_matches(param.device, target_device):
# Force move by creating new tensor on target device
with torch.no_grad():
param.data = param.data.to(target_device, non_blocking=True)
for name, buffer in module.named_buffers(recurse=False):
if not self._device_matches(buffer.device, target_device):
# Force move buffer
buffer.data = buffer.data.to(target_device, non_blocking=True)
# Recursively process all child modules
for child in module.children():
self._ensure_model_on_device(child, target_device)
def _init_functional_param_cache(self, target_device: torch.device):
"""
Initialize cache for functional_call with full parameters (base + LoRA).
CRITICAL: This must be called before using functional_call in LoRA mode.
Caches LoRA parameter metadata and base parameters/buffers for gradient-preserving forward.
What this function does:
1. Collects LoRA parameters (trainable) in the same order as get_flat_params()
2. Caches base parameters (frozen, detached) to avoid repeated lookups
3. Caches buffers (detached) for functional_call
4. Verifies dimension consistency (sum(LoRA numel) == _flat_numel)
5. Verifies parameter name completeness (base + LoRA = all params)
Args:
target_device: Device to cache parameters on (typically GPU)
Raises:
AssertionError: If dimension consistency checks fail
RuntimeError: If parameter name lookup fails
"""
if self._functional_cache_initialized:
return # Already initialized
use_lora = hasattr(self.model, 'use_lora') and self.model.use_lora
if not use_lora:
# Full fine-tuning mode doesn't need special caching
self._functional_cache_initialized = True
return
# Step 1: Build LoRA parameter metadata (trainable parameters only)
# ============================================================
# CRITICAL: Must match get_flat_params() order EXACTLY
# ============================================================
# get_flat_params() in models.py uses:
# for param in self.model.parameters():
# if param.requires_grad:
# lora_params.append(param.data.view(-1))
#
# Problem: parameters() and named_parameters() order may differ!
# Solution: Build a mapping from param object to name, then iterate in parameters() order
# ============================================================
self.lora_param_names = []
self.lora_param_shapes = {}
self.lora_param_numels = {}
self.lora_param_slices = {}
offset = 0
# CRITICAL: Build param -> name mapping first
# This allows us to iterate in parameters() order (matching get_flat_params())
# while still getting parameter names (needed for functional_call)
param_to_name = {param: name for name, param in self.model.named_parameters()}
# CRITICAL: Iterate in parameters() order (SAME as get_flat_params() in models.py)
# This ensures exact order match, preventing parameter misalignment
for param in self.model.parameters():
if param.requires_grad:
# Get parameter name from mapping
name = param_to_name[param]
numel = int(param.numel())
self.lora_param_names.append(name)
self.lora_param_shapes[name] = param.shape
self.lora_param_numels[name] = numel
self.lora_param_slices[name] = slice(offset, offset + numel)
offset += numel
# Step 2: Build base parameters dict (frozen parameters, detached)
self.base_params = {}
for name, param in self.model.named_parameters():
if not param.requires_grad:
# Frozen base parameter - detach and move to target device
with torch.no_grad():
base_param = param.data.clone().detach().to(target_device)
self.base_params[name] = base_param
# Step 3: Build buffers dict (detached)
self.base_buffers = {}
for name, buffer in self.model.named_buffers():
with torch.no_grad():
base_buffer = buffer.data.clone().detach().to(target_device)
self.base_buffers[name] = base_buffer
# Step 4: Consistency assertions (CRITICAL)
total_lora_numel = sum(self.lora_param_numels.values())
if total_lora_numel != self._flat_numel:
# Enhanced error message with diagnostic information
model_params_info = f"Model has {len(list(self.model.named_parameters()))} total params, " \
f"{len([p for p in self.model.parameters() if p.requires_grad])} trainable"
raise RuntimeError(
f"[Attacker {self.client_id}] LoRA dimension mismatch:\n"
f" - Total LoRA numel (from cache): {total_lora_numel}\n"
f" - _flat_numel (from get_flat_params): {self._flat_numel}\n"
f" - LoRA param names: {self.lora_param_names}\n"
f" - {model_params_info}\n"
f"This indicates parameter order mismatch between get_flat_params() and _init_functional_param_cache()."
)
all_param_names = set(dict(self.model.named_parameters()).keys())
expected_param_names = set(self.base_params.keys()) | set(self.lora_param_names)
if all_param_names != expected_param_names:
missing_in_cache = all_param_names - expected_param_names
extra_in_cache = expected_param_names - all_param_names
raise RuntimeError(
f"[Attacker {self.client_id}] Parameter name mismatch:\n"
f" - Model params: {len(all_param_names)} params\n"
f" - Cache params: {len(expected_param_names)} params\n"
f" - Missing in cache: {missing_in_cache}\n"
f" - Extra in cache: {extra_in_cache}\n"
f" - Base params: {set(self.base_params.keys())}\n"
f" - LoRA params: {self.lora_param_names}"
)
self._functional_cache_initialized = True
print(f" [Attacker {self.client_id}] Functional cache initialized: "
f"{len(self.lora_param_names)} LoRA params, {len(self.base_params)} base params, "
f"{len(self.base_buffers)} buffers")
def _flat_to_lora_param_dict(self, flat_lora: torch.Tensor) -> Dict[str, torch.Tensor]:
"""
Convert flat LoRA tensor to parameter dict for functional_call.
CRITICAL: This preserves gradients by using view/reshape operations only.
No .data operations that would break the computational graph.
How it works:
- Uses pre-computed slices (from _init_functional_param_cache) to extract
each parameter from the flat tensor
- Uses .view() to reshape without copying (preserves gradients)
- Order must match get_flat_params() to ensure correctness
Args: