netgamut 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. netgamut/__init__.py +486 -0
  2. netgamut/__main__.py +349 -0
  3. netgamut/_core/__init__.py +74 -0
  4. netgamut/_core/cache.py +373 -0
  5. netgamut/_core/columns.py +308 -0
  6. netgamut/_core/domains.py +285 -0
  7. netgamut/_core/download.py +282 -0
  8. netgamut/_core/formats.py +246 -0
  9. netgamut/_core/hypergraph.py +520 -0
  10. netgamut/_core/in_memory.py +184 -0
  11. netgamut/_core/models.py +52 -0
  12. netgamut/_core/multilayer.py +225 -0
  13. netgamut/_core/network.py +718 -0
  14. netgamut/_core/nodes.py +193 -0
  15. netgamut/_core/state_network.py +343 -0
  16. netgamut/_export.py +352 -0
  17. netgamut/ahorn/__init__.py +291 -0
  18. netgamut/ahorn/_catalog.py +364 -0
  19. netgamut/ahorn/_models.py +252 -0
  20. netgamut/ahorn/catalog.json +3662 -0
  21. netgamut/generate/__init__.py +908 -0
  22. netgamut/generate/_flowgen.py +1957 -0
  23. netgamut/generate/_higher_order.py +315 -0
  24. netgamut/generate/_lfr/__init__.py +508 -0
  25. netgamut/generate/_lfr/_build.py +119 -0
  26. netgamut/generate/_lfr/_io.py +114 -0
  27. netgamut/generate/_lfr/_sources.py +238 -0
  28. netgamut/generate/_mlgm/LICENSE +25 -0
  29. netgamut/generate/_mlgm/__init__.py +36 -0
  30. netgamut/generate/_mlgm/_dcsbm.py +148 -0
  31. netgamut/generate/_mlgm/_partition.py +71 -0
  32. netgamut/generate/_mlgm/_tensors.py +156 -0
  33. netgamut/generate/_multilayer_gm.py +174 -0
  34. netgamut/generate/_naming.py +29 -0
  35. netgamut/generate/_overlapping_memory.py +237 -0
  36. netgamut/generate/_params.py +343 -0
  37. netgamut/generate/_partdist.py +331 -0
  38. netgamut/konect/__init__.py +451 -0
  39. netgamut/konect/_catalog.py +367 -0
  40. netgamut/konect/_copies.py +462 -0
  41. netgamut/konect/_ent.py +114 -0
  42. netgamut/konect/_models.py +263 -0
  43. netgamut/konect/_scrape.py +152 -0
  44. netgamut/konect/catalog.json +22866 -0
  45. netgamut/netzschleuder/__init__.py +332 -0
  46. netgamut/netzschleuder/_catalog.py +265 -0
  47. netgamut/netzschleuder/_models.py +240 -0
  48. netgamut/netzschleuder/catalog.json +63648 -0
  49. netgamut/ogb/__init__.py +298 -0
  50. netgamut/ogb/_catalog.py +189 -0
  51. netgamut/ogb/_models.py +196 -0
  52. netgamut/ogb/catalog.json +365 -0
  53. netgamut/paths/__init__.py +214 -0
  54. netgamut/paths/_catalog.py +161 -0
  55. netgamut/paths/_models.py +393 -0
  56. netgamut/paths/_parsers.py +328 -0
  57. netgamut/paths/catalog.json +92 -0
  58. netgamut/py.typed +0 -0
  59. netgamut/pyg/__init__.py +378 -0
  60. netgamut/pyg/_catalog.py +227 -0
  61. netgamut/pyg/_models.py +194 -0
  62. netgamut/pyg/_scrape.py +190 -0
  63. netgamut/pyg/catalog.json +1644 -0
  64. netgamut/snap/__init__.py +370 -0
  65. netgamut/snap/_catalog.py +304 -0
  66. netgamut/snap/_models.py +367 -0
  67. netgamut/snap/_scrape.py +446 -0
  68. netgamut/snap/catalog.json +5056 -0
  69. netgamut-0.4.0.dist-info/METADATA +1204 -0
  70. netgamut-0.4.0.dist-info/RECORD +73 -0
  71. netgamut-0.4.0.dist-info/WHEEL +4 -0
  72. netgamut-0.4.0.dist-info/entry_points.txt +2 -0
  73. netgamut-0.4.0.dist-info/licenses/LICENSE +21 -0
netgamut/__init__.py ADDED
@@ -0,0 +1,486 @@
1
+ """Browse, download and cache network datasets.
2
+
3
+ Each repository lives in its own subpackage:
4
+
5
+ * :mod:`netgamut.snap` -- the Stanford Large Network Dataset Collection, 109
6
+ datasets. Large, well known, and skewed to social, web and technological
7
+ systems.
8
+ * :mod:`netgamut.netzschleuder` -- networks.skewed.de, 286 networks tagged by
9
+ domain, including the biological, transport and multilayer networks SNAP has
10
+ none of.
11
+ * :mod:`netgamut.konect` -- KONECT, the Koblenz Network Collection, 300
12
+ networks described in one fixed vocabulary of format, edge type and loops.
13
+ A network Netzschleuder or SNAP also holds is listed there but used from the
14
+ other source here, so nothing is counted twice.
15
+ * :mod:`netgamut.ogb` -- Open Graph Benchmark, 12 datasets whose node
16
+ categories are *named* rather than left as anonymous codes.
17
+ * :mod:`netgamut.pyg` -- the PyTorch Geometric corpus, 99 datasets: citation,
18
+ web, social and heterophilous graphs.
19
+ * :mod:`netgamut.paths` -- curated path data (flight itineraries, navigation
20
+ trails) plus the conversion into second-order state networks for Infomap's
21
+ memory model, which no edge list can express.
22
+ * :mod:`netgamut.ahorn` -- the Aachen Higher-Order Repository of Networks, 83
23
+ hypergraphs and simplicial complexes. Its unit is a *set* of nodes rather than
24
+ a pair, so it returns a
25
+ :class:`~netgamut.Hypergraph` rather than a ``Network``.
26
+
27
+ Metadata for every source ships inside the package, so browsing is offline and
28
+ instant; only ``load()`` touches the network, and it caches into a shared
29
+ user-level directory so a dataset is fetched once per machine.
30
+
31
+ Beyond the datasets, :mod:`netgamut.generate` samples synthetic benchmark
32
+ networks with planted communities from any blueprint in the pool (requires
33
+ the ``generate`` extra).
34
+
35
+ Every network -- downloaded or generated, first-order, state or multilayer --
36
+ converts and writes the same way: ``to_networkx()``, ``to_infomap()``, and
37
+ ``to_pajek(directory)`` / ``to_link_list(directory)``, which return the path
38
+ of the file they wrote.
39
+
40
+ All sources are mapped onto one domain vocabulary, so a comparison can span
41
+ kinds of system rather than kinds of provenance::
42
+
43
+ import netgamut
44
+
45
+ netgamut.domains()
46
+ # {'Social': 201, 'Informational': 68, 'Biological': 36, ...}
47
+
48
+ for dataset in netgamut.search(domain="Biological", max_nodes=1000):
49
+ print(dataset.source, dataset.name)
50
+
51
+ Please cite the datasets you use -- each source's records carry the reference
52
+ its site asks for.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ from dataclasses import dataclass
58
+ from typing import Any
59
+
60
+ from . import ahorn, generate, konect, netzschleuder, ogb, paths, pyg, snap
61
+ from ._core.cache import (
62
+ CACHE_ENV_VAR,
63
+ CachedDataset,
64
+ CacheInfo,
65
+ cache_dir,
66
+ cache_info,
67
+ clear_cache,
68
+ set_cache_dir,
69
+ )
70
+ from ._core.columns import Columns
71
+ from ._core.domains import DOMAINS
72
+ from ._core.hypergraph import Hypergraph
73
+ from ._core.in_memory import InMemoryNetwork
74
+ from ._core.multilayer import MultilayerNetwork
75
+ from ._core.network import Network
76
+ from ._core.state_network import StateNetwork
77
+
78
+ __all__ = [
79
+ "CACHE_ENV_VAR",
80
+ "DOMAINS",
81
+ "CacheInfo",
82
+ "CachedDataset",
83
+ "Columns",
84
+ "Hypergraph",
85
+ "Match",
86
+ "MultilayerNetwork",
87
+ "Network",
88
+ "StateNetwork",
89
+ "InMemoryNetwork",
90
+ "__version__",
91
+ "ahorn",
92
+ "cache_dir",
93
+ "cache_info",
94
+ "clear_cache",
95
+ "domains",
96
+ "generate",
97
+ "konect",
98
+ "load",
99
+ "netzschleuder",
100
+ "ogb",
101
+ "paths",
102
+ "pyg",
103
+ "search",
104
+ "set_cache_dir",
105
+ "snap",
106
+ "sources",
107
+ ]
108
+
109
+ __version__ = "0.4.0"
110
+
111
+ #: Source name -> subpackage, in the order results are reported.
112
+ _SOURCES: dict[str, Any] = {
113
+ "snap": snap,
114
+ "netzschleuder": netzschleuder,
115
+ "konect": konect,
116
+ "ogb": ogb,
117
+ "pyg": pyg,
118
+ "paths": paths,
119
+ "ahorn": ahorn,
120
+ }
121
+
122
+ #: The kinds of network :class:`Match` can say yes, no or unknown to, which
123
+ #: :func:`search`, the explorer's index and the web mirror's manifest share.
124
+ _KINDS = ("weighted", "bipartite", "temporal", "multilayer", "signed", "multigraph")
125
+
126
+
127
+ def sources() -> tuple[str, ...]:
128
+ """The available data sources."""
129
+ return tuple(_SOURCES)
130
+
131
+
132
+ def _shadowed(source: str, dataset: Any) -> bool:
133
+ """Whether cross-source queries skip *dataset* for another source's copy.
134
+
135
+ Only KONECT defers: a third of it is Netzschleuder's or SNAP's data. See
136
+ :func:`netgamut.konect.duplicates`.
137
+ """
138
+ return source == "konect" and dataset.duplicate_of is not None
139
+
140
+
141
+ def _owners(name: str) -> list[str]:
142
+ """The sources holding *name*, leaving out a KONECT copy of another's."""
143
+ found = [key for key, module in _SOURCES.items() if name in module.catalog()]
144
+ if len(found) > 1 and "konect" in found and _shadowed("konect", konect.info(name)):
145
+ found.remove("konect")
146
+ return found
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class Match:
151
+ """One dataset found by a cross-source query.
152
+
153
+ Keeps the source alongside the record, because the two catalogs use
154
+ different names for the same idea and a few names appear in both.
155
+ """
156
+
157
+ source: str
158
+ dataset: Any
159
+
160
+ @property
161
+ def name(self) -> str:
162
+ name: str = self.dataset.name
163
+ return name
164
+
165
+ @property
166
+ def domains(self) -> tuple[str, ...]:
167
+ result: tuple[str, ...] = self.dataset.domains
168
+ return result
169
+
170
+ @property
171
+ def nodes(self) -> int | None:
172
+ return self.dataset.nodes # type: ignore[no-any-return]
173
+
174
+ @property
175
+ def edges(self) -> int | None:
176
+ return self.dataset.edges # type: ignore[no-any-return]
177
+
178
+ @property
179
+ def directed(self) -> bool | None:
180
+ return self.dataset.directed # type: ignore[no-any-return]
181
+
182
+ @property
183
+ def node_metadata(self) -> bool | None:
184
+ """Whether :meth:`Network.nodes` returns per-node attributes; ``None`` if unknown.
185
+
186
+ Each source answers from its own catalog, before any download: KONECT and
187
+ Netzschleuder from their file listings and analyses, SNAP from its label
188
+ files, OGB and PyG from their class labels. AHORN keeps node lines inside
189
+ the dataset file, so it cannot say.
190
+ """
191
+ value = getattr(self.dataset, "node_metadata", None)
192
+ return None if value is None else bool(value)
193
+
194
+ @property
195
+ def node_attributes(self) -> tuple[str, ...] | None:
196
+ """The per-node columns the source's catalog can name, ``None`` if unknown.
197
+
198
+ Exact for Netzschleuder, KONECT and SNAP. OGB names only its extra files
199
+ (``year``, ``species``) and PyG none, since their other column names come
200
+ with the download; ``nodes()`` returns those too.
201
+ """
202
+ value = getattr(self.dataset, "node_attributes", None)
203
+ return None if value is None else tuple(value)
204
+
205
+ @property
206
+ def loops(self) -> bool | None:
207
+ """Whether any node links to itself, where some source has checked.
208
+
209
+ KONECT checks every network; for another source's network, KONECT's
210
+ check is used when it holds the same edge list. ``None`` means unknown.
211
+ """
212
+ value = getattr(self.dataset, "loops", None)
213
+ if value is not None:
214
+ return bool(value)
215
+ return konect.loops_for(self.key)
216
+
217
+ def _flag(self, name: str) -> bool | None:
218
+ value = getattr(self.dataset, name, None)
219
+ return None if value is None else bool(value)
220
+
221
+ @property
222
+ def weighted(self) -> bool | None:
223
+ """Whether links carry weights; ``None`` where the source cannot say.
224
+
225
+ From Netzschleuder's tags, KONECT's edge type, SNAP's graph type and
226
+ AHORN's catalog. OGB, PyG and the path data cannot say.
227
+ """
228
+ return self._flag("weighted")
229
+
230
+ @property
231
+ def bipartite(self) -> bool | None:
232
+ """Whether links only join two kinds of node; ``None`` where the source cannot say.
233
+
234
+ KONECT and SNAP say so in their catalogs. For Netzschleuder it is the
235
+ site's structural check, no odd cycle, which every two-mode network
236
+ passes and so do a few trees and grids.
237
+ """
238
+ return self._flag("bipartite")
239
+
240
+ @property
241
+ def temporal(self) -> bool | None:
242
+ """Whether links carry timestamps; ``None`` where the source cannot say.
243
+
244
+ From Netzschleuder's ``Temporal`` and ``Timestamps`` tags, KONECT's
245
+ timestamp column, SNAP's graph type and AHORN's catalog.
246
+ """
247
+ return self._flag("temporal")
248
+
249
+ @property
250
+ def multilayer(self) -> bool | None:
251
+ """Whether Netzschleuder publishes it as a multilayer or multiplex network.
252
+
253
+ ``None`` for every other source, whose catalogs do not say.
254
+ """
255
+ return self._flag("multilayer")
256
+
257
+ @property
258
+ def signed(self) -> bool | None:
259
+ """Whether links are positive or negative; ``None`` where the source cannot say.
260
+
261
+ From Netzschleuder's ``Signed`` tag, KONECT's edge type and SNAP's graph type.
262
+ """
263
+ return self._flag("signed")
264
+
265
+ @property
266
+ def multigraph(self) -> bool | None:
267
+ """Whether some pair of nodes links more than once; ``None`` if the source cannot say.
268
+
269
+ From Netzschleuder's ``Multigraph`` tag and KONECT's edge type.
270
+ """
271
+ return self._flag("multigraph")
272
+
273
+ @property
274
+ def key(self) -> str:
275
+ """``"<source>/<name>"`` -- unique across sources."""
276
+ return f"{self.source}/{self.name}"
277
+
278
+ def load(self, **kwargs: Any) -> Network:
279
+ """Download this dataset via its own source."""
280
+ result: Network = _SOURCES[self.source].load(self.name, **kwargs)
281
+ return result
282
+
283
+ def to_dict(self) -> dict[str, Any]:
284
+ """A JSON-serialisable summary, the same shape for every source.
285
+
286
+ Holds the fields :func:`search` filters on plus what a reader needs to
287
+ choose and cite a dataset: its description, citations, its page at the
288
+ source (``url``) and, where the catalog records it, the original
289
+ publisher (``upstream_url``). ``weighted``, ``bipartite``, ``temporal``,
290
+ ``multilayer``, ``signed`` and ``multigraph`` are the properties of the
291
+ same names, ``None`` where the source cannot say. ``duplicate_of`` is
292
+ the key of the network a KONECT copy defers to (see
293
+ :func:`netgamut.konect.duplicates`), else ``None``.
294
+ ``subgraph_count`` is how many networks the name holds: a Netzschleuder
295
+ entry can hold several, the first few named in ``subgraphs``, and the
296
+ statistics describe the first, which :meth:`load` fetches by default.
297
+ Other sources hold one and name none.
298
+ """
299
+ dataset = self.dataset
300
+ attributes = self.node_attributes
301
+ upstream = getattr(dataset, "upstream_url", "") or getattr(dataset, "source_url", "")
302
+ return {
303
+ "key": self.key,
304
+ "source": self.source,
305
+ "name": self.name,
306
+ "title": getattr(dataset, "title", ""),
307
+ "domains": list(self.domains),
308
+ "nodes": self.nodes,
309
+ "edges": self.edges,
310
+ "directed": self.directed,
311
+ "loops": self.loops,
312
+ "node_metadata": self.node_metadata,
313
+ "node_attributes": None if attributes is None else list(attributes),
314
+ **{name: getattr(self, name) for name in _KINDS},
315
+ "subgraph_count": getattr(dataset, "subgraph_count", 1),
316
+ "subgraphs": list(getattr(dataset, "subgraphs", ())),
317
+ "description": getattr(dataset, "description", ""),
318
+ "citations": [_citation(entry) for entry in getattr(dataset, "citations", ())],
319
+ "url": dataset.url,
320
+ "upstream_url": upstream,
321
+ "duplicate_of": getattr(dataset, "duplicate_of", None),
322
+ }
323
+
324
+ def __str__(self) -> str:
325
+ shape = []
326
+ if self.nodes is not None:
327
+ shape.append(f"{self.nodes:,} nodes")
328
+ if self.edges is not None:
329
+ shape.append(f"{self.edges:,} edges")
330
+ domains = "/".join(self.domains) or "uncategorised"
331
+ tail = f" ({', '.join(shape)})" if shape else ""
332
+ return f"{self.key} [{domains}]{tail}"
333
+
334
+
335
+ def _citation(entry: Any) -> dict[str, str]:
336
+ """One citation as ``{"text", "url"}``: Netzschleuder pairs each text with a link."""
337
+ if isinstance(entry, str):
338
+ return {"text": entry, "url": ""}
339
+ text, *link = entry
340
+ return {"text": str(text), "url": str(link[0]) if link else ""}
341
+
342
+
343
+ def domains() -> dict[str, int]:
344
+ """Dataset counts per domain, summed over every source.
345
+
346
+ A dataset listed under two domains counts once in each, and a network held
347
+ by two sources counts once.
348
+ """
349
+ counts = dict.fromkeys(DOMAINS, 0)
350
+ for source, module in _SOURCES.items():
351
+ for dataset in module.datasets():
352
+ if _shadowed(source, dataset):
353
+ continue
354
+ for domain in dataset.domains:
355
+ counts[domain] += 1
356
+ return counts
357
+
358
+
359
+ def search(
360
+ query: str = "",
361
+ *,
362
+ domain: str | None = None,
363
+ source: str | None = None,
364
+ directed: bool | None = None,
365
+ max_nodes: int | None = None,
366
+ min_nodes: int | None = None,
367
+ max_edges: int | None = None,
368
+ min_edges: int | None = None,
369
+ loops: bool | None = None,
370
+ node_metadata: bool | None = None,
371
+ node_attribute: str | None = None,
372
+ weighted: bool | None = None,
373
+ bipartite: bool | None = None,
374
+ temporal: bool | None = None,
375
+ multilayer: bool | None = None,
376
+ signed: bool | None = None,
377
+ multigraph: bool | None = None,
378
+ unknown: bool = False,
379
+ ) -> tuple[Match, ...]:
380
+ """Search every source at once, filtering on the shared domain vocabulary.
381
+
382
+ Only the fields every source agrees on are accepted here. For anything
383
+ source-specific -- SNAP's ground-truth partitions, Netzschleuder's subtypes,
384
+ OGB's loadability, PyG's class -- use that source's own ``search()``.
385
+
386
+ Size filters exclude datasets whose size the source does not publish rather than
387
+ assuming anything: most PyG entries and the largest OGB ones have no counts.
388
+ ``loops`` does the same with datasets no source has checked for self-links
389
+ (see :attr:`Match.loops`), and ``node_metadata`` with datasets whose catalog
390
+ cannot say (see :attr:`Match.node_metadata`), and so do ``weighted``,
391
+ ``bipartite``, ``temporal``, ``multilayer``, ``signed`` and ``multigraph``
392
+ (see :attr:`Match.weighted` and the rest). With ``unknown=True`` a dataset
393
+ passes every filter its source cannot answer instead, so
394
+ ``search(weighted=False, unknown=True)`` keeps the networks no catalog calls
395
+ weighted. ``node_attribute`` finds
396
+ datasets with a per-node column of that name, case-insensitively, among the
397
+ names each catalog knows -- ``node_attribute="gender"`` for something to score
398
+ a partition against.
399
+
400
+ A network held by both KONECT and Netzschleuder or SNAP appears once, from
401
+ the other source; ``source="konect"`` lists KONECT's copy too.
402
+
403
+ Raises:
404
+ KeyError: Unknown *domain* or *source*.
405
+ """
406
+ if domain is not None and domain not in DOMAINS:
407
+ raise KeyError(f"Unknown domain {domain!r}. Available: {', '.join(DOMAINS)}")
408
+ if source is not None and source not in _SOURCES:
409
+ raise KeyError(f"Unknown source {source!r}. Available: {', '.join(_SOURCES)}")
410
+
411
+ needle = query.casefold().strip()
412
+ results: list[Match] = []
413
+ wanted = (weighted, bipartite, temporal, multilayer, signed, multigraph)
414
+ kinds = dict(zip(_KINDS, wanted, strict=True))
415
+
416
+ def within(value: int | None, low: int | None, high: int | None) -> bool:
417
+ if low is None and high is None:
418
+ return True
419
+ if value is None:
420
+ return unknown
421
+ return (low is None or value >= low) and (high is None or value <= high)
422
+
423
+ def says(value: bool | None, want: bool | None) -> bool:
424
+ if want is None:
425
+ return True
426
+ return unknown if value is None else value is want
427
+
428
+ for name, module in _SOURCES.items():
429
+ if source is not None and name != source:
430
+ continue
431
+ for dataset in module.datasets():
432
+ if source is None and _shadowed(name, dataset):
433
+ continue
434
+ if domain is not None and domain not in dataset.domains:
435
+ continue
436
+ text = f"{dataset.name} {getattr(dataset, 'description', '')}".casefold()
437
+ if needle and needle not in text:
438
+ continue
439
+ if not says(dataset.directed, directed):
440
+ continue
441
+ if not within(dataset.nodes, min_nodes, max_nodes):
442
+ continue
443
+ if not within(dataset.edges, min_edges, max_edges):
444
+ continue
445
+ match = Match(source=name, dataset=dataset)
446
+ if not says(match.loops, loops) or not says(match.node_metadata, node_metadata):
447
+ continue
448
+ if not all(says(getattr(match, name), want) for name, want in kinds.items()):
449
+ continue
450
+ columns = match.node_attributes
451
+ if node_attribute is not None and not (
452
+ unknown
453
+ if columns is None
454
+ else any(column.casefold() == node_attribute.casefold() for column in columns)
455
+ ):
456
+ continue
457
+ results.append(match)
458
+
459
+ return tuple(results)
460
+
461
+
462
+ def load(name: str, *, source: str | None = None, **kwargs: Any) -> Network:
463
+ """Download a dataset by name, searching every source.
464
+
465
+ Pass *source* to disambiguate. Without it, a name present in more than one
466
+ source is an error rather than an arbitrary choice -- except that KONECT's
467
+ copy of another source's network defers to that source.
468
+
469
+ Raises:
470
+ KeyError: No source has *name*, or several do and *source* was omitted.
471
+ """
472
+ if source is not None:
473
+ if source not in _SOURCES:
474
+ raise KeyError(f"Unknown source {source!r}. Available: {', '.join(_SOURCES)}")
475
+ result: Network = _SOURCES[source].load(name, **kwargs)
476
+ return result
477
+
478
+ found = _owners(name)
479
+ if not found:
480
+ raise KeyError(f"No dataset named {name!r} in any source: {', '.join(_SOURCES)}.")
481
+ if len(found) > 1:
482
+ raise KeyError(
483
+ f"{name!r} exists in several sources ({', '.join(found)}). Pass source= to choose."
484
+ )
485
+ loaded: Network = _SOURCES[found[0]].load(name, **kwargs)
486
+ return loaded