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.
@@ -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
+ ]