Skip to content

Commit 306da4c

Browse files
committed
Added unit tests
1 parent aff065a commit 306da4c

5 files changed

Lines changed: 185 additions & 21 deletions

File tree

test.py

Lines changed: 0 additions & 19 deletions
This file was deleted.

test/unit/test_hooks/test_negative_edge_sampler_hook.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from tgm.hooks import (
88
HistoricalNegativeEdgeSamplerHook,
99
HookManager,
10+
NodeTypeNegativeSamplerHook,
1011
RandomNegativeEdgeSamplerHook,
1112
)
1213

@@ -47,6 +48,7 @@ def test_hook_repre():
4748
def test_hook_reset_state():
4849
assert RandomNegativeEdgeSamplerHook.has_state == False
4950
assert HistoricalNegativeEdgeSamplerHook.has_state == True
51+
assert NodeTypeNegativeSamplerHook.has_state == True
5052

5153

5254
def test_bad_negative_edge_sampler_init():
@@ -198,3 +200,168 @@ def test_hst_sampling(data_test_hst_sampling):
198200
sampler.reset_state()
199201
assert sampler._memory is None
200202
assert sampler._count == 0
203+
204+
205+
@pytest.fixture
206+
def data_test_node_type_sampling():
207+
"""Fixture for node type sampling tests.
208+
209+
Graph with 12 nodes (0-11), node types assigned as:
210+
type 0: nodes [0, 2, 4, 6, 8, 10] (even)
211+
type 1: nodes [1, 3, 5, 7, 9, 11] (odd)
212+
213+
Edges (src, dst):
214+
# 1st batch
215+
[1, 2], dst=2 type 0
216+
[3, 4], dst=4 type 0
217+
[5, 6], dst=6 type 0
218+
[7, 1], dst=1 type 1
219+
# 2nd batch
220+
[2, 3], dst=3 type 1 -> can sample node 1 (type 1, seen in batch 1)
221+
[4, 8], dst=8 type 0 -> can sample nodes 2, 4, 6 (type 0, seen in batch 1)
222+
[6, 5], dst=5 type 1 -> can sample node 1 (type 1, seen in batch 1)
223+
[8, 9], dst=9 type 1 -> can sample node 1 (type 1, seen in batch 1)
224+
# 3rd batch
225+
[1, 10], dst=10 type 0 -> can sample nodes 2, 4, 6, 8 (type 0, seen in batches 1&2)
226+
[3, 7], dst=7 type 1 -> can sample nodes 1, 3, 5, 9 (type 1, seen in batches 1&2)
227+
[5, 11], dst=11 type 1 -> can sample nodes 1, 3, 5, 9 (type 1, seen in batches 1&2)
228+
[7, 0], dst=0 type 0 -> can sample nodes 2, 4, 6, 8 (type 0, seen in batches 1&2).
229+
"""
230+
edge_index = torch.IntTensor(
231+
[
232+
# 1st batch
233+
[1, 2],
234+
[3, 4],
235+
[5, 6],
236+
[7, 1],
237+
# 2nd batch
238+
[2, 3],
239+
[4, 8],
240+
[6, 5],
241+
[8, 9],
242+
# 3rd batch
243+
[1, 10],
244+
[3, 7],
245+
[5, 11],
246+
[7, 0],
247+
]
248+
)
249+
edge_time = torch.arange(edge_index.size(0))
250+
node_type = torch.IntTensor([i % 2 for i in range(12)]) # even=type 0, odd=type 1
251+
node_type[-1] = (
252+
2 # The last node (11) is of type 2, which is not present in the graph edges
253+
)
254+
return DGData.from_raw(
255+
edge_time=edge_time, edge_index=edge_index, node_type=node_type
256+
)
257+
258+
259+
def test_node_type_sampling(data_test_node_type_sampling):
260+
dg = DGraph(data_test_node_type_sampling)
261+
262+
hm = HookManager(keys=['unit'])
263+
sampler = NodeTypeNegativeSamplerHook(num_nodes=dg.num_nodes)
264+
265+
hm.register('unit', sampler)
266+
loader = DGDataLoader(dg, batch_size=4, hook_manager=hm)
267+
268+
with hm.activate('unit'):
269+
batch_iter = iter(loader)
270+
271+
# batch 1: no memory yet, all padded
272+
batch_1 = next(batch_iter)
273+
assert batch_1.neg.shape == (4,)
274+
assert torch.equal(
275+
batch_1.neg,
276+
torch.full((4,), PADDED_NODE_ID, dtype=batch_1.neg.dtype),
277+
)
278+
assert torch.equal(
279+
batch_1.valid_neg_mask,
280+
torch.zeros(4, dtype=torch.bool),
281+
)
282+
# memory initialized with num_nodes entries
283+
assert sampler._memory is not None
284+
# print()
285+
assert sampler._memory.shape == (dg.num_nodes,)
286+
# dst nodes 1, 2, 4, 6 recorded in memory
287+
assert sampler._memory[2] == 0 # type 0
288+
assert sampler._memory[4] == 0 # type 0
289+
assert sampler._memory[6] == 0 # type 0
290+
assert sampler._memory[1] == 1 # type 1
291+
292+
# batch 2: memory has nodes 1(t1), 2(t0), 4(t0), 6(t0)
293+
batch_2 = next(batch_iter)
294+
assert batch_2.neg.shape == (4,)
295+
assert torch.equal(
296+
batch_2.valid_neg_mask,
297+
torch.ones(4, dtype=torch.bool),
298+
)
299+
# dst=3(t1) -> neg must be type 1 -> only node 1
300+
assert batch_2.neg[0].item() == 1
301+
# dst=8(t0) -> neg must be type 0 -> one of {2, 4, 6}
302+
assert batch_2.neg[1].item() in {2, 4, 6}
303+
# dst=5(t1) -> neg must be type 1 -> only node 1
304+
assert batch_2.neg[2].item() == 1
305+
# dst=9(t1) -> neg must be type 1 -> only node 1
306+
assert batch_2.neg[3].item() == 1
307+
308+
# batch 3: memory now also has nodes 3(t1), 5(t1), 8(t0), 9(t1)
309+
batch_3 = next(batch_iter)
310+
assert batch_3.neg.shape == (4,)
311+
assert torch.equal(
312+
batch_3.valid_neg_mask,
313+
torch.Tensor([True, True, False, True]),
314+
)
315+
# dst=10(t0) -> neg must be type 0 -> one of {2, 4, 6, 8}
316+
assert batch_3.neg[0].item() in {2, 4, 6, 8}
317+
# dst=7(t1) -> neg must be type 1 -> one of {1, 3, 5, 9}
318+
assert batch_3.neg[1].item() in {1, 3, 5, 9}
319+
# dst=11(t1) -> neg must be type 2 -> no nodes of type 2 have been seen yet, so neg is padded
320+
assert batch_3.neg[2].item() == PADDED_NODE_ID
321+
# dst=0(t0) -> neg must be type 0 -> one of {2, 4, 6, 8}
322+
assert batch_3.neg[3].item() in {2, 4, 6, 8}
323+
324+
sampler.reset_state()
325+
assert sampler._memory is None
326+
327+
328+
def test_node_type_sampling_no_node_type():
329+
"""Hook should raise ValueError when dg.node_type is None."""
330+
edge_index = torch.IntTensor([[1, 2], [3, 4]])
331+
edge_time = torch.arange(2)
332+
data = DGData.from_raw(edge_time=edge_time, edge_index=edge_index)
333+
dg = DGraph(data)
334+
335+
sampler = NodeTypeNegativeSamplerHook(num_nodes=dg.num_nodes)
336+
with pytest.raises(ValueError, match='dg.node_type is None'):
337+
sampler(dg, dg.materialize())
338+
339+
340+
def test_node_type_sampling_with_id(data_test_node_type_sampling):
341+
"""Hook with id should suffix all produced attributes."""
342+
dg = DGraph(data_test_node_type_sampling)
343+
344+
sampler = NodeTypeNegativeSamplerHook(num_nodes=dg.num_nodes, id='foo')
345+
batch = sampler(dg, dg.materialize())
346+
347+
assert hasattr(batch, 'neg_foo')
348+
assert hasattr(batch, 'neg_time_foo')
349+
assert hasattr(batch, 'valid_neg_mask_foo')
350+
351+
352+
def test_node_type_sampling_dependencies():
353+
"""Check requires/produces sets."""
354+
hook = NodeTypeNegativeSamplerHook(num_nodes=10)
355+
assert hook.requires == {'edge_src', 'edge_dst', 'edge_time'}
356+
assert hook.produces == {'neg', 'neg_time', 'valid_neg_mask'}
357+
358+
hook_with_id = NodeTypeNegativeSamplerHook(num_nodes=10, id='foo')
359+
assert hook_with_id.produces == {'neg_foo', 'neg_time_foo', 'valid_neg_mask_foo'}
360+
361+
362+
def test_node_type_sampling_bad_init():
363+
"""Hook should raise ValueError when num_nodes <= 0."""
364+
with pytest.raises(ValueError, match='num_nodes must be a positive integer'):
365+
NodeTypeNegativeSamplerHook(num_nodes=0)
366+
with pytest.raises(ValueError, match='num_nodes must be a positive integer'):
367+
NodeTypeNegativeSamplerHook(num_nodes=-1)

tgm/hooks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
TGBNegativeEdgeSamplerHook,
88
TGBTHGNegativeEdgeSamplerHook,
99
TGBTKGNegativeEdgeSamplerHook,
10+
NodeTypeNegativeSamplerHook,
1011
)
1112
from .neighbors import NeighborSamplerHook, RecencyNeighborHook
1213
from .hook_manager import HookManager

tgm/hooks/negatives/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
from .sampler import RandomNegativeEdgeSamplerHook, HistoricalNegativeEdgeSamplerHook
1+
from .sampler import (
2+
RandomNegativeEdgeSamplerHook,
3+
HistoricalNegativeEdgeSamplerHook,
4+
NodeTypeNegativeSamplerHook,
5+
)
26
from .tgb_sampler import (
37
TGBNegativeEdgeSamplerHook,
48
TGBTHGNegativeEdgeSamplerHook,

tgm/hooks/negatives/sampler.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,16 @@ class NodeTypeNegativeSamplerHook(StatefulHook):
271271

272272
def __init__(
273273
self,
274+
num_nodes: int,
274275
id: str | None = None,
275276
) -> None:
276277
super().__init__()
278+
if num_nodes <= 0:
279+
raise ValueError('num_nodes must be a positive integer.')
280+
277281
self._id = id
278282
self._memory: torch.Tensor | None = None
283+
self._num_nodes = num_nodes
279284

280285
self.__post_init__()
281286

@@ -288,6 +293,9 @@ def __call__(self, dg: DGraph, batch: DGBatch) -> DGBatch:
288293
'dg.node_type is None. NodeTypeNegativeSamplerHook requires node type information in the DGraph.'
289294
)
290295
if self._memory is None:
296+
logger.debug(
297+
f'NodeTypeNegativeSamplerHook: Empty node label memory on first batch. All negatives will be set to PADDED_NODE_ID ({PADDED_NODE_ID}).'
298+
)
291299
neg = torch.full(
292300
(batch.edge_dst.size(0),),
293301
PADDED_NODE_ID,
@@ -370,8 +378,11 @@ def _update_memory(self, dg: DGraph, batch: DGBatch) -> None:
370378
has not yet appeared as a destination and is not eligible for sampling.
371379
"""
372380
if self._memory is None:
381+
logger.debug(
382+
f'Initializing memory for NodeTypeNegativeSamplerHook with shape ({dg.num_nodes},) on device {dg.device}.'
383+
)
373384
self._memory = torch.full(
374-
(dg.num_nodes,),
385+
(self._num_nodes,),
375386
-1,
376387
dtype=torch.int32,
377388
device=dg.device,

0 commit comments

Comments
 (0)