hdlib 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hdlib/__init__.py +16 -0
- hdlib/arithmetic/__init__.py +265 -0
- hdlib/arithmetic/quantum.py +1257 -0
- hdlib/model/__init__.py +12 -0
- hdlib/model/classification.py +1719 -0
- hdlib/model/clustering.py +172 -0
- hdlib/model/graph.py +764 -0
- hdlib/model/regression.py +306 -0
- hdlib/space.py +864 -0
- hdlib/vector.py +608 -0
- hdlib-2.1.0.data/scripts/chopin2.py +473 -0
- hdlib-2.1.0.dist-info/METADATA +137 -0
- hdlib-2.1.0.dist-info/RECORD +16 -0
- hdlib-2.1.0.dist-info/WHEEL +5 -0
- hdlib-2.1.0.dist-info/licenses/LICENSE +21 -0
- hdlib-2.1.0.dist-info/top_level.txt +1 -0
hdlib/model/graph.py
ADDED
|
@@ -0,0 +1,764 @@
|
|
|
1
|
+
"""Directed and undirected, weighted and unweighted graphs with hdlib.
|
|
2
|
+
|
|
3
|
+
It implements the __hdlib.model.graph.GraphModel__ class object which allows to represent weighted directed and undirected graphs
|
|
4
|
+
built according to the Hyperdimensional Computing (HDC) paradigm as described in _Poduval et al. 2022_ https://doi.org/10.3389/fnins.2022.757125."""
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
import multiprocessing as mp
|
|
8
|
+
from typing import Any, Optional, Set, Tuple
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from hdlib import __version__
|
|
13
|
+
from hdlib.space import Space
|
|
14
|
+
from hdlib.vector import Vector
|
|
15
|
+
from hdlib.arithmetic import bind, permute
|
|
16
|
+
|
|
17
|
+
# Private graph vector name
|
|
18
|
+
GRAPH_ID = "__graph__"
|
|
19
|
+
|
|
20
|
+
# Private weight vector name prefix
|
|
21
|
+
WEIGHT_ID = "__weight__"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GraphModel(object):
|
|
25
|
+
"""Hyperdimensional GraphModel representation."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
size: int=10000,
|
|
30
|
+
directed: bool=False,
|
|
31
|
+
seed: Optional[int]=None
|
|
32
|
+
) -> "GraphModel":
|
|
33
|
+
"""Initialize a GraphModel object.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
size : int, default 10000
|
|
38
|
+
The size of vectors used to create a Space and define Vector objects.
|
|
39
|
+
directed : bool, default False
|
|
40
|
+
Directed or undirected.
|
|
41
|
+
seed : int, optional
|
|
42
|
+
Random seed for reproducibility of results.
|
|
43
|
+
|
|
44
|
+
Raises
|
|
45
|
+
------
|
|
46
|
+
TypeError
|
|
47
|
+
If the vector size is not an integer number.
|
|
48
|
+
|
|
49
|
+
Examples
|
|
50
|
+
--------
|
|
51
|
+
>>> from hdlib.model import GraphModel
|
|
52
|
+
>>> graph = GraphModel(size=10000, vtype='bipolar', directed=False)
|
|
53
|
+
>>> type(graph)
|
|
54
|
+
<class 'hdlib.model.GraphModel'>
|
|
55
|
+
|
|
56
|
+
This creates a new undirected GraphModel object around a Space that can host random bipolar Vector objects with size 10,000.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
if not isinstance(size, int):
|
|
60
|
+
raise TypeError("Vectors size must be an integer number")
|
|
61
|
+
|
|
62
|
+
# Register vectors dimensionality
|
|
63
|
+
self.size = size
|
|
64
|
+
|
|
65
|
+
# Register a set with edge weights (classes)
|
|
66
|
+
self.weights = set()
|
|
67
|
+
|
|
68
|
+
# Register vectors type
|
|
69
|
+
self.vtype = "bipolar"
|
|
70
|
+
|
|
71
|
+
# Register whether the graph is directed or undirected
|
|
72
|
+
self.directed = directed
|
|
73
|
+
|
|
74
|
+
# Keep track of the number of nodes
|
|
75
|
+
self.nodes_counter = 0
|
|
76
|
+
|
|
77
|
+
# Keep track of the number of edges
|
|
78
|
+
self.edges_counter = 0
|
|
79
|
+
|
|
80
|
+
# Hyperdimensional space
|
|
81
|
+
self.space = Space(size=self.size, vtype=self.vtype)
|
|
82
|
+
|
|
83
|
+
self.seed = seed
|
|
84
|
+
|
|
85
|
+
if self.seed is None:
|
|
86
|
+
self.rand = np.random.default_rng()
|
|
87
|
+
|
|
88
|
+
else:
|
|
89
|
+
# Conditions on random seed for reproducibility
|
|
90
|
+
# numpy allows integers as random seeds
|
|
91
|
+
if not isinstance(seed, int):
|
|
92
|
+
raise TypeError("Seed must be an integer number")
|
|
93
|
+
|
|
94
|
+
self.rand = np.random.default_rng(seed=self.seed)
|
|
95
|
+
|
|
96
|
+
# We estimate a proper threshold to establish whether an edge exists between two nodes
|
|
97
|
+
# Keep track of these thresholds to avoid recomputing them
|
|
98
|
+
# This is also weight dependant
|
|
99
|
+
self.weight_to_node_specific_thresholds = dict()
|
|
100
|
+
|
|
101
|
+
# Also keep track of the edges for error rate estimation and error mitigation
|
|
102
|
+
self.edges = set()
|
|
103
|
+
|
|
104
|
+
# Keep track of hdlib version
|
|
105
|
+
self.version = __version__
|
|
106
|
+
|
|
107
|
+
def __str__(self) -> str:
|
|
108
|
+
"""Print the GraphModel object properties.
|
|
109
|
+
|
|
110
|
+
Returns
|
|
111
|
+
-------
|
|
112
|
+
str
|
|
113
|
+
A description of the GraphModel object. It reports the vectors size, the vector type,
|
|
114
|
+
the number of nodes, the number of edges, and whether it is directed or undirected.
|
|
115
|
+
|
|
116
|
+
Examples
|
|
117
|
+
--------
|
|
118
|
+
>>> from hdlib.model import GraphModel
|
|
119
|
+
>>> graph = GraphModel()
|
|
120
|
+
>>> print(graph)
|
|
121
|
+
|
|
122
|
+
Class: hdlib.model.graph.GraphModel
|
|
123
|
+
Version: 0.1.17
|
|
124
|
+
Size: 10000
|
|
125
|
+
Type: bipolar
|
|
126
|
+
Directed: False
|
|
127
|
+
Weights: 100
|
|
128
|
+
Nodes: 0
|
|
129
|
+
Edges: 0
|
|
130
|
+
Seed: None
|
|
131
|
+
|
|
132
|
+
Print the GraphModel object properties. By default, the size of vectors in space is 10,000,
|
|
133
|
+
their types is bipolar, and the number of level vectors is 2. The number of data points
|
|
134
|
+
and the number of class labels are empty here since no dataset has been processed yet.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
return f"""
|
|
138
|
+
Class: hdlib.model.graph.GraphModel
|
|
139
|
+
Version: {self.version}
|
|
140
|
+
Size: {self.size}
|
|
141
|
+
Type: {self.vtype}
|
|
142
|
+
Directed: {self.directed}
|
|
143
|
+
Weights: {len(self.weights)}
|
|
144
|
+
Nodes: {self.nodes_counter}
|
|
145
|
+
Edges: {self.edges_counter}
|
|
146
|
+
Seed: {self.seed}
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def _add_edge(
|
|
150
|
+
self,
|
|
151
|
+
node1: str,
|
|
152
|
+
node2: str,
|
|
153
|
+
weight: Any,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Add an edge to the graph and automatically build nodes if they do not exist in the space.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
node1 : str
|
|
160
|
+
Node name.
|
|
161
|
+
node2 : str
|
|
162
|
+
Node name.
|
|
163
|
+
weight : Any
|
|
164
|
+
The edge weight.
|
|
165
|
+
This can be numeric or a string used as a class label.
|
|
166
|
+
|
|
167
|
+
Raises
|
|
168
|
+
------
|
|
169
|
+
ValueError
|
|
170
|
+
If `node1` or `node2` is equals to `GRAPH_ID`.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
if node1 == GRAPH_ID or node2 == GRAPH_ID:
|
|
174
|
+
raise ValueError(f"Node names cannot match with the private graph ID `{GRAPH_ID}`")
|
|
175
|
+
|
|
176
|
+
edge_exists = False
|
|
177
|
+
|
|
178
|
+
if node1 in self.space.space and node2 in self.space.space:
|
|
179
|
+
# Check whether an edge between node1 and node2 already exists
|
|
180
|
+
if self.directed and node2 in self.space.space[node1].children:
|
|
181
|
+
edge_exists = True
|
|
182
|
+
|
|
183
|
+
elif not self.directed and node2 in self.space.space[node1].children and node1 in self.space.space[node2].children:
|
|
184
|
+
edge_exists = True
|
|
185
|
+
|
|
186
|
+
if not edge_exists:
|
|
187
|
+
for node in [node1, node2]:
|
|
188
|
+
# Build node if it is not in the space
|
|
189
|
+
if node not in self.space.space:
|
|
190
|
+
# Build a random binary vector
|
|
191
|
+
vector = Vector(
|
|
192
|
+
name=node,
|
|
193
|
+
size=self.size,
|
|
194
|
+
vtype=self.vtype
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Define a new property called memory to store information
|
|
198
|
+
# about current node neighbors
|
|
199
|
+
setattr(vector, "memory", None)
|
|
200
|
+
|
|
201
|
+
# Define a new property called weights to store
|
|
202
|
+
# edge weights in case of a weighted graph
|
|
203
|
+
setattr(vector, "weights", dict())
|
|
204
|
+
|
|
205
|
+
# Register the node into the space
|
|
206
|
+
self.space.insert(vector)
|
|
207
|
+
|
|
208
|
+
# Increment the nodes counter
|
|
209
|
+
self.nodes_counter += 1
|
|
210
|
+
|
|
211
|
+
# Take track of the edge as a link between the two nodes
|
|
212
|
+
self.space.link(node1, node2)
|
|
213
|
+
|
|
214
|
+
# Increment the edges counter
|
|
215
|
+
self.edges_counter += 1
|
|
216
|
+
|
|
217
|
+
if not self.directed:
|
|
218
|
+
# Take track of the same edge again in case of an undirected graph
|
|
219
|
+
self.space.link(node2, node1)
|
|
220
|
+
|
|
221
|
+
# Increment the edges counter
|
|
222
|
+
self.edges_counter += 1
|
|
223
|
+
|
|
224
|
+
# Keep track of the edge weight
|
|
225
|
+
if node2 not in self.space.space[node1].weights:
|
|
226
|
+
self.space.space[node1].weights[node2] = set()
|
|
227
|
+
|
|
228
|
+
if not self.directed:
|
|
229
|
+
if node1 not in self.space.space[node2].weights:
|
|
230
|
+
self.space.space[node2].weights[node1] = set()
|
|
231
|
+
|
|
232
|
+
self.space.space[node1].weights[node2].add(weight)
|
|
233
|
+
|
|
234
|
+
if not self.directed:
|
|
235
|
+
self.space.space[node2].weights[node1].add(weight)
|
|
236
|
+
|
|
237
|
+
# Keep track of the edges here
|
|
238
|
+
self.edges.add((node1, node2, weight))
|
|
239
|
+
|
|
240
|
+
def _node_memory(self, node: str) -> None:
|
|
241
|
+
"""Build the node memory as a vector containing information about its neighbors.
|
|
242
|
+
|
|
243
|
+
Parameters
|
|
244
|
+
----------
|
|
245
|
+
node : str
|
|
246
|
+
The node for which we want to build the memory.
|
|
247
|
+
|
|
248
|
+
Raises
|
|
249
|
+
------
|
|
250
|
+
Exception
|
|
251
|
+
- if the input `node` is not in the graph space;
|
|
252
|
+
- if the input `node` does not have any neighbors.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
if node not in self.space.space:
|
|
256
|
+
raise Exception(f"Node `{node}` is not in the graph space")
|
|
257
|
+
|
|
258
|
+
neighbors = self.space.space[node].children
|
|
259
|
+
|
|
260
|
+
node_memory = None
|
|
261
|
+
|
|
262
|
+
for neighbor in neighbors:
|
|
263
|
+
# Get the real weight from vector tags
|
|
264
|
+
for weight in self.space.space[node].weights[neighbor]:
|
|
265
|
+
# Retrieve the weight vector from the space
|
|
266
|
+
weight_vector = self.space.space[f"{WEIGHT_ID}{weight}"]
|
|
267
|
+
|
|
268
|
+
if node_memory is None:
|
|
269
|
+
# Initialize the node memory with the first neighbor
|
|
270
|
+
# multiplied by its weight vector
|
|
271
|
+
node_memory = weight_vector * self.space.space[neighbor]
|
|
272
|
+
|
|
273
|
+
else:
|
|
274
|
+
# Multiply each neighbor with its weight vector and
|
|
275
|
+
# bundle all the resulting vectors together to build the node memory
|
|
276
|
+
node_memory = node_memory + (weight_vector * self.space.space[neighbor])
|
|
277
|
+
|
|
278
|
+
# Store the node memory into the memory property of the node vector object
|
|
279
|
+
self.space.space[node].memory = node_memory
|
|
280
|
+
|
|
281
|
+
def _weight_memory(self) -> None:
|
|
282
|
+
"""Build the weights memory.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
# Recover edge weights from the space
|
|
286
|
+
for node in self.space.space:
|
|
287
|
+
# Check whether the current node is not the actual graph memory
|
|
288
|
+
# Also, check whether the current node is not a weight vector
|
|
289
|
+
if node != GRAPH_ID and not node.startswith(WEIGHT_ID):
|
|
290
|
+
for neighbor in self.space.space[node].weights:
|
|
291
|
+
# Retrieve the weights on these edges
|
|
292
|
+
self.weights.update(self.space.space[node].weights[neighbor])
|
|
293
|
+
|
|
294
|
+
for weight in self.weights:
|
|
295
|
+
# Build a random vector
|
|
296
|
+
weight_vector = Vector(name=f"{WEIGHT_ID}{weight}", size=self.size, vtype=self.vtype)
|
|
297
|
+
|
|
298
|
+
self.space.insert(weight_vector)
|
|
299
|
+
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _error_rate(instance: "GraphModel") -> Tuple[float, Set[Tuple[str, str, float]], Set[Tuple[str, str, float]]]:
|
|
302
|
+
"""Just a wrapper around the `error_rate` function to make it callable in multiprocessing.
|
|
303
|
+
It is safe to run in multiprocessing because it does not modify any instance attributes.
|
|
304
|
+
|
|
305
|
+
Parameters
|
|
306
|
+
----------
|
|
307
|
+
instance : GraphModel
|
|
308
|
+
A GraphModel instance.
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
tuple
|
|
313
|
+
A tuple with the error rate, and the sets of flase positive and false negative edges
|
|
314
|
+
among those in the input `edges`.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
return instance.error_rate()
|
|
318
|
+
|
|
319
|
+
def error_rate(self) -> Tuple[float, Set[Tuple[str, str, float]], Set[Tuple[str, str, float]]]:
|
|
320
|
+
"""Compute the error rate defined as the number of mispredicted edges on the total number of edges.
|
|
321
|
+
Note that the error rate depends on the set of edges in input to this function which could be different
|
|
322
|
+
from the actual set of edges used to build the graph model.
|
|
323
|
+
|
|
324
|
+
Returns
|
|
325
|
+
-------
|
|
326
|
+
tuple
|
|
327
|
+
A tuple with the error rate, and the sets of flase positive and false negative edges
|
|
328
|
+
among those in the input `edges`.
|
|
329
|
+
"""
|
|
330
|
+
|
|
331
|
+
# Compute the error rate as the number of mispredicted edges over the total number of edges
|
|
332
|
+
false_negatives = set()
|
|
333
|
+
|
|
334
|
+
for edge in self.edges:
|
|
335
|
+
node1, node2, weight_true = edge
|
|
336
|
+
|
|
337
|
+
# Compute node specific threshold
|
|
338
|
+
node1_threshold = self.weight_to_node_specific_thresholds[weight_true].get(node1) if weight_true in self.weight_to_node_specific_thresholds else None
|
|
339
|
+
|
|
340
|
+
# Search for the current edge
|
|
341
|
+
edge_exists, _, dist_threshold = self.edge_exists(node1, node2, weight_true, threshold=node1_threshold)
|
|
342
|
+
|
|
343
|
+
if weight_true not in self.weight_to_node_specific_thresholds:
|
|
344
|
+
self.weight_to_node_specific_thresholds[weight_true] = dict()
|
|
345
|
+
|
|
346
|
+
if node1 not in self.weight_to_node_specific_thresholds[weight_true]:
|
|
347
|
+
self.weight_to_node_specific_thresholds[weight_true][node1] = dist_threshold
|
|
348
|
+
|
|
349
|
+
if not edge_exists:
|
|
350
|
+
# This is a false negative
|
|
351
|
+
false_negatives.add(edge)
|
|
352
|
+
|
|
353
|
+
return len(false_negatives) / len(self.edges), false_negatives
|
|
354
|
+
|
|
355
|
+
def error_mitigation(
|
|
356
|
+
self,
|
|
357
|
+
max_iter: int=10,
|
|
358
|
+
nproc: int=1
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Mitigate the error rate of the graph model.
|
|
361
|
+
|
|
362
|
+
Parameters
|
|
363
|
+
----------
|
|
364
|
+
max_iter : int, deafult 10
|
|
365
|
+
This is an iterative process that is repeated for up to `max_iter` iterations.
|
|
366
|
+
nproc : int, default 1
|
|
367
|
+
Maximum number of jobs for multiprocessing.
|
|
368
|
+
"""
|
|
369
|
+
|
|
370
|
+
if max_iter > 0:
|
|
371
|
+
# Keep track of misclassified edges
|
|
372
|
+
false_negatives = set()
|
|
373
|
+
|
|
374
|
+
# Split the set of edges into equally sized chunks
|
|
375
|
+
# This is used for multiprocessing only
|
|
376
|
+
chunk_size = len(self.edges) // nproc
|
|
377
|
+
|
|
378
|
+
edges_list = None
|
|
379
|
+
edges_subsets = None
|
|
380
|
+
|
|
381
|
+
# Redefine the number of CPUs for multiprocessing
|
|
382
|
+
if nproc <= 1:
|
|
383
|
+
nproc = 1
|
|
384
|
+
|
|
385
|
+
# Compute the model error rate
|
|
386
|
+
prev_error_rate, false_negatives = self.error_rate()
|
|
387
|
+
|
|
388
|
+
else:
|
|
389
|
+
if nproc > mp.cpu_count():
|
|
390
|
+
nproc = mp.cpu_count()
|
|
391
|
+
|
|
392
|
+
# Very inefficient but required
|
|
393
|
+
edges_list = list(self.edges)
|
|
394
|
+
|
|
395
|
+
edges_subsets = [(self, set(edges_list[i:i+chunk_size])) for i in range(0, len(edges_list), chunk_size)]
|
|
396
|
+
|
|
397
|
+
with mp.Pool(processes=nproc) as pool:
|
|
398
|
+
# Compute the model error rate in multiprocessing
|
|
399
|
+
error_estimation = pool.starmap(self.__class__._error_rate, edges_subsets)
|
|
400
|
+
|
|
401
|
+
# Retrieve the set of misclassified edges
|
|
402
|
+
for _, false_negatives_partial in error_estimation:
|
|
403
|
+
false_negatives = false_negatives.union(false_negatives_partial)
|
|
404
|
+
|
|
405
|
+
prev_error_rate = len(false_negatives) / len(self.edges)
|
|
406
|
+
|
|
407
|
+
print(f"(base)\tError rate: {prev_error_rate}\tFalse negatives: {len(false_negatives)}")
|
|
408
|
+
|
|
409
|
+
# Error mitigate the model if the error rate is > 0
|
|
410
|
+
if prev_error_rate > 0:
|
|
411
|
+
for i in range(max_iter):
|
|
412
|
+
# Work on a copy of self here
|
|
413
|
+
graph = copy.deepcopy(self)
|
|
414
|
+
|
|
415
|
+
# Rebuild the mispredicted node memories
|
|
416
|
+
for edge in false_negatives:
|
|
417
|
+
#node1, node2, weight_true, weights_pred = edge
|
|
418
|
+
node1, node2, weight_true = edge
|
|
419
|
+
|
|
420
|
+
if graph.space.space[node1].memory:
|
|
421
|
+
# Retrieve the weight vector from the space
|
|
422
|
+
weight_true_vector = graph.space.space[f"{WEIGHT_ID}{weight_true}"]
|
|
423
|
+
|
|
424
|
+
# Defined correct connection vector
|
|
425
|
+
# Reinforcement signal
|
|
426
|
+
correct_connection = weight_true_vector * graph.space.space[node2]
|
|
427
|
+
|
|
428
|
+
if self.directed:
|
|
429
|
+
# Increase the signal of node1, node2 connection on weight_true
|
|
430
|
+
graph.space.space[GRAPH_ID] += graph.space.space[node1] * permute(correct_connection, rotate_by=1)
|
|
431
|
+
|
|
432
|
+
else:
|
|
433
|
+
# Do not permute the reinforced node1 memory if the global graph is not directed
|
|
434
|
+
graph.space.space[GRAPH_ID] += graph.space.space[node1] * correct_connection
|
|
435
|
+
|
|
436
|
+
# Reset `false_negatives`
|
|
437
|
+
false_negatives = set()
|
|
438
|
+
|
|
439
|
+
# Redefine the number of CPUs for multiprocessing
|
|
440
|
+
# There is no need to downscale nproc here
|
|
441
|
+
if nproc == 1:
|
|
442
|
+
# Compute the model error rate
|
|
443
|
+
error_rate, false_negatives = graph.error_rate()
|
|
444
|
+
|
|
445
|
+
else:
|
|
446
|
+
with mp.Pool(processes=nproc) as pool:
|
|
447
|
+
# Compute the model error rate in multiprocessing
|
|
448
|
+
error_estimation = pool.starmap(graph.__class__._error_rate, edges_subsets)
|
|
449
|
+
|
|
450
|
+
# Retrieve the set of misclassified edges
|
|
451
|
+
for _, false_negatives_partial in error_estimation:
|
|
452
|
+
false_negatives = false_negatives.union(false_negatives_partial)
|
|
453
|
+
|
|
454
|
+
error_rate = len(false_negatives) / len(graph.edges)
|
|
455
|
+
|
|
456
|
+
print(f"(iter {i+1})\tError rate: {error_rate}\tFalse negatives: {len(false_negatives)}")
|
|
457
|
+
|
|
458
|
+
if error_rate > prev_error_rate:
|
|
459
|
+
# Stop the error mitigating the graph model here
|
|
460
|
+
break
|
|
461
|
+
|
|
462
|
+
# Update the error rate
|
|
463
|
+
prev_error_rate = error_rate
|
|
464
|
+
|
|
465
|
+
# Make the error mitigated graph persistent
|
|
466
|
+
self.__dict__.update(graph.__dict__)
|
|
467
|
+
|
|
468
|
+
if error_rate == 0.0:
|
|
469
|
+
# There is nothing else to do here
|
|
470
|
+
break
|
|
471
|
+
|
|
472
|
+
def fit(
|
|
473
|
+
self,
|
|
474
|
+
edges: Set[Tuple[str, str, Any]],
|
|
475
|
+
build_nodes_memory: bool=True
|
|
476
|
+
) -> None:
|
|
477
|
+
"""Build the graph memory and store it into the space.
|
|
478
|
+
|
|
479
|
+
Parameters
|
|
480
|
+
----------
|
|
481
|
+
edges : set
|
|
482
|
+
The set of edges defined as tuples `<source, target, weight>`.
|
|
483
|
+
build_nodes_memory : bool, default True
|
|
484
|
+
Build nodes and weight memories by default.
|
|
485
|
+
This must be set to False in case this is invoked to mitigate the error rate.
|
|
486
|
+
|
|
487
|
+
Raises
|
|
488
|
+
------
|
|
489
|
+
ValueError
|
|
490
|
+
If no edges are provided in input.
|
|
491
|
+
"""
|
|
492
|
+
|
|
493
|
+
if not edges:
|
|
494
|
+
raise ValueError("Must provide at least one edge")
|
|
495
|
+
|
|
496
|
+
for edge in edges:
|
|
497
|
+
node1, node2, weight = edge
|
|
498
|
+
|
|
499
|
+
self._add_edge(node1, node2, weight)
|
|
500
|
+
|
|
501
|
+
if build_nodes_memory:
|
|
502
|
+
# Build the vector representation of the edges weight
|
|
503
|
+
self._weight_memory()
|
|
504
|
+
|
|
505
|
+
graph = None
|
|
506
|
+
|
|
507
|
+
for node in self.space.space:
|
|
508
|
+
# Check whether the current node is not the actual graph memory
|
|
509
|
+
# Also, check whether the current node is not a weight vector
|
|
510
|
+
# This is required because this function can be run multiple times
|
|
511
|
+
if node != GRAPH_ID and not node.startswith(WEIGHT_ID):
|
|
512
|
+
if build_nodes_memory:
|
|
513
|
+
# Build the node memory
|
|
514
|
+
self._node_memory(node)
|
|
515
|
+
|
|
516
|
+
if self.directed:
|
|
517
|
+
if graph is None:
|
|
518
|
+
if self.space.space[node].memory:
|
|
519
|
+
graph = self.space.space[node] * permute(self.space.space[node].memory, rotate_by=1)
|
|
520
|
+
|
|
521
|
+
else:
|
|
522
|
+
graph = self.space.space[node]
|
|
523
|
+
|
|
524
|
+
else:
|
|
525
|
+
if self.space.space[node].memory:
|
|
526
|
+
# Build the graph memory as the bundle of all the node memories rotated by 1 position
|
|
527
|
+
graph = graph + (self.space.space[node] * permute(self.space.space[node].memory, rotate_by=1))
|
|
528
|
+
|
|
529
|
+
else:
|
|
530
|
+
graph = graph + self.space.space[node]
|
|
531
|
+
|
|
532
|
+
else:
|
|
533
|
+
if graph is None:
|
|
534
|
+
if self.space.space[node].memory:
|
|
535
|
+
graph = self.space.space[node] * self.space.space[node].memory
|
|
536
|
+
|
|
537
|
+
else:
|
|
538
|
+
graph = self.space.space[node]
|
|
539
|
+
|
|
540
|
+
else:
|
|
541
|
+
if self.space.space[node].memory:
|
|
542
|
+
# Build the graph memory as the bundle of all the node memories
|
|
543
|
+
graph = graph + (self.space.space[node] * self.space.space[node].memory)
|
|
544
|
+
|
|
545
|
+
else:
|
|
546
|
+
graph = graph + self.space.space[node]
|
|
547
|
+
|
|
548
|
+
if not self.directed:
|
|
549
|
+
# Introduce a factor 1/2 because if we expand the node memory, then
|
|
550
|
+
# H(i)*H(j) and H(j)*H(i) will be counted twice
|
|
551
|
+
graph.vector = graph.vector / 2
|
|
552
|
+
|
|
553
|
+
# Check whether a graph is already present in the space
|
|
554
|
+
if GRAPH_ID in self.space.space:
|
|
555
|
+
self.space.remove(GRAPH_ID)
|
|
556
|
+
|
|
557
|
+
# Rename the graph vector
|
|
558
|
+
graph.name = GRAPH_ID
|
|
559
|
+
|
|
560
|
+
# Store the graph vector into the space
|
|
561
|
+
self.space.insert(graph)
|
|
562
|
+
|
|
563
|
+
# Also keep track of the edges
|
|
564
|
+
# This is used in case of the error_mitigation()
|
|
565
|
+
self.edges = edges
|
|
566
|
+
|
|
567
|
+
def edge_exists(
|
|
568
|
+
self,
|
|
569
|
+
node1: str,
|
|
570
|
+
node2: str,
|
|
571
|
+
weight: Any,
|
|
572
|
+
threshold: Optional[float]=None
|
|
573
|
+
) -> Tuple[bool, float, float]:
|
|
574
|
+
"""Check whether an edge exists between `node1` and `node2` according to a specified distance `threshold`.
|
|
575
|
+
|
|
576
|
+
node1 : str
|
|
577
|
+
The source node name or ID.
|
|
578
|
+
node2 : str
|
|
579
|
+
The target node name or ID.
|
|
580
|
+
weight : Any
|
|
581
|
+
The edge weight.
|
|
582
|
+
threshold : float, default None
|
|
583
|
+
The distance threshold on vectors to establish the presence of the edge.
|
|
584
|
+
It is automatically estimated if None.
|
|
585
|
+
|
|
586
|
+
Returns
|
|
587
|
+
-------
|
|
588
|
+
Tuple
|
|
589
|
+
True in case an edge between `node1` and `node2` exists in the graph space,
|
|
590
|
+
otherwise False. It also returns the actual distance between the two vectors and
|
|
591
|
+
the threshold used to establish whether the edge exists.
|
|
592
|
+
|
|
593
|
+
Raises
|
|
594
|
+
------
|
|
595
|
+
Exception
|
|
596
|
+
- if there is no graph vector in the space;
|
|
597
|
+
- if `node1` and `node2` are not in the graph space.
|
|
598
|
+
"""
|
|
599
|
+
|
|
600
|
+
if GRAPH_ID not in self.space.space:
|
|
601
|
+
raise Exception("There is no graph in space")
|
|
602
|
+
|
|
603
|
+
for node in [node1, node2]:
|
|
604
|
+
if node not in self.space.space:
|
|
605
|
+
raise Exception(f"Node '{node}' not in space")
|
|
606
|
+
|
|
607
|
+
if f"{WEIGHT_ID}{weight}" not in self.space.space:
|
|
608
|
+
raise Exception(f"Weight vector '{weight}' not in space")
|
|
609
|
+
|
|
610
|
+
# Retrieve the vector representation of the graph
|
|
611
|
+
graph = self.space.space[GRAPH_ID]
|
|
612
|
+
|
|
613
|
+
# Also retrieve the vector representations of the two input nodes
|
|
614
|
+
node1_vector = self.space.space[node1]
|
|
615
|
+
node2_vector = self.space.space[node2]
|
|
616
|
+
|
|
617
|
+
# Retrieve the node1 memory by binding the vector representation of
|
|
618
|
+
# node1 with the vector representation of the graph
|
|
619
|
+
# The resulting vector equals to the actual node1 memory plus noise
|
|
620
|
+
node1_memory = bind(node1_vector, graph)
|
|
621
|
+
|
|
622
|
+
if self.directed:
|
|
623
|
+
# In case of directed graphs, nodes memory are rotated by 1 position
|
|
624
|
+
# Thus, it must be rotated back in order to preserve the similarity
|
|
625
|
+
node1_memory = permute(node1_memory, rotate_by=-1)
|
|
626
|
+
|
|
627
|
+
# Check whether there is a edge between node1 and node2 by computing the distance between node2 and node1 memory
|
|
628
|
+
# A distance close to 0 means the edge exists
|
|
629
|
+
# A distance close to 1 means the edge does not exist
|
|
630
|
+
# Retrieve the weight vector from the space
|
|
631
|
+
weight_vector = self.space.space[f"{WEIGHT_ID}{weight}"]
|
|
632
|
+
|
|
633
|
+
if threshold == None:
|
|
634
|
+
if not self.space.space[node1].children:
|
|
635
|
+
# We cannot do much if node1 has no children
|
|
636
|
+
threshold = 1.0
|
|
637
|
+
|
|
638
|
+
else:
|
|
639
|
+
# Retrieve node1 children
|
|
640
|
+
# There could be hundreds of thousands of nodes here, so we focus on the first 10
|
|
641
|
+
neighbors = set(list(self.space.space[node1].children)[:10])
|
|
642
|
+
|
|
643
|
+
# Pick the same number of random non-neighbors
|
|
644
|
+
# This is very inefficient!
|
|
645
|
+
non_neighbors = set(self.rand.choice(list(self.space.space.keys()), size=len(neighbors), replace=False))
|
|
646
|
+
|
|
647
|
+
# Keep track of cosine distances between node1 and its neighbors plus random non-neighbors
|
|
648
|
+
distances = list()
|
|
649
|
+
|
|
650
|
+
for node in neighbors.union(non_neighbors):
|
|
651
|
+
if node == GRAPH_ID or node.startswith(WEIGHT_ID):
|
|
652
|
+
continue
|
|
653
|
+
|
|
654
|
+
# Retrieve the node vector from the space
|
|
655
|
+
# node is numpy.str_
|
|
656
|
+
node_vector = self.space.space[str(node)]
|
|
657
|
+
|
|
658
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
659
|
+
distances.append((weight_vector * node_vector).dist(node1_memory, method="cosine"))
|
|
660
|
+
|
|
661
|
+
# Use the 5th percentile as threshold
|
|
662
|
+
# This is a node-specific threshold for node1
|
|
663
|
+
threshold = float(np.percentile(distances, 5))
|
|
664
|
+
|
|
665
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
666
|
+
distance = (weight_vector * node2_vector).dist(node1_memory, method="cosine")
|
|
667
|
+
|
|
668
|
+
if distance < threshold:
|
|
669
|
+
return True, distance, threshold
|
|
670
|
+
|
|
671
|
+
return False, distance, threshold
|
|
672
|
+
|
|
673
|
+
@staticmethod
|
|
674
|
+
def _predict(
|
|
675
|
+
instance: "GraphModel",
|
|
676
|
+
name: str,
|
|
677
|
+
y_true: str,
|
|
678
|
+
edges: Set[Tuple[str, str, Any]],
|
|
679
|
+
) -> Tuple[float, Set[Tuple[str, str, float, Tuple[float]]], Set[Tuple[str, str, float, Tuple[float]]]]:
|
|
680
|
+
"""Just a wrapper around the `predict` function to make it callable in multiprocessing.
|
|
681
|
+
It is safe to run in multiprocessing because it does not modify any instance attributes.
|
|
682
|
+
|
|
683
|
+
Parameters
|
|
684
|
+
----------
|
|
685
|
+
instance : GraphModel
|
|
686
|
+
A GraphModel instance.
|
|
687
|
+
name : str
|
|
688
|
+
The name associated to the set of edges.
|
|
689
|
+
y_true : str
|
|
690
|
+
The true class.
|
|
691
|
+
edges : set
|
|
692
|
+
The set of edges used to mitigate the graph model error rate.
|
|
693
|
+
Note that the edges in this set do not necessarily have to be present in the graph.
|
|
694
|
+
|
|
695
|
+
Returns
|
|
696
|
+
-------
|
|
697
|
+
tuple
|
|
698
|
+
A tuple with the name of the test set, the true class, and a tuple with the predicted class and it's accuracy
|
|
699
|
+
in terms of percentage of edges that matched the predicted class.
|
|
700
|
+
"""
|
|
701
|
+
|
|
702
|
+
return name, y_true, instance.predict(edges)
|
|
703
|
+
|
|
704
|
+
def predict(
|
|
705
|
+
self,
|
|
706
|
+
edges: Set[Tuple[str, str, Any]]
|
|
707
|
+
) -> Tuple[Any, float]:
|
|
708
|
+
"""Predict the weight (class) of a specific set of edges.
|
|
709
|
+
|
|
710
|
+
Parameters
|
|
711
|
+
----------
|
|
712
|
+
edges : set
|
|
713
|
+
Set of edges.
|
|
714
|
+
|
|
715
|
+
Returns
|
|
716
|
+
-------
|
|
717
|
+
tuple
|
|
718
|
+
A tuple with the predicted class and it's accuracy in terms of percentage of edges
|
|
719
|
+
that matched the predicted class.
|
|
720
|
+
"""
|
|
721
|
+
|
|
722
|
+
hits = {weight: 0 for weight in self.weights}
|
|
723
|
+
|
|
724
|
+
for node1, node2, edge_weight in edges:
|
|
725
|
+
if node1 in self.space.space and node2 in self.space.space:
|
|
726
|
+
weight_pred = list()
|
|
727
|
+
|
|
728
|
+
for weight in self.weights:
|
|
729
|
+
node1_threshold = self.weight_to_node_specific_thresholds[weight].get(node1) if weight in self.weight_to_node_specific_thresholds else None
|
|
730
|
+
|
|
731
|
+
_, weight_dist, dist_threshold = self.edge_exists(node1, node2, weight, threshold=node1_threshold)
|
|
732
|
+
|
|
733
|
+
weight_pred.append((weight, weight_dist))
|
|
734
|
+
|
|
735
|
+
if weight not in self.weight_to_node_specific_thresholds:
|
|
736
|
+
self.weight_to_node_specific_thresholds[weight] = dict()
|
|
737
|
+
|
|
738
|
+
if node1 not in self.weight_to_node_specific_thresholds[weight]:
|
|
739
|
+
self.weight_to_node_specific_thresholds[weight][node1] = dist_threshold
|
|
740
|
+
|
|
741
|
+
if len(weight_pred) > 1:
|
|
742
|
+
# Sort weights based on their distances
|
|
743
|
+
weight_pred = sorted(weight_pred, key=lambda w: w[1])
|
|
744
|
+
|
|
745
|
+
# Get the difference between the closest and the farthest distances
|
|
746
|
+
dist_diff = weight_pred[-1][1] - weight_pred[0][1]
|
|
747
|
+
|
|
748
|
+
# Use the difference in distances to select the top closest weights
|
|
749
|
+
weight_pred = [w[0] for w in weight_pred if w[1] - weight_pred[0][1] < dist_diff]
|
|
750
|
+
|
|
751
|
+
for weight in weight_pred:
|
|
752
|
+
hits[weight] += 1
|
|
753
|
+
|
|
754
|
+
else:
|
|
755
|
+
weight_pred = weight_pred[0][0]
|
|
756
|
+
|
|
757
|
+
hits[weight_pred] += 1
|
|
758
|
+
|
|
759
|
+
# Get the best hit
|
|
760
|
+
prediction = max(hits, key=hits.get)
|
|
761
|
+
|
|
762
|
+
accuracy = (hits[prediction] / len(edges)) * 100.0
|
|
763
|
+
|
|
764
|
+
return (prediction, accuracy)
|