Add GrapHist v2 + VICReg final checkpoint (best val loss, epoch 99) with loader files and validated model card
Browse files- README.md +183 -0
- graphist_V2.pt +3 -0
- graphist_utils.py +276 -0
- models/__init__.py +73 -0
- models/acm_gin.py +337 -0
- models/acm_gineconv.py +385 -0
- models/edcoder.py +333 -0
- models/utils.py +75 -0
README.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: cc-by-nc-sa-4.0
|
| 3 |
+
library_name: pytorch
|
| 4 |
+
pipeline_tag: graph-ml
|
| 5 |
+
tags:
|
| 6 |
+
- graph-neural-networks
|
| 7 |
+
- histopathology
|
| 8 |
+
- self-supervised-learning
|
| 9 |
+
- pytorch-geometric
|
| 10 |
+
- graph-representation-learning
|
| 11 |
+
- edge-features
|
| 12 |
+
- vicreg
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
# GrapHist v2 + VICReg: Edge-Informed Graph Self-Supervised Learning for Histopathology
|
| 16 |
+
|
| 17 |
+
GrapHist v2 extends [GrapHist](https://huggingface.co/ogutsevda/graphist)
|
| 18 |
+
([arXiv:2603.00143](https://arxiv.org/abs/2603.00143)) with **edge-informed message
|
| 19 |
+
passing**: cell graphs carry a 75-dimensional feature vector per edge describing the
|
| 20 |
+
inter-cellular region, and the encoder becomes an **ACM-GINEConv** that injects those
|
| 21 |
+
edge features into aggregation.
|
| 22 |
+
|
| 23 |
+
The un-regularised v2 encoder **dimensionally collapsed** (`pca_1 ≈ 0.5`, effective
|
| 24 |
+
dim ≈ 2). Adding a **VICReg** variance/covariance regulariser fixes it: this checkpoint
|
| 25 |
+
ends collapse-free at `pca_1 = 0.17`, **effective dim 11.9**, and improves over vanilla
|
| 26 |
+
GrapHist on transfer subtyping, cell-level phenotyping and survival.
|
| 27 |
+
|
| 28 |
+
Self-supervision follows GraphMAE (masked node-feature reconstruction, scaled cosine
|
| 29 |
+
error) plus the VICReg term on the pooled graph embedding.
|
| 30 |
+
|
| 31 |
+
## Repository Structure
|
| 32 |
+
|
| 33 |
+
```
|
| 34 |
+
graphist_V2.pt # final checkpoint - 126,597,010 B, md5 81a2e6b91cefff0bbbc13c6fd318ee78
|
| 35 |
+
models/
|
| 36 |
+
├── __init__.py # build_model(args) factory
|
| 37 |
+
├── edcoder.py # PreModel encoder-decoder wrapper
|
| 38 |
+
├── acm_gineconv.py # ACM-GINEConv backbone (v2, edge-informed)
|
| 39 |
+
├── acm_gin.py # ACM-GIN backbone (v1) - REQUIRED IMPORT, see note
|
| 40 |
+
└── utils.py # activation helpers
|
| 41 |
+
graphist_utils.py # NormalizeData, AddVirtualNode transforms - REQUIRED for inference
|
| 42 |
+
README.md
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
> **`acm_gin.py` is required even though v2 never uses it.** `models/edcoder.py` imports
|
| 46 |
+
> `ACM_GIN_model` and `ACM_GINEConv_model` at module top level; omitting it is an `ImportError`.
|
| 47 |
+
|
| 48 |
+
> **`graphist_utils.py` is required.** Reproducing the published embeddings needs
|
| 49 |
+
> `Compose([ToUndirected(), NormalizeData(...), AddVirtualNode(...)])`, and neither
|
| 50 |
+
> transform lives in `models/`.
|
| 51 |
+
|
| 52 |
+
## Which checkpoint this is
|
| 53 |
+
|
| 54 |
+
The training run produced four checkpoints. **This is the best-validation-loss one**, and
|
| 55 |
+
the one that produced every reported v2+VICReg number:
|
| 56 |
+
|
| 57 |
+
| Field | Value |
|
| 58 |
+
|---|---|
|
| 59 |
+
| `epoch` | 99 |
|
| 60 |
+
| `best_loss` | 0.019939 |
|
| 61 |
+
| `run_id` | `1udpmgxw` |
|
| 62 |
+
| md5 | `81a2e6b91cefff0bbbc13c6fd318ee78` |
|
| 63 |
+
|
| 64 |
+
Parameters: **encoder 7.98 M** (what you use for inference); **10.53 M** for the full
|
| 65 |
+
pretraining model (encoder + projection + decoder + mask token, counting each shared
|
| 66 |
+
module once). A naive `state_dict` sum reports 19.35 M because `acm_gineconv.py`
|
| 67 |
+
registers each channel MLP twice — 156 of the 332 keys are aliases of the same tensors.
|
| 68 |
+
|
| 69 |
+
## Requirements
|
| 70 |
+
|
| 71 |
+
```
|
| 72 |
+
torch >= 2.2
|
| 73 |
+
torch_geometric >= 2.5
|
| 74 |
+
numpy, pandas
|
| 75 |
+
```
|
| 76 |
+
Verified on torch 2.10.0+cu128 / PyG 2.7.0. CPU-only inference works.
|
| 77 |
+
|
| 78 |
+
## Usage
|
| 79 |
+
|
| 80 |
+
### 1. Download
|
| 81 |
+
|
| 82 |
+
```python
|
| 83 |
+
from huggingface_hub import snapshot_download
|
| 84 |
+
path = snapshot_download(repo_id="Ace3Z/graphist-v2")
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### 2. Load
|
| 88 |
+
|
| 89 |
+
⚠️ **Three arguments are load-critical.** `build_model` reads them via `getattr` with
|
| 90 |
+
defaults that do **not** match this checkpoint — omit any one and `load_state_dict` fails
|
| 91 |
+
on a shape or key mismatch:
|
| 92 |
+
|
| 93 |
+
| Argument | Must be | Default if omitted | Failure |
|
| 94 |
+
|---|---|---|---|
|
| 95 |
+
| `edge_distance_in_proj` | `False` | `True` | `edge_input_proj` built as (512, **75**); checkpoint has (512, **74**) |
|
| 96 |
+
| `encoder_norm` | `"layer"` | `"none"` | `encoder.layer_norms.*` missing from the model |
|
| 97 |
+
| `concat_hidden` | `True` | `False` | `encoder_to_decoder` built as (512, 512); checkpoint has (512, **2560**) |
|
| 98 |
+
|
| 99 |
+
```python
|
| 100 |
+
import sys, torch
|
| 101 |
+
sys.path.insert(0, path)
|
| 102 |
+
from models import build_model
|
| 103 |
+
|
| 104 |
+
class Args:
|
| 105 |
+
# --- architecture ---
|
| 106 |
+
encoder = "acm_gineconv"
|
| 107 |
+
decoder = "acm_gineconv"
|
| 108 |
+
num_features = 96 # node features
|
| 109 |
+
num_edge_features = 75 # edge features (projection sees 74; distance excluded)
|
| 110 |
+
num_hidden = 512
|
| 111 |
+
num_layers = 5
|
| 112 |
+
concat_hidden = True # load-critical
|
| 113 |
+
encoder_norm = "layer" # load-critical
|
| 114 |
+
input_norm = "none"
|
| 115 |
+
edge_distance_in_proj = False # load-critical
|
| 116 |
+
batchnorm = False
|
| 117 |
+
activation = "prelu"
|
| 118 |
+
norm = None
|
| 119 |
+
residual = False
|
| 120 |
+
# --- SSL objective ---
|
| 121 |
+
loss_fn = "sce"
|
| 122 |
+
alpha_l = 3
|
| 123 |
+
mask_rate = 0.5
|
| 124 |
+
replace_rate = 0.1
|
| 125 |
+
drop_edge_rate = 0.0
|
| 126 |
+
# --- VICReg (training only) ---
|
| 127 |
+
vicreg_var_weight = 0.05
|
| 128 |
+
vicreg_cov_weight = 0.002
|
| 129 |
+
vicreg_gamma = 1.0
|
| 130 |
+
# --- unused, vestigial from the GAT lineage, but read by build_model ---
|
| 131 |
+
num_heads = 4
|
| 132 |
+
num_out_heads = 1
|
| 133 |
+
in_drop = 0.2
|
| 134 |
+
attn_drop = 0.1
|
| 135 |
+
negative_slope = 0.2
|
| 136 |
+
|
| 137 |
+
model = build_model(Args())
|
| 138 |
+
ckpt = torch.load(f"{path}/graphist_V2.pt", map_location="cpu", weights_only=False)
|
| 139 |
+
model.load_state_dict(ckpt["model_state_dict"], strict=True) # note the key name
|
| 140 |
+
model.eval()
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
The checkpoint is a training dict — `{epoch, model_state_dict, optimizer_state_dict,
|
| 144 |
+
best_loss, run_id}`. The weights are under **`model_state_dict`**, not `model`.
|
| 145 |
+
|
| 146 |
+
### 3. Inference
|
| 147 |
+
|
| 148 |
+
```python
|
| 149 |
+
# x: [num_nodes, 96] edge_index: [2, num_edges] edge_attr: [num_edges, 75]
|
| 150 |
+
# batch: [num_nodes] graph assignment (zeros for a single graph)
|
| 151 |
+
with torch.no_grad():
|
| 152 |
+
node_emb = model.embed(x, edge_index, edge_attr, batch) # -> [num_nodes, 512]
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
Region/slide-level embeddings are the mean over node embeddings; slide-level is the mean
|
| 156 |
+
over its tiles. For results matching the paper, apply the same transform pipeline used at
|
| 157 |
+
training time (`NormalizeData` with the dataset's `normalization.json`, then
|
| 158 |
+
`AddVirtualNode`) — see `graphist_utils.py`.
|
| 159 |
+
|
| 160 |
+
## Verification
|
| 161 |
+
|
| 162 |
+
This repository was validated end-to-end before publishing: `build_model(Args())` +
|
| 163 |
+
`load_state_dict(..., strict=True)` returns **0 missing / 0 unexpected keys** using only
|
| 164 |
+
the files shipped here, and `embed()` returns a finite `[n, 512]` tensor.
|
| 165 |
+
|
| 166 |
+
## Acknowledgements
|
| 167 |
+
|
| 168 |
+
Built on [GrapHist](https://huggingface.co/ogutsevda/graphist) (Ogut et al.), GraphMAE
|
| 169 |
+
(Hou et al., 2022), ACM (Luan et al., 2022), GINEConv (Hu et al., 2020) and VICReg
|
| 170 |
+
(Bardes et al., 2022). Developed at [LTS4, EPFL](https://www.epfl.ch/labs/lts4/).
|
| 171 |
+
|
| 172 |
+
## Citation
|
| 173 |
+
|
| 174 |
+
The v2 preprint is not yet available. Please cite the original GrapHist paper:
|
| 175 |
+
|
| 176 |
+
```bibtex
|
| 177 |
+
@article{graphist2026,
|
| 178 |
+
title = {GrapHist: Graph Self-Supervised Learning for Histopathology},
|
| 179 |
+
year = {2026},
|
| 180 |
+
eprint = {2603.00143},
|
| 181 |
+
archivePrefix = {arXiv}
|
| 182 |
+
}
|
| 183 |
+
```
|
graphist_V2.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4be24e445e5065a01cdb6a157db84bbbda2c44f4dbf263ebd9d61ad26e375dfb
|
| 3 |
+
size 126597010
|
graphist_utils.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import stat as stat_module
|
| 3 |
+
import torch
|
| 4 |
+
import signal
|
| 5 |
+
import random
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
from typing import List
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 11 |
+
from torch_geometric.data import Dataset, Data
|
| 12 |
+
from torch_geometric.transforms import BaseTransform
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _is_valid_graph_path(p):
|
| 16 |
+
"""A single os.stat (regular file, non-empty). One syscall instead of two."""
|
| 17 |
+
try:
|
| 18 |
+
st = os.stat(p)
|
| 19 |
+
return stat_module.S_ISREG(st.st_mode) and st.st_size > 0
|
| 20 |
+
except OSError:
|
| 21 |
+
return False
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def filter_valid_graph_paths(graph_paths: list, num_workers: int = 32) -> list:
|
| 25 |
+
"""Filter out graph paths that don't exist or have zero size (truncated).
|
| 26 |
+
|
| 27 |
+
Parallelized across threads: this is I/O-bound stat() traffic on a network
|
| 28 |
+
filesystem, so a thread pool gives near-linear speedup. On the full dataset
|
| 29 |
+
(~8.9M paths) the serial two-stat version took tens of minutes; this is
|
| 30 |
+
~1-2 min. Order is preserved (map keeps input order) and the result is
|
| 31 |
+
identical to the serial check.
|
| 32 |
+
"""
|
| 33 |
+
with ThreadPoolExecutor(max_workers=num_workers) as ex:
|
| 34 |
+
flags = list(ex.map(_is_valid_graph_path, graph_paths, chunksize=2000))
|
| 35 |
+
valid = [p for p, ok in zip(graph_paths, flags) if ok]
|
| 36 |
+
skipped = len(graph_paths) - len(valid)
|
| 37 |
+
if skipped:
|
| 38 |
+
print(f"WARNING: Filtered out {skipped} missing/empty graph files")
|
| 39 |
+
return valid
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class GraphDataset(Dataset):
|
| 43 |
+
def __init__(self, graph_paths: list, transform=None, pre_transform=None):
|
| 44 |
+
super().__init__(None, transform, pre_transform)
|
| 45 |
+
self.graph_paths = filter_valid_graph_paths(graph_paths)
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def processed_file_names(self):
|
| 49 |
+
return self.graph_paths
|
| 50 |
+
|
| 51 |
+
def len(self):
|
| 52 |
+
return len(self.graph_paths)
|
| 53 |
+
|
| 54 |
+
def get(self, idx):
|
| 55 |
+
graph_path = self.graph_paths[idx]
|
| 56 |
+
graph = torch.load(graph_path, weights_only=False)
|
| 57 |
+
graph.graph_path = graph_path
|
| 58 |
+
return graph
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class NormalizeData(BaseTransform):
|
| 62 |
+
def __init__(
|
| 63 |
+
self,
|
| 64 |
+
scale_dict: dict,
|
| 65 |
+
attrs: List[str] = ["x", "edge_attr"],
|
| 66 |
+
edge_attr_skip_cols: List[int] | None = None,
|
| 67 |
+
min_std: float | None = None,
|
| 68 |
+
clip: float | None = None,
|
| 69 |
+
):
|
| 70 |
+
tensor_dict = defaultdict(lambda: defaultdict()) # convert the data to tensors
|
| 71 |
+
for key, value in scale_dict.items():
|
| 72 |
+
tensor_dict[key]["mean"] = torch.tensor(value["mean"])
|
| 73 |
+
tensor_dict[key]["std"] = torch.tensor(value["std"])
|
| 74 |
+
|
| 75 |
+
# Optional std floor: clamp tiny stds upward to prevent near-constant
|
| 76 |
+
# dims (which have intrinsically tiny variance from the feature pipeline,
|
| 77 |
+
# e.g. high-freq Fourier descriptors, GLCM moments) from inflating
|
| 78 |
+
# outlier raw values by 1/std factors of 10^3-10^4.
|
| 79 |
+
if min_std is not None:
|
| 80 |
+
for key in tensor_dict:
|
| 81 |
+
tensor_dict[key]["std"] = torch.clamp(
|
| 82 |
+
tensor_dict[key]["std"], min=min_std
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# For specified edge_attr columns (e.g. column 0 = spatial distance),
|
| 86 |
+
# set mean=0, std=1 so they pass through untouched.
|
| 87 |
+
if edge_attr_skip_cols and "edge_attr" in tensor_dict:
|
| 88 |
+
for col in edge_attr_skip_cols:
|
| 89 |
+
tensor_dict["edge_attr"]["mean"][col] = 0.0
|
| 90 |
+
tensor_dict["edge_attr"]["std"][col] = 1.0
|
| 91 |
+
|
| 92 |
+
self.tensor_dict = tensor_dict
|
| 93 |
+
self.attrs = attrs
|
| 94 |
+
self.clip = clip
|
| 95 |
+
self.edge_attr_skip_cols = edge_attr_skip_cols or []
|
| 96 |
+
|
| 97 |
+
def forward(self, data: Data) -> Data:
|
| 98 |
+
for store in data.stores:
|
| 99 |
+
for key, value in store.items(*self.attrs):
|
| 100 |
+
if value.numel() > 0:
|
| 101 |
+
mean = self.tensor_dict[key]["mean"]
|
| 102 |
+
std = self.tensor_dict[key]["std"]
|
| 103 |
+
value = (value - mean) / std
|
| 104 |
+
value = torch.nan_to_num(value, nan=0.0)
|
| 105 |
+
# Hard bound on standardized values to stop residual
|
| 106 |
+
# cross-dataset explosion (a feature far from the TCGA mean).
|
| 107 |
+
# Skip edge_attr columns left raw (e.g. col 0 = distance).
|
| 108 |
+
if self.clip is not None:
|
| 109 |
+
if key == "edge_attr" and self.edge_attr_skip_cols:
|
| 110 |
+
keep = value[:, self.edge_attr_skip_cols].clone()
|
| 111 |
+
value = value.clamp(-self.clip, self.clip)
|
| 112 |
+
value[:, self.edge_attr_skip_cols] = keep
|
| 113 |
+
else:
|
| 114 |
+
value = value.clamp(-self.clip, self.clip)
|
| 115 |
+
store[key] = value
|
| 116 |
+
return data
|
| 117 |
+
|
| 118 |
+
def __repr__(self) -> str:
|
| 119 |
+
return f"{self.__class__.__name__}()"
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class AddVirtualNode(BaseTransform):
|
| 123 |
+
"""Add a virtual node connected to every real node.
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
mean_edge_distance: Raw (un-normalised) mean spatial distance from
|
| 127 |
+
the training set. Used as column 0 of the virtual-node edge
|
| 128 |
+
features so that the degree-normalised message weighting remains
|
| 129 |
+
strictly positive. Remaining columns (visual + relational
|
| 130 |
+
features) are set to 0.0 because those columns *are* normalised,
|
| 131 |
+
and 0 represents their mean.
|
| 132 |
+
"""
|
| 133 |
+
|
| 134 |
+
def __init__(self, mean_edge_distance: float):
|
| 135 |
+
self.mean_edge_distance = mean_edge_distance
|
| 136 |
+
|
| 137 |
+
def forward(self, data: Data) -> Data:
|
| 138 |
+
num_nodes = data.num_nodes
|
| 139 |
+
device = data.x.device if data.x is not None else "cpu"
|
| 140 |
+
|
| 141 |
+
virtual_node_feat = torch.zeros((1, data.x.size(-1)), device=device)
|
| 142 |
+
data.x = torch.cat([data.x, virtual_node_feat], dim=0)
|
| 143 |
+
|
| 144 |
+
# Keep any node-level label(s) aligned with the added virtual node: the
|
| 145 |
+
# cell-level embedding path slices batch.label with the VN-inclusive ptr,
|
| 146 |
+
# so a per-node label must gain one entry for the VN (placeholder -1).
|
| 147 |
+
# Scalar / graph-level labels (size != num_nodes) are left untouched.
|
| 148 |
+
for _lab in ("label", "labels"):
|
| 149 |
+
_v = getattr(data, _lab, None)
|
| 150 |
+
if torch.is_tensor(_v) and _v.dim() >= 1 and _v.size(0) == num_nodes:
|
| 151 |
+
_pad = torch.full(
|
| 152 |
+
(1, *_v.shape[1:]), -1, dtype=_v.dtype, device=_v.device
|
| 153 |
+
)
|
| 154 |
+
setattr(data, _lab, torch.cat([_v, _pad], dim=0))
|
| 155 |
+
|
| 156 |
+
row = torch.arange(num_nodes, device=device)
|
| 157 |
+
col = torch.full((num_nodes,), num_nodes, device=device)
|
| 158 |
+
new_edges = torch.stack([torch.cat([row, col]), torch.cat([col, row])], dim=0)
|
| 159 |
+
data.edge_index = torch.cat([data.edge_index, new_edges], dim=1)
|
| 160 |
+
|
| 161 |
+
data.virtual_node_index = torch.tensor([num_nodes], device=device)
|
| 162 |
+
|
| 163 |
+
if data.edge_attr is not None:
|
| 164 |
+
edge_dim = data.edge_attr.size(-1)
|
| 165 |
+
new_edge_attr = torch.zeros(
|
| 166 |
+
(2 * num_nodes, edge_dim), device=device
|
| 167 |
+
)
|
| 168 |
+
# Column 0 = raw spatial distance (not normalised); use training-
|
| 169 |
+
# set mean so virtual-node edges have a typical positive weight.
|
| 170 |
+
new_edge_attr[:, 0] = self.mean_edge_distance
|
| 171 |
+
data.edge_attr = torch.cat([data.edge_attr, new_edge_attr], dim=0)
|
| 172 |
+
|
| 173 |
+
return data
|
| 174 |
+
|
| 175 |
+
def __repr__(self) -> str:
|
| 176 |
+
return f"{self.__class__.__name__}(mean_edge_distance={self.mean_edge_distance})"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
class GracefulKiller:
|
| 180 |
+
kill_now = False
|
| 181 |
+
|
| 182 |
+
def __init__(self):
|
| 183 |
+
signal.signal(signal.SIGINT, self.exit_gracefully)
|
| 184 |
+
signal.signal(signal.SIGTERM, self.exit_gracefully)
|
| 185 |
+
|
| 186 |
+
def exit_gracefully(self, signum, frame):
|
| 187 |
+
self.kill_now = True
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def set_random_seed(seed):
|
| 191 |
+
random.seed(seed)
|
| 192 |
+
np.random.seed(seed)
|
| 193 |
+
torch.manual_seed(seed)
|
| 194 |
+
torch.cuda.manual_seed(seed)
|
| 195 |
+
torch.cuda.manual_seed_all(seed)
|
| 196 |
+
torch.backends.cudnn.deterministic = True
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def seed_worker(worker_id):
|
| 200 |
+
worker_seed = torch.initial_seed() % 2**32
|
| 201 |
+
np.random.seed(worker_seed)
|
| 202 |
+
random.seed(worker_seed)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def split_data(data_df: pd.DataFrame, split_df: pd.DataFrame):
|
| 206 |
+
|
| 207 |
+
train_samples = split_df.loc[split_df["split"] == "train", "sample_id"].unique()
|
| 208 |
+
val_samples = split_df.loc[split_df["split"] == "val", "sample_id"].unique()
|
| 209 |
+
test_samples = split_df.loc[split_df["split"] == "test", "sample_id"].unique()
|
| 210 |
+
|
| 211 |
+
train_mask = data_df["sample_id"].isin(train_samples)
|
| 212 |
+
val_mask = data_df["sample_id"].isin(val_samples)
|
| 213 |
+
test_mask = data_df["sample_id"].isin(test_samples)
|
| 214 |
+
|
| 215 |
+
return train_mask, val_mask, test_mask
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def get_current_lr(optimizer):
|
| 219 |
+
return optimizer.state_dict()["param_groups"][0]["lr"]
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def create_optimizer(
|
| 223 |
+
opt, model, lr, weight_decay, get_num_layer=None, get_layer_scale=None
|
| 224 |
+
):
|
| 225 |
+
opt_lower = opt.lower()
|
| 226 |
+
|
| 227 |
+
parameters = model.parameters()
|
| 228 |
+
opt_args = dict(lr=lr, weight_decay=weight_decay)
|
| 229 |
+
|
| 230 |
+
opt_split = opt_lower.split("_")
|
| 231 |
+
opt_lower = opt_split[-1]
|
| 232 |
+
if opt_lower == "adam":
|
| 233 |
+
optimizer = torch.optim.Adam(parameters, **opt_args)
|
| 234 |
+
elif opt_lower == "adamw":
|
| 235 |
+
optimizer = torch.optim.AdamW(parameters, **opt_args)
|
| 236 |
+
elif opt_lower == "adadelta":
|
| 237 |
+
optimizer = torch.optim.Adadelta(parameters, **opt_args)
|
| 238 |
+
elif opt_lower == "radam":
|
| 239 |
+
optimizer = torch.optim.RAdam(parameters, **opt_args)
|
| 240 |
+
elif opt_lower == "sgd":
|
| 241 |
+
opt_args["momentum"] = 0.9
|
| 242 |
+
return torch.optim.SGD(parameters, **opt_args)
|
| 243 |
+
else:
|
| 244 |
+
assert False and "Invalid optimizer"
|
| 245 |
+
|
| 246 |
+
return optimizer
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def load_checkpoint(checkpoint_fpath, model, optimizer, just_model=False):
|
| 250 |
+
checkpoint = torch.load(checkpoint_fpath, weights_only=False)
|
| 251 |
+
model.load_state_dict(checkpoint["model_state_dict"])
|
| 252 |
+
if just_model:
|
| 253 |
+
return model
|
| 254 |
+
else:
|
| 255 |
+
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
|
| 256 |
+
return (
|
| 257 |
+
model,
|
| 258 |
+
optimizer,
|
| 259 |
+
checkpoint["epoch"],
|
| 260 |
+
checkpoint["best_loss"],
|
| 261 |
+
checkpoint["run_id"],
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def save_checkpoint(checkpoint_fpath, model, optimizer, epoch, best_loss, run_id):
|
| 266 |
+
torch.save(
|
| 267 |
+
{
|
| 268 |
+
"epoch": epoch,
|
| 269 |
+
"model_state_dict": model.state_dict(),
|
| 270 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 271 |
+
"best_loss": best_loss,
|
| 272 |
+
"run_id": run_id,
|
| 273 |
+
},
|
| 274 |
+
checkpoint_fpath,
|
| 275 |
+
)
|
| 276 |
+
return
|
models/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .edcoder import PreModel
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def build_model(args):
|
| 5 |
+
num_heads = args.num_heads
|
| 6 |
+
num_out_heads = args.num_out_heads
|
| 7 |
+
num_hidden = args.num_hidden
|
| 8 |
+
num_layers = args.num_layers
|
| 9 |
+
residual = args.residual
|
| 10 |
+
attn_drop = args.attn_drop
|
| 11 |
+
in_drop = args.in_drop
|
| 12 |
+
norm = args.norm
|
| 13 |
+
negative_slope = args.negative_slope
|
| 14 |
+
encoder_type = args.encoder
|
| 15 |
+
decoder_type = args.decoder
|
| 16 |
+
mask_rate = args.mask_rate
|
| 17 |
+
drop_edge_rate = args.drop_edge_rate
|
| 18 |
+
replace_rate = args.replace_rate
|
| 19 |
+
batchnorm = args.batchnorm
|
| 20 |
+
# Encoder between-layer norm: none | layer | graph. Back-compat: the older
|
| 21 |
+
# boolean --encoder_layernorm maps to "layer" when --encoder_norm is unset.
|
| 22 |
+
encoder_norm = getattr(args, "encoder_norm", "none")
|
| 23 |
+
if encoder_norm == "none" and getattr(args, "encoder_layernorm", False):
|
| 24 |
+
encoder_norm = "layer"
|
| 25 |
+
# Learnable normalization of the projected input embedding (encoder stem).
|
| 26 |
+
encoder_input_norm = getattr(args, "input_norm", "none")
|
| 27 |
+
# When False, the edge distance (col 0) is used only as the aggregation
|
| 28 |
+
# weight, not fed into the edge feature projection.
|
| 29 |
+
edge_distance_in_proj = getattr(args, "edge_distance_in_proj", True)
|
| 30 |
+
# VICReg-style anti-collapse regularizer weights (0 = off). Applied on the
|
| 31 |
+
# graph-level pooled embedding during training. Absent on eval-time args
|
| 32 |
+
# (generate_embs) -> getattr defaults keep those code paths unaffected.
|
| 33 |
+
vicreg_var_weight = getattr(args, "vicreg_var_weight", 0.0)
|
| 34 |
+
vicreg_cov_weight = getattr(args, "vicreg_cov_weight", 0.0)
|
| 35 |
+
vicreg_gamma = getattr(args, "vicreg_gamma", 1.0)
|
| 36 |
+
|
| 37 |
+
activation = args.activation
|
| 38 |
+
loss_fn = args.loss_fn
|
| 39 |
+
alpha_l = args.alpha_l
|
| 40 |
+
concat_hidden = args.concat_hidden
|
| 41 |
+
num_features = args.num_features
|
| 42 |
+
num_edge_features = args.num_edge_features
|
| 43 |
+
|
| 44 |
+
model = PreModel(
|
| 45 |
+
in_dim=int(num_features),
|
| 46 |
+
edge_in_dim=int(num_edge_features),
|
| 47 |
+
num_hidden=int(num_hidden),
|
| 48 |
+
num_layers=num_layers,
|
| 49 |
+
nhead=num_heads,
|
| 50 |
+
nhead_out=num_out_heads,
|
| 51 |
+
activation=activation,
|
| 52 |
+
feat_drop=in_drop,
|
| 53 |
+
attn_drop=attn_drop,
|
| 54 |
+
negative_slope=negative_slope,
|
| 55 |
+
residual=residual,
|
| 56 |
+
encoder_type=encoder_type,
|
| 57 |
+
decoder_type=decoder_type,
|
| 58 |
+
mask_rate=mask_rate,
|
| 59 |
+
norm=norm,
|
| 60 |
+
loss_fn=loss_fn,
|
| 61 |
+
drop_edge_rate=drop_edge_rate,
|
| 62 |
+
replace_rate=replace_rate,
|
| 63 |
+
alpha_l=alpha_l,
|
| 64 |
+
concat_hidden=concat_hidden,
|
| 65 |
+
batchnorm=batchnorm,
|
| 66 |
+
encoder_norm=encoder_norm,
|
| 67 |
+
encoder_input_norm=encoder_input_norm,
|
| 68 |
+
edge_distance_in_proj=edge_distance_in_proj,
|
| 69 |
+
vicreg_var_weight=vicreg_var_weight,
|
| 70 |
+
vicreg_cov_weight=vicreg_cov_weight,
|
| 71 |
+
vicreg_gamma=vicreg_gamma,
|
| 72 |
+
)
|
| 73 |
+
return model
|
models/acm_gin.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
import torch.nn.functional as F
|
| 4 |
+
|
| 5 |
+
from torch import Tensor
|
| 6 |
+
from typing import Union
|
| 7 |
+
from torch_geometric.nn.conv import MessagePassing
|
| 8 |
+
from torch_geometric.nn.inits import reset
|
| 9 |
+
from torch_geometric.typing import OptPairTensor, Size
|
| 10 |
+
from torch_geometric.utils import scatter
|
| 11 |
+
|
| 12 |
+
from .utils import create_activation
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ACM_GIN(MessagePassing):
|
| 16 |
+
"""Single ACM-GIN convolution layer with edge-aware message passing.
|
| 17 |
+
|
| 18 |
+
The message from node j to node i incorporates the intermediate edge
|
| 19 |
+
feature e_ij_prime (precomputed by the outer model) alongside the
|
| 20 |
+
scalar spatial weight a_ij used for degree normalization:
|
| 21 |
+
|
| 22 |
+
m_ij = a_ij * ReLU(H_j + e_ij_prime)
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
nn_lowpass: torch.nn.Module,
|
| 28 |
+
nn_highpass: torch.nn.Module,
|
| 29 |
+
nn_fullpass: torch.nn.Module,
|
| 30 |
+
nn_lowpass_proj: torch.nn.Module,
|
| 31 |
+
nn_highpass_proj: torch.nn.Module,
|
| 32 |
+
nn_fullpass_proj: torch.nn.Module,
|
| 33 |
+
nn_mix: torch.nn.Module,
|
| 34 |
+
T: float = 3.0,
|
| 35 |
+
**kwargs,
|
| 36 |
+
):
|
| 37 |
+
kwargs.setdefault("aggr", "add")
|
| 38 |
+
super().__init__(**kwargs)
|
| 39 |
+
self.nn_lowpass = nn_lowpass
|
| 40 |
+
self.nn_highpass = nn_highpass
|
| 41 |
+
self.nn_fullpass = nn_fullpass
|
| 42 |
+
self.nn_lowpass_proj = nn_lowpass_proj
|
| 43 |
+
self.nn_highpass_proj = nn_highpass_proj
|
| 44 |
+
self.nn_fullpass_proj = nn_fullpass_proj
|
| 45 |
+
self.nn_mix = nn_mix
|
| 46 |
+
self.sigmoid = torch.nn.Sigmoid()
|
| 47 |
+
self.softmax = torch.nn.Softmax(dim=1)
|
| 48 |
+
self.T = T
|
| 49 |
+
self.reset_parameters()
|
| 50 |
+
|
| 51 |
+
def reset_parameters(self):
|
| 52 |
+
reset(self.nn_lowpass)
|
| 53 |
+
reset(self.nn_highpass)
|
| 54 |
+
reset(self.nn_fullpass)
|
| 55 |
+
reset(self.nn_lowpass_proj)
|
| 56 |
+
reset(self.nn_highpass_proj)
|
| 57 |
+
reset(self.nn_fullpass_proj)
|
| 58 |
+
reset(self.nn_mix)
|
| 59 |
+
|
| 60 |
+
def forward(
|
| 61 |
+
self,
|
| 62 |
+
x: Union[Tensor, OptPairTensor],
|
| 63 |
+
edge_index: Tensor,
|
| 64 |
+
edge_weight: Tensor,
|
| 65 |
+
edge_feat: Tensor,
|
| 66 |
+
size: Size = None,
|
| 67 |
+
) -> Tensor:
|
| 68 |
+
"""Forward pass of a single ACM-GIN layer.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
x: Node features [N, hidden_dim] or (x_src, x_dst) pair.
|
| 72 |
+
edge_index: Edge indices [2, E].
|
| 73 |
+
edge_weight: Scalar spatial distance per edge [E] (first column
|
| 74 |
+
of the original edge_attr, used for degree normalization).
|
| 75 |
+
edge_feat: Intermediate edge features [E, hidden_dim], i.e.
|
| 76 |
+
e_ij_prime precomputed by the edge MLP in the outer model.
|
| 77 |
+
size: Optional bipartite graph size.
|
| 78 |
+
"""
|
| 79 |
+
if isinstance(x, Tensor):
|
| 80 |
+
x: OptPairTensor = (x, x)
|
| 81 |
+
|
| 82 |
+
# propagate_type: (x: OptPairTensor, edge_weight: Tensor, edge_feat: Tensor)
|
| 83 |
+
out = self.propagate(
|
| 84 |
+
edge_index, x=x, edge_weight=edge_weight, edge_feat=edge_feat, size=size
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# TODO: This computes degree as the sum of edge weights, not the count of
|
| 88 |
+
# neighbors. Consider whether unweighted degree (standard GIN) would be more
|
| 89 |
+
# appropriate, especially if edge weights can sum to zero for some nodes.
|
| 90 |
+
deg = scatter(edge_weight, edge_index[1], 0, out.size(0), reduce="sum")
|
| 91 |
+
deg_inv = 1.0 / deg
|
| 92 |
+
deg_inv.masked_fill_(deg_inv == float("inf"), 0)
|
| 93 |
+
out = deg_inv.view(-1, 1) * out
|
| 94 |
+
|
| 95 |
+
x_r = x[1]
|
| 96 |
+
assert x_r is not None, (
|
| 97 |
+
"Target node features (x_r) must not be None for ACM_GIN"
|
| 98 |
+
)
|
| 99 |
+
out_lowpass = (x_r + out) / 2.0
|
| 100 |
+
out_highpass = (x_r - out) / 2.0
|
| 101 |
+
|
| 102 |
+
# compute embeddings for each filter
|
| 103 |
+
out_lowpass = self.nn_lowpass(out_lowpass)
|
| 104 |
+
out_highpass = self.nn_highpass(out_highpass)
|
| 105 |
+
out_fullpass = self.nn_fullpass(x_r)
|
| 106 |
+
# compute importance weights per filter
|
| 107 |
+
alpha_lowpass = self.sigmoid(self.nn_lowpass_proj(out_lowpass))
|
| 108 |
+
alpha_highpass = self.sigmoid(self.nn_highpass_proj(out_highpass))
|
| 109 |
+
alpha_fullpass = self.sigmoid(self.nn_fullpass_proj(out_fullpass))
|
| 110 |
+
alpha_cat = torch.concat([alpha_lowpass, alpha_highpass, alpha_fullpass], dim=1)
|
| 111 |
+
alpha_cat = self.softmax(self.nn_mix(alpha_cat / self.T))
|
| 112 |
+
|
| 113 |
+
out = alpha_cat[:, 0].view(-1, 1) * out_lowpass
|
| 114 |
+
out = out + alpha_cat[:, 1].view(-1, 1) * out_highpass
|
| 115 |
+
out = out + alpha_cat[:, 2].view(-1, 1) * out_fullpass
|
| 116 |
+
|
| 117 |
+
return out
|
| 118 |
+
|
| 119 |
+
def message(self, x_j: Tensor, edge_weight: Tensor, edge_feat: Tensor) -> Tensor:
|
| 120 |
+
"""Edge-aware message: m_ij = a_ij * ReLU(H_j + e_ij_prime)."""
|
| 121 |
+
return edge_weight.view(-1, 1) * F.relu(x_j + edge_feat)
|
| 122 |
+
|
| 123 |
+
def __repr__(self) -> str:
|
| 124 |
+
return (
|
| 125 |
+
f"{self.__class__.__name__}("
|
| 126 |
+
f"nn_lowpass={self.nn_lowpass}, "
|
| 127 |
+
f"nn_highpass={self.nn_highpass}, "
|
| 128 |
+
f"nn_fullpass={self.nn_fullpass})"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class ACM_GIN_model(nn.Module):
|
| 133 |
+
"""Multi-layer ACM-GIN model with edge-aware message passing.
|
| 134 |
+
|
| 135 |
+
Both node and edge features are projected into hidden_dim at the start
|
| 136 |
+
(via ``node_input_proj`` and ``edge_input_proj``). This ensures
|
| 137 |
+
uniform dimensions throughout, so every edge MLP receives 3 * hidden_dim
|
| 138 |
+
and the edge residual connection is valid from layer 0 onward.
|
| 139 |
+
|
| 140 |
+
At each layer k the model:
|
| 141 |
+
1. Computes intermediate edge features via an edge MLP:
|
| 142 |
+
e_ij_prime = MLP_edge(H_i || H_j || E_ij)
|
| 143 |
+
2. Updates edge state with a residual connection:
|
| 144 |
+
E_ij^(k) = E_ij^(k-1) + e_ij_prime
|
| 145 |
+
3. Passes messages using the scalar spatial weight and the
|
| 146 |
+
intermediate edge features:
|
| 147 |
+
m_ij = a_ij * ReLU(H_j + e_ij_prime)
|
| 148 |
+
4. Applies ACM channel mixing (low/high/full-pass) on the
|
| 149 |
+
aggregated messages.
|
| 150 |
+
"""
|
| 151 |
+
|
| 152 |
+
def __init__(
|
| 153 |
+
self,
|
| 154 |
+
in_dim,
|
| 155 |
+
out_dim,
|
| 156 |
+
num_layers,
|
| 157 |
+
hidden_dim,
|
| 158 |
+
edge_in_dim,
|
| 159 |
+
batchnorm,
|
| 160 |
+
activation="relu",
|
| 161 |
+
):
|
| 162 |
+
super(ACM_GIN_model, self).__init__()
|
| 163 |
+
self.num_layers = num_layers
|
| 164 |
+
self.hidden_dim = hidden_dim
|
| 165 |
+
self.gnn_batchnorm = batchnorm
|
| 166 |
+
self.out_dim = out_dim
|
| 167 |
+
|
| 168 |
+
# Project raw node and edge features into hidden_dim so that both
|
| 169 |
+
# live in the same space from the very start. This enables the
|
| 170 |
+
# edge residual connection at every layer (including layer 0) and
|
| 171 |
+
# keeps all edge MLP input dimensions uniform at 3 * hidden_dim.
|
| 172 |
+
self.node_input_proj = nn.Linear(in_dim, hidden_dim)
|
| 173 |
+
self.edge_input_proj = nn.Linear(edge_in_dim, hidden_dim)
|
| 174 |
+
|
| 175 |
+
self.ACM_convs = nn.ModuleList()
|
| 176 |
+
self.nns_lowpass = nn.ModuleList()
|
| 177 |
+
self.nns_highpass = nn.ModuleList()
|
| 178 |
+
self.nns_fullpass = nn.ModuleList()
|
| 179 |
+
self.nns_lowpass_proj = nn.ModuleList()
|
| 180 |
+
self.nns_highpass_proj = nn.ModuleList()
|
| 181 |
+
self.nns_fullpass_proj = nn.ModuleList()
|
| 182 |
+
self.nns_mix = nn.ModuleList()
|
| 183 |
+
self.edge_mlps = nn.ModuleList()
|
| 184 |
+
|
| 185 |
+
self.activation_name = activation
|
| 186 |
+
|
| 187 |
+
for i in range(self.num_layers):
|
| 188 |
+
# --- Edge MLP for this layer ---
|
| 189 |
+
# Both nodes and edges have been projected to hidden_dim before
|
| 190 |
+
# the loop, so the input is always (src + dst + edge_state) =
|
| 191 |
+
# 3 * hidden_dim for every layer.
|
| 192 |
+
edge_mlp_in = 3 * hidden_dim
|
| 193 |
+
|
| 194 |
+
if self.gnn_batchnorm:
|
| 195 |
+
self.edge_mlps.append(
|
| 196 |
+
nn.Sequential(
|
| 197 |
+
nn.Linear(edge_mlp_in, hidden_dim),
|
| 198 |
+
nn.BatchNorm1d(hidden_dim),
|
| 199 |
+
create_activation(activation),
|
| 200 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 201 |
+
nn.BatchNorm1d(hidden_dim),
|
| 202 |
+
create_activation(activation),
|
| 203 |
+
)
|
| 204 |
+
)
|
| 205 |
+
else:
|
| 206 |
+
self.edge_mlps.append(
|
| 207 |
+
nn.Sequential(
|
| 208 |
+
nn.Linear(edge_mlp_in, hidden_dim),
|
| 209 |
+
create_activation(activation),
|
| 210 |
+
nn.Linear(hidden_dim, hidden_dim),
|
| 211 |
+
create_activation(activation),
|
| 212 |
+
)
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
# --- Projection modules to compute importance weights ---
|
| 216 |
+
for channel_proj_module in [
|
| 217 |
+
self.nns_lowpass_proj,
|
| 218 |
+
self.nns_highpass_proj,
|
| 219 |
+
self.nns_fullpass_proj,
|
| 220 |
+
]:
|
| 221 |
+
if i == self.num_layers - 1:
|
| 222 |
+
channel_proj_module.append(nn.Linear(self.out_dim, 1))
|
| 223 |
+
else:
|
| 224 |
+
channel_proj_module.append(nn.Linear(self.hidden_dim, 1))
|
| 225 |
+
|
| 226 |
+
# --- Weights mixing module as attention mechanism ---
|
| 227 |
+
self.nns_mix.append(nn.Linear(3, 3))
|
| 228 |
+
|
| 229 |
+
# --- GIN channel MLPs ---
|
| 230 |
+
# After node_input_proj, all nodes are hidden_dim, so
|
| 231 |
+
# local_input_dim is always hidden_dim.
|
| 232 |
+
local_input_dim = self.hidden_dim
|
| 233 |
+
|
| 234 |
+
if i == self.num_layers - 1:
|
| 235 |
+
local_out_dim = self.out_dim
|
| 236 |
+
else:
|
| 237 |
+
local_out_dim = self.hidden_dim
|
| 238 |
+
|
| 239 |
+
for channel_module in [
|
| 240 |
+
self.nns_lowpass,
|
| 241 |
+
self.nns_highpass,
|
| 242 |
+
self.nns_fullpass,
|
| 243 |
+
]:
|
| 244 |
+
if self.gnn_batchnorm:
|
| 245 |
+
sequential = nn.Sequential(
|
| 246 |
+
nn.Linear(local_input_dim, self.hidden_dim),
|
| 247 |
+
nn.BatchNorm1d(self.hidden_dim),
|
| 248 |
+
create_activation(self.activation_name),
|
| 249 |
+
nn.Linear(self.hidden_dim, local_out_dim),
|
| 250 |
+
nn.BatchNorm1d(local_out_dim),
|
| 251 |
+
create_activation(self.activation_name),
|
| 252 |
+
)
|
| 253 |
+
else:
|
| 254 |
+
sequential = nn.Sequential(
|
| 255 |
+
nn.Linear(local_input_dim, self.hidden_dim),
|
| 256 |
+
create_activation(self.activation_name),
|
| 257 |
+
nn.Linear(self.hidden_dim, local_out_dim),
|
| 258 |
+
create_activation(self.activation_name),
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
channel_module.append(sequential)
|
| 262 |
+
|
| 263 |
+
self.ACM_convs.append(
|
| 264 |
+
ACM_GIN(
|
| 265 |
+
nn_lowpass=self.nns_lowpass[i],
|
| 266 |
+
nn_highpass=self.nns_highpass[i],
|
| 267 |
+
nn_fullpass=self.nns_fullpass[i],
|
| 268 |
+
nn_lowpass_proj=self.nns_lowpass_proj[i],
|
| 269 |
+
nn_highpass_proj=self.nns_highpass_proj[i],
|
| 270 |
+
nn_fullpass_proj=self.nns_fullpass_proj[i],
|
| 271 |
+
nn_mix=self.nns_mix[i],
|
| 272 |
+
)
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
def reset_parameters(self):
|
| 276 |
+
for m in self.modules():
|
| 277 |
+
if isinstance(m, nn.Linear):
|
| 278 |
+
m.reset_parameters()
|
| 279 |
+
elif isinstance(m, nn.BatchNorm1d):
|
| 280 |
+
m.reset_parameters()
|
| 281 |
+
|
| 282 |
+
def forward(self, x, edge_index, edge_attr, batch=None, return_hidden=False):
|
| 283 |
+
"""Forward pass through all ACM-GIN layers with edge updates.
|
| 284 |
+
|
| 285 |
+
`batch` is accepted for API parity with ACM_GINEConv_model (GraphNorm);
|
| 286 |
+
it is unused here.
|
| 287 |
+
|
| 288 |
+
Args:
|
| 289 |
+
x: Node features [N, in_dim].
|
| 290 |
+
edge_index: Edge indices [2, E].
|
| 291 |
+
edge_attr: Edge features [E, edge_in_dim]. The first column
|
| 292 |
+
(index 0) is the scalar spatial distance used for degree
|
| 293 |
+
normalization; the full vector evolves through layers via
|
| 294 |
+
the edge MLPs.
|
| 295 |
+
return_hidden: If True, also return all intermediate node states.
|
| 296 |
+
|
| 297 |
+
Returns:
|
| 298 |
+
x: Final node embeddings [N, out_dim].
|
| 299 |
+
outs: (optional) List of node states after each layer.
|
| 300 |
+
"""
|
| 301 |
+
# Extract scalar spatial distance for degree normalization
|
| 302 |
+
# (stays fixed across layers)
|
| 303 |
+
edge_weight = edge_attr[:, 0]
|
| 304 |
+
|
| 305 |
+
# Project node and edge features from raw dims to hidden_dim
|
| 306 |
+
x = self.node_input_proj(x)
|
| 307 |
+
edge_state = self.edge_input_proj(edge_attr)
|
| 308 |
+
|
| 309 |
+
outs = []
|
| 310 |
+
for i in range(self.num_layers):
|
| 311 |
+
# Step 1: Compute intermediate edge features
|
| 312 |
+
src, dst = edge_index
|
| 313 |
+
edge_mlp_input = torch.cat([x[src], x[dst], edge_state], dim=-1)
|
| 314 |
+
e_ij_prime = self.edge_mlps[i](edge_mlp_input)
|
| 315 |
+
|
| 316 |
+
# Step 2: Update edge state with residual (safe at every layer
|
| 317 |
+
# because both edge_state and e_ij_prime are hidden_dim)
|
| 318 |
+
edge_state = edge_state + e_ij_prime
|
| 319 |
+
|
| 320 |
+
# Step 3-4: Edge-aware ACM message passing
|
| 321 |
+
x = self.ACM_convs[i](
|
| 322 |
+
x=x,
|
| 323 |
+
edge_index=edge_index,
|
| 324 |
+
edge_weight=edge_weight,
|
| 325 |
+
edge_feat=e_ij_prime,
|
| 326 |
+
)
|
| 327 |
+
outs.append(x)
|
| 328 |
+
|
| 329 |
+
if return_hidden:
|
| 330 |
+
return x, outs
|
| 331 |
+
else:
|
| 332 |
+
return x
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
if __name__ == "__main__":
|
| 336 |
+
acm_gin = ACM_GIN_model(46, 46, 2, 256, 74, True)
|
| 337 |
+
print(sum(p.numel() for p in acm_gin.parameters() if p.requires_grad))
|
models/acm_gineconv.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
import torch.nn.functional as F
|
| 4 |
+
|
| 5 |
+
from torch import Tensor
|
| 6 |
+
from typing import Union
|
| 7 |
+
from torch_geometric.nn.conv import MessagePassing
|
| 8 |
+
from torch_geometric.nn.inits import reset
|
| 9 |
+
from torch_geometric.typing import OptPairTensor, Size
|
| 10 |
+
from torch_geometric.utils import scatter
|
| 11 |
+
|
| 12 |
+
from .utils import create_activation
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class GraphNorm(nn.Module):
|
| 16 |
+
"""GraphNorm (Cai et al., ICML 2021): per-graph normalization with a
|
| 17 |
+
learnable mean-shift, applied to node states between GNN layers.
|
| 18 |
+
|
| 19 |
+
Faithful to the official implementation (lsj2408/GraphNorm):
|
| 20 |
+
sub = x - alpha * mean_g(x) # per-graph mean, learnable shift
|
| 21 |
+
out = gamma * sub / sqrt(mean_g(sub^2) + eps) + beta
|
| 22 |
+
|
| 23 |
+
where mean_g is computed within each graph of the batch. Unlike BatchNorm
|
| 24 |
+
(cross-graph batch statistics -> noisy on heterogeneous graphs), GraphNorm
|
| 25 |
+
normalizes each graph independently, which is the principled choice for
|
| 26 |
+
batches of heterogeneous cell-graphs.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(self, dim: int, eps: float = 1e-5):
|
| 30 |
+
super().__init__()
|
| 31 |
+
self.dim = dim
|
| 32 |
+
self.eps = eps
|
| 33 |
+
self.gamma = nn.Parameter(torch.ones(dim)) # scale (weight)
|
| 34 |
+
self.beta = nn.Parameter(torch.zeros(dim)) # shift (bias)
|
| 35 |
+
self.alpha = nn.Parameter(torch.ones(dim)) # learnable mean-shift (mean_scale)
|
| 36 |
+
|
| 37 |
+
def reset_parameters(self):
|
| 38 |
+
nn.init.ones_(self.gamma)
|
| 39 |
+
nn.init.zeros_(self.beta)
|
| 40 |
+
nn.init.ones_(self.alpha)
|
| 41 |
+
|
| 42 |
+
def forward(self, x: Tensor, batch: Tensor = None) -> Tensor:
|
| 43 |
+
if batch is None:
|
| 44 |
+
batch = torch.zeros(x.size(0), dtype=torch.long, device=x.device)
|
| 45 |
+
num_graphs = int(batch.max().item()) + 1
|
| 46 |
+
# Per-graph mean, broadcast back to nodes.
|
| 47 |
+
mean = scatter(x, batch, 0, num_graphs, reduce="mean")[batch]
|
| 48 |
+
sub = x - self.alpha * mean
|
| 49 |
+
var = scatter(sub * sub, batch, 0, num_graphs, reduce="mean")[batch]
|
| 50 |
+
std = (var + self.eps).sqrt()
|
| 51 |
+
return self.gamma * sub / std + self.beta
|
| 52 |
+
|
| 53 |
+
def __repr__(self) -> str:
|
| 54 |
+
return f"{self.__class__.__name__}(dim={self.dim})"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class ACM_GINEConv(MessagePassing):
|
| 58 |
+
"""Single ACM-GINEConv layer: GINEConv-style message passing + ACM filtering.
|
| 59 |
+
|
| 60 |
+
Messages incorporate static edge features via addition before ReLU
|
| 61 |
+
(following the GINEConv formulation), then aggregated messages are
|
| 62 |
+
decomposed into low-pass, high-pass, and identity channels with
|
| 63 |
+
learned adaptive mixing.
|
| 64 |
+
|
| 65 |
+
Message: m_ij = a_ij * ReLU(H_j + E_ji)
|
| 66 |
+
where E_ji are static encoded edge features (no per-layer edge MLP).
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
def __init__(
|
| 70 |
+
self,
|
| 71 |
+
nn_lowpass: torch.nn.Module,
|
| 72 |
+
nn_highpass: torch.nn.Module,
|
| 73 |
+
nn_fullpass: torch.nn.Module,
|
| 74 |
+
nn_lowpass_proj: torch.nn.Module,
|
| 75 |
+
nn_highpass_proj: torch.nn.Module,
|
| 76 |
+
nn_fullpass_proj: torch.nn.Module,
|
| 77 |
+
nn_mix: torch.nn.Module,
|
| 78 |
+
T: float = 3.0,
|
| 79 |
+
**kwargs,
|
| 80 |
+
):
|
| 81 |
+
kwargs.setdefault("aggr", "add")
|
| 82 |
+
super().__init__(**kwargs)
|
| 83 |
+
self.nn_lowpass = nn_lowpass
|
| 84 |
+
self.nn_highpass = nn_highpass
|
| 85 |
+
self.nn_fullpass = nn_fullpass
|
| 86 |
+
self.nn_lowpass_proj = nn_lowpass_proj
|
| 87 |
+
self.nn_highpass_proj = nn_highpass_proj
|
| 88 |
+
self.nn_fullpass_proj = nn_fullpass_proj
|
| 89 |
+
self.nn_mix = nn_mix
|
| 90 |
+
self.sigmoid = torch.nn.Sigmoid()
|
| 91 |
+
self.softmax = torch.nn.Softmax(dim=1)
|
| 92 |
+
self.T = T
|
| 93 |
+
self.reset_parameters()
|
| 94 |
+
|
| 95 |
+
def reset_parameters(self):
|
| 96 |
+
reset(self.nn_lowpass)
|
| 97 |
+
reset(self.nn_highpass)
|
| 98 |
+
reset(self.nn_fullpass)
|
| 99 |
+
reset(self.nn_lowpass_proj)
|
| 100 |
+
reset(self.nn_highpass_proj)
|
| 101 |
+
reset(self.nn_fullpass_proj)
|
| 102 |
+
reset(self.nn_mix)
|
| 103 |
+
|
| 104 |
+
def forward(
|
| 105 |
+
self,
|
| 106 |
+
x: Union[Tensor, OptPairTensor],
|
| 107 |
+
edge_index: Tensor,
|
| 108 |
+
edge_weight: Tensor,
|
| 109 |
+
edge_feat: Tensor,
|
| 110 |
+
size: Size = None,
|
| 111 |
+
) -> Tensor:
|
| 112 |
+
"""Forward pass of a single ACM-GINEConv layer.
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
x: Node features [N, hidden_dim] or (x_src, x_dst) pair.
|
| 116 |
+
edge_index: Edge indices [2, E].
|
| 117 |
+
edge_weight: Scalar spatial distance per edge [E] (used for
|
| 118 |
+
degree normalization).
|
| 119 |
+
edge_feat: Static edge features [E, hidden_dim] encoded once
|
| 120 |
+
at the start; same features reused at every layer.
|
| 121 |
+
size: Optional bipartite graph size.
|
| 122 |
+
"""
|
| 123 |
+
if isinstance(x, Tensor):
|
| 124 |
+
x: OptPairTensor = (x, x)
|
| 125 |
+
|
| 126 |
+
# propagate_type: (x: OptPairTensor, edge_weight: Tensor, edge_feat: Tensor)
|
| 127 |
+
out = self.propagate(
|
| 128 |
+
edge_index, x=x, edge_weight=edge_weight, edge_feat=edge_feat, size=size
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
# Degree normalization using sum of edge weights
|
| 132 |
+
deg = scatter(edge_weight, edge_index[1], 0, out.size(0), reduce="sum")
|
| 133 |
+
deg_inv = 1.0 / deg
|
| 134 |
+
deg_inv.masked_fill_(deg_inv == float("inf"), 0)
|
| 135 |
+
out = deg_inv.view(-1, 1) * out
|
| 136 |
+
|
| 137 |
+
x_r = x[1]
|
| 138 |
+
assert x_r is not None, (
|
| 139 |
+
"Target node features (x_r) must not be None for ACM_GINEConv"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# ACM frequency decomposition
|
| 143 |
+
out_lowpass = (x_r + out) / 2.0
|
| 144 |
+
out_highpass = (x_r - out) / 2.0
|
| 145 |
+
|
| 146 |
+
# Compute embeddings for each filter
|
| 147 |
+
out_lowpass = self.nn_lowpass(out_lowpass)
|
| 148 |
+
out_highpass = self.nn_highpass(out_highpass)
|
| 149 |
+
out_fullpass = self.nn_fullpass(x_r)
|
| 150 |
+
|
| 151 |
+
# Compute importance weights per filter
|
| 152 |
+
alpha_lowpass = self.sigmoid(self.nn_lowpass_proj(out_lowpass))
|
| 153 |
+
alpha_highpass = self.sigmoid(self.nn_highpass_proj(out_highpass))
|
| 154 |
+
alpha_fullpass = self.sigmoid(self.nn_fullpass_proj(out_fullpass))
|
| 155 |
+
alpha_cat = torch.concat([alpha_lowpass, alpha_highpass, alpha_fullpass], dim=1)
|
| 156 |
+
alpha_cat = self.softmax(self.nn_mix(alpha_cat / self.T))
|
| 157 |
+
|
| 158 |
+
# Adaptive mixing
|
| 159 |
+
out = alpha_cat[:, 0].view(-1, 1) * out_lowpass
|
| 160 |
+
out = out + alpha_cat[:, 1].view(-1, 1) * out_highpass
|
| 161 |
+
out = out + alpha_cat[:, 2].view(-1, 1) * out_fullpass
|
| 162 |
+
|
| 163 |
+
return out
|
| 164 |
+
|
| 165 |
+
def message(self, x_j: Tensor, edge_weight: Tensor, edge_feat: Tensor) -> Tensor:
|
| 166 |
+
"""GINEConv-style message: m_ij = a_ij * ReLU(H_j + E_ji)."""
|
| 167 |
+
return edge_weight.view(-1, 1) * F.relu(x_j + edge_feat)
|
| 168 |
+
|
| 169 |
+
def __repr__(self) -> str:
|
| 170 |
+
return (
|
| 171 |
+
f"{self.__class__.__name__}("
|
| 172 |
+
f"nn_lowpass={self.nn_lowpass}, "
|
| 173 |
+
f"nn_highpass={self.nn_highpass}, "
|
| 174 |
+
f"nn_fullpass={self.nn_fullpass})"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
class ACM_GINEConv_model(nn.Module):
|
| 179 |
+
"""Multi-layer ACM-GINEConv model with static edge feature integration.
|
| 180 |
+
|
| 181 |
+
Unlike ACM_GIN_model (which learns per-layer edge MLPs), this model
|
| 182 |
+
encodes edge features once via a linear projection and reuses them
|
| 183 |
+
at every GNN layer. This follows the GINEConv philosophy:
|
| 184 |
+
- Edge features are incorporated into messages via ReLU(H_j + E_ji)
|
| 185 |
+
- No per-layer edge update MLP (significant memory savings)
|
| 186 |
+
- ACM filtering and adaptive mixing remain unchanged
|
| 187 |
+
|
| 188 |
+
This is a simpler, more memory-efficient alternative suitable for
|
| 189 |
+
pretraining on a single GPU where the full edge-update model may OOM.
|
| 190 |
+
"""
|
| 191 |
+
|
| 192 |
+
def __init__(
|
| 193 |
+
self,
|
| 194 |
+
in_dim,
|
| 195 |
+
out_dim,
|
| 196 |
+
num_layers,
|
| 197 |
+
hidden_dim,
|
| 198 |
+
edge_in_dim,
|
| 199 |
+
batchnorm,
|
| 200 |
+
activation="relu",
|
| 201 |
+
norm_type="none",
|
| 202 |
+
input_norm="none",
|
| 203 |
+
edge_distance_in_proj=True,
|
| 204 |
+
):
|
| 205 |
+
super(ACM_GINEConv_model, self).__init__()
|
| 206 |
+
self.num_layers = num_layers
|
| 207 |
+
self.hidden_dim = hidden_dim
|
| 208 |
+
self.gnn_batchnorm = batchnorm
|
| 209 |
+
if norm_type not in ("none", "layer", "graph"):
|
| 210 |
+
raise ValueError(f"Unknown norm_type {norm_type!r}; expected none/layer/graph")
|
| 211 |
+
self.norm_type = norm_type
|
| 212 |
+
if input_norm not in ("none", "batch", "layer"):
|
| 213 |
+
raise ValueError(f"Unknown input_norm {input_norm!r}; expected none/batch/layer")
|
| 214 |
+
self.input_norm_type = input_norm
|
| 215 |
+
self.out_dim = out_dim
|
| 216 |
+
|
| 217 |
+
# Column 0 of edge_attr is the spatial distance. When False, exclude it
|
| 218 |
+
# from the edge feature projection: the distance is then used ONLY as the
|
| 219 |
+
# per-edge aggregation weight a_ij (and degree norm), not mixed into the
|
| 220 |
+
# additive edge feature E_ji. This also removes the raw-distance scale
|
| 221 |
+
# from the projection input.
|
| 222 |
+
self.edge_distance_in_proj = edge_distance_in_proj
|
| 223 |
+
edge_proj_in = edge_in_dim if edge_distance_in_proj else edge_in_dim - 1
|
| 224 |
+
|
| 225 |
+
# Project raw node and edge features into hidden_dim once.
|
| 226 |
+
# Edge features remain static after this projection.
|
| 227 |
+
self.node_input_proj = nn.Linear(in_dim, hidden_dim)
|
| 228 |
+
self.edge_input_proj = nn.Linear(edge_proj_in, hidden_dim)
|
| 229 |
+
|
| 230 |
+
# Optional LEARNABLE normalization of the projected input embedding
|
| 231 |
+
# (applied right after node_input_proj). Makes the input conditioning
|
| 232 |
+
# adaptive; the static json NormalizeData still defines the fixed SCE
|
| 233 |
+
# target. "batch" = online per-feature stats; "layer" = per-node.
|
| 234 |
+
if input_norm == "batch":
|
| 235 |
+
self.input_norm = nn.BatchNorm1d(hidden_dim)
|
| 236 |
+
elif input_norm == "layer":
|
| 237 |
+
self.input_norm = nn.LayerNorm(hidden_dim)
|
| 238 |
+
else:
|
| 239 |
+
self.input_norm = None
|
| 240 |
+
|
| 241 |
+
# Optional per-layer normalization on node states BETWEEN conv layers
|
| 242 |
+
# (the research-conventional spot for LayerNorm/GraphNorm). Anti-collapse
|
| 243 |
+
# measure: stops variance concentrating onto a single PCA direction.
|
| 244 |
+
# "layer" -> nn.LayerNorm (per-node, across features)
|
| 245 |
+
# "graph" -> GraphNorm (per-graph, across nodes; needs batch)
|
| 246 |
+
# (BatchNorm lives inside the channel MLPs via `batchnorm`, the GIN spot.)
|
| 247 |
+
self.layer_norms = nn.ModuleList() if norm_type in ("layer", "graph") else None
|
| 248 |
+
|
| 249 |
+
self.ACM_convs = nn.ModuleList()
|
| 250 |
+
self.nns_lowpass = nn.ModuleList()
|
| 251 |
+
self.nns_highpass = nn.ModuleList()
|
| 252 |
+
self.nns_fullpass = nn.ModuleList()
|
| 253 |
+
self.nns_lowpass_proj = nn.ModuleList()
|
| 254 |
+
self.nns_highpass_proj = nn.ModuleList()
|
| 255 |
+
self.nns_fullpass_proj = nn.ModuleList()
|
| 256 |
+
self.nns_mix = nn.ModuleList()
|
| 257 |
+
|
| 258 |
+
self.activation_name = activation
|
| 259 |
+
|
| 260 |
+
for i in range(self.num_layers):
|
| 261 |
+
# --- Projection modules to compute importance weights ---
|
| 262 |
+
for channel_proj_module in [
|
| 263 |
+
self.nns_lowpass_proj,
|
| 264 |
+
self.nns_highpass_proj,
|
| 265 |
+
self.nns_fullpass_proj,
|
| 266 |
+
]:
|
| 267 |
+
if i == self.num_layers - 1:
|
| 268 |
+
channel_proj_module.append(nn.Linear(self.out_dim, 1))
|
| 269 |
+
else:
|
| 270 |
+
channel_proj_module.append(nn.Linear(self.hidden_dim, 1))
|
| 271 |
+
|
| 272 |
+
# --- Weights mixing module as attention mechanism ---
|
| 273 |
+
self.nns_mix.append(nn.Linear(3, 3))
|
| 274 |
+
|
| 275 |
+
# --- ACM channel MLPs ---
|
| 276 |
+
local_input_dim = self.hidden_dim
|
| 277 |
+
|
| 278 |
+
if i == self.num_layers - 1:
|
| 279 |
+
local_out_dim = self.out_dim
|
| 280 |
+
else:
|
| 281 |
+
local_out_dim = self.hidden_dim
|
| 282 |
+
|
| 283 |
+
for channel_module in [
|
| 284 |
+
self.nns_lowpass,
|
| 285 |
+
self.nns_highpass,
|
| 286 |
+
self.nns_fullpass,
|
| 287 |
+
]:
|
| 288 |
+
if self.gnn_batchnorm:
|
| 289 |
+
sequential = nn.Sequential(
|
| 290 |
+
nn.Linear(local_input_dim, self.hidden_dim),
|
| 291 |
+
nn.BatchNorm1d(self.hidden_dim),
|
| 292 |
+
create_activation(self.activation_name),
|
| 293 |
+
nn.Linear(self.hidden_dim, local_out_dim),
|
| 294 |
+
nn.BatchNorm1d(local_out_dim),
|
| 295 |
+
create_activation(self.activation_name),
|
| 296 |
+
)
|
| 297 |
+
else:
|
| 298 |
+
sequential = nn.Sequential(
|
| 299 |
+
nn.Linear(local_input_dim, self.hidden_dim),
|
| 300 |
+
create_activation(self.activation_name),
|
| 301 |
+
nn.Linear(self.hidden_dim, local_out_dim),
|
| 302 |
+
create_activation(self.activation_name),
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
channel_module.append(sequential)
|
| 306 |
+
|
| 307 |
+
self.ACM_convs.append(
|
| 308 |
+
ACM_GINEConv(
|
| 309 |
+
nn_lowpass=self.nns_lowpass[i],
|
| 310 |
+
nn_highpass=self.nns_highpass[i],
|
| 311 |
+
nn_fullpass=self.nns_fullpass[i],
|
| 312 |
+
nn_lowpass_proj=self.nns_lowpass_proj[i],
|
| 313 |
+
nn_highpass_proj=self.nns_highpass_proj[i],
|
| 314 |
+
nn_fullpass_proj=self.nns_fullpass_proj[i],
|
| 315 |
+
nn_mix=self.nns_mix[i],
|
| 316 |
+
)
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
if self.norm_type == "layer":
|
| 320 |
+
self.layer_norms.append(nn.LayerNorm(local_out_dim))
|
| 321 |
+
elif self.norm_type == "graph":
|
| 322 |
+
self.layer_norms.append(GraphNorm(local_out_dim))
|
| 323 |
+
|
| 324 |
+
def reset_parameters(self):
|
| 325 |
+
for m in self.modules():
|
| 326 |
+
if isinstance(m, nn.Linear):
|
| 327 |
+
m.reset_parameters()
|
| 328 |
+
elif isinstance(m, (nn.BatchNorm1d, nn.LayerNorm, GraphNorm)):
|
| 329 |
+
m.reset_parameters()
|
| 330 |
+
|
| 331 |
+
def forward(self, x, edge_index, edge_attr, batch=None, return_hidden=False):
|
| 332 |
+
"""Forward pass through all ACM-GINEConv layers.
|
| 333 |
+
|
| 334 |
+
Args:
|
| 335 |
+
x: Node features [N, in_dim].
|
| 336 |
+
edge_index: Edge indices [2, E].
|
| 337 |
+
edge_attr: Edge features [E, edge_in_dim]. The first column
|
| 338 |
+
(index 0) is the scalar spatial distance used for degree
|
| 339 |
+
normalization.
|
| 340 |
+
batch: Node->graph assignment [N]; required only when
|
| 341 |
+
norm_type == "graph" (GraphNorm normalizes per graph).
|
| 342 |
+
return_hidden: If True, also return all intermediate node states.
|
| 343 |
+
|
| 344 |
+
Returns:
|
| 345 |
+
x: Final node embeddings [N, out_dim].
|
| 346 |
+
outs: (optional) List of node states after each layer.
|
| 347 |
+
"""
|
| 348 |
+
# Extract scalar spatial distance for degree normalization
|
| 349 |
+
edge_weight = edge_attr[:, 0]
|
| 350 |
+
|
| 351 |
+
# Project node and edge features into hidden_dim ONCE
|
| 352 |
+
x = self.node_input_proj(x)
|
| 353 |
+
if self.input_norm is not None:
|
| 354 |
+
x = self.input_norm(x) # learnable input-embedding normalization
|
| 355 |
+
# Distance (col 0) stays as edge_weight only when excluded from the proj.
|
| 356 |
+
edge_proj_input = edge_attr if self.edge_distance_in_proj else edge_attr[:, 1:]
|
| 357 |
+
edge_feat = self.edge_input_proj(edge_proj_input) # Static: reused every layer
|
| 358 |
+
|
| 359 |
+
outs = []
|
| 360 |
+
for i in range(self.num_layers):
|
| 361 |
+
# GINEConv-style message passing with ACM filtering
|
| 362 |
+
# Edge features are static — no per-layer edge update
|
| 363 |
+
x = self.ACM_convs[i](
|
| 364 |
+
x=x,
|
| 365 |
+
edge_index=edge_index,
|
| 366 |
+
edge_weight=edge_weight,
|
| 367 |
+
edge_feat=edge_feat,
|
| 368 |
+
)
|
| 369 |
+
if self.layer_norms is not None:
|
| 370 |
+
if self.norm_type == "graph":
|
| 371 |
+
x = self.layer_norms[i](x, batch)
|
| 372 |
+
else:
|
| 373 |
+
x = self.layer_norms[i](x)
|
| 374 |
+
outs.append(x)
|
| 375 |
+
|
| 376 |
+
if return_hidden:
|
| 377 |
+
return x, outs
|
| 378 |
+
else:
|
| 379 |
+
return x
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
if __name__ == "__main__":
|
| 383 |
+
model = ACM_GINEConv_model(46, 46, 2, 256, 74, True)
|
| 384 |
+
num_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 385 |
+
print(f"ACM_GINEConv_model parameters: {num_params:,}")
|
models/edcoder.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from itertools import chain
|
| 3 |
+
from functools import partial
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn as nn
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
from .acm_gin import ACM_GIN_model
|
| 9 |
+
from .acm_gineconv import ACM_GINEConv_model
|
| 10 |
+
from torch_geometric.utils import dropout_edge
|
| 11 |
+
from torch_geometric.utils import add_self_loops
|
| 12 |
+
from torch_geometric.nn import global_mean_pool
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def sce_loss(x, y, alpha=3):
|
| 16 |
+
x = F.normalize(x, p=2, dim=-1)
|
| 17 |
+
y = F.normalize(y, p=2, dim=-1)
|
| 18 |
+
|
| 19 |
+
loss = (1 - (x * y).sum(dim=-1)).pow_(alpha)
|
| 20 |
+
loss = loss.mean()
|
| 21 |
+
|
| 22 |
+
return loss
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def setup_module(
|
| 26 |
+
m_type,
|
| 27 |
+
in_dim,
|
| 28 |
+
out_dim,
|
| 29 |
+
num_hidden,
|
| 30 |
+
num_layers,
|
| 31 |
+
activation,
|
| 32 |
+
batchnorm,
|
| 33 |
+
edge_in_dim,
|
| 34 |
+
norm_type="none",
|
| 35 |
+
input_norm="none",
|
| 36 |
+
edge_distance_in_proj=True,
|
| 37 |
+
) -> nn.Module:
|
| 38 |
+
|
| 39 |
+
if m_type == "acm_gin":
|
| 40 |
+
mod = ACM_GIN_model(
|
| 41 |
+
int(in_dim),
|
| 42 |
+
int(out_dim),
|
| 43 |
+
num_layers,
|
| 44 |
+
int(num_hidden),
|
| 45 |
+
edge_in_dim,
|
| 46 |
+
batchnorm,
|
| 47 |
+
activation=activation,
|
| 48 |
+
)
|
| 49 |
+
elif m_type == "acm_gineconv":
|
| 50 |
+
mod = ACM_GINEConv_model(
|
| 51 |
+
int(in_dim),
|
| 52 |
+
int(out_dim),
|
| 53 |
+
num_layers,
|
| 54 |
+
int(num_hidden),
|
| 55 |
+
edge_in_dim,
|
| 56 |
+
batchnorm,
|
| 57 |
+
activation=activation,
|
| 58 |
+
norm_type=norm_type,
|
| 59 |
+
input_norm=input_norm,
|
| 60 |
+
edge_distance_in_proj=edge_distance_in_proj,
|
| 61 |
+
)
|
| 62 |
+
else:
|
| 63 |
+
raise NotImplementedError
|
| 64 |
+
|
| 65 |
+
return mod
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class PreModel(nn.Module):
|
| 69 |
+
def __init__(
|
| 70 |
+
self,
|
| 71 |
+
in_dim: int,
|
| 72 |
+
edge_in_dim: int, # Dimensionality of raw edge features (e.g. 74)
|
| 73 |
+
num_hidden: int,
|
| 74 |
+
num_layers: int,
|
| 75 |
+
nhead: int, # TODO: Check if nhead is actually needed; currently unused (vestigial from GAT?)
|
| 76 |
+
nhead_out: int, # TODO: Check if nhead_out is actually needed; currently unused (vestigial from GAT?)
|
| 77 |
+
activation: str,
|
| 78 |
+
feat_drop: float, # TODO: Check if feat_drop is actually needed; currently unused
|
| 79 |
+
attn_drop: float, # TODO: Check if attn_drop is actually needed; currently unused (vestigial from GAT?)
|
| 80 |
+
negative_slope: float, # TODO: Check if negative_slope is actually needed; currently unused (vestigial from GAT?)
|
| 81 |
+
residual: bool, # TODO: Check if residual is actually needed; currently unused
|
| 82 |
+
norm: Optional[str], # TODO: Check if norm is actually needed; currently unused
|
| 83 |
+
mask_rate: float = 0.3,
|
| 84 |
+
encoder_type: str = "gat",
|
| 85 |
+
decoder_type: str = "gat",
|
| 86 |
+
loss_fn: str = "sce",
|
| 87 |
+
drop_edge_rate: float = 0.0,
|
| 88 |
+
replace_rate: float = 0.1,
|
| 89 |
+
alpha_l: float = 2,
|
| 90 |
+
concat_hidden: bool = False,
|
| 91 |
+
batchnorm=False,
|
| 92 |
+
encoder_norm="none",
|
| 93 |
+
encoder_input_norm="none",
|
| 94 |
+
edge_distance_in_proj=True,
|
| 95 |
+
vicreg_var_weight: float = 0.0,
|
| 96 |
+
vicreg_cov_weight: float = 0.0,
|
| 97 |
+
vicreg_gamma: float = 1.0,
|
| 98 |
+
):
|
| 99 |
+
super(PreModel, self).__init__()
|
| 100 |
+
self._mask_rate = mask_rate
|
| 101 |
+
self._encoder_type = encoder_type
|
| 102 |
+
self._decoder_type = decoder_type
|
| 103 |
+
self._drop_edge_rate = drop_edge_rate
|
| 104 |
+
self._output_hidden_size = num_hidden
|
| 105 |
+
self._concat_hidden = concat_hidden
|
| 106 |
+
|
| 107 |
+
# VICReg-style anti-collapse regularizer (0 = off); see _vicreg_terms.
|
| 108 |
+
self._vicreg_var_weight = vicreg_var_weight
|
| 109 |
+
self._vicreg_cov_weight = vicreg_cov_weight
|
| 110 |
+
self._vicreg_gamma = vicreg_gamma
|
| 111 |
+
self.last_loss_components = {}
|
| 112 |
+
|
| 113 |
+
self._replace_rate = replace_rate
|
| 114 |
+
self._mask_token_rate = 1 - self._replace_rate
|
| 115 |
+
|
| 116 |
+
assert num_hidden % nhead == 0
|
| 117 |
+
assert num_hidden % nhead_out == 0
|
| 118 |
+
|
| 119 |
+
enc_num_hidden = num_hidden
|
| 120 |
+
enc_nhead = 1
|
| 121 |
+
|
| 122 |
+
dec_in_dim = num_hidden
|
| 123 |
+
dec_num_hidden = num_hidden
|
| 124 |
+
|
| 125 |
+
# Build encoder
|
| 126 |
+
self.encoder = setup_module(
|
| 127 |
+
m_type=encoder_type,
|
| 128 |
+
in_dim=in_dim,
|
| 129 |
+
out_dim=enc_num_hidden,
|
| 130 |
+
num_hidden=enc_num_hidden,
|
| 131 |
+
num_layers=num_layers,
|
| 132 |
+
activation=activation,
|
| 133 |
+
batchnorm=batchnorm,
|
| 134 |
+
edge_in_dim=edge_in_dim,
|
| 135 |
+
norm_type=encoder_norm,
|
| 136 |
+
input_norm=encoder_input_norm,
|
| 137 |
+
edge_distance_in_proj=edge_distance_in_proj,
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Build decoder for attribute prediction
|
| 141 |
+
self.decoder = setup_module(
|
| 142 |
+
m_type=decoder_type,
|
| 143 |
+
in_dim=dec_in_dim,
|
| 144 |
+
out_dim=in_dim,
|
| 145 |
+
num_hidden=dec_num_hidden,
|
| 146 |
+
num_layers=1,
|
| 147 |
+
activation=activation,
|
| 148 |
+
batchnorm=batchnorm,
|
| 149 |
+
edge_in_dim=edge_in_dim,
|
| 150 |
+
edge_distance_in_proj=edge_distance_in_proj,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
self.enc_mask_token = nn.Parameter(torch.zeros(1, in_dim))
|
| 154 |
+
if concat_hidden:
|
| 155 |
+
self.encoder_to_decoder = nn.Linear(
|
| 156 |
+
dec_in_dim * num_layers, dec_in_dim, bias=False
|
| 157 |
+
)
|
| 158 |
+
else:
|
| 159 |
+
self.encoder_to_decoder = nn.Linear(dec_in_dim, dec_in_dim, bias=False)
|
| 160 |
+
|
| 161 |
+
# Setup loss function
|
| 162 |
+
self.criterion = self.setup_loss_fn(loss_fn, alpha_l)
|
| 163 |
+
|
| 164 |
+
@property
|
| 165 |
+
def output_hidden_dim(self):
|
| 166 |
+
return self._output_hidden_size
|
| 167 |
+
|
| 168 |
+
def setup_loss_fn(self, loss_fn, alpha_l):
|
| 169 |
+
if loss_fn == "mse":
|
| 170 |
+
criterion = nn.MSELoss()
|
| 171 |
+
elif loss_fn == "sce":
|
| 172 |
+
criterion = partial(sce_loss, alpha=alpha_l)
|
| 173 |
+
else:
|
| 174 |
+
raise NotImplementedError
|
| 175 |
+
return criterion
|
| 176 |
+
|
| 177 |
+
def encoding_mask_noise(self, x, mask_rate=0.3, virtual_node_index=None):
|
| 178 |
+
num_nodes = x.shape[0]
|
| 179 |
+
all_indices = torch.arange(num_nodes, device=x.device)
|
| 180 |
+
|
| 181 |
+
# Remove virtual node index from masking candidates
|
| 182 |
+
if virtual_node_index is not None:
|
| 183 |
+
all_indices = all_indices[~torch.isin(all_indices, virtual_node_index)]
|
| 184 |
+
|
| 185 |
+
perm = all_indices[torch.randperm(len(all_indices), device=x.device)]
|
| 186 |
+
|
| 187 |
+
# random masking
|
| 188 |
+
num_mask_nodes = int(mask_rate * len(perm))
|
| 189 |
+
mask_nodes = perm[:num_mask_nodes]
|
| 190 |
+
keep_nodes = perm[num_mask_nodes:]
|
| 191 |
+
|
| 192 |
+
out_x = x.clone()
|
| 193 |
+
|
| 194 |
+
if self._replace_rate > 0:
|
| 195 |
+
num_noise_nodes = int(self._replace_rate * num_mask_nodes)
|
| 196 |
+
perm_mask = torch.randperm(num_mask_nodes, device=x.device)
|
| 197 |
+
token_nodes = mask_nodes[
|
| 198 |
+
perm_mask[: int(self._mask_token_rate * num_mask_nodes)]
|
| 199 |
+
]
|
| 200 |
+
noise_nodes = mask_nodes[
|
| 201 |
+
perm_mask[-int(self._replace_rate * num_mask_nodes) :]
|
| 202 |
+
]
|
| 203 |
+
noise_to_be_chosen = torch.randperm(len(perm), device=x.device)[
|
| 204 |
+
:num_noise_nodes
|
| 205 |
+
]
|
| 206 |
+
noise_to_be_chosen = all_indices[noise_to_be_chosen]
|
| 207 |
+
|
| 208 |
+
out_x[token_nodes] = 0.0
|
| 209 |
+
out_x[noise_nodes] = x[noise_to_be_chosen]
|
| 210 |
+
else:
|
| 211 |
+
token_nodes = mask_nodes
|
| 212 |
+
out_x[mask_nodes] = 0.0
|
| 213 |
+
|
| 214 |
+
out_x[token_nodes] += self.enc_mask_token
|
| 215 |
+
|
| 216 |
+
return out_x, (mask_nodes, keep_nodes)
|
| 217 |
+
|
| 218 |
+
def forward(self, batch):
|
| 219 |
+
# ---- attribute reconstruction ----
|
| 220 |
+
x, edge_index, edge_attr, virtual_node_index, batch = (
|
| 221 |
+
batch.x,
|
| 222 |
+
batch.edge_index,
|
| 223 |
+
batch.edge_attr,
|
| 224 |
+
getattr(batch, "virtual_node_index", None),
|
| 225 |
+
batch.batch,
|
| 226 |
+
)
|
| 227 |
+
loss = self.mask_attr_prediction(
|
| 228 |
+
x, edge_index, edge_attr, batch, virtual_node_index
|
| 229 |
+
)
|
| 230 |
+
return loss
|
| 231 |
+
|
| 232 |
+
def mask_attr_prediction(self, x, edge_index, edge_attr, batch, virtual_node_index):
|
| 233 |
+
|
| 234 |
+
use_x, (mask_nodes, keep_nodes) = self.encoding_mask_noise(
|
| 235 |
+
x,
|
| 236 |
+
self._mask_rate,
|
| 237 |
+
virtual_node_index,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
if self._drop_edge_rate > 0:
|
| 241 |
+
use_edge_index, masked_edges = dropout_edge(
|
| 242 |
+
edge_index, self._drop_edge_rate
|
| 243 |
+
)
|
| 244 |
+
use_edge_attr = edge_attr[masked_edges]
|
| 245 |
+
use_edge_index, use_edge_attr = add_self_loops(
|
| 246 |
+
use_edge_index, use_edge_attr, fill_value="min"
|
| 247 |
+
)
|
| 248 |
+
else:
|
| 249 |
+
use_edge_index = edge_index
|
| 250 |
+
use_edge_attr = edge_attr
|
| 251 |
+
|
| 252 |
+
enc_rep, all_hidden = self.encoder(
|
| 253 |
+
use_x, use_edge_index, use_edge_attr, batch=batch, return_hidden=True
|
| 254 |
+
)
|
| 255 |
+
if self._concat_hidden:
|
| 256 |
+
enc_rep = torch.cat(all_hidden, dim=1)
|
| 257 |
+
|
| 258 |
+
# ---- attribute reconstruction ----
|
| 259 |
+
rep = self.encoder_to_decoder(enc_rep)
|
| 260 |
+
|
| 261 |
+
# VICReg anti-collapse regularization on the graph-level pooled embedding,
|
| 262 |
+
# computed BEFORE the decoder remask below, and only while training so that
|
| 263 |
+
# val_loss stays pure reconstruction and the collapse metric is independent.
|
| 264 |
+
if self.training and (
|
| 265 |
+
self._vicreg_var_weight > 0 or self._vicreg_cov_weight > 0
|
| 266 |
+
):
|
| 267 |
+
reg_loss, reg_comps = self._vicreg_terms(rep, batch)
|
| 268 |
+
else:
|
| 269 |
+
reg_loss, reg_comps = rep.new_zeros(()), {}
|
| 270 |
+
|
| 271 |
+
if self._decoder_type not in ("mlp", "linear"):
|
| 272 |
+
# * remask, re-mask
|
| 273 |
+
rep[mask_nodes] = 0
|
| 274 |
+
|
| 275 |
+
if self._decoder_type in ("mlp", "linear"):
|
| 276 |
+
recon = self.decoder(rep)
|
| 277 |
+
else:
|
| 278 |
+
recon = self.decoder(rep, use_edge_index, use_edge_attr)
|
| 279 |
+
|
| 280 |
+
x_init = x[mask_nodes]
|
| 281 |
+
x_rec = recon[mask_nodes]
|
| 282 |
+
|
| 283 |
+
loss = self.criterion(x_rec, x_init)
|
| 284 |
+
self.last_loss_components = {"sce": float(loss.detach()), **reg_comps}
|
| 285 |
+
|
| 286 |
+
return loss + reg_loss
|
| 287 |
+
|
| 288 |
+
def _vicreg_terms(self, rep, batch):
|
| 289 |
+
"""VICReg-style anti-collapse terms on the graph-level pooled embedding.
|
| 290 |
+
|
| 291 |
+
Returns ``(reg_loss, components)``. Combines a variance hinge (keep each
|
| 292 |
+
embedding dim's per-batch std >= gamma) and a covariance penalty
|
| 293 |
+
(decorrelate dims). Applied on ``global_mean_pool(rep)`` -- the same
|
| 294 |
+
representation the PCA-collapse metric is computed on -- so it directly
|
| 295 |
+
opposes the dimensional collapse observed once reconstruction saturates.
|
| 296 |
+
"""
|
| 297 |
+
z = global_mean_pool(rep, batch) # [G, D]
|
| 298 |
+
G = z.size(0)
|
| 299 |
+
D = z.size(1)
|
| 300 |
+
reg = rep.new_zeros(())
|
| 301 |
+
comps = {}
|
| 302 |
+
if self._vicreg_var_weight > 0:
|
| 303 |
+
std = torch.sqrt(z.var(dim=0, unbiased=False) + 1e-4)
|
| 304 |
+
var_term = torch.clamp(self._vicreg_gamma - std, min=0).mean()
|
| 305 |
+
reg = reg + self._vicreg_var_weight * var_term
|
| 306 |
+
comps["vic_var"] = float(var_term.detach())
|
| 307 |
+
if self._vicreg_cov_weight > 0 and G > 1:
|
| 308 |
+
zc = z - z.mean(dim=0, keepdim=True)
|
| 309 |
+
cov = (zc.t() @ zc) / (G - 1) # [D, D]
|
| 310 |
+
off_diag_sq = cov.pow(2).sum() - cov.diagonal().pow(2).sum()
|
| 311 |
+
cov_term = off_diag_sq / D
|
| 312 |
+
reg = reg + self._vicreg_cov_weight * cov_term
|
| 313 |
+
comps["vic_cov"] = float(cov_term.detach())
|
| 314 |
+
return reg, comps
|
| 315 |
+
|
| 316 |
+
def embed(self, x, edge_index, edge_attr, batch):
|
| 317 |
+
if self._concat_hidden:
|
| 318 |
+
enc_rep, all_hidden = self.encoder(
|
| 319 |
+
x, edge_index, edge_attr, batch=batch, return_hidden=True
|
| 320 |
+
)
|
| 321 |
+
enc_rep = torch.cat(all_hidden, dim=1)
|
| 322 |
+
else:
|
| 323 |
+
enc_rep = self.encoder(x, edge_index, edge_attr, batch=batch)
|
| 324 |
+
rep = self.encoder_to_decoder(enc_rep)
|
| 325 |
+
return rep
|
| 326 |
+
|
| 327 |
+
@property
|
| 328 |
+
def enc_params(self):
|
| 329 |
+
return self.encoder.parameters()
|
| 330 |
+
|
| 331 |
+
@property
|
| 332 |
+
def dec_params(self):
|
| 333 |
+
return chain(*[self.encoder_to_decoder.parameters(), self.decoder.parameters()])
|
models/utils.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch.nn as nn
|
| 2 |
+
import torch
|
| 3 |
+
from functools import partial
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def create_activation(name):
|
| 7 |
+
if name == "relu":
|
| 8 |
+
return nn.ReLU()
|
| 9 |
+
elif name == "gelu":
|
| 10 |
+
return nn.GELU()
|
| 11 |
+
elif name == "prelu":
|
| 12 |
+
return nn.PReLU()
|
| 13 |
+
elif name is None:
|
| 14 |
+
return nn.Identity()
|
| 15 |
+
elif name == "elu":
|
| 16 |
+
return nn.ELU()
|
| 17 |
+
else:
|
| 18 |
+
raise NotImplementedError(f"{name} is not implemented.")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def create_norm(name):
|
| 22 |
+
if name == "layernorm":
|
| 23 |
+
return nn.LayerNorm
|
| 24 |
+
elif name == "batchnorm":
|
| 25 |
+
return nn.BatchNorm1d
|
| 26 |
+
elif name == "graphnorm":
|
| 27 |
+
return partial(NormLayer, norm_type="groupnorm")
|
| 28 |
+
else:
|
| 29 |
+
return nn.Identity
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class NormLayer(nn.Module):
|
| 33 |
+
def __init__(self, hidden_dim, norm_type):
|
| 34 |
+
super().__init__()
|
| 35 |
+
if norm_type == "batchnorm":
|
| 36 |
+
self.norm = nn.BatchNorm1d(hidden_dim)
|
| 37 |
+
elif norm_type == "layernorm":
|
| 38 |
+
self.norm = nn.LayerNorm(hidden_dim)
|
| 39 |
+
elif norm_type == "graphnorm":
|
| 40 |
+
self.norm = norm_type
|
| 41 |
+
self.weight = nn.Parameter(torch.ones(hidden_dim))
|
| 42 |
+
self.bias = nn.Parameter(torch.zeros(hidden_dim))
|
| 43 |
+
|
| 44 |
+
self.mean_scale = nn.Parameter(torch.ones(hidden_dim))
|
| 45 |
+
else:
|
| 46 |
+
raise NotImplementedError
|
| 47 |
+
|
| 48 |
+
def forward(self, graph, x):
|
| 49 |
+
tensor = x
|
| 50 |
+
if self.norm is not None and type(self.norm) != str:
|
| 51 |
+
return self.norm(tensor)
|
| 52 |
+
elif self.norm is None:
|
| 53 |
+
return tensor
|
| 54 |
+
|
| 55 |
+
batch_list = graph.batch_num_nodes
|
| 56 |
+
batch_size = len(batch_list)
|
| 57 |
+
batch_list = torch.Tensor(batch_list).long().to(tensor.device)
|
| 58 |
+
batch_index = (
|
| 59 |
+
torch.arange(batch_size).to(tensor.device).repeat_interleave(batch_list)
|
| 60 |
+
)
|
| 61 |
+
batch_index = batch_index.view((-1,) + (1,) * (tensor.dim() - 1)).expand_as(
|
| 62 |
+
tensor
|
| 63 |
+
)
|
| 64 |
+
mean = torch.zeros(batch_size, *tensor.shape[1:]).to(tensor.device)
|
| 65 |
+
mean = mean.scatter_add_(0, batch_index, tensor)
|
| 66 |
+
mean = (mean.T / batch_list).T
|
| 67 |
+
mean = mean.repeat_interleave(batch_list, dim=0)
|
| 68 |
+
|
| 69 |
+
sub = tensor - mean * self.mean_scale
|
| 70 |
+
|
| 71 |
+
std = torch.zeros(batch_size, *tensor.shape[1:]).to(tensor.device)
|
| 72 |
+
std = std.scatter_add_(0, batch_index, sub.pow(2))
|
| 73 |
+
std = ((std.T / batch_list).T + 1e-6).sqrt()
|
| 74 |
+
std = std.repeat_interleave(batch_list, dim=0)
|
| 75 |
+
return self.weight * sub / std + self.bias
|