scikit-network 0.33.3__cp313-cp313-win_amd64.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.
Potentially problematic release.
This version of scikit-network might be problematic. Click here for more details.
- scikit_network-0.33.3.dist-info/METADATA +122 -0
- scikit_network-0.33.3.dist-info/RECORD +228 -0
- scikit_network-0.33.3.dist-info/WHEEL +5 -0
- scikit_network-0.33.3.dist-info/licenses/AUTHORS.rst +43 -0
- scikit_network-0.33.3.dist-info/licenses/LICENSE +34 -0
- scikit_network-0.33.3.dist-info/top_level.txt +1 -0
- sknetwork/__init__.py +21 -0
- sknetwork/base.py +67 -0
- sknetwork/classification/__init__.py +8 -0
- sknetwork/classification/base.py +142 -0
- sknetwork/classification/base_rank.py +133 -0
- sknetwork/classification/diffusion.py +134 -0
- sknetwork/classification/knn.py +139 -0
- sknetwork/classification/metrics.py +205 -0
- sknetwork/classification/pagerank.py +66 -0
- sknetwork/classification/propagation.py +152 -0
- sknetwork/classification/tests/__init__.py +1 -0
- sknetwork/classification/tests/test_API.py +30 -0
- sknetwork/classification/tests/test_diffusion.py +77 -0
- sknetwork/classification/tests/test_knn.py +23 -0
- sknetwork/classification/tests/test_metrics.py +53 -0
- sknetwork/classification/tests/test_pagerank.py +20 -0
- sknetwork/classification/tests/test_propagation.py +24 -0
- sknetwork/classification/vote.cp313-win_amd64.pyd +0 -0
- sknetwork/classification/vote.cpp +27584 -0
- sknetwork/classification/vote.pyx +56 -0
- sknetwork/clustering/__init__.py +8 -0
- sknetwork/clustering/base.py +172 -0
- sknetwork/clustering/kcenters.py +253 -0
- sknetwork/clustering/leiden.py +242 -0
- sknetwork/clustering/leiden_core.cp313-win_amd64.pyd +0 -0
- sknetwork/clustering/leiden_core.cpp +31575 -0
- sknetwork/clustering/leiden_core.pyx +124 -0
- sknetwork/clustering/louvain.py +286 -0
- sknetwork/clustering/louvain_core.cp313-win_amd64.pyd +0 -0
- sknetwork/clustering/louvain_core.cpp +31220 -0
- sknetwork/clustering/louvain_core.pyx +124 -0
- sknetwork/clustering/metrics.py +91 -0
- sknetwork/clustering/postprocess.py +66 -0
- sknetwork/clustering/propagation_clustering.py +104 -0
- sknetwork/clustering/tests/__init__.py +1 -0
- sknetwork/clustering/tests/test_API.py +38 -0
- sknetwork/clustering/tests/test_kcenters.py +60 -0
- sknetwork/clustering/tests/test_leiden.py +34 -0
- sknetwork/clustering/tests/test_louvain.py +135 -0
- sknetwork/clustering/tests/test_metrics.py +50 -0
- sknetwork/clustering/tests/test_postprocess.py +39 -0
- sknetwork/data/__init__.py +6 -0
- sknetwork/data/base.py +33 -0
- sknetwork/data/load.py +406 -0
- sknetwork/data/models.py +459 -0
- sknetwork/data/parse.py +644 -0
- sknetwork/data/test_graphs.py +84 -0
- sknetwork/data/tests/__init__.py +1 -0
- sknetwork/data/tests/test_API.py +30 -0
- sknetwork/data/tests/test_base.py +14 -0
- sknetwork/data/tests/test_load.py +95 -0
- sknetwork/data/tests/test_models.py +52 -0
- sknetwork/data/tests/test_parse.py +250 -0
- sknetwork/data/tests/test_test_graphs.py +29 -0
- sknetwork/data/tests/test_toy_graphs.py +68 -0
- sknetwork/data/timeout.py +38 -0
- sknetwork/data/toy_graphs.py +611 -0
- sknetwork/embedding/__init__.py +8 -0
- sknetwork/embedding/base.py +94 -0
- sknetwork/embedding/force_atlas.py +198 -0
- sknetwork/embedding/louvain_embedding.py +148 -0
- sknetwork/embedding/random_projection.py +135 -0
- sknetwork/embedding/spectral.py +141 -0
- sknetwork/embedding/spring.py +198 -0
- sknetwork/embedding/svd.py +359 -0
- sknetwork/embedding/tests/__init__.py +1 -0
- sknetwork/embedding/tests/test_API.py +49 -0
- sknetwork/embedding/tests/test_force_atlas.py +35 -0
- sknetwork/embedding/tests/test_louvain_embedding.py +33 -0
- sknetwork/embedding/tests/test_random_projection.py +28 -0
- sknetwork/embedding/tests/test_spectral.py +81 -0
- sknetwork/embedding/tests/test_spring.py +50 -0
- sknetwork/embedding/tests/test_svd.py +43 -0
- sknetwork/gnn/__init__.py +10 -0
- sknetwork/gnn/activation.py +117 -0
- sknetwork/gnn/base.py +181 -0
- sknetwork/gnn/base_activation.py +90 -0
- sknetwork/gnn/base_layer.py +109 -0
- sknetwork/gnn/gnn_classifier.py +305 -0
- sknetwork/gnn/layer.py +153 -0
- sknetwork/gnn/loss.py +180 -0
- sknetwork/gnn/neighbor_sampler.py +65 -0
- sknetwork/gnn/optimizer.py +164 -0
- sknetwork/gnn/tests/__init__.py +1 -0
- sknetwork/gnn/tests/test_activation.py +56 -0
- sknetwork/gnn/tests/test_base.py +75 -0
- sknetwork/gnn/tests/test_base_layer.py +37 -0
- sknetwork/gnn/tests/test_gnn_classifier.py +130 -0
- sknetwork/gnn/tests/test_layers.py +80 -0
- sknetwork/gnn/tests/test_loss.py +33 -0
- sknetwork/gnn/tests/test_neigh_sampler.py +23 -0
- sknetwork/gnn/tests/test_optimizer.py +43 -0
- sknetwork/gnn/tests/test_utils.py +41 -0
- sknetwork/gnn/utils.py +127 -0
- sknetwork/hierarchy/__init__.py +6 -0
- sknetwork/hierarchy/base.py +96 -0
- sknetwork/hierarchy/louvain_hierarchy.py +272 -0
- sknetwork/hierarchy/metrics.py +234 -0
- sknetwork/hierarchy/paris.cp313-win_amd64.pyd +0 -0
- sknetwork/hierarchy/paris.cpp +37868 -0
- sknetwork/hierarchy/paris.pyx +316 -0
- sknetwork/hierarchy/postprocess.py +350 -0
- sknetwork/hierarchy/tests/__init__.py +1 -0
- sknetwork/hierarchy/tests/test_API.py +24 -0
- sknetwork/hierarchy/tests/test_algos.py +34 -0
- sknetwork/hierarchy/tests/test_metrics.py +62 -0
- sknetwork/hierarchy/tests/test_postprocess.py +57 -0
- sknetwork/linalg/__init__.py +9 -0
- sknetwork/linalg/basics.py +37 -0
- sknetwork/linalg/diteration.cp313-win_amd64.pyd +0 -0
- sknetwork/linalg/diteration.cpp +27400 -0
- sknetwork/linalg/diteration.pyx +47 -0
- sknetwork/linalg/eig_solver.py +93 -0
- sknetwork/linalg/laplacian.py +15 -0
- sknetwork/linalg/normalizer.py +86 -0
- sknetwork/linalg/operators.py +225 -0
- sknetwork/linalg/polynome.py +76 -0
- sknetwork/linalg/ppr_solver.py +170 -0
- sknetwork/linalg/push.cp313-win_amd64.pyd +0 -0
- sknetwork/linalg/push.cpp +31072 -0
- sknetwork/linalg/push.pyx +71 -0
- sknetwork/linalg/sparse_lowrank.py +142 -0
- sknetwork/linalg/svd_solver.py +91 -0
- sknetwork/linalg/tests/__init__.py +1 -0
- sknetwork/linalg/tests/test_eig.py +44 -0
- sknetwork/linalg/tests/test_laplacian.py +18 -0
- sknetwork/linalg/tests/test_normalization.py +34 -0
- sknetwork/linalg/tests/test_operators.py +66 -0
- sknetwork/linalg/tests/test_polynome.py +38 -0
- sknetwork/linalg/tests/test_ppr.py +50 -0
- sknetwork/linalg/tests/test_sparse_lowrank.py +61 -0
- sknetwork/linalg/tests/test_svd.py +38 -0
- sknetwork/linkpred/__init__.py +2 -0
- sknetwork/linkpred/base.py +46 -0
- sknetwork/linkpred/nn.py +126 -0
- sknetwork/linkpred/tests/__init__.py +1 -0
- sknetwork/linkpred/tests/test_nn.py +27 -0
- sknetwork/log.py +19 -0
- sknetwork/path/__init__.py +5 -0
- sknetwork/path/dag.py +54 -0
- sknetwork/path/distances.py +98 -0
- sknetwork/path/search.py +31 -0
- sknetwork/path/shortest_path.py +61 -0
- sknetwork/path/tests/__init__.py +1 -0
- sknetwork/path/tests/test_dag.py +37 -0
- sknetwork/path/tests/test_distances.py +62 -0
- sknetwork/path/tests/test_search.py +40 -0
- sknetwork/path/tests/test_shortest_path.py +40 -0
- sknetwork/ranking/__init__.py +8 -0
- sknetwork/ranking/base.py +61 -0
- sknetwork/ranking/betweenness.cp313-win_amd64.pyd +0 -0
- sknetwork/ranking/betweenness.cpp +9707 -0
- sknetwork/ranking/betweenness.pyx +97 -0
- sknetwork/ranking/closeness.py +92 -0
- sknetwork/ranking/hits.py +94 -0
- sknetwork/ranking/katz.py +83 -0
- sknetwork/ranking/pagerank.py +110 -0
- sknetwork/ranking/postprocess.py +37 -0
- sknetwork/ranking/tests/__init__.py +1 -0
- sknetwork/ranking/tests/test_API.py +32 -0
- sknetwork/ranking/tests/test_betweenness.py +38 -0
- sknetwork/ranking/tests/test_closeness.py +30 -0
- sknetwork/ranking/tests/test_hits.py +20 -0
- sknetwork/ranking/tests/test_pagerank.py +62 -0
- sknetwork/ranking/tests/test_postprocess.py +26 -0
- sknetwork/regression/__init__.py +4 -0
- sknetwork/regression/base.py +61 -0
- sknetwork/regression/diffusion.py +210 -0
- sknetwork/regression/tests/__init__.py +1 -0
- sknetwork/regression/tests/test_API.py +32 -0
- sknetwork/regression/tests/test_diffusion.py +56 -0
- sknetwork/sknetwork.py +3 -0
- sknetwork/test_base.py +35 -0
- sknetwork/test_log.py +15 -0
- sknetwork/topology/__init__.py +8 -0
- sknetwork/topology/cliques.cp313-win_amd64.pyd +0 -0
- sknetwork/topology/cliques.cpp +32565 -0
- sknetwork/topology/cliques.pyx +149 -0
- sknetwork/topology/core.cp313-win_amd64.pyd +0 -0
- sknetwork/topology/core.cpp +30651 -0
- sknetwork/topology/core.pyx +90 -0
- sknetwork/topology/cycles.py +243 -0
- sknetwork/topology/minheap.cp313-win_amd64.pyd +0 -0
- sknetwork/topology/minheap.cpp +27332 -0
- sknetwork/topology/minheap.pxd +20 -0
- sknetwork/topology/minheap.pyx +109 -0
- sknetwork/topology/structure.py +194 -0
- sknetwork/topology/tests/__init__.py +1 -0
- sknetwork/topology/tests/test_cliques.py +28 -0
- sknetwork/topology/tests/test_core.py +19 -0
- sknetwork/topology/tests/test_cycles.py +65 -0
- sknetwork/topology/tests/test_structure.py +85 -0
- sknetwork/topology/tests/test_triangles.py +38 -0
- sknetwork/topology/tests/test_wl.py +72 -0
- sknetwork/topology/triangles.cp313-win_amd64.pyd +0 -0
- sknetwork/topology/triangles.cpp +8894 -0
- sknetwork/topology/triangles.pyx +151 -0
- sknetwork/topology/weisfeiler_lehman.py +133 -0
- sknetwork/topology/weisfeiler_lehman_core.cp313-win_amd64.pyd +0 -0
- sknetwork/topology/weisfeiler_lehman_core.cpp +27635 -0
- sknetwork/topology/weisfeiler_lehman_core.pyx +114 -0
- sknetwork/utils/__init__.py +7 -0
- sknetwork/utils/check.py +355 -0
- sknetwork/utils/format.py +221 -0
- sknetwork/utils/membership.py +82 -0
- sknetwork/utils/neighbors.py +115 -0
- sknetwork/utils/tests/__init__.py +1 -0
- sknetwork/utils/tests/test_check.py +190 -0
- sknetwork/utils/tests/test_format.py +63 -0
- sknetwork/utils/tests/test_membership.py +24 -0
- sknetwork/utils/tests/test_neighbors.py +41 -0
- sknetwork/utils/tests/test_tfidf.py +18 -0
- sknetwork/utils/tests/test_values.py +66 -0
- sknetwork/utils/tfidf.py +37 -0
- sknetwork/utils/values.py +76 -0
- sknetwork/visualization/__init__.py +4 -0
- sknetwork/visualization/colors.py +34 -0
- sknetwork/visualization/dendrograms.py +277 -0
- sknetwork/visualization/graphs.py +1039 -0
- sknetwork/visualization/tests/__init__.py +1 -0
- sknetwork/visualization/tests/test_dendrograms.py +53 -0
- sknetwork/visualization/tests/test_graphs.py +176 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""tests for embeddings"""
|
|
4
|
+
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from sknetwork.data.test_graphs import *
|
|
8
|
+
from sknetwork.embedding import Spectral, SVD, GSVD, Spring
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestEmbeddings(unittest.TestCase):
|
|
12
|
+
|
|
13
|
+
def setUp(self):
|
|
14
|
+
"""Algorithms by input types."""
|
|
15
|
+
self.methods = [Spectral(), GSVD(), SVD()]
|
|
16
|
+
|
|
17
|
+
def test_undirected(self):
|
|
18
|
+
adjacency = test_graph()
|
|
19
|
+
n = adjacency.shape[0]
|
|
20
|
+
|
|
21
|
+
method = Spring()
|
|
22
|
+
embedding = method.fit_transform(adjacency)
|
|
23
|
+
self.assertEqual(embedding.shape, (n, 2))
|
|
24
|
+
|
|
25
|
+
embedding = method.transform()
|
|
26
|
+
self.assertEqual(embedding.shape, (n, 2))
|
|
27
|
+
|
|
28
|
+
def test_bipartite(self):
|
|
29
|
+
for adjacency in [test_digraph(), test_bigraph()]:
|
|
30
|
+
n_row, n_col = adjacency.shape
|
|
31
|
+
|
|
32
|
+
for method in self.methods:
|
|
33
|
+
method.fit(adjacency)
|
|
34
|
+
|
|
35
|
+
self.assertEqual(method.embedding_.shape, (n_row, 2))
|
|
36
|
+
self.assertEqual(method.embedding_row_.shape, (n_row, 2))
|
|
37
|
+
self.assertEqual(method.embedding_col_.shape, (n_col, 2))
|
|
38
|
+
|
|
39
|
+
def test_disconnected(self):
|
|
40
|
+
n = 10
|
|
41
|
+
adjacency = np.eye(n)
|
|
42
|
+
for method in self.methods:
|
|
43
|
+
embedding = method.fit_transform(adjacency)
|
|
44
|
+
self.assertEqual(embedding.shape, (n, 2))
|
|
45
|
+
|
|
46
|
+
def test_regularization(self):
|
|
47
|
+
adjacency = test_graph()
|
|
48
|
+
method = Spectral()
|
|
49
|
+
self.assertEqual(method._get_regularization(-1, adjacency), 0)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""tests for force atlas2 embeddings"""
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from sknetwork.data.test_graphs import test_graph, test_digraph
|
|
9
|
+
from sknetwork.embedding.force_atlas import ForceAtlas
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestEmbeddings(unittest.TestCase):
|
|
13
|
+
|
|
14
|
+
def test_options(self):
|
|
15
|
+
for adjacency in [test_graph(), test_digraph()]:
|
|
16
|
+
n = adjacency.shape[0]
|
|
17
|
+
|
|
18
|
+
force_atlas = ForceAtlas()
|
|
19
|
+
layout = force_atlas.fit_transform(adjacency)
|
|
20
|
+
self.assertEqual((n, 2), layout.shape)
|
|
21
|
+
|
|
22
|
+
force_atlas = ForceAtlas(lin_log=True)
|
|
23
|
+
layout = force_atlas.fit_transform(adjacency)
|
|
24
|
+
self.assertEqual((n, 2), layout.shape)
|
|
25
|
+
|
|
26
|
+
force_atlas = ForceAtlas(approx_radius=1.)
|
|
27
|
+
layout = force_atlas.fit_transform(adjacency)
|
|
28
|
+
self.assertEqual((n, 2), layout.shape)
|
|
29
|
+
|
|
30
|
+
force_atlas.fit(adjacency, pos_init=layout, n_iter=1)
|
|
31
|
+
|
|
32
|
+
def test_errors(self):
|
|
33
|
+
adjacency = test_graph()
|
|
34
|
+
with self.assertRaises(ValueError):
|
|
35
|
+
ForceAtlas().fit(adjacency, pos_init=np.ones((5, 7)))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""tests for Louvain embedding"""
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from sknetwork.data.test_graphs import test_graph, test_bigraph
|
|
9
|
+
from sknetwork.embedding import LouvainEmbedding
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestLouvainEmbedding(unittest.TestCase):
|
|
13
|
+
|
|
14
|
+
def test_predict(self):
|
|
15
|
+
adjacency = test_graph()
|
|
16
|
+
adjacency_vector = np.zeros(10, dtype=int)
|
|
17
|
+
adjacency_vector[:5] = 1
|
|
18
|
+
louvain = LouvainEmbedding()
|
|
19
|
+
louvain.fit(adjacency)
|
|
20
|
+
self.assertEqual(louvain.embedding_.shape[0], 10)
|
|
21
|
+
louvain.fit(adjacency, force_bipartite=True)
|
|
22
|
+
self.assertEqual(louvain.embedding_.shape[0], 10)
|
|
23
|
+
|
|
24
|
+
# bipartite
|
|
25
|
+
biadjacency = test_bigraph()
|
|
26
|
+
louvain.fit(biadjacency)
|
|
27
|
+
self.assertEqual(louvain.embedding_row_.shape[0], 6)
|
|
28
|
+
self.assertEqual(louvain.embedding_col_.shape[0], 8)
|
|
29
|
+
|
|
30
|
+
for method in ['remove', 'merge', 'keep']:
|
|
31
|
+
louvain = LouvainEmbedding(isolated_nodes=method)
|
|
32
|
+
embedding = louvain.fit_transform(adjacency)
|
|
33
|
+
self.assertEqual(embedding.shape[0], adjacency.shape[0])
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Tests for random projection"""
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
from sknetwork.data.test_graphs import test_graph, test_bigraph, test_digraph, test_disconnected_graph
|
|
7
|
+
from sknetwork.embedding import RandomProjection
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestEmbeddings(unittest.TestCase):
|
|
11
|
+
|
|
12
|
+
def test_random_projection(self):
|
|
13
|
+
for algo in [RandomProjection(), RandomProjection(random_walk=True)]:
|
|
14
|
+
adjacency = test_graph()
|
|
15
|
+
embedding = algo.fit_transform(adjacency)
|
|
16
|
+
self.assertEqual(embedding.shape[1], 2)
|
|
17
|
+
embedding = algo.fit_transform(adjacency, force_bipartite=True)
|
|
18
|
+
self.assertEqual(embedding.shape[1], 2)
|
|
19
|
+
adjacency = test_digraph()
|
|
20
|
+
embedding = algo.fit_transform(adjacency)
|
|
21
|
+
self.assertEqual(embedding.shape[1], 2)
|
|
22
|
+
adjacency = test_disconnected_graph()
|
|
23
|
+
embedding = algo.fit_transform(adjacency)
|
|
24
|
+
self.assertEqual(embedding.shape[1], 2)
|
|
25
|
+
biadjacency = test_bigraph()
|
|
26
|
+
embedding = algo.fit_transform(biadjacency)
|
|
27
|
+
self.assertEqual(embedding.shape[1], 2)
|
|
28
|
+
self.assertEqual(algo.embedding_col_.shape[1], 2)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Tests for spectral embedding."""
|
|
4
|
+
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from sknetwork.data.test_graphs import *
|
|
8
|
+
from sknetwork.embedding import Spectral
|
|
9
|
+
from sknetwork.utils.check import is_weakly_connected
|
|
10
|
+
from sknetwork.utils.format import bipartite2undirected
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestEmbeddings(unittest.TestCase):
|
|
14
|
+
|
|
15
|
+
def test_undirected(self):
|
|
16
|
+
for adjacency in [test_graph(), test_disconnected_graph()]:
|
|
17
|
+
n = adjacency.shape[0]
|
|
18
|
+
# random walk
|
|
19
|
+
spectral = Spectral(3, normalized=False)
|
|
20
|
+
embedding = spectral.fit_transform(adjacency)
|
|
21
|
+
weights = adjacency.dot(np.ones(n))
|
|
22
|
+
if not is_weakly_connected(adjacency):
|
|
23
|
+
weights += 1
|
|
24
|
+
self.assertAlmostEqual(np.linalg.norm(embedding.T.dot(weights)), 0)
|
|
25
|
+
# Laplacian
|
|
26
|
+
spectral = Spectral(3, decomposition='laplacian', normalized=False)
|
|
27
|
+
embedding = spectral.fit_transform(adjacency)
|
|
28
|
+
self.assertAlmostEqual(np.linalg.norm(embedding.sum(axis=0)), 0)
|
|
29
|
+
|
|
30
|
+
def test_directed(self):
|
|
31
|
+
for adjacency in [test_digraph(), test_digraph().astype(bool)]:
|
|
32
|
+
# random walk
|
|
33
|
+
spectral = Spectral(3, normalized=False)
|
|
34
|
+
embedding = spectral.fit_transform(adjacency)
|
|
35
|
+
self.assertAlmostEqual(embedding.shape[0], adjacency.shape[0])
|
|
36
|
+
# Laplacian
|
|
37
|
+
spectral = Spectral(3, decomposition='laplacian', normalized=False)
|
|
38
|
+
spectral.fit(adjacency)
|
|
39
|
+
self.assertAlmostEqual(np.linalg.norm(spectral.eigenvectors_.sum(axis=0)), 0)
|
|
40
|
+
|
|
41
|
+
def test_regularization(self):
|
|
42
|
+
for adjacency in [test_graph(), test_disconnected_graph()]:
|
|
43
|
+
n = adjacency.shape[0]
|
|
44
|
+
# random walk
|
|
45
|
+
regularization = 0.1
|
|
46
|
+
spectral = Spectral(3, regularization=regularization, normalized=False)
|
|
47
|
+
embedding = spectral.fit_transform(adjacency)
|
|
48
|
+
weights = adjacency.dot(np.ones(n)) + regularization
|
|
49
|
+
self.assertAlmostEqual(np.linalg.norm(embedding.T.dot(weights)), 0)
|
|
50
|
+
# Laplacian
|
|
51
|
+
spectral = Spectral(3, decomposition='laplacian', regularization=1, normalized=False)
|
|
52
|
+
embedding = spectral.fit_transform(adjacency)
|
|
53
|
+
self.assertAlmostEqual(np.linalg.norm(embedding.sum(axis=0)), 0)
|
|
54
|
+
# without regularization
|
|
55
|
+
spectral = Spectral(3, decomposition='laplacian', regularization=-1, normalized=False)
|
|
56
|
+
embedding = spectral.fit_transform(adjacency)
|
|
57
|
+
self.assertAlmostEqual(np.linalg.norm(embedding.sum(axis=0)), 0)
|
|
58
|
+
|
|
59
|
+
def test_bipartite(self):
|
|
60
|
+
for biadjacency in [test_digraph(), test_bigraph(), test_bigraph_disconnect()]:
|
|
61
|
+
n_row, n_col = biadjacency.shape
|
|
62
|
+
adjacency = bipartite2undirected(biadjacency)
|
|
63
|
+
# random walk
|
|
64
|
+
spectral = Spectral(3, normalized=False)
|
|
65
|
+
spectral.fit(biadjacency)
|
|
66
|
+
embedding_full = np.vstack([spectral.embedding_row_, spectral.embedding_col_])
|
|
67
|
+
weights = adjacency.dot(np.ones(n_row + n_col))
|
|
68
|
+
if not is_weakly_connected(adjacency):
|
|
69
|
+
weights += 1
|
|
70
|
+
self.assertAlmostEqual(np.linalg.norm(embedding_full.T.dot(weights)), 0)
|
|
71
|
+
# Laplacian
|
|
72
|
+
spectral = Spectral(3, decomposition='laplacian', normalized=False)
|
|
73
|
+
spectral.fit(biadjacency)
|
|
74
|
+
embedding_full = np.vstack([spectral.embedding_row_, spectral.embedding_col_])
|
|
75
|
+
self.assertAlmostEqual(np.linalg.norm(embedding_full.sum(axis=0)), 0)
|
|
76
|
+
|
|
77
|
+
def test_normalization(self):
|
|
78
|
+
for adjacency in [test_graph(), test_disconnected_graph()]:
|
|
79
|
+
spectral = Spectral(3)
|
|
80
|
+
embedding = spectral.fit_transform(adjacency)
|
|
81
|
+
self.assertAlmostEqual(np.linalg.norm(np.linalg.norm(embedding, axis=1) - np.ones(adjacency.shape[0])), 0)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""tests for spring embeddings"""
|
|
4
|
+
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from sknetwork.data.test_graphs import *
|
|
8
|
+
from sknetwork.embedding import Spring
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestEmbeddings(unittest.TestCase):
|
|
12
|
+
|
|
13
|
+
def test_shape(self):
|
|
14
|
+
for adjacency in [test_graph(), test_digraph()]:
|
|
15
|
+
n = adjacency.shape[0]
|
|
16
|
+
spring = Spring()
|
|
17
|
+
layout = spring.fit_transform(adjacency)
|
|
18
|
+
self.assertEqual((n, 2), layout.shape)
|
|
19
|
+
|
|
20
|
+
spring = Spring(n_components=3)
|
|
21
|
+
layout = spring.fit_transform(adjacency)
|
|
22
|
+
self.assertEqual((n, 3), layout.shape)
|
|
23
|
+
|
|
24
|
+
def test_pos_init(self):
|
|
25
|
+
adjacency = test_graph()
|
|
26
|
+
n = adjacency.shape[0]
|
|
27
|
+
|
|
28
|
+
spring = Spring(strength=0.1, position_init='spectral', tol=1e3)
|
|
29
|
+
layout = spring.fit_transform(adjacency)
|
|
30
|
+
self.assertEqual((n, 2), layout.shape)
|
|
31
|
+
layout = spring.fit_transform(adjacency, position_init=layout)
|
|
32
|
+
self.assertEqual((n, 2), layout.shape)
|
|
33
|
+
|
|
34
|
+
def test_approx_radius(self):
|
|
35
|
+
adjacency = test_graph()
|
|
36
|
+
n = adjacency.shape[0]
|
|
37
|
+
|
|
38
|
+
spring = Spring(approx_radius=1.)
|
|
39
|
+
layout = spring.fit_transform(adjacency)
|
|
40
|
+
self.assertEqual((n, 2), layout.shape)
|
|
41
|
+
|
|
42
|
+
def test_errors(self):
|
|
43
|
+
adjacency = test_graph()
|
|
44
|
+
with self.assertRaises(ValueError):
|
|
45
|
+
Spring(position_init='toto')
|
|
46
|
+
with self.assertRaises(ValueError):
|
|
47
|
+
Spring().fit(adjacency, position_init=np.ones((2, 2)))
|
|
48
|
+
with self.assertRaises(TypeError):
|
|
49
|
+
# noinspection PyTypeChecker
|
|
50
|
+
Spring().fit(adjacency, position_init='toto')
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""tests for svd"""
|
|
4
|
+
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from sknetwork.data import star_wars
|
|
10
|
+
from sknetwork.embedding import GSVD, SVD, PCA
|
|
11
|
+
from sknetwork.linalg import LanczosSVD
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestSVD(unittest.TestCase):
|
|
15
|
+
|
|
16
|
+
def test_options(self):
|
|
17
|
+
biadjacency = star_wars(metadata=False)
|
|
18
|
+
n_row, n_col = biadjacency.shape
|
|
19
|
+
min_dim = min(n_row, n_col) - 1
|
|
20
|
+
gsvd = GSVD(n_components=5, regularization=0., solver='halko')
|
|
21
|
+
|
|
22
|
+
with self.assertWarns(Warning):
|
|
23
|
+
gsvd.fit(biadjacency)
|
|
24
|
+
self.assertEqual(gsvd.embedding_row_.shape, (n_row, min_dim))
|
|
25
|
+
self.assertEqual(gsvd.embedding_col_.shape, (n_col, min_dim))
|
|
26
|
+
|
|
27
|
+
embedding = gsvd.predict(np.array([0, 1, 1]))
|
|
28
|
+
self.assertEqual(embedding.shape, (min_dim,))
|
|
29
|
+
|
|
30
|
+
gsvd = GSVD(n_components=1, regularization=0.1, solver='lanczos')
|
|
31
|
+
gsvd.fit(biadjacency)
|
|
32
|
+
self.assertEqual(gsvd.embedding_row_.shape, (n_row, 1))
|
|
33
|
+
|
|
34
|
+
pca = PCA(n_components=min_dim, solver='lanczos')
|
|
35
|
+
pca.fit(biadjacency)
|
|
36
|
+
self.assertEqual(pca.embedding_row_.shape, (n_row, min_dim))
|
|
37
|
+
pca = PCA(n_components=min_dim, solver=LanczosSVD())
|
|
38
|
+
pca.fit(biadjacency)
|
|
39
|
+
self.assertEqual(pca.embedding_row_.shape, (n_row, min_dim))
|
|
40
|
+
|
|
41
|
+
svd = SVD(n_components=min_dim, solver=LanczosSVD())
|
|
42
|
+
svd.fit(biadjacency)
|
|
43
|
+
self.assertEqual(svd.embedding_row_.shape, (n_row, min_dim))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""gnn module"""
|
|
2
|
+
from sknetwork.gnn.base import BaseGNN
|
|
3
|
+
from sknetwork.gnn.base_activation import BaseActivation, BaseLoss
|
|
4
|
+
from sknetwork.gnn.base_layer import BaseLayer
|
|
5
|
+
from sknetwork.gnn.gnn_classifier import GNNClassifier
|
|
6
|
+
from sknetwork.gnn.layer import Convolution
|
|
7
|
+
from sknetwork.gnn.neighbor_sampler import UniformNeighborSampler
|
|
8
|
+
from sknetwork.gnn.activation import ReLu, Sigmoid, Softmax
|
|
9
|
+
from sknetwork.gnn.loss import BinaryCrossEntropy, CrossEntropy
|
|
10
|
+
from sknetwork.gnn.optimizer import BaseOptimizer, GD, ADAM
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# coding: utf-8
|
|
3
|
+
"""
|
|
4
|
+
Created in April 2022
|
|
5
|
+
@author: Simon Delarue <sdelarue@enst.fr>
|
|
6
|
+
@author: Thomas Bonald <bonald@enst.fr>
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from scipy import special
|
|
13
|
+
|
|
14
|
+
from sknetwork.gnn.base_activation import BaseActivation
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ReLu(BaseActivation):
|
|
18
|
+
"""ReLu (Rectified Linear Unit) activation function:
|
|
19
|
+
|
|
20
|
+
:math:`\\sigma(x) = \\max(0, x)`
|
|
21
|
+
"""
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super(ReLu, self).__init__('ReLu')
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def output(signal: np.ndarray) -> np.ndarray:
|
|
27
|
+
"""Output of the ReLu function."""
|
|
28
|
+
return np.maximum(signal, 0)
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def gradient(signal: np.ndarray, direction: np.ndarray) -> np.ndarray:
|
|
32
|
+
"""Gradient of the ReLu function."""
|
|
33
|
+
return direction * (signal > 0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Sigmoid(BaseActivation):
|
|
37
|
+
"""Sigmoid activation function:
|
|
38
|
+
|
|
39
|
+
:math:`\\sigma(x) = \\frac{1}{1+e^{-x}}`
|
|
40
|
+
Also known as the logistic function.
|
|
41
|
+
"""
|
|
42
|
+
def __init__(self):
|
|
43
|
+
super(Sigmoid, self).__init__('Sigmoid')
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def output(signal: np.ndarray) -> np.ndarray:
|
|
47
|
+
"""Output of the sigmoid function."""
|
|
48
|
+
return special.expit(signal)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def gradient(signal: np.ndarray, direction: np.ndarray) -> np.ndarray:
|
|
52
|
+
"""Gradient of the sigmoid function."""
|
|
53
|
+
output = Sigmoid.output(signal)
|
|
54
|
+
return output * (1 - output) * direction
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Softmax(BaseActivation):
|
|
58
|
+
"""Softmax activation function:
|
|
59
|
+
|
|
60
|
+
:math:`\\sigma(x) =
|
|
61
|
+
(\\frac{e^{x_1}}{\\sum_{i=1}^N e^{x_i})},\\ldots,\\frac{e^{x_N}}{\\sum_{i=1}^N e^{x_i})})`
|
|
62
|
+
|
|
63
|
+
where :math:`N` is the number of channels.
|
|
64
|
+
"""
|
|
65
|
+
def __init__(self):
|
|
66
|
+
super(Softmax, self).__init__('Softmax')
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def output(signal: np.ndarray) -> np.ndarray:
|
|
70
|
+
"""Output of the softmax function (rows sum to 1)."""
|
|
71
|
+
return special.softmax(signal, axis=1)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def gradient(signal: np.ndarray, direction: np.ndarray) -> np.ndarray:
|
|
75
|
+
"""Gradient of the softmax function."""
|
|
76
|
+
output = Softmax.output(signal)
|
|
77
|
+
return output * (direction.T - (output * direction).sum(axis=1)).T
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_activation(activation: Union[BaseActivation, str] = 'identity') -> BaseActivation:
|
|
81
|
+
"""Get the activation function.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
activation : Union[BaseActivation, str]
|
|
86
|
+
Activation function.
|
|
87
|
+
If a name is given, can be either ``'Identity'``, ``'Relu'``, ``'Sigmoid'`` or ``'Softmax'``.
|
|
88
|
+
If a custom activation function is given, must be of class BaseActivation.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
activation : BaseActivation
|
|
93
|
+
Activation function.
|
|
94
|
+
|
|
95
|
+
Raises
|
|
96
|
+
------
|
|
97
|
+
TypeError
|
|
98
|
+
Error raised if the input not a string or an object of class BaseActivation.
|
|
99
|
+
ValueError
|
|
100
|
+
Error raised if the name of the activation function is unknown.
|
|
101
|
+
"""
|
|
102
|
+
if issubclass(type(activation), BaseActivation):
|
|
103
|
+
return activation
|
|
104
|
+
elif type(activation) == str:
|
|
105
|
+
activation = activation.lower()
|
|
106
|
+
if activation in ['identity', '']:
|
|
107
|
+
return BaseActivation()
|
|
108
|
+
elif activation == 'relu':
|
|
109
|
+
return ReLu()
|
|
110
|
+
elif activation == 'sigmoid':
|
|
111
|
+
return Sigmoid()
|
|
112
|
+
elif activation == 'softmax':
|
|
113
|
+
return Softmax()
|
|
114
|
+
else:
|
|
115
|
+
raise ValueError("Activation must be either \"Identity\", \"ReLu\", \"Sigmoid\" or \"Softmax\".")
|
|
116
|
+
else:
|
|
117
|
+
raise TypeError("Activation must be a string or an object of type \"BaseActivation\".")
|
sknetwork/gnn/base.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Created on July 2022
|
|
5
|
+
@author: Simon Delarue <sdelarue@enst.fr>
|
|
6
|
+
@author: Thomas Bonald <bonald@enst.fr>
|
|
7
|
+
"""
|
|
8
|
+
from abc import ABC
|
|
9
|
+
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from collections import defaultdict
|
|
14
|
+
from scipy import sparse
|
|
15
|
+
|
|
16
|
+
from sknetwork.gnn.loss import BaseLoss, get_loss
|
|
17
|
+
from sknetwork.gnn.optimizer import BaseOptimizer, get_optimizer
|
|
18
|
+
from sknetwork.base import Algorithm
|
|
19
|
+
from sknetwork.log import Log
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseGNN(ABC, Algorithm, Log):
|
|
23
|
+
"""Base class for GNNs.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
loss : str or custom loss (default = ``'Cross entropy'``)
|
|
28
|
+
Loss function.
|
|
29
|
+
optimizer : str or custom optimizer (default = ``'Adam'``)
|
|
30
|
+
Optimizer used for training.
|
|
31
|
+
|
|
32
|
+
* ``'Adam'``, a stochastic gradient-based optimizer.
|
|
33
|
+
* ``'GD'``, gradient descent.
|
|
34
|
+
learning_rate : float
|
|
35
|
+
Learning rate.
|
|
36
|
+
verbose : bool
|
|
37
|
+
Verbose mode
|
|
38
|
+
|
|
39
|
+
Attributes
|
|
40
|
+
----------
|
|
41
|
+
layers: list
|
|
42
|
+
List of layers.
|
|
43
|
+
labels_: np.ndarray
|
|
44
|
+
Predicted labels.
|
|
45
|
+
history_: dict
|
|
46
|
+
Training history per epoch: {'embedding', 'loss', 'train_accuracy', 'test_accuracy'}.
|
|
47
|
+
"""
|
|
48
|
+
def __init__(self, loss: Union[BaseLoss, str] = 'CrossEntropy', optimizer: Union[BaseOptimizer, str] = 'Adam',
|
|
49
|
+
learning_rate: float = 0.01, verbose: bool = False):
|
|
50
|
+
Log.__init__(self, verbose)
|
|
51
|
+
self.optimizer = get_optimizer(optimizer, learning_rate)
|
|
52
|
+
self.loss = get_loss(loss)
|
|
53
|
+
self.layers = []
|
|
54
|
+
self.derivative_weight = []
|
|
55
|
+
self.derivative_bias = []
|
|
56
|
+
self.train_mask = None
|
|
57
|
+
self.test_mask = None
|
|
58
|
+
self.val_mask = None
|
|
59
|
+
self.embedding_ = None
|
|
60
|
+
self.output_ = None
|
|
61
|
+
self.labels_ = None
|
|
62
|
+
self.history_ = defaultdict(list)
|
|
63
|
+
|
|
64
|
+
def fit(self, *args, **kwargs):
|
|
65
|
+
"""Fit Algorithm to the data."""
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
def predict(self):
|
|
69
|
+
"""Return the predicted labels."""
|
|
70
|
+
return self.labels_
|
|
71
|
+
|
|
72
|
+
def fit_predict(self, *args, **kwargs) -> np.ndarray:
|
|
73
|
+
"""Fit algorithm to the data and return the labels. Same parameters as the ``fit`` method.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
labels : np.ndarray
|
|
78
|
+
Labels of the nodes.
|
|
79
|
+
"""
|
|
80
|
+
self.fit(*args, **kwargs)
|
|
81
|
+
return self.predict()
|
|
82
|
+
|
|
83
|
+
def predict_proba(self):
|
|
84
|
+
"""Return the probability distribution over labels."""
|
|
85
|
+
probs = self.output_
|
|
86
|
+
if probs is not None:
|
|
87
|
+
if probs.shape[1] == 1:
|
|
88
|
+
probs = np.vstack(1 - probs, probs)
|
|
89
|
+
return probs
|
|
90
|
+
|
|
91
|
+
def fit_predict_proba(self, *args, **kwargs) -> np.ndarray:
|
|
92
|
+
"""Fit algorithm to the data and return the distribution over labels. Same parameters as the ``fit`` method.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
probs : np.ndarray
|
|
97
|
+
Probability distribution over labels.
|
|
98
|
+
"""
|
|
99
|
+
self.fit(*args, **kwargs)
|
|
100
|
+
return self.predict_proba()
|
|
101
|
+
|
|
102
|
+
def transform(self):
|
|
103
|
+
"""Return the embedding of nodes."""
|
|
104
|
+
return self.embedding_
|
|
105
|
+
|
|
106
|
+
def fit_transform(self, *args, **kwargs) -> np.ndarray:
|
|
107
|
+
"""Fit algorithm to the data and return the embedding of the nodes. Same parameters as the ``fit`` method.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
embedding : np.ndarray
|
|
112
|
+
Embedding of the nodes.
|
|
113
|
+
"""
|
|
114
|
+
self.fit(*args, **kwargs)
|
|
115
|
+
return self.transform()
|
|
116
|
+
|
|
117
|
+
def backward(self, features: sparse.csr_matrix, labels: np.ndarray, mask: np.ndarray):
|
|
118
|
+
"""Compute backpropagation.
|
|
119
|
+
|
|
120
|
+
Parameters
|
|
121
|
+
----------
|
|
122
|
+
features : sparse.csr_matrix
|
|
123
|
+
Features, array of shape (n_nodes, n_features).
|
|
124
|
+
labels : np.ndarray
|
|
125
|
+
Labels, array of shape (n_nodes,).
|
|
126
|
+
mask: np.ndarray
|
|
127
|
+
Boolean mask, array of shape (n_nodes,).
|
|
128
|
+
"""
|
|
129
|
+
derivative_weight = []
|
|
130
|
+
derivative_bias = []
|
|
131
|
+
|
|
132
|
+
# discard missing labels
|
|
133
|
+
mask = mask & (labels >= 0)
|
|
134
|
+
labels = labels[mask]
|
|
135
|
+
|
|
136
|
+
# backpropagation
|
|
137
|
+
n_layers = len(self.layers)
|
|
138
|
+
layers_reverse: list = list(reversed(self.layers))
|
|
139
|
+
signal = layers_reverse[0].embedding
|
|
140
|
+
signal = signal[mask]
|
|
141
|
+
gradient = layers_reverse[0].activation.loss_gradient(signal, labels)
|
|
142
|
+
|
|
143
|
+
for i in range(n_layers):
|
|
144
|
+
if i < n_layers - 1:
|
|
145
|
+
signal = layers_reverse[i + 1].output
|
|
146
|
+
else:
|
|
147
|
+
signal = features
|
|
148
|
+
signal = signal[mask]
|
|
149
|
+
|
|
150
|
+
derivative_weight.append(signal.T.dot(gradient))
|
|
151
|
+
derivative_bias.append(np.mean(gradient, axis=0, keepdims=True))
|
|
152
|
+
|
|
153
|
+
if i < n_layers - 1:
|
|
154
|
+
signal = layers_reverse[i + 1].embedding
|
|
155
|
+
signal = signal[mask]
|
|
156
|
+
direction = layers_reverse[i].weight.dot(gradient.T).T
|
|
157
|
+
gradient = layers_reverse[i + 1].activation.gradient(signal, direction)
|
|
158
|
+
|
|
159
|
+
self.derivative_weight = list(reversed(derivative_weight))
|
|
160
|
+
self.derivative_bias = list(reversed(derivative_bias))
|
|
161
|
+
|
|
162
|
+
def _check_fitted(self):
|
|
163
|
+
if self.output_ is None:
|
|
164
|
+
raise ValueError("This embedding instance is not fitted yet. "
|
|
165
|
+
"Call 'fit' with appropriate arguments before using this method.")
|
|
166
|
+
else:
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def __repr__(self) -> str:
|
|
170
|
+
"""String representation of the `GNN`, layers by layers.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
str
|
|
175
|
+
String representation of object.
|
|
176
|
+
"""
|
|
177
|
+
string = f'{self.__class__.__name__}(\n'
|
|
178
|
+
for layer in self.layers:
|
|
179
|
+
string += f' {layer}\n'
|
|
180
|
+
string += ')'
|
|
181
|
+
return string
|