Skip to content

Commit f2d6cda

Browse files
committed
Fix regressions
1 parent 111a4a6 commit f2d6cda

8 files changed

Lines changed: 123 additions & 26 deletions

File tree

‎bertopic/_bertopic.py‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,7 @@ def transform(
671671
# Transform with full pipeline
672672
else:
673673
logger.info("Dimensionality - Reducing dimensionality of input embeddings.")
674-
corpus.umap_embeddings = self.umap_model.transform(corpus.embeddings)
674+
corpus.umap_embeddings = self.umap_model.transform(corpus.embeddings).astype(np.float32)
675675
logger.info("Dimensionality - Completed \u2713")
676676

677677
# Extract predictions and probabilities if it is a HDBSCAN-like model
@@ -689,7 +689,6 @@ def transform(
689689
)
690690
logger.info("Probabilities - Completed \u2713")
691691
else:
692-
corpus.umap_embeddings = corpus.umap_embeddings.astype(np.float32) # Fixes #2097
693692
predictions = self.hdbscan_model.predict(corpus.umap_embeddings)
694693
probabilities = None
695694
logger.info("Cluster - Completed \u2713")
@@ -790,7 +789,6 @@ def partial_fit(
790789

791790
# Reduce dimensionality and cluster
792791
corpus = self._reduce_dimensionality(corpus, partial_fit=True)
793-
corpus.umap_embeddings = corpus.umap_embeddings.astype(np.float32) # Fixes #2097
794792
corpus = self._cluster_embeddings(corpus, partial_fit=True)
795793

796794
# Use cluster labels directly as topic IDs
@@ -2583,6 +2581,7 @@ def _cluster_embeddings(self, corpus: Corpus, partial_fit: bool = False) -> Corp
25832581
return corpus
25842582

25852583
logger.info("Cluster - Start clustering the reduced embeddings")
2584+
corpus.umap_embeddings = corpus.umap_embeddings.astype(np.float32, copy=False)
25862585

25872586
# Fit cluster model
25882587
if partial_fit:

‎bertopic/_corpus.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,14 @@ def __len__(self):
409409

410410
def _validate_length(self, name: str, value) -> None:
411411
"""Checks that the length of the value matches the number of documents."""
412-
if value is not None and len(value) != len(self.documents):
412+
if value is None:
413+
return
414+
415+
# Sparse embeddings have a number of rows, but no len()
416+
nr_rows = value.shape[0] if hasattr(value, "shape") else len(value)
417+
if nr_rows != len(self.documents):
413418
raise ValueError(
414-
f"Length of {name} ({len(value)}) does not match number of documents ({len(self.documents)})"
419+
f"Length of {name} ({nr_rows}) does not match number of documents ({len(self.documents)})"
415420
)
416421

417422
def __setattr__(self, name: str, value) -> None:

‎bertopic/variations/_guided.py‎

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
import numpy as np
66
from sklearn.metrics.pairwise import cosine_similarity
77
from bertopic._corpus import Corpus
8+
from bertopic._utils import MyLogger
89

910

1011
from typing import TYPE_CHECKING
1112

1213
if TYPE_CHECKING:
1314
from bertopic import BERTopic
1415

16+
logger = MyLogger()
17+
1518

1619
def guided_tm(topic_model: "BERTopic", corpus: Corpus) -> Corpus:
1720
"""Apply Guided Topic Modeling.
@@ -24,20 +27,19 @@ def guided_tm(topic_model: "BERTopic", corpus: Corpus) -> Corpus:
2427
one of the topics than the average document.
2528
2629
If a document is more similar to the average document
27-
than any of the topics, it gets the -1 label and is
28-
thereby not included in UMAP.
30+
than any of the topics, it gets the -1 label, which
31+
UMAP treats as unlabeled.
2932
3033
Arguments:
3134
topic_model: The BERTopic instance
3235
corpus: A Corpus object containing documents and their embeddings
3336
3437
Returns:
35-
y: The labels for each seeded topic
36-
embeddings: Updated embeddings
38+
corpus: The corpus with the seed labels as `y` and the updated embeddings
3739
"""
3840
if topic_model.seed_topic_list is not None and topic_model.embedding_model is not None:
39-
topic_model.logger.info("Guided - Find embeddings highly related to seeded topics.")
40-
embeddings = corpus.embeddings
41+
logger.info("Guided - Find embeddings highly related to seeded topics.")
42+
embeddings = corpus.embeddings.copy()
4143

4244
# Create embeddings from the seeded topics
4345
seed_topic_list = [" ".join(seed_topic) for seed_topic in topic_model.seed_topic_list]
@@ -57,9 +59,9 @@ def guided_tm(topic_model: "BERTopic", corpus: Corpus) -> Corpus:
5759
indices = [index for index, topic in enumerate(y) if topic == seed_topic]
5860
embeddings[indices] = embeddings[indices] * 0.75 + seed_topic_embeddings[seed_topic] * 0.25
5961

60-
# Update docs
61-
corpus.y = np.array(corpus.y) if corpus.y is not None else None
62+
# The seed labels guide dimensionality reduction
63+
corpus.y = np.array(y)
6264
corpus.embeddings = embeddings
63-
topic_model.logger.info("Guided - Completed \u2713")
65+
logger.info("Guided - Completed \u2713")
6466

6567
return corpus

‎bertopic/variations/_zeroshot.py‎

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
logger.configure("WARNING")
1919

2020

21-
def zeroshot_tm(topic_model: "BERTopic", corpus: Corpus) -> Corpus:
21+
def zeroshot_tm(topic_model: "BERTopic", corpus: Corpus) -> tuple[Corpus, Corpus]:
2222
"""Find documents that could be assigned to either one of the topics in topic_model.zeroshot_topic_list.
2323
2424
We transform the topics in `topic_model.zeroshot_topic_list` to embeddings and
@@ -30,8 +30,8 @@ def zeroshot_tm(topic_model: "BERTopic", corpus: Corpus) -> Corpus:
3030
corpus: The documents and their embeddings
3131
3232
Returns:
33-
zeroshot_data: Documents containing documents assigned to zero-shot topics
3433
cluster_data: Documents containing documents to be clustered
34+
zeroshot_data: Documents containing documents assigned to zero-shot topics
3535
"""
3636
zeroshot_data = Corpus()
3737
if not topic_model._is_zeroshot():
@@ -58,8 +58,8 @@ def zeroshot_tm(topic_model: "BERTopic", corpus: Corpus) -> Corpus:
5858
zeroshot_data = Corpus()
5959
if len(assigned_ids) > 0:
6060
zeroshot_documents = corpus.get_documents_by_indices(assigned_ids)
61-
zeroshot_topics = [topic for topic in assignment[assigned_ids]]
62-
zeroshot_labels = [topic_model.zeroshot_topic_list[i] for i in sorted(list(set(zeroshot_topics)))]
61+
matched, zeroshot_topics = np.unique(assignment[assigned_ids], return_inverse=True)
62+
zeroshot_labels = [topic_model.zeroshot_topic_list[index] for index in matched]
6363
zeroshot_data = Corpus(
6464
documents=zeroshot_documents,
6565
embeddings=corpus.embeddings[assigned_ids],
@@ -75,10 +75,8 @@ def zeroshot_tm(topic_model: "BERTopic", corpus: Corpus) -> Corpus:
7575
original_indices=non_assigned_ids,
7676
y=corpus.y[non_assigned_ids] if corpus.y is not None else None,
7777
)
78-
cluster_data.umap_embeddings = topic_model.umap_model.transform(cluster_data.embeddings)
79-
# If all documents were assigned to zero-shot topics
80-
if len(assigned_ids) == len(corpus.documents):
81-
corpus.topics = zeroshot_data.topics
78+
if non_assigned_ids:
79+
cluster_data.umap_embeddings = topic_model.umap_model.transform(cluster_data.embeddings)
8280

8381
logger.info("Zeroshot Step 1 - Completed \u2713")
8482

@@ -112,15 +110,14 @@ def combine_zeroshot_topics(topic_model: "BERTopic", corpus: Corpus, zeroshot_da
112110
Returns:
113111
corpus: Documents containing all the original documents with their topic assignments
114112
"""
115-
if len(corpus) > 0 and len(zeroshot_data) > 0:
113+
if len(zeroshot_data) > 0:
116114
logger.info(
117115
"Zeroshot Step 2 - Combining topics from zero-shot topic modeling with topics from clustering..."
118116
)
119117

120-
# Combine data
121-
corpus.sort_topics_by_frequency()
118+
# Combine data, or keep the zero-shot rows alone when every document matched a zero-shot topic
122119
zeroshot_data.sort_topics_by_frequency()
123-
corpus = corpus + zeroshot_data
120+
corpus = corpus.sort_topics_by_frequency() + zeroshot_data if len(corpus) > 0 else zeroshot_data
124121

125122
# Create new Topics
126123
topic_model._topics = Topics().initialize(corpus.topics, corpus._zeroshot_labels).sort_by_frequency()

‎tests/test_sub_models/test_cluster.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from sklearn.datasets import make_blobs
44
from sklearn.cluster import KMeans
5+
from sklearn.decomposition import PCA
56
from hdbscan import HDBSCAN
67

78
from bertopic import BERTopic
@@ -74,3 +75,14 @@ def test_custom_hdbscan_cluster_embeddings(cluster_model, samples, features, cen
7475
corpus = model._cluster_embeddings(corpus)
7576

7677
assert len(set(corpus.topics)) == centers
78+
79+
80+
def test_transform_with_float64_embeddings(documents, document_embeddings):
81+
embeddings = document_embeddings.astype("float64")
82+
topic_model = BERTopic(
83+
umap_model=PCA(n_components=5, random_state=42), hdbscan_model=KMeans(n_clusters=5, random_state=42)
84+
)
85+
topic_model.fit(documents, embeddings)
86+
topics, _ = topic_model.transform(documents[:5], embeddings[:5])
87+
88+
assert len(topics) == 5

‎tests/test_sub_models/test_embeddings.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
import pytest
33
import numpy as np
44
from bertopic import BERTopic
5+
from sklearn.cluster import KMeans
6+
from sklearn.decomposition import TruncatedSVD
7+
from sklearn.feature_extraction.text import TfidfVectorizer
58
from sklearn.metrics.pairwise import cosine_similarity
9+
from sklearn.pipeline import make_pipeline
610

711

812
@pytest.mark.parametrize(
@@ -65,3 +69,15 @@ def test_extract_incorrect_embeddings():
6569
with pytest.raises(ValueError):
6670
model = BERTopic(language="Unknown language")
6771
model.fit(["some document"])
72+
73+
74+
def test_sklearn_embedder_with_sparse_output(documents):
75+
topic_model = BERTopic(
76+
embedding_model=make_pipeline(TfidfVectorizer()),
77+
umap_model=TruncatedSVD(n_components=5, random_state=42),
78+
hdbscan_model=KMeans(n_clusters=5, random_state=42),
79+
)
80+
topic_model.fit(documents)
81+
topics, _ = topic_model.transform(documents[:5])
82+
83+
assert len(topics) == 5
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import numpy as np
2+
3+
from bertopic import BERTopic
4+
from bertopic.dimensionality import BaseDimensionalityReduction
5+
from sklearn.cluster import KMeans
6+
7+
8+
class RecordingReduction(BaseDimensionalityReduction):
9+
"""Skips dimensionality reduction but remembers the labels it was fitted with."""
10+
11+
def fit(self, X, y=None):
12+
self.y = y
13+
return self
14+
15+
16+
def test_guided_seeds_reach_dimensionality_reduction(documents, document_embeddings, embedding_model):
17+
embeddings = document_embeddings.copy()
18+
reduction = RecordingReduction()
19+
topic_model = BERTopic(
20+
embedding_model=embedding_model,
21+
umap_model=reduction,
22+
hdbscan_model=KMeans(n_clusters=5, random_state=42),
23+
seed_topic_list=[["space", "nasa", "orbit"], ["god", "jesus", "church"]],
24+
)
25+
topic_model.fit(documents, embeddings)
26+
27+
# Documents near a seed topic carry its index as a label, the rest -1
28+
assert set(reduction.y) == {-1, 0, 1}
29+
30+
# Pulling documents towards their seed topic must not change the caller's embeddings
31+
assert np.array_equal(embeddings, document_embeddings)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from bertopic import BERTopic
2+
from sklearn.cluster import KMeans
3+
from sklearn.decomposition import PCA
4+
5+
6+
def zeroshot_model(embedding_model, zeroshot_topic_list, zeroshot_min_similarity):
7+
"""A zero-shot model with fast sub-models, since clustering is not what these tests check."""
8+
return BERTopic(
9+
embedding_model=embedding_model,
10+
umap_model=PCA(n_components=5, random_state=42),
11+
hdbscan_model=KMeans(n_clusters=5, random_state=42),
12+
zeroshot_topic_list=zeroshot_topic_list,
13+
zeroshot_min_similarity=zeroshot_min_similarity,
14+
)
15+
16+
17+
def test_every_document_matching_a_zeroshot_topic(documents, document_embeddings, embedding_model):
18+
zeroshot_topic_list = ["religion", "cars", "electronics"]
19+
topic_model = zeroshot_model(embedding_model, zeroshot_topic_list, zeroshot_min_similarity=-1)
20+
topic_model.fit(documents, document_embeddings)
21+
22+
assert set(topic_model.topic_labels_.values()) == set(zeroshot_topic_list)
23+
assert sum(topic_model.topic_sizes_.values()) == len(documents)
24+
25+
26+
def test_unmatched_zeroshot_topic_keeps_the_others_named(documents, document_embeddings, embedding_model):
27+
zeroshot_topic_list = ["space exploration and nasa", "xyzzy quantum frobnicator glorp", "ice hockey"]
28+
topic_model = zeroshot_model(embedding_model, zeroshot_topic_list, zeroshot_min_similarity=0.35)
29+
topic_model.fit(documents, document_embeddings)
30+
31+
# The middle label matches no document, which is what used to drop the label after it
32+
labels = set(topic_model.topic_labels_.values())
33+
assert "xyzzy quantum frobnicator glorp" not in labels
34+
assert {"space exploration and nasa", "ice hockey"} <= labels
35+
assert "" not in labels

0 commit comments

Comments
 (0)