Ace3Z commited on
Commit
1c61c4d
·
verified ·
1 Parent(s): eebc765

Add GrapHist v2 + VICReg final checkpoint (best val loss, epoch 99) with loader files and validated model card

Browse files
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