tmplot 0.3.0__tar.gz → 0.5.0__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,60 +1,40 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tmplot
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Visualization of Topic Modeling Results
5
5
  Author-email: Maksim Terpilovskii <maximtrp@gmail.com>
6
- License: MIT License
7
-
8
- Copyright (c) 2021 Maksim Terpilowski
9
-
10
- Permission is hereby granted, free of charge, to any person obtaining a copy
11
- of this software and associated documentation files (the "Software"), to deal
12
- in the Software without restriction, including without limitation the rights
13
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
- copies of the Software, and to permit persons to whom the Software is
15
- furnished to do so, subject to the following conditions:
16
-
17
- The above copyright notice and this permission notice shall be included in all
18
- copies or substantial portions of the Software.
19
-
20
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
- SOFTWARE.
27
-
6
+ License-Expression: MIT
28
7
  Project-URL: homepage, https://github.com/maximtrp/tmplot
29
8
  Project-URL: documentation, https://tmplot.readthedocs.io/
30
9
  Keywords: data science,data analytics
31
- Classifier: License :: OSI Approved :: MIT License
32
10
  Classifier: Operating System :: OS Independent
33
- Classifier: Programming Language :: Python :: 3.8
34
11
  Classifier: Programming Language :: Python :: 3.9
35
12
  Classifier: Programming Language :: Python :: 3.10
36
13
  Classifier: Programming Language :: Python :: 3.11
37
14
  Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
38
16
  Classifier: Topic :: Scientific/Engineering :: Information Analysis
39
17
  Classifier: Topic :: Text Processing :: General
40
- Requires-Python: >=3.8
18
+ Requires-Python: >=3.9
41
19
  Description-Content-Type: text/markdown
42
20
  License-File: LICENSE
43
- Requires-Dist: numpy
44
- Requires-Dist: seaborn
45
- Requires-Dist: pandas
46
- Requires-Dist: scikit-learn
47
- Requires-Dist: scipy
48
- Requires-Dist: altair
49
- Requires-Dist: ipywidgets
50
- Requires-Dist: tqdm
21
+ Requires-Dist: numpy>=1.23
22
+ Requires-Dist: pandas>=1.5
23
+ Requires-Dist: scikit-learn>=1.4
24
+ Requires-Dist: scipy>=1.9
25
+ Requires-Dist: altair>=5
26
+ Requires-Dist: ipywidgets>=8
27
+ Requires-Dist: IPython>=8
28
+ Requires-Dist: tqdm>=4.64
51
29
  Provides-Extra: test
52
30
  Requires-Dist: pytest; extra == "test"
53
31
  Requires-Dist: coverage; extra == "test"
32
+ Requires-Dist: build; extra == "test"
33
+ Requires-Dist: twine; extra == "test"
54
34
  Provides-Extra: models
55
35
  Requires-Dist: tomotopy>=0.8.0; extra == "models"
56
36
  Requires-Dist: gensim; extra == "models"
57
- Requires-Dist: bitermplus; extra == "models"
37
+ Requires-Dist: bitermplus>=1.0; extra == "models"
58
38
  Dynamic: license-file
59
39
 
60
40
  # tmplot
@@ -170,7 +150,7 @@ saliency = tmp.get_salient_terms(phi, theta)
170
150
 
171
151
  ```python
172
152
  # Create topic distance matrix with different metrics
173
- topic_dists = tmp.get_topics_dist(phi, method='jensen-shannon')
153
+ topic_dists = tmp.get_topics_dist(phi, method='jsd')
174
154
 
175
155
  # Generate coordinates with custom algorithm
176
156
  coords = tmp.get_topics_scatter(topic_dists, theta, method='tsne')
@@ -181,7 +161,7 @@ tmp.plot_scatter_topics(coords, topic=3) # Highlight topic 3
181
161
 
182
162
  - [Complete Tutorial](https://tmplot.readthedocs.io/en/latest/tutorial.html) - Step-by-step guide
183
163
  - [API Reference](https://tmplot.readthedocs.io/) - Full documentation
184
- - [Example Notebooks](https://github.com/maximtrp/tmplot/tree/main/examples) - Jupyter examples
164
+ - [Tutorial Notebook](docs/source/tutorial.ipynb) - Jupyter walkthrough
185
165
 
186
166
  ## Requirements
187
167
 
@@ -111,7 +111,7 @@ saliency = tmp.get_salient_terms(phi, theta)
111
111
 
112
112
  ```python
113
113
  # Create topic distance matrix with different metrics
114
- topic_dists = tmp.get_topics_dist(phi, method='jensen-shannon')
114
+ topic_dists = tmp.get_topics_dist(phi, method='jsd')
115
115
 
116
116
  # Generate coordinates with custom algorithm
117
117
  coords = tmp.get_topics_scatter(topic_dists, theta, method='tsne')
@@ -122,7 +122,7 @@ tmp.plot_scatter_topics(coords, topic=3) # Highlight topic 3
122
122
 
123
123
  - [Complete Tutorial](https://tmplot.readthedocs.io/en/latest/tutorial.html) - Step-by-step guide
124
124
  - [API Reference](https://tmplot.readthedocs.io/) - Full documentation
125
- - [Example Notebooks](https://github.com/maximtrp/tmplot/tree/main/examples) - Jupyter examples
125
+ - [Tutorial Notebook](docs/source/tutorial.ipynb) - Jupyter walkthrough
126
126
 
127
127
  ## Requirements
128
128
 
@@ -0,0 +1,87 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tmplot"
7
+ dynamic = ["version"]
8
+ description = "Visualization of Topic Modeling Results"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Maksim Terpilovskii", email = "maximtrp@gmail.com" },
15
+ ]
16
+ keywords = [
17
+ "data science",
18
+ "data analytics"
19
+ ]
20
+ classifiers = [
21
+ "Operating System :: OS Independent",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Scientific/Engineering :: Information Analysis",
28
+ "Topic :: Text Processing :: General",
29
+ ]
30
+ urls.homepage = "https://github.com/maximtrp/tmplot"
31
+ urls.documentation = "https://tmplot.readthedocs.io/"
32
+
33
+ dependencies = [
34
+ "numpy>=1.23",
35
+ "pandas>=1.5",
36
+ "scikit-learn>=1.4",
37
+ "scipy>=1.9",
38
+ "altair>=5",
39
+ "ipywidgets>=8",
40
+ "IPython>=8",
41
+ "tqdm>=4.64",
42
+ ]
43
+
44
+ [project.optional-dependencies]
45
+ test = ["pytest", "coverage", "build", "twine"]
46
+ models = ["tomotopy>=0.8.0", "gensim", "bitermplus>=1.0"]
47
+
48
+ [tool.setuptools.dynamic]
49
+ version = {attr = "tmplot.__version__"}
50
+
51
+ [tool.pytest.ini_options]
52
+ log_cli = true
53
+ log_cli_level = "INFO"
54
+ log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
55
+ log_cli_date_format = "%Y-%m-%d %H:%M:%S"
56
+
57
+ [tool.ruff]
58
+ line-length = 120
59
+ target-version = "py39"
60
+ extend-exclude = ["build", "dist", "temp", "docs"]
61
+
62
+ [tool.ruff.lint]
63
+ select = [
64
+ "F", "E", "W", "B", "I", "N", "UP", "SIM", "RET", "ARG", "C4",
65
+ "PD", "NPY", "PT", "PTH", "A", "RUF", "PLC", "PLE", "PLW",
66
+ "BLE",
67
+ ]
68
+ ignore = [
69
+ "E501", # the formatter's job
70
+ "PLC0415", # optional-model imports are deliberately local
71
+ # `X | None` is only a runtime type on 3.10+; the package supports 3.9
72
+ # and the annotations stay introspectable for docs tooling.
73
+ "UP007",
74
+ "UP045",
75
+ # The re-export noqa markers in __init__.py are for pyflakes/prospector,
76
+ # which ruff does not need but Codacy still runs.
77
+ "RUF100",
78
+ ]
79
+
80
+ [tool.ruff.lint.per-file-ignores]
81
+ # SIM117: nesting the expected failure inside the mock setup keeps the two
82
+ # apart. N811: aliasing an import to say where it came from is the point.
83
+ "tests/**" = [
84
+ "S101", "ARG", "N806", "NPY002", "PTH", "PT009", "PT027",
85
+ "SIM117", "N811",
86
+ ]
87
+ "docs/**" = ["A001", "INP001"]
@@ -1,8 +1,10 @@
1
- from ._helpers import * # noqa: F401, F403
1
+ from __future__ import annotations
2
+
2
3
  from ._distance import * # noqa: F401, F403
4
+ from ._helpers import * # noqa: F401, F403
5
+ from ._metrics import * # noqa: F401, F403
3
6
  from ._report import * # noqa: F401, F403
4
7
  from ._stability import * # noqa: F401, F403
5
8
  from ._vis import * # noqa: F401, F403
6
- from ._metrics import * # noqa: F401, F403
7
9
 
8
- __version__ = "0.3.0"
10
+ __version__ = "0.5.0"
@@ -0,0 +1,434 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = ["get_top_topic_words", "get_topics_dist", "get_topics_scatter"]
4
+ from inspect import signature
5
+ from typing import Optional, Union
6
+
7
+ import numpy as np
8
+ from pandas import DataFrame, Index
9
+ from scipy.spatial import distance
10
+
11
+ # kl_div and xlogy are compiled ufuncs; pylint cannot see them statically.
12
+ from scipy.special import kl_div, xlogy # pylint: disable=no-name-in-module
13
+ from sklearn.manifold import (
14
+ MDS,
15
+ TSNE,
16
+ Isomap,
17
+ LocallyLinearEmbedding,
18
+ SpectralEmbedding,
19
+ )
20
+
21
+ from ._helpers import calc_topics_marg_probs
22
+
23
+ EPSILON = 1e-64
24
+
25
+
26
+ SCATTER_METHODS = ["tsne", "sem", "mds", "lle", "ltsa", "isomap"]
27
+
28
+
29
+ def _validate_top_words(top_words: int) -> None:
30
+ if not isinstance(top_words, (int, np.integer)) or top_words < 1:
31
+ raise ValueError(f"top_words must be a positive integer, got {top_words!r}")
32
+
33
+
34
+ def _positive_probabilities(values: np.ndarray) -> np.ndarray:
35
+ values = np.clip(np.asarray(values, dtype=float), EPSILON, None)
36
+ return values / values.sum()
37
+
38
+
39
+ def _dist_klb(a1: np.ndarray, a2: np.ndarray):
40
+ return kl_div(_positive_probabilities(a1), _positive_probabilities(a2)).sum()
41
+
42
+
43
+ def _dist_sklb(a1: np.ndarray, a2: np.ndarray):
44
+ a1_safe = _positive_probabilities(a1)
45
+ a2_safe = _positive_probabilities(a2)
46
+ return (kl_div(a1_safe, a2_safe) + kl_div(a2_safe, a1_safe)).sum()
47
+
48
+
49
+ def _dist_jsd(a1: np.ndarray, a2: np.ndarray):
50
+ m = 0.5 * (a1 + a2)
51
+ dist = 0.5 * kl_div(a1, m) + 0.5 * kl_div(a2, m)
52
+ return dist[np.isfinite(dist)].sum()
53
+
54
+
55
+ def _dist_jef(a1: np.ndarray, a2: np.ndarray):
56
+ a1_safe = _positive_probabilities(a1)
57
+ a2_safe = _positive_probabilities(a2)
58
+ return ((a1_safe - a2_safe) * (np.log(a1_safe) - np.log(a2_safe))).sum()
59
+
60
+
61
+ def _dist_hel(a1: np.ndarray, a2: np.ndarray):
62
+ a1_safe = a1.copy()
63
+ a2_safe = a2.copy()
64
+ a1_safe[(a1_safe <= 0) | ~np.isfinite(a1_safe)] = EPSILON
65
+ a2_safe[(a2_safe <= 0) | ~np.isfinite(a2_safe)] = EPSILON
66
+ return distance.euclidean(np.sqrt(a1_safe), np.sqrt(a2_safe)) / np.sqrt(2)
67
+
68
+
69
+ def _dist_bhat(a1: np.ndarray, a2: np.ndarray):
70
+ pq = a1 * a2
71
+ pq[(pq <= 0) | ~np.isfinite(pq)] = EPSILON
72
+ return -np.log(np.sum(np.sqrt(pq)))
73
+
74
+
75
+ def _dist_tv(a1: np.ndarray, a2: np.ndarray):
76
+ return np.sum(np.abs(a1 - a2)) / 2
77
+
78
+
79
+ def _dist_jac(a1: np.ndarray, a2: np.ndarray, top_words=100):
80
+ _validate_top_words(top_words)
81
+ a = np.argsort(a1)[: -top_words - 1 : -1]
82
+ b = np.argsort(a2)[: -top_words - 1 : -1]
83
+ j_num = np.intersect1d(a, b, assume_unique=False).size
84
+ j_den = np.union1d(a, b).size
85
+ return 1 - j_num / j_den
86
+
87
+
88
+ DIST_FUNCS = {
89
+ "klb": _dist_klb,
90
+ "sklb": _dist_sklb,
91
+ "jsd": _dist_jsd,
92
+ "jef": _dist_jef,
93
+ "hel": _dist_hel,
94
+ "bhat": _dist_bhat,
95
+ "tv": _dist_tv,
96
+ "jac": _dist_jac,
97
+ }
98
+
99
+
100
+ def _normalize_columns(values: np.ndarray) -> np.ndarray:
101
+ """Column-wise equivalent of :func:`_positive_probabilities`."""
102
+ values = np.clip(np.asarray(values, dtype=float), EPSILON, None)
103
+ return values / values.sum(axis=0, keepdims=True)
104
+
105
+
106
+ def _sanitize_columns(values: np.ndarray) -> np.ndarray:
107
+ """Replace non-positive and non-finite entries with ``EPSILON``."""
108
+ values = np.array(values, dtype=float)
109
+ values[(values <= 0) | ~np.isfinite(values)] = EPSILON
110
+ return values
111
+
112
+
113
+ def _cross_klb(a: np.ndarray, b: np.ndarray) -> np.ndarray:
114
+ """KL divergence of every column of ``a`` from every column of ``b``."""
115
+ p_a = _normalize_columns(a)
116
+ p_b = _normalize_columns(b)
117
+ # KL(p || q) = sum_w p log p - sum_w p log q; the -p + q terms of ``kl_div``
118
+ # cancel because both columns are normalized.
119
+ self_term = np.einsum("wt,wt->t", p_a, np.log(p_a))
120
+ return self_term[:, None] - p_a.T @ np.log(p_b)
121
+
122
+
123
+ def _cross_jsd(a: np.ndarray, b: np.ndarray) -> np.ndarray:
124
+ # With m = (p + q) / 2 the "-x + y" terms of kl_div cancel between the two
125
+ # halves, leaving JSD = 0.5 * sum xlogy(p, p/m) + 0.5 * sum xlogy(q, q/m).
126
+ # The xlogy(x, x) parts depend on a single column each, so they are hoisted
127
+ # out of the loop; only log(m) has to be recomputed per pair.
128
+ self_a = xlogy(a, a).sum(axis=0)
129
+ self_b = xlogy(b, b).sum(axis=0)
130
+ dists = np.empty((a.shape[1], b.shape[1]), dtype=float)
131
+ for col in range(b.shape[1]):
132
+ other = b[:, [col]]
133
+ mean = 0.5 * (a + other)
134
+ # m is zero only where both columns are zero, and x * 0 == 0 there.
135
+ log_mean = np.log(mean, where=mean > 0, out=np.zeros_like(mean))
136
+ dists[:, col] = 0.5 * (self_a - (a * log_mean).sum(axis=0)) + 0.5 * (
137
+ self_b[col] - (other * log_mean).sum(axis=0)
138
+ )
139
+ return dists
140
+
141
+
142
+ def _cross_bhat(a: np.ndarray, b: np.ndarray) -> np.ndarray:
143
+ # The scalar version clamps the *product* a * b, so every word where either
144
+ # column is zero contributes sqrt(EPSILON) instead of zero.
145
+ a_clean = np.where(np.isfinite(a), np.clip(a, 0.0, None), 0.0)
146
+ b_clean = np.where(np.isfinite(b), np.clip(b, 0.0, None), 0.0)
147
+ coefficient = np.sqrt(a_clean).T @ np.sqrt(b_clean)
148
+ shared_support = (a_clean > 0).astype(float).T @ (b_clean > 0).astype(float)
149
+ clamped = (a.shape[0] - shared_support) * np.sqrt(EPSILON)
150
+ return -np.log(coefficient + clamped)
151
+
152
+
153
+ def _top_words_mask(values: np.ndarray, top_words: int) -> np.ndarray:
154
+ """Boolean T x W matrix marking each column's ``top_words`` highest entries.
155
+
156
+ ``argsort`` (rather than the faster ``argpartition``) is used so that ties are
157
+ broken exactly as in :func:`_dist_jac`.
158
+ """
159
+ words_num, topics_num = values.shape
160
+ count = min(top_words, words_num)
161
+ top = np.argsort(values, axis=0)[-count:]
162
+ mask = np.zeros((topics_num, words_num), dtype=bool)
163
+ mask[np.repeat(np.arange(topics_num), count), top.T.ravel()] = True
164
+ return mask
165
+
166
+
167
+ def _cross_jac(a: np.ndarray, b: np.ndarray, top_words: int = 100) -> np.ndarray:
168
+ _validate_top_words(top_words)
169
+ mask_a = _top_words_mask(a, top_words)
170
+ mask_b = _top_words_mask(b, top_words)
171
+ intersection = mask_a.astype(np.int32) @ mask_b.astype(np.int32).T
172
+ union = mask_a.sum(axis=1)[:, None] + mask_b.sum(axis=1)[None, :] - intersection
173
+ return 1 - intersection / union
174
+
175
+
176
+ def _cross_dists(
177
+ a: np.ndarray, b: np.ndarray, method: str = "sklb", **kwargs
178
+ ) -> np.ndarray:
179
+ """Distances between every column of ``a`` and every column of ``b``.
180
+
181
+ Vectorized counterpart of the scalar ``_dist_*`` functions, which remain the
182
+ reference implementation. Returns an array of shape
183
+ ``(a.shape[1], b.shape[1])`` where entry ``[i, j]`` is the distance from
184
+ ``a[:, i]`` to ``b[:, j]``.
185
+ """
186
+ if method not in DIST_FUNCS:
187
+ raise ValueError(
188
+ f"Unknown distance method {method!r}; choose from {sorted(DIST_FUNCS)}"
189
+ )
190
+
191
+ a = np.asarray(a, dtype=float)
192
+ b = np.asarray(b, dtype=float)
193
+
194
+ if method == "jac":
195
+ return _cross_jac(a, b, **kwargs)
196
+ if kwargs:
197
+ unexpected = ", ".join(sorted(kwargs))
198
+ raise TypeError(
199
+ f"unexpected keyword arguments for method {method!r}: {unexpected}"
200
+ )
201
+
202
+ if method == "klb":
203
+ return _cross_klb(a, b)
204
+ if method in ("sklb", "jef"):
205
+ # Jeffrey's divergence equals the symmetric KL divergence for
206
+ # normalized distributions.
207
+ return _cross_klb(a, b) + _cross_klb(b, a).T # pylint: disable=arguments-out-of-order
208
+ if method == "jsd":
209
+ return _cross_jsd(a, b)
210
+ if method == "hel":
211
+ root_a = np.sqrt(_sanitize_columns(a))
212
+ root_b = np.sqrt(_sanitize_columns(b))
213
+ return distance.cdist(root_a.T, root_b.T, "euclidean") / np.sqrt(2)
214
+ if method == "bhat":
215
+ return _cross_bhat(a, b)
216
+ if method == "tv":
217
+ return distance.cdist(a.T, b.T, "cityblock") / 2
218
+ raise AssertionError(f"validated distance method {method!r} was not handled")
219
+
220
+
221
+ def get_topics_dist(
222
+ phi: Union[np.ndarray, DataFrame], method: str = "sklb", **kwargs
223
+ ) -> np.ndarray:
224
+ """Finding closest topics in models.
225
+
226
+ Parameters
227
+ ----------
228
+ phi : Union[ndarray, DataFrame]
229
+ Words vs topics matrix (W x T).
230
+ method : str = "sklb"
231
+ Comparison method. Possible variants:
232
+ 1) "klb" - Kullback-Leibler divergence.
233
+ 2) "sklb" - Symmetric Kullback-Leibler divergence.
234
+ 3) "jsd" - Jensen-Shannon divergence.
235
+ 4) "jef" - Jeffrey's divergence.
236
+ 5) "hel" - Hellinger distance.
237
+ 6) "bhat" - Bhattacharyya distance.
238
+ 7) "tv" — Total variation distance.
239
+ 8) "jac" - Jaccard index.
240
+ **kwargs : dict
241
+ Keyword arguments passed to distance function.
242
+
243
+ Returns
244
+ -------
245
+ numpy.ndarray
246
+ Topics distances matrix.
247
+ """
248
+ phi_copy = np.array(phi)
249
+
250
+ if phi_copy.ndim != 2:
251
+ raise ValueError("phi must be a 2D array (words x topics)")
252
+ if np.any(phi_copy < 0):
253
+ raise ValueError("phi must contain non-negative probability values")
254
+ if not np.allclose(phi_copy.sum(axis=0), 1.0, atol=1e-6):
255
+ raise ValueError("phi columns must sum to 1 (probability distributions)")
256
+
257
+ topics_dists = _cross_dists(phi_copy, phi_copy, method, **kwargs)
258
+
259
+ # Asymmetric divergences (e.g. "klb") are mirrored across the diagonal: the
260
+ # value computed for the pair (i, j) with i < j is stored in both [i, j] and
261
+ # [j, i]. Downstream consumers such as get_topics_scatter require a
262
+ # symmetric matrix.
263
+ upper = np.triu(topics_dists, 1)
264
+ return upper + upper.T
265
+
266
+
267
+ def _classical_mds(distances: np.ndarray, n_components: int = 2) -> np.ndarray:
268
+ count = distances.shape[0]
269
+ centering = np.eye(count) - np.ones((count, count)) / count
270
+ gram = -0.5 * centering @ (distances**2) @ centering
271
+ eigenvalues, eigenvectors = np.linalg.eigh(gram)
272
+ # finfo builds its attributes at runtime, so pylint misses .eps.
273
+ positive = eigenvalues > np.finfo(float).eps # pylint: disable=no-member
274
+ coords = eigenvectors[:, positive] * np.sqrt(eigenvalues[positive])
275
+
276
+ # LocallyLinearEmbedding rejects an input with fewer dimensions than it is
277
+ # asked to produce. A degenerate distance matrix - identical topics, or a
278
+ # model that never separated - leaves fewer positive eigenvalues than that,
279
+ # so pad with zero columns instead of handing over a narrower matrix.
280
+ if coords.shape[1] < n_components:
281
+ padding = np.zeros((count, n_components - coords.shape[1]))
282
+ coords = np.hstack([coords, padding])
283
+ return coords
284
+
285
+
286
+ def get_topics_scatter(
287
+ topic_dists: np.ndarray,
288
+ theta: np.ndarray,
289
+ method: str = "tsne",
290
+ method_kws: Optional[dict] = None,
291
+ ) -> DataFrame:
292
+ """Calculate topics coordinates for a scatter plot.
293
+
294
+ Parameters
295
+ ----------
296
+ topic_dists : numpy.ndarray
297
+ Topics distance matrix.
298
+ theta : numpy.ndarray
299
+ Topics vs documents probability matrix.
300
+ method : str = 'tsne'
301
+ Method to calculate topics scatter coordinates (X and Y).
302
+ Possible values:
303
+ 1) 'tsne' - t-distributed Stochastic Neighbor Embedding.
304
+ 2) 'sem' - SpectralEmbedding.
305
+ 3) 'mds' - MDS.
306
+ 4) 'lle' - LocallyLinearEmbedding.
307
+ 5) 'ltsa' - LocallyLinearEmbedding with LTDA method.
308
+ 6) 'isomap' - Isomap.
309
+ method_kws : dict = None
310
+ Keyword arguments passed to method function.
311
+
312
+ Returns
313
+ -------
314
+ DataFrame
315
+ Topics scatter coordinates.
316
+ """
317
+ topic_dists = np.asarray(topic_dists, dtype=float)
318
+ theta = np.asarray(theta, dtype=float)
319
+ if topic_dists.ndim != 2 or topic_dists.shape[0] != topic_dists.shape[1]:
320
+ raise ValueError("topic_dists must be a square 2D matrix")
321
+ if not np.isfinite(topic_dists).all():
322
+ raise ValueError("topic_dists must contain only finite values")
323
+ if not np.allclose(topic_dists, topic_dists.T):
324
+ raise ValueError("topic_dists must be symmetric")
325
+ if theta.ndim != 2 or theta.shape[0] != topic_dists.shape[0]:
326
+ raise ValueError("theta topics dimension must match topic_dists")
327
+ if topic_dists.shape[0] < 2:
328
+ raise ValueError("at least two topics are required for scatter coordinates")
329
+
330
+ if method not in SCATTER_METHODS:
331
+ raise ValueError(
332
+ f"Unknown scatter method {method!r}; choose from {SCATTER_METHODS}"
333
+ )
334
+
335
+ if topic_dists.shape[0] == 2:
336
+ half_distance = topic_dists[0, 1] / 2
337
+ coords = np.array([[-half_distance, 0.0], [half_distance, 0.0]])
338
+ topics_xy = DataFrame(coords, columns=Index(["x", "y"]))
339
+ topics_xy["topic"] = topics_xy.index.astype(int)
340
+ topics_xy["size"] = calc_topics_marg_probs(theta) * 100
341
+ return topics_xy
342
+
343
+ method_kws = dict(method_kws or {})
344
+ method_kws.setdefault("n_components", 2)
345
+
346
+ # Most methods consume the distance matrix directly; the branches below
347
+ # override this when a method needs a different representation.
348
+ transform_input = topic_dists
349
+
350
+ if method == "tsne":
351
+ method_kws.setdefault("metric", "precomputed")
352
+ method_kws.setdefault("init", "random")
353
+ method_kws.setdefault("learning_rate", "auto")
354
+ method_kws.setdefault("perplexity", min(50, max(topic_dists.shape[0] // 2, 1)))
355
+ transformer = TSNE(**method_kws)
356
+
357
+ elif method == "sem":
358
+ method_kws.setdefault("affinity", "precomputed")
359
+ transformer = SpectralEmbedding(**method_kws)
360
+ nonzero = topic_dists[topic_dists > 0]
361
+ scale = np.median(nonzero) if nonzero.size else 1.0
362
+ transform_input = np.exp(-((topic_dists / scale) ** 2))
363
+ np.fill_diagonal(transform_input, 1.0)
364
+
365
+ elif method == "mds":
366
+ mds_params = signature(MDS.__init__).parameters
367
+ if "metric_mds" in mds_params:
368
+ # scikit-learn >= 1.9 deprecated `dissimilarity` in favor of `metric`
369
+ method_kws.setdefault("metric", "precomputed")
370
+ else:
371
+ method_kws.setdefault("dissimilarity", "precomputed")
372
+ method_kws.setdefault("normalized_stress", "auto")
373
+ method_kws.setdefault("n_init", 1)
374
+ if "init" in mds_params:
375
+ # `init` is a constructor argument only since scikit-learn 1.9
376
+ method_kws.setdefault("init", "random")
377
+ transformer = MDS(**method_kws)
378
+
379
+ elif method == "lle":
380
+ method_kws["method"] = "standard"
381
+ method_kws.setdefault("n_neighbors", min(5, topic_dists.shape[0] - 1))
382
+ transformer = LocallyLinearEmbedding(**method_kws)
383
+ transform_input = _classical_mds(topic_dists, method_kws["n_components"])
384
+
385
+ elif method == "ltsa":
386
+ method_kws["method"] = "ltsa"
387
+ method_kws.setdefault("n_neighbors", min(5, topic_dists.shape[0] - 1))
388
+ transformer = LocallyLinearEmbedding(**method_kws)
389
+ transform_input = _classical_mds(topic_dists, method_kws["n_components"])
390
+
391
+ elif method == "isomap":
392
+ method_kws.setdefault("metric", "precomputed")
393
+ method_kws.setdefault("n_neighbors", min(5, topic_dists.shape[0] - 1))
394
+ transformer = Isomap(**method_kws)
395
+
396
+ else:
397
+ raise AssertionError("validated scatter method was not handled")
398
+
399
+ coords = transformer.fit_transform(transform_input)
400
+
401
+ topics_xy = DataFrame(coords, columns=Index(["x", "y"]))
402
+ topics_xy["topic"] = topics_xy.index.astype(int)
403
+ # calc_topics_marg_probs already rejects an all-zero theta and returns
404
+ # probabilities summing to 1, so scaling to percentages is unconditional.
405
+ topics_xy["size"] = calc_topics_marg_probs(theta) * 100
406
+ return topics_xy
407
+
408
+
409
+ def get_top_topic_words(
410
+ phi: DataFrame,
411
+ words_num: int = 20,
412
+ topics_idx: Optional[Union[list[int], np.ndarray]] = None,
413
+ ) -> DataFrame:
414
+ """Select top topic words from a fitted model.
415
+
416
+ Parameters
417
+ ----------
418
+ phi : pandas.DataFrame
419
+ Words vs topics matrix (phi) with words as
420
+ indices and topics as columns.
421
+ words_num : int = 20
422
+ The number of words to select.
423
+ topics_idx : Union[List, numpy.ndarray] = None
424
+ Topics indices.
425
+
426
+ Returns
427
+ -------
428
+ DataFrame
429
+ Words with highest probabilities in all (or selected) topics.
430
+ """
431
+ selected_topics = phi.columns if topics_idx is None else topics_idx
432
+ return phi.loc[:, selected_topics].apply(
433
+ lambda x: x.sort_values(ascending=False).head(words_num).index, axis=0
434
+ )