PsychiatryNLPKit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- PsychiatryNLPKit/__init__.py +34 -0
- PsychiatryNLPKit/analysis/Density.py +164 -0
- PsychiatryNLPKit/analysis/Graph.py +302 -0
- PsychiatryNLPKit/analysis/ImageSimilarity.py +227 -0
- PsychiatryNLPKit/analysis/Lexicon.py +116 -0
- PsychiatryNLPKit/analysis/Perplexity.py +356 -0
- PsychiatryNLPKit/analysis/Similarity.py +139 -0
- PsychiatryNLPKit/analysis/Syntax.py +727 -0
- PsychiatryNLPKit/analysis/__init__.py +108 -0
- PsychiatryNLPKit/analysis/batch.py +429 -0
- PsychiatryNLPKit/config.py +76 -0
- PsychiatryNLPKit/data/Audio.py +7 -0
- PsychiatryNLPKit/data/Image.py +36 -0
- PsychiatryNLPKit/data/Text.py +550 -0
- PsychiatryNLPKit/data/__init__.py +23 -0
- PsychiatryNLPKit/data/_resources.py +96 -0
- PsychiatryNLPKit/model/LLM.py +150 -0
- PsychiatryNLPKit/model/ViT.py +74 -0
- PsychiatryNLPKit/model/__init__.py +24 -0
- PsychiatryNLPKit/py.typed +0 -0
- PsychiatryNLPKit/resources/__init__.py +6 -0
- PsychiatryNLPKit/resources/filler_words.json +4 -0
- PsychiatryNLPKit/resources/word2vec.db +0 -0
- psychiatrynlpkit-0.1.0.dist-info/METADATA +570 -0
- psychiatrynlpkit-0.1.0.dist-info/RECORD +28 -0
- psychiatrynlpkit-0.1.0.dist-info/WHEEL +5 -0
- psychiatrynlpkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- psychiatrynlpkit-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Computational linguistics toolkit for psychosis risk assessment and thought disorder analysis.
|
|
2
|
+
|
|
3
|
+
Provides analysis functions grounded in clinical research on language markers of
|
|
4
|
+
psychosis risk, including syntax metrics, semantic coherence, perplexity, graph-based
|
|
5
|
+
network measures, and semantic density estimation. All functions support both English
|
|
6
|
+
and French where applicable.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
import PsychiatryNLPKit as pnlp
|
|
11
|
+
|
|
12
|
+
pnlp.configure_logging()
|
|
13
|
+
data = pnlp.data.TextData(sections=[...], lang="en")
|
|
14
|
+
results = pnlp.analysis.sentence_length(data.pos_tags)
|
|
15
|
+
|
|
16
|
+
Batch analysis::
|
|
17
|
+
|
|
18
|
+
result = pnlp.BatchAnalyzer(text_data).run()
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from . import analysis, data, model
|
|
22
|
+
from .analysis import AnalysisResult, BatchAnalyzer
|
|
23
|
+
from .config import configure_logging, device, hf_token
|
|
24
|
+
|
|
25
|
+
__all__: list[str] = [
|
|
26
|
+
"AnalysisResult",
|
|
27
|
+
"BatchAnalyzer",
|
|
28
|
+
"analysis",
|
|
29
|
+
"configure_logging",
|
|
30
|
+
"data",
|
|
31
|
+
"device",
|
|
32
|
+
"hf_token",
|
|
33
|
+
"model",
|
|
34
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Semantic density analysis for PsychiatryNLPKit.
|
|
2
|
+
|
|
3
|
+
Measures dimensional properties of semantic spaces derived from token embeddings.
|
|
4
|
+
Schizophrenia patients can show high or low semantic density, and lower intrinsic
|
|
5
|
+
dimensionality indicates more redundant speech.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import torch
|
|
12
|
+
from skdim.id import MLE
|
|
13
|
+
from sklearn.decomposition import PCA
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pca_density_metrics(
|
|
19
|
+
token_embedding_vectors: dict[str, torch.Tensor],
|
|
20
|
+
sections: list[str] | None = None,
|
|
21
|
+
) -> dict[str, dict[str, float]]:
|
|
22
|
+
"""PCA-based density metrics per section.
|
|
23
|
+
|
|
24
|
+
PCA is applied in **token space** (features=tokens, i.e. ``X.T``) so metrics
|
|
25
|
+
reflect how many token directions are needed to explain semantic variance.
|
|
26
|
+
During statistical analysis, total paragraph length must be controlled.
|
|
27
|
+
|
|
28
|
+
Notes:
|
|
29
|
+
Theoretical basis - While healthy controls tend to have moderately
|
|
30
|
+
compressible semantic space, schizophrenia patients can have high or low
|
|
31
|
+
semantic density (Palominos et al., 2025).
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
token_embedding_vectors: Dict mapping section names to token-level
|
|
35
|
+
embedding tensors (from ``TextData.token_embedding_vectors``).
|
|
36
|
+
sections: Sections to process. ``None`` processes all sections in the dict.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Dict mapping section names to a metric dict with keys
|
|
40
|
+
``"Ncomp_90"``, ``"Pcomp_90"``, and ``"ExVar_2"``. Empty sections
|
|
41
|
+
receive ``float("nan")`` for all metrics.
|
|
42
|
+
|
|
43
|
+
References:
|
|
44
|
+
Palominos, C., Stein, F., Kircher, T., Ayesa-Arriola, R., Palaniyappan,
|
|
45
|
+
L., Homan, P., Sommer, I. E., & Hinzen, W. (2025). Lexical meaning is
|
|
46
|
+
lower dimensional in psychosis. Scientific Reports, 16(1), 859.
|
|
47
|
+
https://doi.org/10.1038/s41598-025-30443-1
|
|
48
|
+
"""
|
|
49
|
+
if sections is None:
|
|
50
|
+
sections = list(token_embedding_vectors.keys())
|
|
51
|
+
|
|
52
|
+
results: dict[str, dict[str, float]] = {}
|
|
53
|
+
|
|
54
|
+
for sec in sections:
|
|
55
|
+
if sec not in token_embedding_vectors:
|
|
56
|
+
logger.warning("Section %s not found in embeddings, skipping", sec)
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
vectors = token_embedding_vectors[sec]
|
|
60
|
+
n_tokens = vectors.shape[0]
|
|
61
|
+
|
|
62
|
+
# Handle edge case: fewer than 2 tokens.
|
|
63
|
+
if n_tokens < 2:
|
|
64
|
+
results[sec] = {
|
|
65
|
+
"Ncomp_90": float("nan"),
|
|
66
|
+
"Pcomp_90": float("nan"),
|
|
67
|
+
"ExVar_2": float("nan"),
|
|
68
|
+
}
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
# Move to CPU for sklearn.
|
|
72
|
+
X = vectors.detach().cpu().numpy()
|
|
73
|
+
|
|
74
|
+
# PCA in **token space**: transpose so tokens are samples, embedding dims are features.
|
|
75
|
+
n_components = min(n_tokens, X.shape[1])
|
|
76
|
+
pca = PCA(n_components=n_components)
|
|
77
|
+
pca.fit(X.T)
|
|
78
|
+
|
|
79
|
+
explained_variance_ratio = np.asarray(
|
|
80
|
+
pca.explained_variance_ratio_, dtype=float
|
|
81
|
+
)
|
|
82
|
+
cumulative_variance = np.cumsum(explained_variance_ratio)
|
|
83
|
+
|
|
84
|
+
ncomp_90 = int(np.searchsorted(cumulative_variance, 0.9, side="left") + 1)
|
|
85
|
+
pcomp_90 = ncomp_90 / n_tokens
|
|
86
|
+
exvar_2 = float(
|
|
87
|
+
explained_variance_ratio[: min(2, len(explained_variance_ratio))].sum()
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
results[sec] = {
|
|
91
|
+
"Ncomp_90": float(ncomp_90),
|
|
92
|
+
"Pcomp_90": float(pcomp_90),
|
|
93
|
+
"ExVar_2": exvar_2,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def intrinsic_dimensionality_density(
|
|
100
|
+
token_embedding_vectors: dict[str, torch.Tensor],
|
|
101
|
+
sections: list[str] | None = None,
|
|
102
|
+
k: int | None = None,
|
|
103
|
+
) -> dict[str, dict[str, float]]:
|
|
104
|
+
"""Estimate intrinsic dimensionality using MLE (Levina & Bickel, 2004).
|
|
105
|
+
|
|
106
|
+
Intrinsic dimensionality quantifies the local geometric complexity of the
|
|
107
|
+
semantic space formed by token embeddings. Lower values indicate more
|
|
108
|
+
redundant speech.
|
|
109
|
+
|
|
110
|
+
Notes:
|
|
111
|
+
Theoretical basis - Lower intrinsic dimensionality indicates more
|
|
112
|
+
redundant speech (Palominos et al., 2025).
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
token_embedding_vectors: Dict mapping section names to token-level
|
|
116
|
+
embedding tensors (from ``TextData.token_embedding_vectors``).
|
|
117
|
+
sections: Sections to process. ``None`` processes all sections in the dict.
|
|
118
|
+
k: Number of neighbors for MLE estimator. Defaults to
|
|
119
|
+
``min(10, n_samples - 1)``.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Dict mapping section names to a metric dict with key ``"ID_MLE"``.
|
|
123
|
+
Empty sections receive ``float("nan")``.
|
|
124
|
+
|
|
125
|
+
References:
|
|
126
|
+
Palominos, C., Stein, F., Kircher, T., Ayesa-Arriola, R., Palaniyappan,
|
|
127
|
+
L., Homan, P., Sommer, I. E., & Hinzen, W. (2025). Lexical meaning is
|
|
128
|
+
lower dimensional in psychosis. Scientific Reports, 16(1), 859.
|
|
129
|
+
https://doi.org/10.1038/s41598-025-30443-1
|
|
130
|
+
"""
|
|
131
|
+
if sections is None:
|
|
132
|
+
sections = list(token_embedding_vectors.keys())
|
|
133
|
+
|
|
134
|
+
results: dict[str, dict[str, float]] = {}
|
|
135
|
+
|
|
136
|
+
for sec in sections:
|
|
137
|
+
if sec not in token_embedding_vectors:
|
|
138
|
+
logger.warning("Section %s not found in embeddings, skipping", sec)
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
vectors = token_embedding_vectors[sec]
|
|
142
|
+
n_tokens = vectors.shape[0]
|
|
143
|
+
|
|
144
|
+
# Handle edge case: fewer than 2 tokens.
|
|
145
|
+
if n_tokens < 2:
|
|
146
|
+
results[sec] = {"ID_MLE": float("nan")}
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
# Move to CPU for skdim.
|
|
150
|
+
X = vectors.detach().cpu().numpy()
|
|
151
|
+
|
|
152
|
+
k_param = k if k is not None else min(10, n_tokens - 1)
|
|
153
|
+
id_estimator = MLE(K=k_param)
|
|
154
|
+
intrinsic_dim = float(id_estimator.fit_transform(X))
|
|
155
|
+
|
|
156
|
+
results[sec] = {"ID_MLE": intrinsic_dim}
|
|
157
|
+
|
|
158
|
+
return results
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__: list[str] = [
|
|
162
|
+
"pca_density_metrics",
|
|
163
|
+
"intrinsic_dimensionality_density",
|
|
164
|
+
]
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Graph-based network analysis for PsychiatryNLPKit.
|
|
2
|
+
|
|
3
|
+
Constructs structural word graphs from text and computes network metrics
|
|
4
|
+
associated with thought disorder. Schizophrenia patients show smaller
|
|
5
|
+
connected components and lower average shortest path length.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
import networkx as nx
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _z_score(value: float, null_distribution: list[float]) -> float:
|
|
17
|
+
"""Compute z-score of *value* against a null distribution."""
|
|
18
|
+
if not null_distribution:
|
|
19
|
+
return 0.0
|
|
20
|
+
mean = np.mean(null_distribution)
|
|
21
|
+
std = np.std(null_distribution)
|
|
22
|
+
if std == 0:
|
|
23
|
+
return 0.0
|
|
24
|
+
return float((value - mean) / std)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _largest_component_size(
|
|
28
|
+
graph: nx.Graph | nx.DiGraph, strongly: bool = False
|
|
29
|
+
) -> int:
|
|
30
|
+
"""Size of largest (strongly) connected component."""
|
|
31
|
+
if graph.number_of_nodes() == 0:
|
|
32
|
+
return 0
|
|
33
|
+
if strongly and isinstance(graph, nx.DiGraph):
|
|
34
|
+
return max((len(c) for c in nx.strongly_connected_components(graph)), default=0)
|
|
35
|
+
if isinstance(graph, nx.DiGraph):
|
|
36
|
+
return max((len(c) for c in nx.weakly_connected_components(graph)), default=0)
|
|
37
|
+
return max((len(c) for c in nx.connected_components(graph)), default=0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _diameter_and_aspl(
|
|
41
|
+
graph: nx.Graph | nx.DiGraph, weight: str | None = None
|
|
42
|
+
) -> tuple[float, float]:
|
|
43
|
+
"""Diameter and average shortest path length of largest component.
|
|
44
|
+
|
|
45
|
+
For directed graphs, converts to undirected for distance calculations
|
|
46
|
+
to ensure symmetric reachability.
|
|
47
|
+
"""
|
|
48
|
+
if graph.number_of_nodes() == 0:
|
|
49
|
+
return 0.0, 0.0
|
|
50
|
+
|
|
51
|
+
if isinstance(graph, nx.DiGraph):
|
|
52
|
+
components = list(nx.weakly_connected_components(graph))
|
|
53
|
+
else:
|
|
54
|
+
components = list(nx.connected_components(graph))
|
|
55
|
+
|
|
56
|
+
if not components:
|
|
57
|
+
return 0.0, 0.0
|
|
58
|
+
|
|
59
|
+
largest = max(components, key=len)
|
|
60
|
+
subgraph = graph.subgraph(largest)
|
|
61
|
+
|
|
62
|
+
# Convert directed graphs to undirected for distance calculations.
|
|
63
|
+
distance_graph = (
|
|
64
|
+
subgraph.to_undirected() if isinstance(subgraph, nx.DiGraph) else subgraph
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
diam = nx.diameter(distance_graph, weight=weight)
|
|
69
|
+
aspl = nx.average_shortest_path_length(distance_graph, weight=weight)
|
|
70
|
+
except nx.NetworkXError:
|
|
71
|
+
return 0.0, 0.0
|
|
72
|
+
|
|
73
|
+
return float(diam), float(aspl)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _degree_variance(graph: nx.Graph | nx.DiGraph, weight: str | None = None) -> float:
|
|
77
|
+
"""Variance of node degrees (in+out for directed graphs)."""
|
|
78
|
+
if graph.number_of_nodes() == 0:
|
|
79
|
+
return 0.0
|
|
80
|
+
|
|
81
|
+
if isinstance(graph, nx.DiGraph):
|
|
82
|
+
digraph: nx.DiGraph = graph
|
|
83
|
+
degrees = np.fromiter(
|
|
84
|
+
(
|
|
85
|
+
digraph.in_degree(n, weight=weight)
|
|
86
|
+
+ digraph.out_degree(n, weight=weight)
|
|
87
|
+
for n in digraph.nodes()
|
|
88
|
+
),
|
|
89
|
+
dtype=float,
|
|
90
|
+
count=digraph.number_of_nodes(),
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
degrees = np.fromiter(
|
|
94
|
+
(graph.degree(n, weight=weight) for n in graph.nodes()),
|
|
95
|
+
dtype=float,
|
|
96
|
+
count=graph.number_of_nodes(),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return float(degrees.var())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _generate_random_graphs(
|
|
103
|
+
n_nodes: int,
|
|
104
|
+
n_edges: int,
|
|
105
|
+
n_graphs: int = 1000,
|
|
106
|
+
) -> list[nx.Graph | nx.DiGraph]:
|
|
107
|
+
"""Generate random graphs with same node/edge counts (configuration model)."""
|
|
108
|
+
if n_nodes == 0 or n_edges == 0:
|
|
109
|
+
return []
|
|
110
|
+
|
|
111
|
+
graphs: list[nx.Graph | nx.DiGraph] = []
|
|
112
|
+
for i in range(n_graphs):
|
|
113
|
+
G = nx.gnm_random_graph(n_nodes, n_edges, directed=True, seed=i)
|
|
114
|
+
graphs.append(G)
|
|
115
|
+
return graphs
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def structural_graph(
|
|
119
|
+
content_words: dict[str, list[str]],
|
|
120
|
+
directed: bool = True,
|
|
121
|
+
weighted: bool = True,
|
|
122
|
+
sections: list[str] | None = None,
|
|
123
|
+
n_random_graphs: int = 1000,
|
|
124
|
+
) -> dict[str, dict[str, float]]:
|
|
125
|
+
"""Constructs a structural graph from lemmatized content words per section
|
|
126
|
+
and immediately computes all network metrics.
|
|
127
|
+
|
|
128
|
+
Notes:
|
|
129
|
+
Theoretical basis - Graphs from schizophrenia patients show fewer nodes
|
|
130
|
+
and edges, lower average degrees, and smaller connected components. The
|
|
131
|
+
graphs also have lower average shortest path length and network diameter
|
|
132
|
+
per fixed word length (Nikzad et al., 2022).
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
content_words: Dict mapping section names to lists of lemmatized content
|
|
136
|
+
words.
|
|
137
|
+
directed: Whether the constructed graph is directed (word transitions).
|
|
138
|
+
Defaults to ``True``.
|
|
139
|
+
weighted: Whether to assign edge weights based on repetition count. Edge
|
|
140
|
+
weight equals consecutive occurrence count; distance = 1/weight for
|
|
141
|
+
path calculations.
|
|
142
|
+
sections: Sections to process. ``None`` processes all sections in
|
|
143
|
+
*content_words*.
|
|
144
|
+
n_random_graphs: Number of random graphs to generate for z-score
|
|
145
|
+
computation. Defaults to 1000. **Performance note:** this incurs a
|
|
146
|
+
non negligible runtime cost. For each section, ``n_random_graphs``
|
|
147
|
+
random graphs are generated and analyzed. With N sections, total cost
|
|
148
|
+
is O(N * n_random_graphs). For large datasets, consider reducing this
|
|
149
|
+
parameter or pre-computing null distributions for common node/edge
|
|
150
|
+
counts.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Dict mapping section names to a metric dict with keys: nodes_count,
|
|
154
|
+
edges_count, average_degree, density, diameter,
|
|
155
|
+
average_shortest_path_length, largest_connected_component,
|
|
156
|
+
largest_strongly_connected_component, lcc/n, lsc/n,
|
|
157
|
+
edge_weight_repetition_index, lcc_z_score, lsc_z_score,
|
|
158
|
+
aspl_z_score, degree_distribution_z_score.
|
|
159
|
+
|
|
160
|
+
References:
|
|
161
|
+
Nikzad, A. H., Cong, Y., Berretta, S., Hänsel, K., Cho, S., Pradhan, S.,
|
|
162
|
+
Behbehani, L., DeSouza, D. D., Liberman, M. Y., & Tang, S. X. (2022). Who
|
|
163
|
+
does what to whom? graph representations of action-predication in speech
|
|
164
|
+
relate to psychopathological dimensions of psychosis. Schizophrenia,
|
|
165
|
+
8(1), 58. https://doi.org/10.1038/s41537-022-00263-7
|
|
166
|
+
"""
|
|
167
|
+
if sections is None:
|
|
168
|
+
sections = list(content_words.keys())
|
|
169
|
+
|
|
170
|
+
results: dict[str, dict[str, float]] = {}
|
|
171
|
+
|
|
172
|
+
for sec in sections:
|
|
173
|
+
if sec not in content_words:
|
|
174
|
+
logger.warning("Section %s not found in content_words, skipping", sec)
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
graph_class = nx.DiGraph if directed else nx.Graph
|
|
178
|
+
graph = graph_class()
|
|
179
|
+
|
|
180
|
+
# Add nodes.
|
|
181
|
+
for word in content_words[sec]:
|
|
182
|
+
if not graph.has_node(word):
|
|
183
|
+
graph.add_node(word)
|
|
184
|
+
|
|
185
|
+
# Add edges with weights based on consecutive occurrence counts.
|
|
186
|
+
words = content_words[sec]
|
|
187
|
+
for i in range(len(words) - 1):
|
|
188
|
+
w1, w2 = words[i], words[i + 1]
|
|
189
|
+
if graph.has_edge(w1, w2):
|
|
190
|
+
if weighted:
|
|
191
|
+
graph[w1][w2]["weight"] += 1
|
|
192
|
+
graph[w1][w2]["distance"] = 1.0 / graph[w1][w2]["weight"]
|
|
193
|
+
else:
|
|
194
|
+
if weighted:
|
|
195
|
+
graph.add_edge(w1, w2, weight=1, distance=1.0)
|
|
196
|
+
else:
|
|
197
|
+
graph.add_edge(w1, w2)
|
|
198
|
+
|
|
199
|
+
# --- Compute all metrics for this section ---
|
|
200
|
+
nodes_count = graph.number_of_nodes()
|
|
201
|
+
edges_count = graph.number_of_edges()
|
|
202
|
+
|
|
203
|
+
section_metrics: dict[str, float] = {
|
|
204
|
+
"nodes_count": float(nodes_count),
|
|
205
|
+
"edges_count": float(edges_count),
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if nodes_count == 0:
|
|
209
|
+
section_metrics.update(
|
|
210
|
+
{
|
|
211
|
+
"average_degree": 0.0,
|
|
212
|
+
"density": 0.0,
|
|
213
|
+
"diameter": 0.0,
|
|
214
|
+
"average_shortest_path_length": 0.0,
|
|
215
|
+
"largest_connected_component": 0.0,
|
|
216
|
+
"largest_strongly_connected_component": 0.0,
|
|
217
|
+
"lcc/n": 0.0,
|
|
218
|
+
"lsc/n": 0.0,
|
|
219
|
+
"edge_weight_repetition_index": 0.0,
|
|
220
|
+
"lcc_z_score": 0.0,
|
|
221
|
+
"lsc_z_score": 0.0,
|
|
222
|
+
"aspl_z_score": 0.0,
|
|
223
|
+
"degree_distribution_z_score": 0.0,
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
results[sec] = section_metrics
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
# Degree and density.
|
|
230
|
+
weighted_degree_sum = sum(
|
|
231
|
+
degree for _, degree in graph.degree(weight="weight" if weighted else None)
|
|
232
|
+
)
|
|
233
|
+
section_metrics["average_degree"] = float(weighted_degree_sum / nodes_count)
|
|
234
|
+
section_metrics["density"] = float(nx.density(graph))
|
|
235
|
+
|
|
236
|
+
# Diameter and ASPL on largest component (undirected for directed graphs).
|
|
237
|
+
diameter, aspl = _diameter_and_aspl(
|
|
238
|
+
graph, weight="distance" if weighted else None
|
|
239
|
+
)
|
|
240
|
+
section_metrics["diameter"] = diameter
|
|
241
|
+
section_metrics["average_shortest_path_length"] = aspl
|
|
242
|
+
|
|
243
|
+
# Connectivity metrics.
|
|
244
|
+
lcc = _largest_component_size(graph)
|
|
245
|
+
lsc = _largest_component_size(graph, strongly=True)
|
|
246
|
+
section_metrics["largest_connected_component"] = float(lcc)
|
|
247
|
+
section_metrics["largest_strongly_connected_component"] = float(lsc)
|
|
248
|
+
section_metrics["lcc/n"] = float(lcc / nodes_count) if nodes_count else 0.0
|
|
249
|
+
section_metrics["lsc/n"] = float(lsc / nodes_count) if nodes_count else 0.0
|
|
250
|
+
|
|
251
|
+
# Edge weight repetition index.
|
|
252
|
+
total_edge_weight = 0.0
|
|
253
|
+
repeated_weight = 0.0
|
|
254
|
+
observed_weights: list[float] = []
|
|
255
|
+
for _, _, attrs in graph.edges(data=True):
|
|
256
|
+
edge_weight = float(attrs.get("weight", 1.0))
|
|
257
|
+
observed_weights.append(edge_weight)
|
|
258
|
+
total_edge_weight += edge_weight
|
|
259
|
+
repeated_weight += max(0.0, edge_weight - 1.0)
|
|
260
|
+
section_metrics["edge_weight_repetition_index"] = (
|
|
261
|
+
repeated_weight / total_edge_weight if total_edge_weight > 0 else 0.0
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Z-scores against random graphs with SAME edge weights.
|
|
265
|
+
observed_degree_var = _degree_variance(
|
|
266
|
+
graph, weight="weight" if weighted else None
|
|
267
|
+
)
|
|
268
|
+
random_lcc, random_lsc, random_aspl, random_deg_var = [], [], [], []
|
|
269
|
+
|
|
270
|
+
for G in _generate_random_graphs(nodes_count, edges_count, n_random_graphs):
|
|
271
|
+
# Assign observed edge weights to random graph for fair comparison.
|
|
272
|
+
if weighted and observed_weights:
|
|
273
|
+
G = G.to_directed() if graph.is_directed() else G
|
|
274
|
+
for (u, v), random_weight in zip(
|
|
275
|
+
G.edges(), observed_weights, strict=True
|
|
276
|
+
):
|
|
277
|
+
G[u][v]["weight"] = random_weight
|
|
278
|
+
G[u][v]["distance"] = 1.0 / random_weight
|
|
279
|
+
|
|
280
|
+
random_lcc.append(float(_largest_component_size(G)))
|
|
281
|
+
random_lsc.append(float(_largest_component_size(G, strongly=True)))
|
|
282
|
+
_, r_aspl = _diameter_and_aspl(G, weight="distance" if weighted else None)
|
|
283
|
+
random_aspl.append(r_aspl)
|
|
284
|
+
random_deg_var.append(
|
|
285
|
+
_degree_variance(G, weight="weight" if weighted else None)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
section_metrics["lcc_z_score"] = float(_z_score(float(lcc), random_lcc))
|
|
289
|
+
section_metrics["lsc_z_score"] = float(_z_score(float(lsc), random_lsc))
|
|
290
|
+
section_metrics["aspl_z_score"] = float(_z_score(aspl, random_aspl))
|
|
291
|
+
section_metrics["degree_distribution_z_score"] = float(
|
|
292
|
+
_z_score(observed_degree_var, random_deg_var)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
results[sec] = section_metrics
|
|
296
|
+
|
|
297
|
+
return results
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
__all__: list[str] = [
|
|
301
|
+
"structural_graph",
|
|
302
|
+
]
|