"""The propagation kernel as defined in :cite:`neumann2015propagation`."""
# Author: Ioannis Siglidis <y.siglidis@gmail.com>
# License: BSD 3 clause
import warnings
import numpy as np
from itertools import chain
from collections import Counter
from numbers import Real
from sklearn.utils import check_random_state
from sklearn.preprocessing import normalize as normalizer
from grakel.graph import Graph
from grakel.kernels import Kernel
from itertools import filterfalse
from collections.abc import Iterable
def _dot(x, y):
return sum(x[k] * y[k] for k in x)
[docs]
class Propagation(Kernel):
r"""The Propagation kernel for fully labeled graphs.
See :cite:`neumann2015propagation`: Algorithms 1, 3, p. 216, 221.
Parameters
----------
t_max : int, default=5
Maximum number of iterations.
w : int, default=0.01
Bin width.
M : str, default="TV"
The preserved distance metric (on local sensitive hashing):
- "H": hellinger
- "TV": total-variation
metric : function (Counter, Counter -> number),
default=:math:`f(x,y)=\sum_{i} x_{i}*y_{i}`
A metric between two 1-dimensional numpy arrays of numbers that outputs a number.
It must consider the case where the keys of y are not in x, when different features appear
at transform.
random_state : RandomState or int, default=None
A random number generator instance or an int to initialize a RandomState as a seed.
Attributes
----------
_enum_labels : dict
Holds the enumeration of the input labels.
_parent_labels : set
Holds a set of the input labels.
random_state_ : RandomState
A RandomState object handling all randomness of the class.
"""
_graph_format = "adjacency"
attr_ = False
[docs]
def __init__(
self,
n_jobs=None,
verbose=False,
normalize=False,
random_state=None,
metric=_dot,
M="TV",
t_max=5,
w=0.01,
):
"""Initialise a propagation kernel."""
super(Propagation, self).__init__(n_jobs=n_jobs, verbose=verbose, normalize=normalize)
self.random_state = random_state
self.M = M
self.t_max = t_max
self.w = w
self.metric = metric
self._initialized.update(
{"M": False, "t_max": False, "w": False, "random_state": False, "metric": False}
)
def initialize(self):
"""Initialize all transformer arguments, needing initialization."""
super(Propagation, self).initialize()
if not self._initialized["random_state"]:
self.random_state_ = check_random_state(self.random_state)
self._initialized["random_state"] = True
if not self._initialized["metric"]:
if (
type(self.M) is not str
or (self.M not in ["H", "TV"] and not self.attr_)
or (self.M not in ["L1", "L2"] and self.attr_)
):
if self.attr_:
raise TypeError('Metric type must be a str, one of "L1", "L2"')
else:
raise TypeError('Metric type must be a str, one of "H", "TV"')
if not self.attr_:
self.take_sqrt_ = self.M == "H"
self.take_cauchy_ = self.M in ["TV", "L1"]
self._initialized["metric"] = True
if not self._initialized["t_max"]:
if type(self.t_max) is not int or self.t_max <= 0:
raise TypeError("The number of iterations must be a " + "positive integer.")
self._initialized["t_max"] = True
if not self._initialized["w"]:
if not isinstance(self.w, Real) and self.w <= 0:
raise TypeError("The bin width must be a positive number.")
self._initialized["w"] = True
if not self._initialized["metric"]:
if not callable(self.metric):
raise TypeError("The base kernel must be callable.")
self._initialized["metric"] = True
def pairwise_operation(self, x, y):
"""Calculate the kernel value between two elements.
Parameters
----------
x, y: list
Inverse label dictionaries.
Returns
-------
kernel : number
The kernel value.
"""
return sum(self.metric(x[t], y[t]) for t in range(self.t_max))
def parse_input(self, X):
"""Parse and create features for the propation kernel.
Parameters
----------
X : iterable
For the input to pass the test, we must have:
Each element must be an iterable with at most three features and at
least one. The first that is obligatory is a valid graph structure
(adjacency matrix or edge_dictionary) while the second is
node_labels and the third edge_labels (that correspond to the given
graph format). A valid input also consists of graph type objects.
Returns
-------
local_values : dict
A dictionary of pairs between each input graph and a bins where the
sampled graphlets have fallen.
"""
if not isinstance(X, Iterable):
raise ValueError("input must be an iterable\n")
else:
i = -1
transition_matrix = dict()
labels = set()
L = list()
for idx, x in enumerate(iter(X)):
is_iter = isinstance(x, Iterable)
if is_iter:
x = list(x)
if is_iter and len(x) in [0, 2, 3, 4]:
if len(x) == 0:
warnings.warn("Ignoring empty element on " + "index: " + str(idx))
continue
if len(x) == 2 and type(x[0]) is Graph:
g, T = x
else:
g = Graph(x[0], x[1], {}, self._graph_format)
if len(x) == 4:
T = x[3]
else:
T = None
elif type(x) is Graph:
g, T = x, None
else:
raise ValueError(
"Each element of X must be either a "
+ "Graph or an iterable with at least 2 "
+ "and at most 4 elements\n"
)
if T is not None:
if T.shape[0] != T.shape[1]:
raise TypeError(
"Transition matrix on index"
+ " "
+ str(idx)
+ "must be "
+ "a square matrix."
)
if T.shape[0] != g.nv():
raise TypeError(
"Propagation matrix must "
+ "have the same dimension "
+ "as the number of vertices."
)
else:
T = g.get_adjacency_matrix()
i += 1
transition_matrix[i] = normalizer(T, axis=1, norm="l1")
label = g.get_labels(purpose="adjacency")
try:
labels |= set(label.values())
except TypeError:
raise TypeError("For a non attributed kernel, labels should be hashable.")
L.append((g.nv(), label))
if i == -1:
raise ValueError("Parsed input is empty")
# The number of parsed graphs
n = i + 1
# enumerate labels
if self._method_calling == 1:
enum_labels = {l: i for (i, l) in enumerate(list(labels))}
self._enum_labels = enum_labels
self._parent_labels = labels
elif self._method_calling == 3:
new_elements = labels - self._parent_labels
if len(new_elements) > 0:
new_enum_labels = iter(
(l, i) for (i, l) in enumerate(list(new_elements), len(self._enum_labels))
)
enum_labels = dict(chain(self._enum_labels.items(), new_enum_labels))
else:
enum_labels = self._enum_labels
# make a matrix for all graphs that contains label vectors
P, data, indexes = dict(), list(), [0]
for k, (nv, label) in enumerate(L):
data += [(indexes[-1] + j, enum_labels[label[j]]) for j in range(nv)]
indexes.append(indexes[-1] + nv)
# Initialise the on hot vector
rows, cols = zip(*data)
P = np.zeros(shape=(indexes[-1], len(enum_labels)))
P[rows, cols] = 1
dim_orig = len(self._enum_labels)
# feature vectors
if self._method_calling == 1:
# simple normal
self._u, self._b, self._hd = list(), list(), list()
for t in range(self.t_max):
u = self.random_state_.randn(len(enum_labels))
if self.take_cauchy_:
# cauchy
u = np.divide(u, self.random_state_.randn(len(enum_labels)))
self._u.append(u)
# random offset
self._b.append(self.w * self.random_state_.rand())
phi = {k: dict() for k in range(n)}
for t in range(self.t_max):
# for hash all graphs inside P and produce the feature vectors
hashes = self.calculate_LSH(P, self._u[t], self._b[t])
hd = dict((j, i) for i, j in enumerate(set(np.unique(hashes))))
self._hd.append(hd)
features = np.vectorize(lambda i: hd[i])(hashes)
# Accumulate the results.
for k in range(n):
phi[k][t] = Counter(features[indexes[k] : indexes[k + 1]])
# calculate the Propagation matrix if needed
if t < self.t_max - 1:
for k in range(n):
start, end = indexes[k : k + 2]
P[start:end, :] = np.dot(transition_matrix[k], P[start:end, :])
return [phi[k] for k in range(n)]
elif self._method_calling == 3 and dim_orig >= len(enum_labels):
phi = {k: dict() for k in range(n)}
for t in range(self.t_max):
# for hash all graphs inside P and produce the feature vectors
hashes = self.calculate_LSH(P, self._u[t], self._b[t])
hd = dict(
chain(
self._hd[t].items(),
iter(
(j, i)
for i, j in enumerate(
filterfalse(lambda x: x in self._hd[t], np.unique(hashes)),
len(self._hd[t]),
)
),
)
)
features = np.vectorize(lambda i: hd[i])(hashes)
# Accumulate the results.
for k in range(n):
phi[k][t] = Counter(features[indexes[k] : indexes[k + 1]])
# calculate the Propagation matrix if needed
if t < self.t_max - 1:
for k in range(n):
start, end = indexes[k : k + 2]
P[start:end, :] = np.dot(transition_matrix[k], P[start:end, :])
return [phi[k] for k in range(n)]
else:
cols = np.array(cols)
vertices = np.where(cols < dim_orig)[0]
vertices_p = np.where(cols >= dim_orig)[0]
nnv = len(enum_labels) - dim_orig
phi = {k: dict() for k in range(n)}
for t in range(self.t_max):
# hash all graphs inside P and produce the feature vectors
hashes = self.calculate_LSH(P[vertices, :dim_orig], self._u[t], self._b[t])
hd = dict(
chain(
self._hd[t].items(),
iter(
(j, i)
for i, j in enumerate(
filterfalse(lambda x: x in self._hd[t], np.unique(hashes)),
len(self._hd[t]),
)
),
)
)
features = np.vectorize(lambda i: hd[i], otypes=[int])(hashes)
# for each the new labels graph hash P and produce the feature vectors
u = self.random_state_.randn(nnv)
if self.take_cauchy_:
# cauchy
u = np.divide(u, self.random_state_.randn(nnv))
u = np.hstack((self._u[t], u))
# calculate hashes for the remaining
hashes = self.calculate_LSH(P[vertices_p, :], u, self._b[t])
hd = dict(
chain(hd.items(), iter((j, i) for i, j in enumerate(hashes, len(hd))))
)
features_p = np.vectorize(lambda i: hd[i], otypes=[int])(hashes)
# Accumulate the results
for k in range(n):
A = Counter(
features[
np.logical_and(indexes[k] <= vertices, vertices <= indexes[k + 1])
]
)
B = Counter(
features_p[
np.logical_and(
indexes[k] <= vertices_p, vertices_p <= indexes[k + 1]
)
]
)
phi[k][t] = A + B
# calculate the Propagation matrix if needed
if t < self.t_max - 1:
for k in range(n):
start, end = indexes[k : k + 2]
P[start:end, :] = np.dot(transition_matrix[k], P[start:end, :])
Q = np.all(P[:, dim_orig:] > 0, axis=1)
vertices = np.where(~Q)[0]
vertices_p = np.where(Q)[0]
return [phi[k] for k in range(n)]
def calculate_LSH(self, X, u, b):
"""Calculate Local Sensitive Hashing needed for propagation kernels.
See :cite:`neumann2015propagation`, p.12.
Parameters
----------
X : np.array
A float array of shape (N, D) with N vertices and D features.
u : np.array, shape=(D, 1)
A projection vector.
b : float
An offset (times w).
Returns
-------
lsh : np.array.
The local sensitive hash coresponding to each vertex.
"""
if self.take_sqrt_:
X = np.sqrt(X)
# hash
return np.floor((np.dot(X, u) + b) / self.w)
[docs]
class PropagationAttr(Propagation):
r"""The Propagation kernel for fully attributed graphs.
See :cite:`neumann2015propagation`: Algorithms 1, 3, p. 216, 221.
Parameters
----------
t_max : int, default=5
Maximum number of iterations.
w : int, default=0.01
Bin width.
M : str, default="TV"
The preserved distance metric (on local sensitive hashing):
- "L1": l1-norm
- "L2": l2-norm
metric : function (np.array, np.array -> number),
default=:math:`f(x,y)=\sum_{i} x_{i}*y_{i}`
A metric between two 1-dimensional numpy arrays of numbers
that outputs a number.
Attributes
----------
M : str
The preserved distance metric (on local sensitive hashing).
tmax : int
Holds the maximum number of iterations.
w : int
Holds the bin width.
metric : function (np.array, np.array -> number)
A metric between two 1-dimensional numpy arrays of numbers
that outputs a number.
"""
_graph_format = "adjacency"
attr_ = True
[docs]
def __init__(
self,
n_jobs=None,
verbose=False,
normalize=False,
random_state=None,
metric=_dot,
M="L1",
t_max=5,
w=4,
):
"""Initialise a propagation kernel."""
super(PropagationAttr, self).__init__(
n_jobs=n_jobs,
verbose=verbose,
normalize=normalize,
random_state=random_state,
metric=metric,
M=M,
t_max=t_max,
w=w,
)
def initialize(self):
"""Initialize all transformer arguments, needing initialization."""
super(PropagationAttr, self).initialize()
def parse_input(self, X):
"""Parse and create features for the attributed propation kernel.
Parameters
----------
X : iterable
For the input to pass the test, we must have:
Each element must be an iterable with at most three features and at
least one. The first that is obligatory is a valid graph structure
(adjacency matrix or edge_dictionary) while the second is
node_labels and the third edge_labels (that correspond to the given
graph format). A valid input also consists of graph type objects.
Returns
-------
local_values : dict
A dictionary of pairs between each input graph and a bins where the
sampled graphlets have fallen.
"""
if not isinstance(X, Iterable):
raise ValueError("input must be an iterable\n")
else:
# The number of parsed graphs
n = 0
transition_matrix = dict()
indexes = [0]
Attr = list()
for idx, x in enumerate(iter(X)):
is_iter = isinstance(x, Iterable)
if is_iter:
x = list(x)
if is_iter and len(x) in [0, 2, 3, 4]:
if len(x) == 0:
warnings.warn("Ignoring empty element on " + "index: " + str(idx))
continue
if len(x) == 2 and type(x[0]) is Graph:
g, T = x
else:
g = Graph(x[0], x[1], {}, self._graph_format)
if len(x) == 4:
T = x[3]
else:
T = None
elif type(x) is Graph:
g, T = x, None
else:
raise ValueError(
"Each element of X must be either a "
+ "Graph or an iterable with at least 2 "
+ "and at most 4 elements\n"
)
if T is not None:
if T.shape[0] != T.shape[1]:
raise TypeError(
"Transition matrix on index"
+ " "
+ str(idx)
+ "must be "
+ "a square matrix."
)
if T.shape[0] != g.nv():
raise TypeError(
"Propagation matrix must "
+ "have the same dimension "
+ "as the number of vertices."
)
else:
T = g.get_adjacency_matrix()
nv = g.nv()
transition_matrix[n] = normalizer(T, axis=1, norm="l1")
attr = g.get_labels(purpose="adjacency")
try:
attributes = np.array([attr[j] for j in range(nv)])
except TypeError:
raise TypeError(
"All attributes of a single graph should have the same dimension."
)
Attr.append(attributes)
indexes.append(indexes[-1] + nv)
n += 1
try:
P = np.vstack(Attr)
except ValueError:
raise ValueError("Attribute dimensions should be the same, for all graphs")
if self._method_calling == 1:
self._dim = P.shape[1]
else:
if self._dim != P.shape[1]:
raise ValueError(
"transform attribute vectors should have the same dimension as in fit"
)
if n == 0:
raise ValueError("Parsed input is empty")
# feature vectors
if self._method_calling == 1:
# simple normal
self._u, self._b, self._hd = list(), list(), list()
for t in range(self.t_max):
u = self.random_state_.randn(self._dim)
if self.take_cauchy_:
# cauchy
u = np.divide(u, self.random_state_.randn(self._dim))
self._u.append(u)
# random offset
self._b.append(self.w * self.random_state_.randn(self._dim))
phi = {k: dict() for k in range(n)}
for t in range(self.t_max):
# for hash all graphs inside P and produce the feature vectors
hashes = self.calculate_LSH(P, self._u[t], self._b[t]).tolist()
hd = {j: i for i, j in enumerate({tuple(l) for l in hashes})}
self._hd.append(hd)
features = np.array([hd[tuple(l)] for l in hashes])
# Accumulate the results.
for k in range(n):
phi[k][t] = Counter(features[indexes[k] : indexes[k + 1]].flat)
# calculate the Propagation matrix if needed
if t < self.t_max - 1:
for k in range(n):
start, end = indexes[k : k + 2]
P[start:end, :] = np.dot(transition_matrix[k], P[start:end, :])
return [phi[k] for k in range(n)]
if self._method_calling == 3:
phi = {k: dict() for k in range(n)}
for t in range(self.t_max):
# for hash all graphs inside P and produce the feature vectors
hashes = self.calculate_LSH(P, self._u[t], self._b[t]).tolist()
hd = dict(
chain(
self._hd[t].items(),
iter(
(j, i)
for i, j in enumerate(
filterfalse(
lambda x: x in self._hd[t], {tuple(l) for l in hashes}
),
len(self._hd[t]),
)
),
)
)
features = np.array([hd[tuple(l)] for l in hashes])
# Accumulate the results.
for k in range(n):
phi[k][t] = Counter(features[indexes[k] : indexes[k + 1]])
# calculate the Propagation matrix if needed
if t < self.t_max - 1:
for k in range(n):
start, end = indexes[k : k + 2]
P[start:end, :] = np.dot(transition_matrix[k], P[start:end, :])
return [phi[k] for k in range(n)]
def calculate_LSH(self, X, u, b):
"""Calculate Local Sensitive Hashing needed for propagation kernels.
See :cite:`neumann2015propagation`, p.12.
Parameters
----------
X : np.array
A float array of shape (N, D) with N vertices and D features.
u : np.array, shape=(D, 1)
A projection vector.
b : float
An offset (times w).
Returns
-------
lsh : np.array.
The local sensitive hash coresponding to each vertex.
"""
return np.floor((X * u + b) / self.w)