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

528 lines
17 KiB
Python

# /*!
# * Copyright (c) 2022, NVIDIA Corporation
# * Copyright (c) 2022, GT-TDAlab (Muhammed Fatih Balin & Umit V. Catalyurek)
# * All rights reserved.
# *
# * Licensed under the Apache License, Version 2.0 (the "License");
# * you may not use this file except in compliance with the License.
# * You may obtain a copy of the License at
# *
# * http://www.apache.org/licenses/LICENSE-2.0
# *
# * Unless required by applicable law or agreed to in writing, software
# * distributed under the License is distributed on an "AS IS" BASIS,
# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# * See the License for the specific language governing permissions and
# * limitations under the License.
# *
# * @file train_lightning.py
# * @brief labor sampling example
# */
import argparse
import glob
import math
import os
import time
import dgl
import torch as th
import torch.nn as nn
import torch.nn.functional as F
from ladies_sampler import LadiesSampler, normalized_edata, PoissonLadiesSampler
from load_graph import load_dataset
from model import SAGE
from pytorch_lightning import LightningDataModule, LightningModule, Trainer
from pytorch_lightning.callbacks import Callback, EarlyStopping, ModelCheckpoint
from pytorch_lightning.loggers import TensorBoardLogger
from torchmetrics.classification import MulticlassF1Score, MultilabelF1Score
class SAGELightning(LightningModule):
def __init__(
self,
in_feats,
n_hidden,
n_classes,
n_layers,
activation,
dropout,
lr,
multilabel,
):
super().__init__()
self.save_hyperparameters()
self.module = SAGE(
in_feats, n_hidden, n_classes, n_layers, activation, dropout
)
self.lr = lr
self.f1score_class = lambda: (
MulticlassF1Score if not multilabel else MultilabelF1Score
)(n_classes, average="micro")
self.train_acc = self.f1score_class()
self.val_acc = self.f1score_class()
self.num_steps = 0
self.cum_sampled_nodes = [0 for _ in range(n_layers + 1)]
self.cum_sampled_edges = [0 for _ in range(n_layers)]
self.w = 0.99
self.loss_fn = (
nn.CrossEntropyLoss() if not multilabel else nn.BCEWithLogitsLoss()
)
self.pt = 0
def num_sampled_nodes(self, i):
return (
self.cum_sampled_nodes[i] / self.num_steps
if self.w >= 1
else self.cum_sampled_nodes[i]
* (1 - self.w)
/ (1 - self.w**self.num_steps)
)
def num_sampled_edges(self, i):
return (
self.cum_sampled_edges[i] / self.num_steps
if self.w >= 1
else self.cum_sampled_edges[i]
* (1 - self.w)
/ (1 - self.w**self.num_steps)
)
def training_step(self, batch, batch_idx):
input_nodes, output_nodes, mfgs = batch
mfgs = [mfg.int().to(device) for mfg in mfgs]
self.num_steps += 1
for i, mfg in enumerate(mfgs):
self.cum_sampled_nodes[i] = (
self.cum_sampled_nodes[i] * self.w + mfg.num_src_nodes()
)
self.cum_sampled_edges[i] = (
self.cum_sampled_edges[i] * self.w + mfg.num_edges()
)
self.log(
"num_nodes/{}".format(i),
self.num_sampled_nodes(i),
prog_bar=True,
on_step=True,
on_epoch=False,
)
self.log(
"num_edges/{}".format(i),
self.num_sampled_edges(i),
prog_bar=True,
on_step=True,
on_epoch=False,
)
# for batch size monitoring
i = len(mfgs)
self.cum_sampled_nodes[i] = (
self.cum_sampled_nodes[i] * self.w + mfgs[-1].num_dst_nodes()
)
self.log(
"num_nodes/{}".format(i),
self.num_sampled_nodes(i),
prog_bar=True,
on_step=True,
on_epoch=False,
)
batch_inputs = mfgs[0].srcdata["features"]
batch_labels = mfgs[-1].dstdata["labels"]
self.st = time.time()
batch_pred = self.module(mfgs, batch_inputs)
loss = self.loss_fn(batch_pred, batch_labels)
self.train_acc(batch_pred, batch_labels.int())
self.log(
"train_acc",
self.train_acc,
prog_bar=True,
on_step=True,
on_epoch=True,
batch_size=batch_labels.shape[0],
)
self.log(
"train_loss",
loss,
on_step=True,
on_epoch=True,
batch_size=batch_labels.shape[0],
)
t = time.time()
self.log(
"iter_time",
t - self.pt,
prog_bar=True,
on_step=True,
on_epoch=False,
)
self.pt = t
return loss
def on_train_batch_end(self, outputs, batch, batch_idx):
self.log(
"forward_backward_time",
time.time() - self.st,
prog_bar=True,
on_step=True,
on_epoch=False,
)
def validation_step(self, batch, batch_idx, dataloader_idx=0):
input_nodes, output_nodes, mfgs = batch
mfgs = [mfg.int().to(device) for mfg in mfgs]
batch_inputs = mfgs[0].srcdata["features"]
batch_labels = mfgs[-1].dstdata["labels"]
batch_pred = self.module(mfgs, batch_inputs)
loss = self.loss_fn(batch_pred, batch_labels)
self.val_acc(batch_pred, batch_labels.int())
self.log(
"val_acc",
self.val_acc,
prog_bar=True,
on_step=False,
on_epoch=True,
sync_dist=True,
batch_size=batch_labels.shape[0],
)
self.log(
"val_loss",
loss,
on_step=False,
on_epoch=True,
sync_dist=True,
batch_size=batch_labels.shape[0],
)
def configure_optimizers(self):
optimizer = th.optim.Adam(self.parameters(), lr=self.lr)
return optimizer
class DataModule(LightningDataModule):
def __init__(
self,
dataset_name,
undirected,
data_cpu=False,
use_uva=False,
fan_out=[10, 25],
lad_out=[11000, 5000],
device=th.device("cpu"),
batch_size=1000,
num_workers=4,
sampler="labor",
importance_sampling=0,
layer_dependency=False,
batch_dependency=1,
cache_size=0,
):
super().__init__()
g, n_classes, multilabel = load_dataset(dataset_name)
if undirected:
src, dst = g.all_edges()
g.add_edges(dst, src)
cast_to_int = max(g.num_nodes(), g.num_edges()) <= 2e9
if cast_to_int:
g = g.int()
train_nid = th.nonzero(g.ndata["train_mask"], as_tuple=True)[0]
val_nid = th.nonzero(g.ndata["val_mask"], as_tuple=True)[0]
test_nid = th.nonzero(g.ndata["test_mask"], as_tuple=True)[0]
fanouts = [int(_) for _ in fan_out]
ladouts = [int(_) for _ in lad_out]
if sampler == "neighbor":
sampler = dgl.dataloading.NeighborSampler(
fanouts,
prefetch_node_feats=["features"],
prefetch_edge_feats=["etype"] if "etype" in g.edata else [],
prefetch_labels=["labels"],
)
elif "ladies" in sampler:
g.edata["w"] = normalized_edata(g)
sampler = (
PoissonLadiesSampler if "poisson" in sampler else LadiesSampler
)(ladouts)
else:
sampler = dgl.dataloading.LaborSampler(
fanouts,
importance_sampling=importance_sampling,
layer_dependency=layer_dependency,
batch_dependency=batch_dependency,
prefetch_node_feats=["features"],
prefetch_edge_feats=["etype"] if "etype" in g.edata else [],
prefetch_labels=["labels"],
)
dataloader_device = th.device("cpu")
g = g.formats(["csc"])
if use_uva or not data_cpu:
train_nid = train_nid.to(device)
val_nid = val_nid.to(device)
test_nid = test_nid.to(device)
if not data_cpu and not use_uva:
g = g.to(device)
dataloader_device = device
self.g = g
self.train_nid = train_nid.to(g.idtype)
self.val_nid = val_nid.to(g.idtype)
self.test_nid = test_nid.to(g.idtype)
self.sampler = sampler
self.device = dataloader_device
self.use_uva = use_uva
self.batch_size = batch_size
self.num_workers = num_workers
self.in_feats = g.ndata["features"].shape[1]
self.n_classes = n_classes
self.multilabel = multilabel
self.gpu_cache_arg = {"node": {"features": cache_size}}
def train_dataloader(self):
return dgl.dataloading.DataLoader(
self.g,
self.train_nid,
self.sampler,
device=self.device,
use_uva=self.use_uva,
batch_size=self.batch_size,
shuffle=True,
drop_last=True,
num_workers=self.num_workers,
gpu_cache=self.gpu_cache_arg,
)
def val_dataloader(self):
return dgl.dataloading.DataLoader(
self.g,
self.val_nid,
self.sampler,
device=self.device,
use_uva=self.use_uva,
batch_size=self.batch_size,
shuffle=False,
drop_last=False,
num_workers=self.num_workers,
gpu_cache=self.gpu_cache_arg,
)
class BatchSizeCallback(Callback):
def __init__(self, limit, factor=3):
super().__init__()
self.limit = limit
self.factor = factor
self.clear()
def clear(self):
self.n = 0
self.m = 0
self.s = 0
def push(self, x):
self.n += 1
m = self.m
self.m += (x - m) / self.n
self.s += (x - m) * (x - self.m)
@property
def var(self):
return self.s / (self.n - 1)
@property
def std(self):
return math.sqrt(self.var)
def on_train_batch_start(self, trainer, datamodule, batch, batch_idx):
input_nodes, output_nodes, mfgs = batch
features = mfgs[0].srcdata["features"]
if hasattr(features, "__cache_miss__"):
trainer.strategy.model.log(
"cache_miss",
features.__cache_miss__,
prog_bar=True,
on_step=True,
on_epoch=False,
)
def on_train_batch_end(
self, trainer, datamodule, outputs, batch, batch_idx
):
input_nodes, output_nodes, mfgs = batch
self.push(mfgs[0].num_src_nodes())
def on_train_epoch_end(self, trainer, datamodule):
if (
self.limit > 0
and self.n >= 2
and abs(self.limit - self.m) * self.n >= self.std * self.factor
):
trainer.datamodule.batch_size = int(
trainer.datamodule.batch_size * self.limit / self.m
)
loop = trainer._active_loop
assert loop is not None
loop._combined_loader = None
loop.setup_data()
self.clear()
if __name__ == "__main__":
argparser = argparse.ArgumentParser()
argparser.add_argument(
"--gpu",
type=int,
default=0 if th.cuda.is_available() else -1,
help="GPU device ID. Use -1 for CPU training",
)
argparser.add_argument("--dataset", type=str, default="reddit")
argparser.add_argument("--num-epochs", type=int, default=-1)
argparser.add_argument("--num-steps", type=int, default=-1)
argparser.add_argument("--min-steps", type=int, default=0)
argparser.add_argument("--num-hidden", type=int, default=256)
argparser.add_argument("--num-layers", type=int, default=3)
argparser.add_argument("--fan-out", type=str, default="10,10,10")
argparser.add_argument("--lad-out", type=str, default="16000,11000,5000")
argparser.add_argument("--batch-size", type=int, default=1024)
argparser.add_argument("--lr", type=float, default=0.001)
argparser.add_argument("--dropout", type=float, default=0.5)
argparser.add_argument(
"--num-workers",
type=int,
default=0,
help="Number of sampling processes. Use 0 for no extra process.",
)
argparser.add_argument(
"--data-cpu",
action="store_true",
help="By default the script puts the node features and labels "
"on GPU when using it to save time for data copy. This may "
"be undesired if they cannot fit in GPU memory at once. "
"This flag disables that.",
)
argparser.add_argument(
"--sampler",
type=str,
default="labor",
choices=["neighbor", "labor", "ladies", "poisson-ladies"],
)
argparser.add_argument("--importance-sampling", type=int, default=0)
argparser.add_argument("--layer-dependency", action="store_true")
argparser.add_argument("--batch-dependency", type=int, default=1)
argparser.add_argument("--logdir", type=str, default="tb_logs")
argparser.add_argument("--vertex-limit", type=int, default=-1)
argparser.add_argument("--use-uva", action="store_true")
argparser.add_argument("--cache-size", type=int, default=0)
argparser.add_argument("--undirected", action="store_true")
argparser.add_argument("--val-acc-target", type=float, default=1)
argparser.add_argument("--early-stopping-patience", type=int, default=10)
argparser.add_argument("--disable-checkpoint", action="store_true")
argparser.add_argument("--precision", type=str, default="highest")
args = argparser.parse_args()
if args.precision != "highest":
th.set_float32_matmul_precision(args.precision)
if args.gpu >= 0:
device = th.device("cuda:%d" % args.gpu)
else:
device = th.device("cpu")
datamodule = DataModule(
args.dataset,
args.undirected,
args.data_cpu,
args.use_uva,
[int(_) for _ in args.fan_out.split(",")],
[int(_) for _ in args.lad_out.split(",")],
device,
args.batch_size,
args.num_workers,
args.sampler,
args.importance_sampling,
args.layer_dependency,
args.batch_dependency,
args.cache_size,
)
model = SAGELightning(
datamodule.in_feats,
args.num_hidden,
datamodule.n_classes,
args.num_layers,
F.relu,
args.dropout,
args.lr,
datamodule.multilabel,
)
# Train
callbacks = []
if not args.disable_checkpoint:
callbacks.append(
ModelCheckpoint(monitor="val_acc", save_top_k=1, mode="max")
)
callbacks.append(BatchSizeCallback(args.vertex_limit))
callbacks.append(
EarlyStopping(
monitor="val_acc",
stopping_threshold=args.val_acc_target,
mode="max",
patience=args.early_stopping_patience,
)
)
subdir = "{}_{}_{}_{}_{}".format(
args.dataset,
args.sampler,
args.importance_sampling,
args.layer_dependency,
args.batch_dependency,
)
logger = TensorBoardLogger(args.logdir, name=subdir)
trainer = Trainer(
accelerator="gpu" if args.gpu != -1 else "cpu",
devices=[args.gpu] if args.gpu != -1 else "auto",
max_epochs=args.num_epochs,
max_steps=args.num_steps,
min_steps=args.min_steps,
callbacks=callbacks,
logger=logger,
)
trainer.fit(model, datamodule=datamodule)
# Test
if not args.disable_checkpoint:
logdir = os.path.join(args.logdir, subdir)
dirs = glob.glob("./{}/*".format(logdir))
version = max([int(os.path.split(x)[-1].split("_")[-1]) for x in dirs])
logdir = "./{}/version_{}".format(logdir, version)
print("Evaluating model in", logdir)
ckpt = glob.glob(os.path.join(logdir, "checkpoints", "*"))[0]
model = SAGELightning.load_from_checkpoint(
checkpoint_path=ckpt,
hparams_file=os.path.join(logdir, "hparams.yaml"),
).to(device)
with th.no_grad():
graph = datamodule.g
pred = model.module.inference(
graph,
f"cuda:{args.gpu}" if args.gpu != -1 else "cpu",
4096,
args.use_uva,
args.num_workers,
)
for nid, split_name in zip(
[datamodule.train_nid, datamodule.val_nid, datamodule.test_nid],
["Train", "Validation", "Test"],
):
nid = nid.to(pred.device).long()
pred_nid = pred[nid]
label = graph.ndata["labels"][nid]
f1score = model.f1score_class().to(pred.device)
acc = f1score(pred_nid, label)
print(f"{split_name} accuracy: {acc.item()}")