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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: d3graph
3
- Version: 2.9.3
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
@@ -17,7 +17,7 @@ from d3graph.d3graph import (
17
17
 
18
18
  __author__ = 'Erdogan Tasksen'
19
19
  __email__ = 'erdogant@gmail.com'
20
- __version__ = '2.9.3'
20
+ __version__ = '2.9.4'
21
21
 
22
22
  # Setup root logger
23
23
  _logger = logging.getLogger('d3graph')
@@ -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
- darkSwitch.addEventListener('click', function () {
170
- darkMode = !darkMode;
171
- setDarkMode(darkMode);
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').addEventListener('click', function () {
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:10000]
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, density_blur=10, density_opacity=0.6, dark_mode=True, show_density=True)
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
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
@@ -6,6 +6,8 @@ d3graph/__init__.py
6
6
  d3graph/d3graph.py
7
7
  d3graph/examples.py
8
8
  d3graph/examples_docs.py
9
+ d3graph/examples_rankings.py
10
+ d3graph/examples_rankings_Pvalue.py
9
11
  d3graph.egg-info/PKG-INFO
10
12
  d3graph.egg-info/SOURCES.txt
11
13
  d3graph.egg-info/dependency_links.txt
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes