topiclayers 0.2.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/LICENSE +21 -0
- topiclayers-0.2.0/PKG-INFO +201 -0
- topiclayers-0.2.0/README.md +164 -0
- topiclayers-0.2.0/pyproject.toml +52 -0
- topiclayers-0.2.0/src/topiclayers/__init__.py +3 -0
- topiclayers-0.2.0/src/topiclayers/adapters/__init__.py +7 -0
- topiclayers-0.2.0/src/topiclayers/adapters/csv.py +91 -0
- topiclayers-0.2.0/src/topiclayers/adapters/registry.py +47 -0
- topiclayers-0.2.0/src/topiclayers/adapters/twitter.py +73 -0
- topiclayers-0.2.0/src/topiclayers/cli.py +192 -0
- topiclayers-0.2.0/src/topiclayers/config.py +84 -0
- topiclayers-0.2.0/src/topiclayers/core/__init__.py +26 -0
- topiclayers-0.2.0/src/topiclayers/core/data.py +217 -0
- topiclayers-0.2.0/src/topiclayers/core/network.py +281 -0
- topiclayers-0.2.0/src/topiclayers/core/topic.py +712 -0
- topiclayers-0.2.0/src/topiclayers/manifest.py +88 -0
- topiclayers-0.2.0/src/topiclayers/schema.py +27 -0
- topiclayers-0.2.0/src/topiclayers/status.py +214 -0
- topiclayers-0.2.0/src/topiclayers/support/__init__.py +1 -0
- topiclayers-0.2.0/src/topiclayers/support/cache.py +11 -0
- topiclayers-0.2.0/src/topiclayers/support/repro.py +16 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Alessio Gandelli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: topiclayers
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: From any collection of posts to topical multilayer networks — hardened and social-scientist-friendly
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: topic-modeling,network-analysis,social-science,polarisation
|
|
8
|
+
Author: alessiogandelli
|
|
9
|
+
Author-email: alessiogandelli99@gmail.com
|
|
10
|
+
Requires-Python: >=3.12,<3.13
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Provides-Extra: labeling
|
|
17
|
+
Requires-Dist: bertopic (>=0.17.4,<0.18.0)
|
|
18
|
+
Requires-Dist: igraph (>=0.11.4)
|
|
19
|
+
Requires-Dist: jsonlines (>=4.0.0,<5.0.0)
|
|
20
|
+
Requires-Dist: litellm (>=1.40) ; extra == "labeling"
|
|
21
|
+
Requires-Dist: networkx (>=3.6,<4.0)
|
|
22
|
+
Requires-Dist: numpy (>=1.26,<3)
|
|
23
|
+
Requires-Dist: openai (>=3.0.0,<4.0.0)
|
|
24
|
+
Requires-Dist: pandas (>=2.2,<4)
|
|
25
|
+
Requires-Dist: python-dotenv (>=1.0.1,<2.0.0)
|
|
26
|
+
Requires-Dist: pyyaml (>=6.0,<7.0)
|
|
27
|
+
Requires-Dist: qdrant-client (>=1.19.0,<2.0.0)
|
|
28
|
+
Requires-Dist: scikit-learn (>=1.5)
|
|
29
|
+
Requires-Dist: sentence-transformers (>=6.0.0,<7.0.0)
|
|
30
|
+
Requires-Dist: umap-learn (>=0.5.12,<0.6.0)
|
|
31
|
+
Requires-Dist: uunet (>=2.1.1,<3.0.0)
|
|
32
|
+
Project-URL: Changelog, https://github.com/alessiogandelli/topiclayers/blob/main/CHANGELOG.md
|
|
33
|
+
Project-URL: Issues, https://github.com/alessiogandelli/topiclayers/issues
|
|
34
|
+
Project-URL: Repository, https://github.com/alessiogandelli/topiclayers
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# topiclayers
|
|
38
|
+
|
|
39
|
+
> From posts to topical multilayer networks — hardened and social-scientist-friendly.
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Requires Python 3.12
|
|
45
|
+
pip install topiclayers
|
|
46
|
+
topiclayers run examples/generic.yml
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That's it. You get a GML network file in `./out/generic_toy/networks/`.
|
|
50
|
+
|
|
51
|
+
## What does it do?
|
|
52
|
+
|
|
53
|
+
1. **Loads** your posts (Twitter JSONL, CSV, Parquet).
|
|
54
|
+
2. **Cleans** text (removes URLs, @mentions, newlines).
|
|
55
|
+
3. **Models topics** using BERTopic with SentenceTransformer embeddings.
|
|
56
|
+
4. **Labels topics** with an LLM (local via Ollama by default) and propagates topic labels through retweet chains.
|
|
57
|
+
5. **Builds networks**: single-layer retweet, multilayer per-topic, and bipartite temporal-text networks.
|
|
58
|
+
|
|
59
|
+
## Minimal config
|
|
60
|
+
|
|
61
|
+
Save this as `my_config.yml`:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
input:
|
|
65
|
+
format: csv_posts
|
|
66
|
+
posts: my_data.csv
|
|
67
|
+
|
|
68
|
+
name: my_dataset
|
|
69
|
+
|
|
70
|
+
topic_model:
|
|
71
|
+
embedder: all-MiniLM-L6-v2
|
|
72
|
+
min_topic_size: 50
|
|
73
|
+
|
|
74
|
+
network:
|
|
75
|
+
type: multilayer_repost
|
|
76
|
+
|
|
77
|
+
output_dir: ./out/my_dataset
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Run it: `topiclayers run my_config.yml`
|
|
81
|
+
|
|
82
|
+
## Checking progress
|
|
83
|
+
|
|
84
|
+
Long runs write a live status file to `<output_dir>/run_status.json`. Check the current stage, elapsed time, and ETA from another terminal without touching the running job:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
topiclayers status out/my_dataset
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Example output:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
Stage 3/7: embed
|
|
94
|
+
Status: running
|
|
95
|
+
Run: my_dataset
|
|
96
|
+
Progress: 412000/1200000 (34%)
|
|
97
|
+
Stage elapsed: 18m 12s
|
|
98
|
+
Stage ETA: ~35m 20s
|
|
99
|
+
Total elapsed: 21m 05s
|
|
100
|
+
```
|
|
101
|
+
|
|
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>/cache/stage_timings.json`.
|
|
103
|
+
|
|
104
|
+
## Topic labeling with an LLM (optional)
|
|
105
|
+
|
|
106
|
+
After clustering, the pipeline can ask a local LLM to write one short, readable
|
|
107
|
+
label per topic (instead of raw keyword lists like `-1_proclamation_plante_trending`).
|
|
108
|
+
Default is **Ollama** — free, local, nothing leaves your machine:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# one-time setup
|
|
112
|
+
pip install 'topiclayers[labeling]'
|
|
113
|
+
ollama pull qwen2.5:7b
|
|
114
|
+
|
|
115
|
+
# start the server in another terminal
|
|
116
|
+
ollama serve
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Then just run the pipeline as usual (`label_model` is on by default). Any
|
|
120
|
+
[LiteLLM](https://docs.litellm.ai/docs/providers) model string works:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
topic_model:
|
|
124
|
+
label_model: ollama/qwen2.5:7b # default
|
|
125
|
+
# label_model: gpt-4o-mini # OpenAI (needs OPENAI_API_KEY)
|
|
126
|
+
# label_model: anthropic/claude-3-haiku-20240307
|
|
127
|
+
# label_model: "" # disable labeling
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Custom / self-hosted OpenAI-compatible endpoints
|
|
131
|
+
|
|
132
|
+
Any server speaking the OpenAI protocol (Unsloth, llama.cpp, vLLM, LM Studio...)
|
|
133
|
+
works via `label_api_base`:
|
|
134
|
+
|
|
135
|
+
```yaml
|
|
136
|
+
topic_model:
|
|
137
|
+
label_model: openai/unsloth/Qwen3.8-27B-GGUF # note the openai/ prefix
|
|
138
|
+
label_api_base: http://localhost:8888/v1
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
with the token in `.env` (key resolution order: `LABEL_API_KEY` →
|
|
142
|
+
`OPENAI_LIKE_API_KEY` → `OPENAI_API_KEY`). You can also set `LABEL_API_BASE`
|
|
143
|
+
in `.env` instead of the YAML. Find the exact model name your server exposes
|
|
144
|
+
with `curl http://localhost:8888/v1/models`.
|
|
145
|
+
|
|
146
|
+
Labeling is non-fatal: if the model server is unreachable, topics keep their
|
|
147
|
+
keyword labels and everything else proceeds normally.
|
|
148
|
+
|
|
149
|
+
## Input formats
|
|
150
|
+
|
|
151
|
+
| Format | Extension | Description |
|
|
152
|
+
|--------|-----------|-------------|
|
|
153
|
+
| `twitter_jsonl` | `.json`, `.jsonl` | Twitter/X API v2 JSONL |
|
|
154
|
+
| `csv_posts` | `.csv` | Generic CSV with `post_id`, `user_id`, `text` columns |
|
|
155
|
+
| `parquet_posts` | `.parquet` | Same schema as CSV, Parquet format |
|
|
156
|
+
|
|
157
|
+
CSV optional columns: `created_at`, `lang`, `interaction_type` (`original`/`repost`/`quote`/`reply`), `target_post_id`, `mentions` (semicolon-separated), `extra_*` passthrough columns.
|
|
158
|
+
|
|
159
|
+
## What if something fails?
|
|
160
|
+
|
|
161
|
+
| Symptom | Likely cause | Fix |
|
|
162
|
+
|---------|-------------|-----|
|
|
163
|
+
| "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 -e .` |
|
|
165
|
+
| "OpenAI API key not set" | Using OpenAI embedder without key | Switch `embedder` to `all-MiniLM-L6-v2` (default, works offline) |
|
|
166
|
+
| "Qdrant connection refused" | Qdrant vector DB not running | Ignore — Qdrant is optional. Set `QDRANT_URL` in `.env` to enable |
|
|
167
|
+
| "Cannot create multilayer network" | All posts are outliers | Reduce `min_topic_size` or provide more data |
|
|
168
|
+
| "UMAP spectral layout failed" | Dataset too small (<10 posts) | Topic modeling needs more data; consider using topic labels from elsewhere |
|
|
169
|
+
|
|
170
|
+
## Output files
|
|
171
|
+
|
|
172
|
+
For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
<output_dir>/
|
|
176
|
+
├── run_manifest.json # Reproducibility metadata
|
|
177
|
+
├── run_status.json # Live stage/ETA tracking (while running)
|
|
178
|
+
├── cache/
|
|
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
|
|
184
|
+
└── networks/
|
|
185
|
+
├── <name>_retweet.gml # Single-layer retweet network
|
|
186
|
+
├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
|
|
187
|
+
├── <name>_ttt.gml # Temporal-text bipartite network
|
|
188
|
+
└── projected/
|
|
189
|
+
└── <name>__prj_<topic>.gml # Per-topic projected networks
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Requirements
|
|
193
|
+
|
|
194
|
+
- Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
|
|
195
|
+
- Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
|
|
196
|
+
|
|
197
|
+
## Planned: JOSS software paper
|
|
198
|
+
|
|
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
|
+
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# topiclayers
|
|
2
|
+
|
|
3
|
+
> From posts to topical multilayer networks — hardened and social-scientist-friendly.
|
|
4
|
+
|
|
5
|
+
## Quickstart
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Requires Python 3.12
|
|
9
|
+
pip install topiclayers
|
|
10
|
+
topiclayers run examples/generic.yml
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That's it. You get a GML network file in `./out/generic_toy/networks/`.
|
|
14
|
+
|
|
15
|
+
## What does it do?
|
|
16
|
+
|
|
17
|
+
1. **Loads** your posts (Twitter JSONL, CSV, Parquet).
|
|
18
|
+
2. **Cleans** text (removes URLs, @mentions, newlines).
|
|
19
|
+
3. **Models topics** using BERTopic with SentenceTransformer embeddings.
|
|
20
|
+
4. **Labels topics** with an LLM (local via Ollama by default) and propagates topic labels through retweet chains.
|
|
21
|
+
5. **Builds networks**: single-layer retweet, multilayer per-topic, and bipartite temporal-text networks.
|
|
22
|
+
|
|
23
|
+
## Minimal config
|
|
24
|
+
|
|
25
|
+
Save this as `my_config.yml`:
|
|
26
|
+
|
|
27
|
+
```yaml
|
|
28
|
+
input:
|
|
29
|
+
format: csv_posts
|
|
30
|
+
posts: my_data.csv
|
|
31
|
+
|
|
32
|
+
name: my_dataset
|
|
33
|
+
|
|
34
|
+
topic_model:
|
|
35
|
+
embedder: all-MiniLM-L6-v2
|
|
36
|
+
min_topic_size: 50
|
|
37
|
+
|
|
38
|
+
network:
|
|
39
|
+
type: multilayer_repost
|
|
40
|
+
|
|
41
|
+
output_dir: ./out/my_dataset
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Run it: `topiclayers run my_config.yml`
|
|
45
|
+
|
|
46
|
+
## Checking progress
|
|
47
|
+
|
|
48
|
+
Long runs write a live status file to `<output_dir>/run_status.json`. Check the current stage, elapsed time, and ETA from another terminal without touching the running job:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
topiclayers status out/my_dataset
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Example output:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Stage 3/7: embed
|
|
58
|
+
Status: running
|
|
59
|
+
Run: my_dataset
|
|
60
|
+
Progress: 412000/1200000 (34%)
|
|
61
|
+
Stage elapsed: 18m 12s
|
|
62
|
+
Stage ETA: ~35m 20s
|
|
63
|
+
Total elapsed: 21m 05s
|
|
64
|
+
```
|
|
65
|
+
|
|
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>/cache/stage_timings.json`.
|
|
67
|
+
|
|
68
|
+
## Topic labeling with an LLM (optional)
|
|
69
|
+
|
|
70
|
+
After clustering, the pipeline can ask a local LLM to write one short, readable
|
|
71
|
+
label per topic (instead of raw keyword lists like `-1_proclamation_plante_trending`).
|
|
72
|
+
Default is **Ollama** — free, local, nothing leaves your machine:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# one-time setup
|
|
76
|
+
pip install 'topiclayers[labeling]'
|
|
77
|
+
ollama pull qwen2.5:7b
|
|
78
|
+
|
|
79
|
+
# start the server in another terminal
|
|
80
|
+
ollama serve
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Then just run the pipeline as usual (`label_model` is on by default). Any
|
|
84
|
+
[LiteLLM](https://docs.litellm.ai/docs/providers) model string works:
|
|
85
|
+
|
|
86
|
+
```yaml
|
|
87
|
+
topic_model:
|
|
88
|
+
label_model: ollama/qwen2.5:7b # default
|
|
89
|
+
# label_model: gpt-4o-mini # OpenAI (needs OPENAI_API_KEY)
|
|
90
|
+
# label_model: anthropic/claude-3-haiku-20240307
|
|
91
|
+
# label_model: "" # disable labeling
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Custom / self-hosted OpenAI-compatible endpoints
|
|
95
|
+
|
|
96
|
+
Any server speaking the OpenAI protocol (Unsloth, llama.cpp, vLLM, LM Studio...)
|
|
97
|
+
works via `label_api_base`:
|
|
98
|
+
|
|
99
|
+
```yaml
|
|
100
|
+
topic_model:
|
|
101
|
+
label_model: openai/unsloth/Qwen3.8-27B-GGUF # note the openai/ prefix
|
|
102
|
+
label_api_base: http://localhost:8888/v1
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
with the token in `.env` (key resolution order: `LABEL_API_KEY` →
|
|
106
|
+
`OPENAI_LIKE_API_KEY` → `OPENAI_API_KEY`). You can also set `LABEL_API_BASE`
|
|
107
|
+
in `.env` instead of the YAML. Find the exact model name your server exposes
|
|
108
|
+
with `curl http://localhost:8888/v1/models`.
|
|
109
|
+
|
|
110
|
+
Labeling is non-fatal: if the model server is unreachable, topics keep their
|
|
111
|
+
keyword labels and everything else proceeds normally.
|
|
112
|
+
|
|
113
|
+
## Input formats
|
|
114
|
+
|
|
115
|
+
| Format | Extension | Description |
|
|
116
|
+
|--------|-----------|-------------|
|
|
117
|
+
| `twitter_jsonl` | `.json`, `.jsonl` | Twitter/X API v2 JSONL |
|
|
118
|
+
| `csv_posts` | `.csv` | Generic CSV with `post_id`, `user_id`, `text` columns |
|
|
119
|
+
| `parquet_posts` | `.parquet` | Same schema as CSV, Parquet format |
|
|
120
|
+
|
|
121
|
+
CSV optional columns: `created_at`, `lang`, `interaction_type` (`original`/`repost`/`quote`/`reply`), `target_post_id`, `mentions` (semicolon-separated), `extra_*` passthrough columns.
|
|
122
|
+
|
|
123
|
+
## What if something fails?
|
|
124
|
+
|
|
125
|
+
| Symptom | Likely cause | Fix |
|
|
126
|
+
|---------|-------------|-----|
|
|
127
|
+
| "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 -e .` |
|
|
129
|
+
| "OpenAI API key not set" | Using OpenAI embedder without key | Switch `embedder` to `all-MiniLM-L6-v2` (default, works offline) |
|
|
130
|
+
| "Qdrant connection refused" | Qdrant vector DB not running | Ignore — Qdrant is optional. Set `QDRANT_URL` in `.env` to enable |
|
|
131
|
+
| "Cannot create multilayer network" | All posts are outliers | Reduce `min_topic_size` or provide more data |
|
|
132
|
+
| "UMAP spectral layout failed" | Dataset too small (<10 posts) | Topic modeling needs more data; consider using topic labels from elsewhere |
|
|
133
|
+
|
|
134
|
+
## Output files
|
|
135
|
+
|
|
136
|
+
For a dataset named `<name>` (e.g. `my_dataset`), output goes to `<output_dir>/`:
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
<output_dir>/
|
|
140
|
+
├── run_manifest.json # Reproducibility metadata
|
|
141
|
+
├── run_status.json # Live stage/ETA tracking (while running)
|
|
142
|
+
├── cache/
|
|
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
|
|
148
|
+
└── networks/
|
|
149
|
+
├── <name>_retweet.gml # Single-layer retweet network
|
|
150
|
+
├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
|
|
151
|
+
├── <name>_ttt.gml # Temporal-text bipartite network
|
|
152
|
+
└── projected/
|
|
153
|
+
└── <name>__prj_<topic>.gml # Per-topic projected networks
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Requirements
|
|
157
|
+
|
|
158
|
+
- Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
|
|
159
|
+
- Optional: Ollama + `topiclayers[labeling]` extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
|
|
160
|
+
|
|
161
|
+
## Planned: JOSS software paper
|
|
162
|
+
|
|
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.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "topiclayers"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "From any collection of posts to topical multilayer networks — hardened and social-scientist-friendly"
|
|
5
|
+
authors = ["alessiogandelli <alessiogandelli99@gmail.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [{include = "topiclayers", from = "src"}]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
keywords = ["topic-modeling", "network-analysis", "social-science", "polarisation"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Science/Research",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[tool.poetry.dependencies]
|
|
17
|
+
python = ">=3.12,<3.13"
|
|
18
|
+
jsonlines = "^4.0.0"
|
|
19
|
+
pandas = ">=2.2,<4"
|
|
20
|
+
numpy = ">=1.26,<3"
|
|
21
|
+
sentence-transformers = "^6.0.0"
|
|
22
|
+
bertopic = "^0.17.4"
|
|
23
|
+
scikit-learn = ">=1.5"
|
|
24
|
+
umap-learn = "^0.5.12"
|
|
25
|
+
networkx = "^3.6"
|
|
26
|
+
igraph = ">=0.11.4"
|
|
27
|
+
uunet = "^2.1.1"
|
|
28
|
+
qdrant-client = "^1.19.0"
|
|
29
|
+
openai = "^3.0.0"
|
|
30
|
+
python-dotenv = "^1.0.1"
|
|
31
|
+
pyyaml = "^6.0"
|
|
32
|
+
litellm = {version = ">=1.40", optional = true}
|
|
33
|
+
|
|
34
|
+
[tool.poetry.extras]
|
|
35
|
+
labeling = ["litellm"]
|
|
36
|
+
|
|
37
|
+
[tool.poetry.group.dev.dependencies]
|
|
38
|
+
pytest = ">=8.0"
|
|
39
|
+
pytest-cov = ">=6.0"
|
|
40
|
+
jupyter = "^1.0.0"
|
|
41
|
+
|
|
42
|
+
[tool.poetry.urls]
|
|
43
|
+
Repository = "https://github.com/alessiogandelli/topiclayers"
|
|
44
|
+
Issues = "https://github.com/alessiogandelli/topiclayers/issues"
|
|
45
|
+
Changelog = "https://github.com/alessiogandelli/topiclayers/blob/main/CHANGELOG.md"
|
|
46
|
+
|
|
47
|
+
[tool.poetry.scripts]
|
|
48
|
+
topiclayers = "topiclayers.cli:main"
|
|
49
|
+
|
|
50
|
+
[build-system]
|
|
51
|
+
requires = ["poetry-core"]
|
|
52
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from topiclayers.adapters.registry import register
|
|
9
|
+
from topiclayers.schema import InteractionType, Post
|
|
10
|
+
|
|
11
|
+
_INTERACTION_MAP = {
|
|
12
|
+
"original": None,
|
|
13
|
+
"repost": InteractionType.REPOST,
|
|
14
|
+
"quote": InteractionType.QUOTE,
|
|
15
|
+
"reply": InteractionType.REPLY,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_interaction(value) -> InteractionType | None:
|
|
20
|
+
if value is None:
|
|
21
|
+
return None
|
|
22
|
+
if isinstance(value, float) and pd.isna(value):
|
|
23
|
+
return None
|
|
24
|
+
s = str(value).strip().lower()
|
|
25
|
+
if not s or s in ("nan", "none", ""):
|
|
26
|
+
return None
|
|
27
|
+
return _INTERACTION_MAP.get(s, None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_mentions(value) -> list[str]:
|
|
31
|
+
if value is None:
|
|
32
|
+
return []
|
|
33
|
+
if isinstance(value, float) and pd.isna(value):
|
|
34
|
+
return []
|
|
35
|
+
s = str(value).strip()
|
|
36
|
+
if not s or s.lower() in ("nan", "none"):
|
|
37
|
+
return []
|
|
38
|
+
return [m.strip() for m in s.split(";") if m.strip()]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _row_to_post(row, extra_cols: list[str]) -> Post:
|
|
42
|
+
extra = {}
|
|
43
|
+
for col in extra_cols:
|
|
44
|
+
val = row.get(col)
|
|
45
|
+
if isinstance(val, float) and pd.isna(val):
|
|
46
|
+
val = None
|
|
47
|
+
key = col.removeprefix("extra_")
|
|
48
|
+
extra[key] = val
|
|
49
|
+
|
|
50
|
+
return Post(
|
|
51
|
+
post_id=str(row["post_id"]),
|
|
52
|
+
user_id=str(row["user_id"]),
|
|
53
|
+
text=str(row.get("text", "")),
|
|
54
|
+
created_at=row.get("created_at") if not (isinstance(row.get("created_at"), float) and pd.isna(row.get("created_at"))) else None,
|
|
55
|
+
lang=row.get("lang") if not (isinstance(row.get("lang"), float) and pd.isna(row.get("lang"))) else None,
|
|
56
|
+
interaction_type=_parse_interaction(row.get("interaction_type")),
|
|
57
|
+
target_post_id=str(row["target_post_id"]) if row.get("target_post_id") and not (isinstance(row.get("target_post_id"), float) and pd.isna(row.get("target_post_id"))) else None,
|
|
58
|
+
mentions=_parse_mentions(row.get("mentions")),
|
|
59
|
+
extra=extra,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_csv(path: Path) -> List[Post]:
|
|
64
|
+
df = pd.read_csv(path)
|
|
65
|
+
required = {"post_id", "user_id", "text"}
|
|
66
|
+
missing = required - set(df.columns)
|
|
67
|
+
if missing:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"CSV file at {path} is missing required columns: {missing}. "
|
|
70
|
+
f"Required columns are: post_id, user_id, text. "
|
|
71
|
+
f"Optional: created_at, lang, interaction_type, target_post_id, target_user_id, mentions, and extra_* columns."
|
|
72
|
+
)
|
|
73
|
+
extra_cols = [c for c in df.columns if c.startswith("extra_")]
|
|
74
|
+
return [_row_to_post(row, extra_cols) for _, row in df.iterrows()]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_parquet(path: Path) -> List[Post]:
|
|
78
|
+
df = pd.read_parquet(path)
|
|
79
|
+
required = {"post_id", "user_id", "text"}
|
|
80
|
+
missing = required - set(df.columns)
|
|
81
|
+
if missing:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"Parquet file at {path} is missing required columns: {missing}. "
|
|
84
|
+
f"Required columns are: post_id, user_id, text."
|
|
85
|
+
)
|
|
86
|
+
extra_cols = [c for c in df.columns if c.startswith("extra_")]
|
|
87
|
+
return [_row_to_post(row, extra_cols) for _, row in df.iterrows()]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
register("csv_posts", load_csv)
|
|
91
|
+
register("parquet_posts", load_parquet)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Input adapter registry — maps format strings to loader functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, List
|
|
8
|
+
|
|
9
|
+
from topiclayers.schema import Post
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
AdapterFn = Callable[[Path], List[Post]]
|
|
13
|
+
|
|
14
|
+
_registry: dict[str, AdapterFn] = {}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(format_name: str, fn: AdapterFn) -> None:
|
|
18
|
+
_registry[format_name] = fn
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_adapter(path: Path) -> tuple[str, AdapterFn]:
|
|
22
|
+
"""Auto-detect format from file extension and return (format_name, loader)."""
|
|
23
|
+
ext = path.suffix.lower()
|
|
24
|
+
if ext in (".jsonl", ".json", ".dat"):
|
|
25
|
+
fmt = "twitter_jsonl"
|
|
26
|
+
elif ext == ".csv":
|
|
27
|
+
fmt = "csv_posts"
|
|
28
|
+
elif ext == ".parquet":
|
|
29
|
+
fmt = "parquet_posts"
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Cannot auto-detect format for extension '{ext}'. "
|
|
33
|
+
f"Supported: .jsonl, .json, .dat, .csv, .parquet. "
|
|
34
|
+
f"Use explicit `format:` in config for other formats."
|
|
35
|
+
)
|
|
36
|
+
if fmt not in _registry:
|
|
37
|
+
raise KeyError(
|
|
38
|
+
f"No adapter registered for format '{fmt}'. "
|
|
39
|
+
f"Available adapters: {list(_registry.keys())}. "
|
|
40
|
+
f"Did you import topiclayers.adapters.{fmt.split('_')[0]}?"
|
|
41
|
+
)
|
|
42
|
+
return fmt, _registry[fmt]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AdapterRegistry:
|
|
46
|
+
"""Explicit registry for manual adapter lookups."""
|
|
47
|
+
pass
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from topiclayers.adapters.registry import register
|
|
8
|
+
from topiclayers.schema import InteractionType, Post
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _map_interaction_type(ref_type: str | None) -> InteractionType | None:
|
|
12
|
+
if ref_type == "retweeted":
|
|
13
|
+
return InteractionType.REPOST
|
|
14
|
+
elif ref_type == "quoted":
|
|
15
|
+
return InteractionType.QUOTE
|
|
16
|
+
elif ref_type == "replied_to":
|
|
17
|
+
return InteractionType.REPLY
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load(path: Path) -> List[Post]:
|
|
22
|
+
posts = []
|
|
23
|
+
with open(path) as f:
|
|
24
|
+
for line in f:
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line:
|
|
27
|
+
continue
|
|
28
|
+
obj = json.loads(line)
|
|
29
|
+
|
|
30
|
+
post_id = str(obj.get("id", ""))
|
|
31
|
+
user_id = str(obj.get("author_id", ""))
|
|
32
|
+
text = obj.get("text", "")
|
|
33
|
+
created_at = obj.get("created_at")
|
|
34
|
+
lang = obj.get("lang")
|
|
35
|
+
|
|
36
|
+
ref_tweets = obj.get("referenced_tweets")
|
|
37
|
+
if ref_tweets and isinstance(ref_tweets, list) and len(ref_tweets) > 0:
|
|
38
|
+
ref = ref_tweets[0]
|
|
39
|
+
interaction_type = _map_interaction_type(ref.get("type"))
|
|
40
|
+
target_post_id = str(ref.get("id")) if ref.get("id") is not None else None
|
|
41
|
+
else:
|
|
42
|
+
interaction_type = None
|
|
43
|
+
target_post_id = None
|
|
44
|
+
|
|
45
|
+
entities = obj.get("entities", {})
|
|
46
|
+
mentions_raw = entities.get("mentions", []) if isinstance(entities, dict) else []
|
|
47
|
+
mentions = [m.get("username", "") for m in mentions_raw if isinstance(m, dict)]
|
|
48
|
+
|
|
49
|
+
extra = {
|
|
50
|
+
"conversation_id": str(obj.get("conversation_id")) if obj.get("conversation_id") is not None else None,
|
|
51
|
+
"context_annotations": obj.get("context_annotations", []),
|
|
52
|
+
"public_metrics": obj.get("public_metrics", {}),
|
|
53
|
+
"possibly_sensitive": obj.get("possibly_sensitive"),
|
|
54
|
+
"edit_history_tweet_ids": obj.get("edit_history_tweet_ids", []),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
post = Post(
|
|
58
|
+
post_id=post_id,
|
|
59
|
+
user_id=user_id,
|
|
60
|
+
text=text,
|
|
61
|
+
created_at=created_at,
|
|
62
|
+
lang=lang,
|
|
63
|
+
interaction_type=interaction_type,
|
|
64
|
+
target_post_id=target_post_id,
|
|
65
|
+
mentions=mentions,
|
|
66
|
+
extra=extra,
|
|
67
|
+
)
|
|
68
|
+
posts.append(post)
|
|
69
|
+
|
|
70
|
+
return posts
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
register("twitter_jsonl", load)
|