├── run.sh ├── ckpt └── model.pt ├── .gitignore ├── requirements.txt ├── subgraph_mining ├── config.py ├── decoder.py └── search_agents.py ├── analyze ├── .ipynb_checkpoints │ └── Visualize Graph Statistics-checkpoint.ipynb ├── analyze_pattern_counts.py ├── count_patterns.py └── Visualize Graph Statistics.ipynb ├── subgraph_matching ├── config.py ├── alignment.py ├── hyp_search.py ├── test.py └── train.py ├── README.md └── common ├── combined_syn.py ├── feature_preprocess.py ├── utils.py ├── models.py └── data.py /run.sh: -------------------------------------------------------------------------------- 1 | python3 -m subgraph_matching.train 2 | -------------------------------------------------------------------------------- /ckpt/model.pt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snap-stanford/neural-subgraph-learning-GNN/HEAD/ckpt/model.pt -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | env/ 3 | log/ 4 | log-*/ 5 | *.p 6 | *.png 7 | plots/ 8 | data/ 9 | runs/ 10 | results/ 11 | *.sw? 12 | orca/ 13 | core 14 | deepsnap-experimental/ 15 | ckpt/ 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | deepsnap == 0.1.2 2 | matplotlib == 2.1.1 3 | networkx == 2.4 4 | numpy == 1.13.3 5 | scikit_learn == 0.21.3 6 | scipy == 0.19.1 7 | seaborn == 0.9.0 8 | torch == 1.4.0 9 | torch_geometric == 1.4.3 10 | test_tube == 0.7.5 11 | tqdm == 4.43.0 12 | -------------------------------------------------------------------------------- /subgraph_mining/config.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from common import utils 3 | 4 | def parse_decoder(parser): 5 | dec_parser = parser.add_argument_group() 6 | dec_parser.add_argument('--sample_method', type=str, 7 | help='"tree" or "radial"') 8 | dec_parser.add_argument('--motif_dataset', type=str, 9 | help='Motif dataset') 10 | dec_parser.add_argument('--radius', type=int, 11 | help='radius of node neighborhoods') 12 | dec_parser.add_argument('--subgraph_sample_size', type=int, 13 | help='number of nodes to take from each neighborhood') 14 | dec_parser.add_argument('--out_path', type=str, 15 | help='path to output candidate motifs') 16 | dec_parser.add_argument('--n_clusters', type=int) 17 | dec_parser.add_argument('--min_pattern_size', type=int) 18 | dec_parser.add_argument('--max_pattern_size', type=int) 19 | dec_parser.add_argument('--min_neighborhood_size', type=int) 20 | dec_parser.add_argument('--max_neighborhood_size', type=int) 21 | dec_parser.add_argument('--n_neighborhoods', type=int) 22 | dec_parser.add_argument('--n_trials', type=int, 23 | help='number of search trials to run') 24 | dec_parser.add_argument('--out_batch_size', type=int, 25 | help='number of motifs to output per graph size') 26 | dec_parser.add_argument('--analyze', action="store_true") 27 | dec_parser.add_argument('--search_strategy', type=str, 28 | help='"greedy" or "mcts"') 29 | dec_parser.add_argument('--use_whole_graphs', action="store_true", 30 | help="whether to cluster whole graphs or sampled node neighborhoods") 31 | 32 | dec_parser.set_defaults(out_path="results/out-patterns.p", 33 | n_neighborhoods=10000, 34 | n_trials=1000, 35 | decode_thresh=0.5, 36 | radius=3, 37 | subgraph_sample_size=0, 38 | sample_method="tree", 39 | skip="learnable", 40 | min_pattern_size=5, 41 | max_pattern_size=20, 42 | min_neighborhood_size=20, 43 | max_neighborhood_size=29, 44 | search_strategy="greedy", 45 | out_batch_size=10, 46 | node_anchored=True) 47 | 48 | parser.set_defaults(dataset="enzymes", 49 | batch_size=1000) 50 | -------------------------------------------------------------------------------- /analyze/.ipynb_checkpoints/Visualize Graph Statistics-checkpoint.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 2, 6 | "metadata": {}, 7 | "outputs": [ 8 | { 9 | "ename": "NameError", 10 | "evalue": "name 'data' is not defined", 11 | "output_type": "error", 12 | "traceback": [ 13 | "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", 14 | "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", 15 | "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 4\u001b[0m \u001b[0;32mimport\u001b[0m \u001b[0mpickle\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 5\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 6\u001b[0;31m \u001b[0mtrain\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mtest\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mtask\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mload_dataset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m\"enzymes\"\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 7\u001b[0m \u001b[0mdata\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m[\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 8\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0mi\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mrange\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;36m1000\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", 16 | "\u001b[0;31mNameError\u001b[0m: name 'data' is not defined" 17 | ] 18 | } 19 | ], 20 | "source": [ 21 | "import networkx as nx\n", 22 | "import matplotlib\n", 23 | "import matplotlib.pyplot as plt\n", 24 | "import pickle\n", 25 | "from common import data\n", 26 | "\n", 27 | "train, test, task = data.load_dataset(\"enzymes\")\n", 28 | "graphs = []\n", 29 | "for i in range(1000):\n", 30 | " graph, neigh = utils.sample_neigh(train, random.randint(3, 29))\n", 31 | " graphs.append(graph.subgraph(neigh))\n", 32 | " \n", 33 | "clustering = [nx.average_clustering(G.subgraph(max(nx.connected_components(G), key=len))) for G in graphs]\n", 34 | "path_length = [nx.average_shortest_path_length(G.subgraph(max(nx.connected_components(G), key=len))) for G in graphs]\n", 35 | "plt.scatter(clustering, path_length, s=10)\n", 36 | "plt.show()" 37 | ] 38 | }, 39 | { 40 | "cell_type": "code", 41 | "execution_count": null, 42 | "metadata": {}, 43 | "outputs": [], 44 | "source": [] 45 | } 46 | ], 47 | "metadata": { 48 | "kernelspec": { 49 | "display_name": "Python 3", 50 | "language": "python", 51 | "name": "python3" 52 | }, 53 | "language_info": { 54 | "codemirror_mode": { 55 | "name": "ipython", 56 | "version": 3 57 | }, 58 | "file_extension": ".py", 59 | "mimetype": "text/x-python", 60 | "name": "python", 61 | "nbconvert_exporter": "python", 62 | "pygments_lexer": "ipython3", 63 | "version": "3.6.8" 64 | } 65 | }, 66 | "nbformat": 4, 67 | "nbformat_minor": 2 68 | } 69 | -------------------------------------------------------------------------------- /subgraph_matching/config.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | from common import utils 3 | 4 | def parse_encoder(parser, arg_str=None): 5 | enc_parser = parser.add_argument_group() 6 | #utils.parse_optimizer(parser) 7 | 8 | enc_parser.add_argument('--conv_type', type=str, 9 | help='type of convolution') 10 | enc_parser.add_argument('--method_type', type=str, 11 | help='type of embedding') 12 | enc_parser.add_argument('--batch_size', type=int, 13 | help='Training batch size') 14 | enc_parser.add_argument('--n_layers', type=int, 15 | help='Number of graph conv layers') 16 | enc_parser.add_argument('--hidden_dim', type=int, 17 | help='Training hidden size') 18 | enc_parser.add_argument('--skip', type=str, 19 | help='"all" or "last"') 20 | enc_parser.add_argument('--dropout', type=float, 21 | help='Dropout rate') 22 | enc_parser.add_argument('--n_batches', type=int, 23 | help='Number of training minibatches') 24 | enc_parser.add_argument('--margin', type=float, 25 | help='margin for loss') 26 | enc_parser.add_argument('--dataset', type=str, 27 | help='Dataset') 28 | enc_parser.add_argument('--test_set', type=str, 29 | help='test set filename') 30 | enc_parser.add_argument('--eval_interval', type=int, 31 | help='how often to eval during training') 32 | enc_parser.add_argument('--val_size', type=int, 33 | help='validation set size') 34 | enc_parser.add_argument('--model_path', type=str, 35 | help='path to save/load model') 36 | enc_parser.add_argument('--opt_scheduler', type=str, 37 | help='scheduler name') 38 | enc_parser.add_argument('--node_anchored', action="store_true", 39 | help='whether to use node anchoring in training') 40 | enc_parser.add_argument('--test', action="store_true") 41 | enc_parser.add_argument('--n_workers', type=int) 42 | enc_parser.add_argument('--tag', type=str, 43 | help='tag to identify the run') 44 | 45 | enc_parser.set_defaults(conv_type='SAGE', 46 | method_type='order', 47 | dataset='syn', 48 | n_layers=8, 49 | batch_size=64, 50 | hidden_dim=64, 51 | skip="learnable", 52 | dropout=0.0, 53 | n_batches=1000000, 54 | opt='adam', # opt_enc_parser 55 | opt_scheduler='none', 56 | opt_restart=100, 57 | weight_decay=0.0, 58 | lr=1e-4, 59 | margin=0.1, 60 | test_set='', 61 | eval_interval=1000, 62 | n_workers=4, 63 | model_path="ckpt/model.pt", 64 | tag='', 65 | val_size=4096, 66 | node_anchored=True) 67 | 68 | #return enc_parser.parse_args(arg_str) 69 | 70 | -------------------------------------------------------------------------------- /analyze/analyze_pattern_counts.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | import argparse 3 | import json 4 | import numpy as np 5 | import pickle 6 | from scipy.stats import ttest_rel, ttest_ind 7 | from collections import defaultdict 8 | import matplotlib.pyplot as plt 9 | from scipy import stats 10 | import seaborn as sns 11 | import os 12 | 13 | def arg_parse(): 14 | parser = argparse.ArgumentParser(description='count graphlets in a graph') 15 | #parser.add_argument('--graphlets_path', type=str) 16 | parser.add_argument('--counts_path', type=str) 17 | parser.add_argument('--out_path', type=str) 18 | parser.set_defaults(counts_path="results/counts.json") 19 | #parser.set_defaults(graphlets_path="out/out-graphlets.p") 20 | parser.set_defaults(out_path="results/analysis.csv") 21 | return parser.parse_args() 22 | 23 | if __name__ == "__main__": 24 | args = arg_parse() 25 | 26 | all_counts = {} 27 | for fn in os.listdir(args.counts_path): 28 | if not fn.endswith(".json"): continue 29 | 30 | with open(os.path.join(args.counts_path, fn), "r") as f: 31 | graphlet_lens, n_matches, n_matches_bl = json.load(f) 32 | name = fn[:-5] 33 | all_counts[name] = graphlet_lens, n_matches 34 | 35 | all_labels, all_xs, all_ys, all_ub_ys, all_lb_ys = [], [], [], [], [] 36 | for name, (sizes, counts) in all_counts.items(): 37 | all_labels.append(name) 38 | 39 | matches_by_size = defaultdict(list) 40 | for i in range(len(sizes)): 41 | matches_by_size[sizes[i]].append(counts[i]) 42 | 43 | #print("By size:") 44 | ys = [] 45 | ub_ys, lb_ys = [], [] 46 | for size in sorted(matches_by_size.keys()): 47 | #a, b = (stats.t.interval(0.95, len(matches_by_size[size]) - 1, 48 | # loc=np.mean(np.log10(matches_by_size[size])), 49 | # scale=stats.sem(np.log10(matches_by_size[size])))) 50 | #s = np.std(np.log10(matches_by_size[size]), ddof=1) 51 | #m = np.mean(np.log10(matches_by_size[size])) 52 | #a, b = m - s, m + s 53 | a, b = np.percentile(np.log10(matches_by_size[size]), [25, 75]) 54 | 55 | ub_ys.append(b) 56 | lb_ys.append(a) 57 | #ys.append(np.mean(np.log10(matches_by_size[size]))) 58 | ys.append(np.median(np.log10(matches_by_size[size]))) 59 | 60 | all_xs.append(list(sorted(matches_by_size.keys()))) 61 | all_ys.append(ys) 62 | all_ub_ys.append(ub_ys) 63 | all_lb_ys.append(lb_ys) 64 | 65 | #print("By size (log):") 66 | #for size in sorted(matches_by_size.keys()): 67 | # print("- {}. N: {}. Mean log count: {:.4f}. Baseline: {:.4f}. " 68 | # "Different with p={:.4f}".format(size, len(matches_by_size[size]), 69 | # np.mean(np.log10(matches_by_size[size])), 70 | # np.mean(np.log10(matches_by_size_bl[size])), 71 | # ttest_ind(np.log10(matches_by_size[size]), 72 | # np.log10(matches_by_size_bl[size])).pvalue)) 73 | 74 | for i in range(len(all_xs)): 75 | sns.set() 76 | plt.plot(all_xs[i], np.power(10, all_ys[i]), label=all_labels[i], 77 | marker="o") 78 | plt.fill_between(all_xs[i], np.power(10, all_lb_ys[i]), 79 | np.power(10, all_ub_ys[i]), alpha=0.3) 80 | plt.xlabel("Graph size") 81 | plt.ylabel("Frequency") 82 | plt.yscale("log") 83 | plt.legend() 84 | plt.savefig("plots/pattern-counts.png") 85 | -------------------------------------------------------------------------------- /subgraph_matching/alignment.py: -------------------------------------------------------------------------------- 1 | """Build an alignment matrix for matching a query subgraph in a target graph. 2 | Subgraph matching model needs to have been trained with the node-anchored option 3 | (default).""" 4 | 5 | import argparse 6 | from itertools import permutations 7 | import pickle 8 | from queue import PriorityQueue 9 | import os 10 | import random 11 | import time 12 | 13 | from deepsnap.batch import Batch 14 | import networkx as nx 15 | import numpy as np 16 | from sklearn.manifold import TSNE 17 | import torch 18 | import torch.nn as nn 19 | import torch.multiprocessing as mp 20 | import torch.nn.functional as F 21 | import torch.optim as optim 22 | from torch.utils.tensorboard import SummaryWriter 23 | from torch_geometric.data import DataLoader 24 | from torch_geometric.datasets import TUDataset 25 | import torch_geometric.utils as pyg_utils 26 | import torch_geometric.nn as pyg_nn 27 | 28 | from common import data 29 | from common import models 30 | from common import utils 31 | from subgraph_matching.config import parse_encoder 32 | from subgraph_matching.test import validation 33 | from subgraph_matching.train import build_model 34 | 35 | def gen_alignment_matrix(model, query, target, method_type="order"): 36 | """Generate subgraph matching alignment matrix for a given query and 37 | target graph. Each entry (u, v) of the matrix contains the confidence score 38 | the model gives for the query graph, anchored at u, being a subgraph of the 39 | target graph, anchored at v. 40 | 41 | Args: 42 | model: the subgraph matching model. Must have been trained with 43 | node anchored setting (--node_anchored, default) 44 | query: the query graph (networkx Graph) 45 | target: the target graph (networkx Graph) 46 | method_type: the method used for the model. 47 | "order" for order embedding or "mlp" for MLP model 48 | """ 49 | 50 | mat = np.zeros((len(query), len(target))) 51 | for i, u in enumerate(query.nodes): 52 | for j, v in enumerate(target.nodes): 53 | batch = utils.batch_nx_graphs([query, target], anchors=[u, v]) 54 | embs = model.emb_model(batch) 55 | pred = model(embs[1].unsqueeze(0), embs[0].unsqueeze(0)) 56 | raw_pred = model.predict(pred) 57 | if method_type == "order": 58 | raw_pred = torch.log(raw_pred) 59 | elif method_type == "mlp": 60 | raw_pred = raw_pred[0][1] 61 | mat[i][j] = raw_pred.item() 62 | return mat 63 | 64 | def main(): 65 | if not os.path.exists("plots/"): 66 | os.makedirs("plots/") 67 | if not os.path.exists("results/"): 68 | os.makedirs("results/") 69 | 70 | parser = argparse.ArgumentParser(description='Alignment arguments') 71 | utils.parse_optimizer(parser) 72 | parse_encoder(parser) 73 | parser.add_argument('--query_path', type=str, help='path of query graph', 74 | default="") 75 | parser.add_argument('--target_path', type=str, help='path of target graph', 76 | default="") 77 | args = parser.parse_args() 78 | args.test = True 79 | if args.query_path: 80 | with open(args.query_path, "rb") as f: 81 | query = pickle.load(f) 82 | else: 83 | query = nx.gnp_random_graph(8, 0.25) 84 | if args.target_path: 85 | with open(args.target_path, "rb") as f: 86 | target = pickle.load(f) 87 | else: 88 | target = nx.gnp_random_graph(16, 0.25) 89 | 90 | model = build_model(args) 91 | mat = gen_alignment_matrix(model, query, target, 92 | method_type=args.method_type) 93 | 94 | np.save("results/alignment.npy", mat) 95 | print("Saved alignment matrix in results/alignment.npy") 96 | 97 | plt.imshow(mat, interpolation="nearest") 98 | plt.savefig("plots/alignment.png") 99 | print("Saved alignment matrix plot in plots/alignment.png") 100 | 101 | if __name__ == '__main__': 102 | import matplotlib.pyplot as plt 103 | main() 104 | 105 | -------------------------------------------------------------------------------- /subgraph_matching/hyp_search.py: -------------------------------------------------------------------------------- 1 | def parse_encoder(parser): 2 | parser.opt_list('--conv_type', type=str, tunable=True, 3 | options=['GIN', 'SAGE'],#, 'GCN'],#, 'GAT'], 4 | help='type of model') 5 | parser.opt_list('--skip', type=str, tunable=True, 6 | options=['all', 'last'],#, 'GCN'],#, 'GAT'], 7 | help='type of model') 8 | parser.opt_list('--method_type', type=str, tunable=True, 9 | options=['order'], 10 | help='type of convolution') # can change name to embedding_type 11 | parser.opt_list('--order_func_grid_size', type=int, tunable=False, 12 | options=[1000]) 13 | parser.opt_list('--batch_size', type=int, tunable=False, 14 | help='Training batch size') 15 | parser.opt_list('--n_layers', type=int, tunable=True, 16 | options=[4, 8, 12], 17 | help='Number of graph conv layers') 18 | parser.opt_list('--hidden_dim', type=int, tunable=False, 19 | help='Training hidden size') 20 | parser.opt_list('--dropout', type=float, tunable=False, 21 | help='Dropout rate') 22 | parser.opt_list('--margin', type=float, tunable=False, 23 | help='margin for loss') 24 | parser.opt_list('--regularization', type=float, tunable=False, 25 | help='regularization coeff') 26 | 27 | # non-tunable 28 | parser.add_argument('--n_inner_layers', type=int, 29 | help='Number of inner graph conv layers (gatedgraphconv)') 30 | parser.add_argument('--max_graph_size', type=int, 31 | help='max training graph size') 32 | parser.add_argument('--n_batches', type=int, 33 | help='Number of training minibatches') 34 | parser.add_argument('--dataset', type=str, 35 | help='Dataset') 36 | parser.add_argument('--dataset_type', type=str, 37 | help='"syn" or "real"') 38 | parser.add_argument('--test_set', type=str, 39 | help='test set filename') 40 | parser.add_argument('--eval_interval', type=int, 41 | help='how often to eval during training') 42 | parser.add_argument('--val_size', type=int, 43 | help='validation set size') 44 | parser.add_argument('--model_path', type=str, 45 | help='path to save/load model') 46 | parser.add_argument('--start_weights', type=str, 47 | help='file to load weights from') 48 | parser.add_argument('--opt_scheduler', type=str, 49 | help='scheduler name') 50 | parser.add_argument('--use_intersection', type=bool, 51 | help='whether to use intersections in training') 52 | parser.add_argument('--use_diverse_motifs', action="store_true", 53 | help='whether to use diverse motifs in training') 54 | parser.add_argument('--node_anchored', action="store_true", 55 | help='whether to use node anchoring in training') 56 | parser.add_argument('--test', action="store_true") 57 | parser.add_argument('--n_workers', type=int) 58 | parser.add_argument('--tag', type=str, 59 | help='tag to identify the run') 60 | 61 | parser.set_defaults(conv_type='SAGE', 62 | method_type='order', 63 | dataset='syn', 64 | n_layers=8, 65 | batch_size=64, 66 | hidden_dim=64, 67 | skip="learnable", 68 | dropout=0.0, 69 | n_batches=1000000, 70 | opt='adam', # opt_enc_parser 71 | opt_scheduler='none', 72 | opt_restart=100, 73 | weight_decay=0.0, 74 | lr=1e-4, 75 | margin=0.1, 76 | test_set='', 77 | eval_interval=1000, 78 | n_workers=4, 79 | model_path="ckpt/model.pt", 80 | tag='', 81 | val_size=4096, 82 | node_anchored=True) 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Neural Subgraph Learning Library 2 | 3 | Neural Subgraph Learning (NSL) is a general library that implements various tasks related to 4 | learning of subgraph relations. 5 | 6 | It is able to perform 2 tasks: 7 | 1. Neural subgraph matching. 8 | 2. Frequent subgraph mining. 9 | 10 | ## Neural Subgraph Matching 11 | The library implements the algorithm [NeuroMatch](http://snap.stanford.edu/subgraph-matching/). 12 | 13 | ### Problem setup 14 | Given a query graph Q anchored at node q, and a target graph T anchored at node v, 15 | predict if there exists an isomorphism mapping a subgraph of T to Q, such that the isomorphism maps 16 | v to q. 17 | The framework maps the query and target into an embedding space, and either uses MLP/Neural tensor network + cross entropy loss 18 | or order embedding + max margin loss to obtain a prediction score and make the binary prediction of subgraph relationship based on a 19 | threshold of the score. 20 | 21 | See paper and website for detailed explanation of the algorithm. 22 | 23 | ### Train the matching GNN encoder 24 | 1. Train the encoder: `python3 -m subgraph_matching.train --node_anchored`. Note that a trained order embedding model checkpoint is provided in `ckpt/model.pt`. 25 | 2. Optionally, analyze the trained encoder via `python3 -m subgraph_matching.test --node_anchored`, or by running the "Analyze Embeddings" notebook in `analyze/` 26 | 27 | By default, the encoder is trained with on-the-fly generated synthetic data (`--dataset=syn-balanced`). The dataset argument can be used to change to a real-world dataset (e.g. `--dataset=enzymes`), or an imbalanced class version of a dataset (e.g. `--dataset=syn-imbalanced`). It is recommended to train on a balanced dataset. 28 | 29 | ### Usage 30 | The module `python3 -m subgraph_matching.alignment.py [--query_path=...] [--target_path=...]` provides a utility to obtain all pairs of corresponding matching scores, given a pickle file of the query and target graphs in networkx format. Run the module without these arguments for an example using random graphs. 31 | If exact isomorphism mapping is desired, a conflict resolution algorithm can be applied on the 32 | alignment matrix (the output of alignment.py). 33 | Such algorithms are available in recent works. For example: [Deep Graph Matching 34 | Consensus](https://arxiv.org/abs/2001.09621) and [Convolutional Set Matching for Graph 35 | Similarity](https://arxiv.org/abs/1810.10866). 36 | 37 | Both synthetic data (`common/combined_syn.py`) and real-world data (`common/data.py`) can be used to train the model. 38 | One can also train with synthetic data, and transfer the learned model to make inference on real 39 | data (see `subgraph_matching/test.py`). 40 | The `neural_matching` folder contains an encoder that uses GNN to map the query and target into the 41 | embedding space and make subgraph predictions. 42 | 43 | Available configurations can be found in `subgraph_matching/config.py`. 44 | 45 | 46 | ## Frequent Subgraph Mining 47 | This package also contains an implementation of SPMiner, a graph neural network based framework to extract frequent subgraph patterns from an input graph dataset. 48 | 49 | Running the pipeline consists of training the encoder on synthetic data, then running the decoder on the dataset from which to mine patterns. 50 | 51 | Full configuration options can be found in `subgraph_matching/config.py` and `subgraph_mining/config.py`. 52 | 53 | ### Run SPMiner 54 | To run SPMiner to identify common subgraph pattern, the prerequisite is to have a checkpoint of 55 | trained subgraph matching model (obtained by training the GNN encoder). 56 | The config argument `args.model_path` (`subgraph_matching/config.py`) specifies the location of the 57 | saved checkpoint, and is shared for both the `subgraph_matching` and `subgraph_mining` models. 58 | 1. `python3 -m subgraph_mining.decoder --dataset=enzymes --node_anchored` 59 | 60 | Full configuration options can be found in `decoder/config.py`. SPMiner also shares the 61 | configurations of NeuroMatch `subgraph_matching/config.py` since it's used as a subroutine. 62 | 63 | ## Analyze results 64 | - Analyze the order embeddings after training the encoder: `python3 -m analyze.analyze_embeddings --node_anchored` 65 | - Count the frequencies of patterns generated by the decoder: `python3 -m analyze.count_patterns --dataset=enzymes --out_path=results/counts.json --node_anchored` 66 | - Analyze the raw output from counting: `python3 -m analyze.analyze_pattern_counts --counts_path=results/` 67 | 68 | ## Dependencies 69 | The library uses PyTorch and [PyTorch Geometric](https://github.com/rusty1s/pytorch_geometric) to implement message passing graph neural networks (GNN). 70 | It also uses [DeepSNAP](https://github.com/snap-stanford/deepsnap), which facilitates easy use 71 | of graph algorithms (such as subgraph operation and matching operation) to be performed during training for every iteration, 72 | thanks to its synchronization between an internal graph object (such as a NetworkX object) and the Pytorch Geometric Data object. 73 | 74 | Detailed library requirements can be found in requirements.txt 75 | 76 | -------------------------------------------------------------------------------- /common/combined_syn.py: -------------------------------------------------------------------------------- 1 | # Combination of synthetic graph generators # (first used in subgraph matching and motif mining) 2 | 3 | import logging 4 | import networkx as nx 5 | import numpy as np 6 | 7 | import deepsnap.dataset as dataset 8 | 9 | class ERGenerator(dataset.Generator): 10 | def __init__(self, sizes, p_alpha=1.3, **kwargs): 11 | super(ERGenerator, self).__init__(sizes, **kwargs) 12 | self.p_alpha = p_alpha 13 | 14 | def generate(self, size=None): 15 | num_nodes = self._get_size(size) 16 | # p follows beta distribution with mean = log2(num_graphs) / num_graphs 17 | alpha = self.p_alpha 18 | mean = np.log2(num_nodes) / num_nodes 19 | beta = alpha / mean - alpha 20 | p = np.random.beta(alpha, beta) 21 | graph = nx.gnp_random_graph(num_nodes, p) 22 | 23 | while not nx.is_connected(graph): 24 | p = np.random.beta(alpha, beta) 25 | graph = nx.gnp_random_graph(num_nodes, p) 26 | logging.debug('Generated {}-node E-R graphs with average p: {}'.format( 27 | num_nodes, mean)) 28 | return graph 29 | 30 | class WSGenerator(dataset.Generator): 31 | def __init__(self, sizes, density_alpha=1.3, 32 | rewire_alpha=2, rewire_beta=2, **kwargs): 33 | super(WSGenerator, self).__init__(sizes, **kwargs) 34 | self.density_alpha = density_alpha 35 | self.rewire_alpha = rewire_alpha 36 | self.rewire_beta = rewire_beta 37 | 38 | def generate(self, size=None): 39 | num_nodes = self._get_size(size) 40 | curr_num_graphs = 0 41 | 42 | density_alpha = self.density_alpha 43 | density_mean = np.log2(num_nodes) / num_nodes 44 | density_beta = density_alpha / density_mean - density_alpha 45 | 46 | rewire_alpha = self.rewire_alpha 47 | rewire_beta = self.rewire_beta 48 | while curr_num_graphs < 1: 49 | k = int(np.random.beta(density_alpha, density_beta) * num_nodes) 50 | k = max(k, 2) 51 | p = np.random.beta(rewire_alpha, rewire_beta) 52 | try: 53 | graph = nx.connected_watts_strogatz_graph(num_nodes, k, p) 54 | curr_num_graphs += 1 55 | except: 56 | pass 57 | logging.debug('Generated {}-node W-S graph with average density: {}'.format( 58 | num_nodes, density_mean)) 59 | return graph 60 | 61 | class BAGenerator(dataset.Generator): 62 | def __init__(self, sizes, max_p=0.2, max_q=0.2, **kwargs): 63 | super(BAGenerator, self).__init__(sizes, **kwargs) 64 | self.max_p = 0.2 65 | self.max_q = 0.2 66 | 67 | def generate(self, size=None): 68 | num_nodes = self._get_size(size) 69 | max_m = int(2 * np.log2(num_nodes)) 70 | found = False 71 | m = np.random.choice(max_m) + 1 72 | p = np.min([np.random.exponential(20), self.max_p]) 73 | q = np.min([np.random.exponential(20), self.max_q]) 74 | while not found: 75 | graph = nx.extended_barabasi_albert_graph(num_nodes, m, p, q) 76 | if nx.is_connected(graph): 77 | found = True 78 | logging.debug('Generated {}-node extended B-A graph with max m: {}'.format( 79 | num_nodes, max_m)) 80 | return graph 81 | 82 | class PowerLawClusterGenerator(dataset.Generator): 83 | def __init__(self, sizes, max_triangle_prob=0.5, **kwargs): 84 | super(PowerLawClusterGenerator, self).__init__(sizes, **kwargs) 85 | self.max_triangle_prob = max_triangle_prob 86 | 87 | def generate(self, size=None): 88 | num_nodes = self._get_size(size) 89 | max_m = int(2 * np.log2(num_nodes)) 90 | m = np.random.choice(max_m) + 1 91 | p = np.random.uniform(high=self.max_triangle_prob) 92 | found = False 93 | while not found: 94 | graph = nx.powerlaw_cluster_graph(num_nodes, m, p) 95 | if nx.is_connected(graph): 96 | found = True 97 | logging.debug('Generated {}-node powerlaw cluster graph with max m: {}'.format( 98 | num_nodes, max_m)) 99 | return graph 100 | 101 | def get_generator(sizes, size_prob=None, dataset_len=None): 102 | #gen_prob = [1/3.5, 1/3.5, 1/3.5, 0.5/3.5] 103 | generator = dataset.EnsembleGenerator( 104 | [ERGenerator(sizes, size_prob=size_prob), 105 | WSGenerator(sizes, size_prob=size_prob), 106 | BAGenerator(sizes, size_prob=size_prob), 107 | PowerLawClusterGenerator(sizes, size_prob=size_prob)], 108 | #gen_prob=gen_prob, 109 | dataset_len=dataset_len) 110 | #print(generator) 111 | return generator 112 | 113 | def get_dataset(task, dataset_len, sizes, size_prob=None, **kwargs): 114 | generator = get_generator(sizes, size_prob=size_prob, 115 | dataset_len=dataset_len) 116 | return dataset.GraphDataset( 117 | None, task=task, generator=generator, **kwargs) 118 | 119 | def main(): 120 | sizes = np.arange(6, 31) 121 | dataset = get_dataset("graph", sizes) 122 | print('On the fly generated dataset has length: {}'.format(len(dataset))) 123 | example_graph = dataset[0] 124 | print('Example graph: nodes {}; edges {}'.format(example_graph.G.nodes, example_graph.G.edges)) 125 | 126 | print('Even the same index causes a new graph to be generated: edges {}'.format( 127 | dataset[0].G.edges)) 128 | 129 | print('This generator has no label: {}, ' 130 | '(but can be augmented via apply_transform)'.format(dataset.num_node_labels)) 131 | 132 | if __name__ == '__main__': 133 | main() 134 | -------------------------------------------------------------------------------- /subgraph_matching/test.py: -------------------------------------------------------------------------------- 1 | from common import utils 2 | from collections import defaultdict 3 | from datetime import datetime 4 | from sklearn.metrics import roc_auc_score, confusion_matrix 5 | from sklearn.metrics import precision_recall_curve, average_precision_score 6 | import torch 7 | 8 | USE_ORCA_FEATS = False # whether to use orca motif counts along with embeddings 9 | MAX_MARGIN_SCORE = 1e9 # a very large margin score to given orca constraints 10 | 11 | def validation(args, model, test_pts, logger, batch_n, epoch, verbose=False): 12 | # test on new motifs 13 | model.eval() 14 | all_raw_preds, all_preds, all_labels = [], [], [] 15 | for pos_a, pos_b, neg_a, neg_b in test_pts: 16 | if pos_a: 17 | pos_a = pos_a.to(utils.get_device()) 18 | pos_b = pos_b.to(utils.get_device()) 19 | neg_a = neg_a.to(utils.get_device()) 20 | neg_b = neg_b.to(utils.get_device()) 21 | labels = torch.tensor([1]*(pos_a.num_graphs if pos_a else 0) + 22 | [0]*neg_a.num_graphs).to(utils.get_device()) 23 | with torch.no_grad(): 24 | emb_neg_a, emb_neg_b = (model.emb_model(neg_a), 25 | model.emb_model(neg_b)) 26 | if pos_a: 27 | emb_pos_a, emb_pos_b = (model.emb_model(pos_a), 28 | model.emb_model(pos_b)) 29 | emb_as = torch.cat((emb_pos_a, emb_neg_a), dim=0) 30 | emb_bs = torch.cat((emb_pos_b, emb_neg_b), dim=0) 31 | else: 32 | emb_as, emb_bs = emb_neg_a, emb_neg_b 33 | pred = model(emb_as, emb_bs) 34 | raw_pred = model.predict(pred) 35 | if USE_ORCA_FEATS: 36 | import orca 37 | import matplotlib.pyplot as plt 38 | def make_feats(g): 39 | counts5 = np.array(orca.orbit_counts("node", 5, g)) 40 | for v, n in zip(counts5, g.nodes): 41 | if g.nodes[n]["node_feature"][0] > 0: 42 | anchor_v = v 43 | break 44 | v5 = np.sum(counts5, axis=0) 45 | return v5, anchor_v 46 | for i, (ga, gb) in enumerate(zip(neg_a.G, neg_b.G)): 47 | (va, na), (vb, nb) = make_feats(ga), make_feats(gb) 48 | if (va < vb).any() or (na < nb).any(): 49 | raw_pred[pos_a.num_graphs + i] = MAX_MARGIN_SCORE 50 | 51 | if args.method_type == "order": 52 | pred = model.clf_model(raw_pred.unsqueeze(1)).argmax(dim=-1) 53 | raw_pred *= -1 54 | elif args.method_type == "ensemble": 55 | pred = torch.stack([m.clf_model( 56 | raw_pred.unsqueeze(1)).argmax(dim=-1) for m in model.models]) 57 | for i in range(pred.shape[1]): 58 | print(pred[:,i]) 59 | pred = torch.min(pred, dim=0)[0] 60 | raw_pred *= -1 61 | elif args.method_type == "mlp": 62 | raw_pred = raw_pred[:,1] 63 | pred = pred.argmax(dim=-1) 64 | all_raw_preds.append(raw_pred) 65 | all_preds.append(pred) 66 | all_labels.append(labels) 67 | pred = torch.cat(all_preds, dim=-1) 68 | labels = torch.cat(all_labels, dim=-1) 69 | raw_pred = torch.cat(all_raw_preds, dim=-1) 70 | acc = torch.mean((pred == labels).type(torch.float)) 71 | prec = (torch.sum(pred * labels).item() / torch.sum(pred).item() if 72 | torch.sum(pred) > 0 else float("NaN")) 73 | recall = (torch.sum(pred * labels).item() / 74 | torch.sum(labels).item() if torch.sum(labels) > 0 else 75 | float("NaN")) 76 | labels = labels.detach().cpu().numpy() 77 | raw_pred = raw_pred.detach().cpu().numpy() 78 | pred = pred.detach().cpu().numpy() 79 | auroc = roc_auc_score(labels, raw_pred) 80 | avg_prec = average_precision_score(labels, raw_pred) 81 | tn, fp, fn, tp = confusion_matrix(labels, pred).ravel() 82 | if verbose: 83 | import matplotlib.pyplot as plt 84 | precs, recalls, threshs = precision_recall_curve(labels, raw_pred) 85 | plt.plot(recalls, precs) 86 | plt.xlabel("Recall") 87 | plt.ylabel("Precision") 88 | plt.savefig("plots/precision-recall-curve.png") 89 | print("Saved PR curve plot in plots/precision-recall-curve.png") 90 | 91 | print("\n{}".format(str(datetime.now()))) 92 | print("Validation. Epoch {}. Acc: {:.4f}. " 93 | "P: {:.4f}. R: {:.4f}. AUROC: {:.4f}. AP: {:.4f}.\n " 94 | "TN: {}. FP: {}. FN: {}. TP: {}".format(epoch, 95 | acc, prec, recall, auroc, avg_prec, 96 | tn, fp, fn, tp)) 97 | 98 | if not args.test: 99 | logger.add_scalar("Accuracy/test", acc, batch_n) 100 | logger.add_scalar("Precision/test", prec, batch_n) 101 | logger.add_scalar("Recall/test", recall, batch_n) 102 | logger.add_scalar("AUROC/test", auroc, batch_n) 103 | logger.add_scalar("AvgPrec/test", avg_prec, batch_n) 104 | logger.add_scalar("TP/test", tp, batch_n) 105 | logger.add_scalar("TN/test", tn, batch_n) 106 | logger.add_scalar("FP/test", fp, batch_n) 107 | logger.add_scalar("FN/test", fn, batch_n) 108 | print("Saving {}".format(args.model_path)) 109 | torch.save(model.state_dict(), args.model_path) 110 | 111 | if verbose: 112 | conf_mat_examples = defaultdict(list) 113 | idx = 0 114 | for pos_a, pos_b, neg_a, neg_b in test_pts: 115 | if pos_a: 116 | pos_a = pos_a.to(utils.get_device()) 117 | pos_b = pos_b.to(utils.get_device()) 118 | neg_a = neg_a.to(utils.get_device()) 119 | neg_b = neg_b.to(utils.get_device()) 120 | for list_a, list_b in [(pos_a, pos_b), (neg_a, neg_b)]: 121 | if not list_a: continue 122 | for a, b in zip(list_a.G, list_b.G): 123 | correct = pred[idx] == labels[idx] 124 | conf_mat_examples[correct, pred[idx]].append((a, b)) 125 | idx += 1 126 | 127 | if __name__ == "__main__": 128 | from subgraph_matching.train import main 129 | main(force_test=True) 130 | -------------------------------------------------------------------------------- /subgraph_matching/train.py: -------------------------------------------------------------------------------- 1 | """Train the order embedding model""" 2 | 3 | # Set this flag to True to use hyperparameter optimization 4 | # We use Testtube for hyperparameter tuning 5 | HYPERPARAM_SEARCH = False 6 | HYPERPARAM_SEARCH_N_TRIALS = None # how many grid search trials to run 7 | # (set to None for exhaustive search) 8 | 9 | import argparse 10 | from itertools import permutations 11 | import pickle 12 | from queue import PriorityQueue 13 | import os 14 | import random 15 | import time 16 | 17 | from deepsnap.batch import Batch 18 | import networkx as nx 19 | import numpy as np 20 | from sklearn.manifold import TSNE 21 | import torch 22 | import torch.nn as nn 23 | import torch.multiprocessing as mp 24 | import torch.nn.functional as F 25 | import torch.optim as optim 26 | from torch.utils.tensorboard import SummaryWriter 27 | from torch_geometric.data import DataLoader 28 | from torch_geometric.datasets import TUDataset 29 | import torch_geometric.utils as pyg_utils 30 | import torch_geometric.nn as pyg_nn 31 | 32 | from common import data 33 | from common import models 34 | from common import utils 35 | if HYPERPARAM_SEARCH: 36 | from test_tube import HyperOptArgumentParser 37 | from subgraph_matching.hyp_search import parse_encoder 38 | else: 39 | from subgraph_matching.config import parse_encoder 40 | from subgraph_matching.test import validation 41 | 42 | def build_model(args): 43 | # build model 44 | if args.method_type == "order": 45 | model = models.OrderEmbedder(1, args.hidden_dim, args) 46 | elif args.method_type == "mlp": 47 | model = models.BaselineMLP(1, args.hidden_dim, args) 48 | model.to(utils.get_device()) 49 | if args.test and args.model_path: 50 | model.load_state_dict(torch.load(args.model_path, 51 | map_location=utils.get_device())) 52 | return model 53 | 54 | def make_data_source(args): 55 | toks = args.dataset.split("-") 56 | if toks[0] == "syn": 57 | if len(toks) == 1 or toks[1] == "balanced": 58 | data_source = data.OTFSynDataSource( 59 | node_anchored=args.node_anchored) 60 | elif toks[1] == "imbalanced": 61 | data_source = data.OTFSynImbalancedDataSource( 62 | node_anchored=args.node_anchored) 63 | else: 64 | raise Exception("Error: unrecognized dataset") 65 | else: 66 | if len(toks) == 1 or toks[1] == "balanced": 67 | data_source = data.DiskDataSource(toks[0], 68 | node_anchored=args.node_anchored) 69 | elif toks[1] == "imbalanced": 70 | data_source = data.DiskImbalancedDataSource(toks[0], 71 | node_anchored=args.node_anchored) 72 | else: 73 | raise Exception("Error: unrecognized dataset") 74 | return data_source 75 | 76 | def train(args, model, logger, in_queue, out_queue): 77 | """Train the order embedding model. 78 | 79 | args: Commandline arguments 80 | logger: logger for logging progress 81 | in_queue: input queue to an intersection computation worker 82 | out_queue: output queue to an intersection computation worker 83 | """ 84 | scheduler, opt = utils.build_optimizer(args, model.parameters()) 85 | if args.method_type == "order": 86 | clf_opt = optim.Adam(model.clf_model.parameters(), lr=args.lr) 87 | 88 | done = False 89 | while not done: 90 | data_source = make_data_source(args) 91 | loaders = data_source.gen_data_loaders(args.eval_interval * 92 | args.batch_size, args.batch_size, train=True) 93 | for batch_target, batch_neg_target, batch_neg_query in zip(*loaders): 94 | msg, _ = in_queue.get() 95 | if msg == "done": 96 | done = True 97 | break 98 | # train 99 | model.train() 100 | model.zero_grad() 101 | pos_a, pos_b, neg_a, neg_b = data_source.gen_batch(batch_target, 102 | batch_neg_target, batch_neg_query, True) 103 | emb_pos_a, emb_pos_b = model.emb_model(pos_a), model.emb_model(pos_b) 104 | emb_neg_a, emb_neg_b = model.emb_model(neg_a), model.emb_model(neg_b) 105 | #print(emb_pos_a.shape, emb_neg_a.shape, emb_neg_b.shape) 106 | emb_as = torch.cat((emb_pos_a, emb_neg_a), dim=0) 107 | emb_bs = torch.cat((emb_pos_b, emb_neg_b), dim=0) 108 | labels = torch.tensor([1]*pos_a.num_graphs + [0]*neg_a.num_graphs).to( 109 | utils.get_device()) 110 | intersect_embs = None 111 | pred = model(emb_as, emb_bs) 112 | loss = model.criterion(pred, intersect_embs, labels) 113 | loss.backward() 114 | torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) 115 | opt.step() 116 | if scheduler: 117 | scheduler.step() 118 | 119 | if args.method_type == "order": 120 | with torch.no_grad(): 121 | pred = model.predict(pred) 122 | model.clf_model.zero_grad() 123 | pred = model.clf_model(pred.unsqueeze(1)) 124 | criterion = nn.NLLLoss() 125 | clf_loss = criterion(pred, labels) 126 | clf_loss.backward() 127 | clf_opt.step() 128 | pred = pred.argmax(dim=-1) 129 | acc = torch.mean((pred == labels).type(torch.float)) 130 | train_loss = loss.item() 131 | train_acc = acc.item() 132 | 133 | out_queue.put(("step", (loss.item(), acc))) 134 | 135 | def train_loop(args): 136 | if not os.path.exists(os.path.dirname(args.model_path)): 137 | os.makedirs(os.path.dirname(args.model_path)) 138 | if not os.path.exists("plots/"): 139 | os.makedirs("plots/") 140 | 141 | print("Starting {} workers".format(args.n_workers)) 142 | in_queue, out_queue = mp.Queue(), mp.Queue() 143 | 144 | print("Using dataset {}".format(args.dataset)) 145 | 146 | record_keys = ["conv_type", "n_layers", "hidden_dim", 147 | "margin", "dataset", "max_graph_size", "skip"] 148 | args_str = ".".join(["{}={}".format(k, v) 149 | for k, v in sorted(vars(args).items()) if k in record_keys]) 150 | logger = SummaryWriter(comment=args_str) 151 | 152 | model = build_model(args) 153 | model.share_memory() 154 | 155 | if args.method_type == "order": 156 | clf_opt = optim.Adam(model.clf_model.parameters(), lr=args.lr) 157 | else: 158 | clf_opt = None 159 | 160 | data_source = make_data_source(args) 161 | loaders = data_source.gen_data_loaders(args.val_size, args.batch_size, 162 | train=False, use_distributed_sampling=False) 163 | test_pts = [] 164 | for batch_target, batch_neg_target, batch_neg_query in zip(*loaders): 165 | pos_a, pos_b, neg_a, neg_b = data_source.gen_batch(batch_target, 166 | batch_neg_target, batch_neg_query, False) 167 | if pos_a: 168 | pos_a = pos_a.to(torch.device("cpu")) 169 | pos_b = pos_b.to(torch.device("cpu")) 170 | neg_a = neg_a.to(torch.device("cpu")) 171 | neg_b = neg_b.to(torch.device("cpu")) 172 | test_pts.append((pos_a, pos_b, neg_a, neg_b)) 173 | 174 | workers = [] 175 | for i in range(args.n_workers): 176 | worker = mp.Process(target=train, args=(args, model, data_source, 177 | in_queue, out_queue)) 178 | worker.start() 179 | workers.append(worker) 180 | 181 | if args.test: 182 | validation(args, model, test_pts, logger, 0, 0, verbose=True) 183 | else: 184 | batch_n = 0 185 | for epoch in range(args.n_batches // args.eval_interval): 186 | for i in range(args.eval_interval): 187 | in_queue.put(("step", None)) 188 | for i in range(args.eval_interval): 189 | msg, params = out_queue.get() 190 | train_loss, train_acc = params 191 | print("Batch {}. Loss: {:.4f}. Training acc: {:.4f}".format( 192 | batch_n, train_loss, train_acc), end=" \r") 193 | logger.add_scalar("Loss/train", train_loss, batch_n) 194 | logger.add_scalar("Accuracy/train", train_acc, batch_n) 195 | batch_n += 1 196 | validation(args, model, test_pts, logger, batch_n, epoch) 197 | 198 | for i in range(args.n_workers): 199 | in_queue.put(("done", None)) 200 | for worker in workers: 201 | worker.join() 202 | 203 | def main(force_test=False): 204 | mp.set_start_method("spawn", force=True) 205 | parser = (argparse.ArgumentParser(description='Order embedding arguments') 206 | if not HYPERPARAM_SEARCH else 207 | HyperOptArgumentParser(strategy='grid_search')) 208 | 209 | utils.parse_optimizer(parser) 210 | parse_encoder(parser) 211 | args = parser.parse_args() 212 | 213 | if force_test: 214 | args.test = True 215 | 216 | # Currently due to parallelism in multi-gpu training, this code performs 217 | # sequential hyperparameter tuning. 218 | # All gpus are used for every run of training in hyperparameter search. 219 | if HYPERPARAM_SEARCH: 220 | for i, hparam_trial in enumerate(args.trials(HYPERPARAM_SEARCH_N_TRIALS)): 221 | print("Running hyperparameter search trial", i) 222 | print(hparam_trial) 223 | train_loop(hparam_trial) 224 | else: 225 | train_loop(args) 226 | 227 | if __name__ == '__main__': 228 | main() 229 | -------------------------------------------------------------------------------- /common/feature_preprocess.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | import random 4 | import networkx as nx 5 | import numpy as np 6 | from sklearn.manifold import TSNE 7 | import torch 8 | import torch.nn as nn 9 | import torch.multiprocessing as mp 10 | import torch.nn.functional as F 11 | import torch.optim as optim 12 | from torch_geometric.data import DataLoader 13 | from torch_geometric.datasets import TUDataset, PPI, QM9 14 | import torch_geometric.utils as pyg_utils 15 | import torch_geometric.nn as pyg_nn 16 | from tqdm import tqdm 17 | import queue 18 | from deepsnap.dataset import GraphDataset 19 | from deepsnap.batch import Batch 20 | from deepsnap.graph import Graph as DSGraph 21 | #import orca 22 | from torch_scatter import scatter_add 23 | 24 | from common import utils 25 | 26 | AUGMENT_METHOD = "concat" 27 | FEATURE_AUGMENT, FEATURE_AUGMENT_DIMS = [], [] 28 | #FEATURE_AUGMENT, FEATURE_AUGMENT_DIMS = ["identity"], [4] 29 | #FEATURE_AUGMENT = ["motif_counts"] 30 | #FEATURE_AUGMENT_DIMS = [73] 31 | #FEATURE_AUGMENT_DIMS = [15] 32 | 33 | def norm(edge_index, num_nodes, edge_weight=None, improved=False, 34 | dtype=None): 35 | if edge_weight is None: 36 | edge_weight = torch.ones((edge_index.size(1),), dtype=dtype, 37 | device=edge_index.device) 38 | 39 | fill_value = 1 if not improved else 2 40 | edge_index, edge_weight = pyg_utils.add_remaining_self_loops( 41 | edge_index, edge_weight, fill_value, num_nodes) 42 | 43 | row, col = edge_index 44 | deg = scatter_add(edge_weight, row, dim=0, dim_size=num_nodes) 45 | deg_inv_sqrt = deg.pow(-0.5) 46 | deg_inv_sqrt[deg_inv_sqrt == float('inf')] = 0 47 | 48 | return edge_index, deg_inv_sqrt[row] * edge_weight * deg_inv_sqrt[col] 49 | 50 | def compute_identity(edge_index, n, k): 51 | edge_weight = torch.ones((edge_index.size(1),), dtype=torch.float, 52 | device=edge_index.device) 53 | edge_index, edge_weight = pyg_utils.add_remaining_self_loops( 54 | edge_index, edge_weight, 1, n) 55 | adj_sparse = torch.sparse.FloatTensor(edge_index, edge_weight, 56 | torch.Size([n, n])) 57 | adj = adj_sparse.to_dense() 58 | 59 | deg = torch.diag(torch.sum(adj, -1)) 60 | deg_inv_sqrt = deg.pow(-0.5) 61 | adj = deg_inv_sqrt @ adj @ deg_inv_sqrt 62 | 63 | diag_all = [torch.diag(adj)] 64 | adj_power = adj 65 | for i in range(1, k): 66 | adj_power = adj_power @ adj 67 | diag_all.append(torch.diag(adj_power)) 68 | diag_all = torch.stack(diag_all, dim=1) 69 | return diag_all 70 | 71 | class FeatureAugment(nn.Module): 72 | def __init__(self): 73 | super(FeatureAugment, self).__init__() 74 | 75 | def degree_fun(graph, feature_dim): 76 | graph.node_degree = self._one_hot_tensor( 77 | [d for _, d in graph.G.degree()], 78 | one_hot_dim=feature_dim) 79 | return graph 80 | 81 | def centrality_fun(graph, feature_dim): 82 | nodes = list(graph.G.nodes) 83 | centrality = nx.betweenness_centrality(graph.G) 84 | graph.betweenness_centrality = torch.tensor( 85 | [centrality[x] for x in 86 | nodes]).unsqueeze(1) 87 | return graph 88 | 89 | def path_len_fun(graph, feature_dim): 90 | nodes = list(graph.G.nodes) 91 | graph.path_len = self._one_hot_tensor( 92 | [np.mean(list(nx.shortest_path_length(graph.G, 93 | source=x).values())) for x in nodes], 94 | one_hot_dim=feature_dim) 95 | return graph 96 | 97 | def pagerank_fun(graph, feature_dim): 98 | nodes = list(graph.G.nodes) 99 | pagerank = nx.pagerank(graph.G) 100 | graph.pagerank = torch.tensor([pagerank[x] for x in 101 | nodes]).unsqueeze(1) 102 | return graph 103 | 104 | def identity_fun(graph, feature_dim): 105 | graph.identity = compute_identity( 106 | graph.edge_index, graph.num_nodes, feature_dim) 107 | return graph 108 | 109 | def clustering_coefficient_fun(graph, feature_dim): 110 | node_cc = list(nx.clustering(graph.G).values()) 111 | if feature_dim == 1: 112 | graph.node_clustering_coefficient = torch.tensor( 113 | node_cc, dtype=torch.float).unsqueeze(1) 114 | else: 115 | graph.node_clustering_coefficient = FeatureAugment._bin_features( 116 | node_cc, feature_dim=feature_dim) 117 | 118 | def motif_counts_fun(graph, feature_dim): 119 | assert feature_dim % 73 == 0 120 | counts = orca.orbit_counts("node", 5, graph.G) 121 | counts = [[np.log(c) if c > 0 else -1.0 for c in l] for l in counts] 122 | counts = torch.tensor(counts).type(torch.float) 123 | #counts = FeatureAugment._wave_features(counts, 124 | # feature_dim=feature_dim // 73) 125 | graph.motif_counts = counts 126 | return graph 127 | 128 | def node_features_base_fun(graph, feature_dim): 129 | for v in graph.G.nodes: 130 | if "node_feature" not in graph.G.nodes[v]: 131 | graph.G.nodes[v]["node_feature"] = torch.ones(feature_dim) 132 | return graph 133 | 134 | self.node_features_base_fun = node_features_base_fun 135 | 136 | self.node_feature_funs = {"node_degree": degree_fun, 137 | "betweenness_centrality": centrality_fun, 138 | "path_len": path_len_fun, 139 | "pagerank": pagerank_fun, 140 | 'node_clustering_coefficient': clustering_coefficient_fun, 141 | "motif_counts": motif_counts_fun, 142 | "identity": identity_fun} 143 | 144 | def register_feature_fun(name, feature_fun): 145 | self.node_feature_funs[name] = feature_fun 146 | 147 | @staticmethod 148 | def _wave_features(list_scalars, feature_dim=4, scale=10000): 149 | pos = np.array(list_scalars) 150 | if len(pos.shape) == 1: 151 | pos = pos[:,np.newaxis] 152 | batch_size, n_feats = pos.shape 153 | pos = pos.reshape(-1) 154 | 155 | rng = np.arange(0, feature_dim // 2).astype( 156 | np.float) / (feature_dim // 2) 157 | sins = np.sin(pos[:,np.newaxis] / scale**rng[np.newaxis,:]) 158 | coss = np.cos(pos[:,np.newaxis] / scale**rng[np.newaxis,:]) 159 | m = np.concatenate((coss, sins), axis=-1) 160 | m = m.reshape(batch_size, -1).astype(np.float) 161 | m = torch.from_numpy(m).type(torch.float) 162 | return m 163 | 164 | @staticmethod 165 | def _bin_features(list_scalars, feature_dim=2): 166 | arr = np.array(list_scalars) 167 | min_val, max_val = np.min(arr), np.max(arr) 168 | bins = np.linspace(min_val, max_val, num=feature_dim) 169 | feat = np.digitize(arr, bins) - 1 170 | assert np.min(feat) == 0 171 | assert np.max(feat) == feature_dim - 1 172 | return FeatureAugment._one_hot_tensor(feat, one_hot_dim=feature_dim) 173 | 174 | @staticmethod 175 | def _one_hot_tensor(list_scalars, one_hot_dim=1): 176 | if not isinstance(list_scalars, list) and not list_scalars.ndim == 1: 177 | raise ValueError("input to _one_hot_tensor must be 1-D list") 178 | vals = torch.LongTensor(list_scalars).view(-1,1) 179 | vals = vals - min(vals) 180 | vals = torch.min(vals, torch.tensor(one_hot_dim - 1)) 181 | vals = torch.max(vals, torch.tensor(0)) 182 | one_hot = torch.zeros(len(list_scalars), one_hot_dim) 183 | one_hot.scatter_(1, vals, 1.0) 184 | return one_hot 185 | 186 | def augment(self, dataset): 187 | dataset = dataset.apply_transform(self.node_features_base_fun, 188 | feature_dim=1) 189 | for key, dim in zip(FEATURE_AUGMENT, FEATURE_AUGMENT_DIMS): 190 | dataset = dataset.apply_transform(self.node_feature_funs[key], 191 | feature_dim=dim) 192 | return dataset 193 | 194 | class Preprocess(nn.Module): 195 | def __init__(self, dim_in): 196 | super(Preprocess, self).__init__() 197 | self.dim_in = dim_in 198 | if AUGMENT_METHOD == 'add': 199 | self.module_dict = { 200 | key: nn.Linear(aug_dim, dim_in) 201 | for key, aug_dim in zip(FEATURE_AUGMENT, 202 | FEATURE_AUGMENT_DIMS) 203 | } 204 | 205 | @property 206 | def dim_out(self): 207 | if AUGMENT_METHOD == 'concat': 208 | return self.dim_in + sum( 209 | [aug_dim for aug_dim in FEATURE_AUGMENT_DIMS]) 210 | elif AUGMENT_METHOD == 'add': 211 | return dim_in 212 | else: 213 | raise ValueError('Unknown feature augmentation method {}.'.format( 214 | AUGMENT_METHOD)) 215 | 216 | def forward(self, batch): 217 | if AUGMENT_METHOD == 'concat': 218 | feature_list = [batch.node_feature] 219 | for key in FEATURE_AUGMENT: 220 | feature_list.append(batch[key]) 221 | batch.node_feature = torch.cat(feature_list, dim=-1) 222 | elif AUGMENT_METHOD == 'add': 223 | for key in FEATURE_AUGMENT: 224 | batch.node_feature = batch.node_feature + self.module_dict[key]( 225 | batch[key]) 226 | else: 227 | raise ValueError('Unknown feature augmentation method {}.'.format( 228 | AUGMENT_METHOD)) 229 | return batch 230 | -------------------------------------------------------------------------------- /common/utils.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict, Counter 2 | 3 | from deepsnap.graph import Graph as DSGraph 4 | from deepsnap.batch import Batch 5 | from deepsnap.dataset import GraphDataset 6 | import torch 7 | import torch.optim as optim 8 | import torch_geometric.utils as pyg_utils 9 | from torch_geometric.data import DataLoader 10 | import networkx as nx 11 | import numpy as np 12 | import random 13 | import scipy.stats as stats 14 | from tqdm import tqdm 15 | 16 | from common import feature_preprocess 17 | 18 | def sample_neigh(graphs, size): 19 | ps = np.array([len(g) for g in graphs], dtype=np.float) 20 | ps /= np.sum(ps) 21 | dist = stats.rv_discrete(values=(np.arange(len(graphs)), ps)) 22 | while True: 23 | idx = dist.rvs() 24 | #graph = random.choice(graphs) 25 | graph = graphs[idx] 26 | start_node = random.choice(list(graph.nodes)) 27 | neigh = [start_node] 28 | frontier = list(set(graph.neighbors(start_node)) - set(neigh)) 29 | visited = set([start_node]) 30 | while len(neigh) < size and frontier: 31 | new_node = random.choice(list(frontier)) 32 | #new_node = max(sorted(frontier)) 33 | assert new_node not in neigh 34 | neigh.append(new_node) 35 | visited.add(new_node) 36 | frontier += list(graph.neighbors(new_node)) 37 | frontier = [x for x in frontier if x not in visited] 38 | if len(neigh) == size: 39 | return graph, neigh 40 | 41 | cached_masks = None 42 | def vec_hash(v): 43 | global cached_masks 44 | if cached_masks is None: 45 | random.seed(2019) 46 | cached_masks = [random.getrandbits(32) for i in range(len(v))] 47 | #v = [hash(tuple(v)) ^ mask for mask in cached_masks] 48 | v = [hash(v[i]) ^ mask for i, mask in enumerate(cached_masks)] 49 | #v = [np.sum(v) for mask in cached_masks] 50 | return v 51 | 52 | def wl_hash(g, dim=64, node_anchored=False): 53 | g = nx.convert_node_labels_to_integers(g) 54 | vecs = np.zeros((len(g), dim), dtype=np.int) 55 | if node_anchored: 56 | for v in g.nodes: 57 | if g.nodes[v]["anchor"] == 1: 58 | vecs[v] = 1 59 | break 60 | for i in range(len(g)): 61 | newvecs = np.zeros((len(g), dim), dtype=np.int) 62 | for n in g.nodes: 63 | newvecs[n] = vec_hash(np.sum(vecs[list(g.neighbors(n)) + [n]], 64 | axis=0)) 65 | vecs = newvecs 66 | return tuple(np.sum(vecs, axis=0)) 67 | 68 | def gen_baseline_queries_rand_esu(queries, targets, node_anchored=False): 69 | sizes = Counter([len(g) for g in queries]) 70 | max_size = max(sizes.keys()) 71 | all_subgraphs = defaultdict(lambda: defaultdict(list)) 72 | total_n_max_subgraphs, total_n_subgraphs = 0, 0 73 | for target in tqdm(targets): 74 | subgraphs = enumerate_subgraph(target, k=max_size, 75 | progress_bar=len(targets) < 10, node_anchored=node_anchored) 76 | for (size, k), v in subgraphs.items(): 77 | all_subgraphs[size][k] += v 78 | if size == max_size: total_n_max_subgraphs += len(v) 79 | total_n_subgraphs += len(v) 80 | print(total_n_subgraphs, "subgraphs explored") 81 | print(total_n_max_subgraphs, "max-size subgraphs explored") 82 | out = [] 83 | for size, count in sizes.items(): 84 | counts = all_subgraphs[size] 85 | for _, neighs in list(sorted(counts.items(), key=lambda x: len(x[1]), 86 | reverse=True))[:count]: 87 | print(len(neighs)) 88 | out.append(random.choice(neighs)) 89 | return out 90 | 91 | def enumerate_subgraph(G, k=3, progress_bar=False, node_anchored=False): 92 | ps = np.arange(1.0, 0.0, -1.0/(k+1)) ** 1.5 93 | #ps = [1.0]*(k+1) 94 | motif_counts = defaultdict(list) 95 | for node in tqdm(G.nodes) if progress_bar else G.nodes: 96 | sg = set() 97 | sg.add(node) 98 | v_ext = set() 99 | neighbors = [nbr for nbr in list(G[node].keys()) if nbr > node] 100 | n_frac = len(neighbors) * ps[1] 101 | n_samples = int(n_frac) + (1 if random.random() < n_frac - int(n_frac) 102 | else 0) 103 | neighbors = random.sample(neighbors, n_samples) 104 | for nbr in neighbors: 105 | v_ext.add(nbr) 106 | extend_subgraph(G, k, sg, v_ext, node, motif_counts, ps, node_anchored) 107 | return motif_counts 108 | 109 | def extend_subgraph(G, k, sg, v_ext, node_id, motif_counts, ps, node_anchored): 110 | # Base case 111 | sg_G = G.subgraph(sg) 112 | if node_anchored: 113 | sg_G = sg_G.copy() 114 | nx.set_node_attributes(sg_G, 0, name="anchor") 115 | sg_G.nodes[node_id]["anchor"] = 1 116 | 117 | motif_counts[len(sg), wl_hash(sg_G, 118 | node_anchored=node_anchored)].append(sg_G) 119 | if len(sg) == k: 120 | return 121 | # Recursive step: 122 | old_v_ext = v_ext.copy() 123 | while len(v_ext) > 0: 124 | w = v_ext.pop() 125 | new_v_ext = v_ext.copy() 126 | neighbors = [nbr for nbr in list(G[w].keys()) if nbr > node_id and nbr 127 | not in sg and nbr not in old_v_ext] 128 | n_frac = len(neighbors) * ps[len(sg) + 1] 129 | n_samples = int(n_frac) + (1 if random.random() < n_frac - int(n_frac) 130 | else 0) 131 | neighbors = random.sample(neighbors, n_samples) 132 | for nbr in neighbors: 133 | #if nbr > node_id and nbr not in sg and nbr not in old_v_ext: 134 | new_v_ext.add(nbr) 135 | sg.add(w) 136 | extend_subgraph(G, k, sg, new_v_ext, node_id, motif_counts, ps, 137 | node_anchored) 138 | sg.remove(w) 139 | 140 | def gen_baseline_queries_mfinder(queries, targets, n_samples=10000, 141 | node_anchored=False): 142 | sizes = Counter([len(g) for g in queries]) 143 | #sizes = {} 144 | #for i in range(5, 17): 145 | # sizes[i] = 10 146 | out = [] 147 | for size, count in tqdm(sizes.items()): 148 | print(size) 149 | counts = defaultdict(list) 150 | for i in tqdm(range(n_samples)): 151 | graph, neigh = sample_neigh(targets, size) 152 | v = neigh[0] 153 | neigh = graph.subgraph(neigh).copy() 154 | nx.set_node_attributes(neigh, 0, name="anchor") 155 | neigh.nodes[v]["anchor"] = 1 156 | neigh.remove_edges_from(nx.selfloop_edges(neigh)) 157 | counts[wl_hash(neigh, node_anchored=node_anchored)].append(neigh) 158 | #bads, t = 0, 0 159 | #for ka, nas in counts.items(): 160 | # for kb, nbs in counts.items(): 161 | # if ka != kb: 162 | # for a in nas: 163 | # for b in nbs: 164 | # if nx.is_isomorphic(a, b): 165 | # bads += 1 166 | # print("bad", bads, t) 167 | # t += 1 168 | 169 | for _, neighs in list(sorted(counts.items(), key=lambda x: len(x[1]), 170 | reverse=True))[:count]: 171 | print(len(neighs)) 172 | out.append(random.choice(neighs)) 173 | return out 174 | 175 | device_cache = None 176 | def get_device(): 177 | global device_cache 178 | if device_cache is None: 179 | device_cache = torch.device("cuda") if torch.cuda.is_available() \ 180 | else torch.device("cpu") 181 | #device_cache = torch.device("cpu") 182 | return device_cache 183 | 184 | def parse_optimizer(parser): 185 | opt_parser = parser.add_argument_group() 186 | opt_parser.add_argument('--opt', dest='opt', type=str, 187 | help='Type of optimizer') 188 | opt_parser.add_argument('--opt-scheduler', dest='opt_scheduler', type=str, 189 | help='Type of optimizer scheduler. By default none') 190 | opt_parser.add_argument('--opt-restart', dest='opt_restart', type=int, 191 | help='Number of epochs before restart (by default set to 0 which means no restart)') 192 | opt_parser.add_argument('--opt-decay-step', dest='opt_decay_step', type=int, 193 | help='Number of epochs before decay') 194 | opt_parser.add_argument('--opt-decay-rate', dest='opt_decay_rate', type=float, 195 | help='Learning rate decay ratio') 196 | opt_parser.add_argument('--lr', dest='lr', type=float, 197 | help='Learning rate.') 198 | opt_parser.add_argument('--clip', dest='clip', type=float, 199 | help='Gradient clipping.') 200 | opt_parser.add_argument('--weight_decay', type=float, 201 | help='Optimizer weight decay.') 202 | 203 | def build_optimizer(args, params): 204 | weight_decay = args.weight_decay 205 | filter_fn = filter(lambda p : p.requires_grad, params) 206 | if args.opt == 'adam': 207 | optimizer = optim.Adam(filter_fn, lr=args.lr, weight_decay=weight_decay) 208 | elif args.opt == 'sgd': 209 | optimizer = optim.SGD(filter_fn, lr=args.lr, momentum=0.95, 210 | weight_decay=weight_decay) 211 | elif args.opt == 'rmsprop': 212 | optimizer = optim.RMSprop(filter_fn, lr=args.lr, weight_decay=weight_decay) 213 | elif args.opt == 'adagrad': 214 | optimizer = optim.Adagrad(filter_fn, lr=args.lr, weight_decay=weight_decay) 215 | if args.opt_scheduler == 'none': 216 | return None, optimizer 217 | elif args.opt_scheduler == 'step': 218 | scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=args.opt_decay_step, gamma=args.opt_decay_rate) 219 | elif args.opt_scheduler == 'cos': 220 | scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=args.opt_restart) 221 | return scheduler, optimizer 222 | 223 | def batch_nx_graphs(graphs, anchors=None): 224 | #motifs_batch = [pyg_utils.from_networkx( 225 | # nx.convert_node_labels_to_integers(graph)) for graph in graphs] 226 | #loader = DataLoader(motifs_batch, batch_size=len(motifs_batch)) 227 | #for b in loader: batch = b 228 | augmenter = feature_preprocess.FeatureAugment() 229 | 230 | if anchors is not None: 231 | for anchor, g in zip(anchors, graphs): 232 | for v in g.nodes: 233 | g.nodes[v]["node_feature"] = torch.tensor([float(v == anchor)]) 234 | 235 | batch = Batch.from_data_list([DSGraph(g) for g in graphs]) 236 | batch = augmenter.augment(batch) 237 | batch = batch.to(get_device()) 238 | return batch 239 | -------------------------------------------------------------------------------- /subgraph_mining/decoder.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import csv 3 | from itertools import combinations 4 | import time 5 | import os 6 | 7 | from deepsnap.batch import Batch 8 | import numpy as np 9 | import torch 10 | import torch.optim as optim 11 | import torch.nn as nn 12 | import torch.nn.functional as F 13 | from tqdm import tqdm 14 | 15 | from torch_geometric.datasets import TUDataset, PPI 16 | from torch_geometric.datasets import Planetoid, KarateClub, QM7b 17 | from torch_geometric.data import DataLoader 18 | import torch_geometric.utils as pyg_utils 19 | 20 | import torch_geometric.nn as pyg_nn 21 | from matplotlib import cm 22 | 23 | from common import data 24 | from common import models 25 | from common import utils 26 | from common import combined_syn 27 | from subgraph_mining.config import parse_decoder 28 | from subgraph_matching.config import parse_encoder 29 | from subgraph_mining.search_agents import GreedySearchAgent, MCTSSearchAgent 30 | 31 | import matplotlib.pyplot as plt 32 | 33 | import random 34 | from scipy.io import mmread 35 | import scipy.stats as stats 36 | from sklearn.manifold import TSNE 37 | from sklearn.cluster import KMeans, AgglomerativeClustering 38 | from collections import defaultdict 39 | from itertools import permutations 40 | from queue import PriorityQueue 41 | import matplotlib.colors as mcolors 42 | import networkx as nx 43 | import pickle 44 | import torch.multiprocessing as mp 45 | from sklearn.decomposition import PCA 46 | 47 | def make_plant_dataset(size): 48 | generator = combined_syn.get_generator([size]) 49 | random.seed(3001) 50 | np.random.seed(14853) 51 | # PATTERN 1 52 | pattern = generator.generate(size=10) 53 | # PATTERN 2 54 | #pattern = nx.star_graph(9) 55 | # PATTERN 3 56 | #pattern = nx.complete_graph(10) 57 | # PATTERN 4 58 | #pattern = nx.Graph() 59 | #pattern.add_edges_from([(1, 2), (2, 3), (3, 4), (4, 5), (5, 6), 60 | # (6, 7), (7, 2), (7, 8), (8, 9), (9, 10), (10, 6)]) 61 | nx.draw(pattern, with_labels=True) 62 | plt.savefig("plots/cluster/plant-pattern.png") 63 | plt.close() 64 | graphs = [] 65 | for i in range(1000): 66 | graph = generator.generate() 67 | n_old = len(graph) 68 | graph = nx.disjoint_union(graph, pattern) 69 | for j in range(1, 3): 70 | u = random.randint(0, n_old - 1) 71 | v = random.randint(n_old, len(graph) - 1) 72 | graph.add_edge(u, v) 73 | graphs.append(graph) 74 | return graphs 75 | 76 | def pattern_growth(dataset, task, args): 77 | # init model 78 | if args.method_type == "end2end": 79 | model = models.End2EndOrder(1, args.hidden_dim, args) 80 | elif args.method_type == "mlp": 81 | model = models.BaselineMLP(1, args.hidden_dim, args) 82 | else: 83 | model = models.OrderEmbedder(1, args.hidden_dim, args) 84 | model.to(utils.get_device()) 85 | model.eval() 86 | model.load_state_dict(torch.load(args.model_path, 87 | map_location=utils.get_device())) 88 | 89 | if task == "graph-labeled": 90 | dataset, labels = dataset 91 | 92 | # load data 93 | neighs_pyg, neighs = [], [] 94 | print(len(dataset), "graphs") 95 | print("search strategy:", args.search_strategy) 96 | if task == "graph-labeled": print("using label 0") 97 | graphs = [] 98 | for i, graph in enumerate(dataset): 99 | if task == "graph-labeled" and labels[i] != 0: continue 100 | if task == "graph-truncate" and i >= 1000: break 101 | if not type(graph) == nx.Graph: 102 | graph = pyg_utils.to_networkx(graph).to_undirected() 103 | graphs.append(graph) 104 | if args.use_whole_graphs: 105 | neighs = graphs 106 | else: 107 | anchors = [] 108 | if args.sample_method == "radial": 109 | for i, graph in enumerate(graphs): 110 | print(i) 111 | for j, node in enumerate(graph.nodes): 112 | if len(dataset) <= 10 and j % 100 == 0: print(i, j) 113 | if args.use_whole_graphs: 114 | neigh = graph.nodes 115 | else: 116 | neigh = list(nx.single_source_shortest_path_length(graph, 117 | node, cutoff=args.radius).keys()) 118 | if args.subgraph_sample_size != 0: 119 | neigh = random.sample(neigh, min(len(neigh), 120 | args.subgraph_sample_size)) 121 | if len(neigh) > 1: 122 | neigh = graph.subgraph(neigh) 123 | if args.subgraph_sample_size != 0: 124 | neigh = neigh.subgraph(max( 125 | nx.connected_components(neigh), key=len)) 126 | neigh = nx.convert_node_labels_to_integers(neigh) 127 | neigh.add_edge(0, 0) 128 | neighs.append(neigh) 129 | elif args.sample_method == "tree": 130 | start_time = time.time() 131 | for j in tqdm(range(args.n_neighborhoods)): 132 | graph, neigh = utils.sample_neigh(graphs, 133 | random.randint(args.min_neighborhood_size, 134 | args.max_neighborhood_size)) 135 | neigh = graph.subgraph(neigh) 136 | neigh = nx.convert_node_labels_to_integers(neigh) 137 | neigh.add_edge(0, 0) 138 | neighs.append(neigh) 139 | if args.node_anchored: 140 | anchors.append(0) # after converting labels, 0 will be anchor 141 | 142 | embs = [] 143 | if len(neighs) % args.batch_size != 0: 144 | print("WARNING: number of graphs not multiple of batch size") 145 | for i in range(len(neighs) // args.batch_size): 146 | #top = min(len(neighs), (i+1)*args.batch_size) 147 | top = (i+1)*args.batch_size 148 | with torch.no_grad(): 149 | batch = utils.batch_nx_graphs(neighs[i*args.batch_size:top], 150 | anchors=anchors if args.node_anchored else None) 151 | emb = model.emb_model(batch) 152 | emb = emb.to(torch.device("cpu")) 153 | 154 | embs.append(emb) 155 | 156 | if args.analyze: 157 | embs_np = torch.stack(embs).numpy() 158 | plt.scatter(embs_np[:,0], embs_np[:,1], label="node neighborhood") 159 | 160 | if args.search_strategy == "mcts": 161 | assert args.method_type == "order" 162 | agent = MCTSSearchAgent(args.min_pattern_size, args.max_pattern_size, 163 | model, graphs, embs, node_anchored=args.node_anchored, 164 | analyze=args.analyze, out_batch_size=args.out_batch_size) 165 | elif args.search_strategy == "greedy": 166 | agent = GreedySearchAgent(args.min_pattern_size, args.max_pattern_size, 167 | model, graphs, embs, node_anchored=args.node_anchored, 168 | analyze=args.analyze, model_type=args.method_type, 169 | out_batch_size=args.out_batch_size) 170 | out_graphs = agent.run_search(args.n_trials) 171 | print(time.time() - start_time, "TOTAL TIME") 172 | x = int(time.time() - start_time) 173 | print(x // 60, "mins", x % 60, "secs") 174 | 175 | # visualize out patterns 176 | count_by_size = defaultdict(int) 177 | for pattern in out_graphs: 178 | if args.node_anchored: 179 | colors = ["red"] + ["blue"]*(len(pattern)-1) 180 | nx.draw(pattern, node_color=colors, with_labels=True) 181 | else: 182 | nx.draw(pattern) 183 | print("Saving plots/cluster/{}-{}.png".format(len(pattern), 184 | count_by_size[len(pattern)])) 185 | plt.savefig("plots/cluster/{}-{}.png".format(len(pattern), 186 | count_by_size[len(pattern)])) 187 | plt.savefig("plots/cluster/{}-{}.pdf".format(len(pattern), 188 | count_by_size[len(pattern)])) 189 | plt.close() 190 | count_by_size[len(pattern)] += 1 191 | 192 | if not os.path.exists("results"): 193 | os.makedirs("results") 194 | with open(args.out_path, "wb") as f: 195 | pickle.dump(out_graphs, f) 196 | 197 | def main(): 198 | if not os.path.exists("plots/cluster"): 199 | os.makedirs("plots/cluster") 200 | 201 | parser = argparse.ArgumentParser(description='Decoder arguments') 202 | parse_encoder(parser) 203 | parse_decoder(parser) 204 | args = parser.parse_args() 205 | args.dataset = "enzymes" 206 | 207 | print("Using dataset {}".format(args.dataset)) 208 | if args.dataset == 'enzymes': 209 | dataset = TUDataset(root='/tmp/ENZYMES', name='ENZYMES') 210 | task = 'graph' 211 | elif args.dataset == 'cox2': 212 | dataset = TUDataset(root='/tmp/cox2', name='COX2') 213 | task = 'graph' 214 | elif args.dataset == 'reddit-binary': 215 | dataset = TUDataset(root='/tmp/REDDIT-BINARY', name='REDDIT-BINARY') 216 | task = 'graph' 217 | elif args.dataset == 'dblp': 218 | dataset = TUDataset(root='/tmp/dblp', name='DBLP_v1') 219 | task = 'graph-truncate' 220 | elif args.dataset == 'coil': 221 | dataset = TUDataset(root='/tmp/coil', name='COIL-DEL') 222 | task = 'graph' 223 | elif args.dataset.startswith('roadnet-'): 224 | graph = nx.Graph() 225 | with open("data/{}.txt".format(args.dataset), "r") as f: 226 | for row in f: 227 | if not row.startswith("#"): 228 | a, b = row.split("\t") 229 | graph.add_edge(int(a), int(b)) 230 | dataset = [graph] 231 | task = 'graph' 232 | elif args.dataset == "ppi": 233 | dataset = PPI(root="/tmp/PPI") 234 | task = 'graph' 235 | elif args.dataset in ['diseasome', 'usroads', 'mn-roads', 'infect']: 236 | fn = {"diseasome": "bio-diseasome.mtx", 237 | "usroads": "road-usroads.mtx", 238 | "mn-roads": "mn-roads.mtx", 239 | "infect": "infect-dublin.edges"} 240 | graph = nx.Graph() 241 | with open("data/{}".format(fn[args.dataset]), "r") as f: 242 | for line in f: 243 | if not line.strip(): continue 244 | a, b = line.strip().split(" ") 245 | graph.add_edge(int(a), int(b)) 246 | dataset = [graph] 247 | task = 'graph' 248 | elif args.dataset.startswith('plant-'): 249 | size = int(args.dataset.split("-")[-1]) 250 | dataset = make_plant_dataset(size) 251 | task = 'graph' 252 | 253 | pattern_growth(dataset, task, args) 254 | 255 | if __name__ == '__main__': 256 | main() 257 | 258 | -------------------------------------------------------------------------------- /common/models.py: -------------------------------------------------------------------------------- 1 | """Defines all graph embedding models""" 2 | from functools import reduce 3 | import random 4 | 5 | import networkx as nx 6 | import numpy as np 7 | import torch 8 | import torch.nn as nn 9 | import torch.nn.functional as F 10 | import torch_geometric.nn as pyg_nn 11 | import torch_geometric.utils as pyg_utils 12 | 13 | from common import utils 14 | from common import feature_preprocess 15 | 16 | # GNN -> concat -> MLP graph classification baseline 17 | class BaselineMLP(nn.Module): 18 | def __init__(self, input_dim, hidden_dim, args): 19 | super(BaselineMLP, self).__init__() 20 | self.emb_model = SkipLastGNN(input_dim, hidden_dim, hidden_dim, args) 21 | self.mlp = nn.Sequential(nn.Linear(2 * hidden_dim, 256), nn.ReLU(), 22 | nn.Linear(256, 2)) 23 | 24 | def forward(self, emb_motif, emb_motif_mod): 25 | pred = self.mlp(torch.cat((emb_motif, emb_motif_mod), dim=1)) 26 | pred = F.log_softmax(pred, dim=1) 27 | return pred 28 | 29 | def predict(self, pred): 30 | return pred#.argmax(dim=1) 31 | 32 | def criterion(self, pred, _, label): 33 | return F.nll_loss(pred, label) 34 | 35 | # Order embedder model -- contains a graph embedding model `emb_model` 36 | class OrderEmbedder(nn.Module): 37 | def __init__(self, input_dim, hidden_dim, args): 38 | super(OrderEmbedder, self).__init__() 39 | self.emb_model = SkipLastGNN(input_dim, hidden_dim, hidden_dim, args) 40 | self.margin = args.margin 41 | self.use_intersection = False 42 | 43 | self.clf_model = nn.Sequential(nn.Linear(1, 2), nn.LogSoftmax(dim=-1)) 44 | 45 | def forward(self, emb_as, emb_bs): 46 | return emb_as, emb_bs 47 | 48 | def predict(self, pred): 49 | """Predict if b is a subgraph of a (batched), where emb_as, emb_bs = pred. 50 | 51 | pred: list (emb_as, emb_bs) of embeddings of graph pairs 52 | 53 | Returns: list of bools (whether a is subgraph of b in the pair) 54 | """ 55 | emb_as, emb_bs = pred 56 | 57 | e = torch.sum(torch.max(torch.zeros_like(emb_as, 58 | device=emb_as.device), emb_bs - emb_as)**2, dim=1) 59 | return e 60 | 61 | def criterion(self, pred, intersect_embs, labels): 62 | """Loss function for order emb. 63 | The e term is the amount of violation (if b is a subgraph of a). 64 | For positive examples, the e term is minimized (close to 0); 65 | for negative examples, the e term is trained to be at least greater than self.margin. 66 | 67 | pred: lists of embeddings outputted by forward 68 | intersect_embs: not used 69 | labels: subgraph labels for each entry in pred 70 | """ 71 | emb_as, emb_bs = pred 72 | e = torch.sum(torch.max(torch.zeros_like(emb_as, 73 | device=utils.get_device()), emb_bs - emb_as)**2, dim=1) 74 | 75 | margin = self.margin 76 | e[labels == 0] = torch.max(torch.tensor(0.0, 77 | device=utils.get_device()), margin - e)[labels == 0] 78 | 79 | relation_loss = torch.sum(e) 80 | 81 | return relation_loss 82 | 83 | class SkipLastGNN(nn.Module): 84 | def __init__(self, input_dim, hidden_dim, output_dim, args): 85 | super(SkipLastGNN, self).__init__() 86 | self.dropout = args.dropout 87 | self.n_layers = args.n_layers 88 | 89 | if len(feature_preprocess.FEATURE_AUGMENT) > 0: 90 | self.feat_preprocess = feature_preprocess.Preprocess(input_dim) 91 | input_dim = self.feat_preprocess.dim_out 92 | else: 93 | self.feat_preprocess = None 94 | 95 | self.pre_mp = nn.Sequential(nn.Linear(input_dim, 3*hidden_dim if 96 | args.conv_type == "PNA" else hidden_dim)) 97 | 98 | conv_model = self.build_conv_model(args.conv_type, 1) 99 | if args.conv_type == "PNA": 100 | self.convs_sum = nn.ModuleList() 101 | self.convs_mean = nn.ModuleList() 102 | self.convs_max = nn.ModuleList() 103 | else: 104 | self.convs = nn.ModuleList() 105 | 106 | if args.skip == 'learnable': 107 | self.learnable_skip = nn.Parameter(torch.ones(self.n_layers, 108 | self.n_layers)) 109 | 110 | for l in range(args.n_layers): 111 | if args.skip == 'all' or args.skip == 'learnable': 112 | hidden_input_dim = hidden_dim * (l + 1) 113 | else: 114 | hidden_input_dim = hidden_dim 115 | if args.conv_type == "PNA": 116 | self.convs_sum.append(conv_model(3*hidden_input_dim, hidden_dim)) 117 | self.convs_mean.append(conv_model(3*hidden_input_dim, hidden_dim)) 118 | self.convs_max.append(conv_model(3*hidden_input_dim, hidden_dim)) 119 | else: 120 | self.convs.append(conv_model(hidden_input_dim, hidden_dim)) 121 | 122 | post_input_dim = hidden_dim * (args.n_layers + 1) 123 | if args.conv_type == "PNA": 124 | post_input_dim *= 3 125 | self.post_mp = nn.Sequential( 126 | nn.Linear(post_input_dim, hidden_dim), nn.Dropout(args.dropout), 127 | nn.LeakyReLU(0.1), 128 | nn.Linear(hidden_dim, output_dim), 129 | nn.ReLU(), 130 | nn.Linear(hidden_dim, 256), nn.ReLU(), 131 | nn.Linear(256, hidden_dim)) 132 | #self.batch_norm = nn.BatchNorm1d(output_dim, eps=1e-5, momentum=0.1) 133 | self.skip = args.skip 134 | self.conv_type = args.conv_type 135 | 136 | def build_conv_model(self, model_type, n_inner_layers): 137 | if model_type == "GCN": 138 | return pyg_nn.GCNConv 139 | elif model_type == "GIN": 140 | #return lambda i, h: pyg_nn.GINConv(nn.Sequential( 141 | # nn.Linear(i, h), nn.ReLU())) 142 | return lambda i, h: GINConv(nn.Sequential( 143 | nn.Linear(i, h), nn.ReLU(), nn.Linear(h, h) 144 | )) 145 | elif model_type == "SAGE": 146 | return SAGEConv 147 | elif model_type == "graph": 148 | return pyg_nn.GraphConv 149 | elif model_type == "GAT": 150 | return pyg_nn.GATConv 151 | elif model_type == "gated": 152 | return lambda i, h: pyg_nn.GatedGraphConv(h, n_inner_layers) 153 | elif model_type == "PNA": 154 | return SAGEConv 155 | else: 156 | print("unrecognized model type") 157 | 158 | def forward(self, data): 159 | #if data.x is None: 160 | # data.x = torch.ones((data.num_nodes, 1), device=utils.get_device()) 161 | 162 | #x = self.pre_mp(x) 163 | if self.feat_preprocess is not None: 164 | if not hasattr(data, "preprocessed"): 165 | data = self.feat_preprocess(data) 166 | data.preprocessed = True 167 | x, edge_index, batch = data.node_feature, data.edge_index, data.batch 168 | x = self.pre_mp(x) 169 | 170 | all_emb = x.unsqueeze(1) 171 | emb = x 172 | for i in range(len(self.convs_sum) if self.conv_type=="PNA" else 173 | len(self.convs)): 174 | if self.skip == 'learnable': 175 | skip_vals = self.learnable_skip[i, 176 | :i+1].unsqueeze(0).unsqueeze(-1) 177 | curr_emb = all_emb * torch.sigmoid(skip_vals) 178 | curr_emb = curr_emb.view(x.size(0), -1) 179 | if self.conv_type == "PNA": 180 | x = torch.cat((self.convs_sum[i](curr_emb, edge_index), 181 | self.convs_mean[i](curr_emb, edge_index), 182 | self.convs_max[i](curr_emb, edge_index)), dim=-1) 183 | else: 184 | x = self.convs[i](curr_emb, edge_index) 185 | elif self.skip == 'all': 186 | if self.conv_type == "PNA": 187 | x = torch.cat((self.convs_sum[i](emb, edge_index), 188 | self.convs_mean[i](emb, edge_index), 189 | self.convs_max[i](emb, edge_index)), dim=-1) 190 | else: 191 | x = self.convs[i](emb, edge_index) 192 | else: 193 | x = self.convs[i](x, edge_index) 194 | x = F.relu(x) 195 | x = F.dropout(x, p=self.dropout, training=self.training) 196 | emb = torch.cat((emb, x), 1) 197 | if self.skip == 'learnable': 198 | all_emb = torch.cat((all_emb, x.unsqueeze(1)), 1) 199 | 200 | # x = pyg_nn.global_mean_pool(x, batch) 201 | emb = pyg_nn.global_add_pool(emb, batch) 202 | emb = self.post_mp(emb) 203 | #emb = self.batch_norm(emb) # TODO: test 204 | #out = F.log_softmax(emb, dim=1) 205 | return emb 206 | 207 | def loss(self, pred, label): 208 | return F.nll_loss(pred, label) 209 | 210 | class SAGEConv(pyg_nn.MessagePassing): 211 | def __init__(self, in_channels, out_channels, aggr="add"): 212 | super(SAGEConv, self).__init__(aggr=aggr) 213 | 214 | self.in_channels = in_channels 215 | self.out_channels = out_channels 216 | 217 | self.lin = nn.Linear(in_channels, out_channels) 218 | self.lin_update = nn.Linear(out_channels + in_channels, 219 | out_channels) 220 | 221 | def forward(self, x, edge_index, edge_weight=None, size=None, 222 | res_n_id=None): 223 | """ 224 | Args: 225 | res_n_id (Tensor, optional): Residual node indices coming from 226 | :obj:`DataFlow` generated by :obj:`NeighborSampler` are used to 227 | select central node features in :obj:`x`. 228 | Required if operating in a bipartite graph and :obj:`concat` is 229 | :obj:`True`. (default: :obj:`None`) 230 | """ 231 | #edge_index, edge_weight = add_remaining_self_loops( 232 | # edge_index, edge_weight, 1, x.size(self.node_dim)) 233 | edge_index, _ = pyg_utils.remove_self_loops(edge_index) 234 | 235 | return self.propagate(edge_index, size=size, x=x, 236 | edge_weight=edge_weight, res_n_id=res_n_id) 237 | 238 | def message(self, x_j, edge_weight): 239 | #return x_j if edge_weight is None else edge_weight.view(-1, 1) * x_j 240 | return self.lin(x_j) 241 | 242 | def update(self, aggr_out, x, res_n_id): 243 | aggr_out = torch.cat([aggr_out, x], dim=-1) 244 | 245 | aggr_out = self.lin_update(aggr_out) 246 | #aggr_out = torch.matmul(aggr_out, self.weight) 247 | 248 | #if self.bias is not None: 249 | # aggr_out = aggr_out + self.bias 250 | 251 | #if self.normalize: 252 | # aggr_out = F.normalize(aggr_out, p=2, dim=-1) 253 | 254 | return aggr_out 255 | 256 | def __repr__(self): 257 | return '{}({}, {})'.format(self.__class__.__name__, self.in_channels, 258 | self.out_channels) 259 | 260 | # pytorch geom GINConv + weighted edges 261 | class GINConv(pyg_nn.MessagePassing): 262 | def __init__(self, nn, eps=0, train_eps=False, **kwargs): 263 | super(GINConv, self).__init__(aggr='add', **kwargs) 264 | self.nn = nn 265 | self.initial_eps = eps 266 | if train_eps: 267 | self.eps = torch.nn.Parameter(torch.Tensor([eps])) 268 | else: 269 | self.register_buffer('eps', torch.Tensor([eps])) 270 | self.reset_parameters() 271 | 272 | def reset_parameters(self): 273 | #reset(self.nn) 274 | self.eps.data.fill_(self.initial_eps) 275 | 276 | def forward(self, x, edge_index, edge_weight=None): 277 | """""" 278 | x = x.unsqueeze(-1) if x.dim() == 1 else x 279 | edge_index, edge_weight = pyg_utils.remove_self_loops(edge_index, 280 | edge_weight) 281 | out = self.nn((1 + self.eps) * x + self.propagate(edge_index, x=x, 282 | edge_weight=edge_weight)) 283 | return out 284 | 285 | def message(self, x_j, edge_weight): 286 | return x_j if edge_weight is None else edge_weight.view(-1, 1) * x_j 287 | 288 | def __repr__(self): 289 | return '{}(nn={})'.format(self.__class__.__name__, self.nn) 290 | 291 | -------------------------------------------------------------------------------- /analyze/count_patterns.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import csv 3 | import time 4 | import os 5 | import json 6 | 7 | import numpy as np 8 | import torch 9 | import torch.optim as optim 10 | import torch.nn as nn 11 | import torch.nn.functional as F 12 | 13 | from torch_geometric.datasets import TUDataset 14 | from torch_geometric.datasets import Planetoid, KarateClub, QM7b 15 | from torch_geometric.data import DataLoader 16 | import torch_geometric.utils as pyg_utils 17 | 18 | import torch_geometric.nn as pyg_nn 19 | from matplotlib import cm 20 | 21 | from common import data 22 | from common import models 23 | from common import utils 24 | from subgraph_mining import decoder 25 | 26 | from tqdm import tqdm 27 | import matplotlib.pyplot as plt 28 | 29 | from multiprocessing import Pool 30 | import random 31 | from sklearn.manifold import TSNE 32 | from sklearn.cluster import KMeans 33 | from collections import defaultdict, Counter 34 | from itertools import permutations 35 | from queue import PriorityQueue 36 | import matplotlib.colors as mcolors 37 | import networkx as nx 38 | import networkx.algorithms.isomorphism as iso 39 | import pickle 40 | import torch.multiprocessing as mp 41 | from sklearn.decomposition import PCA 42 | 43 | import orca 44 | 45 | def arg_parse(): 46 | parser = argparse.ArgumentParser(description='count graphlets in a graph') 47 | parser.add_argument('--dataset', type=str) 48 | parser.add_argument('--queries_path', type=str) 49 | parser.add_argument('--out_path', type=str) 50 | parser.add_argument('--n_workers', type=int) 51 | parser.add_argument('--count_method', type=str) 52 | parser.add_argument('--baseline', type=str) 53 | parser.add_argument('--node_anchored', action="store_true") 54 | parser.set_defaults(dataset="enzymes", 55 | queries_path="results/out-patterns.p", 56 | out_path="results/counts.json", 57 | n_workers=4, 58 | count_method="bin", 59 | baseline="none") 60 | #node_anchored=True) 61 | return parser.parse_args() 62 | 63 | def gen_baseline_queries(queries, targets, method="mfinder", 64 | node_anchored=False): 65 | # use this to generate N size K queries 66 | #queries = [[0]*n for n in range(5, 21) for i in range(10)] 67 | if method == "mfinder": 68 | return utils.gen_baseline_queries_mfinder(queries, targets, 69 | node_anchored=node_anchored) 70 | elif method == "rand-esu": 71 | return utils.gen_baseline_queries_rand_esu(queries, targets, 72 | node_anchored=node_anchored) 73 | neighs = [] 74 | for i, query in enumerate(queries): 75 | print(i) 76 | found = False 77 | if len(query) == 0: 78 | neighs.append(query) 79 | found = True 80 | while not found: 81 | if method == "radial": 82 | graph = random.choice(targets) 83 | node = random.choice(list(graph.nodes)) 84 | neigh = list(nx.single_source_shortest_path_length(graph, node, 85 | cutoff=3).keys()) 86 | #neigh = random.sample(neigh, min(len(neigh), 15)) 87 | neigh = graph.subgraph(neigh) 88 | neigh = neigh.subgraph(list(sorted(nx.connected_components( 89 | neigh), key=len))[-1]) 90 | neigh = nx.convert_node_labels_to_integers(neigh) 91 | print(i, len(neigh), len(query)) 92 | if len(neigh) == len(query): 93 | neighs.append(neigh) 94 | found = True 95 | elif method == "tree": 96 | # https://academic.oup.com/bioinformatics/article/20/11/1746/300212 97 | graph = random.choice(targets) 98 | start_node = random.choice(list(graph.nodes)) 99 | neigh = [start_node] 100 | frontier = list(set(graph.neighbors(start_node)) - set(neigh)) 101 | while len(neigh) < len(query) and frontier: 102 | new_node = random.choice(list(frontier)) 103 | assert new_node not in neigh 104 | neigh.append(new_node) 105 | frontier += list(graph.neighbors(new_node)) 106 | frontier = [x for x in frontier if x not in neigh] 107 | if len(neigh) == len(query): 108 | neigh = graph.subgraph(neigh) 109 | neigh = nx.convert_node_labels_to_integers(neigh) 110 | neighs.append(neigh) 111 | found = True 112 | return neighs 113 | 114 | def count_graphlets_helper(inp): 115 | i, query, target, method, node_anchored, anchor_or_none = inp 116 | # NOTE: removing self loops!! 117 | query = query.copy() 118 | query.remove_edges_from(nx.selfloop_edges(query)) 119 | #if node_anchored and method == "bin": 120 | # n_chances_left = sum([len(g) for g in targets]) 121 | if method == "freq": 122 | ismags = nx.isomorphism.ISMAGS(query, query) 123 | n_symmetries = len(list(ismags.isomorphisms_iter(symmetry=False))) 124 | 125 | #print(n_symmetries, "symmetries") 126 | n, n_bin = 0, 0 127 | target = target.copy() 128 | target.remove_edges_from(nx.selfloop_edges(target)) 129 | #print(i, j, len(target), n / n_symmetries) 130 | #matcher = nx.isomorphism.ISMAGS(target, query) 131 | if method == "bin": 132 | if node_anchored: 133 | for anchor in (target.nodes if anchor_or_none is None else 134 | [anchor_or_none]): 135 | #if random.random() > 0.1: continue 136 | nx.set_node_attributes(target, 0, name="anchor") 137 | target.nodes[anchor]["anchor"] = 1 138 | matcher = iso.GraphMatcher(target, query, 139 | node_match=iso.categorical_node_match(["anchor"], [0])) 140 | if matcher.subgraph_is_isomorphic(): 141 | n += 1 142 | #else: 143 | #n_chances_left -= 1 144 | #if n_chances_left < min_count: 145 | # return i, -1 146 | else: 147 | matcher = iso.GraphMatcher(target, query) 148 | n += int(matcher.subgraph_is_isomorphic()) 149 | elif method == "freq": 150 | matcher = iso.GraphMatcher(target, query) 151 | n += len(list(matcher.subgraph_isomorphisms_iter())) / n_symmetries 152 | else: 153 | print("counting method not understood") 154 | #n_matches.append(n / n_symmetries) 155 | #print(i, n / n_symmetries) 156 | count = n# / n_symmetries 157 | #if include_bin: 158 | # count = (count, n_bin) 159 | #print(i, count) 160 | return i, count 161 | 162 | def count_graphlets(queries, targets, n_workers=1, method="bin", 163 | node_anchored=False, min_count=0): 164 | print(len(queries), len(targets)) 165 | #idxs, counts = zip(*[count_graphlets_helper((i, q, targets, include_bin)) 166 | # for i, q in enumerate(queries)]) 167 | #counts = list(counts) 168 | #return counts 169 | 170 | n_matches = defaultdict(float) 171 | #for i, query in enumerate(queries): 172 | pool = Pool(processes=n_workers) 173 | if node_anchored: 174 | inp = [(i, query, target, method, node_anchored, anchor) for i, query 175 | in enumerate(queries) for target in targets for anchor in (target 176 | if len(targets) < 10 else [None])] 177 | else: 178 | inp = [(i, query, target, method, node_anchored, None) for i, query 179 | in enumerate(queries) for target in targets] 180 | print(len(inp)) 181 | n_done = 0 182 | for i, n in pool.imap_unordered(count_graphlets_helper, inp): 183 | print(n_done, len(n_matches), i, n, " ", end="\r") 184 | n_matches[i] += n 185 | n_done += 1 186 | print() 187 | n_matches = [n_matches[i] for i in range(len(n_matches))] 188 | return n_matches 189 | 190 | def count_exact(queries, targets, args): 191 | print("WARNING: orca only works for node anchored") 192 | # TODO: non node anchored 193 | n_matches_baseline = np.zeros(73) 194 | for target in targets: 195 | counts = np.array(orca.orbit_counts("node", 5, target)) 196 | if args.count_method == "bin": 197 | counts = np.sign(counts) 198 | counts = np.sum(counts, axis=0) 199 | n_matches_baseline += counts 200 | # don't include size < 5 201 | n_matches_baseline = list(n_matches_baseline)[15:] 202 | counts5 = [] 203 | num5 = 10#len([q for q in queries if len(q) == 5]) 204 | for x in list(sorted(n_matches_baseline, reverse=True))[:num5]: 205 | print(x) 206 | counts5.append(x) 207 | print("Average for size 5:", np.mean(np.log10(counts5))) 208 | 209 | atlas = [g for g in nx.graph_atlas_g()[1:] if nx.is_connected(g) 210 | and len(g) == 6] 211 | queries = [] 212 | for g in atlas: 213 | for v in g.nodes: 214 | g = g.copy() 215 | nx.set_node_attributes(g, 0, name="anchor") 216 | g.nodes[v]["anchor"] = 1 217 | is_dup = False 218 | for g2 in queries: 219 | if nx.is_isomorphic(g, g2, node_match=(lambda a, b: a["anchor"] 220 | == b["anchor"]) if args.node_anchored else None): 221 | is_dup = True 222 | break 223 | if not is_dup: 224 | queries.append(g) 225 | print(len(queries)) 226 | n_matches_baseline = count_graphlets(queries, targets, 227 | n_workers=args.n_workers, method=args.count_method, 228 | node_anchored=args.node_anchored, 229 | min_count=10000) 230 | counts6 = [] 231 | num6 = 20#len([q for q in queries if len(q) == 6]) 232 | for x in list(sorted(n_matches_baseline, reverse=True))[:num6]: 233 | print(x) 234 | counts6.append(x) 235 | print("Average for size 6:", np.mean(np.log10(counts6))) 236 | return counts5 + counts6 237 | 238 | if __name__ == "__main__": 239 | args = arg_parse() 240 | print("Using {} workers".format(args.n_workers)) 241 | print("Baseline:", args.baseline) 242 | 243 | if args.dataset == 'enzymes': 244 | dataset = TUDataset(root='/tmp/ENZYMES', name='ENZYMES') 245 | elif args.dataset == 'cox2': 246 | dataset = TUDataset(root='/tmp/cox2', name='COX2') 247 | elif args.dataset == 'reddit-binary': 248 | dataset = TUDataset(root='/tmp/REDDIT-BINARY', name='REDDIT-BINARY') 249 | elif args.dataset == 'coil': 250 | dataset = TUDataset(root='/tmp/COIL-DEL', name='COIL-DEL') 251 | elif args.dataset == 'ppi-pathways': 252 | graph = nx.Graph() 253 | with open("data/ppi-pathways.csv", "r") as f: 254 | reader = csv.reader(f) 255 | for row in reader: 256 | graph.add_edge(int(row[0]), int(row[1])) 257 | dataset = [graph] 258 | elif args.dataset in ['diseasome', 'usroads', 'mn-roads', 'infect']: 259 | fn = {"diseasome": "bio-diseasome.mtx", 260 | "usroads": "road-usroads.mtx", 261 | "mn-roads": "mn-roads.mtx", 262 | "infect": "infect-dublin.edges"} 263 | graph = nx.Graph() 264 | with open("data/{}".format(fn[args.dataset]), "r") as f: 265 | for line in f: 266 | if not line.strip(): continue 267 | a, b = line.strip().split(" ") 268 | graph.add_edge(int(a), int(b)) 269 | dataset = [graph] 270 | elif args.dataset.startswith('plant-'): 271 | size = int(args.dataset.split("-")[-1]) 272 | dataset = decoder.make_plant_dataset(size) 273 | elif args.dataset == "analyze": 274 | with open("results/analyze.p", "rb") as f: 275 | cand_patterns, _ = pickle.load(f) 276 | queries = [q for score, q in cand_patterns[10]][:200] 277 | dataset = TUDataset(root='/tmp/ENZYMES', name='ENZYMES') 278 | 279 | targets = [] 280 | for i in range(len(dataset)): 281 | graph = dataset[i] 282 | if not type(graph) == nx.Graph: 283 | graph = pyg_utils.to_networkx(dataset[i]).to_undirected() 284 | targets.append(graph) 285 | 286 | if args.dataset != "analyze": 287 | with open(args.queries_path, "rb") as f: 288 | queries = pickle.load(f) 289 | 290 | # filter only top nonisomorphic size 6 motifs 291 | #filt_q = [] 292 | #for q in queries: 293 | # if len([qc for qc in filt_q if nx.is_isomorphic(q, qc)]) == 0: 294 | # filt_q.append(q) 295 | #queries = filt_q[:] 296 | #print(len(queries)) 297 | 298 | query_lens = [len(query) for query in queries] 299 | 300 | if args.baseline == "exact": 301 | n_matches_baseline = count_exact(queries, targets, args) 302 | n_matches = count_graphlets(queries[:len(n_matches_baseline)], targets, 303 | n_workers=args.n_workers, method=args.count_method, 304 | node_anchored=args.node_anchored) 305 | elif args.baseline == "none": 306 | n_matches = count_graphlets(queries, targets, 307 | n_workers=args.n_workers, method=args.count_method, 308 | node_anchored=args.node_anchored) 309 | else: 310 | baseline_queries = gen_baseline_queries(queries, targets, 311 | node_anchored=args.node_anchored, method=args.baseline) 312 | query_lens = [len(q) for q in baseline_queries] 313 | n_matches = count_graphlets(baseline_queries, targets, 314 | n_workers=args.n_workers, method=args.count_method, 315 | node_anchored=args.node_anchored) 316 | with open(args.out_path, "w") as f: 317 | json.dump((query_lens, n_matches, []), f) 318 | -------------------------------------------------------------------------------- /subgraph_mining/search_agents.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import csv 3 | from itertools import combinations 4 | import time 5 | import os 6 | 7 | from deepsnap.batch import Batch 8 | import numpy as np 9 | import torch 10 | import torch.optim as optim 11 | import torch.nn as nn 12 | import torch.nn.functional as F 13 | from tqdm import tqdm 14 | 15 | import torch_geometric.utils as pyg_utils 16 | 17 | import torch_geometric.nn as pyg_nn 18 | from matplotlib import cm 19 | 20 | from common import data 21 | from common import models 22 | from common import utils 23 | from common import combined_syn 24 | from subgraph_mining.config import parse_decoder 25 | from subgraph_matching.config import parse_encoder 26 | 27 | import matplotlib.pyplot as plt 28 | 29 | import random 30 | from scipy.io import mmread 31 | import scipy.stats as stats 32 | from sklearn.manifold import TSNE 33 | from sklearn.cluster import KMeans, AgglomerativeClustering 34 | from collections import defaultdict 35 | from itertools import permutations 36 | from queue import PriorityQueue 37 | import matplotlib.colors as mcolors 38 | import networkx as nx 39 | import pickle 40 | import torch.multiprocessing as mp 41 | from sklearn.decomposition import PCA 42 | 43 | class SearchAgent: 44 | """ Class for search strategies to identify frequent subgraphs in embedding space. 45 | 46 | The problem is formulated as a search. The first action chooses a seed node to grow from. 47 | Subsequent actions chooses a node in dataset to connect to the existing subgraph pattern, 48 | increasing the pattern size by 1. 49 | 50 | See paper for rationale and algorithm details. 51 | """ 52 | def __init__(self, min_pattern_size, max_pattern_size, model, dataset, 53 | embs, node_anchored=False, analyze=False, model_type="order", 54 | out_batch_size=20): 55 | """ Subgraph pattern search by walking in embedding space. 56 | 57 | Args: 58 | min_pattern_size: minimum size of frequent subgraphs to be identified. 59 | max_pattern_size: maximum size of frequent subgraphs to be identified. 60 | model: the trained subgraph matching model (PyTorch nn.Module). 61 | dataset: the DeepSNAP dataset for which to mine the frequent subgraph pattern. 62 | embs: embeddings of sampled node neighborhoods (see paper). 63 | node_anchored: an option to specify whether to identify node_anchored subgraph patterns. 64 | node_anchored search procedure has to use a node_anchored model (specified in subgraph 65 | matching config.py). 66 | analyze: whether to enable analysis visualization. 67 | model_type: type of the subgraph matching model (requires to be consistent with the model parameter). 68 | out_batch_size: the number of frequent subgraphs output by the mining algorithm for each size. 69 | They are predicted to be the out_batch_size most frequent subgraphs in the dataset. 70 | """ 71 | self.min_pattern_size = min_pattern_size 72 | self.max_pattern_size = max_pattern_size 73 | self.model = model 74 | self.dataset = dataset 75 | self.embs = embs 76 | self.node_anchored = node_anchored 77 | self.analyze = analyze 78 | self.model_type = model_type 79 | self.out_batch_size = out_batch_size 80 | 81 | def run_search(self, n_trials=1000): 82 | self.cand_patterns = defaultdict(list) 83 | self.counts = defaultdict(lambda: defaultdict(list)) 84 | self.n_trials = n_trials 85 | 86 | self.init_search() 87 | while not self.is_search_done(): 88 | self.step() 89 | return self.finish_search() 90 | 91 | def init_search(): 92 | raise NotImplementedError 93 | 94 | def step(self): 95 | """ Abstract method for executing a search step. 96 | Every step adds a new node to the subgraph pattern. 97 | Run_search calls step at least min_pattern_size times to generate a pattern of at least this 98 | size. To be inherited by concrete search strategy implementations. 99 | """ 100 | raise NotImplementedError 101 | 102 | class MCTSSearchAgent(SearchAgent): 103 | def __init__(self, min_pattern_size, max_pattern_size, model, dataset, 104 | embs, node_anchored=False, analyze=False, model_type="order", 105 | out_batch_size=20, c_uct=0.7): 106 | """ MCTS implementation of the subgraph pattern search. 107 | Uses MCTS strategy to search for the most common pattern. 108 | 109 | Args: 110 | c_uct: the exploration constant used in UCT criteria (See paper). 111 | """ 112 | super().__init__(min_pattern_size, max_pattern_size, model, dataset, 113 | embs, node_anchored=node_anchored, analyze=analyze, 114 | model_type=model_type, out_batch_size=out_batch_size) 115 | self.c_uct = c_uct 116 | assert not analyze 117 | 118 | def init_search(self): 119 | self.wl_hash_to_graphs = defaultdict(list) 120 | self.cum_action_values = defaultdict(lambda: defaultdict(float)) 121 | self.visit_counts = defaultdict(lambda: defaultdict(float)) 122 | self.visited_seed_nodes = set() 123 | self.max_size = self.min_pattern_size 124 | 125 | def is_search_done(self): 126 | return self.max_size == self.max_pattern_size + 1 127 | 128 | # returns whether there are at least n nodes reachable from start_node in graph 129 | def has_min_reachable_nodes(self, graph, start_node, n): 130 | for depth_limit in range(n+1): 131 | edges = nx.bfs_edges(graph, start_node, depth_limit=depth_limit) 132 | nodes = set([v for u, v in edges]) 133 | if len(nodes) + 1 >= n: 134 | return True 135 | return False 136 | 137 | def step(self): 138 | ps = np.array([len(g) for g in self.dataset], dtype=np.float) 139 | ps /= np.sum(ps) 140 | graph_dist = stats.rv_discrete(values=(np.arange(len(self.dataset)), ps)) 141 | 142 | print("Size", self.max_size) 143 | print(len(self.visited_seed_nodes), "distinct seeds") 144 | for simulation_n in tqdm(range(self.n_trials // 145 | (self.max_pattern_size+1-self.min_pattern_size))): 146 | # pick seed node 147 | best_graph_idx, best_start_node, best_score = None, None, -float("inf") 148 | for cand_graph_idx, cand_start_node in self.visited_seed_nodes: 149 | state = cand_graph_idx, cand_start_node 150 | my_visit_counts = sum(self.visit_counts[state].values()) 151 | q_score = (sum(self.cum_action_values[state].values()) / 152 | (my_visit_counts or 1)) 153 | uct_score = self.c_uct * np.sqrt(np.log(simulation_n or 1) / 154 | (my_visit_counts or 1)) 155 | node_score = q_score + uct_score 156 | if node_score > best_score: 157 | best_score = node_score 158 | best_graph_idx = cand_graph_idx 159 | best_start_node = cand_start_node 160 | # if existing seed beats choosing a new seed 161 | if best_score >= self.c_uct * np.sqrt(np.log(simulation_n or 1)): 162 | graph_idx, start_node = best_graph_idx, best_start_node 163 | assert best_start_node in self.dataset[graph_idx].nodes 164 | graph = self.dataset[graph_idx] 165 | else: 166 | found = False 167 | while not found: 168 | graph_idx = np.arange(len(self.dataset))[graph_dist.rvs()] 169 | graph = self.dataset[graph_idx] 170 | start_node = random.choice(list(graph.nodes)) 171 | # don't pick isolated nodes or small islands 172 | if self.has_min_reachable_nodes(graph, start_node, 173 | self.min_pattern_size): 174 | found = True 175 | self.visited_seed_nodes.add((graph_idx, start_node)) 176 | neigh = [start_node] 177 | frontier = list(set(graph.neighbors(start_node)) - set(neigh)) 178 | visited = set([start_node]) 179 | neigh_g = nx.Graph() 180 | neigh_g.add_node(start_node, anchor=1) 181 | cur_state = graph_idx, start_node 182 | state_list = [cur_state] 183 | while frontier and len(neigh) < self.max_size: 184 | cand_neighs, anchors = [], [] 185 | for cand_node in frontier: 186 | cand_neigh = graph.subgraph(neigh + [cand_node]) 187 | cand_neighs.append(cand_neigh) 188 | if self.node_anchored: 189 | anchors.append(neigh[0]) 190 | cand_embs = self.model.emb_model(utils.batch_nx_graphs( 191 | cand_neighs, anchors=anchors if self.node_anchored else None)) 192 | best_v_score, best_node_score, best_node = 0, -float("inf"), None 193 | for cand_node, cand_emb in zip(frontier, cand_embs): 194 | score, n_embs = 0, 0 195 | for emb_batch in self.embs: 196 | score += torch.sum(self.model.predict(( 197 | emb_batch.to(utils.get_device()), cand_emb))).item() 198 | n_embs += len(emb_batch) 199 | v_score = -np.log(score/n_embs + 1) + 1 200 | # get wl hash of next state 201 | neigh_g = graph.subgraph(neigh + [cand_node]).copy() 202 | neigh_g.remove_edges_from(nx.selfloop_edges(neigh_g)) 203 | for v in neigh_g.nodes: 204 | neigh_g.nodes[v]["anchor"] = 1 if v == neigh[0] else 0 205 | next_state = utils.wl_hash(neigh_g, 206 | node_anchored=self.node_anchored) 207 | # compute node score 208 | parent_visit_counts = sum(self.visit_counts[cur_state].values()) 209 | my_visit_counts = sum(self.visit_counts[next_state].values()) 210 | q_score = (sum(self.cum_action_values[next_state].values()) / 211 | (my_visit_counts or 1)) 212 | uct_score = self.c_uct * np.sqrt(np.log(parent_visit_counts or 213 | 1) / (my_visit_counts or 1)) 214 | node_score = q_score + uct_score 215 | if node_score > best_node_score: 216 | best_node_score = node_score 217 | best_v_score = v_score 218 | best_node = cand_node 219 | frontier = list(((set(frontier) | 220 | set(graph.neighbors(best_node))) - visited) - 221 | set([best_node])) 222 | visited.add(best_node) 223 | neigh.append(best_node) 224 | 225 | # update visit counts, wl cache 226 | neigh_g = graph.subgraph(neigh).copy() 227 | neigh_g.remove_edges_from(nx.selfloop_edges(neigh_g)) 228 | for v in neigh_g.nodes: 229 | neigh_g.nodes[v]["anchor"] = 1 if v == neigh[0] else 0 230 | prev_state = cur_state 231 | cur_state = utils.wl_hash(neigh_g, node_anchored=self.node_anchored) 232 | state_list.append(cur_state) 233 | self.wl_hash_to_graphs[cur_state].append(neigh_g) 234 | 235 | # backprop value 236 | for i in range(0, len(state_list) - 1): 237 | self.cum_action_values[state_list[i]][ 238 | state_list[i+1]] += best_v_score 239 | self.visit_counts[state_list[i]][state_list[i+1]] += 1 240 | self.max_size += 1 241 | 242 | def finish_search(self): 243 | counts = defaultdict(lambda: defaultdict(int)) 244 | for _, v in self.visit_counts.items(): 245 | for s2, count in v.items(): 246 | counts[len(random.choice(self.wl_hash_to_graphs[s2]))][s2] += count 247 | 248 | cand_patterns_uniq = [] 249 | for pattern_size in range(self.min_pattern_size, self.max_pattern_size+1): 250 | for wl_hash, count in sorted(counts[pattern_size].items(), key=lambda 251 | x: x[1], reverse=True)[:self.out_batch_size]: 252 | cand_patterns_uniq.append(random.choice( 253 | self.wl_hash_to_graphs[wl_hash])) 254 | print("- outputting", count, "motifs of size", pattern_size) 255 | return cand_patterns_uniq 256 | 257 | class GreedySearchAgent(SearchAgent): 258 | def __init__(self, min_pattern_size, max_pattern_size, model, dataset, 259 | embs, node_anchored=False, analyze=False, rank_method="counts", 260 | model_type="order", out_batch_size=20, n_beams=1): 261 | """Greedy implementation of the subgraph pattern search. 262 | At every step, the algorithm chooses greedily the next node to grow while the pattern 263 | remains predicted to be frequent. The criteria to choose the next action depends 264 | on the score predicted by the subgraph matching model 265 | (the actual score is determined by the rank_method argument). 266 | 267 | Args: 268 | rank_method: greedy search heuristic requires a score to rank the 269 | possible next actions. 270 | If rank_method=='counts', counts of the pattern in search tree is used; 271 | if rank_method=='margin', margin score of the pattern predicted by the matching model is 272 | used. 273 | if rank_method=='hybrid', it considers both the count and margin to rank the actions. 274 | """ 275 | super().__init__(min_pattern_size, max_pattern_size, model, dataset, 276 | embs, node_anchored=node_anchored, analyze=analyze, 277 | model_type=model_type, out_batch_size=out_batch_size) 278 | self.rank_method = rank_method 279 | self.n_beams = n_beams 280 | print("Rank Method:", rank_method) 281 | 282 | def init_search(self): 283 | ps = np.array([len(g) for g in self.dataset], dtype=np.float) 284 | ps /= np.sum(ps) 285 | graph_dist = stats.rv_discrete(values=(np.arange(len(self.dataset)), ps)) 286 | 287 | beams = [] 288 | for trial in range(self.n_trials): 289 | graph_idx = np.arange(len(self.dataset))[graph_dist.rvs()] 290 | graph = self.dataset[graph_idx] 291 | start_node = random.choice(list(graph.nodes)) 292 | neigh = [start_node] 293 | frontier = list(set(graph.neighbors(start_node)) - set(neigh)) 294 | visited = set([start_node]) 295 | beams.append([(0, neigh, frontier, visited, graph_idx)]) 296 | self.beam_sets = beams 297 | self.analyze_embs = [] 298 | 299 | def is_search_done(self): 300 | return len(self.beam_sets) == 0 301 | 302 | def step(self): 303 | new_beam_sets = [] 304 | print("seeds come from", len(set(b[0][-1] for b in self.beam_sets)), 305 | "distinct graphs") 306 | analyze_embs_cur = [] 307 | for beam_set in tqdm(self.beam_sets): 308 | new_beams = [] 309 | for _, neigh, frontier, visited, graph_idx in beam_set: 310 | graph = self.dataset[graph_idx] 311 | if len(neigh) >= self.max_pattern_size or not frontier: continue 312 | cand_neighs, anchors = [], [] 313 | for cand_node in frontier: 314 | cand_neigh = graph.subgraph(neigh + [cand_node]) 315 | cand_neighs.append(cand_neigh) 316 | if self.node_anchored: 317 | anchors.append(neigh[0]) 318 | cand_embs = self.model.emb_model(utils.batch_nx_graphs( 319 | cand_neighs, anchors=anchors if self.node_anchored else None)) 320 | best_score, best_node = float("inf"), None 321 | for cand_node, cand_emb in zip(frontier, cand_embs): 322 | score, n_embs = 0, 0 323 | for emb_batch in self.embs: 324 | n_embs += len(emb_batch) 325 | if self.model_type == "order": 326 | score -= torch.sum(torch.argmax( 327 | self.model.clf_model(self.model.predict(( 328 | emb_batch.to(utils.get_device()), 329 | cand_emb)).unsqueeze(1)), axis=1)).item() 330 | elif self.model_type == "mlp": 331 | score += torch.sum(self.model( 332 | emb_batch.to(utils.get_device()), 333 | cand_emb.unsqueeze(0).expand(len(emb_batch), -1) 334 | )[:,0]).item() 335 | else: 336 | print("unrecognized model type") 337 | if score < best_score: 338 | best_score = score 339 | best_node = cand_node 340 | new_frontier = list(((set(frontier) | 341 | set(graph.neighbors(cand_node))) - visited) - 342 | set([cand_node])) 343 | new_beams.append(( 344 | score, neigh + [cand_node], 345 | new_frontier, visited | set([cand_node]), graph_idx)) 346 | new_beams = list(sorted(new_beams, key=lambda x: 347 | x[0]))[:self.n_beams] 348 | for score, neigh, frontier, visited, graph_idx in new_beams[:1]: 349 | graph = self.dataset[graph_idx] 350 | # add to record 351 | neigh_g = graph.subgraph(neigh).copy() 352 | neigh_g.remove_edges_from(nx.selfloop_edges(neigh_g)) 353 | for v in neigh_g.nodes: 354 | neigh_g.nodes[v]["anchor"] = 1 if v == neigh[0] else 0 355 | self.cand_patterns[len(neigh_g)].append((score, neigh_g)) 356 | if self.rank_method in ["counts", "hybrid"]: 357 | self.counts[len(neigh_g)][utils.wl_hash(neigh_g, 358 | node_anchored=self.node_anchored)].append(neigh_g) 359 | if self.analyze and len(neigh) >= 3: 360 | emb = self.model.emb_model(utils.batch_nx_graphs( 361 | [neigh_g], anchors=[neigh[0]] if self.node_anchored 362 | else None)).squeeze(0) 363 | analyze_embs_cur.append(emb.detach().cpu().numpy()) 364 | if len(new_beams) > 0: 365 | new_beam_sets.append(new_beams) 366 | self.beam_sets = new_beam_sets 367 | self.analyze_embs.append(analyze_embs_cur) 368 | 369 | def finish_search(self): 370 | if self.analyze: 371 | print("Saving analysis info in results/analyze.p") 372 | with open("results/analyze.p", "wb") as f: 373 | pickle.dump((self.cand_patterns, self.analyze_embs), f) 374 | xs, ys = [], [] 375 | for embs_ls in self.analyze_embs: 376 | for emb in embs_ls: 377 | xs.append(emb[0]) 378 | ys.append(emb[1]) 379 | print("Saving analysis plot in results/analyze.png") 380 | plt.scatter(xs, ys, color="red", label="motif") 381 | plt.legend() 382 | plt.savefig("plots/analyze.png") 383 | plt.close() 384 | 385 | cand_patterns_uniq = [] 386 | for pattern_size in range(self.min_pattern_size, self.max_pattern_size+1): 387 | if self.rank_method == "hybrid": 388 | cur_rank_method = "margin" if len(max( 389 | self.counts[pattern_size].values(), key=len)) < 3 else "counts" 390 | else: 391 | cur_rank_method = self.rank_method 392 | 393 | if cur_rank_method == "margin": 394 | wl_hashes = set() 395 | cands = cand_patterns[pattern_size] 396 | cand_patterns_uniq_size = [] 397 | for pattern in sorted(cands, key=lambda x: x[0]): 398 | wl_hash = utils.wl_hash(pattern[1], node_anchored=node_anchored) 399 | if wl_hash not in wl_hashes: 400 | wl_hashes.add(wl_hash) 401 | cand_patterns_uniq_size.append(pattern[1]) 402 | if len(cand_patterns_uniq_size) >= out_batch_size: 403 | cand_patterns_uniq += cand_patterns_uniq_size 404 | break 405 | elif cur_rank_method == "counts": 406 | for _, neighs in list(sorted(self.counts[pattern_size].items(), 407 | key=lambda x: len(x[1]), reverse=True))[:self.out_batch_size]: 408 | cand_patterns_uniq.append(random.choice(neighs)) 409 | else: 410 | print("Unrecognized rank method") 411 | return cand_patterns_uniq 412 | -------------------------------------------------------------------------------- /common/data.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | import random 4 | 5 | from deepsnap.graph import Graph as DSGraph 6 | from deepsnap.batch import Batch 7 | from deepsnap.dataset import GraphDataset, Generator 8 | import networkx as nx 9 | import numpy as np 10 | from sklearn.manifold import TSNE 11 | import torch 12 | import torch.multiprocessing as mp 13 | import torch.nn.functional as F 14 | import torch.optim as optim 15 | from torch_geometric.data import DataLoader 16 | from torch.utils.data import DataLoader as TorchDataLoader 17 | from torch_geometric.datasets import TUDataset, PPI, QM9 18 | import torch_geometric.utils as pyg_utils 19 | import torch_geometric.nn as pyg_nn 20 | from tqdm import tqdm 21 | import queue 22 | import scipy.stats as stats 23 | 24 | from common import combined_syn 25 | from common import feature_preprocess 26 | from common import utils 27 | 28 | def load_dataset(name): 29 | """ Load real-world datasets, available in PyTorch Geometric. 30 | 31 | Used as a helper for DiskDataSource. 32 | """ 33 | task = "graph" 34 | if name == "enzymes": 35 | dataset = TUDataset(root="/tmp/ENZYMES", name="ENZYMES") 36 | elif name == "proteins": 37 | dataset = TUDataset(root="/tmp/PROTEINS", name="PROTEINS") 38 | elif name == "cox2": 39 | dataset = TUDataset(root="/tmp/cox2", name="COX2") 40 | elif name == "aids": 41 | dataset = TUDataset(root="/tmp/AIDS", name="AIDS") 42 | elif name == "reddit-binary": 43 | dataset = TUDataset(root="/tmp/REDDIT-BINARY", name="REDDIT-BINARY") 44 | elif name == "imdb-binary": 45 | dataset = TUDataset(root="/tmp/IMDB-BINARY", name="IMDB-BINARY") 46 | elif name == "firstmm_db": 47 | dataset = TUDataset(root="/tmp/FIRSTMM_DB", name="FIRSTMM_DB") 48 | elif name == "dblp": 49 | dataset = TUDataset(root="/tmp/DBLP_v1", name="DBLP_v1") 50 | elif name == "ppi": 51 | dataset = PPI(root="/tmp/PPI") 52 | elif name == "qm9": 53 | dataset = QM9(root="/tmp/QM9") 54 | elif name == "atlas": 55 | dataset = [g for g in nx.graph_atlas_g()[1:] if nx.is_connected(g)] 56 | if task == "graph": 57 | train_len = int(0.8 * len(dataset)) 58 | train, test = [], [] 59 | dataset = list(dataset) 60 | random.shuffle(dataset) 61 | has_name = hasattr(dataset[0], "name") 62 | for i, graph in tqdm(enumerate(dataset)): 63 | if not type(graph) == nx.Graph: 64 | if has_name: del graph.name 65 | graph = pyg_utils.to_networkx(graph).to_undirected() 66 | if i < train_len: 67 | train.append(graph) 68 | else: 69 | test.append(graph) 70 | return train, test, task 71 | 72 | class DataSource: 73 | def gen_batch(batch_target, batch_neg_target, batch_neg_query, train): 74 | raise NotImplementedError 75 | 76 | class OTFSynDataSource(DataSource): 77 | """ On-the-fly generated synthetic data for training the subgraph model. 78 | 79 | At every iteration, new batch of graphs (positive and negative) are generated 80 | with a pre-defined generator (see combined_syn.py). 81 | 82 | DeepSNAP transforms are used to generate the positive and negative examples. 83 | """ 84 | def __init__(self, max_size=29, min_size=5, n_workers=4, 85 | max_queue_size=256, node_anchored=False): 86 | self.closed = False 87 | self.max_size = max_size 88 | self.min_size = min_size 89 | self.node_anchored = node_anchored 90 | self.generator = combined_syn.get_generator(np.arange( 91 | self.min_size + 1, self.max_size + 1)) 92 | 93 | def gen_data_loaders(self, size, batch_size, train=True, 94 | use_distributed_sampling=False): 95 | loaders = [] 96 | for i in range(2): 97 | dataset = combined_syn.get_dataset("graph", size // 2, 98 | np.arange(self.min_size + 1, self.max_size + 1)) 99 | sampler = torch.utils.data.distributed.DistributedSampler( 100 | dataset, num_replicas=hvd.size(), rank=hvd.rank()) if \ 101 | use_distributed_sampling else None 102 | loaders.append(TorchDataLoader(dataset, 103 | collate_fn=Batch.collate([]), batch_size=batch_size // 2 if i 104 | == 0 else batch_size // 2, 105 | sampler=sampler, shuffle=False)) 106 | loaders.append([None]*(size // batch_size)) 107 | return loaders 108 | 109 | def gen_batch(self, batch_target, batch_neg_target, batch_neg_query, 110 | train): 111 | def sample_subgraph(graph, offset=0, use_precomp_sizes=False, 112 | filter_negs=False, supersample_small_graphs=False, neg_target=None, 113 | hard_neg_idxs=None): 114 | if neg_target is not None: graph_idx = graph.G.graph["idx"] 115 | use_hard_neg = (hard_neg_idxs is not None and graph.G.graph["idx"] 116 | in hard_neg_idxs) 117 | done = False 118 | n_tries = 0 119 | while not done: 120 | if use_precomp_sizes: 121 | size = graph.G.graph["subgraph_size"] 122 | else: 123 | if train and supersample_small_graphs: 124 | sizes = np.arange(self.min_size + offset, 125 | len(graph.G) + offset) 126 | ps = (sizes - self.min_size + 2) ** (-1.1) 127 | ps /= ps.sum() 128 | size = stats.rv_discrete(values=(sizes, ps)).rvs() 129 | else: 130 | d = 1 if train else 0 131 | size = random.randint(self.min_size + offset - d, 132 | len(graph.G) - 1 + offset) 133 | start_node = random.choice(list(graph.G.nodes)) 134 | neigh = [start_node] 135 | frontier = list(set(graph.G.neighbors(start_node)) - set(neigh)) 136 | visited = set([start_node]) 137 | while len(neigh) < size: 138 | new_node = random.choice(list(frontier)) 139 | assert new_node not in neigh 140 | neigh.append(new_node) 141 | visited.add(new_node) 142 | frontier += list(graph.G.neighbors(new_node)) 143 | frontier = [x for x in frontier if x not in visited] 144 | if self.node_anchored: 145 | anchor = neigh[0] 146 | for v in graph.G.nodes: 147 | graph.G.nodes[v]["node_feature"] = (torch.ones(1) if 148 | anchor == v else torch.zeros(1)) 149 | #print(v, graph.G.nodes[v]["node_feature"]) 150 | neigh = graph.G.subgraph(neigh) 151 | if use_hard_neg and train: 152 | neigh = neigh.copy() 153 | if random.random() < 1.0 or not self.node_anchored: # add edges 154 | non_edges = list(nx.non_edges(neigh)) 155 | if len(non_edges) > 0: 156 | for u, v in random.sample(non_edges, random.randint(1, 157 | min(len(non_edges), 5))): 158 | neigh.add_edge(u, v) 159 | else: # perturb anchor 160 | anchor = random.choice(list(neigh.nodes)) 161 | for v in neigh.nodes: 162 | neigh.nodes[v]["node_feature"] = (torch.ones(1) if 163 | anchor == v else torch.zeros(1)) 164 | 165 | if (filter_negs and train and len(neigh) <= 6 and neg_target is 166 | not None): 167 | matcher = nx.algorithms.isomorphism.GraphMatcher( 168 | neg_target[graph_idx], neigh) 169 | if not matcher.subgraph_is_isomorphic(): done = True 170 | else: 171 | done = True 172 | 173 | return graph, DSGraph(neigh) 174 | 175 | augmenter = feature_preprocess.FeatureAugment() 176 | 177 | pos_target = batch_target 178 | pos_target, pos_query = pos_target.apply_transform_multi(sample_subgraph) 179 | neg_target = batch_neg_target 180 | # TODO: use hard negs 181 | hard_neg_idxs = set(random.sample(range(len(neg_target.G)), 182 | int(len(neg_target.G) * 1/2))) 183 | #hard_neg_idxs = set() 184 | batch_neg_query = Batch.from_data_list( 185 | [DSGraph(self.generator.generate(size=len(g)) 186 | if i not in hard_neg_idxs else g) 187 | for i, g in enumerate(neg_target.G)]) 188 | for i, g in enumerate(batch_neg_query.G): 189 | g.graph["idx"] = i 190 | _, neg_query = batch_neg_query.apply_transform_multi(sample_subgraph, 191 | hard_neg_idxs=hard_neg_idxs) 192 | if self.node_anchored: 193 | def add_anchor(g, anchors=None): 194 | if anchors is not None: 195 | anchor = anchors[g.G.graph["idx"]] 196 | else: 197 | anchor = random.choice(list(g.G.nodes)) 198 | for v in g.G.nodes: 199 | if "node_feature" not in g.G.nodes[v]: 200 | g.G.nodes[v]["node_feature"] = (torch.ones(1) if anchor == v 201 | else torch.zeros(1)) 202 | return g 203 | neg_target = neg_target.apply_transform(add_anchor) 204 | pos_target = augmenter.augment(pos_target).to(utils.get_device()) 205 | pos_query = augmenter.augment(pos_query).to(utils.get_device()) 206 | neg_target = augmenter.augment(neg_target).to(utils.get_device()) 207 | neg_query = augmenter.augment(neg_query).to(utils.get_device()) 208 | #print(len(pos_target.G[0]), len(pos_query.G[0])) 209 | return pos_target, pos_query, neg_target, neg_query 210 | 211 | class OTFSynImbalancedDataSource(OTFSynDataSource): 212 | """ Imbalanced on-the-fly synthetic data. 213 | 214 | Unlike the balanced dataset, this data source does not use 1:1 ratio for 215 | positive and negative examples. Instead, it randomly samples 2 graphs from 216 | the on-the-fly generator, and records the groundtruth label for the pair (subgraph or not). 217 | As a result, the data is imbalanced (subgraph relationships are rarer). 218 | This setting is a challenging model inference scenario. 219 | """ 220 | def __init__(self, max_size=29, min_size=5, n_workers=4, 221 | max_queue_size=256, node_anchored=False): 222 | super().__init__(max_size=max_size, min_size=min_size, 223 | n_workers=n_workers, node_anchored=node_anchored) 224 | self.batch_idx = 0 225 | 226 | def gen_batch(self, graphs_a, graphs_b, _, train): 227 | def add_anchor(g): 228 | anchor = random.choice(list(g.G.nodes)) 229 | for v in g.G.nodes: 230 | g.G.nodes[v]["node_feature"] = (torch.ones(1) if anchor == v 231 | or not self.node_anchored else torch.zeros(1)) 232 | return g 233 | pos_a, pos_b, neg_a, neg_b = [], [], [], [] 234 | fn = "data/cache/imbalanced-{}-{}".format(str(self.node_anchored), 235 | self.batch_idx) 236 | if not os.path.exists(fn): 237 | graphs_a = graphs_a.apply_transform(add_anchor) 238 | graphs_b = graphs_b.apply_transform(add_anchor) 239 | for graph_a, graph_b in tqdm(list(zip(graphs_a.G, graphs_b.G))): 240 | matcher = nx.algorithms.isomorphism.GraphMatcher(graph_a, graph_b, 241 | node_match=(lambda a, b: (a["node_feature"][0] > 0.5) == 242 | (b["node_feature"][0] > 0.5)) if self.node_anchored else None) 243 | if matcher.subgraph_is_isomorphic(): 244 | pos_a.append(graph_a) 245 | pos_b.append(graph_b) 246 | else: 247 | neg_a.append(graph_a) 248 | neg_b.append(graph_b) 249 | if not os.path.exists("data/cache"): 250 | os.makedirs("data/cache") 251 | with open(fn, "wb") as f: 252 | pickle.dump((pos_a, pos_b, neg_a, neg_b), f) 253 | print("saved", fn) 254 | else: 255 | with open(fn, "rb") as f: 256 | print("loaded", fn) 257 | pos_a, pos_b, neg_a, neg_b = pickle.load(f) 258 | print(len(pos_a), len(neg_a)) 259 | if pos_a: 260 | pos_a = utils.batch_nx_graphs(pos_a) 261 | pos_b = utils.batch_nx_graphs(pos_b) 262 | neg_a = utils.batch_nx_graphs(neg_a) 263 | neg_b = utils.batch_nx_graphs(neg_b) 264 | self.batch_idx += 1 265 | return pos_a, pos_b, neg_a, neg_b 266 | 267 | class DiskDataSource(DataSource): 268 | """ Uses a set of graphs saved in a dataset file to train the subgraph model. 269 | 270 | At every iteration, new batch of graphs (positive and negative) are generated 271 | by sampling subgraphs from a given dataset. 272 | 273 | See the load_dataset function for supported datasets. 274 | """ 275 | def __init__(self, dataset_name, node_anchored=False, min_size=5, 276 | max_size=29): 277 | self.node_anchored = node_anchored 278 | self.dataset = load_dataset(dataset_name) 279 | self.min_size = min_size 280 | self.max_size = max_size 281 | 282 | def gen_data_loaders(self, size, batch_size, train=True, 283 | use_distributed_sampling=False): 284 | loaders = [[batch_size]*(size // batch_size) for i in range(3)] 285 | return loaders 286 | 287 | def gen_batch(self, a, b, c, train, max_size=15, min_size=5, seed=None, 288 | filter_negs=False, sample_method="tree-pair"): 289 | batch_size = a 290 | train_set, test_set, task = self.dataset 291 | graphs = train_set if train else test_set 292 | if seed is not None: 293 | random.seed(seed) 294 | 295 | pos_a, pos_b = [], [] 296 | pos_a_anchors, pos_b_anchors = [], [] 297 | for i in range(batch_size // 2): 298 | if sample_method == "tree-pair": 299 | size = random.randint(min_size+1, max_size) 300 | graph, a = utils.sample_neigh(graphs, size) 301 | b = a[:random.randint(min_size, len(a) - 1)] 302 | elif sample_method == "subgraph-tree": 303 | graph = None 304 | while graph is None or len(graph) < min_size + 1: 305 | graph = random.choice(graphs) 306 | a = graph.nodes 307 | _, b = utils.sample_neigh([graph], random.randint(min_size, 308 | len(graph) - 1)) 309 | if self.node_anchored: 310 | anchor = list(graph.nodes)[0] 311 | pos_a_anchors.append(anchor) 312 | pos_b_anchors.append(anchor) 313 | neigh_a, neigh_b = graph.subgraph(a), graph.subgraph(b) 314 | pos_a.append(neigh_a) 315 | pos_b.append(neigh_b) 316 | 317 | neg_a, neg_b = [], [] 318 | neg_a_anchors, neg_b_anchors = [], [] 319 | while len(neg_a) < batch_size // 2: 320 | if sample_method == "tree-pair": 321 | size = random.randint(min_size+1, max_size) 322 | graph_a, a = utils.sample_neigh(graphs, size) 323 | graph_b, b = utils.sample_neigh(graphs, random.randint(min_size, 324 | size - 1)) 325 | elif sample_method == "subgraph-tree": 326 | graph_a = None 327 | while graph_a is None or len(graph_a) < min_size + 1: 328 | graph_a = random.choice(graphs) 329 | a = graph_a.nodes 330 | graph_b, b = utils.sample_neigh(graphs, random.randint(min_size, 331 | len(graph_a) - 1)) 332 | if self.node_anchored: 333 | neg_a_anchors.append(list(graph_a.nodes)[0]) 334 | neg_b_anchors.append(list(graph_b.nodes)[0]) 335 | neigh_a, neigh_b = graph_a.subgraph(a), graph_b.subgraph(b) 336 | if filter_negs: 337 | matcher = nx.algorithms.isomorphism.GraphMatcher(neigh_a, neigh_b) 338 | if matcher.subgraph_is_isomorphic(): # a <= b (b is subgraph of a) 339 | continue 340 | neg_a.append(neigh_a) 341 | neg_b.append(neigh_b) 342 | 343 | pos_a = utils.batch_nx_graphs(pos_a, anchors=pos_a_anchors if 344 | self.node_anchored else None) 345 | pos_b = utils.batch_nx_graphs(pos_b, anchors=pos_b_anchors if 346 | self.node_anchored else None) 347 | neg_a = utils.batch_nx_graphs(neg_a, anchors=neg_a_anchors if 348 | self.node_anchored else None) 349 | neg_b = utils.batch_nx_graphs(neg_b, anchors=neg_b_anchors if 350 | self.node_anchored else None) 351 | return pos_a, pos_b, neg_a, neg_b 352 | 353 | class DiskImbalancedDataSource(OTFSynDataSource): 354 | """ Imbalanced on-the-fly real data. 355 | 356 | Unlike the balanced dataset, this data source does not use 1:1 ratio for 357 | positive and negative examples. Instead, it randomly samples 2 graphs from 358 | the on-the-fly generator, and records the groundtruth label for the pair (subgraph or not). 359 | As a result, the data is imbalanced (subgraph relationships are rarer). 360 | This setting is a challenging model inference scenario. 361 | """ 362 | def __init__(self, dataset_name, max_size=29, min_size=5, n_workers=4, 363 | max_queue_size=256, node_anchored=False): 364 | super().__init__(max_size=max_size, min_size=min_size, 365 | n_workers=n_workers, node_anchored=node_anchored) 366 | self.batch_idx = 0 367 | self.dataset = load_dataset(dataset_name) 368 | self.train_set, self.test_set, _ = self.dataset 369 | self.dataset_name = dataset_name 370 | 371 | def gen_data_loaders(self, size, batch_size, train=True, 372 | use_distributed_sampling=False): 373 | loaders = [] 374 | for i in range(2): 375 | neighs = [] 376 | for j in range(size // 2): 377 | graph, neigh = utils.sample_neigh(self.train_set if train else 378 | self.test_set, random.randint(self.min_size, self.max_size)) 379 | neighs.append(graph.subgraph(neigh)) 380 | dataset = GraphDataset(neighs) 381 | loaders.append(TorchDataLoader(dataset, 382 | collate_fn=Batch.collate([]), batch_size=batch_size // 2 if i 383 | == 0 else batch_size // 2, 384 | sampler=None, shuffle=False)) 385 | loaders.append([None]*(size // batch_size)) 386 | return loaders 387 | 388 | def gen_batch(self, graphs_a, graphs_b, _, train): 389 | def add_anchor(g): 390 | anchor = random.choice(list(g.G.nodes)) 391 | for v in g.G.nodes: 392 | g.G.nodes[v]["node_feature"] = (torch.ones(1) if anchor == v 393 | or not self.node_anchored else torch.zeros(1)) 394 | return g 395 | pos_a, pos_b, neg_a, neg_b = [], [], [], [] 396 | fn = "data/cache/imbalanced-{}-{}-{}".format(self.dataset_name.lower(), 397 | str(self.node_anchored), self.batch_idx) 398 | if not os.path.exists(fn): 399 | graphs_a = graphs_a.apply_transform(add_anchor) 400 | graphs_b = graphs_b.apply_transform(add_anchor) 401 | for graph_a, graph_b in tqdm(list(zip(graphs_a.G, graphs_b.G))): 402 | matcher = nx.algorithms.isomorphism.GraphMatcher(graph_a, graph_b, 403 | node_match=(lambda a, b: (a["node_feature"][0] > 0.5) == 404 | (b["node_feature"][0] > 0.5)) if self.node_anchored else None) 405 | if matcher.subgraph_is_isomorphic(): 406 | pos_a.append(graph_a) 407 | pos_b.append(graph_b) 408 | else: 409 | neg_a.append(graph_a) 410 | neg_b.append(graph_b) 411 | if not os.path.exists("data/cache"): 412 | os.makedirs("data/cache") 413 | with open(fn, "wb") as f: 414 | pickle.dump((pos_a, pos_b, neg_a, neg_b), f) 415 | print("saved", fn) 416 | else: 417 | with open(fn, "rb") as f: 418 | print("loaded", fn) 419 | pos_a, pos_b, neg_a, neg_b = pickle.load(f) 420 | print(len(pos_a), len(neg_a)) 421 | if pos_a: 422 | pos_a = utils.batch_nx_graphs(pos_a) 423 | pos_b = utils.batch_nx_graphs(pos_b) 424 | neg_a = utils.batch_nx_graphs(neg_a) 425 | neg_b = utils.batch_nx_graphs(neg_b) 426 | self.batch_idx += 1 427 | return pos_a, pos_b, neg_a, neg_b 428 | 429 | if __name__ == "__main__": 430 | import matplotlib.pyplot as plt 431 | plt.rcParams.update({"font.size": 14}) 432 | for name in ["enzymes", "reddit-binary", "cox2"]: 433 | data_source = DiskDataSource(name) 434 | train, test, _ = data_source.dataset 435 | i = 11 436 | neighs = [utils.sample_neigh(train, i) for j in range(10000)] 437 | clustering = [nx.average_clustering(graph.subgraph(nodes)) for graph, 438 | nodes in neighs] 439 | path_length = [nx.average_shortest_path_length(graph.subgraph(nodes)) 440 | for graph, nodes in neighs] 441 | #plt.subplot(1, 2, i-9) 442 | plt.scatter(clustering, path_length, s=10, label=name) 443 | plt.legend() 444 | plt.savefig("plots/clustering-vs-path-length.png") 445 | -------------------------------------------------------------------------------- /analyze/Visualize Graph Statistics.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Graph statistics\n", 8 | "\n", 9 | "Here we visualize graph statistics for several real-world datasets and the synthetically generated dataset. We observe that the synthetic dataset covers a diverse spectrum of fundamental graph statistics in the real-world graphs." 10 | ] 11 | }, 12 | { 13 | "cell_type": "markdown", 14 | "metadata": {}, 15 | "source": [ 16 | "First we compare clustering and shortest path length for all graph datasets. We find that the synthetic data (\"syn\") covers all regions covered by the cox2 and reddit-binary. Enzymes contains some more unique long and skinny graphs due to protein structures." 17 | ] 18 | }, 19 | { 20 | "cell_type": "code", 21 | "execution_count": 5, 22 | "metadata": {}, 23 | "outputs": [ 24 | { 25 | "name": "stderr", 26 | "output_type": "stream", 27 | "text": [ 28 | "600it [00:00, 1281.37it/s]\n", 29 | "467it [00:00, 1786.57it/s]\n", 30 | "2000it [00:16, 123.25it/s]\n" 31 | ] 32 | }, 33 | { 34 | "data": { 35 | "image/png": "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\n", 36 | "text/plain": [ 37 | "
" 38 | ] 39 | }, 40 | "metadata": { 41 | "needs_background": "light" 42 | }, 43 | "output_type": "display_data" 44 | } 45 | ], 46 | "source": [ 47 | "import sys, os\n", 48 | "sys.path.insert(0, os.path.abspath(\"..\"))\n", 49 | "import networkx as nx\n", 50 | "import matplotlib\n", 51 | "import matplotlib.pyplot as plt\n", 52 | "import pickle\n", 53 | "from common import data, utils, combined_syn\n", 54 | "\n", 55 | "for dataset in [\"enzymes\", \"cox2\", \"reddit-binary\", \"syn\"]:\n", 56 | " if dataset == \"syn\":\n", 57 | " gen = combined_syn.get_generator([11])\n", 58 | " graphs = [gen.generate() for i in range(10000)]\n", 59 | " else:\n", 60 | " train, test, task = data.load_dataset(dataset)\n", 61 | " graphs = []\n", 62 | " for i in range(10000):\n", 63 | " graph, neigh = utils.sample_neigh(train, 11)\n", 64 | " graphs.append(graph.subgraph(neigh))\n", 65 | "\n", 66 | " clustering = [nx.average_clustering(G.subgraph(max(nx.connected_components(G), key=len))) for G in graphs]\n", 67 | " path_length = [nx.average_shortest_path_length(G.subgraph(max(nx.connected_components(G), key=len))) for G in graphs]\n", 68 | " plt.scatter(clustering, path_length, s=10, label=dataset)\n", 69 | "\n", 70 | "plt.xlabel(\"Clustering\")\n", 71 | "plt.ylabel(\"Shortest path length\")\n", 72 | "plt.legend()\n", 73 | "plt.show()" 74 | ] 75 | }, 76 | { 77 | "cell_type": "markdown", 78 | "metadata": {}, 79 | "source": [ 80 | "Next we compare diameters and densities of all graph types and again see that the synthetic data generally covers the real-world graphs." 81 | ] 82 | }, 83 | { 84 | "cell_type": "code", 85 | "execution_count": 6, 86 | "metadata": {}, 87 | "outputs": [ 88 | { 89 | "name": "stderr", 90 | "output_type": "stream", 91 | "text": [ 92 | "600it [00:00, 1238.75it/s]\n", 93 | "467it [00:00, 1757.02it/s]\n", 94 | "2000it [00:15, 125.04it/s]\n" 95 | ] 96 | }, 97 | { 98 | "data": { 99 | "image/png": "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djF69Gh69+5Nnz59mDZtmt+RPKVCICIC3Hjjjaxfv553332XvLw8/vnPf/odyTMqBB4qnjKFnS+/Qk1pKTtffkXNYpEW9NRTT3HiiSeSkZHBZZddxubNmxk+fDgnnngiI0aM4MMPP6S0tJQTTjiBDRs2ADB27Fhmz55N+/btOf300wFo27YtmZmZFBcX+/nreEqFwENqFotErV8A/7gx8r0FFBYWMmPGDHJzc1m1ahV/+MMfuO6667jiiit47733uPTSS5k8eTIpKSk8+OCDjB8/nrlz5/L5559z9dVXxzzXF198wUsvvcSIESNaJFtroELgITWLRYh8+P9lIrwzO/K9BYpBbm4uY8aMoXPnzgAcdthhLFu2jEsuuQSAyy67jH/9618AjBw5kvT0dK699loeffTRmOepqqpi7NixTJ48meOPP/6Ac7UWKgQeatgcVrNYQunfuVC5O/Jz5e7I2EM1NTWsW7eO9u3b8/nnn8fcd80119CzZ0+mhGzaVoXAQ+t6pzY5FgmF7w+HpHaRn5PaRcYHaPjw4bzwwgvs2LEDiCxDPWTIEObOnQvAnDlz6pacnjlzJqmpqTz77LNMmDCByspKAG677TZKS0uZFcL/oGn1UQ819sGfun6dD0lEWs63Wn10/YLInsD3h0Pvc1okx5NPPsk999xDYmIi/fv354477mDChAl8+umndOnShccff5zdu3dzwQUXsHz5cjp16sT1119Pp06duPrqqznmmGPo3bt33YVnJk2axFVXXdUi2by2v6uPqhB4SIVADkYH0zLUB4v9LQSaGvJQww99FQERCQIVAo+16do15ruIiN9UCDxUdOppVG3bBkDVtm0UnXqav4FERFAh8FRtEdjbWETEDyoEHmo4HaTpIREJAhUCD/V8fUmTYxERP6gQeEgnlIkEx29+8xvuvffeb9y+efNm+vbtC8CKFSuYPHkyAEuWLOHNN9/c6/N17Nix0dunT5/OokWLWiBx/LTxO4CISEtyzuGcIyHhwP+fm5WVRVZW5ND7JUuW0LFjR4YMGbJfz3HnnXcecA6IrIPUpk18PrK1RyAird7mzZs54YQTuPzyy+nbty933XUXAwcO5MQTT+T222+v2+63v/0tvXr14uSTT65bihpg5cqVZGRkkJGRwUMPPVR3+5IlSzj33HPZvHkzf/rTn5g5cyb9+vVj6dKl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\n", 100 | "text/plain": [ 101 | "
" 102 | ] 103 | }, 104 | "metadata": { 105 | "needs_background": "light" 106 | }, 107 | "output_type": "display_data" 108 | } 109 | ], 110 | "source": [ 111 | "for dataset in [\"enzymes\", \"cox2\", \"reddit-binary\", \"syn\"]:\n", 112 | " if dataset == \"syn\":\n", 113 | " gen = combined_syn.get_generator([11])\n", 114 | " graphs = [gen.generate() for i in range(10000)]\n", 115 | " else:\n", 116 | " train, test, task = data.load_dataset(dataset)\n", 117 | " graphs = []\n", 118 | " for i in range(10000):\n", 119 | " graph, neigh = utils.sample_neigh(train, 11)\n", 120 | " graphs.append(graph.subgraph(neigh))\n", 121 | "\n", 122 | " diameter = [nx.diameter(G.subgraph(max(nx.connected_components(G), key=len))) for G in graphs]\n", 123 | " density = [nx.density(G.subgraph(max(nx.connected_components(G), key=len))) for G in graphs]\n", 124 | " plt.scatter(diameter, density, s=10, label=dataset)\n", 125 | "\n", 126 | "plt.xlabel(\"Diameter\")\n", 127 | "plt.ylabel(\"Density\")\n", 128 | "plt.legend()\n", 129 | "plt.show()" 130 | ] 131 | }, 132 | { 133 | "cell_type": "code", 134 | "execution_count": null, 135 | "metadata": {}, 136 | "outputs": [], 137 | "source": [] 138 | } 139 | ], 140 | "metadata": { 141 | "kernelspec": { 142 | "display_name": "Python 3", 143 | "language": "python", 144 | "name": "python3" 145 | }, 146 | "language_info": { 147 | "codemirror_mode": { 148 | "name": "ipython", 149 | "version": 3 150 | }, 151 | "file_extension": ".py", 152 | "mimetype": "text/x-python", 153 | "name": "python", 154 | "nbconvert_exporter": "python", 155 | "pygments_lexer": "ipython3", 156 | "version": "3.6.8" 157 | } 158 | }, 159 | "nbformat": 4, 160 | "nbformat_minor": 2 161 | } 162 | --------------------------------------------------------------------------------