beiken1201 commited on
Commit
ba359cc
·
verified ·
1 Parent(s): 5ed2951

Fix training accumulation, evaluation and checkpoint loading

Browse files

Initialize and refresh evaluation caches safely; migrate checkpoint keys before filtering; correct accumulation tails and distributed validation control flow and metric aggregation. Use a trainable decoder in the pretraining example and align environment installation with the pinned pip requirements. Update installation documentation. Validated with focused CPU/Gloo and RTX 4070 Ti SUPER CUDA regression checks, including same-precision real-checkpoint inference comparisons. Full downstream training accuracy and GPU multi-card execution were not benchmarked. Only the twelve reviewed source, configuration and documentation files are updated; model weights, paired model configuration and original datasets are unchanged.

BioMod/README.md CHANGED
@@ -93,7 +93,8 @@ conda env create -f environment.yml
93
  conda activate biomod
94
  ```
95
 
96
- Or install Python packages into an existing CUDA/PyTorch environment:
 
97
 
98
  ```bash
99
  python -m pip install -r requirements.txt
 
93
  conda activate biomod
94
  ```
95
 
96
+ The Conda file installs the GPU stack through the pinned PyTorch/PyG wheels
97
+ in `requirements.txt`. Alternatively, use a fresh Python 3.9 environment:
98
 
99
  ```bash
100
  python -m pip install -r requirements.txt
BioMod/biomod.py CHANGED
@@ -57,6 +57,7 @@
57
  import torch.distributed as dist
58
  import sys
59
  from pathlib import Path
 
60
 
61
  SOURCE_DIR = Path(__file__).resolve().parent
62
  PROJECT_ROOT = SOURCE_DIR.parent
@@ -261,8 +262,82 @@ def restore_model_args_from_config(args, config):
261
  args.no_gene2vec = not bool(config["use_gene2vec"])
262
  return args
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  if __name__ == "__main__":
265
  args = parser.parse_args()
 
 
266
  resume_model_config = None
267
  resolved_model_config_path = None
268
  if args.model_path != 'None':
@@ -686,7 +761,7 @@ def save_initialized_checkpoint_and_exit(model_obj):
686
  loader_kwargs["prefetch_factor"] = args.prefetch_factor
687
  train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, sampler=train_sampler, **loader_kwargs)
688
  val_loader = DataLoader(val_dataset, batch_size=VALID_BATCH_SIZE, sampler=val_sampler, **loader_kwargs)
689
- updates_per_epoch = max(1, len(train_loader) // GRADIENT_ACCUMULATION)
690
 
691
  # Step 3.6: Wrap the model using DDP (distributed data parallelism)
692
  # DDP will copy the model to each GPU and synchronize the gradients on each backward
@@ -757,71 +832,56 @@ def any_rank_true(local_flag: bool) -> bool:
757
  train_loader.sampler.set_epoch(i) # DDP requires setting a different random seed for each epoch
758
  model.train() # Set to training mode
759
  dist.barrier() # Synchronize all processes
760
- running_loss = 0.0 # Accumulate the loss of all steps in the epoch
 
 
 
761
 
762
  # Step 4.2: Start batch loop (gradient accumulation)
763
  # Each iteration processes a batch, shape=(batch_size, num_genes)=(3, 27874)
764
  for index, data in enumerate(train_loader):
765
  index += 1
766
- data = data.to(device, non_blocking=pin_memory) # Move data to GPU, shape=(3, 27874)
767
-
768
- # Check if input data contains NaN or Inf
769
- invalid_input_local = bool(torch.isnan(data).any().item() or torch.isinf(data).any().item())
 
 
 
 
 
 
770
  if any_rank_true(invalid_input_local):
771
  if is_master:
772
- print(f"Warning: Input data contains NaN or Inf at step {index}, skipping...")
 
 
773
  continue
774
 
775
- # Step 4.3: Gradient accumulation training
776
- # GRADIENT_ACCUMULATION=60: Update parameters after accumulating 60 batches of gradients.
777
- # Equivalent batch_size = batch_size * grad_acc = 3 * 60 = 180
778
- if index % GRADIENT_ACCUMULATION != 0:
779
- # Step 4.3.1: Gradient accumulation step (no parameter update is performed)
780
- # model.no_sync(): DDP does not perform gradient synchronization to reduce communication overhead.
781
- with model.no_sync():
782
- with autocast(device_type='cuda', dtype=ptdtype):
783
- # [Model forward propagation]
784
- # Input: data shape=(3, 27874)
785
- # Internal processing:
786
- # 1. Flat to Module: (3, 27874) -> (3, num_modules, module_avg_size)
787
- # 2. Module Embedding: -> (3, num_modules, 1024)
788
- # 3. Random Masking: mask out 75% of modules
789
- # 4. BioMod encoder: Process visible modules
790
- # 5. Reconstruction: Predict the masked module
791
- # Output: loss=MSE on masked module, preds=reconstructed value, mask=mask mark
792
- loss, preds, mask = model(data)
793
-
794
- # Check if loss is NaN or Inf
795
- invalid_loss_local = bool(torch.isnan(loss).item() or torch.isinf(loss).item())
796
- if any_rank_true(invalid_loss_local):
797
- if is_master:
798
- print(f"Warning: Loss is NaN or Inf at step {index}, skipping...")
799
- continue
800
-
801
- # [Backpropagation] Cumulative gradient (no parameter update)
802
- # loss / GRADIENT_ACCUMULATION: Gradient will automatically accumulate and eventually average
803
- scaler.scale(loss / GRADIENT_ACCUMULATION).backward()# cumulative gradient
804
- else:
805
- # Step 4.3.2: Parameter update step (performed every GRADIENT_ACCUMULATION step)
806
- # This step will perform gradient synchronization and parameter update.
807
  with autocast(device_type='cuda', dtype=ptdtype):
808
  loss, preds, mask = model(data)
809
-
810
- # Check if loss is NaN or Inf
811
- invalid_loss_local = bool(torch.isnan(loss).item() or torch.isinf(loss).item())
812
  if any_rank_true(invalid_loss_local):
813
- if is_master:
814
- print(f"Warning: Loss is NaN or Inf at step {index}, skipping...")
 
 
815
  optimizer.zero_grad()
 
 
 
 
 
816
  continue
 
817
 
818
- # [Backpropagation] Accumulated gradient (the last accumulation, parameters will be updated soon)
819
- scaler.scale(loss / GRADIENT_ACCUMULATION).backward()
820
-
821
- # Step 4.3.3: Gradient clipping and parameter update
822
- scaler.unscale_(optimizer) # Descale gradients so that they crop correctly
823
-
824
- # Check if the gradient contains NaN or Inf and do gradient clipping
825
  grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), int(1e2))
826
  grad_norm_value = float(grad_norm.item())
827
  invalid_grad_local = (
@@ -836,18 +896,17 @@ def any_rank_true(local_flag: bool) -> bool:
836
  optimizer.zero_grad()
837
  continue
838
 
839
- # Update parameters
840
- scaler.step(optimizer)# Update model parameters
841
  scaler.update()
842
- optimizer.zero_grad() # Clear the gradient and prepare for the next round of accumulation
843
  if args.lr_step_within_epoch:
844
  scheduler.step()
845
 
846
- if is_master:
847
  current_time = time.time()
848
  if current_time - last_save_time >= save_time_interval:
849
  time_stamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
850
- save_scaler_ckpt(time_stamp, model, optimizer, scheduler, scaler, loss.item(), model_name, ckpt_dir) # 1/3 every save_time_interval hour (default 24 hours) timestamp {YYYY-MM-DD_HH-MM-SS} during training
851
  last_save_time = current_time
852
 
853
  if index % LOG_EVERY == 0:
@@ -868,86 +927,29 @@ def any_rank_true(local_flag: bool) -> bool:
868
  save_scaler_ckpt(i, model, optimizer, scheduler, scaler, loss.item(), model_name, latest_ckpt_dir)
869
 
870
  running_loss += loss.item()
 
871
 
872
  # ========== [Step 5: Verification during training] ==========
873
  # Verify every VALIDATE_STEP step (default 1000 steps)
874
  if index % VALIDATE_STEP == 0:
875
  model.eval() # Switch to evaluation mode
876
  dist.barrier()
877
- running_loss_val = 0.0 # Cumulative validation set loss
878
- val_index_num = 0 # Verify batch count
879
- try:
880
- with torch.no_grad(): # Do not calculate gradients
881
- for val_index, data in enumerate(val_loader):
882
- data = data.to(device, non_blocking=pin_memory)
883
-
884
- # Check input data
885
- if torch.isnan(data).any() or torch.isinf(data).any():
886
- continue
887
-
888
- try:
889
- # Step 5.1: Validation set forward propagation
890
- # Input shape: (valid_batch_size, num_genes) = (5, 27874)
891
- with autocast(device_type='cuda', dtype=ptdtype):
892
- loss_val, preds, mask = model(data)
893
-
894
- # Check loss
895
- if torch.isnan(loss_val) or torch.isinf(loss_val):
896
- continue
897
-
898
- running_loss_val += loss_val.item()
899
- val_index_num += 1
900
-
901
- # Step 5.2: Print verification progress regularly
902
- if val_index % LOG_EVERY == 0:
903
- if is_master:
904
- current_time = time.time()
905
- if current_time - last_save_time >= save_time_interval:
906
- time_stamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
907
- save_scaler_ckpt(time_stamp, model, optimizer, scheduler, scaler, loss.item(), model_name, ckpt_dir) # 2/3 timestamp {YYYY-MM-DD_HH-MM-SS} every save_time_interval hours (default 24 hours) during the verification process
908
- last_save_time = current_time
909
- except Exception as e:
910
- if is_master:
911
- print(f"Warning: Error in validation step {val_index}: {e}")
912
- continue
913
- finally:
914
- # Make sure to clear memory
915
- del data
916
- if 'preds' in locals():
917
- del preds
918
- if 'mask' in locals():
919
- del mask
920
-
921
- # Make sure all processes have completed the verification loop
922
- dist.barrier()
923
-
924
- # Step 5.3: Calculate average verification loss
925
- # Each process calculates its own average loss and then synchronizes it through all_reduce
926
- if val_index_num > 0:
927
- loss_val_all = running_loss_val / val_index_num
928
- else:
929
- loss_val_all = 0.0
930
-
931
- # Step 5.4: DDP multi-process synchronization
932
- # Sum the val_index_num of all processes to ensure consistency
933
- val_index_num_tensor = torch.tensor(val_index_num, dtype=torch.float32, device=device)
934
- dist.all_reduce(val_index_num_tensor, op=dist.ReduceOp.SUM)
935
- total_val_index_num = int(val_index_num_tensor.item())
936
-
937
- # If all processes have no valid data, skip reduce
938
- if total_val_index_num > 0:
939
- # get_reduced: Collect the losses of all processes and calculate the average
940
- loss_val_reduced = get_reduced(loss_val_all, local_rank, 0, world_size)
941
- else:
942
- loss_val_reduced = 0.0
943
-
944
- except Exception as e:
945
- if is_master:
946
- print(f"Error in validation: {e}")
947
- loss_val_reduced = 0.0
948
- finally:
949
- # Make sure all processes are in sync
950
- dist.barrier()
951
 
952
  # Step 5.5: Print validation results
953
  if is_master:
@@ -959,7 +961,10 @@ def any_rank_true(local_flag: bool) -> bool:
959
 
960
  # ========== [Step 6: Epoch end processing] ==========
961
  # Step 6.1: Calculate and record epoch training loss
962
- epoch_loss = running_loss / index # The average loss of this process
 
 
 
963
  epoch_loss = get_reduced(epoch_loss, local_rank, 0, world_size) # Multi-process average
964
  if is_master:
965
  print(f' == Epoch: {i} | Training Loss: {epoch_loss:.6f}')
@@ -972,77 +977,24 @@ def any_rank_true(local_flag: bool) -> bool:
972
  if i % VALIDATE_EVERY == 0:
973
  model.eval() # Switch to evaluation mode
974
  dist.barrier()
975
- running_loss = 0.0
976
- val_index_num = 0
977
- try:
978
- with torch.no_grad():
979
- # Step 7.1: Traverse the validation set
980
- for index, data in enumerate(val_loader):
981
- index += 1
982
- data = data.to(device, non_blocking=pin_memory)
983
-
984
- # Check input data
985
- if torch.isnan(data).any() or torch.isinf(data).any():
986
- continue
987
-
988
- try:
989
- # Step 7.2: Verify forward propagation
990
- with autocast(device_type='cuda', dtype=ptdtype):
991
- loss, preds, mask = model(data)
992
-
993
- # Check loss
994
- if torch.isnan(loss) or torch.isinf(loss):
995
- continue
996
-
997
- running_loss += loss.item()
998
- val_index_num += 1
999
-
1000
- if index % LOG_EVERY == 0:
1001
- if is_master:
1002
- print(f"- epoch {i} step {index} val_loss {loss.item()} -")
1003
- if is_master:
1004
- current_time = time.time()
1005
- if current_time - last_save_time >= save_time_interval:
1006
- time_stamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
1007
- save_scaler_ckpt(time_stamp, model, optimizer, scheduler, scaler, loss.item(), model_name, ckpt_dir)# Every save_time_interval hour (default 24 hours) during the 3/3 epoch verification process timestamp {YYYY-MM-DD_HH-MM-SS}
1008
- last_save_time = current_time
1009
- except Exception as e:
1010
- if is_master:
1011
- print(f"Warning: Error in validation step {index}: {e}")
1012
- continue
1013
- finally:
1014
- # Clear memory and release verification batch data
1015
- del data
1016
- if 'preds' in locals():
1017
- del preds
1018
- if 'mask' in locals():
1019
- del mask
1020
-
1021
- # Step 7.3: Ensure all processes complete verification
1022
- dist.barrier()
1023
-
1024
- # Step 7.4: Calculate average verification loss
1025
- if val_index_num > 0:
1026
- val_loss = running_loss / val_index_num
1027
- else:
1028
- val_loss = 0.0
1029
 
1030
- # Step 7.5: DDP multi-process synchronization
1031
- val_index_num_tensor = torch.tensor(val_index_num, dtype=torch.float32, device=device)
1032
- dist.all_reduce(val_index_num_tensor, op=dist.ReduceOp.SUM)
1033
- total_val_index_num = int(val_index_num_tensor.item())
 
 
 
 
 
1034
 
1035
- if total_val_index_num > 0:
1036
- val_loss = get_reduced(val_loss, local_rank, 0, world_size)
1037
- else:
1038
- val_loss = 0.0
1039
-
1040
- except Exception as e:
1041
- if is_master:
1042
- print(f"Error in epoch validation: {e}")
1043
- val_loss = 0.0
1044
- finally:
1045
- dist.barrier()
1046
 
1047
  # Step 7.6: Print epoch validation results
1048
  if is_master:
 
57
  import torch.distributed as dist
58
  import sys
59
  from pathlib import Path
60
+ from contextlib import nullcontext
61
 
62
  SOURCE_DIR = Path(__file__).resolve().parent
63
  PROJECT_ROOT = SOURCE_DIR.parent
 
262
  args.no_gene2vec = not bool(config["use_gene2vec"])
263
  return args
264
 
265
+ def evaluate_pretraining(
266
+ model, data_loader, device, amp_dtype, pin_memory=False, on_batch=None,
267
+ ):
268
+ """Evaluate without DDP forward collectives and aggregate valid batches.
269
+
270
+ Each rank may skip non-finite batches independently because inference uses
271
+ the underlying module. Unexpected validation errors are reported collectively;
272
+ an empty validation result is an error rather than a zero loss.
273
+ """
274
+ evaluation_model = model
275
+ buffer_sync_model = None
276
+ while True:
277
+ if hasattr(evaluation_model, "_orig_mod"):
278
+ evaluation_model = evaluation_model._orig_mod
279
+ elif isinstance(evaluation_model, DDP):
280
+ buffer_sync_model = evaluation_model
281
+ evaluation_model = evaluation_model.module
282
+ elif isinstance(evaluation_model, nn.DataParallel):
283
+ evaluation_model = evaluation_model.module
284
+ else:
285
+ break
286
+ evaluation_model.eval()
287
+ if buffer_sync_model is not None and buffer_sync_model.broadcast_buffers:
288
+ # Preserve the first DDP evaluation forward's buffer synchronization.
289
+ # Reuse PyTorch's coalesced broadcast once, before rank-local skips.
290
+ buffer_sync_model._sync_buffers()
291
+ loss_sum = 0.0
292
+ valid_batches = 0
293
+ skipped_batches = 0
294
+ first_error = None
295
+ batch_index = -1
296
+ try:
297
+ with torch.no_grad():
298
+ for batch_index, data in enumerate(data_loader):
299
+ data = data.to(device, non_blocking=pin_memory)
300
+ if not bool(torch.isfinite(data).all().item()):
301
+ skipped_batches += 1
302
+ continue
303
+ with autocast(
304
+ device_type=torch.device(device).type, dtype=amp_dtype,
305
+ enabled=(amp_dtype != torch.float32),
306
+ ):
307
+ loss, _, _ = evaluation_model(data)
308
+ if not bool(torch.isfinite(loss).item()):
309
+ skipped_batches += 1
310
+ continue
311
+ batch_loss = float(loss.item())
312
+ loss_sum += batch_loss
313
+ valid_batches += 1
314
+ if on_batch is not None:
315
+ on_batch(batch_index, batch_loss)
316
+ except Exception as exc:
317
+ first_error = f"at or after batch {batch_index}: {type(exc).__name__}: {exc}"
318
+
319
+ statistics = torch.tensor(
320
+ [loss_sum, valid_batches, skipped_batches, int(first_error is not None)],
321
+ dtype=torch.float64, device=device,
322
+ )
323
+ if dist.is_available() and dist.is_initialized():
324
+ dist.all_reduce(statistics, op=dist.ReduceOp.SUM)
325
+ total_loss, total_valid, total_skipped, failed_ranks = statistics.tolist()
326
+ if failed_ranks:
327
+ detail = first_error or "a validation error occurred on another rank"
328
+ raise RuntimeError(f"Validation failed on {int(failed_ranks)} rank(s); {detail}")
329
+ if total_valid == 0:
330
+ raise RuntimeError(
331
+ f"Validation produced no finite batches ({int(total_skipped)} skipped); "
332
+ "no validation loss is available."
333
+ )
334
+ return total_loss / total_valid, int(total_valid), int(total_skipped)
335
+
336
+
337
  if __name__ == "__main__":
338
  args = parser.parse_args()
339
+ if args.grad_acc <= 0:
340
+ raise ValueError("grad_acc must be a positive integer")
341
  resume_model_config = None
342
  resolved_model_config_path = None
343
  if args.model_path != 'None':
 
761
  loader_kwargs["prefetch_factor"] = args.prefetch_factor
762
  train_loader = DataLoader(train_dataset, batch_size=BATCH_SIZE, sampler=train_sampler, **loader_kwargs)
763
  val_loader = DataLoader(val_dataset, batch_size=VALID_BATCH_SIZE, sampler=val_sampler, **loader_kwargs)
764
+ updates_per_epoch = max(1, math.ceil(len(train_loader) / GRADIENT_ACCUMULATION))
765
 
766
  # Step 3.6: Wrap the model using DDP (distributed data parallelism)
767
  # DDP will copy the model to each GPU and synchronize the gradients on each backward
 
832
  train_loader.sampler.set_epoch(i) # DDP requires setting a different random seed for each epoch
833
  model.train() # Set to training mode
834
  dist.barrier() # Synchronize all processes
835
+ running_loss = 0.0 # Accumulate finite training microbatch losses.
836
+ valid_train_steps = 0
837
+ optimizer.zero_grad()
838
+ discard_window = False
839
 
840
  # Step 4.2: Start batch loop (gradient accumulation)
841
  # Each iteration processes a batch, shape=(batch_size, num_genes)=(3, 27874)
842
  for index, data in enumerate(train_loader):
843
  index += 1
844
+ if (index - 1) % GRADIENT_ACCUMULATION == 0:
845
+ window_size = min(GRADIENT_ACCUMULATION, len(train_loader) - index + 1)
846
+ discard_window = False
847
+ if discard_window:
848
+ continue
849
+ update_boundary = (
850
+ index % GRADIENT_ACCUMULATION == 0 or index == len(train_loader)
851
+ )
852
+ data = data.to(device, non_blocking=pin_memory)
853
+ invalid_input_local = not bool(torch.isfinite(data).all().item())
854
  if any_rank_true(invalid_input_local):
855
  if is_master:
856
+ print(f"Warning: Non-finite input at step {index}; discarding its accumulation window.")
857
+ optimizer.zero_grad()
858
+ discard_window = True
859
  continue
860
 
861
+ # The final partial window uses its actual size. All other windows
862
+ # retain the original loss/grad_acc normalization and DDP reduction.
863
+ synchronization = nullcontext() if update_boundary else model.no_sync()
864
+ with synchronization:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
865
  with autocast(device_type='cuda', dtype=ptdtype):
866
  loss, preds, mask = model(data)
867
+ scaled_loss = scaler.scale(loss / window_size)
868
+ invalid_loss_local = not bool(torch.isfinite(loss).item())
 
869
  if any_rank_true(invalid_loss_local):
870
+ if update_boundary:
871
+ # A synchronized DDP forward must finish its reducer
872
+ # cycle even when this window will not update weights.
873
+ scaled_loss.backward()
874
  optimizer.zero_grad()
875
+ if scaler.is_enabled():
876
+ scaler.update(new_scale=scaler.get_scale() * scaler.get_backoff_factor())
877
+ discard_window = True
878
+ if is_master:
879
+ print(f"Warning: Non-finite loss at step {index}; discarding its accumulation window.")
880
  continue
881
+ scaled_loss.backward()
882
 
883
+ if update_boundary:
884
+ scaler.unscale_(optimizer)
 
 
 
 
 
885
  grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), int(1e2))
886
  grad_norm_value = float(grad_norm.item())
887
  invalid_grad_local = (
 
896
  optimizer.zero_grad()
897
  continue
898
 
899
+ scaler.step(optimizer)
 
900
  scaler.update()
901
+ optimizer.zero_grad()
902
  if args.lr_step_within_epoch:
903
  scheduler.step()
904
 
905
+ if is_master:
906
  current_time = time.time()
907
  if current_time - last_save_time >= save_time_interval:
908
  time_stamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
909
+ save_scaler_ckpt(time_stamp, model, optimizer, scheduler, scaler, loss.item(), model_name, ckpt_dir)
910
  last_save_time = current_time
911
 
912
  if index % LOG_EVERY == 0:
 
927
  save_scaler_ckpt(i, model, optimizer, scheduler, scaler, loss.item(), model_name, latest_ckpt_dir)
928
 
929
  running_loss += loss.item()
930
+ valid_train_steps += 1
931
 
932
  # ========== [Step 5: Verification during training] ==========
933
  # Verify every VALIDATE_STEP step (default 1000 steps)
934
  if index % VALIDATE_STEP == 0:
935
  model.eval() # Switch to evaluation mode
936
  dist.barrier()
937
+ validation_save_state = {"last_save_time": last_save_time}
938
+
939
+ def on_validation_batch(batch_index, batch_loss):
940
+ if is_master and batch_index % LOG_EVERY == 0:
941
+ current_time = time.time()
942
+ if current_time - validation_save_state["last_save_time"] >= save_time_interval:
943
+ time_stamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
944
+ save_scaler_ckpt(time_stamp, model, optimizer, scheduler, scaler, loss.item(), model_name, ckpt_dir)
945
+ validation_save_state["last_save_time"] = current_time
946
+
947
+ loss_val_reduced, _, skipped_val_batches = evaluate_pretraining(
948
+ model, val_loader, device, ptdtype, pin_memory, on_validation_batch,
949
+ )
950
+ last_save_time = validation_save_state["last_save_time"]
951
+ if is_master and skipped_val_batches:
952
+ print(f"Warning: Skipped {skipped_val_batches} non-finite validation batches across all ranks.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
953
 
954
  # Step 5.5: Print validation results
955
  if is_master:
 
961
 
962
  # ========== [Step 6: Epoch end processing] ==========
963
  # Step 6.1: Calculate and record epoch training loss
964
+ optimizer.zero_grad() # Never carry a discarded window into the next epoch.
965
+ if valid_train_steps == 0:
966
+ raise RuntimeError("The epoch produced no finite training microbatches.")
967
+ epoch_loss = running_loss / valid_train_steps
968
  epoch_loss = get_reduced(epoch_loss, local_rank, 0, world_size) # Multi-process average
969
  if is_master:
970
  print(f' == Epoch: {i} | Training Loss: {epoch_loss:.6f}')
 
977
  if i % VALIDATE_EVERY == 0:
978
  model.eval() # Switch to evaluation mode
979
  dist.barrier()
980
+ validation_save_state = {"last_save_time": last_save_time}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
981
 
982
+ def on_epoch_validation_batch(batch_index, batch_loss):
983
+ validation_step = batch_index + 1
984
+ if is_master and validation_step % LOG_EVERY == 0:
985
+ print(f"- epoch {i} step {validation_step} val_loss {batch_loss} -")
986
+ current_time = time.time()
987
+ if current_time - validation_save_state["last_save_time"] >= save_time_interval:
988
+ time_stamp = datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
989
+ save_scaler_ckpt(time_stamp, model, optimizer, scheduler, scaler, batch_loss, model_name, ckpt_dir)
990
+ validation_save_state["last_save_time"] = current_time
991
 
992
+ val_loss, _, skipped_val_batches = evaluate_pretraining(
993
+ model, val_loader, device, ptdtype, pin_memory, on_epoch_validation_batch,
994
+ )
995
+ last_save_time = validation_save_state["last_save_time"]
996
+ if is_master and skipped_val_batches:
997
+ print(f"Warning: Skipped {skipped_val_batches} non-finite validation batches across all ranks.")
 
 
 
 
 
998
 
999
  # Step 7.6: Print epoch validation results
1000
  if is_master:
BioMod/cell_annotation/train.py CHANGED
@@ -980,6 +980,8 @@ def train_one_epoch(
980
  use_ddp: bool,
981
  selector_sparsity_weight: float = 0.0,
982
  ) -> dict:
 
 
983
  model.train()
984
  running_loss = 0.0
985
  sample_count = 0
@@ -989,13 +991,18 @@ def train_one_epoch(
989
 
990
  num_steps = len(data_loader)
991
  for step, (x, y) in enumerate(data_loader, start=1):
 
 
 
 
 
992
  x = x.to(device)
993
  y = y.to(device)
994
  x_model = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
995
 
996
  sync_context = (
997
  model.no_sync()
998
- if use_ddp and grad_accum_steps > 1 and step % grad_accum_steps != 0
999
  else contextlib.nullcontext()
1000
  )
1001
  with sync_context:
@@ -1004,9 +1011,9 @@ def train_one_epoch(
1004
  if selector_sparsity_weight > 0:
1005
  model_raw = model.module if use_ddp else model
1006
  loss = loss + selector_sparsity_weight * model_raw.get_selector_sparsity_loss()
1007
- (loss / grad_accum_steps).backward()
1008
 
1009
- if step % grad_accum_steps == 0 or step == num_steps:
1010
  if grad_clip > 0:
1011
  torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
1012
  optimizer.step()
 
980
  use_ddp: bool,
981
  selector_sparsity_weight: float = 0.0,
982
  ) -> dict:
983
+ if grad_accum_steps < 1:
984
+ raise ValueError("grad_accum_steps must be positive")
985
  model.train()
986
  running_loss = 0.0
987
  sample_count = 0
 
991
 
992
  num_steps = len(data_loader)
993
  for step, (x, y) in enumerate(data_loader, start=1):
994
+ # The final partial window must use its actual microbatch count and
995
+ # synchronize gradients before every optimizer step, including in DDP.
996
+ window_start = ((step - 1) // grad_accum_steps) * grad_accum_steps
997
+ window_steps = min(grad_accum_steps, num_steps - window_start)
998
+ should_step = step % grad_accum_steps == 0 or step == num_steps
999
  x = x.to(device)
1000
  y = y.to(device)
1001
  x_model = preprocessor.reindex_tensor_to_model_space(x, reindex_state=reindex_state)
1002
 
1003
  sync_context = (
1004
  model.no_sync()
1005
+ if use_ddp and not should_step
1006
  else contextlib.nullcontext()
1007
  )
1008
  with sync_context:
 
1011
  if selector_sparsity_weight > 0:
1012
  model_raw = model.module if use_ddp else model
1013
  loss = loss + selector_sparsity_weight * model_raw.get_selector_sparsity_loss()
1014
+ (loss / window_steps).backward()
1015
 
1016
+ if should_step:
1017
  if grad_clip > 0:
1018
  torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip)
1019
  optimizer.step()
BioMod/command.txt CHANGED
@@ -22,7 +22,7 @@ torchrun --standalone --nproc_per_node="${NPROC_PER_NODE:-1}" \
22
  --model_name biomod_pretraining \
23
  --fusion_method multiply \
24
  --loss_mode gene_mean \
25
- --decoder_type simple \
26
  --num_workers 12 \
27
  --prefetch_factor 4 \
28
  --dec_embed_dim 1024
 
22
  --model_name biomod_pretraining \
23
  --fusion_method multiply \
24
  --loss_mode gene_mean \
25
+ --decoder_type cross_attn \
26
  --num_workers 12 \
27
  --prefetch_factor 4 \
28
  --dec_embed_dim 1024
BioMod/environment.yml CHANGED
@@ -1,67 +1,11 @@
1
  name: biomod
2
  channels:
3
- - pytorch
4
- - nvidia
5
- - pyg
6
  - conda-forge
7
- - defaults
8
  dependencies:
9
  - python=3.9
10
- - pip>=24.0
11
-
12
- # GPU stack matching the current server environment.
13
- # For a different CUDA driver/toolkit, adjust pytorch-cuda and the PyG notes
14
- # in requirements.txt accordingly.
15
- - pytorch=2.6.*
16
- - torchvision=0.21.*
17
- - torchaudio=2.6.*
18
- - pytorch-cuda=12.4
19
- - pyg=2.6.*
20
-
21
- # Core scientific stack
22
- - numpy=1.26.*
23
- - scipy=1.13.*
24
- - pandas>=2.2,<2.4
25
- - scikit-learn=1.6.*
26
- - h5py>=3.10
27
- - anndata=0.10.*
28
- - scanpy=1.10.*
29
- - matplotlib=3.9.*
30
- - seaborn=0.13.*
31
- - tqdm>=4.66
32
- - requests>=2.31
33
- - pyyaml>=6.0
34
- - networkx>=3.2
35
- - statsmodels>=0.14
36
- - umap-learn>=0.5
37
- - numba
38
- - openpyxl
39
- - jupyterlab
40
- - ipykernel
41
-
42
- # Single-cell and graph-analysis helpers
43
- - python-igraph>=0.11
44
- - leidenalg>=0.10
45
- - rdkit
46
-
47
  - pip:
48
- # BioMod model utilities
49
- - einops==0.8.1
50
- - local-attention==1.11.2
51
- - hickle>=5.0.0
52
- - dcor==0.6
53
-
54
- # Optional task modules used by downstream scripts.
55
- - scib>=1.1.5
56
- - decoupler>=1.8.0
57
- - gseapy>=1.1.0
58
- - mygene>=3.2.2
59
- - lifelines>=0.27.0
60
-
61
- # Text-alignment and LLM-assisted annotation utilities.
62
- - sentence-transformers>=2.7.0
63
- - transformers>=4.40.0
64
- - openai>=1.40.0
65
-
66
- # Only needed if running legacy pickles on Python < 3.8.
67
- - pickle5>=0.0.12; python_version < "3.8"
 
1
  name: biomod
2
  channels:
 
 
 
3
  - conda-forge
 
4
  dependencies:
5
  - python=3.9
6
+ - pip>=24.0,<26
7
+ # Install the pinned CUDA 12.4 / PyTorch 2.6 / PyG stack from wheels.
8
+ # Conda's PyG 2.6 builds require older PyTorch releases.
9
+ # Conda resolves this path relative to this environment file.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  - pip:
11
+ - -r requirements.txt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
BioMod/gene_perturbation/components/inference.py CHANGED
@@ -98,6 +98,110 @@ def evaluate(loader, model, uncertainty, device, real_num_genes = None):
98
 
99
  return results
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  #This evaluation is just as test. IT HAS INFORMATION LEAKAGE!
102
  def evaluate_with_nonzero_mask(loader, model, uncertainty, device):
103
  """
 
98
 
99
  return results
100
 
101
+
102
+ def evaluate_perturbation_sums(loader, model, uncertainty, device, num_valid_samples):
103
+ """Accumulate real validation cells by perturbation without retaining cell matrices.
104
+
105
+ The sequential distributed sampler pads only after this rank's real cells.
106
+ Every batch still runs forward so all DDP ranks execute the same number of
107
+ forwards; padding contributes neither values nor counts to the statistics.
108
+ """
109
+ if num_valid_samples < 0:
110
+ raise ValueError("num_valid_samples must be non-negative")
111
+ model.eval()
112
+ model.to(device)
113
+ if isinstance(model, torch.nn.parallel.DistributedDataParallel):
114
+ # Match the rank-zero checkpoint while keeping training buffer sync off.
115
+ # All ranks enter this single coalesced broadcast before validation.
116
+ model._sync_buffers()
117
+ statistics = {}
118
+ accepted = 0
119
+ with torch.no_grad():
120
+ for batch in loader:
121
+ batch.to(device)
122
+ output = model(batch)
123
+ prediction = output[0] if uncertainty else output
124
+ keep = min(len(batch.pert), num_valid_samples - accepted)
125
+ if keep == 0:
126
+ continue
127
+ truth = batch.y
128
+ # Match evaluate(): each cell uses its own ordered DE-gene indices.
129
+ values = {
130
+ 'pred': prediction[:keep].detach().cpu().numpy(),
131
+ 'truth': truth[:keep].detach().cpu().numpy(),
132
+ 'pred_de': torch.stack([
133
+ prediction[i, batch.de_idx[i]] for i in range(keep)
134
+ ]).detach().cpu().numpy(),
135
+ 'truth_de': torch.stack([
136
+ truth[i, batch.de_idx[i]] for i in range(keep)
137
+ ]).detach().cpu().numpy(),
138
+ }
139
+ conditions = np.asarray(batch.pert[:keep])
140
+ for condition in np.unique(conditions):
141
+ selected = conditions == condition
142
+ sums = {
143
+ key: value[selected].sum(axis=0, dtype=np.float64)
144
+ for key, value in values.items()
145
+ }
146
+ count = int(selected.sum())
147
+ if condition not in statistics:
148
+ statistics[condition] = {'count': count, **sums}
149
+ else:
150
+ statistics[condition]['count'] += count
151
+ for key, value in sums.items():
152
+ statistics[condition][key] += value
153
+ accepted += keep
154
+ if accepted != num_valid_samples:
155
+ raise ValueError(f"Expected {num_valid_samples} real validation cells, got {accepted}")
156
+ return statistics
157
+
158
+
159
+ def merge_perturbation_sums(rank_statistics):
160
+ """Restore global perturbation means, the sufficient input to compute_metrics."""
161
+ merged = {}
162
+ fields = ('pred', 'truth', 'pred_de', 'truth_de')
163
+ for statistics in rank_statistics:
164
+ for condition, values in statistics.items():
165
+ if values['count'] <= 0:
166
+ raise ValueError("Perturbation sample counts must be positive")
167
+ if condition not in merged:
168
+ merged[condition] = {
169
+ 'count': values['count'],
170
+ **{key: np.array(values[key], dtype=np.float64, copy=True) for key in fields},
171
+ }
172
+ else:
173
+ merged[condition]['count'] += values['count']
174
+ for key in fields:
175
+ if merged[condition][key].shape != values[key].shape:
176
+ raise ValueError(f"Inconsistent {key} shape for perturbation {condition}")
177
+ merged[condition][key] += values[key]
178
+ if not merged:
179
+ raise ValueError("Validation requires at least one real cell")
180
+ conditions = sorted(merged)
181
+ return {
182
+ 'pert_cat': np.asarray(conditions),
183
+ **{key: np.stack([merged[condition][key] / merged[condition]['count']
184
+ for condition in conditions]) for key in fields},
185
+ }
186
+
187
+
188
+ def gather_perturbation_metrics(local_statistics):
189
+ """Gather condition-level sums once; rank zero computes the unchanged metrics.
190
+
191
+ Communication scales with the number of conditions, not the number of cells.
192
+ Non-master ranks return None after participating in the same collective.
193
+ """
194
+ distributed = torch.distributed.is_available() and torch.distributed.is_initialized()
195
+ if distributed and torch.distributed.get_world_size() > 1:
196
+ rank = torch.distributed.get_rank()
197
+ gathered = [None] * torch.distributed.get_world_size() if rank == 0 else None
198
+ torch.distributed.gather_object(local_statistics, gathered, dst=0)
199
+ if rank != 0:
200
+ return None
201
+ else:
202
+ gathered = [local_statistics]
203
+ return compute_metrics(merge_perturbation_sums(gathered))[0]
204
+
205
  #This evaluation is just as test. IT HAS INFORMATION LEAKAGE!
206
  def evaluate_with_nonzero_mask(loader, model, uncertainty, device):
207
  """
BioMod/gene_perturbation/components/model.py CHANGED
@@ -53,6 +53,7 @@
53
  BioModEmbedding,
54
  BioMod,
55
  sanitize_biomod_config,
 
56
  )
57
  except ModuleNotFoundError:
58
  project_root = str(Path(__file__).resolve().parents[3])
@@ -62,6 +63,7 @@
62
  BioModEmbedding,
63
  BioMod,
64
  sanitize_biomod_config,
 
65
  )
66
 
67
  # ============================================================
@@ -256,7 +258,10 @@ def __init__(self, input_genes, scfm_genes,
256
  # ============================================================
257
  if scfm_ckpt != 'None':
258
  ckpt = torch.load(scfm_ckpt, map_location=device)
259
- model_state_dict = ckpt.get('model_state_dict', ckpt)
 
 
 
260
  current_state_dict = self.scfm.state_dict()
261
  filtered_state_dict = {}
262
  for k, v in model_state_dict.items():
 
53
  BioModEmbedding,
54
  BioMod,
55
  sanitize_biomod_config,
56
+ migrate_legacy_biomod_state_dict,
57
  )
58
  except ModuleNotFoundError:
59
  project_root = str(Path(__file__).resolve().parents[3])
 
63
  BioModEmbedding,
64
  BioMod,
65
  sanitize_biomod_config,
66
+ migrate_legacy_biomod_state_dict,
67
  )
68
 
69
  # ============================================================
 
258
  # ============================================================
259
  if scfm_ckpt != 'None':
260
  ckpt = torch.load(scfm_ckpt, map_location=device)
261
+ # Normalize historical keys before checking membership or shapes.
262
+ model_state_dict = migrate_legacy_biomod_state_dict(
263
+ ckpt.get('model_state_dict', ckpt)
264
+ )
265
  current_state_dict = self.scfm.state_dict()
266
  filtered_state_dict = {}
267
  for k, v in model_state_dict.items():
BioMod/gene_perturbation/components/trainer.py CHANGED
@@ -26,6 +26,7 @@
26
  """
27
 
28
  from copy import deepcopy
 
29
  import argparse
30
  from time import time
31
  import sys, os
@@ -48,7 +49,8 @@
48
  # ============================================================
49
  from .model import GEARS_Model_BioMod
50
  from .inference import evaluate, compute_metrics, deeper_analysis, \
51
- non_dropout_analysis, compute_synergy_loss
 
52
  from .utils import loss_fct, uncertainty_loss_fct, parse_any_pert, \
53
  get_similarity_network, print_sys, GeneSimNetwork, \
54
  create_cell_graph_dataset_for_prediction, get_mean_control, \
@@ -556,9 +558,14 @@ def train(self,
556
  ):
557
  # Simulate larger batches through gradient accumulation. This value affects optimization stability and equivalent learning rate.
558
  GRADIENT_ACCUMULATION = self.config['accumulation_steps']
 
 
559
  test = (epochs == 1)
560
  train_loader = self.dataloader['train_loader']
561
  val_loader = self.dataloader['val_loader']
 
 
 
562
 
563
  self.model = self.model
564
 
@@ -591,34 +598,31 @@ def train(self,
591
  for epoch in range(epochs):
592
  train_loader.sampler.set_epoch(epoch)
593
  self.model.train()
 
594
  dist.barrier()
595
  for step, batch in enumerate(train_loader):
596
- if test and (step+1 > 2 * GRADIENT_ACCUMULATION):
597
  break
598
  batch.to(self.device)
599
  y = batch.y
600
- if (step+1) % GRADIENT_ACCUMULATION != 0:
601
- # no_sync: Avoid doing all-reduce for each micro-batch to save communication.
602
- with self.model.no_sync():
603
- pred = self.model(batch)
604
- loss = loss_fct(pred, y, batch.pert,
605
- ctrl = self.ctrl_expression,
606
- dict_filter = self.dict_filter,
607
- direction_lambda = self.config['direction_lambda'])
608
-
609
- loss.backward()
610
- elif (step+1) % GRADIENT_ACCUMULATION == 0:
611
  pred = self.model(batch)
612
  loss = loss_fct(pred, y, batch.pert,
613
  ctrl = self.ctrl_expression,
614
  dict_filter = self.dict_filter,
615
  direction_lambda = self.config['direction_lambda'])
616
-
617
- loss.backward()
618
  # Gradient clipping affects training stability; if it is too small, it will inhibit learning, and if it is too large, the gradient may explode.
619
  nn.utils.clip_grad_value_(self.model.parameters(), clip_value=1.0)
620
  optimizer.step()
621
- optimizer.zero_grad()
622
 
623
  if (step + 1) % 50 == 0:
624
  if self.is_master:
@@ -627,15 +631,13 @@ def train(self,
627
  dist.barrier()
628
  scheduler.step()
629
  if (epoch + 1) % valid_every == 0:
630
- # Evaluate model performance on train and val set
631
- with torch.no_grad():
632
- val_res = evaluate(val_loader, self.model, self.config['uncertainty'], self.device)
633
- val_metrics, _ = compute_metrics(val_res)
634
- del val_res
635
- val_metrics_reduced = {}
636
- for m in ['mse', 'mse_de', 'pearson', 'pearson_de']:
637
- val_metrics_reduced[m] = get_reduced(val_metrics[m], self.local_rank, 0, self.world_size)
638
- val_metrics = val_metrics_reduced
639
  # Print epoch performance
640
  log = "Epoch {}: Validation Overall MSE: {:.4f}. Validation Top 20 DE MSE: {:.4f}."
641
 
 
26
  """
27
 
28
  from copy import deepcopy
29
+ from contextlib import nullcontext
30
  import argparse
31
  from time import time
32
  import sys, os
 
49
  # ============================================================
50
  from .model import GEARS_Model_BioMod
51
  from .inference import evaluate, compute_metrics, deeper_analysis, \
52
+ non_dropout_analysis, compute_synergy_loss, \
53
+ evaluate_perturbation_sums, gather_perturbation_metrics
54
  from .utils import loss_fct, uncertainty_loss_fct, parse_any_pert, \
55
  get_similarity_network, print_sys, GeneSimNetwork, \
56
  create_cell_graph_dataset_for_prediction, get_mean_control, \
 
558
  ):
559
  # Simulate larger batches through gradient accumulation. This value affects optimization stability and equivalent learning rate.
560
  GRADIENT_ACCUMULATION = self.config['accumulation_steps']
561
+ if GRADIENT_ACCUMULATION < 1:
562
+ raise ValueError("accumulation_steps must be at least 1")
563
  test = (epochs == 1)
564
  train_loader = self.dataloader['train_loader']
565
  val_loader = self.dataloader['val_loader']
566
+ epoch_batches = min(len(train_loader), 2 * GRADIENT_ACCUMULATION) if test else len(train_loader)
567
+ if epoch_batches == 0:
568
+ raise ValueError("Training requires at least one batch per process")
569
 
570
  self.model = self.model
571
 
 
598
  for epoch in range(epochs):
599
  train_loader.sampler.set_epoch(epoch)
600
  self.model.train()
601
+ optimizer.zero_grad(set_to_none=True)
602
  dist.barrier()
603
  for step, batch in enumerate(train_loader):
604
+ if step >= epoch_batches:
605
  break
606
  batch.to(self.device)
607
  y = batch.y
608
+ window_start = (step // GRADIENT_ACCUMULATION) * GRADIENT_ACCUMULATION
609
+ window_size = min(GRADIENT_ACCUMULATION, epoch_batches - window_start)
610
+ update_now = (step - window_start + 1) == window_size
611
+ # Normalize by the actual window length, including an epoch's tail.
612
+ # Keep the existing microbatch loss definition and clipping rule.
613
+ sync_context = nullcontext() if update_now else self.model.no_sync()
614
+ with sync_context:
 
 
 
 
615
  pred = self.model(batch)
616
  loss = loss_fct(pred, y, batch.pert,
617
  ctrl = self.ctrl_expression,
618
  dict_filter = self.dict_filter,
619
  direction_lambda = self.config['direction_lambda'])
620
+ (loss / window_size).backward()
621
+ if update_now:
622
  # Gradient clipping affects training stability; if it is too small, it will inhibit learning, and if it is too large, the gradient may explode.
623
  nn.utils.clip_grad_value_(self.model.parameters(), clip_value=1.0)
624
  optimizer.step()
625
+ optimizer.zero_grad(set_to_none=True)
626
 
627
  if (step + 1) % 50 == 0:
628
  if self.is_master:
 
631
  dist.barrier()
632
  scheduler.step()
633
  if (epoch + 1) % valid_every == 0:
634
+ # Exclude sampler padding before forming global perturbation means.
635
+ val_statistics = evaluate_perturbation_sums(
636
+ val_loader, self.model, self.config['uncertainty'], self.device,
637
+ val_loader.sampler.num_valid_samples,
638
+ )
639
+ val_metrics = gather_perturbation_metrics(val_statistics)
640
+ del val_statistics
 
 
641
  # Print epoch performance
642
  log = "Epoch {}: Validation Overall MSE: {:.4f}. Validation Top 20 DE MSE: {:.4f}."
643
 
BioMod/gene_perturbation/components/training_utils.py CHANGED
@@ -583,9 +583,15 @@ def __init__(self, dataset, batch_size, world_size, rank=None, num_replicas=None
583
  self.batch_size = batch_size
584
  self.num_samples = int(math.ceil(len(self.dataset) * 1.0 / self.batch_size / self.num_replicas)) * self.batch_size
585
  self.total_size = self.num_samples * self.num_replicas
 
 
 
 
586
 
587
  def __iter__(self):
588
  indices = list(range(len(self.dataset)))
 
 
589
  # Add padding samples to make the data size divisible
590
  indices += [indices[-1]] * (self.total_size - len(indices))
591
  # Subsampling: Each process only takes the part it is responsible for
 
583
  self.batch_size = batch_size
584
  self.num_samples = int(math.ceil(len(self.dataset) * 1.0 / self.batch_size / self.num_replicas)) * self.batch_size
585
  self.total_size = self.num_samples * self.num_replicas
586
+ # All synthetic padding follows the real samples in this sequential layout.
587
+ self.num_valid_samples = max(
588
+ 0, min(self.num_samples, len(self.dataset) - self.rank * self.num_samples)
589
+ )
590
 
591
  def __iter__(self):
592
  indices = list(range(len(self.dataset)))
593
+ if not indices:
594
+ return iter(indices)
595
  # Add padding samples to make the data size divisible
596
  indices += [indices[-1]] * (self.total_size - len(indices))
597
  # Subsampling: Each process only takes the part it is responsible for
BioMod/models_biomod.py CHANGED
@@ -427,9 +427,9 @@ def __init__(
427
  if self.pool_affine_min_batch_size < 1:
428
  raise ValueError("MODULE_POOL_AFFINE_MIN_BATCH_SIZE must be positive")
429
  # Derived model weights only; no cell-dependent features are cached.
430
- # These buffers add no parameters or checkpoint entries.
431
- self.register_buffer("_pool_affine_score_weight", torch.empty(0), persistent=False)
432
- self.register_buffer("_pool_affine_score_bias", torch.empty(0), persistent=False)
433
  self._pool_affine_cache_signature = None
434
 
435
  @property
@@ -442,12 +442,20 @@ def _pool_affine_signature(self):
442
  self.attn_score.weight, self.attn_score.bias)
443
  return tuple((id(t), t.data_ptr(), t._version, t.device, t.dtype) for t in tensors)
444
 
 
 
 
 
 
 
 
 
445
  @torch.no_grad()
446
  def prepare_pool_affine(self):
447
- """Compose final affine and score AFTER checkpoint/device/dtype placement.
448
 
449
- Call again after changing weights, dtype or device. Forward rejects a
450
- stale cache rather than silently using outdated model-derived weights.
451
  """
452
  weight = self.proj[2].weight
453
  compose_dtype = torch.float64 if weight.dtype == torch.float64 else torch.float32
@@ -474,7 +482,7 @@ def _forward_pool_affine(
474
  BF16 reassociation is validated separately; it is not bitwise equivalent.
475
  """
476
  if self._pool_affine_cache_signature != self._pool_affine_signature():
477
- raise RuntimeError("Pool-affine cache is stale; call prepare_pool_affine() after loading/placing weights")
478
  batch_size = expr.shape[0]
479
  flat_expr = torch.index_select(expr, 1, self._bucketed_gene_idx)
480
  flat_gene_emb = self.gene_embed(self._bucketed_gene_idx)
 
427
  if self.pool_affine_min_batch_size < 1:
428
  raise ValueError("MODULE_POOL_AFFINE_MIN_BATCH_SIZE must be positive")
429
  # Derived model weights only; no cell-dependent features are cached.
430
+ # Keep this rank-local cache out of DDP buffer broadcasts and checkpoints.
431
+ self._pool_affine_score_weight = None
432
+ self._pool_affine_score_bias = None
433
  self._pool_affine_cache_signature = None
434
 
435
  @property
 
442
  self.attn_score.weight, self.attn_score.bias)
443
  return tuple((id(t), t.data_ptr(), t._version, t.device, t.dtype) for t in tensors)
444
 
445
+ def _apply(self, fn, recurse=True):
446
+ result = super()._apply(fn, recurse=recurse)
447
+ # Derived tensors are rebuilt on the destination device and dtype.
448
+ self._pool_affine_score_weight = None
449
+ self._pool_affine_score_bias = None
450
+ self._pool_affine_cache_signature = None
451
+ return result
452
+
453
  @torch.no_grad()
454
  def prepare_pool_affine(self):
455
+ """Compose the final affine and score for the current parameters.
456
 
457
+ Inference prepares this cache lazily and refreshes it after parameter
458
+ updates or checkpoint/device/dtype changes. Call explicitly to warm up.
459
  """
460
  weight = self.proj[2].weight
461
  compose_dtype = torch.float64 if weight.dtype == torch.float64 else torch.float32
 
482
  BF16 reassociation is validated separately; it is not bitwise equivalent.
483
  """
484
  if self._pool_affine_cache_signature != self._pool_affine_signature():
485
+ self.prepare_pool_affine()
486
  batch_size = expr.shape[0]
487
  flat_expr = torch.index_select(expr, 1, self._bucketed_gene_idx)
488
  flat_gene_emb = self.gene_embed(self._bucketed_gene_idx)
BioMod/requirements.txt CHANGED
@@ -2,8 +2,8 @@
2
  #
3
  # Recommended use:
4
  # 1. Prefer `conda env create -f environment.yml` for a clean GPU setup.
5
- # 2. Use this file when PyTorch/CUDA are already installed and you only need
6
- # to fill in Python package dependencies.
7
  #
8
  # CUDA 12.4 / PyTorch 2.6 wheel sources used by the server environment:
9
  --extra-index-url https://download.pytorch.org/whl/cu124
@@ -27,6 +27,11 @@ statsmodels==0.14.5
27
  umap-learn==0.5.9.post2
28
  dcor==0.6
29
 
 
 
 
 
 
30
  # PyTorch and graph learning stack
31
  torch==2.6.0+cu124
32
  torchvision==0.21.0+cu124
 
2
  #
3
  # Recommended use:
4
  # 1. Prefer `conda env create -f environment.yml` for a clean GPU setup.
5
+ # 2. Or install this file into a fresh Python 3.9 environment with pip.
6
+ # This installs the pinned GPU stack as well as task dependencies.
7
  #
8
  # CUDA 12.4 / PyTorch 2.6 wheel sources used by the server environment:
9
  --extra-index-url https://download.pytorch.org/whl/cu124
 
27
  umap-learn==0.5.9.post2
28
  dcor==0.6
29
 
30
+ # Dataset tables and tutorial notebooks (also used by environment.yml).
31
+ openpyxl>=3.1,<4
32
+ jupyterlab>=4,<5
33
+ ipykernel>=6,<7
34
+
35
  # PyTorch and graph learning stack
36
  torch==2.6.0+cu124
37
  torchvision==0.21.0+cu124
README.md CHANGED
@@ -45,7 +45,9 @@ conda activate biomod
45
  ```
46
 
47
  The environment specification uses Python 3.9, PyTorch 2.6, CUDA 12.4, and PyTorch
48
- Geometric 2.6. See [environment.yml](BioMod/environment.yml) and
 
 
49
  [requirements.txt](BioMod/requirements.txt) for the complete dependency lists.
50
  Match the GPU packages to your system when adapting the environment.
51
 
 
45
  ```
46
 
47
  The environment specification uses Python 3.9, PyTorch 2.6, CUDA 12.4, and PyTorch
48
+ Geometric 2.6. Conda creates the Python environment, then installs the pinned
49
+ GPU packages from the PyTorch/PyG wheel indexes through `requirements.txt`.
50
+ See [environment.yml](BioMod/environment.yml) and
51
  [requirements.txt](BioMod/requirements.txt) for the complete dependency lists.
52
  Match the GPU packages to your system when adapting the environment.
53