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 +1684 -0
- mlnlib/preparation.py +517 -0
- mlnlib-0.2.35.dist-info/LICENSE.md +40 -0
- mlnlib-0.2.35.dist-info/METADATA +126 -0
- mlnlib-0.2.35.dist-info/RECORD +7 -0
- mlnlib-0.2.35.dist-info/WHEEL +5 -0
- mlnlib-0.2.35.dist-info/top_level.txt +1 -0
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
|
+
|