topiclayers 0.2.0__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.
- {topiclayers-0.2.0 → topiclayers-0.3.0}/PKG-INFO +27 -12
- {topiclayers-0.2.0 → topiclayers-0.3.0}/README.md +26 -11
- {topiclayers-0.2.0 → topiclayers-0.3.0}/pyproject.toml +1 -1
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/__init__.py +1 -1
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/cli.py +42 -15
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/config.py +17 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/core/data.py +8 -7
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/core/network.py +4 -2
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/core/topic.py +31 -21
- topiclayers-0.3.0/src/topiclayers/examples/generic/posts.csv +8 -0
- topiclayers-0.3.0/src/topiclayers/examples/generic.yml +15 -0
- topiclayers-0.3.0/src/topiclayers/examples/template_csv.yml +49 -0
- topiclayers-0.3.0/src/topiclayers/examples/template_twitter.yml +90 -0
- topiclayers-0.3.0/src/topiclayers/examples/twitter/toy.json +1 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/status.py +142 -5
- {topiclayers-0.2.0 → topiclayers-0.3.0}/LICENSE +0 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/adapters/__init__.py +0 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/adapters/csv.py +0 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/adapters/registry.py +0 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/adapters/twitter.py +0 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/core/__init__.py +0 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/manifest.py +0 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/schema.py +0 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/support/__init__.py +0 -0
- {topiclayers-0.2.0 → topiclayers-0.3.0}/src/topiclayers/support/cache.py +0 -0
- {topiclayers-0.2.0 → 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.
|
|
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
|
|
@@ -43,6 +43,10 @@ Description-Content-Type: text/markdown
|
|
|
43
43
|
```bash
|
|
44
44
|
# Requires Python 3.12
|
|
45
45
|
pip install topiclayers
|
|
46
|
+
|
|
47
|
+
# copy runnable examples (configs + toy data) into ./examples/
|
|
48
|
+
topiclayers init
|
|
49
|
+
|
|
46
50
|
topiclayers run examples/generic.yml
|
|
47
51
|
```
|
|
48
52
|
|
|
@@ -99,7 +103,7 @@ Stage ETA: ~35m 20s
|
|
|
99
103
|
Total elapsed: 21m 05s
|
|
100
104
|
```
|
|
101
105
|
|
|
102
|
-
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>/
|
|
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.
|
|
103
107
|
|
|
104
108
|
## Topic labeling with an LLM (optional)
|
|
105
109
|
|
|
@@ -161,7 +165,8 @@ CSV optional columns: `created_at`, `lang`, `interaction_type` (`original`/`repo
|
|
|
161
165
|
| Symptom | Likely cause | Fix |
|
|
162
166
|
|---------|-------------|-----|
|
|
163
167
|
| "All topics are -1" / "no topics found" | `min_topic_size` too high or dataset too small | Halve `min_topic_size` in config, or use a larger dataset (>100 posts) |
|
|
164
|
-
| "Module not found" | topiclayers not installed | `pip install
|
|
168
|
+
| "Module not found" | topiclayers not installed | `pip install topiclayers` |
|
|
169
|
+
| `topiclayers: command not found` | installed in a venv that isn't active | Activate the venv (`source .venv/bin/activate`) or use `python -m topiclayers.cli`; on install with `--user` add `~/.local/bin` to PATH |
|
|
165
170
|
| "OpenAI API key not set" | Using OpenAI embedder without key | Switch `embedder` to `all-MiniLM-L6-v2` (default, works offline) |
|
|
166
171
|
| "Qdrant connection refused" | Qdrant vector DB not running | Ignore — Qdrant is optional. Set `QDRANT_URL` in `.env` to enable |
|
|
167
172
|
| "Cannot create multilayer network" | All posts are outliers | Reduce `min_topic_size` or provide more data |
|
|
@@ -175,12 +180,7 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
|
|
|
175
180
|
<output_dir>/
|
|
176
181
|
├── run_manifest.json # Reproducibility metadata
|
|
177
182
|
├── run_status.json # Live stage/ETA tracking (while running)
|
|
178
|
-
├──
|
|
179
|
-
│ └── data/
|
|
180
|
-
│ ├── tweets_<name>.pkl/.csv # Full tweet table
|
|
181
|
-
│ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
|
|
182
|
-
│ └── manifest_<name>.json # Cache validity key
|
|
183
|
-
│ └── stage_timings.json # Per-stage durations for ETA prediction
|
|
183
|
+
├── stage_timings.json # Per-stage durations for ETA prediction
|
|
184
184
|
└── networks/
|
|
185
185
|
├── <name>_retweet.gml # Single-layer retweet network
|
|
186
186
|
├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
|
|
@@ -189,13 +189,28 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
|
|
|
189
189
|
└── <name>__prj_<topic>.gml # Per-topic projected networks
|
|
190
190
|
```
|
|
191
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
|
+
|
|
192
210
|
## Requirements
|
|
193
211
|
|
|
194
212
|
- Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
|
|
195
213
|
- Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
|
|
196
214
|
|
|
197
|
-
## Planned: JOSS software paper
|
|
198
215
|
|
|
199
|
-
Once the API stabilises and the PLOS ONE core paper results are regenerated with this library,
|
|
200
|
-
a short JOSS (Journal of Open Source Software) paper will be submitted with a Zenodo DOI.
|
|
201
216
|
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
```bash
|
|
8
8
|
# Requires Python 3.12
|
|
9
9
|
pip install topiclayers
|
|
10
|
+
|
|
11
|
+
# copy runnable examples (configs + toy data) into ./examples/
|
|
12
|
+
topiclayers init
|
|
13
|
+
|
|
10
14
|
topiclayers run examples/generic.yml
|
|
11
15
|
```
|
|
12
16
|
|
|
@@ -63,7 +67,7 @@ Stage ETA: ~35m 20s
|
|
|
63
67
|
Total elapsed: 21m 05s
|
|
64
68
|
```
|
|
65
69
|
|
|
66
|
-
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>/
|
|
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.
|
|
67
71
|
|
|
68
72
|
## Topic labeling with an LLM (optional)
|
|
69
73
|
|
|
@@ -125,7 +129,8 @@ CSV optional columns: `created_at`, `lang`, `interaction_type` (`original`/`repo
|
|
|
125
129
|
| Symptom | Likely cause | Fix |
|
|
126
130
|
|---------|-------------|-----|
|
|
127
131
|
| "All topics are -1" / "no topics found" | `min_topic_size` too high or dataset too small | Halve `min_topic_size` in config, or use a larger dataset (>100 posts) |
|
|
128
|
-
| "Module not found" | topiclayers not installed | `pip install
|
|
132
|
+
| "Module not found" | topiclayers not installed | `pip install topiclayers` |
|
|
133
|
+
| `topiclayers: command not found` | installed in a venv that isn't active | Activate the venv (`source .venv/bin/activate`) or use `python -m topiclayers.cli`; on install with `--user` add `~/.local/bin` to PATH |
|
|
129
134
|
| "OpenAI API key not set" | Using OpenAI embedder without key | Switch `embedder` to `all-MiniLM-L6-v2` (default, works offline) |
|
|
130
135
|
| "Qdrant connection refused" | Qdrant vector DB not running | Ignore — Qdrant is optional. Set `QDRANT_URL` in `.env` to enable |
|
|
131
136
|
| "Cannot create multilayer network" | All posts are outliers | Reduce `min_topic_size` or provide more data |
|
|
@@ -139,12 +144,7 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
|
|
|
139
144
|
<output_dir>/
|
|
140
145
|
├── run_manifest.json # Reproducibility metadata
|
|
141
146
|
├── run_status.json # Live stage/ETA tracking (while running)
|
|
142
|
-
├──
|
|
143
|
-
│ └── data/
|
|
144
|
-
│ ├── tweets_<name>.pkl/.csv # Full tweet table
|
|
145
|
-
│ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
|
|
146
|
-
│ └── manifest_<name>.json # Cache validity key
|
|
147
|
-
│ └── stage_timings.json # Per-stage durations for ETA prediction
|
|
147
|
+
├── stage_timings.json # Per-stage durations for ETA prediction
|
|
148
148
|
└── networks/
|
|
149
149
|
├── <name>_retweet.gml # Single-layer retweet network
|
|
150
150
|
├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
|
|
@@ -153,12 +153,27 @@ For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`
|
|
|
153
153
|
└── <name>__prj_<topic>.gml # Per-topic projected networks
|
|
154
154
|
```
|
|
155
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
|
+
|
|
156
174
|
## Requirements
|
|
157
175
|
|
|
158
176
|
- Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
|
|
159
177
|
- Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
|
|
160
178
|
|
|
161
|
-
## Planned: JOSS software paper
|
|
162
179
|
|
|
163
|
-
Once the API stabilises and the PLOS ONE core paper results are regenerated with this library,
|
|
164
|
-
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.
|
|
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
|
-
|
|
48
|
+
status.log(f"Loading posts via '{fmt}' adapter from {input_path}")
|
|
49
49
|
posts = loader(input_path)
|
|
50
|
-
|
|
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.
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
+
status.log(f" Skipping multilayer network: {e}")
|
|
134
137
|
nw.create_ttnetwork(project=config.network.project_ttn)
|
|
135
138
|
else:
|
|
136
|
-
|
|
137
|
-
|
|
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,13 +170,37 @@ 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
|
|
|
173
|
+
def _cmd_init(dest: Path) -> None:
|
|
174
|
+
import shutil
|
|
175
|
+
from importlib.resources import as_file, files
|
|
176
|
+
|
|
177
|
+
if dest.exists():
|
|
178
|
+
print(f"Directory '{dest}' already exists — remove it or pass another path.",
|
|
179
|
+
file=sys.stderr)
|
|
180
|
+
sys.exit(1)
|
|
181
|
+
|
|
182
|
+
with as_file(files("topiclayers") / "examples") as bundled:
|
|
183
|
+
shutil.copytree(bundled, dest)
|
|
184
|
+
|
|
185
|
+
print(f"Examples copied to {dest.resolve()}")
|
|
186
|
+
print("Try it:")
|
|
187
|
+
print(f" topiclayers run {dest / 'generic.yml'}")
|
|
188
|
+
|
|
170
189
|
|
|
171
190
|
def main() -> None:
|
|
172
|
-
if len(sys.argv) <
|
|
191
|
+
if len(sys.argv) < 2 or (
|
|
192
|
+
sys.argv[1] not in ("run", "status", "init")
|
|
193
|
+
or (sys.argv[1] != "init" and len(sys.argv) < 3)
|
|
194
|
+
):
|
|
173
195
|
print("Usage: topiclayers run <config.yaml>", file=sys.stderr)
|
|
174
196
|
print(" topiclayers status <output_dir>", file=sys.stderr)
|
|
197
|
+
print(" topiclayers init [dest=./examples]", file=sys.stderr)
|
|
175
198
|
sys.exit(1)
|
|
176
199
|
|
|
200
|
+
if sys.argv[1] == "init":
|
|
201
|
+
_cmd_init(Path(sys.argv[2]) if len(sys.argv) > 2 else Path("examples"))
|
|
202
|
+
return
|
|
203
|
+
|
|
177
204
|
if sys.argv[1] == "status":
|
|
178
205
|
from topiclayers.status import print_status
|
|
179
206
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
+
log_via(reporter, f"Unresolved references: {unresolved_count}")
|
|
201
202
|
if unresolved_count > 0:
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
622
|
+
log_via(reporter, f" Labeled {ok} topics with '{model}' in {n_chunks} batch(es){suffix}.")
|
|
613
623
|
return labels
|
|
614
624
|
|
|
615
625
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
post_id,user_id,text,created_at,lang,interaction_type,target_post_id
|
|
2
|
+
t1,a,tweet1,2022-01-01,en,original,
|
|
3
|
+
t2,b,tweet2,2022-01-02,en,repost,t1
|
|
4
|
+
t3,c,tweet3,2022-01-03,en,repost,t2
|
|
5
|
+
t4,c,tweet4,2022-01-04,en,original,
|
|
6
|
+
t5,a,tweet5,2022-01-05,en,repost,t7
|
|
7
|
+
t6,d,tweet6,2022-01-06,en,repost,t7
|
|
8
|
+
t7,d,tweet7,2022-01-07,en,original,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
input:
|
|
2
|
+
format: csv_posts
|
|
3
|
+
# paths are resolved relative to this config file's directory
|
|
4
|
+
posts: generic/posts.csv
|
|
5
|
+
|
|
6
|
+
name: generic_toy
|
|
7
|
+
|
|
8
|
+
topic_model:
|
|
9
|
+
embedder: all-MiniLM-L6-v2
|
|
10
|
+
min_topic_size: 2
|
|
11
|
+
|
|
12
|
+
network:
|
|
13
|
+
type: multilayer_repost
|
|
14
|
+
|
|
15
|
+
output_dir: ./out/generic_toy
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# TEMPLATE — Generic CSV/Parquet posts (any platform)
|
|
3
|
+
# Copy this file, rename it, adjust the values below.
|
|
4
|
+
# Run: topiclayers run examples/<your_config>.yml
|
|
5
|
+
# Paths in `input` are resolved relative to THIS file's directory.
|
|
6
|
+
# ============================================================
|
|
7
|
+
|
|
8
|
+
input:
|
|
9
|
+
format: csv_posts # or parquet_posts
|
|
10
|
+
posts: generic/posts.csv # required columns: post_id, user_id, text
|
|
11
|
+
# optional columns: created_at, lang,
|
|
12
|
+
# interaction_type ("original"|"repost"|"quote"|"reply"),
|
|
13
|
+
# target_post_id, mentions (semicolon-separated),
|
|
14
|
+
# any extra_* column is carried through as metadata
|
|
15
|
+
# users: path/to/users.csv # optional user metadata file
|
|
16
|
+
|
|
17
|
+
name: my_dataset # prefixes outputs and cache keys; changing it starts
|
|
18
|
+
# a fresh cache
|
|
19
|
+
|
|
20
|
+
topic_model:
|
|
21
|
+
embedder: all-MiniLM-L6-v2 # sentence-transformers model, or OpenAI
|
|
22
|
+
# "text-embedding-3-small"/"-large" (needs
|
|
23
|
+
# OPENAI_API_KEY in .env)
|
|
24
|
+
min_topic_size: 10 # small datasets need small values; start low and raise
|
|
25
|
+
# until topics look coherent
|
|
26
|
+
nr_topics: auto # "auto", or an int to force reduction after fit
|
|
27
|
+
random_state: 42
|
|
28
|
+
umap_n_neighbors: 15
|
|
29
|
+
umap_n_components: 5
|
|
30
|
+
exact_probabilities: false # true = full membership matrix (slow on big corpora)
|
|
31
|
+
repro_mode: fast # "fast" = parallel, non-reproducible;
|
|
32
|
+
# "strict" = seeded single-threaded, bit-reproducible
|
|
33
|
+
umap_init: pca # default; ~20x faster than spectral, deterministic
|
|
34
|
+
# when seeded (docs/UMAP_INIT_BENCHMARK.md)
|
|
35
|
+
label_model: ollama/qwen2.5:7b # optional LLM topic labeling (LiteLLM string);
|
|
36
|
+
# "" disables. See template_twitter.yml for setup
|
|
37
|
+
|
|
38
|
+
network:
|
|
39
|
+
type: retweet # for CSV data usually "retweet" (or "multilayer_repost"
|
|
40
|
+
# / "ttn" / "all"); requires target_post_id +
|
|
41
|
+
# interaction_type=repost rows to build edges
|
|
42
|
+
project_ttn: true
|
|
43
|
+
|
|
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.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# ============================================================
|
|
2
|
+
# TEMPLATE — Twitter/X JSONL input (Academic API dump)
|
|
3
|
+
# Copy this file, rename it, adjust the values below.
|
|
4
|
+
# Run: topiclayers run examples/<your_config>.yml
|
|
5
|
+
# Status from another terminal while running:
|
|
6
|
+
# topiclayers status <output_dir>
|
|
7
|
+
# Paths in `input` are resolved relative to THIS file's directory.
|
|
8
|
+
# ============================================================
|
|
9
|
+
|
|
10
|
+
input:
|
|
11
|
+
format: twitter_jsonl # one JSON tweet per line (Twitter API v2 fields:
|
|
12
|
+
# id, author_id, text, referenced_tweets, entities...)
|
|
13
|
+
posts: twitter/toy.json # required — path to the .json/.jsonl tweet file
|
|
14
|
+
# users: path/to/users.dat # optional — JSONL of user objects
|
|
15
|
+
# ({id, username, public_metrics}); used to map
|
|
16
|
+
# author ids -> usernames in outputs.
|
|
17
|
+
# Delete the line if you don't have it.
|
|
18
|
+
|
|
19
|
+
name: cop22 # dataset name: prefixes output files and cache keys.
|
|
20
|
+
# Changing it starts a fresh cache.
|
|
21
|
+
|
|
22
|
+
topic_model:
|
|
23
|
+
embedder: all-MiniLM-L6-v2 # any sentence-transformers model, or an OpenAI
|
|
24
|
+
# embedding model ("text-embedding-3-small" /
|
|
25
|
+
# "text-embedding-3-large"; requires OPENAI_API_KEY
|
|
26
|
+
# in .env)
|
|
27
|
+
min_topic_size: 50 # smallest cluster HDBSCAN will accept (= BERTopic's
|
|
28
|
+
# granularity knob). Too high -> everything is outlier
|
|
29
|
+
# -1; too low -> thousands of micro-topics.
|
|
30
|
+
# Rule of thumb: ~1/2000 of originals for large sets.
|
|
31
|
+
nr_topics: auto # "auto", or an int to force topic reduction after fit
|
|
32
|
+
random_state: 42 # seed; keep fixed for reproducibility
|
|
33
|
+
umap_n_neighbors: 15 # UMAP locality (kept for reference; pipeline uses
|
|
34
|
+
# adaptive values for small datasets)
|
|
35
|
+
umap_n_components: 5 # UMAP output dims (same note as above)
|
|
36
|
+
exact_probabilities: false # true = full membership matrix (much slower on big
|
|
37
|
+
# corpora); topics are identical either way
|
|
38
|
+
repro_mode: fast # "fast" = parallel UMAP+HDBSCAN (~20 min saved on
|
|
39
|
+
# 180k docs), results vary between runs;
|
|
40
|
+
# "strict" = seeded single-threaded UMAP,
|
|
41
|
+
# bit-reproducible, slower
|
|
42
|
+
umap_init: pca # default; deterministic when seeded and ~20x faster
|
|
43
|
+
# than spectral (see docs/UMAP_INIT_BENCHMARK.md).
|
|
44
|
+
# Pass "spectral" only if you specifically need it.
|
|
45
|
+
label_model: ollama/qwen2.5:7b # LLM that writes a short human-readable label per
|
|
46
|
+
# topic (any LiteLLM model string). Local & free
|
|
47
|
+
# via Ollama (https://ollama.com):
|
|
48
|
+
# ollama serve && ollama pull qwen2.5:7b
|
|
49
|
+
# Cloud examples: "gpt-4o-mini",
|
|
50
|
+
# "anthropic/claude-3-haiku-20240307".
|
|
51
|
+
# Custom OpenAI-compatible server (Unsloth,
|
|
52
|
+
# llama.cpp, vLLM...): prefix with "openai/" and
|
|
53
|
+
# set label_api_base, e.g.
|
|
54
|
+
# label_model: openai/unsloth/Qwen3.8-27B-GGUF
|
|
55
|
+
# label_api_base: http://localhost:8888/v1
|
|
56
|
+
# Requires the labeling extra:
|
|
57
|
+
# pip install 'topiclayers[labeling]'
|
|
58
|
+
# API key from LABEL_API_KEY / OPENAI_LIKE_API_KEY
|
|
59
|
+
# / OPENAI_API_KEY in .env. Set label_model to ""
|
|
60
|
+
# to disable; non-fatal if unreachable.
|
|
61
|
+
# label_context: "" # optional context sentence ("all documents are ...");
|
|
62
|
+
# by default derived from `name` for cop* datasets.
|
|
63
|
+
# Helps the LLM label the specific SUBTOPIC instead
|
|
64
|
+
# of the general domain. Topics are labeled in
|
|
65
|
+
# batches (~40 per call); use a strong model for
|
|
66
|
+
# publishable labels (e.g. gpt-4o-mini, <1 min and
|
|
67
|
+
# cents for a full dataset)
|
|
68
|
+
label_api_base: "" # base URL of a custom OpenAI-compatible endpoint;
|
|
69
|
+
# leave "" for Ollama/OpenAI. Can also be set via
|
|
70
|
+
# LABEL_API_BASE in .env
|
|
71
|
+
|
|
72
|
+
network:
|
|
73
|
+
type: multilayer_repost # one of:
|
|
74
|
+
# multilayer_repost -> retweet graph + multilayer
|
|
75
|
+
# network by topic (uunet)
|
|
76
|
+
# retweet -> retweet graph only (.gml)
|
|
77
|
+
# ttn -> topic-targeted network
|
|
78
|
+
# all -> all of the above
|
|
79
|
+
project_ttn: true # ttn only: also export the projected monopartite
|
|
80
|
+
# version
|
|
81
|
+
|
|
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`)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"lang": "en", "possibly_sensitive": false, "public_metrics": {"retweet_count": 8, "reply_count": 0, "like_count": 0, "quote_count": 0, "impression_count": 0}, "conversation_id": "815345170468208642", "entities": {"mentions": [{"start": 3, "end": 14, "username": "pablorodas", "id": "52119056"}], "hashtags": [{"start": 16, "end": 30, "tag": "CLIMATEchange"}, {"start": 31, "end": 34, "tag": "p2"}, {"start": 112, "end": 118, "tag": "COP22"}, {"start": 119, "end": 124, "tag": "tcot"}, {"start": 125, "end": 128, "tag": "2A"}], "urls": [{"start": 88, "end": 111, "url": "https://t.co/f5lY9m4sE2", "expanded_url": "http://dlvr.it/N0sp69", "display_url": "dlvr.it/N0sp69"}], "annotations": [{"start": 113, "end": 117, "probability": 0.775, "type": "Other", "normalized_text": "COP22"}]}, "referenced_tweets": [{"type": "retweeted", "id": "815344316495237120"}], "text": "RT @pablorodas: #CLIMATEchange #p2 RT Our best environment stories from this past\u00a0year. https://t.co/f5lY9m4sE2 #COP22 #tcot #2A https://t.\u2026", "id": "815345170468208642", "created_at": "2016-12-31T23:53:31.000Z", "author_id": "17227307", "context_annotations": [{"domain": {"id": "25", "name": "Events", "description": ""}, "entity": {"id": "10040144401", "name": "Extreme Weather + Climate Change"}}, {"domain": {"id": "25", "name": "Events", "description": ""}, "entity": {"id": "10040529339", "name": "Discovery VCN - Earth"}}], "edit_history_tweet_ids": ["815345170468208642"]}
|
|
@@ -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 /
|
|
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 /
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|