tmplot 0.1.1__tar.gz → 0.1.3__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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tmplot
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Visualization of Topic Modeling Results
5
5
  Author-email: Maksim Terpilovskii <maximtrp@gmail.com>
6
6
  License: MIT License
@@ -50,7 +50,6 @@ Requires-Dist: ipywidgets
50
50
  Requires-Dist: tqdm
51
51
  Provides-Extra: test
52
52
  Requires-Dist: pytest; extra == "test"
53
- Requires-Dist: codecov; extra == "test"
54
53
  Requires-Dist: coverage; extra == "test"
55
54
  Provides-Extra: models
56
55
  Requires-Dist: tomotopy>=0.8.0; extra == "models"
@@ -59,7 +58,8 @@ Requires-Dist: bitermplus; extra == "models"
59
58
 
60
59
  # tmplot
61
60
 
62
- [![Codecov](https://img.shields.io/codecov/c/gh/maximtrp/tmplot?token=APKBRPDKXS&label=coverage)](https://codecov.io/gh/maximtrp/tmplot)
61
+ [![Codacy coverage](https://img.shields.io/codacy/coverage/5939b1cf99bc4f9d91de11c0d3ff9e50)](https://app.codacy.com/gh/maximtrp/tmplot/coverage)
62
+ [![Codacy grade](https://img.shields.io/codacy/grade/5939b1cf99bc4f9d91de11c0d3ff9e50)](https://app.codacy.com/gh/maximtrp/tmplot)
63
63
  [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/maximtrp/tmplot/python-package.yml?label=tests)](https://github.com/maximtrp/tmplot/actions/workflows/python-package.yml)
64
64
  [![Documentation Status](https://readthedocs.org/projects/tmplot/badge/?version=latest)](https://tmplot.readthedocs.io/en/latest/?badge=latest)
65
65
  [![Downloads](https://static.pepy.tech/badge/tmplot)](https://pepy.tech/project/tmplot)
@@ -96,6 +96,12 @@ Requires-Dist: bitermplus; extra == "models"
96
96
  - LocallyLinearEmbedding
97
97
  - Isomap
98
98
 
99
+ ## Donate
100
+
101
+ If you find this package useful, please consider donating any amount of money. This will help me spend more time on supporting open-source software.
102
+
103
+ <a href="https://www.buymeacoffee.com/maximtrp" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
104
+
99
105
  ## Installation
100
106
 
101
107
  The package can be installed from PyPi:
@@ -1,6 +1,7 @@
1
1
  # tmplot
2
2
 
3
- [![Codecov](https://img.shields.io/codecov/c/gh/maximtrp/tmplot?token=APKBRPDKXS&label=coverage)](https://codecov.io/gh/maximtrp/tmplot)
3
+ [![Codacy coverage](https://img.shields.io/codacy/coverage/5939b1cf99bc4f9d91de11c0d3ff9e50)](https://app.codacy.com/gh/maximtrp/tmplot/coverage)
4
+ [![Codacy grade](https://img.shields.io/codacy/grade/5939b1cf99bc4f9d91de11c0d3ff9e50)](https://app.codacy.com/gh/maximtrp/tmplot)
4
5
  [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/maximtrp/tmplot/python-package.yml?label=tests)](https://github.com/maximtrp/tmplot/actions/workflows/python-package.yml)
5
6
  [![Documentation Status](https://readthedocs.org/projects/tmplot/badge/?version=latest)](https://tmplot.readthedocs.io/en/latest/?badge=latest)
6
7
  [![Downloads](https://static.pepy.tech/badge/tmplot)](https://pepy.tech/project/tmplot)
@@ -37,6 +38,12 @@
37
38
  - LocallyLinearEmbedding
38
39
  - Isomap
39
40
 
41
+ ## Donate
42
+
43
+ If you find this package useful, please consider donating any amount of money. This will help me spend more time on supporting open-source software.
44
+
45
+ <a href="https://www.buymeacoffee.com/maximtrp" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
46
+
40
47
  ## Installation
41
48
 
42
49
  The package can be installed from PyPi:
@@ -42,7 +42,7 @@ dependencies = [
42
42
  ]
43
43
 
44
44
  [project.optional-dependencies]
45
- test = ["pytest", "codecov", "coverage"]
45
+ test = ["pytest", "coverage"]
46
46
  models = ["tomotopy>=0.8.0", "gensim", "bitermplus"]
47
47
 
48
48
  [tool.setuptools.dynamic]
@@ -5,4 +5,4 @@ from ._stability import * # noqa: F401, F403
5
5
  from ._vis import * # noqa: F401, F403
6
6
  from ._metrics import * # noqa: F401, F403
7
7
 
8
- __version__ = '0.1.1'
8
+ __version__ = '0.1.3'
@@ -1,18 +1,24 @@
1
1
  __all__ = [
2
- 'get_phi', 'get_theta',
3
- 'get_relevant_terms', 'get_salient_terms',
4
- 'get_docs', 'get_top_docs',
5
- 'calc_terms_marg_probs', 'calc_topics_marg_probs',
6
- 'calc_terms_probs_ratio']
2
+ "get_phi",
3
+ "get_theta",
4
+ "get_relevant_terms",
5
+ "get_salient_terms",
6
+ "get_docs",
7
+ "get_top_docs",
8
+ "calc_terms_marg_probs",
9
+ "calc_topics_marg_probs",
10
+ "calc_terms_probs_ratio",
11
+ ]
7
12
  from warnings import warn
8
13
  from importlib.util import find_spec
9
14
  from typing import Union, Optional, Sequence, List
10
15
  from functools import partial
11
16
  from math import log
12
17
  from numpy import ndarray, zeros, argsort, array, arange, vstack
18
+ from numpy import log as nplog
13
19
  from pandas import concat, Series, DataFrame
14
20
 
15
- tomotopy_installed = find_spec('tomotopy')
21
+ tomotopy_installed = find_spec("tomotopy")
16
22
  if tomotopy_installed:
17
23
  from tomotopy import (
18
24
  LDAModel as tomotopyLDA,
@@ -22,14 +28,15 @@ if tomotopy_installed:
22
28
  HDPModel as tomotopyHDP,
23
29
  PTModel as tomotopyPT,
24
30
  SLDAModel as tomotopySLDA,
25
- GDMRModel as tomotopyGDMR)
31
+ GDMRModel as tomotopyGDMR,
32
+ )
26
33
 
27
- gensim_installed = find_spec('gensim')
34
+ gensim_installed = find_spec("gensim")
28
35
  if gensim_installed:
29
36
  from gensim.models.ldamodel import LdaModel as gensimLDA
30
37
  from gensim.models.ldamulticore import LdaMulticore as gensimLDAMC
31
38
 
32
- bitermplus_installed = find_spec('bitermplus')
39
+ bitermplus_installed = find_spec("bitermplus")
33
40
  if bitermplus_installed:
34
41
  from bitermplus._btm import BTM
35
42
 
@@ -37,12 +44,11 @@ if bitermplus_installed:
37
44
  def __warn_package_installation(package_name: str):
38
45
  warn(
39
46
  f'Please install "{package_name}" package to analyze its models.\n'
40
- f'Run `pip install {package_name}` in the console.')
47
+ f"Run `pip install {package_name}` in the console."
48
+ )
41
49
 
42
50
 
43
- def get_phi(
44
- model: object,
45
- vocabulary: Optional[Sequence] = None) -> DataFrame:
51
+ def get_phi(model: object, vocabulary: Optional[Sequence] = None) -> DataFrame:
46
52
  """Get words vs topics matrix (phi).
47
53
 
48
54
  Returns ``phi`` matrix of shape W x T, where W is the number of words,
@@ -64,7 +70,6 @@ def get_phi(
64
70
  phi = None
65
71
 
66
72
  if _is_tomotopy(model):
67
-
68
73
  # Topics vs words distributions
69
74
  twd = list(map(model.get_topic_word_dist, range(model.k)))
70
75
 
@@ -75,7 +80,6 @@ def get_phi(
75
80
  phi.index = list(model.used_vocabs)
76
81
 
77
82
  elif _is_gensim(model):
78
-
79
83
  phi = DataFrame(model.get_topics().T)
80
84
  if vocabulary:
81
85
  phi.index = vocabulary
@@ -84,8 +88,8 @@ def get_phi(
84
88
  phi = model.df_words_topics_
85
89
 
86
90
  if isinstance(phi, DataFrame):
87
- phi.index.name = 'words'
88
- phi.columns.name = 'topics'
91
+ phi.index.name = "words"
92
+ phi.columns.name = "topics"
89
93
 
90
94
  return phi
91
95
 
@@ -93,8 +97,15 @@ def get_phi(
93
97
  def _is_tomotopy(model: object) -> bool:
94
98
  if tomotopy_installed:
95
99
  tomotopy_models = [
96
- tomotopyLDA, tomotopyLLDA, tomotopyCT, tomotopyDMR, tomotopyHDP,
97
- tomotopyPT, tomotopySLDA, tomotopyGDMR]
100
+ tomotopyLDA,
101
+ tomotopyLLDA,
102
+ tomotopyCT,
103
+ tomotopyDMR,
104
+ tomotopyHDP,
105
+ tomotopyPT,
106
+ tomotopySLDA,
107
+ tomotopyGDMR,
108
+ ]
98
109
  return any(map(partial(isinstance, model), tomotopy_models))
99
110
 
100
111
  __warn_package_installation("tomotopy")
@@ -118,9 +129,7 @@ def _is_btmplus(model: object) -> bool:
118
129
  return False
119
130
 
120
131
 
121
- def get_theta(
122
- model: object,
123
- corpus: Optional[List] = None) -> DataFrame:
132
+ def get_theta(model: object, corpus: Optional[List] = None) -> Optional[DataFrame]:
124
133
  """Get topics vs documents (theta) matrix.
125
134
 
126
135
  Returns theta matrix of shape T x D, where T is the number of topics,
@@ -131,7 +140,7 @@ def get_theta(
131
140
  model : object
132
141
  Topic model instance.
133
142
  corpus : Optional[List], optional
134
- Corpus.
143
+ Corpus (must be specified for a `gensim` model).
135
144
 
136
145
  Returns
137
146
  -------
@@ -146,8 +155,7 @@ def get_theta(
146
155
 
147
156
  elif _is_gensim(model):
148
157
  if corpus is None:
149
- raise ValueError(
150
- '`corpus` must be supplied for a gensim model')
158
+ raise ValueError("`corpus` must be supplied for a gensim model")
151
159
  tdd = list(map(model.get_document_topics, corpus))
152
160
  theta = DataFrame(zeros((len(tdd), model.num_topics)))
153
161
  for doc_id, doc_topic in enumerate(tdd):
@@ -159,14 +167,13 @@ def get_theta(
159
167
  theta = DataFrame(model.matrix_topics_docs_)
160
168
 
161
169
  if isinstance(theta, DataFrame):
162
- theta.index.name = 'topics'
163
- theta.columns.name = 'docs'
170
+ theta.index.name = "topics"
171
+ theta.columns.name = "docs"
164
172
 
165
173
  return theta
166
174
 
167
175
 
168
- def get_docs(
169
- model: object) -> List[str]:
176
+ def get_docs(model: object) -> Optional[List[str]]:
170
177
  """Retrieve documents from topic model object.
171
178
 
172
179
  Parameters
@@ -182,19 +189,19 @@ def get_docs(
182
189
  if _is_tomotopy(model):
183
190
  docs_raw = map(lambda x: x.words, model.docs)
184
191
  return list(
185
- map(
186
- lambda doc: " ".join(map(lambda x: model.vocabs[x], doc)),
187
- docs_raw))
192
+ map(lambda doc: " ".join(map(lambda x: model.vocabs[x], doc)), docs_raw)
193
+ )
188
194
  return None
189
195
 
190
196
 
191
197
  def get_top_docs(
192
- docs: Sequence[str],
193
- model: object = None,
194
- theta: ndarray = None,
195
- corpus: Optional[List] = None,
196
- docs_num: int = 5,
197
- topics: Sequence[int] = None) -> DataFrame:
198
+ docs: Sequence[str],
199
+ model: object = None,
200
+ theta: Optional[ndarray] = None,
201
+ corpus: Optional[List] = None,
202
+ docs_num: int = 5,
203
+ topics: Optional[Sequence[int]] = None,
204
+ ) -> DataFrame:
198
205
  """Get top documents for all (or a selected) topic.
199
206
 
200
207
  Parameters
@@ -230,20 +237,19 @@ def get_top_docs(
230
237
 
231
238
  def _select_docs(docs, theta, topic_id: int):
232
239
  probs = theta[topic_id, :]
233
- idx = argsort(probs)[:-docs_num-1:-1]
240
+ idx = argsort(probs)[: -docs_num - 1 : -1]
234
241
  result = Series(list(map(lambda x: docs[x], idx)))
235
- result.name = f'topic{topic_id}'
242
+ result.name = f"topic{topic_id}"
236
243
  return result
237
244
 
238
245
  topics_num = theta.shape[0]
239
246
  topics_idx = arange(topics_num) if topics is None else topics
240
- return concat(
241
- map(lambda x: _select_docs(docs, theta, x), topics_idx), axis=1)
247
+ return concat(map(lambda x: _select_docs(docs, theta, x), topics_idx), axis=1)
242
248
 
243
249
 
244
250
  def calc_topics_marg_probs(
245
- theta: Union[DataFrame, ndarray],
246
- topic_id: int = None) -> Union[DataFrame, ndarray]:
251
+ theta: Union[DataFrame, ndarray], topic_id: Optional[int] = None
252
+ ) -> Union[DataFrame, ndarray]:
247
253
  """Calculate marginal topics probabilities.
248
254
 
249
255
  Parameters
@@ -268,8 +274,8 @@ def calc_topics_marg_probs(
268
274
 
269
275
 
270
276
  def calc_terms_marg_probs(
271
- phi: Union[ndarray, DataFrame],
272
- word_id: Optional[int] = None) -> Union[ndarray, Series]:
277
+ phi: Union[ndarray, DataFrame], word_id: Optional[int] = None
278
+ ) -> Union[ndarray, Series]:
273
279
  """Calculate marginal terms probabilities.
274
280
 
275
281
  Parameters
@@ -293,10 +299,7 @@ def calc_terms_marg_probs(
293
299
  return phi.sum(axis=1)
294
300
 
295
301
 
296
- def get_salient_terms(
297
- terms_freqs: ndarray,
298
- phi: ndarray,
299
- theta: ndarray) -> ndarray:
302
+ def get_salient_terms(terms_freqs: ndarray, phi: ndarray, theta: ndarray) -> ndarray:
300
303
  """Get salient terms.
301
304
 
302
305
  Calculated as:
@@ -323,12 +326,18 @@ def get_salient_terms(
323
326
  def _p_tw(phi, w, t):
324
327
  return phi[w, t] * p_t[t] / p_w[w]
325
328
 
326
- saliency = array((
327
- terms_freqs[w] * sum((
328
- _p_tw(phi, w, t) * log(_p_tw(phi, w, t) / p_t[t])
329
- for t in range(phi.shape[1])))
330
- for w in range(phi.shape[0])
331
- ))
329
+ saliency = array(
330
+ (
331
+ terms_freqs[w]
332
+ * sum(
333
+ (
334
+ _p_tw(phi, w, t) * log(_p_tw(phi, w, t) / p_t[t])
335
+ for t in range(phi.shape[1])
336
+ )
337
+ )
338
+ for w in range(phi.shape[0])
339
+ )
340
+ )
332
341
  # saliency(term w) = frequency(w)
333
342
  # * [sum_t p(t | w) * log(p(t | w)/p(t))] for topics t
334
343
  # p(t | w) = p(w | t) * p(t) / p(w)
@@ -336,10 +345,8 @@ def get_salient_terms(
336
345
 
337
346
 
338
347
  def calc_terms_probs_ratio(
339
- phi: DataFrame,
340
- topic: int,
341
- terms_num: int = 30,
342
- lambda_: float = 0.6) -> DataFrame:
348
+ phi: DataFrame, topic: int, terms_num: int = 30, lambda_: float = 0.6
349
+ ) -> DataFrame:
343
350
  """Get terms conditional and marginal probabilities.
344
351
 
345
352
  Parameters
@@ -367,35 +374,38 @@ def calc_terms_probs_ratio(
367
374
  pandas.DataFrame
368
375
  Words conditional and marginal probabilities.
369
376
  """
370
- p_cond_name = 'Conditional term probability, p(w|t)'
371
- p_cond = phi.iloc[:, topic]\
372
- .rename(p_cond_name)\
373
- if isinstance(phi, DataFrame)\
377
+ p_cond_name = "Conditional term probability, p(w | t)"
378
+ p_cond = (
379
+ phi.iloc[:, topic].rename(p_cond_name)
380
+ if isinstance(phi, DataFrame)
374
381
  else Series(phi[:, topic], name=p_cond_name)
382
+ )
375
383
 
376
- p_marg_name = 'Marginal term probability, p(w)'
377
- p_marg = phi.sum(axis=1)\
378
- .rename(p_marg_name)\
379
- if isinstance(phi, DataFrame)\
384
+ p_marg_name = "Marginal term probability, p(w)"
385
+ p_marg = (
386
+ phi.sum(axis=1).rename(p_marg_name)
387
+ if isinstance(phi, DataFrame)
380
388
  else Series(phi[:, topic], name=p_marg_name)
389
+ )
381
390
 
382
391
  terms_probs = concat((p_marg, p_cond), axis=1)
383
392
  relevant_idx = get_relevant_terms(phi, topic, lambda_).index
384
393
  terms_probs_slice = terms_probs.loc[relevant_idx].head(terms_num)
385
394
 
386
- return terms_probs_slice\
387
- .reset_index(drop=False)\
395
+ return (
396
+ terms_probs_slice.reset_index(drop=False)
388
397
  .melt(
389
398
  id_vars=[terms_probs_slice.index.name],
390
- var_name='Type',
391
- value_name='Probability')\
392
- .rename(columns={terms_probs_slice.index.name: 'Terms'})
399
+ var_name="Type",
400
+ value_name="Probability",
401
+ )
402
+ .rename(columns={terms_probs_slice.index.name: "Terms"})
403
+ )
393
404
 
394
405
 
395
406
  def get_relevant_terms(
396
- phi: Union[ndarray, DataFrame],
397
- topic: int,
398
- lambda_: float = 0.6) -> Series:
407
+ phi: Union[ndarray, DataFrame], topic: int, lambda_: float = 0.6
408
+ ) -> Series:
399
409
  """Select relevant terms.
400
410
 
401
411
  Parameters
@@ -421,10 +431,10 @@ def get_relevant_terms(
421
431
  pandas.Series
422
432
  Terms sorted by relevance (descendingly).
423
433
  """
424
- phi_topic = phi.iloc[:, topic]\
425
- if isinstance(phi, DataFrame)\
426
- else phi[:, topic]
427
- # relevance = lambda * p(w | t) + (1 - lambda) * p(w | t)/p(w)
428
- relevance = lambda_ * phi_topic\
429
- + (1 - lambda_) * phi_topic / phi.sum(axis=1)
434
+ phi_topic = phi.iloc[:, topic] if isinstance(phi, DataFrame) else phi[:, topic]
435
+
436
+ # relevance = lambda * log(p(w | t)) + (1 - lambda) * log(p(w | t) / p(w))
437
+ relevance = lambda_ * nplog(phi_topic) + (1 - lambda_) * nplog(
438
+ phi_topic / phi.sum(axis=1)
439
+ )
430
440
  return relevance.sort_values(ascending=False)
@@ -0,0 +1,334 @@
1
+ __all__ = ["prepare_coords", "report"]
2
+ import warnings
3
+ from typing import Dict, Optional, Sequence, List
4
+ from copy import deepcopy
5
+ from IPython.display import display
6
+ from ipywidgets import widgets as wdg
7
+ from pandas import DataFrame
8
+ from ._distance import get_topics_dist, get_topics_scatter
9
+ from ._vis import plot_scatter_topics, plot_terms, plot_docs
10
+ from ._helpers import calc_terms_probs_ratio, get_phi, get_theta, get_top_docs
11
+
12
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
13
+ warnings.filterwarnings("ignore", category=FutureWarning)
14
+
15
+
16
+ def prepare_coords(
17
+ model: object,
18
+ labels: Optional[Sequence] = None,
19
+ corpus: Optional[List] = None,
20
+ dist_kws: Optional[Dict] = None,
21
+ scatter_kws: Optional[Dict] = None,
22
+ ) -> DataFrame:
23
+ """Prepare coordinates for topics scatter plot.
24
+
25
+ Parameters
26
+ ----------
27
+ model : object
28
+ Topic model instance.
29
+ labels : Optional[Sequence]
30
+ Topics labels.
31
+ corpus : Optional[List], optional
32
+ Corpus (must be specified for a `gensim` model).
33
+ dist_kws : dict, optional
34
+ Keyword arguments passed to :py:meth:`tmplot.get_topics_dist()`.
35
+ scatter_kws : dict, optional
36
+ Keyword arguments passed to :py:meth:`tmplot.get_topics_scatter()`.
37
+ """
38
+ if not dist_kws:
39
+ dist_kws = {}
40
+ if not scatter_kws:
41
+ scatter_kws = {}
42
+
43
+ phi = get_phi(model)
44
+ theta = get_theta(model, corpus=corpus)
45
+ topics_dists = get_topics_dist(phi, **dist_kws)
46
+ topics_coords = get_topics_scatter(topics_dists, theta, **scatter_kws)
47
+ topics_coords["label"] = labels or theta.index
48
+ return topics_coords
49
+
50
+
51
+ def report(
52
+ model: object,
53
+ docs: Sequence[str],
54
+ *,
55
+ topics_labels: Optional[Sequence[str]] = None,
56
+ corpus: Optional[List] = None,
57
+ layout: Optional[wdg.Layout] = None,
58
+ show_headers: bool = True,
59
+ show_docs: bool = True,
60
+ show_words: bool = True,
61
+ show_topics: bool = True,
62
+ topics_kws: Optional[dict] = None,
63
+ height: int = 500,
64
+ width: int = 300,
65
+ coords_kws: Optional[dict] = None,
66
+ words_kws: Optional[dict] = None,
67
+ docs_kws: Optional[dict] = None,
68
+ top_docs_kws: Optional[dict] = None,
69
+ ) -> wdg.VBox:
70
+ """Interactive report interface.
71
+
72
+ Parameters
73
+ ----------
74
+ model : object
75
+ Topic model instance.
76
+ docs : Sequence[str]
77
+ Documents.
78
+ topics_labels : Optional[Sequence[str]], optional
79
+ Topics labels.
80
+ corpus : Optional[List[str]], optional
81
+ Gensim corpus (must be specified if you are using a `gensim` model).
82
+ layout : wdg.Layout, optional
83
+ Interface layout instance.
84
+ show_headers : bool, optional
85
+ Show headers.
86
+ show_docs : bool, optional
87
+ Show documents widget.
88
+ show_words : bool, optional
89
+ Show words widget.
90
+ show_topics : bool, optional
91
+ Show topics scatter plot widget.
92
+ topics_kws : dict, optional
93
+ Keyword arguments passed to :py:meth:`tmplot.plot_scatter_topics()`.
94
+ coords_kws : dict, optional
95
+ Keyword arguments passed to :py:meth:`tmplot.prepare_coords()`.
96
+ words_kws : dict, optional
97
+ Keyword arguments passed to :py:meth:`tmplot.plot_terms()`.
98
+ docs_kws : dict, optional
99
+ Keyword arguments passed to :py:meth:`tmplot.plot_docs()`.
100
+ top_docs_kws : dict, optional
101
+ Keyword arguments passed to :py:meth:`tmplot.get_top_docs()`.
102
+
103
+ Returns
104
+ -------
105
+ ipywidgets.widgets.widget_box.VBox
106
+ Report interface as a VBox instance.
107
+ """
108
+
109
+ _topics_kws = (
110
+ {"chart_kws": {"height": height, "width": width}}
111
+ if not topics_kws
112
+ else deepcopy(topics_kws)
113
+ )
114
+ _coords_kws = {"corpus": corpus} if not coords_kws else deepcopy(coords_kws)
115
+ _words_kws = (
116
+ {"chart_kws": {"height": height, "width": width}}
117
+ if not words_kws
118
+ else deepcopy(words_kws)
119
+ )
120
+ _top_docs_kws = {} if not docs_kws else deepcopy(top_docs_kws)
121
+ _docs_kws = {} if not docs_kws else deepcopy(docs_kws)
122
+
123
+ # Headers init
124
+ topics_header = (
125
+ wdg.HTML("<b>Intertopic distance plot</b>")
126
+ if show_headers and show_topics
127
+ else None
128
+ )
129
+ words_header = (
130
+ wdg.HTML("<b>Relevant words (terms)</b>")
131
+ if show_headers and show_words
132
+ else None
133
+ )
134
+ docs_header = (
135
+ wdg.HTML("<b>Top documents in a topic</b>")
136
+ if show_headers and show_docs
137
+ else None
138
+ )
139
+
140
+ # Layout init
141
+ grid_cols = " ".join(["1fr"] * sum([show_docs, show_words, show_topics]))
142
+ layout = (
143
+ wdg.Layout(
144
+ grid_template_columns=grid_cols,
145
+ # justify_items='center'
146
+ )
147
+ if not layout
148
+ else layout
149
+ )
150
+
151
+ # Children widgets list init
152
+ children = []
153
+
154
+ if "topics_coords" not in _topics_kws:
155
+ topics_coords = prepare_coords(model, **_coords_kws)
156
+ _topics_kws.update(
157
+ {
158
+ "topics_coords": topics_coords,
159
+ "label_col": "label",
160
+ "size_col": "size",
161
+ "topic": 0,
162
+ }
163
+ )
164
+
165
+ if "terms_probs" not in _words_kws:
166
+ phi = get_phi(model)
167
+ terms_probs = calc_terms_probs_ratio(phi, topic=0)
168
+ _words_kws.update({"terms_probs": terms_probs})
169
+
170
+ if "docs" not in _docs_kws:
171
+ theta = get_theta(model, corpus=corpus).values
172
+ _top_docs_kws.update(
173
+ {"docs": docs, "theta": theta, "topics": [0], "docs_num": 2}
174
+ )
175
+ top_docs = get_top_docs(**_top_docs_kws)
176
+ top_docs.columns = [""]
177
+ _docs_kws.update({"docs": top_docs})
178
+
179
+ # Topic selection
180
+ def _on_select_topic(sel):
181
+ topic = sel['new']
182
+
183
+ if show_words:
184
+ words_plot_output.clear_output(wait=False)
185
+ with words_plot_output:
186
+ terms_probs = calc_terms_probs_ratio(
187
+ phi, topic=topic, lambda_=lambda_slider.value)
188
+ _words_kws.update({'terms_probs': terms_probs})
189
+ display(plot_terms(**_words_kws))
190
+
191
+ if show_topics:
192
+ topics_plot_output.clear_output(wait=False)
193
+ with topics_plot_output:
194
+ _topics_kws.update({'topic': topic})
195
+ display(plot_scatter_topics(**_topics_kws))
196
+
197
+ if show_docs:
198
+ docs_plot_output.clear_output(wait=False)
199
+ with docs_plot_output:
200
+ _top_docs_kws.update({'topics': [sel['new']]})
201
+ top_docs = get_top_docs(**_top_docs_kws)
202
+ top_docs.columns = ['']
203
+ _docs_kws.update({'docs': top_docs})
204
+ display(plot_docs(**_docs_kws))
205
+
206
+ topics_ids = list(range(len(_topics_kws["topics_coords"])))
207
+ topics_labels = topics_labels or topics_ids
208
+ select_topic = wdg.Dropdown(options=list(zip(topics_labels, topics_ids)), value=0)
209
+ select_topic.observe(_on_select_topic, names="value")
210
+ select_topic_header = wdg.HTML("<b>Select a topic</b>:")
211
+ select_topic_widget = wdg.HBox([select_topic_header, select_topic])
212
+ select_topic_wrapper = wdg.VBox(
213
+ [select_topic_widget], layout={"align_items": "center"}
214
+ )
215
+
216
+ # Topics scatter
217
+ def _on_select_topics_method(names):
218
+ topics_plot_output.clear_output(wait=False)
219
+ with topics_plot_output:
220
+ _coords_kws.update({"scatter_kws": {"method": names["new"]}})
221
+ topics_coords = prepare_coords(model, **_coords_kws)
222
+ _topics_kws.update(
223
+ {"topics_coords": topics_coords, "topic": select_topic.value}
224
+ )
225
+ display(plot_scatter_topics(**_topics_kws))
226
+
227
+ if show_topics:
228
+ topics_plot_children = [topics_header] if show_headers else []
229
+ options_methods = [
230
+ ("TSNE", "tsne"),
231
+ ("Spectral Embedding", "sem"),
232
+ ("MDS", "mds"),
233
+ ("Locally Linear Embedding (Standard)", "lle"),
234
+ ("Locally Linear Embedding (LTSA)", "ltsa"),
235
+ ("Isomap", "isomap"),
236
+ ]
237
+ topics_method_header = wdg.HTML("Select a method:")
238
+ topics_method = wdg.Dropdown(
239
+ options=options_methods,
240
+ value="tsne",
241
+ layout=wdg.Layout(width=f"{width/1.25}px"),
242
+ )
243
+ topics_method_widget = wdg.HBox([topics_method_header, topics_method])
244
+ topics_method.observe(_on_select_topics_method, names="value")
245
+ topics_plot_output = wdg.Output()
246
+ topics_plot = plot_scatter_topics(**_topics_kws)
247
+ topics_plot_output.append_display_data(topics_plot)
248
+ topics_plot_children.extend([topics_method_widget, topics_plot_output])
249
+ topics_widget = wdg.VBox(topics_plot_children, layout={"align_items": "center"})
250
+ children.append(topics_widget)
251
+
252
+ # Words
253
+ if show_words:
254
+
255
+ def _on_select_lambda(sel):
256
+ topic = select_topic.value
257
+ lambda_ = lambda_slider.value
258
+ words_plot_output.clear_output(wait=False)
259
+ with words_plot_output:
260
+ terms_probs = calc_terms_probs_ratio(phi, topic=topic, lambda_=lambda_)
261
+ _words_kws.update({"terms_probs": terms_probs})
262
+ display(plot_terms(**_words_kws))
263
+
264
+ lambda_slider = wdg.FloatSlider(
265
+ value=0.6,
266
+ min=0.0,
267
+ max=1.0,
268
+ step=0.01,
269
+ description="",
270
+ continuous_update=False,
271
+ orientation="horizontal",
272
+ readout=True,
273
+ readout_format=".2f",
274
+ layout=wdg.Layout(width=f"{width/1.25}px"),
275
+ )
276
+ lambda_slider.observe(_on_select_lambda, names="value")
277
+ lambda_slider_header = wdg.HTML("Lambda value:")
278
+ lambda_slider_widget = wdg.HBox([lambda_slider_header, lambda_slider])
279
+ words_plot = plot_terms(**_words_kws)
280
+ words_plot_output = wdg.Output()
281
+ words_plot_output.append_display_data(words_plot)
282
+ words_plot_children = (
283
+ [words_header, lambda_slider_widget]
284
+ if show_headers
285
+ else [lambda_slider_widget]
286
+ )
287
+ words_plot_children.append(words_plot_output)
288
+ words_widget = wdg.VBox(words_plot_children, layout={"align_items": "center"})
289
+ children.append(words_widget)
290
+
291
+ # Docs
292
+ if show_docs:
293
+
294
+ def _on_select_docs_num(_):
295
+ docs_num = docs_num_slider.value
296
+ docs_plot_output.clear_output(wait=False)
297
+ with docs_plot_output:
298
+ _top_docs_kws.update({"docs_num": docs_num})
299
+ top_docs = get_top_docs(**_top_docs_kws)
300
+ top_docs.columns = [""]
301
+ _docs_kws.update({"docs": top_docs})
302
+ display(plot_docs(**_docs_kws))
303
+
304
+ docs_num_slider = wdg.IntSlider(
305
+ value=2,
306
+ min=1,
307
+ max=100,
308
+ continuous_update=False,
309
+ orientation="horizontal",
310
+ readout=True,
311
+ readout_format="d",
312
+ layout=wdg.Layout(width=f"{width/1.25}px"),
313
+ )
314
+ docs_num_slider.observe(_on_select_docs_num, names="value")
315
+ docs_num_slider_header = wdg.HTML("Documents number:")
316
+ docs_num_slider_widget = wdg.HBox([docs_num_slider_header, docs_num_slider])
317
+
318
+ docs_plot = plot_docs(**_docs_kws)
319
+ docs_plot_output = wdg.Output()
320
+ docs_plot_output.append_display_data(docs_plot)
321
+ docs_plot_children = (
322
+ [docs_header, docs_num_slider_widget]
323
+ if show_headers
324
+ else [docs_num_slider_widget]
325
+ )
326
+ docs_plot_children.append(docs_plot_output)
327
+ docs_widget = wdg.VBox(docs_plot_children, layout={"align_items": "center"})
328
+ children.append(docs_widget)
329
+
330
+ grid_box = wdg.GridBox(children, layout=layout)
331
+ hr_line = wdg.HTML('<hr style="border: 0; border-bottom: 1px solid #aaa">')
332
+ app = wdg.VBox([select_topic_wrapper, hr_line, grid_box])
333
+
334
+ return app
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: tmplot
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Summary: Visualization of Topic Modeling Results
5
5
  Author-email: Maksim Terpilovskii <maximtrp@gmail.com>
6
6
  License: MIT License
@@ -50,7 +50,6 @@ Requires-Dist: ipywidgets
50
50
  Requires-Dist: tqdm
51
51
  Provides-Extra: test
52
52
  Requires-Dist: pytest; extra == "test"
53
- Requires-Dist: codecov; extra == "test"
54
53
  Requires-Dist: coverage; extra == "test"
55
54
  Provides-Extra: models
56
55
  Requires-Dist: tomotopy>=0.8.0; extra == "models"
@@ -59,7 +58,8 @@ Requires-Dist: bitermplus; extra == "models"
59
58
 
60
59
  # tmplot
61
60
 
62
- [![Codecov](https://img.shields.io/codecov/c/gh/maximtrp/tmplot?token=APKBRPDKXS&label=coverage)](https://codecov.io/gh/maximtrp/tmplot)
61
+ [![Codacy coverage](https://img.shields.io/codacy/coverage/5939b1cf99bc4f9d91de11c0d3ff9e50)](https://app.codacy.com/gh/maximtrp/tmplot/coverage)
62
+ [![Codacy grade](https://img.shields.io/codacy/grade/5939b1cf99bc4f9d91de11c0d3ff9e50)](https://app.codacy.com/gh/maximtrp/tmplot)
63
63
  [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/maximtrp/tmplot/python-package.yml?label=tests)](https://github.com/maximtrp/tmplot/actions/workflows/python-package.yml)
64
64
  [![Documentation Status](https://readthedocs.org/projects/tmplot/badge/?version=latest)](https://tmplot.readthedocs.io/en/latest/?badge=latest)
65
65
  [![Downloads](https://static.pepy.tech/badge/tmplot)](https://pepy.tech/project/tmplot)
@@ -96,6 +96,12 @@ Requires-Dist: bitermplus; extra == "models"
96
96
  - LocallyLinearEmbedding
97
97
  - Isomap
98
98
 
99
+ ## Donate
100
+
101
+ If you find this package useful, please consider donating any amount of money. This will help me spend more time on supporting open-source software.
102
+
103
+ <a href="https://www.buymeacoffee.com/maximtrp" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 60px !important;width: 217px !important;" ></a>
104
+
99
105
  ## Installation
100
106
 
101
107
  The package can be installed from PyPi:
@@ -14,5 +14,4 @@ bitermplus
14
14
 
15
15
  [test]
16
16
  pytest
17
- codecov
18
17
  coverage
@@ -1,309 +0,0 @@
1
- __all__ = ['prepare_coords', 'report']
2
- import warnings
3
- from typing import Optional, Sequence, List
4
- from copy import deepcopy
5
- from ipywidgets import widgets as wdg
6
- from pandas import DataFrame
7
- from ._distance import get_topics_dist, get_topics_scatter
8
- from ._vis import plot_scatter_topics, plot_terms, plot_docs
9
- from ._helpers import (
10
- calc_terms_probs_ratio,
11
- get_phi, get_theta,
12
- get_top_docs)
13
-
14
- warnings.filterwarnings("ignore", category=DeprecationWarning)
15
- warnings.filterwarnings("ignore", category=FutureWarning)
16
-
17
-
18
- def prepare_coords(
19
- model: object,
20
- labels: Optional[Sequence] = None,
21
- dist_kws: dict = None,
22
- scatter_kws: dict = None) -> DataFrame:
23
- """Prepare coordinates for topics scatter plot.
24
-
25
- Parameters
26
- ----------
27
- model : object
28
- Topic model instance.
29
- labels : Optional[Sequence]
30
- Topics labels.
31
- dist_kws : dict, optional
32
- Keyword arguments passed to :py:meth:`tmplot.get_topics_dist()`.
33
- scatter_kws : dict, optional
34
- Keyword arguments passed to :py:meth:`tmplot.get_topics_scatter()`.
35
- """
36
- if not dist_kws:
37
- dist_kws = {}
38
- if not scatter_kws:
39
- scatter_kws = {}
40
-
41
- phi = get_phi(model)
42
- theta = get_theta(model)
43
- topics_dists = get_topics_dist(phi, **dist_kws)
44
- topics_coords = get_topics_scatter(topics_dists, theta, **scatter_kws)
45
- topics_coords['label'] = labels or theta.index
46
- return topics_coords
47
-
48
-
49
- def report(
50
- model: object,
51
- docs: Sequence[str],
52
- *,
53
- topics_labels: Optional[Sequence[str]] = None,
54
- corpus: Optional[List] = None,
55
- layout: wdg.Layout = None,
56
- show_headers: bool = True,
57
- show_docs: bool = True,
58
- show_words: bool = True,
59
- show_topics: bool = True,
60
- topics_kws: dict = None,
61
- height: int = 500,
62
- width: int = 300,
63
- coords_kws: dict = None,
64
- words_kws: dict = None,
65
- docs_kws: dict = None,
66
- top_docs_kws: dict = None) -> wdg.VBox:
67
- """Interactive report interface.
68
-
69
- Parameters
70
- ----------
71
- model : object
72
- Topic model instance.
73
- docs : Sequence[str]
74
- Documents.
75
- topics_labels : Optional[Sequence[str]], optional
76
- Topics labels.
77
- corpus : Optional[List[str]], optional
78
- Gensim corpus.
79
- layout : wdg.Layout, optional
80
- Interface layout instance.
81
- show_headers : bool, optional
82
- Show headers.
83
- show_docs : bool, optional
84
- Show documents widget.
85
- show_words : bool, optional
86
- Show words widget.
87
- show_topics : bool, optional
88
- Show topics scatter plot widget.
89
- topics_kws : dict, optional
90
- Keyword arguments passed to :py:meth:`tmplot.plot_scatter_topics()`.
91
- coords_kws : dict, optional
92
- Keyword arguments passed to :py:meth:`tmplot.prepare_coords()`.
93
- words_kws : dict, optional
94
- Keyword arguments passed to :py:meth:`tmplot.plot_terms()`.
95
- docs_kws : dict, optional
96
- Keyword arguments passed to :py:meth:`tmplot.plot_docs()`.
97
- top_docs_kws : dict, optional
98
- Keyword arguments passed to :py:meth:`tmplot.get_top_docs()`.
99
-
100
- Returns
101
- -------
102
- ipywidgets.widgets.widget_box.VBox
103
- Report interface as a VBox instance.
104
- """
105
- from IPython.display import display
106
-
107
- _topics_kws = {
108
- 'chart_kws': {'height': height, 'width': width}}\
109
- if not topics_kws else deepcopy(topics_kws)
110
- _coords_kws = {} if not coords_kws else deepcopy(coords_kws)
111
- _words_kws = {
112
- 'chart_kws': {'height': height, 'width': width}}\
113
- if not words_kws else deepcopy(words_kws)
114
- _top_docs_kws = {} if not docs_kws else deepcopy(top_docs_kws)
115
- _docs_kws = {} if not docs_kws else deepcopy(docs_kws)
116
-
117
- # Headers init
118
- topics_header = wdg.HTML('<b>Intertopic distance plot</b>')\
119
- if show_headers and show_topics else None
120
- words_header = wdg.HTML('<b>Relevant words (terms)</b>')\
121
- if show_headers and show_words else None
122
- docs_header = wdg.HTML('<b>Top documents in a topic</b>')\
123
- if show_headers and show_docs else None
124
-
125
- # Layout init
126
- grid_cols = " ".join(['1fr'] * sum([show_docs, show_words, show_topics]))
127
- layout = wdg.Layout(
128
- grid_template_columns=grid_cols,
129
- # justify_items='center'
130
- )\
131
- if not layout else layout
132
-
133
- # Children widgets list init
134
- children = []
135
-
136
- if 'topics_coords' not in _topics_kws:
137
- topics_coords = prepare_coords(model, **_coords_kws)
138
- _topics_kws.update({
139
- 'topics_coords': topics_coords,
140
- 'label_col': 'label',
141
- 'size_col': 'size',
142
- 'topic': 0
143
- })
144
-
145
- if 'terms_probs' not in _words_kws:
146
- phi = get_phi(model)
147
- terms_probs = calc_terms_probs_ratio(phi, topic=0)
148
- _words_kws.update({'terms_probs': terms_probs})
149
-
150
- if 'docs' not in _docs_kws:
151
- theta = get_theta(model, corpus=corpus).values
152
- _top_docs_kws.update({
153
- 'docs': docs, 'theta': theta,
154
- 'topics': [0], 'docs_num': 2})
155
- top_docs = get_top_docs(**_top_docs_kws)
156
- top_docs.columns = ['']
157
- _docs_kws.update({'docs': top_docs})
158
-
159
- # Topic selection
160
- def _on_select_topic(sel):
161
- topic = sel['new']
162
- topics_plot_output.clear_output(wait=False)
163
- words_plot_output.clear_output(wait=False)
164
- docs_plot_output.clear_output(wait=False)
165
- with words_plot_output:
166
- terms_probs = calc_terms_probs_ratio(
167
- phi, topic=topic, lambda_=lambda_slider.value)
168
- _words_kws.update({'terms_probs': terms_probs})
169
- display(plot_terms(**_words_kws))
170
- with topics_plot_output:
171
- _topics_kws.update({'topic': topic})
172
- display(plot_scatter_topics(**_topics_kws))
173
- with docs_plot_output:
174
- _top_docs_kws.update({'topics': [sel['new']]})
175
- top_docs = get_top_docs(**_top_docs_kws)
176
- top_docs.columns = ['']
177
- _docs_kws.update({'docs': top_docs})
178
- display(plot_docs(**_docs_kws))
179
-
180
- topics_ids = list(range(len(_topics_kws['topics_coords'])))
181
- topics_labels = topics_labels or topics_ids
182
- select_topic = wdg.Dropdown(
183
- options=list(zip(topics_labels, topics_ids)), value=0)
184
- select_topic.observe(_on_select_topic, names='value')
185
- select_topic_header = wdg.HTML('<b>Select a topic</b>:')
186
- select_topic_widget = wdg.HBox([select_topic_header, select_topic])
187
- select_topic_wrapper = wdg.VBox(
188
- [select_topic_widget], layout={'align_items': 'center'})
189
-
190
- # Topics scatter
191
- def _on_select_topics_method(names):
192
- topics_plot_output.clear_output(wait=False)
193
- with topics_plot_output:
194
- _coords_kws.update({'scatter_kws': {'method': names['new']}})
195
- topics_coords = prepare_coords(model, **_coords_kws)
196
- _topics_kws.update({
197
- 'topics_coords': topics_coords,
198
- 'topic': select_topic.value
199
- })
200
- display(plot_scatter_topics(**_topics_kws))
201
-
202
- if show_topics:
203
- topics_plot_children = [topics_header] if show_headers else []
204
- options_methods = [
205
- ('TSNE', 'tsne'),
206
- ('Spectral Embedding', 'sem'),
207
- ('MDS', 'mds'),
208
- ('Locally Linear Embedding (Standard)', 'lle'),
209
- ('Locally Linear Embedding (LTSA)', 'ltsa'),
210
- ('Isomap', 'isomap')
211
- ]
212
- topics_method_header = wdg.HTML('Select a method:')
213
- topics_method = wdg.Dropdown(
214
- options=options_methods,
215
- value='tsne',
216
- layout=wdg.Layout(width=f'{width/1.25}px')
217
- )
218
- topics_method_widget = wdg.HBox([topics_method_header, topics_method])
219
- topics_method.observe(_on_select_topics_method, names='value')
220
- topics_plot_output = wdg.Output()
221
- topics_plot = plot_scatter_topics(**_topics_kws)
222
- topics_plot_output.append_display_data(topics_plot)
223
- topics_plot_children.extend([topics_method_widget, topics_plot_output])
224
- topics_widget = wdg.VBox(
225
- topics_plot_children,
226
- layout={'align_items': 'center'})
227
- children.append(topics_widget)
228
-
229
- # Words
230
- if show_words:
231
- def _on_select_lambda(sel):
232
- topic = select_topic.value
233
- lambda_ = lambda_slider.value
234
- words_plot_output.clear_output(wait=False)
235
- with words_plot_output:
236
- terms_probs = calc_terms_probs_ratio(
237
- phi, topic=topic, lambda_=lambda_)
238
- _words_kws.update({'terms_probs': terms_probs})
239
- display(plot_terms(**_words_kws))
240
-
241
- lambda_slider = wdg.FloatSlider(
242
- value=0.6,
243
- min=0.0,
244
- max=1.0,
245
- step=0.01,
246
- description='',
247
- continuous_update=False,
248
- orientation='horizontal',
249
- readout=True,
250
- readout_format='.2f',
251
- layout=wdg.Layout(width=f'{width/1.25}px')
252
- )
253
- lambda_slider.observe(_on_select_lambda, names='value')
254
- lambda_slider_header = wdg.HTML('Lambda value:')
255
- lambda_slider_widget = wdg.HBox([lambda_slider_header, lambda_slider])
256
- words_plot = plot_terms(**_words_kws)
257
- words_plot_output = wdg.Output()
258
- words_plot_output.append_display_data(words_plot)
259
- words_plot_children = [words_header, lambda_slider_widget]\
260
- if show_headers else [lambda_slider_widget]
261
- words_plot_children.append(words_plot_output)
262
- words_widget = wdg.VBox(
263
- words_plot_children,
264
- layout={'align_items': 'center'})
265
- children.append(words_widget)
266
-
267
- # Docs
268
- if show_docs:
269
- def _on_select_docs_num(_):
270
- docs_num = docs_num_slider.value
271
- docs_plot_output.clear_output(wait=False)
272
- with docs_plot_output:
273
- _top_docs_kws.update({'docs_num': docs_num})
274
- top_docs = get_top_docs(**_top_docs_kws)
275
- top_docs.columns = ['']
276
- _docs_kws.update({'docs': top_docs})
277
- display(plot_docs(**_docs_kws))
278
-
279
- docs_num_slider = wdg.IntSlider(
280
- value=2,
281
- min=1,
282
- max=100,
283
- continuous_update=False,
284
- orientation='horizontal',
285
- readout=True,
286
- readout_format='d',
287
- layout=wdg.Layout(width=f'{width/1.25}px')
288
- )
289
- docs_num_slider.observe(_on_select_docs_num, names='value')
290
- docs_num_slider_header = wdg.HTML('Documents number:')
291
- docs_num_slider_widget = wdg.HBox(
292
- [docs_num_slider_header, docs_num_slider])
293
-
294
- docs_plot = plot_docs(**_docs_kws)
295
- docs_plot_output = wdg.Output()
296
- docs_plot_output.append_display_data(docs_plot)
297
- docs_plot_children = [docs_header, docs_num_slider_widget]\
298
- if show_headers else [docs_num_slider_widget]
299
- docs_plot_children.append(docs_plot_output)
300
- docs_widget = wdg.VBox(
301
- docs_plot_children,
302
- layout={'align_items': 'center'})
303
- children.append(docs_widget)
304
-
305
- grid_box = wdg.GridBox(children, layout=layout)
306
- hr_line = wdg.HTML('<hr style="border: 0; border-bottom: 1px solid #aaa">')
307
- app = wdg.VBox([select_topic_wrapper, hr_line, grid_box])
308
-
309
- return app
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes