Skip to content

Commit c6ef597

Browse files
committed
Restore Name prefix, Representation list, and top_n_words, and seed representative docs
1 parent 2a75efe commit c6ef597

6 files changed

Lines changed: 128 additions & 9 deletions

File tree

‎bertopic/_bertopic.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2869,6 +2869,13 @@ def _extract_words_per_topic(
28692869
c_tf_idf=c_tf_idf,
28702870
name="Main",
28712871
)
2872+
else:
2873+
# Without a representation model the top c-TF-IDF words are the representation,
2874+
# so cut the extra candidates back to the `top_n_words` that were asked for
2875+
topic_representations = {
2876+
topic_id: Keywords(keywords.data[: self.top_n_words])
2877+
for topic_id, keywords in topic_representations.items()
2878+
}
28722879

28732880
# Extract topic representations other than the Main one (called aspects)
28742881
if calculate_aspects and isinstance(self.representation_model, dict):

‎bertopic/_corpus.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,9 +258,12 @@ def get_topic(self, topic_id: int, nr_samples: int | None = None) -> "Corpus":
258258
filtered_docs = [doc for doc, topic in zip(self.documents, self.topics) if topic == topic_id]
259259
filtered_indices = [i for i, topic in enumerate(self.topics) if topic == topic_id]
260260

261-
# Sample documents if nr_samples is specified
261+
# Sample documents if nr_samples is specified, seeded so that representative
262+
# documents — and any LLM representation built from them — repeat across runs
262263
if nr_samples is not None and len(filtered_docs) > nr_samples:
263-
sampled_indices = np.random.choice(len(filtered_docs), size=nr_samples, replace=False)
264+
sampled_indices = np.random.default_rng(42).choice(
265+
len(filtered_docs), size=nr_samples, replace=False
266+
)
264267
filtered_docs = [filtered_docs[i] for i in sampled_indices]
265268
filtered_indices = [filtered_indices[i] for i in sampled_indices]
266269

‎bertopic/_topics.py‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,15 @@ def label(self) -> str:
345345
else:
346346
return "No label could be created"
347347

348+
@property
349+
def name(self) -> str:
350+
"""How the topic is named in `get_topic_info` and `topic_labels_`.
351+
352+
The topic's ID followed by its label, which is what makes two topics with similar
353+
keywords tellable apart. A label the user set is returned as it was given.
354+
"""
355+
return self._label if self._label is not None else f"{self.id}_{self.label}"
356+
348357
def __getitem__(self, source: str) -> TopicRepresentation:
349358
"""Get representation for a specific source, or default if not found."""
350359
return self.representations.get(source, TopicRepresentation())
@@ -355,17 +364,15 @@ def __setitem__(self, source: str, rep: TopicRepresentation):
355364

356365
def to_info_dict(self) -> dict:
357366
"""Serialize topic info to a flat dictionary for tabular output."""
358-
info = {"Topic": self.id, "Count": self.nr_documents, "Name": self.label}
359-
info["Representation"] = str(self.representations.get("Main"))
367+
info = {"Topic": self.id, "Count": self.nr_documents, "Name": self.name}
368+
info["Representation"] = self["Main"].words
360369

361370
# Extract all other representations
362371
for name, rep in self.representations.items():
363372
if name != "Main":
364-
info[name] = str(rep)
373+
info[name] = rep.words
365374

366-
# Representative documents and images + truncate documents for readability
367-
documents = self.representative_documents or [""]
368-
info["Representative_Docs"] = [document[:80] for document in documents]
375+
info["Representative_Docs"] = self.representative_documents or [""]
369376

370377
return info
371378

@@ -479,7 +486,7 @@ def frequencies(self) -> dict[int, int]:
479486
@property
480487
def labels(self) -> dict[int, str]:
481488
"""Get labels for all topics."""
482-
labels = {topic.id: topic.label if topic.label is not None else "" for topic in self.topics.values()}
489+
labels = {topic.id: topic.name if topic.name is not None else "" for topic in self.topics.values()}
483490
return dict(sorted(labels.items()))
484491

485492
@property

‎tests/test_corpus.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,3 +271,27 @@ def test_recombining_a_split_corpus_keeps_documents_distinct():
271271
combined = first + second
272272

273273
assert combined.nr_documents == 6
274+
275+
276+
def test_candidate_sampling_is_repeatable():
277+
"""Representative documents feed LLM representations, so they must not drift.
278+
279+
The sampling was unseeded, so every fit chose different candidates and any label
280+
built from them changed run to run. `random_state` on UMAP does not reach here.
281+
"""
282+
corpus = Corpus(
283+
documents=[f"document {index}" for index in range(1000)],
284+
topics=np.array([0] * 1000),
285+
embeddings=np.zeros((1000, 4)),
286+
)
287+
288+
runs = [corpus.get_topic(0, nr_samples=100).documents for _ in range(3)]
289+
290+
assert runs[0] == runs[1] == runs[2]
291+
292+
293+
def test_sampling_only_kicks_in_above_the_limit():
294+
"""A topic smaller than `nr_samples` keeps every document, in order."""
295+
corpus = Corpus(documents=["a", "b", "c"], topics=np.array([0, 0, 0]))
296+
297+
assert corpus.get_topic(0, nr_samples=100).documents == ["a", "b", "c"]

‎tests/test_invariants.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,20 @@ def test_reduction_fixtures_have_more_topics_than_they_are_reduced_to(model, req
160160
f"{model} produced only {nr_topics} topics; the reduction tests reduce to 10 "
161161
"and need more than that to exercise a real reduction"
162162
)
163+
164+
165+
@pytest.mark.parametrize("model", ALL_MODEL_FIXTURES)
166+
def test_topic_names_carry_their_id(model, request):
167+
"""A generated name is `{topic}_{words}`, which tells similar topics apart.
168+
169+
Names the user chose are left alone, which is why zero-shot topics read as `cars`
170+
rather than `0_cars` — 0.17 overwrote the generated label for those too. The prefix
171+
was lost during the refactor and nothing noticed, because no test read the shape of
172+
the column.
173+
"""
174+
topic_model = request.getfixturevalue(model)
175+
176+
generated = [topic for topic in topic_model._topics if topic._label is None]
177+
assert generated, "every topic was given a label, so nothing exercises the prefix"
178+
for topic in generated:
179+
assert topic.name.startswith(f"{topic.id}_")

‎tests/test_topics.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,3 +579,64 @@ def test_topics_keeps_rows_and_probabilities_consistent_through_a_merge():
579579
assert set(topics.predictions) <= set(topics.topic_ids())
580580
assert topics.probabilities.shape == (19, len(topics.topic_ids()))
581581
assert topics.probabilities[0].sum() == pytest.approx(1.0)
582+
583+
584+
# --------------------------------------------------------------------------------------
585+
# get_topic_info columns
586+
# --------------------------------------------------------------------------------------
587+
588+
589+
def test_representation_is_the_whole_word_list():
590+
"""0.17 returned every word; a `str(...)` here silently truncated to the top five."""
591+
topics = build_topics({0: 4})
592+
words = [(f"word{index}", 1.0 - index / 100) for index in range(10)]
593+
topics.set_data(representations={"Main": {0: Keywords(data=words)}})
594+
595+
info = topics[0].to_info_dict()
596+
597+
assert info["Representation"] == [f"word{index}" for index in range(10)]
598+
599+
600+
def test_aspect_columns_are_word_lists_too():
601+
"""An aspect had the same bug, and `str(rep)` was never meaningful in a table."""
602+
topics = build_topics({0: 4})
603+
topics.set_data(representations={"Main": {0: Keywords(data=[("cat", 0.8)])}})
604+
topics.set_data(representations={"Visual": {0: Images(data="collage", captions=["a cat"])}})
605+
606+
info = topics[0].to_info_dict()
607+
608+
assert info["Visual"] == ["a cat"]
609+
610+
611+
def test_name_carries_the_topic_id():
612+
"""What tells two topics with similar keywords apart, and what 0.17's `Name` had."""
613+
topics = build_topics({0: 4})
614+
topics.set_data(representations={"Main": {0: Keywords(data=[("cat", 0.9), ("dog", 0.8)])}})
615+
616+
assert topics[0].name == "0_cat_dog"
617+
assert topics[0].to_info_dict()["Name"] == "0_cat_dog"
618+
619+
620+
def test_a_user_label_is_never_prefixed():
621+
"""`set_topic_labels("Politics")` should not become `0_Politics`."""
622+
topics = build_topics({0: 4})
623+
topics[0]._label = "Politics"
624+
625+
assert topics[0].name == "Politics"
626+
627+
628+
def test_label_stays_unprefixed():
629+
"""`TopicHierarchy` names its nodes from `label`, which 0.17 left unprefixed."""
630+
topics = build_topics({0: 4})
631+
topics.set_data(representations={"Main": {0: Keywords(data=[("cat", 0.9), ("dog", 0.8)])}})
632+
633+
assert topics[0].label == "cat_dog"
634+
635+
636+
def test_representative_documents_are_not_truncated():
637+
"""`get_topic_info` is a data accessor; the display layer already elides long text."""
638+
topics = build_topics({0: 4})
639+
long_document = "word " * 200
640+
topics.set_data(representative_documents={0: [long_document]})
641+
642+
assert topics[0].to_info_dict()["Representative_Docs"] == [long_document]

0 commit comments

Comments
 (0)