tmplot 0.4.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.
- {tmplot-0.4.0 → tmplot-0.5.0}/MANIFEST.in +0 -1
- {tmplot-0.4.0/src/tmplot.egg-info → tmplot-0.5.0}/PKG-INFO +3 -2
- {tmplot-0.4.0 → tmplot-0.5.0}/pyproject.toml +34 -1
- {tmplot-0.4.0 → tmplot-0.5.0}/src/tmplot/__init__.py +5 -3
- {tmplot-0.4.0 → tmplot-0.5.0}/src/tmplot/_distance.py +190 -60
- {tmplot-0.4.0 → tmplot-0.5.0}/src/tmplot/_helpers.py +163 -59
- tmplot-0.5.0/src/tmplot/_metrics.py +203 -0
- {tmplot-0.4.0 → tmplot-0.5.0}/src/tmplot/_report.py +50 -29
- {tmplot-0.4.0 → tmplot-0.5.0}/src/tmplot/_stability.py +34 -43
- {tmplot-0.4.0 → tmplot-0.5.0}/src/tmplot/_vis.py +46 -36
- {tmplot-0.4.0 → tmplot-0.5.0/src/tmplot.egg-info}/PKG-INFO +3 -2
- {tmplot-0.4.0 → tmplot-0.5.0}/src/tmplot.egg-info/requires.txt +1 -1
- tmplot-0.4.0/src/tmplot/_metrics.py +0 -92
- {tmplot-0.4.0 → tmplot-0.5.0}/LICENSE +0 -0
- {tmplot-0.4.0 → tmplot-0.5.0}/README.md +0 -0
- {tmplot-0.4.0 → tmplot-0.5.0}/setup.cfg +0 -0
- {tmplot-0.4.0 → tmplot-0.5.0}/src/tmplot.egg-info/SOURCES.txt +0 -0
- {tmplot-0.4.0 → tmplot-0.5.0}/src/tmplot.egg-info/dependency_links.txt +0 -0
- {tmplot-0.4.0 → tmplot-0.5.0}/src/tmplot.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tmplot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Visualization of Topic Modeling Results
|
|
5
5
|
Author-email: Maksim Terpilovskii <maximtrp@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.9
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.10
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.11
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
16
|
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
16
17
|
Classifier: Topic :: Text Processing :: General
|
|
17
18
|
Requires-Python: >=3.9
|
|
@@ -33,7 +34,7 @@ Requires-Dist: twine; extra == "test"
|
|
|
33
34
|
Provides-Extra: models
|
|
34
35
|
Requires-Dist: tomotopy>=0.8.0; extra == "models"
|
|
35
36
|
Requires-Dist: gensim; extra == "models"
|
|
36
|
-
Requires-Dist: bitermplus; extra == "models"
|
|
37
|
+
Requires-Dist: bitermplus>=1.0; extra == "models"
|
|
37
38
|
Dynamic: license-file
|
|
38
39
|
|
|
39
40
|
# tmplot
|
|
@@ -23,6 +23,7 @@ classifiers = [
|
|
|
23
23
|
"Programming Language :: Python :: 3.10",
|
|
24
24
|
"Programming Language :: Python :: 3.11",
|
|
25
25
|
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
26
27
|
"Topic :: Scientific/Engineering :: Information Analysis",
|
|
27
28
|
"Topic :: Text Processing :: General",
|
|
28
29
|
]
|
|
@@ -42,7 +43,7 @@ dependencies = [
|
|
|
42
43
|
|
|
43
44
|
[project.optional-dependencies]
|
|
44
45
|
test = ["pytest", "coverage", "build", "twine"]
|
|
45
|
-
models = ["tomotopy>=0.8.0", "gensim", "bitermplus"]
|
|
46
|
+
models = ["tomotopy>=0.8.0", "gensim", "bitermplus>=1.0"]
|
|
46
47
|
|
|
47
48
|
[tool.setuptools.dynamic]
|
|
48
49
|
version = {attr = "tmplot.__version__"}
|
|
@@ -52,3 +53,35 @@ log_cli = true
|
|
|
52
53
|
log_cli_level = "INFO"
|
|
53
54
|
log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)"
|
|
54
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
|
|
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.
|
|
10
|
+
__version__ = "0.5.0"
|
|
@@ -1,24 +1,36 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
__all__ = ["get_top_topic_words", "get_topics_dist", "get_topics_scatter"]
|
|
3
4
|
from inspect import signature
|
|
4
|
-
from
|
|
5
|
-
|
|
5
|
+
from typing import Optional, Union
|
|
6
|
+
|
|
6
7
|
import numpy as np
|
|
7
|
-
from
|
|
8
|
+
from pandas import DataFrame, Index
|
|
8
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
|
|
9
13
|
from sklearn.manifold import (
|
|
14
|
+
MDS,
|
|
10
15
|
TSNE,
|
|
11
16
|
Isomap,
|
|
12
17
|
LocallyLinearEmbedding,
|
|
13
|
-
MDS,
|
|
14
18
|
SpectralEmbedding,
|
|
15
19
|
)
|
|
16
|
-
from ._helpers import calc_topics_marg_probs
|
|
17
20
|
|
|
21
|
+
from ._helpers import calc_topics_marg_probs
|
|
18
22
|
|
|
19
23
|
EPSILON = 1e-64
|
|
20
24
|
|
|
21
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
|
+
|
|
22
34
|
def _positive_probabilities(values: np.ndarray) -> np.ndarray:
|
|
23
35
|
values = np.clip(np.asarray(values, dtype=float), EPSILON, None)
|
|
24
36
|
return values / values.sum()
|
|
@@ -51,29 +63,159 @@ def _dist_hel(a1: np.ndarray, a2: np.ndarray):
|
|
|
51
63
|
a2_safe = a2.copy()
|
|
52
64
|
a1_safe[(a1_safe <= 0) | ~np.isfinite(a1_safe)] = EPSILON
|
|
53
65
|
a2_safe[(a2_safe <= 0) | ~np.isfinite(a2_safe)] = EPSILON
|
|
54
|
-
|
|
55
|
-
return hel_val
|
|
66
|
+
return distance.euclidean(np.sqrt(a1_safe), np.sqrt(a2_safe)) / np.sqrt(2)
|
|
56
67
|
|
|
57
68
|
|
|
58
69
|
def _dist_bhat(a1: np.ndarray, a2: np.ndarray):
|
|
59
70
|
pq = a1 * a2
|
|
60
71
|
pq[(pq <= 0) | ~np.isfinite(pq)] = EPSILON
|
|
61
|
-
|
|
62
|
-
return dist
|
|
72
|
+
return -np.log(np.sum(np.sqrt(pq)))
|
|
63
73
|
|
|
64
74
|
|
|
65
75
|
def _dist_tv(a1: np.ndarray, a2: np.ndarray):
|
|
66
|
-
|
|
67
|
-
return dist
|
|
76
|
+
return np.sum(np.abs(a1 - a2)) / 2
|
|
68
77
|
|
|
69
78
|
|
|
70
79
|
def _dist_jac(a1: np.ndarray, a2: np.ndarray, top_words=100):
|
|
80
|
+
_validate_top_words(top_words)
|
|
71
81
|
a = np.argsort(a1)[: -top_words - 1 : -1]
|
|
72
82
|
b = np.argsort(a2)[: -top_words - 1 : -1]
|
|
73
83
|
j_num = np.intersect1d(a, b, assume_unique=False).size
|
|
74
84
|
j_den = np.union1d(a, b).size
|
|
75
|
-
|
|
76
|
-
|
|
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")
|
|
77
219
|
|
|
78
220
|
|
|
79
221
|
def get_topics_dist(
|
|
@@ -112,45 +254,33 @@ def get_topics_dist(
|
|
|
112
254
|
if not np.allclose(phi_copy.sum(axis=0), 1.0, atol=1e-6):
|
|
113
255
|
raise ValueError("phi columns must sum to 1 (probability distributions)")
|
|
114
256
|
|
|
115
|
-
|
|
116
|
-
topics_pairs = combinations(range(topics_num), 2)
|
|
117
|
-
|
|
118
|
-
# Topics distances matrix
|
|
119
|
-
topics_dists = np.zeros(shape=(topics_num, topics_num), dtype=float)
|
|
257
|
+
topics_dists = _cross_dists(phi_copy, phi_copy, method, **kwargs)
|
|
120
258
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
"bhat": _dist_bhat,
|
|
128
|
-
"tv": _dist_tv,
|
|
129
|
-
"jac": _dist_jac,
|
|
130
|
-
}
|
|
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
|
|
131
265
|
|
|
132
|
-
if method not in dist_funcs:
|
|
133
|
-
raise ValueError(
|
|
134
|
-
f"Unknown distance method {method!r}; choose from {sorted(dist_funcs)}"
|
|
135
|
-
)
|
|
136
|
-
_dist_func = dist_funcs[method]
|
|
137
|
-
for i, j in topics_pairs:
|
|
138
|
-
topics_dists[((i, j), (j, i))] = _dist_func(
|
|
139
|
-
phi_copy[:, i], phi_copy[:, j], **kwargs
|
|
140
|
-
)
|
|
141
266
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def _classical_mds(distances: np.ndarray) -> np.ndarray:
|
|
267
|
+
def _classical_mds(distances: np.ndarray, n_components: int = 2) -> np.ndarray:
|
|
146
268
|
count = distances.shape[0]
|
|
147
269
|
centering = np.eye(count) - np.ones((count, count)) / count
|
|
148
270
|
gram = -0.5 * centering @ (distances**2) @ centering
|
|
149
271
|
eigenvalues, eigenvectors = np.linalg.eigh(gram)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
154
284
|
|
|
155
285
|
|
|
156
286
|
def get_topics_scatter(
|
|
@@ -197,10 +327,9 @@ def get_topics_scatter(
|
|
|
197
327
|
if topic_dists.shape[0] < 2:
|
|
198
328
|
raise ValueError("at least two topics are required for scatter coordinates")
|
|
199
329
|
|
|
200
|
-
|
|
201
|
-
if method not in valid_methods:
|
|
330
|
+
if method not in SCATTER_METHODS:
|
|
202
331
|
raise ValueError(
|
|
203
|
-
f"Unknown scatter method {method!r}; choose from {
|
|
332
|
+
f"Unknown scatter method {method!r}; choose from {SCATTER_METHODS}"
|
|
204
333
|
)
|
|
205
334
|
|
|
206
335
|
if topic_dists.shape[0] == 2:
|
|
@@ -214,6 +343,10 @@ def get_topics_scatter(
|
|
|
214
343
|
method_kws = dict(method_kws or {})
|
|
215
344
|
method_kws.setdefault("n_components", 2)
|
|
216
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
|
+
|
|
217
350
|
if method == "tsne":
|
|
218
351
|
method_kws.setdefault("metric", "precomputed")
|
|
219
352
|
method_kws.setdefault("init", "random")
|
|
@@ -247,13 +380,13 @@ def get_topics_scatter(
|
|
|
247
380
|
method_kws["method"] = "standard"
|
|
248
381
|
method_kws.setdefault("n_neighbors", min(5, topic_dists.shape[0] - 1))
|
|
249
382
|
transformer = LocallyLinearEmbedding(**method_kws)
|
|
250
|
-
transform_input = _classical_mds(topic_dists)
|
|
383
|
+
transform_input = _classical_mds(topic_dists, method_kws["n_components"])
|
|
251
384
|
|
|
252
385
|
elif method == "ltsa":
|
|
253
386
|
method_kws["method"] = "ltsa"
|
|
254
387
|
method_kws.setdefault("n_neighbors", min(5, topic_dists.shape[0] - 1))
|
|
255
388
|
transformer = LocallyLinearEmbedding(**method_kws)
|
|
256
|
-
transform_input = _classical_mds(topic_dists)
|
|
389
|
+
transform_input = _classical_mds(topic_dists, method_kws["n_components"])
|
|
257
390
|
|
|
258
391
|
elif method == "isomap":
|
|
259
392
|
method_kws.setdefault("metric", "precomputed")
|
|
@@ -263,23 +396,20 @@ def get_topics_scatter(
|
|
|
263
396
|
else:
|
|
264
397
|
raise AssertionError("validated scatter method was not handled")
|
|
265
398
|
|
|
266
|
-
coords = transformer.fit_transform(
|
|
399
|
+
coords = transformer.fit_transform(transform_input)
|
|
267
400
|
|
|
268
401
|
topics_xy = DataFrame(coords, columns=Index(["x", "y"]))
|
|
269
402
|
topics_xy["topic"] = topics_xy.index.astype(int)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
topics_xy["size"] *= 100 / topics_xy["size"].sum()
|
|
274
|
-
else:
|
|
275
|
-
topics_xy["size"] = np.nan
|
|
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
|
|
276
406
|
return topics_xy
|
|
277
407
|
|
|
278
408
|
|
|
279
409
|
def get_top_topic_words(
|
|
280
410
|
phi: DataFrame,
|
|
281
411
|
words_num: int = 20,
|
|
282
|
-
topics_idx: Optional[Union[
|
|
412
|
+
topics_idx: Optional[Union[list[int], np.ndarray]] = None,
|
|
283
413
|
) -> DataFrame:
|
|
284
414
|
"""Select top topic words from a fitted model.
|
|
285
415
|
|