Skip to content

Commit abcec99

Browse files
committed
Update merge_models
1 parent 88e1fc5 commit abcec99

9 files changed

Lines changed: 244 additions & 403 deletions

File tree

bertopic/_bertopic.py

Lines changed: 32 additions & 282 deletions
Large diffs are not rendered by default.

bertopic/_topics.py

Lines changed: 199 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ def to_dict(self) -> dict:
2828
return {"type": "base", "data": self.data}
2929

3030
@classmethod
31-
def from_dict(cls, d: dict) -> "TopicRepresentation":
31+
def from_dict(cls, data: dict) -> "TopicRepresentation":
3232
"""Deserialize from dictionary."""
33-
return cls(data=d.get("data"))
33+
return cls(data=data.get("data"))
3434

3535

3636
@dataclass
@@ -61,8 +61,8 @@ def to_dict(self) -> dict:
6161
return {"type": "keywords", "data": [list(item) for item in self.data]}
6262

6363
@classmethod
64-
def from_dict(cls, d: dict) -> "Keywords":
65-
return cls(data=[tuple(item) for item in d["data"]])
64+
def from_dict(cls, data: dict) -> "Keywords":
65+
return cls(data=[tuple(item) for item in data["data"]])
6666

6767

6868
@dataclass
@@ -75,8 +75,8 @@ def to_dict(self) -> dict:
7575
return {"type": "label", "data": self.data}
7676

7777
@classmethod
78-
def from_dict(cls, d: dict) -> "Label":
79-
return cls(data=d["data"])
78+
def from_dict(cls, data: dict) -> "Label":
79+
return cls(data=data["data"])
8080

8181

8282
@dataclass
@@ -89,8 +89,8 @@ def to_dict(self) -> dict:
8989
return {"type": "structured_json", "data": self.data}
9090

9191
@classmethod
92-
def from_dict(cls, d: dict) -> "StructuredJSON":
93-
return cls(data=d["data"])
92+
def from_dict(cls, data: dict) -> "StructuredJSON":
93+
return cls(data=data["data"])
9494

9595

9696
@dataclass
@@ -111,8 +111,8 @@ def to_dict(self) -> dict:
111111
return {"type": "metadata", "data": self.data}
112112

113113
@classmethod
114-
def from_dict(cls, d: dict) -> "Metadata":
115-
return cls(data=d["data"])
114+
def from_dict(cls, data: dict) -> "Metadata":
115+
return cls(data=data["data"])
116116

117117

118118
def representation_from_dict(d: dict) -> TopicRepresentation:
@@ -237,13 +237,17 @@ def to_dict(self) -> dict:
237237
}
238238

239239
@classmethod
240-
def from_dict(cls, d: dict) -> "TopicMapping":
240+
def from_dict(cls, data: dict) -> "TopicMapping":
241241
"""Deserialize from dictionary."""
242242
mapping = cls()
243-
mapping._mapping = {int(k): v for k, v in d.get("mapping", {}).items()}
244-
mapping._recent_mapping = {int(k): v for k, v in d.get("recent_mapping", {}).items()}
243+
mapping._mapping = {int(k): v for k, v in data.get("mapping", {}).items()}
244+
mapping._recent_mapping = {int(k): v for k, v in data.get("recent_mapping", {}).items()}
245245
return mapping
246246

247+
def copy(self) -> "TopicMapping":
248+
"""Create a copy of this mapping."""
249+
return TopicMapping.from_dict(self.to_dict())
250+
247251

248252
@dataclass
249253
class Topic:
@@ -342,48 +346,91 @@ def to_info_dict(self) -> dict:
342346

343347
return info
344348

345-
def to_dict(self) -> dict:
346-
"""Serialize topic for storage."""
347-
d = {
349+
def to_dict(self, full: bool = False) -> dict:
350+
"""Serialize topic for storage.
351+
352+
Arguments:
353+
full: If True, include embeddings and c_tf_idf (for in-memory copy).
354+
If False, exclude large arrays (for disk serialization).
355+
"""
356+
data = {
348357
"id": self.id,
349358
"label": self._label,
350359
"nr_documents": self.nr_documents,
351360
"topic_type": self.topic_type.value,
352361
"representations": {name: rep.to_dict() for name, rep in self.representations.items()},
353362
"representative_documents": self.representative_documents,
354363
}
364+
365+
if full:
366+
data["embedding"] = self.embedding.tolist() if self.embedding.size else []
367+
if self.c_tf_idf.nnz > 0:
368+
data["c_tf_idf"] = {
369+
"data": self.c_tf_idf.data.tolist(),
370+
"indices": self.c_tf_idf.indices.tolist(),
371+
"indptr": self.c_tf_idf.indptr.tolist(),
372+
"shape": list(self.c_tf_idf.shape),
373+
}
374+
if self.representative_images is not None and self.representative_images.size:
375+
data["representative_images"] = self.representative_images.tolist()
376+
355377
# Hierarchy fields (only if set)
356378
if self.parent_id is not None:
357-
d["parent_id"] = self.parent_id
379+
data["parent_id"] = self.parent_id
358380
if self.child_ids is not None:
359-
d["child_ids"] = list(self.child_ids)
381+
data["child_ids"] = list(self.child_ids)
360382
if self.merge_distance is not None:
361-
d["merge_distance"] = self.merge_distance
383+
data["merge_distance"] = self.merge_distance
362384
if self.leaf_topic_ids:
363-
d["leaf_topic_ids"] = self.leaf_topic_ids
364-
return d
385+
data["leaf_topic_ids"] = self.leaf_topic_ids
386+
return data
365387

366388
@classmethod
367-
def from_dict(cls, d: dict) -> "Topic":
389+
def from_dict(cls, data: dict) -> "Topic":
368390
"""Deserialize topic from storage."""
369391
representations = {
370392
name: representation_from_dict(rep_dict)
371-
for name, rep_dict in d.get("representations", {}).items()
393+
for name, rep_dict in data.get("representations", {}).items()
372394
}
373-
child_ids = tuple(d["child_ids"]) if d.get("child_ids") else None
395+
396+
# Handle full format fields
397+
embedding = np.array(data["embedding"]) if "embedding" in data else np.array([])
398+
c_tf_idf_data = data.get("c_tf_idf")
399+
if c_tf_idf_data:
400+
c_tf_idf = csr_matrix(
401+
(c_tf_idf_data["data"], c_tf_idf_data["indices"], c_tf_idf_data["indptr"]),
402+
shape=tuple(c_tf_idf_data["shape"]),
403+
)
404+
else:
405+
c_tf_idf = csr_matrix([])
406+
407+
representative_images = (
408+
np.array(data["representative_images"]) if "representative_images" in data else np.array([])
409+
)
410+
374411
return cls(
375-
id=d["id"],
376-
_label=d.get("label"),
377-
nr_documents=d.get("nr_documents", 0),
378-
topic_type=TopicType(d.get("topic_type", "normal")),
412+
id=data["id"],
413+
_label=data.get("label"),
414+
nr_documents=data.get("nr_documents", 0),
415+
topic_type=TopicType(data.get("topic_type", "normal")),
379416
representations=representations,
380-
representative_documents=d.get("representative_documents", []),
381-
parent_id=d.get("parent_id"),
382-
child_ids=child_ids,
383-
merge_distance=d.get("merge_distance"),
384-
leaf_topic_ids=d.get("leaf_topic_ids", []),
417+
representative_documents=data.get("representative_documents", []),
418+
embedding=embedding,
419+
c_tf_idf=c_tf_idf,
420+
representative_images=representative_images if representative_images.size else None,
421+
parent_id=data.get("parent_id"),
422+
child_ids=tuple(data["child_ids"]) if data.get("child_ids") else None,
423+
merge_distance=data.get("merge_distance"),
424+
leaf_topic_ids=data.get("leaf_topic_ids", []),
385425
)
386426

427+
def copy(self, new_id: int | None = None) -> "Topic":
428+
"""Create a copy of this topic, optionally with a new ID."""
429+
copied = Topic.from_dict(self.to_dict(full=True))
430+
if new_id is not None:
431+
copied.id = new_id
432+
return copied
433+
387434
def __str__(self) -> str:
388435
"""Pretty print all representations of the topic."""
389436
lines = [f"Topic {self.id} Representations:"]
@@ -840,26 +887,128 @@ def to_polars(self, topic: int | None = None) -> pl.DataFrame:
840887
data = {col: [row.get(col) for row in rows] for col in columns}
841888
return pl.DataFrame(data)
842889

843-
def to_dict(self) -> dict:
844-
"""Serialize Topics for storage."""
845-
return {
890+
def to_dict(self, full: bool = False) -> dict:
891+
"""Serialize Topics for storage.
892+
893+
Arguments:
894+
full: If True, include embeddings and probabilities (for in-memory copy).
895+
If False, exclude large arrays (for disk serialization).
896+
"""
897+
data = {
846898
"bertopic_version": BERTOPIC_VERSION,
847-
"topics": {str(tid): topic.to_dict() for tid, topic in self.topics.items()},
899+
"topics": {str(tid): topic.to_dict(full=full) for tid, topic in self.topics.items()},
848900
"mapping": self.mapping.to_dict(),
849901
"predictions": self._original_predictions.tolist() if self._original_predictions.size > 0 else [],
850902
"actions": [a.value for a in self.actions],
851903
}
852904

905+
if full:
906+
if self._original_probabilities is not None:
907+
data["original_probabilities"] = self._original_probabilities.tolist()
908+
if self._zeroshot_probabilities is not None:
909+
data["zeroshot_probabilities"] = self._zeroshot_probabilities.tolist()
910+
911+
return data
912+
853913
@classmethod
854-
def from_dict(cls, d: dict) -> "Topics":
914+
def from_dict(cls, data: dict) -> "Topics":
855915
"""Deserialize Topics from storage."""
856916
topics = cls()
857-
topics.topics = {int(tid): Topic.from_dict(td) for tid, td in d.get("topics", {}).items()}
858-
topics.mapping = TopicMapping.from_dict(d.get("mapping", {}))
859-
topics._original_predictions = np.array(d.get("predictions", []))
860-
topics.actions = [TopicAction(a) for a in d.get("actions", [])]
917+
topics.topics = {int(tid): Topic.from_dict(td) for tid, td in data.get("topics", {}).items()}
918+
topics.mapping = TopicMapping.from_dict(data.get("mapping", {}))
919+
topics._original_predictions = np.array(data.get("predictions", []))
920+
topics.actions = [TopicAction(a) for a in data.get("actions", [])]
921+
922+
# Handle full format fields
923+
if "original_probabilities" in data:
924+
topics._original_probabilities = np.array(data["original_probabilities"])
925+
if "zeroshot_probabilities" in data:
926+
topics._zeroshot_probabilities = np.array(data["zeroshot_probabilities"])
927+
861928
return topics
862929

930+
def copy(self) -> "Topics":
931+
"""Create a deep copy of this Topics collection."""
932+
return Topics.from_dict(self.to_dict(full=True))
933+
934+
def merge_similar(self, other: "Topics", min_similarity: float = 0.7) -> "Topics":
935+
"""Merge another Topics collection based on embedding similarity.
936+
937+
Topics from `other` are compared against topics in `self`. Those with
938+
cosine similarity >= min_similarity are deduplicated (their document
939+
predictions map to the existing similar topic). Dissimilar topics are
940+
added as new topics with new IDs.
941+
942+
After merging:
943+
- New topics are added to self.topics
944+
- Predictions from other are remapped and appended to self
945+
- Document counts are recalculated
946+
- The mapping is reset to identity
947+
948+
Arguments:
949+
other: Another Topics collection to merge into this one.
950+
min_similarity: Minimum cosine similarity to consider topics as duplicates.
951+
952+
Returns:
953+
self (for method chaining)
954+
"""
955+
from sklearn.metrics.pairwise import cosine_similarity
956+
from collections import Counter
957+
958+
self_ids = self.topic_ids(outliers=False)
959+
other_ids = other.topic_ids(outliers=False)
960+
961+
# Handle edge cases
962+
if not other_ids:
963+
return self
964+
965+
# Build ID mapping: other_id -> self_id
966+
id_mapping = {-1: -1}
967+
968+
if not self_ids:
969+
# Self has no real topics, just add all from other
970+
for other_id in other_ids:
971+
id_mapping[other_id] = other_id
972+
self.topics[other_id] = other.topics[other_id].copy()
973+
else:
974+
# Compute similarity
975+
self_emb = np.array([self.topics[tid].embedding for tid in self_ids])
976+
other_emb = np.array([other.topics[tid].embedding for tid in other_ids])
977+
sim_matrix = cosine_similarity(other_emb, self_emb)
978+
979+
max_sims = np.max(sim_matrix, axis=1)
980+
best_matches = np.argmax(sim_matrix, axis=1)
981+
next_id = max(self_ids) + 1
982+
983+
for i, other_id in enumerate(other_ids):
984+
if max_sims[i] >= min_similarity:
985+
id_mapping[other_id] = self_ids[best_matches[i]]
986+
else:
987+
id_mapping[other_id] = next_id
988+
self.topics[next_id] = other.topics[other_id].copy(new_id=next_id)
989+
next_id += 1
990+
991+
# Ensure outlier exists
992+
if -1 in other.topics and -1 not in self.topics:
993+
self.topics[-1] = other.topics[-1].copy()
994+
995+
# Merge predictions: get current, remap other's, concatenate
996+
current_preds = list(self.predictions)
997+
other_preds = [id_mapping[p] for p in other.predictions]
998+
all_preds = current_preds + other_preds
999+
1000+
# Store as new "original" with identity mapping
1001+
self._original_predictions = np.array(all_preds)
1002+
self.mapping.reset()
1003+
1004+
# Recalculate document counts
1005+
counts = Counter(all_preds)
1006+
for topic in self.topics.values():
1007+
topic.nr_documents = counts.get(topic.id, 0)
1008+
1009+
self.add_action(TopicAction.MERGED)
1010+
return self
1011+
8631012

8641013
@dataclass
8651014
class TopicHierarchy:
@@ -1051,12 +1200,14 @@ def to_dict(self) -> dict:
10511200
}
10521201

10531202
@classmethod
1054-
def from_dict(cls, d: dict) -> "TopicHierarchy":
1203+
def from_dict(cls, data: dict) -> "TopicHierarchy":
10551204
"""Deserialize hierarchy from storage."""
10561205
hierarchy = cls()
1057-
hierarchy.nodes = {int(nid): Topic.from_dict(nd) for nid, nd in d.get("nodes", {}).items()}
1058-
hierarchy.linkage_matrix = np.array(d.get("linkage_matrix", []))
1059-
hierarchy.n_leaves = d.get("n_leaves", 0)
1060-
hierarchy.outlier_topic = Topic.from_dict(d["outlier_topic"]) if d.get("outlier_topic") else None
1061-
hierarchy._original_predictions = np.array(d.get("predictions", []))
1206+
hierarchy.nodes = {int(nid): Topic.from_dict(nd) for nid, nd in data.get("nodes", {}).items()}
1207+
hierarchy.linkage_matrix = np.array(data.get("linkage_matrix", []))
1208+
hierarchy.n_leaves = data.get("n_leaves", 0)
1209+
hierarchy.outlier_topic = (
1210+
Topic.from_dict(data["outlier_topic"]) if data.get("outlier_topic") else None
1211+
)
1212+
hierarchy._original_predictions = np.array(data.get("predictions", []))
10621213
return hierarchy

bertopic/variations/_distribution.py

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,5 @@
11
"""Approximate topic distributions across documents.
2-
3-
# Methodology
4-
5-
To perform this approximation, each document is split into tokens according to the provided tokenizer in the CountVectorizer.
6-
Then, a sliding window is applied on each document creating subsets of the document.
7-
For example, with a window size of 3 and stride of 1, the document:
8-
9-
`Solving the right problem is difficult.`
10-
11-
can be split up into `solving the right`, `the right problem`, `right problem is`, and `problem is difficult`.
12-
13-
These are called token sets. For each of these token sets, we calculate their c-TF-IDF representation and
14-
find out how similar they are to the previously generated topics. Then, the similarities to the topics for each token
15-
set are summed to create a topic distribution for the entire document.
16-
17-
Although it is often said that documents can contain a mixture of topics, these are often modeled by assigning each
18-
word to a single topic. With this approach, we take into account that there may be multiple topics for a single word.
19-
20-
We can make this multiple-topic word assignment a bit more accurate by then splitting these token sets up into
21-
individual tokens and assigning the topic distributions for each token set to each individual token. That way, we can
22-
visualize the extent to which a certain word contributes to a document's topic distribution.
2+
See: https://maartengr.github.io/BERTopic/getting_started/distribution/distribution.html.
233
"""
244

255
import math

bertopic/variations/_guided.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,5 @@
11
"""Guided Topic Modeling Variation for BERTopic.
2-
3-
# Methodology
4-
5-
This method has two main steps:
6-
7-
## Step 1: Semi-Supervised UMAP
8-
9-
* Create embeddings for each seeded topic using the same embedder as for documents
10-
* For each document, compute cosine similarity with each seeded topic embedding
11-
* Similarity is used to assign labels to documents (-1 is assigned if most similar to average document embedding)
12-
* UMAP is applied in a semi-supervised manner using these labels to nudge topic creation towards seeded topics
13-
14-
## Step 2: IDF Adjustment
15-
16-
* All words in seeded topics are given a multiplier larger than 1
17-
* These multipliers are used to increase the IDF values of the words across all topics
18-
* This increases the likelihood that a seeded topic word will appear in a topic
2+
See: https://maartengr.github.io/BERTopic/getting_started/guided/guided.html.
193
"""
204

215
import numpy as np

0 commit comments

Comments
 (0)