Skip to content

Commit a5e8232

Browse files
authored
Add incoming sampling direction for graph transformer input contruction & anchor based PE (#671)
1 parent 30a9158 commit a5e8232

6 files changed

Lines changed: 232 additions & 5 deletions

File tree

gigl/nn/graph_transformer.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,12 @@ class GraphTransformerEncoder(nn.Module):
437437
pairwise_attention_bias_attr_names: List of pairwise feature names used
438438
as additive attention bias. These must correspond to sparse
439439
graph-level attributes on ``data``.
440+
sampling_direction: Direction used for sequence token construction.
441+
``"out"`` preserves the existing k-hop reachability expansion.
442+
``"in"`` expands over reversed edges and is supported only
443+
when ``sequence_construction_method="khop"``. Directed relative
444+
encodings such as ``"hop_distance"`` should be computed with the
445+
same direction.
440446
feature_embedding_layer_dict: Optional ModuleDict mapping node types to
441447
feature embedding layers. If provided, these are applied to node
442448
features before node projection. (default: None)
@@ -504,6 +510,7 @@ def __init__(
504510
anchor_based_input_attr_names: Optional[list[str]] = None,
505511
anchor_based_input_embedding_dict: Optional[nn.ModuleDict] = None,
506512
pairwise_attention_bias_attr_names: Optional[list[str]] = None,
513+
sampling_direction: Literal["in", "out"] = "out",
507514
feature_embedding_layer_dict: Optional[nn.ModuleDict] = None,
508515
pe_integration_mode: Literal["concat", "add"] = "concat",
509516
activation: str = "gelu",
@@ -535,6 +542,16 @@ def __init__(
535542
"sequence_construction_method must be one of {'khop', 'ppr'}, "
536543
f"got '{sequence_construction_method}'"
537544
)
545+
if sampling_direction not in {"in", "out"}:
546+
raise ValueError(
547+
"sampling_direction must be one of {'in', 'out'}, "
548+
f"got '{sampling_direction}'"
549+
)
550+
if sequence_construction_method == "ppr" and sampling_direction != "out":
551+
raise ValueError(
552+
"sequence_construction_method='ppr' supports only "
553+
"sampling_direction='out'."
554+
)
538555
if sequence_positional_encoding_type is not None:
539556
sequence_positional_encoding_type = (
540557
sequence_positional_encoding_type.lower()
@@ -573,6 +590,7 @@ def __init__(
573590
"sequence_construction_method='ppr'."
574591
)
575592
self._sequence_construction_method = sequence_construction_method
593+
self._sampling_direction = sampling_direction
576594
self._sequence_positional_encoding_type = sequence_positional_encoding_type
577595
self._should_l2_normalize_embedding_layer_output = (
578596
should_l2_normalize_embedding_layer_output
@@ -816,6 +834,7 @@ def forward(
816834
anchor_node_ids=anchor_node_ids,
817835
hop_distance=self._hop_distance,
818836
sequence_construction_method=self._sequence_construction_method,
837+
sampling_direction=self._sampling_direction,
819838
anchor_based_attention_bias_attr_names=self._anchor_based_attention_bias_attr_names,
820839
anchor_based_input_attr_names=self._anchor_based_input_attr_names,
821840
pairwise_attention_bias_attr_names=self._pairwise_attention_bias_attr_names,

gigl/transforms/add_positional_encodings.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional
1+
from typing import Literal, Optional
22

33
import torch
44
from torch_geometric.data import HeteroData
@@ -253,17 +253,30 @@ class AddHeteroHopDistanceEncoding(BaseTransform):
253253
is_undirected (bool, optional): If set to :obj:`True`, the graph is
254254
assumed to be undirected for distance computation.
255255
(default: :obj:`False`)
256+
sampling_direction (str, optional): Direction used for directed
257+
distance computation. ``"out"`` preserves existing shortest paths
258+
over graph edges, while ``"in"`` computes shortest paths over
259+
reversed graph edges. Use ``"in"`` to align hop-distance relative
260+
encodings with incoming Graph Transformer sampling.
261+
(default: :obj:`"out"`)
256262
"""
257263

258264
def __init__(
259265
self,
260266
h_max: int,
261267
attr_name: Optional[str] = "hop_distance",
262268
is_undirected: bool = False,
269+
sampling_direction: Literal["in", "out"] = "out",
263270
) -> None:
271+
if sampling_direction not in {"in", "out"}:
272+
raise ValueError(
273+
"sampling_direction must be one of {'in', 'out'}, "
274+
f"got '{sampling_direction}'."
275+
)
264276
self.h_max = h_max
265277
self.attr_name = attr_name
266278
self.is_undirected = is_undirected
279+
self.sampling_direction = sampling_direction
267280

268281
def forward(self, data: HeteroData) -> HeteroData:
269282
assert isinstance(data, HeteroData), (
@@ -274,6 +287,8 @@ def forward(self, data: HeteroData) -> HeteroData:
274287
# Convert to homogeneous to compute shortest paths
275288
homo_data = data.to_homogeneous()
276289
edge_index = homo_data.edge_index
290+
if self.sampling_direction == "in":
291+
edge_index = edge_index.flip(0)
277292
num_nodes = homo_data.num_nodes
278293
num_edges = edge_index.size(1)
279294

@@ -442,4 +457,8 @@ def forward(self, data: HeteroData) -> HeteroData:
442457
return data
443458

444459
def __repr__(self) -> str:
445-
return f"{self.__class__.__name__}(h_max={self.h_max})"
460+
return (
461+
f"{self.__class__.__name__}("
462+
f"h_max={self.h_max}, sampling_direction='{self.sampling_direction}'"
463+
")"
464+
)

gigl/transforms/graph_transformer.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def heterodata_to_graph_transformer_input(
9090
anchor_based_attention_bias_attr_names: Optional[list[str]] = None,
9191
anchor_based_input_attr_names: Optional[list[str]] = None,
9292
pairwise_attention_bias_attr_names: Optional[list[str]] = None,
93+
sampling_direction: Literal["in", "out"] = "out",
9394
) -> tuple[Tensor, Tensor, SequenceAuxiliaryData]:
9495
"""
9596
Transform a HeteroData object to Graph Transformer sequence input.
@@ -131,6 +132,12 @@ def heterodata_to_graph_transformer_input(
131132
pairwise_attention_bias_attr_names: List of pairwise feature names used
132133
as attention bias. These must correspond to sparse graph-level
133134
attributes on ``data``. Example: ['pairwise_distance'].
135+
sampling_direction: Direction used for token construction.
136+
``"out"`` preserves the existing k-hop reachability expansion.
137+
``"in"`` expands over reversed edges and is supported only
138+
with ``sequence_construction_method="khop"``. Directed relative
139+
encodings such as ``"hop_distance"`` should be computed with the
140+
same direction.
134141
135142
Returns:
136143
(sequences, valid_mask, attention_bias_data), where:
@@ -190,6 +197,17 @@ def heterodata_to_graph_transformer_input(
190197
"be used as pairwise attention bias."
191198
)
192199

200+
if sampling_direction not in {"in", "out"}:
201+
raise ValueError(
202+
"sampling_direction must be one of {'in', 'out'}, "
203+
f"got '{sampling_direction}'."
204+
)
205+
206+
if sequence_construction_method == "ppr" and sampling_direction != "out":
207+
raise ValueError(
208+
"sequence_construction_method='ppr' supports only sampling_direction='out'."
209+
)
210+
193211
if (
194212
PPR_WEIGHT_FEATURE_NAME in anchor_bias_attr_names + anchor_input_attr_names
195213
and sequence_construction_method != "ppr"
@@ -233,6 +251,8 @@ def heterodata_to_graph_transformer_input(
233251
ppr_weight_sequences: Optional[Tensor] = None
234252
if sequence_construction_method == "khop":
235253
homo_edge_index = homo_data.edge_index # (2, num_edges)
254+
if sampling_direction == "in":
255+
homo_edge_index = homo_edge_index.flip(0)
236256
# Use sparse matrix operations for efficient k-hop neighbor extraction
237257
# Returns: (batch_size, num_nodes) sparse matrix where non-zero entries are reachable
238258
reachable = _get_k_hop_neighbors_sparse(

tests/unit/nn/graph_transformer_test.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Tests for GraphTransformerEncoder."""
22

3-
from typing import cast
3+
from typing import Literal, cast
44

55
import torch
66
import torch.nn as nn
@@ -238,10 +238,24 @@ def test_deterministic_eval_mode(self) -> None:
238238

239239
self.assertTrue(torch.allclose(result_1, result_2))
240240

241+
def test_forward_with_in_sampling_direction(self) -> None:
242+
"""Test forward pass with incoming k-hop sampling."""
243+
data = _create_simple_hetero_data()
244+
encoder = self._create_encoder(sampling_direction="in")
245+
encoder.eval()
246+
241247
def test_readout_mode_rejects_invalid_value(self) -> None:
242248
"""Test that unsupported readout modes fail fast."""
243249
with self.assertRaisesRegex(ValueError, "readout_mode"):
244-
self._create_encoder(readout_mode="neighbor_average")
250+
self._create_encoder(
251+
readout_mode=cast(
252+
Literal[
253+
"anchor_neighbor_attention",
254+
"anchor_only",
255+
],
256+
"mean_pool",
257+
)
258+
)
245259

246260
def test_forward_with_anchor_only_readout(self) -> None:
247261
"""Test public forward with anchor-only readout."""
@@ -261,6 +275,17 @@ def test_forward_with_anchor_only_readout(self) -> None:
261275
self.assertEqual(embeddings.shape, (3, self._out_dim))
262276
self.assertFalse(torch.isnan(embeddings).any())
263277

278+
def test_sampling_direction_rejects_invalid_value(self) -> None:
279+
with self.assertRaisesRegex(ValueError, "sampling_direction"):
280+
self._create_encoder(sampling_direction="sideways")
281+
282+
def test_in_sampling_direction_requires_khop(self) -> None:
283+
with self.assertRaisesRegex(ValueError, "supports only"):
284+
self._create_encoder(
285+
sequence_construction_method="ppr",
286+
sampling_direction="in",
287+
)
288+
264289
def test_anchor_only_readout_returns_anchor_token(self) -> None:
265290
"""Test anchor-only readout returns the post-norm anchor token."""
266291
encoder = self._create_encoder(

tests/unit/transforms/add_positional_encodings_test.py

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import Literal, cast
2+
13
import torch
24
from absl.testing import absltest
35
from torch_geometric.data import HeteroData
@@ -50,6 +52,19 @@ def create_empty_hetero_data() -> HeteroData:
5052
return data
5153

5254

55+
def create_directed_chain_data() -> HeteroData:
56+
"""Create a directed chain 0 -> 1 -> 2 for direction tests."""
57+
data = HeteroData()
58+
data["user"].x = torch.randn(3, 4)
59+
data["user", "to", "user"].edge_index = torch.tensor(
60+
[
61+
[0, 1],
62+
[1, 2],
63+
]
64+
)
65+
return data
66+
67+
5368
class TestAddHeteroRandomWalkEncodings(TestCase):
5469
"""Tests for AddHeteroRandomWalkEncodings (consolidated PE and SE in single pass)."""
5570

@@ -267,6 +282,45 @@ def test_forward_undirected(self):
267282
self.assertTrue(result.hop_distance.is_sparse_csr)
268283
self.assertEqual(result.hop_distance.shape, (5, 5))
269284

285+
def test_forward_sampling_direction_defaults_to_out(self):
286+
"""Out hop distances preserve existing directed reachability."""
287+
data = create_directed_chain_data()
288+
transform = AddHeteroHopDistanceEncoding(h_max=2)
289+
290+
result = transform(data)
291+
dense = result.hop_distance.to_dense()
292+
293+
self.assertEqual(dense[0, 1].item(), 1.0)
294+
self.assertEqual(dense[0, 2].item(), 2.0)
295+
self.assertEqual(dense[2, 1].item(), 0.0)
296+
self.assertEqual(dense[2, 0].item(), 0.0)
297+
298+
def test_forward_sampling_direction_in_reverses_reachability(self):
299+
"""In hop distances are computed over reversed graph edges."""
300+
data = create_directed_chain_data()
301+
transform = AddHeteroHopDistanceEncoding(
302+
h_max=2,
303+
sampling_direction="in",
304+
)
305+
306+
result = transform(data)
307+
dense = result.hop_distance.to_dense()
308+
309+
self.assertEqual(dense[2, 1].item(), 1.0)
310+
self.assertEqual(dense[2, 0].item(), 2.0)
311+
self.assertEqual(dense[0, 1].item(), 0.0)
312+
self.assertEqual(dense[0, 2].item(), 0.0)
313+
314+
def test_sampling_direction_rejects_invalid_value(self):
315+
with self.assertRaisesRegex(ValueError, "sampling_direction"):
316+
AddHeteroHopDistanceEncoding(
317+
h_max=2,
318+
sampling_direction=cast(
319+
Literal["in", "out"],
320+
"sideways",
321+
),
322+
)
323+
270324
def test_forward_empty_graph(self):
271325
"""Test forward pass with empty graph."""
272326
data = create_empty_hetero_data()
@@ -283,7 +337,21 @@ def test_forward_empty_graph(self):
283337
def test_repr(self):
284338
"""Test string representation."""
285339
transform = AddHeteroHopDistanceEncoding(h_max=5)
286-
self.assertEqual(repr(transform), "AddHeteroHopDistanceEncoding(h_max=5)")
340+
self.assertEqual(
341+
repr(transform),
342+
"AddHeteroHopDistanceEncoding(h_max=5, sampling_direction='out')",
343+
)
344+
345+
def test_repr_in_sampling_direction(self):
346+
"""Test string representation with non-default direction."""
347+
transform = AddHeteroHopDistanceEncoding(
348+
h_max=5,
349+
sampling_direction="in",
350+
)
351+
self.assertEqual(
352+
repr(transform),
353+
"AddHeteroHopDistanceEncoding(h_max=5, sampling_direction='in')",
354+
)
287355

288356

289357
if __name__ == "__main__":

0 commit comments

Comments
 (0)