Initial commit: GraPHFormer codebase
Browse files- .gitignore +22 -0
- finetune.py +554 -0
- graphformer/__init__.py +12 -0
- graphformer/augmentations/__init__.py +23 -0
- graphformer/augmentations/persistence_augmentations.py +304 -0
- graphformer/augmentations/tree_augmentations.py +435 -0
- graphformer/losses/__init__.py +9 -0
- graphformer/losses/contrastive.py +442 -0
- graphformer/losses/infonce.py +303 -0
- graphformer/models/__init__.py +15 -0
- graphformer/models/clip_model.py +170 -0
- graphformer/models/finetune_model.py +322 -0
- graphformer/models/fusion.py +339 -0
- graphformer/models/hybrid_resnet_persistencevit.py +414 -0
- graphformer/models/image_encoder.py +702 -0
- graphformer/models/tree_encoder.py +594 -0
- graphformer/utils/__init__.py +7 -0
- graphformer/utils/training.py +102 -0
- scripts/finetune_example.sh +24 -0
- scripts/prepare_data.py +236 -0
- scripts/train_example.sh +27 -0
- setup.py +30 -0
- train.py +786 -0
.gitignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs, results, and data
|
| 2 |
+
work_dir/
|
| 3 |
+
data/
|
| 4 |
+
|
| 5 |
+
# Python
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.py[cod]
|
| 8 |
+
*.egg-info/
|
| 9 |
+
*.egg
|
| 10 |
+
.eggs/
|
| 11 |
+
dist/
|
| 12 |
+
build/
|
| 13 |
+
|
| 14 |
+
# Jupyter
|
| 15 |
+
.ipynb_checkpoints/
|
| 16 |
+
|
| 17 |
+
# Environment
|
| 18 |
+
.env
|
| 19 |
+
*.log
|
| 20 |
+
|
| 21 |
+
# OS
|
| 22 |
+
.DS_Store
|
finetune.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GraPHFormer Fine-tuning Script
|
| 3 |
+
|
| 4 |
+
Fine-tune pretrained CLIP-style models for classification.
|
| 5 |
+
Supports: image_only, tree_only, multimodal modes.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python finetune.py --exp_name my_finetune --pretrained_checkpoint path/to/checkpoint.pth
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import datetime
|
| 13 |
+
import time
|
| 14 |
+
import os
|
| 15 |
+
import json
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
+
import torch.backends.cudnn as cudnn
|
| 21 |
+
from torch.utils.data import DataLoader
|
| 22 |
+
from sklearn.neighbors import KNeighborsClassifier
|
| 23 |
+
|
| 24 |
+
from graphformer.models import CLIPModel, FineTuneModel
|
| 25 |
+
from graphformer.augmentations import (
|
| 26 |
+
Compose,
|
| 27 |
+
RandomScaleCoords, RandomRotate, RandomJitter, RandomShift,
|
| 28 |
+
RandomFlip, RandomMaskFeats, RandomJitterLength, RandomElasticate,
|
| 29 |
+
RandomDropSubTrees, RandomSkipParentNode, RandomSwapSiblingSubTrees,
|
| 30 |
+
CombinedPersistenceAugmentation,
|
| 31 |
+
)
|
| 32 |
+
from graphformer.utils import save_checkpoint, get_root_logger, set_seed
|
| 33 |
+
from graphformer.data import NeuronTreeDataset, get_collate_fn, LABEL_DICT
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def evaluate_accuracy(model, data_loader, device):
|
| 37 |
+
"""Evaluate classification accuracy"""
|
| 38 |
+
model.eval()
|
| 39 |
+
correct = 0
|
| 40 |
+
total = 0
|
| 41 |
+
|
| 42 |
+
with torch.no_grad():
|
| 43 |
+
for batch in data_loader:
|
| 44 |
+
batch = batch.to(device)
|
| 45 |
+
_, logits = model(batch)
|
| 46 |
+
|
| 47 |
+
pred = logits.argmax(dim=1)
|
| 48 |
+
labels = batch.label.cuda() if not batch.label.is_cuda else batch.label
|
| 49 |
+
|
| 50 |
+
correct += (pred == labels).sum().item()
|
| 51 |
+
total += labels.size(0)
|
| 52 |
+
|
| 53 |
+
accuracy = correct / total * 100
|
| 54 |
+
model.train()
|
| 55 |
+
return accuracy
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def extract_features(model, data_loader, device):
|
| 59 |
+
"""Extract features for KNN evaluation"""
|
| 60 |
+
model.eval()
|
| 61 |
+
features_list = []
|
| 62 |
+
labels_list = []
|
| 63 |
+
|
| 64 |
+
with torch.no_grad():
|
| 65 |
+
for batch in data_loader:
|
| 66 |
+
batch = batch.to(device)
|
| 67 |
+
_, _, features = model(batch, return_features=True)
|
| 68 |
+
|
| 69 |
+
features_list.append(features)
|
| 70 |
+
labels = batch.label.cuda() if not batch.label.is_cuda else batch.label
|
| 71 |
+
labels_list.append(labels)
|
| 72 |
+
|
| 73 |
+
features = torch.cat(features_list, dim=0)
|
| 74 |
+
labels = torch.cat(labels_list, dim=0)
|
| 75 |
+
|
| 76 |
+
model.train()
|
| 77 |
+
return features, labels
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def evaluate_knn(model, train_loader, test_loader, device, knn_k=20):
|
| 81 |
+
"""KNN evaluation using sklearn"""
|
| 82 |
+
x_train, y_train = extract_features(model, train_loader, device)
|
| 83 |
+
x_test, y_test = extract_features(model, test_loader, device)
|
| 84 |
+
|
| 85 |
+
neigh = KNeighborsClassifier(n_neighbors=knn_k)
|
| 86 |
+
neigh.fit(x_train.cpu().numpy(), y_train.cpu().numpy())
|
| 87 |
+
|
| 88 |
+
score = neigh.score(x_test.cpu().numpy(), y_test.cpu().numpy())
|
| 89 |
+
|
| 90 |
+
return score * 100
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def mixup_data(features, labels, alpha=1.0):
|
| 94 |
+
"""Apply mixup augmentation"""
|
| 95 |
+
if alpha > 0:
|
| 96 |
+
lam = np.random.beta(alpha, alpha)
|
| 97 |
+
else:
|
| 98 |
+
lam = 1
|
| 99 |
+
|
| 100 |
+
batch_size = features.size(0)
|
| 101 |
+
index = torch.randperm(batch_size).to(features.device)
|
| 102 |
+
|
| 103 |
+
mixed_features = lam * features + (1 - lam) * features[index, :]
|
| 104 |
+
labels_a, labels_b = labels, labels[index]
|
| 105 |
+
|
| 106 |
+
return mixed_features, labels_a, labels_b, lam
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def mixup_criterion(criterion, pred, labels_a, labels_b, lam):
|
| 110 |
+
"""Mixup loss"""
|
| 111 |
+
return lam * criterion(pred, labels_a) + (1 - lam) * criterion(pred, labels_b)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
if __name__ == "__main__":
|
| 115 |
+
parser = argparse.ArgumentParser("GraPHFormer Fine-tuning")
|
| 116 |
+
|
| 117 |
+
# Basic
|
| 118 |
+
parser.add_argument("--work_dir", type=str, default="./work_dir")
|
| 119 |
+
parser.add_argument("--exp_name", type=str, required=True)
|
| 120 |
+
parser.add_argument("--dataset", type=str, default="bil_6_classes")
|
| 121 |
+
parser.add_argument("--data_dir", type=str, default="data/raw/bil")
|
| 122 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 123 |
+
|
| 124 |
+
# Pretrained checkpoint
|
| 125 |
+
parser.add_argument("--pretrained_checkpoint", type=str, default=None)
|
| 126 |
+
|
| 127 |
+
# Fine-tuning mode
|
| 128 |
+
parser.add_argument("--mode", type=str, default="multimodal",
|
| 129 |
+
choices=["image_only", "tree_only", "multimodal"])
|
| 130 |
+
parser.add_argument("--freeze_encoders", action="store_true", default=False)
|
| 131 |
+
parser.add_argument("--freeze_image_only", action="store_true", default=False)
|
| 132 |
+
parser.add_argument("--linear_probe_epochs", type=int, default=0)
|
| 133 |
+
parser.add_argument("--use_projection", action="store_true", default=False)
|
| 134 |
+
parser.add_argument("--fusion_mode", type=str, default="concat",
|
| 135 |
+
choices=["concat", "add", "cross_attention", "bi_attention", "gated", "cmf", "mhcma"])
|
| 136 |
+
|
| 137 |
+
# Tree Model
|
| 138 |
+
parser.add_argument("--tree_model", type=str, default="double",
|
| 139 |
+
choices=["ori", "v2", "double"])
|
| 140 |
+
parser.add_argument("--child_mode", type=str, default="sum")
|
| 141 |
+
parser.add_argument("--input_features", nargs="+", type=int,
|
| 142 |
+
default=[2, 3, 4, 12, 13])
|
| 143 |
+
parser.add_argument("--h_size", type=int, default=256)
|
| 144 |
+
parser.add_argument("--bn", action="store_true", default=False)
|
| 145 |
+
|
| 146 |
+
# Image Model
|
| 147 |
+
parser.add_argument("--image_encoder", type=str, default="resnet18")
|
| 148 |
+
parser.add_argument("--image_size", type=int, default=256)
|
| 149 |
+
parser.add_argument("--freeze_image_backbone", action="store_true", default=False)
|
| 150 |
+
|
| 151 |
+
# CLIP settings
|
| 152 |
+
parser.add_argument("--embed_dim", type=int, default=128)
|
| 153 |
+
parser.add_argument("--temperature", type=float, default=0.07)
|
| 154 |
+
parser.add_argument("--loss_type", type=str, default="clip")
|
| 155 |
+
|
| 156 |
+
# Training
|
| 157 |
+
parser.add_argument("--batch_size", type=int, default=64)
|
| 158 |
+
parser.add_argument("--epochs", default=50, type=int)
|
| 159 |
+
parser.add_argument("--lr", default=1e-4, type=float)
|
| 160 |
+
parser.add_argument("--wd", default=0.01, type=float)
|
| 161 |
+
parser.add_argument("--warmup_epochs", type=int, default=5)
|
| 162 |
+
parser.add_argument("--start_epoch", type=int, default=0)
|
| 163 |
+
parser.add_argument("--save_freq", type=int, default=10)
|
| 164 |
+
parser.add_argument("--val_freq", type=int, default=1)
|
| 165 |
+
parser.add_argument("--gpu", default=0, type=int)
|
| 166 |
+
|
| 167 |
+
# Regularization
|
| 168 |
+
parser.add_argument("--label_smoothing", type=float, default=0.0)
|
| 169 |
+
parser.add_argument("--dropout", type=float, default=0.5)
|
| 170 |
+
parser.add_argument("--mixup_alpha", type=float, default=0.0)
|
| 171 |
+
parser.add_argument("--early_stopping_patience", type=int, default=0)
|
| 172 |
+
|
| 173 |
+
# ArcFace loss
|
| 174 |
+
parser.add_argument("--use_arcface", action="store_true", default=False)
|
| 175 |
+
parser.add_argument("--arcface_s", type=float, default=30.0)
|
| 176 |
+
parser.add_argument("--arcface_m", type=float, default=0.50)
|
| 177 |
+
|
| 178 |
+
# Augmentation
|
| 179 |
+
parser.add_argument("--aug_scale_coords", action="store_true", default=False)
|
| 180 |
+
parser.add_argument("--aug_rotate", action="store_true", default=False)
|
| 181 |
+
parser.add_argument("--aug_jitter_coords", action="store_true", default=False)
|
| 182 |
+
parser.add_argument("--aug_shift_coords", action="store_true", default=False)
|
| 183 |
+
parser.add_argument("--aug_flip", action="store_true", default=False)
|
| 184 |
+
parser.add_argument("--aug_mask_feats", action="store_true", default=False)
|
| 185 |
+
parser.add_argument("--aug_jitter_length", action="store_true", default=False)
|
| 186 |
+
parser.add_argument("--aug_elasticate", action="store_true", default=False)
|
| 187 |
+
parser.add_argument("--aug_drop_tree", action="store_true", default=False)
|
| 188 |
+
parser.add_argument("--aug_skip_parent_node", action="store_true", default=False)
|
| 189 |
+
parser.add_argument("--aug_swap_sibling_subtrees", action="store_true", default=False)
|
| 190 |
+
|
| 191 |
+
# Persistence augmentation
|
| 192 |
+
parser.add_argument("--use_persistence_aug", action="store_true", default=False)
|
| 193 |
+
parser.add_argument("--pers_translation_scale", type=float, default=0.05)
|
| 194 |
+
parser.add_argument("--pers_noise_scale", type=float, default=0.02)
|
| 195 |
+
parser.add_argument("--pers_sigma_min", type=float, default=12.0)
|
| 196 |
+
parser.add_argument("--pers_sigma_max", type=float, default=20.0)
|
| 197 |
+
parser.add_argument("--sigma_px", type=float, default=16.0)
|
| 198 |
+
|
| 199 |
+
# Evaluation
|
| 200 |
+
parser.add_argument("--eval_mode", type=str, default="accuracy",
|
| 201 |
+
choices=["accuracy", "knn"])
|
| 202 |
+
parser.add_argument("--knn_k", type=int, default=20)
|
| 203 |
+
|
| 204 |
+
parser.add_argument("--cache_images", action="store_true", default=True)
|
| 205 |
+
parser.add_argument("--debug", action="store_true", default=False)
|
| 206 |
+
|
| 207 |
+
args = parser.parse_args()
|
| 208 |
+
set_seed(args.seed)
|
| 209 |
+
|
| 210 |
+
if args.linear_probe_epochs > 0:
|
| 211 |
+
args.freeze_encoders = True
|
| 212 |
+
|
| 213 |
+
# Setup work directory
|
| 214 |
+
args.work_dir = f"{args.work_dir}/{args.exp_name}"
|
| 215 |
+
if not os.path.exists(args.work_dir):
|
| 216 |
+
os.makedirs(args.work_dir)
|
| 217 |
+
|
| 218 |
+
# Logger
|
| 219 |
+
timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
|
| 220 |
+
if args.debug:
|
| 221 |
+
log_file = None
|
| 222 |
+
args.save_freq = 10000
|
| 223 |
+
args.val_freq = 1
|
| 224 |
+
else:
|
| 225 |
+
log_file = f"{args.work_dir}/finetune_{timestamp}.log"
|
| 226 |
+
logger = get_root_logger(log_file=log_file, log_level="INFO")
|
| 227 |
+
|
| 228 |
+
logger.info("=" * 60)
|
| 229 |
+
logger.info("GraPHFormer FINE-TUNING")
|
| 230 |
+
logger.info(f"Mode: {args.mode}")
|
| 231 |
+
if args.mode == "multimodal":
|
| 232 |
+
logger.info(f"Fusion Mode: {args.fusion_mode}")
|
| 233 |
+
logger.info(f"Freeze Encoders: {args.freeze_encoders}")
|
| 234 |
+
logger.info(f"Pretrained Checkpoint: {args.pretrained_checkpoint}")
|
| 235 |
+
logger.info(f"Dataset: {args.dataset}")
|
| 236 |
+
logger.info("=" * 60)
|
| 237 |
+
logger.info(json.dumps(vars(args), indent=4, sort_keys=True))
|
| 238 |
+
|
| 239 |
+
device = torch.device("cuda")
|
| 240 |
+
|
| 241 |
+
# Load pretrained model or create from scratch
|
| 242 |
+
if args.pretrained_checkpoint is not None:
|
| 243 |
+
logger.info("=> Loading pretrained model...")
|
| 244 |
+
if not os.path.isfile(args.pretrained_checkpoint):
|
| 245 |
+
raise FileNotFoundError(f"Checkpoint not found: {args.pretrained_checkpoint}")
|
| 246 |
+
|
| 247 |
+
checkpoint = torch.load(args.pretrained_checkpoint, map_location=f"cuda:{args.gpu}")
|
| 248 |
+
state_dict = checkpoint["state_dict"]
|
| 249 |
+
|
| 250 |
+
# Auto-detect image encoder
|
| 251 |
+
if args.mode in ['image_only', 'multimodal']:
|
| 252 |
+
if "image_encoder.encoder.backbone.cls_token" in state_dict:
|
| 253 |
+
args.image_encoder = "dinov2_vits14"
|
| 254 |
+
logger.info(f"=> Detected DINOv2 image encoder")
|
| 255 |
+
|
| 256 |
+
pretrained_model = CLIPModel(args).to(device)
|
| 257 |
+
missing_keys, unexpected_keys = pretrained_model.load_state_dict(state_dict, strict=False)
|
| 258 |
+
|
| 259 |
+
if missing_keys:
|
| 260 |
+
logger.warning(f"=> Missing keys: {len(missing_keys)}")
|
| 261 |
+
if unexpected_keys:
|
| 262 |
+
logger.warning(f"=> Unexpected keys: {len(unexpected_keys)}")
|
| 263 |
+
|
| 264 |
+
logger.info(f"=> Loaded checkpoint from epoch {checkpoint.get('epoch', 'unknown')}")
|
| 265 |
+
else:
|
| 266 |
+
logger.info("=> Training from scratch")
|
| 267 |
+
pretrained_model = CLIPModel(args).to(device)
|
| 268 |
+
|
| 269 |
+
# Setup augmentations
|
| 270 |
+
aug_switchs = [
|
| 271 |
+
False,
|
| 272 |
+
args.aug_scale_coords,
|
| 273 |
+
args.aug_rotate,
|
| 274 |
+
args.aug_jitter_coords,
|
| 275 |
+
args.aug_shift_coords,
|
| 276 |
+
args.aug_flip,
|
| 277 |
+
args.aug_mask_feats,
|
| 278 |
+
args.aug_jitter_length,
|
| 279 |
+
args.aug_elasticate,
|
| 280 |
+
]
|
| 281 |
+
aug_fns = [
|
| 282 |
+
None,
|
| 283 |
+
RandomScaleCoords(p=0.2),
|
| 284 |
+
RandomRotate(p=0.5),
|
| 285 |
+
RandomJitter(p=0.2),
|
| 286 |
+
RandomShift(p=0.2),
|
| 287 |
+
RandomFlip(p=1),
|
| 288 |
+
RandomMaskFeats(p=0.2),
|
| 289 |
+
RandomJitterLength(p=0.2),
|
| 290 |
+
RandomElasticate(p=0.2),
|
| 291 |
+
]
|
| 292 |
+
feat_augs = [aug_fns[i] for i in range(len(aug_switchs)) if aug_switchs[i] and aug_fns[i] is not None]
|
| 293 |
+
feat_augs = Compose(feat_augs) if feat_augs else None
|
| 294 |
+
|
| 295 |
+
topo_aug_switchs = [
|
| 296 |
+
args.aug_drop_tree,
|
| 297 |
+
args.aug_skip_parent_node,
|
| 298 |
+
args.aug_swap_sibling_subtrees,
|
| 299 |
+
]
|
| 300 |
+
topo_aug_fns = [
|
| 301 |
+
RandomDropSubTrees(probs=[0.05], max_cnt=5),
|
| 302 |
+
RandomSkipParentNode(probs=[0.05], max_cnt=10),
|
| 303 |
+
RandomSwapSiblingSubTrees(probs=[0.05], max_cnt=10),
|
| 304 |
+
]
|
| 305 |
+
topo_augs = [topo_aug_fns[i] for i in range(len(topo_aug_switchs)) if topo_aug_switchs[i]]
|
| 306 |
+
topo_augs = Compose(topo_augs) if topo_augs else None
|
| 307 |
+
|
| 308 |
+
# Persistence augmentation
|
| 309 |
+
persistence_aug = None
|
| 310 |
+
if args.use_persistence_aug:
|
| 311 |
+
persistence_aug = CombinedPersistenceAugmentation(
|
| 312 |
+
translation_scale=args.pers_translation_scale,
|
| 313 |
+
noise_scale=args.pers_noise_scale,
|
| 314 |
+
sigma_min=args.pers_sigma_min,
|
| 315 |
+
sigma_max=args.pers_sigma_max,
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
# Create datasets
|
| 319 |
+
collate_fn = get_collate_fn(device, use_images=True)
|
| 320 |
+
|
| 321 |
+
trainset = NeuronTreeDataset(
|
| 322 |
+
phase="train",
|
| 323 |
+
dataset=args.dataset,
|
| 324 |
+
label_dict=LABEL_DICT[args.dataset],
|
| 325 |
+
input_features=args.input_features,
|
| 326 |
+
topology_transformations=topo_augs,
|
| 327 |
+
attribute_transformations=feat_augs,
|
| 328 |
+
use_images=True,
|
| 329 |
+
image_size=args.image_size,
|
| 330 |
+
cache_images=args.cache_images,
|
| 331 |
+
persistence_augmentation=persistence_aug,
|
| 332 |
+
sigma_px=args.sigma_px,
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
testset = NeuronTreeDataset(
|
| 336 |
+
phase="test",
|
| 337 |
+
dataset=args.dataset,
|
| 338 |
+
label_dict=LABEL_DICT[args.dataset],
|
| 339 |
+
input_features=args.input_features,
|
| 340 |
+
use_images=True,
|
| 341 |
+
image_size=args.image_size,
|
| 342 |
+
cache_images=args.cache_images,
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
train_loader = DataLoader(
|
| 346 |
+
dataset=trainset,
|
| 347 |
+
batch_size=args.batch_size,
|
| 348 |
+
collate_fn=collate_fn,
|
| 349 |
+
shuffle=True,
|
| 350 |
+
num_workers=4,
|
| 351 |
+
pin_memory=True,
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
test_loader = DataLoader(
|
| 355 |
+
dataset=testset,
|
| 356 |
+
batch_size=args.batch_size,
|
| 357 |
+
collate_fn=collate_fn,
|
| 358 |
+
shuffle=False,
|
| 359 |
+
num_workers=4,
|
| 360 |
+
pin_memory=True,
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
logger.info(f"Train samples: {len(trainset)}, Test samples: {len(testset)}")
|
| 364 |
+
logger.info(f"Number of classes: {len(trainset.classes)}")
|
| 365 |
+
|
| 366 |
+
# Create fine-tuning model
|
| 367 |
+
logger.info("=> Creating fine-tuning model...")
|
| 368 |
+
model = FineTuneModel(
|
| 369 |
+
pretrained_model=pretrained_model,
|
| 370 |
+
num_classes=len(trainset.classes),
|
| 371 |
+
mode=args.mode,
|
| 372 |
+
freeze_encoders=args.freeze_encoders,
|
| 373 |
+
fusion_mode=args.fusion_mode if args.mode == "multimodal" else None,
|
| 374 |
+
dropout=args.dropout,
|
| 375 |
+
label_smoothing=args.label_smoothing,
|
| 376 |
+
use_projection=args.use_projection,
|
| 377 |
+
use_arcface=args.use_arcface,
|
| 378 |
+
arcface_s=args.arcface_s,
|
| 379 |
+
arcface_m=args.arcface_m,
|
| 380 |
+
freeze_image_only=args.freeze_image_only
|
| 381 |
+
).to(device)
|
| 382 |
+
|
| 383 |
+
del pretrained_model
|
| 384 |
+
logger.info(model)
|
| 385 |
+
|
| 386 |
+
# Count parameters
|
| 387 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 388 |
+
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 389 |
+
logger.info(f"Total parameters: {total_params:,}")
|
| 390 |
+
logger.info(f"Trainable parameters: {trainable_params:,}")
|
| 391 |
+
|
| 392 |
+
# Optimizer
|
| 393 |
+
optimizer = torch.optim.AdamW(
|
| 394 |
+
filter(lambda p: p.requires_grad, model.parameters()),
|
| 395 |
+
lr=args.lr,
|
| 396 |
+
weight_decay=args.wd,
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
cudnn.benchmark = True
|
| 400 |
+
|
| 401 |
+
# Scheduler
|
| 402 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(
|
| 403 |
+
optimizer,
|
| 404 |
+
T_0=25,
|
| 405 |
+
T_mult=1,
|
| 406 |
+
eta_min=args.lr * 0.01
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
# Training loop
|
| 410 |
+
best_metric = 0.0
|
| 411 |
+
best_epoch = 0
|
| 412 |
+
patience_counter = 0
|
| 413 |
+
total_iters = len(train_loader) * args.epochs
|
| 414 |
+
current_iter = 0
|
| 415 |
+
start_time = time.time()
|
| 416 |
+
|
| 417 |
+
logger.info("=> Starting fine-tuning...")
|
| 418 |
+
logger.info(f"=> Regularization: dropout={args.dropout}, label_smoothing={args.label_smoothing}")
|
| 419 |
+
|
| 420 |
+
for epoch in range(args.start_epoch + 1, args.epochs + 1):
|
| 421 |
+
|
| 422 |
+
if epoch == args.linear_probe_epochs + 1 and args.linear_probe_epochs > 0:
|
| 423 |
+
logger.info("="*30)
|
| 424 |
+
logger.info(f"Linear probe phase completed. Unfreezing encoders for full fine-tuning.")
|
| 425 |
+
model.unfreeze_encoders()
|
| 426 |
+
|
| 427 |
+
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 428 |
+
logger.info(f"Trainable parameters after unfreezing: {trainable_params:,}")
|
| 429 |
+
|
| 430 |
+
optimizer = torch.optim.AdamW(
|
| 431 |
+
filter(lambda p: p.requires_grad, model.parameters()),
|
| 432 |
+
lr=args.lr / 10,
|
| 433 |
+
weight_decay=args.wd,
|
| 434 |
+
)
|
| 435 |
+
logger.info(f"Optimizer reset to include unfrozen parameters with a new learning rate of {args.lr / 10}.")
|
| 436 |
+
logger.info("="*30)
|
| 437 |
+
|
| 438 |
+
model.train()
|
| 439 |
+
epoch_loss = 0.0
|
| 440 |
+
correct = 0
|
| 441 |
+
total = 0
|
| 442 |
+
|
| 443 |
+
for step, batch in enumerate(train_loader):
|
| 444 |
+
try:
|
| 445 |
+
batch = batch.to(device)
|
| 446 |
+
|
| 447 |
+
if args.mixup_alpha > 0:
|
| 448 |
+
_, _, features = model(batch, return_features=True)
|
| 449 |
+
labels = batch.label.cuda() if not batch.label.is_cuda else batch.label
|
| 450 |
+
|
| 451 |
+
features, labels_a, labels_b, lam = mixup_data(features, labels, args.mixup_alpha)
|
| 452 |
+
|
| 453 |
+
if model.use_arcface:
|
| 454 |
+
extracted_features = model.feature_extractor(features)
|
| 455 |
+
logits = model.arcface(extracted_features, labels)
|
| 456 |
+
loss = model.criterion(logits, labels)
|
| 457 |
+
else:
|
| 458 |
+
logits = model.classifier(features)
|
| 459 |
+
loss = mixup_criterion(model.criterion, logits, labels_a, labels_b, lam)
|
| 460 |
+
else:
|
| 461 |
+
loss, logits = model(batch)
|
| 462 |
+
|
| 463 |
+
optimizer.zero_grad()
|
| 464 |
+
loss.backward()
|
| 465 |
+
|
| 466 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
|
| 467 |
+
|
| 468 |
+
optimizer.step()
|
| 469 |
+
|
| 470 |
+
pred = logits.argmax(dim=1)
|
| 471 |
+
labels = batch.label.cuda() if not batch.label.is_cuda else batch.label
|
| 472 |
+
correct += (pred == labels).sum().item()
|
| 473 |
+
total += labels.size(0)
|
| 474 |
+
|
| 475 |
+
epoch_loss += loss.item()
|
| 476 |
+
current_iter += 1
|
| 477 |
+
|
| 478 |
+
if step % 10 == 0:
|
| 479 |
+
current_time = time.time()
|
| 480 |
+
elapsed = current_time - start_time
|
| 481 |
+
|
| 482 |
+
log_str = (
|
| 483 |
+
f"Epoch {epoch:03d} | Step {step:03d}/{len(train_loader)} | "
|
| 484 |
+
f"Loss {loss.item():.4f} | "
|
| 485 |
+
f"LR {optimizer.param_groups[0]['lr']:.6f} | "
|
| 486 |
+
f"Elapsed {str(datetime.timedelta(seconds=int(elapsed)))}"
|
| 487 |
+
)
|
| 488 |
+
logger.info(log_str)
|
| 489 |
+
except Exception as e:
|
| 490 |
+
logger.info(f"Error in step {step}: {e}")
|
| 491 |
+
continue
|
| 492 |
+
|
| 493 |
+
scheduler.step()
|
| 494 |
+
|
| 495 |
+
avg_loss = epoch_loss / len(train_loader)
|
| 496 |
+
train_acc = correct / total * 100
|
| 497 |
+
logger.info(f"Epoch {epoch:03d} | Avg Loss: {avg_loss:.4f} | Train Acc: {train_acc:.2f}%")
|
| 498 |
+
|
| 499 |
+
# Evaluation
|
| 500 |
+
if epoch % args.val_freq == 0:
|
| 501 |
+
logger.info("=> Evaluating...")
|
| 502 |
+
|
| 503 |
+
if args.eval_mode == "accuracy":
|
| 504 |
+
test_acc = evaluate_accuracy(model, test_loader, device)
|
| 505 |
+
logger.info(f" Test Accuracy: {test_acc:.2f}%")
|
| 506 |
+
metric = test_acc
|
| 507 |
+
else:
|
| 508 |
+
knn_acc = evaluate_knn(model, train_loader, test_loader, device, args.knn_k)
|
| 509 |
+
logger.info(f" KNN Accuracy (k={args.knn_k}): {knn_acc:.2f}%")
|
| 510 |
+
metric = knn_acc
|
| 511 |
+
|
| 512 |
+
if metric > best_metric:
|
| 513 |
+
best_metric = metric
|
| 514 |
+
best_epoch = epoch
|
| 515 |
+
patience_counter = 0
|
| 516 |
+
|
| 517 |
+
checkpoint_path = f"{args.work_dir}/best_model.pth"
|
| 518 |
+
save_checkpoint(
|
| 519 |
+
{
|
| 520 |
+
"epoch": epoch,
|
| 521 |
+
"state_dict": model.state_dict(),
|
| 522 |
+
"optimizer": optimizer.state_dict(),
|
| 523 |
+
"metric": metric,
|
| 524 |
+
"mode": args.mode,
|
| 525 |
+
},
|
| 526 |
+
is_best=True,
|
| 527 |
+
filename=checkpoint_path,
|
| 528 |
+
)
|
| 529 |
+
logger.info(f" Saved new best checkpoint: {checkpoint_path}")
|
| 530 |
+
else:
|
| 531 |
+
patience_counter += 1
|
| 532 |
+
|
| 533 |
+
logger.info(f" Best: {best_metric:.2f}% at epoch {best_epoch}")
|
| 534 |
+
|
| 535 |
+
if args.early_stopping_patience > 0 and patience_counter >= args.early_stopping_patience:
|
| 536 |
+
logger.info(f" Early stopping triggered")
|
| 537 |
+
break
|
| 538 |
+
|
| 539 |
+
# Save periodic checkpoint
|
| 540 |
+
if epoch % args.save_freq == 0:
|
| 541 |
+
checkpoint_path = f"{args.work_dir}/epoch_{epoch}.pth"
|
| 542 |
+
save_checkpoint(
|
| 543 |
+
{
|
| 544 |
+
"epoch": epoch,
|
| 545 |
+
"state_dict": model.state_dict(),
|
| 546 |
+
"optimizer": optimizer.state_dict(),
|
| 547 |
+
},
|
| 548 |
+
is_best=False,
|
| 549 |
+
filename=checkpoint_path,
|
| 550 |
+
)
|
| 551 |
+
logger.info(f"Saved checkpoint: {checkpoint_path}")
|
| 552 |
+
|
| 553 |
+
logger.info("Fine-tuning complete!")
|
| 554 |
+
logger.info(f"Best {args.eval_mode}: {best_metric:.2f}% at epoch {best_epoch}")
|
graphformer/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GraPHFormer: Graph-Persistence Hybrid Transformer for Neuron Morphology
|
| 3 |
+
|
| 4 |
+
A CLIP-style contrastive learning framework for neuron representation learning
|
| 5 |
+
that combines tree-structured morphology with persistence images.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from .models import CLIPModel, FineTuneModel, CLIPLoss
|
| 9 |
+
from .losses import InfoNCELoss, NTXentLoss, TripletLoss
|
| 10 |
+
|
| 11 |
+
__version__ = "1.0.0"
|
| 12 |
+
__all__ = ["CLIPModel", "FineTuneModel", "CLIPLoss", "InfoNCELoss", "NTXentLoss", "TripletLoss"]
|
graphformer/augmentations/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Augmentation strategies for GraPHFormer."""
|
| 2 |
+
|
| 3 |
+
from .tree_augmentations import (
|
| 4 |
+
Compose,
|
| 5 |
+
RandomDropSubTrees, RandomSkipParentNode, RandomSwapSiblingSubTrees,
|
| 6 |
+
RandomRotate, RandomJitter, RandomShift, RandomFlip,
|
| 7 |
+
RandomScaleCoords, RandomScaleFeats, RandomMaskFeats,
|
| 8 |
+
RandomElasticate, RandomJitterLength,
|
| 9 |
+
)
|
| 10 |
+
from .persistence_augmentations import (
|
| 11 |
+
PersistenceSpaceAugmentation, SigmaVariationAugmentation,
|
| 12 |
+
CombinedPersistenceAugmentation, get_default_augmentation,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
"Compose",
|
| 17 |
+
"RandomDropSubTrees", "RandomSkipParentNode", "RandomSwapSiblingSubTrees",
|
| 18 |
+
"RandomRotate", "RandomJitter", "RandomShift", "RandomFlip",
|
| 19 |
+
"RandomScaleCoords", "RandomScaleFeats", "RandomMaskFeats",
|
| 20 |
+
"RandomElasticate", "RandomJitterLength",
|
| 21 |
+
"PersistenceSpaceAugmentation", "SigmaVariationAugmentation",
|
| 22 |
+
"CombinedPersistenceAugmentation", "get_default_augmentation",
|
| 23 |
+
]
|
graphformer/augmentations/persistence_augmentations.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Persistence Image Augmentation Module
|
| 3 |
+
|
| 4 |
+
This module provides augmentation strategies specifically designed for persistence images
|
| 5 |
+
generated from neuron morphology data. Unlike standard image augmentations (ColorJitter, etc.),
|
| 6 |
+
these augmentations work in the persistence space (birth, persistence) to create meaningful
|
| 7 |
+
variations while preserving the underlying topological structure.
|
| 8 |
+
|
| 9 |
+
Augmentation Strategies:
|
| 10 |
+
1. Translation in birth/persistence space - shifts points in the diagram
|
| 11 |
+
2. Gaussian noise addition - adds small random perturbations to point positions
|
| 12 |
+
3. Sigma variation - varies the Gaussian kernel width during image generation
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
from typing import Tuple, Optional
|
| 18 |
+
import random
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class PersistenceSpaceAugmentation:
|
| 22 |
+
"""
|
| 23 |
+
Augmentation that operates in persistence space before image generation.
|
| 24 |
+
|
| 25 |
+
This augmentation modifies the birth/persistence coordinates of points
|
| 26 |
+
in the persistence diagram, then regenerates the image with these modified coordinates.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(
|
| 30 |
+
self,
|
| 31 |
+
translation_scale: float = 0.05,
|
| 32 |
+
noise_scale: float = 0.02,
|
| 33 |
+
translation_prob: float = 0.5,
|
| 34 |
+
noise_prob: float = 0.5,
|
| 35 |
+
persistence_scale_prob: float = 0.0,
|
| 36 |
+
persistence_scale_min: float = 0.9,
|
| 37 |
+
persistence_scale_max: float = 1.1,
|
| 38 |
+
radius_perturb_prob: float = 0.0,
|
| 39 |
+
radius_perturb_min: float = 0.85,
|
| 40 |
+
radius_perturb_max: float = 1.15,
|
| 41 |
+
):
|
| 42 |
+
"""
|
| 43 |
+
Args:
|
| 44 |
+
translation_scale: Scale of random translation (relative to data range)
|
| 45 |
+
noise_scale: Scale of Gaussian noise (relative to data range)
|
| 46 |
+
translation_prob: Probability of applying translation
|
| 47 |
+
noise_prob: Probability of applying Gaussian noise
|
| 48 |
+
persistence_scale_prob: Probability of applying persistence scaling
|
| 49 |
+
persistence_scale_min: Minimum scaling factor for persistence
|
| 50 |
+
persistence_scale_max: Maximum scaling factor for persistence
|
| 51 |
+
radius_perturb_prob: Probability of applying radius perturbation
|
| 52 |
+
radius_perturb_min: Minimum scaling factor for radius
|
| 53 |
+
radius_perturb_max: Maximum scaling factor for radius
|
| 54 |
+
"""
|
| 55 |
+
self.translation_scale = translation_scale
|
| 56 |
+
self.noise_scale = noise_scale
|
| 57 |
+
self.translation_prob = translation_prob
|
| 58 |
+
self.noise_prob = noise_prob
|
| 59 |
+
self.persistence_scale_prob = persistence_scale_prob
|
| 60 |
+
self.persistence_scale_min = persistence_scale_min
|
| 61 |
+
self.persistence_scale_max = persistence_scale_max
|
| 62 |
+
self.radius_perturb_prob = radius_perturb_prob
|
| 63 |
+
self.radius_perturb_min = radius_perturb_min
|
| 64 |
+
self.radius_perturb_max = radius_perturb_max
|
| 65 |
+
|
| 66 |
+
def augment_pairs_features(self, pairs_feats, global_bounds=None):
|
| 67 |
+
"""
|
| 68 |
+
Augment the pairs features in persistence space.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
pairs_feats: List of dictionaries with 'birth', 'death', 'persistence', 'mean_radius'
|
| 72 |
+
global_bounds: Tuple of (birth_min, birth_max, pers_min, pers_max)
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
Augmented pairs_feats
|
| 76 |
+
"""
|
| 77 |
+
if not pairs_feats or len(pairs_feats) == 0:
|
| 78 |
+
return pairs_feats
|
| 79 |
+
|
| 80 |
+
# Create a copy to avoid modifying original
|
| 81 |
+
augmented_feats = [f.copy() for f in pairs_feats]
|
| 82 |
+
|
| 83 |
+
# Extract births and persistence values
|
| 84 |
+
births = np.array([f['birth'] for f in augmented_feats])
|
| 85 |
+
pers = np.array([f['persistence'] for f in augmented_feats])
|
| 86 |
+
|
| 87 |
+
# Determine data range for scaling
|
| 88 |
+
if global_bounds is not None:
|
| 89 |
+
birth_range = global_bounds[1] - global_bounds[0]
|
| 90 |
+
pers_range = global_bounds[3] - global_bounds[2]
|
| 91 |
+
else:
|
| 92 |
+
birth_range = births.max() - births.min() + 1e-6
|
| 93 |
+
pers_range = pers.max() - pers.min() + 1e-6
|
| 94 |
+
|
| 95 |
+
# Apply random translation in birth/persistence space
|
| 96 |
+
if random.random() < self.translation_prob:
|
| 97 |
+
birth_shift = np.random.uniform(-self.translation_scale, self.translation_scale) * birth_range
|
| 98 |
+
pers_shift = np.random.uniform(-self.translation_scale, self.translation_scale) * pers_range
|
| 99 |
+
|
| 100 |
+
births += birth_shift
|
| 101 |
+
pers += pers_shift
|
| 102 |
+
|
| 103 |
+
# Apply Gaussian noise
|
| 104 |
+
if random.random() < self.noise_prob:
|
| 105 |
+
birth_noise = np.random.normal(0, self.noise_scale * birth_range, size=births.shape)
|
| 106 |
+
pers_noise = np.random.normal(0, self.noise_scale * pers_range, size=pers.shape)
|
| 107 |
+
|
| 108 |
+
births += birth_noise
|
| 109 |
+
pers += pers_noise
|
| 110 |
+
|
| 111 |
+
# Apply persistence scaling
|
| 112 |
+
if random.random() < self.persistence_scale_prob:
|
| 113 |
+
alpha = np.random.uniform(self.persistence_scale_min, self.persistence_scale_max)
|
| 114 |
+
pers *= alpha
|
| 115 |
+
|
| 116 |
+
# Apply radius perturbation
|
| 117 |
+
if random.random() < self.radius_perturb_prob:
|
| 118 |
+
beta = np.random.uniform(self.radius_perturb_min, self.radius_perturb_max)
|
| 119 |
+
for f in augmented_feats:
|
| 120 |
+
if 'mean_radius' in f:
|
| 121 |
+
f['mean_radius'] *= beta
|
| 122 |
+
|
| 123 |
+
# Ensure persistence values remain positive
|
| 124 |
+
pers = np.maximum(pers, 1e-9)
|
| 125 |
+
|
| 126 |
+
# Update the augmented features
|
| 127 |
+
for i, f in enumerate(augmented_feats):
|
| 128 |
+
f['birth'] = float(births[i])
|
| 129 |
+
f['persistence'] = float(pers[i])
|
| 130 |
+
# Note: death = birth - persistence (in TMD convention where persistence is negative)
|
| 131 |
+
# Actually in this code: persistence = birth - death, so death = birth - persistence
|
| 132 |
+
f['death'] = float(births[i] - pers[i])
|
| 133 |
+
|
| 134 |
+
return augmented_feats
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class SigmaVariationAugmentation:
|
| 138 |
+
"""
|
| 139 |
+
Augmentation that varies the sigma parameter during Gaussian kernel application.
|
| 140 |
+
|
| 141 |
+
This creates different "blur" levels in the persistence image, which can help
|
| 142 |
+
the model learn features at multiple scales.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
def __init__(
|
| 146 |
+
self,
|
| 147 |
+
sigma_min: float = 12.0,
|
| 148 |
+
sigma_max: float = 20.0,
|
| 149 |
+
prob: float = 1.0,
|
| 150 |
+
):
|
| 151 |
+
"""
|
| 152 |
+
Args:
|
| 153 |
+
sigma_min: Minimum sigma value
|
| 154 |
+
sigma_max: Maximum sigma value
|
| 155 |
+
prob: Probability of varying sigma (1.0 means always vary)
|
| 156 |
+
"""
|
| 157 |
+
self.sigma_min = sigma_min
|
| 158 |
+
self.sigma_max = sigma_max
|
| 159 |
+
self.prob = prob
|
| 160 |
+
|
| 161 |
+
def sample_sigma(self, base_sigma: float = 16.0) -> float:
|
| 162 |
+
"""
|
| 163 |
+
Sample a sigma value.
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
base_sigma: Base sigma value (unused when prob=1.0, kept for compatibility)
|
| 167 |
+
|
| 168 |
+
Returns:
|
| 169 |
+
Sampled sigma value
|
| 170 |
+
"""
|
| 171 |
+
if random.random() < self.prob:
|
| 172 |
+
return np.random.uniform(self.sigma_min, self.sigma_max)
|
| 173 |
+
else:
|
| 174 |
+
return base_sigma
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class CombinedPersistenceAugmentation:
|
| 178 |
+
"""
|
| 179 |
+
Combines multiple persistence space augmentations.
|
| 180 |
+
|
| 181 |
+
This is the main augmentation class that should be used for training.
|
| 182 |
+
"""
|
| 183 |
+
|
| 184 |
+
def __init__(
|
| 185 |
+
self,
|
| 186 |
+
translation_scale: float = 0.05,
|
| 187 |
+
noise_scale: float = 0.02,
|
| 188 |
+
sigma_min: float = 12.0,
|
| 189 |
+
sigma_max: float = 20.0,
|
| 190 |
+
translation_prob: float = 0.5,
|
| 191 |
+
noise_prob: float = 0.5,
|
| 192 |
+
sigma_variation_prob: float = 1.0,
|
| 193 |
+
persistence_scale_prob: float = 0.0,
|
| 194 |
+
persistence_scale_min: float = 0.9,
|
| 195 |
+
persistence_scale_max: float = 1.1,
|
| 196 |
+
radius_perturb_prob: float = 0.0,
|
| 197 |
+
radius_perturb_min: float = 0.85,
|
| 198 |
+
radius_perturb_max: float = 1.15,
|
| 199 |
+
):
|
| 200 |
+
"""
|
| 201 |
+
Args:
|
| 202 |
+
translation_scale: Scale of random translation in birth/persistence space
|
| 203 |
+
noise_scale: Scale of Gaussian noise
|
| 204 |
+
sigma_min: Minimum sigma for Gaussian kernel
|
| 205 |
+
sigma_max: Maximum sigma for Gaussian kernel
|
| 206 |
+
translation_prob: Probability of applying translation
|
| 207 |
+
noise_prob: Probability of applying noise
|
| 208 |
+
sigma_variation_prob: Probability of varying sigma
|
| 209 |
+
persistence_scale_prob: Probability of applying persistence scaling
|
| 210 |
+
persistence_scale_min: Minimum scaling factor for persistence
|
| 211 |
+
persistence_scale_max: Maximum scaling factor for persistence
|
| 212 |
+
radius_perturb_prob: Probability of applying radius perturbation
|
| 213 |
+
radius_perturb_min: Minimum scaling factor for radius
|
| 214 |
+
radius_perturb_max: Maximum scaling factor for radius
|
| 215 |
+
"""
|
| 216 |
+
self.space_aug = PersistenceSpaceAugmentation(
|
| 217 |
+
translation_scale=translation_scale,
|
| 218 |
+
noise_scale=noise_scale,
|
| 219 |
+
translation_prob=translation_prob,
|
| 220 |
+
noise_prob=noise_prob,
|
| 221 |
+
persistence_scale_prob=persistence_scale_prob,
|
| 222 |
+
persistence_scale_min=persistence_scale_min,
|
| 223 |
+
persistence_scale_max=persistence_scale_max,
|
| 224 |
+
radius_perturb_prob=radius_perturb_prob,
|
| 225 |
+
radius_perturb_min=radius_perturb_min,
|
| 226 |
+
radius_perturb_max=radius_perturb_max,
|
| 227 |
+
)
|
| 228 |
+
self.sigma_aug = SigmaVariationAugmentation(
|
| 229 |
+
sigma_min=sigma_min,
|
| 230 |
+
sigma_max=sigma_max,
|
| 231 |
+
prob=sigma_variation_prob,
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
def augment_pairs_features(self, pairs_feats, global_bounds=None):
|
| 235 |
+
"""Augment pairs features in persistence space."""
|
| 236 |
+
return self.space_aug.augment_pairs_features(pairs_feats, global_bounds)
|
| 237 |
+
|
| 238 |
+
def sample_sigma(self, base_sigma: float = 16.0) -> float:
|
| 239 |
+
"""Sample a sigma value for image generation."""
|
| 240 |
+
return self.sigma_aug.sample_sigma(base_sigma)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def get_default_augmentation(mode='train'):
|
| 244 |
+
"""
|
| 245 |
+
Get default augmentation configuration.
|
| 246 |
+
|
| 247 |
+
Args:
|
| 248 |
+
mode: 'train' or 'test'
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
CombinedPersistenceAugmentation instance
|
| 252 |
+
"""
|
| 253 |
+
if mode == 'train':
|
| 254 |
+
return CombinedPersistenceAugmentation(
|
| 255 |
+
translation_scale=0.05, # 5% of range
|
| 256 |
+
noise_scale=0.02, # 2% of range
|
| 257 |
+
sigma_min=12.0,
|
| 258 |
+
sigma_max=20.0,
|
| 259 |
+
translation_prob=0.5,
|
| 260 |
+
noise_prob=0.5,
|
| 261 |
+
sigma_variation_prob=1.0,
|
| 262 |
+
)
|
| 263 |
+
else:
|
| 264 |
+
# No augmentation for test
|
| 265 |
+
return CombinedPersistenceAugmentation(
|
| 266 |
+
translation_scale=0.0,
|
| 267 |
+
noise_scale=0.0,
|
| 268 |
+
sigma_min=16.0,
|
| 269 |
+
sigma_max=16.0,
|
| 270 |
+
translation_prob=0.0,
|
| 271 |
+
noise_prob=0.0,
|
| 272 |
+
sigma_variation_prob=0.0,
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# Example usage
|
| 277 |
+
if __name__ == "__main__":
|
| 278 |
+
# Create augmentation
|
| 279 |
+
aug = get_default_augmentation('train')
|
| 280 |
+
|
| 281 |
+
# Example pairs features
|
| 282 |
+
pairs_feats = [
|
| 283 |
+
{'birth': 100.0, 'death': 50.0, 'persistence': 50.0, 'mean_radius': 2.5},
|
| 284 |
+
{'birth': 150.0, 'death': 80.0, 'persistence': 70.0, 'mean_radius': 3.0},
|
| 285 |
+
{'birth': 200.0, 'death': 120.0, 'persistence': 80.0, 'mean_radius': 2.8},
|
| 286 |
+
]
|
| 287 |
+
|
| 288 |
+
# Augment
|
| 289 |
+
global_bounds = (0, 300, 0, 100)
|
| 290 |
+
augmented = aug.augment_pairs_features(pairs_feats, global_bounds)
|
| 291 |
+
|
| 292 |
+
print("Original pairs:")
|
| 293 |
+
for f in pairs_feats[:2]:
|
| 294 |
+
print(f" birth={f['birth']:.2f}, persistence={f['persistence']:.2f}")
|
| 295 |
+
|
| 296 |
+
print("\nAugmented pairs:")
|
| 297 |
+
for f in augmented[:2]:
|
| 298 |
+
print(f" birth={f['birth']:.2f}, persistence={f['persistence']:.2f}")
|
| 299 |
+
|
| 300 |
+
# Sample sigma values
|
| 301 |
+
print("\nSampled sigma values:")
|
| 302 |
+
for _ in range(5):
|
| 303 |
+
sigma = aug.sample_sigma()
|
| 304 |
+
print(f" sigma={sigma:.2f}")
|
graphformer/augmentations/tree_augmentations.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
from nltk.tree import Tree
|
| 3 |
+
import random
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Compose(object):
|
| 7 |
+
def __init__(self, transforms):
|
| 8 |
+
self.transforms = transforms
|
| 9 |
+
|
| 10 |
+
def __call__(self, coords_and_feats):
|
| 11 |
+
for t in self.transforms:
|
| 12 |
+
coords_and_feats = t(coords_and_feats)
|
| 13 |
+
return coords_and_feats
|
| 14 |
+
|
| 15 |
+
def __str__(self) -> str:
|
| 16 |
+
sep = "\n\t"
|
| 17 |
+
msg = "Compose of ["
|
| 18 |
+
for t in self.transforms:
|
| 19 |
+
msg += f"{sep}{t},"
|
| 20 |
+
msg += "]\n"
|
| 21 |
+
return msg
|
| 22 |
+
|
| 23 |
+
def __repr__(self) -> str:
|
| 24 |
+
sep = "\n\t"
|
| 25 |
+
msg = "Compose of ["
|
| 26 |
+
for t in self.transforms:
|
| 27 |
+
msg += f"{sep}{t}"
|
| 28 |
+
msg += "]\n"
|
| 29 |
+
return msg
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class RandomDropSubTrees(object):
|
| 33 |
+
def __init__(self, probs=[0.1, 0.2, 0.3, 0.4, 0.5], max_cnt=5):
|
| 34 |
+
self.probs = probs
|
| 35 |
+
self.max_cnt = max_cnt
|
| 36 |
+
self.cnt = 0
|
| 37 |
+
|
| 38 |
+
def remove_subtrees(self, root, level_idx):
|
| 39 |
+
reduced_root = Tree(root.label(), [])
|
| 40 |
+
if len(root) == 0:
|
| 41 |
+
return reduced_root
|
| 42 |
+
p = np.random.uniform(0, 1, len(root))
|
| 43 |
+
# padding the dropping probabilities
|
| 44 |
+
if level_idx >= len(self.probs):
|
| 45 |
+
level_idx = len(self.probs) - 1
|
| 46 |
+
for child_idx in range(len(root)):
|
| 47 |
+
if self.cnt > self.max_cnt:
|
| 48 |
+
reduced_root.append(root[child_idx])
|
| 49 |
+
continue
|
| 50 |
+
else:
|
| 51 |
+
if p[child_idx] > self.probs[level_idx]:
|
| 52 |
+
reduced_root.append(
|
| 53 |
+
self.remove_subtrees(root[child_idx], level_idx + 1)
|
| 54 |
+
)
|
| 55 |
+
else:
|
| 56 |
+
self.cnt += 1
|
| 57 |
+
return reduced_root
|
| 58 |
+
|
| 59 |
+
def __call__(self, tree):
|
| 60 |
+
self.cnt = 0
|
| 61 |
+
return self.remove_subtrees(tree, level_idx=0)
|
| 62 |
+
|
| 63 |
+
def __str__(self) -> str:
|
| 64 |
+
return f"RandomDropSubTrees(probs={self.probs}, max_cnt={self.max_cnt})"
|
| 65 |
+
|
| 66 |
+
def __repr__(self) -> str:
|
| 67 |
+
return f"RandomDropSubTrees(probs={self.probs}, max_cnt={self.max_cnt})"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class RandomSkipParentNode(object):
|
| 71 |
+
def __init__(self, probs=[0.1], max_cnt=5):
|
| 72 |
+
self.probs = probs
|
| 73 |
+
self.max_cnt = max_cnt
|
| 74 |
+
self.cnt = 0
|
| 75 |
+
|
| 76 |
+
def move_grandson_to_son(self, root, level_idx):
|
| 77 |
+
if len(root) == 0 or len(root) == 1:
|
| 78 |
+
return root
|
| 79 |
+
p = np.random.uniform(0, 1, len(root))
|
| 80 |
+
# padding the dropping probabilities
|
| 81 |
+
if level_idx >= len(self.probs):
|
| 82 |
+
level_idx = len(self.probs) - 1
|
| 83 |
+
for child_idx in range(len(root)):
|
| 84 |
+
if self.cnt >= self.max_cnt:
|
| 85 |
+
break
|
| 86 |
+
if p[child_idx] < self.probs[level_idx]:
|
| 87 |
+
if len(root[child_idx]) == 0 or len(root[child_idx]) == 1:
|
| 88 |
+
continue
|
| 89 |
+
else:
|
| 90 |
+
idx = random.randint(0, len(root[child_idx]) - 1)
|
| 91 |
+
root[child_idx] = root[child_idx][idx]
|
| 92 |
+
self.cnt += 1
|
| 93 |
+
else:
|
| 94 |
+
root[child_idx] = self.move_grandson_to_son(
|
| 95 |
+
root[child_idx], level_idx + 1
|
| 96 |
+
)
|
| 97 |
+
return root
|
| 98 |
+
|
| 99 |
+
def __call__(self, tree):
|
| 100 |
+
self.cnt = 0
|
| 101 |
+
return self.move_grandson_to_son(tree, level_idx=0)
|
| 102 |
+
|
| 103 |
+
def __str__(self) -> str:
|
| 104 |
+
return f"RandomSkipParentNode(probs={self.probs}, max_cnt={self.max_cnt})"
|
| 105 |
+
|
| 106 |
+
def __repr__(self) -> str:
|
| 107 |
+
return f"RandomSkipParentNode(probs={self.probs}, max_cnt={self.max_cnt})"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class RandomSwapSiblingSubTrees(object):
|
| 111 |
+
def __init__(self, probs=[0.1], max_cnt=5):
|
| 112 |
+
self.probs = probs
|
| 113 |
+
self.max_cnt = max_cnt
|
| 114 |
+
self.cnt = 0
|
| 115 |
+
|
| 116 |
+
def swap_sibling_subtrees(self, root, level_idx):
|
| 117 |
+
if len(root) < 2:
|
| 118 |
+
return root
|
| 119 |
+
p = np.random.uniform(0, 1, len(root))
|
| 120 |
+
# padding the dropping probabilities
|
| 121 |
+
if level_idx >= len(self.probs):
|
| 122 |
+
level_idx = len(self.probs) - 1
|
| 123 |
+
for child_idx in range(len(root)):
|
| 124 |
+
if self.cnt >= self.max_cnt:
|
| 125 |
+
break
|
| 126 |
+
if p[child_idx] < self.probs[level_idx]:
|
| 127 |
+
if len(root[child_idx]) < 2:
|
| 128 |
+
continue
|
| 129 |
+
else:
|
| 130 |
+
my_subtree_idx = random.randint(0, len(root[child_idx]) - 1)
|
| 131 |
+
sibling_idx = random.randint(0, len(root) - 1)
|
| 132 |
+
if len(root[sibling_idx]) == 0:
|
| 133 |
+
continue
|
| 134 |
+
sibling_subtree_idx = random.randint(0, len(root[sibling_idx]) - 1)
|
| 135 |
+
my_subtree = root[child_idx][my_subtree_idx].copy()
|
| 136 |
+
sibling_subtree = root[sibling_idx][sibling_subtree_idx].copy()
|
| 137 |
+
root[child_idx][my_subtree_idx] = sibling_subtree
|
| 138 |
+
root[sibling_idx][sibling_subtree_idx] = my_subtree
|
| 139 |
+
self.cnt += 1
|
| 140 |
+
else:
|
| 141 |
+
root[child_idx] = self.swap_sibling_subtrees(
|
| 142 |
+
root[child_idx], level_idx + 1
|
| 143 |
+
)
|
| 144 |
+
return root
|
| 145 |
+
|
| 146 |
+
def __call__(self, tree):
|
| 147 |
+
self.cnt = 0
|
| 148 |
+
return self.swap_sibling_subtrees(tree, level_idx=0)
|
| 149 |
+
|
| 150 |
+
def __str__(self) -> str:
|
| 151 |
+
return f"RandomSwapSiblingSubTrees(probs={self.probs}, max_cnt={self.max_cnt})"
|
| 152 |
+
|
| 153 |
+
def __repr__(self) -> str:
|
| 154 |
+
return f"RandomSwapSiblingSubTrees(probs={self.probs}, max_cnt={self.max_cnt})"
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class RandomRotateAligned(object):
|
| 158 |
+
def __init__(self, p=0.5, axis=2):
|
| 159 |
+
self.prob = p
|
| 160 |
+
self.axis = axis
|
| 161 |
+
|
| 162 |
+
def __call__(self, coords_and_feats):
|
| 163 |
+
coord = coords_and_feats[:, :3]
|
| 164 |
+
if np.random.rand() < self.prob:
|
| 165 |
+
angle = np.random.uniform() * 2 * np.pi
|
| 166 |
+
cos, sin = np.cos(angle), np.sin(angle)
|
| 167 |
+
R_x = np.array([[1, 0, 0], [0, cos, -sin], [0, sin, cos]])
|
| 168 |
+
R_y = np.array([[cos, 0, sin], [0, 1, 0], [-sin, 0, cos]])
|
| 169 |
+
R_z = np.array([[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]])
|
| 170 |
+
R = [R_x, R_y, R_z][self.axis]
|
| 171 |
+
coord = np.dot(coord, R)
|
| 172 |
+
coords_and_feats[:, :3] = coord
|
| 173 |
+
return coords_and_feats
|
| 174 |
+
|
| 175 |
+
def __str__(self) -> str:
|
| 176 |
+
return f"RandomRotateAligned(p={self.prob},axis={self.axis})"
|
| 177 |
+
|
| 178 |
+
def __repr__(self) -> str:
|
| 179 |
+
return f"RandomRotateAligned(p={self.prob},axis={self.axis})"
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class RandomRotate(object):
|
| 183 |
+
def __init__(self, sigma=0.03, clip=0.09, p=0.5):
|
| 184 |
+
self.sigma = sigma
|
| 185 |
+
self.clip = clip
|
| 186 |
+
self.prob = p
|
| 187 |
+
|
| 188 |
+
def __call__(self, coords_and_feats):
|
| 189 |
+
coord = coords_and_feats[:, :3]
|
| 190 |
+
if np.random.rand() < self.prob:
|
| 191 |
+
angle_x = np.random.uniform() * 2 * np.pi
|
| 192 |
+
angle_y = np.random.uniform() * 2 * np.pi
|
| 193 |
+
angle_z = np.random.uniform() * 2 * np.pi
|
| 194 |
+
cos_x, sin_x = np.cos(angle_x), np.sin(angle_x)
|
| 195 |
+
cos_y, sin_y = np.cos(angle_y), np.sin(angle_y)
|
| 196 |
+
cos_z, sin_z = np.cos(angle_z), np.sin(angle_z)
|
| 197 |
+
R_x = np.array([[1, 0, 0], [0, cos_x, -sin_x], [0, sin_x, cos_x]])
|
| 198 |
+
R_y = np.array([[cos_y, 0, sin_y], [0, 1, 0], [-sin_y, 0, cos_y]])
|
| 199 |
+
R_z = np.array([[cos_z, -sin_z, 0], [sin_z, cos_z, 0], [0, 0, 1]])
|
| 200 |
+
R = np.dot(R_z, np.dot(R_y, R_x))
|
| 201 |
+
coord = np.dot(coord, R)
|
| 202 |
+
coords_and_feats[:, :3] = coord
|
| 203 |
+
return coords_and_feats
|
| 204 |
+
|
| 205 |
+
def __str__(self) -> str:
|
| 206 |
+
return f"RandomRotate(p={self.prob})"
|
| 207 |
+
|
| 208 |
+
def __repr__(self) -> str:
|
| 209 |
+
return f"RandomRotate(p={self.prob})"
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class RandomMaskFeats(object):
|
| 213 |
+
def __init__(self, p=0.2):
|
| 214 |
+
self.prob = p
|
| 215 |
+
|
| 216 |
+
def __call__(self, coords_and_feats):
|
| 217 |
+
if len(coords_and_feats[0]) > 5:
|
| 218 |
+
feats = coords_and_feats[:, 5:]
|
| 219 |
+
feats[
|
| 220 |
+
:,
|
| 221 |
+
np.random.choice(
|
| 222 |
+
np.arange(len(feats[0])), int(len(feats[0]) * self.prob)
|
| 223 |
+
),
|
| 224 |
+
] = 0
|
| 225 |
+
coords_and_feats[:, 5:] = feats
|
| 226 |
+
return coords_and_feats
|
| 227 |
+
|
| 228 |
+
def __str__(self) -> str:
|
| 229 |
+
return f"RandomMaskFeats(p={self.prob})"
|
| 230 |
+
|
| 231 |
+
def __repr__(self) -> str:
|
| 232 |
+
return f"RandomMaskFeats(p={self.prob})"
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class RandomElasticate(object):
|
| 236 |
+
def __init__(self, p=0.2, scales=[0.8, 1.2]):
|
| 237 |
+
self.prob = p
|
| 238 |
+
self.scales = scales
|
| 239 |
+
|
| 240 |
+
def __call__(self, coords_and_feats):
|
| 241 |
+
if len(coords_and_feats[0]) > 5:
|
| 242 |
+
if np.random.rand() < self.prob:
|
| 243 |
+
branches = coords_and_feats[:, 5:]
|
| 244 |
+
scales = np.random.uniform(
|
| 245 |
+
self.scales[0], self.scales[1], branches.shape
|
| 246 |
+
)
|
| 247 |
+
branches *= scales
|
| 248 |
+
coords_and_feats[:, 5:] = branches
|
| 249 |
+
return coords_and_feats
|
| 250 |
+
|
| 251 |
+
def __str__(self) -> str:
|
| 252 |
+
return f"RandomElasticate(p={self.prob}, scales={self.scales})"
|
| 253 |
+
|
| 254 |
+
def __repr__(self) -> str:
|
| 255 |
+
return f"RandomElasticate(p={self.prob}, scales={self.scales})"
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
class RandomScaleCoords(object):
|
| 259 |
+
def __init__(self, scale=[0.8, 1.2], p=0.5):
|
| 260 |
+
self.scale = scale
|
| 261 |
+
self.prob = p
|
| 262 |
+
|
| 263 |
+
def __call__(self, coords_and_feats):
|
| 264 |
+
if np.random.rand() < self.prob:
|
| 265 |
+
scale = np.random.uniform(self.scale[0], self.scale[1])
|
| 266 |
+
coords_and_feats[:, :4] *= scale
|
| 267 |
+
if len(coords_and_feats[0]) > 5:
|
| 268 |
+
coords_and_feats[:, 5:] *= scale
|
| 269 |
+
return coords_and_feats
|
| 270 |
+
|
| 271 |
+
def __str__(self) -> str:
|
| 272 |
+
return f"RandomScaleCoords(p={self.prob}, scale={self.scale})"
|
| 273 |
+
|
| 274 |
+
def __repr__(self) -> str:
|
| 275 |
+
return f"RandomScaleCoords(p={self.prob}, scale={self.scale})"
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
class RandomScaleCoordsTranslation(object):
|
| 279 |
+
def __init__(self, scale=[0.5, 2], p=0.5):
|
| 280 |
+
self.scale = scale
|
| 281 |
+
self.prob = p
|
| 282 |
+
|
| 283 |
+
def __call__(self, coords_and_feats):
|
| 284 |
+
if np.random.rand() < self.prob:
|
| 285 |
+
scale = np.random.uniform(self.scale[0], self.scale[1])
|
| 286 |
+
coord1 = coords_and_feats[:, :4]
|
| 287 |
+
coord1 *= scale
|
| 288 |
+
coords_and_feats[:, :4] = coord1
|
| 289 |
+
if len(coords_and_feats[0]) > 5:
|
| 290 |
+
coord2 = coords_and_feats[:, 5:]
|
| 291 |
+
coord2 *= scale
|
| 292 |
+
coords_and_feats[:, 5:] = coord2
|
| 293 |
+
return coords_and_feats
|
| 294 |
+
|
| 295 |
+
def __str__(self) -> str:
|
| 296 |
+
return f"RandomScaleCoordsTranslation(p={self.prob}, scale={self.scale})"
|
| 297 |
+
|
| 298 |
+
def __repr__(self) -> str:
|
| 299 |
+
return f"RandomScaleCoordsTranslation(p={self.prob}, scale={self.scale})"
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
class RandomScaleFeats(object):
|
| 303 |
+
def __init__(self, scale=[0.5, 2], p=0.5):
|
| 304 |
+
self.scale = scale
|
| 305 |
+
self.prob = p
|
| 306 |
+
|
| 307 |
+
def __call__(self, coords_and_feats):
|
| 308 |
+
feats = coords_and_feats[:, 4:]
|
| 309 |
+
if np.random.rand() < self.prob:
|
| 310 |
+
scale = np.random.uniform(self.scale[0], self.scale[1])
|
| 311 |
+
feats *= scale
|
| 312 |
+
coords_and_feats[:, 4:] = feats
|
| 313 |
+
return coords_and_feats
|
| 314 |
+
|
| 315 |
+
def __str__(self) -> str:
|
| 316 |
+
return f"RandomScaleFeats(p={self.prob}, scale={self.scale})"
|
| 317 |
+
|
| 318 |
+
def __repr__(self) -> str:
|
| 319 |
+
return f"RandomScaleFeats(p={self.prob}, scale={self.scale})"
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
class RandomShift(object):
|
| 323 |
+
def __init__(self, shift=[5, 5, 5], p=0.5):
|
| 324 |
+
self.shift = shift
|
| 325 |
+
self.prob = p
|
| 326 |
+
|
| 327 |
+
def __call__(self, coords_and_feats):
|
| 328 |
+
coord = coords_and_feats[:, :3]
|
| 329 |
+
if np.random.rand() < self.prob:
|
| 330 |
+
shift_x = np.random.uniform(-self.shift[0], self.shift[0])
|
| 331 |
+
shift_y = np.random.uniform(-self.shift[1], self.shift[1])
|
| 332 |
+
shift_z = np.random.uniform(-self.shift[2], self.shift[2])
|
| 333 |
+
coord += [shift_x, shift_y, shift_z]
|
| 334 |
+
coords_and_feats[:, :3] = coord
|
| 335 |
+
return coords_and_feats
|
| 336 |
+
|
| 337 |
+
def __str__(self) -> str:
|
| 338 |
+
return f"RandomShift(p={self.prob}, shift={self.shift})"
|
| 339 |
+
|
| 340 |
+
def __repr__(self) -> str:
|
| 341 |
+
return f"RandomShift(p={self.prob}, shift={self.shift})"
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
class RandomFlip(object):
|
| 345 |
+
def __init__(self, p=0.5):
|
| 346 |
+
self.prob = p
|
| 347 |
+
|
| 348 |
+
def __call__(self, coords_and_feats):
|
| 349 |
+
coord = coords_and_feats[:, :3]
|
| 350 |
+
if np.random.rand() < self.prob:
|
| 351 |
+
if np.random.rand() < 0.5:
|
| 352 |
+
coord[:, 0] = -coord[:, 0]
|
| 353 |
+
if np.random.rand() < 0.5:
|
| 354 |
+
coord[:, 1] = -coord[:, 1]
|
| 355 |
+
coords_and_feats[:, :3] = coord
|
| 356 |
+
return coords_and_feats
|
| 357 |
+
|
| 358 |
+
def __str__(self) -> str:
|
| 359 |
+
return f"RandomFlip(p={self.prob})"
|
| 360 |
+
|
| 361 |
+
def __repr__(self) -> str:
|
| 362 |
+
return f"RandomFlip(p={self.prob})"
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
class RandomJitter(object):
|
| 366 |
+
def __init__(self, sigma=1, clip=5, p=0.5):
|
| 367 |
+
self.sigma = sigma
|
| 368 |
+
self.clip = clip
|
| 369 |
+
self.prob = p
|
| 370 |
+
|
| 371 |
+
def __call__(self, coords_and_feats):
|
| 372 |
+
coord = coords_and_feats[:, :3]
|
| 373 |
+
assert self.clip > 0
|
| 374 |
+
if np.random.rand() < self.prob:
|
| 375 |
+
jitter = np.clip(
|
| 376 |
+
self.sigma * np.random.randn(coord.shape[0], 3), -self.clip, self.clip
|
| 377 |
+
)
|
| 378 |
+
coord += jitter
|
| 379 |
+
coords_and_feats[:, :3] = coord
|
| 380 |
+
return coords_and_feats
|
| 381 |
+
|
| 382 |
+
def __str__(self) -> str:
|
| 383 |
+
return f"RandomJitter(p={self.prob}, sigma={self.sigma}, clip={self.clip})"
|
| 384 |
+
|
| 385 |
+
def __repr__(self) -> str:
|
| 386 |
+
return f"RandomJitter(p={self.prob}, sigma={self.sigma}, clip={self.clip})"
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
class RandomJitterLength(object):
|
| 390 |
+
def __init__(self, sigma=0.1, clip=1, p=0.5):
|
| 391 |
+
self.sigma = sigma
|
| 392 |
+
self.clip = clip
|
| 393 |
+
self.prob = p
|
| 394 |
+
|
| 395 |
+
def __call__(self, coords_and_feats):
|
| 396 |
+
feats1 = coords_and_feats[:, 3:4]
|
| 397 |
+
assert self.clip > 0
|
| 398 |
+
if np.random.rand() < self.prob:
|
| 399 |
+
jitter1 = np.clip(
|
| 400 |
+
self.sigma * np.random.randn(*feats1.shape), -self.clip, self.clip
|
| 401 |
+
)
|
| 402 |
+
feats1 += jitter1
|
| 403 |
+
if len(coords_and_feats[0]) > 5:
|
| 404 |
+
feats2 = coords_and_feats[:, 5:]
|
| 405 |
+
jitter2 = np.clip(
|
| 406 |
+
self.sigma * np.random.randn(*feats2.shape), -self.clip, self.clip
|
| 407 |
+
)
|
| 408 |
+
feats2 += jitter2
|
| 409 |
+
coords_and_feats[:, 5:] = feats2
|
| 410 |
+
coords_and_feats[:, 3:4] = feats1
|
| 411 |
+
return coords_and_feats
|
| 412 |
+
|
| 413 |
+
def __str__(self) -> str:
|
| 414 |
+
return (
|
| 415 |
+
f"RandomJitterLength(p={self.prob}, sigma={self.sigma}, clip={self.clip})"
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
def __repr__(self) -> str:
|
| 419 |
+
return (
|
| 420 |
+
f"RandomJitterLength(p={self.prob}, sigma={self.sigma}, clip={self.clip})"
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
if __name__ == "__main__":
|
| 425 |
+
transform = RandomScaleCoordsTranslation(p=1)
|
| 426 |
+
|
| 427 |
+
coords_feats = np.random.rand(1024, 3)
|
| 428 |
+
# coords_feats = np.concatenate([
|
| 429 |
+
# np.zeros((1,29)),
|
| 430 |
+
# coords_feats
|
| 431 |
+
# ])
|
| 432 |
+
print(coords_feats[-1])
|
| 433 |
+
transformed = transform(coords_feats)
|
| 434 |
+
print(transformed[-1])
|
| 435 |
+
print(coords_feats[-1])
|
graphformer/losses/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Loss functions for GraPHFormer."""
|
| 2 |
+
|
| 3 |
+
from .infonce import InfoNCELoss, SymmetricInfoNCELoss, HardNegativeInfoNCELoss, MultiModalInfoNCELoss
|
| 4 |
+
from .contrastive import NTXentLoss, TripletLoss, CombinedContrastiveLoss
|
| 5 |
+
|
| 6 |
+
__all__ = [
|
| 7 |
+
"InfoNCELoss", "SymmetricInfoNCELoss", "HardNegativeInfoNCELoss", "MultiModalInfoNCELoss",
|
| 8 |
+
"NTXentLoss", "TripletLoss", "CombinedContrastiveLoss",
|
| 9 |
+
]
|
graphformer/losses/contrastive.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Contrastive Loss Implementations for Neuron Representation Learning
|
| 3 |
+
|
| 4 |
+
Includes:
|
| 5 |
+
- NT-Xent (Normalized Temperature-scaled Cross Entropy) - SimCLR loss
|
| 6 |
+
- Triplet Loss (with various mining strategies)
|
| 7 |
+
- InfoNCE variants (imported from infonce_loss.py)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class NTXentLoss(nn.Module):
|
| 16 |
+
"""
|
| 17 |
+
NT-Xent (Normalized Temperature-scaled Cross Entropy) Loss
|
| 18 |
+
|
| 19 |
+
Used in SimCLR (Chen et al., 2020) - "A Simple Framework for Contrastive Learning"
|
| 20 |
+
|
| 21 |
+
Key features:
|
| 22 |
+
- Normalizes embeddings to unit sphere
|
| 23 |
+
- Uses temperature scaling
|
| 24 |
+
- Treats all other samples in batch as negatives
|
| 25 |
+
- Symmetric loss over both views
|
| 26 |
+
"""
|
| 27 |
+
def __init__(self, temperature=0.5, use_cosine_similarity=True):
|
| 28 |
+
"""
|
| 29 |
+
Args:
|
| 30 |
+
temperature: Temperature parameter for scaling (default 0.5 from SimCLR)
|
| 31 |
+
use_cosine_similarity: If True, use cosine similarity (default True)
|
| 32 |
+
"""
|
| 33 |
+
super(NTXentLoss, self).__init__()
|
| 34 |
+
self.temperature = temperature
|
| 35 |
+
self.use_cosine_similarity = use_cosine_similarity
|
| 36 |
+
|
| 37 |
+
def forward(self, z_i, z_j):
|
| 38 |
+
"""
|
| 39 |
+
Compute NT-Xent loss between two views
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
z_i: (B, D) embeddings from view 1
|
| 43 |
+
z_j: (B, D) embeddings from view 2
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
loss: scalar NT-Xent loss
|
| 47 |
+
"""
|
| 48 |
+
batch_size = z_i.shape[0]
|
| 49 |
+
|
| 50 |
+
# Normalize embeddings
|
| 51 |
+
z_i = F.normalize(z_i, dim=1)
|
| 52 |
+
z_j = F.normalize(z_j, dim=1)
|
| 53 |
+
|
| 54 |
+
# Concatenate both views: (2B, D)
|
| 55 |
+
z = torch.cat([z_i, z_j], dim=0)
|
| 56 |
+
|
| 57 |
+
# Compute similarity matrix: (2B, 2B)
|
| 58 |
+
if self.use_cosine_similarity:
|
| 59 |
+
similarity_matrix = torch.matmul(z, z.T)
|
| 60 |
+
else:
|
| 61 |
+
# Dot product similarity
|
| 62 |
+
similarity_matrix = torch.matmul(z, z.T)
|
| 63 |
+
|
| 64 |
+
# Remove diagonal elements (self-similarity)
|
| 65 |
+
# Create mask to exclude diagonal
|
| 66 |
+
mask = torch.eye(2 * batch_size, dtype=torch.bool, device=z.device)
|
| 67 |
+
similarity_matrix = similarity_matrix.masked_fill(mask, -9e15)
|
| 68 |
+
|
| 69 |
+
# Apply temperature scaling
|
| 70 |
+
similarity_matrix = similarity_matrix / self.temperature
|
| 71 |
+
|
| 72 |
+
# Positive pairs are at positions (i, i+B) and (i+B, i)
|
| 73 |
+
# For each sample, its positive is at distance B
|
| 74 |
+
positive_samples = torch.cat([
|
| 75 |
+
torch.arange(batch_size, 2 * batch_size), # Positives for first half
|
| 76 |
+
torch.arange(0, batch_size) # Positives for second half
|
| 77 |
+
], dim=0).to(z.device)
|
| 78 |
+
|
| 79 |
+
# Extract positive similarities
|
| 80 |
+
positive_sim = similarity_matrix[torch.arange(2 * batch_size), positive_samples]
|
| 81 |
+
positive_sim = positive_sim.reshape(2 * batch_size, 1)
|
| 82 |
+
|
| 83 |
+
# Negative similarities: all other samples (already in similarity_matrix)
|
| 84 |
+
# For numerical stability, use log-sum-exp trick
|
| 85 |
+
negatives_mask = ~mask
|
| 86 |
+
negatives_mask[torch.arange(2 * batch_size), positive_samples] = False
|
| 87 |
+
|
| 88 |
+
# Compute loss using LogSumExp
|
| 89 |
+
# loss = -log(exp(pos) / (exp(pos) + sum(exp(neg))))
|
| 90 |
+
# For numerical stability: -pos + log(exp(pos) + sum(exp(neg)))
|
| 91 |
+
|
| 92 |
+
# Get all similarities (positive at correct position + negatives)
|
| 93 |
+
all_similarities = similarity_matrix # (2B, 2B)
|
| 94 |
+
|
| 95 |
+
# Create labels for cross-entropy: positive is at position batch_size or 0
|
| 96 |
+
labels = positive_samples
|
| 97 |
+
|
| 98 |
+
# Use cross-entropy loss (equivalent to NT-Xent)
|
| 99 |
+
loss = F.cross_entropy(all_similarities, labels)
|
| 100 |
+
|
| 101 |
+
return loss
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class TripletLoss(nn.Module):
|
| 105 |
+
"""
|
| 106 |
+
Triplet Loss for metric learning
|
| 107 |
+
|
| 108 |
+
L = max(d(a, p) - d(a, n) + margin, 0)
|
| 109 |
+
|
| 110 |
+
Where:
|
| 111 |
+
- a: anchor
|
| 112 |
+
- p: positive (same class as anchor)
|
| 113 |
+
- n: negative (different class)
|
| 114 |
+
- d: distance metric (L2 or cosine)
|
| 115 |
+
"""
|
| 116 |
+
def __init__(self, margin=1.0, distance_metric='euclidean', mining='batch_hard'):
|
| 117 |
+
"""
|
| 118 |
+
Args:
|
| 119 |
+
margin: Margin for triplet loss
|
| 120 |
+
distance_metric: 'euclidean' or 'cosine'
|
| 121 |
+
mining: 'batch_hard', 'batch_all', or 'semi_hard'
|
| 122 |
+
"""
|
| 123 |
+
super(TripletLoss, self).__init__()
|
| 124 |
+
self.margin = margin
|
| 125 |
+
self.distance_metric = distance_metric
|
| 126 |
+
self.mining = mining
|
| 127 |
+
|
| 128 |
+
def _compute_distance_matrix(self, embeddings):
|
| 129 |
+
"""Compute pairwise distance matrix."""
|
| 130 |
+
if self.distance_metric == 'euclidean':
|
| 131 |
+
# Efficient euclidean distance: ||a-b||^2 = ||a||^2 + ||b||^2 - 2*a·b
|
| 132 |
+
dot_product = torch.matmul(embeddings, embeddings.T)
|
| 133 |
+
square_norm = torch.diag(dot_product)
|
| 134 |
+
distances = square_norm.unsqueeze(1) - 2.0 * dot_product + square_norm.unsqueeze(0)
|
| 135 |
+
distances = torch.clamp(distances, min=0.0) # Numerical stability
|
| 136 |
+
distances = torch.sqrt(distances + 1e-16)
|
| 137 |
+
elif self.distance_metric == 'cosine':
|
| 138 |
+
# Cosine distance = 1 - cosine_similarity
|
| 139 |
+
embeddings_norm = F.normalize(embeddings, dim=1)
|
| 140 |
+
cosine_sim = torch.matmul(embeddings_norm, embeddings_norm.T)
|
| 141 |
+
distances = 1.0 - cosine_sim
|
| 142 |
+
else:
|
| 143 |
+
raise ValueError(f"Unknown distance metric: {self.distance_metric}")
|
| 144 |
+
|
| 145 |
+
return distances
|
| 146 |
+
|
| 147 |
+
def _batch_hard_mining(self, embeddings, labels):
|
| 148 |
+
"""
|
| 149 |
+
Batch hard mining: for each anchor, select hardest positive and hardest negative.
|
| 150 |
+
|
| 151 |
+
Hardest positive: furthest positive sample
|
| 152 |
+
Hardest negative: closest negative sample
|
| 153 |
+
"""
|
| 154 |
+
batch_size = embeddings.shape[0]
|
| 155 |
+
|
| 156 |
+
# Compute pairwise distances
|
| 157 |
+
distances = self._compute_distance_matrix(embeddings)
|
| 158 |
+
|
| 159 |
+
# Create masks for positives and negatives
|
| 160 |
+
labels = labels.unsqueeze(1)
|
| 161 |
+
positive_mask = (labels == labels.T).float()
|
| 162 |
+
negative_mask = (labels != labels.T).float()
|
| 163 |
+
|
| 164 |
+
# Remove self-comparisons from positive mask
|
| 165 |
+
positive_mask = positive_mask - torch.eye(batch_size, device=embeddings.device)
|
| 166 |
+
|
| 167 |
+
# Hard positive: maximum distance among positives
|
| 168 |
+
# Set non-positive distances to 0 so they won't be selected
|
| 169 |
+
positive_distances = distances * positive_mask
|
| 170 |
+
hardest_positive_dist, _ = torch.max(positive_distances, dim=1)
|
| 171 |
+
|
| 172 |
+
# Hard negative: minimum distance among negatives
|
| 173 |
+
# Set non-negative distances to large value so they won't be selected
|
| 174 |
+
negative_distances = distances + (1.0 - negative_mask) * 1e9
|
| 175 |
+
hardest_negative_dist, _ = torch.min(negative_distances, dim=1)
|
| 176 |
+
|
| 177 |
+
# Compute triplet loss
|
| 178 |
+
triplet_loss = F.relu(hardest_positive_dist - hardest_negative_dist + self.margin)
|
| 179 |
+
|
| 180 |
+
return triplet_loss.mean()
|
| 181 |
+
|
| 182 |
+
def _batch_all_mining(self, embeddings, labels):
|
| 183 |
+
"""
|
| 184 |
+
Batch all mining: use all valid triplets in the batch.
|
| 185 |
+
"""
|
| 186 |
+
batch_size = embeddings.shape[0]
|
| 187 |
+
|
| 188 |
+
# Compute pairwise distances
|
| 189 |
+
distances = self._compute_distance_matrix(embeddings)
|
| 190 |
+
|
| 191 |
+
# Create masks
|
| 192 |
+
labels = labels.unsqueeze(1)
|
| 193 |
+
positive_mask = (labels == labels.T).float()
|
| 194 |
+
negative_mask = (labels != labels.T).float()
|
| 195 |
+
|
| 196 |
+
# Remove self-comparisons
|
| 197 |
+
positive_mask = positive_mask - torch.eye(batch_size, device=embeddings.device)
|
| 198 |
+
|
| 199 |
+
# Get anchor-positive distances: (B, B)
|
| 200 |
+
anchor_positive_dist = distances.unsqueeze(2) # (B, B, 1)
|
| 201 |
+
|
| 202 |
+
# Get anchor-negative distances: (B, B)
|
| 203 |
+
anchor_negative_dist = distances.unsqueeze(1) # (B, 1, B)
|
| 204 |
+
|
| 205 |
+
# Compute triplet loss for all valid triplets
|
| 206 |
+
triplet_loss = anchor_positive_dist - anchor_negative_dist + self.margin
|
| 207 |
+
|
| 208 |
+
# Mask out invalid triplets
|
| 209 |
+
# Valid triplet: (i, j, k) where label[i] == label[j] != label[k]
|
| 210 |
+
valid_triplets = positive_mask.unsqueeze(2) * negative_mask.unsqueeze(1)
|
| 211 |
+
|
| 212 |
+
# Apply mask and ReLU
|
| 213 |
+
triplet_loss = triplet_loss * valid_triplets
|
| 214 |
+
triplet_loss = F.relu(triplet_loss)
|
| 215 |
+
|
| 216 |
+
# Count valid triplets
|
| 217 |
+
num_valid = valid_triplets.sum()
|
| 218 |
+
|
| 219 |
+
if num_valid > 0:
|
| 220 |
+
triplet_loss = triplet_loss.sum() / num_valid
|
| 221 |
+
else:
|
| 222 |
+
triplet_loss = torch.tensor(0.0, device=embeddings.device)
|
| 223 |
+
|
| 224 |
+
return triplet_loss
|
| 225 |
+
|
| 226 |
+
def _semi_hard_mining(self, embeddings, labels):
|
| 227 |
+
"""
|
| 228 |
+
Semi-hard mining: select negatives that are harder than positive but still within margin.
|
| 229 |
+
|
| 230 |
+
Semi-hard negative: d(a,p) < d(a,n) < d(a,p) + margin
|
| 231 |
+
"""
|
| 232 |
+
batch_size = embeddings.shape[0]
|
| 233 |
+
|
| 234 |
+
# Compute pairwise distances
|
| 235 |
+
distances = self._compute_distance_matrix(embeddings)
|
| 236 |
+
|
| 237 |
+
# Create masks
|
| 238 |
+
labels = labels.unsqueeze(1)
|
| 239 |
+
positive_mask = (labels == labels.T).float()
|
| 240 |
+
negative_mask = (labels != labels.T).float()
|
| 241 |
+
|
| 242 |
+
# Remove self-comparisons
|
| 243 |
+
positive_mask = positive_mask - torch.eye(batch_size, device=embeddings.device)
|
| 244 |
+
|
| 245 |
+
losses = []
|
| 246 |
+
|
| 247 |
+
for i in range(batch_size):
|
| 248 |
+
# Get positive distances for anchor i
|
| 249 |
+
pos_dists = distances[i] * positive_mask[i]
|
| 250 |
+
if pos_dists.sum() == 0:
|
| 251 |
+
continue
|
| 252 |
+
|
| 253 |
+
# Select a positive (use hardest for stability)
|
| 254 |
+
pos_dist = pos_dists.max()
|
| 255 |
+
|
| 256 |
+
# Get negative distances for anchor i
|
| 257 |
+
neg_dists = distances[i] * negative_mask[i]
|
| 258 |
+
|
| 259 |
+
# Semi-hard negatives: pos_dist < neg_dist < pos_dist + margin
|
| 260 |
+
semi_hard_mask = (neg_dists > pos_dist) & (neg_dists < pos_dist + self.margin)
|
| 261 |
+
|
| 262 |
+
if semi_hard_mask.any():
|
| 263 |
+
# Use hardest semi-hard negative (closest to anchor)
|
| 264 |
+
semi_hard_negatives = neg_dists.clone()
|
| 265 |
+
semi_hard_negatives[~semi_hard_mask] = 1e9
|
| 266 |
+
neg_dist = semi_hard_negatives.min()
|
| 267 |
+
else:
|
| 268 |
+
# Fall back to hardest negative
|
| 269 |
+
neg_dists_masked = neg_dists + (1.0 - negative_mask[i]) * 1e9
|
| 270 |
+
neg_dist = neg_dists_masked.min()
|
| 271 |
+
|
| 272 |
+
# Compute triplet loss
|
| 273 |
+
loss = F.relu(pos_dist - neg_dist + self.margin)
|
| 274 |
+
losses.append(loss)
|
| 275 |
+
|
| 276 |
+
if len(losses) > 0:
|
| 277 |
+
return torch.stack(losses).mean()
|
| 278 |
+
else:
|
| 279 |
+
return torch.tensor(0.0, device=embeddings.device)
|
| 280 |
+
|
| 281 |
+
def forward(self, embeddings, labels):
|
| 282 |
+
"""
|
| 283 |
+
Compute triplet loss
|
| 284 |
+
|
| 285 |
+
Args:
|
| 286 |
+
embeddings: (B, D) embeddings
|
| 287 |
+
labels: (B,) class labels for each sample
|
| 288 |
+
|
| 289 |
+
Returns:
|
| 290 |
+
loss: scalar triplet loss
|
| 291 |
+
"""
|
| 292 |
+
if self.mining == 'batch_hard':
|
| 293 |
+
return self._batch_hard_mining(embeddings, labels)
|
| 294 |
+
elif self.mining == 'batch_all':
|
| 295 |
+
return self._batch_all_mining(embeddings, labels)
|
| 296 |
+
elif self.mining == 'semi_hard':
|
| 297 |
+
return self._semi_hard_mining(embeddings, labels)
|
| 298 |
+
else:
|
| 299 |
+
raise ValueError(f"Unknown mining strategy: {self.mining}")
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
class CombinedContrastiveLoss(nn.Module):
|
| 303 |
+
"""
|
| 304 |
+
Combine multiple contrastive losses with configurable weights
|
| 305 |
+
|
| 306 |
+
Example: NT-Xent + Triplet Loss
|
| 307 |
+
"""
|
| 308 |
+
def __init__(self, loss_types=['ntxent'], loss_weights=None, **loss_kwargs):
|
| 309 |
+
"""
|
| 310 |
+
Args:
|
| 311 |
+
loss_types: List of loss types ('ntxent', 'triplet', 'infonce')
|
| 312 |
+
loss_weights: List of weights for each loss (default: equal weights)
|
| 313 |
+
**loss_kwargs: Keyword arguments for each loss
|
| 314 |
+
- ntxent_temperature: Temperature for NT-Xent
|
| 315 |
+
- triplet_margin: Margin for Triplet Loss
|
| 316 |
+
- triplet_mining: Mining strategy for Triplet Loss
|
| 317 |
+
"""
|
| 318 |
+
super(CombinedContrastiveLoss, self).__init__()
|
| 319 |
+
self.loss_types = loss_types
|
| 320 |
+
|
| 321 |
+
if loss_weights is None:
|
| 322 |
+
self.loss_weights = [1.0] * len(loss_types)
|
| 323 |
+
else:
|
| 324 |
+
self.loss_weights = loss_weights
|
| 325 |
+
|
| 326 |
+
# Initialize losses
|
| 327 |
+
self.losses = nn.ModuleDict()
|
| 328 |
+
|
| 329 |
+
for loss_type in loss_types:
|
| 330 |
+
if loss_type == 'ntxent':
|
| 331 |
+
temp = loss_kwargs.get('ntxent_temperature', 0.5)
|
| 332 |
+
self.losses['ntxent'] = NTXentLoss(temperature=temp)
|
| 333 |
+
elif loss_type == 'triplet':
|
| 334 |
+
margin = loss_kwargs.get('triplet_margin', 1.0)
|
| 335 |
+
mining = loss_kwargs.get('triplet_mining', 'batch_hard')
|
| 336 |
+
distance = loss_kwargs.get('triplet_distance', 'euclidean')
|
| 337 |
+
self.losses['triplet'] = TripletLoss(
|
| 338 |
+
margin=margin,
|
| 339 |
+
distance_metric=distance,
|
| 340 |
+
mining=mining
|
| 341 |
+
)
|
| 342 |
+
else:
|
| 343 |
+
raise ValueError(f"Unknown loss type: {loss_type}")
|
| 344 |
+
|
| 345 |
+
def forward(self, z_i, z_j, labels=None):
|
| 346 |
+
"""
|
| 347 |
+
Compute combined loss
|
| 348 |
+
|
| 349 |
+
Args:
|
| 350 |
+
z_i: (B, D) embeddings from view 1
|
| 351 |
+
z_j: (B, D) embeddings from view 2
|
| 352 |
+
labels: (B,) class labels (required for triplet loss)
|
| 353 |
+
|
| 354 |
+
Returns:
|
| 355 |
+
loss: scalar combined loss
|
| 356 |
+
loss_dict: dictionary with individual loss values
|
| 357 |
+
"""
|
| 358 |
+
total_loss = 0.0
|
| 359 |
+
loss_dict = {}
|
| 360 |
+
|
| 361 |
+
for i, loss_type in enumerate(self.loss_types):
|
| 362 |
+
weight = self.loss_weights[i]
|
| 363 |
+
|
| 364 |
+
if loss_type == 'ntxent':
|
| 365 |
+
loss_val = self.losses['ntxent'](z_i, z_j)
|
| 366 |
+
elif loss_type == 'triplet':
|
| 367 |
+
if labels is None:
|
| 368 |
+
raise ValueError("Triplet loss requires labels")
|
| 369 |
+
# Combine both views for triplet loss
|
| 370 |
+
embeddings = torch.cat([z_i, z_j], dim=0)
|
| 371 |
+
combined_labels = torch.cat([labels, labels], dim=0)
|
| 372 |
+
loss_val = self.losses['triplet'](embeddings, combined_labels)
|
| 373 |
+
|
| 374 |
+
loss_dict[loss_type] = loss_val.item()
|
| 375 |
+
total_loss += weight * loss_val
|
| 376 |
+
|
| 377 |
+
loss_dict['total'] = total_loss.item()
|
| 378 |
+
|
| 379 |
+
return total_loss, loss_dict
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
if __name__ == "__main__":
|
| 383 |
+
print("Testing Contrastive Loss Implementations...")
|
| 384 |
+
print("=" * 60)
|
| 385 |
+
|
| 386 |
+
B, D = 32, 128
|
| 387 |
+
num_classes = 10
|
| 388 |
+
|
| 389 |
+
# Test 1: NT-Xent Loss
|
| 390 |
+
print("\n1. NT-Xent Loss (SimCLR):")
|
| 391 |
+
ntxent = NTXentLoss(temperature=0.5)
|
| 392 |
+
|
| 393 |
+
z_i = torch.randn(B, D)
|
| 394 |
+
z_j = torch.randn(B, D)
|
| 395 |
+
|
| 396 |
+
loss = ntxent(z_i, z_j)
|
| 397 |
+
print(f" Loss: {loss.item():.4f}")
|
| 398 |
+
|
| 399 |
+
# Test 2: Triplet Loss (Batch Hard)
|
| 400 |
+
print("\n2. Triplet Loss (Batch Hard Mining):")
|
| 401 |
+
triplet = TripletLoss(margin=1.0, mining='batch_hard')
|
| 402 |
+
|
| 403 |
+
embeddings = torch.randn(B, D)
|
| 404 |
+
labels = torch.randint(0, num_classes, (B,))
|
| 405 |
+
|
| 406 |
+
loss = triplet(embeddings, labels)
|
| 407 |
+
print(f" Loss: {loss.item():.4f}")
|
| 408 |
+
|
| 409 |
+
# Test 3: Triplet Loss (Batch All)
|
| 410 |
+
print("\n3. Triplet Loss (Batch All Mining):")
|
| 411 |
+
triplet_all = TripletLoss(margin=1.0, mining='batch_all')
|
| 412 |
+
|
| 413 |
+
loss = triplet_all(embeddings, labels)
|
| 414 |
+
print(f" Loss: {loss.item():.4f}")
|
| 415 |
+
|
| 416 |
+
# Test 4: Triplet Loss (Semi-Hard)
|
| 417 |
+
print("\n4. Triplet Loss (Semi-Hard Mining):")
|
| 418 |
+
triplet_semi = TripletLoss(margin=1.0, mining='semi_hard')
|
| 419 |
+
|
| 420 |
+
loss = triplet_semi(embeddings, labels)
|
| 421 |
+
print(f" Loss: {loss.item():.4f}")
|
| 422 |
+
|
| 423 |
+
# Test 5: Combined Loss (NT-Xent + Triplet)
|
| 424 |
+
print("\n5. Combined Loss (NT-Xent + Triplet):")
|
| 425 |
+
combined = CombinedContrastiveLoss(
|
| 426 |
+
loss_types=['ntxent', 'triplet'],
|
| 427 |
+
loss_weights=[1.0, 0.5],
|
| 428 |
+
ntxent_temperature=0.5,
|
| 429 |
+
triplet_margin=1.0,
|
| 430 |
+
triplet_mining='batch_hard'
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
z_i = torch.randn(B, D)
|
| 434 |
+
z_j = torch.randn(B, D)
|
| 435 |
+
labels = torch.randint(0, num_classes, (B,))
|
| 436 |
+
|
| 437 |
+
loss, loss_dict = combined(z_i, z_j, labels)
|
| 438 |
+
print(f" Total Loss: {loss.item():.4f}")
|
| 439 |
+
print(f" Loss breakdown: {loss_dict}")
|
| 440 |
+
|
| 441 |
+
print("\n" + "=" * 60)
|
| 442 |
+
print("All tests passed!")
|
graphformer/losses/infonce.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
InfoNCE Loss Implementation
|
| 3 |
+
|
| 4 |
+
InfoNCE (Information Noise-Contrastive Estimation) loss from:
|
| 5 |
+
"Representation Learning with Contrastive Predictive Coding" (van den Oord et al., 2018)
|
| 6 |
+
|
| 7 |
+
Used in many contrastive learning methods like MoCo, SimCLR, etc.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class InfoNCELoss(nn.Module):
|
| 16 |
+
"""
|
| 17 |
+
InfoNCE loss for contrastive learning
|
| 18 |
+
|
| 19 |
+
The loss encourages positive pairs to have high similarity while
|
| 20 |
+
negative pairs have low similarity using a contrastive objective.
|
| 21 |
+
"""
|
| 22 |
+
def __init__(self, temperature=0.07, reduction='mean'):
|
| 23 |
+
"""
|
| 24 |
+
Args:
|
| 25 |
+
temperature: Temperature parameter for scaling similarities
|
| 26 |
+
reduction: 'mean' or 'sum' for loss reduction
|
| 27 |
+
"""
|
| 28 |
+
super(InfoNCELoss, self).__init__()
|
| 29 |
+
self.temperature = temperature
|
| 30 |
+
self.reduction = reduction
|
| 31 |
+
|
| 32 |
+
def forward(self, anchor, positive, negatives=None):
|
| 33 |
+
"""
|
| 34 |
+
Compute InfoNCE loss
|
| 35 |
+
|
| 36 |
+
Args:
|
| 37 |
+
anchor: (B, D) anchor embeddings
|
| 38 |
+
positive: (B, D) positive embeddings (paired with anchor)
|
| 39 |
+
negatives: (B, N, D) or None - negative embeddings
|
| 40 |
+
If None, uses all other samples in batch as negatives (in-batch negatives)
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
loss: scalar InfoNCE loss
|
| 44 |
+
"""
|
| 45 |
+
# Normalize embeddings
|
| 46 |
+
anchor = F.normalize(anchor, dim=-1)
|
| 47 |
+
positive = F.normalize(positive, dim=-1)
|
| 48 |
+
|
| 49 |
+
B = anchor.shape[0]
|
| 50 |
+
|
| 51 |
+
if negatives is None:
|
| 52 |
+
# Use in-batch negatives (like SimCLR)
|
| 53 |
+
# Each sample uses all other samples as negatives
|
| 54 |
+
|
| 55 |
+
# Positive similarity: (B,)
|
| 56 |
+
pos_sim = torch.sum(anchor * positive, dim=-1) / self.temperature
|
| 57 |
+
|
| 58 |
+
# Negative similarities: (B, B)
|
| 59 |
+
# anchor vs all positives (including itself)
|
| 60 |
+
neg_sim = torch.matmul(anchor, positive.T) / self.temperature
|
| 61 |
+
|
| 62 |
+
# Create labels (diagonal elements are positive pairs)
|
| 63 |
+
labels = torch.arange(B, device=anchor.device)
|
| 64 |
+
|
| 65 |
+
# InfoNCE loss using cross-entropy
|
| 66 |
+
loss = F.cross_entropy(neg_sim, labels, reduction=self.reduction)
|
| 67 |
+
|
| 68 |
+
else:
|
| 69 |
+
# Explicit negatives provided
|
| 70 |
+
negatives = F.normalize(negatives, dim=-1)
|
| 71 |
+
|
| 72 |
+
# Positive similarity: (B,)
|
| 73 |
+
pos_sim = torch.sum(anchor * positive, dim=-1, keepdim=True) / self.temperature
|
| 74 |
+
|
| 75 |
+
# Negative similarities: (B, N)
|
| 76 |
+
neg_sim = torch.matmul(anchor.unsqueeze(1), negatives.transpose(1, 2)).squeeze(1) / self.temperature
|
| 77 |
+
|
| 78 |
+
# Concatenate positive and negative similarities
|
| 79 |
+
logits = torch.cat([pos_sim, neg_sim], dim=1) # (B, 1+N)
|
| 80 |
+
|
| 81 |
+
# Labels: first position (index 0) is the positive
|
| 82 |
+
labels = torch.zeros(B, dtype=torch.long, device=anchor.device)
|
| 83 |
+
|
| 84 |
+
# InfoNCE loss
|
| 85 |
+
loss = F.cross_entropy(logits, labels, reduction=self.reduction)
|
| 86 |
+
|
| 87 |
+
return loss
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class SymmetricInfoNCELoss(nn.Module):
|
| 91 |
+
"""
|
| 92 |
+
Symmetric InfoNCE loss (like CLIP)
|
| 93 |
+
|
| 94 |
+
Computes InfoNCE in both directions:
|
| 95 |
+
- anchor -> positive (image -> text in CLIP)
|
| 96 |
+
- positive -> anchor (text -> image in CLIP)
|
| 97 |
+
|
| 98 |
+
Then averages the two losses.
|
| 99 |
+
"""
|
| 100 |
+
def __init__(self, temperature=0.07):
|
| 101 |
+
super(SymmetricInfoNCELoss, self).__init__()
|
| 102 |
+
self.temperature = temperature
|
| 103 |
+
|
| 104 |
+
def forward(self, embedding_a, embedding_b):
|
| 105 |
+
"""
|
| 106 |
+
Compute symmetric InfoNCE loss between two sets of embeddings
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
embedding_a: (B, D) - first modality (e.g., tree embeddings)
|
| 110 |
+
embedding_b: (B, D) - second modality (e.g., image embeddings)
|
| 111 |
+
|
| 112 |
+
Returns:
|
| 113 |
+
loss: scalar symmetric InfoNCE loss
|
| 114 |
+
"""
|
| 115 |
+
# Normalize
|
| 116 |
+
embedding_a = F.normalize(embedding_a, dim=-1)
|
| 117 |
+
embedding_b = F.normalize(embedding_b, dim=-1)
|
| 118 |
+
|
| 119 |
+
B = embedding_a.shape[0]
|
| 120 |
+
|
| 121 |
+
# Compute similarity matrix: (B, B)
|
| 122 |
+
similarity = torch.matmul(embedding_a, embedding_b.T) / self.temperature
|
| 123 |
+
|
| 124 |
+
# Labels: diagonal elements are positive pairs
|
| 125 |
+
labels = torch.arange(B, device=embedding_a.device)
|
| 126 |
+
|
| 127 |
+
# Loss in both directions
|
| 128 |
+
loss_a2b = F.cross_entropy(similarity, labels)
|
| 129 |
+
loss_b2a = F.cross_entropy(similarity.T, labels)
|
| 130 |
+
|
| 131 |
+
# Average
|
| 132 |
+
loss = (loss_a2b + loss_b2a) / 2
|
| 133 |
+
|
| 134 |
+
return loss
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class HardNegativeInfoNCELoss(nn.Module):
|
| 138 |
+
"""
|
| 139 |
+
InfoNCE with hard negative mining
|
| 140 |
+
|
| 141 |
+
Selects the hardest negatives (highest similarity) for each anchor
|
| 142 |
+
to make training more challenging and effective.
|
| 143 |
+
"""
|
| 144 |
+
def __init__(self, temperature=0.07, num_hard_negatives=10):
|
| 145 |
+
"""
|
| 146 |
+
Args:
|
| 147 |
+
temperature: Temperature parameter
|
| 148 |
+
num_hard_negatives: Number of hard negatives to mine per anchor
|
| 149 |
+
"""
|
| 150 |
+
super(HardNegativeInfoNCELoss, self).__init__()
|
| 151 |
+
self.temperature = temperature
|
| 152 |
+
self.num_hard_negatives = num_hard_negatives
|
| 153 |
+
|
| 154 |
+
def forward(self, anchor, positive, negative_pool):
|
| 155 |
+
"""
|
| 156 |
+
Compute InfoNCE with hard negative mining
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
anchor: (B, D) anchor embeddings
|
| 160 |
+
positive: (B, D) positive embeddings
|
| 161 |
+
negative_pool: (M, D) pool of negative embeddings (M >> B)
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
loss: scalar InfoNCE loss with hard negatives
|
| 165 |
+
"""
|
| 166 |
+
# Normalize
|
| 167 |
+
anchor = F.normalize(anchor, dim=-1)
|
| 168 |
+
positive = F.normalize(positive, dim=-1)
|
| 169 |
+
negative_pool = F.normalize(negative_pool, dim=-1)
|
| 170 |
+
|
| 171 |
+
B = anchor.shape[0]
|
| 172 |
+
|
| 173 |
+
# Positive similarity: (B,)
|
| 174 |
+
pos_sim = torch.sum(anchor * positive, dim=-1, keepdim=True) / self.temperature
|
| 175 |
+
|
| 176 |
+
# Compute similarity to all negatives: (B, M)
|
| 177 |
+
all_neg_sim = torch.matmul(anchor, negative_pool.T) / self.temperature
|
| 178 |
+
|
| 179 |
+
# Select top-k hard negatives (highest similarity = hardest)
|
| 180 |
+
hard_neg_sim, _ = torch.topk(all_neg_sim, k=self.num_hard_negatives, dim=1)
|
| 181 |
+
|
| 182 |
+
# Concatenate positive and hard negative similarities
|
| 183 |
+
logits = torch.cat([pos_sim, hard_neg_sim], dim=1) # (B, 1+K)
|
| 184 |
+
|
| 185 |
+
# Labels: first position is positive
|
| 186 |
+
labels = torch.zeros(B, dtype=torch.long, device=anchor.device)
|
| 187 |
+
|
| 188 |
+
# InfoNCE loss
|
| 189 |
+
loss = F.cross_entropy(logits, labels)
|
| 190 |
+
|
| 191 |
+
return loss
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class MultiModalInfoNCELoss(nn.Module):
|
| 195 |
+
"""
|
| 196 |
+
Multi-modal InfoNCE for learning joint embeddings across multiple modalities
|
| 197 |
+
|
| 198 |
+
Example: tree structure + persistence image + graph features
|
| 199 |
+
"""
|
| 200 |
+
def __init__(self, temperature=0.07, weight_modalities=None):
|
| 201 |
+
"""
|
| 202 |
+
Args:
|
| 203 |
+
temperature: Temperature parameter
|
| 204 |
+
weight_modalities: List of weights for each modality pair loss
|
| 205 |
+
"""
|
| 206 |
+
super(MultiModalInfoNCELoss, self).__init__()
|
| 207 |
+
self.temperature = temperature
|
| 208 |
+
self.weight_modalities = weight_modalities
|
| 209 |
+
|
| 210 |
+
def forward(self, embeddings):
|
| 211 |
+
"""
|
| 212 |
+
Compute InfoNCE across all modality pairs
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
embeddings: List of (B, D) embeddings for each modality
|
| 216 |
+
|
| 217 |
+
Returns:
|
| 218 |
+
loss: scalar multi-modal InfoNCE loss
|
| 219 |
+
"""
|
| 220 |
+
num_modalities = len(embeddings)
|
| 221 |
+
|
| 222 |
+
# Normalize all embeddings
|
| 223 |
+
embeddings = [F.normalize(emb, dim=-1) for emb in embeddings]
|
| 224 |
+
|
| 225 |
+
B = embeddings[0].shape[0]
|
| 226 |
+
labels = torch.arange(B, device=embeddings[0].device)
|
| 227 |
+
|
| 228 |
+
# Compute loss for all pairs of modalities
|
| 229 |
+
total_loss = 0
|
| 230 |
+
num_pairs = 0
|
| 231 |
+
|
| 232 |
+
for i in range(num_modalities):
|
| 233 |
+
for j in range(i + 1, num_modalities):
|
| 234 |
+
# Similarity matrix
|
| 235 |
+
similarity = torch.matmul(embeddings[i], embeddings[j].T) / self.temperature
|
| 236 |
+
|
| 237 |
+
# Symmetric loss
|
| 238 |
+
loss_ij = F.cross_entropy(similarity, labels)
|
| 239 |
+
loss_ji = F.cross_entropy(similarity.T, labels)
|
| 240 |
+
pair_loss = (loss_ij + loss_ji) / 2
|
| 241 |
+
|
| 242 |
+
# Apply weight if provided
|
| 243 |
+
if self.weight_modalities is not None:
|
| 244 |
+
weight = self.weight_modalities[num_pairs]
|
| 245 |
+
pair_loss = weight * pair_loss
|
| 246 |
+
|
| 247 |
+
total_loss += pair_loss
|
| 248 |
+
num_pairs += 1
|
| 249 |
+
|
| 250 |
+
# Average over all pairs
|
| 251 |
+
loss = total_loss / num_pairs
|
| 252 |
+
|
| 253 |
+
return loss
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
if __name__ == "__main__":
|
| 257 |
+
print("Testing InfoNCE Loss implementations...")
|
| 258 |
+
|
| 259 |
+
B, D = 32, 128
|
| 260 |
+
|
| 261 |
+
# Test 1: Basic InfoNCE with in-batch negatives
|
| 262 |
+
print("\n1. Basic InfoNCE Loss:")
|
| 263 |
+
loss_fn = InfoNCELoss(temperature=0.07)
|
| 264 |
+
|
| 265 |
+
anchor = torch.randn(B, D)
|
| 266 |
+
positive = torch.randn(B, D)
|
| 267 |
+
|
| 268 |
+
loss = loss_fn(anchor, positive)
|
| 269 |
+
print(f" Loss: {loss.item():.4f}")
|
| 270 |
+
|
| 271 |
+
# Test 2: Symmetric InfoNCE (CLIP-style)
|
| 272 |
+
print("\n2. Symmetric InfoNCE Loss (CLIP-style):")
|
| 273 |
+
symmetric_loss = SymmetricInfoNCELoss(temperature=0.07)
|
| 274 |
+
|
| 275 |
+
tree_embeddings = torch.randn(B, D)
|
| 276 |
+
image_embeddings = torch.randn(B, D)
|
| 277 |
+
|
| 278 |
+
loss = symmetric_loss(tree_embeddings, image_embeddings)
|
| 279 |
+
print(f" Loss: {loss.item():.4f}")
|
| 280 |
+
|
| 281 |
+
# Test 3: Hard Negative Mining
|
| 282 |
+
print("\n3. InfoNCE with Hard Negative Mining:")
|
| 283 |
+
hard_neg_loss = HardNegativeInfoNCELoss(temperature=0.07, num_hard_negatives=10)
|
| 284 |
+
|
| 285 |
+
anchor = torch.randn(B, D)
|
| 286 |
+
positive = torch.randn(B, D)
|
| 287 |
+
negative_pool = torch.randn(500, D) # Large pool of negatives
|
| 288 |
+
|
| 289 |
+
loss = hard_neg_loss(anchor, positive, negative_pool)
|
| 290 |
+
print(f" Loss: {loss.item():.4f}")
|
| 291 |
+
|
| 292 |
+
# Test 4: Multi-modal InfoNCE
|
| 293 |
+
print("\n4. Multi-modal InfoNCE:")
|
| 294 |
+
multimodal_loss = MultiModalInfoNCELoss(temperature=0.07)
|
| 295 |
+
|
| 296 |
+
tree_emb = torch.randn(B, D)
|
| 297 |
+
image_emb = torch.randn(B, D)
|
| 298 |
+
graph_emb = torch.randn(B, D)
|
| 299 |
+
|
| 300 |
+
loss = multimodal_loss([tree_emb, image_emb, graph_emb])
|
| 301 |
+
print(f" Loss: {loss.item():.4f}")
|
| 302 |
+
|
| 303 |
+
print("\nAll tests passed!")
|
graphformer/models/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model components for GraPHFormer."""
|
| 2 |
+
|
| 3 |
+
from .clip_model import CLIPModel, CLIPLoss
|
| 4 |
+
from .finetune_model import FineTuneModel, ArcMarginProduct
|
| 5 |
+
from .tree_encoder import TreeLSTM, TreeLSTMv2, TreeLSTMDouble, TreeLSTMCell
|
| 6 |
+
from .image_encoder import ImageEncoder, SimpleCNN, SmallViT, PersistenceViT, DINOv2ImageEncoder
|
| 7 |
+
from .fusion import CrossAttentionFusion, BiDirectionalCrossAttention, GatedFusion, CMF, MultiHeadCrossModalAttention
|
| 8 |
+
|
| 9 |
+
__all__ = [
|
| 10 |
+
"CLIPModel", "CLIPLoss",
|
| 11 |
+
"FineTuneModel", "ArcMarginProduct",
|
| 12 |
+
"TreeLSTM", "TreeLSTMv2", "TreeLSTMDouble", "TreeLSTMCell",
|
| 13 |
+
"ImageEncoder", "SimpleCNN", "SmallViT", "PersistenceViT", "DINOv2ImageEncoder",
|
| 14 |
+
"CrossAttentionFusion", "BiDirectionalCrossAttention", "GatedFusion", "CMF", "MultiHeadCrossModalAttention",
|
| 15 |
+
]
|
graphformer/models/clip_model.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
CLIP-style Contrastive Model for Neuron Morphology
|
| 3 |
+
|
| 4 |
+
Aligns tree structure representations with persistence images using
|
| 5 |
+
contrastive learning with separate encoders.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
import torch.nn as nn
|
| 10 |
+
import torch.nn.functional as F
|
| 11 |
+
|
| 12 |
+
from .tree_encoder import TreeLSTM, TreeLSTMv2, TreeLSTMDouble
|
| 13 |
+
from .image_encoder import ImageEncoder
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class CLIPLoss(nn.Module):
|
| 17 |
+
"""CLIP-style symmetric contrastive loss"""
|
| 18 |
+
def __init__(self, temperature=0.07):
|
| 19 |
+
super(CLIPLoss, self).__init__()
|
| 20 |
+
self.temperature = temperature
|
| 21 |
+
|
| 22 |
+
def forward(self, tree_features, image_features):
|
| 23 |
+
"""
|
| 24 |
+
Args:
|
| 25 |
+
tree_features: (B, dim) - normalized tree embeddings
|
| 26 |
+
image_features: (B, dim) - normalized image embeddings
|
| 27 |
+
Returns:
|
| 28 |
+
loss: scalar contrastive loss
|
| 29 |
+
"""
|
| 30 |
+
tree_features = F.normalize(tree_features, dim=-1)
|
| 31 |
+
image_features = F.normalize(image_features, dim=-1)
|
| 32 |
+
|
| 33 |
+
logits = torch.matmul(tree_features, image_features.T) / self.temperature
|
| 34 |
+
|
| 35 |
+
batch_size = tree_features.shape[0]
|
| 36 |
+
labels = torch.arange(batch_size, device=tree_features.device)
|
| 37 |
+
|
| 38 |
+
loss_tree_to_image = F.cross_entropy(logits, labels)
|
| 39 |
+
loss_image_to_tree = F.cross_entropy(logits.T, labels)
|
| 40 |
+
|
| 41 |
+
loss = (loss_tree_to_image + loss_image_to_tree) / 2
|
| 42 |
+
|
| 43 |
+
return loss
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class CLIPModel(nn.Module):
|
| 47 |
+
"""CLIP-style model with separate tree and image encoders"""
|
| 48 |
+
def __init__(self, args):
|
| 49 |
+
super(CLIPModel, self).__init__()
|
| 50 |
+
|
| 51 |
+
self.tree_encoder_type = args.tree_model
|
| 52 |
+
|
| 53 |
+
# Tree encoder
|
| 54 |
+
if args.tree_model == "ori":
|
| 55 |
+
self.tree_encoder = TreeLSTM(
|
| 56 |
+
x_size=len(args.input_features),
|
| 57 |
+
h_size=args.h_size,
|
| 58 |
+
num_classes=0,
|
| 59 |
+
fc=False,
|
| 60 |
+
bn=args.bn,
|
| 61 |
+
mode=args.child_mode,
|
| 62 |
+
)
|
| 63 |
+
elif args.tree_model == "v2":
|
| 64 |
+
self.tree_encoder = TreeLSTMv2(
|
| 65 |
+
x_size=len(args.input_features),
|
| 66 |
+
h_size=args.h_size,
|
| 67 |
+
num_classes=0,
|
| 68 |
+
fc=False,
|
| 69 |
+
bn=args.bn,
|
| 70 |
+
mode=args.child_mode,
|
| 71 |
+
)
|
| 72 |
+
elif args.tree_model == "double":
|
| 73 |
+
self.tree_encoder = TreeLSTMDouble(
|
| 74 |
+
x_size=len(args.input_features),
|
| 75 |
+
h_size=args.h_size,
|
| 76 |
+
num_classes=0,
|
| 77 |
+
fc=False,
|
| 78 |
+
bn=args.bn,
|
| 79 |
+
mode=args.child_mode,
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
raise ValueError(f"Unknown tree model: {args.tree_model}")
|
| 83 |
+
|
| 84 |
+
# Image encoder
|
| 85 |
+
self.image_encoder = ImageEncoder(
|
| 86 |
+
output_dim=args.h_size,
|
| 87 |
+
model_type=args.image_encoder,
|
| 88 |
+
image_size=args.image_size,
|
| 89 |
+
freeze_backbone=args.freeze_image_backbone,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# Get image encoder output dimension
|
| 93 |
+
if hasattr(self.image_encoder.encoder if hasattr(self.image_encoder, 'encoder') else self.image_encoder, 'feat_dim'):
|
| 94 |
+
image_feat_dim = self.image_encoder.encoder.feat_dim if hasattr(self.image_encoder, 'encoder') else self.image_encoder.feat_dim
|
| 95 |
+
else:
|
| 96 |
+
image_feat_dim = args.h_size
|
| 97 |
+
|
| 98 |
+
# Projection heads
|
| 99 |
+
if getattr(args, 'single_linear_proj', False):
|
| 100 |
+
self.tree_projection = nn.Linear(args.h_size, args.embed_dim)
|
| 101 |
+
self.image_projection = nn.Linear(image_feat_dim, args.embed_dim)
|
| 102 |
+
else:
|
| 103 |
+
self.tree_projection = nn.Sequential(
|
| 104 |
+
nn.Linear(args.h_size, args.h_size),
|
| 105 |
+
nn.ReLU(),
|
| 106 |
+
nn.Linear(args.h_size, args.embed_dim),
|
| 107 |
+
)
|
| 108 |
+
self.image_projection = nn.Sequential(
|
| 109 |
+
nn.Linear(image_feat_dim, args.h_size),
|
| 110 |
+
nn.ReLU(),
|
| 111 |
+
nn.Linear(args.h_size, args.embed_dim),
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
self.loss_type = args.loss_type
|
| 115 |
+
|
| 116 |
+
# Loss function
|
| 117 |
+
if args.loss_type == 'infonce':
|
| 118 |
+
from ..losses import SymmetricInfoNCELoss
|
| 119 |
+
self.criterion = SymmetricInfoNCELoss(temperature=args.temperature)
|
| 120 |
+
elif args.loss_type == 'ntxent':
|
| 121 |
+
from ..losses import NTXentLoss
|
| 122 |
+
self.criterion = NTXentLoss(temperature=args.temperature)
|
| 123 |
+
elif args.loss_type == 'triplet':
|
| 124 |
+
from ..losses import TripletLoss
|
| 125 |
+
self.criterion = TripletLoss(
|
| 126 |
+
margin=args.triplet_margin,
|
| 127 |
+
distance_metric=args.triplet_distance,
|
| 128 |
+
mining=args.triplet_mining
|
| 129 |
+
)
|
| 130 |
+
else: # 'clip' (default)
|
| 131 |
+
self.criterion = CLIPLoss(temperature=args.temperature)
|
| 132 |
+
|
| 133 |
+
def encode_tree(self, batch):
|
| 134 |
+
"""Encode tree data"""
|
| 135 |
+
tree_feats = self.tree_encoder(batch)
|
| 136 |
+
tree_embed = self.tree_projection(tree_feats)
|
| 137 |
+
return tree_embed
|
| 138 |
+
|
| 139 |
+
def encode_image(self, images):
|
| 140 |
+
"""Encode image data"""
|
| 141 |
+
image_feats = self.image_encoder(images)
|
| 142 |
+
image_embed = self.image_projection(image_feats)
|
| 143 |
+
return image_embed
|
| 144 |
+
|
| 145 |
+
def forward(self, batch, return_recon=False):
|
| 146 |
+
"""
|
| 147 |
+
Forward pass computing CLIP loss
|
| 148 |
+
|
| 149 |
+
Args:
|
| 150 |
+
batch: contains batch.graph, batch.feats, batch.images
|
| 151 |
+
return_recon: unused, kept for compatibility
|
| 152 |
+
Returns:
|
| 153 |
+
loss: Contrastive loss
|
| 154 |
+
"""
|
| 155 |
+
tree_embed = self.encode_tree(batch)
|
| 156 |
+
images = batch.images.cuda() if not batch.images.is_cuda else batch.images
|
| 157 |
+
|
| 158 |
+
image_feats = self.image_encoder(images)
|
| 159 |
+
image_embed = self.image_projection(image_feats)
|
| 160 |
+
|
| 161 |
+
# Contrastive loss
|
| 162 |
+
if self.loss_type == 'triplet':
|
| 163 |
+
embeddings = torch.cat([tree_embed, image_embed], dim=0)
|
| 164 |
+
labels_gpu = batch.label.cuda() if not batch.label.is_cuda else batch.label
|
| 165 |
+
labels = torch.cat([labels_gpu, labels_gpu], dim=0)
|
| 166 |
+
clip_loss = self.criterion(embeddings, labels)
|
| 167 |
+
else:
|
| 168 |
+
clip_loss = self.criterion(tree_embed, image_embed)
|
| 169 |
+
|
| 170 |
+
return clip_loss
|
graphformer/models/finetune_model.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fine-tuning Model for GraPHFormer
|
| 3 |
+
|
| 4 |
+
Supports three modes: image_only, tree_only, multimodal
|
| 5 |
+
Loads pretrained weights from CLIP-style training checkpoints
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import torch
|
| 10 |
+
import torch.nn as nn
|
| 11 |
+
import torch.nn.functional as F
|
| 12 |
+
|
| 13 |
+
from .fusion import (
|
| 14 |
+
CrossAttentionFusion, BiDirectionalCrossAttention,
|
| 15 |
+
GatedFusion, CMF, MultiHeadCrossModalAttention
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ArcMarginProduct(nn.Module):
|
| 20 |
+
"""ArcFace: Additive Angular Margin Loss"""
|
| 21 |
+
def __init__(self, in_features, out_features, s=30.0, m=0.50, easy_margin=False):
|
| 22 |
+
super(ArcMarginProduct, self).__init__()
|
| 23 |
+
self.in_features = in_features
|
| 24 |
+
self.out_features = out_features
|
| 25 |
+
self.s = s
|
| 26 |
+
self.m = m
|
| 27 |
+
self.easy_margin = easy_margin
|
| 28 |
+
|
| 29 |
+
self.weight = nn.Parameter(torch.FloatTensor(out_features, in_features))
|
| 30 |
+
nn.init.xavier_uniform_(self.weight)
|
| 31 |
+
|
| 32 |
+
self.cos_m = np.cos(m)
|
| 33 |
+
self.sin_m = np.sin(m)
|
| 34 |
+
self.th = np.cos(np.pi - m)
|
| 35 |
+
self.mm = np.sin(np.pi - m) * m
|
| 36 |
+
|
| 37 |
+
def forward(self, input, label):
|
| 38 |
+
cosine = F.linear(F.normalize(input), F.normalize(self.weight))
|
| 39 |
+
sine = torch.sqrt(1.0 - torch.pow(cosine, 2))
|
| 40 |
+
|
| 41 |
+
phi = cosine * self.cos_m - sine * self.sin_m
|
| 42 |
+
|
| 43 |
+
if self.easy_margin:
|
| 44 |
+
phi = torch.where(cosine > 0, phi, cosine)
|
| 45 |
+
else:
|
| 46 |
+
phi = torch.where(cosine > self.th, phi, cosine - self.mm)
|
| 47 |
+
|
| 48 |
+
one_hot = torch.zeros(cosine.size(), device=input.device)
|
| 49 |
+
one_hot.scatter_(1, label.view(-1, 1).long(), 1)
|
| 50 |
+
|
| 51 |
+
output = (one_hot * phi) + ((1.0 - one_hot) * cosine)
|
| 52 |
+
output *= self.s
|
| 53 |
+
|
| 54 |
+
return output
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class FineTuneModel(nn.Module):
|
| 58 |
+
"""Fine-tuning model with classification head"""
|
| 59 |
+
def __init__(
|
| 60 |
+
self,
|
| 61 |
+
pretrained_model,
|
| 62 |
+
num_classes,
|
| 63 |
+
mode='multimodal',
|
| 64 |
+
freeze_encoders=False,
|
| 65 |
+
fusion_mode='concat',
|
| 66 |
+
dropout=0.5,
|
| 67 |
+
label_smoothing=0.0,
|
| 68 |
+
use_projection=False,
|
| 69 |
+
use_arcface=False,
|
| 70 |
+
arcface_s=30.0,
|
| 71 |
+
arcface_m=0.50,
|
| 72 |
+
freeze_image_only=False
|
| 73 |
+
):
|
| 74 |
+
"""
|
| 75 |
+
Args:
|
| 76 |
+
pretrained_model: Pretrained CLIPModel
|
| 77 |
+
num_classes: Number of classes for classification
|
| 78 |
+
mode: 'image_only', 'tree_only', or 'multimodal'
|
| 79 |
+
freeze_encoders: If True, freeze encoder weights
|
| 80 |
+
fusion_mode: For multimodal - 'concat', 'add', 'cross_attention', 'bi_attention', 'gated', 'cmf', 'mhcma'
|
| 81 |
+
dropout: Dropout rate for first layer
|
| 82 |
+
label_smoothing: Label smoothing factor
|
| 83 |
+
use_projection: If True, use projection heads from pretrained model
|
| 84 |
+
use_arcface: If True, use ArcFace loss instead of CrossEntropy
|
| 85 |
+
arcface_s: ArcFace scale parameter
|
| 86 |
+
arcface_m: ArcFace margin parameter
|
| 87 |
+
freeze_image_only: If True, freeze only image encoder
|
| 88 |
+
"""
|
| 89 |
+
super(FineTuneModel, self).__init__()
|
| 90 |
+
|
| 91 |
+
self.mode = mode
|
| 92 |
+
self.fusion_mode = fusion_mode
|
| 93 |
+
self.use_projection = use_projection
|
| 94 |
+
self.use_arcface = use_arcface
|
| 95 |
+
self.tree_encoder_type = pretrained_model.tree_encoder_type
|
| 96 |
+
|
| 97 |
+
# Copy encoders from pretrained model
|
| 98 |
+
if mode in ['tree_only', 'multimodal']:
|
| 99 |
+
self.tree_encoder = pretrained_model.tree_encoder
|
| 100 |
+
if use_projection:
|
| 101 |
+
self.tree_projection = pretrained_model.tree_projection
|
| 102 |
+
|
| 103 |
+
if mode in ['image_only', 'multimodal']:
|
| 104 |
+
self.image_encoder = pretrained_model.image_encoder
|
| 105 |
+
if use_projection:
|
| 106 |
+
self.image_projection = pretrained_model.image_projection
|
| 107 |
+
|
| 108 |
+
# Freeze encoders if requested
|
| 109 |
+
if freeze_encoders:
|
| 110 |
+
if mode in ['tree_only', 'multimodal']:
|
| 111 |
+
for param in self.tree_encoder.parameters():
|
| 112 |
+
param.requires_grad = False
|
| 113 |
+
if use_projection:
|
| 114 |
+
for param in self.tree_projection.parameters():
|
| 115 |
+
param.requires_grad = False
|
| 116 |
+
|
| 117 |
+
if mode in ['image_only', 'multimodal']:
|
| 118 |
+
for param in self.image_encoder.parameters():
|
| 119 |
+
param.requires_grad = False
|
| 120 |
+
if use_projection:
|
| 121 |
+
for param in self.image_projection.parameters():
|
| 122 |
+
param.requires_grad = False
|
| 123 |
+
|
| 124 |
+
# Freeze only image encoder
|
| 125 |
+
if freeze_image_only and mode == 'multimodal':
|
| 126 |
+
for param in self.image_encoder.parameters():
|
| 127 |
+
param.requires_grad = False
|
| 128 |
+
if use_projection:
|
| 129 |
+
for param in self.image_projection.parameters():
|
| 130 |
+
param.requires_grad = False
|
| 131 |
+
|
| 132 |
+
# Get embedding dimensions
|
| 133 |
+
if use_projection:
|
| 134 |
+
tree_embed_dim = pretrained_model.tree_projection[-1].out_features
|
| 135 |
+
image_embed_dim = pretrained_model.image_projection[-1].out_features
|
| 136 |
+
else:
|
| 137 |
+
if mode in ['tree_only', 'multimodal']:
|
| 138 |
+
tree_embed_dim = pretrained_model.tree_encoder.h_size
|
| 139 |
+
else:
|
| 140 |
+
tree_embed_dim = 0
|
| 141 |
+
|
| 142 |
+
if mode in ['image_only', 'multimodal']:
|
| 143 |
+
if hasattr(pretrained_model.image_encoder, 'encoder'):
|
| 144 |
+
if hasattr(pretrained_model.image_encoder.encoder, 'feat_dim'):
|
| 145 |
+
image_embed_dim = pretrained_model.image_encoder.encoder.feat_dim
|
| 146 |
+
else:
|
| 147 |
+
image_embed_dim = pretrained_model.image_encoder.encoder[-1].in_features
|
| 148 |
+
elif hasattr(pretrained_model.image_encoder, 'feat_dim'):
|
| 149 |
+
image_embed_dim = pretrained_model.image_encoder.feat_dim
|
| 150 |
+
else:
|
| 151 |
+
image_embed_dim = pretrained_model.tree_encoder.h_size
|
| 152 |
+
else:
|
| 153 |
+
image_embed_dim = 0
|
| 154 |
+
|
| 155 |
+
# Setup fusion for multimodal
|
| 156 |
+
if mode == 'multimodal':
|
| 157 |
+
if fusion_mode == 'concat':
|
| 158 |
+
fusion_dim = tree_embed_dim + image_embed_dim
|
| 159 |
+
elif fusion_mode == 'add':
|
| 160 |
+
fusion_dim = min(tree_embed_dim, image_embed_dim)
|
| 161 |
+
if tree_embed_dim != image_embed_dim:
|
| 162 |
+
self.tree_dim_match = nn.Linear(tree_embed_dim, fusion_dim) if tree_embed_dim != fusion_dim else nn.Identity()
|
| 163 |
+
self.image_dim_match = nn.Linear(image_embed_dim, fusion_dim) if image_embed_dim != fusion_dim else nn.Identity()
|
| 164 |
+
elif fusion_mode == 'cross_attention':
|
| 165 |
+
fusion_dim = min(tree_embed_dim, image_embed_dim)
|
| 166 |
+
if tree_embed_dim != image_embed_dim:
|
| 167 |
+
self.tree_dim_match = nn.Linear(tree_embed_dim, fusion_dim) if tree_embed_dim != fusion_dim else nn.Identity()
|
| 168 |
+
self.image_dim_match = nn.Linear(image_embed_dim, fusion_dim) if image_embed_dim != fusion_dim else nn.Identity()
|
| 169 |
+
self.fusion_layer = CrossAttentionFusion(fusion_dim, num_heads=4)
|
| 170 |
+
elif fusion_mode == 'bi_attention':
|
| 171 |
+
fusion_dim = min(tree_embed_dim, image_embed_dim)
|
| 172 |
+
if tree_embed_dim != image_embed_dim:
|
| 173 |
+
self.tree_dim_match = nn.Linear(tree_embed_dim, fusion_dim) if tree_embed_dim != fusion_dim else nn.Identity()
|
| 174 |
+
self.image_dim_match = nn.Linear(image_embed_dim, fusion_dim) if image_embed_dim != fusion_dim else nn.Identity()
|
| 175 |
+
self.fusion_layer = BiDirectionalCrossAttention(fusion_dim, num_heads=4)
|
| 176 |
+
elif fusion_mode == 'gated':
|
| 177 |
+
fusion_dim = min(tree_embed_dim, image_embed_dim)
|
| 178 |
+
if tree_embed_dim != image_embed_dim:
|
| 179 |
+
self.tree_dim_match = nn.Linear(tree_embed_dim, fusion_dim) if tree_embed_dim != fusion_dim else nn.Identity()
|
| 180 |
+
self.image_dim_match = nn.Linear(image_embed_dim, fusion_dim) if image_embed_dim != fusion_dim else nn.Identity()
|
| 181 |
+
self.fusion_layer = GatedFusion(fusion_dim)
|
| 182 |
+
elif fusion_mode == 'cmf':
|
| 183 |
+
fusion_dim = min(tree_embed_dim, image_embed_dim)
|
| 184 |
+
if tree_embed_dim != image_embed_dim:
|
| 185 |
+
self.tree_dim_match = nn.Linear(tree_embed_dim, fusion_dim) if tree_embed_dim != fusion_dim else nn.Identity()
|
| 186 |
+
self.image_dim_match = nn.Linear(image_embed_dim, fusion_dim) if image_embed_dim != fusion_dim else nn.Identity()
|
| 187 |
+
self.fusion_layer = CMF(fusion_dim)
|
| 188 |
+
elif fusion_mode == 'mhcma':
|
| 189 |
+
fusion_dim = min(tree_embed_dim, image_embed_dim)
|
| 190 |
+
if tree_embed_dim != image_embed_dim:
|
| 191 |
+
self.tree_dim_match = nn.Linear(tree_embed_dim, fusion_dim) if tree_embed_dim != fusion_dim else nn.Identity()
|
| 192 |
+
self.image_dim_match = nn.Linear(image_embed_dim, fusion_dim) if image_embed_dim != fusion_dim else nn.Identity()
|
| 193 |
+
self.fusion_layer = MultiHeadCrossModalAttention(fusion_dim, num_heads=8)
|
| 194 |
+
else:
|
| 195 |
+
raise ValueError(f"Unknown fusion_mode: {fusion_mode}")
|
| 196 |
+
input_dim = fusion_dim
|
| 197 |
+
else:
|
| 198 |
+
if mode == 'tree_only':
|
| 199 |
+
input_dim = tree_embed_dim
|
| 200 |
+
else:
|
| 201 |
+
input_dim = image_embed_dim
|
| 202 |
+
|
| 203 |
+
# Classification head
|
| 204 |
+
if use_arcface:
|
| 205 |
+
self.feature_extractor = nn.Sequential(
|
| 206 |
+
nn.Dropout(dropout),
|
| 207 |
+
nn.Linear(input_dim, input_dim // 2),
|
| 208 |
+
nn.BatchNorm1d(input_dim // 2),
|
| 209 |
+
nn.ReLU(),
|
| 210 |
+
nn.Dropout(dropout * 0.7),
|
| 211 |
+
)
|
| 212 |
+
self.arcface = ArcMarginProduct(input_dim // 2, num_classes, s=arcface_s, m=arcface_m)
|
| 213 |
+
self.classifier = None
|
| 214 |
+
else:
|
| 215 |
+
self.classifier = nn.Sequential(
|
| 216 |
+
nn.Dropout(dropout),
|
| 217 |
+
nn.Linear(input_dim, input_dim // 2),
|
| 218 |
+
nn.BatchNorm1d(input_dim // 2),
|
| 219 |
+
nn.ReLU(),
|
| 220 |
+
nn.Dropout(dropout * 0.7),
|
| 221 |
+
nn.Linear(input_dim // 2, num_classes)
|
| 222 |
+
)
|
| 223 |
+
self.feature_extractor = None
|
| 224 |
+
self.arcface = None
|
| 225 |
+
|
| 226 |
+
self.criterion = nn.CrossEntropyLoss(label_smoothing=label_smoothing)
|
| 227 |
+
|
| 228 |
+
def encode_tree(self, batch):
|
| 229 |
+
"""Encode tree data"""
|
| 230 |
+
tree_feats = self.tree_encoder(batch)
|
| 231 |
+
|
| 232 |
+
if self.use_projection:
|
| 233 |
+
tree_embed = self.tree_projection(tree_feats)
|
| 234 |
+
return tree_embed
|
| 235 |
+
else:
|
| 236 |
+
return tree_feats
|
| 237 |
+
|
| 238 |
+
def encode_image(self, images):
|
| 239 |
+
"""Encode image data"""
|
| 240 |
+
image_feats = self.image_encoder(images)
|
| 241 |
+
|
| 242 |
+
if self.use_projection:
|
| 243 |
+
image_embed = self.image_projection(image_feats)
|
| 244 |
+
return image_embed
|
| 245 |
+
else:
|
| 246 |
+
return image_feats
|
| 247 |
+
|
| 248 |
+
def forward(self, batch, return_features=False):
|
| 249 |
+
"""
|
| 250 |
+
Forward pass
|
| 251 |
+
|
| 252 |
+
Args:
|
| 253 |
+
batch: contains batch.graph, batch.feats, batch.images, batch.label
|
| 254 |
+
return_features: if True, return embeddings along with logits
|
| 255 |
+
Returns:
|
| 256 |
+
loss: classification loss
|
| 257 |
+
logits: (B, num_classes)
|
| 258 |
+
features: (optional) embeddings
|
| 259 |
+
"""
|
| 260 |
+
images = batch.images.cuda() if not batch.images.is_cuda else batch.images
|
| 261 |
+
labels = batch.label.cuda() if not batch.label.is_cuda else batch.label
|
| 262 |
+
|
| 263 |
+
if self.mode == 'tree_only':
|
| 264 |
+
tree_embed = self.encode_tree(batch)
|
| 265 |
+
tree_embed = F.normalize(tree_embed, dim=-1)
|
| 266 |
+
features = tree_embed
|
| 267 |
+
|
| 268 |
+
elif self.mode == 'image_only':
|
| 269 |
+
image_embed = self.encode_image(images)
|
| 270 |
+
image_embed = F.normalize(image_embed, dim=-1)
|
| 271 |
+
features = image_embed
|
| 272 |
+
|
| 273 |
+
else: # multimodal
|
| 274 |
+
tree_embed = self.encode_tree(batch)
|
| 275 |
+
image_embed = self.encode_image(images)
|
| 276 |
+
|
| 277 |
+
tree_embed = F.normalize(tree_embed, dim=-1)
|
| 278 |
+
image_embed = F.normalize(image_embed, dim=-1)
|
| 279 |
+
|
| 280 |
+
if self.fusion_mode == 'concat':
|
| 281 |
+
features = torch.cat([tree_embed, image_embed], dim=1)
|
| 282 |
+
elif self.fusion_mode == 'add':
|
| 283 |
+
if hasattr(self, 'tree_dim_match'):
|
| 284 |
+
tree_embed = self.tree_dim_match(tree_embed)
|
| 285 |
+
image_embed = self.image_dim_match(image_embed)
|
| 286 |
+
features = tree_embed + image_embed
|
| 287 |
+
elif self.fusion_mode in ['gated', 'cmf', 'cross_attention', 'bi_attention', 'mhcma']:
|
| 288 |
+
if hasattr(self, 'tree_dim_match'):
|
| 289 |
+
tree_embed = self.tree_dim_match(tree_embed)
|
| 290 |
+
image_embed = self.image_dim_match(image_embed)
|
| 291 |
+
features = self.fusion_layer(tree_embed, image_embed)
|
| 292 |
+
else:
|
| 293 |
+
raise ValueError(f"Unknown fusion_mode: {self.fusion_mode}")
|
| 294 |
+
|
| 295 |
+
# Classification
|
| 296 |
+
if self.use_arcface:
|
| 297 |
+
extracted_features = self.feature_extractor(features)
|
| 298 |
+
logits = self.arcface(extracted_features, labels)
|
| 299 |
+
loss = self.criterion(logits, labels)
|
| 300 |
+
else:
|
| 301 |
+
logits = self.classifier(features)
|
| 302 |
+
loss = self.criterion(logits, labels)
|
| 303 |
+
|
| 304 |
+
if return_features:
|
| 305 |
+
return loss, logits, features
|
| 306 |
+
return loss, logits
|
| 307 |
+
|
| 308 |
+
def unfreeze_encoders(self):
|
| 309 |
+
"""Unfreeze encoder weights for full fine-tuning"""
|
| 310 |
+
if self.mode in ['tree_only', 'multimodal'] and hasattr(self, 'tree_encoder'):
|
| 311 |
+
for param in self.tree_encoder.parameters():
|
| 312 |
+
param.requires_grad = True
|
| 313 |
+
if self.use_projection and hasattr(self, 'tree_projection'):
|
| 314 |
+
for param in self.tree_projection.parameters():
|
| 315 |
+
param.requires_grad = True
|
| 316 |
+
|
| 317 |
+
if self.mode in ['image_only', 'multimodal'] and hasattr(self, 'image_encoder'):
|
| 318 |
+
for param in self.image_encoder.parameters():
|
| 319 |
+
param.requires_grad = True
|
| 320 |
+
if self.use_projection and hasattr(self, 'image_projection'):
|
| 321 |
+
for param in self.image_projection.parameters():
|
| 322 |
+
param.requires_grad = True
|
graphformer/models/fusion.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Fusion Mechanisms for Multimodal Learning
|
| 3 |
+
|
| 4 |
+
This module contains various fusion strategies to combine tree and image features:
|
| 5 |
+
- CrossAttentionFusion: Cross-modal attention
|
| 6 |
+
- CMF: Cross-Modal Fusion
|
| 7 |
+
- BiDirectionalCrossAttention: Bidirectional cross-attention
|
| 8 |
+
- GatedFusion: Gated fusion with learnable gates
|
| 9 |
+
- MultiHeadCrossModalAttention: Multi-head cross-modal attention
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CrossAttentionFusion(nn.Module):
|
| 18 |
+
"""Cross-attention fusion for tree and image features"""
|
| 19 |
+
def __init__(self, dim, num_heads=4, dropout=0.1):
|
| 20 |
+
super(CrossAttentionFusion, self).__init__()
|
| 21 |
+
self.num_heads = num_heads
|
| 22 |
+
self.dim = dim
|
| 23 |
+
self.head_dim = dim // num_heads
|
| 24 |
+
|
| 25 |
+
assert dim % num_heads == 0, "dim must be divisible by num_heads"
|
| 26 |
+
|
| 27 |
+
# Query, Key, Value projections
|
| 28 |
+
self.q_proj = nn.Linear(dim, dim)
|
| 29 |
+
self.k_proj = nn.Linear(dim, dim)
|
| 30 |
+
self.v_proj = nn.Linear(dim, dim)
|
| 31 |
+
self.out_proj = nn.Sequential(
|
| 32 |
+
nn.Linear(dim, dim * 4),
|
| 33 |
+
nn.ReLU(),
|
| 34 |
+
nn.Dropout(dropout),
|
| 35 |
+
nn.Linear(dim * 4, dim),
|
| 36 |
+
nn.Dropout(dropout)
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
self.dropout = nn.Dropout(dropout)
|
| 40 |
+
self.scale = self.head_dim ** -0.5
|
| 41 |
+
|
| 42 |
+
def forward(self, tree_feat, image_feat):
|
| 43 |
+
"""
|
| 44 |
+
Args:
|
| 45 |
+
tree_feat: (B, dim) tree features
|
| 46 |
+
image_feat: (B, dim) image features
|
| 47 |
+
Returns:
|
| 48 |
+
fused: (B, dim) fused features
|
| 49 |
+
"""
|
| 50 |
+
B = tree_feat.shape[0]
|
| 51 |
+
|
| 52 |
+
# Add sequence dimension: (B, 1, dim)
|
| 53 |
+
tree_feat = tree_feat.unsqueeze(1)
|
| 54 |
+
image_feat = image_feat.unsqueeze(1)
|
| 55 |
+
|
| 56 |
+
# Tree attends to image (tree as query, image as key/value)
|
| 57 |
+
Q = self.q_proj(tree_feat).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 58 |
+
K = self.k_proj(image_feat).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 59 |
+
V = self.v_proj(image_feat).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 60 |
+
|
| 61 |
+
# Attention scores
|
| 62 |
+
attn = (Q @ K.transpose(-2, -1)) * self.scale
|
| 63 |
+
attn = torch.softmax(attn, dim=-1)
|
| 64 |
+
attn = self.dropout(attn)
|
| 65 |
+
|
| 66 |
+
# Apply attention to values
|
| 67 |
+
out = (attn @ V).transpose(1, 2).contiguous().view(B, 1, self.dim)
|
| 68 |
+
|
| 69 |
+
# Project and add residual
|
| 70 |
+
out = self.out_proj(out.squeeze(1))
|
| 71 |
+
fused = out + tree_feat.squeeze(1)
|
| 72 |
+
|
| 73 |
+
return fused
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class CMF(nn.Module):
|
| 77 |
+
"""Cross-Modal Fusion with attention mechanism"""
|
| 78 |
+
def __init__(self, dim, dropout=0.1):
|
| 79 |
+
super(CMF, self).__init__()
|
| 80 |
+
self.dim = dim
|
| 81 |
+
|
| 82 |
+
# Feature-level attention
|
| 83 |
+
self.tree_attn = nn.Sequential(
|
| 84 |
+
nn.Linear(dim, dim),
|
| 85 |
+
nn.Tanh(),
|
| 86 |
+
nn.Linear(dim, 1)
|
| 87 |
+
)
|
| 88 |
+
self.image_attn = nn.Sequential(
|
| 89 |
+
nn.Linear(dim, dim),
|
| 90 |
+
nn.Tanh(),
|
| 91 |
+
nn.Linear(dim, 1)
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# Cross-modal interaction
|
| 95 |
+
self.cross_proj = nn.Sequential(
|
| 96 |
+
nn.Linear(dim * 2, dim),
|
| 97 |
+
nn.ReLU(),
|
| 98 |
+
nn.Dropout(dropout),
|
| 99 |
+
nn.Linear(dim, dim)
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
def forward(self, tree_feat, image_feat):
|
| 103 |
+
"""
|
| 104 |
+
Args:
|
| 105 |
+
tree_feat: (B, dim)
|
| 106 |
+
image_feat: (B, dim)
|
| 107 |
+
Returns:
|
| 108 |
+
fused: (B, dim)
|
| 109 |
+
"""
|
| 110 |
+
# Compute attention weights
|
| 111 |
+
tree_weight = torch.sigmoid(self.tree_attn(tree_feat))
|
| 112 |
+
image_weight = torch.sigmoid(self.image_attn(image_feat))
|
| 113 |
+
|
| 114 |
+
# Normalize weights
|
| 115 |
+
total_weight = tree_weight + image_weight + 1e-8
|
| 116 |
+
tree_weight = tree_weight / total_weight
|
| 117 |
+
image_weight = image_weight / total_weight
|
| 118 |
+
|
| 119 |
+
# Weighted combination
|
| 120 |
+
weighted_tree = tree_feat * tree_weight
|
| 121 |
+
weighted_image = image_feat * image_weight
|
| 122 |
+
|
| 123 |
+
# Cross-modal projection
|
| 124 |
+
combined = torch.cat([weighted_tree, weighted_image], dim=1)
|
| 125 |
+
fused = self.cross_proj(combined)
|
| 126 |
+
|
| 127 |
+
return fused
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class BiDirectionalCrossAttention(nn.Module):
|
| 131 |
+
"""Bidirectional cross-attention: tree→image and image→tree"""
|
| 132 |
+
def __init__(self, dim, num_heads=4, dropout=0.1):
|
| 133 |
+
super(BiDirectionalCrossAttention, self).__init__()
|
| 134 |
+
self.num_heads = num_heads
|
| 135 |
+
self.dim = dim
|
| 136 |
+
self.head_dim = dim // num_heads
|
| 137 |
+
|
| 138 |
+
assert dim % num_heads == 0, "dim must be divisible by num_heads"
|
| 139 |
+
|
| 140 |
+
# Tree → Image attention
|
| 141 |
+
self.tree2img_q = nn.Linear(dim, dim)
|
| 142 |
+
self.tree2img_k = nn.Linear(dim, dim)
|
| 143 |
+
self.tree2img_v = nn.Linear(dim, dim)
|
| 144 |
+
self.tree2img_out = nn.Linear(dim, dim)
|
| 145 |
+
|
| 146 |
+
# Image → Tree attention
|
| 147 |
+
self.img2tree_q = nn.Linear(dim, dim)
|
| 148 |
+
self.img2tree_k = nn.Linear(dim, dim)
|
| 149 |
+
self.img2tree_v = nn.Linear(dim, dim)
|
| 150 |
+
self.img2tree_out = nn.Linear(dim, dim)
|
| 151 |
+
|
| 152 |
+
self.dropout = nn.Dropout(dropout)
|
| 153 |
+
self.scale = self.head_dim ** -0.5
|
| 154 |
+
|
| 155 |
+
# Layer norm
|
| 156 |
+
self.norm1 = nn.LayerNorm(dim)
|
| 157 |
+
self.norm2 = nn.LayerNorm(dim)
|
| 158 |
+
|
| 159 |
+
def _compute_attention(self, q_proj, k_proj, v_proj, query, key_value):
|
| 160 |
+
"""Helper function to compute cross-attention"""
|
| 161 |
+
B = query.shape[0]
|
| 162 |
+
|
| 163 |
+
# Add sequence dimension
|
| 164 |
+
query = query.unsqueeze(1) # (B, 1, dim)
|
| 165 |
+
key_value = key_value.unsqueeze(1) # (B, 1, dim)
|
| 166 |
+
|
| 167 |
+
# Project
|
| 168 |
+
Q = q_proj(query).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 169 |
+
K = k_proj(key_value).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 170 |
+
V = v_proj(key_value).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 171 |
+
|
| 172 |
+
# Attention
|
| 173 |
+
attn = (Q @ K.transpose(-2, -1)) * self.scale
|
| 174 |
+
attn = torch.softmax(attn, dim=-1)
|
| 175 |
+
attn = self.dropout(attn)
|
| 176 |
+
|
| 177 |
+
# Apply to values
|
| 178 |
+
out = (attn @ V).transpose(1, 2).contiguous().view(B, 1, self.dim)
|
| 179 |
+
return out.squeeze(1)
|
| 180 |
+
|
| 181 |
+
def forward(self, tree_feat, image_feat):
|
| 182 |
+
"""
|
| 183 |
+
Args:
|
| 184 |
+
tree_feat: (B, dim)
|
| 185 |
+
image_feat: (B, dim)
|
| 186 |
+
Returns:
|
| 187 |
+
tree_enhanced: (B, dim)
|
| 188 |
+
image_enhanced: (B, dim)
|
| 189 |
+
"""
|
| 190 |
+
# Tree attends to image
|
| 191 |
+
tree_enhanced = self._compute_attention(
|
| 192 |
+
self.tree2img_q, self.tree2img_k, self.tree2img_v,
|
| 193 |
+
tree_feat, image_feat
|
| 194 |
+
)
|
| 195 |
+
tree_enhanced = self.tree2img_out(tree_enhanced)
|
| 196 |
+
tree_enhanced = self.norm1(tree_feat + tree_enhanced)
|
| 197 |
+
|
| 198 |
+
# Image attends to tree
|
| 199 |
+
image_enhanced = self._compute_attention(
|
| 200 |
+
self.img2tree_q, self.img2tree_k, self.img2tree_v,
|
| 201 |
+
image_feat, tree_feat
|
| 202 |
+
)
|
| 203 |
+
image_enhanced = self.img2tree_out(image_enhanced)
|
| 204 |
+
image_enhanced = self.norm2(image_feat + image_enhanced)
|
| 205 |
+
|
| 206 |
+
# Concatenate both enhanced features
|
| 207 |
+
fused = torch.cat([tree_enhanced, image_enhanced], dim=1)
|
| 208 |
+
|
| 209 |
+
return fused
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class GatedFusion(nn.Module):
|
| 213 |
+
"""Gated fusion with learnable gates for tree and image modalities"""
|
| 214 |
+
def __init__(self, dim, dropout=0.1):
|
| 215 |
+
super(GatedFusion, self).__init__()
|
| 216 |
+
self.dim = dim
|
| 217 |
+
|
| 218 |
+
# Gating mechanism
|
| 219 |
+
self.gate_tree = nn.Sequential(
|
| 220 |
+
nn.Linear(dim * 2, dim),
|
| 221 |
+
nn.Sigmoid()
|
| 222 |
+
)
|
| 223 |
+
self.gate_image = nn.Sequential(
|
| 224 |
+
nn.Linear(dim * 2, dim),
|
| 225 |
+
nn.Sigmoid()
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
# Feature transformation
|
| 229 |
+
self.tree_transform = nn.Sequential(
|
| 230 |
+
nn.Linear(dim, dim),
|
| 231 |
+
nn.ReLU(),
|
| 232 |
+
nn.Dropout(dropout)
|
| 233 |
+
)
|
| 234 |
+
self.image_transform = nn.Sequential(
|
| 235 |
+
nn.Linear(dim, dim),
|
| 236 |
+
nn.ReLU(),
|
| 237 |
+
nn.Dropout(dropout)
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# Output projection
|
| 241 |
+
self.output = nn.Linear(dim, dim)
|
| 242 |
+
|
| 243 |
+
def forward(self, tree_feat, image_feat):
|
| 244 |
+
"""
|
| 245 |
+
Args:
|
| 246 |
+
tree_feat: (B, dim)
|
| 247 |
+
image_feat: (B, dim)
|
| 248 |
+
Returns:
|
| 249 |
+
fused: (B, dim)
|
| 250 |
+
"""
|
| 251 |
+
# Concatenate features for gating
|
| 252 |
+
combined = torch.cat([tree_feat, image_feat], dim=1)
|
| 253 |
+
|
| 254 |
+
# Compute gates
|
| 255 |
+
gate_t = self.gate_tree(combined)
|
| 256 |
+
gate_i = self.gate_image(combined)
|
| 257 |
+
|
| 258 |
+
# Transform features
|
| 259 |
+
tree_transformed = self.tree_transform(tree_feat)
|
| 260 |
+
image_transformed = self.image_transform(image_feat)
|
| 261 |
+
|
| 262 |
+
# Apply gates
|
| 263 |
+
gated_tree = gate_t * tree_transformed
|
| 264 |
+
gated_image = gate_i * image_transformed
|
| 265 |
+
|
| 266 |
+
# Combine
|
| 267 |
+
fused = gated_tree + gated_image
|
| 268 |
+
fused = self.output(fused)
|
| 269 |
+
|
| 270 |
+
return fused
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
class MultiHeadCrossModalAttention(nn.Module):
|
| 274 |
+
"""Multi-head cross-modal attention for flexible fusion"""
|
| 275 |
+
def __init__(self, dim, num_heads=8, dropout=0.1):
|
| 276 |
+
super(MultiHeadCrossModalAttention, self).__init__()
|
| 277 |
+
self.num_heads = num_heads
|
| 278 |
+
self.dim = dim
|
| 279 |
+
self.head_dim = dim // num_heads
|
| 280 |
+
|
| 281 |
+
assert dim % num_heads == 0, "dim must be divisible by num_heads"
|
| 282 |
+
|
| 283 |
+
# Projections
|
| 284 |
+
self.q_tree = nn.Linear(dim, dim)
|
| 285 |
+
self.k_tree = nn.Linear(dim, dim)
|
| 286 |
+
self.v_tree = nn.Linear(dim, dim)
|
| 287 |
+
|
| 288 |
+
self.q_image = nn.Linear(dim, dim)
|
| 289 |
+
self.k_image = nn.Linear(dim, dim)
|
| 290 |
+
self.v_image = nn.Linear(dim, dim)
|
| 291 |
+
|
| 292 |
+
self.out_proj = nn.Linear(dim * 2, dim)
|
| 293 |
+
self.dropout = nn.Dropout(dropout)
|
| 294 |
+
self.scale = self.head_dim ** -0.5
|
| 295 |
+
|
| 296 |
+
self.norm = nn.LayerNorm(dim)
|
| 297 |
+
|
| 298 |
+
def forward(self, tree_feat, image_feat):
|
| 299 |
+
"""
|
| 300 |
+
Args:
|
| 301 |
+
tree_feat: (B, dim)
|
| 302 |
+
image_feat: (B, dim)
|
| 303 |
+
Returns:
|
| 304 |
+
fused: (B, dim)
|
| 305 |
+
"""
|
| 306 |
+
B = tree_feat.shape[0]
|
| 307 |
+
|
| 308 |
+
# Add sequence dimension
|
| 309 |
+
tree_feat = tree_feat.unsqueeze(1) # (B, 1, dim)
|
| 310 |
+
image_feat = image_feat.unsqueeze(1) # (B, 1, dim)
|
| 311 |
+
|
| 312 |
+
# Project tree features
|
| 313 |
+
Q_t = self.q_tree(tree_feat).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 314 |
+
K_t = self.k_tree(tree_feat).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 315 |
+
V_t = self.v_tree(tree_feat).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 316 |
+
|
| 317 |
+
# Project image features
|
| 318 |
+
Q_i = self.q_image(image_feat).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 319 |
+
K_i = self.k_image(image_feat).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 320 |
+
V_i = self.v_image(image_feat).view(B, 1, self.num_heads, self.head_dim).transpose(1, 2)
|
| 321 |
+
|
| 322 |
+
# Tree self-attention with image context
|
| 323 |
+
attn_t = (Q_t @ K_i.transpose(-2, -1)) * self.scale
|
| 324 |
+
attn_t = torch.softmax(attn_t, dim=-1)
|
| 325 |
+
attn_t = self.dropout(attn_t)
|
| 326 |
+
out_t = (attn_t @ V_i).transpose(1, 2).contiguous().view(B, 1, self.dim)
|
| 327 |
+
|
| 328 |
+
# Image self-attention with tree context
|
| 329 |
+
attn_i = (Q_i @ K_t.transpose(-2, -1)) * self.scale
|
| 330 |
+
attn_i = torch.softmax(attn_i, dim=-1)
|
| 331 |
+
attn_i = self.dropout(attn_i)
|
| 332 |
+
out_i = (attn_i @ V_t).transpose(1, 2).contiguous().view(B, 1, self.dim)
|
| 333 |
+
|
| 334 |
+
# Concatenate and project
|
| 335 |
+
combined = torch.cat([out_t.squeeze(1), out_i.squeeze(1)], dim=1)
|
| 336 |
+
fused = self.out_proj(combined)
|
| 337 |
+
fused = self.norm(fused)
|
| 338 |
+
|
| 339 |
+
return fused
|
graphformer/models/hybrid_resnet_persistencevit.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hybrid ResNet-PersistenceViT Architecture
|
| 3 |
+
|
| 4 |
+
Combines:
|
| 5 |
+
- ResNet's early convolutional layers (conv1, bn1, relu, maxpool, layer1, layer2)
|
| 6 |
+
- PersistenceViT's topological attention mechanism (replacing layer3, layer4)
|
| 7 |
+
|
| 8 |
+
This hybrid leverages:
|
| 9 |
+
1. ResNet's proven feature extraction in lower layers
|
| 10 |
+
2. PersistenceViT's topological awareness in higher layers
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
import torch.nn.functional as F
|
| 16 |
+
import torchvision.models as models
|
| 17 |
+
from torchvision.models import ResNet18_Weights, ResNet50_Weights
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Import PersistenceViT components
|
| 21 |
+
from .image_encoder import (
|
| 22 |
+
BirthDeathAttention,
|
| 23 |
+
BirthDeathTransformerBlock,
|
| 24 |
+
TopologicalFeatureAggregation,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ResNetFeatureExtractor(nn.Module):
|
| 29 |
+
"""Extract features from early ResNet layers"""
|
| 30 |
+
def __init__(self, model_type='resnet18', num_layers=2):
|
| 31 |
+
"""
|
| 32 |
+
Args:
|
| 33 |
+
model_type: 'resnet18' or 'resnet50'
|
| 34 |
+
num_layers: How many ResNet layers to keep (1, 2, or 3)
|
| 35 |
+
1 = conv1 + layer1 (64 channels, /4 spatial)
|
| 36 |
+
2 = conv1 + layer1 + layer2 (128/512 channels, /8 spatial)
|
| 37 |
+
3 = conv1 + layer1 + layer2 + layer3 (256/1024 channels, /16 spatial)
|
| 38 |
+
"""
|
| 39 |
+
super().__init__()
|
| 40 |
+
|
| 41 |
+
if model_type == 'resnet18':
|
| 42 |
+
resnet = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
|
| 43 |
+
self.layer_dims = [64, 128, 256, 512]
|
| 44 |
+
elif model_type == 'resnet50':
|
| 45 |
+
resnet = models.resnet50(weights=ResNet50_Weights.IMAGENET1K_V1)
|
| 46 |
+
self.layer_dims = [256, 512, 1024, 2048]
|
| 47 |
+
else:
|
| 48 |
+
raise ValueError(f"model_type must be 'resnet18' or 'resnet50'")
|
| 49 |
+
|
| 50 |
+
self.model_type = model_type
|
| 51 |
+
self.num_layers = num_layers
|
| 52 |
+
|
| 53 |
+
# Initial conv layers
|
| 54 |
+
self.conv1 = resnet.conv1
|
| 55 |
+
self.bn1 = resnet.bn1
|
| 56 |
+
self.relu = resnet.relu
|
| 57 |
+
self.maxpool = resnet.maxpool
|
| 58 |
+
|
| 59 |
+
# ResNet layers
|
| 60 |
+
self.layer1 = resnet.layer1
|
| 61 |
+
if num_layers >= 2:
|
| 62 |
+
self.layer2 = resnet.layer2
|
| 63 |
+
if num_layers >= 3:
|
| 64 |
+
self.layer3 = resnet.layer3
|
| 65 |
+
|
| 66 |
+
# Output channels
|
| 67 |
+
self.out_channels = self.layer_dims[num_layers - 1]
|
| 68 |
+
|
| 69 |
+
def forward(self, x):
|
| 70 |
+
"""
|
| 71 |
+
Args:
|
| 72 |
+
x: (B, 3, H, W)
|
| 73 |
+
Returns:
|
| 74 |
+
features: (B, out_channels, H', W') where H' = H / (4 * 2^num_layers)
|
| 75 |
+
"""
|
| 76 |
+
x = self.conv1(x)
|
| 77 |
+
x = self.bn1(x)
|
| 78 |
+
x = self.relu(x)
|
| 79 |
+
x = self.maxpool(x)
|
| 80 |
+
|
| 81 |
+
x = self.layer1(x)
|
| 82 |
+
if self.num_layers >= 2:
|
| 83 |
+
x = self.layer2(x)
|
| 84 |
+
if self.num_layers >= 3:
|
| 85 |
+
x = self.layer3(x)
|
| 86 |
+
|
| 87 |
+
return x
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class CNNToTransformerAdapter(nn.Module):
|
| 91 |
+
"""Convert CNN feature maps to transformer tokens"""
|
| 92 |
+
def __init__(self, in_channels, dim, patch_size=2):
|
| 93 |
+
"""
|
| 94 |
+
Args:
|
| 95 |
+
in_channels: Number of input channels from CNN
|
| 96 |
+
dim: Transformer hidden dimension
|
| 97 |
+
patch_size: Size of patches to group CNN features (default: 2x2)
|
| 98 |
+
"""
|
| 99 |
+
super().__init__()
|
| 100 |
+
self.patch_size = patch_size
|
| 101 |
+
|
| 102 |
+
# 1x1 conv to reduce channels, then adaptive pooling
|
| 103 |
+
self.proj = nn.Sequential(
|
| 104 |
+
nn.Conv2d(in_channels, dim, kernel_size=patch_size, stride=patch_size),
|
| 105 |
+
nn.BatchNorm2d(dim),
|
| 106 |
+
nn.ReLU(inplace=True)
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Importance estimator (similar to PersistenceViT)
|
| 110 |
+
self.importance_estimator = nn.Sequential(
|
| 111 |
+
nn.Conv2d(in_channels, 32, kernel_size=patch_size, stride=patch_size),
|
| 112 |
+
nn.ReLU(),
|
| 113 |
+
nn.Conv2d(32, 1, kernel_size=1),
|
| 114 |
+
nn.Sigmoid()
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
def forward(self, x):
|
| 118 |
+
"""
|
| 119 |
+
Args:
|
| 120 |
+
x: (B, in_channels, H, W)
|
| 121 |
+
Returns:
|
| 122 |
+
tokens: (B, N, dim) where N = (H/patch_size) * (W/patch_size)
|
| 123 |
+
importance: (B, N)
|
| 124 |
+
"""
|
| 125 |
+
B, C, H, W = x.shape
|
| 126 |
+
|
| 127 |
+
# Project to embedding dimension
|
| 128 |
+
tokens = self.proj(x) # (B, dim, H', W')
|
| 129 |
+
tokens = tokens.flatten(2).transpose(1, 2) # (B, N, dim)
|
| 130 |
+
|
| 131 |
+
# Compute importance weights
|
| 132 |
+
importance = self.importance_estimator(x) # (B, 1, H', W')
|
| 133 |
+
importance = importance.flatten(2).transpose(1, 2).squeeze(-1) # (B, N)
|
| 134 |
+
|
| 135 |
+
# Weight tokens by importance
|
| 136 |
+
weighted_tokens = tokens * (1 + importance.unsqueeze(-1))
|
| 137 |
+
|
| 138 |
+
return weighted_tokens, importance
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class HybridResNetPersistenceViT(nn.Module):
|
| 142 |
+
"""
|
| 143 |
+
Hybrid architecture combining ResNet and PersistenceViT
|
| 144 |
+
|
| 145 |
+
Architecture:
|
| 146 |
+
1. ResNet early layers (conv1 + layer1 + layer2) - proven feature extraction
|
| 147 |
+
2. CNN-to-Transformer adapter - convert feature maps to tokens
|
| 148 |
+
3. PersistenceViT attention blocks - topological reasoning
|
| 149 |
+
4. Classification head
|
| 150 |
+
"""
|
| 151 |
+
def __init__(
|
| 152 |
+
self,
|
| 153 |
+
output_dim=128,
|
| 154 |
+
image_size=224,
|
| 155 |
+
resnet_type='resnet18',
|
| 156 |
+
resnet_layers=2,
|
| 157 |
+
dim=256,
|
| 158 |
+
depth=4,
|
| 159 |
+
heads=8,
|
| 160 |
+
mlp_dim=512,
|
| 161 |
+
dropout=0.2,
|
| 162 |
+
homology_dims=3,
|
| 163 |
+
freeze_resnet=False,
|
| 164 |
+
):
|
| 165 |
+
"""
|
| 166 |
+
Args:
|
| 167 |
+
output_dim: Output feature dimension
|
| 168 |
+
image_size: Input image size
|
| 169 |
+
resnet_type: 'resnet18' or 'resnet50'
|
| 170 |
+
resnet_layers: Number of ResNet layers to keep (1, 2, or 3)
|
| 171 |
+
dim: Transformer hidden dimension
|
| 172 |
+
depth: Number of transformer blocks
|
| 173 |
+
heads: Number of attention heads
|
| 174 |
+
mlp_dim: MLP hidden dimension
|
| 175 |
+
dropout: Dropout rate
|
| 176 |
+
homology_dims: Number of homology dimension tokens
|
| 177 |
+
freeze_resnet: Freeze ResNet layers
|
| 178 |
+
"""
|
| 179 |
+
super().__init__()
|
| 180 |
+
|
| 181 |
+
self.image_size = image_size
|
| 182 |
+
self.dim = dim
|
| 183 |
+
|
| 184 |
+
# 1. ResNet feature extractor
|
| 185 |
+
self.resnet_extractor = ResNetFeatureExtractor(
|
| 186 |
+
model_type=resnet_type,
|
| 187 |
+
num_layers=resnet_layers
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
if freeze_resnet:
|
| 191 |
+
for param in self.resnet_extractor.parameters():
|
| 192 |
+
param.requires_grad = False
|
| 193 |
+
|
| 194 |
+
# 2. CNN-to-Transformer adapter
|
| 195 |
+
# After resnet_layers=2: spatial size is image_size / 8
|
| 196 |
+
# We further reduce by patch_size=2, so final is image_size / 16
|
| 197 |
+
self.adapter = CNNToTransformerAdapter(
|
| 198 |
+
in_channels=self.resnet_extractor.out_channels,
|
| 199 |
+
dim=dim,
|
| 200 |
+
patch_size=2
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Calculate number of patches
|
| 204 |
+
spatial_reduction = 8 * 2 # ResNet (layer2) + adapter patch_size
|
| 205 |
+
self.num_patches = (image_size // spatial_reduction) ** 2
|
| 206 |
+
|
| 207 |
+
# 3. Special tokens (like PersistenceViT)
|
| 208 |
+
self.cls_token = nn.Parameter(torch.randn(1, 1, dim))
|
| 209 |
+
self.homology_tokens = nn.Parameter(torch.randn(1, homology_dims, dim))
|
| 210 |
+
|
| 211 |
+
# 4. Positional encoding
|
| 212 |
+
num_special_tokens = 1 + homology_dims
|
| 213 |
+
self.pos_embedding = nn.Parameter(
|
| 214 |
+
torch.randn(1, num_special_tokens + self.num_patches, dim)
|
| 215 |
+
)
|
| 216 |
+
self.dropout = nn.Dropout(dropout)
|
| 217 |
+
|
| 218 |
+
# 5. PersistenceViT transformer blocks
|
| 219 |
+
self.transformer_blocks = nn.ModuleList([
|
| 220 |
+
BirthDeathTransformerBlock(dim, heads, mlp_dim, dropout)
|
| 221 |
+
for _ in range(depth)
|
| 222 |
+
])
|
| 223 |
+
|
| 224 |
+
# 6. Topological aggregation
|
| 225 |
+
self.topological_aggregation = TopologicalFeatureAggregation(
|
| 226 |
+
dim, homology_dims
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# 7. Output head
|
| 230 |
+
self.norm = nn.LayerNorm(dim)
|
| 231 |
+
self.mlp_head = nn.Sequential(
|
| 232 |
+
nn.Dropout(dropout),
|
| 233 |
+
nn.Linear(dim, output_dim)
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
def forward(self, images):
|
| 237 |
+
"""
|
| 238 |
+
Args:
|
| 239 |
+
images: (B, 3, H, W)
|
| 240 |
+
Returns:
|
| 241 |
+
features: (B, output_dim)
|
| 242 |
+
"""
|
| 243 |
+
B = images.shape[0]
|
| 244 |
+
|
| 245 |
+
# 1. Extract features with ResNet
|
| 246 |
+
cnn_features = self.resnet_extractor(images) # (B, C, H', W')
|
| 247 |
+
|
| 248 |
+
# 2. Convert to transformer tokens
|
| 249 |
+
tokens, importance_weights = self.adapter(cnn_features) # (B, N, dim)
|
| 250 |
+
|
| 251 |
+
# 3. Add special tokens
|
| 252 |
+
cls_tokens = self.cls_token.expand(B, -1, -1)
|
| 253 |
+
homology_tokens = self.homology_tokens.expand(B, -1, -1)
|
| 254 |
+
x = torch.cat([cls_tokens, homology_tokens, tokens], dim=1) # (B, 1+H+N, dim)
|
| 255 |
+
|
| 256 |
+
# 4. Add positional encoding
|
| 257 |
+
x = x + self.pos_embedding[:, :(x.shape[1])]
|
| 258 |
+
x = self.dropout(x)
|
| 259 |
+
|
| 260 |
+
# 5. Pad importance weights for special tokens
|
| 261 |
+
num_special_tokens = 1 + self.homology_tokens.shape[1]
|
| 262 |
+
importance_padding = torch.zeros(B, num_special_tokens, device=importance_weights.device)
|
| 263 |
+
importance_weights_padded = torch.cat([importance_padding, importance_weights], dim=1)
|
| 264 |
+
|
| 265 |
+
# 6. Apply transformer blocks with topological attention
|
| 266 |
+
for block in self.transformer_blocks:
|
| 267 |
+
x = block(x, importance_weights_padded)
|
| 268 |
+
|
| 269 |
+
x = self.norm(x)
|
| 270 |
+
|
| 271 |
+
# 7. Topological aggregation (use cls + homology tokens)
|
| 272 |
+
x = self.topological_aggregation(x[:, :num_special_tokens])
|
| 273 |
+
|
| 274 |
+
# 8. Output projection
|
| 275 |
+
output = self.mlp_head(x)
|
| 276 |
+
|
| 277 |
+
return output
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
class HybridImageEncoder(nn.Module):
|
| 281 |
+
"""
|
| 282 |
+
Wrapper to integrate HybridResNetPersistenceViT into existing codebase
|
| 283 |
+
Compatible with ImageEncoder interface
|
| 284 |
+
"""
|
| 285 |
+
def __init__(
|
| 286 |
+
self,
|
| 287 |
+
output_dim=128,
|
| 288 |
+
image_size=224,
|
| 289 |
+
resnet_type='resnet18',
|
| 290 |
+
resnet_layers=2,
|
| 291 |
+
dim=256,
|
| 292 |
+
depth=4,
|
| 293 |
+
heads=8,
|
| 294 |
+
mlp_dim=512,
|
| 295 |
+
dropout=0.2,
|
| 296 |
+
homology_dims=3,
|
| 297 |
+
freeze_resnet=False,
|
| 298 |
+
):
|
| 299 |
+
super().__init__()
|
| 300 |
+
|
| 301 |
+
self.encoder = HybridResNetPersistenceViT(
|
| 302 |
+
output_dim=output_dim,
|
| 303 |
+
image_size=image_size,
|
| 304 |
+
resnet_type=resnet_type,
|
| 305 |
+
resnet_layers=resnet_layers,
|
| 306 |
+
dim=dim,
|
| 307 |
+
depth=depth,
|
| 308 |
+
heads=heads,
|
| 309 |
+
mlp_dim=mlp_dim,
|
| 310 |
+
dropout=dropout,
|
| 311 |
+
homology_dims=homology_dims,
|
| 312 |
+
freeze_resnet=freeze_resnet,
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
def forward(self, images, persistence_coords=None, pixel_coords=None):
|
| 316 |
+
"""
|
| 317 |
+
Args:
|
| 318 |
+
images: (B, 3, H, W)
|
| 319 |
+
persistence_coords: ignored (for interface compatibility)
|
| 320 |
+
pixel_coords: ignored (for interface compatibility)
|
| 321 |
+
Returns:
|
| 322 |
+
features: (B, output_dim)
|
| 323 |
+
"""
|
| 324 |
+
return self.encoder(images)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
# ============================================================================
|
| 328 |
+
# Integration into existing ImageEncoder
|
| 329 |
+
# ============================================================================
|
| 330 |
+
|
| 331 |
+
def create_hybrid_encoder(output_dim=128, image_size=224, freeze_backbone=False, **kwargs):
|
| 332 |
+
"""
|
| 333 |
+
Factory function to create hybrid encoder
|
| 334 |
+
|
| 335 |
+
Args:
|
| 336 |
+
output_dim: Output dimension
|
| 337 |
+
image_size: Input image size
|
| 338 |
+
freeze_backbone: Freeze ResNet layers
|
| 339 |
+
**kwargs: Additional arguments
|
| 340 |
+
- resnet_type: 'resnet18' or 'resnet50' (default: 'resnet50')
|
| 341 |
+
- resnet_layers: Number of ResNet layers (1, 2, or 3) (default: 2)
|
| 342 |
+
- dim: Transformer dimension (default: 256)
|
| 343 |
+
- depth: Number of transformer blocks (default: 4)
|
| 344 |
+
- heads: Number of attention heads (default: 8)
|
| 345 |
+
"""
|
| 346 |
+
resnet_type = kwargs.get('resnet_type', 'resnet50')
|
| 347 |
+
resnet_layers = kwargs.get('resnet_layers', 2)
|
| 348 |
+
dim = kwargs.get('dim', 256)
|
| 349 |
+
depth = kwargs.get('depth', 4)
|
| 350 |
+
heads = kwargs.get('heads', 8)
|
| 351 |
+
mlp_dim = kwargs.get('mlp_dim', 512)
|
| 352 |
+
dropout = kwargs.get('dropout', 0.2)
|
| 353 |
+
homology_dims = kwargs.get('homology_dims', 3)
|
| 354 |
+
|
| 355 |
+
return HybridImageEncoder(
|
| 356 |
+
output_dim=output_dim,
|
| 357 |
+
image_size=image_size,
|
| 358 |
+
resnet_type=resnet_type,
|
| 359 |
+
resnet_layers=resnet_layers,
|
| 360 |
+
dim=dim,
|
| 361 |
+
depth=depth,
|
| 362 |
+
heads=heads,
|
| 363 |
+
mlp_dim=mlp_dim,
|
| 364 |
+
dropout=dropout,
|
| 365 |
+
homology_dims=homology_dims,
|
| 366 |
+
freeze_resnet=freeze_backbone,
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
if __name__ == "__main__":
|
| 371 |
+
# Test the hybrid model
|
| 372 |
+
print("Testing HybridResNetPersistenceViT...")
|
| 373 |
+
|
| 374 |
+
# Create model
|
| 375 |
+
model = HybridResNetPersistenceViT(
|
| 376 |
+
output_dim=256,
|
| 377 |
+
image_size=224,
|
| 378 |
+
resnet_type='resnet18',
|
| 379 |
+
resnet_layers=2,
|
| 380 |
+
dim=256,
|
| 381 |
+
depth=4,
|
| 382 |
+
heads=8,
|
| 383 |
+
mlp_dim=512,
|
| 384 |
+
dropout=0.2,
|
| 385 |
+
homology_dims=3,
|
| 386 |
+
freeze_resnet=False,
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
# Count parameters
|
| 390 |
+
total_params = sum(p.numel() for p in model.parameters())
|
| 391 |
+
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 392 |
+
|
| 393 |
+
print(f"Total parameters: {total_params:,}")
|
| 394 |
+
print(f"Trainable parameters: {trainable_params:,}")
|
| 395 |
+
|
| 396 |
+
# Test forward pass
|
| 397 |
+
batch_size = 4
|
| 398 |
+
images = torch.randn(batch_size, 3, 224, 224)
|
| 399 |
+
|
| 400 |
+
output = model(images)
|
| 401 |
+
print(f"Input shape: {images.shape}")
|
| 402 |
+
print(f"Output shape: {output.shape}")
|
| 403 |
+
|
| 404 |
+
# Component breakdown
|
| 405 |
+
resnet_params = sum(p.numel() for p in model.resnet_extractor.parameters())
|
| 406 |
+
adapter_params = sum(p.numel() for p in model.adapter.parameters())
|
| 407 |
+
transformer_params = sum(p.numel() for p in model.transformer_blocks.parameters())
|
| 408 |
+
|
| 409 |
+
print(f"\nComponent breakdown:")
|
| 410 |
+
print(f" ResNet layers: {resnet_params:,}")
|
| 411 |
+
print(f" CNN-to-Transformer adapter: {adapter_params:,}")
|
| 412 |
+
print(f" Transformer blocks: {transformer_params:,}")
|
| 413 |
+
|
| 414 |
+
print("\nHybrid model test passed!")
|
graphformer/models/image_encoder.py
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image Encoder Components for Persistence Images
|
| 3 |
+
|
| 4 |
+
This module contains various image encoders optimized for persistence images:
|
| 5 |
+
- SimpleCNN: Lightweight CNN
|
| 6 |
+
- SmallViT: Compact Vision Transformer
|
| 7 |
+
- PersistenceViT: Topologically-aware Vision Transformer with persistence-weighted positional encoding
|
| 8 |
+
- ResNet18/ResNet50: Standard ResNet encoders
|
| 9 |
+
- DINOv2: Self-supervised visual encoders
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
import torchvision.models as models
|
| 16 |
+
from torchvision.models import ResNet18_Weights, ResNet50_Weights, ResNet101_Weights
|
| 17 |
+
from typing import Tuple, Optional
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ============================================================================
|
| 21 |
+
# Simple CNN Encoder
|
| 22 |
+
# ============================================================================
|
| 23 |
+
|
| 24 |
+
class SimpleCNN(nn.Module):
|
| 25 |
+
"""Lightweight CNN for persistence images"""
|
| 26 |
+
def __init__(self, output_dim=128):
|
| 27 |
+
super(SimpleCNN, self).__init__()
|
| 28 |
+
self.conv = nn.Sequential(
|
| 29 |
+
nn.Conv2d(3, 32, 3, padding=1),
|
| 30 |
+
nn.ReLU(),
|
| 31 |
+
nn.MaxPool2d(2),
|
| 32 |
+
nn.Conv2d(32, 64, 3, padding=1),
|
| 33 |
+
nn.ReLU(),
|
| 34 |
+
nn.MaxPool2d(2),
|
| 35 |
+
nn.Dropout2d(0.3),
|
| 36 |
+
nn.Conv2d(64, 128, 3, padding=1),
|
| 37 |
+
nn.ReLU(),
|
| 38 |
+
nn.AdaptiveAvgPool2d(1)
|
| 39 |
+
)
|
| 40 |
+
self.fc = nn.Linear(128, output_dim)
|
| 41 |
+
|
| 42 |
+
def forward(self, x):
|
| 43 |
+
x = self.conv(x)
|
| 44 |
+
x = x.view(x.size(0), -1)
|
| 45 |
+
x = self.fc(x)
|
| 46 |
+
return x
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ============================================================================
|
| 50 |
+
# Vision Transformer Components
|
| 51 |
+
# ============================================================================
|
| 52 |
+
|
| 53 |
+
class MultiHeadAttention(nn.Module):
|
| 54 |
+
"""Multi-head self-attention for Vision Transformer"""
|
| 55 |
+
def __init__(self, dim, heads, dropout=0.1):
|
| 56 |
+
super().__init__()
|
| 57 |
+
self.heads = heads
|
| 58 |
+
self.scale = (dim // heads) ** -0.5
|
| 59 |
+
|
| 60 |
+
self.qkv = nn.Linear(dim, dim * 3, bias=False)
|
| 61 |
+
self.attn_drop = nn.Dropout(dropout)
|
| 62 |
+
self.proj = nn.Linear(dim, dim)
|
| 63 |
+
self.proj_drop = nn.Dropout(dropout)
|
| 64 |
+
|
| 65 |
+
def forward(self, x):
|
| 66 |
+
B, N, C = x.shape
|
| 67 |
+
qkv = self.qkv(x).reshape(B, N, 3, self.heads, C // self.heads).permute(2, 0, 3, 1, 4)
|
| 68 |
+
q, k, v = qkv[0], qkv[1], qkv[2]
|
| 69 |
+
|
| 70 |
+
attn = (q @ k.transpose(-2, -1)) * self.scale
|
| 71 |
+
attn = attn.softmax(dim=-1)
|
| 72 |
+
attn = self.attn_drop(attn)
|
| 73 |
+
|
| 74 |
+
x = (attn @ v).transpose(1, 2).reshape(B, N, C)
|
| 75 |
+
x = self.proj(x)
|
| 76 |
+
x = self.proj_drop(x)
|
| 77 |
+
return x
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class TransformerBlock(nn.Module):
|
| 81 |
+
"""Transformer block with self-attention and MLP"""
|
| 82 |
+
def __init__(self, dim, heads, mlp_dim, dropout=0.1):
|
| 83 |
+
super().__init__()
|
| 84 |
+
self.attention = MultiHeadAttention(dim, heads, dropout)
|
| 85 |
+
self.mlp = nn.Sequential(
|
| 86 |
+
nn.Linear(dim, mlp_dim),
|
| 87 |
+
nn.GELU(),
|
| 88 |
+
nn.Dropout(dropout),
|
| 89 |
+
nn.Linear(mlp_dim, dim),
|
| 90 |
+
nn.Dropout(dropout)
|
| 91 |
+
)
|
| 92 |
+
self.norm1 = nn.LayerNorm(dim)
|
| 93 |
+
self.norm2 = nn.LayerNorm(dim)
|
| 94 |
+
|
| 95 |
+
def forward(self, x):
|
| 96 |
+
x = x + self.attention(self.norm1(x))
|
| 97 |
+
x = x + self.mlp(self.norm2(x))
|
| 98 |
+
return x
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class SmallViT(nn.Module):
|
| 102 |
+
"""Lightweight Vision Transformer for persistence images"""
|
| 103 |
+
def __init__(
|
| 104 |
+
self,
|
| 105 |
+
image_size=224,
|
| 106 |
+
patch_size=14,
|
| 107 |
+
output_dim=128,
|
| 108 |
+
dim=128,
|
| 109 |
+
depth=4,
|
| 110 |
+
heads=4,
|
| 111 |
+
mlp_dim=256,
|
| 112 |
+
channels=3,
|
| 113 |
+
dropout=0.2,
|
| 114 |
+
emb_dropout=0.2
|
| 115 |
+
):
|
| 116 |
+
super().__init__()
|
| 117 |
+
assert image_size % patch_size == 0
|
| 118 |
+
num_patches = (image_size // patch_size) ** 2
|
| 119 |
+
|
| 120 |
+
self.patch_embedding = nn.Sequential(
|
| 121 |
+
nn.Conv2d(channels, dim, kernel_size=patch_size, stride=patch_size),
|
| 122 |
+
nn.Flatten(2),
|
| 123 |
+
)
|
| 124 |
+
self.pos_embedding = nn.Parameter(torch.randn(1, num_patches + 1, dim))
|
| 125 |
+
self.cls_token = nn.Parameter(torch.randn(1, 1, dim))
|
| 126 |
+
self.dropout = nn.Dropout(emb_dropout)
|
| 127 |
+
|
| 128 |
+
self.transformer = nn.ModuleList([
|
| 129 |
+
TransformerBlock(dim, heads, mlp_dim, dropout)
|
| 130 |
+
for _ in range(depth)
|
| 131 |
+
])
|
| 132 |
+
|
| 133 |
+
self.mlp_head = nn.Sequential(
|
| 134 |
+
nn.LayerNorm(dim),
|
| 135 |
+
nn.Dropout(dropout),
|
| 136 |
+
nn.Linear(dim, output_dim)
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
def forward(self, img):
|
| 140 |
+
x = self.patch_embedding(img).transpose(1, 2)
|
| 141 |
+
b, n, _ = x.shape
|
| 142 |
+
cls_tokens = self.cls_token.expand(b, -1, -1)
|
| 143 |
+
x = torch.cat([cls_tokens, x], dim=1)
|
| 144 |
+
x = x + self.pos_embedding[:, :(n + 1)]
|
| 145 |
+
x = self.dropout(x)
|
| 146 |
+
|
| 147 |
+
for transformer_block in self.transformer:
|
| 148 |
+
x = transformer_block(x)
|
| 149 |
+
|
| 150 |
+
return self.mlp_head(x[:, 0])
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ============================================================================
|
| 154 |
+
# Topologically-Aware Vision Transformer (PersistenceViT)
|
| 155 |
+
# ============================================================================
|
| 156 |
+
|
| 157 |
+
class TopologicalPatchEmbedding(nn.Module):
|
| 158 |
+
"""Topological-Aware Patch Embedding with importance weighting"""
|
| 159 |
+
def __init__(self, in_channels, dim, patch_size, image_size):
|
| 160 |
+
super().__init__()
|
| 161 |
+
self.patch_size = patch_size
|
| 162 |
+
self.num_patches = (image_size // patch_size) ** 2
|
| 163 |
+
|
| 164 |
+
self.proj = nn.Conv2d(in_channels, dim, kernel_size=patch_size, stride=patch_size)
|
| 165 |
+
|
| 166 |
+
# Importance estimator for high-persistence regions
|
| 167 |
+
self.importance_estimator = nn.Sequential(
|
| 168 |
+
nn.Conv2d(in_channels, 32, kernel_size=patch_size, stride=patch_size),
|
| 169 |
+
nn.ReLU(),
|
| 170 |
+
nn.Conv2d(32, 1, kernel_size=1),
|
| 171 |
+
nn.Sigmoid()
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
def forward(self, x):
|
| 175 |
+
B, C, H, W = x.shape
|
| 176 |
+
patches = self.proj(x).flatten(2).transpose(1, 2) # (B, N, dim)
|
| 177 |
+
importance = self.importance_estimator(x).flatten(2).transpose(1, 2) # (B, N, 1)
|
| 178 |
+
weighted_patches = patches * (1 + importance)
|
| 179 |
+
return weighted_patches, importance.squeeze(-1)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class BirthDeathAttention(nn.Module):
|
| 183 |
+
"""Attention mechanism with persistence-based modulation"""
|
| 184 |
+
def __init__(self, dim, heads, dropout=0.1):
|
| 185 |
+
super().__init__()
|
| 186 |
+
self.heads = heads
|
| 187 |
+
self.scale = (dim // heads) ** -0.5
|
| 188 |
+
|
| 189 |
+
self.qkv = nn.Linear(dim, dim * 3, bias=False)
|
| 190 |
+
self.attn_drop = nn.Dropout(dropout)
|
| 191 |
+
self.proj = nn.Linear(dim, dim)
|
| 192 |
+
self.proj_drop = nn.Dropout(dropout)
|
| 193 |
+
self.persistence_bias = nn.Parameter(torch.zeros(1, heads, 1, 1))
|
| 194 |
+
|
| 195 |
+
def forward(self, x, importance_weights=None):
|
| 196 |
+
B, N, C = x.shape
|
| 197 |
+
qkv = self.qkv(x).reshape(B, N, 3, self.heads, C // self.heads).permute(2, 0, 3, 1, 4)
|
| 198 |
+
q, k, v = qkv[0], qkv[1], qkv[2]
|
| 199 |
+
|
| 200 |
+
attn = (q @ k.transpose(-2, -1)) * self.scale
|
| 201 |
+
|
| 202 |
+
if importance_weights is not None:
|
| 203 |
+
importance = importance_weights.unsqueeze(1).unsqueeze(-1)
|
| 204 |
+
attn = attn + self.persistence_bias + importance * 0.1
|
| 205 |
+
|
| 206 |
+
attn = attn.softmax(dim=-1)
|
| 207 |
+
attn = self.attn_drop(attn)
|
| 208 |
+
|
| 209 |
+
x = (attn @ v).transpose(1, 2).reshape(B, N, C)
|
| 210 |
+
x = self.proj(x)
|
| 211 |
+
x = self.proj_drop(x)
|
| 212 |
+
return x
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class BirthDeathTransformerBlock(nn.Module):
|
| 216 |
+
"""Transformer block with topological awareness"""
|
| 217 |
+
def __init__(self, dim, heads, mlp_dim, dropout=0.1):
|
| 218 |
+
super().__init__()
|
| 219 |
+
self.attention = BirthDeathAttention(dim, heads, dropout)
|
| 220 |
+
self.mlp = nn.Sequential(
|
| 221 |
+
nn.Linear(dim, mlp_dim),
|
| 222 |
+
nn.GELU(),
|
| 223 |
+
nn.Dropout(dropout),
|
| 224 |
+
nn.Linear(mlp_dim, dim),
|
| 225 |
+
nn.Dropout(dropout)
|
| 226 |
+
)
|
| 227 |
+
self.norm1 = nn.LayerNorm(dim)
|
| 228 |
+
self.norm2 = nn.LayerNorm(dim)
|
| 229 |
+
|
| 230 |
+
def forward(self, x, importance_weights=None):
|
| 231 |
+
x = x + self.attention(self.norm1(x), importance_weights)
|
| 232 |
+
x = x + self.mlp(self.norm2(x))
|
| 233 |
+
return x
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
class TopologicalFeatureAggregation(nn.Module):
|
| 237 |
+
"""Aggregates CLS token with homology-specific tokens"""
|
| 238 |
+
def __init__(self, dim, num_homology_dims):
|
| 239 |
+
super().__init__()
|
| 240 |
+
self.num_homology = num_homology_dims
|
| 241 |
+
self.cross_attn = nn.MultiheadAttention(dim, num_heads=4, dropout=0.1, batch_first=True)
|
| 242 |
+
self.norm = nn.LayerNorm(dim)
|
| 243 |
+
self.aggregation_weights = nn.Parameter(torch.ones(1, num_homology_dims + 1, 1))
|
| 244 |
+
|
| 245 |
+
def forward(self, tokens):
|
| 246 |
+
B = tokens.shape[0]
|
| 247 |
+
cls_token = tokens[:, 0:1]
|
| 248 |
+
homology_tokens = tokens[:, 1:]
|
| 249 |
+
|
| 250 |
+
aggregated, _ = self.cross_attn(cls_token, homology_tokens, homology_tokens)
|
| 251 |
+
aggregated = self.norm(aggregated)
|
| 252 |
+
|
| 253 |
+
weights = F.softmax(self.aggregation_weights, dim=1)
|
| 254 |
+
weighted_tokens = tokens * weights
|
| 255 |
+
final = aggregated + weighted_tokens.sum(dim=1, keepdim=True)
|
| 256 |
+
|
| 257 |
+
return final.squeeze(1)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class PersistenceWeightedPositionalEncoding(nn.Module):
|
| 261 |
+
"""
|
| 262 |
+
NOVEL CONTRIBUTION: Positional encoding based on topological persistence
|
| 263 |
+
rather than just spatial grid location.
|
| 264 |
+
|
| 265 |
+
Key insight: Patches corresponding to high-persistence features should have
|
| 266 |
+
similar positional encodings regardless of their spatial location, because
|
| 267 |
+
they represent similar topological significance.
|
| 268 |
+
"""
|
| 269 |
+
def __init__(self, dim, image_size=224, patch_size=14):
|
| 270 |
+
super().__init__()
|
| 271 |
+
self.dim = dim
|
| 272 |
+
self.image_size = image_size
|
| 273 |
+
self.patch_size = patch_size
|
| 274 |
+
self.num_patches = (image_size // patch_size) ** 2
|
| 275 |
+
|
| 276 |
+
# Spatial positional encoding (standard grid-based)
|
| 277 |
+
self.spatial_pos = nn.Parameter(torch.randn(1, self.num_patches, dim // 2))
|
| 278 |
+
|
| 279 |
+
# Persistence-based encoding
|
| 280 |
+
self.birth_encoder = nn.Sequential(
|
| 281 |
+
nn.Linear(1, dim // 4),
|
| 282 |
+
nn.ReLU(),
|
| 283 |
+
nn.Linear(dim // 4, dim // 4)
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
self.persistence_encoder = nn.Sequential(
|
| 287 |
+
nn.Linear(1, dim // 4),
|
| 288 |
+
nn.ReLU(),
|
| 289 |
+
nn.Linear(dim // 4, dim // 4)
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# Fusion of spatial and topological encodings
|
| 293 |
+
self.fusion = nn.Sequential(
|
| 294 |
+
nn.Linear(dim, dim),
|
| 295 |
+
nn.LayerNorm(dim),
|
| 296 |
+
nn.Tanh()
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
def encode_patch_persistence(
|
| 300 |
+
self,
|
| 301 |
+
persistence_coords: torch.Tensor, # (N_features, 3)
|
| 302 |
+
pixel_coords: torch.Tensor # (N_features, 2)
|
| 303 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 304 |
+
"""
|
| 305 |
+
Assign persistence values to patches based on which features fall in each patch.
|
| 306 |
+
|
| 307 |
+
Returns:
|
| 308 |
+
patch_birth: (num_patches, 1) - average birth for each patch
|
| 309 |
+
patch_persistence: (num_patches, 1) - average persistence for each patch
|
| 310 |
+
"""
|
| 311 |
+
device = persistence_coords.device
|
| 312 |
+
num_patches_h = self.image_size // self.patch_size
|
| 313 |
+
num_patches_w = self.image_size // self.patch_size
|
| 314 |
+
|
| 315 |
+
# Initialize patch statistics
|
| 316 |
+
patch_birth_sum = torch.zeros(num_patches_h, num_patches_w, device=device)
|
| 317 |
+
patch_pers_sum = torch.zeros(num_patches_h, num_patches_w, device=device)
|
| 318 |
+
patch_count = torch.zeros(num_patches_h, num_patches_w, device=device)
|
| 319 |
+
|
| 320 |
+
# Assign features to patches
|
| 321 |
+
for i in range(len(pixel_coords)):
|
| 322 |
+
# Skip padded entries (all zeros)
|
| 323 |
+
if torch.all(pixel_coords[i] == 0) and torch.all(persistence_coords[i] == 0):
|
| 324 |
+
continue
|
| 325 |
+
|
| 326 |
+
x, y = pixel_coords[i]
|
| 327 |
+
patch_x = int(torch.clamp(x / self.patch_size, 0, num_patches_w - 1))
|
| 328 |
+
patch_y = int(torch.clamp(y / self.patch_size, 0, num_patches_h - 1))
|
| 329 |
+
|
| 330 |
+
birth = persistence_coords[i, 0]
|
| 331 |
+
pers = persistence_coords[i, 2]
|
| 332 |
+
|
| 333 |
+
patch_birth_sum[patch_y, patch_x] += birth
|
| 334 |
+
patch_pers_sum[patch_y, patch_x] += pers
|
| 335 |
+
patch_count[patch_y, patch_x] += 1
|
| 336 |
+
|
| 337 |
+
# Average persistence values per patch
|
| 338 |
+
mask = patch_count > 0
|
| 339 |
+
patch_birth_avg = torch.zeros_like(patch_birth_sum)
|
| 340 |
+
patch_pers_avg = torch.zeros_like(patch_pers_sum)
|
| 341 |
+
|
| 342 |
+
patch_birth_avg[mask] = patch_birth_sum[mask] / patch_count[mask]
|
| 343 |
+
patch_pers_avg[mask] = patch_pers_sum[mask] / patch_count[mask]
|
| 344 |
+
|
| 345 |
+
# Flatten to (num_patches, 1)
|
| 346 |
+
patch_birth_flat = patch_birth_avg.flatten().unsqueeze(-1) # (num_patches, 1)
|
| 347 |
+
patch_pers_flat = patch_pers_avg.flatten().unsqueeze(-1) # (num_patches, 1)
|
| 348 |
+
|
| 349 |
+
return patch_birth_flat, patch_pers_flat
|
| 350 |
+
|
| 351 |
+
def forward(
|
| 352 |
+
self,
|
| 353 |
+
batch_size: int,
|
| 354 |
+
persistence_coords: Optional[torch.Tensor] = None,
|
| 355 |
+
pixel_coords: Optional[torch.Tensor] = None
|
| 356 |
+
) -> torch.Tensor:
|
| 357 |
+
"""
|
| 358 |
+
Generate positional encoding combining spatial and persistence information.
|
| 359 |
+
|
| 360 |
+
Args:
|
| 361 |
+
batch_size: Batch size
|
| 362 |
+
persistence_coords: (B, N_features, 3) - optional persistence coordinates
|
| 363 |
+
pixel_coords: (B, N_features, 2) - optional pixel coordinates
|
| 364 |
+
|
| 365 |
+
Returns:
|
| 366 |
+
pos_encoding: (B, num_patches, dim)
|
| 367 |
+
"""
|
| 368 |
+
# Start with spatial encoding
|
| 369 |
+
spatial_enc = self.spatial_pos.expand(batch_size, -1, -1) # (B, num_patches, dim//2)
|
| 370 |
+
|
| 371 |
+
if persistence_coords is None or pixel_coords is None:
|
| 372 |
+
# Fall back to spatial-only encoding
|
| 373 |
+
zero_enc = torch.zeros(batch_size, self.num_patches, self.dim // 2,
|
| 374 |
+
device=spatial_enc.device)
|
| 375 |
+
return torch.cat([spatial_enc, zero_enc], dim=-1)
|
| 376 |
+
|
| 377 |
+
# Encode persistence information for each sample in batch
|
| 378 |
+
batch_persistence_enc = []
|
| 379 |
+
|
| 380 |
+
for b in range(batch_size):
|
| 381 |
+
pers_coords_b = persistence_coords[b] # (N_features, 3)
|
| 382 |
+
pix_coords_b = pixel_coords[b] # (N_features, 2)
|
| 383 |
+
|
| 384 |
+
# Get per-patch persistence statistics
|
| 385 |
+
patch_birth, patch_pers = self.encode_patch_persistence(pers_coords_b, pix_coords_b)
|
| 386 |
+
|
| 387 |
+
# Encode through MLPs
|
| 388 |
+
birth_enc = self.birth_encoder(patch_birth) # (num_patches, dim//4)
|
| 389 |
+
pers_enc = self.persistence_encoder(patch_pers) # (num_patches, dim//4)
|
| 390 |
+
|
| 391 |
+
# Combine
|
| 392 |
+
persistence_enc = torch.cat([birth_enc, pers_enc], dim=-1) # (num_patches, dim//2)
|
| 393 |
+
batch_persistence_enc.append(persistence_enc)
|
| 394 |
+
|
| 395 |
+
persistence_enc = torch.stack(batch_persistence_enc, dim=0) # (B, num_patches, dim//2)
|
| 396 |
+
|
| 397 |
+
# Combine spatial and persistence encodings
|
| 398 |
+
combined = torch.cat([spatial_enc, persistence_enc], dim=-1) # (B, num_patches, dim)
|
| 399 |
+
|
| 400 |
+
# Fuse through learned transformation
|
| 401 |
+
pos_encoding = self.fusion(combined)
|
| 402 |
+
|
| 403 |
+
return pos_encoding
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
class PersistenceViT(nn.Module):
|
| 407 |
+
"""
|
| 408 |
+
Vision Transformer for Persistence Images with topological inductive biases.
|
| 409 |
+
|
| 410 |
+
Features:
|
| 411 |
+
- Topological-aware patch embedding with importance weighting
|
| 412 |
+
- Birth-death attention mechanism
|
| 413 |
+
- Multi-scale persistence encoding via homology tokens
|
| 414 |
+
- Topological feature aggregation
|
| 415 |
+
- Persistence-weighted positional encoding
|
| 416 |
+
"""
|
| 417 |
+
def __init__(
|
| 418 |
+
self,
|
| 419 |
+
image_size=256,
|
| 420 |
+
patch_size=16,
|
| 421 |
+
output_dim=128,
|
| 422 |
+
dim=128,
|
| 423 |
+
depth=4,
|
| 424 |
+
heads=4,
|
| 425 |
+
mlp_dim=256,
|
| 426 |
+
channels=3,
|
| 427 |
+
dropout=0.2,
|
| 428 |
+
homology_dims=3,
|
| 429 |
+
):
|
| 430 |
+
super().__init__()
|
| 431 |
+
|
| 432 |
+
self.image_size = image_size
|
| 433 |
+
self.patch_size = patch_size
|
| 434 |
+
num_patches = (image_size // patch_size) ** 2
|
| 435 |
+
|
| 436 |
+
# Topological-aware patch embedding
|
| 437 |
+
self.patch_embedding = TopologicalPatchEmbedding(
|
| 438 |
+
channels, dim, patch_size, image_size
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
# Multi-scale persistence tokens
|
| 442 |
+
self.homology_tokens = nn.Parameter(torch.randn(1, homology_dims, dim))
|
| 443 |
+
self.cls_token = nn.Parameter(torch.randn(1, 1, dim))
|
| 444 |
+
|
| 445 |
+
# Persistence-weighted positional encoding
|
| 446 |
+
self.pos_encoder = PersistenceWeightedPositionalEncoding(
|
| 447 |
+
dim=dim,
|
| 448 |
+
image_size=image_size,
|
| 449 |
+
patch_size=patch_size
|
| 450 |
+
)
|
| 451 |
+
self.dropout = nn.Dropout(dropout)
|
| 452 |
+
|
| 453 |
+
# Birth-death transformer blocks
|
| 454 |
+
self.transformer_blocks = nn.ModuleList([
|
| 455 |
+
BirthDeathTransformerBlock(dim, heads, mlp_dim, dropout)
|
| 456 |
+
for _ in range(depth)
|
| 457 |
+
])
|
| 458 |
+
|
| 459 |
+
# Topological aggregation
|
| 460 |
+
self.topological_aggregation = TopologicalFeatureAggregation(dim, homology_dims)
|
| 461 |
+
|
| 462 |
+
# Output head
|
| 463 |
+
self.norm = nn.LayerNorm(dim)
|
| 464 |
+
self.mlp_head = nn.Sequential(
|
| 465 |
+
nn.Dropout(dropout),
|
| 466 |
+
nn.Linear(dim, output_dim)
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
def forward(self, persistence_img, persistence_coords=None, pixel_coords=None):
|
| 470 |
+
"""
|
| 471 |
+
Forward pass with optional persistence coordinate information.
|
| 472 |
+
|
| 473 |
+
Args:
|
| 474 |
+
persistence_img: (B, C, H, W) - persistence image
|
| 475 |
+
persistence_coords: (B, N_features, 3) - optional normalized (birth, death, persistence)
|
| 476 |
+
pixel_coords: (B, N_features, 2) - optional (x, y) pixel locations
|
| 477 |
+
|
| 478 |
+
Returns:
|
| 479 |
+
output: (B, output_dim) - encoded representation
|
| 480 |
+
"""
|
| 481 |
+
B = persistence_img.shape[0]
|
| 482 |
+
|
| 483 |
+
# Topological-aware patch embedding
|
| 484 |
+
x, importance_weights = self.patch_embedding(persistence_img) # (B, N, dim)
|
| 485 |
+
|
| 486 |
+
# Generate persistence-weighted positional encoding
|
| 487 |
+
pos_encoding = self.pos_encoder(
|
| 488 |
+
batch_size=B,
|
| 489 |
+
persistence_coords=persistence_coords,
|
| 490 |
+
pixel_coords=pixel_coords
|
| 491 |
+
) # (B, num_patches, dim)
|
| 492 |
+
|
| 493 |
+
# Add positional encoding
|
| 494 |
+
x = x + pos_encoding
|
| 495 |
+
|
| 496 |
+
# Add special tokens
|
| 497 |
+
cls_tokens = self.cls_token.expand(B, -1, -1)
|
| 498 |
+
homology_tokens = self.homology_tokens.expand(B, -1, -1)
|
| 499 |
+
x = torch.cat([cls_tokens, homology_tokens, x], dim=1)
|
| 500 |
+
|
| 501 |
+
# Pad importance_weights for special tokens (cls + homology)
|
| 502 |
+
num_special_tokens = 1 + self.homology_tokens.shape[1]
|
| 503 |
+
importance_padding = torch.zeros(B, num_special_tokens, device=importance_weights.device)
|
| 504 |
+
importance_weights_padded = torch.cat([importance_padding, importance_weights], dim=1)
|
| 505 |
+
|
| 506 |
+
x = self.dropout(x)
|
| 507 |
+
|
| 508 |
+
# Birth-death transformer blocks
|
| 509 |
+
for block in self.transformer_blocks:
|
| 510 |
+
x = block(x, importance_weights_padded)
|
| 511 |
+
|
| 512 |
+
x = self.norm(x)
|
| 513 |
+
|
| 514 |
+
# Topological aggregation
|
| 515 |
+
x = self.topological_aggregation(x[:, :1+self.homology_tokens.shape[1]])
|
| 516 |
+
|
| 517 |
+
return self.mlp_head(x)
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
# ============================================================================
|
| 521 |
+
# Unified Image Encoder Interface
|
| 522 |
+
# ============================================================================
|
| 523 |
+
|
| 524 |
+
class ImageEncoder(nn.Module):
|
| 525 |
+
"""
|
| 526 |
+
Unified image encoder interface supporting multiple architectures:
|
| 527 |
+
- SimpleCNN: Lightweight CNN
|
| 528 |
+
- SmallViT: Compact Vision Transformer
|
| 529 |
+
- PersistenceViT: Topologically-aware ViT
|
| 530 |
+
- ResNet18/ResNet50/ResNet101: Standard CNNs with ImageNet pretraining
|
| 531 |
+
- DINOv2: Self-supervised vision encoders (ViT-S/B/L/g)
|
| 532 |
+
- ConvNeXt-Small: Modern CNN architecture
|
| 533 |
+
- HybridResNetViT: ResNet conv layers + PersistenceViT attention
|
| 534 |
+
"""
|
| 535 |
+
def __init__(self, output_dim=128, model_type='resnet18', image_size=224, freeze_backbone=False):
|
| 536 |
+
super(ImageEncoder, self).__init__()
|
| 537 |
+
|
| 538 |
+
if model_type == 'hybrid_resnet18_vit':
|
| 539 |
+
from .hybrid_resnet_persistencevit import create_hybrid_encoder
|
| 540 |
+
self.encoder = create_hybrid_encoder(
|
| 541 |
+
output_dim=output_dim,
|
| 542 |
+
image_size=image_size,
|
| 543 |
+
freeze_backbone=freeze_backbone,
|
| 544 |
+
resnet_type='resnet18',
|
| 545 |
+
resnet_layers=2,
|
| 546 |
+
dim=256,
|
| 547 |
+
depth=4,
|
| 548 |
+
heads=8,
|
| 549 |
+
mlp_dim=512,
|
| 550 |
+
dropout=0.2,
|
| 551 |
+
)
|
| 552 |
+
self.use_simple_model = True
|
| 553 |
+
|
| 554 |
+
elif model_type == 'hybrid_resnet50_vit':
|
| 555 |
+
from .hybrid_resnet_persistencevit import create_hybrid_encoder
|
| 556 |
+
self.encoder = create_hybrid_encoder(
|
| 557 |
+
output_dim=output_dim,
|
| 558 |
+
image_size=image_size,
|
| 559 |
+
freeze_backbone=freeze_backbone,
|
| 560 |
+
resnet_type='resnet50',
|
| 561 |
+
resnet_layers=2,
|
| 562 |
+
dim=256,
|
| 563 |
+
depth=4,
|
| 564 |
+
heads=8,
|
| 565 |
+
mlp_dim=512,
|
| 566 |
+
dropout=0.2,
|
| 567 |
+
)
|
| 568 |
+
self.use_simple_model = True
|
| 569 |
+
|
| 570 |
+
elif model_type == 'simplecnn':
|
| 571 |
+
self.encoder = SimpleCNN(output_dim=output_dim)
|
| 572 |
+
self.use_simple_model = True
|
| 573 |
+
elif model_type == 'smallvit':
|
| 574 |
+
self.encoder = SmallViT(image_size=image_size, patch_size=16, output_dim=output_dim,
|
| 575 |
+
dim=128, depth=6, heads=4, mlp_dim=256, channels=3,
|
| 576 |
+
dropout=0.2, emb_dropout=0.2)
|
| 577 |
+
self.use_simple_model = True
|
| 578 |
+
elif model_type == 'persistencevit':
|
| 579 |
+
self.encoder = PersistenceViT(image_size=image_size, patch_size=16, output_dim=output_dim,
|
| 580 |
+
dim=128, depth=6, heads=4, mlp_dim=256, channels=3,
|
| 581 |
+
dropout=0.2, homology_dims=3)
|
| 582 |
+
self.use_simple_model = True
|
| 583 |
+
elif model_type == 'resnet18':
|
| 584 |
+
resnet = models.resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
|
| 585 |
+
feature_dim = 512
|
| 586 |
+
self.encoder = nn.Sequential(*list(resnet.children())[:-1])
|
| 587 |
+
self.fc = nn.Sequential(
|
| 588 |
+
nn.Linear(feature_dim, 256),
|
| 589 |
+
nn.LayerNorm(256),
|
| 590 |
+
nn.ReLU(),
|
| 591 |
+
nn.Linear(256, output_dim),
|
| 592 |
+
)
|
| 593 |
+
self.use_simple_model = False
|
| 594 |
+
elif model_type == 'resnet50':
|
| 595 |
+
resnet = models.resnet50(weights=ResNet50_Weights.IMAGENET1K_V1)
|
| 596 |
+
feature_dim = 2048
|
| 597 |
+
self.encoder = nn.Sequential(*list(resnet.children())[:-1])
|
| 598 |
+
self.fc = nn.Sequential(
|
| 599 |
+
nn.Linear(feature_dim, 256),
|
| 600 |
+
nn.LayerNorm(256),
|
| 601 |
+
nn.ReLU(),
|
| 602 |
+
nn.Linear(256, output_dim),
|
| 603 |
+
)
|
| 604 |
+
self.use_simple_model = False
|
| 605 |
+
elif model_type == 'resnet101':
|
| 606 |
+
resnet = models.resnet101(weights=ResNet101_Weights.IMAGENET1K_V1)
|
| 607 |
+
feature_dim = 2048
|
| 608 |
+
self.encoder = nn.Sequential(*list(resnet.children())[:-1])
|
| 609 |
+
self.fc = nn.Sequential(
|
| 610 |
+
nn.Linear(feature_dim, 256)
|
| 611 |
+
)
|
| 612 |
+
self.use_simple_model = False
|
| 613 |
+
elif model_type.startswith('dinov2'):
|
| 614 |
+
self.encoder = DINOv2ImageEncoder(output_dim=output_dim, freeze_backbone=False, model_variant=model_type)
|
| 615 |
+
|
| 616 |
+
self.use_simple_model = True
|
| 617 |
+
elif model_type == 'convnext_small':
|
| 618 |
+
from torchvision.models import convnext_small, ConvNeXt_Small_Weights
|
| 619 |
+
convnext = convnext_small(weights=ConvNeXt_Small_Weights.IMAGENET1K_V1)
|
| 620 |
+
feature_dim = 768
|
| 621 |
+
self.encoder = nn.Sequential(*list(convnext.children())[:-1])
|
| 622 |
+
if freeze_backbone:
|
| 623 |
+
for param in self.encoder.parameters():
|
| 624 |
+
param.requires_grad = False
|
| 625 |
+
self.fc = nn.Sequential(
|
| 626 |
+
nn.Flatten(),
|
| 627 |
+
nn.Linear(feature_dim, 256),
|
| 628 |
+
nn.LayerNorm(256),
|
| 629 |
+
nn.ReLU(),
|
| 630 |
+
nn.Linear(256, output_dim),
|
| 631 |
+
)
|
| 632 |
+
self.use_simple_model = False
|
| 633 |
+
else:
|
| 634 |
+
raise ValueError(f"model_type must be 'simplecnn', 'smallvit', 'persistencevit', 'resnet18', 'resnet50', 'resnet101', 'dinov2_vits14', 'dinov2_vitb14', 'dinov2_vitl14', 'dinov2_vitg14', 'convnext_small', or 'hybrid_resnet_vit', got {model_type}")
|
| 635 |
+
|
| 636 |
+
self.model_type = model_type
|
| 637 |
+
|
| 638 |
+
def forward(self, images, persistence_coords=None, pixel_coords=None):
|
| 639 |
+
"""
|
| 640 |
+
Args:
|
| 641 |
+
images: (B, 3, H, W) or (B, 1, H, W) tensor of persistence images
|
| 642 |
+
persistence_coords: (B, N_features, 3) - optional for PersistenceViT
|
| 643 |
+
pixel_coords: (B, N_features, 2) - optional for PersistenceViT
|
| 644 |
+
Returns:
|
| 645 |
+
features: (B, output_dim) tensor of image features
|
| 646 |
+
"""
|
| 647 |
+
if self.use_simple_model:
|
| 648 |
+
# PersistenceViT can use the coordinates
|
| 649 |
+
if self.model_type == 'persistencevit':
|
| 650 |
+
return self.encoder(images, persistence_coords, pixel_coords)
|
| 651 |
+
else:
|
| 652 |
+
return self.encoder(images)
|
| 653 |
+
else:
|
| 654 |
+
features = self.encoder(images) # (B, feature_dim, 1, 1)
|
| 655 |
+
features = features.view(features.size(0), -1) # (B, feature_dim)
|
| 656 |
+
features = self.fc(features) # (B, output_dim)
|
| 657 |
+
return features
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
class DINOv2ImageEncoder(nn.Module):
|
| 661 |
+
"""
|
| 662 |
+
DINOv2-based image encoder for persistence images.
|
| 663 |
+
Supports multiple DINOv2 variants: ViT-S/14, ViT-B/14, ViT-L/14, ViT-g/14.
|
| 664 |
+
Returns raw features (384-dim for vits14) without projection head.
|
| 665 |
+
"""
|
| 666 |
+
def __init__(self, output_dim=128, freeze_backbone=True, model_variant='dinov2_vits14'):
|
| 667 |
+
super(DINOv2ImageEncoder, self).__init__()
|
| 668 |
+
|
| 669 |
+
# Map model variant to feature dimension
|
| 670 |
+
variant_dims = {
|
| 671 |
+
'dinov2_vits14': 384,
|
| 672 |
+
'dinov2_vitb14': 768,
|
| 673 |
+
'dinov2_vitl14': 1024,
|
| 674 |
+
'dinov2_vitg14': 1536
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
if model_variant not in variant_dims:
|
| 678 |
+
raise ValueError(f"Unknown DINOv2 variant: {model_variant}")
|
| 679 |
+
|
| 680 |
+
self.feat_dim = variant_dims[model_variant]
|
| 681 |
+
|
| 682 |
+
# Load DINOv2 model
|
| 683 |
+
self.backbone = torch.hub.load('facebookresearch/dinov2', model_variant)
|
| 684 |
+
|
| 685 |
+
# Freeze backbone if requested
|
| 686 |
+
if freeze_backbone:
|
| 687 |
+
for param in self.backbone.parameters():
|
| 688 |
+
param.requires_grad = False
|
| 689 |
+
|
| 690 |
+
def forward(self, images, persistence_coords=None, pixel_coords=None):
|
| 691 |
+
"""
|
| 692 |
+
Args:
|
| 693 |
+
images: (B, 3, H, W) tensor
|
| 694 |
+
persistence_coords: ignored (for interface compatibility)
|
| 695 |
+
pixel_coords: ignored (for interface compatibility)
|
| 696 |
+
Returns:
|
| 697 |
+
features: (B, feat_dim) raw DINOv2 features (384 for vits14)
|
| 698 |
+
"""
|
| 699 |
+
# DINOv2 forward - return raw features
|
| 700 |
+
features = self.backbone(images) # (B, feat_dim)
|
| 701 |
+
|
| 702 |
+
return features
|
graphformer/models/tree_encoder.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tree Encoder Components for Neuron Morphology Analysis
|
| 3 |
+
|
| 4 |
+
This module contains TreeLSTM and related components for encoding tree structures.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn as nn
|
| 9 |
+
import dgl
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MultiNodeAggregation(nn.Module):
|
| 13 |
+
"""Attention-based aggregation over all tree nodes instead of just root"""
|
| 14 |
+
def __init__(self, h_size, aggregation_type="attention"):
|
| 15 |
+
super(MultiNodeAggregation, self).__init__()
|
| 16 |
+
self.h_size = h_size
|
| 17 |
+
self.aggregation_type = aggregation_type
|
| 18 |
+
|
| 19 |
+
if aggregation_type == "attention":
|
| 20 |
+
# Learnable attention over nodes
|
| 21 |
+
self.attention = nn.Sequential(
|
| 22 |
+
nn.Linear(h_size, h_size),
|
| 23 |
+
nn.Tanh(),
|
| 24 |
+
nn.Linear(h_size, 1)
|
| 25 |
+
)
|
| 26 |
+
elif aggregation_type == "weighted":
|
| 27 |
+
# Learnable weighted sum
|
| 28 |
+
self.weight_net = nn.Sequential(
|
| 29 |
+
nn.Linear(h_size, h_size // 2),
|
| 30 |
+
nn.ReLU(),
|
| 31 |
+
nn.Linear(h_size // 2, 1),
|
| 32 |
+
nn.Sigmoid()
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
def forward(self, g, node_features, offsets):
|
| 36 |
+
"""
|
| 37 |
+
Args:
|
| 38 |
+
g: DGL graph
|
| 39 |
+
node_features: (N, h_size) features for all nodes
|
| 40 |
+
offsets: indices of root nodes for each tree in batch
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
aggregated: (batch_size, h_size) aggregated features
|
| 44 |
+
"""
|
| 45 |
+
batch_size = len(offsets)
|
| 46 |
+
aggregated = []
|
| 47 |
+
|
| 48 |
+
# For each tree in batch
|
| 49 |
+
for i in range(batch_size):
|
| 50 |
+
# Get start and end indices for this tree's nodes
|
| 51 |
+
if i < batch_size - 1:
|
| 52 |
+
start_idx = offsets[i] if i == 0 else offsets[i-1]
|
| 53 |
+
end_idx = offsets[i+1]
|
| 54 |
+
else:
|
| 55 |
+
start_idx = offsets[i-1] if i > 0 else 0
|
| 56 |
+
end_idx = len(node_features)
|
| 57 |
+
|
| 58 |
+
# Extract this tree's node features
|
| 59 |
+
tree_feats = node_features[start_idx:end_idx] # (num_nodes_i, h_size)
|
| 60 |
+
|
| 61 |
+
if self.aggregation_type == "attention":
|
| 62 |
+
# Attention weights
|
| 63 |
+
attn_scores = self.attention(tree_feats) # (num_nodes_i, 1)
|
| 64 |
+
attn_weights = torch.softmax(attn_scores, dim=0)
|
| 65 |
+
|
| 66 |
+
# Weighted sum
|
| 67 |
+
agg = (tree_feats * attn_weights).sum(dim=0) # (h_size,)
|
| 68 |
+
|
| 69 |
+
elif self.aggregation_type == "weighted":
|
| 70 |
+
# Simple learnable weighting
|
| 71 |
+
weights = self.weight_net(tree_feats) # (num_nodes_i, 1)
|
| 72 |
+
agg = (tree_feats * weights).sum(dim=0) # (h_size,)
|
| 73 |
+
|
| 74 |
+
elif self.aggregation_type == "mean":
|
| 75 |
+
agg = tree_feats.mean(dim=0) # (h_size,)
|
| 76 |
+
|
| 77 |
+
elif self.aggregation_type == "max":
|
| 78 |
+
agg = tree_feats.max(dim=0)[0] # (h_size,)
|
| 79 |
+
|
| 80 |
+
aggregated.append(agg)
|
| 81 |
+
|
| 82 |
+
return torch.stack(aggregated, dim=0) # (batch_size, h_size)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class TreeLSTMCell(nn.Module):
|
| 86 |
+
def __init__(self, x_size, h_size, mode="sum"):
|
| 87 |
+
super(TreeLSTMCell, self).__init__()
|
| 88 |
+
self.h_size, self.mode = h_size, mode
|
| 89 |
+
self.W_iou = nn.Linear(x_size, 3 * h_size, bias=False)
|
| 90 |
+
self.U_iou = nn.Linear(h_size, 3 * h_size, bias=False)
|
| 91 |
+
self.b_iou = nn.Parameter(torch.zeros(1, 3 * h_size))
|
| 92 |
+
self.U_f = nn.Linear(h_size, h_size)
|
| 93 |
+
|
| 94 |
+
def message_func(self, edges):
|
| 95 |
+
return {"h": edges.src["h"], "c": edges.src["c"]}
|
| 96 |
+
|
| 97 |
+
def reduce_func(self, nodes):
|
| 98 |
+
if self.mode == "sum":
|
| 99 |
+
h_cat = nodes.mailbox["h"].sum(dim=1)
|
| 100 |
+
elif self.mode == "max":
|
| 101 |
+
h_cat = nodes.mailbox["h"].max(dim=1)[0]
|
| 102 |
+
elif self.mode == "mean":
|
| 103 |
+
h_cat = nodes.mailbox["h"].mean(dim=1)
|
| 104 |
+
else:
|
| 105 |
+
raise NotImplementedError
|
| 106 |
+
|
| 107 |
+
f = torch.sigmoid(self.U_f(nodes.mailbox["h"]))
|
| 108 |
+
c = torch.sum(f * nodes.mailbox["c"], 1)
|
| 109 |
+
return {"iou": self.U_iou(h_cat), "c": c}
|
| 110 |
+
|
| 111 |
+
def apply_node_func(self, nodes):
|
| 112 |
+
iou = nodes.data["iou"] + self.b_iou
|
| 113 |
+
i, o, u = torch.chunk(iou, 3, 1)
|
| 114 |
+
i, o, u = torch.sigmoid(i), torch.sigmoid(o), torch.tanh(u)
|
| 115 |
+
c = i * u + nodes.data["c"]
|
| 116 |
+
h = o * torch.tanh(c)
|
| 117 |
+
return {"h": h, "c": c}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class BidirectionalTreeLSTMCell(nn.Module):
|
| 121 |
+
"""Bidirectional TreeLSTM Cell that processes tree in both bottom-up and top-down directions"""
|
| 122 |
+
def __init__(self, x_size, h_size, mode="sum"):
|
| 123 |
+
super(BidirectionalTreeLSTMCell, self).__init__()
|
| 124 |
+
self.h_size = h_size
|
| 125 |
+
self.mode = mode
|
| 126 |
+
|
| 127 |
+
# Bottom-up (child to parent) parameters
|
| 128 |
+
self.W_iou_bu = nn.Linear(x_size, 3 * h_size, bias=False)
|
| 129 |
+
self.U_iou_bu = nn.Linear(h_size, 3 * h_size, bias=False)
|
| 130 |
+
self.b_iou_bu = nn.Parameter(torch.zeros(1, 3 * h_size))
|
| 131 |
+
self.U_f_bu = nn.Linear(h_size, h_size)
|
| 132 |
+
|
| 133 |
+
# Top-down (parent to child) parameters
|
| 134 |
+
self.W_iou_td = nn.Linear(x_size, 3 * h_size, bias=False)
|
| 135 |
+
self.U_iou_td = nn.Linear(h_size, 3 * h_size, bias=False)
|
| 136 |
+
self.b_iou_td = nn.Parameter(torch.zeros(1, 3 * h_size))
|
| 137 |
+
self.U_f_td = nn.Linear(h_size, h_size)
|
| 138 |
+
|
| 139 |
+
def message_func_bu(self, edges):
|
| 140 |
+
"""Bottom-up message function (from children to parent)"""
|
| 141 |
+
return {"h_bu": edges.src["h_bu"], "c_bu": edges.src["c_bu"]}
|
| 142 |
+
|
| 143 |
+
def reduce_func_bu(self, nodes):
|
| 144 |
+
"""Bottom-up reduce function"""
|
| 145 |
+
if self.mode == "sum":
|
| 146 |
+
h_cat = nodes.mailbox["h_bu"].sum(dim=1)
|
| 147 |
+
elif self.mode == "max":
|
| 148 |
+
h_cat = nodes.mailbox["h_bu"].max(dim=1)[0]
|
| 149 |
+
elif self.mode == "mean":
|
| 150 |
+
h_cat = nodes.mailbox["h_bu"].mean(dim=1)
|
| 151 |
+
else:
|
| 152 |
+
raise NotImplementedError
|
| 153 |
+
|
| 154 |
+
f = torch.sigmoid(self.U_f_bu(nodes.mailbox["h_bu"]))
|
| 155 |
+
c = torch.sum(f * nodes.mailbox["c_bu"], 1)
|
| 156 |
+
return {"iou_bu": self.U_iou_bu(h_cat), "c_bu": c}
|
| 157 |
+
|
| 158 |
+
def apply_node_func_bu(self, nodes):
|
| 159 |
+
"""Bottom-up apply node function"""
|
| 160 |
+
iou = nodes.data["iou_bu"] + self.b_iou_bu
|
| 161 |
+
i, o, u = torch.chunk(iou, 3, 1)
|
| 162 |
+
i, o, u = torch.sigmoid(i), torch.sigmoid(o), torch.tanh(u)
|
| 163 |
+
c = i * u + nodes.data["c_bu"]
|
| 164 |
+
h = o * torch.tanh(c)
|
| 165 |
+
return {"h_bu": h, "c_bu": c}
|
| 166 |
+
|
| 167 |
+
def message_func_td(self, edges):
|
| 168 |
+
"""Top-down message function (from parent to children)"""
|
| 169 |
+
return {"h_td": edges.dst["h_td"], "c_td": edges.dst["c_td"]}
|
| 170 |
+
|
| 171 |
+
def reduce_func_td(self, nodes):
|
| 172 |
+
"""Top-down reduce function"""
|
| 173 |
+
if nodes.mailbox["h_td"].shape[1] == 0:
|
| 174 |
+
# Root node has no parent
|
| 175 |
+
return {"iou_td": torch.zeros(nodes.batch_size(), 3 * self.h_size, device=nodes.mailbox["h_td"].device),
|
| 176 |
+
"c_td": torch.zeros(nodes.batch_size(), self.h_size, device=nodes.mailbox["h_td"].device)}
|
| 177 |
+
|
| 178 |
+
# For non-root nodes, aggregate parent information
|
| 179 |
+
h_parent = nodes.mailbox["h_td"][:, 0, :] # Take first (and only) parent
|
| 180 |
+
c_parent = nodes.mailbox["c_td"][:, 0, :]
|
| 181 |
+
|
| 182 |
+
return {"iou_td": self.U_iou_td(h_parent), "c_td": c_parent}
|
| 183 |
+
|
| 184 |
+
def apply_node_func_td(self, nodes):
|
| 185 |
+
"""Top-down apply node function"""
|
| 186 |
+
iou = nodes.data["iou_td"] + self.b_iou_td
|
| 187 |
+
i, o, u = torch.chunk(iou, 3, 1)
|
| 188 |
+
i, o, u = torch.sigmoid(i), torch.sigmoid(o), torch.tanh(u)
|
| 189 |
+
c = i * u + nodes.data["c_td"]
|
| 190 |
+
h = o * torch.tanh(c)
|
| 191 |
+
return {"h_td": h, "c_td": c}
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class BidirectionalTreeLSTM(nn.Module):
|
| 195 |
+
"""Bidirectional TreeLSTM that combines bottom-up and top-down processing"""
|
| 196 |
+
def __init__(self, x_size, h_size, num_classes, mode="sum", fc=True, bn=False,
|
| 197 |
+
node_aggregation=None):
|
| 198 |
+
super(BidirectionalTreeLSTM, self).__init__()
|
| 199 |
+
self.x_size = x_size
|
| 200 |
+
self.h_size = h_size
|
| 201 |
+
self.node_aggregation = node_aggregation
|
| 202 |
+
|
| 203 |
+
# Input projection
|
| 204 |
+
if bn:
|
| 205 |
+
self.mlp1 = nn.Sequential(
|
| 206 |
+
nn.Linear(x_size, h_size),
|
| 207 |
+
nn.BatchNorm1d(h_size),
|
| 208 |
+
nn.ReLU(),
|
| 209 |
+
)
|
| 210 |
+
else:
|
| 211 |
+
self.mlp1 = nn.Sequential(
|
| 212 |
+
nn.Linear(x_size, h_size),
|
| 213 |
+
nn.ReLU(),
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# Bidirectional TreeLSTM cell
|
| 217 |
+
self.cell = BidirectionalTreeLSTMCell(h_size, h_size, mode=mode)
|
| 218 |
+
|
| 219 |
+
# Node aggregation
|
| 220 |
+
if node_aggregation:
|
| 221 |
+
self.node_agg = MultiNodeAggregation(h_size * 2, aggregation_type=node_aggregation)
|
| 222 |
+
|
| 223 |
+
# Classification head
|
| 224 |
+
self.fc = fc
|
| 225 |
+
if fc:
|
| 226 |
+
self.linear = nn.Linear(h_size * 2, num_classes)
|
| 227 |
+
|
| 228 |
+
def forward(self, batch):
|
| 229 |
+
"""Forward pass combining bottom-up and top-down TreeLSTM"""
|
| 230 |
+
g = batch.graph.to(torch.device("cuda"))
|
| 231 |
+
g = dgl.graph(g.edges())
|
| 232 |
+
n = g.number_of_nodes()
|
| 233 |
+
|
| 234 |
+
# Input projection
|
| 235 |
+
feats = self.mlp1(batch.feats.cuda())
|
| 236 |
+
|
| 237 |
+
# Initialize node states for both directions
|
| 238 |
+
g.ndata["iou_bu"] = self.cell.W_iou_bu(feats)
|
| 239 |
+
g.ndata["iou_td"] = self.cell.W_iou_td(feats)
|
| 240 |
+
g.ndata["h_bu"] = torch.zeros((n, self.h_size)).cuda()
|
| 241 |
+
g.ndata["c_bu"] = torch.zeros((n, self.h_size)).cuda()
|
| 242 |
+
g.ndata["h_td"] = torch.zeros((n, self.h_size)).cuda()
|
| 243 |
+
g.ndata["c_td"] = torch.zeros((n, self.h_size)).cuda()
|
| 244 |
+
|
| 245 |
+
# Bottom-up pass
|
| 246 |
+
dgl.prop_nodes_topo(
|
| 247 |
+
g,
|
| 248 |
+
message_func=self.cell.message_func_bu,
|
| 249 |
+
reduce_func=self.cell.reduce_func_bu,
|
| 250 |
+
apply_node_func=self.cell.apply_node_func_bu,
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
# Top-down pass (reverse topological order)
|
| 254 |
+
dgl.prop_nodes_topo(
|
| 255 |
+
g,
|
| 256 |
+
message_func=self.cell.message_func_td,
|
| 257 |
+
reduce_func=self.cell.reduce_func_td,
|
| 258 |
+
apply_node_func=self.cell.apply_node_func_td,
|
| 259 |
+
reverse=True,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# Combine bottom-up and top-down representations
|
| 263 |
+
h_combined = torch.cat([g.ndata.pop("c_bu"), g.ndata.pop("c_td")], dim=1)
|
| 264 |
+
|
| 265 |
+
# Aggregate node features
|
| 266 |
+
if self.node_aggregation:
|
| 267 |
+
h = self.node_agg(g, h_combined, batch.offset.long())
|
| 268 |
+
else:
|
| 269 |
+
h = h_combined[batch.offset.long()]
|
| 270 |
+
|
| 271 |
+
# Classification
|
| 272 |
+
if self.fc:
|
| 273 |
+
return self.linear(h)
|
| 274 |
+
return h
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
class TreeLSTM(nn.Module):
|
| 278 |
+
def __init__(self, x_size, h_size, num_classes, mode="sum", fc=True, bn=False,
|
| 279 |
+
node_aggregation=None):
|
| 280 |
+
super(TreeLSTM, self).__init__()
|
| 281 |
+
self.x_size = x_size
|
| 282 |
+
self.h_size = h_size
|
| 283 |
+
self.node_aggregation = node_aggregation
|
| 284 |
+
|
| 285 |
+
if bn:
|
| 286 |
+
self.mlp1 = nn.Sequential(
|
| 287 |
+
nn.Linear(x_size, h_size),
|
| 288 |
+
nn.BatchNorm1d(h_size),
|
| 289 |
+
nn.ReLU(),
|
| 290 |
+
)
|
| 291 |
+
else:
|
| 292 |
+
self.mlp1 = nn.Sequential(
|
| 293 |
+
nn.Linear(x_size, h_size),
|
| 294 |
+
nn.ReLU(),
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
self.cell = TreeLSTMCell(h_size, h_size, mode=mode)
|
| 298 |
+
|
| 299 |
+
if node_aggregation:
|
| 300 |
+
self.node_agg = MultiNodeAggregation(h_size, aggregation_type=node_aggregation)
|
| 301 |
+
|
| 302 |
+
self.fc = fc
|
| 303 |
+
if fc:
|
| 304 |
+
self.linear = nn.Linear(h_size, num_classes)
|
| 305 |
+
|
| 306 |
+
def forward(self, batch):
|
| 307 |
+
g = batch.graph.to(torch.device("cuda"))
|
| 308 |
+
g = dgl.graph(g.edges())
|
| 309 |
+
n = g.number_of_nodes()
|
| 310 |
+
|
| 311 |
+
feats = self.mlp1(batch.feats.cuda())
|
| 312 |
+
g.ndata["iou"] = self.cell.W_iou(feats)
|
| 313 |
+
g.ndata["h"] = torch.zeros((n, self.h_size)).cuda()
|
| 314 |
+
g.ndata["c"] = torch.zeros((n, self.h_size)).cuda()
|
| 315 |
+
|
| 316 |
+
dgl.prop_nodes_topo(
|
| 317 |
+
g,
|
| 318 |
+
message_func=self.cell.message_func,
|
| 319 |
+
reduce_func=self.cell.reduce_func,
|
| 320 |
+
apply_node_func=self.cell.apply_node_func,
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
h = g.ndata.pop("c")
|
| 324 |
+
|
| 325 |
+
if self.node_aggregation:
|
| 326 |
+
h = self.node_agg(g, h, batch.offset.long())
|
| 327 |
+
else:
|
| 328 |
+
h = h[batch.offset.long()]
|
| 329 |
+
|
| 330 |
+
if self.fc:
|
| 331 |
+
return self.linear(h)
|
| 332 |
+
return h
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
class TreeLSTM_wo_MLP(nn.Module):
|
| 336 |
+
"""TreeLSTM without initial MLP projection"""
|
| 337 |
+
def __init__(self, x_size, h_size, num_classes, mode="sum", fc=True):
|
| 338 |
+
super(TreeLSTM_wo_MLP, self).__init__()
|
| 339 |
+
self.x_size = x_size
|
| 340 |
+
self.h_size = h_size
|
| 341 |
+
self.cell = TreeLSTMCell(x_size, h_size, mode=mode)
|
| 342 |
+
self.fc = fc
|
| 343 |
+
if fc:
|
| 344 |
+
self.linear = nn.Linear(h_size, num_classes)
|
| 345 |
+
|
| 346 |
+
def forward(self, batch):
|
| 347 |
+
g = batch.graph.to(torch.device("cuda"))
|
| 348 |
+
g = dgl.graph(g.edges())
|
| 349 |
+
n = g.number_of_nodes()
|
| 350 |
+
|
| 351 |
+
feats = batch.feats.cuda()
|
| 352 |
+
g.ndata["iou"] = self.cell.W_iou(feats)
|
| 353 |
+
g.ndata["h"] = torch.zeros((n, self.h_size)).cuda()
|
| 354 |
+
g.ndata["c"] = torch.zeros((n, self.h_size)).cuda()
|
| 355 |
+
|
| 356 |
+
dgl.prop_nodes_topo(
|
| 357 |
+
g,
|
| 358 |
+
message_func=self.cell.message_func,
|
| 359 |
+
reduce_func=self.cell.reduce_func,
|
| 360 |
+
apply_node_func=self.cell.apply_node_func,
|
| 361 |
+
)
|
| 362 |
+
|
| 363 |
+
h = g.ndata.pop("c")[batch.offset.long()]
|
| 364 |
+
|
| 365 |
+
if self.fc:
|
| 366 |
+
return self.linear(h)
|
| 367 |
+
return h
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
class TreeLSTMCellv2(nn.Module):
|
| 371 |
+
"""TreeLSTM Cell with alternative architecture"""
|
| 372 |
+
def __init__(self, x_size, h_size, mode="sum"):
|
| 373 |
+
super(TreeLSTMCellv2, self).__init__()
|
| 374 |
+
self.h_size = h_size
|
| 375 |
+
self.mode = mode
|
| 376 |
+
self.W_iou = nn.Linear(x_size, 3 * h_size, bias=False)
|
| 377 |
+
self.U_iou = nn.Linear(2 * h_size, 3 * h_size, bias=False)
|
| 378 |
+
self.b_iou = nn.Parameter(torch.zeros(1, 3 * h_size))
|
| 379 |
+
self.U_f = nn.Linear(2 * h_size, h_size)
|
| 380 |
+
|
| 381 |
+
def message_func(self, edges):
|
| 382 |
+
return {"h": edges.src["h"], "c": edges.src["c"]}
|
| 383 |
+
|
| 384 |
+
def reduce_func(self, nodes):
|
| 385 |
+
if self.mode == "sum":
|
| 386 |
+
h_cat = nodes.mailbox["h"].sum(dim=1)
|
| 387 |
+
elif self.mode == "max":
|
| 388 |
+
h_cat = nodes.mailbox["h"].max(dim=1)[0]
|
| 389 |
+
elif self.mode == "mean":
|
| 390 |
+
h_cat = nodes.mailbox["h"].mean(dim=1)
|
| 391 |
+
|
| 392 |
+
h_max = nodes.mailbox["h"].max(dim=1)[0]
|
| 393 |
+
h_combined = torch.cat([h_cat, h_max], dim=1)
|
| 394 |
+
|
| 395 |
+
f = torch.sigmoid(self.U_f(h_combined.unsqueeze(1).expand(-1, nodes.mailbox["h"].shape[1], -1)))
|
| 396 |
+
c = torch.sum(f * nodes.mailbox["c"], 1)
|
| 397 |
+
return {"iou": self.U_iou(h_combined), "c": c}
|
| 398 |
+
|
| 399 |
+
def apply_node_func(self, nodes):
|
| 400 |
+
iou = nodes.data["iou"] + self.b_iou
|
| 401 |
+
i, o, u = torch.chunk(iou, 3, 1)
|
| 402 |
+
i, o, u = torch.sigmoid(i), torch.sigmoid(o), torch.tanh(u)
|
| 403 |
+
c = i * u + nodes.data["c"]
|
| 404 |
+
h = o * torch.tanh(c)
|
| 405 |
+
return {"h": h, "c": c}
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
class TreeLSTMDoubleCell(nn.Module):
|
| 409 |
+
"""TreeLSTM Cell with double hidden state (original implementation)"""
|
| 410 |
+
def __init__(self, x_size, h_size, mode="sum"):
|
| 411 |
+
super(TreeLSTMDoubleCell, self).__init__()
|
| 412 |
+
self.W1_iouf = nn.Linear(x_size, 4 * h_size)
|
| 413 |
+
self.U1_iouf = nn.Linear(h_size, 4 * h_size)
|
| 414 |
+
self.W2_iouf = nn.Linear(h_size, 4 * h_size)
|
| 415 |
+
self.U2_iouf = nn.Linear(h_size, 4 * h_size)
|
| 416 |
+
self.mode = mode
|
| 417 |
+
self.init_state = True
|
| 418 |
+
self.h_size = h_size
|
| 419 |
+
|
| 420 |
+
def message_func(self, edges):
|
| 421 |
+
return {
|
| 422 |
+
"h1": edges.src["h1"],
|
| 423 |
+
"c1": edges.src["c1"],
|
| 424 |
+
"h2": edges.src["h2"],
|
| 425 |
+
"c2": edges.src["c2"],
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
def reduce_func(self, nodes):
|
| 429 |
+
h1, c1 = nodes.mailbox["h1"], nodes.mailbox["c1"]
|
| 430 |
+
h2, c2 = nodes.mailbox["h2"], nodes.mailbox["c2"]
|
| 431 |
+
if self.mode == "sum":
|
| 432 |
+
h1, c1, h2, c2 = h1.sum(-2), c1.sum(-2), h2.sum(-2), c2.sum(-2)
|
| 433 |
+
elif self.mode == "mean":
|
| 434 |
+
h1, c1, h2, c2 = h1.mean(-2), c1.mean(-2), h2.mean(-2), c2.mean(-2)
|
| 435 |
+
else:
|
| 436 |
+
raise ValueError("must in [sum, mean]")
|
| 437 |
+
x_iouf = nodes.data["iouf"]
|
| 438 |
+
xi, xo, xu, xf = torch.chunk(x_iouf, 4, 1)
|
| 439 |
+
h_iouf1 = self.U1_iouf(h1)
|
| 440 |
+
hi1, ho1, hu1, hf1 = torch.chunk(h_iouf1, 4, 1)
|
| 441 |
+
i = torch.sigmoid(xi + hi1)
|
| 442 |
+
f = torch.sigmoid(xf + hf1)
|
| 443 |
+
o = torch.sigmoid(xo + ho1)
|
| 444 |
+
u = torch.tanh(xu + hu1)
|
| 445 |
+
c1 = i * u + f * c1
|
| 446 |
+
h1 = o * torch.tanh(c1)
|
| 447 |
+
|
| 448 |
+
x_iouf2 = self.W2_iouf(c1)
|
| 449 |
+
xi, xo, xu, xf = torch.chunk(x_iouf2, 4, 1)
|
| 450 |
+
h_iouf2 = self.U2_iouf(h2)
|
| 451 |
+
hi2, ho2, hu2, hf2 = torch.chunk(h_iouf2, 4, 1)
|
| 452 |
+
i = torch.sigmoid(xi + hi2)
|
| 453 |
+
f = torch.sigmoid(xf + hf2)
|
| 454 |
+
o = torch.sigmoid(xo + ho2)
|
| 455 |
+
u = torch.tanh(xu + hu2)
|
| 456 |
+
c2 = i * u + f * c2
|
| 457 |
+
h2 = o * torch.tanh(c2)
|
| 458 |
+
return {"h1": h1, "c1": c1, "h2": h2, "c2": c2}
|
| 459 |
+
|
| 460 |
+
def apply_node_func(self, nodes):
|
| 461 |
+
if self.init_state:
|
| 462 |
+
iouf = nodes.data["iouf"]
|
| 463 |
+
i, o, u, f = torch.chunk(iouf, 4, 1)
|
| 464 |
+
i, o, u, f = torch.sigmoid(i), torch.sigmoid(o), torch.tanh(u), torch.sigmoid(f)
|
| 465 |
+
c1 = i * u + f * nodes.data["c1"]
|
| 466 |
+
h1 = o * torch.tanh(c1)
|
| 467 |
+
|
| 468 |
+
iouf2 = self.W2_iouf(c1)
|
| 469 |
+
i, o, u, f = torch.chunk(iouf2, 4, 1)
|
| 470 |
+
i, o, u, f = torch.sigmoid(i), torch.sigmoid(o), torch.tanh(u), torch.sigmoid(f)
|
| 471 |
+
c2 = i * u + f * nodes.data["c2"]
|
| 472 |
+
h2 = o * torch.tanh(c2)
|
| 473 |
+
self.init_state = False
|
| 474 |
+
return {"h1": h1, "c1": c1, "h2": h2, "c2": c2}
|
| 475 |
+
else:
|
| 476 |
+
return {
|
| 477 |
+
"h1": nodes.data["h1"],
|
| 478 |
+
"c1": nodes.data["c1"],
|
| 479 |
+
"h2": nodes.data["h2"],
|
| 480 |
+
"c2": nodes.data["c2"],
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
class TreeLSTMDouble(nn.Module):
|
| 485 |
+
"""TreeLSTM with double hidden state aggregation"""
|
| 486 |
+
def __init__(self, x_size, h_size, num_classes, mode="sum", fc=True, bn=False, node_aggregation=None):
|
| 487 |
+
super(TreeLSTMDouble, self).__init__()
|
| 488 |
+
self.x_size, self.h_size = x_size, h_size
|
| 489 |
+
self.node_aggregation = node_aggregation
|
| 490 |
+
|
| 491 |
+
if bn:
|
| 492 |
+
self.mlp1 = nn.Sequential(
|
| 493 |
+
nn.Linear(x_size, h_size),
|
| 494 |
+
nn.BatchNorm1d(h_size),
|
| 495 |
+
nn.ReLU(),
|
| 496 |
+
nn.Linear(h_size, 2 * h_size),
|
| 497 |
+
nn.BatchNorm1d(2 * h_size),
|
| 498 |
+
nn.ReLU(),
|
| 499 |
+
nn.Linear(2 * h_size, h_size),
|
| 500 |
+
)
|
| 501 |
+
else:
|
| 502 |
+
self.mlp1 = nn.Sequential(
|
| 503 |
+
nn.Linear(x_size, h_size),
|
| 504 |
+
nn.ReLU(),
|
| 505 |
+
nn.Linear(h_size, 2 * h_size),
|
| 506 |
+
nn.ReLU(),
|
| 507 |
+
nn.Linear(2 * h_size, h_size),
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
self.cell = TreeLSTMDoubleCell(h_size, h_size, mode=mode)
|
| 511 |
+
|
| 512 |
+
if node_aggregation:
|
| 513 |
+
self.node_agg = MultiNodeAggregation(h_size, aggregation_type=node_aggregation)
|
| 514 |
+
|
| 515 |
+
self.fc = fc
|
| 516 |
+
if fc:
|
| 517 |
+
self.linear = nn.Linear(h_size, num_classes)
|
| 518 |
+
|
| 519 |
+
def forward_backbone(self, batch):
|
| 520 |
+
g = batch.graph.to(torch.device("cuda"))
|
| 521 |
+
# to heterogenous graph
|
| 522 |
+
g = dgl.graph(g.edges())
|
| 523 |
+
n = g.number_of_nodes()
|
| 524 |
+
# feed embedding
|
| 525 |
+
feats = self.mlp1(batch.feats.cuda())
|
| 526 |
+
g.ndata["iouf"] = self.cell.W1_iouf(feats)
|
| 527 |
+
g.ndata["h1"] = torch.zeros((n, self.h_size)).cuda()
|
| 528 |
+
g.ndata["c1"] = torch.zeros((n, self.h_size)).cuda()
|
| 529 |
+
g.ndata["h2"] = torch.zeros((n, self.h_size)).cuda()
|
| 530 |
+
g.ndata["c2"] = torch.zeros((n, self.h_size)).cuda()
|
| 531 |
+
# propagate
|
| 532 |
+
dgl.prop_nodes_topo(
|
| 533 |
+
g,
|
| 534 |
+
message_func=self.cell.message_func,
|
| 535 |
+
reduce_func=self.cell.reduce_func,
|
| 536 |
+
apply_node_func=self.cell.apply_node_func,
|
| 537 |
+
)
|
| 538 |
+
logits = g.ndata.pop("c2")[batch.offset.long()]
|
| 539 |
+
return logits
|
| 540 |
+
|
| 541 |
+
def forward(self, batch):
|
| 542 |
+
logits = self.forward_backbone(batch)
|
| 543 |
+
if self.fc:
|
| 544 |
+
logits = self.linear(logits)
|
| 545 |
+
return logits
|
| 546 |
+
else:
|
| 547 |
+
return logits
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
class TreeLSTMv2(nn.Module):
|
| 551 |
+
"""TreeLSTM variant with improved architecture"""
|
| 552 |
+
def __init__(self, x_size, h_size, num_classes, mode="sum", fc=True, bn=False):
|
| 553 |
+
super(TreeLSTMv2, self).__init__()
|
| 554 |
+
self.x_size, self.h_size = x_size, h_size
|
| 555 |
+
|
| 556 |
+
if bn:
|
| 557 |
+
self.mlp1 = nn.Sequential(
|
| 558 |
+
nn.Linear(x_size, h_size),
|
| 559 |
+
nn.BatchNorm1d(h_size),
|
| 560 |
+
nn.ReLU(),
|
| 561 |
+
)
|
| 562 |
+
else:
|
| 563 |
+
self.mlp1 = nn.Sequential(
|
| 564 |
+
nn.Linear(x_size, h_size),
|
| 565 |
+
nn.ReLU(),
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
self.cell = TreeLSTMCellv2(h_size, h_size, mode=mode)
|
| 569 |
+
self.fc = fc
|
| 570 |
+
if fc:
|
| 571 |
+
self.linear = nn.Linear(h_size, num_classes)
|
| 572 |
+
|
| 573 |
+
def forward(self, batch):
|
| 574 |
+
g = batch.graph.to(torch.device("cuda"))
|
| 575 |
+
g = dgl.graph(g.edges())
|
| 576 |
+
n = g.number_of_nodes()
|
| 577 |
+
|
| 578 |
+
feats = self.mlp1(batch.feats.cuda())
|
| 579 |
+
g.ndata["iou"] = self.cell.W_iou(feats)
|
| 580 |
+
g.ndata["h"] = torch.zeros((n, self.h_size)).cuda()
|
| 581 |
+
g.ndata["c"] = torch.zeros((n, self.h_size)).cuda()
|
| 582 |
+
|
| 583 |
+
dgl.prop_nodes_topo(
|
| 584 |
+
g,
|
| 585 |
+
message_func=self.cell.message_func,
|
| 586 |
+
reduce_func=self.cell.reduce_func,
|
| 587 |
+
apply_node_func=self.cell.apply_node_func,
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
h = g.ndata.pop("c")[batch.offset.long()]
|
| 591 |
+
|
| 592 |
+
if self.fc:
|
| 593 |
+
return self.linear(h)
|
| 594 |
+
return h
|
graphformer/utils/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utility functions for GraPHFormer."""
|
| 2 |
+
|
| 3 |
+
from .training import set_seed, save_checkpoint, adjust_learning_rate, get_root_logger
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"set_seed", "save_checkpoint", "adjust_learning_rate", "get_root_logger",
|
| 7 |
+
]
|
graphformer/utils/training.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import shutil
|
| 3 |
+
import math
|
| 4 |
+
import logging
|
| 5 |
+
import numpy as np
|
| 6 |
+
import random
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def set_seed(seed):
|
| 10 |
+
"""
|
| 11 |
+
Setting of Global Seed
|
| 12 |
+
|
| 13 |
+
"""
|
| 14 |
+
torch.backends.cudnn.enabled = True
|
| 15 |
+
torch.backends.cudnn.deterministic = True # consistent results on the cpu and gpu
|
| 16 |
+
torch.backends.cudnn.benchmark = False
|
| 17 |
+
|
| 18 |
+
np.random.seed(seed)
|
| 19 |
+
random.seed(seed)
|
| 20 |
+
torch.manual_seed(seed) # cpu
|
| 21 |
+
torch.cuda.manual_seed(seed)
|
| 22 |
+
torch.cuda.manual_seed_all(seed) # gpu
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def save_checkpoint(state, is_best, filename="checkpoint.pth"):
|
| 26 |
+
torch.save(state, filename)
|
| 27 |
+
if is_best:
|
| 28 |
+
pth = "/".join(filename.split("/")[:-1])
|
| 29 |
+
shutil.copyfile(filename, f"{pth}/model_best.pth")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def adjust_learning_rate(optimizer, epoch, args):
|
| 33 |
+
"""Decay the learning rate based on schedule"""
|
| 34 |
+
lr = args.lr
|
| 35 |
+
if args.cos: # cosine lr schedule
|
| 36 |
+
lr *= 0.5 * (1.0 + math.cos(math.pi * epoch / args.epochs))
|
| 37 |
+
else: # stepwise lr schedule
|
| 38 |
+
for milestone in args.schedule:
|
| 39 |
+
lr *= 0.1 if epoch >= milestone else 1.0
|
| 40 |
+
for param_group in optimizer.param_groups:
|
| 41 |
+
param_group["lr"] = lr
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_root_logger(log_file=None, log_level=logging.INFO):
|
| 45 |
+
"""Get the root logger.
|
| 46 |
+
|
| 47 |
+
The logger will be initialized if it has not been initialized. By default a
|
| 48 |
+
StreamHandler will be added. If `log_file` is specified, a FileHandler will
|
| 49 |
+
also be added. The name of the root logger is the top-level package name,
|
| 50 |
+
e.g., "openselfsup".
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
log_file (str | None): The log filename. If specified, a FileHandler
|
| 54 |
+
will be added to the root logger.
|
| 55 |
+
log_level (int): The root logger level. Note that only the process of
|
| 56 |
+
rank 0 is affected, while other processes will set the level to
|
| 57 |
+
"Error" and be silent most of the time.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
logging.Logger: The root logger.
|
| 61 |
+
"""
|
| 62 |
+
logger = logging.getLogger(__name__.split(".")[0]) # i.e., openselfsup
|
| 63 |
+
# if the logger has been initialized, just return it
|
| 64 |
+
if logger.hasHandlers():
|
| 65 |
+
return logger
|
| 66 |
+
|
| 67 |
+
format_str = "%(asctime)s - %(message)s"
|
| 68 |
+
logging.basicConfig(format=format_str, level=log_level)
|
| 69 |
+
if log_file is not None:
|
| 70 |
+
file_handler = logging.FileHandler(log_file, "w")
|
| 71 |
+
file_handler.setFormatter(logging.Formatter(format_str))
|
| 72 |
+
file_handler.setLevel(log_level)
|
| 73 |
+
logger.addHandler(file_handler)
|
| 74 |
+
|
| 75 |
+
return logger
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def print_log(msg, logger=None, level=logging.INFO):
|
| 79 |
+
"""Print a log message.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
msg (str): The message to be logged.
|
| 83 |
+
logger (logging.Logger | str | None): The logger to be used. Some
|
| 84 |
+
special loggers are:
|
| 85 |
+
- "root": the root logger obtained with `get_root_logger()`.
|
| 86 |
+
- "silent": no message will be printed.
|
| 87 |
+
- None: The `print()` method will be used to print log messages.
|
| 88 |
+
level (int): Logging level. Only available when `logger` is a Logger
|
| 89 |
+
object or "root".
|
| 90 |
+
"""
|
| 91 |
+
if logger is None:
|
| 92 |
+
print(msg)
|
| 93 |
+
elif logger == "root":
|
| 94 |
+
_logger = get_root_logger()
|
| 95 |
+
_logger.log(level, msg)
|
| 96 |
+
elif isinstance(logger, logging.Logger):
|
| 97 |
+
logger.log(level, msg)
|
| 98 |
+
elif logger != "silent":
|
| 99 |
+
raise TypeError(
|
| 100 |
+
'logger should be either a logging.Logger object, "root", '
|
| 101 |
+
'"silent" or None, but got {}'.format(logger)
|
| 102 |
+
)
|
scripts/finetune_example.sh
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Example fine-tuning script for GraPHFormer
|
| 3 |
+
|
| 4 |
+
python finetune.py \
|
| 5 |
+
--exp_name finetune_bil \
|
| 6 |
+
--dataset bil_6_classes \
|
| 7 |
+
--pretrained_checkpoint work_dir/graphformer_resnet18/model_best.pth \
|
| 8 |
+
--mode multimodal \
|
| 9 |
+
--fusion_mode concat \
|
| 10 |
+
--tree_model double \
|
| 11 |
+
--image_encoder dinov2_vits14 \
|
| 12 |
+
--image_size 252 \
|
| 13 |
+
--embed_dim 128 \
|
| 14 |
+
--h_size 256 \
|
| 15 |
+
--batch_size 64 \
|
| 16 |
+
--epochs 50 \
|
| 17 |
+
--lr 1e-4 \
|
| 18 |
+
--wd 0.01 \
|
| 19 |
+
--dropout 0.5 \
|
| 20 |
+
--label_smoothing 0.1 \
|
| 21 |
+
--linear_probe_epochs 10 \
|
| 22 |
+
--early_stopping_patience 10 \
|
| 23 |
+
--val_freq 1 \
|
| 24 |
+
--eval_mode accuracy
|
scripts/prepare_data.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Please refer to data/README.md for data download and preparation.
|
| 3 |
+
Then run this script to preprocess data.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os, sys
|
| 7 |
+
from data_io.process_raw_script import (
|
| 8 |
+
convert_janelia_json,
|
| 9 |
+
normalize_root_and_check,
|
| 10 |
+
filter_axon_and_check,
|
| 11 |
+
summarize_branch,
|
| 12 |
+
split_sample_10fold_cv_and_merge,
|
| 13 |
+
)
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
data_path = "./data"
|
| 17 |
+
|
| 18 |
+
# convert JML json file
|
| 19 |
+
convert_janelia_json(
|
| 20 |
+
os.path.join(data_path, "raw/janelia_mouselight/json30/*.json"),
|
| 21 |
+
os.path.join(data_path, "raw/janelia_mouselight/swc"),
|
| 22 |
+
os.path.join(data_path, "info/JML_info_swc.csv"),
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# preprocess data
|
| 26 |
+
for source in ["janelia_mouselight", "allen_cell_type", "bil"]:
|
| 27 |
+
print(f"Processing:{source}")
|
| 28 |
+
# normalize neuron's center, orientation and size
|
| 29 |
+
print(f"Normalize neuron")
|
| 30 |
+
folder_in = f"{data_path}/raw/{source}/swc/"
|
| 31 |
+
folder_out = f"{data_path}/raw/{source}/swc_soma0/"
|
| 32 |
+
if source == "bil":
|
| 33 |
+
normalize_root_and_check(folder_in + "*reg.swc", folder_out)
|
| 34 |
+
# some BIL reconstructions are not correctly scaled. this will fix them
|
| 35 |
+
normalize_root_and_check(
|
| 36 |
+
folder_in + "*__reg.swc", folder_out, scale=[0.114, 0.114, 0.28]
|
| 37 |
+
)
|
| 38 |
+
else:
|
| 39 |
+
normalize_root_and_check(folder_in + "*.swc", folder_out)
|
| 40 |
+
|
| 41 |
+
# remove axon file
|
| 42 |
+
print(f"Remove axons")
|
| 43 |
+
folder_in = f"{data_path}/raw/{source}/swc_soma0/*.swc"
|
| 44 |
+
folder_out = f"{data_path}/dendrite/{source}/swc_soma0/"
|
| 45 |
+
filter_axon_and_check(folder_in, folder_out)
|
| 46 |
+
|
| 47 |
+
print(f"Calculate features")
|
| 48 |
+
folder_in = f"{data_path}/dendrite/{source}/swc_soma0/*.swc"
|
| 49 |
+
folder_out = f"{data_path}/dendrite/{source}/eswc_soma0/"
|
| 50 |
+
summarize_branch(folder_in, folder_out)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# split data into 10 folds
|
| 54 |
+
split_sample_10fold_cv_and_merge(data_path)
|
| 55 |
+
|
| 56 |
+
# hack folder creation
|
| 57 |
+
folder_names = ["allen_cell_type", "bil", "janelia_mouselight"]
|
| 58 |
+
all_wo_others = {
|
| 59 |
+
"VPM": 0,
|
| 60 |
+
"Isocortex_layer23": 1,
|
| 61 |
+
"Isocortex_layer4": 2,
|
| 62 |
+
"PRE": 3,
|
| 63 |
+
"SUB": 4,
|
| 64 |
+
"CP": 5,
|
| 65 |
+
"VPL": 6,
|
| 66 |
+
"Isocortex_layer6": 7,
|
| 67 |
+
"MG": 8,
|
| 68 |
+
"Isocortex_layer5": 9,
|
| 69 |
+
}
|
| 70 |
+
for i, split_csv in enumerate(
|
| 71 |
+
[
|
| 72 |
+
f"{data_path}/info/ACT_info_swc_10folds.csv",
|
| 73 |
+
f"{data_path}/info/BIL_info_swc_10folds.csv",
|
| 74 |
+
f"{data_path}/info/JML_info_swc_10folds.csv",
|
| 75 |
+
]
|
| 76 |
+
):
|
| 77 |
+
csv = pd.read_csv(split_csv)
|
| 78 |
+
folder_name = folder_names[i]
|
| 79 |
+
for split in range(10):
|
| 80 |
+
for fname in csv[csv["model__fold"] == split]["swc__fname"]:
|
| 81 |
+
# get acronym from "structure_merge__acronym"
|
| 82 |
+
acronym = csv[csv["swc__fname"] == fname][
|
| 83 |
+
"structure_merge__acronym"
|
| 84 |
+
].values[0]
|
| 85 |
+
if acronym not in all_wo_others:
|
| 86 |
+
if acronym == "Isocortex_layer2/3":
|
| 87 |
+
acronym = "Isocortex_layer23"
|
| 88 |
+
else:
|
| 89 |
+
continue
|
| 90 |
+
os.makedirs(
|
| 91 |
+
f"{data_path}/dendrite/all_eswc_soma0_ssl/{acronym}/{folder_name}-{split}/",
|
| 92 |
+
exist_ok=True,
|
| 93 |
+
)
|
| 94 |
+
source_path = os.path.abspath(
|
| 95 |
+
f"{data_path}/dendrite/{folder_name}/eswc_soma0/{fname}"
|
| 96 |
+
)
|
| 97 |
+
target_path = f"{data_path}/dendrite/all_eswc_soma0_ssl/{acronym}/{folder_name}-{split}/{fname}"
|
| 98 |
+
|
| 99 |
+
# Check if the source path is a valid file
|
| 100 |
+
if os.path.isfile(source_path):
|
| 101 |
+
os.symlink(source_path, target_path)
|
| 102 |
+
else:
|
| 103 |
+
print(f"{source_path} is not a valid file!")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# # hack folder creation
|
| 107 |
+
# folder_names = ["allen_cell_type"]
|
| 108 |
+
# all_wo_others = {
|
| 109 |
+
# "Isocortex_layer23": 0,
|
| 110 |
+
# "Isocortex_layer4": 1,
|
| 111 |
+
# "Isocortex_layer5": 2,
|
| 112 |
+
# "Isocortex_layer6": 3,
|
| 113 |
+
# }
|
| 114 |
+
# for i, split_csv in enumerate(
|
| 115 |
+
# [
|
| 116 |
+
# f"{data_path}/info/ACT_info_swc_10folds.csv",
|
| 117 |
+
# # f"{data_path}/info/BIL_info_swc_10folds.csv",
|
| 118 |
+
# # f"{data_path}/info/JML_info_swc_10folds.csv",
|
| 119 |
+
# ]
|
| 120 |
+
# ):
|
| 121 |
+
# csv = pd.read_csv(split_csv)
|
| 122 |
+
# folder_name = folder_names[i]
|
| 123 |
+
# for split in range(10):
|
| 124 |
+
# for fname in csv[csv["model__fold"] == split]["swc__fname"]:
|
| 125 |
+
# # get acronym from "structure_merge__acronym"
|
| 126 |
+
# acronym = csv[csv["swc__fname"] == fname][
|
| 127 |
+
# "structure_merge__acronym"
|
| 128 |
+
# ].values[0]
|
| 129 |
+
# if acronym not in all_wo_others:
|
| 130 |
+
# if acronym == "Isocortex_layer2/3":
|
| 131 |
+
# acronym = "Isocortex_layer23"
|
| 132 |
+
# else:
|
| 133 |
+
# continue
|
| 134 |
+
# os.makedirs(
|
| 135 |
+
# f"{data_path}/dendrite/ACT/{acronym}/{folder_name}-{split}/",
|
| 136 |
+
# exist_ok=True,
|
| 137 |
+
# )
|
| 138 |
+
# source_path = os.path.abspath(
|
| 139 |
+
# f"{data_path}/dendrite/{folder_name}/eswc_soma0/{fname}"
|
| 140 |
+
# )
|
| 141 |
+
# target_path = f"{data_path}/dendrite/ACT/{acronym}/{folder_name}-{split}/{fname}"
|
| 142 |
+
|
| 143 |
+
# # Check if the source path is a valid file
|
| 144 |
+
# if os.path.isfile(source_path):
|
| 145 |
+
# os.symlink(source_path, target_path)
|
| 146 |
+
# else:
|
| 147 |
+
# print(f"{source_path} is not a valid file!")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# # hack folder creation
|
| 151 |
+
# folder_names = ["janelia_mouselight"]
|
| 152 |
+
# all_wo_others = {
|
| 153 |
+
# "Isocortex_layer23": 0,
|
| 154 |
+
# "Isocortex_layer5": 1,
|
| 155 |
+
# "Isocortex_layer6": 2,
|
| 156 |
+
# "VPM": 3,
|
| 157 |
+
# }
|
| 158 |
+
# for i, split_csv in enumerate(
|
| 159 |
+
# [
|
| 160 |
+
# # f"{data_path}/info/ACT_info_swc_10folds.csv",
|
| 161 |
+
# # f"{data_path}/info/BIL_info_swc_10folds.csv",
|
| 162 |
+
# f"{data_path}/info/JML_info_swc_10folds.csv",
|
| 163 |
+
# ]
|
| 164 |
+
# ):
|
| 165 |
+
# csv = pd.read_csv(split_csv)
|
| 166 |
+
# folder_name = folder_names[i]
|
| 167 |
+
# for split in range(10):
|
| 168 |
+
# for fname in csv[csv["model__fold"] == split]["swc__fname"]:
|
| 169 |
+
# # get acronym from "structure_merge__acronym"
|
| 170 |
+
# acronym = csv[csv["swc__fname"] == fname][
|
| 171 |
+
# "structure_merge__acronym"
|
| 172 |
+
# ].values[0]
|
| 173 |
+
# if acronym not in all_wo_others:
|
| 174 |
+
# if acronym == "Isocortex_layer2/3":
|
| 175 |
+
# acronym = "Isocortex_layer23"
|
| 176 |
+
# else:
|
| 177 |
+
# continue
|
| 178 |
+
# os.makedirs(
|
| 179 |
+
# f"{data_path}/dendrite/JML/{acronym}/{folder_name}-{split}/",
|
| 180 |
+
# exist_ok=True,
|
| 181 |
+
# )
|
| 182 |
+
# source_path = os.path.abspath(
|
| 183 |
+
# f"{data_path}/dendrite/{folder_name}/eswc_soma0/{fname}"
|
| 184 |
+
# )
|
| 185 |
+
# target_path = f"{data_path}/dendrite/JML/{acronym}/{folder_name}-{split}/{fname}"
|
| 186 |
+
|
| 187 |
+
# # Check if the source path is a valid file
|
| 188 |
+
# if os.path.isfile(source_path):
|
| 189 |
+
# os.symlink(source_path, target_path)
|
| 190 |
+
# else:
|
| 191 |
+
# print(f"{source_path} is not a valid file!")
|
| 192 |
+
|
| 193 |
+
# # hack folder creation
|
| 194 |
+
# folder_names = ["bil"]
|
| 195 |
+
# all_wo_others = {
|
| 196 |
+
# "CP": 0,
|
| 197 |
+
# "Isocortex_layer23": 1,
|
| 198 |
+
# "Isocortex_layer4": 2,
|
| 199 |
+
# "Isocortex_layer5": 3,
|
| 200 |
+
# "Isocortex_layer6": 4,
|
| 201 |
+
# "VPM": 5,
|
| 202 |
+
# }
|
| 203 |
+
# for i, split_csv in enumerate(
|
| 204 |
+
# [
|
| 205 |
+
# # f"{data_path}/info/ACT_info_swc_10folds.csv",
|
| 206 |
+
# f"{data_path}/info/BIL_info_swc_10folds.csv",
|
| 207 |
+
# # f"{data_path}/info/JML_info_swc_10folds.csv",
|
| 208 |
+
# ]
|
| 209 |
+
# ):
|
| 210 |
+
# csv = pd.read_csv(split_csv)
|
| 211 |
+
# folder_name = folder_names[i]
|
| 212 |
+
# for split in range(10):
|
| 213 |
+
# for fname in csv[csv["model__fold"] == split]["swc__fname"]:
|
| 214 |
+
# # get acronym from "structure_merge__acronym"
|
| 215 |
+
# acronym = csv[csv["swc__fname"] == fname][
|
| 216 |
+
# "structure_merge__acronym"
|
| 217 |
+
# ].values[0]
|
| 218 |
+
# if acronym not in all_wo_others:
|
| 219 |
+
# if acronym == "Isocortex_layer2/3":
|
| 220 |
+
# acronym = "Isocortex_layer23"
|
| 221 |
+
# else:
|
| 222 |
+
# continue
|
| 223 |
+
# os.makedirs(
|
| 224 |
+
# f"{data_path}/dendrite/BIL/{acronym}/{folder_name}-{split}/",
|
| 225 |
+
# exist_ok=True,
|
| 226 |
+
# )
|
| 227 |
+
# source_path = os.path.abspath(
|
| 228 |
+
# f"{data_path}/dendrite/{folder_name}/eswc_soma0/{fname}"
|
| 229 |
+
# )
|
| 230 |
+
# target_path = f"{data_path}/dendrite/BIL/{acronym}/{folder_name}-{split}/{fname}"
|
| 231 |
+
|
| 232 |
+
# # Check if the source path is a valid file
|
| 233 |
+
# if os.path.isfile(source_path):
|
| 234 |
+
# os.symlink(source_path, target_path)
|
| 235 |
+
# else:
|
| 236 |
+
# print(f"{source_path} is not a valid file!")
|
scripts/train_example.sh
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
# Example training script for GraPHFormer
|
| 3 |
+
|
| 4 |
+
python train.py \
|
| 5 |
+
--exp_name graphformer_resnet18 \
|
| 6 |
+
--dataset all_wo_others \
|
| 7 |
+
--tree_model double \
|
| 8 |
+
--image_encoder dinov2_vits14 \
|
| 9 |
+
--image_size 252 \
|
| 10 |
+
--embed_dim 128 \
|
| 11 |
+
--h_size 256 \
|
| 12 |
+
--batch_size 128 \
|
| 13 |
+
--epochs 100 \
|
| 14 |
+
--lr 3e-4 \
|
| 15 |
+
--wd 0.1 \
|
| 16 |
+
--temperature 0.07 \
|
| 17 |
+
--loss_type clip \
|
| 18 |
+
--warmup_epochs 5 \
|
| 19 |
+
--save_freq 10 \
|
| 20 |
+
--val_freq 5 \
|
| 21 |
+
--aug_rotate \
|
| 22 |
+
--aug_jitter_coords \
|
| 23 |
+
--use_persistence_aug \
|
| 24 |
+
--use_knn_eval \
|
| 25 |
+
--knn_k 20 \
|
| 26 |
+
--eval_jm \
|
| 27 |
+
--eval_act
|
setup.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Setup script for GraPHFormer."""
|
| 2 |
+
|
| 3 |
+
from setuptools import setup, find_packages
|
| 4 |
+
|
| 5 |
+
setup(
|
| 6 |
+
name="graphformer",
|
| 7 |
+
version="1.0.0",
|
| 8 |
+
description="GraPHFormer: Graph-Persistence Hybrid Transformer for Neuron Morphology",
|
| 9 |
+
author="",
|
| 10 |
+
packages=find_packages(),
|
| 11 |
+
python_requires=">=3.8",
|
| 12 |
+
install_requires=[
|
| 13 |
+
"torch>=1.10.0",
|
| 14 |
+
"torchvision>=0.11.0",
|
| 15 |
+
"dgl>=0.8.0",
|
| 16 |
+
"numpy>=1.20.0",
|
| 17 |
+
"scikit-learn>=1.0.0",
|
| 18 |
+
"nltk>=3.6.0",
|
| 19 |
+
"tqdm>=4.60.0",
|
| 20 |
+
"networkx>=2.6.0",
|
| 21 |
+
"Pillow>=8.0.0",
|
| 22 |
+
],
|
| 23 |
+
extras_require={
|
| 24 |
+
"dev": [
|
| 25 |
+
"pytest>=6.0.0",
|
| 26 |
+
"black>=21.0",
|
| 27 |
+
"flake8>=3.9.0",
|
| 28 |
+
],
|
| 29 |
+
},
|
| 30 |
+
)
|
train.py
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GraPHFormer Training Script
|
| 3 |
+
|
| 4 |
+
CLIP-style contrastive learning for neuron morphology representation.
|
| 5 |
+
Aligns tree structure representations with persistence images.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python train.py --exp_name my_experiment --dataset all_wo_others
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import datetime
|
| 13 |
+
import time
|
| 14 |
+
import os
|
| 15 |
+
import json
|
| 16 |
+
import copy
|
| 17 |
+
from tqdm import tqdm
|
| 18 |
+
|
| 19 |
+
import torch
|
| 20 |
+
import torch.nn as nn
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
import torch.backends.cudnn as cudnn
|
| 23 |
+
from torch.utils.data import DataLoader
|
| 24 |
+
from sklearn.neighbors import KNeighborsClassifier
|
| 25 |
+
|
| 26 |
+
from graphformer.models import CLIPModel
|
| 27 |
+
from graphformer.augmentations import (
|
| 28 |
+
Compose,
|
| 29 |
+
RandomScaleCoords, RandomRotate, RandomJitter, RandomShift,
|
| 30 |
+
RandomFlip, RandomMaskFeats, RandomJitterLength, RandomElasticate,
|
| 31 |
+
RandomDropSubTrees, RandomSkipParentNode, RandomSwapSiblingSubTrees,
|
| 32 |
+
CombinedPersistenceAugmentation,
|
| 33 |
+
)
|
| 34 |
+
from graphformer.utils import save_checkpoint, get_root_logger, set_seed
|
| 35 |
+
from graphformer.data import NeuronTreeDataset, get_collate_fn, LABEL_DICT
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def knn_predict(feature, feature_bank, feature_labels, classes, knn_k, knn_t):
|
| 39 |
+
"""KNN classification"""
|
| 40 |
+
feature = F.normalize(feature, dim=-1)
|
| 41 |
+
feature_bank = F.normalize(feature_bank, dim=-1)
|
| 42 |
+
|
| 43 |
+
sim_matrix = torch.mm(feature, feature_bank.t())
|
| 44 |
+
sim_weight, sim_indices = sim_matrix.topk(k=knn_k, dim=-1)
|
| 45 |
+
|
| 46 |
+
sim_labels = torch.gather(
|
| 47 |
+
feature_labels.expand(feature.size(0), -1), dim=-1, index=sim_indices
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
sim_weight = (sim_weight / knn_t).exp()
|
| 51 |
+
|
| 52 |
+
one_hot_label = torch.zeros(
|
| 53 |
+
feature.size(0) * knn_k, classes, device=sim_labels.device
|
| 54 |
+
)
|
| 55 |
+
one_hot_label = one_hot_label.scatter(
|
| 56 |
+
dim=-1, index=sim_labels.view(-1, 1), value=1.0
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
pred_scores = torch.sum(
|
| 60 |
+
one_hot_label.view(feature.size(0), -1, classes) * sim_weight.unsqueeze(dim=-1),
|
| 61 |
+
dim=1,
|
| 62 |
+
)
|
| 63 |
+
pred_labels = pred_scores.argmax(dim=-1)
|
| 64 |
+
|
| 65 |
+
return pred_labels
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_features_from_encoder(model, data_loader, device, fusion='concat'):
|
| 69 |
+
"""Extract features from encoder"""
|
| 70 |
+
model.eval()
|
| 71 |
+
features = []
|
| 72 |
+
labels = []
|
| 73 |
+
|
| 74 |
+
with torch.no_grad():
|
| 75 |
+
for batch in data_loader:
|
| 76 |
+
batch = batch.to(device)
|
| 77 |
+
tree_embed = model.encode_tree(batch)
|
| 78 |
+
images = batch.images.cuda() if not batch.images.is_cuda else batch.images
|
| 79 |
+
image_embed = model.encode_image(images)
|
| 80 |
+
|
| 81 |
+
tree_embed = F.normalize(tree_embed, dim=-1)
|
| 82 |
+
image_embed = F.normalize(image_embed, dim=-1)
|
| 83 |
+
|
| 84 |
+
if fusion == 'concat':
|
| 85 |
+
combined = torch.cat([tree_embed, image_embed], dim=1)
|
| 86 |
+
elif fusion == 'add':
|
| 87 |
+
combined = tree_embed + image_embed
|
| 88 |
+
elif fusion == 'tree_only':
|
| 89 |
+
combined = tree_embed
|
| 90 |
+
elif fusion == 'image_only':
|
| 91 |
+
combined = image_embed
|
| 92 |
+
else:
|
| 93 |
+
combined = torch.cat([tree_embed, image_embed], dim=1)
|
| 94 |
+
|
| 95 |
+
combined = F.normalize(combined, dim=-1)
|
| 96 |
+
|
| 97 |
+
features.append(combined)
|
| 98 |
+
labels.append(batch.label.to(device))
|
| 99 |
+
|
| 100 |
+
features = torch.cat(features, dim=0)
|
| 101 |
+
labels = torch.cat(labels, dim=0)
|
| 102 |
+
|
| 103 |
+
return features, labels
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def evaluate_sklearn_knn(model, train_loader, test_loader, device, knn_k=20, fusion='concat'):
|
| 107 |
+
"""Sklearn KNN evaluation"""
|
| 108 |
+
tmp_model = copy.deepcopy(model).eval()
|
| 109 |
+
|
| 110 |
+
x_train, y_train = get_features_from_encoder(tmp_model, train_loader, device, fusion)
|
| 111 |
+
x_test, y_test = get_features_from_encoder(tmp_model, test_loader, device, fusion)
|
| 112 |
+
|
| 113 |
+
neigh = KNeighborsClassifier(n_neighbors=knn_k)
|
| 114 |
+
neigh.fit(x_train.cpu().numpy(), y_train.cpu().numpy())
|
| 115 |
+
|
| 116 |
+
score = neigh.score(x_test.cpu().numpy(), y_test.cpu().numpy())
|
| 117 |
+
|
| 118 |
+
del tmp_model
|
| 119 |
+
return score
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def evaluate_knn(model, memory_loader, test_loader, device, num_classes, knn_k=20, knn_t=0.5, fusion='concat'):
|
| 123 |
+
"""KNN evaluation during training"""
|
| 124 |
+
model.eval()
|
| 125 |
+
|
| 126 |
+
# Build memory bank from training set
|
| 127 |
+
feature_bank = []
|
| 128 |
+
with torch.no_grad():
|
| 129 |
+
for batch in memory_loader:
|
| 130 |
+
batch = batch.to(device)
|
| 131 |
+
tree_embed = model.encode_tree(batch)
|
| 132 |
+
images = batch.images.cuda() if not batch.images.is_cuda else batch.images
|
| 133 |
+
image_embed = model.encode_image(images)
|
| 134 |
+
|
| 135 |
+
tree_embed = F.normalize(tree_embed, dim=-1)
|
| 136 |
+
image_embed = F.normalize(image_embed, dim=-1)
|
| 137 |
+
|
| 138 |
+
if fusion == 'concat':
|
| 139 |
+
combined = torch.cat([tree_embed, image_embed], dim=1)
|
| 140 |
+
elif fusion == 'add':
|
| 141 |
+
combined = tree_embed + image_embed
|
| 142 |
+
elif fusion == 'tree_only':
|
| 143 |
+
combined = tree_embed
|
| 144 |
+
elif fusion == 'image_only':
|
| 145 |
+
combined = image_embed
|
| 146 |
+
else:
|
| 147 |
+
combined = torch.cat([tree_embed, image_embed], dim=1)
|
| 148 |
+
|
| 149 |
+
combined = F.normalize(combined, dim=-1)
|
| 150 |
+
feature_bank.append(combined)
|
| 151 |
+
|
| 152 |
+
feature_bank = torch.cat(feature_bank, dim=0).t().contiguous()
|
| 153 |
+
feature_labels = torch.tensor(memory_loader.dataset.targets, device=device)
|
| 154 |
+
|
| 155 |
+
# Test
|
| 156 |
+
total_top1, total_num = 0.0, 0
|
| 157 |
+
with torch.no_grad():
|
| 158 |
+
for batch in test_loader:
|
| 159 |
+
batch = batch.to(device)
|
| 160 |
+
tree_embed = model.encode_tree(batch)
|
| 161 |
+
images = batch.images.cuda() if not batch.images.is_cuda else batch.images
|
| 162 |
+
image_embed = model.encode_image(images)
|
| 163 |
+
|
| 164 |
+
tree_embed = F.normalize(tree_embed, dim=-1)
|
| 165 |
+
image_embed = F.normalize(image_embed, dim=-1)
|
| 166 |
+
|
| 167 |
+
if fusion == 'concat':
|
| 168 |
+
combined = torch.cat([tree_embed, image_embed], dim=1)
|
| 169 |
+
elif fusion == 'add':
|
| 170 |
+
combined = tree_embed + image_embed
|
| 171 |
+
elif fusion == 'tree_only':
|
| 172 |
+
combined = tree_embed
|
| 173 |
+
elif fusion == 'image_only':
|
| 174 |
+
combined = image_embed
|
| 175 |
+
else:
|
| 176 |
+
combined = torch.cat([tree_embed, image_embed], dim=1)
|
| 177 |
+
|
| 178 |
+
combined = F.normalize(combined, dim=-1)
|
| 179 |
+
|
| 180 |
+
pred_labels = knn_predict(
|
| 181 |
+
combined, feature_bank.t(), feature_labels,
|
| 182 |
+
num_classes, knn_k, knn_t
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
total_num += combined.size(0)
|
| 186 |
+
total_top1 += (pred_labels == batch.label.to(device)).float().sum().item()
|
| 187 |
+
|
| 188 |
+
accuracy = total_top1 / total_num * 100
|
| 189 |
+
model.train()
|
| 190 |
+
return accuracy
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def create_eval_dataset(phase, dataset_name, args):
|
| 194 |
+
"""Create evaluation dataset"""
|
| 195 |
+
return NeuronTreeDataset(
|
| 196 |
+
phase=phase,
|
| 197 |
+
dataset=dataset_name,
|
| 198 |
+
label_dict=LABEL_DICT[dataset_name],
|
| 199 |
+
input_features=args.input_features,
|
| 200 |
+
use_images=True,
|
| 201 |
+
image_size=args.image_size,
|
| 202 |
+
cache_images=True,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
if __name__ == "__main__":
|
| 207 |
+
parser = argparse.ArgumentParser("GraPHFormer Training")
|
| 208 |
+
|
| 209 |
+
# Basic
|
| 210 |
+
parser.add_argument("--work_dir", type=str, default="./work_dir")
|
| 211 |
+
parser.add_argument("--exp_name", type=str, required=True)
|
| 212 |
+
parser.add_argument("--dataset", type=str, default="all_wo_others")
|
| 213 |
+
parser.add_argument("--data_dir", type=str, default="data/raw/bil")
|
| 214 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 215 |
+
|
| 216 |
+
# Tree Model
|
| 217 |
+
parser.add_argument("--tree_model", type=str, default="double",
|
| 218 |
+
choices=["ori", "v2", "double"])
|
| 219 |
+
parser.add_argument("--child_mode", type=str, default="sum")
|
| 220 |
+
parser.add_argument("--input_features", nargs="+", type=int,
|
| 221 |
+
default=[2, 3, 4, 12, 13])
|
| 222 |
+
parser.add_argument("--h_size", type=int, default=256)
|
| 223 |
+
parser.add_argument("--bn", action="store_true", default=False)
|
| 224 |
+
|
| 225 |
+
# Image Model
|
| 226 |
+
parser.add_argument("--image_encoder", type=str, default="resnet18",
|
| 227 |
+
choices=["resnet18", "resnet50", "resnet101", "simplecnn",
|
| 228 |
+
"smallvit", "persistencevit", "dinov2_vits14"])
|
| 229 |
+
parser.add_argument("--image_size", type=int, default=256)
|
| 230 |
+
parser.add_argument("--freeze_image_backbone", action="store_true", default=False)
|
| 231 |
+
|
| 232 |
+
# CLIP settings
|
| 233 |
+
parser.add_argument("--embed_dim", type=int, default=128)
|
| 234 |
+
parser.add_argument("--single_linear_proj", action="store_true", default=False)
|
| 235 |
+
parser.add_argument("--temperature", type=float, default=0.07)
|
| 236 |
+
parser.add_argument("--loss_type", type=str, default="clip",
|
| 237 |
+
choices=["clip", "infonce", "ntxent", "triplet"])
|
| 238 |
+
|
| 239 |
+
# Triplet loss parameters
|
| 240 |
+
parser.add_argument("--triplet_margin", type=float, default=1.0)
|
| 241 |
+
parser.add_argument("--triplet_mining", type=str, default="batch_hard")
|
| 242 |
+
parser.add_argument("--triplet_distance", type=str, default="euclidean")
|
| 243 |
+
|
| 244 |
+
# Training
|
| 245 |
+
parser.add_argument("--batch_size", type=int, default=128)
|
| 246 |
+
parser.add_argument("--epochs", default=100, type=int)
|
| 247 |
+
parser.add_argument("--lr", default=3e-4, type=float)
|
| 248 |
+
parser.add_argument("--wd", default=0.1, type=float)
|
| 249 |
+
parser.add_argument("--optimizer", type=str, default="adamw", choices=["adamw", "sgd"])
|
| 250 |
+
parser.add_argument("--momentum", type=float, default=0.9)
|
| 251 |
+
parser.add_argument("--warmup_epochs", type=int, default=5)
|
| 252 |
+
parser.add_argument("--start_epoch", type=int, default=0)
|
| 253 |
+
parser.add_argument("--save_freq", type=int, default=10)
|
| 254 |
+
parser.add_argument("--val_freq", type=int, default=5)
|
| 255 |
+
parser.add_argument("--resume", default="", type=str)
|
| 256 |
+
parser.add_argument("--gpu", default=0, type=int)
|
| 257 |
+
|
| 258 |
+
# Augmentation
|
| 259 |
+
parser.add_argument("--aug_scale_coords", action="store_true", default=False)
|
| 260 |
+
parser.add_argument("--aug_rotate", action="store_true", default=False)
|
| 261 |
+
parser.add_argument("--aug_jitter_coords", action="store_true", default=False)
|
| 262 |
+
parser.add_argument("--aug_shift_coords", action="store_true", default=False)
|
| 263 |
+
parser.add_argument("--aug_flip", action="store_true", default=False)
|
| 264 |
+
parser.add_argument("--aug_mask_feats", action="store_true", default=False)
|
| 265 |
+
parser.add_argument("--aug_jitter_length", action="store_true", default=False)
|
| 266 |
+
parser.add_argument("--aug_elasticate", action="store_true", default=False)
|
| 267 |
+
parser.add_argument("--aug_drop_tree", action="store_true", default=False)
|
| 268 |
+
parser.add_argument("--aug_skip_parent_node", action="store_true", default=False)
|
| 269 |
+
parser.add_argument("--aug_swap_sibling_subtrees", action="store_true", default=False)
|
| 270 |
+
|
| 271 |
+
# Persistence augmentation
|
| 272 |
+
parser.add_argument("--use_persistence_aug", action="store_true", default=False)
|
| 273 |
+
parser.add_argument("--pers_translation_scale", type=float, default=0.05)
|
| 274 |
+
parser.add_argument("--pers_noise_scale", type=float, default=0.02)
|
| 275 |
+
parser.add_argument("--pers_sigma_min", type=float, default=12.0)
|
| 276 |
+
parser.add_argument("--pers_sigma_max", type=float, default=20.0)
|
| 277 |
+
parser.add_argument("--sigma_px", type=float, default=16.0)
|
| 278 |
+
|
| 279 |
+
# Evaluation datasets
|
| 280 |
+
parser.add_argument("--eval_jm", action="store_true", default=False)
|
| 281 |
+
parser.add_argument("--eval_act", action="store_true", default=False)
|
| 282 |
+
parser.add_argument("--eval_neuron7", action="store_true", default=False)
|
| 283 |
+
parser.add_argument("--eval_m1_cell", action="store_true", default=False)
|
| 284 |
+
parser.add_argument("--eval_m1_region", action="store_true", default=False)
|
| 285 |
+
parser.add_argument("--eval_swc_glia", action="store_true", default=False)
|
| 286 |
+
|
| 287 |
+
# KNN evaluation
|
| 288 |
+
parser.add_argument("--use_knn_eval", action="store_true", default=False)
|
| 289 |
+
parser.add_argument("--knn_k", type=int, default=20)
|
| 290 |
+
parser.add_argument("--knn_t", type=float, default=0.5)
|
| 291 |
+
parser.add_argument("--knn_fusion", type=str, default="concat",
|
| 292 |
+
choices=["concat", "add", "tree_only", "image_only"])
|
| 293 |
+
parser.add_argument("--use_sklearn_knn", action="store_true", default=False)
|
| 294 |
+
|
| 295 |
+
parser.add_argument("--cache_images", action="store_true", default=True)
|
| 296 |
+
parser.add_argument("--debug", action="store_true", default=False)
|
| 297 |
+
|
| 298 |
+
args = parser.parse_args()
|
| 299 |
+
set_seed(args.seed)
|
| 300 |
+
|
| 301 |
+
# Setup work directory
|
| 302 |
+
args.work_dir = f"{args.work_dir}/{args.exp_name}"
|
| 303 |
+
if not os.path.exists(args.work_dir):
|
| 304 |
+
os.makedirs(args.work_dir)
|
| 305 |
+
|
| 306 |
+
# Logger
|
| 307 |
+
timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
|
| 308 |
+
if args.debug:
|
| 309 |
+
log_file = None
|
| 310 |
+
args.save_freq = 10000
|
| 311 |
+
args.val_freq = 1
|
| 312 |
+
else:
|
| 313 |
+
log_file = f"{args.work_dir}/train_{timestamp}.log"
|
| 314 |
+
logger = get_root_logger(log_file=log_file, log_level="INFO")
|
| 315 |
+
|
| 316 |
+
logger.info("=" * 60)
|
| 317 |
+
logger.info("GraPHFormer TRAINING")
|
| 318 |
+
logger.info(f"Tree Encoder: {args.tree_model}")
|
| 319 |
+
logger.info(f"Image Encoder: {args.image_encoder}")
|
| 320 |
+
logger.info(f"Embedding Dimension: {args.embed_dim}")
|
| 321 |
+
logger.info(f"Temperature: {args.temperature}")
|
| 322 |
+
logger.info("=" * 60)
|
| 323 |
+
logger.info(json.dumps(vars(args), indent=4, sort_keys=True))
|
| 324 |
+
|
| 325 |
+
device = torch.device("cuda")
|
| 326 |
+
|
| 327 |
+
# Create model
|
| 328 |
+
logger.info("=> Creating model...")
|
| 329 |
+
model = CLIPModel(args).to(device)
|
| 330 |
+
logger.info(model)
|
| 331 |
+
|
| 332 |
+
# Optimizer
|
| 333 |
+
if args.optimizer == "sgd":
|
| 334 |
+
optimizer = torch.optim.SGD(
|
| 335 |
+
model.parameters(),
|
| 336 |
+
lr=args.lr,
|
| 337 |
+
weight_decay=args.wd,
|
| 338 |
+
momentum=args.momentum
|
| 339 |
+
)
|
| 340 |
+
else:
|
| 341 |
+
optimizer = torch.optim.AdamW(
|
| 342 |
+
model.parameters(),
|
| 343 |
+
lr=args.lr,
|
| 344 |
+
weight_decay=args.wd,
|
| 345 |
+
betas=(0.9, 0.98),
|
| 346 |
+
eps=1e-6,
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# Resume from checkpoint
|
| 350 |
+
if args.resume:
|
| 351 |
+
if os.path.isfile(args.resume):
|
| 352 |
+
logger.info(f"=> Loading checkpoint '{args.resume}'")
|
| 353 |
+
checkpoint = torch.load(args.resume, map_location=f"cuda:{args.gpu}")
|
| 354 |
+
args.start_epoch = checkpoint["epoch"]
|
| 355 |
+
model.load_state_dict(checkpoint["state_dict"])
|
| 356 |
+
optimizer.load_state_dict(checkpoint["optimizer"])
|
| 357 |
+
logger.info(f"=> Loaded checkpoint (epoch {checkpoint['epoch']})")
|
| 358 |
+
|
| 359 |
+
cudnn.benchmark = True
|
| 360 |
+
|
| 361 |
+
# Build augmentations
|
| 362 |
+
aug_switchs = [
|
| 363 |
+
False,
|
| 364 |
+
args.aug_scale_coords,
|
| 365 |
+
args.aug_rotate,
|
| 366 |
+
args.aug_jitter_coords,
|
| 367 |
+
args.aug_shift_coords,
|
| 368 |
+
args.aug_flip,
|
| 369 |
+
args.aug_mask_feats,
|
| 370 |
+
args.aug_jitter_length,
|
| 371 |
+
args.aug_elasticate,
|
| 372 |
+
]
|
| 373 |
+
aug_fns = [
|
| 374 |
+
None,
|
| 375 |
+
RandomScaleCoords(p=0.2),
|
| 376 |
+
RandomRotate(p=0.5),
|
| 377 |
+
RandomJitter(p=0.2),
|
| 378 |
+
RandomShift(p=0.2),
|
| 379 |
+
RandomFlip(p=1),
|
| 380 |
+
RandomMaskFeats(p=0.2),
|
| 381 |
+
RandomJitterLength(p=0.2),
|
| 382 |
+
RandomElasticate(p=0.2),
|
| 383 |
+
]
|
| 384 |
+
feat_augs = [aug_fns[i] for i in range(len(aug_switchs)) if aug_switchs[i] and aug_fns[i] is not None]
|
| 385 |
+
feat_augs = Compose(feat_augs) if feat_augs else None
|
| 386 |
+
|
| 387 |
+
topo_aug_switchs = [
|
| 388 |
+
args.aug_drop_tree,
|
| 389 |
+
args.aug_skip_parent_node,
|
| 390 |
+
args.aug_swap_sibling_subtrees,
|
| 391 |
+
]
|
| 392 |
+
topo_aug_fns = [
|
| 393 |
+
RandomDropSubTrees(probs=[0.05], max_cnt=5),
|
| 394 |
+
RandomSkipParentNode(probs=[0.05], max_cnt=10),
|
| 395 |
+
RandomSwapSiblingSubTrees(probs=[0.05], max_cnt=10),
|
| 396 |
+
]
|
| 397 |
+
topo_augs = [topo_aug_fns[i] for i in range(len(topo_aug_switchs)) if topo_aug_switchs[i]]
|
| 398 |
+
topo_augs = Compose(topo_augs) if topo_augs else None
|
| 399 |
+
|
| 400 |
+
# Persistence augmentation
|
| 401 |
+
persistence_aug = None
|
| 402 |
+
if args.use_persistence_aug:
|
| 403 |
+
persistence_aug = CombinedPersistenceAugmentation(
|
| 404 |
+
translation_scale=args.pers_translation_scale,
|
| 405 |
+
noise_scale=args.pers_noise_scale,
|
| 406 |
+
sigma_min=args.pers_sigma_min,
|
| 407 |
+
sigma_max=args.pers_sigma_max,
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
# Create training dataset
|
| 411 |
+
use_full_phase = args.dataset in ["all_wo_others", "all_with_neuron7", "neuron7", "ACT"]
|
| 412 |
+
|
| 413 |
+
trainset = NeuronTreeDataset(
|
| 414 |
+
phase="full" if use_full_phase else "train",
|
| 415 |
+
dataset=args.dataset,
|
| 416 |
+
label_dict=LABEL_DICT[args.dataset],
|
| 417 |
+
data_dir=args.data_dir,
|
| 418 |
+
topology_transformations=topo_augs,
|
| 419 |
+
attribute_transformations=feat_augs,
|
| 420 |
+
input_features=args.input_features,
|
| 421 |
+
use_images=True,
|
| 422 |
+
image_size=args.image_size,
|
| 423 |
+
cache_images=args.cache_images,
|
| 424 |
+
persistence_augmentation=persistence_aug,
|
| 425 |
+
sigma_px=args.sigma_px,
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
collate_fn = get_collate_fn(device, use_images=True)
|
| 429 |
+
|
| 430 |
+
train_loader = DataLoader(
|
| 431 |
+
dataset=trainset,
|
| 432 |
+
batch_size=args.batch_size,
|
| 433 |
+
collate_fn=collate_fn,
|
| 434 |
+
shuffle=True,
|
| 435 |
+
drop_last=True,
|
| 436 |
+
num_workers=6,
|
| 437 |
+
pin_memory=True,
|
| 438 |
+
persistent_workers=True,
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
# Evaluation datasets
|
| 442 |
+
eval_datasets = []
|
| 443 |
+
eval_loaders = []
|
| 444 |
+
eval_memory_loaders = []
|
| 445 |
+
|
| 446 |
+
# BIL (always evaluated)
|
| 447 |
+
bil_testset = create_eval_dataset("test", "bil_6_classes", args)
|
| 448 |
+
bil_test_loader = DataLoader(
|
| 449 |
+
dataset=bil_testset,
|
| 450 |
+
batch_size=args.batch_size,
|
| 451 |
+
collate_fn=collate_fn,
|
| 452 |
+
shuffle=False,
|
| 453 |
+
num_workers=4,
|
| 454 |
+
pin_memory=True,
|
| 455 |
+
)
|
| 456 |
+
eval_datasets.append("BIL")
|
| 457 |
+
eval_loaders.append(bil_test_loader)
|
| 458 |
+
|
| 459 |
+
if args.use_knn_eval:
|
| 460 |
+
bil_memory = create_eval_dataset("train", "bil_6_classes", args)
|
| 461 |
+
bil_memory_loader = DataLoader(
|
| 462 |
+
dataset=bil_memory,
|
| 463 |
+
batch_size=args.batch_size,
|
| 464 |
+
collate_fn=collate_fn,
|
| 465 |
+
shuffle=False,
|
| 466 |
+
num_workers=4,
|
| 467 |
+
pin_memory=True,
|
| 468 |
+
)
|
| 469 |
+
eval_memory_loaders.append(bil_memory_loader)
|
| 470 |
+
|
| 471 |
+
# JM
|
| 472 |
+
if args.eval_jm:
|
| 473 |
+
jm_testset = create_eval_dataset("test", "JM", args)
|
| 474 |
+
jm_test_loader = DataLoader(
|
| 475 |
+
dataset=jm_testset,
|
| 476 |
+
batch_size=args.batch_size,
|
| 477 |
+
collate_fn=collate_fn,
|
| 478 |
+
shuffle=False,
|
| 479 |
+
num_workers=4,
|
| 480 |
+
pin_memory=True,
|
| 481 |
+
)
|
| 482 |
+
eval_datasets.append("JM")
|
| 483 |
+
eval_loaders.append(jm_test_loader)
|
| 484 |
+
|
| 485 |
+
if args.use_knn_eval:
|
| 486 |
+
jm_memory = create_eval_dataset("train", "JM", args)
|
| 487 |
+
jm_memory_loader = DataLoader(
|
| 488 |
+
dataset=jm_memory,
|
| 489 |
+
batch_size=args.batch_size,
|
| 490 |
+
collate_fn=collate_fn,
|
| 491 |
+
shuffle=False,
|
| 492 |
+
num_workers=4,
|
| 493 |
+
pin_memory=True,
|
| 494 |
+
)
|
| 495 |
+
eval_memory_loaders.append(jm_memory_loader)
|
| 496 |
+
|
| 497 |
+
# ACT
|
| 498 |
+
if args.eval_act:
|
| 499 |
+
act_testset = create_eval_dataset("test", "ACT", args)
|
| 500 |
+
act_test_loader = DataLoader(
|
| 501 |
+
dataset=act_testset,
|
| 502 |
+
batch_size=args.batch_size,
|
| 503 |
+
collate_fn=collate_fn,
|
| 504 |
+
shuffle=False,
|
| 505 |
+
num_workers=4,
|
| 506 |
+
pin_memory=True,
|
| 507 |
+
)
|
| 508 |
+
eval_datasets.append("ACT")
|
| 509 |
+
eval_loaders.append(act_test_loader)
|
| 510 |
+
|
| 511 |
+
if args.use_knn_eval:
|
| 512 |
+
act_memory = create_eval_dataset("train", "ACT", args)
|
| 513 |
+
act_memory_loader = DataLoader(
|
| 514 |
+
dataset=act_memory,
|
| 515 |
+
batch_size=args.batch_size,
|
| 516 |
+
collate_fn=collate_fn,
|
| 517 |
+
shuffle=False,
|
| 518 |
+
num_workers=4,
|
| 519 |
+
pin_memory=True,
|
| 520 |
+
)
|
| 521 |
+
eval_memory_loaders.append(act_memory_loader)
|
| 522 |
+
|
| 523 |
+
# Neuron7
|
| 524 |
+
if args.eval_neuron7:
|
| 525 |
+
neuron7_testset = create_eval_dataset("test", "neuron7", args)
|
| 526 |
+
neuron7_test_loader = DataLoader(
|
| 527 |
+
dataset=neuron7_testset,
|
| 528 |
+
batch_size=args.batch_size,
|
| 529 |
+
collate_fn=collate_fn,
|
| 530 |
+
shuffle=False,
|
| 531 |
+
num_workers=4,
|
| 532 |
+
pin_memory=True,
|
| 533 |
+
)
|
| 534 |
+
eval_datasets.append("neuron7")
|
| 535 |
+
eval_loaders.append(neuron7_test_loader)
|
| 536 |
+
|
| 537 |
+
if args.use_knn_eval:
|
| 538 |
+
neuron7_memory = create_eval_dataset("train", "neuron7", args)
|
| 539 |
+
neuron7_memory_loader = DataLoader(
|
| 540 |
+
dataset=neuron7_memory,
|
| 541 |
+
batch_size=args.batch_size,
|
| 542 |
+
collate_fn=collate_fn,
|
| 543 |
+
shuffle=False,
|
| 544 |
+
num_workers=4,
|
| 545 |
+
pin_memory=True,
|
| 546 |
+
)
|
| 547 |
+
eval_memory_loaders.append(neuron7_memory_loader)
|
| 548 |
+
|
| 549 |
+
# M1_EXC_cell
|
| 550 |
+
if args.eval_m1_cell:
|
| 551 |
+
m1_cell_testset = create_eval_dataset("test", "m1_exc_cell", args)
|
| 552 |
+
m1_cell_test_loader = DataLoader(
|
| 553 |
+
dataset=m1_cell_testset,
|
| 554 |
+
batch_size=args.batch_size,
|
| 555 |
+
collate_fn=collate_fn,
|
| 556 |
+
shuffle=False,
|
| 557 |
+
num_workers=4,
|
| 558 |
+
pin_memory=True,
|
| 559 |
+
)
|
| 560 |
+
eval_datasets.append("m1_exc_cell")
|
| 561 |
+
eval_loaders.append(m1_cell_test_loader)
|
| 562 |
+
|
| 563 |
+
if args.use_knn_eval:
|
| 564 |
+
m1_cell_memory = create_eval_dataset("train", "m1_exc_cell", args)
|
| 565 |
+
m1_cell_memory_loader = DataLoader(
|
| 566 |
+
dataset=m1_cell_memory,
|
| 567 |
+
batch_size=args.batch_size,
|
| 568 |
+
collate_fn=collate_fn,
|
| 569 |
+
shuffle=False,
|
| 570 |
+
num_workers=4,
|
| 571 |
+
pin_memory=True,
|
| 572 |
+
)
|
| 573 |
+
eval_memory_loaders.append(m1_cell_memory_loader)
|
| 574 |
+
|
| 575 |
+
# M1_EXC_region
|
| 576 |
+
if args.eval_m1_region:
|
| 577 |
+
m1_region_testset = create_eval_dataset("test", "m1_exc_region", args)
|
| 578 |
+
m1_region_test_loader = DataLoader(
|
| 579 |
+
dataset=m1_region_testset,
|
| 580 |
+
batch_size=args.batch_size,
|
| 581 |
+
collate_fn=collate_fn,
|
| 582 |
+
shuffle=False,
|
| 583 |
+
num_workers=4,
|
| 584 |
+
pin_memory=True,
|
| 585 |
+
)
|
| 586 |
+
eval_datasets.append("m1_exc_region")
|
| 587 |
+
eval_loaders.append(m1_region_test_loader)
|
| 588 |
+
|
| 589 |
+
if args.use_knn_eval:
|
| 590 |
+
m1_region_memory = create_eval_dataset("train", "m1_exc_region", args)
|
| 591 |
+
m1_region_memory_loader = DataLoader(
|
| 592 |
+
dataset=m1_region_memory,
|
| 593 |
+
batch_size=args.batch_size,
|
| 594 |
+
collate_fn=collate_fn,
|
| 595 |
+
shuffle=False,
|
| 596 |
+
num_workers=4,
|
| 597 |
+
pin_memory=True,
|
| 598 |
+
)
|
| 599 |
+
eval_memory_loaders.append(m1_region_memory_loader)
|
| 600 |
+
|
| 601 |
+
# swc_glia
|
| 602 |
+
if args.eval_swc_glia:
|
| 603 |
+
swc_glia_testset = create_eval_dataset("test", "swc_glia_filtered_1000", args)
|
| 604 |
+
swc_glia_test_loader = DataLoader(
|
| 605 |
+
dataset=swc_glia_testset,
|
| 606 |
+
batch_size=args.batch_size,
|
| 607 |
+
collate_fn=collate_fn,
|
| 608 |
+
shuffle=False,
|
| 609 |
+
num_workers=4,
|
| 610 |
+
pin_memory=True,
|
| 611 |
+
)
|
| 612 |
+
eval_datasets.append("swc_glia")
|
| 613 |
+
eval_loaders.append(swc_glia_test_loader)
|
| 614 |
+
|
| 615 |
+
if args.use_knn_eval:
|
| 616 |
+
swc_glia_memory = create_eval_dataset("train", "swc_glia_filtered_1000", args)
|
| 617 |
+
swc_glia_memory_loader = DataLoader(
|
| 618 |
+
dataset=swc_glia_memory,
|
| 619 |
+
batch_size=args.batch_size,
|
| 620 |
+
collate_fn=collate_fn,
|
| 621 |
+
shuffle=False,
|
| 622 |
+
num_workers=4,
|
| 623 |
+
pin_memory=True,
|
| 624 |
+
)
|
| 625 |
+
eval_memory_loaders.append(swc_glia_memory_loader)
|
| 626 |
+
|
| 627 |
+
# Learning rate scheduler
|
| 628 |
+
def lr_schedule(epoch):
|
| 629 |
+
if epoch < args.warmup_epochs:
|
| 630 |
+
return (epoch + 1) / args.warmup_epochs
|
| 631 |
+
else:
|
| 632 |
+
progress = (epoch - args.warmup_epochs) / (args.epochs - args.warmup_epochs)
|
| 633 |
+
cosine_decay = 0.5 * (1 + torch.cos(torch.tensor(progress * 3.14159)))
|
| 634 |
+
min_lr_factor = 1e-6 / args.lr
|
| 635 |
+
return min_lr_factor + (1 - min_lr_factor) * cosine_decay
|
| 636 |
+
|
| 637 |
+
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_schedule)
|
| 638 |
+
|
| 639 |
+
# Training loop
|
| 640 |
+
best_metrics = {dataset: {"recall@5": 0.0, "epoch": 0} for dataset in eval_datasets}
|
| 641 |
+
total_iters = len(train_loader) * args.epochs
|
| 642 |
+
current_iter = 0
|
| 643 |
+
start_time = time.time()
|
| 644 |
+
|
| 645 |
+
logger.info("=> Starting training...")
|
| 646 |
+
for epoch in range(args.start_epoch + 1, args.epochs + 1):
|
| 647 |
+
model.train()
|
| 648 |
+
epoch_loss = 0.0
|
| 649 |
+
|
| 650 |
+
for step, batch in enumerate(train_loader):
|
| 651 |
+
try:
|
| 652 |
+
batch = batch.to(device)
|
| 653 |
+
loss = model(batch)
|
| 654 |
+
|
| 655 |
+
optimizer.zero_grad()
|
| 656 |
+
loss.backward()
|
| 657 |
+
optimizer.step()
|
| 658 |
+
|
| 659 |
+
epoch_loss += loss.item()
|
| 660 |
+
current_iter += 1
|
| 661 |
+
|
| 662 |
+
if step % 10 == 0:
|
| 663 |
+
current_time = time.time()
|
| 664 |
+
elapsed = current_time - start_time
|
| 665 |
+
|
| 666 |
+
log_str = (
|
| 667 |
+
f"Epoch {epoch:03d} | Step {step:03d}/{len(train_loader)} | "
|
| 668 |
+
f"Loss {loss.item():.4f} | "
|
| 669 |
+
f"LR {optimizer.param_groups[0]['lr']:.6f} | "
|
| 670 |
+
f"Elapsed {str(datetime.timedelta(seconds=int(elapsed)))}"
|
| 671 |
+
)
|
| 672 |
+
logger.info(log_str)
|
| 673 |
+
except Exception as e:
|
| 674 |
+
logger.info(f"Error in step {step}: {e}")
|
| 675 |
+
continue
|
| 676 |
+
|
| 677 |
+
scheduler.step()
|
| 678 |
+
avg_loss = epoch_loss / len(train_loader)
|
| 679 |
+
logger.info(f"Epoch {epoch:03d} | Avg Loss: {avg_loss:.4f}")
|
| 680 |
+
|
| 681 |
+
# Evaluation
|
| 682 |
+
if epoch % args.val_freq == 0 and args.use_knn_eval:
|
| 683 |
+
logger.info("=> Evaluating...")
|
| 684 |
+
|
| 685 |
+
fusion_modes = ['concat', 'add', 'tree_only', 'image_only']
|
| 686 |
+
|
| 687 |
+
for idx, (dataset_name, test_loader) in enumerate(zip(eval_datasets, eval_loaders)):
|
| 688 |
+
memory_loader = eval_memory_loaders[idx]
|
| 689 |
+
num_classes = len(test_loader.dataset.classes)
|
| 690 |
+
|
| 691 |
+
# Use k=5 for JM, otherwise use args.knn_k
|
| 692 |
+
k = 5 if dataset_name == "JM" else args.knn_k
|
| 693 |
+
|
| 694 |
+
logger.info(f"\n === {dataset_name} Dataset ===")
|
| 695 |
+
|
| 696 |
+
fusion_results = {}
|
| 697 |
+
best_fusion_acc = 0
|
| 698 |
+
best_fusion_mode = None
|
| 699 |
+
|
| 700 |
+
for fusion_mode in fusion_modes:
|
| 701 |
+
if args.use_sklearn_knn:
|
| 702 |
+
knn_acc = evaluate_sklearn_knn(
|
| 703 |
+
model, memory_loader, test_loader, device,
|
| 704 |
+
knn_k=k, fusion=fusion_mode
|
| 705 |
+
)
|
| 706 |
+
knn_acc = knn_acc * 100
|
| 707 |
+
else:
|
| 708 |
+
knn_acc = evaluate_knn(
|
| 709 |
+
model, memory_loader, test_loader, device,
|
| 710 |
+
num_classes, k, args.knn_t, fusion=fusion_mode
|
| 711 |
+
)
|
| 712 |
+
|
| 713 |
+
fusion_results[fusion_mode] = knn_acc
|
| 714 |
+
|
| 715 |
+
if knn_acc > best_fusion_acc:
|
| 716 |
+
best_fusion_acc = knn_acc
|
| 717 |
+
best_fusion_mode = fusion_mode
|
| 718 |
+
|
| 719 |
+
logger.info(f" {fusion_mode:12s}: {knn_acc:.2f}%")
|
| 720 |
+
|
| 721 |
+
logger.info(f" {'BEST':12s}: {best_fusion_mode} ({best_fusion_acc:.2f}%)")
|
| 722 |
+
|
| 723 |
+
# Save best checkpoint based on higher of concat or add
|
| 724 |
+
concat_acc = fusion_results['concat']
|
| 725 |
+
add_acc = fusion_results['add']
|
| 726 |
+
primary_acc = max(concat_acc, add_acc)
|
| 727 |
+
primary_fusion = 'add' if add_acc > concat_acc else 'concat'
|
| 728 |
+
|
| 729 |
+
logger.info(f" Best concat : {concat_acc:.2f}%")
|
| 730 |
+
logger.info(f" Best add : {add_acc:.2f}%")
|
| 731 |
+
logger.info(f" Selected : {primary_fusion} ({primary_acc:.2f}%)")
|
| 732 |
+
|
| 733 |
+
if primary_acc > best_metrics[dataset_name]["recall@5"]:
|
| 734 |
+
best_metrics[dataset_name]["recall@5"] = primary_acc
|
| 735 |
+
best_metrics[dataset_name]["epoch"] = epoch
|
| 736 |
+
best_metrics[dataset_name]["fusion_mode"] = primary_fusion
|
| 737 |
+
best_metrics[dataset_name]["best_concat"] = concat_acc
|
| 738 |
+
best_metrics[dataset_name]["best_add"] = add_acc
|
| 739 |
+
|
| 740 |
+
checkpoint_path = f"{args.work_dir}/best_{dataset_name}_epoch_{epoch}.pth"
|
| 741 |
+
save_checkpoint(
|
| 742 |
+
{
|
| 743 |
+
"epoch": epoch,
|
| 744 |
+
"state_dict": model.state_dict(),
|
| 745 |
+
"optimizer": optimizer.state_dict(),
|
| 746 |
+
"knn_accuracy": primary_acc,
|
| 747 |
+
"primary_fusion_mode": primary_fusion,
|
| 748 |
+
"concat_accuracy": concat_acc,
|
| 749 |
+
"add_accuracy": add_acc,
|
| 750 |
+
"fusion_results": fusion_results,
|
| 751 |
+
"dataset": dataset_name,
|
| 752 |
+
},
|
| 753 |
+
is_best=True,
|
| 754 |
+
filename=checkpoint_path,
|
| 755 |
+
)
|
| 756 |
+
logger.info(f" Saved new best for {dataset_name}: {checkpoint_path}")
|
| 757 |
+
|
| 758 |
+
logger.info(
|
| 759 |
+
f" Best {dataset_name} ({best_metrics[dataset_name].get('fusion_mode', primary_fusion)}): "
|
| 760 |
+
f"{best_metrics[dataset_name]['recall@5']:.2f}% at epoch {best_metrics[dataset_name]['epoch']} "
|
| 761 |
+
f"[concat: {best_metrics[dataset_name].get('best_concat', concat_acc):.2f}%, "
|
| 762 |
+
f"add: {best_metrics[dataset_name].get('best_add', add_acc):.2f}%]"
|
| 763 |
+
)
|
| 764 |
+
|
| 765 |
+
# Save periodic checkpoint
|
| 766 |
+
if epoch % args.save_freq == 0:
|
| 767 |
+
checkpoint_path = f"{args.work_dir}/epoch_{epoch}.pth"
|
| 768 |
+
save_checkpoint(
|
| 769 |
+
{
|
| 770 |
+
"epoch": epoch,
|
| 771 |
+
"state_dict": model.state_dict(),
|
| 772 |
+
"optimizer": optimizer.state_dict(),
|
| 773 |
+
},
|
| 774 |
+
is_best=False,
|
| 775 |
+
filename=checkpoint_path,
|
| 776 |
+
)
|
| 777 |
+
logger.info(f"Saved checkpoint: {checkpoint_path}")
|
| 778 |
+
|
| 779 |
+
logger.info("Training complete!")
|
| 780 |
+
if args.use_knn_eval:
|
| 781 |
+
logger.info("Best results:")
|
| 782 |
+
for dataset_name in eval_datasets:
|
| 783 |
+
logger.info(
|
| 784 |
+
f" {dataset_name}: {best_metrics[dataset_name]['recall@5']:.2f}% "
|
| 785 |
+
f"at epoch {best_metrics[dataset_name]['epoch']}"
|
| 786 |
+
)
|