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.
- {tmplot-0.3.0/src/tmplot.egg-info → tmplot-0.5.0}/PKG-INFO +17 -37
- {tmplot-0.3.0 → tmplot-0.5.0}/README.md +2 -2
- tmplot-0.5.0/pyproject.toml +87 -0
- {tmplot-0.3.0 → tmplot-0.5.0}/src/tmplot/__init__.py +5 -3
- tmplot-0.5.0/src/tmplot/_distance.py +434 -0
- tmplot-0.5.0/src/tmplot/_helpers.py +657 -0
- tmplot-0.5.0/src/tmplot/_metrics.py +203 -0
- {tmplot-0.3.0 → tmplot-0.5.0}/src/tmplot/_report.py +110 -58
- {tmplot-0.3.0 → tmplot-0.5.0}/src/tmplot/_stability.py +50 -45
- {tmplot-0.3.0 → tmplot-0.5.0}/src/tmplot/_vis.py +60 -45
- {tmplot-0.3.0 → tmplot-0.5.0/src/tmplot.egg-info}/PKG-INFO +17 -37
- tmplot-0.5.0/src/tmplot.egg-info/requires.txt +19 -0
- tmplot-0.3.0/pyproject.toml +0 -55
- tmplot-0.3.0/src/tmplot/_distance.py +0 -234
- tmplot-0.3.0/src/tmplot/_helpers.py +0 -472
- tmplot-0.3.0/src/tmplot/_metrics.py +0 -80
- tmplot-0.3.0/src/tmplot.egg-info/requires.txt +0 -17
- {tmplot-0.3.0 → tmplot-0.5.0}/LICENSE +0 -0
- {tmplot-0.3.0 → tmplot-0.5.0}/MANIFEST.in +0 -0
- {tmplot-0.3.0 → tmplot-0.5.0}/setup.cfg +0 -0
- {tmplot-0.3.0 → tmplot-0.5.0}/src/tmplot.egg-info/SOURCES.txt +0 -0
- {tmplot-0.3.0 → tmplot-0.5.0}/src/tmplot.egg-info/dependency_links.txt +0 -0
- {tmplot-0.3.0 → tmplot-0.5.0}/src/tmplot.egg-info/top_level.txt +0 -0
|
@@ -1,60 +1,40 @@
|
|
|
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
|
-
License: MIT
|
|
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.
|
|
18
|
+
Requires-Python: >=3.9
|
|
41
19
|
Description-Content-Type: text/markdown
|
|
42
20
|
License-File: LICENSE
|
|
43
|
-
Requires-Dist: numpy
|
|
44
|
-
Requires-Dist:
|
|
45
|
-
Requires-Dist:
|
|
46
|
-
Requires-Dist:
|
|
47
|
-
Requires-Dist:
|
|
48
|
-
Requires-Dist:
|
|
49
|
-
Requires-Dist:
|
|
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='
|
|
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
|
-
- [
|
|
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='
|
|
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
|
-
- [
|
|
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
|
|
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"
|
|
@@ -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
|
+
)
|