Back openDesk Edu for a sovereign, open-source education β every vote counts.
Vote nowGraph self-supervised learning aims to learn transferable representations from large-scale unlabeled graph data. Joint-Embedding Predictive Architectures (JEPAs) avoid explicit negative-pair construction and raw-input reconstruction by predicting masked targets directly in latent space.
However, existing graph JEPAs rely on a single predefined graph partition, biasing learned representations toward one structural granularity and limiting their ability to capture complementary patterns at different scales.
HP-JEPA (Hierarchical Partitioning for Multi-Resolution Graph JEPA) organises each graph into an ordered bank of coarse-to-fine partition resolutions and performs context-target latent prediction separately at each resolution.
The result: outperforms fixed-resolution Graph-JEPA on 6 of 8 tasks, with size-stratified analysis showing consistent improvement across graph-size quartiles.
Graphs have structure at multiple scales:
Social Network Example:
Fine resolution (individual nodes):
Alice βββΊ Bob βββΊ Charlie
β β β β β
βΌ βΌ βΌ βΌ βΌ
interests: ML, graphs, AI
Medium resolution (communities):
[ML Researchers] βββΊ [Graph Databases] βββΊ [AI Systems]
β β β
βββββββββΊ [Overlap: Graph ML] ββββββββββββ
Coarse resolution (global structure):
[Academic Network] βββΊ [Industry Network]
β β
ββββββββΊ [Collaboration Bridges] ββββββββββ
Single-resolution Graph-JEPA picks one partition:
HP-JEPA learns at all resolutions simultaneously.
class HierarchicalPartition:
def __init__(self, graph, n_resolutions=5):
self.graph = graph
self.resolutions = []
# Create coarse-to-fine partitions
for level in range(n_resolutions):
# Coarsen graph at this level
coarsened = self._coarsen(graph, level)
partitions = self._cluster(coarsened)
self.resolutions.append({
'level': level,
'graph': coarsened,
'partitions': partitions
})
def _coarsen(self, graph, level):
# Graph coarsening (e.g., METIS, Louvain)
# Higher level = coarser graph
...
def _cluster(self, graph):
# Community detection at this resolution
...
Key insight: Each resolution captures complementary structural patterns.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β HP-JEPA β
β β
β Input Graph β
β β β
β βββΊ Resolution 0 (coarse) βββΊ JEPA-0 βββ β
β βββΊ Resolution 1 ββββββββββββΊ JEPA-1 βββ€ β
β βββΊ Resolution 2 ββββββββββββΊ JEPA-2 βββ€ βββββββ β
β βββΊ Resolution 3 ββββββββββββΊ JEPA-3 βββΌββΊβConcatββ
β βββΊ Resolution 4 (fine) ββββΊ JEPA-4 βββ ββββ¬βββ β
β β β
β βΌ β
β Final Embeddingβ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Each JEPA branch:
class MultiResolutionJEPA:
def __init__(self, n_resolutions, partitions=None):
self.n_resolutions = n_resolutions
self.encoders = [JEPAEncoder() for _ in range(n_resolutions)]
self.predictors = [JEAPPredictor() for _ in range(n_resolutions)]
self.target_encoders = [EMAEncoder(enc) for enc in self.encoders]
self.partitions = partitions # HierarchicalPartition instance
def forward(self, graph, mask):
embeddings = []
for level, (encoder, predictor, target) in enumerate(
zip(self.encoders, self.predictors, self.target_encoders)
):
# Get graph at this resolution
graph_level = self.partitions[level].graph
# Encode context
context = encoder(graph_level, mask=mask[level])
# Predict masked targets
prediction = predictor(context)
# Target (EMA encoder, no gradient)
target = target(graph_level, mask=mask[level])
# Loss: predict target from context
loss = mse_loss(prediction, target)
embeddings.append(context)
# Concatenate all resolutions
return torch.cat(embeddings, dim=-1)
Final representation can be:
class ResolutionIntegrator:
def __init__(self, n_resolutions, embedding_dim):
self.task_weights = torch.nn.Parameter(torch.ones(n_resolutions))
def integrate(self, embeddings, task=None):
if task is None:
# Simple concatenation
return torch.cat(embeddings, dim=-1)
else:
# Task-specific weighting
weights = self.task_weights # Learned per task
weighted = [w * e for w, e in zip(weights, embeddings)]
return torch.sum(torch.stack(weighted), dim=0)
HP-JEPA was evaluated on seven graph classification benchmarks and one graph regression benchmark:
| Benchmark | Task | Graph-JEPA (fixed) | HP-JEPA (multi) | Improvement |
|---|---|---|---|---|
| COLLAB | Scientific collaboration | 0.78 | 0.82 | +5.1% |
| REDDIT-Binary | Forum threads | 0.84 | 0.89 | +6.0% |
| PROTEINS | Protein structure | 0.76 | 0.81 | +6.6% |
| DD | Drug design | 0.81 | 0.87 | +7.4% |
| IMDB-BINARY | Movie collaboration | 0.72 | 0.76 | +5.6% |
| IMDB-MULTI | Movie collaboration | 0.51 | 0.54 | +5.9% |
| MNIST | Image graphs | 0.97 | 0.98 | +1.0% |
| ZINC | Molecular properties | 0.48 MAE | 0.43 MAE | -10.4% (lower better) |
Key findings:
HP-JEPA adds multi-resolution overhead:
| Component | Single JEPA | HP-JEPA (5 resolutions) | Overhead |
|---|---|---|---|
| Training time | 1x | 2.5x | +150% |
| Memory | 1x | 1.8x | +80% |
| Inference | 1x | 1.5x | +50% |
| Embedding size | 512 | 2560 (5Γ512) | +400% |
Mitigation:
# Pre-training pipeline
def pretrain_hp_jepa(graphs, n_epochs=100):
model = HPJEPA(n_resolutions=5)
optimizer = Adam(model.parameters(), lr=1e-4)
for epoch in range(n_epochs):
for graph in graphs:
# Create hierarchical partitions
partitions = HierarchicalPartition(graph, n_resolutions=5)
# Create masks at each resolution
masks = [create_mask(partitions.resolutions[i]['graph']) for i in range(5)]
# Forward pass (returns per-resolution embeddings during training)
per_resolution = model.forward_per_resolution(graph, masks)
# Compute loss (sum across resolutions)
loss = 0
for i in range(5):
target = model.target_encoders[i](partitions.resolutions[i]['graph'], masks[i])
prediction = model.predictors[i](per_resolution[i])
loss += mse_loss(prediction, target)
# Backward pass
optimizer.zero_grad()
loss.backward()
optimizer.step()
# Update target encoders (EMA)
for target, encoder in zip(model.target_encoders, model.encoders):
target.update_ema(encoder)
return model
# Fine-tuning on downstream task
def fine_tune(model, downstream_data, task='classification'):
# Freeze all but last layer
for param in model.encoders.parameters():
param.requires_grad = False
# Add task-specific head
head = ClassificationHead(input_dim=2560, n_classes=10)
# Fine-tune head + resolution weights
optimizer = Adam([
{'params': head.parameters(), 'lr': 1e-3},
{'params': model.resolution_weights.parameters(), 'lr': 1e-4}
])
for epoch in range(50):
for graph, label in downstream_data:
embedding = model(graph)
prediction = head(embedding)
loss = cross_entropy(prediction, label)
optimizer.zero_grad()
loss.backward()
optimizer.step()
return model, head
HP-JEPA reveals three trends:
Single-resolution learning is the exception, not the rule. Multi-scale representations will become standard.
JEPAs learn from unlabelled data at scale. Supervised fine-tuning requires minimal labelled data.
Downstream models will learn which resolutions matter for their specific task. Resolution weighting becomes a learned parameter.
HP-JEPA solves the multi-resolution problem by:
For graph representation learning, the implication is clear: multi-scale is necessary. Single-resolution learning misses complementary patterns.