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.
@@ -1,4 +1,3 @@
1
1
  include LICENSE
2
2
  include README.md
3
- include CHANGELOG.md
4
3
  recursive-exclude tests *
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tmplot
3
- Version: 0.4.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 ._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.4.0"
10
+ __version__ = "0.5.0"
@@ -1,24 +1,36 @@
1
- __all__ = ["get_topics_dist", "get_topics_scatter", "get_top_topic_words"]
2
- from typing import Optional, Union, List
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 itertools import combinations
5
- from pandas import DataFrame, Index
5
+ from typing import Optional, Union
6
+
6
7
  import numpy as np
7
- from scipy.special import kl_div
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
- hel_val = distance.euclidean(np.sqrt(a1_safe), np.sqrt(a2_safe)) / np.sqrt(2)
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
- dist = -np.log(np.sum(np.sqrt(pq)))
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
- dist = np.sum(np.abs(a1 - a2)) / 2
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
- jac_val = 1 - j_num / j_den
76
- return jac_val
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
- topics_num = phi_copy.shape[1]
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
- dist_funcs = {
122
- "klb": _dist_klb,
123
- "sklb": _dist_sklb,
124
- "jsd": _dist_jsd,
125
- "jef": _dist_jef,
126
- "hel": _dist_hel,
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
- return topics_dists
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
- positive = eigenvalues > np.finfo(float).eps
151
- if not positive.any():
152
- return np.zeros((count, 1))
153
- return eigenvectors[:, positive] * np.sqrt(eigenvalues[positive])
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
- valid_methods = ["tsne", "sem", "mds", "lle", "ltsa", "isomap"]
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 {valid_methods}"
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(locals().get("transform_input", topic_dists))
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
- topics_xy["size"] = calc_topics_marg_probs(theta)
271
- size_sum = topics_xy["size"].sum()
272
- if size_sum > 0:
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[List[int], np.ndarray]] = None,
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