d3graph 2.9.6__tar.gz → 3.0.1__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,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: d3graph
3
- Version: 2.9.6
3
+ Version: 3.0.1
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
7
7
  Project-URL: Homepage, https://erdogant.github.io/d3graph
8
8
  Project-URL: Download, https://github.com/erdogant/d3graph/archive/{version}.tar.gz
9
- Keywords: Python,network,graph,network analysis,visualization,interactive,d3js,d3graph,data visualization,web,modular,customizable
9
+ Keywords: Python,network,graph,network analysis,visualization,interactive,d3js,d3graph,data visualization,web,modular,customizable,Network significance
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Intended Audience :: Education
@@ -27,6 +27,7 @@ Requires-Dist: packaging
27
27
  Requires-Dist: markupsafe
28
28
  Requires-Dist: python-louvain
29
29
  Requires-Dist: datazets
30
+ Requires-Dist: distfit
30
31
  Dynamic: license-file
31
32
 
32
33
  # D3graph: Interactive force-directed networks
@@ -105,8 +106,8 @@ from d3graph import d3graph
105
106
 
106
107
 
107
108
  <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"/>
109
+ <a href="http://erdogant.github.io/docs/d3graph/d3graph/socialmedia.html">
110
+ <img src="http://erdogant.github.io/docs/d3graph/d3graph/socialmedia.gif" width="1000"/>
110
111
  </a>
111
112
  </p>
112
113
 
@@ -74,8 +74,8 @@ from d3graph import d3graph
74
74
 
75
75
 
76
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"/>
77
+ <a href="http://erdogant.github.io/docs/d3graph/d3graph/socialmedia.html">
78
+ <img src="http://erdogant.github.io/docs/d3graph/d3graph/socialmedia.gif" width="1000"/>
79
79
  </a>
80
80
  </p>
81
81
 
@@ -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.6'
20
+ __version__ = '3.0.1'
21
21
 
22
22
  # Setup root logger
23
23
  _logger = logging.getLogger('d3graph')
@@ -15,6 +15,7 @@ from sys import platform
15
15
  from tempfile import gettempdir
16
16
  from unicodedata import normalize
17
17
  import uuid
18
+ from tqdm import tqdm
18
19
 
19
20
  from community import community_louvain
20
21
  import colourmap as cm
@@ -26,6 +27,7 @@ from ismember import ismember
26
27
  from jinja2 import Environment, PackageLoader
27
28
  from packaging import version
28
29
  import datazets as dz
30
+ from distfit import distfit
29
31
 
30
32
  logger = logging.getLogger(__name__)
31
33
 
@@ -129,6 +131,7 @@ class d3graph:
129
131
  density_blur: int = 15,
130
132
  density_opacity: float = 0.8,
131
133
  show_controls: bool = True,
134
+ highlight_full_network: bool = True,
132
135
  ) -> None:
133
136
  """Build and show the graph.
134
137
 
@@ -216,6 +219,13 @@ class d3graph:
216
219
  just hidden, so there's no extra DOM/clutter in the embed. The weight/component
217
220
  sliders are controlled separately via show_slider; the Save button specifically
218
221
  is also controlled via save_button, independent of this.
222
+ highlight_full_network : bool, (default: True)
223
+ Controls how much of the graph lights up when a node is clicked.
224
+ True: The entire connected network reachable from the clicked node (every node
225
+ and edge in its connected component, not just its direct neighbors) is
226
+ highlighted; everything outside that component is dimmed.
227
+ False: Only the clicked node's directly-connected neighbors/edges (one hop)
228
+ are highlighted, matching the old default behavior.
219
229
 
220
230
  Returns
221
231
  -------
@@ -246,6 +256,7 @@ class d3graph:
246
256
  self.config['density_blur'] = density_blur
247
257
  self.config['density_opacity'] = density_opacity
248
258
  self.config['show_controls'] = show_controls
259
+ self.config['highlight_full_network'] = highlight_full_network
249
260
 
250
261
  # Allow show() to override the link_tension set at __init__ time
251
262
  if link_tension is not None:
@@ -534,6 +545,7 @@ class d3graph:
534
545
  'color': color of the node
535
546
  'opacity': Opacity of the node
536
547
  'size': size of the node
548
+ 'proba': Significance (p-value) of the node from network_significance(). NaN until that method is run.
537
549
  'fontcolor': color of the node text
538
550
  'fontsize': node text size
539
551
  'edge_size': edge_size of the node
@@ -673,6 +685,7 @@ class d3graph:
673
685
  'fontcolor': str(fontcolor[i]),
674
686
  'fontsize': str(fontsize[i]),
675
687
  'size': size[i],
688
+ 'proba': np.nan,
676
689
  'edge_size': edge_size[i],
677
690
  'edge_color': edge_color[i],
678
691
  'group': group[i]}
@@ -874,8 +887,10 @@ class d3graph:
874
887
  'density_blur': self.config.get('density_blur', 8),
875
888
  'density_opacity': self.config.get('density_opacity', 0.6),
876
889
  'show_controls': self.config.get('show_controls', True),
890
+ 'highlight_full_network': self.config.get('highlight_full_network', True),
877
891
  'save_button': self.config['save_button'],
878
892
  'node_text_inside': self.config.get('node_text_inside', False),
893
+ 'SIGNIFICANCE_ALPHA': self.config.get('significance_alpha', 0.05),
879
894
  'CLICK_COMMENT': CLICK_COMMENT,
880
895
  'CLICK_FILL': click_properties['fill'],
881
896
  'CLICK_STROKE': click_properties['stroke'],
@@ -946,6 +961,289 @@ class d3graph:
946
961
  # Set to config
947
962
  self.config['filepath'] = Path(filepath)
948
963
 
964
+ # Network statistics
965
+ def network_statistic(self, adjmat, statistic):
966
+ """
967
+ Calculate node-level network statistics.
968
+
969
+ Parameters
970
+ ----------
971
+ adjmat : pd.DataFrame
972
+ Weighted adjacency matrix.
973
+
974
+ statistic : str
975
+ One of:
976
+ - pagerank
977
+ - hits_hub
978
+ - hits_authority
979
+ - degree
980
+ - closeness
981
+ - betweenness
982
+
983
+ Returns
984
+ -------
985
+ pd.Series
986
+ """
987
+
988
+ if not isinstance(adjmat, pd.DataFrame):
989
+ raise TypeError("adjmat must be a pandas DataFrame.")
990
+
991
+ if adjmat.shape[0] != adjmat.shape[1]:
992
+ raise ValueError("adjmat must be square.")
993
+
994
+ G = nx.from_pandas_adjacency(adjmat, create_using=nx.DiGraph)
995
+ statistic = statistic.lower()
996
+
997
+ if statistic == "pagerank":
998
+ values = nx.pagerank(G, weight="weight")
999
+ elif statistic in ["hits_hub", "hub"]:
1000
+ hubs, authorities = nx.hits(G, max_iter=1000, normalized=True)
1001
+ values = hubs
1002
+ elif statistic in ["hits_authority", "authority"]:
1003
+ hubs, authorities = nx.hits(G, max_iter=1000, normalized=True)
1004
+ values = authorities
1005
+ elif statistic == "degree":
1006
+ values = dict(G.degree(weight="weight"))
1007
+ elif statistic == "closeness":
1008
+ values = nx.closeness_centrality(G, distance="weight")
1009
+ elif statistic == "betweenness":
1010
+ values = nx.betweenness_centrality(G, weight="weight")
1011
+ else:
1012
+ raise ValueError(f"Unknown statistic: {statistic}")
1013
+
1014
+ return pd.Series(values, name=statistic)
1015
+
1016
+ def network_randomize(self, adjmat, nswap=None, max_tries=None, seed=None):
1017
+ """
1018
+ Degree-preserving randomization using Maslov-Sneppen edge rewiring.
1019
+
1020
+ Preserves:
1021
+ - Out-degree for directed graphs
1022
+ - In-degree for directed graphs
1023
+ - Degree for undirected graphs
1024
+ - Edge weights
1025
+
1026
+ Parameters
1027
+ ----------
1028
+ adjmat : pd.DataFrame
1029
+ Square adjacency matrix.
1030
+
1031
+ nswap : int, optional
1032
+ Number of successful edge swaps.
1033
+
1034
+ max_tries : int, optional
1035
+ Maximum number of attempted swaps.
1036
+
1037
+ seed : int, optional
1038
+ Random seed.
1039
+
1040
+ Returns
1041
+ -------
1042
+ pd.DataFrame
1043
+ Randomized adjacency matrix.
1044
+ """
1045
+
1046
+ if not isinstance(adjmat, pd.DataFrame):
1047
+ raise TypeError("adjmat must be a pandas DataFrame.")
1048
+
1049
+ if adjmat.shape[0] != adjmat.shape[1]:
1050
+ raise ValueError("adjmat must be square.")
1051
+
1052
+ index = adjmat.index
1053
+ columns = adjmat.columns
1054
+ A = adjmat.to_numpy(copy=True)
1055
+
1056
+ # Store edges as list of tuples (more stable than numpy array)
1057
+ edges = list(zip(*np.where(A > 0)))
1058
+
1059
+ if len(edges) < 2:
1060
+ return adjmat.copy()
1061
+ if nswap is None:
1062
+ nswap = len(edges) * 10
1063
+ if max_tries is None:
1064
+ max_tries = nswap * 20
1065
+
1066
+ rng = np.random.default_rng(seed)
1067
+ # O(1) lookup
1068
+ edge_set = set(edges)
1069
+ successful = 0
1070
+ tries = 0
1071
+
1072
+ while successful < nswap and tries < max_tries:
1073
+ tries += 1
1074
+ idx1, idx2 = rng.choice(len(edges), size=2, replace=False)
1075
+ u, v = edges[idx1]
1076
+ x, y = edges[idx2]
1077
+ # Need four distinct nodes
1078
+ if len({u, v, x, y}) < 4:
1079
+ continue
1080
+
1081
+ # New edges
1082
+ e1 = (u, y)
1083
+ e2 = (x, v)
1084
+
1085
+ # No self loops
1086
+ if u == y or x == v:
1087
+ continue
1088
+
1089
+ # Avoid duplicates
1090
+ if e1 in edge_set or e2 in edge_set:
1091
+ continue
1092
+
1093
+ # Store weights
1094
+ w1 = A[u, v]
1095
+ w2 = A[x, y]
1096
+
1097
+ # Perform swap
1098
+ A[u, v] = 0
1099
+ A[x, y] = 0
1100
+
1101
+ A[u, y] = w1
1102
+ A[x, v] = w2
1103
+
1104
+ # Update edge tracking
1105
+ edge_set.remove((u, v))
1106
+ edge_set.remove((x, y))
1107
+
1108
+ edge_set.add(e1)
1109
+ edge_set.add(e2)
1110
+
1111
+ edges[idx1] = e1
1112
+ edges[idx2] = e2
1113
+
1114
+ successful += 1
1115
+
1116
+ return pd.DataFrame(A, index=index, columns=columns)
1117
+
1118
+ def network_significance(
1119
+ self,
1120
+ adjmat,
1121
+ statistic,
1122
+ n_top=100,
1123
+ n_random=1000,
1124
+ nswap=None,
1125
+ alpha=0.05,
1126
+ seed=None,
1127
+ ):
1128
+ """
1129
+ Test significance of top scoring nodes against
1130
+ degree-preserving randomized networks.
1131
+
1132
+ Parameters
1133
+ ----------
1134
+ adjmat : pd.DataFrame
1135
+ Weighted adjacency matrix. Node labels are sanitized the same
1136
+ way graph() does (matching self.adjmat / self.node_properties),
1137
+ so passing your original, unsanitized adjmat here is safe —
1138
+ but for identical results, prefer passing self.adjmat.
1139
+ statistic : str
1140
+ 'pagerank'
1141
+ 'hits_hub'
1142
+ 'hits_authority'
1143
+ 'closeness'
1144
+ 'betweenness'
1145
+ Note: 'degree' is not supported here — network_randomize() preserves every node's in/out-degree exactly,
1146
+ so there is no valid null distribution to test it against (see Note below).
1147
+ n_top : int
1148
+ Number of highest scoring nodes tested.
1149
+ n_random : int
1150
+ Number of randomized networks.
1151
+ nswap : int
1152
+ Number of edge swaps per random network.
1153
+ alpha : float
1154
+ Significance threshold.
1155
+ seed : int
1156
+ Random seed.
1157
+
1158
+ Note
1159
+ ----
1160
+ Call this *after* set_node_properties(), not before: set_node_properties()
1161
+ rebuilds self.node_properties from scratch on every call, so calling it
1162
+ again after network_significance() would silently erase the computed
1163
+ 'proba' values.
1164
+
1165
+ 'degree' is deliberately unsupported as a `statistic`: network_randomize()
1166
+ (Maslov-Sneppen edge swaps) holds every node's in/out-degree exactly
1167
+ fixed by construction, so the null distribution for 'degree' has
1168
+ ~zero variance and any p-value computed from it is a fitting
1169
+ artifact, not a real result — it's tested here and raises ValueError.
1170
+
1171
+ Returns
1172
+ -------
1173
+ pd.DataFrame
1174
+ Significance results.
1175
+ """
1176
+ rng = np.random.default_rng(seed)
1177
+ # Make copy
1178
+ adjmat = data_checks(adjmat.copy())
1179
+
1180
+ # 'degree' can never be meaningfully tested against this null model:
1181
+ # the Maslov-Sneppen double-edge-swap is defined to hold every
1182
+ # node's in-degree and out-degree exactly fixed (that's the "degree-
1183
+ # preserving" part), so network_statistic(random_adj, 'degree') is
1184
+ # identical to the real degree in every single randomization for an
1185
+ # unweighted/binary graph.
1186
+ if statistic.lower() == 'degree':
1187
+ raise ValueError("Network_significance(statistic='degree') is not meaningful.")
1188
+
1189
+ # Remember alpha so write_html() can pass it into the browser as SIGNIFICANCE_ALPHA
1190
+ self.config['significance_alpha'] = alpha
1191
+
1192
+ # Real network scores
1193
+ real_scores = self.network_statistic(adjmat, statistic)
1194
+ top_nodes = real_scores.sort_values(ascending=False).head(n_top).index
1195
+
1196
+ # Null distributions
1197
+ logger.info('Creating degree-preserving randomized networks')
1198
+ null_scores = {node: [] for node in top_nodes}
1199
+
1200
+ for i in tqdm(range(n_random)):
1201
+ random_adj = self.network_randomize(adjmat, nswap=nswap, seed=rng.integers(0, 1_000_000))
1202
+ scores = self.network_statistic(random_adj, statistic)
1203
+ # Store only the relevant node-scores
1204
+ for node in top_nodes:
1205
+ null_scores[node].append(scores[node])
1206
+
1207
+ # Statistics
1208
+ logger.info(f'Computing {statistic} significance for the top {len(top_nodes)} nodes.')
1209
+ results = []
1210
+ for node in tqdm(top_nodes):
1211
+ # Get the real score
1212
+ y = real_scores[node]
1213
+ # Null distribution for this node
1214
+ rand_scores = np.asarray(null_scores[node])
1215
+ # Set very small values to 0
1216
+ rand_scores = np.where(np.abs(rand_scores) < 1e-10, 0, rand_scores)
1217
+ # Initialize
1218
+ model = distfit(distr="popular", method='parametric', alpha=alpha, verbose=None)
1219
+ # Fit null distribution
1220
+ model_results = model.fit_transform(rand_scores)
1221
+ # model.plot()
1222
+
1223
+ # Calculate probability of observing score >= observed
1224
+ Pout = model.predict(y)
1225
+ # Get Probability
1226
+ y_proba = Pout['y_proba'][0]
1227
+ # FDR Multiple test correction
1228
+ y_proba = np.minimum(y_proba * n_top, 1)
1229
+
1230
+ # Store
1231
+ results.append({"node": node,
1232
+ "score_real": y,
1233
+ "score_random_mean": np.mean(rand_scores),
1234
+ "proba": y_proba,
1235
+ "significant": y_proba < alpha,
1236
+ "statistic": statistic,
1237
+ "distribution": model_results['model']['name'],
1238
+ })
1239
+
1240
+ # Store in node
1241
+ if self.node_properties.get(node):
1242
+ self.node_properties[node]['proba'] = y_proba
1243
+
1244
+ return pd.DataFrame(results).sort_values("proba", ascending=True).reset_index(drop=True)
1245
+
1246
+
949
1247
  def import_example(self, data='energy', url=None, sep=','):
950
1248
  """Import example dataset from github source.
951
1249
 
@@ -1163,6 +1461,7 @@ def json_create(G: nx.Graph, compute_stats: bool = True) -> str:
1163
1461
  nodes[i]['node_color'] = nodes[i].pop('color')
1164
1462
  nodes[i]['node_opacity'] = nodes[i].pop('opacity')
1165
1463
  nodes[i]['node_size'] = nodes[i].pop('size')
1464
+ nodes[i]['node_proba'] = nodes[i].pop('proba')
1166
1465
  nodes[i]['node_size_edge'] = nodes[i].pop('edge_size')
1167
1466
  nodes[i]['node_color_edge'] = nodes[i].pop('edge_color')
1168
1467
  nodes[i]['node_fontcolor'] = nodes[i].pop('fontcolor')
@@ -1733,46 +2032,6 @@ def vec2adjmat(source, target, weight=None, symmetric: bool = True, aggfunc='sum
1733
2032
  return adjmat
1734
2033
 
1735
2034
 
1736
- # %% Convert adjacency matrix to vector
1737
- # def adjmat2vec(adjmat, min_weight: float = 1.0) -> pd.DataFrame:
1738
- # """Convert adjacency matrix into vector with source and target.
1739
-
1740
- # Parameters
1741
- # ----------
1742
- # adjmat : pd.DataFrame()
1743
- # Adjacency matrix.
1744
-
1745
- # min_weight : float
1746
- # edges are returned with a minimum weight.
1747
-
1748
- # Returns
1749
- # -------
1750
- # pd.DataFrame()
1751
- # nodes that are connected based on source and target
1752
-
1753
- # Examples
1754
- # --------
1755
- # >>> source = ['Cloudy', 'Cloudy', 'Sprinkler', 'Rain']
1756
- # >>> target = ['Sprinkler', 'Rain', 'Wet_Grass', 'Wet_Grass']
1757
- # >>> adjmat = vec2adjmat(source, target, weight=[1, 2, 1, 3])
1758
- # >>> vector = adjmat2vec(adjmat)
1759
-
1760
- # """
1761
- # # Convert adjacency matrix into vector
1762
- # logger.info('Converting adjacency matrix into source-target..')
1763
- # adjmat = adjmat.stack().reset_index()
1764
- # # Set columns
1765
- # adjmat.columns = ['source', 'target', 'weight']
1766
- # # Remove self loops and no-connected edges
1767
- # Iloc1 = adjmat['source'] != adjmat['target']
1768
- # Iloc2 = adjmat['weight'] >= min_weight
1769
- # Iloc = Iloc1 & Iloc2
1770
- # # Take only connected nodes
1771
- # adjmat = adjmat.loc[Iloc, :]
1772
- # adjmat.reset_index(drop=True, inplace=True)
1773
- # return adjmat
1774
-
1775
-
1776
2035
  def adjmat2vec(adjmat, min_weight: float = 1.0) -> pd.DataFrame:
1777
2036
  """
1778
2037
  Fast conversion of adjacency matrix → edge list.
@@ -2064,6 +2323,8 @@ def import_example(data='energy', url=None, sep=','):
2064
2323
  return dz.get(data=data, url=url, sep=sep)
2065
2324
 
2066
2325
 
2326
+ # %%
2327
+
2067
2328
  def get_support(support):
2068
2329
  """Support."""
2069
2330
  script=''