TriTopic
Topic modeling for research

TriTopic

TriTopic is a Python library that reads a large collection of texts and tells you what they are about: which themes come up, how often, in which words, and how sure you can be about each of them.

pip install git+https://github.com/SmartVisions-AI/tritopic.git

What TriTopic does, in plain words

Nobody can read 10,000 survey answers, interviews or customer reviews one by one and keep an overview. Topic modeling does the first pass for you: it sorts the texts into piles by subject, names each pile with its typical words, and counts how big each pile is. You then read the piles, not the whole stack.

TriTopic sorts the texts like three careful readers who compare notes. The first reader looks at what a text means, so "the room was freezing" and "heating didn't work" land on the same pile. The second looks at which words are used, which keeps apart texts that sound alike but are about different things. The third, if you have it, looks at extra information such as the source or the date. The three readers then vote ten times on where the piles are; only groupings they agree on survive. That is why TriTopic gives nearly the same result every time you run it, and why every text ends up on a pile instead of in a "could not sort" heap.

An example: 2,400 hotel reviews

A hotel group wants to know what its guests write about. Illustrative example with invented reviews; the BBC run further down shows real output.

2,400guest reviews, unsorted"Great breakfast, but the street noise kept us up…""Staff upgraded our room without asking…""€25 a night for parking is too much…"
Breakfast and food22% · reliable
breakfastbuffetcoffeefresh

"The breakfast buffet alone was worth the stay."

Friendly staff19% · reliable
stafffriendlyreceptionhelpful

"Reception went out of their way to find us a cot."

Location and transport17% · reliable
locationstationwalkcentre

"Five minutes from the station, ten from the old town."

Noise at night11% · reliable
noisestreetnightwindows

"Rooms facing the street are loud until 2 am."

Parking fees4% · not on anyone's list
parkinggaragefeenight

"€25 a night for the garage is simply too much."

For every pile you get its name and a short description (written by an LLM if you want), its keywords, its share of all reviews with an uncertainty range, real sentences you can quote, and a reliability score that tells you whether the pile would come out the same with somewhat different data. If the hotel already has categories it wants to track ("breakfast", "staff", "cleanliness"), it can give them to TriTopic as a starting point, and themes nobody expected, like the parking fees here, still appear as their own piles. With dates, TriTopic also shows when a theme first came up.

What you can do with TriTopic

You want to…TriTopic…How
Find the topics in a collection of textsGroups every text into a topic and describes each topic with keywords; no text is left overTriTopic().fit(texts)
Decide how many topics make senseChooses the number automatically, takes a number you give, or shows how topics split when you zoom inn_topics, resolution_ladder()
Get readable names and descriptionsLets an LLM name and explain every topic, and splits topics that mix two themesTopicInterpreter
Start from categories you already knowUses a one-sentence description per category as a starting point; unknown themes still emergefit(texts, seeds=…)
Sort new texts into the existing topicsAssigns new texts (with probabilities) and flags texts that fit no topictransform()
Use source, rating or date as extra informationStrengthens links between similar texts that share metadatause_metadata_view
Follow topics over timeShows which themes appear, split, merge and fade, period by periodtopic_evolution()
Compare groups (countries, sources, customer segments)Topic shares per group with confidence intervals, significance tests and the words that separate the groupscompare_groups()
Know which topics you can trustRefits on parts of the data and scores how stable each topic istopic_reliability()
Check whether you have enough dataShows after how many texts no new topics appear (saturation)saturation_curve()
Find texts between two topicsLists bridge texts and how strongly topics are connectedbridge_documents()
Quote real examplesPicks the most telling sentences per topictopic_quotes()
Prepare manual codingWrites a coding scheme with definitions and anchor examples, and checks it with an LLM second coder (Cohen's kappa)codebook()
Write the methods sectionDrafts the paragraph and parameter table from the fitted modelmethods_report()
Show the resultsSixteen research charts, document maps and topic hierarchiescharts.plot_…()
Work in other languagesGerman, French and Spanish stop words built in; 100+ languages with a multilingual modellanguage=
See coarse and fine levelsBuilds a topic tree from broad themes down to sub-topicsbuild_hierarchy()

Where TriTopic fits, and where it fits less

Use caseFitWhy
Open survey answers, interviews, customer reviews, support ticketsWell suitedEvery text gets a topic, topics are stable between runs, and you can start from your own categories.
Research that has to report how the topics were foundWell suitedReliability per topic, saturation, significance tests, coding scheme with inter-coder agreement and a ready methods paragraph.
Mixed coding: some categories known, others to discoverWell suitedSeeds pin the known categories while new themes still form their own topics.
News, social media and media monitoring over timeWell suitedTopic evolution marks new and fading themes (tested on eleven years of news).
Collections from a few hundred to tens of thousands of textsWell suited1,000 texts take seconds, 18,000 texts well under a minute with pre-computed embeddings.
Texts in several languagesWell suitedWith a multilingual embedding model, texts in different languages about the same theme end up together.
Very short snippets (single words, hashtags, 3-word titles)Less suitedBoth the meaning and the wording view have too little to work with; group or concatenate snippets first.
Long documents that each cover many themes (books, long reports)Less suited as isTriTopic gives each text one main topic. Split long documents into paragraphs or sections first.
Millions of textsLess suitedThe graph and the ten-run vote grow with the corpus; beyond about 100,000 texts it is untested. Fit on a sample, then transform() the rest.
A fixed list of categories that must not changeLess suitedTriTopic discovers topics. If the categories are fixed and you have labelled examples, a supervised classifier is the better tool.
Live streams where new themes must appear immediatelyLess suitedtransform() sorts new texts into known topics; finding new themes needs a refit.

The LLM features (names, descriptions, coding scheme, second coder) need an OpenAI API key. Everything else runs on your own machine.

How it works

From documents to topics in seven steps

The core is a graph and a vote, not a language model. The LLM is an optional reader at the end.

How TriTopic works in seven steps: 1 understand meaning (sentence embeddings, UMAP to 10 dimensions), 2 identify words (TF-IDF), 3 connect documents in one graph, optional metadata strengthens existing links, 4 find the shared structure (Leiden, consensus voting), 5 find the right level of detail (15 resolutions scored by keyword coherence), 6 refine results (max. 5 rounds), 7 topics and keywords with the 5 most typical documents.
The seven steps at a glance. On small screens, scroll sideways.
Documents Meaningembeddings → UMAP 10d WordingTF-IDF, shared tokens Metadataoptional, reweights links One graphkNN, k = 15, views fused ×10 Leiden runs Runs voteconsensus, auto granularity,refine until stable Technologymobile, software Businessgrowth, bank, oil Politicslabour, election, blair Topics + keywords optional: seeds (pins) optional: LLM reads topics research outputs
1

Meaning lens

A sentence-embedding model turns every document into a vector; UMAP compresses it to 10 dimensions. Documents about the same thing land close together, even in different words.

Graph construction →
2

Wording lens

The corpus is tokenized once; TF-IDF weights distinctive words. This view separates topics that share ideas but use different vocabulary, and the other way round.

Lexical view →
3

One graph

Each view links every document to its 15 nearest neighbours. The views are fused into one weighted graph; links found by both views get a bonus. Metadata can strengthen existing links but never adds new ones.

Fusion →
4

Ten runs vote

The Leiden algorithm finds communities ten times. For every link, the share of runs that keep both ends together becomes its weight, and the vote is repeated until the runs agree.

Consensus clustering →
5

How many topics?

In automatic mode TriTopic scans 15 resolutions and keeps the coarsest partition whose keywords are within 5% of the most coherent one, skipping partitions where one topic swallows half the corpus.

Resolution →
6

Refine until stable

Documents are pulled slightly towards their topic's centre and the graph is clustered again, until two rounds agree (at most five rounds).

Refinement →
7

Keywords

A keyword must be frequent in the topic and spread across its documents: IDF · √(term share · document coverage). Numbers and boilerplate do not make it.

Keywords →
Get started

Installation and first model

Install

pip install git+https://github.com/SmartVisions-AI/tritopic.git
# LLM features: set an OpenAI key (only needed for TopicInterpreter and the Decisions API)
export OPENAI_API_KEY=sk-...

Python 3.9-3.13. The first fit downloads the embedding model all-MiniLM-L6-v2 (about 90 MB). Extras: [llm], [benchmark], [turftopic], [full], [dev].

Your first model

from tritopic import TriTopic

model = TriTopic()                       # n_topics="auto"
labels = model.fit_transform(documents)  # one topic id per document

model.get_topic_info()                   # Topic, Size, Keywords, Label, Coherence
model.get_representative_docs(0, n_docs=3)
model.visualize().write_html("map.html")

Then

from tritopic import TopicInterpreter
from tritopic.research import topic_reliability, saturation_curve, methods_report

TopicInterpreter(domain_hint="customer reviews").interpret(model)   # labels + descriptions (LLM)
topic_reliability(model)                                            # which topics can you trust?
saturation_curve(model)                                             # enough data?
print(methods_report(model, corpus="12,400 hotel reviews"))         # methods paragraph

Every feature with code: user guide. Runnable scripts: examples.

User guide

Every task, with code

The same content as docs/user_guide.md on GitHub.

Task-by-task instructions with code. Every snippet comes from a runnable script in examples/; the technical documentation explains how each part works and why.


1. Installation

pip install git+https://github.com/SmartVisions-AI/tritopic.git     # 2.5 (this repository)
pip install "tritopic[benchmark] @ git+https://github.com/SmartVisions-AI/tritopic.git"  # + BERTopic, datasets

Python 3.9-3.13. The first fit downloads the embedding model (all-MiniLM-L6-v2, about 90 MB).

The LLM features (TopicInterpreter, the Decisions API functions, codebook(), intercoder_reliability()) call the OpenAI API over HTTPS and only need an API key:

export OPENAI_API_KEY=sk-...          # Windows PowerShell: $env:OPENAI_API_KEY = "sk-..."

2. Quick start

Script: examples/01_quickstart.py

from tritopic import TriTopic

model = TriTopic()                          # n_topics="auto"
labels = model.fit_transform(documents)     # one topic id per document

print(model.get_topic_info())               # Topic, Size, Keywords, Label, Coherence, ...
topic = model.get_topic(0)
print(topic.keywords, topic.size)
print(model.get_representative_docs(0, n_docs=3))
model.visualize().write_html("map.html")    # interactive 2D map

documents is a list of strings. Every document gets a topic; -1 (outlier) only appears for clusters smaller than min_cluster_size.

3. Your own data

Script: examples/02_your_own_data.py

import re
import pandas as pd
from tritopic import TriTopic, TriTopicConfig

df = pd.read_csv("my_texts.csv")
df["text"] = df["text"].fillna("").map(lambda t: re.sub(r"\s+", " ", t).strip())
df = df[df["text"].str.split().str.len() >= 10].reset_index(drop=True)   # drop very short texts
documents = df["text"].tolist()

# Optional: your own embeddings (any sentence-transformers model, GPU, caching ...)
from sentence_transformers import SentenceTransformer
embeddings = SentenceTransformer("all-MiniLM-L6-v2").encode(documents, normalize_embeddings=True)

config = TriTopicConfig(min_cluster_size=10, n_keywords=10, random_state=42, verbose=False)
model = TriTopic(config=config, n_topics=8)
df["topic"] = model.fit_transform(documents, embeddings=embeddings)

info = model.get_topic_info().set_index("Topic")
df["topic_keywords"] = df["topic"].map(info["Keywords"])
df["topic_probability"] = model.probabilities_.max(axis=1)
df.to_csv("my_texts_with_topics.csv", index=False)

Tips:

  • Clean only what is noise for your question (HTML, signatures, boilerplate). Stop words are handled by language; do not stem or lemmatize, the embeddings need natural text.
  • Long documents are fine. Embedding models truncate (MiniLM: 256 tokens), so for books or long reports split into paragraphs or sections first and fit on the parts.
  • Pre-computed embeddings make experiments fast: encode once, fit many times.

4. How many topics?

Script: examples/03_shape_the_topics.py

TriTopic()                                            # "auto": coarsest partition with near-best keyword coherence
TriTopic(n_topics=12)                                 # exactly 12 topics
TriTopic(config=TriTopicConfig(auto_resolution=False, resolution=0.5))   # fixed Leiden resolution

model.resolution_                                     # chosen resolution
model.resolution_search_                              # [(resolution, n_topics, coherence), ...]

Auto mode tends to give more topics than a corpus has classes (BBC: about 6-14 for 5 sections). If you know the granularity you need, pass n_topics; if you know the categories, use seeds (§10).

5. Shaping the topics after fitting

model.reduce_outliers(strategy="neighbors")      # or "embeddings", or "decisions" (LLM, §9)
model.reduce_topics(10)                          # merge the most similar topics until 10 remain
model.merge_topics([4, 7])                       # merge specific topics
model.divide(topic_id=0, n_subtopics=3)          # split one topic

hierarchy = model.build_hierarchy(n_levels=3)    # coarse -> fine
for root in hierarchy.roots:
    print(root.node_id, root.size, root.keywords[:4], [c.node_id for c in root.children])
model.visualize_hierarchy_tree().write_html("hierarchy.html")

All operations refresh keywords, centroids and probabilities.

6. New documents, saving and loading

Script: examples/04_new_documents_and_saving.py

model.transform(new_docs)            # topic ids; -1 if less similar than model.outlier_threshold_
model.transform_proba(new_docs)      # (n_new, n_topics), columns in topic-id order
model.probabilities_                 # the same for the training documents

model.save("model.pkl")
model = TriTopic.load("model.pkl")   # keeps topics, labels, seeds, reducer, embeddings

The outlier threshold is calibrated on the training data (1st percentile of the training documents' similarity to their own topic). Set TriTopicConfig(outlier_threshold=0.3) for a fixed value.

7. Metadata, time and languages

Script: examples/05_metadata_time_and_languages.py

config = TriTopicConfig(use_metadata_view=True, metadata_weight=0.2)
model = TriTopic(config=config).fit(documents, metadata=df[["source", "rating"]])
pd.crosstab(df["source"], model.labels_, normalize="index")       # topic share per source

from tritopic import TopicVisualizer
TopicVisualizer().plot_topic_over_time(labels=model.labels_, timestamps=df["date"].tolist(),
                                       topics=model.topics_).write_html("over_time.html")

TriTopic(language="german")                        # german / french / spanish stop words
TriTopic(language="multilingual")                  # switches to BAAI/bge-m3 (100+ languages)

Metadata only strengthens edges between documents that are already similar in content; it never connects unrelated documents. For how topics change content over time, see topic_evolution() in §11 and example 12.

8. Let an LLM interpret the topics

Script: examples/06_llm_interpretation.py

from tritopic import TopicInterpreter

interpreter = TopicInterpreter(domain_hint="customer reviews of hotels", language="English")
results = interpreter.interpret(model)          # writes label + description into model.topics_
for tid, r in results.items():
    print(tid, r.label, r.verdict, r.description, r.aspects)

log = interpreter.refine(model, results=results)   # split topics the LLM calls "mixed"
print(interpreter.summarize(model))                # overview of the topic landscape

refine() only keeps a split when the graph split agrees with the LLM's sub-themes. Default model gpt-6-luna; pass model="..." to use another. Cost: about 1,500 input tokens per topic.

Labels only, with Anthropic or OpenAI SDKs: model.generate_labels(LLMLabeler(provider="anthropic", api_key=...)). Without any LLM: model.generate_labels(SimpleLabeler(n_words=3)).

9. Typed LLM judgements (Decisions API)

Script: examples/07_decisions_api.py

from tritopic.integrations.decisions import (DecisionsClient, word_intrusion, rate_topics,
                                             assign_documents, suggest_merges, apply_merges)

client = DecisionsClient()
keywords = [t.keywords for t in model.topics_ if t.topic_id != -1]
rate_topics(keywords, client)                               # 0-3 per topic
word_intrusion(keywords, client).intruder_probability       # automated word-intrusion test
labels, conf = assign_documents(model, new_docs, client, allow_other=True)   # -1 = none fits
model.reduce_outliers(strategy="decisions", decisions_client=client)
apply_merges(model, suggest_merges(model, client, threshold=0.5))

Merge suggestions are conservative and often empty at 0.5; lower the threshold to see borderline pairs and check them before merging.

10. Start from a codebook (seeded topics)

Script: examples/09_seeded_topics.py

When you already know (some of) the categories, describe each in one sentence or a list of words:

seeds = {
    "Space": "Space flight: NASA, rockets, satellites, orbits, the moon and planets",
    "Cars": "Cars: engines, driving, car models, dealers, tires and fuel",
    "Medicine": ["disease", "doctor", "patient", "treatment", "drug"],     # word lists work too
}
model = TriTopic().fit(documents, seeds=seeds)

model.get_topic_info()[["Topic", "Seed", "Size", "Keywords"]]
model.seed_topics_          # {seed name: topic id}; TopicInfo.seed holds the seed name
model.emergent_topics_      # ids of the topics nobody asked for: the interesting part
model.seed_anchors_         # the documents pinned to each seed

The best-matching documents of each seed are pinned to one topic; all other documents join a seeded topic or form new ones. A seed that fits no document is ignored with a warning, which shows codebook categories missing from the data.

  • With pre-computed embeddings TriTopic encodes the seeds with embedding_model. If your embeddings come from another model, pass seed_embeddings=encoder.encode(list(seeds.values())).
  • TriTopicConfig(seed_anchors=20) sets the number of anchors per seed (default 1% of the corpus, 5-30).
  • Seed every theme you know: with a partial codebook a seeded topic can absorb a neighbouring unseeded theme. TopicInterpreter.refine() splits such mixed topics afterwards.

Effect on benchmark data: full codebook ARI 0.515 → 0.624 (dev), 0.399 → 0.498 (held-out); BBC demo with five one-line seeds NMI 0.742 → 0.801, ARI 0.631 → 0.800.

11. Research toolkit

Script: examples/10_research_toolkit.py

from tritopic.research import (topic_reliability, saturation_curve, plot_saturation,
                               bridge_documents, topic_connections, topic_prevalence, compare_groups,
                               distinctive_keywords, topic_evolution, plot_evolution,
                               topic_quotes, methods_report)

Which topics can I trust?

rel = topic_reliability(model)            # refits on 5 random 80% samples
rel[["topic", "label", "reliability", "core_share", "reliable"]]
model.document_stability_                 # per document: share of refits it stayed with its topic

Report topics with reliability below 0.7 with care, or merge them. method="consensus" is instant (uses the stored Leiden runs) but a weaker signal.

Do I have enough data?

sat = saturation_curve(model)
print(sat)                                # e.g. "all topics discoverable with 30% of the documents"
sat.summary                               # fraction, n_docs, recovered
sat.point_95, sat.point_all, sat.saturated, sat.late_topics
plot_saturation(sat).show()

If late_topics is not empty, those topics only appear with (nearly) all documents: collect more data before drawing conclusions about them.

Where do topics meet?

bridge_documents(model, top_n=10)         # doc, topic, other_topic, bridge_score, text
topic_connections(model).head()           # topic_a, topic_b, strength, bridge_docs

How big is each topic, and does it differ between groups?

topic_prevalence(model)                                   # share with 95% Wilson interval
topic_prevalence(model, groups=df["country"])             # per group
compare_groups(model, df["country"])                      # chi2 / Fisher, Cramér's V, BH-adjusted p
distinctive_keywords(model, df["country"], "AT", "DE", n=10)              # words that separate groups
distinctive_keywords(model, df["country"], "AT", "DE", topic_id=3)        # ... within one topic

How do topics develop over time?

evo = topic_evolution(model, df["date"], freq="Q")        # "M", "Q", "Y"
evo.events                                                # birth / continuation / split / merge / death
evo.events[evo.events.event == "birth"]                   # what was new, with keywords
evo.nodes.docs                                            # document indices of each period topic
evo.lineage(3)                                            # period topics of global topic 3
plot_evolution(evo).show()                                # Sankey diagram

Each period gets its own graph and is clustered finer than the global model (resolution=4 × model.resolution_), so a new event inside a broad topic shows up as a birth. Topics of consecutive periods are linked when their centroids have cosine ≥ link_threshold (0.75); lower it for fewer births, raise it for more. Use periods with at least a few hundred documents. A theme that skips a period counts as a new birth. Real-data walkthrough (HuffPost news 2012-2022, finds COVID in 2020 and the war in Ukraine in 2022): examples/12_topic_evolution_news.py.

Quotes and the methods section

topic_quotes(model, n=3)                                  # citable sentences per topic
print(methods_report(model, corpus="12,400 hotel reviews from 2023-2025"))

methods_report is written from the fitted model without an LLM, so it is reproducible. It includes the parameter table and software versions; check and adapt the wording before publishing.

12. Codebook and second coder for manual coding

Script: examples/11_codebook_and_second_coder.py

from tritopic import TopicInterpreter
from tritopic.integrations.decisions import DecisionsClient, intercoder_reliability

interp = TopicInterpreter(domain_hint="interview transcripts")
interp.interpret(model)                                   # labels + descriptions first

codebook = interp.codebook(model, n_quotes=3)             # name, definition, inclusion, exclusion,
codebook.to_excel("codebook.xlsx")                        # coding_notes, anchor_examples (real quotes)

ic = intercoder_reliability(model, DecisionsClient(), sample_size=200)
print(ic.kappa, ic.agreement)                             # Cohen's kappa, raw agreement
ic.per_topic                                              # precision / recall / F1 per topic
ic.confusion                                              # rows: TriTopic, columns: LLM

The second coder sees only what a human coder would see in a codebook (label, description, keywords), so kappa measures whether the topics can be applied consistently. Kappa above 0.8 counts as almost perfect agreement, 0.6-0.8 as substantial (Landis & Koch, 1977). Low F1 for a topic points to a category that is hard to apply: sharpen its definition or merge it.

13. Evaluation and visualization

Script: examples/08_evaluate_and_visualize.py

model.evaluate()         # coherence (NPMI, whole corpus), diversity, stability, outlier ratio

from sklearn.metrics import normalized_mutual_info_score
normalized_mutual_info_score(true_labels, model.labels_)        # if you have reference labels

model.visualize()                       # 2D document map
model.visualize_topics(n_keywords=8)    # keyword bars
model.visualize_hierarchy()             # dendrogram
model.visualize_overlap(threshold=0.1)  # topic co-occurrence
model.visualize_hierarchy_tree()        # after build_hierarchy()

All visualizations return Plotly figures (.show(), .write_html()).

14. Research charts

Script: examples/13_research_charts.py

Sixteen Plotly charts for analysing and presenting a model. Each returns a figure (.show(), .write_html()); the numbers behind it come from tritopic.research, so you can also report them as tables.

from tritopic.research import topic_reliability
from tritopic.visualization import charts

rel = topic_reliability(model)
charts.plot_topic_table(model, reliability=rel).show()     # overview tiles
charts.plot_triview(model).show()                          # what holds each topic together
charts.plot_resolution_ladder(model).show()                # how topics split when you zoom in
charts.plot_topic_onions(model).show()                     # core and edge, stability, bridges
charts.plot_keyword_barcode(model, topic_id=0).show()      # is a keyword carried by the whole topic?
charts.plot_trust(model, rel).show()                       # which topics can go into the paper
Function Chart Data behind it
plot_topic_table(model, reliability=rel) Periodic table of topics: symbol, size, share, reliability, NPMI get_topic_info(), topic_reliability()
plot_triview(model) Tri-View triangle: links inside each topic by view (meaning only / wording only / both) view_composition()
plot_resolution_ladder(model) Zoom ladder: Sankey of the clusters at five resolutions resolution_ladder()
plot_topic_onions(model) Core and edge of every topic, coloured by stability, bridges ringed topic_cores()
plot_keyword_barcode(model, topic_id) Which documents (core → edge) contain each keyword keyword_coverage()
plot_constellation(model) Topic network: similarity layout, links with bridge counts topic_connections()
plot_coassignment(model) Fuzzy borders: how often two documents share a topic across refits coassignment()
plot_group_tilt(model, groups) Topic shares per group with CIs, Cramér's V, significance topic_prevalence(), compare_groups()
plot_trust(model, rel) Size against reliability with the 0.7 / 0.5 guides topic_reliability()
plot_composition(model, reference) Reference categories inside each topic labels you supply
plot_birth_timeline(evolution) New and ending topics per period topic_evolution()
plot_codebook_coverage({name: model}) Seeded vs. emerged share of the corpus codebook_coverage()
plot_quote_wall(model) The most telling sentence per topic topic_quotes()
plot_verdict_board(results, refine_log) LLM verdicts (coherent / mixed / unclear) with evidence TopicInterpreter.interpret()
plot_coder_confusion(ic, model) Where the LLM second coder disagrees intercoder_reliability()
plot_topic_discovery(model) Saturation curve plus when each topic becomes visible topic_discovery()

Seven of these chart types are new: the Tri-View triangle, the zoom ladder, topic onions, the keyword barcode, the birth timeline, codebook coverage and saturation with topic dots. They need what only TriTopic has: two graph views, consensus and bootstrap refits, seeds and topic evolution.

15. Configuration recipes

from tritopic import TriTopicConfig

# Short texts (tweets, reviews): denser graph, smaller clusters allowed
TriTopicConfig(n_neighbors=20, min_cluster_size=5)

# Long, technical documents: more weight on wording
TriTopicConfig(semantic_weight=0.4, lexical_weight=0.4)

# Large corpora (50k+): fewer consensus runs, fewer refinement iterations
TriTopicConfig(n_consensus_runs=5, max_iterations=3)

# Broader topics without fixing the number
TriTopicConfig(auto_resolution_tolerance=0.10)

# Reproducibility
TriTopicConfig(random_state=42)

All parameters with defaults: technical documentation §15.

16. FAQ and troubleshooting

The first fit is slow. The embedding model is downloaded and the documents are encoded once; with pre-computed embeddings a fit of 2,000 documents takes about 7-10 s. UMAP compiles with numba on first use.

Too many / too few topics. Pass n_topics=k, use reduce_topics(k), or raise/lower auto_resolution_tolerance. With known categories, use seeds.

One topic mixes two themes. model.divide(topic_id, n_subtopics=2), or let TopicInterpreter.refine() decide.

Results differ between runs. Set random_state. Consensus clustering keeps runs close (NMI spread 0.014 across seeds in the benchmark); topic_reliability() shows which topics are unstable.

Keywords contain domain boilerplate (e.g. "hotel" in hotel reviews). Words in nearly every document get a low IDF weight already; to remove them completely, delete them from the texts before fitting (single frequent words hardly change the embeddings).

No OpenAI key. All non-LLM features work without a key. SimpleLabeler gives keyword labels.

Dropbox / OneDrive folders. Keep virtual environments and model caches outside synced folders.

Examples

Thirteen runnable scripts

Each script runs on public data (20 Newsgroups, HuffPost news) and prints its results. Open a script to read the full code.

01 · Quick start: fit TriTopic and look at the topics.

examples/01_quickstart.py

Run: python examples/01_quickstart.py Data: 20 Newsgroups (downloaded by scikit-learn on first use).

Show the code (40 lines)
"""
01 - Quick start: fit TriTopic and look at the topics.

Run:  python examples/01_quickstart.py
Data: 20 Newsgroups (downloaded by scikit-learn on first use).
"""
from sklearn.datasets import fetch_20newsgroups

from tritopic import TriTopic

# --- 1. Documents: a list of strings -------------------------------------------------
data = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"),
                          categories=["sci.space", "rec.sport.baseball", "sci.med", "comp.graphics"])
documents = [d for d in data.data if len(d.strip()) > 50][:1500]

# --- 2. Fit ----------------------------------------------------------------------------
# n_topics="auto" (default) lets TriTopic pick the number of topics.
# The first run downloads the embedding model (all-MiniLM-L6-v2, ~90 MB).
model = TriTopic(verbose=True)
labels = model.fit_transform(documents)          # one topic id per document, -1 = outlier

# --- 3. Overview -----------------------------------------------------------------------
info = model.get_topic_info()                    # pandas DataFrame
print(info[["Topic", "Size", "Keywords"]].to_string(index=False))

# --- 4. One topic in detail ------------------------------------------------------------
topic = model.get_topic(info.Topic.iloc[0])      # TopicInfo of the largest topic
print("\nKeywords:", topic.keywords)
print("Scores:  ", [round(s, 4) for s in topic.keyword_scores[:5]])
for doc_id, text in model.get_representative_docs(topic.topic_id, n_docs=2):
    print(f"\n[{doc_id}] {text[:200]}...")

# --- 5. One document in detail ---------------------------------------------------------
print("\nDocument 0 belongs to topic", labels[0])
print("Top topics with probabilities:", model.get_document_topics(doc_idx=0, top_n=3))

# --- 6. Interactive map ----------------------------------------------------------------
fig = model.visualize(title="20 Newsgroups - TriTopic")
fig.write_html("topic_map.html")                 # open in a browser
print("\nSaved topic_map.html")
Open on GitHub →

02 · Your own data: CSV in, configured model, results back into the table.

examples/02_your_own_data.py

Run: python examples/02_your_own_data.py The script first writes a small demo CSV so it runs anywhere; replace it with your own file.

Show the code (53 lines)
"""
02 - Your own data: CSV in, configured model, results back into the table.

Run:  python examples/02_your_own_data.py
The script first writes a small demo CSV so it runs anywhere; replace it with your own file.
"""
import re

import pandas as pd
from sklearn.datasets import fetch_20newsgroups

from tritopic import TriTopic, TriTopicConfig

# --- demo CSV (replace with your own) ------------------------------------------------------
news = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"),
                          categories=["rec.autos", "talk.politics.guns", "sci.electronics"])
pd.DataFrame({"id": range(len(news.data)), "text": news.data}).to_csv("my_texts.csv", index=False)

# --- 1. Load and clean ------------------------------------------------------------------
df = pd.read_csv("my_texts.csv")
df["text"] = (df["text"].fillna("")
              .map(lambda t: re.sub(r"\s+", " ", t).strip()))       # collapse whitespace
df = df[df["text"].str.split().str.len() >= 10].reset_index(drop=True)  # drop very short texts
documents = df["text"].tolist()
print(f"{len(documents)} documents")

# --- 2. Optional: compute embeddings yourself (any sentence-transformers model) -----------
# Useful to try several TriTopic settings without re-encoding, or to use a GPU / other model.
from sentence_transformers import SentenceTransformer

encoder = SentenceTransformer("all-MiniLM-L6-v2")      # e.g. "all-mpnet-base-v2", "BAAI/bge-m3"
embeddings = encoder.encode(documents, batch_size=64, normalize_embeddings=True, show_progress_bar=True)

# --- 3. Configure ---------------------------------------------------------------------
config = TriTopicConfig(
    n_neighbors=15,            # larger -> smoother, broader topics
    min_cluster_size=10,       # smaller clusters become outliers (-1)
    n_keywords=10,
    keyword_method="ctfidf",   # or "bm25", "keybert"
    random_state=42,
    verbose=False,
)
model = TriTopic(config=config, n_topics=8)            # exactly 8 topics
labels = model.fit_transform(documents, embeddings=embeddings)

# --- 4. Write results back -------------------------------------------------------------
info = model.get_topic_info().set_index("Topic")
df["topic"] = labels
df["topic_keywords"] = df["topic"].map(info["Keywords"])
df["topic_probability"] = model.probabilities_.max(axis=1)   # confidence of the assignment
df.to_csv("my_texts_with_topics.csv", index=False)
print(df[["topic", "topic_keywords", "topic_probability"]].head())
print(model.get_topic_info()[["Topic", "Size", "Keywords"]].to_string(index=False))
Open on GitHub →

03 · Shape the topics after fitting: granularity, outliers, merging, splitting, hierarchy.

examples/03_shape_the_topics.py

Run: python examples/03_shape_the_topics.py

Show the code (59 lines)
"""
03 - Shape the topics after fitting: granularity, outliers, merging, splitting, hierarchy.

Run:  python examples/03_shape_the_topics.py
"""
from sklearn.datasets import fetch_20newsgroups

from tritopic import TriTopic, TriTopicConfig


def show(model, title):
    info = model.get_topic_info()
    n = len(info[info.Topic != -1])
    print(f"\n== {title}: {n} topics, {int((model.labels_ == -1).sum())} outliers")
    print(info[["Topic", "Size", "Keywords"]].head(8).to_string(index=False))


data = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"))
documents = [d for d in data.data if len(d.strip()) > 50][:3000]

# --- 1. How many topics? ---------------------------------------------------------------
# a) automatic: scans 15 resolutions, keeps the coarsest with near-best keyword coherence
model = TriTopic(verbose=False).fit(documents)
show(model, "auto")
print("chosen resolution:", round(model.resolution_, 3))
print("scan (resolution, topics, coherence):", [(round(r, 3), k, round(c, 3)) for r, k, c in model.resolution_search_][:5], "...")

# b) exact number of topics:   TriTopic(n_topics=20)
# c) fixed Leiden resolution:  TriTopic(config=TriTopicConfig(auto_resolution=False, resolution=0.5))

# --- 2. Outliers -------------------------------------------------------------------------
# Documents in clusters smaller than min_cluster_size get the label -1.
model.reduce_outliers(strategy="neighbors")           # majority vote of the nearest documents
# alternatives: strategy="embeddings" (nearest topic centroid above a threshold),
#               strategy="decisions"  (LLM, see examples/07_decisions_api.py)

# --- 3. Fewer topics: merge the most similar ones ---------------------------------------
model.reduce_topics(10)
show(model, "after reduce_topics(10)")

# --- 4. Merge specific topics -----------------------------------------------------------
ids = model.get_topic_info().Topic.tolist()
model.merge_topics([ids[-1], ids[-2]])                 # the two smallest topics
show(model, "after merge_topics")

# --- 5. Split one topic ------------------------------------------------------------------
largest = model.get_topic_info().Topic.iloc[0]
for sub in model.divide(topic_id=largest, n_subtopics=3):
    print("sub-topic", sub.topic_id, sub.size, sub.keywords[:5])

# --- 6. Topic hierarchy: coarse -> fine ---------------------------------------------------
hierarchy = model.build_hierarchy(n_levels=3)
print("\n", hierarchy)
for root in hierarchy.roots[:3]:
    print(root.node_id, root.size, root.keywords[:4])
    for child in root.children[:3]:
        print("   ", child.node_id, child.size, child.keywords[:4])
model.visualize_hierarchy_tree().write_html("hierarchy.html")
print("Saved hierarchy.html")
Open on GitHub →

04 · Assign new documents and save/load a model.

examples/04_new_documents_and_saving.py

Run: python examples/04_new_documents_and_saving.py

Show the code (40 lines)
"""
04 - Assign new documents and save/load a model.

Run:  python examples/04_new_documents_and_saving.py
"""
import numpy as np
from sklearn.datasets import fetch_20newsgroups

from tritopic import TriTopic

cats = ["sci.space", "rec.autos", "sci.med", "comp.graphics"]
train = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"), categories=cats)
documents = [d for d in train.data if len(d.strip()) > 50]

model = TriTopic(n_topics=4, verbose=False).fit(documents)
print(model.get_topic_info()[["Topic", "Size", "Keywords"]].to_string(index=False))

# --- 1. Hard labels for new documents ----------------------------------------------------
new_docs = [
    "NASA plans a new mission to put a rover on the surface of Mars.",
    "My car's engine makes a strange noise when I brake on the highway.",
    "The patient was treated with antibiotics after the infection got worse.",
    "Which file format is best for storing 3D models and textures?",
]
print("\nAssigned topics:", model.transform(new_docs))      # -1 if below model.outlier_threshold_ (calibrated on the training data)

# --- 2. Topic probabilities --------------------------------------------------------------
proba = model.transform_proba(new_docs)                      # shape (n_new_docs, n_topics)
topic_ids = [t.topic_id for t in model.topics_ if t.topic_id != -1]
for doc, p in zip(new_docs, proba):
    best = int(np.argmax(p))
    print(f"{p[best]:.2f}  topic {topic_ids[best]}  <- {doc[:60]}")

# --- 3. Probabilities for the training documents ------------------------------------------
print("\nprobabilities_ shape:", model.probabilities_.shape)  # columns follow topic_ids

# --- 4. Save and load (embeddings, reducer, topics, labels, LLM labels) --------------------
model.save("tritopic_model.pkl")
loaded = TriTopic.load("tritopic_model.pkl")
print("Same predictions after loading:", np.array_equal(loaded.transform(new_docs), model.transform(new_docs)))
Open on GitHub →

05 · Metadata, topics over time, and other languages.

examples/05_metadata_time_and_languages.py

Run: python examples/05_metadata_time_and_languages.py

Show the code (49 lines)
"""
05 - Metadata, topics over time, and other languages.

Run:  python examples/05_metadata_time_and_languages.py
"""
import numpy as np
import pandas as pd
from sklearn.datasets import fetch_20newsgroups

from tritopic import TopicVisualizer, TriTopic, TriTopicConfig

data = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"),
                          categories=["sci.space", "rec.sport.hockey", "talk.politics.mideast", "sci.crypt"])
keep = [i for i, d in enumerate(data.data) if len(d.strip()) > 50]
documents = [data.data[i] for i in keep]

# Example metadata. In real projects: source, author, product, rating, date, ...
rng = np.random.default_rng(0)
metadata = pd.DataFrame({
    "source": rng.choice(["forum", "mailing list", "news"], len(documents)),       # categorical
    "rating": rng.integers(1, 6, len(documents)),                                   # numerical
    "date": pd.Timestamp("2024-01-01") + pd.to_timedelta(rng.integers(0, 365, len(documents)), unit="D"),
})

# --- 1. Metadata view ------------------------------------------------------------------
# Metadata strengthens edges between documents that are already similar in content and
# share metadata; it never connects unrelated documents.
config = TriTopicConfig(use_metadata_view=True, metadata_weight=0.2, verbose=False)
model = TriTopic(config=config, n_topics=4)
model.fit(documents, metadata=metadata)
print(model.get_topic_info()[["Topic", "Size", "Keywords"]].to_string(index=False))

# Topic share per metadata value
table = pd.crosstab(metadata["source"], model.labels_, normalize="index").round(2)
print("\nTopic share per source:\n", table)

# --- 2. Topics over time ----------------------------------------------------------------
fig = TopicVisualizer().plot_topic_over_time(labels=model.labels_, timestamps=metadata["date"].tolist(),
                                             topics=model.topics_)
fig.write_html("topics_over_time.html")
monthly = pd.crosstab(metadata["date"].dt.to_period("M"), model.labels_)
print("\nDocuments per topic and month:\n", monthly.head())

# --- 3. Other languages -----------------------------------------------------------------
# Stop words for keywords:  "english", "german", "french", "spanish", "multilingual"
german = TriTopic(language="german")                    # German stop words, default embedding model
mixed = TriTopic(language="multilingual")               # switches to BAAI/bge-m3 (100+ languages)
custom = TriTopic(language="german", embedding_model="paraphrase-multilingual-MiniLM-L12-v2")
print("\nlanguage settings:", german.config.language, mixed.config.embedding_model, custom.config.embedding_model)
Open on GitHub →

06 · Let an LLM interpret the topics: labels, descriptions, mixed-topic detection, overview.

examples/06_llm_interpretation.py

Run: OPENAI_API_KEY=sk-... python examples/06_llm_interpretation.py Cost: about 1,500 input tokens per topic (a few cents for this example).

Show the code (53 lines)
"""
06 - Let an LLM interpret the topics: labels, descriptions, mixed-topic detection, overview.

Run:  OPENAI_API_KEY=sk-... python examples/06_llm_interpretation.py
Cost: about 1,500 input tokens per topic (a few cents for this example).
"""
import os

from sklearn.datasets import fetch_20newsgroups

from tritopic import SimpleLabeler, TopicInterpreter, TriTopic

data = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"),
                          categories=["sci.space", "rec.autos", "rec.motorcycles", "sci.med", "talk.politics.guns"])
documents = [d for d in data.data if len(d.strip()) > 50]

# A deliberately coarse model: some topics will mix two newsgroups
model = TriTopic(n_topics=3, verbose=False).fit(documents)

if not os.environ.get("OPENAI_API_KEY"):
    # Without an API key: keyword-based labels
    model.generate_labels(SimpleLabeler(n_words=3))
    print(model.get_topic_info()[["Topic", "Size", "Label"]].to_string(index=False))
    raise SystemExit("Set OPENAI_API_KEY to run the LLM part.")

# --- 1. Interpret every topic ------------------------------------------------------------
interpreter = TopicInterpreter(domain_hint="Usenet newsgroup posts")   # default model: gpt-6-luna
results = interpreter.interpret(model)          # also sets topic.label / topic.description
for topic_id, r in results.items():
    print(f"\nTopic {topic_id} ({r.size} docs): {r.label}  [{r.verdict}, confidence {r.confidence:.2f}]")
    print("  ", r.description)
    print("   aspects:", ", ".join(r.aspects))
    if r.verdict == "mixed":
        print("   sub-themes:", " | ".join(s["name"] for s in r.sub_themes))
    print("   evidence:", r.evidence)

# --- 2. Split topics the LLM judges as mixed ------------------------------------------------
log = interpreter.refine(model, results=results)   # reuse the interpretations from step 1
for entry in log:
    status = "split" if entry["kept"] else "kept together"
    print(f"\n{entry['label']}: {status} -> {entry['sub_themes']}")
print(model.get_topic_info()[["Topic", "Size", "Label"]].to_string(index=False))

# --- 3. Overview of the whole corpus ---------------------------------------------------------
print("\n" + interpreter.summarize(model))
for group in model.overview_["groups"]:
    print(f"- {group['name']}: {', '.join(group['topics'])}")

print(f"\n{interpreter.n_requests} requests, {interpreter.usage['input_tokens']} input tokens")

# --- Alternative: labels only, Anthropic or OpenAI SDK ---------------------------------------
# from tritopic import LLMLabeler
# model.generate_labels(LLMLabeler(provider="anthropic", api_key="...", language="german"))
Open on GitHub →

07 · Typed LLM judgements with the OpenAI Decisions API: rate topics, assign documents, merge.

examples/07_decisions_api.py

Run: OPENAI_API_KEY=sk-... python examples/07_decisions_api.py Cost: well under one cent for this example.

Show the code (48 lines)
"""
07 - Typed LLM judgements with the OpenAI Decisions API: rate topics, assign documents, merge.

Run:  OPENAI_API_KEY=sk-... python examples/07_decisions_api.py
Cost: well under one cent for this example.
"""
from sklearn.datasets import fetch_20newsgroups

from tritopic import TriTopic
from tritopic.integrations.decisions import (DecisionsClient, apply_merges, assign_documents,
                                             rate_topics, suggest_merges, word_intrusion)

data = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"),
                          categories=["sci.space", "rec.autos", "sci.med", "comp.graphics"])
documents = [d for d in data.data if len(d.strip()) > 50]
model = TriTopic(n_topics=8, verbose=False).fit(documents)

client = DecisionsClient()                      # reads OPENAI_API_KEY; model gpt-6-luna
keywords = [t.keywords for t in model.topics_ if t.topic_id != -1]

# --- 1. How interpretable are the topics? -------------------------------------------------
ratings = rate_topics(keywords, client)         # 0 = unrelated ... 3 = one clear theme
for kws, r in zip(keywords, ratings):
    print(f"{r:.2f}  {', '.join(kws[:6])}")
test = word_intrusion(keywords, client)
print(f"Word intrusion: accuracy {test.accuracy:.2f}, probability on the intruder {test.intruder_probability:.2f}")

# --- 2. Assign documents with the LLM ------------------------------------------------------
new_docs = ["The shuttle docked with the space station after a delayed launch.",
            "Which video card renders OpenGL scenes fastest?"]
labels, confidence = assign_documents(model, new_docs, client)            # always picks a topic
print("LLM assignment:", labels, confidence.round(2))
labels, confidence = assign_documents(model, new_docs, client, allow_other=True)  # may answer -1
print("With abstention:", labels)

# --- 3. Outliers via LLM (abstains when no topic fits) --------------------------------------
model.reduce_outliers(strategy="decisions", decisions_client=client)

# --- 4. Merge topics the LLM considers the same theme ----------------------------------------
# The judgement is conservative: with threshold=0.5 it often suggests nothing, which is fine.
# A lower threshold shows the borderline pairs; inspect them before merging.
merges = suggest_merges(model, client, n_pairs=10, threshold=0.5)
print("Merge suggestions at 0.5 (topic a, topic b, probability):", merges)
if not merges:
    print("Borderline pairs at 0.2:", suggest_merges(model, client, n_pairs=10, threshold=0.2))
apply_merges(model, merges)
print(model.get_topic_info()[["Topic", "Size", "Keywords"]].to_string(index=False))
print(f"{client.n_requests} requests")
Open on GitHub →

08 · Evaluate a model and create every visualization.

examples/08_evaluate_and_visualize.py

Run: python examples/08_evaluate_and_visualize.py All figures are Plotly figures: .show() in a notebook, .write_html() / .write_image() to save.

Show the code (40 lines)
"""
08 - Evaluate a model and create every visualization.

Run:  python examples/08_evaluate_and_visualize.py
All figures are Plotly figures: .show() in a notebook, .write_html() / .write_image() to save.
"""
from sklearn.datasets import fetch_20newsgroups
from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score

from tritopic import TopicVisualizer, TriTopic
from tritopic.utils.metrics import compute_silhouette

data = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"),
                          categories=["sci.space", "rec.sport.baseball", "sci.med", "comp.graphics", "talk.politics.guns"])
keep = [i for i, d in enumerate(data.data) if len(d.strip()) > 50]
documents = [data.data[i] for i in keep]
true_labels = data.target[keep]

model = TriTopic(verbose=False).fit(documents)

# --- 1. Built-in quality metrics -------------------------------------------------------
metrics = model.evaluate()          # also stores per-topic coherence in get_topic_info()
for name, value in metrics.items():
    print(f"{name:16s} {value}")
print(model.get_topic_info()[["Topic", "Size", "Coherence"]].head().to_string(index=False))

# --- 2. If you have reference labels -----------------------------------------------------
print("NMI vs. newsgroups:", round(normalized_mutual_info_score(true_labels, model.labels_), 3))
print("ARI vs. newsgroups:", round(adjusted_rand_score(true_labels, model.labels_), 3))
print("Silhouette:", round(compute_silhouette(model.original_embeddings_, model.labels_), 3))

# --- 3. Visualizations ---------------------------------------------------------------------
model.visualize(title="Document map").write_html("viz_map.html")              # 2D map of documents
model.visualize_topics(n_keywords=8).write_html("viz_keywords.html")           # keyword bars per topic
model.visualize_hierarchy().write_html("viz_dendrogram.html")                  # topic dendrogram
model.visualize_overlap(threshold=0.1).write_html("viz_overlap.html")         # topic co-occurrence
TopicVisualizer().plot_topic_similarity(model.topic_embeddings_, model.topics_).write_html("viz_similarity.html")
model.build_hierarchy(n_levels=3)
model.visualize_hierarchy_tree().write_html("viz_tree.html")                   # coarse -> fine tree
print("Saved viz_*.html")
Open on GitHub →

09 · Codebook mode: start from the topics you expect, let the rest emerge.

examples/09_seeded_topics.py

Run: python examples/09_seeded_topics.py

Describe each expected topic in one sentence. TriTopic pins the documents that fit a description best to one topic per seed; all other documents can join a seeded topic or form new (emergent) topics. Seeds you list but that are not in the data stay small or empty, so a codebook can be checked against the data.

Show the code (44 lines)
"""
09 - Codebook mode: start from the topics you expect, let the rest emerge.

Run:  python examples/09_seeded_topics.py

Describe each expected topic in one sentence. TriTopic pins the documents that fit a
description best to one topic per seed; all other documents can join a seeded topic or
form new (emergent) topics. Seeds you list but that are not in the data stay small or
empty, so a codebook can be checked against the data.
"""
from sklearn.datasets import fetch_20newsgroups

from tritopic import TriTopic, TriTopicConfig

data = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"),
                          categories=["sci.space", "rec.autos", "sci.med", "comp.graphics", "talk.politics.guns"])
documents = [d for d in data.data if len(d.strip()) > 50]

# --- 1. Seeds: topic name -> short description ------------------------------------------
# Only three of the five themes are seeded; graphics and guns have to emerge on their own.
seeds = {
    "Space": "Space flight: NASA, rockets, satellites, orbits, the moon and planets",
    "Cars": "Cars: engines, driving, car models, dealers, tires and fuel",
    "Medicine": "Medicine: diseases, doctors, patients, treatment, drugs and symptoms",
}
model = TriTopic(config=TriTopicConfig(verbose=False, random_state=42))
model.fit(documents, seeds=seeds)

print(model.get_topic_info()[["Topic", "Seed", "Size", "Keywords"]].to_string(index=False))

# --- 2. Seeded vs. emergent topics --------------------------------------------------------
print("\nSeeded topics (seed -> topic id):", model.seed_topics_)
print("Emergent topics:", [(tid, ", ".join(model.get_topic(tid).keywords[:4])) for tid in model.emergent_topics_])

# --- 3. Options ------------------------------------------------------------------------------
# * seed_anchors:        documents pinned per seed (default: 1% of the corpus, 5 to 30)
# * seed_keyword_weight: weight of the seed words vs. the semantic match (default 0.5)
# * seed_embeddings:     pass your own seed vectors when you fit with precomputed embeddings
#   model.fit(docs, embeddings=emb, seeds=seeds, seed_embeddings=my_model.encode(list(seeds.values())))
#
# Seeds still go through the normal pipeline: reduce_topics(), refine() with an LLM,
# transform() and save()/load() keep the seed names.
# Tip: seed every theme you know. With a partial codebook a seeded topic can absorb a
# neighbouring unseeded theme; refine() (examples/06) splits such mixed topics.
Open on GitHub →

10 · Research toolkit: the numbers you need for a paper.

examples/10_research_toolkit.py

Run: python examples/10_research_toolkit.py

reliability per topic, saturation, bridge documents, prevalence with confidence intervals, group comparison with tests, topic evolution, quotes and a methods paragraph. None of these steps needs an LLM.

Show the code (71 lines)
"""
10 - Research toolkit: the numbers you need for a paper.

Run:  python examples/10_research_toolkit.py

reliability per topic, saturation, bridge documents, prevalence with confidence
intervals, group comparison with tests, topic evolution, quotes and a methods paragraph.
None of these steps needs an LLM.
"""
import numpy as np
import pandas as pd
from sklearn.datasets import fetch_20newsgroups

from tritopic import TriTopic, TriTopicConfig
from tritopic.research import (bridge_documents, compare_groups, distinctive_keywords, methods_report,
                               saturation_curve, topic_connections, topic_evolution, topic_prevalence,
                               topic_quotes, topic_reliability)

data = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"),
                          categories=["sci.space", "rec.autos", "sci.med", "comp.graphics"])
keep = [i for i, d in enumerate(data.data) if len(d.strip()) > 50]
documents = [data.data[i] for i in keep]
newsgroup = np.array(data.target_names)[data.target[keep]]

model = TriTopic(config=TriTopicConfig(verbose=False, random_state=42)).fit(documents)
print(model.get_topic_info()[["Topic", "Size", "Keywords"]].to_string(index=False))

# --- 1. Reliability: which topics survive refitting on 80% samples? -------------------------
rel = topic_reliability(model)                  # 5 refits; method="consensus" is instant but weaker
print("\nReliability (>= 0.7 = reliable):\n", rel.round(2).to_string(index=False))
# per-document stability is now in model.document_stability_

# --- 2. Saturation: would more data reveal new topics? ---------------------------------------
sat = saturation_curve(model)                   # exact accumulation curve, no refitting
print("\n", sat)
print(sat.summary[["fraction", "n_docs", "recovered"]].round(2).to_string(index=False))
# plot_saturation(sat).show()   (plotly)

# --- 3. Bridges: documents between two topics -------------------------------------------------
br = bridge_documents(model, top_n=5)
for _, b in br.iterrows():
    print(f"\nTopic {b.topic} <-> {b.other_topic} ({b.bridge_score:.0%} of neighbours): {b.text[:120]}...")
print("\nStrongest topic connections:\n", topic_connections(model).head(5).round(3).to_string(index=False))

# --- 4. Prevalence with 95% confidence intervals ---------------------------------------------
print("\n", topic_prevalence(model).round(3).to_string(index=False))           # Wilson intervals
# per group: topic_prevalence(model, groups=newsgroup)

# --- 5. Group comparison: chi-square / Fisher, Cramér's V, Benjamini-Hochberg ----------------
length = np.where([len(d.split()) > 100 for d in documents], "long", "short")
print("\n", compare_groups(model, length).round(3).to_string(index=False))
print("\nWords typical for long vs. short posts:\n",
      distinctive_keywords(model, length, "long", "short", n=6).round(2).to_string(index=False))

# --- 6. Topic evolution: births, splits, merges and deaths over time --------------------------
# 20 Newsgroups has no usable dates, so we simulate: graphics posts only appear in 2024.
# Real dated news: examples/12_topic_evolution_news.py
rng = np.random.default_rng(0)
dates = pd.Timestamp("2023-01-01") + pd.to_timedelta(rng.integers(0, 730, len(documents)), unit="D")
late = newsgroup == "comp.graphics"
dates = dates.where(~late, pd.Timestamp("2024-01-01") + pd.to_timedelta(rng.integers(0, 365, late.sum()), unit="D"))
evo = topic_evolution(model, dates, freq="Y")
print("\nEvents:\n", evo.events.to_string(index=False))
# plot_evolution(evo).show()    (Sankey diagram)

# --- 7. Quotes: citable sentences per topic ---------------------------------------------------
for _, q in topic_quotes(model, n=1).iterrows():
    print(f"\nTopic {q.topic}: \"{q.quote}\"")

# --- 8. Methods paragraph for the paper --------------------------------------------------------
print("\n" + methods_report(model, corpus="four newsgroups of the 20 Newsgroups corpus"))
Open on GitHub →

11 · From topics to a coding scheme, checked by an LLM second coder.

examples/11_codebook_and_second_coder.py

Run: OPENAI_API_KEY=sk-... python examples/11_codebook_and_second_coder.py Cost: a few cents (one request per topic for the codebook, ~one per document for the coder).

1. TopicInterpreter.codebook() writes a category per topic in the style of qualitative content analysis (Mayring): definition, inclusion and exclusion criteria, coding notes. The anchor examples are real sentences from the documents, not generated text. 2. intercoder_reliability() lets an LLM assign a random sample to the topics, knowing only the codebook information (label, description, keywords), and reports Cohen's kappa and precision / recall / F1 per topic.

Show the code (41 lines)
"""
11 - From topics to a coding scheme, checked by an LLM second coder.

Run:  OPENAI_API_KEY=sk-... python examples/11_codebook_and_second_coder.py
Cost: a few cents (one request per topic for the codebook, ~one per document for the coder).

1. TopicInterpreter.codebook() writes a category per topic in the style of qualitative
   content analysis (Mayring): definition, inclusion and exclusion criteria, coding notes.
   The anchor examples are real sentences from the documents, not generated text.
2. intercoder_reliability() lets an LLM assign a random sample to the topics, knowing only
   the codebook information (label, description, keywords), and reports Cohen's kappa and
   precision / recall / F1 per topic.
"""
from sklearn.datasets import fetch_20newsgroups

from tritopic import TopicInterpreter, TriTopic, TriTopicConfig
from tritopic.integrations.decisions import DecisionsClient, intercoder_reliability

data = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"),
                          categories=["sci.space", "rec.autos", "sci.med", "comp.graphics"])
documents = [d for d in data.data if len(d.strip()) > 50]
model = TriTopic(config=TriTopicConfig(verbose=False, random_state=42)).fit(documents)

# Labels and descriptions first: the second coder works from them.
interp = TopicInterpreter(domain_hint="newsgroup posts")
interp.interpret(model)

# --- 1. Codebook ------------------------------------------------------------------------------
codebook = interp.codebook(model, n_quotes=2)            # also stored as model.codebook_
for _, c in codebook.iterrows():
    print(f"\n## {c['name']} ({c['size']} posts)\n{c['definition']}")
    print("Include:", "; ".join(c["inclusion"]))
    print("Exclude:", "; ".join(c["exclusion"]))
    print("Anchor:", c["anchor_examples"][0] if c["anchor_examples"] else "-")
# codebook.to_excel("codebook.xlsx")  /  codebook.to_markdown()

# --- 2. LLM as second coder ---------------------------------------------------------------------
ic = intercoder_reliability(model, DecisionsClient(), sample_size=100)
print(f"\nCohen's kappa {ic.kappa:.2f}, agreement {ic.agreement:.0%} on {ic.n} posts")
print(ic.per_topic.round(2).to_string(index=False))
print(ic.confusion)                                       # rows: TriTopic, columns: LLM
Open on GitHub →

12 · Topic evolution on real dated news: which themes are born, split, merge and die?

examples/12_topic_evolution_news.py

Run: python examples/12_topic_evolution_news.py Data: HuffPost News Category dataset (Misra, 2022), 2012-2022, downloaded from the Hugging Face hub (about 90 MB, once). We sample 700 articles per year.

On this corpus topic_evolution() finds the COVID pandemic as a new topic in 2020 (continuing in 2021 and 2022) and the war in Ukraine in 2022, together with smaller events such as the Flint water crisis (2016), the Hong Kong protests (2019) and the Roe v. Wade decision (2022).

Show the code (52 lines)
"""
12 - Topic evolution on real dated news: which themes are born, split, merge and die?

Run:  python examples/12_topic_evolution_news.py
Data: HuffPost News Category dataset (Misra, 2022), 2012-2022, downloaded from the Hugging Face hub
      (about 90 MB, once).  We sample 700 articles per year.

On this corpus topic_evolution() finds the COVID pandemic as a new topic in 2020 (continuing in 2021
and 2022) and the war in Ukraine in 2022, together with smaller events such as the Flint water
crisis (2016), the Hong Kong protests (2019) and the Roe v. Wade decision (2022).
"""
import numpy as np
import pandas as pd
from datasets import load_dataset

from tritopic import TriTopic, TriTopicConfig
from tritopic.research import plot_evolution, topic_evolution

df = load_dataset("heegyu/news-category-dataset", split="train").to_pandas()
df["text"] = (df.headline.fillna("") + ". " + df.short_description.fillna("")).str.strip()
df = df[df.text.str.split().str.len() >= 8]
df["year"] = pd.to_datetime(df.date).dt.year
df = df.loc[np.concatenate([g.sample(min(len(g), 700), random_state=0).index for _, g in df.groupby("year")])]
df = df.sort_values("date").reset_index(drop=True)

model = TriTopic(config=TriTopicConfig(verbose=False, random_state=42)).fit(df.text.tolist())
print(model.get_topic_info()[["Topic", "Size", "Keywords"]].to_string(index=False))

# --- 1. Topics per year, linked across years ---------------------------------------------------
# Each year is clustered on its own (finer than the global model: resolution = 4 x model.resolution_)
# and topics of consecutive years are linked when their centroids have cosine >= 0.75.
evo = topic_evolution(model, df.date, freq="Y")
print("\n", evo.events.event.value_counts().to_string())

# --- 2. What was new? ----------------------------------------------------------------------------
births = evo.events[evo.events.event == "birth"]
print("\nNew topics per year:")
for r in births.itertuples():
    print(f"  {r.period}  {r.size:4d} articles  {r.keywords}")

# --- 3. Follow one theme -------------------------------------------------------------------------
covid = evo.nodes[evo.nodes.keywords.str.contains("covid|coronavirus")]
print("\nCOVID topics:\n", covid[["node", "period", "size", "keywords"]].to_string(index=False))
print("Successors of the first COVID topic:\n",
      evo.links[evo.links.source == covid.node.iloc[0]].round(3).to_string(index=False))

# The documents of a period topic: evo.nodes.docs  (indices into df)
first = covid.iloc[0]
print("\nExample headlines:", df.headline.iloc[first.docs[:3]].tolist())

plot_evolution(evo).write_html("topic_evolution.html")     # Sankey diagram
print("Saved topic_evolution.html")
Open on GitHub →

13 · Research charts: sixteen views of a fitted model, saved as interactive HTML files.

examples/13_research_charts.py

Run: python examples/13_research_charts.py (charts 14 and 15 need OPENAI_API_KEY and are skipped without it)

Every chart is a Plotly figure from tritopic.visualization.charts; the numbers behind it come from tritopic.research, so you can also use them directly in tables or statistics.

Show the code (74 lines)
"""
13 - Research charts: sixteen views of a fitted model, saved as interactive HTML files.

Run:  python examples/13_research_charts.py
      (charts 14 and 15 need OPENAI_API_KEY and are skipped without it)

Every chart is a Plotly figure from tritopic.visualization.charts; the numbers behind it come from
tritopic.research, so you can also use them directly in tables or statistics.
"""
import os

import numpy as np
import pandas as pd
from sklearn.datasets import fetch_20newsgroups

from tritopic import TopicInterpreter, TriTopic, TriTopicConfig
from tritopic.research import topic_evolution, topic_reliability
from tritopic.visualization import charts

cats = ["sci.space", "rec.autos", "sci.med", "comp.graphics", "talk.politics.guns"]
data = fetch_20newsgroups(subset="train", remove=("headers", "footers", "quotes"), categories=cats)
keep = [i for i, d in enumerate(data.data) if len(d.strip()) > 50]
documents = [data.data[i] for i in keep]
newsgroup = np.array(data.target_names)[data.target[keep]]

model = TriTopic(config=TriTopicConfig(verbose=False, random_state=42)).fit(documents)
rel = topic_reliability(model)            # needed for charts 1, 4 and 9
os.makedirs("charts", exist_ok=True)


def save(fig, name):
    fig.write_html(f"charts/{name}.html")
    print("saved", f"charts/{name}.html")


save(charts.plot_topic_table(model, reliability=rel), "01_topic_table")           # overview tiles
save(charts.plot_triview(model), "02_triview")                                    # meaning / wording / both
save(charts.plot_resolution_ladder(model), "03_zoom_ladder")                      # how topics split
save(charts.plot_topic_onions(model), "04_topic_onions")                          # core and edge
save(charts.plot_keyword_barcode(model, int(rel.topic.iloc[0])), "05_keyword_barcode")
save(charts.plot_constellation(model), "06_constellation")                        # topic network
save(charts.plot_coassignment(model, n_refits=5), "07_fuzzy_borders")             # stability under refits
lengths = np.array([len(d.split()) for d in documents])
save(charts.plot_group_tilt(model, np.where(lengths > np.median(lengths), "long", "short")), "08_group_tilt")
save(charts.plot_trust(model, rel), "09_trust_quadrant")
save(charts.plot_composition(model, newsgroup, reference_name="newsgroup"), "10_composition")

# 11 - birth timeline (20 Newsgroups has no dates: graphics posts are simulated to start in 2024;
#      real dated news: examples/12_topic_evolution_news.py)
rng = np.random.default_rng(0)
dates = pd.Timestamp("2023-01-01") + pd.to_timedelta(rng.integers(0, 730, len(documents)), unit="D")
late = newsgroup == "comp.graphics"
dates = dates.where(~late, pd.Timestamp("2024-01-01") + pd.to_timedelta(rng.integers(0, 365, late.sum()), unit="D"))
save(charts.plot_birth_timeline(topic_evolution(model, dates, freq="Y"), highlight={"graphics": "graphics|image"}), "11_birth_timeline")

# 12 - codebook coverage: one model with all themes seeded, one with two seeds
seeds = {"Space": "Space flight, NASA, rockets, orbits and planets", "Cars": "Cars, engines, driving and dealers"}
seeded = TriTopic(config=TriTopicConfig(verbose=False, random_state=42)).fit(documents, seeds=seeds)
save(charts.plot_codebook_coverage({"2 seeds": seeded}), "12_codebook_coverage")

save(charts.plot_quote_wall(model), "13_quote_wall")                              # one sentence per topic
save(charts.plot_topic_discovery(model), "16_topic_discovery")                    # saturation per topic

if os.environ.get("OPENAI_API_KEY"):
    from tritopic.integrations.decisions import DecisionsClient, intercoder_reliability

    interp = TopicInterpreter(domain_hint="newsgroup posts")
    results = interp.interpret(model)
    log = interp.refine(model, results=results)
    save(charts.plot_verdict_board(results, refine_log=log), "14_verdict_board")
    ic = intercoder_reliability(model, DecisionsClient(), sample_size=100)
    save(charts.plot_coder_confusion(ic, model), "15_coder_confusion")
else:
    print("charts 14 and 15 skipped (set OPENAI_API_KEY)")
Open on GitHub →
Results

TriTopic on 1,000 BBC articles

A complete run, live: TriTopic against BERTopic, the LLM interpreter, codebook seeds, every research output, and topic evolution on real dated news.

TriTopic 2.5 · demo run

What does TriTopic find in 1,000 BBC articles?

TriTopic 2.4 and BERTopic ran on the same 1,000 news articles with identical embeddings. The BBC files every article under one of five sections, so you can check how well the topics match the real structure. An LLM then reads TriTopic's topics, names them and repairs the ones that mix two themes. Further down: a run seeded from a codebook, and the research outputs TriTopic adds for a paper (reliability, saturation, bridges, group differences, a coding scheme, a second coder and the methods paragraph).

1,000 articles5 sections: Business, Entertainment, Politics, Sport, Tech384 words on average272 LLM requests
Results at a glance

Six runs compared

Green marks the best value per column. The runs differ in their number of topics; NMI and ARI compare each run with the five BBC sections.

RunTopicsNMIARINo topicLLM rating (0–3)Intruder foundTime
TriTopic · auto + LLM80.7810.7390.0%2.6873.0%9.4 s
TriTopic · full codebook50.8010.8000.0%2.2583.6%2.8 s
TriTopic · partial codebook40.7360.6590.0%2.3951.2%2.5 s
TriTopic · auto60.7420.6310.0%2.6588.8%3.2 s
BERTopic · tuned170.6710.5807.8%2.4571.4%1.9 s
BERTopic · default170.6710.5807.8%1.6455.1%1.8 s
NMIHow much the topics reveal about the true section, from 0 (nothing) to 1 (perfect). Robust to different topic counts.
ARIHow often two articles from the same section also share a topic, corrected for chance. Stricter than NMI.
No topicShare of articles the model leaves as outliers. BERTopic (HDBSCAN) does this on purpose; TriTopic assigns every article.
LLM ratingAn LLM (gpt-6-luna) rates each topic's keywords: 0 = no shared theme, 3 = one clear, nameable theme. Mean over topics.
Intruder foundProbability with which the LLM spots a word smuggled in from another topic. Clearer, better separated topics score higher.
TimeClustering time with pre-computed embeddings on the same machine; for "+ LLM" including the API calls.
Show the code for this
from tritopic import TriTopic
from sklearn.metrics import normalized_mutual_info_score as nmi, adjusted_rand_score as ari

model = TriTopic(random_state=42).fit(documents, embeddings=embeddings)   # same embeddings for every model
print(nmi(true_labels, model.labels_), ari(true_labels, model.labels_))
print((model.labels_ == -1).mean())                                          # share without a topic (0 for TriTopic)

# full benchmark against BERTopic (repository):
#   python benchmarks/compare_bertopic.py --pkg . --models tritopic,bertopic,bertopic_tuned --out results.csv
LLM interpretation

The LLM reads every topic

For each topic, TopicInterpreter gives the LLM the keywords, eight example articles that cover the topic in proportion, and the neighbouring topics. It answers with a label, a description and a verdict: one coherent theme, or a mix. These are its verdicts on TriTopic's automatic run.

Politics and economic news mixed

376 articles · confidence 0.88

The examples divide between UK political reporting (0, 2, 4) and distinct business or economic stories (1, 3, 5, 6, 7), including international corporate and commodity news.
Sub-themes: UK politics and government · Business and economic affairs

Internet and Computing Technology coherent

204 articles · confidence 0.91

The examples range from mobile internet and blogs to computer security, desktop search, gaming, and peer-to-peer networks, but all concern digital technology and its uses or consequences.

Film, Music and Television coherent

171 articles · confidence 0.90

The examples span film, music, television and other arts, but share a clear entertainment-and-culture focus rather than splitting into unrelated subjects.

Football and Rugby News mixed

166 articles · confidence 0.98

The examples divide clearly between association-football stories (such as Liverpool, Arsenal, and Manchester United) and rugby-union stories (such as Sale, Wales, Ireland, and Scotland), subjects normally filed under separate sports headings.
Sub-themes: Association football · Rugby union

Olympic Athletics coherent

43 articles · confidence 0.96

The examples consistently concern athletics and its athletes, from Olympic results and race reports to records and doping cases.

Professional Tennis Tournaments coherent

40 articles · confidence 0.97

The examples consistently concern professional tennis, ranging from Grand Slam and tour matches to Davis Cup ties, which are different facets of the same sport.

refine() split 2 topics

Politics and economic news → UK politics and government + Business and economic affairs

Football and Rugby News → Association football + Rugby union

Agreement with the BBC sections rose from NMI 0.742 to 0.781 (ARI 0.631 → 0.739).

The LLM's overview of the corpus

The corpus spans several major areas of BBC coverage, with technology (204 documents), business and economics (196), UK politics (180), and arts and entertainment (171) forming its largest clusters. Together, these topics account for most of the articles, while sport is divided among English football, international rugby, athletics, and tennis. The sports coverage focuses on teams, competitions, athletes, results, and related controversies. The topic descriptions suggest coherent coverage areas, with an isolated building-society article noted as an outlier within the football topic.

Technology and digital lifeInternet and Computing TechnologyBusiness and the economyBusiness and Economic NewsUK politics and governmentUK Labour Government PoliticsArts and entertainmentFilm, Music and TelevisionSportEnglish Football Clubs, International Rugby Union, Olympic Athletics, Professional Tennis Tournaments
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic import TopicInterpreter

interpreter = TopicInterpreter(domain_hint="BBC news articles")   # OPENAI_API_KEY, model gpt-6-luna
results = interpreter.interpret(model)          # label, description, verdict, sub-themes, evidence
for r in results.values():
    print(r.label, r.verdict, r.confidence, r.evidence)
log = interpreter.refine(model, results=results)  # splits mixed topics when the graph agrees
print(interpreter.summarize(model))
The map

Every dot is an article

The map projects the embeddings onto two dimensions, so articles with similar content sit close together. Pick a run to colour its topics, or switch to the true BBC sections. Hover over a dot to read the start of the article.

TriTopic · auto + LLM

Internet and Computing Technology204
Business and Economic News196
UK Labour Government Politics180
Film, Music and Television171
English Football Clubs96
International Rugby Union70
Olympic Athletics43
Professional Tennis Tournaments40
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

fig = model.visualize(title="Document map")      # 2D map, coloured by topic
fig.write_html("map.html")
The topics

8 topics of TriTopic · auto + LLM

Per topic: its label (for the LLM run), the top keywords, which BBC sections its articles come from (the coloured part of the bar is the most frequent section), the LLM rating and a typical article.

Internet and Computing Technology

204 articles

BBC articles about developments, products, and controversies in digital technology, computers, software, and online services. They cover both consumer technology and its effects on privacy, security, and media.

mobiletechnologyuserssoftwarepeopleonlinedigitalcomputer
Tech 185Business 14Entertainment 5
Purity 90.7%LLM rating 2.75 / 3

“but many net users will continue to ignore the entertainment industry s potential controlling grip on content and p2p technology by continuing to use it for their own creations.”

Business and Economic News

196 articles

Articles cover companies, financial markets, and economic conditions, including corporate deals and performance, currency and commodity prices, and national or regional growth. The examples are different facets of business and the economy rather than separate subjects.

oilgrowthbankcompanymarketsharesfirmeconomy
Business 191Politics 5
Purity 97.4%LLM rating 1.51 / 3

“to get any profit growth in the coming years they will have to lower costs so shedding jobs makes total sense ivo geijsen an analyst with bank oyens & van eeghen told bloomberg.”

UK Labour Government Politics

180 articles

The documents cover British party politics and government during Tony Blair’s premiership, including election campaigning, disputes between parties, and debates over policy and public administration. They also report on political relationships and parliamentary decisions involving ministers and opposition figures.

labourelectionblairmrgovernmentpartyministermr blair
Politics 168Business 9Entertainment 2Sport 1
Purity 93.3%LLM rating 2.94 / 3

“the dressing down appears to have done the trick with mr brown joining his alleged rival alan milburn - who the prime minister put in charge of election planning in”

Film, Music and Television

171 articles

BBC arts and entertainment coverage, including film releases and awards, music news, television programmes, and performing arts. The examples also include literary prizes and celebrity-related stories within the wider entertainment sphere.

filmbestawardsmusicactorawardtvstar
Entertainment 167Politics 2Tech 2
Purity 97.7%LLM rating 2.57 / 3

“most of the critics tipped jamie foxx and hilary swank to scoop best actor and actress for ray and million dollar baby respectively.”

English Football Clubs

96 articles

The documents cover English football, especially club news involving players, managers, transfers, league results and European competition. A sampled building-society article is an outlier rather than evidence of a second recurring theme.

clubchelseaarsenalunitedleaguefootballmanchesterboss
Sport 86Business 10
Purity 89.6%LLM rating 2.98 / 3

“but he denied his club was suffering a dip in form which league rivals arsenal and manchester united could exploit.”

International Rugby Union

70 articles

BBC articles about rugby union played between national teams, especially the Six Nations campaign. They cover match results and performances as well as team selection, injuries, coaching decisions, and other international competitions.

englandwalesirelandrugbyfrancescotlandnationsgame
Sport 68Tech 2
Purity 97.1%LLM rating 2.98 / 3

“with michalak overlooked the french stuttered to narrow wins over scotland and then england before ironically playing their best rugby in the defeat to wales.”

Olympic Athletics

43 articles

BBC sports coverage of track and field athletes, races, championships, and Olympic performances. It also includes doping cases and other controversies involving prominent athletes.

olympicathenschampionathleticsathletesraceolympicsiaaf
Sport 42Entertainment 1
Purity 97.7%LLM rating 2.99 / 3

“kenteris who won olympic 200m gold at the 2000 olympics and thanou were suspended by the iaaf last december after failing to take routine drugs tests before the athens games.”

Professional Tennis Tournaments

40 articles

BBC articles report on professional tennis matches, players, and tournament progress, including Grand Slams and other events. They cover both men’s and women’s tennis, with Davis Cup matches also represented.

roddickopenseedaustraliannadalaustralian openhewittwimbledon
Sport 39Entertainment 1
Purity 97.5%LLM rating 2.73 / 3

“the 23-year-old has never been beyond round four in eight attempts at melbourne park but has at least secured the opposite half of the draw to federer who beat him in the australian open wimbledon and us open last year.”

Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

model.get_topic_info()                    # Topic, Size, Keywords, Label, Seed, Coherence
topic = model.get_topic(0)
topic.keywords, topic.size, topic.label, topic.description
model.get_representative_docs(0, n_docs=3)
model.visualize_topics(n_keywords=8)        # keyword bars per topic
Automatic mode

How TriTopic picks the number of topics

Without a target, TriTopic tries 15 resolutions. For each one it extracts keywords and measures how often these words appear together in articles (NPMI coherence). It keeps the coarsest resolution whose coherence is within 5% of the best.

0.300.350.400.450.010.030.10.31Leiden resolution (log scale)Keyword coherence (NPMI)within 5% of the best value3467778810121216182226chosen: 6 topics

The number above each point is the topic count at that resolution. The amber point is the automatic choice.

Show the code for this
from tritopic import TriTopic

model = TriTopic().fit(documents)           # n_topics="auto" is the default
model.resolution_                           # chosen Leiden resolution
model.resolution_search_                    # [(resolution, n_topics, keyword coherence), ...]
TriTopic(n_topics=5).fit(documents)         # or a fixed number of topics
Codebook mode

Start from the categories you expect

With fit(documents, seeds={...}) you describe the topics you expect in a sentence each. The articles that fit a description best are pinned to one topic per seed; all other articles can join a seeded topic or form new ones. Here once with all five BBC sections, and once with only three of them, so the other two have to emerge on their own.

Full codebook (5 seeds)

NMI 0.801 · ARI 0.800 (automatic, no seeds: 0.742 / 0.631)

Sport game, games, england281 articles · Sport 83.6%
Business oil, growth, bank196 articles · Business 97.4%
Politics labour, election, blair180 articles · Politics 93.3%
Technology mobile, technology, users172 articles · Tech 90.1%
Entertainment film, best, awards171 articles · Entertainment 97.7%

Partial codebook (3 seeds)

NMI 0.736 · ARI 0.659 (automatic, no seeds: 0.742 / 0.631)

emerged mr, government, labour376 articles · Business 53.2%
Sport game, games, england281 articles · Sport 83.6%
Technology mobile, technology, users172 articles · Tech 90.1%
Entertainment film, best, awards171 articles · Entertainment 97.7%
Show the code for this
from tritopic import TriTopic

seeds = {
    "Technology": "Technology news: computers, mobile phones, the internet, software and gadgets",
    "Sport": "Sport news: football, rugby, tennis, athletics, matches and players",
    "Politics": ["government", "election", "minister", "parliament"],   # word lists work too
}
model = TriTopic().fit(documents, seeds=seeds)
model.seed_topics_        # {"Technology": 0, ...}
model.emergent_topics_    # topics that emerged without a seed
Reliability

Which topics can you trust?

TriTopic refits the model on five random 80% samples and checks how well each topic reappears (Jaccard overlap). On four benchmark corpora, topics above 0.7 were 77% pure against the true classes, topics below 0.5 only 54%. Core share = articles that stay in the topic in at least 90% of the refits.

Professional Tennis Tournaments · 40 articles · core 100.0% 1.00
UK Labour Government Politics · 180 articles · core 95.6% 0.96
Film, Music and Television · 171 articles · core 91.2% 0.94
Olympic Athletics · 43 articles · core 100.0% 0.87
Internet and Computing Technology · 204 articles · core 62.7% 0.82
Business and Economic News · 196 articles · core 39.3% 0.75
English Football Clubs · 96 articles · core 97.9% 0.71
International Rugby Union · 70 articles · core 95.7% 0.58
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import topic_reliability

rel = topic_reliability(model)           # 5 refits on random 80% samples
rel[["topic", "label", "reliability", "core_share", "reliable"]]
model.document_stability_                # per document
Saturation

Have we seen every topic?

If you read the articles in random order, when does each topic have enough articles (at least five) to be found? The curve is exact (hypergeometric), the topic accumulation curve known from ecology and the "no new codes in the last interviews" criterion of qualitative research.

0%25%50%75%100%0%20%40%60%80%100%share of articles readall topics found
30%

of the articles are enough to find every topic (95% of them after 20%). The remaining articles add no new themes: the corpus is saturated.

Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import saturation_curve, plot_saturation

sat = saturation_curve(model)
print(sat)                               # "... all of them with 30% of the documents"
sat.point_95, sat.point_all, sat.late_topics
plot_saturation(sat).show()
Bridges and prevalence

Where topics meet, and how big they are

Bridge articles

Articles whose nearest neighbours lie mostly in another topic: hybrid cases that show how two themes connect.

Business and Economic News ↔ UK Labour Government Politics · 85.4% of its neighbours in the other topic

iraqi voters turn to economic issues beyond the desperate security situation in iraq lies an economy in tatters. a vicious cycle of unemployment poor social services and poverty has been made worse by…

Film, Music and Television ↔ English Football Clubs · 65.8% of its neighbours in the other topic

court halts mark morrison album premiership footballer and record company boss kevin campbell has gained a court injunction stopping r&b singer mark morrison from releasing an album. the everton strik…

Film, Music and Television ↔ Internet and Computing Technology · 64.1% of its neighbours in the other topic

uk tv channel rapped for csi ad tv channel five has been criticised for sending offensive and threatening advertising material to viewers for a new show about murder scene scientists. five mailed thou…

International Rugby Union ↔ English Football Clubs · 63.8% of its neighbours in the other topic

bath faced with tindall ultimatum mike tindall s agent has warned bath they have until next week to improve their contract offer to the england man or risk losing him to a rival club. dipo alli says h…

Film, Music and Television ↔ Internet and Computing Technology · 61.9% of its neighbours in the other topic

help for indies in download sales a campaign has been launched to help independent labels get their music online and benefit from the growing trend for downloading music. the british phonographic indu…

Share of articles per topic, 95% confidence interval

Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import bridge_documents, topic_connections, topic_prevalence

bridge_documents(model, top_n=10)        # doc, topic, other_topic, bridge_score, text
topic_connections(model)                 # how strongly topics are linked
topic_prevalence(model)                  # share per topic with 95% Wilson interval
Group comparison

Do long articles cover different topics than short ones?

Articles above the median length (384 words) against those below. Chi-square test per topic, Cramér's V as effect size, p-values corrected for multiple testing (Benjamini-Hochberg). On the right, the words that separate the two groups most (log-odds with informative prior).

TopicShortLongCramér's Vp (adj.)
Internet and Computing Technology11.8%29.0%0.21 < 0.001
UK Labour Government Politics10.6%25.4%0.19 < 0.001
Film, Music and Television22.6%11.6%0.15 < 0.001
Business and Economic News24.8%14.4%0.13 < 0.001
Professional Tennis Tournaments5.8%2.2%0.09 0.006
Olympic Athletics6.0%2.6%0.08 0.011
English Football Clubs11.2%8.0%0.05 0.098
International Rugby Union7.2%6.8%0.01 0.804

Typical for short articles

filmsharesyearwonisraelstarawardseed

Typical for long articles

peoplemrlabourmr browncontentmobilebrownbt
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

import numpy as np
from tritopic.research import compare_groups, distinctive_keywords

lengths = np.array([len(d.split()) for d in documents])
groups = np.where(lengths > np.median(lengths), "long", "short")
compare_groups(model, groups)                         # chi2 / Fisher, Cramér's V, BH-adjusted p
distinctive_keywords(model, groups, "short", "long")  # log-odds with informative prior
Topic evolution · real dated news

What was new, year by year?

The BBC articles carry no dates, so this part uses another corpus: 16,398 HuffPost headlines with short descriptions from 2012 to 2022 (up to 1,500 random articles per year, all-MiniLM-L6-v2 embeddings; 11 topics in the global model). topic_evolution(model, dates, freq="Y") clusters every year on its own, finer than the global model, and links topics of consecutive years when their centroids are similar (cosine ≥ 0.75). A topic without a predecessor is a birth. Nobody told the model about any event.

The pandemic, as the model saw it

Strongest link forward from the first COVID topic; the number is the centroid similarity to the previous year.

2019no COVID topic6 articles mention it
→
2020 · BORN coronavirus, covid, pandemic205 articles “Read The Latest On Trump’s Health And The Spread Of COVID-19 Among Republicans”
→ 0.89
2021 covid, vaccine, covid vaccine153 articles “WHO Chief Urges Halt To COVID-19 Booster Shots For Rest Of The Year”
→ 0.93
2022 covid, monkeypox, vaccine64 articles “CDC Drops Some Quarantine, Screening Recommendations For COVID-19”

And in 2022

2022 · BORN ukraine, russia, russian130 articles “Ukraine Claws Back Some Territory; Nuclear Plant In Peril”

Before and after the real-data test

Version 2.5.0 clustered each year's slice of the global graph. That kept about 5 of 28 links per article, and most yearly topics looked new. 2.5.1 builds a graph per year.

2.5.02.5.1
Yearly topics197166
Counted as born143 (72.6%)41 (24.7%)
Articles in born topics37.8%15.4%
COVID born in 2020yes, among the noiseyes
Ukraine war born in 2022yes, among the noiseyes

Every topic born in a year (keywords, articles)

Highlighted: COVID and the war in Ukraine. Hover a topic for its most typical headline. Some births are recurring formats (weekly tweet round-ups, sport seasons) rather than events.

2013
gay, marriage, couples39health, diabetes, study38video, year, espn26workout, workout songs, songs25weight, weight loss, loss17
2014
obama, ebola, isis193police, gay, school105obama, senate, election90pet, water, dog56climate, carbon, climate change27education, college, higher21
2015
obamacare, trade, bank58pope, gay, francis50aids, repayment, student47hurricane joaquin, airline, pilot31
2016
flint, water, zika86russia, trump, clinton30
2017
climate, climate change, change34disease, congo, infections26moore, roy moore, roy20korea, north korea, north16
2018
gop, state, court77
2019
stories, women, huffpost stories35climate, measles, climate change33epstein, ocasio cortez, ocasio29hong, hong kong, kong25sale, nordstrom, yearly sale25prince, duchess, duke22boris, boris johnson, brexit19abortion, abortions, ban18
2020
coronavirus, covid, pandemic205pandemic, kids, home154tweets, funniest tweets, funniest48food, coronavirus, pandemic45mask, masks, face43
2021
biden, president, joe biden140voting, voting rights, rights72olympics, tokyo, tokyo olympics72
2022
ukraine, russia, russian130nfl, quarterback, super bowl36musk, twitter, elon musk24
Show the code for this
from tritopic import TriTopic
from tritopic.research import topic_evolution, plot_evolution

model = TriTopic().fit(df["text"].tolist())
evo = topic_evolution(model, df["date"], freq="Y")   # "M", "Q" or "Y"
evo.events[evo.events.event == "birth"]              # what was new, with keywords
evo.nodes.docs                                       # documents of each period topic
plot_evolution(evo).show()                           # Sankey
# full walkthrough: examples/12_topic_evolution_news.py
Codebook for manual coding

A coding scheme, written from the topics

TopicInterpreter.codebook() writes a category per topic in the style of qualitative content analysis: definition, inclusion and exclusion criteria (naming the neighbouring category to use instead) and coding notes. The anchor examples are real sentences from the articles, picked by topic_quotes(), not generated text.

CategoryDefinitionInclude whenExclude whenAnchor examples
Internet and Computing Technology
204 articles
BBC news articles about digital technologies, computing devices, software, internet services, and how people use them. Coverage may include the development, commercial use, security, regulation, or social effects of these technologies.
  • Articles primarily about computers, mobile phones, software, networks, or other digital technologies.
  • Articles about internet access and services, including websites, search, blogs, online communication, and peer-to-peer networks.
  • Articles about digital technology applications or risks, such as cybersecurity, online fraud, privacy, or digital media delivery.
  • Articles about technology users and the effects of digital tools on work, communication, or everyday life.
  • If the main focus is a company’s financial performance, markets, or the wider economy, code as “Business and Economic News” instead; use this category only when technology itself is the central subject.
  • If the main focus is elections, political parties, government, or politicians’ actions, code as “UK Labour Government Politics” instead; use this category only when political context is secondary to the technology topic.
  • If the main focus is films, music, television programmes, performers, or entertainment awards, code as “Film, Music and Television” instead; use this category only when the article primarily concerns the technology used to create, distribute, or access media.

“but many net users will continue to ignore the entertainment industry s potential controlling grip on content and p2p technology by continuing to use it for their own creations.”

“key to the appeal of sending tv programmes down the same line as the web data whenever a viewer wants it is that it uses the same technology as the internet.”

Business and Economic News
196 articles
News primarily about businesses, markets, finance, or economic conditions and their effects. Coverage may concern companies and corporate actions, prices and trading, economic indicators, employment, profits, or the financial consequences of events.
  • The main focus is a company, industry, or commercial activity, including bids, mergers, lawsuits, financial results, sales, or business strategy.
  • The main focus is markets or financial matters, such as currencies, shares, oil prices, banking, investment, or corporate accounting and fraud.
  • The main focus is an economy’s performance or outlook, including growth, inflation, production, consumer spending, or the economic costs of a disaster.
  • Include political or technological developments when the article’s central subject is their business or economic impact.
  • If the central subject is computing or internet technology, its products, or users’ online activity rather than its commercial or financial significance, code as “Internet and Computing Technology.”
  • If the central subject is elections, parties, politicians, or government activity rather than economic conditions or business consequences, code as “UK Labour Government Politics.”
  • If the central subject is English football clubs, matches, leagues, or club personnel rather than the business or financial aspects of the sport, code as “English Football Clubs.”

“to get any profit growth in the coming years they will have to lower costs so shedding jobs makes total sense ivo geijsen an analyst with bank oyens & van eeghen told bloomberg.”

“the sec has already delayed implementation of this clause for smaller firms - including us ones - with market capitalisations below $700m (£374m).”

UK Labour Government Politics
180 articles
BBC news about the UK Labour Party and its role in government, including party leadership, elections, relations among ministers and MPs, and government decisions or policy debates. The focus is political actors, party competition, or the exercise and scrutiny of government rather than sport or commercial economic activity.
  • Reports on Tony Blair, Gordon Brown, Labour ministers, Labour MPs, or party organisation and internal relations.
  • Coverage of UK elections, campaigning, party pledges, or disputes between Labour and opposition parties.
  • Reports on government policy, legislation, ministerial decisions, or political accountability when the main focus is the UK government or its political handling.
  • Code as “English Football Clubs” when the main subject is English football clubs, managers, players, or league competition; political or government references are incidental.
  • Code as “International Rugby Union” when the main subject is international rugby teams, matches, or competitions; UK political references are incidental.
  • Code as “Business and Economic News” when the main subject is companies, financial markets, banking, oil, or economic performance as commercial or economic activity, rather than party politics or government policy.

“the dressing down appears to have done the trick with mr brown joining his alleged rival alan milburn - who the prime minister put in charge of election planning in”

“mr blair told mps and peers: i know from everyone here in cabinet and government nothing is going to get in the way of a unified labour party with a unified position and winning the third term people desperately need.”

Film, Music and Television
171 articles
This category covers BBC news articles whose main subject is film, music, or television as cultural and entertainment fields. It includes coverage of works, performers, industry events, releases, rankings, reviews, and awards in these fields.
  • The article focuses on films, cinema, actors or actresses, or film festivals, releases, box-office performance, or awards such as the Oscars.
  • The article focuses on music, musicians, bands, recordings, performances, or music-industry polls and events.
  • The article focuses on television programmes, broadcasters, television personalities, or television awards.
  • A cultural work or event is covered across formats, such as a singer’s documentary screened at a film festival, when entertainment media is the central subject.
  • If the main subject is books, authors, or literary prizes rather than screen or music entertainment, code under a neighbouring books or literature category, because the article concerns publishing and literature.
  • If the main subject is computing, software, online services, or digital technology, code under “Internet and Computing Technology,” because technology rather than cultural entertainment is central.
  • If the main subject is Olympic competition, athletes, or race results, code under “Olympic Athletics,” because it concerns sport rather than entertainment media.
  • If the main subject is elections, political parties, government, or ministers, code under “UK Labour Government Politics,” because political affairs rather than entertainment are central.
  • If a celebrity is mentioned only in passing and the article principally concerns another topic, code under the category matching that main topic, because incidental celebrity references do not make it entertainment coverage.

“most of the critics tipped jamie foxx and hilary swank to scoop best actor and actress for ray and million dollar baby respectively.”

“us afflicted with awards fatigue the film world and media may be wild about the oscars but cinema-goers and the tv-viewing public are sick of watching award ceremonies according to some la film critics.”

English Football Clubs
96 articles
News focused on English football clubs, their players, managers, officials, supporters, or business affairs. Coverage often concerns matches and league performance, transfers, injuries, club decisions, and competition with other clubs.
  • The main subject is an English club or one of its players, managers, executives, or supporters.
  • Reports on English club matches, results, league positions, transfers, contracts, injuries, or disputes.
  • Coverage of club ownership, finances, governance, or supporter responses when tied to an English football club.
  • Code as “International Rugby Union” when the central subject is rugby teams, players, or competitions rather than English football clubs.
  • Code as “UK Labour Government Politics” when the central subject is Labour, government, elections, or political officials rather than club affairs.
  • Code as “Olympic Athletics” when the central subject is Olympic competition, athletics, or track-and-field athletes rather than football clubs.

“but he denied his club was suffering a dip in form which league rivals arsenal and manchester united could exploit.”

“i do not think there is anything that manchester united fans can do about it he told the bbc.”

International Rugby Union
70 articles
Coverage of rugby union played or governed at the international level, with emphasis on national teams, international competitions, and representative fixtures. Articles may report on matches, squad selection, injuries, coaching, refereeing, or tournament prospects involving national sides.
  • The central subject is a national rugby union team, its players or coaches, or a match between national teams.
  • Coverage concerns international competitions such as the Six Nations, Rugby World Cup, or international sevens, including qualification, draws, and tournament standings.
  • Reports on selection, injuries, tactics, or disciplinary issues belong when tied to a national team or international fixture.
  • International representative or charity matches belong even when players are drawn from multiple national teams.
  • Code as English Football Clubs when the main subject is association football, its clubs, or domestic football leagues; shared references to an English club or coach do not make it rugby union.
  • Code as Professional Tennis Tournaments when the article primarily concerns tennis players, matches, rankings, or events such as Wimbledon or the Australian Open.
  • Code as Olympic Athletics when the article primarily concerns track and field, athletes, races, or Olympic athletics competitions rather than rugby union.

“with michalak overlooked the french stuttered to narrow wins over scotland and then england before ironically playing their best rugby in the defeat to wales.”

“despite the withdrawal of wales wing rhys williams who is required for the blues celtic league match with munster three other members of their six nations squad - ceri sweeney john yapp and jonathan thomas - will also play.”

Olympic Athletics
43 articles
News about track and field athletes, competitions, and governance, especially where Olympic participation, results, or status provide the context. Coverage also includes athletes’ performances at other meets and related issues such as doping when the athletes or events are associated with Olympic athletics.
  • The article focuses on track and field events or athletes, including running, jumping, throwing, road running, or race walking.
  • The article discusses Olympic athletics results, preparation, selection, medals, or the aftermath of Olympic competition.
  • The article reports on doping cases, records, or careers involving prominent Olympic track and field athletes, even if the immediate event is outside the Olympics.
  • Code as Professional Tennis Tournaments when the central subject is a tennis tournament or tennis player, rather than track and field.
  • Code as International Rugby Union when the central subject is rugby union competition, teams, or players.
  • Code as English Football Clubs when the central subject is English football clubs, leagues, or their players and managers.
  • Do not include an article merely because it mentions the Olympics; use the relevant neighbouring sports category when its main subject is tennis, rugby union, or English football.

“kenteris who won olympic 200m gold at the 2000 olympics and thanou were suspended by the iaaf last december after failing to take routine drugs tests before the athens games.”

“greek athletics federation boss vassilli sevastis told the country s parliament: i believe kenteris and thanou won t race again.”

Professional Tennis Tournaments
40 articles
Articles primarily about professional tennis competitions, including tournament matches, results, draws, finals, titles, and players’ progress through events. Coverage may concern Grand Slam championships, other professional tour events, or team competitions such as the Davis Cup.
  • The main subject is a tennis tournament or match, including previews, live or post-match reports, scores, and advancement between rounds.
  • Reports on tournament entries, seedings, withdrawals, injuries, or scheduling when these are discussed in relation to a specific tennis event.
  • Coverage of professional tennis players’ performances or rivalries within a competition, including Australian Open, Wimbledon, and other named events.
  • Code as Olympic Athletics when the article primarily concerns Olympic or track-and-field events, athletes, or results rather than tennis.
  • Code as International Rugby Union when rugby teams, matches, competitions, or players are the primary subject, even if a tennis event is mentioned in passing.
  • Code as English Football Clubs when football clubs, leagues, managers, or matches are the primary subject rather than a tennis competition.

“the 23-year-old has never been beyond round four in eight attempts at melbourne park but has at least secured the opposite half of the draw to federer who beat him in the australian open wimbledon and us open last year.”

“roddick still looks a bit sluggish but he attacks the net and is rewarded with a break point which nadal saves with a good first serve and the spaniard goes on to hold.”

Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic import TopicInterpreter

interp = TopicInterpreter(domain_hint="BBC news articles")
interp.interpret(model)                        # labels and descriptions first
codebook = interp.codebook(model, n_quotes=2)  # name, definition, inclusion, exclusion, anchor quotes
codebook.to_excel("codebook.xlsx")
Second coder

Would an independent reader assign the same topics?

κ = 0.94

Cohen's kappa between TriTopic and an LLM (gpt-6-luna) that assigned 150 random articles to the topics, knowing only their labels, descriptions and keywords, like a human coder with a codebook. 94.7% identical assignments; above 0.8 counts as almost perfect agreement (Landis & Koch, 1977).

Agreement per topic (F1)

English Football Clubs · n = 19 1.00
Olympic Athletics · n = 6 1.00
UK Labour Government Politics · n = 28 0.96
Film, Music and Television · n = 28 0.96
International Rugby Union · n = 12 0.96
Internet and Computing Technology · n = 28 0.93
Business and Economic News · n = 29 0.91
Professional Tennis Tournaments · n = 0 0.00
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.integrations.decisions import DecisionsClient, intercoder_reliability

ic = intercoder_reliability(model, DecisionsClient(), sample_size=200)
ic.kappa, ic.agreement        # Cohen's kappa, raw agreement
ic.per_topic                  # precision / recall / F1 per topic
ic.confusion                  # rows: TriTopic, columns: LLM
For the paper

Methods paragraph

methods_report(model) writes it from the fitted model, without an LLM, so it is reproducible. It also returns a parameter table with software versions.

Topics were identified with TriTopic 2.5.0 (Egger, 2026). The BBC News test split comprised 1,000 documents (mean length 384 words). Documents were embedded with all-MiniLM-L6-v2 and reduced to 10 dimensions with UMAP. A document graph was built from semantic (sentence embeddings), lexical (TF-IDF) views (15 nearest neighbours, hybrid graph) and partitioned with consensus Leiden clustering over 10 runs with iterative refinement. The number of topics was chosen automatically: Leiden was run at 15 resolutions, and the coarsest resolution whose mean keyword coherence (NPMI) was within 5% of the best was kept (resolution 0.019). Clusters smaller than 5 documents were treated as outliers. Topics were described by 10 keywords from coverage-weighted class-based TF-IDF (ctfidf). The final model has 8 topics (all documents assigned); mean keyword coherence (NPMI) 0.435, topic diversity 1.00, consensus stability (mean pairwise ARI) 1.000. Random seed: 42. Topic labels and descriptions were produced with an LLM (TopicInterpreter), which read each topic's keywords, proportionally sampled example documents and neighbouring topics.
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import methods_report

print(methods_report(model, corpus="the BBC News test split"))   # markdown; fmt="text" for plain text
Takeaways

What this run shows

+

Closer to the real structure. Automatic TriTopic reaches NMI 0.742 vs. 0.671 for BERTopic and assigns every article; BERTopic leaves 7.8% without a topic. Seeded from a five-line codebook, TriTopic reaches NMI 0.801 and ARI 0.800.

+

The LLM finds and fixes the weak spots. Two of TriTopic's topics mix two themes (politics with business, football with rugby). The interpreter calls them mixed, names the sub-themes, and refine() separates them: NMI 0.742 → 0.781, ARI 0.631 → 0.739.

+

Readable topics. The LLM rates TriTopic's topics 2.65 of 3 (BERTopic: 2.45 tuned, 1.64 default) and finds a smuggled-in word with 88.8% probability (BERTopic tuned: 71.4%).

+

Sensible granularity without a target. Automatic mode chooses 6 topics, close to the 5 sections. BERTopic finds 17, many of them very specific (single company scandals, car makers).

–

BERTopic is faster on short runs (1.9 s vs. 3.2 s without LLM). The LLM step adds about 6 s of API calls for 19 requests.

+

Numbers a reviewer asks for. Reliability per topic, a saturation curve (all topics visible after 30% of the articles), an LLM second coder with Cohen's κ = 0.94, a coding scheme with real anchor quotes, and a reproducible methods paragraph.

+

Finds what was new. On 11 years of dated news, topic evolution marks the pandemic as born in 2020 (continuing as vaccines in 2021) and the war in Ukraine in 2022, without being told about either.

–

A partial codebook can swallow neighbours. Seeded with only three sections, the unseeded business and politics articles form one shared topic. Seed every theme you know, or let refine() split mixed topics afterwards.

–

LLM answers vary. Verdicts and labels can differ between runs; refine() only keeps a split when the graph confirms the LLM's sub-themes.

Data: BBC News test split (SetFit/bbc-news), 1,000 articles, 384 words on average. Embeddings: all-MiniLM-L6-v2, identical for all models. Seed 42. TriTopic 2.5.1 · BERTopic 0.17.4 ("tuned" = stop words, bigrams, reduce_frequent_words) · LLM: gpt-6-luna via the OpenAI Responses and Decisions APIs (272 requests). One run with one seed, and LLM answers vary between runs. The robust numbers over four datasets and three seeds are in the README benchmarks.
Chart gallery

Sixteen ways to look at a topic model

Every chart below is drawn from the BBC model. Each one exists in the library as a Plotly function in tritopic.visualization.charts; open "Show the code" under a chart to see how to make it for your own data. The versions on this page are drawn for the web; the library versions show the same data.

TriTopic · chart lab

Sixteen ways to look at a topic model

Prototypes on the BBC demo data: 1,000 articles, eight topics labelled by an LLM. Seven of the charts do not exist in other topic-modeling tools, because they need what only TriTopic has: two graph views, consensus runs, bootstrap refits, bridge documents, seeds, topic evolution and an LLM layer. Charts 11 to 16 use the HuffPost news corpus (11) and the demo's codebook and LLM runs (12, 14). Hover any mark for details.

Internet and Computing TechnologyBusiness and Economic NewsBlair-Era Labour PoliticsFilm, TV and music entertainmentEnglish Football ClubsSix Nations RugbyOlympic Athletics NewsProfessional Tennis Coverage
1 · OverviewReport-ready

The periodic table of topics

Every topic as an element: share of the corpus, reliability under resampling, and keyword coherence. Ordered by size. One glance tells you what the corpus is made of and which topics to trust.

1204 articles
Ic
Internet and Computing Technology
mobile, technology, users
20%share0.82reliab.0.34NPMI
2196 articles
Be
Business and Economic News
oil, growth, bank
20%share0.75reliab.0.22NPMI
3180 articles
Bl
Blair-Era Labour Politics
labour, election, blair
18%share0.96reliab.0.53NPMI
4171 articles
Fm
Film, TV and music entertainment
film, best, awards
17%share0.94reliab.0.36NPMI
596 articles
Ef
English Football Clubs
club, chelsea, arsenal
10%share0.71reliab.0.45NPMI
670 articles
Sn
Six Nations Rugby
england, wales, ireland
7%share0.58reliab.0.42NPMI
743 articles
Oa
Olympic Athletics News
olympic, athens, champion
4%share0.87reliab.0.63NPMI
840 articles
Pt
Professional Tennis Coverage
roddick, open, seed
4%share1.00reliab.0.54NPMI
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import topic_reliability
from tritopic.visualization import charts

rel = topic_reliability(model)
charts.plot_topic_table(model, reliability=rel).show()
2 · Tri-View triangleNew chart type

What holds a topic together: meaning, wording, or both?

TriTopic links articles in two views: similar meaning (sentence embeddings) and similar wording (TF-IDF). For the links inside a topic, the triangle shows the share that exists only in the meaning view, only in the wording view, or in both. Small dots are single articles, large circles the topics. The chart zooms into the corner where all topics lie (at least 30% meaning-only links). A topic near "meaning only" is one theme written in many different words. A topic near "wording only" shares vocabulary that the embeddings miss. Topics near "both" are the most clear-cut.

10%10%20%20%30%30%40%40%50%50%60%60%↑ more links in both views100% meaning onlymore wording only →

Reading the BBC topics

TopicMean.Word.Both
Blair-Era Labour Politics69%8%23%
Internet and Computing Technology59%9%32%
Business and Economic53%15%32%
Film, TV and music entertainment60%14%26%
English Football Clubs58%11%32%
Six Nations Rugby61%12%28%
Professional Tennis47%13%41%
Olympic Athletics40%12%48%

Most meaning-held: Blair-Era Labour Politics (69% of its links exist only in the meaning view). Political stories share ideas more than exact words.


Most wording-held: Business and Economic News (15% wording only): company names, figures and market vocabulary repeat across articles.


Most clear-cut: Olympic Athletics News (48% of links confirmed by both views).


Use: topics far from "both" are where a single-view model (BERTopic uses embeddings only) is most likely to draw the border differently.

Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import view_composition
from tritopic.visualization import charts

topics, docs = view_composition(model)   # sem_only / lex_only / both links per topic and document
charts.plot_triview(model).show()        # zoom=0 shows the full triangle
3 · Zoom ladderNew chart type

How do the topics split when you zoom in?

The same graph clustered at five resolutions, from very coarse to very fine. Ribbons show where the articles go. The colour is the BBC section most of a cluster's articles come from. The ladder answers "how many topics?" without a single magic number: stable blocks that pass through several levels unchanged are real themes, and late splits show sub-themes.

2 topicsresolution ×0.25mr, peoplegame, england3 topicsresolution ×0.56 topicsresolution ×17 topicsresolution ×312 topicsresolution ×9labour, electionoil, growthfilm, actormobile, technologyclub, chelseaengland, walesmail, virusolympic, athensroddick, openband, albumgames, game
Colour = BBC section: BusinessEntertainmentPoliticsSportTech
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import resolution_ladder
from tritopic.visualization import charts

ladder = resolution_ladder(model, multipliers=(0.25, 0.5, 1, 3, 9))
ladder.levels                            # clusters per level with keywords and size
charts.plot_resolution_ladder(model, ladder=ladder).show()
4 · Topic onionsNew chart type

Core and edge of every topic

Each circle is one topic. Articles closest to the topic's centre sit in the middle; the rings mark the 25%, 50% and 75% most central articles. The direction matches the document map. Colour shows how often an article stayed in its topic across five refits on 80% samples: amber = left its topic in some refits, petrol = always stayed. A ring in another topic's colour marks a bridge article whose neighbours lie mostly in that topic.

Internet and Computing Technology

204 articles · 63% always stayed · 7 bridges

Business and Economic News

196 articles · 39% always stayed · 6 bridges

Blair-Era Labour Politics

180 articles · 96% always stayed · 3 bridges

Film, TV and music entertainment

171 articles · 91% always stayed · 10 bridges

English Football Clubs

96 articles · 98% always stayed · 4 bridges

Six Nations Rugby

70 articles · 96% always stayed · 8 bridges

Olympic Athletics News

43 articles · 100% always stayed · 3 bridges

Professional Tennis Coverage

40 articles · 100% always stayed · 2 bridges

Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import topic_cores, topic_reliability
from tritopic.visualization import charts

topic_reliability(model)                 # sets model.document_stability_ (colour)
topic_cores(model)                       # core_rank, angle, stability, bridge_to per document
charts.plot_topic_onions(model).show()
5 · Keyword barcodeNew chart type

Is a keyword carried by the whole topic or by a few articles?

Each column is one article of the topic, ordered from the core (left) to the edge (right); a dark stripe means the article contains the keyword. Coverage is the share of the topic's articles with the keyword, outside the share among all other articles. A good keyword is dense inside and rare outside. A keyword that only shows up at the right edge describes the periphery, not the topic.

KeywordArticles, core → edge (204)InsideOutside
mobile23%1%
technology45%2%
users36%0%
software28%1%
people69%27%
online34%2%
digital29%1%
computer32%1%
use46%8%
net29%4%
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import keyword_coverage
from tritopic.visualization import charts

keyword_coverage(model, topic_id=0)      # coverage inside, share outside, presence per document
charts.plot_keyword_barcode(model, topic_id=0).show()
6 · ConstellationKnown type, new data

Which topics are neighbours?

Topics placed by how similar their content is (multidimensional scaling of centroid similarity), circle area by size. Lines are actual links between articles of two topics (nearest-neighbour weight); the number is the count of bridge articles. Unlike distance maps, the lines show where readers really cross from one topic into another.

2223181554763Internet and Computing TechnologyBusiness and EconomicBlair-Era Labour PoliticsFilm, TV and music entertainmentEnglish Football ClubsSix Nations RugbyOlympic AthleticsProfessional Tennis
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import topic_connections
from tritopic.visualization import charts

topic_connections(model)                 # topic_a, topic_b, strength, bridge_docs
charts.plot_constellation(model, top_links=12).show()
7 · Fuzzy bordersKnown in cluster analysis

How sharp are the topic borders?

A sample of 240 articles, sorted by topic and, within each topic, by stability. Each cell shows how often two articles landed in the same topic across 10 refits on random 80% samples (counted over the refits that contained both). Solid blocks are sharp topics; haze between blocks marks articles that switch topics when the data changes.

What the blocks show

Blocks run in the order of the topic legend above; dark = always together.


Strongest haze between English Football Clubs and Six Nations Rugby: same topic in 58% of the refits on average.


Next: Olympic Athletics News / Professional Tennis Coverage (10%), then Business and Economic News / Blair-Era Labour Politics (4%).


Least stable inside: Internet and Computing Technology (articles of this topic share a topic in 79% of the refits). The consensus runs on the full data agree on every article, so stability under resampling is the more honest picture.

Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import coassignment
from tritopic.visualization import charts

res = coassignment(model, sample_size=240, n_refits=10)   # refits on random 80% samples
charts.plot_coassignment(model, result=res).show()
8 · Group tiltStatistics built in

Do long and short articles cover different topics?

Share of each topic among articles above and below the median length (334 words), with 95% confidence intervals. * = significant after Benjamini-Hochberg correction; V = Cramér's V. Two colours: short articles, long articles.

0%5%10%15%20%25%30%35%Internet and Computing Technology* V = 0.21Business and Economic* V = 0.13Blair-Era Labour Politics* V = 0.19Film, TV and music entertainment* V = 0.15English Football ClubsV = 0.05Six Nations RugbyV = 0.01Olympic Athletics* V = 0.08Professional Tennis* V = 0.09
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

import numpy as np
from tritopic.research import compare_groups, topic_prevalence
from tritopic.visualization import charts

groups = df["country"]                   # any grouping, one value per document
topic_prevalence(model, groups=groups)   # shares with 95% CI per group
compare_groups(model, groups)            # tests, Cramér's V, BH correction
charts.plot_group_tilt(model, groups).show()
9 · Trust quadrantDecision aid

Which topics can go into the paper?

Size against reliability. Topics above 0.7 were 77% pure in the validation, topics below 0.5 only 54%. Large and reliable topics are safe to report; small reliable ones are niche themes; unreliable ones need a check (merge, split or drop).

0.000.250.500.751.002550100200400articles in the topic (log scale)↑ report with confidence↓ check before reportingProfessional TennisBlair-Era Labour PoliticsFilm, TV and music entertainmentOlympic AthleticsInternet and Computing TechnologyBusiness and EconomicEnglish Football ClubsSix Nations Rugby
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import topic_reliability
from tritopic.visualization import charts

rel = topic_reliability(model)
charts.plot_trust(model, rel).show()
10 · Composition mosaicValidation

What is inside each topic?

Row height is the topic's size, the colours the BBC sections of its articles. Clean single-colour rows are topics that match a section; mixed rows show where TriTopic cuts differently from the editors (here: sport split into football, rugby, athletics and tennis).

Internet and Computing TechnologyTech 91%Business and EconomicBusiness 97%Blair-Era Labour PoliticsPolitics 93%Film, TV and music entertainmentEntertainment 98%English Football ClubsSport 90%Six Nations RugbySport 97%Olympic AthleticsProfessional Tennis0%25%50%75%100%
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.visualization import charts

charts.plot_composition(model, df["section"], reference_name="BBC section").show()
11 · Birth timelineNew chart type

When did themes appear, and when did they fade?

16,398 dated HuffPost articles, 2012–2022. Above the axis: topics that appear in a year without a predecessor. Below: topics that have no successor in the next year. The number on the axis counts topics that carry on. Read births together with their keywords: some are events (COVID, Ukraine, Flint, Hong Kong), some are recurring formats such as weekly tweet round-ups.

2012201310 carry on20149 carry on201512 carry on201610 carry onUkraine201713 carry on201813 carry on201911 carry on202011 carry onCOVIDCOVIDCOVIDCOVID202111 carry onCOVID202212 carry onUkraine↑ born↓ ends
new topic (birth)COVID / war in Ukrainetopic that ends (death)circle area = articles
Show the code for this
from tritopic import TriTopic
from tritopic.research import topic_evolution
from tritopic.visualization import charts

model = TriTopic().fit(df["text"].tolist())
evo = topic_evolution(model, df["date"], freq="Y")
charts.plot_birth_timeline(evo, highlight={"COVID": "covid|coronavirus", "Ukraine": "ukrain"}).show()
12 · Codebook coverageNew chart type

How much does the codebook explain, and what emerged on its own?

Each bar is the whole corpus, split into topics. Solid segments grew from a seed of the codebook, hatched segments emerged without one. With all five BBC sections as seeds nothing is left over; with only three seeds, business and politics have to find their own place.

Full codebook · 5 seeds seeded 100% · emerged 0% · NMI 0.801 · ARI 0.800
TechnologyBusinessSportEntertainmentPolitics
Partial codebook · 3 seeds (Technology, Sport, Entertainment) seeded 62% · emerged 38% · NMI 0.736 · ARI 0.659
TechnologySportEntertainmentemerged: mr, government
Show the code for this
from tritopic import TriTopic
from tritopic.research import codebook_coverage
from tritopic.visualization import charts

full = TriTopic().fit(documents, seeds=codebook)                       # all five sections
part = TriTopic().fit(documents, seeds={k: codebook[k] for k in ["Technology", "Sport", "Entertainment"]})
codebook_coverage(part)                                                # size, share, seed (None = emerged)
charts.plot_codebook_coverage({"5 seeds": full, "3 seeds": part}).show()
13 · Quote wallFor presentations

One sentence per topic

The most telling sentence of each topic, picked by topic_quotes() from the most central articles. Real sentences from the corpus, not generated text, so they can be cited.

“but many net users will continue to ignore the entertainment industry s potential controlling grip on content and p2p technology by continuing to use it for their own creations.”

“mobile companies are keen for people to use multimedia functions their phones like sending mms and going online as this generates more money for them.”

Internet and Computing Technology · 204 articles
“the company warned the move would cut its profits by $3.8m to $4.9m (£2m to £2.6m) - or between 6.6% and 8.6%.”

“the sec has already delayed implementation of this clause for smaller firms - including us ones - with market capitalisations below $700m (£374m).”

Business and Economic News · 196 articles
“the dressing down appears to have done the trick with mr brown joining his alleged rival alan milburn - who the prime minister put in charge of election planning in”

“mr blair told mps and peers: i know from everyone here in cabinet and government nothing is going to get in the way of a unified labour party with a unified position and winning the third term people desperately need.”

Blair-Era Labour Politics · 180 articles
“most of the critics tipped jamie foxx and hilary swank to scoop best actor and actress for ray and million dollar baby respectively.”

“foxx and swank take actors awards jamie foxx and hilary swank have won the screen actors guild awards for best male and female film actors boosting their oscars hopes this month.”

Film, TV and music entertainment · 171 articles
“but he denied his club was suffering a dip in form which league rivals arsenal and manchester united could exploit.”

“mourinho to escape fa charge chelsea boss jose mourinho will not face any football association action over the comments he made after their carling cup tie with manchester united.”

English Football Clubs · 96 articles
“despite the withdrawal of wales wing rhys williams who is required for the blues celtic league match with munster three other members of their six nations squad - ceri sweeney john yapp and jonathan thomas - will also play.”

“our first aim was to get gavin settled into the team but giving him the kicks is something we ll talk about in selection this week wales coach mike ruddock told bbc sport wales.”

Six Nations Rugby · 70 articles
“kenteris who won olympic 200m gold at the 2000 olympics and thanou were suspended by the iaaf last december after failing to take routine drugs tests before the athens games.”

“speaking to the greek parliament on tuesday sevastis said that the evidence sent by the international olympic committee and athletics governing body the iaaf was not strong enough for the greek association to find the sprinters guilty.”

Olympic Athletics News · 43 articles
“the 23-year-old has never been beyond round four in eight attempts at melbourne park but has at least secured the opposite half of the draw to federer who beat him in the australian open wimbledon and us open last year.”

“andy roddick was the only player to put up any real resistance at wimbledon and federer s performance against lleyton hewitt in the us open final was breathtaking.”

Professional Tennis Coverage · 40 articles
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import topic_quotes
from tritopic.visualization import charts

quotes = topic_quotes(model, n=2)        # topic, rank, quote, doc, score
charts.plot_quote_wall(model, quotes=quotes).show()
14 · LLM verdict boardLLM

What the LLM thought of each topic, and what happened next

First pass of TopicInterpreter over the automatic BBC model (six topics). For each topic the LLM reads keywords, typical articles and the neighbouring topics, then calls it coherent, mixed or unclear, with its evidence. Mixed topics go to refine(), which keeps a split only if the graph agrees. Here both splits were kept: NMI 0.742 → 0.781, ARI 0.631 → 0.739.

Coherent 4

Internet and Computing Technology204 articles · confidence 91%

The examples range from mobile internet and blogs to computer security, desktop search, gaming, and peer-to-peer networks, but all concern digital technology and its uses or consequences.

Film, Music and Television171 articles · confidence 90%

The examples span film, music, television and other arts, but share a clear entertainment-and-culture focus rather than splitting into unrelated subjects.

Olympic Athletics43 articles · confidence 96%

The examples consistently concern athletics and its athletes, from Olympic results and race reports to records and doping cases.

Professional Tennis Tournaments40 articles · confidence 97%

The examples consistently concern professional tennis, ranging from Grand Slam and tour matches to Davis Cup ties, which are different facets of the same sport.

Mixed 2

Politics and economic news376 articles · confidence 88%
UK politics and governmentBusiness and economic affairs

The examples divide between UK political reporting (0, 2, 4) and distinct business or economic stories (1, 3, 5, 6, 7), including international corporate and commodity news.

→ refine(): split kept, keyword coherence 0.41 → 0.37

Football and Rugby News166 articles · confidence 98%
Association footballRugby union

The examples divide clearly between association-football stories (such as Liverpool, Arsenal, and Manchester United) and rugby-union stories (such as Sale, Wales, Ireland, and Scotland), subjects normally filed under separate sports headings.

→ refine(): split kept, keyword coherence 0.27 → 0.43

Unclear 0

No topic.

Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic import TopicInterpreter
from tritopic.visualization import charts

interp = TopicInterpreter(domain_hint="BBC news articles")
results = interp.interpret(model)
log = interp.refine(model, results=results)
charts.plot_verdict_board(results, refine_log=log).show()
15 · Coder confusion gridLLM

Where does the second coder disagree?

200 random articles were assigned by an LLM that only saw the topic labels, descriptions and keywords, like a human coder with a codebook. Rows: TriTopic's topic; columns: the LLM's choice. The diagonal is agreement; every off-diagonal cell is a disagreement, and the darker the cell the more articles.

Internet and Computing Te…3641Business and Economic341Blair-Era Labour Politics3336Film, TV and music entert…1128English Football Clubs223Six Nations Rugby28Olympic Athletics117Professional Tennis8Internet and Computin…Business and EconomicBlair-Era Labour Poli…Film, TV and music en…English Football ClubsSix Nations RugbyOlympic AthleticsProfessional TennisLLM coder →TriTopic ↓
κ = 0.88

Cohen's kappa on 200 articles, 90% identical assignments: almost perfect agreement (above 0.8, Landis & Koch).


Most frequent disagreements:

  • 4× TriTopic Internet and Computing Technology, LLM Business and Economic
  • 3× TriTopic Blair-Era Labour Politics, LLM Internet and Computing Technology
  • 3× TriTopic Blair-Era Labour Politics, LLM Business and Economic

Weakest category: Business and Economic News (F1 0.86). Its definition is the first to sharpen before manual coding.

Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.integrations.decisions import DecisionsClient, intercoder_reliability
from tritopic.visualization import charts

ic = intercoder_reliability(model, DecisionsClient(), sample_size=200)
charts.plot_coder_confusion(ic, model).show()
16 · Saturation with topic dotsNew chart type

When does each topic become visible?

Imagine reading the 1,000 articles in random order. A topic counts as visible once 5 of its articles have been read. The curve shows the expected share of visible topics; below it, one lane per topic: the dot marks when the topic is visible with 50% probability, the bar ends where it is visible with 95%. Large topics appear almost at once, small ones (athletics, tennis) set the saturation point at 22% of the corpus.

0%5%10%15%20%25%30%0%50%100%share of topics visibleInternet and Computing Tech…50 articlesBusiness and Economic50 articlesBlair-Era Labour Politics50 articlesFilm, TV and music entertai…60 articlesEnglish Football Clubs100 articlesSix Nations Rugby130 articlesOlympic Athletics200 articlesProfessional Tennis220 articlesper topic: ● 50% likely visible — bar end: 95% likely visible
Show the code for this
from tritopic import TriTopic
model = TriTopic().fit(documents)        # documents: list of strings

from tritopic.research import topic_discovery
from tritopic.visualization import charts

topics, curve = topic_discovery(model)   # f50 / f95 per topic, expected accumulation curve
charts.plot_topic_discovery(model).show()
Rejected

Charts we decided against

Word clouds. Word size is hard to compare and position carries no meaning; the keyword barcode answers the same question with numbers.
Chord diagrams. Unreadable beyond about six topics; the constellation shows the same links.
Per-document radar charts. Topic probabilities are mostly near zero, so the shapes all look alike.
Data: BBC News test split (SetFit/bbc-news), 1,000 articles · TriTopic 2.6.0, all-MiniLM-L6-v2 · labels by gpt-6-luna · reliability from 5 bootstrap refits
Technical documentation

How every part works, and why

The same content as docs/docs.md: the algorithm in detail, the configuration reference and the evidence behind each design decision.

How TriTopic works, why it is built this way, and how to use every part of it. For a quick start see the README, for task-by-task code the user guide; for release notes the CHANGELOG.


1. Design principles

  1. No single view is enough. Embeddings capture meaning but blur specific wording; TF-IDF captures wording but misses synonyms; metadata captures structure but not content. TriTopic fuses them in one graph. Removing the lexical view costs about 0.05 NMI on the development benchmarks.
  2. Stochastic clustering needs stabilising. One Leiden run depends on its seed. TriTopic runs it ten times and builds a consensus; across seeds the NMI spread is 0.014 (BERTopic: 0.105).
  3. Every document gets a topic. Graph clustering assigns every document; only clusters smaller than min_cluster_size become outliers, which is rare.
  4. Decide on development data, report on held-out data. Every default in 2.4 was chosen on separate development splits; the benchmark numbers come from untouched evaluation splits.

2. The pipeline step by step

model.fit(documents, embeddings=None, metadata=None):

Step What happens Stored as
1 Encode documents with a sentence-transformer (skipped if embeddings are passed) original_embeddings_
2 Reduce to 10 dimensions with UMAP (min_dist=0, cosine input) reduced_embeddings_, _dim_reducer
3 Tokenize once; derive the TF-IDF matrix from the counts lexical_matrix_
4 Encode metadata (optional) —
5 Build the fused graph; with n_topics="auto" scan resolutions graph_, resolution_, resolution_search_
6 Consensus Leiden + iterative refinement labels_
7 Keywords and representative documents per topic topics_
8 Centroids and soft probabilities topic_embeddings_, probabilities_
9 With n_topics=k: resolution search for k topics, merge down if needed —

Full-dimensional, unrefined embeddings are used for centroids, probabilities, transform(), outlier reduction and merging, so new documents are always compared in the same space.

3. Graph construction

Semantic graph (graph_type="hybrid", default). kNN on the reduced embeddings with Euclidean distance (reduced_metric). UMAP output is a Euclidean layout; cosine angles around its arbitrary origin would join clusters that happen to lie on the same ray. Similarities use a self-tuning Gaussian kernel, exp(-d² / (σᵢ σⱼ)) with σ the distance to the k-th neighbour, so dense and sparse regions get comparable weights. Without dimensionality reduction the metric is cosine on the full embeddings.

  • Mutual kNN: an edge only if both points are among each other's neighbours (removes noise bridges).
  • SNN: weight = shared neighbours / k (structural similarity), computed as a sparse product A·Aᵀ.
  • Hybrid: (1 - snn_weight) · mutual + snn_weight · SNN, each max-normalised.

Lexical graph. Mutual kNN with cosine similarity on TF-IDF (top 10,000 terms, sublinear TF, uni- and bigrams, stop words of language). It is built once per fit and cached.

Fusion. w_sem · semantic + w_lex · lexical + 0.1 · min(semantic, lexical); weights are renormalised over the active views. The last term rewards edges both views agree on.

Metadata. Columns are encoded per document (categorical: exact match, including strings and booleans; numerical and datetime: min-max scaled, similar if 1 - |Δ| > 0.8). The metadata similarity of an edge, averaged over columns, is added with weight metadata_weight to existing edges only. Connecting every pair of documents that share a category would add O(n²) edges and let metadata dominate the topics.

4. Consensus clustering

  1. Leiden (RBConfigurationVertexPartition, weighted) runs n_consensus_runs times with seeds random_state + i.
  2. For every graph edge, the agreement is the share of runs that put both ends in the same cluster (Lancichinetti & Fortunato, 2012).
  3. Edges with agreement below 50% are dropped (each node keeps its most consistent edge), the rest are weighted by agreement × original weight, and Leiden runs again on this consensus graph.
  4. Steps 2-3 repeat until all runs agree or the consensus graph stops changing (max. 5 rounds); the run with the highest mean ARI to the others is returned.
  5. Clusters smaller than min_cluster_size become outliers (-1).

Memory and time grow with the number of edges. Earlier versions built an all-pairs co-occurrence matrix, which needed several GB beyond ~20k documents. stability_score_ is the mean pairwise ARI of the initial runs.

5. Choosing the resolution

The Leiden resolution sets the granularity (higher = more topics).

n_topics="auto" (with auto_resolution=True, default): on the initial graph, one Leiden run per resolution in resolution_range (default 0.01-1.0, 15 log-spaced steps). For each partition, keywords are extracted and scored by mean NPMI from the cached document-term matrix. TriTopic keeps the coarsest resolution whose coherence is within auto_resolution_tolerance (5%) of the best, among partitions whose largest topic holds at most auto_resolution_max_share (50%) of the documents. Very coarse partitions score high NPMI on generic, frequently co-occurring words; the share guard prevents collapses to two topics.

On the development splits (3 seeds) this gave NMI 0.594 / ARI 0.474, against 0.565 / 0.415 for a fixed resolution. It still tends to find more topics than a corpus has classes.

n_topics=k: log-space bisection for a resolution with k clusters of at least min_cluster_size. The bracket widens (×4 steps) while the count still moves towards k; on very clean or very noisy graphs a fixed range silently missed the target. If the result overshoots, reduce_topics(k) merges down.

Fixed: auto_resolution=False uses resolution (default 0.3; Leiden's usual 1.0 over-segments kNN graphs badly, e.g. 60 topics for 6 newsgroup categories).

6. Iterative refinement

After clustering, the graph-building embeddings (the reduced ones) are pulled towards their topic centroid and the semantic graph is re-clustered:

refined = (1 - b) · x + b · centroid,    b = blend · 1 / (1 + (d / median_d)²)

blend decays from 0.3 to 0.1 over max_iterations; documents far from the centroid move less. The loop stops when the ARI between consecutive partitions reaches convergence_threshold (0.95). Refinement works in the reduced space directly; earlier versions re-projected the full embeddings through UMAP.transform() every iteration, which was slow and noisy. On the development benchmarks refinement does not hurt but has no clear effect either; it can be switched off with use_iterative_refinement=False.

7. Keywords

The corpus is tokenized once (CountVectorizer, uni- and bigrams, stop words of language, min_df=2, max_df=0.95). Tokens must start with two letters, so 000, __ or years never become keywords. The same counts feed the lexical view.

Coverage-weighted c-TF-IDF (keyword_method="ctfidf", default):

score(t) = IDF(t) · √( tf_share(t) · coverage(t) )
tf_share(t) = share of the topic's tokens that are t
coverage(t) = share of the topic's documents that contain t
IDF(t)      = log(N / (1 + df(t)))

Term frequency alone favours words repeated in a few long documents; coverage alone favours boilerplate ("thanks", "mail address"). The geometric mean raised NPMI coherence from 0.210 to 0.305 on the development splits at equal diversity. BERTopic's formula (tf · log(1 + A/f)) scored 0.193 in the same test.

BM25 ("bm25"): average Okapi BM25 weight of a term in the topic's documents relative to the corpus average, times log(1 + frequency). KeyBERT ("keybert"): embedding-based candidates with MMR.

All topics are scored in one sparse product; build_hierarchy() and divide() reuse the cached matrix.

8. Centroids, probabilities and new documents

  • topic_embeddings_: mean of the unrefined embeddings per topic.
  • probabilities_ (soft_assignment_method="centroid"): softmax(cos(x, centroids) · softmax_temperature) (temperature 5). With "graph", the topic distribution of a document's weighted graph neighbours.
  • transform(docs): encode, nearest centroid by cosine; below the outlier threshold → -1. By default (outlier_threshold=None) the threshold is calibrated on the training data: the 1st percentile of the training documents' similarity to their own centroid (model.outlier_threshold_). The fixed 0.35 used up to 2.4 rejected up to 40% of in-domain documents on the benchmark corpora, the calibrated rule about 1%. transform_proba(docs) returns the softmax distribution.

9. Post-fit operations

All operations refresh keywords, centroids and probabilities and reuse the cached token counts.

  • reduce_outliers(strategy): "embeddings" (nearest centroid above a threshold), "neighbors" (majority vote of the nearest non-outlier documents), "decisions" (an LLM picks the topic or "other", see §13).
  • reduce_topics(n, size_penalty=0.0): repeatedly merges the two topics with the most similar centroids. A size penalty (min/max size)^p is optional; the 0.3 used up to 2.4.0 merged small topics into large unrelated ones (reducing 2k → k topics: NMI 0.542 without vs. 0.591 with the fix on the evaluation splits). Graph-based agglomeration by modularity gain was tested and is worse.
  • merge_topics([ids]): merges the given topics into the largest one.
  • divide(topic_id, n_subtopics): re-clusters the topic's subgraph with a resolution search in both directions; the new topics get fresh ids.

10. Hierarchies and per-document analysis

build_hierarchy(n_levels=3) clusters the graph at resolutions spaced around resolution_ (/4 … ×4) and links each fine node to the coarse node holding most of its documents. TopicHierarchy.cut(depth), flatten(), get_node(id); visualize_hierarchy_tree() draws it.

get_document_topics(doc_idx, top_n) returns the top topics of one document (centroid or graph method). topic_overlap_matrix(threshold) counts how often two topics are both "active" (probability above the threshold) in the same document; visualize_overlap() shows it as a heatmap.

11. Languages

language sets the stop words for keywords, the lexical view and coherence: "english" (scikit-learn list), "german", "french", "spanish" (built-in lists) or "multilingual" (no stop words). With "multilingual" and the default embedding model, TriTopic switches to BAAI/bge-m3. The token pattern accepts letters of any alphabet.

12. LLM topic interpretation

tritopic.TopicInterpreter (OpenAI Responses API with a strict JSON schema; default model gpt-6-luna; needs OPENAI_API_KEY, no extra package).

Evidence per topic. The top 15 keywords; n_docs (8) example documents chosen by k-means on the topic's embeddings (the document nearest each cluster centre, largest cluster first), so a large sub-group gets several examples while the topic's spread is still covered; the three nearest topics for contrast; topic size and share; an optional domain_hint.

Answer (TopicInterpretation): label, description, aspects, verdict (coherent, mixed, unclear), sub_themes (name, description, supporting example documents), confidence, evidence. interpret(model) writes labels and descriptions into model.topics_ and stores all results in model.interpretations_.

refine(model) splits a topic with divide() when

  1. the verdict is mixed with confidence ≥ 0.6,
  2. at least two sub-themes are each backed by ≥ 2 example documents (one stray article does not make a topic mixed), and
  3. the acceptance rule holds (accept="consistency", default): the example documents of different sub-themes land in different new topics, each sub-theme's examples mostly in one. "coherence" (keyword NPMI of the new topics beats the original), "both" and "none" are available.

Rejected splits are undone; the new topics are interpreted. summarize(model) returns a short overview and a grouping of related topics (model.overview_).

Acceptance rules compared on identical LLM verdicts (10 dataset settings × 2 interpretation runs):

Rule Mean NMI gain Worst case
none (keep every split) +0.044 −0.017
consistency (default) +0.038 −0.001
coherence +0.033 −0.021
both +0.026 −0.021

With the default, refine() raised NMI from 0.614 to 0.658 on the development splits and from 0.606 to 0.639 on the held-out evaluation splits (BBC with 3 topics: 0.716 → 0.859).

Model choice (development splits, deliberately coarse topics, 28 topics of which 15 genuinely mixed):

Model Mixed topics found False alarms refine(): NMI Time per corpus
gpt-6-luna 9 5 0.614 → 0.654 29 s
gpt-5.5 8 9 0.614 → 0.638 41 s
gpt-5.4-mini 4 4 0.614 → 0.625 16 s

Two lessons from the development: farthest-point sampling of examples over-represented spread-out minorities (the BBC politics/business topic got one politics and six business examples), and keyword NPMI is a poor referee for splits (business keywords are naturally less coherent than political ones, so the correct split was rejected). Proportional sampling and the consistency check fixed both.

13. LLM judgements via the Decisions API

tritopic.integrations.decisions uses OpenAI's Decisions API (POST /v1/decisions, model gpt-6-luna, typed answers: predicate → probability, choice → one option, score → ordered levels). DecisionsClient runs requests in parallel, retries on 429/5xx and caches identical requests.

Function Question type What it does
word_intrusion(keywords, client) choice Top-5 keywords + one top keyword of another topic; which word does not belong? Returns accuracy and mean probability on the intruder (Chang et al., 2009).
rate_topics(keywords, client) score 0 (unrelated) … 3 (one clear, nameable theme), optionally with example documents.
assign_documents(model, docs, client) choice Assign documents to topics (label + keywords + 2 example snippets; with many topics the 10 nearest centroids are the candidates). allow_other=True adds an abstain option.
reduce_outliers(strategy="decisions") choice As above with the abstain option; abstaining keeps the outlier.
suggest_merges(model, client), apply_merges predicate Asks for the most similar topic pairs whether they describe the same theme.
intercoder_reliability(model, client, sample_size=200) choice The LLM codes a random sample like a second human coder (labels, descriptions, keywords only). Returns IntercoderResult: Cohen's kappa, agreement, per-topic precision/recall/F1, confusion matrix.

Measured on the evaluation splits: LLM topic rating 2.21 for TriTopic vs. 1.94 (BERTopic tuned) and 0.49 (default); assignment accuracy 0.657 vs. 0.648 for nearest-centroid, about 74% on the documents it does not abstain on; merge judgements separate same-class pairs better than centroid cosine (AUC 0.63-0.75 vs. 0.61-0.65) but are conservative, so merges rarely change NMI.

14. Evaluation metrics

model.evaluate():

  • coherence_mean, coherence_std: NPMI of each topic's keywords over the whole corpus (document co-occurrence; pairs that never co-occur count as -1; bigram keywords are matched with the keyword analyzer). Not comparable with versions before 2.4, which used only each topic's own documents.
  • diversity: unique share of all topics' keywords.
  • stability: mean pairwise ARI of the consensus runs.
  • n_topics, outlier_ratio.

Standalone in tritopic.utils.metrics: compute_coherence, compute_coherence_batch, coherence_from_doc_term, compute_diversity, compute_stability, compute_silhouette, compute_downstream_score.

15. Configuration reference

Parameter Default Meaning
language "english" Stop words; "multilingual" selects BAAI/bge-m3
embedding_model "all-MiniLM-L6-v2" sentence-transformers model
embedding_batch_size 32 Encoding batch size
use_dim_reduction True UMAP before graph building
reduced_dims 10 Target dimensions
dim_reduction_method "umap" or "pacmap"
umap_n_neighbors, umap_min_dist 15, 0.0 UMAP parameters
reduced_metric "euclidean" kNN metric on reduced embeddings
metric "cosine" kNN metric without reduction
n_neighbors 15 k of the kNN graphs
graph_type "hybrid" "knn", "mutual_knn", "snn", "hybrid"
snn_weight 0.5 SNN share in the hybrid graph
use_lexical_view, use_metadata_view True, False Views
semantic_weight, lexical_weight, metadata_weight 0.5, 0.3, 0.2 View weights (renormalised)
auto_resolution True Coherence-based resolution for n_topics="auto"
resolution_range None → (0.01, 1.0) Scan range
auto_resolution_steps 15 Resolutions scanned
auto_resolution_tolerance 0.05 Within 5% of the best coherence
auto_resolution_max_share 0.5 Skip partitions dominated by one topic
resolution 0.3 Start of the n_topics=k search; used if auto_resolution=False
n_consensus_runs 10 Leiden runs per consensus
min_cluster_size 5 Smaller clusters become outliers
use_iterative_refinement True Refinement loop
max_iterations, convergence_threshold 5, 0.95 Loop limits
n_keywords, n_representative_docs 10, 5 Per topic
keyword_method "ctfidf" or "bm25", "keybert"
soft_assignment_method "centroid" or "graph"
softmax_temperature 5.0 Sharpness of probabilities
outlier_threshold None transform() cut-off; None = calibrated (1st percentile of training similarities)
seed_anchors None Anchor documents per seed; None = 1% of the corpus, clipped to 5-30
seed_keyword_weight 0.5 Weight of the seed words vs. semantic similarity when picking anchors
seed_aware_resolution False Anchored resolution scan (tested worse, off)
random_state, verbose 42, True

16. Design decisions and the evidence behind them

Decision Evidence (development splits unless noted)
Leiden on a kNN graph instead of HDBSCAN 0% vs. 18% outliers; NMI 0.582 vs. 0.439 (evaluation splits)
Consensus of 10 runs NMI spread across seeds 0.014 vs. 0.105 for BERTopic
Lexical view on Without it NMI −0.05 (ablation)
Graph variants, UMAP dims, k = 30 Within seed noise of the defaults; defaults kept
Euclidean kernel on UMAP output Small but consistent gain over cosine; cosine is geometrically wrong there
Coverage-weighted c-TF-IDF NPMI 0.305 vs. 0.210 (old) and 0.193 (BERTopic formula)
Coherence-based auto resolution with share guard NMI 0.594 vs. 0.565 (fixed 0.3); worst run 0.390 vs. 0.041 without the guard
No size penalty in reduce_topics 2k → k: NMI 0.632 vs. 0.492 (dev), 0.591 vs. 0.542 (evaluation)
gpt-6-luna for interpretation Most mixed topics found, best refine() gain
Seeds as fixed Leiden memberships Full codebook: NMI 0.625 → 0.661, ARI 0.515 → 0.624 (dev); held-out ARI 0.399 → 0.498
Seed-aware resolution scan off The anchored scan chose worse partitions than the plain scan
Bootstrap reliability as default Spearman rho with purity 0.42 vs. 0.26 for the consensus variant
Exact accumulation curve for saturation Michaelis-Menten extrapolation was unstable (AG News: 69 estimated topics)
Calibrated transform() threshold About 1% vs. up to 40% rejected in-domain documents

All experiments are archived in benchmarks/experiments.

17. Performance

Fit times with pre-computed embeddings (one machine, sequential runs):

Corpus TriTopic 2.3 TriTopic 2.4 BERTopic tuned
20 Newsgroups, 2,000 short posts 35 s 7 s 6 s
arXiv, 2,000 papers (55k characters each) 102 s 23 s 50 s
20 Newsgroups, 17,900 posts 148 s 34-44 s —

The one-time UMAP fit dominates on short texts, tokenization on long ones. TopicInterpreter adds about 2-3 s per topic of API time (parallelised over 8 workers).

18. Seeded topics (codebook mode)

fit(documents, seeds={...}) starts from the topics you expect. A seed is either a one-sentence description ("Sport news: football, rugby, tennis ...") or a list of words (["football", "rugby"]).

  1. Anchors. The seed texts are embedded with the model's embedding model. Each document gets the score cos(document, seed), for word-list seeds plus seed_keyword_weight (0.5) × the share of the seed words present in the document. Every document competes for its best seed; the highest-scoring seed_anchors documents of each seed (default 1% of the corpus, clipped to 5-30) become its anchors. A seed that is no document's best match is ignored with a warning, which makes codebook categories that are absent from the data visible.
  2. Fixed memberships. In every Leiden run (consensus, refinement, resolution search) the anchors start in one community per seed and are passed to Leiden as is_membership_fixed. All other documents move freely: they join a seeded community or form new ones.
  3. Naming. A topic whose anchors mostly come from one seed gets TopicInfo.seed and, until an LLM relabels it, label = seed name. seed_topics_ and emergent_topics_ split the result; get_topic_info() has a Seed column; seed_anchors_ lists the anchor documents. Seeds survive save()/load().

With pre-computed document embeddings, pass seed_embeddings (one vector per seed, same model) or let TriTopic encode the seeds with embedding_model; a dimension mismatch raises an error.

Results: full codebook on the dev splits NMI 0.625 → 0.661, ARI 0.515 → 0.624; held-out ARI 0.399 → 0.498; 81% of the anchors belong to the seed's true class; with half of the classes seeded ARI +17%. BBC demo: NMI 0.742 → 0.801, ARI 0.631 → 0.800 with five one-line seeds. Limitation: with a partial codebook a seeded topic can absorb an unseeded neighbouring theme (BBC with three seeds: business and politics ended up in one emergent topic). Seed every theme you know, or run TopicInterpreter.refine() afterwards.

19. Research toolkit

tritopic.research turns a fitted model into the numbers and material a paper needs. Nothing here calls an LLM unless marked.

Function Returns How
topic_reliability(model, method="bootstrap", n_boot=5, sample_frac=0.8) topic, label, size, reliability, core_share, reliable; sets model.document_stability_ Refits on random 80% samples and matches each topic to its best-overlapping refit topic (Jaccard; Greene et al., 2014). method="consensus" reuses the Leiden runs: instant but weaker. Topics ≥ 0.7 were 77% pure, topics < 0.5 only 54% (4 corpora).
saturation_curve(model, fractions=None, method="accumulation", min_docs=None) SaturationResult: summary, point_95, point_all, novelty, late_topics, saturated Expected share of topics with at least min_docs documents in a random subsample, exact via the hypergeometric distribution: a topic accumulation curve. method="refit" refits on subsamples instead. Benchmarks: 95% of the topics visible after 25-35% of the data, all after 45-65%. plot_saturation(result)
bridge_documents(model, top_n=20, min_share=0.25) doc, topic, other_topic, own_share, bridge_score, text Share of a document's nearest-neighbour weight (directed kNN in embedding space) that points into another topic.
topic_connections(model) topic_a, topic_b, strength, bridge_docs W_ab / sqrt(W_a · W_b) of the cross-topic neighbour weight.
topic_prevalence(model, groups=None, ci=0.95, method="wilson") topic, label, group, n, count, share, ci_low, ci_high Wilson score interval, or method="bootstrap".
compare_groups(model, groups, alpha=0.05) share[<group>] columns, difference, odds_ratio, cramers_v, p_value, p_adjusted, significant χ² test per topic (Fisher's exact test for 2 groups with small counts), Benjamini-Hochberg across topics.
distinctive_keywords(model, groups, group_a, group_b, topic_id=None, n=10) word, z, typical_for Log-odds ratio with informative Dirichlet prior ("Fightin' Words", Monroe et al., 2008), overall or within one topic.
topic_evolution(model, timestamps, freq="Q", link_threshold=0.75, resolution=None) TopicEvolution: nodes (with docs), links, events, lineage(topic) Builds a kNN graph per period (same reduced layout and lexical view as the model) and clusters it at 4× the model's resolution, so events inside a broad global topic become visible; links topics of consecutive periods by centroid cosine and labels birth, continuation, split, merge, death. HuffPost news 2012-2022: COVID (2020) and the war in Ukraine (2022) found as births at every tested sample size; the 2.5.0 variant (subgraph of the global graph) kept 5 of 28 edges per document and produced mostly noise. plot_evolution(evo) draws a Sankey diagram.
topic_quotes(model, topic_id=None, n=3) topic, rank, quote, doc, score Sentences from the topic's most central documents, ranked by similarity to the topic plus a keyword bonus; one quote per document, near-duplicates skipped. Works on lower-cased text.
methods_report(model, corpus="the corpus", fmt="markdown") text Methods paragraph and parameter table written from the fitted model (embedding model, views, resolution choice, quality metrics, software versions). Reproducible, no LLM.
TopicInterpreter.codebook(model, n_quotes=3) (LLM) topic, size, name, definition, inclusion, exclusion, coding_notes, anchor_examples One codebook entry per topic for qualitative content analysis (Mayring); exclusion criteria name the neighbouring category; anchors are real quotes. Stored as model.codebook_.
intercoder_reliability(model, client) (LLM) IntercoderResult See §13. BBC demo: kappa 0.94 on 150 articles.

Structure analyses (new in 2.6)

Function Returns How
view_composition(model) per topic and document: links sem_only, lex_only, both and shares Semantic kNN graph on the unrefined embeddings vs. lexical mutual-kNN graph, restricted to links inside each topic.
resolution_ladder(model, multipliers=(0.25, 0.5, 1, 3, 9)) ResolutionLadder(levels, flows, labels) Consensus Leiden on the fitted graph at multiples of resolution_; flows = document counts between clusters of consecutive levels.
topic_cores(model) core_rank, similarity, angle, stability, bridge_to, bridge_share per document Rank of cosine similarity to the topic centroid; angle from the topic's first two principal components.
keyword_coverage(model, topic_id=None) coverage, outside, presence per keyword Document presence from the shared count matrix, documents ordered core → edge.
coassignment(model, sample_size=240, n_refits=10) CoassignmentResult(matrix, docs, topics, n_refits) Refits on 80% samples with the same settings and topic count; share of refits that keep two documents together (over refits containing both).
topic_discovery(model) per topic f50, f95; accumulation curve Hypergeometric probability that ≥ min_cluster_size documents of a topic are in a random fraction of the corpus.
codebook_coverage(model) size, share and seed per topic From TopicInfo.seed.

20. Research charts

tritopic.visualization.charts draws every analysis above as a Plotly figure. Topics are coloured by size rank with a fixed eight-colour palette (further topics grey), so colours stay the same across charts.

Function Chart Data behind it
plot_topic_table(model, reliability=rel) Periodic table of topics: symbol, size, share, reliability, NPMI get_topic_info(), topic_reliability()
plot_triview(model) Tri-View triangle: links inside each topic by view (meaning only / wording only / both) view_composition()
plot_resolution_ladder(model) Zoom ladder: Sankey of the clusters at five resolutions resolution_ladder()
plot_topic_onions(model) Core and edge of every topic, coloured by stability, bridges ringed topic_cores()
plot_keyword_barcode(model, topic_id) Which documents (core → edge) contain each keyword keyword_coverage()
plot_constellation(model) Topic network: similarity layout, links with bridge counts topic_connections()
plot_coassignment(model) Fuzzy borders: how often two documents share a topic across refits coassignment()
plot_group_tilt(model, groups) Topic shares per group with CIs, Cramér's V, significance topic_prevalence(), compare_groups()
plot_trust(model, rel) Size against reliability with the 0.7 / 0.5 guides topic_reliability()
plot_composition(model, reference) Reference categories inside each topic labels you supply
plot_birth_timeline(evolution) New and ending topics per period topic_evolution()
plot_codebook_coverage({name: model}) Seeded vs. emerged share of the corpus codebook_coverage()
plot_quote_wall(model) The most telling sentence per topic topic_quotes()
plot_verdict_board(results, refine_log) LLM verdicts (coherent / mixed / unclear) with evidence TopicInterpreter.interpret()
plot_coder_confusion(ic, model) Where the LLM second coder disagrees intercoder_reliability()
plot_topic_discovery(model) Saturation curve plus when each topic becomes visible topic_discovery()
Changelog

Release notes

2.6.0 (October 2026)

New

  • tritopic.visualization.charts: sixteen research charts (Plotly) — topic table, Tri-View triangle, zoom ladder, topic onions, keyword barcode, constellation, fuzzy borders, group tilt, trust quadrant, composition, birth timeline, codebook coverage, quote wall, LLM verdict board, coder confusion grid, saturation with topic dots.
  • Structure analyses in tritopic.research: view_composition(), resolution_ladder(), topic_cores(), keyword_coverage(), coassignment(), topic_discovery(), codebook_coverage().
  • Website docs/index.html: how it works, user guide, all examples with code, live BBC results, the chart gallery with code for every chart, technical documentation, benchmarks.
  • Example 13_research_charts.py; docs/algorithm_visual_brief.md (brief for an algorithm illustration).

Fixed

  • BBC demo: topic labels in the prevalence chart were cut off; the chart now wraps long labels.

2.5.1 (October 2026)

Changed

  • topic_evolution() tested on real dated news and redesigned. On HuffPost news 2012-2022 (16,400 articles) the 2.5.0 version, which clustered each period's part of the global graph, kept only about 5 of 28 edges per document and produced mostly noise (70% of period topics "born"). Each period now gets its own kNN graph in the model's layout, clustered at 4× the model's resolution (resolution= to override), and link_threshold defaults to 0.75. Result: the COVID pandemic (2020, continuing as vaccines in 2021) and the war in Ukraine (2022) are detected as births at every tested sample size, together with events such as the Flint water crisis, the Hong Kong protests and Roe v. Wade.
  • TopicEvolution.nodes has a docs column (document indices); events carry keywords and size; mismatched timestamp counts raise an error.
  • Example 12_topic_evolution_news.py on real dated news.

2.5.0 (October 2026)

Codebook mode and a research toolkit: start from the topics you expect, and get the reliability, saturation, group-comparison and reporting outputs a paper needs.

New

  • Seeded topics (codebook mode): fit(documents, seeds={"name": "description" | [words]}). The documents that match a seed best are pinned to one Leiden community per seed (is_membership_fixed); all other documents join a seeded topic or form emergent ones. TopicInfo.seed, seed_topics_, emergent_topics_, seed_anchors_, a Seed column in get_topic_info(); seed_embeddings for pre-computed embeddings; config seed_anchors, seed_keyword_weight. Full codebook on the dev splits: NMI 0.625 → 0.661, ARI 0.515 → 0.624; held-out ARI 0.399 → 0.498.
  • tritopic.research:
  • topic_reliability(): bootstrap refits (default) or consensus runs; reliability and core share per topic, document_stability_ per document. Reliable topics (≥ 0.7) were 77% pure vs. 54% below 0.5.
  • saturation_curve() / plot_saturation(): exact topic accumulation curve (hypergeometric), saturation points for 95% and 100% of the topics, optional refit method.
  • bridge_documents(), topic_connections(): documents and links between topics.
  • topic_prevalence() with Wilson or bootstrap intervals; compare_groups() with χ²/Fisher tests, Cramér's V, odds ratios and Benjamini-Hochberg correction; distinctive_keywords() (log-odds with informative prior).
  • topic_evolution() / plot_evolution(): per-period topics linked into births, splits, merges, deaths.
  • topic_quotes(): citable sentences per topic.
  • methods_report(): methods paragraph and parameter table from the fitted model.
  • TopicInterpreter.codebook(): codebook entries for qualitative content analysis (definition, inclusion/exclusion criteria, coding notes) with real anchor quotes.
  • intercoder_reliability() (Decisions API): an LLM as independent second coder; Cohen's kappa, per-topic precision/recall/F1, confusion matrix. BBC demo: kappa 0.94.
  • Examples 09_seeded_topics.py, 10_research_toolkit.py, 11_codebook_and_second_coder.py; user guide docs/user_guide.md; BBC demo extended with all new outputs.

Changed

  • transform() outlier threshold is calibrated on the training data by default (outlier_threshold=None → 1st percentile of the training similarities, model.outlier_threshold_). The fixed 0.35 rejected up to 40% of in-domain documents; the calibrated rule about 1%.

Fixed

  • fit(verbose=True) with automatic resolution crashed when printing the scan summary.

2.4.0 (October 2026)

Head-to-head with BERTopic on identical embeddings: NMI 0.582 vs. 0.439, ARI 0.440 vs. 0.285, keyword NPMI 0.262 vs. 0.117, 0% vs. 18% outliers (4 datasets × 5 topic counts × 3 seeds). See the README benchmarks.

New

  • TopicInterpreter (tritopic.labeling): an LLM reads each topic (keywords, proportionally sampled example documents, neighbouring topics) and returns label, description, aspects and a verdict (coherent / mixed / unclear) with sub-themes. refine() splits mixed topics when the graph split agrees with the LLM's sub-themes; summarize() writes an overview. OpenAI Responses API with a strict JSON schema, default model gpt-6-luna.
  • OpenAI Decisions API integration (tritopic.integrations.decisions, optional): LLM word-intrusion test and topic ratings, assign_documents(), reduce_outliers(strategy="decisions"), merge suggestions.
  • Automatic topic count: n_topics="auto" picks the Leiden resolution by keyword coherence, skipping partitions dominated by one topic (auto_resolution, resolution_range, auto_resolution_steps, auto_resolution_tolerance, auto_resolution_max_share; results in resolution_, resolution_search_).
  • Coverage-weighted c-TF-IDF: IDF · √(tf_share · coverage), +45% NPMI on the dev splits.
  • Benchmark harness benchmarks/compare_bertopic.py, llm_eval.py, summarize.py, results_to_markdown.py; decision log in benchmarks/experiments/.
  • BBC demo page in examples/bbc_demo/.

Performance (fit 4.5x faster than 2.3 at equal NMI)

  • Iterative refinement works on the reduced embeddings instead of calling UMAP.transform() each iteration.
  • Consensus clustering on graph edges (O(edges)) instead of an all-pairs co-occurrence matrix.
  • The corpus is tokenized once; the lexical TF-IDF view is derived from the keyword counts (arXiv, 55k characters per document: 44 s → 22 s).
  • Batched keyword extraction; build_hierarchy() and divide() no longer re-tokenize per topic; vectorized coherence and topic_overlap_matrix(); lexical kNN graph cached.

Fixes

  • kNN graph on UMAP output used cosine distance; now Euclidean with a self-tuning kernel (reduced_metric). FAISS L2 distances are square-rooted.
  • BM25 keywords scored the first n corpus documents instead of the topic's documents.
  • Metadata connected every pair of documents sharing a category (millions of edges); it now reweights existing edges. String columns no longer fail with pandas 3.
  • reduce_topics() applied a size penalty that merged small topics into large unrelated ones (2k → k: NMI 0.542 → 0.591); available as size_penalty=0.3.
  • n_topics=k and divide() counted clusters below min_cluster_size and searched a fixed range; the search now widens until k is reachable and searches both directions.
  • NPMI coherence used only each topic's own documents as reference and skipped bigram keywords; it now uses the whole corpus. Values are not comparable with earlier versions.
  • Tokens must start with two letters (no 000, __, years as keywords).
  • reduce_outliers(), reduce_topics() and representative documents use the same unrefined embedding space as the topic centroids.

Changed defaults

  • resolution 1.0 → 0.3 (used for n_topics=k and auto_resolution=False).
  • n_topics="auto" uses the coherence-based resolution search.

2.3.0 (February 2026)

Cross-lingual stop words (language=), automatic BAAI/bge-m3 for language="multilingual", hierarchical topics (build_hierarchy(), divide(), visualize_hierarchy_tree()), per-document topics (get_document_topics(), topic_overlap_matrix(), visualize_overlap()), graph-based soft assignment, turftopic adapter.

2.2.x

Dimensionality reduction before graph building, soft topic probabilities, reduce_outliers(), reduce_topics(), merge_topics(), save/load of the full model state.

Cite and licence

Use TriTopic in your work

Citation

@software{tritopic2026,
  author = {Egger, Roman},
  title  = {TriTopic: Tri-Modal Graph Topic Modeling},
  year   = {2026},
  url    = {https://github.com/SmartVisions-AI/tritopic}
}

License

MIT, see LICENSE. Issues and pull requests are welcome. Maintained by SmartVisions.

Comparison

TriTopic and BERTopic

BERTopic is the most widely used neural topic model. Both tools were run with identical embeddings on four datasets, five topic counts each, three random seeds.

0.582NMI against the true classes (BERTopic 0.439)
0%documents left without a topic (BERTopic 18.4%)
0.262keyword coherence, NPMI (BERTopic 0.117)
0.014NMI spread across random seeds (BERTopic 0.105)

Head-to-head with BERTopic 0.17.4 on the datasets of the original TriTopic benchmark: 20 Newsgroups (2,000 docs), BBC News (1,225), AG News (2,000), arXiv (2,000); five topic counts per dataset plus automatic mode; seeds 42, 123, 456.

  • Same embeddings for every model: all-MiniLM-L6-v2, pre-computed once per dataset.
  • Two BERTopic variants: defaults, and "tuned" with BERTopic's keyword best practice (CountVectorizer(stop_words="english", ngram_range=(1, 2), min_df=2), ClassTfidfTransformer(reduce_frequent_words=True)); UMAP seed fixed.
  • No tuning on the evaluation data: every 2.4 design decision was made on separate development splits (20NG test, BBC test, AG News train sample, arXiv validation).
  • Metrics: NMI/ARI on all documents (BERTopic outliers form one class); strict NPMI over the whole corpus; fit time with pre-computed embeddings on one machine.
Fixed topic count
Model NMI ARI NPMI Diversity Outliers Fit time (s)
TriTopic 2.4 0.582 0.440 0.262 0.94 0% 9.5
TriTopic 2.3 0.581 0.436 0.212 0.92 0% 44.3
BERTopic (tuned) 0.439 0.285 0.117 0.89 18.4% 16.6
BERTopic (default) 0.439 0.285 0.048 0.44 18.4% 9.9
Automatic topic count
Model NMI ARI NPMI Outliers Topics found
TriTopic 2.4 0.560 0.398 0.299 0% 21.4
TriTopic 2.3 0.528 0.302 0.200 0% 30.0
BERTopic (tuned) 0.443 0.247 0.123 18.4% 26.8
BERTopic (default) 0.443 0.247 0.109 18.4% 26.8
NMI per dataset (fixed k)
Dataset TriTopic 2.4 BERTopic
20 Newsgroups 0.529 0.264
AG News 0.526 0.377
arXiv 0.568 0.450
BBC News 0.702 0.663

Where TriTopic does not lead: on BBC News, BERTopic has the higher ARI for k = 5-20 (NMI is close). On short texts BERTopic is 1-2 s per run faster; on long documents (arXiv) TriTopic is about twice as fast (23 s vs. 50 s). BERTopic collapsed to 4 topics on 20 Newsgroups for two of three seeds, which is part of its lower average there.

Earlier releases quoted mean NMI 0.575 vs. 0.513 for BERTopic. That benchmark gave BERTopic stronger embeddings (all-mpnet-base-v2) than TriTopic and used a lenient coherence measure; the numbers above supersede it.

Reproduce:

pip install "tritopic[benchmark]"
python benchmarks/compare_bertopic.py --pkg . --models tritopic,bertopic,bertopic_tuned --out results.csv
python benchmarks/summarize.py results.csv
python benchmarks/llm_eval.py --pkg . --out llm.csv     # LLM judgements; needs OPENAI_API_KEY, --dry-run estimates cost

The decisions behind 2.4 (keyword scoring, auto resolution, ablations, LLM experiments) are documented in benchmarks/experiments/README.md. Release notes: CHANGELOG.md.