mlnlib 0.2.35__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.
mlnlib/mln.py ADDED
@@ -0,0 +1,1684 @@
1
+ """
2
+ Author: Eszter Bokanyi
3
+ E-mail: e.bokanyi@uva.nl
4
+
5
+ This file is created in the context of the POPNET project:
6
+ https://popnet.io
7
+
8
+ This file contains the MultiLayerNetwork class, which is the main class of
9
+ the package. It contains tools to work with a large multilayer network
10
+ using different edge types and layers efficiently.
11
+
12
+ There are three main ingredients:
13
+ * nodes: pd.DataFrame with node attributes
14
+ * A: scipy.sparse.csr_matrix with edge types encoded
15
+ * layers: pd.DataFrame with layer information
16
+
17
+ The network is either loaded from disk, using the library mode
18
+ e.g.
19
+ >>> mln = MultiLayerNetwork(load_from_library=True, library_path="my_library")
20
+ the referred folder should contain the following files:
21
+ * edges.npz (scipy.sparse.csr_matrix of size NxN with binary linktypes)
22
+ * nodes.csv.gz or nodes_{pd.__version__}.pkl (pandas dataframe with node attributes, at least 'label' column)
23
+ * layers.csv (pandas dataframe with layer information, at least 'layer' column)
24
+ or from raw CSV files using the RawCSVtoMLN class from preparation.py
25
+ e.g.
26
+ >>> mln = MultiLayerNetwork(load_from_config=True, config_path="config.json")
27
+ see the documentation of RawCSVtoMLN for more information
28
+ see config.json for an example configuration file
29
+
30
+ It can also be initialized using in-memory objects.
31
+ e.g.
32
+ >>> mln = MultiLayerNetwork(nodes, edges, layers)
33
+
34
+ After loading the three key elements, class attributes and methods work the same.
35
+
36
+ Nodes have a label that can be of arbirary type, and an id that is an integer. The methods
37
+ `to_id` and `to_label` convert between the two representations. The nodes dataframe is
38
+ stored in `self.nodes`, and it always has at least a column called `label` that contains
39
+ the node labels, and an `id` column that contains the node ids. Apart from label and id,
40
+ there can be several more columns to store node properties.
41
+
42
+ The adjacency matrix self.A is stored in a `scipy.sparse.csr_matrix` class, that only
43
+ saves nonzero elements, and on which scipy csgraph algorithms run. The node index 'id'
44
+ is the same as the row and column index of the adjacency matrix.
45
+
46
+ The adjacency matrix contains integers that encode layers if viewed as binary numbers.
47
+
48
+ The layer information is stored in self.layers, which is a pandas DataFrame. It has the
49
+ following columns:
50
+ * layer: the layer id, which is an integer between 0 and L-1, where L is the number
51
+ of layers stored in self.L
52
+ * label: the human-readable name of the layer
53
+ * binary: the binary representation of the layer, which is power of 2 between 0 and
54
+ 2**L-1, where L is the number of layers
55
+ * group: the group to which the layer belongs. This is useful for aggregating layers.
56
+
57
+ For further information, see the documentation of the MultiLayerNetwork class.
58
+
59
+ It is possible to export the network to disk either using the three files as described
60
+ above, or to a graphml file. The latter is useful for visualization purposes.
61
+
62
+ It is also possible to convert the network to igraph or networkx objects.
63
+
64
+ """
65
+
66
+ # importing libraries
67
+ import pandas as pd
68
+ import polars as pl
69
+ import numpy as np
70
+ import igraph as ig
71
+ import networkx as nx
72
+
73
+ import gzip
74
+
75
+ from mln.preparation import RawCSVtoMLN
76
+
77
+ from scipy.sparse import csr_matrix, save_npz, load_npz, eye, block_diag, kron
78
+ import json
79
+
80
+ from scipy.special import comb
81
+
82
+ from copy import deepcopy
83
+ import os
84
+
85
+ from datetime import datetime
86
+
87
+ # define constants
88
+ # how many nodes should one export in a networkx object
89
+ nx_node_limit = 10000
90
+ # how deep the BFS should go when getting the egonetworks
91
+ ego_depth_limit = 3
92
+
93
+ class MultiLayerNetwork:
94
+ def __init__(
95
+ self,
96
+ nodes = "",
97
+ edges = "",
98
+ layers = "",
99
+ load_from_library = False,
100
+ library_path = "",
101
+ load_from_config = False,
102
+ config_path = "",
103
+ verbose = False,
104
+ adjacency_element = "binary",
105
+ use_polars = False,
106
+ **kwargs
107
+ ):
108
+ """
109
+ This class contains methods and attributes to work with a large
110
+ multilayer network using different edge types and layers efficiently.
111
+
112
+ There are three main ingredients:
113
+ * nodes: pd.DataFrame with node attributes
114
+ * edges: scipy.sparse.csr_matrix with edge types
115
+ * layers: pd.DataFrame with layer information
116
+
117
+
118
+ The network is either loaded
119
+ 1. from disk, using the library mode
120
+ e.g.
121
+ >>> mln = MultiLayerNetwork(load_from_library=True, library_path="my_library")
122
+ the referred folder should contain the following files:
123
+ * edges.npz (scipy.sparse.csr_matrix of size NxN with binary linktypes)
124
+ * nodes.csv.gz or nodes_{pd.__version__}.pkl (pandas dataframe with node attributes, at least 'label' column)
125
+ * layers.csv (pandas dataframe with layer information, at least 'layer' column)
126
+ 2. from in-memory objects
127
+ e.g.
128
+ >>> mln = MultiLayerNetwork(nodes, edges, layers)
129
+ 3. from raw CSV files using the RawCSVtoMLN class from preparation.py
130
+ e.g.
131
+ >>> mln = MultiLayerNetwork(load_from_config=True, config_path="config.json")
132
+ see the documentation of RawCSVtoMLN for more information
133
+ see config.json for an example configuration file
134
+
135
+ After loading the three key elements, class attributes and methods work
136
+ the same.
137
+
138
+ Nodes have a label that can be of arbirary type, and an id that is
139
+ an integer between 0 and N-1, where N is the number of nodes. The methods
140
+ `to_id` and `to_label` convert between the two representations. The
141
+ nodes dataframe is stored in `self.nodes`, and it always has at least
142
+ a column called `label` that contains the node labels, and an `id`
143
+ column that contains the node ids.
144
+
145
+ Apart from label and id, there can be several more columns to store
146
+ node properties. If necessary, a codebook CSV can be added to translate
147
+ column values into a human-readable form.
148
+
149
+ The adjacency matrix self.A is stored in a `scipy.sparse.csr_matrix`
150
+ class, that only saves nonzero elements, and on which scipy csgraph
151
+ algorithms run. The node index 'id' is the same as the row and column
152
+ index of the adjacency matrix.
153
+
154
+ The adjacency matrix contains integers that encode layers if viewed
155
+ as binary numbers. Each possible layer is assigned an integer of the
156
+ form 2**i. For example, if both type i and type j edge is present
157
+ between two people, then the corresponding value in self.A would be
158
+ 2**i+2**j. It means that we can test for a certain layer using
159
+ bitwise AND operation very cheaply. E.g. a certain element of self.A is
160
+ 7, then 7=1+2+4 which means that layers 0,1, and 2 are present
161
+ between the two people, and 7&2 = 2 in Python (it behaves like a mask
162
+ 111 & 010 = 010).
163
+
164
+ scipy.csr matrices are cheap to slice rowwise, but beware, some
165
+ operation that seem straightforward in numpy might be costly (e.g.
166
+ getting random coordinates after each other or colwise slicing)! If
167
+ something is running too long, consult the scipy reference manual.
168
+
169
+ Whenever a new instance is created from a parent instance, the node
170
+ mapping is updated to refer to the new number of nodes and the new
171
+ adjacency matrix.
172
+
173
+ The layer information is stored in self.layers, which is a pandas
174
+ DataFrame. It has the following columns:
175
+ * layer: the layer id, which is an integer between 0 and L-1, where
176
+ L is the number of layers stored in self.L
177
+ * label: the human-readable name of the layer
178
+ * binary: the binary representation of the layer, which is power of 2
179
+ between 0 and 2**L-1, where L is the number of layers
180
+ * group: the group to which the layer belongs. This is useful for
181
+ aggregating layers.
182
+
183
+ There are two methods to convert between the different representations
184
+ of layers:
185
+ * `convert_layer_representation` converts between binary, label and
186
+ layer representations
187
+ * `convert_layer_binary_to_list` converts a binary representation
188
+ to a list of layers that are present in the binary representation.
189
+ It is possible to convert both to layer and label representations.
190
+
191
+ Parameters:
192
+ -------------
193
+ nodes : pandas dataframe
194
+ dataframe with node attributes, at least 'label' column
195
+ edges : scipy.sparse.csr_matrix
196
+ sparse matrix with edge types
197
+ layers : pandas dataframe
198
+ dataframe with layer information, at least 'layer' column
199
+ load_from_library : bool, default False
200
+ if True, load from library
201
+ library_path : string, default ""
202
+ path to library folder
203
+ load_from_config : bool, default False
204
+ if True, load from raw CSV files
205
+ config_path : string, default ""
206
+ path to config file
207
+ verbose : bool, default False
208
+ if True, print verbose output
209
+ adjacency_element : string, default "binary"
210
+ if "binary", then the adjacency matrix is binary, if "weight",
211
+ then the adjacency matrix is weighted and has no layer information
212
+ -------------
213
+
214
+ Attributes:
215
+ -------------
216
+ nodes : pandas dataframe
217
+ dataframe with node attributes, at least 'label' column
218
+ A : scipy.sparse.csr_matrix
219
+ sparse matrix with edge types
220
+ layers : pandas dataframe
221
+ dataframe with layer information, at least 'layer' column
222
+ N : int
223
+ number of nodes (does not necessarily correspond to number of active
224
+ nodes in longitudinal networks)
225
+ L : int
226
+ number of layers
227
+ adjacency_element : string
228
+ if "binary", then the adjacency matrix is binary, if "weight",
229
+ then the adjacency matrix is weighted and has no layer information
230
+ igraph : igraph object
231
+ igraph object of the network if created
232
+ layer_adjacency_matrix : dict
233
+ dictionary containing layer adjacency matrices if calculated
234
+ group_adjacency_matrix : dict
235
+ dictionary containing group adjacency matrices if calculated
236
+ -------------
237
+
238
+ Methods:
239
+ -------------
240
+ get_filtered_network: selecting subgraph
241
+ get_layer_adjacency_matrix: getting layer adjacency matrix
242
+ clear_layer_adjacency_matrices: clearing layer adjacency matrix storage
243
+ clear_group_adjacency_matrices: clearing group adjacency matrix storage
244
+ clear_all_adjacency_matrices: clearing layer and group adjacency matrix storage
245
+ get_edgelist: getting edgelist in a pandas dataframe
246
+ to_igraph: getting igraph object
247
+ to_networkx: getting networkx object
248
+ convert_layer_representation: converting between binary, label and layer representations
249
+ convert_layer_binary_to_list: converting binary representation to list of layers
250
+ to_id: converting node label to id
251
+ to_label: converting node id to label
252
+ -------------
253
+ """
254
+
255
+ """
256
+
257
+ """
258
+
259
+ # verbose printing for debugging purposes
260
+ # function by kindall at StackOverflow:
261
+ # https://stackoverflow.com/questions/5980042/how-to-implement-the-verbose-or-v-option-into-a-script
262
+ if verbose:
263
+ def verboseprint(*args):
264
+ # Print each argument separately so caller doesn't need to
265
+ # stuff everything to be printed into a single string
266
+ for arg in args:
267
+ print(arg)
268
+ print
269
+ self.verboseprint = verboseprint
270
+ else:
271
+ self.verboseprint = lambda *a: None # do-nothing function
272
+ # check if a valid input exists
273
+ if not load_from_library and not load_from_config and "csr_matrix" not in str(type(edges)):
274
+ raise ValueError("You should either load a saved MLN object, load from raw files, or pass an edge adjacency matrix.")
275
+
276
+ self.use_polars = use_polars
277
+
278
+ # TODO document these
279
+ self.null_values = kwargs.get("null_values",{})
280
+ self.load_dtypes = kwargs.get("load_dtypes",{})
281
+ self.final_dtypes = kwargs.get("final_dtypes",{})
282
+
283
+ if "usecols" in kwargs:
284
+ self.usecols = kwargs["usecols"]
285
+
286
+ # loading from library
287
+ if load_from_library:
288
+ self.nodes, self.A, self.layers = self.load(library_path)
289
+ # loading from config
290
+ elif load_from_config:
291
+ print("""
292
+ You are loading from raw CSV files.
293
+ This is a slow process, and it is recommended to use the library mode.
294
+ Use the export method to save time in the future.
295
+
296
+ E.g.:
297
+ >>> mln.export("my_library")
298
+ """)
299
+
300
+ config = json.load(open(config_path))
301
+ preparer = RawCSVtoMLN(**config)
302
+ preparer.init_all()
303
+ self.nodes, self.A, self.layers = preparer.nodes, preparer.A, preparer.layers
304
+ # loading from in-memory objects
305
+ else:
306
+ self.nodes, self.A, self.layers = nodes, edges, layers
307
+
308
+ self._map_id_to_label = dict(zip(self.nodes["id"],self.nodes["label"]))
309
+ self._map_label_to_id = dict(zip(self.nodes["label"],self.nodes["id"]))
310
+ self.N = self.A.shape[0]
311
+ self.L = self.layers.shape[0]
312
+
313
+ self.init_layer_dict()
314
+
315
+ # helper variables for later use
316
+ self.igraph = None
317
+ self.layer_adjacency_matrix = {}
318
+ self.group_adjacency_matrix = {}
319
+
320
+ # set kwargs as attributes
321
+ for k,v in kwargs.items():
322
+ setattr(self, k, v)
323
+
324
+
325
+ if "codebook" in kwargs:
326
+ self.codebook = kwargs["codebook"]
327
+ else:
328
+ self.codebook = None
329
+ self.init_codebook()
330
+
331
+ self._verbose = verbose
332
+ self._max_bin_linktype = self.layers["binary"].max()
333
+ # TODO work with this attribute to switch between weight and binary
334
+ self.adjacency_element = adjacency_element
335
+
336
+ # passing some variables to children instances that don't change
337
+ # shortens subsequent calls
338
+ self._to_pass = {
339
+ "layers" : self.layers,
340
+ "codebook" : self.codebook,
341
+ "use_polars" : self.use_polars,
342
+ "_verbose" : self._verbose,
343
+ "_layer_conversion_dict" : self._layer_conversion_dict,
344
+ "_max_bin_linktype" : self._max_bin_linktype
345
+ }
346
+
347
+ def load(self, path):
348
+ """
349
+ If load_from_library is True, this function loads nodes, edges, and layers.
350
+
351
+ Parameters:
352
+ -------------
353
+ path : string
354
+ path to library folder
355
+
356
+ Returns:
357
+ --------
358
+ nodes : string or None
359
+ resulting path to a nodelist with attributes
360
+ edges : string or None
361
+ resulting path to an edgelist / adjacency matrix
362
+ layers : string or None
363
+ resulting path to a list of layers
364
+ """
365
+
366
+ # check if save_path exists
367
+ if not os.path.exists(path):
368
+ raise ValueError(f"Path {path} does not exist.")
369
+ # check if save_path is a directory
370
+ if not os.path.isdir(path):
371
+ raise ValueError(f"Path {path} is not a directory.")
372
+ # check if save_path has a /
373
+ if path!="" and not path.endswith("/"):
374
+ path += "/"
375
+
376
+ edges_path = os.path.join(path, "edges.npz")
377
+ nodes_path = os.path.join(path, f"nodes_{pd.__version__}.pkl")
378
+ if not os.path.exists(nodes_path):
379
+ nodes_path = os.path.join(path, "nodes.csv.gz")
380
+ if not os.path.exists(edges_path):
381
+ raise ValueError(f"Edges file {edges_path} does not exist.")
382
+ if not os.path.exists(nodes_path):
383
+ raise ValueError(f"Nodes file {nodes_path} does not exist.")
384
+ layers_path = os.path.join(path, "layers.csv")
385
+ if not os.path.exists(layers_path):
386
+ raise ValueError(f"Layers file {layers_path} does not exist.")
387
+
388
+ # if nodes is a pkl file, load picke, if csv.gz, load csv
389
+ if nodes_path.endswith(".pkl"):
390
+ nodes = pd.read_pickle(nodes_path)
391
+ else:
392
+ if hasattr(self,'usecols'):
393
+ if self.use_polars:
394
+ kwargs = {"columns": self.usecols}
395
+ else:
396
+ kwargs = {"usecols": self.usecols}
397
+ else:
398
+ kwargs = {}
399
+
400
+ if self.use_polars:
401
+ nodes = pl.read_csv(
402
+ nodes_path,
403
+ has_header=True,
404
+ null_values = self.null_values,
405
+ dtypes = self.load_dtypes,
406
+ low_memory=True, **kwargs
407
+ )
408
+ for col in self.load_dtypes:
409
+ if col in self.final_dtypes and self.load_dtypes[col]!=self.final_dtypes[col] and col in nodes.columns:
410
+ self.nodes = nodes.with_columns(nodes[col].cast(self.final_dtypes[col]))
411
+ else:
412
+ nodes = pd.read_csv(
413
+ nodes_path,
414
+ index_col=None,
415
+ header=0,
416
+ dtype = self.load_dtypes, **kwargs
417
+ )
418
+ # load edges from npz file
419
+ edges = load_npz(edges_path)
420
+
421
+ # load layers from csv file
422
+ layers = pd.read_csv(layers_path, index_col=None, header=0)
423
+
424
+ return nodes, edges, layers
425
+
426
+ def init_layer_dict(self):
427
+ """
428
+ Initialize dictionary for efficient conversion from layer, label, and
429
+ binary layer representations to each other.
430
+ Resulting dictionary is stored in self._layer_conversion_dict.
431
+
432
+ Returns:
433
+ -------------
434
+ None
435
+ """
436
+ # machine readable
437
+ binary = self.layers["binary"].tolist()
438
+ # short
439
+ layer = self.layers["layer"].tolist()
440
+ # human-readable
441
+ label = self.layers["label"].tolist()
442
+
443
+ bin_to_layer = dict(zip(binary, layer))
444
+ bin_to_label = dict(zip(binary, label))
445
+ label_to_layer = dict(zip(label, layer))
446
+ label_to_bin = dict(zip(label, binary))
447
+ layer_to_bin = dict(zip(layer, binary))
448
+ layer_to_label = dict(zip(layer, label))
449
+
450
+ self._layer_conversion_dict = {
451
+ 'binary_to_layer' : bin_to_layer,
452
+ 'binary_to_label' : bin_to_label,
453
+ 'layer_to_label' : layer_to_label,
454
+ 'layer_to_binary' : layer_to_bin,
455
+ 'label_to_binary' : label_to_bin,
456
+ 'label_to_layer' : label_to_layer
457
+ }
458
+
459
+
460
+ def init_codebook(self):
461
+ """
462
+ # Initialize self.codebook with data from codebook. File should be .csv.
463
+ # If no (valid) file is given, self.codebook = None
464
+
465
+ Columns of the codebook should be:
466
+ - column
467
+ - key
468
+ - value
469
+
470
+ Parameters:
471
+ -------------
472
+ codebook : string
473
+ string containing the (path to the) codebook CSV
474
+ -------------
475
+ """
476
+ # check if codebook is given
477
+ if self.codebook is not None:
478
+ # check if self.codebook is a string and a valid path
479
+ if isinstance(self.codebook, str) and os.path.exists(self.codebook):
480
+ try:
481
+ # load codebook
482
+ self.codebook = pd.read_csv(self.codebook, index_col=None, header=0)
483
+ except:
484
+ raise ValueError("Not a valid codebook file.")
485
+
486
+ def get_filtered_network(
487
+ self,
488
+ nodes_selected=None,
489
+ layers_selected=[],
490
+ groups_selected=[],
491
+ node_type="label",
492
+ layer_type="label",
493
+ keep_node_alignment = False
494
+ ):
495
+ """
496
+ Returns MultiLayerNetwork based on node and edge filtering.
497
+
498
+ Parameters:
499
+ -------------
500
+ nodes_selected : list, default None
501
+ list of node labels or ids to be selected
502
+ which node representation is selected is controlled by node_type
503
+ layers_selected : list, default []
504
+ list of layer labels or ids to be selected
505
+ which layer representation is selected is controlled by layer_type
506
+ groups_selected : list, default []
507
+ list of group labels to be selected
508
+ this adds all layers in the groups to layers_selected
509
+ Returns:
510
+ -------------
511
+ MultiLayeredNetwork
512
+ filtered network with less data but the same structure as
513
+ the parent object
514
+ """
515
+
516
+ if layer_type not in ["label", "layer"]:
517
+ raise ValueError(f"Invalid layer_type '{layer_type}'. Please choose from 'label' or 'layer'.")
518
+
519
+ if len(groups_selected)>0:
520
+ if "group" not in self.layers.columns:
521
+ raise ValueError("No group information found in self.layers. Please add a column called 'group' to self.layers.")
522
+ # get layers corresponding to group
523
+ for g in groups_selected:
524
+ if g not in self.layers["group"].unique().tolist():
525
+ raise ValueError(f"Invalid group '{g}'. Please choose from {self.layers['group'].unique().tolist()}.")
526
+ layers_selected += self.layers[self.layers["group"] == g][layer_type].tolist()
527
+
528
+ # if there is any node selection, then decrease matrix size and grab the
529
+ # relevant rows from the node attributes table
530
+ if nodes_selected is not None: # and len(selected_nodes) > 0:
531
+ # remove duplicates from list
532
+ nodes_selected = np.unique(nodes_selected)
533
+ # mapping label to node ids, creating node mapping
534
+ if node_type == "label":
535
+ nodes_selected = np.array([self.to_id(i) for i in nodes_selected])
536
+ elif node_type == "id":
537
+ nodes_selected = np.array(nodes_selected)
538
+ else:
539
+ raise ValueError(f"Invalid node_type '{node_type}'. Please choose from 'label' or 'id'.")
540
+
541
+ # creating True/False mask for faster selection
542
+ idx = np.array(np.zeros(self.N,dtype=bool))
543
+ idx[nodes_selected] = True
544
+
545
+ if keep_node_alignment:
546
+ print("Getting indices...")
547
+ i,j = self.A.nonzero()
548
+ test = np.isin(i,nodes_selected) & np.isin(j,nodes_selected)
549
+ selected_i = i[test]
550
+ del i
551
+ selected_j = j[test]
552
+ del j
553
+ selected_data = self.A.data[test]
554
+ del test
555
+ print("Done.")
556
+ print("Constructing larger matrix...")
557
+ A_selected = csr_matrix((selected_data,(selected_i,selected_j)),shape=(self.N,self.N),dtype=self.A.dtype)
558
+ print("Done.")
559
+ print("Constructing nodes...")
560
+ if self.use_polars:
561
+ nodes_selected = self.nodes.clone()
562
+ else:
563
+ nodes_selected = self.nodes.copy()
564
+ nodes_selected["active"] = idx
565
+ print("Done.")
566
+ else:
567
+ # slicing the adjacency matrix
568
+ A_selected = self.A[idx,:][:,idx]
569
+ # slicing the attribute table
570
+ if self.use_polars:
571
+ nodes_selected = self.nodes.filter(idx)
572
+ else:
573
+ nodes_selected = self.nodes.iloc[nodes_selected].reset_index(drop=True)
574
+ else:
575
+ A_selected = deepcopy(self.A)
576
+ nodes_selected = self.nodes
577
+
578
+ if len(layers_selected)>0:
579
+ for l in layers_selected:
580
+ if l not in self.layers[layer_type].tolist():
581
+ raise ValueError(f"Invalid layer '{l}' for layer_type '{layer_type}'. Please choose from {self.layers[layer_type].tolist()}.")
582
+ # adding up the binary codes for the full layers from the argumentif len(layers)>0:
583
+ # based on type of layer value:
584
+ binary_repr = sum([self._layer_conversion_dict[layer_type + "_to_binary"][layer] for layer in layers_selected])
585
+ # select corresponding edges
586
+ A_selected.data = A_selected.data & binary_repr
587
+ # compress sparse matrix
588
+ A_selected.eliminate_zeros()
589
+
590
+ f = MultiLayerNetwork(
591
+ nodes = nodes_selected,
592
+ edges = A_selected,
593
+ **self._to_pass
594
+ )
595
+
596
+ return f
597
+
598
+ def get_layer_adjacency_matrix(self, layer, layer_type = 'layer', store = False, dtype='int64'):
599
+ """
600
+ Creates a binary adjacency matrix for one of the layers or one of the groups.
601
+
602
+ Parameters:
603
+ -------------
604
+ layer : string
605
+ layer or group to be selected
606
+ layer_type : string, default 'layer'
607
+ type of layer representation. Possible values are 'layer', 'label', and 'group'
608
+ store : bool, default False
609
+ if True, store the adjacency matrix in self.layer_adjacency_matrix or self.group_adjacency_matrix
610
+ dtype : string, default 'int64'
611
+ datatype of the adjacency matrix, smaller type means smaller memory footprint
612
+ -------------
613
+ """
614
+ if layer_type not in ["label", "layer", "binary", "group"]:
615
+ raise ValueError(f"Invalid layer_type '{layer_type}'. Please choose from 'label' or 'layer' or 'binary'.")
616
+
617
+ if layer not in self.layers[layer_type].tolist():
618
+ raise ValueError(f"Invalid layer '{layer}' for layer_type '{layer_type}'. Please choose from {self.layers[layer_type].tolist()}.")
619
+
620
+ if layer_type == "group":
621
+ if "group" not in self.layers.columns:
622
+ raise ValueError("No group information found in self.layers. Please add a column called 'group' to self.layers.")
623
+ if layer not in self.layers["group"].tolist():
624
+ raise ValueError(f"Invalid group '{layer}'. Please choose from {self.layers['group'].unique().tolist()}.")
625
+ # get layers corresponding to group
626
+ layers = self.layers[self.layers["group"] == layer]["layer"].unique().tolist()
627
+ # get corresponding binary representation
628
+ binary_repr = sum([self._layer_conversion_dict["layer_to_binary"][layer] for layer in layers])
629
+ else:
630
+ # get binary layer value if it's not already given in binary
631
+ if layer_type != "binary":
632
+ binary_repr = self._layer_conversion_dict[layer_type + "_to_binary"][layer]
633
+ else:
634
+ binary_repr = layer
635
+
636
+ # get layer value if it's not already given in layer
637
+ if layer_type != "layer" and layer_type != "group":
638
+ l = self._layer_conversion_dict[layer_type + "_to_layer"][layer]
639
+ else:
640
+ l = layer
641
+
642
+ # check if layer is already stored in layer/group adjacency matrices
643
+ if layer_type!="group" and l in self.layer_adjacency_matrix:
644
+ return self.layer_adjacency_matrix[l]
645
+ if layer_type=="group" and layer in self.group_adjacency_matrix:
646
+ return self.group_adjacency_matrix[layer]
647
+
648
+ # if it's not already stored, create it
649
+ A_layer = deepcopy(self.A)
650
+ A_layer.data = A_layer.data & binary_repr
651
+ A_layer.eliminate_zeros()
652
+ A_layer = A_layer.sign()
653
+
654
+ if dtype != 'int64':
655
+ A_layer = A_layer.astype(dtype)
656
+
657
+ # store if necessary
658
+ if store:
659
+ if layer_type == "group":
660
+ self.group_adjacency_matrix[layer] = A_layer
661
+ else:
662
+ self.layer_adjacency_matrix[l] = A_layer
663
+
664
+ return A_layer
665
+
666
+ def clear_layer_adjacency_matrices(self):
667
+ """
668
+ Clears all layer adjacency matrices.
669
+
670
+ This method resets the layer adjacency matrix attribute to an empty dictionary,
671
+ effectively removing all previously stored layer adjacency matrices.
672
+ """
673
+ self.layer_adjacency_matrix = {}
674
+
675
+ def clear_group_adjacency_matrices(self):
676
+ """
677
+ Clears all group adjacency matrices.
678
+
679
+ This method resets the group adjacency matrix attribute to an empty dictionary,
680
+ effectively removing all previously stored group adjacency matrices.
681
+ """
682
+ self.group_adjacency_matrix_adjacency_matrix = {}
683
+
684
+ def clear_all_adjacency_matrices(self):
685
+ """
686
+ Clears all adjacency matrices, group and layer.
687
+
688
+ This method resets the group and layer adjacency matrix attribute to an empty dictionary,
689
+ effectively removing all previously stored adjacency matrices.
690
+ """
691
+ self.clear_layer_adjacency_matrices()
692
+ self.clear_group_adjacency_matrices()
693
+
694
+ def report_time(self, message = "", init=False):
695
+ """
696
+ This is a helper function for optimization time measurements.
697
+
698
+ Parameters:
699
+ -----------
700
+ message : str, default ""
701
+ Some hints on the measurement may be given here. If empty,
702
+ only the elapsed time will be printed.
703
+ init : bool, default False
704
+ If True, start timer, if False, measure elapsed time since
705
+ last call.
706
+ """
707
+ if init:
708
+ self.tic = datetime.now().timestamp()
709
+ print("Initialized timer.")
710
+ else:
711
+ self.toc = datetime.now().timestamp()
712
+ elapsed = self.toc - self.tic
713
+ if message == "":
714
+ print(f"Time elapsed: {elapsed/1000:.3f} seconds.")
715
+ else:
716
+ print(message + "\n" + f"Time elapsed: {elapsed:.5f} seconds.")
717
+ self.tic = datetime.now().timestamp()
718
+
719
+ def get_edgelist(self, edge_attribute = "binary"):
720
+ """
721
+ This function returns a pandas dataframe containing the edge list
722
+ representing sparse matrix stored in self.A.
723
+
724
+ Parameters:
725
+ -----------
726
+ edge_attribute : string, default "binary"
727
+ controls what column is returned in the dataframe. Possible
728
+ values are "binary", "layer", "label", and "weight". If None,
729
+ only the source and target columns are returned.
730
+
731
+ Returns
732
+ -------
733
+ edgelist : pandas dataframe containig the edge list representing self.A
734
+ columns are source, target, and the one specified in edge_attribute
735
+ """
736
+ # self.report_time(init=True)
737
+
738
+ # getting edges and binary linktypes
739
+ edges = np.array(self.A.nonzero()).T
740
+ weights = np.array([self.A.data]).T
741
+ # self.report_time(message = "Getting data from sparse matrix.")
742
+ if edge_attribute == "binary" or edge_attribute=="weight":
743
+ # combine edges and weights
744
+ edges_with_weights = np.concatenate((edges, weights), axis=1)
745
+ if self.use_polars:
746
+ edgelist = pl.DataFrame(edges_with_weights, schema = ["source", "target", edge_attribute])
747
+ else:
748
+ edgelist = pd.DataFrame(edges_with_weights, columns = ["source", "target", edge_attribute])
749
+
750
+ if self.use_polars:
751
+ # mapping back edges to the original labels
752
+ edgelist = edgelist.with_column(
753
+ pl.col("source").map_dict(self.to_label).alias("source"),
754
+ pl.col("target").map_dict(self.to_label).alias("target")
755
+ )
756
+ else:
757
+ # mapping back edges to the original labels
758
+ edgelist["source"] = edgelist["source"].map(self.to_label)
759
+ edgelist["target"] = edgelist["target"].map(self.to_label)
760
+
761
+ elif edge_attribute == "layer" or edge_attribute == "label":
762
+ edges_with_weights = np.concatenate((edges, weights), axis=1)
763
+ if self.use_polars:
764
+ edgelist = pl.DataFrame(edges_with_weights, schema = ["source", "target", "binary"])
765
+ else:
766
+ edgelist = pd.DataFrame(edges_with_weights, columns = ["source", "target", "binary"])
767
+ # self.report_time(message = "Creating edgelist dataframe.")
768
+ if self.use_polars:
769
+ edgelist = edgelist.with_column(
770
+ pl.col("source").map_dict(self.to_label).alias("source"),
771
+ pl.col("target").map_dict(self.to_label).alias("target")
772
+ )
773
+ else:
774
+ # mapping back edges to the original labels
775
+ edgelist["source"] = edgelist["source"].map(self.to_label)
776
+ edgelist["target"] = edgelist["target"].map(self.to_label)
777
+ # self.report_time(message = "Remapping node labels.")
778
+
779
+ # add colnames and (human) readable link types
780
+ # if an edge has multiple linktypes, it is listed multiple times with the linktype code
781
+ # convert all unique binary linktypes to their labels
782
+ link_dict = {}
783
+ for link in edgelist["binary"].unique():
784
+ link_dict[link] = self.convert_layer_binary_to_list(link, output_type=edge_attribute)
785
+ # self.report_time(message = "Unfolded binary link identifiers.")
786
+ # print(edgelist.head())
787
+
788
+ # get pairs (binary_linktype, label) of each link
789
+ if self.use_polars:
790
+ # get pairs (binary_linktype, label) of each link
791
+ edgelist = edgelist.with_column(
792
+ pl.col("binary").map_dict(link_dict).alias(edge_attribute)
793
+ )
794
+ else:
795
+ edgelist[edge_attribute] = edgelist["binary"].map(link_dict)
796
+ # self.report_time(message = "Mapped binary link identifiers.")
797
+ # print(edgelist.head())
798
+
799
+ if self.use_polars:
800
+ edgelist.drop("binary")
801
+ else:
802
+ edgelist.drop("binary", axis=1, inplace=True)
803
+ edgelist = edgelist.explode(edge_attribute)
804
+ # self.report_time(message = "Exploded binary link identifiers.")
805
+ # print(edgelist.head())
806
+ # self.report_time(message = "Adding that to dataframe?")
807
+ # print(edgelist.head())
808
+ elif edge_attribute is None:
809
+ if self.use_polars:
810
+ edgelist = pl.DataFrame(edges, schema = ["source", "target"])
811
+ else:
812
+ edgelist = pd.DataFrame(edges, columns = ["source", "target"])
813
+ if self.use_polars:
814
+ edgelist = edgelist.with_column(
815
+ pl.col("source").map_dict(self.to_label).alias("source"),
816
+ pl.col("target").map_dict(self.to_label).alias("target")
817
+ )
818
+ else:
819
+ # mapping back edges to the original labels
820
+ edgelist["source"] = edgelist["source"].map(self.to_label)
821
+ edgelist["target"] = edgelist["target"].map(self.to_label)
822
+ else:
823
+ raise ValueError(f"Invalid edge_attribute '{edge_attribute}'. Please choose from 'binary', 'layer', 'label' or 'weight' or None.")
824
+
825
+ return edgelist
826
+
827
+ def to_igraph(self, directed=True, edge_attributes=True, node_attributes=False, replace_igraph=False, edge_attribute_type="binary"):
828
+ """
829
+ This function returns an igraph object of the sparse matrix stored in
830
+ self.A. Edge attributes (layer types) and node attributes (from
831
+ self.nodes) can be added to this object.
832
+
833
+ Parameters:
834
+ -----------
835
+ directed : boolean, default True
836
+ mode of returned igraph object: True for directed, False for
837
+ undirected graph
838
+ edge_attributes : boolean, default True
839
+ True if weights or layer types should be stored in the igraph object
840
+ node_attributes : boolean, default False
841
+ True if node attributes should be added to igraph object
842
+ replace_igraph : boolean, default False
843
+ True if self.igraph should be replaced by the new igraph object
844
+ edge_attribute_type : string, default "binary"
845
+ Type of edge attribute. Options: "binary", "layer", "label" or "weight"
846
+ Returns
847
+ -------
848
+ g : igraph object describing graph from input_data
849
+ """
850
+
851
+ # set mode directed / undirected
852
+ if directed: mode = 'directed'
853
+ else: mode = 'undirected'
854
+
855
+ # create igraph object
856
+ if edge_attributes:
857
+ g = ig.Graph.Weighted_Adjacency(self.A, mode=mode, attr="layer")
858
+ else:
859
+ g = ig.Graph.Adjacency(self.A.sign(), mode=mode)
860
+
861
+ if edge_attributes:
862
+ if edge_attribute_type == "binary":
863
+ # obtain and add (human readable) link types
864
+ layer_dict = {}
865
+ for layer in set(g.es["layer"]):
866
+ layer_dict[layer] = self.convert_layer_binary_to_list(layer, output_type="layer")
867
+
868
+ # rewrite link types to human readable
869
+ g.es["layer"] = [layer_dict[x] for x in g.es["layer"]]
870
+ elif edge_attribute_type == "weight":
871
+ g.es["weight"] = g.es["layer"]
872
+ del g.es["layer"]
873
+ else:
874
+ raise ValueError(f"Invalid edge_attribute_type '{edge_attribute_type}'. Please choose from 'binary' or 'weight'.")
875
+
876
+ # add node attributes to graph from self.node_attributes
877
+ if node_attributes:
878
+ for col_name in self.nodes.columns:
879
+ # first turning the series into a list improves performance
880
+ g.vs[col_name] = list(self.nodes[col_name])
881
+ else:
882
+ # Add only label
883
+ g.vs["label"] = list(self.nodes["label"])
884
+
885
+ # store igraph object as mln attribute
886
+ if self.igraph is None or replace_igraph:
887
+ self.igraph = g
888
+
889
+ return g
890
+
891
+ def to_networkx(self,
892
+ directed = True,
893
+ edge_attributes = True,
894
+ node_attributes = False,
895
+ edge_attribute_type = "binary",
896
+ layer_type = "layer",
897
+ ignore_limit = False):
898
+ """
899
+ This function returns a networkx object of the sparse matrix stored in
900
+ self.A. Edge attributes (layer types) and node attributes (from
901
+ self.nodes) can be added to this object.
902
+
903
+ Parameters:
904
+ -----------
905
+ directed : boolean, default True
906
+ mode of returned igraph object: True for directed, False for
907
+ undirected graph
908
+ edge_attributes : boolean, default True
909
+ True if edge attributes (layer types) from should be
910
+ added to igraph object.
911
+ node_attributes : boolean, default False
912
+ True if node attributes should be added to networkx object
913
+ obtained from self.node_attributes The "label" column is always
914
+ added
915
+ ignore_limit : boolean, default False
916
+ False if nx object can have at most nx_node_limit nodes Set to
917
+ True to ignore this limit
918
+
919
+ Returns:
920
+ -------
921
+ g : networkx object based on self.A
922
+ """
923
+
924
+ if not ignore_limit and self.A.shape[0] > nx_node_limit:
925
+ print(f"Networkx limit ({nx_node_limit}) exceeded. We recommend using igraph for larger graphs.")
926
+ print("To ignore this limit, please use \'ignore_limit=True\'")
927
+ return
928
+
929
+ # set graph_type to directed or undirected
930
+ if directed: graph_type = nx.DiGraph
931
+ else: graph_type = nx.Graph
932
+
933
+ # create networkx graph
934
+ g = nx.from_scipy_sparse_array(self.A, create_using=graph_type)
935
+
936
+ # add remaining attributes
937
+ if node_attributes:
938
+ for col_name in self.nodes:
939
+ cur_col = list(self.nodes[col_name])
940
+ attribute_list = dict(zip(list(range(len(cur_col))), cur_col))
941
+ nx.set_node_attributes(g, attribute_list, col_name)
942
+ else:
943
+ # always add "label" column
944
+ nx.set_node_attributes(g, self._map_id_to_label, "label")
945
+
946
+ # obtain and add (human readable) link types use dict for optimization
947
+ link_dict = {}
948
+
949
+ if edge_attributes:
950
+ if edge_attribute_type == "weight":
951
+ for s, t, d in g.edges(data=True):
952
+ d["weight"] = g[s][t]["weight"]
953
+ elif edge_attribute_type == "binary":
954
+ if edge_attributes:
955
+ for s, t, d in g.edges(data=True):
956
+ weight = g[s][t]["weight"]
957
+
958
+ if weight in link_dict:
959
+ g[s][t]["layer"] = link_dict[weight]
960
+ else:
961
+ link_types = self.convert_layer_binary_to_list(weight,output_type=layer_type)
962
+ link_dict[weight] = link_types
963
+ g[s][t]["layer"] = link_types
964
+ # remove weights
965
+ d.pop("weight", None)
966
+
967
+ return g
968
+
969
+ def convert_layer_representation(self, layer, input_type="layer", output_type="binary"):
970
+ """
971
+ This function converts a single layer type or list of layer types to
972
+ another representation.
973
+
974
+ Parameters:
975
+ -----------
976
+ layer : int, string or list, no default
977
+ input layer type(s) to be converted
978
+ input_type : string
979
+ Type of input. Options: "label", "layer" and "binary"
980
+ output_type : string
981
+ Type of output. Options: "label", "layer" and "binary"
982
+
983
+ Returns:
984
+ -----------
985
+ layers : string or list
986
+ converted layer type(s)
987
+ """
988
+
989
+ dict_name = input_type + '_to_' + output_type
990
+ try:
991
+ d = self._layer_conversion_dict[dict_name]
992
+ except KeyError:
993
+ print('Error: dictionary value not found. Please choose from "layer", "label", "binary"')
994
+ return None
995
+
996
+ try:
997
+ if type(layer) == list:
998
+ return [d[link] for link in layer]
999
+ else:
1000
+ return d[layer]
1001
+ except:
1002
+ print(f'Error: invalid linktype found: {input_type} to {output_type}')
1003
+ return None
1004
+
1005
+ def convert_layer_binary_to_list(self,num,output_type="layer"):
1006
+ """
1007
+ Based on the integer binary layer type, returns a list with the layers.
1008
+
1009
+ e.g.:
1010
+ >>> self.convert_layer_binary_to_list(3) returns ["aunt/uncle","co-parent"]
1011
+
1012
+ Parameters:
1013
+ --------------
1014
+ num: int
1015
+ integer number to be converted to binary and returned as linktypes
1016
+ output_type: string, default "layer"
1017
+ type of output. Options: "label", "layer" and "binary"
1018
+ Returns:
1019
+ ---------------
1020
+ list(type(output type of self.convert_layer_representation))
1021
+ list of layers corresponding to binary integer value
1022
+ """
1023
+
1024
+ # sanity check, max value is 1111....1 with as many 1s as there are layers self.L converted to decimal
1025
+ if np.log2(float(num)) >= self.layers.index[-1]+1:
1026
+ raise ValueError(f"Layer binary value {num} is not a valid linktype in the network.")
1027
+
1028
+ return [self.convert_layer_representation(2**i, input_type='binary', output_type=output_type)\
1029
+ for i in range(self.layers.index[-1]+1) if int(num)&(2**i)>0]
1030
+
1031
+ def save_to_graphml(self,
1032
+ file_name,
1033
+ directed = True,
1034
+ edge_attributes = True,
1035
+ node_attributes = False,
1036
+ overwrite = False,
1037
+ edge_attribute_type="binary"):
1038
+ """
1039
+ Save self.igraph to GraphML file called file_name. This can be read into
1040
+ Gephi or other external software.
1041
+
1042
+ If self.igraph is None, it first creates an igraph object.
1043
+
1044
+ Parameters:
1045
+ -------------
1046
+ file_name : string
1047
+ file to write graph to. Extension should be .graphml
1048
+ directed : boolean, default True
1049
+ mode of returned igraph object: True for directed, False for
1050
+ undirected graph
1051
+ edge_attributes : boolean, default True
1052
+ True if weights or layer types should be stored in the igraph object
1053
+ node_attributes : boolean, default False
1054
+ True if node attributes should be added to igraph object
1055
+ overwrite : boolean, default False
1056
+ if True, overwrites existing files
1057
+ edge_attribute_type : string, default "binary"
1058
+ Type of edge attribute. Options: "binary", "layer", "label" or "weight"
1059
+ -------------
1060
+ """
1061
+ _, extension = os.path.splitext(file_name)
1062
+
1063
+ if extension == ".graphml":
1064
+ if self.igraph is None or overwrite:
1065
+ self.igraph = self.to_igraph(
1066
+ node_attributes=node_attributes,
1067
+ edge_attributes=edge_attributes,
1068
+ directed=directed,
1069
+ edge_attribute_type=edge_attribute_type
1070
+ )
1071
+ else:
1072
+ raise Warning("Warning: igraph object already exists, not creating a new one - using it as you've created it earlier." +\
1073
+ "If you want to overwrite it, call the function with overwrite=True.")
1074
+ self.igraph.write_graphml(file_name)
1075
+ else:
1076
+ raise ValueError(f"Error: {extension} is not a valid file extension for graphml saving.")
1077
+
1078
+ def export_edges(self, file_name):
1079
+ """
1080
+ Write self.A to file called file_name. The file extension is read to
1081
+ determine the type of output. Options are:
1082
+ - ".npz" or no extension: Binary (default)
1083
+ - ".csv" ".csv.gz": Edgelist format
1084
+ Note: if self.igraph does not yet exist, an igraph object will be
1085
+ generated
1086
+
1087
+ Parameters:
1088
+ -----------
1089
+ file_name : str
1090
+ file to write graph to. Extension should contain the type of output
1091
+ [".npz", ".csv", ".csv.gz"]
1092
+
1093
+ """
1094
+ try:
1095
+ f = open(file_name, "w")
1096
+ except:
1097
+ raise ValueError(f"Error: {file_name} could not be opened.")
1098
+
1099
+ _, extension = os.path.splitext(file_name)
1100
+
1101
+ if extension in ['.csv','.gz']:
1102
+ # get edgelist of self.A
1103
+ edgelist = self.get_edgelist(edge_attribute = "layer")
1104
+
1105
+ # write to csv file
1106
+ if file_name.endswith(".csv"):
1107
+ edgelist.to_csv(file_name, index=False)
1108
+ else:
1109
+ edgelist.to_csv(file_name, index=False, compression = 'gzip')
1110
+ elif extension == ".npz":
1111
+ save_npz(file_name, self.A)
1112
+ else:
1113
+ raise ValueError(f"Error: {extension} is not a valid file extension for edge saving.")
1114
+
1115
+ def export_nodes(self, file_name):
1116
+ """
1117
+ Write self.nodes to file to .csv or .csv.gz file
1118
+
1119
+ Parameters:
1120
+ -----------
1121
+ file_name : str
1122
+ file to write graph to. Extension should contain the type of output
1123
+ [".csv", ".csv.gz"]
1124
+ """
1125
+ try:
1126
+ f = open(file_name, "w")
1127
+ f.close()
1128
+ except:
1129
+ raise ValueError(f"Error: {file_name} could not be opened.")
1130
+
1131
+ _, extension = os.path.splitext(file_name)
1132
+ if extension == ".csv":
1133
+ if self.use_polars:
1134
+ self.nodes.write_csv(file_name, include_header = True)
1135
+ else:
1136
+ self.nodes.to_csv(file_name)
1137
+ elif extension == ".gz":
1138
+ if self.use_polars:
1139
+ with gzip.open(file_name,"wb") as f:
1140
+ self.nodes.write_csv(f, include_header = True)
1141
+ else:
1142
+ self.nodes.to_csv(file_name, compression="gzip")
1143
+ else:
1144
+ raise ValueError(f"Error: {extension} is not a valid file extension for node saving.")
1145
+
1146
+ def save(self, path = "", overwrite = False, **kwargs):
1147
+ """
1148
+ This function saves the MultiLayerNetwork instance to a given path.
1149
+ It can be read from this path later with the library mode.
1150
+
1151
+ Parameters:
1152
+ -------------
1153
+ path : string, default ""
1154
+ path to save the network to. If empty, saves to current directory
1155
+ overwrite : boolean, default False
1156
+ if True, overwrites existing files
1157
+ node_file : string, default ""
1158
+ name of file to save node attributes to
1159
+ edge_file : string, default ""
1160
+ name of file to save edge list to
1161
+ layer_file : string, default ""
1162
+ name of file to save layer information to
1163
+ -------------
1164
+ """
1165
+ # check if path is empty, give warning if yes
1166
+ if path == "":
1167
+ print("Warning: no save path given, files will be saved in current directory.")
1168
+ path += "./"
1169
+ elif path[-1] != "/":
1170
+ path += "/"
1171
+
1172
+ # check if save_path exists, if not, create it
1173
+ if not os.path.isdir(path):
1174
+ os.mkdir(path)
1175
+ else:
1176
+ if not overwrite:
1177
+ # warning about overwriting
1178
+ print(f'The folder "{path}" already exists, call function with `overwrite = True` keyword argument if you\'re sure!')
1179
+ return
1180
+ if "node_file" not in kwargs:
1181
+ node_file = path + "nodes.csv.gz"
1182
+ else:
1183
+ node_file = kwargs["node_file"]
1184
+ if "edge_file" not in kwargs:
1185
+ edge_file = path + "edges.npz"
1186
+ else:
1187
+ edge_file = kwargs["edge_file"]
1188
+ if "layer_file" not in kwargs:
1189
+ layer_file = path + "layers.csv"
1190
+ else:
1191
+ layer_file = kwargs["layer_file"]
1192
+ if "codebook_file" not in kwargs:
1193
+ codebook_file = path + "codebook.csv"
1194
+
1195
+ self.export_edges(edge_file)
1196
+ self.export_nodes(node_file)
1197
+ self.export_layers(layer_file)
1198
+ self.export_codebook(codebook_file)
1199
+
1200
+ def export_layers(self, file_name):
1201
+ """
1202
+ This function exports the layer information to a csv file.
1203
+
1204
+ Parameters:
1205
+ -------------
1206
+ file_name : string
1207
+ file to write layer dataframe to. Extension should be .csv
1208
+ -------------
1209
+ """
1210
+ self.layers.to_csv(file_name, index=False, header=True)
1211
+
1212
+ def export_codebook(self, file_name):
1213
+ """
1214
+ This function exports the codebook to a csv file.
1215
+
1216
+ Parameters:
1217
+ -------------
1218
+ file_name : string
1219
+ file to write codebook to. Extension should be .csv
1220
+ -------------
1221
+ """
1222
+ if self.codebook is not None:
1223
+ self.codebook.to_csv(file_name, index=False, header=True)
1224
+
1225
+ def get_egonetwork(self, ego_label, depth=1, return_list=False, ignore_limit=False):
1226
+ """
1227
+ Starting out from a given node, this function returns the network at a
1228
+ given depth around that selected node.
1229
+
1230
+ Parameters:
1231
+ -----------
1232
+
1233
+ ego_label : int
1234
+ label of selected node
1235
+ depth : int, default 1
1236
+ depth in which to return network
1237
+ return_list : boolean, default False
1238
+ If False, returns an mln object representing the egonetwork
1239
+ Otherwise, a list of nodes in the egonetwork is returned (faster)
1240
+ ignore_limit : boolean, default False
1241
+ False if depth can be at most ego_depth_limit
1242
+ Set to True to ignore this limit
1243
+
1244
+ Returns
1245
+ -------
1246
+ MultiLayerNetwork instance from egonetwork or list of nodes in egonetwork
1247
+ """
1248
+
1249
+ if not ignore_limit and depth > ego_depth_limit:
1250
+ raise ValueError(f"Depth {depth} is too large for ego network." +\
1251
+ f"Please use a maximum depth of {ego_depth_limit}" +\
1252
+ f"To ignore, use ignore_limit=True")
1253
+
1254
+ ego = self.to_id(ego_label)
1255
+
1256
+ # separating the first round allows to skip the expensive for loop,
1257
+ # leading to a substantial speedup
1258
+ selected = [ego] + self.A[ego].indices.tolist()
1259
+
1260
+ # iteratively search at further steps
1261
+ for k in range(1, depth):
1262
+ next_neighbors = self.A[selected].indices
1263
+ # there might be circles when looking for new neighbors, so we take unique integer ids
1264
+ selected = np.unique(np.concatenate((selected, next_neighbors)))
1265
+
1266
+ selected = list(map(lambda x: self._map_id_to_label[x], selected))
1267
+
1268
+ # return mln or list of nodes in egonetwork
1269
+ if return_list:
1270
+ return selected
1271
+ else:
1272
+ return self.get_filtered_network(nodes_selected = selected)
1273
+
1274
+ def create_affiliation_matrix(self, key, affil_edgelist):
1275
+ """
1276
+ This method takes a node label -> affiliation bipartite edgelist, and
1277
+ creates scipy sparse representation for it, storing the mapping of
1278
+ affiliation ids in a dict.
1279
+
1280
+ Parameters:
1281
+ -----------
1282
+ key : str
1283
+ the name of the bipartite structure for storage
1284
+ affil_edgelist : list[list]
1285
+ list of 2-tuples containing bipartite edgelist
1286
+ first node in the edges should correspond to a label in the mln instance
1287
+ second node can be anything, e.g. work IDs
1288
+
1289
+ Returns
1290
+ -------
1291
+ None
1292
+
1293
+ Creates a dictionary entry with key `key` into mln.affiliation_matrix
1294
+ attribute with the following structure:
1295
+
1296
+ mln.affiliation_matrix[key] = {
1297
+ 'A': bipartite sparse adjacency matrix, mln.N times M
1298
+ 'M' : if M is the number of unique elements in the second class
1299
+ 'column_map_label_to_nid' : mapping IDs in second class to matrix column integer indices
1300
+ 'column_map_nid_to_label' : mapping column integer indices back to second class IDs
1301
+ }
1302
+ """
1303
+ if not hasattr(self,"affiliation_matrix"):
1304
+ self.affiliation_matrix = {}
1305
+
1306
+ self.affiliation_matrix[key] = {}
1307
+
1308
+ unique_affiliations = sorted(list(set([v for k,v in affil_edgelist])))
1309
+
1310
+ M = len(unique_affiliations)
1311
+
1312
+ self.affiliation_matrix[key]['column_id_to_label'] = {i:elem for i,elem in enumerate(unique_affiliations)}
1313
+ self.affiliation_matrix[key]['column_label_to_id'] = {elem:i for i,elem in enumerate(unique_affiliations)}
1314
+
1315
+ self.affiliation_matrix[key]['M'] = M
1316
+
1317
+ # col number
1318
+ i = []
1319
+ j = []
1320
+ for k,v in affil_edgelist:
1321
+ i.append(self.to_id(k))
1322
+ j.append(self.affiliation_matrix[key]['column_label_to_id'][v])
1323
+
1324
+ # sparse adjacency matrix for affiliations (work, school, region etc.)
1325
+ A = csr_matrix((np.ones(len(i)),(i,j)), shape=(self.N,M), dtype = 'int')
1326
+
1327
+ self.affiliation_matrix[key]['A'] = A
1328
+
1329
+ def get_binary_adjacency(self, dtype='int64'):
1330
+ """
1331
+ Downcast all values in self.A to a binary value. Only 1s occur in the
1332
+ resulting matrix.
1333
+
1334
+ Parameters:
1335
+ -------------
1336
+ dtype : string, default "int64"
1337
+ datatype to downcast to
1338
+ -------------
1339
+
1340
+ Returns:
1341
+ -------------
1342
+ A : sparsegraph A which is a downcasted version of self.A
1343
+
1344
+ """
1345
+ if dtype != 'int64':
1346
+ return self.A.sign().astype(dtype)
1347
+ else:
1348
+ return self.A.sign()
1349
+
1350
+ def to_id(self, labels):
1351
+ """
1352
+ This function converts a label, or list of labels to their corresponding
1353
+ ids
1354
+
1355
+ Parameters:
1356
+ -----------
1357
+ labels : int or list, no default
1358
+ A label or list of labels to convert to NIDs
1359
+ Returns:
1360
+ -----------
1361
+ ids : int or list
1362
+ ids corresponding to node ids of given labels
1363
+ """
1364
+
1365
+ if type(labels) != list:
1366
+ return self._map_label_to_id[labels]
1367
+ else:
1368
+ return [self._map_label_to_id[elem] for elem in labels]
1369
+
1370
+
1371
+ def to_label(self, ids):
1372
+ """
1373
+ This function converts an id, or list of ids to their corresponding
1374
+ labels
1375
+
1376
+ Parameters:
1377
+ -----------
1378
+ nids : int or list, no default
1379
+ An id or list of ids to convert to labels
1380
+ Returns:
1381
+ -----------
1382
+ labels : int or list
1383
+ labels corresponding to user ids of given id(s)
1384
+
1385
+ """
1386
+
1387
+ if type(ids) != list:
1388
+ return self._map_id_to_label[ids]
1389
+ else:
1390
+ return [self._map_id_to_label[elem] for elem in ids]
1391
+
1392
+ def get_degrees(self, selected_nodes=[]):
1393
+ """
1394
+ Calculate degree for selected nodes.
1395
+
1396
+ If you also want to select layers, we suggest using the get_filtered_network method first.
1397
+ """
1398
+ if len(selected_nodes)==0:
1399
+ selected_nodes = list(self.nodes["label"])
1400
+
1401
+ selected_nodes = [self.to_id(n) for n in selected_nodes]
1402
+
1403
+ return dict(zip([self.to_label(n) for n in selected_nodes], self.A[selected_nodes, :].sign().sum(axis=0).tolist()[0]))
1404
+
1405
+ def get_clustering_coefficient(self, selected_nodes=[],batchsize=100000):
1406
+ """
1407
+ Calculate clustering coefficient for selected nodes with selected layers.
1408
+
1409
+ Parameters:
1410
+ -----------
1411
+ selected_nodes : list, default None
1412
+ which nodes to compute cc for, if None, use all
1413
+ batchsize : int, default 100000
1414
+ chunks in which to split up matrix multiplication bc of memory
1415
+ issues
1416
+
1417
+ Returns:
1418
+ --------
1419
+ dict of label -> cc values
1420
+
1421
+ """
1422
+ if len(selected_nodes)==0:
1423
+ selected_nodes = list(self.nodes["label"])
1424
+
1425
+ selected_nodes = [self.to_id(n) for n in selected_nodes]
1426
+
1427
+ if batchsize > len(selected_nodes):
1428
+ batchsize = len(selected_nodes)
1429
+
1430
+ A = self.get_binary_adjacency()
1431
+
1432
+ # find all triangles
1433
+ triangles = np.empty(0, dtype=np.int64)
1434
+ # B = A @ A @ A contains the number of paths of length 3 between B[i,j]
1435
+ # so B.diagonal() contains the number of triangles:
1436
+ # paths of length 3 between a node and itself
1437
+ # // 2 as every path is found in both directions
1438
+ # B will not fit in memory, so we do this in steps
1439
+ for i in range(0, len(selected_nodes), batchsize):
1440
+ A_ = A[selected_nodes[i:i+batchsize],:]
1441
+ res = (A_ @ A @ A_.T).diagonal() // 2
1442
+ triangles = np.concatenate((triangles, res))
1443
+
1444
+ # print("Triangles",dict(zip(self.nodes["label"],triangles)))
1445
+
1446
+ A = A[selected_nodes,:]
1447
+
1448
+ # compute the denominator
1449
+ # sum of neighbor degrees, over 2
1450
+ l = comb(A.sum(axis=1), 2)
1451
+ # sum of: neighbor degrees over two
1452
+ r = csr_matrix((comb(A.data, 2), A.indices, A.indptr),(len(selected_nodes),self.N)).sum(axis=1)
1453
+ P = np.array(l - r).T[0]
1454
+
1455
+ # we ensure that division by zero errors are correctly handled and nan/inf
1456
+ # values are avoided
1457
+
1458
+ # triangles / P
1459
+ clustering_coefficient = np.divide(triangles, P, out=np.zeros(len(selected_nodes)), where=P!=0)
1460
+
1461
+ return dict(zip([self.to_label(n) for n in selected_nodes],clustering_coefficient))
1462
+
1463
+ def get_supra_adjacency_matrix(self,dtype='int64'):
1464
+ """
1465
+ Decompressing the binary format into a supra-adjacency matrix.
1466
+
1467
+ Parameters:
1468
+ ------------
1469
+ dtype : string, default "int64"
1470
+ datatype to downcast to
1471
+
1472
+ Returns
1473
+ -------
1474
+ 0/1 supra-adjacency matrix of shape (self.L*self.N,self.L*self.N)
1475
+ """
1476
+ # layer couplings
1477
+ self.sA = kron(np.ones((self.L,self.L)),eye(self.N)) - eye(self.L * self.N)
1478
+ # diagonal matrices
1479
+ d = []
1480
+ for l in self.layers["layer"]:
1481
+ if l not in self.layer_adjacency_matrix:
1482
+ d.append(self.get_layer_adjacency_matrix(layer=l))
1483
+ else:
1484
+ d.append(self.layer_adjacency_matrix[l])
1485
+ self.sA += block_diag(d)
1486
+
1487
+ if dtype!='int64':
1488
+ self.sA = self.sA.astype(dtype)
1489
+
1490
+ return self.sA
1491
+
1492
+ def get_grouped_mln(self):
1493
+ """
1494
+ Returns an MLN object with the same nodelist,
1495
+ but modified edge adjacency matrix and layer
1496
+ dataframe according to the 'group' column in
1497
+ self.layers. Combines edges whose layertype belongs to
1498
+ the group to into one single layer.
1499
+ """
1500
+ self.gA = csr_matrix((self.N,self.N),dtype=np.uint8)
1501
+ groups = self.layers["group"].unique()
1502
+
1503
+ self.g_layers = pd.DataFrame(groups,columns=["label"])
1504
+ self.g_layers["layer"] = self.g_layers.index+1
1505
+ self.g_layers["binary"] = self.g_layers.index.map(lambda i: int(2**i))
1506
+
1507
+ print(self.g_layers)
1508
+
1509
+ for g in groups:
1510
+ b = self.g_layers.set_index("label")["binary"].loc[g]
1511
+ print(f"Creating adjacency matrix for group layer {g}...")
1512
+ if g not in self.group_adjacency_matrix:
1513
+ glA = self.get_layer_adjacency_matrix(g,layer_type="group",dtype=np.uint8)
1514
+ else:
1515
+ glA = csr_matrix(self.group_adjacency_matrix[g],shape=(self.N,self.N),dtype=np.uint8)
1516
+ print("Done.")
1517
+ print(f"Adding {glA.nnz} edges to layer {g}...")
1518
+ self.gA+=glA*b
1519
+ print("Done.")
1520
+
1521
+
1522
+ return MultiLayerNetwork(
1523
+ nodes = self.nodes,
1524
+ edges = self.gA,
1525
+ layers = self.g_layers
1526
+ )
1527
+
1528
+ def get_excess_closure(self, selected_nodes=[], node_type="label", selected_layers=[], layer_type="layer", batchsize=100000):
1529
+ """
1530
+ Calculate the measure called excess closure for selected nodes and layers.
1531
+
1532
+ See Bokany et al. 2023 for more details.
1533
+ """
1534
+
1535
+ # ===================== input handling =========================
1536
+
1537
+ # handling layer input
1538
+ self.verboseprint("Handling layer input...")
1539
+ if layer_type not in ["label", "layer", "binary", "group"]:
1540
+ raise ValueError(f"Invalid layer_type '{layer_type}'. Please choose from 'label' or 'layer' or 'binary'.")
1541
+
1542
+ for layer in selected_layers:
1543
+ if layer not in self.layers[layer_type].tolist():
1544
+ raise ValueError(f"Invalid layer '{layer}' for layer_type '{layer_type}'. Please choose from {self.layers[layer_type].unique().tolist()}.")
1545
+
1546
+ if len(selected_layers) == 0:
1547
+ selected_layers = self.layers[layer_type].unique().tolist()
1548
+
1549
+ # get corresonding binary representation
1550
+ binary_repr = sum([self._layer_conversion_dict[f"{layer_type}_to_binary"][layer] for layer in selected_layers])
1551
+
1552
+ # handling node input
1553
+ if len(selected_nodes) == 0:
1554
+ selected_nodes = self.nodes["id"].tolist()
1555
+ else:
1556
+ if node_type == "label":
1557
+ selected_nodes = [self.to_id(n) for n in selected_nodes]
1558
+ elif node_type == "id":
1559
+ pass
1560
+ else:
1561
+ raise ValueError(f"Invalid node_type '{node_type}'. Please choose from 'label' or 'id'.")
1562
+ self.verboseprint("Done.")
1563
+
1564
+ # ====================== actual calculation ========================
1565
+ # obtain a copy of the sparse adjacency matrix such that each element
1566
+ # A[i,j] contains the number of links between i and j
1567
+ self.verboseprint("Getting multiplexity counts of full matrix...")
1568
+ A = deepcopy(self.A)
1569
+ A.data = A.data & binary_repr
1570
+ # this is necessary, otherwise the denominators will not be correct
1571
+ # (we're using indptr and indices to calculate neighbor degrees)
1572
+ A.eliminate_zeros()
1573
+
1574
+
1575
+ # create lookup table for mapping binary values to number of layers / multiplexity
1576
+ lookup = {}
1577
+ for num in np.unique(A.data):
1578
+ lookup[num] = bin(num).count("1")
1579
+ # apply lookup table
1580
+ A.data = np.array([lookup[x] for x in A.data])
1581
+ self.verboseprint("Done.")
1582
+
1583
+ self.verboseprint("Total number of triangles...")
1584
+ # find all triangles
1585
+ triangles = np.empty(0, dtype=np.int64)
1586
+ # B = A @ A @ A contains the number of paths of length 3 between B[i,j]
1587
+ # so B.diagonal() contains the number of triangles:
1588
+ # paths of length 3 between a node and itself
1589
+ # // 2 as every path is found in both directions
1590
+ # B will not fit in memory, so we do this in steps
1591
+ for i in range(0, len(selected_nodes), batchsize):
1592
+ self.verboseprint(f"\tBatch {i}/{len(selected_nodes)//batchsize+1}")
1593
+ start = i
1594
+ end = i + batchsize
1595
+ # if end is larger than matrix size, let it be the matrix size
1596
+ if end > len(selected_nodes):
1597
+ end = len(selected_nodes)
1598
+ A_ = A[selected_nodes[start:end], :]
1599
+ res = (A_ @ A @ A_.T).diagonal() // 2
1600
+ triangles = np.concatenate((triangles, res))
1601
+ self.verboseprint("Done.")
1602
+
1603
+ self.verboseprint("Pure triangle count...")
1604
+ # get number of triangles which only use 1 layer
1605
+ pure_triangles = np.zeros(len(selected_nodes))
1606
+ for layer in selected_layers:
1607
+ self.verboseprint(f"\tLayer {layer}...")
1608
+ t = np.empty(0, dtype=np.int64)
1609
+ lA = self.get_layer_adjacency_matrix(layer=layer, layer_type=layer_type)
1610
+ self.verboseprint("\tStart calc...")
1611
+ for i in range(0, len(selected_nodes), batchsize):
1612
+ start = i
1613
+ end = i + batchsize
1614
+ # if end is larger than matrix size, let it be the matrix size
1615
+ if end > len(selected_nodes):
1616
+ end = len(selected_nodes)
1617
+ A_ = lA[selected_nodes[start:end], :]
1618
+ res = (A_ @ lA @ A_.T).diagonal() // 2
1619
+ t = np.concatenate((t, np.array(res)))
1620
+ self.verboseprint("\tEnd calc...")
1621
+ pure_triangles += t
1622
+ self.verboseprint("Done.")
1623
+
1624
+ self.verboseprint("Calculating denominator...")
1625
+ # decrease matrix size to only selected nodes
1626
+ A = A[selected_nodes, :]
1627
+
1628
+ # compute the denominator
1629
+ # sum of neighbor degrees, over 2
1630
+ l = comb(A.sum(axis=1), 2)
1631
+ # sum of: neighbor degrees over 2
1632
+ r = csr_matrix((comb(A.data, 2), A.indices, A.indptr), (len(selected_nodes), self.N)).sum(axis=1)
1633
+ P = np.array(l - r).T[0]
1634
+ # we ensure that division by zero erros are correctly handled and nan/inf
1635
+ # values are avoided
1636
+ self.verboseprint("Done.")
1637
+
1638
+ # pure triangles / P
1639
+ Cpure = np.divide(pure_triangles, P, out=np.zeros(len(selected_nodes)), where=P!= 0)
1640
+ # triangles / P
1641
+ Cunique = np.divide(triangles, P, out=np.zeros(len(selected_nodes)), where=P!= 0)
1642
+
1643
+ # (Cunique - Cpure / 1 - Cpure)
1644
+ excess_closure = np.divide(Cunique-Cpure, 1-Cpure, out=np.zeros(len(selected_nodes)), where=(1-Cpure)!= 0)
1645
+ clustering_coefficient = Cunique
1646
+
1647
+ return {
1648
+ "clustering_coefficient": dict(zip([self.to_label(n) for n in selected_nodes], clustering_coefficient)),
1649
+ "excess_closure": dict(zip([self.to_label(n) for n in selected_nodes], excess_closure))
1650
+ }
1651
+
1652
+ def add_nodes(self,nodes_df):
1653
+ if "label" not in nodes_df:
1654
+ raise ValueError("The dataframe should contains at least a column called label.")
1655
+ M = nodes_df.shape[0]
1656
+ nodes_df["id"] = range(self.N,self.N+M)
1657
+ self.N = self.N + M
1658
+ self._map_id_to_label.update(dict(zip(nodes_df["id"],nodes_df["label"])))
1659
+ self._map_label_to_id.update(dict(zip(nodes_df["label"],nodes_df["id"])))
1660
+ self.nodes = pd.concat([self.nodes,nodes_df],axis=0)
1661
+ self.nodes.index = self.nodes["id"]
1662
+ self.A = block_diag([self.A,csr_matrix((M,M))],format="csr")
1663
+
1664
+ def remove_nodes(self,nodes):
1665
+ if any(self.nodes.index!=self.nodes.id):
1666
+ raise ValueError("Node dataframe not aligned correcly for operation.")
1667
+ mask = ~self.nodes["label"].isin(nodes)
1668
+ self.nodes = self.nodes[mask].copy()
1669
+ self.nodes.reset_index(drop=True,inplace=True)
1670
+ self.nodes["id"] = self.nodes.index
1671
+ self._map_id_to_label = dict(zip(self.nodes["id"],self.nodes["label"]))
1672
+ self._map_label_to_id = dict(zip(self.nodes["label"],self.nodes["id"]))
1673
+ self.N = mask.sum()
1674
+ self.A = self.A[mask,:][:,mask]
1675
+
1676
+
1677
+
1678
+
1679
+
1680
+
1681
+
1682
+
1683
+
1684
+