|
7 | 7 | from tgm.hooks import ( |
8 | 8 | HistoricalNegativeEdgeSamplerHook, |
9 | 9 | HookManager, |
| 10 | + NodeTypeNegativeSamplerHook, |
10 | 11 | RandomNegativeEdgeSamplerHook, |
11 | 12 | ) |
12 | 13 |
|
@@ -47,6 +48,7 @@ def test_hook_repre(): |
47 | 48 | def test_hook_reset_state(): |
48 | 49 | assert RandomNegativeEdgeSamplerHook.has_state == False |
49 | 50 | assert HistoricalNegativeEdgeSamplerHook.has_state == True |
| 51 | + assert NodeTypeNegativeSamplerHook.has_state == True |
50 | 52 |
|
51 | 53 |
|
52 | 54 | def test_bad_negative_edge_sampler_init(): |
@@ -198,3 +200,168 @@ def test_hst_sampling(data_test_hst_sampling): |
198 | 200 | sampler.reset_state() |
199 | 201 | assert sampler._memory is None |
200 | 202 | 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) |
0 commit comments