topiclayers
From posts to topical multilayer networks — hardened and social-scientist-friendly.
Quickstart
# Requires Python 3.12
pip install topiclayers
# copy runnable examples (configs + toy data) into ./examples/
topiclayers init
topiclayers run examples/generic.yml
That's it. You get a GML network file in ./out/generic_toy/networks/.
What does it do?
- Loads your posts (Twitter JSONL, CSV, Parquet).
- Cleans text (removes URLs, @mentions, newlines).
- Models topics using BERTopic with SentenceTransformer embeddings.
- Labels topics with an LLM (local via Ollama by default) and propagates topic labels through retweet chains.
- Builds networks: single-layer retweet, multilayer per-topic, and bipartite temporal-text networks.
Minimal config
Save this as my_config.yml:
input:
format: csv_posts
posts: my_data.csv
name: my_dataset
topic_model:
embedder: all-MiniLM-L6-v2
min_topic_size: 50
network:
type: multilayer_repost
output_dir: ./out/my_dataset
Run it: topiclayers run my_config.yml
Checking progress
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:
topiclayers status out/my_dataset
Example output:
Stage 3/7: embed
Status: running
Run: my_dataset
Progress: 412000/1200000 (34%)
Stage elapsed: 18m 12s
Stage ETA: ~35m 20s
Total elapsed: 21m 05s
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.
Topic labeling with an LLM (optional)
After clustering, the pipeline can ask a local LLM to write one short, readable
label per topic (instead of raw keyword lists like -1_proclamation_plante_trending).
Default is Ollama — free, local, nothing leaves your machine:
# one-time setup
pip install 'topiclayers[labeling]'
ollama pull qwen2.5:7b
# start the server in another terminal
ollama serve
Then just run the pipeline as usual (label_model is on by default). Any
LiteLLM model string works:
topic_model:
label_model: ollama/qwen2.5:7b # default
# label_model: gpt-4o-mini # OpenAI (needs OPENAI_API_KEY)
# label_model: anthropic/claude-3-haiku-20240307
# label_model: "" # disable labeling
Custom / self-hosted OpenAI-compatible endpoints
Any server speaking the OpenAI protocol (Unsloth, llama.cpp, vLLM, LM Studio...)
works via label_api_base:
topic_model:
label_model: openai/unsloth/Qwen3.8-27B-GGUF # note the openai/ prefix
label_api_base: http://localhost:8888/v1
with the token in .env (key resolution order: LABEL_API_KEY →
OPENAI_LIKE_API_KEY → OPENAI_API_KEY). You can also set LABEL_API_BASE
in .env instead of the YAML. Find the exact model name your server exposes
with curl http://localhost:8888/v1/models.
Labeling is non-fatal: if the model server is unreachable, topics keep their keyword labels and everything else proceeds normally.
Choosing an embedder
embedder is any SentenceTransformer model (or an OpenAI text-embedding-*
model). The pipeline applies the model-specific input treatment automatically
— E5 gets its "query: " prefix, Qwen3-Embedding its instruction format, the
MiniLM/MPNet families plain text — and L2-normalises, because clustering uses
cosine/UMAP. Override with embedder_prompt / embedder_normalize:
topic_model:
embedder: Qwen/Qwen3-Embedding-0.6B # strong multilingual; ~1.8x the ARI of MiniLM on noisy data
# embedder: all-mpnet-base-v2 # English-only fallback
# embedder: paraphrase-multilingual-MiniLM-L12-v2 # lightweight multilingual
# embedder_prompt: "" # override the auto prefix
# embedder_normalize: true
# embedder_device: "" # "" = auto (MPS/CUDA if available)
umap_n_neighbors: 15
umap_n_components: 10 # 10 worked better than 5 for Qwen3
Rule of thumb (from the benchmark in experiments/): the embedder matters more
than any clustering knob. all-MiniLM-L6-v2 (the default) is fine for English
toy data but collapses multilingual corpora; use a multilingual model
(Qwen3-Embedding, multilingual MiniLM/mpnet) for real, mixed-language data.
Input formats
| Format | Extension | Description |
|---|---|---|
twitter_jsonl |
.json, .jsonl |
Twitter/X API v2 JSONL |
csv_posts |
.csv |
Generic CSV with post_id, user_id, text columns |
parquet_posts |
.parquet |
Same schema as CSV, Parquet format |
CSV optional columns: created_at, lang, interaction_type (original/repost/quote/reply), target_post_id, mentions (semicolon-separated), extra_* passthrough columns.
What if something fails?
| Symptom | Likely cause | Fix |
|---|---|---|
| "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) |
| "Module not found" | topiclayers not installed | pip install topiclayers |
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 |
| "OpenAI API key not set" | Using OpenAI embedder without key | Switch embedder to all-MiniLM-L6-v2 (default, works offline) |
| "Qdrant connection refused" | Qdrant vector DB not running | Ignore — Qdrant is optional. Set QDRANT_URL in .env to enable |
| "Cannot create multilayer network" | All posts are outliers | Reduce min_topic_size or provide more data |
| "UMAP spectral layout failed" | Dataset too small (<10 posts) | Topic modeling needs more data; consider using topic labels from elsewhere |
Output files
For a dataset named <name> (e.g. my_dataset), output goes to <output_dir>/:
<output_dir>/
├── run_manifest.json # Reproducibility metadata
├── run_status.json # Live stage/ETA tracking (while running)
├── stage_timings.json # Per-stage durations for ETA prediction
└── networks/
├── <name>_retweet.gml # Single-layer retweet network
├── <name>_retweet_network_ml.gml # Multilayer (uunet format)
├── <name>_ttt.gml # Temporal-text bipartite network
└── projected/
└── <name>__prj_<topic>.gml # Per-topic projected networks
Reusable artifacts (processed data, embeddings, the BERTopic model) go to the
cache, not the output dir. By default the cache lives in a cache/ folder
next to the input data file, so it persists across runs regardless of
output_dir and can be shared between configs (files are prefixed with the
dataset name). Override the location with cache_dir: in the config:
<cache_dir>/
├── data/
│ ├── tweets_<name>.pkl/.csv # Full tweet table
│ ├── retweet_labeled_<name>.pkl/.csv # Retweets with topic labels
│ └── manifest_<name>.json # Cache validity key
└── tm/<embedder_name>/
├── embeddings_<name>.pkl # Document embeddings
├── topics_<name>.csv # Topic table (with LLM labels)
└── model_<name>/ # BERTopic safetensors dir
Requirements
- Python 3.12 (uunet, used for multilayer networks, does not ship wheels for 3.13+ yet)
- Optional: Ollama +
topiclayers[labeling]extra (LLM topic labeling), Docker (for Qdrant vector search), OpenAI API key (for OpenAI embeddings)
Metadata
Release files for topiclayers 0.3.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| topiclayers-0.3.1.tar.gz | 37.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| topiclayers-0.3.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 80.8 kB
Release files / topiclayers-0.3.1.tar.gz
| Download URL | topiclayers-0.3.1.tar.gz |
|---|---|
| Size | 37.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3207fa20539423e1bbf79bdff1d56e9a649b175ff920099543bb3b34ae93bc7a
|
|
BLAKE2b-256 checksum How to use checksums |
4f36319f6aeb4435d35b8228094959de8d75a72abbde41d18161501be79ab254
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / topiclayers-0.3.1-py3-none-any.whl
| Download URL | topiclayers-0.3.1-py3-none-any.whl |
|---|---|
| Size | 43.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c2da6ccf347413bee628e07d64a7c72708e81e96f911bfd56f31c3077b7cd1b0
|
|
BLAKE2b-256 checksum How to use checksums |
c3c38aad4d73ac3b9a1f75c8b06bff08c710fbc44527384d1b2009215bcfca6d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency log