@@ -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
118118def 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
249253class 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
8651014class 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
0 commit comments