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/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)