d3graph 2.9.3__tar.gz → 2.9.4__tar.gz
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.
- {d3graph-2.9.3/d3graph.egg-info → d3graph-2.9.4}/PKG-INFO +9 -1
- {d3graph-2.9.3 → d3graph-2.9.4}/README.md +8 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph/__init__.py +1 -1
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph/d3graph.py +12 -3
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph/d3js/index.html.j2 +16 -14
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph/examples.py +10 -3
- d3graph-2.9.4/d3graph/examples_rankings.py +308 -0
- d3graph-2.9.4/d3graph/examples_rankings_Pvalue.py +378 -0
- {d3graph-2.9.3 → d3graph-2.9.4/d3graph.egg-info}/PKG-INFO +9 -1
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph.egg-info/SOURCES.txt +2 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/LICENSE +0 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/MANIFEST.in +0 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph/d3js/d3.v3.js +0 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph/d3js/d3graphscript.js +0 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph/d3js/style.css +0 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph/examples_docs.py +0 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph.egg-info/dependency_links.txt +0 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph.egg-info/requires.txt +0 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/d3graph.egg-info/top_level.txt +0 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/pyproject.toml +0 -0
- {d3graph-2.9.3 → d3graph-2.9.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: d3graph
|
|
3
|
-
Version: 2.9.
|
|
3
|
+
Version: 2.9.4
|
|
4
4
|
Summary: Python package to create interactive network based on d3js.
|
|
5
5
|
Author-email: Erdogan Taskesen <erdogant@gmail.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -103,6 +103,14 @@ from d3graph import d3graph
|
|
|
103
103
|
</a>
|
|
104
104
|
</p>
|
|
105
105
|
|
|
106
|
+
|
|
107
|
+
<p align="left">
|
|
108
|
+
<a href="https://erdogant.github.io/docs/d3graph/titanic_example/index.html">
|
|
109
|
+
<img src="https://github.com/d3blocks/d3blocks/blob/main/docs/figs/d3graph/socialmedia.gif" width="1000"/>
|
|
110
|
+
</a>
|
|
111
|
+
</p>
|
|
112
|
+
|
|
113
|
+
|
|
106
114
|
<hr>
|
|
107
115
|
|
|
108
116
|
### Contributors
|
|
@@ -72,6 +72,14 @@ from d3graph import d3graph
|
|
|
72
72
|
</a>
|
|
73
73
|
</p>
|
|
74
74
|
|
|
75
|
+
|
|
76
|
+
<p align="left">
|
|
77
|
+
<a href="https://erdogant.github.io/docs/d3graph/titanic_example/index.html">
|
|
78
|
+
<img src="https://github.com/d3blocks/d3blocks/blob/main/docs/figs/d3graph/socialmedia.gif" width="1000"/>
|
|
79
|
+
</a>
|
|
80
|
+
</p>
|
|
81
|
+
|
|
82
|
+
|
|
75
83
|
<hr>
|
|
76
84
|
|
|
77
85
|
### Contributors
|
|
@@ -128,6 +128,7 @@ class d3graph:
|
|
|
128
128
|
density_grid_size: int = 60,
|
|
129
129
|
density_blur: int = 10,
|
|
130
130
|
density_opacity: float = 0.6,
|
|
131
|
+
show_controls: bool = True,
|
|
131
132
|
) -> None:
|
|
132
133
|
"""Build and show the graph.
|
|
133
134
|
|
|
@@ -207,6 +208,14 @@ class d3graph:
|
|
|
207
208
|
blocky grid.
|
|
208
209
|
density_opacity : float, (default: 0.6)
|
|
209
210
|
Maximum heatmap opacity, reached at the highest-density grid cell.
|
|
211
|
+
show_controls : bool, (default: True)
|
|
212
|
+
Whether to render the top-panel buttons (Dark Mode, Hide Edges, Show Density,
|
|
213
|
+
Save) at all. Set to False when embedding the generated HTML into an existing
|
|
214
|
+
page/app that provides its own UI chrome and doesn't need d3graph's built-in
|
|
215
|
+
controls - the buttons (and their JS wiring) are omitted entirely rather than
|
|
216
|
+
just hidden, so there's no extra DOM/clutter in the embed. The weight/component
|
|
217
|
+
sliders are controlled separately via show_slider; the Save button specifically
|
|
218
|
+
is also controlled via save_button, independent of this.
|
|
210
219
|
|
|
211
220
|
Returns
|
|
212
221
|
-------
|
|
@@ -236,6 +245,7 @@ class d3graph:
|
|
|
236
245
|
self.config['density_grid_size'] = density_grid_size
|
|
237
246
|
self.config['density_blur'] = density_blur
|
|
238
247
|
self.config['density_opacity'] = density_opacity
|
|
248
|
+
self.config['show_controls'] = show_controls
|
|
239
249
|
|
|
240
250
|
# Allow show() to override the link_tension set at __init__ time
|
|
241
251
|
if link_tension is not None:
|
|
@@ -837,7 +847,6 @@ class d3graph:
|
|
|
837
847
|
|
|
838
848
|
# Hide slider
|
|
839
849
|
show_slider = ['', ''] if self.config['show_slider'] else ['<!--', '-->']
|
|
840
|
-
show_save_button = ['', ''] if self.config['save_button'] else ['<!--', '-->']
|
|
841
850
|
# Set width and height to screen resolution if None.
|
|
842
851
|
width = 'window.screen.width' if self.config['figsize'][0] is None else self.config['figsize'][0]
|
|
843
852
|
height = 'window.screen.height' if self.config['figsize'][1] is None else self.config['figsize'][1]
|
|
@@ -864,6 +873,8 @@ class d3graph:
|
|
|
864
873
|
'density_grid_size': self.config.get('density_grid_size', 40),
|
|
865
874
|
'density_blur': self.config.get('density_blur', 8),
|
|
866
875
|
'density_opacity': self.config.get('density_opacity', 0.6),
|
|
876
|
+
'show_controls': self.config.get('show_controls', True),
|
|
877
|
+
'save_button': self.config['save_button'],
|
|
867
878
|
'node_text_inside': self.config.get('node_text_inside', False),
|
|
868
879
|
'CLICK_COMMENT': CLICK_COMMENT,
|
|
869
880
|
'CLICK_FILL': click_properties['fill'],
|
|
@@ -872,8 +883,6 @@ class d3graph:
|
|
|
872
883
|
'CLICK_STROKEW': click_properties['stroke-width'],
|
|
873
884
|
'slider_comment_start': show_slider[0],
|
|
874
885
|
'slider_comment_stop': show_slider[1],
|
|
875
|
-
'save_button_comment_start': show_save_button[0],
|
|
876
|
-
'save_button_comment_stop': show_save_button[1],
|
|
877
886
|
'SET_SLIDER': self.config['set_slider'],
|
|
878
887
|
'SUPPORT': support,
|
|
879
888
|
'background_color': self.config['background_color'],
|
|
@@ -101,17 +101,14 @@
|
|
|
101
101
|
<body style="background-color: {{ background_color }};{% if dark_mode %} color: #eee;{% endif %}"{% if dark_mode %} class="dark-mode"{% endif %}>
|
|
102
102
|
|
|
103
103
|
<!-- Top right panel with dark mode and save buttons -->
|
|
104
|
+
{% if show_controls %}
|
|
104
105
|
<div class="top-panel">
|
|
105
106
|
<button id="darkModeSwitch" class="button-switch">{% if dark_mode %}☀ Light Mode{% else %}🌙 Dark Mode{% endif %}</button>
|
|
106
107
|
<button id="edgeToggleButton" class="button-switch">Hide Edges</button>
|
|
107
108
|
<button id="densityToggleButton" class="button-switch">{% if show_density %}Hide Density{% else %}Show Density{% endif %}</button>
|
|
108
|
-
<button id="saveButton" class="button-switch">Save</button>
|
|
109
|
+
{% if save_button %}<button id="saveButton" class="button-switch">Save</button>{% endif %}
|
|
109
110
|
</div>
|
|
110
|
-
|
|
111
|
-
<!-- Create save button (removed old location) -->
|
|
112
|
-
{{ save_button_comment_start }}
|
|
113
|
-
<!-- Save button now in top-panel -->
|
|
114
|
-
{{ save_button_comment_stop }}
|
|
111
|
+
{% endif %}
|
|
115
112
|
|
|
116
113
|
<script>
|
|
117
114
|
{% include "d3.v3.js" %}
|
|
@@ -153,29 +150,33 @@
|
|
|
153
150
|
document.body.style.color = '#eee';
|
|
154
151
|
let graphBg = document.getElementById('graphContainer');
|
|
155
152
|
if (graphBg) graphBg.style.backgroundColor = '#222';
|
|
156
|
-
darkSwitch.textContent = '☀ Light Mode';
|
|
153
|
+
if (darkSwitch) darkSwitch.textContent = '☀ Light Mode';
|
|
157
154
|
} else {
|
|
158
155
|
document.body.classList.remove('dark-mode');
|
|
159
156
|
document.body.style.backgroundColor = originalBg;
|
|
160
157
|
document.body.style.color = '';
|
|
161
158
|
let graphBg = document.getElementById('graphContainer');
|
|
162
159
|
if (graphBg) graphBg.style.backgroundColor = originalBg;
|
|
163
|
-
darkSwitch.textContent = '🌙 Dark Mode';
|
|
160
|
+
if (darkSwitch) darkSwitch.textContent = '🌙 Dark Mode';
|
|
164
161
|
}
|
|
165
162
|
if (window.d3graphSetDarkMode) window.d3graphSetDarkMode(on);
|
|
166
163
|
}
|
|
167
164
|
// Set initial mode
|
|
168
165
|
setDarkMode(darkMode);
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
166
|
+
if (darkSwitch) {
|
|
167
|
+
darkSwitch.addEventListener('click', function () {
|
|
168
|
+
darkMode = !darkMode;
|
|
169
|
+
setDarkMode(darkMode);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
173
172
|
|
|
174
173
|
// Save image to svg
|
|
175
174
|
// Note: when the graph is large enough to switch to canvas-rendered edges
|
|
176
175
|
// (see canvas_edge_threshold), this export will include nodes but not edges,
|
|
177
176
|
// since edges live on a separate <canvas> element, not in the SVG DOM.
|
|
178
|
-
document.getElementById('saveButton')
|
|
177
|
+
var saveBtn = document.getElementById('saveButton');
|
|
178
|
+
if (saveBtn) {
|
|
179
|
+
saveBtn.addEventListener('click', function () {
|
|
179
180
|
var svgData = document.querySelector('svg').outerHTML;
|
|
180
181
|
var blob = new Blob([svgData], {type: "image/svg+xml;charset=utf-8"});
|
|
181
182
|
var url = URL.createObjectURL(blob);
|
|
@@ -183,7 +184,8 @@
|
|
|
183
184
|
link.href = url;
|
|
184
185
|
link.download = '{{ title }}.svg';
|
|
185
186
|
link.click();
|
|
186
|
-
|
|
187
|
+
});
|
|
188
|
+
}
|
|
187
189
|
|
|
188
190
|
|
|
189
191
|
</script>
|
|
@@ -6,7 +6,7 @@ d3 = d3graph()
|
|
|
6
6
|
# Load example data
|
|
7
7
|
df = d3.import_example('socialmedia')
|
|
8
8
|
# Slice first 10000 rows
|
|
9
|
-
df = df[0:
|
|
9
|
+
df = df[0:1000]
|
|
10
10
|
# Create adjmat
|
|
11
11
|
adjmat = vec2adjmat(source=df['source'], target=df['target'], weight=df['weight'])
|
|
12
12
|
# Update matrix with random weights
|
|
@@ -20,7 +20,14 @@ d3.graph(adjmat)
|
|
|
20
20
|
# d3.show()
|
|
21
21
|
|
|
22
22
|
# Show graph with custom specific settings
|
|
23
|
-
d3.show(density_grid_size=60,
|
|
23
|
+
d3.show(density_grid_size=60,
|
|
24
|
+
density_blur=100,
|
|
25
|
+
density_opacity=0.6,
|
|
26
|
+
dark_mode=True,
|
|
27
|
+
show_density=True,
|
|
28
|
+
show_slider=True,
|
|
29
|
+
show_controls=True,
|
|
30
|
+
)
|
|
24
31
|
|
|
25
32
|
# %%
|
|
26
33
|
from d3graph import d3graph, vec2adjmat
|
|
@@ -60,7 +67,7 @@ d3.set_path(output_path)
|
|
|
60
67
|
d3.config['filepath']
|
|
61
68
|
|
|
62
69
|
d3.graph(adjmat)
|
|
63
|
-
d3.show()
|
|
70
|
+
d3.show(show_controls=True)
|
|
64
71
|
|
|
65
72
|
# sticky=False — classic spring-back behaviour
|
|
66
73
|
d3 = d3graph(adjmat, sticky=False)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
from d3graph import d3graph, vec2adjmat
|
|
2
|
+
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import networkx as nx
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ---------------------------------------------------------
|
|
10
|
+
# Helper functions
|
|
11
|
+
# ---------------------------------------------------------
|
|
12
|
+
def scale_values(values, min_value=8, max_value=40):
|
|
13
|
+
"""Scale numeric values to an integer range."""
|
|
14
|
+
values = np.asarray(values, dtype=float)
|
|
15
|
+
|
|
16
|
+
if np.allclose(values.min(), values.max()):
|
|
17
|
+
return np.full(values.shape, min_value, dtype=int)
|
|
18
|
+
|
|
19
|
+
scaled = (
|
|
20
|
+
min_value
|
|
21
|
+
+ (values - values.min())
|
|
22
|
+
* (max_value - min_value)
|
|
23
|
+
/ (values.max() - values.min())
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
return np.round(scaled).astype(int)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def values_to_colors(values, cmap="viridis"):
|
|
30
|
+
"""Convert numeric values to hexadecimal colors."""
|
|
31
|
+
values = np.asarray(values, dtype=float)
|
|
32
|
+
|
|
33
|
+
if np.allclose(values.min(), values.max()):
|
|
34
|
+
normalized = np.zeros_like(values)
|
|
35
|
+
else:
|
|
36
|
+
normalized = (
|
|
37
|
+
(values - values.min())
|
|
38
|
+
/ (values.max() - values.min())
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
colormap = plt.get_cmap(cmap)
|
|
42
|
+
|
|
43
|
+
return [
|
|
44
|
+
"#{:02x}{:02x}{:02x}".format(
|
|
45
|
+
int(r * 255),
|
|
46
|
+
int(g * 255),
|
|
47
|
+
int(b * 255),
|
|
48
|
+
)
|
|
49
|
+
for r, g, b, _ in colormap(normalized)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------
|
|
54
|
+
# Load data
|
|
55
|
+
# ---------------------------------------------------------
|
|
56
|
+
d3 = d3graph()
|
|
57
|
+
df = d3.import_example("socialmedia")
|
|
58
|
+
|
|
59
|
+
# Slice the first 1,000 interactions
|
|
60
|
+
df = df.iloc[:10000].copy()
|
|
61
|
+
# Create adjacency matrix
|
|
62
|
+
adjmat = vec2adjmat(source=df["source"], target=df["target"], weight=df["weight"])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------
|
|
66
|
+
# Compute weighted HITS
|
|
67
|
+
# ---------------------------------------------------------
|
|
68
|
+
G = nx.from_pandas_adjacency(adjmat, create_using=nx.DiGraph)
|
|
69
|
+
hub_scores, authority_scores = nx.hits(G, max_iter=1000, tol=1e-8, normalized=True)
|
|
70
|
+
|
|
71
|
+
# Make sure scores follow exactly the adjacency-matrix order
|
|
72
|
+
nodes = adjmat.index.tolist()
|
|
73
|
+
|
|
74
|
+
scores = pd.DataFrame({
|
|
75
|
+
"node": nodes,
|
|
76
|
+
"hub_score": [hub_scores[node] for node in nodes],
|
|
77
|
+
"authority_score": [
|
|
78
|
+
authority_scores[node]
|
|
79
|
+
for node in nodes
|
|
80
|
+
],
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
scores["combined_score"] = (scores["hub_score"] + scores["authority_score"]) / 2
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------
|
|
87
|
+
# Convert scores into visual properties
|
|
88
|
+
# ---------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
# Large node = strong authority
|
|
91
|
+
node_size = scale_values(scores["authority_score"], min_value=8, max_value=100)
|
|
92
|
+
|
|
93
|
+
# Bright color = strong hub
|
|
94
|
+
node_color = values_to_colors(
|
|
95
|
+
scores["hub_score"],
|
|
96
|
+
cmap="plasma",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
node_tooltip = [
|
|
100
|
+
(
|
|
101
|
+
f"{row.node}<br>"
|
|
102
|
+
f"Authority: {row.authority_score:.4f}<br>"
|
|
103
|
+
f"Hub: {row.hub_score:.4f}<br>"
|
|
104
|
+
f"Combined: {row.combined_score:.4f}"
|
|
105
|
+
)
|
|
106
|
+
for row in scores.itertuples()
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------
|
|
111
|
+
# Create d3graph
|
|
112
|
+
# ---------------------------------------------------------
|
|
113
|
+
d3.graph(adjmat)
|
|
114
|
+
d3.set_node_properties(size=node_size, color=node_color, tooltip=node_tooltip)
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------
|
|
117
|
+
# Show graph
|
|
118
|
+
# ---------------------------------------------------------
|
|
119
|
+
d3.show(
|
|
120
|
+
density_grid_size=150,
|
|
121
|
+
density_blur=10,
|
|
122
|
+
density_opacity=0.6,
|
|
123
|
+
dark_mode=True,
|
|
124
|
+
show_density=True,
|
|
125
|
+
show_slider=True,
|
|
126
|
+
show_controls=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# %%
|
|
130
|
+
|
|
131
|
+
top_authorities = scores.sort_values("authority_score", ascending=False).head(10)
|
|
132
|
+
top_hubs = scores.sort_values("hub_score", ascending=False).head(10)
|
|
133
|
+
|
|
134
|
+
print("Top authorities")
|
|
135
|
+
print(
|
|
136
|
+
top_authorities[
|
|
137
|
+
["node", "authority_score", "hub_score"]
|
|
138
|
+
]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
print("\nTop hubs")
|
|
142
|
+
print(
|
|
143
|
+
top_hubs[
|
|
144
|
+
["node", "hub_score", "authority_score"]
|
|
145
|
+
]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# These nodes have the highest hub scores, which means they are good connectors rather than necessarily being the most influential or popular accounts.
|
|
149
|
+
|
|
150
|
+
# In HITS:
|
|
151
|
+
# Hub score answers: "Does this account point to important accounts?"
|
|
152
|
+
# Authority score answers: "Do important accounts point to this account?"
|
|
153
|
+
|
|
154
|
+
# | Node | Hub | Authority | Interpretation |
|
|
155
|
+
# | ----------------------- | -----------: | --------: | ------------------------------------------------------------------------------------ |
|
|
156
|
+
# | @Uzbekmastodon.social | **0.014656** | 0.001477 | Connects to many important accounts but is not itself frequently referenced by them. |
|
|
157
|
+
# | @you_laugh2@... | **0.013841** | 0.000501 | Acts as a curator or broadcaster, directing attention toward authoritative users. |
|
|
158
|
+
# | @alegrilmastodon.social | **0.013661** | 0.000205 | Similar role: a strong connector but not a central authority. |
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Intuition
|
|
162
|
+
# Imagine a conference:
|
|
163
|
+
# Authorities are the keynote speakers everyone refers to.
|
|
164
|
+
# Hubs are the attendees who know all the keynote speakers and introduce people to them.
|
|
165
|
+
|
|
166
|
+
# A hub doesn't have to be famous—it becomes valuable because it connects others to influential people.
|
|
167
|
+
# Why are the authority scores so small?
|
|
168
|
+
# This is perfectly normal. HITS computes the dominant eigenvectors of the graph, and the absolute values themselves have no intrinsic meaning. What matters is the ranking.
|
|
169
|
+
|
|
170
|
+
# A good way to explain this in your blog
|
|
171
|
+
|
|
172
|
+
# HITS distinguishes two different notions of importance. Authorities are users that receive attention from well-connected users,
|
|
173
|
+
# while hubs are users that actively connect to many authorities.
|
|
174
|
+
# In social networks, authorities often represent influential individuals, whereas hubs act more like curators, aggregators,
|
|
175
|
+
# or information distributors.
|
|
176
|
+
|
|
177
|
+
# This distinction is one of the main advantages of HITS over methods like PageRank, which produces a single influence score.
|
|
178
|
+
# HITS reveals different roles that users play within the network rather than collapsing everything into one ranking.
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# %%
|
|
183
|
+
# =============================================================================
|
|
184
|
+
# PAGERANKS
|
|
185
|
+
# =============================================================================
|
|
186
|
+
import networkx as nx
|
|
187
|
+
import pandas as pd
|
|
188
|
+
|
|
189
|
+
# Create directed graph from adjacency matrix
|
|
190
|
+
G = nx.from_pandas_adjacency(adjmat, create_using=nx.DiGraph)
|
|
191
|
+
|
|
192
|
+
# Compute weighted PageRank
|
|
193
|
+
pagerank = nx.pagerank(G, alpha=0.85, weight="weight")
|
|
194
|
+
|
|
195
|
+
# Convert to dataframe
|
|
196
|
+
scores = (
|
|
197
|
+
pd.DataFrame({
|
|
198
|
+
"node": list(pagerank.keys()),
|
|
199
|
+
"pagerank": list(pagerank.values())
|
|
200
|
+
})
|
|
201
|
+
.sort_values("pagerank", ascending=False)
|
|
202
|
+
.reset_index(drop=True)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
print(scores.head(10))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# Node size
|
|
209
|
+
node_size = scale_values(
|
|
210
|
+
scores["pagerank"],
|
|
211
|
+
min_value=8,
|
|
212
|
+
max_value=45,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Node color
|
|
216
|
+
node_color = values_to_colors(
|
|
217
|
+
scores["pagerank"],
|
|
218
|
+
cmap="plasma",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
node_tooltip = [
|
|
222
|
+
f"{row.node}<br>PageRank: {row.pagerank:.5f}"
|
|
223
|
+
for row in scores.itertuples()
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
d3.graph(adjmat)
|
|
227
|
+
|
|
228
|
+
d3.set_node_properties(
|
|
229
|
+
size=node_size,
|
|
230
|
+
color=node_color,
|
|
231
|
+
tooltip=node_tooltip,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
d3.show(
|
|
235
|
+
density_grid_size=150,
|
|
236
|
+
density_blur=10,
|
|
237
|
+
density_opacity=0.6,
|
|
238
|
+
dark_mode=True,
|
|
239
|
+
show_density=True,
|
|
240
|
+
show_slider=True,
|
|
241
|
+
show_controls=True,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# %%
|
|
245
|
+
# For a social network, permute the edges while preserving the number of nodes, or even better, preserve the degree distribution if you want a stronger null model. For a blog, simple edge permutation is easy to explain.
|
|
246
|
+
|
|
247
|
+
# 1 Compute the observed PageRank.
|
|
248
|
+
# 2 Destroy the network structure by random permutation.
|
|
249
|
+
# 3 Compute PageRank again.
|
|
250
|
+
# 4 Repeat many times (e.g., 1000).
|
|
251
|
+
# 5 This gives a null distribution for every node.
|
|
252
|
+
# 6 Use distfit to estimate the null distribution (or use the empirical distribution directly).
|
|
253
|
+
# 7 Compute p-values and FDR.
|
|
254
|
+
|
|
255
|
+
G = nx.from_pandas_adjacency(adjmat, create_using=nx.DiGraph)
|
|
256
|
+
pagerank_obs = nx.pagerank(G, alpha=0.85, weight="weight")
|
|
257
|
+
nodes = list(adjmat.index)
|
|
258
|
+
pagerank_obs = np.array([pagerank_obs[n] for n in nodes])
|
|
259
|
+
|
|
260
|
+
# %%
|
|
261
|
+
# =============================================================================
|
|
262
|
+
# PERMUTE
|
|
263
|
+
# =============================================================================
|
|
264
|
+
|
|
265
|
+
n_perm = 100
|
|
266
|
+
pagerank_null = np.zeros((n_perm, len(nodes)))
|
|
267
|
+
adj = adjmat.values.copy()
|
|
268
|
+
|
|
269
|
+
for i in range(n_perm):
|
|
270
|
+
# Randomize edge locations
|
|
271
|
+
shuffled = adj.flatten().copy()
|
|
272
|
+
np.random.shuffle(shuffled)
|
|
273
|
+
shuffled = shuffled.reshape(adj.shape)
|
|
274
|
+
G_perm = nx.from_numpy_array(shuffled, create_using=nx.DiGraph)
|
|
275
|
+
pr = nx.pagerank(G_perm, alpha=0.85, weight="weight")
|
|
276
|
+
pagerank_null[i] = np.array([pr[k] for k in range(len(nodes))])
|
|
277
|
+
|
|
278
|
+
# %%
|
|
279
|
+
# =============================================================================
|
|
280
|
+
# Use distfit to estimate the null distribution (or use the empirical distribution directly).
|
|
281
|
+
# =============================================================================
|
|
282
|
+
from distfit import distfit
|
|
283
|
+
|
|
284
|
+
results = []
|
|
285
|
+
|
|
286
|
+
for i, node in enumerate(nodes):
|
|
287
|
+
dfit = distfit(verbose=0)
|
|
288
|
+
dfit.fit_transform(pagerank_null[:, i])
|
|
289
|
+
p = dfit.predict(pagerank_obs[i])["y_proba"]
|
|
290
|
+
results.append([node, pagerank_obs[i], p, dfit.model["name"]])
|
|
291
|
+
|
|
292
|
+
df = pd.DataFrame(results, columns=["node", "pagerank", "pvalue", "distribution"])
|
|
293
|
+
|
|
294
|
+
# %%
|
|
295
|
+
node = 0
|
|
296
|
+
|
|
297
|
+
dfit = distfit()
|
|
298
|
+
dfit.fit_transform(pagerank_null[:, node])
|
|
299
|
+
|
|
300
|
+
dfit.plot()
|
|
301
|
+
dfit.plot_summary()
|
|
302
|
+
node = 0
|
|
303
|
+
|
|
304
|
+
dfit = distfit()
|
|
305
|
+
dfit.fit_transform(pagerank_null[:, node])
|
|
306
|
+
|
|
307
|
+
dfit.plot()
|
|
308
|
+
dfit.plot_summary()
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import networkx as nx
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from distfit import distfit
|
|
6
|
+
from scipy import stats
|
|
7
|
+
from statsmodels.stats.multitest import multipletests
|
|
8
|
+
|
|
9
|
+
from d3graph import d3graph, vec2adjmat
|
|
10
|
+
|
|
11
|
+
import matplotlib.pyplot as plt
|
|
12
|
+
|
|
13
|
+
# ---------------------------------------------------------
|
|
14
|
+
# Helper functions
|
|
15
|
+
# ---------------------------------------------------------
|
|
16
|
+
def scale_values(values, min_value=8, max_value=40):
|
|
17
|
+
"""Scale numeric values to an integer range."""
|
|
18
|
+
values = np.asarray(values, dtype=float)
|
|
19
|
+
|
|
20
|
+
if np.allclose(values.min(), values.max()):
|
|
21
|
+
return np.full(values.shape, min_value, dtype=int)
|
|
22
|
+
|
|
23
|
+
scaled = (
|
|
24
|
+
min_value
|
|
25
|
+
+ (values - values.min())
|
|
26
|
+
* (max_value - min_value)
|
|
27
|
+
/ (values.max() - values.min())
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
return np.round(scaled).astype(int)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def values_to_colors(values, cmap="viridis"):
|
|
34
|
+
"""Convert numeric values to hexadecimal colors."""
|
|
35
|
+
values = np.asarray(values, dtype=float)
|
|
36
|
+
|
|
37
|
+
if np.allclose(values.min(), values.max()):
|
|
38
|
+
normalized = np.zeros_like(values)
|
|
39
|
+
else:
|
|
40
|
+
normalized = (
|
|
41
|
+
(values - values.min())
|
|
42
|
+
/ (values.max() - values.min())
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
colormap = plt.get_cmap(cmap)
|
|
46
|
+
|
|
47
|
+
return [
|
|
48
|
+
"#{:02x}{:02x}{:02x}".format(
|
|
49
|
+
int(r * 255),
|
|
50
|
+
int(g * 255),
|
|
51
|
+
int(b * 255),
|
|
52
|
+
)
|
|
53
|
+
for r, g, b, _ in colormap(normalized)
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def pagerank_permutation_test(
|
|
59
|
+
adjmat,
|
|
60
|
+
n_perm=1000,
|
|
61
|
+
alpha=0.85,
|
|
62
|
+
swaps_per_edge=10,
|
|
63
|
+
random_state=42,
|
|
64
|
+
fit_distributions=True,
|
|
65
|
+
verbose=0,
|
|
66
|
+
):
|
|
67
|
+
"""Test PageRank scores using directed degree-preserving permutations.
|
|
68
|
+
|
|
69
|
+
The null model preserves:
|
|
70
|
+
- number of nodes
|
|
71
|
+
- number of edges
|
|
72
|
+
- in-degree of every node
|
|
73
|
+
- out-degree of every node
|
|
74
|
+
- global edge-weight distribution
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
adjmat : pandas.DataFrame
|
|
79
|
+
Directed adjacency matrix. Rows are sources and columns are targets.
|
|
80
|
+
|
|
81
|
+
n_perm : int, default=1000
|
|
82
|
+
Number of randomized networks.
|
|
83
|
+
|
|
84
|
+
alpha : float, default=0.85
|
|
85
|
+
PageRank damping parameter.
|
|
86
|
+
|
|
87
|
+
swaps_per_edge : int, default=10
|
|
88
|
+
Requested edge swaps per edge for each randomized graph.
|
|
89
|
+
|
|
90
|
+
random_state : int, default=42
|
|
91
|
+
Random seed.
|
|
92
|
+
|
|
93
|
+
fit_distributions : bool, default=True
|
|
94
|
+
Fit a parametric null distribution with distfit for each node.
|
|
95
|
+
|
|
96
|
+
verbose : int, default=0
|
|
97
|
+
Print progress when larger than zero.
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
results : pandas.DataFrame
|
|
102
|
+
Observed PageRank, empirical p-values, fitted p-values and FDR values.
|
|
103
|
+
|
|
104
|
+
pagerank_null : pandas.DataFrame
|
|
105
|
+
Null PageRank values. Rows are permutations and columns are nodes.
|
|
106
|
+
|
|
107
|
+
fitted_models : dict
|
|
108
|
+
Fitted distfit objects keyed by node.
|
|
109
|
+
"""
|
|
110
|
+
if not isinstance(adjmat, pd.DataFrame):
|
|
111
|
+
raise TypeError("adjmat must be a pandas DataFrame.")
|
|
112
|
+
|
|
113
|
+
if adjmat.shape[0] != adjmat.shape[1]:
|
|
114
|
+
raise ValueError("adjmat must be square.")
|
|
115
|
+
|
|
116
|
+
if not adjmat.index.equals(adjmat.columns):
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"The row and column labels of adjmat must have the same order."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
rng = np.random.default_rng(random_state)
|
|
122
|
+
|
|
123
|
+
# Build observed graph
|
|
124
|
+
G = nx.from_pandas_adjacency(
|
|
125
|
+
adjmat,
|
|
126
|
+
create_using=nx.DiGraph,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Remove zero-weight edges and self-loops
|
|
130
|
+
zero_edges = [
|
|
131
|
+
(source, target)
|
|
132
|
+
for source, target, data in G.edges(data=True)
|
|
133
|
+
if data.get("weight", 0) <= 0
|
|
134
|
+
]
|
|
135
|
+
G.remove_edges_from(zero_edges)
|
|
136
|
+
G.remove_edges_from(nx.selfloop_edges(G))
|
|
137
|
+
|
|
138
|
+
nodes = list(adjmat.index)
|
|
139
|
+
n_edges = G.number_of_edges()
|
|
140
|
+
|
|
141
|
+
if n_edges < 3:
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"At least three directed edges are required for directed edge swaps."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Observed PageRank
|
|
147
|
+
observed_dict = nx.pagerank(
|
|
148
|
+
G,
|
|
149
|
+
alpha=alpha,
|
|
150
|
+
weight="weight",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
observed = np.array(
|
|
154
|
+
[observed_dict[node] for node in nodes],
|
|
155
|
+
dtype=float,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Preserve the global edge-weight distribution
|
|
159
|
+
original_weights = np.array(
|
|
160
|
+
[
|
|
161
|
+
data.get("weight", 1.0)
|
|
162
|
+
for _, _, data in G.edges(data=True)
|
|
163
|
+
],
|
|
164
|
+
dtype=float,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
pagerank_null = np.full(
|
|
168
|
+
shape=(n_perm, len(nodes)),
|
|
169
|
+
fill_value=np.nan,
|
|
170
|
+
dtype=float,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
nswap = max(1, swaps_per_edge * n_edges)
|
|
174
|
+
max_tries = max(100, nswap * 20)
|
|
175
|
+
|
|
176
|
+
for permutation in range(n_perm):
|
|
177
|
+
G_null = G.copy()
|
|
178
|
+
|
|
179
|
+
# Topology only: remove weights before rewiring
|
|
180
|
+
for source, target in G_null.edges():
|
|
181
|
+
G_null[source][target]["weight"] = 1.0
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
nx.directed_edge_swap(
|
|
185
|
+
G_null,
|
|
186
|
+
nswap=nswap,
|
|
187
|
+
max_tries=max_tries,
|
|
188
|
+
seed=int(rng.integers(0, 2**32 - 1)),
|
|
189
|
+
)
|
|
190
|
+
except nx.NetworkXAlgorithmError:
|
|
191
|
+
# Dense or highly constrained networks may not permit all swaps.
|
|
192
|
+
# Retry with fewer requested swaps.
|
|
193
|
+
nx.directed_edge_swap(
|
|
194
|
+
G_null,
|
|
195
|
+
nswap=max(1, nswap // 10),
|
|
196
|
+
max_tries=max_tries,
|
|
197
|
+
seed=int(rng.integers(0, 2**32 - 1)),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Randomly assign original weights to the rewired edges
|
|
201
|
+
shuffled_weights = rng.permutation(original_weights)
|
|
202
|
+
|
|
203
|
+
for edge, weight in zip(G_null.edges(), shuffled_weights):
|
|
204
|
+
source, target = edge
|
|
205
|
+
G_null[source][target]["weight"] = float(weight)
|
|
206
|
+
|
|
207
|
+
pr_null = nx.pagerank(
|
|
208
|
+
G_null,
|
|
209
|
+
alpha=alpha,
|
|
210
|
+
weight="weight",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
pagerank_null[permutation, :] = [
|
|
214
|
+
pr_null[node] for node in nodes
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
if verbose and (permutation + 1) % 100 == 0:
|
|
218
|
+
print(
|
|
219
|
+
f"Completed {permutation + 1}/{n_perm} permutations"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# Empirical upper-tail permutation p-value.
|
|
223
|
+
#
|
|
224
|
+
# Adding 1 prevents a zero p-value:
|
|
225
|
+
# p = (number of null scores >= observed + 1) / (n_perm + 1)
|
|
226
|
+
empirical_pvalue = (
|
|
227
|
+
1
|
|
228
|
+
+ np.sum(
|
|
229
|
+
pagerank_null >= observed[np.newaxis, :],
|
|
230
|
+
axis=0,
|
|
231
|
+
)
|
|
232
|
+
) / (n_perm + 1)
|
|
233
|
+
|
|
234
|
+
null_mean = np.nanmean(pagerank_null, axis=0)
|
|
235
|
+
null_std = np.nanstd(
|
|
236
|
+
pagerank_null,
|
|
237
|
+
axis=0,
|
|
238
|
+
ddof=1,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
null_zscore = np.divide(
|
|
242
|
+
observed - null_mean,
|
|
243
|
+
null_std,
|
|
244
|
+
out=np.full_like(observed, np.nan),
|
|
245
|
+
where=null_std > 0,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
results = pd.DataFrame({
|
|
249
|
+
"node": nodes,
|
|
250
|
+
"pagerank": observed,
|
|
251
|
+
"null_mean": null_mean,
|
|
252
|
+
"null_std": null_std,
|
|
253
|
+
"zscore": null_zscore,
|
|
254
|
+
"pvalue_empirical": empirical_pvalue,
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
fitted_models = {}
|
|
258
|
+
|
|
259
|
+
if fit_distributions:
|
|
260
|
+
distribution_names = []
|
|
261
|
+
fitted_pvalues = []
|
|
262
|
+
|
|
263
|
+
for node_index, node in enumerate(nodes):
|
|
264
|
+
null_values = pagerank_null[:, node_index]
|
|
265
|
+
null_values = null_values[np.isfinite(null_values)]
|
|
266
|
+
|
|
267
|
+
dfit = distfit(verbose=0)
|
|
268
|
+
dfit.fit_transform(null_values)
|
|
269
|
+
|
|
270
|
+
fitted_models[node] = dfit
|
|
271
|
+
|
|
272
|
+
model_name = dfit.model["name"]
|
|
273
|
+
model_params = dfit.model["params"]
|
|
274
|
+
|
|
275
|
+
distribution = getattr(stats, model_name)
|
|
276
|
+
|
|
277
|
+
# One-sided upper-tail probability:
|
|
278
|
+
# probability of observing this PageRank or a larger one.
|
|
279
|
+
pvalue = distribution.sf(
|
|
280
|
+
observed[node_index],
|
|
281
|
+
*model_params,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
distribution_names.append(model_name)
|
|
285
|
+
fitted_pvalues.append(float(pvalue))
|
|
286
|
+
|
|
287
|
+
results["distribution"] = distribution_names
|
|
288
|
+
results["pvalue_fitted"] = fitted_pvalues
|
|
289
|
+
|
|
290
|
+
results["qvalue_fitted"] = multipletests(
|
|
291
|
+
results["pvalue_fitted"],
|
|
292
|
+
method="fdr_bh",
|
|
293
|
+
)[1]
|
|
294
|
+
|
|
295
|
+
results["qvalue_empirical"] = multipletests(
|
|
296
|
+
results["pvalue_empirical"],
|
|
297
|
+
method="fdr_bh",
|
|
298
|
+
)[1]
|
|
299
|
+
|
|
300
|
+
results["significant"] = (
|
|
301
|
+
results["qvalue_empirical"] < 0.05
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
results = results.sort_values(
|
|
305
|
+
["qvalue_empirical", "pagerank"],
|
|
306
|
+
ascending=[True, False],
|
|
307
|
+
).reset_index(drop=True)
|
|
308
|
+
|
|
309
|
+
pagerank_null = pd.DataFrame(
|
|
310
|
+
pagerank_null,
|
|
311
|
+
columns=nodes,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return results, pagerank_null, fitted_models
|
|
315
|
+
|
|
316
|
+
# %%
|
|
317
|
+
d3 = d3graph()
|
|
318
|
+
|
|
319
|
+
df = d3.import_example('socialmedia')
|
|
320
|
+
adjmat = vec2adjmat(source=df['source'], target=df['target'], weight=df['weight'])
|
|
321
|
+
results, pagerank_null, fitted_models = pagerank_permutation_test(adjmat, n_perm=1000, swaps_per_edge=10, random_state=42, fit_distributions=True, verbose=1)
|
|
322
|
+
|
|
323
|
+
print(results[["node", "pagerank", "null_mean", "zscore", "pvalue_empirical", "qvalue_empirical", "distribution", "pvalue_fitted", "significant"]].head(20))
|
|
324
|
+
|
|
325
|
+
# The interpretation is:
|
|
326
|
+
# The observed PageRank of @important_user.social is higher than almost all PageRank scores obtained from randomized networks with exactly the same node-level in-degree and out-degree. Its position is therefore unlikely to be explained by degree alone.
|
|
327
|
+
|
|
328
|
+
# %%
|
|
329
|
+
|
|
330
|
+
node = results.iloc[0]["node"]
|
|
331
|
+
dfit = fitted_models[node]
|
|
332
|
+
dfit.plot(title=f"Null PageRank distribution: {node}")
|
|
333
|
+
observed_pagerank = results.loc[results["node"] == node, "pagerank"].iloc[0]
|
|
334
|
+
|
|
335
|
+
print("Observed PageRank:", observed_pagerank)
|
|
336
|
+
print("Empirical p-value:", results.loc[results["node"] == node, "pvalue_empirical"].iloc[0])
|
|
337
|
+
|
|
338
|
+
# %%
|
|
339
|
+
|
|
340
|
+
node_results = results.set_index("node").reindex(adjmat.index)
|
|
341
|
+
|
|
342
|
+
node_size = scale_values(
|
|
343
|
+
node_results["pagerank"].values,
|
|
344
|
+
min_value=8,
|
|
345
|
+
max_value=45,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
node_color = np.where(
|
|
349
|
+
node_results["significant"].values,
|
|
350
|
+
"#ff4d4d",
|
|
351
|
+
"#808080",
|
|
352
|
+
).tolist()
|
|
353
|
+
|
|
354
|
+
node_tooltip = [
|
|
355
|
+
(
|
|
356
|
+
f"{node}<br>"
|
|
357
|
+
f"PageRank: {row.pagerank:.5f}<br>"
|
|
358
|
+
f"Expected: {row.null_mean:.5f}<br>"
|
|
359
|
+
f"Z-score: {row.zscore:.2f}<br>"
|
|
360
|
+
f"Empirical p: {row.pvalue_empirical:.4g}<br>"
|
|
361
|
+
f"FDR q: {row.qvalue_empirical:.4g}<br>"
|
|
362
|
+
f"Significant: {row.significant}"
|
|
363
|
+
)
|
|
364
|
+
for node, row in node_results.iterrows()
|
|
365
|
+
]
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
d3.graph(adjmat)
|
|
369
|
+
|
|
370
|
+
d3.set_node_properties(
|
|
371
|
+
size=node_size,
|
|
372
|
+
color=node_color,
|
|
373
|
+
tooltip=node_tooltip,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# For reporting significance, I recommend using pvalue_empirical and qvalue_empirical as the primary results. The fitted distfit distribution is useful for smoothing and visualization, but the empirical permutation p-value makes fewer distributional assumptions.
|
|
378
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: d3graph
|
|
3
|
-
Version: 2.9.
|
|
3
|
+
Version: 2.9.4
|
|
4
4
|
Summary: Python package to create interactive network based on d3js.
|
|
5
5
|
Author-email: Erdogan Taskesen <erdogant@gmail.com>
|
|
6
6
|
License-Expression: BSD-3-Clause
|
|
@@ -103,6 +103,14 @@ from d3graph import d3graph
|
|
|
103
103
|
</a>
|
|
104
104
|
</p>
|
|
105
105
|
|
|
106
|
+
|
|
107
|
+
<p align="left">
|
|
108
|
+
<a href="https://erdogant.github.io/docs/d3graph/titanic_example/index.html">
|
|
109
|
+
<img src="https://github.com/d3blocks/d3blocks/blob/main/docs/figs/d3graph/socialmedia.gif" width="1000"/>
|
|
110
|
+
</a>
|
|
111
|
+
</p>
|
|
112
|
+
|
|
113
|
+
|
|
106
114
|
<hr>
|
|
107
115
|
|
|
108
116
|
### Contributors
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|