Note
Click here to download the full example code
Training GNN with Neighbor Sampling for Node Classification¶
This tutorial shows how to train a multi-layer GraphSAGE for node
classification on ogbn-arxiv
provided by Open Graph
Benchmark (OGB). The dataset contains around
170 thousand nodes and 1 million edges.
By the end of this tutorial, you will be able to
Train a GNN model for node classification on a single GPU with DGL’s neighbor sampling components.
This tutorial assumes that you have read the Introduction of Neighbor Sampling for GNN Training.
Loading Dataset¶
OGB already prepared the data as DGL graph.
import dgl
import torch
import numpy as np
from ogb.nodeproppred import DglNodePropPredDataset
dataset = DglNodePropPredDataset('ogbn-arxiv')
device = 'cpu' # change to 'cuda' for GPU
Out:
WARNING:root:The OGB package is out of date. Your version is 1.2.4, while the latest version is 1.3.1.
Downloading https://snap.stanford.edu/ogb/data/nodeproppred/arxiv.zip
0%| | 0/81 [00:00<?, ?it/s]
Downloaded 0.00 GB: 0%| | 0/81 [00:00<?, ?it/s]
Downloaded 0.00 GB: 1%|1 | 1/81 [00:00<00:20, 3.99it/s]
Downloaded 0.00 GB: 1%|1 | 1/81 [00:00<00:20, 3.99it/s]
Downloaded 0.00 GB: 1%|1 | 1/81 [00:00<00:20, 3.99it/s]
Downloaded 0.00 GB: 1%|1 | 1/81 [00:00<00:20, 3.99it/s]
Downloaded 0.00 GB: 5%|4 | 4/81 [00:00<00:14, 5.35it/s]
Downloaded 0.00 GB: 5%|4 | 4/81 [00:00<00:14, 5.35it/s]
Downloaded 0.01 GB: 5%|4 | 4/81 [00:00<00:14, 5.35it/s]
Downloaded 0.01 GB: 5%|4 | 4/81 [00:00<00:14, 5.35it/s]
Downloaded 0.01 GB: 5%|4 | 4/81 [00:00<00:14, 5.35it/s]
Downloaded 0.01 GB: 5%|4 | 4/81 [00:00<00:14, 5.35it/s]
Downloaded 0.01 GB: 11%|#1 | 9/81 [00:00<00:09, 7.28it/s]
Downloaded 0.01 GB: 11%|#1 | 9/81 [00:00<00:09, 7.28it/s]
Downloaded 0.01 GB: 11%|#1 | 9/81 [00:00<00:09, 7.28it/s]
Downloaded 0.01 GB: 11%|#1 | 9/81 [00:00<00:09, 7.28it/s]
Downloaded 0.01 GB: 11%|#1 | 9/81 [00:00<00:09, 7.28it/s]
Downloaded 0.01 GB: 11%|#1 | 9/81 [00:00<00:09, 7.28it/s]
Downloaded 0.01 GB: 17%|#7 | 14/81 [00:00<00:06, 9.63it/s]
Downloaded 0.01 GB: 17%|#7 | 14/81 [00:00<00:06, 9.63it/s]
Downloaded 0.02 GB: 17%|#7 | 14/81 [00:00<00:06, 9.63it/s]
Downloaded 0.02 GB: 17%|#7 | 14/81 [00:00<00:06, 9.63it/s]
Downloaded 0.02 GB: 21%|## | 17/81 [00:00<00:05, 12.05it/s]
Downloaded 0.02 GB: 21%|## | 17/81 [00:00<00:05, 12.05it/s]
Downloaded 0.02 GB: 21%|## | 17/81 [00:00<00:05, 12.05it/s]
Downloaded 0.02 GB: 21%|## | 17/81 [00:00<00:05, 12.05it/s]
Downloaded 0.02 GB: 21%|## | 17/81 [00:00<00:05, 12.05it/s]
Downloaded 0.02 GB: 26%|##5 | 21/81 [00:00<00:04, 14.73it/s]
Downloaded 0.02 GB: 26%|##5 | 21/81 [00:00<00:04, 14.73it/s]
Downloaded 0.02 GB: 26%|##5 | 21/81 [00:00<00:04, 14.73it/s]
Downloaded 0.02 GB: 26%|##5 | 21/81 [00:00<00:04, 14.73it/s]
Downloaded 0.02 GB: 26%|##5 | 21/81 [00:00<00:04, 14.73it/s]
Downloaded 0.02 GB: 31%|### | 25/81 [00:00<00:03, 17.51it/s]
Downloaded 0.03 GB: 31%|### | 25/81 [00:00<00:03, 17.51it/s]
Downloaded 0.03 GB: 31%|### | 25/81 [00:01<00:03, 17.51it/s]
Downloaded 0.03 GB: 31%|### | 25/81 [00:01<00:03, 17.51it/s]
Downloaded 0.03 GB: 31%|### | 25/81 [00:01<00:03, 17.51it/s]
Downloaded 0.03 GB: 36%|###5 | 29/81 [00:01<00:02, 20.15it/s]
Downloaded 0.03 GB: 36%|###5 | 29/81 [00:01<00:02, 20.15it/s]
Downloaded 0.03 GB: 36%|###5 | 29/81 [00:01<00:02, 20.15it/s]
Downloaded 0.03 GB: 36%|###5 | 29/81 [00:01<00:02, 20.15it/s]
Downloaded 0.03 GB: 36%|###5 | 29/81 [00:01<00:02, 20.15it/s]
Downloaded 0.03 GB: 41%|#### | 33/81 [00:01<00:02, 22.63it/s]
Downloaded 0.03 GB: 41%|#### | 33/81 [00:01<00:02, 22.63it/s]
Downloaded 0.03 GB: 41%|#### | 33/81 [00:01<00:02, 22.63it/s]
Downloaded 0.04 GB: 41%|#### | 33/81 [00:01<00:02, 22.63it/s]
Downloaded 0.04 GB: 41%|#### | 33/81 [00:01<00:02, 22.63it/s]
Downloaded 0.04 GB: 46%|####5 | 37/81 [00:01<00:01, 24.95it/s]
Downloaded 0.04 GB: 46%|####5 | 37/81 [00:01<00:01, 24.95it/s]
Downloaded 0.04 GB: 46%|####5 | 37/81 [00:01<00:01, 24.95it/s]
Downloaded 0.04 GB: 46%|####5 | 37/81 [00:01<00:01, 24.95it/s]
Downloaded 0.04 GB: 46%|####5 | 37/81 [00:01<00:01, 24.95it/s]
Downloaded 0.04 GB: 51%|##### | 41/81 [00:01<00:01, 26.85it/s]
Downloaded 0.04 GB: 51%|##### | 41/81 [00:01<00:01, 26.85it/s]
Downloaded 0.04 GB: 51%|##### | 41/81 [00:01<00:01, 26.85it/s]
Downloaded 0.04 GB: 51%|##### | 41/81 [00:01<00:01, 26.85it/s]
Downloaded 0.04 GB: 51%|##### | 41/81 [00:01<00:01, 26.85it/s]
Downloaded 0.04 GB: 56%|#####5 | 45/81 [00:01<00:01, 28.52it/s]
Downloaded 0.04 GB: 56%|#####5 | 45/81 [00:01<00:01, 28.52it/s]
Downloaded 0.05 GB: 56%|#####5 | 45/81 [00:01<00:01, 28.52it/s]
Downloaded 0.05 GB: 56%|#####5 | 45/81 [00:01<00:01, 28.52it/s]
Downloaded 0.05 GB: 56%|#####5 | 45/81 [00:01<00:01, 28.52it/s]
Downloaded 0.05 GB: 60%|###### | 49/81 [00:01<00:01, 27.91it/s]
Downloaded 0.05 GB: 60%|###### | 49/81 [00:01<00:01, 27.91it/s]
Downloaded 0.05 GB: 60%|###### | 49/81 [00:01<00:01, 27.91it/s]
Downloaded 0.05 GB: 60%|###### | 49/81 [00:01<00:01, 27.91it/s]
Downloaded 0.05 GB: 60%|###### | 49/81 [00:01<00:01, 27.91it/s]
Downloaded 0.05 GB: 65%|######5 | 53/81 [00:01<00:01, 26.47it/s]
Downloaded 0.05 GB: 65%|######5 | 53/81 [00:01<00:01, 26.47it/s]
Downloaded 0.05 GB: 65%|######5 | 53/81 [00:01<00:01, 26.47it/s]
Downloaded 0.05 GB: 65%|######5 | 53/81 [00:02<00:01, 26.47it/s]
Downloaded 0.05 GB: 69%|######9 | 56/81 [00:02<00:00, 25.62it/s]
Downloaded 0.06 GB: 69%|######9 | 56/81 [00:02<00:00, 25.62it/s]
Downloaded 0.06 GB: 69%|######9 | 56/81 [00:02<00:00, 25.62it/s]
Downloaded 0.06 GB: 69%|######9 | 56/81 [00:02<00:00, 25.62it/s]
Downloaded 0.06 GB: 73%|#######2 | 59/81 [00:02<00:00, 23.62it/s]
Downloaded 0.06 GB: 73%|#######2 | 59/81 [00:02<00:00, 23.62it/s]
Downloaded 0.06 GB: 73%|#######2 | 59/81 [00:02<00:00, 23.62it/s]
Downloaded 0.06 GB: 73%|#######2 | 59/81 [00:02<00:00, 23.62it/s]
Downloaded 0.06 GB: 77%|#######6 | 62/81 [00:02<00:00, 20.99it/s]
Downloaded 0.06 GB: 77%|#######6 | 62/81 [00:02<00:00, 20.99it/s]
Downloaded 0.06 GB: 77%|#######6 | 62/81 [00:02<00:00, 20.99it/s]
Downloaded 0.06 GB: 77%|#######6 | 62/81 [00:02<00:00, 20.99it/s]
Downloaded 0.06 GB: 80%|######## | 65/81 [00:02<00:00, 19.59it/s]
Downloaded 0.06 GB: 80%|######## | 65/81 [00:02<00:00, 19.59it/s]
Downloaded 0.07 GB: 80%|######## | 65/81 [00:02<00:00, 19.59it/s]
Downloaded 0.07 GB: 80%|######## | 65/81 [00:02<00:00, 19.59it/s]
Downloaded 0.07 GB: 84%|########3 | 68/81 [00:02<00:00, 18.76it/s]
Downloaded 0.07 GB: 84%|########3 | 68/81 [00:02<00:00, 18.76it/s]
Downloaded 0.07 GB: 84%|########3 | 68/81 [00:02<00:00, 18.76it/s]
Downloaded 0.07 GB: 86%|########6 | 70/81 [00:02<00:00, 18.45it/s]
Downloaded 0.07 GB: 86%|########6 | 70/81 [00:02<00:00, 18.45it/s]
Downloaded 0.07 GB: 86%|########6 | 70/81 [00:02<00:00, 18.45it/s]
Downloaded 0.07 GB: 89%|########8 | 72/81 [00:02<00:00, 18.20it/s]
Downloaded 0.07 GB: 89%|########8 | 72/81 [00:02<00:00, 18.20it/s]
Downloaded 0.07 GB: 89%|########8 | 72/81 [00:03<00:00, 18.20it/s]
Downloaded 0.07 GB: 91%|#########1| 74/81 [00:03<00:00, 16.77it/s]
Downloaded 0.07 GB: 91%|#########1| 74/81 [00:03<00:00, 16.77it/s]
Downloaded 0.07 GB: 91%|#########1| 74/81 [00:03<00:00, 16.77it/s]
Downloaded 0.07 GB: 94%|#########3| 76/81 [00:03<00:00, 13.91it/s]
Downloaded 0.08 GB: 94%|#########3| 76/81 [00:03<00:00, 13.91it/s]
Downloaded 0.08 GB: 94%|#########3| 76/81 [00:03<00:00, 13.91it/s]
Downloaded 0.08 GB: 96%|#########6| 78/81 [00:03<00:00, 12.03it/s]
Downloaded 0.08 GB: 96%|#########6| 78/81 [00:03<00:00, 12.03it/s]
Downloaded 0.08 GB: 96%|#########6| 78/81 [00:03<00:00, 12.03it/s]
Downloaded 0.08 GB: 99%|#########8| 80/81 [00:03<00:00, 13.05it/s]
Downloaded 0.08 GB: 99%|#########8| 80/81 [00:03<00:00, 13.05it/s]
Downloaded 0.08 GB: 100%|##########| 81/81 [00:03<00:00, 22.37it/s]
Extracting dataset/arxiv.zip
Loading necessary files...
This might take a while.
Processing graphs...
0%| | 0/1 [00:00<?, ?it/s]
100%|##########| 1/1 [00:00<00:00, 7319.90it/s]
Converting graphs into DGL objects...
0%| | 0/1 [00:00<?, ?it/s]
100%|##########| 1/1 [00:00<00:00, 87.06it/s]
Saving...
OGB dataset is a collection of graphs and their labels. ogbn-arxiv
dataset only contains a single graph. So you can
simply get the graph and its node labels like this:
graph, node_labels = dataset[0]
# Add reverse edges since ogbn-arxiv is unidirectional.
graph = dgl.add_reverse_edges(graph)
graph.ndata['label'] = node_labels[:, 0]
print(graph)
print(node_labels)
node_features = graph.ndata['feat']
num_features = node_features.shape[1]
num_classes = (node_labels.max() + 1).item()
print('Number of classes:', num_classes)
Out:
Graph(num_nodes=169343, num_edges=2332486,
ndata_schemes={'year': Scheme(shape=(1,), dtype=torch.int64), 'feat': Scheme(shape=(128,), dtype=torch.float32), 'label': Scheme(shape=(), dtype=torch.int64)}
edata_schemes={})
tensor([[ 4],
[ 5],
[28],
...,
[10],
[ 4],
[ 1]])
Number of classes: 40
You can get the training-validation-test split of the nodes with
get_split_idx
method.
How DGL Handles Computation Dependency¶
In the previous tutorial, you have seen that the computation dependency for message passing of a single node can be described as a series of message flow graphs (MFG).
Defining Neighbor Sampler and Data Loader in DGL¶
DGL provides tools to iterate over the dataset in minibatches
while generating the computation dependencies to compute their outputs
with the MFGs above. For node classification, you can use
dgl.dataloading.NodeDataLoader
for iterating over the dataset.
It accepts a sampler object to control how to generate the computation
dependencies in the form of MFGs. DGL provides
implementations of common sampling algorithms such as
dgl.dataloading.MultiLayerNeighborSampler
which randomly picks
a fixed number of neighbors for each node.
Note
To write your own neighbor sampler, please refer to this user guide section.
The syntax of dgl.dataloading.NodeDataLoader
is mostly similar to a
PyTorch DataLoader
, with the addition that it needs a graph to
generate computation dependency from, a set of node IDs to iterate on,
and the neighbor sampler you defined.
Let’s say that each node will gather messages from 4 neighbors on each layer. The code defining the data loader and neighbor sampler will look like the following.
sampler = dgl.dataloading.MultiLayerNeighborSampler([4, 4])
train_dataloader = dgl.dataloading.NodeDataLoader(
# The following arguments are specific to NodeDataLoader.
graph, # The graph
train_nids, # The node IDs to iterate over in minibatches
sampler, # The neighbor sampler
device=device, # Put the sampled MFGs on CPU or GPU
# The following arguments are inherited from PyTorch DataLoader.
batch_size=1024, # Batch size
shuffle=True, # Whether to shuffle the nodes for every epoch
drop_last=False, # Whether to drop the last incomplete batch
num_workers=0 # Number of sampler processes
)
Note
Since DGL 0.7 neighborhood sampling on GPU is supported. Please refer to 6.7 Using GPU for Neighborhood Sampling if you are interested.
You can iterate over the data loader and see what it yields.
input_nodes, output_nodes, mfgs = example_minibatch = next(iter(train_dataloader))
print(example_minibatch)
print("To compute {} nodes' outputs, we need {} nodes' input features".format(len(output_nodes), len(input_nodes)))
Out:
[tensor([164062, 78180, 127778, ..., 11504, 46101, 148422]), tensor([164062, 78180, 127778, ..., 109947, 133918, 106121]), [Block(num_src_nodes=12715, num_dst_nodes=4083, num_edges=14719), Block(num_src_nodes=4083, num_dst_nodes=1024, num_edges=3263)]]
To compute 1024 nodes' outputs, we need 12715 nodes' input features
NodeDataLoader
gives us three items per iteration.
An ID tensor for the input nodes, i.e., nodes whose input features are needed on the first GNN layer for this minibatch.
An ID tensor for the output nodes, i.e. nodes whose representations are to be computed.
A list of MFGs storing the computation dependencies for each GNN layer.
You can get the source and destination node IDs of the MFGs and verify that the first few source nodes are always the same as the destination nodes. As we described in the overview, destination nodes’ own features from the previous layer may also be necessary in the computation of the new features.
Out:
tensor([164062, 78180, 127778, ..., 11504, 46101, 148422])
tensor([164062, 78180, 127778, ..., 30314, 16044, 142640])
True
Defining Model¶
Let’s consider training a 2-layer GraphSAGE with neighbor sampling. The model can be written as follows:
import torch.nn as nn
import torch.nn.functional as F
from dgl.nn import SAGEConv
class Model(nn.Module):
def __init__(self, in_feats, h_feats, num_classes):
super(Model, self).__init__()
self.conv1 = SAGEConv(in_feats, h_feats, aggregator_type='mean')
self.conv2 = SAGEConv(h_feats, num_classes, aggregator_type='mean')
self.h_feats = h_feats
def forward(self, mfgs, x):
# Lines that are changed are marked with an arrow: "<---"
h_dst = x[:mfgs[0].num_dst_nodes()] # <---
h = self.conv1(mfgs[0], (x, h_dst)) # <---
h = F.relu(h)
h_dst = h[:mfgs[1].num_dst_nodes()] # <---
h = self.conv2(mfgs[1], (h, h_dst)) # <---
return h
model = Model(num_features, 128, num_classes).to(device)
If you compare against the code in the introduction, you will notice several differences:
DGL GNN layers on MFGs. Instead of computing on the full graph:
h = self.conv1(g, x)
you only compute on the sampled MFG:
h = self.conv1(mfgs[0], (x, h_dst))
All DGL’s GNN modules support message passing on MFGs, where you supply a pair of features, one for source nodes and another for destination nodes.
Feature slicing for self-dependency. There are statements that perform slicing to obtain the previous-layer representation of the
nodes:
h_dst = x[:mfgs[0].num_dst_nodes()]
num_dst_nodes
method works with MFGs, where it will return the number of destination nodes.Since the first few source nodes of the yielded MFG are always the same as the destination nodes, these statements obtain the representations of the destination nodes on the previous layer. They are then combined with neighbor aggregation in
dgl.nn.SAGEConv
layer.
Note
See the custom message passing
tutorial for more details on how to
manipulate MFGs produced in this way, such as the usage
of num_dst_nodes
.
Defining Training Loop¶
The following initializes the model and defines the optimizer.
opt = torch.optim.Adam(model.parameters())
When computing the validation score for model selection, usually you can also do neighbor sampling. To do that, you need to define another data loader.
The following is a training loop that performs validation every epoch. It also saves the model with the best validation accuracy into a file.
import tqdm
import sklearn.metrics
best_accuracy = 0
best_model_path = 'model.pt'
for epoch in range(10):
model.train()
with tqdm.tqdm(train_dataloader) as tq:
for step, (input_nodes, output_nodes, mfgs) in enumerate(tq):
# feature copy from CPU to GPU takes place here
inputs = mfgs[0].srcdata['feat']
labels = mfgs[-1].dstdata['label']
predictions = model(mfgs, inputs)
loss = F.cross_entropy(predictions, labels)
opt.zero_grad()
loss.backward()
opt.step()
accuracy = sklearn.metrics.accuracy_score(labels.cpu().numpy(), predictions.argmax(1).detach().cpu().numpy())
tq.set_postfix({'loss': '%.03f' % loss.item(), 'acc': '%.03f' % accuracy}, refresh=False)
model.eval()
predictions = []
labels = []
with tqdm.tqdm(valid_dataloader) as tq, torch.no_grad():
for input_nodes, output_nodes, mfgs in tq:
inputs = mfgs[0].srcdata['feat']
labels.append(mfgs[-1].dstdata['label'].cpu().numpy())
predictions.append(model(mfgs, inputs).argmax(1).cpu().numpy())
predictions = np.concatenate(predictions)
labels = np.concatenate(labels)
accuracy = sklearn.metrics.accuracy_score(labels, predictions)
print('Epoch {} Validation Accuracy {}'.format(epoch, accuracy))
if best_accuracy < accuracy:
best_accuracy = accuracy
torch.save(model.state_dict(), best_model_path)
# Note that this tutorial do not train the whole model to the end.
break
Out:
0%| | 0/89 [00:00<?, ?it/s]
3%|3 | 3/89 [00:00<00:04, 21.41it/s, loss=3.624, acc=0.060]
7%|6 | 6/89 [00:00<00:03, 22.79it/s, loss=3.253, acc=0.182]
10%|# | 9/89 [00:00<00:03, 23.19it/s, loss=3.193, acc=0.178]
13%|#3 | 12/89 [00:00<00:03, 23.26it/s, loss=3.000, acc=0.226]
17%|#6 | 15/89 [00:00<00:03, 23.10it/s, loss=2.962, acc=0.245]
20%|## | 18/89 [00:00<00:03, 23.28it/s, loss=2.792, acc=0.293]
24%|##3 | 21/89 [00:00<00:02, 23.03it/s, loss=2.763, acc=0.284]
27%|##6 | 24/89 [00:01<00:02, 23.58it/s, loss=2.818, acc=0.270]
30%|### | 27/89 [00:01<00:02, 24.45it/s, loss=2.650, acc=0.298]
34%|###3 | 30/89 [00:01<00:02, 23.92it/s, loss=2.548, acc=0.335]
37%|###7 | 33/89 [00:01<00:02, 24.00it/s, loss=2.582, acc=0.314]
40%|#### | 36/89 [00:01<00:02, 24.06it/s, loss=2.493, acc=0.347]
44%|####3 | 39/89 [00:01<00:02, 24.10it/s, loss=2.444, acc=0.377]
47%|####7 | 42/89 [00:01<00:01, 24.33it/s, loss=2.399, acc=0.395]
51%|##### | 45/89 [00:01<00:01, 24.80it/s, loss=2.371, acc=0.383]
54%|#####3 | 48/89 [00:01<00:01, 24.38it/s, loss=2.230, acc=0.396]
57%|#####7 | 51/89 [00:02<00:01, 23.86it/s, loss=2.338, acc=0.375]
61%|###### | 54/89 [00:02<00:01, 24.67it/s, loss=2.248, acc=0.425]
64%|######4 | 57/89 [00:02<00:01, 24.74it/s, loss=2.177, acc=0.441]
67%|######7 | 60/89 [00:02<00:01, 24.84it/s, loss=2.244, acc=0.422]
71%|####### | 63/89 [00:02<00:01, 24.51it/s, loss=1.994, acc=0.479]
74%|#######4 | 66/89 [00:02<00:00, 24.28it/s, loss=2.000, acc=0.474]
78%|#######7 | 69/89 [00:02<00:00, 24.53it/s, loss=2.135, acc=0.452]
81%|######## | 72/89 [00:02<00:00, 25.16it/s, loss=2.003, acc=0.514]
84%|########4 | 75/89 [00:03<00:00, 25.35it/s, loss=1.963, acc=0.479]
88%|########7 | 78/89 [00:03<00:00, 24.28it/s, loss=1.893, acc=0.487]
91%|#########1| 81/89 [00:03<00:00, 24.23it/s, loss=1.941, acc=0.499]
94%|#########4| 84/89 [00:03<00:00, 24.24it/s, loss=1.904, acc=0.515]
98%|#########7| 87/89 [00:03<00:00, 24.46it/s, loss=1.845, acc=0.520]
100%|##########| 89/89 [00:03<00:00, 24.29it/s, loss=1.856, acc=0.498]
0%| | 0/30 [00:00<?, ?it/s]
17%|#6 | 5/30 [00:00<00:00, 44.67it/s]
33%|###3 | 10/30 [00:00<00:00, 45.65it/s]
50%|##### | 15/30 [00:00<00:00, 45.69it/s]
67%|######6 | 20/30 [00:00<00:00, 46.37it/s]
83%|########3 | 25/30 [00:00<00:00, 46.86it/s]
100%|##########| 30/30 [00:00<00:00, 48.07it/s]
Epoch 0 Validation Accuracy 0.5300177858317393
Conclusion¶
In this tutorial, you have learned how to train a multi-layer GraphSAGE with neighbor sampling.
What’s next?¶
During inference you may wish to disable neighbor sampling. If so, please refer to the user guide on exact offline inference.
# Thumbnail credits: Stanford CS224W Notes
# sphinx_gallery_thumbnail_path = '_static/blitz_1_introduction.png'
Total running time of the script: ( 0 minutes 12.781 seconds)