ushah commited on
Commit
cf84204
·
1 Parent(s): 5b9b7ec

Initial commit: GraPHFormer codebase

Browse files
.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
+ )