Fix training accumulation, evaluation and checkpoint loading
Browse filesInitialize 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 +2 -1
- BioMod/biomod.py +152 -200
- BioMod/cell_annotation/train.py +10 -3
- BioMod/command.txt +1 -1
- BioMod/environment.yml +5 -61
- BioMod/gene_perturbation/components/inference.py +104 -0
- BioMod/gene_perturbation/components/model.py +6 -1
- BioMod/gene_perturbation/components/trainer.py +27 -25
- BioMod/gene_perturbation/components/training_utils.py +6 -0
- BioMod/models_biomod.py +15 -7
- BioMod/requirements.txt +7 -2
- README.md +3 -1
BioMod/README.md
CHANGED
|
@@ -93,7 +93,8 @@ conda env create -f environment.yml
|
|
| 93 |
conda activate biomod
|
| 94 |
```
|
| 95 |
|
| 96 |
-
|
|
|
|
| 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) /
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
if any_rank_true(invalid_input_local):
|
| 771 |
if is_master:
|
| 772 |
-
print(f"Warning:
|
|
|
|
|
|
|
| 773 |
continue
|
| 774 |
|
| 775 |
-
#
|
| 776 |
-
#
|
| 777 |
-
|
| 778 |
-
|
| 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 |
-
|
| 811 |
-
invalid_loss_local = bool(torch.isnan(loss).item() or torch.isinf(loss).item())
|
| 812 |
if any_rank_true(invalid_loss_local):
|
| 813 |
-
if
|
| 814 |
-
|
|
|
|
|
|
|
| 815 |
optimizer.zero_grad()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 816 |
continue
|
|
|
|
| 817 |
|
| 818 |
-
|
| 819 |
-
scaler.
|
| 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 |
-
|
| 840 |
-
scaler.step(optimizer)# Update model parameters
|
| 841 |
scaler.update()
|
| 842 |
-
optimizer.zero_grad()
|
| 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)
|
| 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 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1034 |
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 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
|
| 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 /
|
| 1008 |
|
| 1009 |
-
if
|
| 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
|
| 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 |
-
#
|
| 13 |
-
#
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 597 |
break
|
| 598 |
batch.to(self.device)
|
| 599 |
y = batch.y
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 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 |
-
#
|
| 431 |
-
self.
|
| 432 |
-
self.
|
| 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
|
| 448 |
|
| 449 |
-
|
| 450 |
-
|
| 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 |
-
|
| 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.
|
| 6 |
-
#
|
| 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.
|
|
|
|
|
|
|
| 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 |
|