topiclayers 0.2.1__tar.gz → 0.3.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.
Files changed (26) hide show
  1. {topiclayers-0.2.1 → topiclayers-0.3.0}/PKG-INFO +21 -11
  2. {topiclayers-0.2.1 → topiclayers-0.3.0}/README.md +20 -10
  3. {topiclayers-0.2.1 → topiclayers-0.3.0}/pyproject.toml +1 -1
  4. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/cli.py +17 -15
  5. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/config.py +17 -0
  6. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/core/data.py +8 -7
  7. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/core/network.py +4 -2
  8. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/core/topic.py +31 -21
  9. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/examples/template_csv.yml +6 -1
  10. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/examples/template_twitter.yml +9 -3
  11. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/status.py +142 -5
  12. {topiclayers-0.2.1 → topiclayers-0.3.0}/LICENSE +0 -0
  13. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/__init__.py +0 -0
  14. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/adapters/__init__.py +0 -0
  15. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/adapters/csv.py +0 -0
  16. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/adapters/registry.py +0 -0
  17. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/adapters/twitter.py +0 -0
  18. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/core/__init__.py +0 -0
  19. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/examples/generic/posts.csv +0 -0
  20. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/examples/generic.yml +0 -0
  21. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/examples/twitter/toy.json +0 -0
  22. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/manifest.py +0 -0
  23. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/schema.py +0 -0
  24. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/support/__init__.py +0 -0
  25. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/support/cache.py +0 -0
  26. {topiclayers-0.2.1 → topiclayers-0.3.0}/src/topiclayers/support/repro.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: topiclayers
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: From any collection of posts to topical multilayer networks — hardened and social-scientist-friendly
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -103,7 +103,7 @@ Stage ETA: ~35m 20s
103
103
  Total elapsed: 21m 05s
104
104
  ```
105
105
 
106
- Stages are `load → data → embed → topic_model → label → network → manifest`. Stages with a per-item counter (data, embed, network) report a live ETA. Opaque stages (BERTopic's UMAP+HDBSCAN) fall back to the median duration of past runs, stored in `<output_dir>/cache/stage_timings.json`.
106
+ Stages are `load → data → embed → topic_model → label → network → manifest`. Stages with a per-item counter (data, embed, network) report a live ETA. Opaque stages (BERTopic's UMAP+HDBSCAN) fall back to the median duration of past runs, stored in `<output_dir>/stage_timings.json`. While running, the terminal itself shows a live progress line (spinner, counter, ETA, elapsed time) that keeps ticking even in stages without a counter, plus a per-stage duration summary when the run finishes.
107
107
 
108
108
  ## Topic labeling with an LLM (optional)
109
109
 
@@ -180,12 +180,7 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
180
180
  <output_dir>/
181
181
  ├── run_manifest.json # Reproducibility metadata
182
182
  ├── run_status.json # Live stage/ETA tracking (while running)
183
- ├── cache/
184
- │ └── data/
185
- │ ├── tweets_<name>.pkl/.csv # Full tweet table
186
- │ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
187
- │ └── manifest_<name>.json # Cache validity key
188
- │ └── stage_timings.json # Per-stage durations for ETA prediction
183
+ ├── stage_timings.json # Per-stage durations for ETA prediction
189
184
  └── networks/
190
185
  ├── <name>_retweet.gml # Single-layer retweet network
191
186
  ├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
@@ -194,13 +189,28 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
194
189
  └── <name>__prj_<topic>.gml # Per-topic projected networks
195
190
  ```
196
191
 
192
+ Reusable artifacts (processed data, embeddings, the BERTopic model) go to the
193
+ **cache**, not the output dir. By default the cache lives in a `cache/` folder
194
+ **next to the input data file**, so it persists across runs regardless of
195
+ `output_dir` and can be shared between configs (files are prefixed with the
196
+ dataset `name`). Override the location with `cache_dir:` in the config:
197
+
198
+ ```
199
+ <cache_dir>/
200
+ ├── data/
201
+ │ ├── tweets_<name>.pkl/.csv # Full tweet table
202
+ │ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
203
+ │ └── manifest_<name>.json # Cache validity key
204
+ └── tm/<embedder_name>/
205
+ ├── embeddings_<name>.pkl # Document embeddings
206
+ ├── topics_<name>.csv # Topic table (with LLM labels)
207
+ └── model_<name>/ # BERTopic safetensors dir
208
+ ```
209
+
197
210
  ## Requirements
198
211
 
199
212
  - Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
200
213
  - Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
201
214
 
202
- ## Planned: JOSS software paper
203
215
 
204
- Once the API stabilises and the PLOS ONE core paper results are regenerated with this library,
205
- a short JOSS (Journal of Open Source Software) paper will be submitted with a Zenodo DOI.
206
216
 
@@ -67,7 +67,7 @@ Stage ETA: ~35m 20s
67
67
  Total elapsed: 21m 05s
68
68
  ```
69
69
 
70
- Stages are `load → data → embed → topic_model → label → network → manifest`. Stages with a per-item counter (data, embed, network) report a live ETA. Opaque stages (BERTopic's UMAP+HDBSCAN) fall back to the median duration of past runs, stored in `<output_dir>/cache/stage_timings.json`.
70
+ Stages are `load → data → embed → topic_model → label → network → manifest`. Stages with a per-item counter (data, embed, network) report a live ETA. Opaque stages (BERTopic's UMAP+HDBSCAN) fall back to the median duration of past runs, stored in `<output_dir>/stage_timings.json`. While running, the terminal itself shows a live progress line (spinner, counter, ETA, elapsed time) that keeps ticking even in stages without a counter, plus a per-stage duration summary when the run finishes.
71
71
 
72
72
  ## Topic labeling with an LLM (optional)
73
73
 
@@ -144,12 +144,7 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
144
144
  <output_dir>/
145
145
  ├── run_manifest.json # Reproducibility metadata
146
146
  ├── run_status.json # Live stage/ETA tracking (while running)
147
- ├── cache/
148
- │ └── data/
149
- │ ├── tweets_<name>.pkl/.csv # Full tweet table
150
- │ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
151
- │ └── manifest_<name>.json # Cache validity key
152
- │ └── stage_timings.json # Per-stage durations for ETA prediction
147
+ ├── stage_timings.json # Per-stage durations for ETA prediction
153
148
  └── networks/
154
149
  ├── <name>_retweet.gml # Single-layer retweet network
155
150
  ├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
@@ -158,12 +153,27 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
158
153
  └── <name>__prj_<topic>.gml # Per-topic projected networks
159
154
  ```
160
155
 
156
+ Reusable artifacts (processed data, embeddings, the BERTopic model) go to the
157
+ **cache**, not the output dir. By default the cache lives in a `cache/` folder
158
+ **next to the input data file**, so it persists across runs regardless of
159
+ `output_dir` and can be shared between configs (files are prefixed with the
160
+ dataset `name`). Override the location with `cache_dir:` in the config:
161
+
162
+ ```
163
+ <cache_dir>/
164
+ ├── data/
165
+ │ ├── tweets_<name>.pkl/.csv # Full tweet table
166
+ │ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
167
+ │ └── manifest_<name>.json # Cache validity key
168
+ └── tm/<embedder_name>/
169
+ ├── embeddings_<name>.pkl # Document embeddings
170
+ ├── topics_<name>.csv # Topic table (with LLM labels)
171
+ └── model_<name>/ # BERTopic safetensors dir
172
+ ```
173
+
161
174
  ## Requirements
162
175
 
163
176
  - Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
164
177
  - Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
165
178
 
166
- ## Planned: JOSS software paper
167
179
 
168
- Once the API stabilises and the PLOS ONE core paper results are regenerated with this library,
169
- a short JOSS (Journal of Open Source Software) paper will be submitted with a Zenodo DOI.
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "topiclayers"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "From any collection of posts to topical multilayer networks — hardened and social-scientist-friendly"
5
5
  authors = ["alessiogandelli <alessiogandelli99@gmail.com>"]
6
6
  readme = "README.md"
@@ -45,14 +45,16 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
45
45
 
46
46
  status.stage("load")
47
47
  fmt, loader = resolve_adapter(input_path)
48
- print(f"Loading posts via '{fmt}' adapter from {input_path}")
48
+ status.log(f"Loading posts via '{fmt}' adapter from {input_path}")
49
49
  posts = loader(input_path)
50
- print(f" {len(posts)} posts loaded")
50
+ status.log(f" {len(posts)} posts loaded")
51
51
 
52
52
  row_counts = {"raw_posts": len(posts)}
53
53
  tm = None
54
54
 
55
- cache_dir = config.output_dir / "cache"
55
+ cache_dir = config.cache_dir or (input_path.parent / "cache")
56
+ cache_dir.mkdir(parents=True, exist_ok=True)
57
+ status.log(f"Cache: {cache_dir.resolve()}")
56
58
 
57
59
  status.stage("data", total=len(posts))
58
60
  ds = Dataset(posts, n_cop=config.name, file_user=config.input.users)
@@ -60,11 +62,11 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
60
62
  row_counts["tweets"] = len(ds.df_tweets)
61
63
  row_counts["original"] = len(ds.df_original)
62
64
  row_counts["retweets"] = len(ds.df_retweets)
63
- print(f" {len(ds.df_original)} original, {len(ds.df_retweets)} retweets, "
64
- f"{len(ds.df_quotes)} quotes, {len(ds.df_reply)} replies")
65
+ status.log(f" {len(ds.df_original)} original, {len(ds.df_retweets)} retweets, "
66
+ f"{len(ds.df_quotes)} quotes, {len(ds.df_reply)} replies")
65
67
 
66
68
  if len(ds.df_original) == 0:
67
- print(" No original posts found — skipping topic modeling. All retweets will be assigned topic -1.")
69
+ status.log(" No original posts found — skipping topic modeling. All retweets will be assigned topic -1.")
68
70
  df_labeled = ds.df_original.copy()
69
71
  df_labeled["topic"] = -1
70
72
  df_labeled["topic_prob"] = None
@@ -88,15 +90,15 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
88
90
  umap_init=config.topic_model.umap_init,
89
91
  )
90
92
  except RuntimeError as e:
91
- print(f" Topic modeling failed: {e}")
92
- print(" Assigning topic -1 to all originals and continuing.")
93
+ status.log(f" Topic modeling failed: {e}")
94
+ status.log(" Assigning topic -1 to all originals and continuing.")
93
95
  df_labeled = ds.df_original.copy()
94
96
  df_labeled["topic"] = -1
95
97
  df_labeled["topic_prob"] = None
96
98
  row_counts["labeled_originals"] = len(df_labeled)
97
99
  topic_counts = df_labeled["topic"].value_counts().to_dict()
98
100
  topic_dist = {str(k): int(v) for k, v in topic_counts.items()}
99
- print(f" {len(topic_dist)} topics found (including -1 outlier)")
101
+ status.log(f" {len(topic_dist)} topics found (including -1 outlier)")
100
102
 
101
103
  status.stage("label")
102
104
  if config.topic_model.label_model and tm is not None and tm.model is not None:
@@ -104,8 +106,9 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
104
106
  model=config.topic_model.label_model,
105
107
  api_base=config.topic_model.label_api_base,
106
108
  label_context=config.topic_model.label_context,
109
+ reporter=status,
107
110
  )
108
- df_labeled = ds.update_df(df_labeled)
111
+ df_labeled = ds.update_df(df_labeled, reporter=status)
109
112
  ds._save_labeled_dataframe(cache_dir)
110
113
  row_counts["retweets_labeled"] = len(df_labeled)
111
114
 
@@ -120,7 +123,7 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
120
123
  try:
121
124
  nw.create_retweet_ml(reporter=status)
122
125
  except ValueError as e:
123
- print(f" Skipping multilayer network: {e}")
126
+ status.log(f" Skipping multilayer network: {e}")
124
127
  elif network_type == "retweet":
125
128
  nw.create_retweet_network(reporter=status)
126
129
  elif network_type == "ttn":
@@ -130,11 +133,11 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
130
133
  try:
131
134
  nw.create_retweet_ml(reporter=status)
132
135
  except ValueError as e:
133
- print(f" Skipping multilayer network: {e}")
136
+ status.log(f" Skipping multilayer network: {e}")
134
137
  nw.create_ttnetwork(project=config.network.project_ttn)
135
138
  else:
136
- print(f"Unknown network type: '{network_type}'. "
137
- f"Use one of: multilayer_repost, retweet, ttn, all.", file=sys.stderr)
139
+ status.log(f"Unknown network type: '{network_type}'. "
140
+ f"Use one of: multilayer_repost, retweet, ttn, all.")
138
141
  sys.exit(1)
139
142
 
140
143
  status.stage("manifest")
@@ -167,7 +170,6 @@ def _run_pipeline_stages(config: RunConfig, input_path: Path, status) -> None:
167
170
  print(f"\nDone. Output in {config.output_dir.resolve()}")
168
171
  print(f" Run manifest: {config.output_dir / 'run_manifest.json'}")
169
172
 
170
-
171
173
  def _cmd_init(dest: Path) -> None:
172
174
  import shutil
173
175
  from importlib.resources import as_file, files
@@ -57,6 +57,12 @@ class RunConfig:
57
57
  topic_model: TopicModelConfig = field(default_factory=TopicModelConfig)
58
58
  network: NetworkConfig = field(default_factory=NetworkConfig)
59
59
  output_dir: Path = Path("./out")
60
+ # Where reusable artifacts (processed data, embeddings, models) live.
61
+ # Defaults to a 'cache/' folder next to the input data, so the cache
62
+ # survives output_dir changes and is shared across runs. Independent
63
+ # datasets can share one cache dir: all cache files are prefixed with
64
+ # the dataset `name`.
65
+ cache_dir: Optional[Path] = None
60
66
 
61
67
  @classmethod
62
68
  def from_yaml(cls, path: Path) -> "RunConfig":
@@ -75,10 +81,21 @@ class RunConfig:
75
81
  p = (path.parent / p).resolve()
76
82
  input_raw[key] = p
77
83
 
84
+ cache_dir = raw.get("cache_dir")
85
+ if cache_dir:
86
+ cache_dir = Path(cache_dir)
87
+ if not cache_dir.is_absolute():
88
+ cache_dir = (path.parent / cache_dir).resolve()
89
+ else:
90
+ # default: keep the cache next to the input data
91
+ posts = input_raw.get("posts")
92
+ cache_dir = posts.parent / "cache" if posts else Path("./cache")
93
+
78
94
  return cls(
79
95
  input=InputConfig(**input_raw),
80
96
  name=raw.get("name", "default"),
81
97
  topic_model=TopicModelConfig(**raw.get("topic_model", {})),
82
98
  network=NetworkConfig(**raw.get("network", {})),
83
99
  output_dir=Path(raw.get("output_dir", "./out")),
100
+ cache_dir=cache_dir,
84
101
  )
@@ -10,6 +10,7 @@ from typing import Optional
10
10
  import pandas as pd
11
11
 
12
12
  from topiclayers.support.cache import cache_key
13
+ from topiclayers.status import log_via
13
14
  from topiclayers.schema import InteractionType, Post
14
15
 
15
16
  _INTERACTION_REVERSE_MAP = {
@@ -82,8 +83,8 @@ class Dataset:
82
83
  self.df_users = pd.read_pickle(users_pkl)
83
84
  else:
84
85
  if tweets_pkl.exists():
85
- print("Cache key mismatch — regenerating data cache.")
86
- users_dict = self._load_user_dict() if self.file_user is not None else {}
86
+ log_via(reporter, "Cache key mismatch — regenerating data cache.")
87
+ users_dict = self._load_user_dict(reporter=reporter) if self.file_user is not None else {}
87
88
  tweets_dict = {}
88
89
  for i, post in enumerate(self.posts):
89
90
  if reporter is not None:
@@ -135,13 +136,13 @@ class Dataset:
135
136
 
136
137
  self._create_dataframes()
137
138
 
138
- def _load_user_dict(self) -> dict:
139
+ def _load_user_dict(self, reporter=None) -> dict:
139
140
  import json
140
141
 
141
142
  if self.file_user is None:
142
143
  return {}
143
144
  if not self.file_user.exists():
144
- print(f"User file not found: {self.file_user} — skipping user metadata.")
145
+ log_via(reporter, f"User file not found: {self.file_user} — skipping user metadata.")
145
146
  return {}
146
147
 
147
148
  users = {}
@@ -167,7 +168,7 @@ class Dataset:
167
168
  self.df_quotes = df[df["referenced_type"] == "quoted"].copy()
168
169
  self.df_reply = df[df["referenced_type"] == "replied_to"].copy()
169
170
 
170
- def update_df(self, df_labeled: pd.DataFrame) -> pd.DataFrame:
171
+ def update_df(self, df_labeled: pd.DataFrame, reporter=None) -> pd.DataFrame:
171
172
  if "id" in df_labeled.columns and df_labeled.index.name != "id":
172
173
  df_labeled = df_labeled.set_index("id")
173
174
 
@@ -197,9 +198,9 @@ class Dataset:
197
198
  resolved[idx] = val
198
199
 
199
200
  self.df_retweets_labeled["topic"] = self.df_retweets_labeled.index.map(resolved)
200
- print(f"Unresolved references: {unresolved_count}")
201
+ log_via(reporter, f"Unresolved references: {unresolved_count}")
201
202
  if unresolved_count > 0:
202
- print(f" {unresolved_count} retweets could not be resolved to a topic. These will be excluded from the labeled output.")
203
+ log_via(reporter, f" {unresolved_count} retweets could not be resolved to a topic. These will be excluded from the labeled output.")
203
204
 
204
205
  self.df_retweets_labeled = self.df_retweets_labeled[
205
206
  self.df_retweets_labeled["topic"].apply(lambda x: not isinstance(x, str))
@@ -5,6 +5,8 @@ from pathlib import Path
5
5
  import networkx as nx
6
6
  import pandas as pd
7
7
 
8
+ from topiclayers.status import log_via
9
+
8
10
 
9
11
  class NetworkCreator:
10
12
  def __init__(self, df: pd.DataFrame, name: str, output_dir: Path):
@@ -25,7 +27,7 @@ class NetworkCreator:
25
27
  )
26
28
 
27
29
  if missing_refs:
28
- print(f" {missing_refs} retweets reference tweets not in dataset — skipped.")
30
+ log_via(reporter, f" {missing_refs} retweets reference tweets not in dataset — skipped.")
29
31
 
30
32
  self._save_graph(G, "retweet")
31
33
  return G
@@ -101,7 +103,7 @@ class NetworkCreator:
101
103
  ml.add_nx_layer(ml_network, G, str(topic))
102
104
 
103
105
  if dropped_edges_total:
104
- print(f" {dropped_edges_total} edges dropped (referenced tweet missing in topic slice).")
106
+ log_via(reporter, f" {dropped_edges_total} edges dropped (referenced tweet missing in topic slice).")
105
107
 
106
108
  self._save_multilayer(ml_network)
107
109
  self.ml_network = ml_network
@@ -11,6 +11,7 @@ import numpy as np
11
11
  import pandas as pd
12
12
 
13
13
  from topiclayers.support.cache import cache_key
14
+ from topiclayers.status import log_via
14
15
 
15
16
 
16
17
  class TopicModeler:
@@ -77,7 +78,7 @@ class TopicModeler:
77
78
  except Exception:
78
79
  pass
79
80
  if cache_valid:
80
- print("using cached topics")
81
+ log_via(reporter, "using cached topics")
81
82
  self.df = pd.read_pickle(self.df_labeled_path)
82
83
  from bertopic import BERTopic
83
84
  self.model = BERTopic.load(self.model_path)
@@ -85,7 +86,7 @@ class TopicModeler:
85
86
  self.docs = self.df["new_text"].tolist()
86
87
  return self.df
87
88
 
88
- print("running topic modeling")
89
+ log_via(reporter, "running topic modeling")
89
90
  docs = self._preprocess()
90
91
 
91
92
  if self.embeddings_path.exists():
@@ -101,7 +102,7 @@ class TopicModeler:
101
102
  except Exception:
102
103
  pass
103
104
  if use_cached:
104
- print(" loading embeddings from cache")
105
+ log_via(reporter, " loading embeddings from cache")
105
106
  with open(self.embeddings_path, "rb") as f:
106
107
  self.embeddings = pickle.load(f)
107
108
  else:
@@ -214,6 +215,11 @@ class TopicModeler:
214
215
  repro_mode: str = "strict",
215
216
  umap_init: str = "pca",
216
217
  ) -> None:
218
+ # mark the stage before importing: torch/umap/bertopic imports take a
219
+ # while and would otherwise be billed to the previous stage
220
+ if reporter is not None:
221
+ reporter.stage("topic_model")
222
+
217
223
  import random
218
224
 
219
225
  from umap import UMAP
@@ -222,9 +228,6 @@ class TopicModeler:
222
228
  from bertopic import BERTopic
223
229
  from bertopic.vectorizers import ClassTfidfTransformer
224
230
 
225
- if reporter is not None:
226
- reporter.stage("topic_model")
227
-
228
231
  np.random.seed(random_state)
229
232
  random.seed(random_state)
230
233
  try:
@@ -293,7 +296,9 @@ class TopicModeler:
293
296
  # large corpora); the default computes the probability of the
294
297
  # assigned topic only — topics themselves are identical
295
298
  calculate_probabilities=exact_probabilities,
296
- verbose=True,
299
+ # False: BERTopic's per-step INFO logging would clobber the live
300
+ # progress line; our reporter provides the progress feedback
301
+ verbose=False,
297
302
  )
298
303
 
299
304
  try:
@@ -350,7 +355,7 @@ class TopicModeler:
350
355
  def _save_qdrant(self, docs: list[str], reporter=None) -> None:
351
356
  qdrant_url = os.getenv("QDRANT_URL")
352
357
  if not qdrant_url:
353
- print("Qdrant not configured — skipping. Set QDRANT_URL in .env to enable vector search.")
358
+ log_via(reporter, "Qdrant not configured — skipping. Set QDRANT_URL in .env to enable vector search.")
354
359
  return
355
360
 
356
361
  try:
@@ -386,8 +391,8 @@ class TopicModeler:
386
391
  if reporter is not None:
387
392
  reporter.update(len(points), total=len(points))
388
393
  except Exception as e:
389
- print(f"Qdrant upload failed (non-fatal): {type(e).__name__}: {e}")
390
- print("The pipeline continues — networks and topic labels are unaffected.")
394
+ log_via(reporter, f"Qdrant upload failed (non-fatal): {type(e).__name__}: {e}")
395
+ log_via(reporter, "The pipeline continues — networks and topic labels are unaffected.")
391
396
 
392
397
  def _save_files(self) -> None:
393
398
  self.df_labeled_path.parent.mkdir(parents=True, exist_ok=True)
@@ -409,6 +414,7 @@ class TopicModeler:
409
414
  batch_size: int = 40,
410
415
  label_context: str = "",
411
416
  max_topics: int = 0,
417
+ reporter=None,
412
418
  ) -> dict[int, str]:
413
419
  """Label topics with an LLM, batching many topics per request.
414
420
 
@@ -443,18 +449,19 @@ class TopicModeler:
443
449
  api_base = api_base or os.getenv("LABEL_API_BASE", "")
444
450
 
445
451
  if self.model is None:
446
- print(" No fitted topic model — skipping LLM labeling.")
452
+ log_via(reporter, " No fitted topic model — skipping LLM labeling.")
447
453
  return {}
448
454
 
449
455
  topic_info = self.model.get_topic_info()
450
456
  if "LLM_label" in topic_info.columns and not overwrite:
451
- print(" LLM labels already present — skipping (use overwrite=True).")
457
+ log_via(reporter, " LLM labels already present — skipping (use overwrite=True).")
452
458
  return {}
453
459
 
454
460
  try:
455
461
  import litellm # noqa: F401
456
462
  except ImportError:
457
- print(
463
+ log_via(
464
+ reporter,
458
465
  " LLM labeling skipped: 'litellm' is not installed.\n"
459
466
  " Install it with: pip install 'topiclayers[labeling]'\n"
460
467
  " Topics keep their c-TF-IDF keyword labels."
@@ -470,7 +477,8 @@ class TopicModeler:
470
477
  conn.getresponse().read()
471
478
  conn.close()
472
479
  except (OSError, socket.timeout):
473
- print(
480
+ log_via(
481
+ reporter,
474
482
  f" LLM labeling skipped: Ollama not reachable at {host}:{port}.\n"
475
483
  f" Start it with: ollama serve (then pull the model:\n"
476
484
  f" ollama pull {model.split('/', 1)[1]})\n"
@@ -486,7 +494,8 @@ class TopicModeler:
486
494
  or os.getenv("OPENAI_API_KEY")
487
495
  )
488
496
  if not api_key:
489
- print(
497
+ log_via(
498
+ reporter,
490
499
  " LLM labeling skipped: custom api_base set but no API key found.\n"
491
500
  " Set LABEL_API_KEY (or OPENAI_LIKE_API_KEY / OPENAI_API_KEY) in .env."
492
501
  )
@@ -515,12 +524,12 @@ class TopicModeler:
515
524
  entries.append((tid, keywords, docs_str))
516
525
 
517
526
  if not entries:
518
- print(" No non-outlier topics to label.")
527
+ log_via(reporter, " No non-outlier topics to label.")
519
528
  return {}
520
529
 
521
530
  if max_topics and max_topics < len(entries):
522
531
  entries = entries[:max_topics] # topic_info is sorted by size
523
- print(f" Preview mode: labeling only the {max_topics} largest topics.")
532
+ log_via(reporter, f" Preview mode: labeling only the {max_topics} largest topics.")
524
533
 
525
534
  system_msg = (
526
535
  "You annotate discussion topics from social media.\n"
@@ -577,16 +586,17 @@ class TopicModeler:
577
586
  _load_openai_like_model(model, api_base, generator_kwargs.get("api_key", ""))
578
587
  continue
579
588
  if attempt == 1:
580
- print(f" Batch {i // batch_size + 1}/{n_chunks}: {type(e).__name__}, retrying once.")
589
+ log_via(reporter, f" Batch {i // batch_size + 1}/{n_chunks}: {type(e).__name__}, retrying once.")
581
590
  continue
582
591
  failed += 1
583
- print(
592
+ log_via(
593
+ reporter,
584
594
  f" Batch {i // batch_size + 1}/{n_chunks} failed (non-fatal): "
585
595
  f"{type(e).__name__}: {str(e)[:120]} — those topics keep keyword labels."
586
596
  )
587
597
 
588
598
  if not llm_labels:
589
- print(" No labels generated — topics keep their c-TF-IDF keyword labels.")
599
+ log_via(reporter, " No labels generated — topics keep their c-TF-IDF keyword labels.")
590
600
  return {}
591
601
 
592
602
  # expose labels as an aspect column named LLM_label in get_topic_info()
@@ -609,7 +619,7 @@ class TopicModeler:
609
619
  info.to_csv(self.topic_path)
610
620
  ok = len(llm_labels)
611
621
  suffix = f", {failed} batch(es) failed" if failed else ""
612
- print(f" Labeled {ok} topics with '{model}' in {n_chunks} batch(es){suffix}.")
622
+ log_via(reporter, f" Labeled {ok} topics with '{model}' in {n_chunks} batch(es){suffix}.")
613
623
  return labels
614
624
 
615
625
 
@@ -41,4 +41,9 @@ network:
41
41
  # interaction_type=repost rows to build edges
42
42
  project_ttn: true
43
43
 
44
- output_dir: ./out/my_dataset
44
+ output_dir: ./out/my_dataset # research outputs only: networks/, run_manifest.json,
45
+ # run_status.json, stage_timings.json
46
+
47
+ # cache_dir: ./cache # optional — where reusable artifacts live (processed
48
+ # data, embeddings, topic model). Defaults to a
49
+ # 'cache/' folder next to the input data file.
@@ -79,6 +79,12 @@ network:
79
79
  project_ttn: true # ttn only: also export the projected monopartite
80
80
  # version
81
81
 
82
- output_dir: ./out/cop22 # everything lands here: networks/, cache/
83
- # (embeddings, model, labeled df), run_manifest.json,
84
- # run_status.json
82
+ output_dir: ./out/cop22 # research outputs only: networks/, run_manifest.json,
83
+ # run_status.json, stage_timings.json
84
+
85
+ # cache_dir: ./cache # optional — where reusable artifacts live (processed
86
+ # data, embeddings, topic model). Defaults to a
87
+ # 'cache/' folder next to the input data file, so it
88
+ # persists regardless of output_dir and is shared
89
+ # between runs; safe to share across datasets (files
90
+ # are prefixed with `name`)
@@ -1,9 +1,19 @@
1
- """Run status: live stage tracking and ETA for long-running pipeline runs."""
1
+ """Run status: live stage tracking and ETA for long-running pipeline runs.
2
+
3
+ Writes `run_status.json` (machine-readable, consumed by `topiclayers status`)
4
+ and, when attached to a terminal, renders live console output: colored stage
5
+ banners, an in-place progress line with ETA, and a per-stage summary at the
6
+ end. A heartbeat keeps the elapsed time ticking (and the status file fresh)
7
+ during opaque stages that never call update(), so a silent stage is
8
+ distinguishable from a stuck one.
9
+ """
2
10
 
3
11
  from __future__ import annotations
4
12
 
5
13
  import json
6
14
  import os
15
+ import shutil
16
+ import sys
7
17
  import threading
8
18
  import time
9
19
  from datetime import datetime, timezone
@@ -14,15 +24,32 @@ STAGES = ["load", "data", "embed", "topic_model", "label", "network", "manifest"
14
24
  _STATUS_FILE = "run_status.json"
15
25
  _TIMINGS_FILE = "stage_timings.json"
16
26
  _MIN_UPDATE_INTERVAL = 1.0
27
+ _MIN_RENDER_INTERVAL = 0.2
17
28
  _HEARTBEAT_INTERVAL = 5.0
29
+ _SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
30
+
31
+
32
+ def _supports_ansi() -> bool:
33
+ return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
34
+
35
+
36
+ def _c(code: str, text: str) -> str:
37
+ if not _supports_ansi():
38
+ return text
39
+ return f"\033[{code}m{text}\033[0m"
40
+
41
+
42
+ def _fmt_count(n: int) -> str:
43
+ return f"{n:,}"
18
44
 
19
45
 
20
46
  class RunStatus:
21
- def __init__(self, output_dir: Path, name: str):
47
+ def __init__(self, output_dir: Path, name: str, console: bool = True):
22
48
  self.output_dir = Path(output_dir)
23
49
  self.name = name
50
+ self.console = console
24
51
  self.path = self.output_dir / _STATUS_FILE
25
- self.timings_path = self.output_dir / "cache" / _TIMINGS_FILE
52
+ self.timings_path = self.output_dir / _TIMINGS_FILE
26
53
  self.current_stage: Optional[str] = None
27
54
  self.stage_index: Optional[int] = None
28
55
  self.stage_total: Optional[int] = None
@@ -30,20 +57,111 @@ class RunStatus:
30
57
  self.started_at = datetime.now(timezone.utc)
31
58
  self.stage_started_at: Optional[datetime] = None
32
59
  self._last_write = 0.0
60
+ self._last_render = 0.0
61
+ self._line_active = False
62
+ self._spin_idx = 0
63
+ self._console_lock = threading.Lock()
64
+ self._stage_durations: dict[str, float] = {}
33
65
  self._stop_heartbeat = threading.Event()
34
66
  self._heartbeat_thread: Optional[threading.Thread] = None
35
67
 
68
+ # ------------------------------------------------------------------ console
69
+
70
+ def log(self, message: str) -> None:
71
+ """Print a message without clobbering the in-place progress line."""
72
+ with self._console_lock:
73
+ self._clear_line_locked()
74
+ print(message, flush=True)
75
+
76
+ def _clear_line_locked(self) -> None:
77
+ if self._line_active:
78
+ sys.stdout.write("\r\033[K")
79
+ sys.stdout.flush()
80
+ self._line_active = False
81
+
82
+ def _render(self, force: bool = False) -> None:
83
+ # in-place progress line; only meaningful on a real terminal
84
+ if not self.console or not sys.stdout.isatty():
85
+ return
86
+ now = time.monotonic()
87
+ if not force and now - self._last_render < _MIN_RENDER_INTERVAL:
88
+ return
89
+ self._last_render = now
90
+
91
+ with self._console_lock:
92
+ self._spin_idx += 1
93
+ spin = _c("2", _SPINNER[self._spin_idx % len(_SPINNER)])
94
+ stage = _c("36", self.current_stage) if self.current_stage else ""
95
+ bits = []
96
+ if (
97
+ self.processed is not None
98
+ and self.stage_total
99
+ and self.stage_total > 0
100
+ ):
101
+ pct = 100 * self.processed / self.stage_total
102
+ bits.append(
103
+ f"{_fmt_count(self.processed)}/{_fmt_count(self.stage_total)}"
104
+ f" ({pct:.0f}%)"
105
+ )
106
+ eta = self._eta_seconds()
107
+ if eta is not None:
108
+ bits.append(f"eta ~{_c('33', format_seconds(eta))}")
109
+ else:
110
+ bits.append(_c("2", "running…"))
111
+ if self.stage_started_at is not None:
112
+ elapsed = (datetime.now(timezone.utc) - self.stage_started_at).total_seconds()
113
+ bits.append(f"elapsed {format_seconds(elapsed)}")
114
+
115
+ line = f"{spin} {stage} " + _c("2", "·") + " " + f" {_c('2', '·')} ".join(bits)
116
+ width = shutil.get_terminal_size((80, 20)).columns
117
+ if len(line) + 8 > width:
118
+ line = line[: width - 8]
119
+ sys.stdout.write("\r\033[K" + line)
120
+ sys.stdout.flush()
121
+ self._line_active = True
122
+
123
+ def _banner(self, name: str, total: Optional[int], hist: Optional[float]) -> None:
124
+ with self._console_lock:
125
+ self._clear_line_locked()
126
+ if not self.console:
127
+ return
128
+ idx = STAGES.index(name) + 1 if name in STAGES else None
129
+ head = f"[{idx}/{len(STAGES)}] {name}" if idx else name
130
+ parts = [_c("2", "──"), _c("36;1", head)]
131
+ if total is not None:
132
+ parts.append(_c("2", f"── {_fmt_count(total)} items"))
133
+ if hist is not None:
134
+ parts.append(_c("2", f"── past runs ~{format_seconds(hist)}"))
135
+ print(" ".join(parts), flush=True)
136
+
137
+ def _summary(self, status: str) -> None:
138
+ with self._console_lock:
139
+ self._clear_line_locked()
140
+ if not self.console:
141
+ return
142
+ total = (datetime.now(timezone.utc) - self.started_at).total_seconds()
143
+ mark = _c("32", "✓") if status == "success" else _c("31", "✗")
144
+ print(f"\n{mark} run {status} in {format_seconds(total)}")
145
+ if self._stage_durations:
146
+ print(_c("2", " per-stage:"))
147
+ for stage, duration in self._stage_durations.items():
148
+ print(_c("2", f" {stage:<14} {format_seconds(duration):>10}"))
149
+
150
+ # ------------------------------------------------------------------- status
151
+
36
152
  def start(self) -> None:
37
153
  self._start_heartbeat()
38
154
  self._write(status="running")
39
155
 
40
156
  def _start_heartbeat(self) -> None:
41
157
  # keeps stage_elapsed_seconds fresh during opaque stages (e.g. UMAP +
42
- # HDBSCAN) that never call update()
158
+ # HDBSCAN) that never call update(); also re-renders the console line
159
+ # with an advancing spinner so silence is visibly not a hang
43
160
  def beat():
44
161
  while not self._stop_heartbeat.wait(_HEARTBEAT_INTERVAL):
45
162
  if self.current_stage is not None:
46
163
  self._write(status="running", force=True)
164
+ self._render(force=True)
47
165
 
48
166
  self._heartbeat_thread = threading.Thread(target=beat, daemon=True)
49
167
  self._heartbeat_thread.start()
@@ -59,15 +177,20 @@ class RunStatus:
59
177
  self.processed = 0
60
178
  self.stage_started_at = now
61
179
  self._write(status="running", force=True)
180
+ if self.console:
181
+ self._banner(name, total, historical_eta(self.timings_path, name))
182
+ self._last_render = time.monotonic()
62
183
 
63
184
  def update(self, processed: int, total: Optional[int] = None) -> None:
64
185
  self.processed = processed
65
186
  if total is not None:
66
187
  self.stage_total = total
67
188
  self._write(status="running", throttle=True)
189
+ self._render()
68
190
 
69
191
  def flush(self) -> None:
70
192
  self._write(status="running", force=True)
193
+ self._render(force=True)
71
194
 
72
195
  def finish(self, status: str = "success") -> None:
73
196
  self._stop_heartbeat.set()
@@ -75,6 +198,10 @@ class RunStatus:
75
198
  duration = (datetime.now(timezone.utc) - self.stage_started_at).total_seconds()
76
199
  self._record_timing(self.current_stage, duration)
77
200
  self._write(status=status, force=True)
201
+ if self.console:
202
+ self._summary(status)
203
+
204
+ # ------------------------------------------------------------------ helpers
78
205
 
79
206
  def _eta_seconds(self) -> Optional[float]:
80
207
  if (
@@ -120,6 +247,7 @@ class RunStatus:
120
247
  os.replace(tmp, self.path)
121
248
 
122
249
  def _record_timing(self, stage: str, duration: float) -> None:
250
+ self._stage_durations[stage] = duration
123
251
  self.timings_path.parent.mkdir(parents=True, exist_ok=True)
124
252
  records: list[dict] = []
125
253
  if self.timings_path.exists():
@@ -139,6 +267,15 @@ class RunStatus:
139
267
  os.replace(tmp, self.timings_path)
140
268
 
141
269
 
270
+ def log_via(reporter, message: str) -> None:
271
+ """Print a message through the reporter's console line (plain print fallback)."""
272
+ log_fn = getattr(reporter, "log", None)
273
+ if log_fn is not None:
274
+ log_fn(message)
275
+ else:
276
+ print(message)
277
+
278
+
142
279
  def load_status(path: Path) -> dict:
143
280
  with open(path) as f:
144
281
  return json.load(f)
@@ -202,7 +339,7 @@ def print_status(output_dir: Path) -> int:
202
339
  if eta is not None:
203
340
  print(f"Stage ETA: ~{format_seconds(eta)}")
204
341
  else:
205
- hist = historical_eta(output_dir / "cache" / _TIMINGS_FILE, stage)
342
+ hist = historical_eta(output_dir / _TIMINGS_FILE, stage)
206
343
  if hist is not None:
207
344
  print(f"No live ETA for this stage; past runs took ~{format_seconds(hist)} (median).")
208
345
  else:
File without changes