Files
2026-07-13 13:35:51 +08:00

396 lines
14 KiB
Python

import math
import os
import random
import time
import dgl
import dgl.function as fn
import numpy as np
import scipy
import torch as th
from dgl.sampling import pack_traces, random_walk
from torch.utils.data import DataLoader
# The base class of sampler
class SAINTSampler:
"""
Description
-----------
SAINTSampler implements the sampler described in GraphSAINT. This sampler implements offline sampling in
pre-sampling phase as well as fully offline sampling, fully online sampling in training phase.
Users can conveniently set param 'online' of the sampler to choose different modes.
Parameters
----------
node_budget : int
the expected number of nodes in each subgraph, which is specifically explained in the paper. Actually this
param specifies the times of sampling nodes from the original graph with replacement. The meaning of edge_budget
is similar to the node_budget.
dn : str
name of dataset.
g : DGLGraph
the full graph.
train_nid : list
ids of training nodes.
num_workers_sampler : int
number of processes to sample subgraphs in pre-sampling procedure using torch.dataloader.
num_subg_sampler : int, optional
the max number of subgraphs sampled in pre-sampling phase for computing normalization coefficients in the beginning.
Actually this param is used as ``__len__`` of sampler in pre-sampling phase.
Please make sure that num_subg_sampler is greater than batch_size_sampler so that we can sample enough subgraphs.
Defaults: 10000
batch_size_sampler : int, optional
the number of subgraphs sampled by each process concurrently in pre-sampling phase.
Defaults: 200
online : bool, optional
If `True`, we employ online sampling in training phase. Otherwise employing offline sampling.
Defaults: True
num_subg : int, optional
the expected number of sampled subgraphs in pre-sampling phase.
It is actually the 'N' in the original paper. Note that this param is different from the num_subg_sampler.
This param is just used to control the number of pre-sampled subgraphs.
Defaults: 50
full : bool, optional
True if the number of subgraphs used in the training phase equals to that of pre-sampled subgraphs, or
``math.ceil(self.train_g.num_nodes() / self.node_budget)``. This formula takes the result of A divided by B as
the number of subgraphs used in the training phase, where A is the number of training nodes in the original
graph, B is the expected number of nodes in each pre-sampled subgraph. Please refer to the paper to check the
details.
Defaults: True
Notes
-----
For parallelism of pre-sampling, we utilize `torch.DataLoader` to concurrently speed up sampling.
The `num_subg_sampler` is the return value of `__len__` in pre-sampling phase. Moreover, the param `batch_size_sampler`
determines the batch_size of `torch.DataLoader` in internal pre-sampling part. But note that if we wanna pass the
SAINTSampler to `torch.DataLoader` for concurrently sampling subgraphs in training phase, we need to specify
`batch_size` of `DataLoader`, that is, `batch_size_sampler` is not related to how sampler works in training procedure.
"""
def __init__(
self,
node_budget,
dn,
g,
train_nid,
num_workers_sampler,
num_subg_sampler=10000,
batch_size_sampler=200,
online=True,
num_subg=50,
full=True,
):
self.g = g.cpu()
self.node_budget = node_budget
self.train_g: dgl.graph = g.subgraph(train_nid)
self.dn, self.num_subg = dn, num_subg
self.node_counter = th.zeros((self.train_g.num_nodes(),))
self.edge_counter = th.zeros((self.train_g.num_edges(),))
self.prob = None
self.num_subg_sampler = num_subg_sampler
self.batch_size_sampler = batch_size_sampler
self.num_workers_sampler = num_workers_sampler
self.train = False
self.online = online
self.full = full
assert (
self.num_subg_sampler >= self.batch_size_sampler
), "num_subg_sampler should be greater than batch_size_sampler"
graph_fn, norm_fn = self.__generate_fn__()
if os.path.exists(graph_fn):
self.subgraphs = np.load(graph_fn, allow_pickle=True)
aggr_norm, loss_norm = np.load(norm_fn, allow_pickle=True)
else:
os.makedirs("./subgraphs/", exist_ok=True)
self.subgraphs = []
self.N, sampled_nodes = 0, 0
# N: the number of pre-sampled subgraphs
# Employ parallelism to speed up the sampling procedure
loader = DataLoader(
self,
batch_size=self.batch_size_sampler,
shuffle=True,
num_workers=self.num_workers_sampler,
collate_fn=self.__collate_fn__,
drop_last=False,
)
t = time.perf_counter()
for num_nodes, subgraphs_nids, subgraphs_eids in loader:
self.subgraphs.extend(subgraphs_nids)
sampled_nodes += num_nodes
_subgraphs, _node_counts = np.unique(
np.concatenate(subgraphs_nids), return_counts=True
)
sampled_nodes_idx = th.from_numpy(_subgraphs)
_node_counts = th.from_numpy(_node_counts)
self.node_counter[sampled_nodes_idx] += _node_counts
_subgraphs_eids, _edge_counts = np.unique(
np.concatenate(subgraphs_eids), return_counts=True
)
sampled_edges_idx = th.from_numpy(_subgraphs_eids)
_edge_counts = th.from_numpy(_edge_counts)
self.edge_counter[sampled_edges_idx] += _edge_counts
self.N += len(subgraphs_nids) # number of subgraphs
if sampled_nodes > self.train_g.num_nodes() * num_subg:
break
print(f"Sampling time: [{time.perf_counter() - t:.2f}s]")
np.save(graph_fn, self.subgraphs)
t = time.perf_counter()
aggr_norm, loss_norm = self.__compute_norm__()
print(f"Normalization time: [{time.perf_counter() - t:.2f}s]")
np.save(norm_fn, (aggr_norm, loss_norm))
self.train_g.ndata["l_n"] = th.Tensor(loss_norm)
self.train_g.edata["w"] = th.Tensor(aggr_norm)
self.__compute_degree_norm() # basically normalizing adjacent matrix
random.shuffle(self.subgraphs)
self.__clear__()
print("The number of subgraphs is: ", len(self.subgraphs))
self.train = True
def __len__(self):
if self.train is False:
return self.num_subg_sampler
else:
if self.full:
return len(self.subgraphs)
else:
return math.ceil(self.train_g.num_nodes() / self.node_budget)
def __getitem__(self, idx):
# Only when sampling subgraphs in training procedure and need to utilize sampled subgraphs and we still
# have sampled subgraphs we can fetch a subgraph from sampled subgraphs
if self.train:
if self.online:
subgraph = self.__sample__()
return dgl.node_subgraph(self.train_g, subgraph)
else:
return dgl.node_subgraph(self.train_g, self.subgraphs[idx])
else:
subgraph_nids = self.__sample__()
num_nodes = len(subgraph_nids)
subgraph_eids = dgl.node_subgraph(
self.train_g, subgraph_nids
).edata[dgl.EID]
return num_nodes, subgraph_nids, subgraph_eids
def __collate_fn__(self, batch):
if (
self.train
): # sample only one graph each epoch, batch_size in training phase in 1
return batch[0]
else:
sum_num_nodes = 0
subgraphs_nids_list = []
subgraphs_eids_list = []
for num_nodes, subgraph_nids, subgraph_eids in batch:
sum_num_nodes += num_nodes
subgraphs_nids_list.append(subgraph_nids)
subgraphs_eids_list.append(subgraph_eids)
return sum_num_nodes, subgraphs_nids_list, subgraphs_eids_list
def __clear__(self):
self.prob = None
self.node_counter = None
self.edge_counter = None
self.g = None
def __generate_fn__(self):
raise NotImplementedError
def __compute_norm__(self):
self.node_counter[self.node_counter == 0] = 1
self.edge_counter[self.edge_counter == 0] = 1
loss_norm = self.N / self.node_counter / self.train_g.num_nodes()
self.train_g.ndata["n_c"] = self.node_counter
self.train_g.edata["e_c"] = self.edge_counter
self.train_g.apply_edges(fn.v_div_e("n_c", "e_c", "a_n"))
aggr_norm = self.train_g.edata.pop("a_n")
self.train_g.ndata.pop("n_c")
self.train_g.edata.pop("e_c")
return aggr_norm.numpy(), loss_norm.numpy()
def __compute_degree_norm(self):
self.train_g.ndata[
"train_D_norm"
] = 1.0 / self.train_g.in_degrees().float().clamp(min=1).unsqueeze(1)
self.g.ndata["full_D_norm"] = 1.0 / self.g.in_degrees().float().clamp(
min=1
).unsqueeze(1)
def __sample__(self):
raise NotImplementedError
class SAINTNodeSampler(SAINTSampler):
"""
Description
-----------
GraphSAINT with node sampler.
Parameters
----------
node_budget : int
the expected number of nodes in each subgraph, which is specifically explained in the paper.
"""
def __init__(self, node_budget, **kwargs):
self.node_budget = node_budget
super(SAINTNodeSampler, self).__init__(
node_budget=node_budget, **kwargs
)
def __generate_fn__(self):
graph_fn = os.path.join(
"./subgraphs/{}_Node_{}_{}.npy".format(
self.dn, self.node_budget, self.num_subg
)
)
norm_fn = os.path.join(
"./subgraphs/{}_Node_{}_{}_norm.npy".format(
self.dn, self.node_budget, self.num_subg
)
)
return graph_fn, norm_fn
def __sample__(self):
if self.prob is None:
self.prob = self.train_g.in_degrees().float().clamp(min=1)
sampled_nodes = th.multinomial(
self.prob, num_samples=self.node_budget, replacement=True
).unique()
return sampled_nodes.numpy()
class SAINTEdgeSampler(SAINTSampler):
"""
Description
-----------
GraphSAINT with edge sampler.
Parameters
----------
edge_budget : int
the expected number of edges in each subgraph, which is specifically explained in the paper.
"""
def __init__(self, edge_budget, **kwargs):
self.edge_budget = edge_budget
self.rng = np.random.default_rng()
super(SAINTEdgeSampler, self).__init__(
node_budget=edge_budget * 2, **kwargs
)
def __generate_fn__(self):
graph_fn = os.path.join(
"./subgraphs/{}_Edge_{}_{}.npy".format(
self.dn, self.edge_budget, self.num_subg
)
)
norm_fn = os.path.join(
"./subgraphs/{}_Edge_{}_{}_norm.npy".format(
self.dn, self.edge_budget, self.num_subg
)
)
return graph_fn, norm_fn
# TODO: only sample half edges, then add another half edges
# TODO: use numpy to implement cython sampling method
def __sample__(self):
if self.prob is None:
src, dst = self.train_g.edges()
src_degrees, dst_degrees = self.train_g.in_degrees(
src
).float().clamp(min=1), self.train_g.in_degrees(dst).float().clamp(
min=1
)
prob_mat = 1.0 / src_degrees + 1.0 / dst_degrees
prob_mat = scipy.sparse.csr_matrix(
(prob_mat.numpy(), (src.numpy(), dst.numpy()))
)
# The edge probability here only contains that of edges in upper triangle adjacency matrix
# Because we assume the graph is undirected, that is, the adjacency matrix is symmetric. We only need
# to consider half of edges in the graph.
self.prob = th.tensor(scipy.sparse.triu(prob_mat).data)
self.prob /= self.prob.sum()
self.adj_nodes = np.stack(prob_mat.nonzero(), axis=1)
sampled_edges = np.unique(
dgl.random.choice(
len(self.prob),
size=self.edge_budget,
prob=self.prob,
replace=False,
)
)
sampled_nodes = np.unique(
self.adj_nodes[sampled_edges].flatten()
).astype("long")
return sampled_nodes
class SAINTRandomWalkSampler(SAINTSampler):
"""
Description
-----------
GraphSAINT with random walk sampler
Parameters
----------
num_roots : int
the number of roots to generate random walks.
length : int
the length of each random walk.
"""
def __init__(self, num_roots, length, **kwargs):
self.num_roots, self.length = num_roots, length
super(SAINTRandomWalkSampler, self).__init__(
node_budget=num_roots * length, **kwargs
)
def __generate_fn__(self):
graph_fn = os.path.join(
"./subgraphs/{}_RW_{}_{}_{}.npy".format(
self.dn, self.num_roots, self.length, self.num_subg
)
)
norm_fn = os.path.join(
"./subgraphs/{}_RW_{}_{}_{}_norm.npy".format(
self.dn, self.num_roots, self.length, self.num_subg
)
)
return graph_fn, norm_fn
def __sample__(self):
sampled_roots = th.randint(
0, self.train_g.num_nodes(), (self.num_roots,)
)
traces, types = random_walk(
self.train_g, nodes=sampled_roots, length=self.length
)
sampled_nodes, _, _, _ = pack_traces(traces, types)
sampled_nodes = sampled_nodes.unique()
return sampled_nodes.numpy()