From 7142ce7f9c12e92ec9146ea97d8d4d5ef8332354 Mon Sep 17 00:00:00 2001 From: Maarten Grootendorst Date: Tue, 14 Feb 2023 14:23:20 +0100 Subject: [PATCH] v0.14 (#977) * Add representation models * bertopic.representation.KeyBERTInspired * bertopic.representation.PartOfSpeech * bertopic.representation.MaximalMarginalRelevance * bertopic.representation.Cohere * bertopic.representation.OpenAI * bertopic.representation.TextGeneration * bertopic.representation.LangChain * bertopic.representation.ZeroShotClassification * Fix topic selection when extracting repr docs * Improve documentation, #769, #954, #912 * Add wordcloud example to documentation * Add title param for each graph, #800 * Improved nr_topics procedure * Fix #952, #903, #911, #965. Add #976 --- .github/workflows/testing.yml | 2 +- bertopic/__init__.py | 2 +- bertopic/_bertopic.py | 325 +++++++------ bertopic/_mmr.py | 53 --- bertopic/_utils.py | 9 +- bertopic/plotting/_barchart.py | 4 +- bertopic/plotting/_distribution.py | 4 +- bertopic/plotting/_documents.py | 4 +- bertopic/plotting/_heatmap.py | 4 +- bertopic/plotting/_hierarchical_documents.py | 4 +- bertopic/plotting/_hierarchy.py | 4 +- bertopic/plotting/_term_rank.py | 4 +- bertopic/plotting/_topics.py | 19 +- bertopic/plotting/_topics_over_time.py | 4 +- bertopic/plotting/_topics_per_class.py | 4 +- bertopic/representation/__init__.py | 52 +++ bertopic/representation/_base.py | 38 ++ bertopic/representation/_cohere.py | 143 ++++++ bertopic/representation/_keybert.py | 198 ++++++++ bertopic/representation/_langchain.py | 93 ++++ bertopic/representation/_mmr.py | 110 +++++ bertopic/representation/_openai.py | 145 ++++++ bertopic/representation/_pos.py | 153 +++++++ bertopic/representation/_textgeneration.py | 139 ++++++ bertopic/representation/_zeroshot.py | 99 ++++ docs/algorithm/algorithm.md | 60 ++- docs/algorithm/default.svg | 88 ++-- docs/algorithm/modularity.svg | 427 ++++++++++-------- docs/api/mmr.md | 3 - docs/api/representation/base.md | 3 + docs/api/representation/cohere.md | 3 + docs/api/representation/generation.md | 3 + docs/api/representation/keybert.md | 3 + docs/api/representation/langchain.md | 3 + docs/api/representation/mmr.md | 3 + docs/api/representation/openai.md | 3 + docs/api/representation/pos.md | 3 + docs/api/representation/zeroshot.md | 3 + docs/changelog.md | 233 ++++++++++ docs/faq.md | 4 +- docs/getting_started/clustering/clustering.md | 2 +- .../getting_started/clustering/clustering.svg | 98 ++-- docs/getting_started/ctfidf/ctfidf.svg | 98 ++-- .../dim_reduction/dimensionality.svg | 98 ++-- docs/getting_started/embeddings/embeddings.md | 28 +- .../getting_started/embeddings/embeddings.svg | 146 +++--- .../outlier_reduction/outlier_reduction.md | 28 +- .../getting_started/representation/cohere.svg | 13 + docs/getting_started/representation/hf.svg | 13 + .../representation/keybert.svg | 13 + .../representation/keybertinspired.svg | 23 + docs/getting_started/representation/mmr.svg | 56 +++ .../representation/mmr_output.svg | 13 + .../getting_started/representation/openai.svg | 13 + .../representation/partofspeech.svg | 17 + docs/getting_started/representation/pos.svg | 14 + .../representation/representation.md | 415 +++++++++++++++++ .../representation/representation.svg | 53 +++ docs/getting_started/representation/zero.svg | 13 + .../tips_and_tricks/tips_and_tricks.md | 66 ++- .../tips_and_tricks/wordcloud.jpg | Bin 0 -> 90359 bytes .../vectorizers/vectorizers.svg | 98 ++-- docs/index.md | 2 +- mkdocs.yml | 13 +- setup.py | 3 +- tests/conftest.py | 9 + tests/test_bertopic.py | 7 +- tests/test_representation/test_get.py | 17 +- .../test_representations.py | 2 +- tests/test_sub_models/test_mmr.py | 25 - 70 files changed, 2993 insertions(+), 861 deletions(-) delete mode 100644 bertopic/_mmr.py create mode 100644 bertopic/representation/__init__.py create mode 100644 bertopic/representation/_base.py create mode 100644 bertopic/representation/_cohere.py create mode 100644 bertopic/representation/_keybert.py create mode 100644 bertopic/representation/_langchain.py create mode 100644 bertopic/representation/_mmr.py create mode 100644 bertopic/representation/_openai.py create mode 100644 bertopic/representation/_pos.py create mode 100644 bertopic/representation/_textgeneration.py create mode 100644 bertopic/representation/_zeroshot.py delete mode 100644 docs/api/mmr.md create mode 100644 docs/api/representation/base.md create mode 100644 docs/api/representation/cohere.md create mode 100644 docs/api/representation/generation.md create mode 100644 docs/api/representation/keybert.md create mode 100644 docs/api/representation/langchain.md create mode 100644 docs/api/representation/mmr.md create mode 100644 docs/api/representation/openai.md create mode 100644 docs/api/representation/pos.md create mode 100644 docs/api/representation/zeroshot.md create mode 100644 docs/getting_started/representation/cohere.svg create mode 100644 docs/getting_started/representation/hf.svg create mode 100644 docs/getting_started/representation/keybert.svg create mode 100644 docs/getting_started/representation/keybertinspired.svg create mode 100644 docs/getting_started/representation/mmr.svg create mode 100644 docs/getting_started/representation/mmr_output.svg create mode 100644 docs/getting_started/representation/openai.svg create mode 100644 docs/getting_started/representation/partofspeech.svg create mode 100644 docs/getting_started/representation/pos.svg create mode 100644 docs/getting_started/representation/representation.md create mode 100644 docs/getting_started/representation/representation.svg create mode 100644 docs/getting_started/representation/zero.svg create mode 100644 docs/getting_started/tips_and_tricks/wordcloud.jpg delete mode 100644 tests/test_sub_models/test_mmr.py diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index edfca558..1467270d 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.7, 3.8] + python-version: [3.8, 3.9] steps: - uses: actions/checkout@v2 diff --git a/bertopic/__init__.py b/bertopic/__init__.py index b2e745e7..2b105108 100644 --- a/bertopic/__init__.py +++ b/bertopic/__init__.py @@ -1,6 +1,6 @@ from bertopic._bertopic import BERTopic -__version__ = "0.13.0" +__version__ = "0.14.0" __all__ = [ "BERTopic", diff --git a/bertopic/_bertopic.py b/bertopic/_bertopic.py index ef0da5e1..c111af8d 100644 --- a/bertopic/_bertopic.py +++ b/bertopic/_bertopic.py @@ -26,15 +26,16 @@ from umap import UMAP from sklearn.preprocessing import normalize from sklearn import __version__ as sklearn_version +from sklearn.cluster import AgglomerativeClustering from sklearn.metrics.pairwise import cosine_similarity from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer # BERTopic from bertopic import plotting -from bertopic._mmr import mmr from bertopic.vectorizers import ClassTfidfTransformer from bertopic.backend import BaseEmbedder from bertopic.backend._utils import select_backend +from bertopic.representation import BaseRepresentation from bertopic.cluster._utils import hdbscan_delegator, is_supported_hdbscan from bertopic._utils import MyLogger, check_documents_type, check_embeddings_shape, check_is_fitted @@ -109,13 +110,13 @@ def __init__(self, nr_topics: Union[int, str] = None, low_memory: bool = False, calculate_probabilities: bool = False, - diversity: float = None, seed_topic_list: List[List[str]] = None, embedding_model=None, umap_model: UMAP = None, hdbscan_model: hdbscan.HDBSCAN = None, vectorizer_model: CountVectorizer = None, ctfidf_model: TfidfTransformer = None, + representation_model: BaseRepresentation = None, verbose: bool = False, ): """BERTopic initialization @@ -126,6 +127,7 @@ def __init__(self, supported languages see bertopic.backend.languages. Select "multilingual" to load in the `paraphrase-multilingual-MiniLM-L12-v2` sentence-tranformers model that supports 50+ languages. + NOTE: This is not used if `embedding_model` is used. top_n_words: The number of words per topic to extract. Setting this too high can negatively impact topic embeddings as topics are typically best represented by at most 10 words. @@ -145,17 +147,14 @@ def __init__(self, low_memory: Sets UMAP low memory to True to make sure less memory is used. NOTE: This is only used in UMAP. For example, if you use PCA instead of UMAP this parameter will not be used. - calculate_probabilities: Whether to calculate the probabilities of all topics + calculate_probabilities: Calculate the probabilities of all topics per document instead of the probability of the assigned topic per document. This could slow down the extraction - of topics if you have many documents (> 100_000). Set this - only to True if you have a low amount of documents or if - you do not mind more computation time. + of topics if you have many documents (> 100_000). NOTE: If false you cannot use the corresponding visualization method `visualize_probabilities`. - diversity: Whether to use MMR to diversify the resulting topic representations. - If set to None, MMR will not be used. Accepted values lie between - 0 and 1 with 0 being not at all diverse and 1 being very diverse. + NOTE: This is an approximation of topic probabilities + as used in HDBSCAN and not an exact representation. seed_topic_list: A list of seed words per topic to converge around verbose: Changes the verbosity of the model, Set to True if you want to track the stages of the model. @@ -177,17 +176,20 @@ def __init__(self, `.fit` and `.predict` functions along with the `.labels_` variable. vectorizer_model: Pass in a custom `CountVectorizer` instead of the default model. ctfidf_model: Pass in a custom ClassTfidfTransformer instead of the default model. + representation_model: Pass in a model that fine-tunes the topic representations + calculated through c-TF-IDF. Models from `bertopic.representation` + are supported. """ # Topic-based parameters - if top_n_words > 30: - raise ValueError("top_n_words should be lower or equal to 30. The preferred value is 10.") + if top_n_words > 100: + warnings.warn("Note that extracting more than 100 words from a sparse " + "can slow down computation quite a bit.") self.top_n_words = top_n_words self.min_topic_size = min_topic_size self.nr_topics = nr_topics self.low_memory = low_memory self.calculate_probabilities = calculate_probabilities - self.diversity = diversity self.verbose = verbose self.seed_topic_list = seed_topic_list @@ -200,6 +202,9 @@ def __init__(self, self.vectorizer_model = vectorizer_model or CountVectorizer(ngram_range=self.n_gram_range) self.ctfidf_model = ctfidf_model or ClassTfidfTransformer() + # Representation model + self.representation_model = representation_model + # UMAP or another algorithm that has .fit and .transform functions self.umap_model = umap_model or UMAP(n_neighbors=15, n_components=5, @@ -364,11 +369,10 @@ def fit_transform(self, if self.nr_topics: documents = self._reduce_topics(documents) - if isinstance(self.hdbscan_model, hdbscan.HDBSCAN): - self._map_representative_docs(original_topics=True) - else: - self._save_representative_docs(documents) + # Save the top 3 most representative documents per topic + self._save_representative_docs(documents) + # Resulting output self.probabilities_ = self._map_probabilities(probabilities, original_topics=True) predictions = documents.Topic.to_list() @@ -574,7 +578,7 @@ def partial_fit(self, # Update topic representations self.c_tf_idf_, updated_words = self._c_tf_idf(documents_per_topic, partial_fit=True) - self.topic_representations_ = self._extract_words_per_topic(updated_words, self.c_tf_idf_, labels=updated_topics) + self.topic_representations_ = self._extract_words_per_topic(updated_words, documents, self.c_tf_idf_) self._create_topic_vectors() self.topic_labels_ = {key: f"{key}_" + "_".join([word[0] for word in values[:4]]) for key, values in self.topic_representations_.items()} @@ -714,8 +718,7 @@ def topics_over_time(self, c_tf_idf = (global_c_tf_idf[selected_topics] + c_tf_idf) / 2.0 # Extract the words per topic - labels = sorted(list(documents_per_topic.Topic.unique())) - words_per_topic = self._extract_words_per_topic(words, c_tf_idf, labels) + words_per_topic = self._extract_words_per_topic(words, selection, c_tf_idf) topic_frequency = pd.Series(documents_per_topic.Timestamps.values, index=documents_per_topic.Topic).to_dict() @@ -790,8 +793,7 @@ def topics_per_class(self, c_tf_idf = (global_c_tf_idf[documents_per_topic.Topic.values + self._outliers] + c_tf_idf) / 2.0 # Extract the words per topic - labels = sorted(list(documents_per_topic.Topic.unique())) - words_per_topic = self._extract_words_per_topic(words, c_tf_idf, labels) + words_per_topic = self._extract_words_per_topic(words, selection, c_tf_idf) topic_frequency = pd.Series(documents_per_topic.Class.values, index=documents_per_topic.Topic).to_dict() @@ -877,7 +879,7 @@ def hierarchical_topics(self, "Topic": self.topics_}) documents_per_topic = documents.groupby(['Topic'], as_index=False).agg({'Document': ' '.join}) documents_per_topic = documents_per_topic.loc[documents_per_topic.Topic != -1, :] - documents = self._preprocess_text(documents_per_topic.Document.values) + clean_documents = self._preprocess_text(documents_per_topic.Document.values) # Scikit-Learn Deprecation: get_feature_names is deprecated in 1.0 # and will be removed in 1.2. Please use get_feature_names_out instead. @@ -886,7 +888,7 @@ def hierarchical_topics(self, else: words = self.vectorizer_model.get_feature_names() - bow = self.vectorizer_model.transform(documents) + bow = self.vectorizer_model.transform(clean_documents) # Extract clusters hier_topics = pd.DataFrame(columns=["Parent_ID", "Parent_Name", "Topics", @@ -913,7 +915,9 @@ def hierarchical_topics(self, # Group bow per cluster, calculate c-TF-IDF and extract words grouped = csr_matrix(bow[clustered_topics].sum(axis=0)) c_tf_idf = self.ctfidf_model.transform(grouped) - words_per_topic = self._extract_words_per_topic(words, c_tf_idf, labels=[0]) + selection = documents.loc[documents.Topic.isin(clustered_topics), :] + selection.Topic = 0 + words_per_topic = self._extract_words_per_topic(words, selection, c_tf_idf) # Extract parent's name and ID parent_id = index + len(clusters) @@ -1227,9 +1231,9 @@ def update_topics(self, topics: List[int] = None, top_n_words: int = 10, n_gram_range: Tuple[int, int] = None, - diversity: float = None, vectorizer_model: CountVectorizer = None, - ctfidf_model: ClassTfidfTransformer = None): + ctfidf_model: ClassTfidfTransformer = None, + representation_model: BaseRepresentation = None): """ Updates the topic representation by recalculating c-TF-IDF with the new parameters as defined in this function. @@ -1249,11 +1253,11 @@ def update_topics(self, too high can negatively impact topic embeddings as topics are typically best represented by at most 10 words. n_gram_range: The n-gram range for the CountVectorizer. - diversity: Whether to use MMR to diversify the resulting topic representations. - If set to None, MMR will not be used. Accepted values lie between - 0 and 1 with 0 being not at all diverse and 1 being very diverse. vectorizer_model: Pass in your own CountVectorizer from scikit-learn ctfidf_model: Pass in your own c-TF-IDF model to update the representations + representation_model: Pass in a model that fine-tunes the topic representations + calculated through c-TF-IDF. Models from `bertopic.representation` + are supported. Examples: @@ -1283,18 +1287,17 @@ def update_topics(self, if not n_gram_range: n_gram_range = self.n_gram_range - if top_n_words > 30: - raise ValueError("top_n_words should be lower or equal to 30. The preferred value is 10.") + if top_n_words > 100: + warnings.warn("Note that extracting more than 100 words from a sparse " + "can slow down computation quite a bit.") self.top_n_words = top_n_words - self.diversity = diversity self.vectorizer_model = vectorizer_model or CountVectorizer(ngram_range=n_gram_range) self.ctfidf_model = ctfidf_model or ClassTfidfTransformer() + self.representation_model = representation_model if topics is None: topics = self.topics_ - labels = None else: - labels = sorted(list(set(topics))) warnings.warn("Using a custom list of topic assignments may lead to errors if " "topic reduction techniques are used afterwards. Make sure that " "manually assigning topics is the last step in the pipeline.") @@ -1302,7 +1305,7 @@ def update_topics(self, documents = pd.DataFrame({"Document": docs, "Topic": topics}) documents_per_topic = documents.groupby(['Topic'], as_index=False).agg({'Document': ' '.join}) self.c_tf_idf_, words = self._c_tf_idf(documents_per_topic) - self.topic_representations_ = self._extract_words_per_topic(words, labels=labels) + self.topic_representations_ = self._extract_words_per_topic(words, documents) self._create_topic_vectors() self.topic_labels_ = {key: f"{key}_" + "_".join([word[0] for word in values[:4]]) for key, values in @@ -1806,17 +1809,22 @@ def merge_topics(self, documents = self._sort_mappings_by_frequency(documents) self._extract_topics(documents) self._update_topic_size(documents) - self._map_representative_docs() + self._save_representative_docs(documents) self.probabilities_ = self._map_probabilities(self.probabilities_) def reduce_topics(self, docs: List[str], - nr_topics: int = 20) -> None: - """ Further reduce the number of topics to nr_topics. + nr_topics: Union[int, str] = 20) -> None: + """ Reduce the number of topics to a fixed number of topics + or automatically. + + If nr_topics is a integer, then the number of topics is reduced + to nr_topics using `AgglomerativeClustering` on the cosine distance matrix + of the topic embeddings. + + If nr_topics is `"auto"`, then HDBSCAN is used to automatically + reduce the number of topics by running it on the topic embeddings. - The number of topics is further reduced by calculating the c-TF-IDF matrix - of the documents and then reducing them by iteratively merging the least - frequent topic with the most similar one based on their c-TF-IDF matrices. The topics, their sizes, and representations are updated. Arguments: @@ -1844,15 +1852,14 @@ def reduce_topics(self, ``` """ check_is_fitted(self) + self.nr_topics = nr_topics documents = pd.DataFrame({"Document": docs, "Topic": self.topics_}) # Reduce number of topics documents = self._reduce_topics(documents) self._merged_topics = None - self._map_representative_docs() - - # Map probabilities + self._save_representative_docs(documents) self.probabilities_ = self._map_probabilities(self.probabilities_) return self @@ -1975,6 +1982,9 @@ def reduce_outliers(self, # Reduce outliers by finding the most similar topic embeddings elif strategy.lower() == "embeddings": + if self.embedding_model is None: + raise ValueError("To use this strategy, you will need to pass a model to `embedding_model`" + "when instantiating BERTopic.") outlier_ids = [index for index, topic in enumerate(topics) if topic == -1] outlier_docs = [documents[index] for index in outlier_ids] @@ -2875,10 +2885,6 @@ def _cluster_embeddings(self, # track if there are outlier labels and act accordingly when slicing. self._outliers = 1 if -1 in set(labels) else 0 - # Save representative docs - if isinstance(self.hdbscan_model, hdbscan.HDBSCAN): - self._save_representative_docs(documents) - # Extract probabilities probabilities = None if hasattr(self.hdbscan_model, "probabilities_"): @@ -2941,99 +2947,82 @@ def _extract_topics(self, documents: pd.DataFrame): """ documents_per_topic = documents.groupby(['Topic'], as_index=False).agg({'Document': ' '.join}) self.c_tf_idf_, words = self._c_tf_idf(documents_per_topic) - self.topic_representations_ = self._extract_words_per_topic(words) + self.topic_representations_ = self._extract_words_per_topic(words, documents) self._create_topic_vectors() self.topic_labels_ = {key: f"{key}_" + "_".join([word[0] for word in values[:4]]) for key, values in self.topic_representations_.items()} def _save_representative_docs(self, documents: pd.DataFrame): - """ Save the most representative docs (3) per topic - - The most representative docs are extracted by taking - the exemplars from the HDBSCAN-generated clusters. - - Full instructions can be found here: - https://hdbscan.readthedocs.io/en/latest/soft_clustering_explanation.html + """ Save the 3 most representative docs per topic Arguments: documents: Dataframe with documents and their corresponding IDs - """ - smallest_cluster_size = min(self.topic_sizes_.items(), key=lambda x: x[1])[1] - if smallest_cluster_size < 3: - top_n_representative_docs = smallest_cluster_size - else: - top_n_representative_docs = 3 - - if isinstance(self.hdbscan_model, hdbscan.HDBSCAN): - # Prepare the condensed tree and luf clusters beneath a given cluster - condensed_tree = self.hdbscan_model.condensed_tree_ - raw_tree = condensed_tree._raw_tree - clusters = sorted(condensed_tree._select_clusters()) - cluster_tree = raw_tree[raw_tree['child_size'] > 1] - - # Find the points with maximum lambda value in each leaf - representative_docs = {} - for topic in documents['Topic'].unique(): - if topic != -1: - leaves = hdbscan.plots._recurse_leaf_dfs(cluster_tree, clusters[topic]) - - result = np.array([]) - for leaf in leaves: - max_lambda = raw_tree['lambda_val'][raw_tree['parent'] == leaf].max() - points = raw_tree['child'][(raw_tree['parent'] == leaf) & (raw_tree['lambda_val'] == max_lambda)] - result = np.hstack((result, points)) - - representative_docs[topic] = list(np.random.choice(result, top_n_representative_docs, replace=False).astype(int)) - - # Convert indices to documents - self.representative_docs_ = {topic: [documents.iloc[doc_id].Document for doc_id in doc_ids] - for topic, doc_ids in - representative_docs.items()} - else: - documents_per_topic = documents.groupby('Topic').sample(n=500, replace=True, random_state=42).drop_duplicates() - self.representative_docs_ = {} - for topic in documents['Topic'].unique(): - - # Calculate similarity - selected_docs = documents_per_topic.loc[documents_per_topic.Topic == topic, "Document"].values - bow = self.vectorizer_model.transform(selected_docs) - ctfidf = self.ctfidf_model.transform(bow) - sim_matrix = cosine_similarity(ctfidf, self.c_tf_idf_[topic + self._outliers]) - - # Extract top 3 most representative documents - indices = np.argpartition(sim_matrix.reshape(1, -1)[0], - -top_n_representative_docs)[-top_n_representative_docs:] - self.representative_docs_[topic] = [selected_docs[index] for index in indices] - def _map_representative_docs(self, original_topics: bool = False): - """ Map the representative docs per topic to the correct topics - - If topics were reduced, remove documents from topics that were - merged into larger topics as we assume that the documents from - larger topics are better representative of the entire merged - topic. + Updates: + self.representative_docs_: Populate each topic with 3 representative docs + """ + repr_docs, _, _= self._extract_representative_docs(self.c_tf_idf_, + documents, + self.topic_representations_, + nr_samples=500, + nr_repr_docs=3) + self.representative_docs_ = repr_docs + + def _extract_representative_docs(self, + c_tf_idf: csr_matrix, + documents: pd.DataFrame, + topics: Mapping[str, List[Tuple[str, float]]], + nr_samples: int = 500, + nr_repr_docs: int = 5, + ) -> Union[List[str], List[List[int]]]: + """ Approximate most representative documents per topic by sampling + a subset of the documents in each topic and calculating which are + most represenative to their topic based on the cosine similarity between + c-TF-IDF representations. Arguments: - original_topics: Whether we want to map from the - original topics to the most recent topics - or from the second-most recent topics. - """ - mappings = self.topic_mapper_.get_mappings(original_topics) - if self.representative_docs_ is not None: - representative_docs = self.representative_docs_.copy() - else: - representative_docs = {} + c_tf_idf: The topic c-TF-IDF representation + documents: All input documents + topics: The candidate topics as calculated with c-TF-IDF + nr_samples: The number of candidate documents to extract per topic + nr_repr_docs: The number of representative documents to extract per topic - # Update the representative documents - updated_representative_docs = {mappings[old_topic]: [] - for old_topic, _ in representative_docs.items()} - for old_topic, docs in representative_docs.items(): - new_topic = mappings[old_topic] - updated_representative_docs[new_topic].extend(docs) + Returns: + repr_docs_mappings: A dictionary from topic to representative documents + representative_docs: A flat list of representative documents + repr_doc_indices: The indices of representative documents + that belong to each topic + """ + # Sample documents per topic + documents_per_topic = ( + documents.groupby('Topic') + .sample(n=nr_samples, replace=True, random_state=42) + .drop_duplicates() + ) - self.representative_docs_ = updated_representative_docs - self.representative_docs_.pop(-1, None) + # Find and extract documents that are most similar to the topic + repr_docs = [] + repr_docs_indices = [] + repr_docs_mappings = {} + labels = sorted(list(topics.keys())) + for index, topic in enumerate(labels): + + # Calculate similarity + selected_docs = documents_per_topic.loc[documents_per_topic.Topic == topic, "Document"].values + bow = self.vectorizer_model.transform(selected_docs) + ctfidf = self.ctfidf_model.transform(bow) + sim_matrix = cosine_similarity(ctfidf, c_tf_idf[index]) + + # Extract top n most representative documents + nr_docs = nr_repr_docs if len(selected_docs) > nr_repr_docs else len(selected_docs) + indices = np.argpartition(sim_matrix.reshape(1, -1)[0], + -nr_docs)[-nr_docs:] + repr_docs.extend([selected_docs[index] for index in indices]) + repr_docs_indices.append([repr_docs_indices[-1][-1] + i + 1 if index != 0 else i for i in range(nr_docs)]) + repr_docs_mappings = {topic: repr_docs[i[0]:i[-1]+1] for topic, i in zip(topics.keys(), repr_docs_indices)} + + return repr_docs_mappings, repr_docs, repr_docs_indices def _create_topic_vectors(self): """ Creates embeddings per topics based on their topic representation @@ -3050,7 +3039,11 @@ def _create_topic_vectors(self): if self.embedding_model is not None and type(self.embedding_model) is not BaseEmbedder: topic_list = list(self.topic_representations_.keys()) topic_list.sort() - n = self.top_n_words + + # Only extract top n words + n = len(self.topic_representations_[topic_list[0]]) + if self.top_n_words < n: + n = self.top_n_words # Extract embeddings for all words in all topics topic_words = [self.get_topic(topic) for topic in topic_list] @@ -3131,9 +3124,9 @@ def _update_topic_size(self, documents: pd.DataFrame): def _extract_words_per_topic(self, words: List[str], - c_tf_idf: csr_matrix = None, - labels: List[int] = None) -> Mapping[str, - List[Tuple[str, float]]]: + documents: pd.DataFrame, + c_tf_idf: csr_matrix = None) -> Mapping[str, + List[Tuple[str, float]]]: """ Based on tf_idf scores per topic, extract the top n words per topic If the top words per topic need to be extracted, then only the `words` parameter @@ -3143,8 +3136,8 @@ def _extract_words_per_topic(self, Arguments: words: List of all words (sorted according to tf_idf matrix position) + documents: DataFrame with documents and their topic IDs c_tf_idf: A c-TF-IDF matrix from which to calculate the top words - labels: A list of topic labels Returns: topics: The top words per topic @@ -3152,11 +3145,12 @@ def _extract_words_per_topic(self, if c_tf_idf is None: c_tf_idf = self.c_tf_idf_ - if labels is None: - labels = sorted(list(self.topic_sizes_.keys())) + labels = sorted(list(documents.Topic.unique())) + labels = [int(label) for label in labels] - # Get the top 30 indices and values per row in a sparse c-TF-IDF matrix - indices = self._top_n_idx_sparse(c_tf_idf, 30) + # Get at least the top 30 indices and values per row in a sparse c-TF-IDF matrix + top_n_words = max(self.top_n_words, 30) + indices = self._top_n_idx_sparse(c_tf_idf, top_n_words) scores = self._top_n_values_sparse(c_tf_idf, indices) sorted_indices = np.argsort(scores, 1) indices = np.take_along_axis(indices, sorted_indices, axis=1) @@ -3170,22 +3164,13 @@ def _extract_words_per_topic(self, ] for index, label in enumerate(labels)} - # Extract word embeddings for the top 30 words per topic and compare it - # with the topic embedding to keep only the words most similar to the topic embedding - if self.diversity is not None: - if self.embedding_model is not None: + # Fine-tune the topic representations + if isinstance(self.representation_model, list): + for tuner in self.representation_model: + topics = tuner.extract_topics(self, documents, c_tf_idf, topics) + elif isinstance(self.representation_model, BaseRepresentation): + topics = self.representation_model.extract_topics(self, documents, c_tf_idf, topics) - for topic, topic_words in topics.items(): - words = [word[0] for word in topic_words] - word_embeddings = self._extract_embeddings(words, - method="word", - verbose=False) - topic_embedding = self._extract_embeddings(" ".join(words), - method="word", - verbose=False).reshape(1, -1) - topic_words = mmr(topic_embedding, word_embeddings, words, - top_n=self.top_n_words, diversity=self.diversity) - topics[topic] = [(word, value) for word, value in topics[topic] if word in topic_words] topics = {label: values[:self.top_n_words] for label, values in topics.items()} return topics @@ -3221,30 +3206,28 @@ def _reduce_to_n_topics(self, documents: pd.DataFrame) -> pd.DataFrame: Returns: documents: Updated dataframe with documents and the reduced number of Topics """ - # Track which topics where originally merged - if not self._merged_topics: - self._merged_topics = [] + topics = documents.Topic.tolist().copy() - # Create topic similarity matrix - similarities = cosine_similarity(self.c_tf_idf_) - np.fill_diagonal(similarities, 0) + # Create topic distance matrix + if self.topic_embeddings_ is not None: + topic_embeddings = np.array(self.topic_embeddings_)[self._outliers:, ] + else: + topic_embeddings = self.c_tf_idf_[self._outliers:, ].toarray() + distance_matrix = 1-cosine_similarity(topic_embeddings) + np.fill_diagonal(distance_matrix, 0) - # Find most similar topic to least common topic - topics = documents.Topic.tolist().copy() - mapped_topics = {} - while len(self.get_topic_freq()) > self.nr_topics + self._outliers: - topic_to_merge = self.get_topic_freq().iloc[-1].Topic - topic_to_merge_into = np.argmax(similarities[topic_to_merge + self._outliers]) - self._outliers - similarities[:, topic_to_merge + self._outliers] = -self._outliers - self._merged_topics.append(topic_to_merge) - - # Update Topic labels - documents.loc[documents.Topic == topic_to_merge, "Topic"] = topic_to_merge_into - mapped_topics[topic_to_merge] = topic_to_merge_into - self._update_topic_size(documents) + # Cluster the topic embeddings using AgglomerativeClustering + if version.parse(sklearn_version) >= version.parse("1.4.0"): + cluster = AgglomerativeClustering(self.nr_topics - self._outliers, metric="precomputed", linkage="average") + else: + cluster = AgglomerativeClustering(self.nr_topics - self._outliers, affinity="precomputed", linkage="average") + cluster.fit(distance_matrix) + new_topics = [cluster.labels_[topic] if topic != -1 else -1 for topic in topics] # Map topics - mapped_topics = {from_topic: to_topic for from_topic, to_topic in zip(topics, documents.Topic.tolist())} + documents.Topic = new_topics + self._update_topic_size(documents) + mapped_topics = {from_topic: to_topic for from_topic, to_topic in zip(topics, new_topics)} self.topic_mapper_.add_mappings(mapped_topics) # Update representations diff --git a/bertopic/_mmr.py b/bertopic/_mmr.py deleted file mode 100644 index 75b66e85..00000000 --- a/bertopic/_mmr.py +++ /dev/null @@ -1,53 +0,0 @@ -import numpy as np -from sklearn.metrics.pairwise import cosine_similarity -from typing import List - - -def mmr(doc_embedding: np.ndarray, - word_embeddings: np.ndarray, - words: List[str], - top_n: int = 5, - diversity: float = 0.8) -> List[str]: - """ Calculate Maximal Marginal Relevance (MMR) - between candidate keywords and the document. - MMR considers the similarity of keywords/keyphrases with the - document, along with the similarity of already selected - keywords and keyphrases. This results in a selection of keywords - that maximize their within diversity with respect to the document. - - Arguments: - doc_embedding: The document embeddings - word_embeddings: The embeddings of the selected candidate keywords/phrases - words: The selected candidate keywords/keyphrases - top_n: The number of keywords/keyhprases to return - diversity: How diverse the select keywords/keyphrases are. - Values between 0 and 1 with 0 being not diverse at all - and 1 being most diverse. - - Returns: - List[str]: The selected keywords/keyphrases - """ - - # Extract similarity within words, and between words and the document - word_doc_similarity = cosine_similarity(word_embeddings, doc_embedding) - word_similarity = cosine_similarity(word_embeddings) - - # Initialize candidates and already choose best keyword/keyphras - keywords_idx = [np.argmax(word_doc_similarity)] - candidates_idx = [i for i in range(len(words)) if i != keywords_idx[0]] - - for _ in range(top_n - 1): - # Extract similarities within candidates and - # between candidates and selected keywords/phrases - candidate_similarities = word_doc_similarity[candidates_idx, :] - target_similarities = np.max(word_similarity[candidates_idx][:, keywords_idx], axis=1) - - # Calculate MMR - mmr = (1-diversity) * candidate_similarities - diversity * target_similarities.reshape(-1, 1) - mmr_idx = candidates_idx[np.argmax(mmr)] - - # Update keywords & candidates - keywords_idx.append(mmr_idx) - candidates_idx.remove(mmr_idx) - - return [words[idx] for idx in keywords_idx] \ No newline at end of file diff --git a/bertopic/_utils.py b/bertopic/_utils.py index 65a0890e..b6a33a2d 100644 --- a/bertopic/_utils.py +++ b/bertopic/_utils.py @@ -76,12 +76,15 @@ class NotInstalled: installed in order to use the string matching model. """ - def __init__(self, tool, dep): + def __init__(self, tool, dep, custom_msg=None): self.tool = tool self.dep = dep - msg = f"In order to use {self.tool} you'll need to install via;\n\n" - msg += f"pip install bertopic[{self.dep}]\n\n" + msg = f"In order to use {self.tool} you will need to install via;\n\n" + if custom_msg is not None: + msg += custom_msg + else: + msg += f"pip install bertopic[{self.dep}]\n\n" self.msg = msg def __getattr__(self, *args, **kwargs): diff --git a/bertopic/plotting/_barchart.py b/bertopic/plotting/_barchart.py index 5c1116aa..029fade6 100644 --- a/bertopic/plotting/_barchart.py +++ b/bertopic/plotting/_barchart.py @@ -11,7 +11,7 @@ def visualize_barchart(topic_model, top_n_topics: int = 8, n_words: int = 5, custom_labels: bool = False, - title: str = "Topic Word Scores", + title: str = "Topic Word Scores", width: int = 250, height: int = 250) -> go.Figure: """ Visualize a barchart of selected topics @@ -99,7 +99,7 @@ def visualize_barchart(topic_model, template="plotly_white", showlegend=False, title={ - 'text': f"{title}", + 'text': f"{title}", 'x': .5, 'xanchor': 'center', 'yanchor': 'top', diff --git a/bertopic/plotting/_distribution.py b/bertopic/plotting/_distribution.py index f13172a9..937000b4 100644 --- a/bertopic/plotting/_distribution.py +++ b/bertopic/plotting/_distribution.py @@ -6,6 +6,7 @@ def visualize_distribution(topic_model, probabilities: np.ndarray, min_probability: float = 0.015, custom_labels: bool = False, + title: str = "Topic Probability Distribution", width: int = 800, height: int = 600) -> go.Figure: """ Visualize the distribution of topic probabilities @@ -17,6 +18,7 @@ def visualize_distribution(topic_model, All others are ignored. custom_labels: Whether to use custom topic labels that were defined using `topic_model.set_topic_labels`. + title: Title of the plot. width: The width of the figure. height: The height of the figure. @@ -80,7 +82,7 @@ def visualize_distribution(topic_model, fig.update_layout( xaxis_title="Probability", title={ - 'text': "Topic Probability Distribution", + 'text': f"{title}", 'y': .95, 'x': 0.5, 'xanchor': 'center', diff --git a/bertopic/plotting/_documents.py b/bertopic/plotting/_documents.py index b927da53..0dd94e26 100644 --- a/bertopic/plotting/_documents.py +++ b/bertopic/plotting/_documents.py @@ -15,6 +15,7 @@ def visualize_documents(topic_model, hide_annotations: bool = False, hide_document_hover: bool = False, custom_labels: bool = False, + title: str = "Documents and Topics", width: int = 1200, height: int = 750): """ Visualize documents and their topics in 2D @@ -37,6 +38,7 @@ def visualize_documents(topic_model, specific points. Helps to speed up generation of visualization. custom_labels: Whether to use custom topic labels that were defined using `topic_model.set_topic_labels`. + title: Title of the plot. width: The width of the figure. height: The height of the figure. @@ -203,7 +205,7 @@ def visualize_documents(topic_model, fig.update_layout( template="simple_white", title={ - 'text': "Documents and Topics", + 'text': f"{title}", 'x': 0.5, 'xanchor': 'center', 'yanchor': 'top', diff --git a/bertopic/plotting/_heatmap.py b/bertopic/plotting/_heatmap.py index 545d24cb..5189dc56 100644 --- a/bertopic/plotting/_heatmap.py +++ b/bertopic/plotting/_heatmap.py @@ -12,6 +12,7 @@ def visualize_heatmap(topic_model, top_n_topics: int = None, n_clusters: int = None, custom_labels: bool = False, + title: str = "Similarity Matrix", width: int = 800, height: int = 800) -> go.Figure: """ Visualize a heatmap of the topic's similarity matrix @@ -27,6 +28,7 @@ def visualize_heatmap(topic_model, matrix by those clusters. custom_labels: Whether to use custom topic labels that were defined using `topic_model.set_topic_labels`. + title: Title of the plot. width: The width of the figure. height: The height of the figure. @@ -108,7 +110,7 @@ def visualize_heatmap(topic_model, fig.update_layout( title={ - 'text': "Similarity Matrix", + 'text': f"{title}", 'y': .95, 'x': 0.55, 'xanchor': 'center', diff --git a/bertopic/plotting/_hierarchical_documents.py b/bertopic/plotting/_hierarchical_documents.py index 4e54f6c5..dc919b55 100644 --- a/bertopic/plotting/_hierarchical_documents.py +++ b/bertopic/plotting/_hierarchical_documents.py @@ -17,6 +17,7 @@ def visualize_hierarchical_documents(topic_model, hide_document_hover: bool = True, nr_levels: int = 10, custom_labels: bool = False, + title: str = "Hierarchical Documents and Topics", width: int = 1200, height: int = 750) -> go.Figure: """ Visualize documents and their topics in 2D at different levels of hierarchy @@ -48,6 +49,7 @@ def visualize_hierarchical_documents(topic_model, `topic_model.set_topic_labels`. NOTE: Custom labels are only generated for the original un-merged topics. + title: Title of the plot. width: The width of the figure. height: The height of the figure. @@ -300,7 +302,7 @@ def visualize_hierarchical_documents(topic_model, sliders=sliders, template="simple_white", title={ - 'text': "Hierarchical Documents and Topics", + 'text': f"{title}", 'x': 0.5, 'xanchor': 'center', 'yanchor': 'top', diff --git a/bertopic/plotting/_hierarchy.py b/bertopic/plotting/_hierarchy.py index 364c582c..1948891a 100644 --- a/bertopic/plotting/_hierarchy.py +++ b/bertopic/plotting/_hierarchy.py @@ -15,6 +15,7 @@ def visualize_hierarchy(topic_model, topics: List[int] = None, top_n_topics: int = None, custom_labels: bool = False, + title: str = "Hierarchical Clustering", width: int = 1000, height: int = 600, hierarchical_topics: pd.DataFrame = None, @@ -37,6 +38,7 @@ def visualize_hierarchy(topic_model, `topic_model.set_topic_labels`. NOTE: Custom labels are only generated for the original un-merged topics. + title: Title of the plot. width: The width of the figure. Only works if orientation is set to 'left' height: The height of the figure. Only works if orientation is set to 'bottom' hierarchical_topics: A dataframe that contains a hierarchy of topics @@ -143,7 +145,7 @@ def visualize_hierarchy(topic_model, plot_bgcolor='#ECEFF1', template="plotly_white", title={ - 'text': "Hierarchical Clustering", + 'text': f"{title}", 'x': 0.5, 'xanchor': 'center', 'yanchor': 'top', diff --git a/bertopic/plotting/_term_rank.py b/bertopic/plotting/_term_rank.py index 329ce9d1..b6d939f9 100644 --- a/bertopic/plotting/_term_rank.py +++ b/bertopic/plotting/_term_rank.py @@ -7,6 +7,7 @@ def visualize_term_rank(topic_model, topics: List[int] = None, log_scale: bool = False, custom_labels: bool = False, + title: str = "Term score decline per Topic", width: int = 800, height: int = 500) -> go.Figure: """ Visualize the ranks of all terms across all topics @@ -23,6 +24,7 @@ def visualize_term_rank(topic_model, log_scale: Whether to represent the ranking on a log scale custom_labels: Whether to use custom topic labels that were defined using `topic_model.set_topic_labels`. + title: Title of the plot. width: The width of the figure. height: The height of the figure. @@ -103,7 +105,7 @@ def visualize_term_rank(topic_model, showlegend=False, template="plotly_white", title={ - 'text': "Term score decline per Topic", + 'text': f"{title}", 'y': .9, 'x': 0.5, 'xanchor': 'center', diff --git a/bertopic/plotting/_topics.py b/bertopic/plotting/_topics.py index 73308e71..656b03ad 100644 --- a/bertopic/plotting/_topics.py +++ b/bertopic/plotting/_topics.py @@ -11,6 +11,8 @@ def visualize_topics(topic_model, topics: List[int] = None, top_n_topics: int = None, + custom_labels: bool = False, + title: str = "Intertopic Distance Map", width: int = 650, height: int = 650) -> go.Figure: """ Visualize topics, their sizes, and their corresponding words @@ -22,6 +24,9 @@ def visualize_topics(topic_model, topic_model: A fitted BERTopic instance. topics: A selection of topics to visualize top_n_topics: Only select the top n most frequent topics + custom_labels: Whether to use custom topic labels that were defined using + `topic_model.set_topic_labels`. + title: Title of the plot. width: The width of the figure. height: The height of the figure. @@ -55,23 +60,27 @@ def visualize_topics(topic_model, # Extract topic words and their frequencies topic_list = sorted(topics) frequencies = [topic_model.topic_sizes_[topic] for topic in topic_list] - words = [" | ".join([word[0] for word in topic_model.get_topic(topic)[:5]]) for topic in topic_list] + if custom_labels and topic_model.custom_labels_ is not None: + words = [topic_model.custom_labels_[topic + topic_model._outliers] for topic in topic_list] + else: + words = [" | ".join([word[0] for word in topic_model.get_topic(topic)[:5]]) for topic in topic_list] # Embed c-TF-IDF into 2D all_topics = sorted(list(topic_model.get_topics().keys())) indices = np.array([all_topics.index(topic) for topic in topics]) embeddings = topic_model.c_tf_idf_.toarray()[indices] embeddings = MinMaxScaler().fit_transform(embeddings) - embeddings = UMAP(n_neighbors=2, n_components=2, metric='hellinger').fit_transform(embeddings) + embeddings = UMAP(n_neighbors=2, n_components=2, metric='hellinger', random_state=42).fit_transform(embeddings) # Visualize with plotly df = pd.DataFrame({"x": embeddings[:, 0], "y": embeddings[:, 1], "Topic": topic_list, "Words": words, "Size": frequencies}) - return _plotly_topic_visualization(df, topic_list, width, height) + return _plotly_topic_visualization(df, topic_list, title, width, height) def _plotly_topic_visualization(df: pd.DataFrame, topic_list: List[str], + title: str, width: int, height: int): """ Create plotly-based visualization of topics with a slider for topic selection """ @@ -94,7 +103,7 @@ def get_color(topic_selected): # Update hover order fig.update_traces(hovertemplate="
".join(["Topic %{customdata[0]}", - "Words: %{customdata[1]}", + "%{customdata[1]}", "Size: %{customdata[2]}"])) # Create a slider for topic selection @@ -104,7 +113,7 @@ def get_color(topic_selected): # Stylize layout fig.update_layout( title={ - 'text': "Intertopic Distance Map", + 'text': f"{title}", 'y': .95, 'x': 0.5, 'xanchor': 'center', diff --git a/bertopic/plotting/_topics_over_time.py b/bertopic/plotting/_topics_over_time.py index 2d6269b8..871abbaa 100644 --- a/bertopic/plotting/_topics_over_time.py +++ b/bertopic/plotting/_topics_over_time.py @@ -10,6 +10,7 @@ def visualize_topics_over_time(topic_model, topics: List[int] = None, normalize_frequency: bool = False, custom_labels: bool = False, + title: str = "Topics over Time", width: int = 1250, height: int = 450) -> go.Figure: """ Visualize topics over time @@ -23,6 +24,7 @@ def visualize_topics_over_time(topic_model, normalize_frequency: Whether to normalize each topic's frequency individually custom_labels: Whether to use custom topic labels that were defined using `topic_model.set_topic_labels`. + title: Title of the plot. width: The width of the figure. height: The height of the figure. @@ -91,7 +93,7 @@ def visualize_topics_over_time(topic_model, fig.update_layout( yaxis_title="Normalized Frequency" if normalize_frequency else "Frequency", title={ - 'text': "Topics over Time", + 'text': f"{title}", 'y': .95, 'x': 0.40, 'xanchor': 'center', diff --git a/bertopic/plotting/_topics_per_class.py b/bertopic/plotting/_topics_per_class.py index 8590700f..d9fb57fa 100644 --- a/bertopic/plotting/_topics_per_class.py +++ b/bertopic/plotting/_topics_per_class.py @@ -10,6 +10,7 @@ def visualize_topics_per_class(topic_model, topics: List[int] = None, normalize_frequency: bool = False, custom_labels: bool = False, + title: str = "Topics per Class", width: int = 1250, height: int = 900) -> go.Figure: """ Visualize topics per class @@ -23,6 +24,7 @@ def visualize_topics_per_class(topic_model, normalize_frequency: Whether to normalize each topic's frequency individually custom_labels: Whether to use custom topic labels that were defined using `topic_model.set_topic_labels`. + title: Title of the plot. width: The width of the figure. height: The height of the figure. @@ -98,7 +100,7 @@ def visualize_topics_per_class(topic_model, xaxis_title="Normalized Frequency" if normalize_frequency else "Frequency", yaxis_title="Class", title={ - 'text': "Topics per Class", + 'text': f"{title}", 'y': .95, 'x': 0.40, 'xanchor': 'center', diff --git a/bertopic/representation/__init__.py b/bertopic/representation/__init__.py new file mode 100644 index 00000000..321145ab --- /dev/null +++ b/bertopic/representation/__init__.py @@ -0,0 +1,52 @@ +from bertopic._utils import NotInstalled +from bertopic.representation._cohere import Cohere +from bertopic.representation._base import BaseRepresentation +from bertopic.representation._keybert import KeyBERTInspired +from bertopic.representation._mmr import MaximalMarginalRelevance + +# Text Generation using transformers +try: + from bertopic.representation._textgeneration import TextGeneration +except ModuleNotFoundError: + msg = "`pip install bertopic` without `--no-deps` \n\n" + TextGeneration = NotInstalled("TextGeneration", "transformers", custom_msg=msg) + +# Zero-shot classification using transformers +try: + from bertopic.representation._zeroshot import ZeroShotClassification +except ModuleNotFoundError: + msg = "`pip install bertopic` without `--no-deps` \n\n" + ZeroShotClassification = NotInstalled("ZeroShotClassification", "transformers", custom_msg=msg) + +# OpenAI Generator +try: + from bertopic.representation._openai import OpenAI +except ModuleNotFoundError: + msg = "`pip install openai` \n\n" + OpenAI = NotInstalled("OpenAI", "openai", custom_msg=msg) + +# OpenAI Generator +try: + from bertopic.representation._langchain import LangChain +except ModuleNotFoundError: + msg = "`pip install langchain` \n\n" + LangChain = NotInstalled("langchain", "langchain", custom_msg=msg) + +# POS using Spacy +try: + from bertopic.representation._pos import PartOfSpeech +except ModuleNotFoundError: + PartOfSpeech = NotInstalled("Part of Speech with Spacy", "spacy") + + +__all__ = [ + "BaseRepresentation", + "TextGeneration", + "ZeroShotClassification", + "KeyBERTInspired", + "PartOfSpeech", + "MaximalMarginalRelevance", + "Cohere", + "OpenAI", + "LangChain" +] diff --git a/bertopic/representation/_base.py b/bertopic/representation/_base.py new file mode 100644 index 00000000..cf3dcf75 --- /dev/null +++ b/bertopic/representation/_base.py @@ -0,0 +1,38 @@ +import pandas as pd +from scipy.sparse import csr_matrix +from sklearn.base import BaseEstimator +from typing import Mapping, List, Tuple + + +class BaseRepresentation(BaseEstimator): + """ The base representation model for fine-tuning topic representations """ + def extract_topics(self, + topic_model, + documents: pd.DataFrame, + c_tf_idf: csr_matrix, + topics: Mapping[str, List[Tuple[str, float]]] + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract topics + + Each representation model that inherits this class will have + its arguments (topic_model, documents, c_tf_idf, topics) + automatically passed. Therefore, the representation model + will only have access to the information about topics related + to those arguments. + + Arguments: + topic_model: The BERTopic model that is fitted until topic + representations are calculated. + documents: A dataframe with columns "Document" and "Topic" + that contains all documents with each corresponding + topic. + c_tf_idf: A c-TF-IDF representation that is typically + identical to `topic_model.c_tf_idf_` except for + dynamic, class-based, and hierarchical topic modeling + where it is calculated on a subset of the documents. + topics: A dictionary with topic (key) and tuple of word and + weight (value) as calculated by c-TF-IDF. This is the + default topics that are returned if no representation + model is used. + """ + return topic_model.topic_representations_ diff --git a/bertopic/representation/_cohere.py b/bertopic/representation/_cohere.py new file mode 100644 index 00000000..9212ad3c --- /dev/null +++ b/bertopic/representation/_cohere.py @@ -0,0 +1,143 @@ +import numpy as np +import pandas as pd +from scipy.sparse import csr_matrix +from typing import Mapping, List, Tuple, Union +from sklearn.metrics.pairwise import cosine_similarity +from bertopic.representation._base import BaseRepresentation + + +DEFAULT_PROMPT = """ +This is a list of texts where each collection of texts describe a topic. After each collection of texts, the name of the topic they represent is mentioned as a short-highly-descriptive title +--- +Topic: +Sample texts from this topic: +- Traditional diets in most cultures were primarily plant-based with a little meat on top, but with the rise of industrial style meat production and factory farming, meat has become a staple food. +- Meat, but especially beef, is the word food in terms of emissions. +- Eating meat doesn't make you a bad person, not eating meat doesn't make you a good one. + +Keywords: meat beef eat eating emissions steak food health processed chicken +Topic name: Environmental impacts of eating meat +--- +Topic: +Sample texts from this topic: +- I have ordered the product weeks ago but it still has not arrived! +- The website mentions that it only takes a couple of days to deliver but I still have not received mine. +- I got a message stating that I received the monitor but that is not true! +- It took a month longer to deliver than was advised... + +Keywords: deliver weeks product shipping long delivery received arrived arrive week +Topic name: Shipping and delivery issues +--- +""" + + +class Cohere(BaseRepresentation): + """ Use the Cohere API to generate topic labels based on their + generative model. + + Find more about their models here: + https://docs.cohere.ai/docs + + Arguments: + client: A cohere.Client + model: Model to use within Cohere, defaults to `"xlarge"`. + prompt: The prompt to be used in the model. If no prompt is given, + `self.default_prompt_` is used instead. + NOTE: Use `"[KEYWORDS]"` and `"[DOCUMENTS]"` in the prompt + to decide where the keywords and documents need to be + inserted. + + Usage: + + To use this, you will need to install cohere first: + + `pip install cohere` + + Then, get yourself an API key and use Cohere's API as follows: + + ```python + import cohere + from bertopic.representation import Cohere + from bertopic import BERTopic + + # Create your representation model + co = cohere.Client(my_api_key) + representation_model = Cohere(co) + + # Use the representation model in BERTopic on top of the default pipeline + topic_model = BERTopic(representation_model=representation_model) + ``` + + You can also use a custom prompt: + + ```python + prompt = "I have the following documents: [DOCUMENTS]. What topic do they contain?" + representation_model = Cohere(co, prompt=prompt) + ``` + """ + def __init__(self, + client, + model: str = "xlarge", + prompt: str = None, + ): + self.client = client + self.model = model + self.prompt = prompt if prompt is not None else DEFAULT_PROMPT + self.default_prompt_ = DEFAULT_PROMPT + + def extract_topics(self, + topic_model, + documents: pd.DataFrame, + c_tf_idf: csr_matrix, + topics: Mapping[str, List[Tuple[str, float]]] + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract topics + + Arguments: + topic_model: Not used + documents: Not used + c_tf_idf: Not used + topics: The candidate topics as calculated with c-TF-IDF + + Returns: + updated_topics: Updated topic representations + """ + # Extract the top 4 representative documents per topic + repr_docs_mappings, _, _ = topic_model._extract_representative_docs(c_tf_idf, documents, topics, 500, 4) + + # Generate using Cohere's Language Model + updated_topics = {} + for topic, docs in repr_docs_mappings.items(): + prompt = self._create_prompt(docs, topic, topics) + request = self.client.generate(model=self.model, + prompt=prompt, + max_tokens=50, + num_generations=1, + stop_sequences=["\n"]) + label = request.generations[0].text.strip() + updated_topics[topic] = [(label, 1)] + [("", 0) for _ in range(9)] + + return updated_topics + + def _create_prompt(self, docs, topic, topics): + keywords = list(zip(*topics[topic]))[0] + + # Use a prompt that leverages either keywords or documents in + # a custom location + prompt = "" + if "[KEYWORDS]" in self.prompt: + prompt += self.prompt.replace("[KEYWORDS]", keywords) + if "[DOCUMENTS]" in self.prompt: + to_replace = "" + for doc in docs: + to_replace += f"- {doc[:255]}\n" + prompt += self.prompt.replace("[DOCUMENTS]", to_replace) + + # Use the default prompt + if "[KEYWORDS]" and "[DOCUMENTS]" not in self.prompt: + prompt = self.prompt + 'Topic:\nSample texts from this topic:\n' + for doc in docs: + prompt += f"- {doc[:255]}\n" + prompt += "Keywords: " + " ".join(keywords) + prompt += "\nTopic name:" + return prompt diff --git a/bertopic/representation/_keybert.py b/bertopic/representation/_keybert.py new file mode 100644 index 00000000..10057882 --- /dev/null +++ b/bertopic/representation/_keybert.py @@ -0,0 +1,198 @@ +import numpy as np +import pandas as pd + +from packaging import version +from scipy.sparse import csr_matrix +from typing import Mapping, List, Tuple, Union +from sklearn.metrics.pairwise import cosine_similarity +from bertopic.representation._base import BaseRepresentation +from sklearn import __version__ as sklearn_version + + +class KeyBERTInspired(BaseRepresentation): + def __init__(self, + top_n_words: int = 10, + nr_repr_docs: int = 5, + nr_samples: int = 500, + nr_candidate_words: int = 100, + random_state: int = 42): + """ Use a KeyBERT-like model to fine-tune the topic representations + + The algorithm follows KeyBERT but does some optimization in + order to speed up inference. + + The steps are as follows. First, we extract the top n representative + documents per topic. To extract the representative documents, we + randomly sample a number of candidate documents per cluster + which is controlled by the `nr_samples` parameter. Then, + the top n representative documents are extracted by calculating + the c-TF-IDF representation for the candidate documents and finding, + through cosine similarity, which are closest to the topic c-TF-IDF representation. + Next, the top n words per topic are extracted based on their + c-TF-IDF representation, which is controlled by the `nr_repr_docs` + parameter. + + Then, we extract the embeddings for words and representative documents + and create topic embeddings by averaging the representative documents. + Finally, the most similar words to each topic are extracted by + calculating the cosine similarity between word and topic embeddings. + + Arguments: + top_n_words: The top n words to extract per topic. + nr_repr_docs: The number of representative documents to extract per cluster. + nr_samples: The number of candidate documents to extract per cluster. + nr_candidate_words: The number of candidate words per cluster. + random_state: The random state for randomly sampling candidate documents. + + Usage: + + ```python + from bertopic.representation import KeyBERTInspired + from bertopic import BERTopic + + # Create your representation model + representation_model = KeyBERTInspired() + + # Use the representation model in BERTopic on top of the default pipeline + topic_model = BERTopic(representation_model=representation_model) + ``` + """ + self.top_n_words = top_n_words + self.nr_repr_docs = nr_repr_docs + self.nr_samples = nr_samples + self.nr_candidate_words = nr_candidate_words + self.random_state = random_state + + def extract_topics(self, + topic_model, + documents: pd.DataFrame, + c_tf_idf: csr_matrix, + topics: Mapping[str, List[Tuple[str, float]]] + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract topics + + Arguments: + topic_model: A BERTopic model + documents: All input documents + c_tf_idf: The topic c-TF-IDF representation + topics: The candidate topics as calculated with c-TF-IDF + + Returns: + updated_topics: Updated topic representations + """ + # We extract the top n representative documents per class + _, representative_docs, repr_doc_indices = topic_model._extract_representative_docs(c_tf_idf, documents, topics, self.nr_samples, self.nr_repr_docs) + + # We extract the top n words per class + topics = self._extract_candidate_words(topic_model, c_tf_idf, topics) + + # We calculate the similarity between word and document embeddings and create + # topic embeddings from the representative document embeddings + sim_matrix, words = self._extract_embeddings(topic_model, topics, representative_docs, repr_doc_indices) + + # Find the best matching words based on the similarity matrix for each topic + updated_topics = self._extract_top_words(words, topics, sim_matrix) + + return updated_topics + + def _extract_candidate_words(self, + topic_model, + c_tf_idf: csr_matrix, + topics: Mapping[str, List[Tuple[str, float]]] + ) -> Mapping[str, List[Tuple[str, float]]]: + """ For each topic, extract candidate words based on the c-TF-IDF + representation. + + Arguments: + topic_model: A BERTopic model + c_tf_idf: The topic c-TF-IDF representation + topics: The top words per topic + + Returns: + topics: The `self.top_n_words` per topic + """ + labels = [int(label) for label in sorted(list(topics.keys()))] + + # Scikit-Learn Deprecation: get_feature_names is deprecated in 1.0 + # and will be removed in 1.2. Please use get_feature_names_out instead. + if version.parse(sklearn_version) >= version.parse("1.0.0"): + words = topic_model.vectorizer_model.get_feature_names_out() + else: + words = topic_model.vectorizer_model.get_feature_names() + + indices = topic_model._top_n_idx_sparse(c_tf_idf, self.nr_candidate_words) + scores = topic_model._top_n_values_sparse(c_tf_idf, indices) + sorted_indices = np.argsort(scores, 1) + indices = np.take_along_axis(indices, sorted_indices, axis=1) + scores = np.take_along_axis(scores, sorted_indices, axis=1) + + # Get top 30 words per topic based on c-TF-IDF score + topics = {label: [(words[word_index], score) + if word_index is not None and score > 0 + else ("", 0.00001) + for word_index, score in zip(indices[index][::-1], scores[index][::-1]) + ] + for index, label in enumerate(labels)} + topics = {label: list(zip(*values[:self.nr_candidate_words]))[0] for label, values in topics.items()} + + return topics + + def _extract_embeddings(self, + topic_model, + topics: Mapping[str, List[Tuple[str, float]]], + representative_docs: List[str], + repr_doc_indices: List[List[int]] + ) -> Union[np.ndarray, List[str]]: + """ Extract the representative document embeddings and create topic embeddings. + Then extract word embeddings and calculate the cosine similarity between topic + embeddings and the word embeddings. Topic embeddings are the average of + representative document embeddings. + + Arguments: + topic_model: A BERTopic model + topics: The top words per topic + representative_docs: A flat list of representative documents + repr_doc_indices: The indices of representative documents + that belong to each topic + + Returns: + sim: The similarity matrix between word and topic embeddings + vocab: The complete vocabulary of input documents + """ + # Calculate representative docs embeddings and create topic embeddings + repr_embeddings = topic_model._extract_embeddings(representative_docs, method="document", verbose=False) + topic_embeddings = [np.mean(repr_embeddings[i[0]:i[-1]+1], axis=0) for i in repr_doc_indices] + + # Calculate word embeddings and extract best matching with updated topic_embeddings + vocab = list(set([word for words in topics.values() for word in words])) + word_embeddings = topic_model._extract_embeddings(vocab, method="document", verbose=False) + sim = cosine_similarity(topic_embeddings, word_embeddings) + + return sim, vocab + + def _extract_top_words(self, + vocab: List[str], + topics: Mapping[str, List[Tuple[str, float]]], + sim: np.ndarray + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract the top n words per topic based on the + similarity matrix between topics and words. + + Arguments: + vocab: The complete vocabulary of input documents + labels: All topic labels + topics: The top words per topic + sim: The similarity matrix between word and topic embeddings + + Returns: + updated_topics: The updated topic representations + """ + labels = [int(label) for label in sorted(list(topics.keys()))] + updated_topics = {} + for i, topic in enumerate(labels): + indices = [vocab.index(word) for word in topics[topic]] + values = sim[:, indices][i] + word_indices = [indices[index] for index in np.argsort(values)[-self.top_n_words:]] + updated_topics[topic] = [(vocab[index], val) for val, index in zip(np.sort(values)[-self.top_n_words:], word_indices)][::-1] + + return updated_topics diff --git a/bertopic/representation/_langchain.py b/bertopic/representation/_langchain.py new file mode 100644 index 00000000..2f098fa9 --- /dev/null +++ b/bertopic/representation/_langchain.py @@ -0,0 +1,93 @@ +import pandas as pd +from scipy.sparse import csr_matrix +from typing import Mapping, List, Tuple +from langchain.docstore.document import Document +from bertopic.representation._base import BaseRepresentation + + +DEFAULT_PROMPT = "What are these documents about? Please give a single label." + + +class LangChain(BaseRepresentation): + """ Using chains in langchain to generate topic labels. + + Currently, only chains from question answering is implemented. See: + https://langchain.readthedocs.io/en/latest/modules/chains/combine_docs_examples/question_answering.html + + Arguments: + chain: A langchain chain that has two input parameters, `input_documents` and `query`. + prompt: The prompt to be used in the model. If no prompt is given, + `self.default_prompt_` is used instead. + + Usage: + + To use this, you will need to install the langchain package first. + Additionally, you will need an underlying LLM to support langchain, + like openai: + + `pip install langchain` + `pip install openai` + + Then, you can create your chain as follows: + + ```python + from langchain.chains.question_answering import load_qa_chain + from langchain.llms import OpenAI + chain = load_qa_chain(OpenAI(temperature=0, openai_api_key=my_openai_api_key), chain_type="stuff") + ``` + + Finally, you can pass the chain to BERTopic as follows: + + ```python + from bertopic.representation import LangChain + + # Create your representation model + representation_model = LangChain(chain) + + # Use the representation model in BERTopic on top of the default pipeline + topic_model = BERTopic(representation_model=representation_model) + ``` + + You can also use a custom prompt: + + ```python + prompt = "What are these documents about? Please give a single label." + representation_model = LangChain(chain, prompt=prompt) + ``` + """ + def __init__(self, + chain, + prompt: str = None, + ): + self.chain = chain + self.prompt = prompt if prompt is not None else DEFAULT_PROMPT + self.default_prompt_ = DEFAULT_PROMPT + + def extract_topics(self, + topic_model, + documents: pd.DataFrame, + c_tf_idf: csr_matrix, + topics: Mapping[str, List[Tuple[str, float]]] + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract topics + + Arguments: + topic_model: A BERTopic model + documents: All input documents + c_tf_idf: The topic c-TF-IDF representation + topics: The candidate topics as calculated with c-TF-IDF + + Returns: + updated_topics: Updated topic representations + """ + # Extract the top 4 representative documents per topic + repr_docs_mappings, _, _ = topic_model._extract_representative_docs(c_tf_idf, documents, topics, 500, 4) + + # Generate label using langchain + updated_topics = {} + for topic, docs in repr_docs_mappings.items(): + chain_docs = [Document(page_content=doc[:1000]) for doc in docs] + label = self.chain.run(input_documents=chain_docs, question=self.prompt).strip() + updated_topics[topic] = [(label, 1)] + [("", 0) for _ in range(9)] + + return updated_topics diff --git a/bertopic/representation/_mmr.py b/bertopic/representation/_mmr.py new file mode 100644 index 00000000..0f446c59 --- /dev/null +++ b/bertopic/representation/_mmr.py @@ -0,0 +1,110 @@ +import warnings +import numpy as np +import pandas as pd +from typing import List, Mapping, Tuple +from scipy.sparse import csr_matrix +from sklearn.metrics.pairwise import cosine_similarity +from bertopic.representation._base import BaseRepresentation + + +class MaximalMarginalRelevance(BaseRepresentation): + """ Calculate Maximal Marginal Relevance (MMR) + between candidate keywords and the document. + + MMR considers the similarity of keywords/keyphrases with the + document, along with the similarity of already selected + keywords and keyphrases. This results in a selection of keywords + that maximize their within diversity with respect to the document. + + Arguments: + diversity: How diverse the select keywords/keyphrases are. + Values range between 0 and 1 with 0 being not diverse at all + and 1 being most diverse. + top_n_words: The number of keywords/keyhprases to return + + Usage: + + ```python + from bertopic.representation import MaximalMarginalRelevance + from bertopic import BERTopic + + # Create your representation model + representation_model = MaximalMarginalRelevance(diversity=0.3) + + # Use the representation model in BERTopic on top of the default pipeline + topic_model = BERTopic(representation_model=representation_model) + ``` + """ + def __init__(self, diversity: float = 0.1, top_n_words: int = 10): + self.diversity = diversity + self.top_n_words = top_n_words + + def extract_topics(self, + topic_model, + documents: pd.DataFrame, + c_tf_idf: csr_matrix, + topics: Mapping[str, List[Tuple[str, float]]] + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract topic representations + + Arguments: + topic_model: The BERTopic model + documents: Not used + c_tf_idf: Not used + topics: The candidate topics as calculated with c-TF-IDF + + Returns: + updated_topics: Updated topic representations + """ + + if topic_model.embedding_model is None: + warnings.warn("MaximalMarginalRelevance can only be used BERTopic was instantiated" + "with the `embedding_model` parameter.") + return topics + + updated_topics = {} + for topic, topic_words in topics.items(): + words = [word[0] for word in topic_words] + word_embeddings = topic_model._extract_embeddings(words, method="word", verbose=False) + topic_embedding = topic_model._extract_embeddings(" ".join(words), method="word", verbose=False).reshape(1, -1) + topic_words = self._mmr(topic_embedding, word_embeddings, words) + updated_topics[topic] = [(word, value) for word, value in topics[topic] if word in topic_words] + return updated_topics + + def _mmr(self, + doc_embedding: np.ndarray, + word_embeddings: np.ndarray, + words: List[str]) -> List[str]: + """ + Arguments: + doc_embedding: The document embeddings + word_embeddings: The embeddings of the selected candidate keywords/phrases + words: The selected candidate keywords/keyphrases + + Returns: + List[str]: The selected keywords/keyphrases + """ + + # Extract similarity within words, and between words and the document + word_doc_similarity = cosine_similarity(word_embeddings, doc_embedding) + word_similarity = cosine_similarity(word_embeddings) + + # Initialize candidates and already choose best keyword/keyphras + keywords_idx = [np.argmax(word_doc_similarity)] + candidates_idx = [i for i in range(len(words)) if i != keywords_idx[0]] + + for _ in range(self.top_n_words - 1): + # Extract similarities within candidates and + # between candidates and selected keywords/phrases + candidate_similarities = word_doc_similarity[candidates_idx, :] + target_similarities = np.max(word_similarity[candidates_idx][:, keywords_idx], axis=1) + + # Calculate MMR + mmr = (1-self.diversity) * candidate_similarities - self.diversity * target_similarities.reshape(-1, 1) + mmr_idx = candidates_idx[np.argmax(mmr)] + + # Update keywords & candidates + keywords_idx.append(mmr_idx) + candidates_idx.remove(mmr_idx) + + return [words[idx] for idx in keywords_idx] diff --git a/bertopic/representation/_openai.py b/bertopic/representation/_openai.py new file mode 100644 index 00000000..1d7b518f --- /dev/null +++ b/bertopic/representation/_openai.py @@ -0,0 +1,145 @@ +import openai +import numpy as np +import pandas as pd +from scipy.sparse import csr_matrix +from typing import Mapping, List, Tuple, Union, Any +from sklearn.metrics.pairwise import cosine_similarity +from bertopic.representation._base import BaseRepresentation + + +DEFAULT_PROMPT = """ +This is a list of texts where each collection of texts describe a topic. After each collection of texts, the name of the topic they represent is mentioned as a short-highly-descriptive title +--- +Topic: +Sample texts from this topic: +- Traditional diets in most cultures were primarily plant-based with a little meat on top, but with the rise of industrial style meat production and factory farming, meat has become a staple food. +- Meat, but especially beef, is the word food in terms of emissions. +- Eating meat doesn't make you a bad person, not eating meat doesn't make you a good one. + +Keywords: meat beef eat eating emissions steak food health processed chicken +Topic name: Environmental impacts of eating meat +--- +Topic: +Sample texts from this topic: +- I have ordered the product weeks ago but it still has not arrived! +- The website mentions that it only takes a couple of days to deliver but I still have not received mine. +- I got a message stating that I received the monitor but that is not true! +- It took a month longer to deliver than was advised... + +Keywords: deliver weeks product shipping long delivery received arrived arrive week +Topic name: Shipping and delivery issues +--- +""" + + +class OpenAI(BaseRepresentation): + """ Using the OpenAI API to generate topic labels based + on one of their GPT-3 models. For an overview see: + + https://platform.openai.com/docs/models/gpt-3 + + Arguments: + generator_kwargs: Kwargs passed to `openai.Completion.create` + for fine-tuning the output. + prompt: The prompt to be used in the model. If no prompt is given, + `self.default_prompt_` is used instead. + NOTE: Use `"[KEYWORDS]"` and `"[DOCUMENTS]"` in the prompt + to decide where the keywords and documents need to be + inserted. + + Usage: + + To use this, you will need to install the openai package first: + + `pip install openai` + + Then, get yourself an API key and use OpenAI's API as follows: + + ```python + import openai + from bertopic.representation import OpenAI + from bertopic import BERTopic + + # Create your representation model + representation_model = OpenAI() + + # Use the representation model in BERTopic on top of the default pipeline + topic_model = BERTopic(representation_model=representation_model) + ``` + + You can also use a custom prompt: + + ```python + prompt = "I have the following documents: [DOCUMENTS]. What topic do they contain?" + representation_model = OpenAI(prompt=prompt) + ``` + """ + def __init__(self, + model: str = "text-ada-001", + prompt: str = None, + generator_kwargs: Mapping[str, Any] = {}, + ): + self.model = model + self.prompt = prompt if prompt is not None else DEFAULT_PROMPT + self.default_prompt_ = DEFAULT_PROMPT + + self.generator_kwargs = generator_kwargs + if self.generator_kwargs.get("model"): + self.model = generator_kwargs.get("model") + if self.generator_kwargs.get("prompt"): + del self.generator_kwargs["prompt"] + if not self.generator_kwargs.get("stop"): + self.generator_kwargs["stop"] = "\n" + + def extract_topics(self, + topic_model, + documents: pd.DataFrame, + c_tf_idf: csr_matrix, + topics: Mapping[str, List[Tuple[str, float]]] + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract topics + + Arguments: + topic_model: A BERTopic model + documents: All input documents + c_tf_idf: The topic c-TF-IDF representation + topics: The candidate topics as calculated with c-TF-IDF + + Returns: + updated_topics: Updated topic representations + """ + # Extract the top 4 representative documents per topic + repr_docs_mappings, _, _ = topic_model._extract_representative_docs(c_tf_idf, documents, topics, 500, 4) + + # Generate using OpenAI's Language Model + updated_topics = {} + for topic, docs in repr_docs_mappings.items(): + prompt = self._create_prompt(docs, topic, topics) + response = openai.Completion.create(model=self.model, prompt=prompt, **self.generator_kwargs) + label = response["choices"][0]["text"].strip() + updated_topics[topic] = [(label, 1)] + [("", 0) for _ in range(9)] + + return updated_topics + + def _create_prompt(self, docs, topic, topics): + keywords = list(zip(*topics[topic]))[0] + + # Use a prompt that leverages either keywords or documents in + # a custom location + prompt = "" + if "[KEYWORDS]" in self.prompt: + prompt += self.prompt.replace("[KEYWORDS]", " ".join(keywords)) + if "[DOCUMENTS]" in self.prompt: + to_replace = "" + for doc in docs: + to_replace += f"- {doc[:255]}\n" + prompt += self.prompt.replace("[DOCUMENTS]", to_replace) + + # Use the default prompt + if "[KEYWORDS]" and "[DOCUMENTS]" not in self.prompt: + prompt = self.prompt + 'Topic:\nSample texts from this topic:\n' + for doc in docs: + prompt += f"- {doc[:255]}\n" + prompt += "Keywords: " + " ".join(keywords) + prompt += "\nTopic name:" + return prompt diff --git a/bertopic/representation/_pos.py b/bertopic/representation/_pos.py new file mode 100644 index 00000000..9d5cd1f6 --- /dev/null +++ b/bertopic/representation/_pos.py @@ -0,0 +1,153 @@ + +import numpy as np +import pandas as pd + +import spacy +from spacy.matcher import Matcher +from spacy.language import Language + +from packaging import version +from scipy.sparse import csr_matrix +from typing import List, Mapping, Tuple, Union +from sklearn import __version__ as sklearn_version +from bertopic.representation._base import BaseRepresentation + + +class PartOfSpeech(BaseRepresentation): + """ Extract Topic Keywords based on their Part-of-Speech + + DEFAULT_PATTERNS = [ + [{'POS': 'ADJ'}, {'POS': 'NOUN'}], + [{'POS': 'NOUN'}], + [{'POS': 'ADJ'}] + ] + + From candidate topics, as extracted with c-TF-IDF, + find documents that contain keywords found in the + candidate topics. These candidate documents then + serve as the representative set of documents from + which the Spacy model can extract a set of candidate + keywords for each topic. + + These candidate keywords are first judged by whether + they fall within the DEFAULT_PATTERNS or the user-defined + pattern. Then, the resulting keywords are sorted by + their respective c-TF-IDF values. + + Arguments: + model: The Spacy model to use + top_n_words: The top n words to extract + pos_patterns: Patterns for Spacy to use. + See https://spacy.io/usage/rule-based-matching + + Usage: + + ```python + from bertopic.representation import PartOfSpeech + from bertopic import BERTopic + + # Create your representation model + representation_model = PartOfSpeech("en_core_web_sm") + + # Use the representation model in BERTopic on top of the default pipeline + topic_model = BERTopic(representation_model=representation_model) + ``` + + You can define custom POS patterns to be extracted: + + ```python + pos_patterns = [ + [{'POS': 'ADJ'}, {'POS': 'NOUN'}], + [{'POS': 'NOUN'}], [{'POS': 'ADJ'}] + ] + representation_model = PartOfSpeech("en_core_web_sm", pos_patterns=pos_patterns) + ``` + """ + def __init__(self, + model: Union[str, Language] = "en_core_web_sm", + top_n_words: int = 10, + pos_patterns: List[str] = None): + if isinstance(model, str): + self.model = spacy.load(model) + elif isinstance(model, Language): + self.model = model + else: + raise ValueError("Make sure that the Spacy model that you" + "pass is either a string referring to a" + "Spacy model or a Spacy nlp object.") + + self.top_n_words = top_n_words + + if pos_patterns is None: + self.pos_patterns = [ + [{'POS': 'ADJ'}, {'POS': 'NOUN'}], + [{'POS': 'NOUN'}], [{'POS': 'ADJ'}] + ] + else: + self.pos_patterns = pos_patterns + + def extract_topics(self, + topic_model, + documents: pd.DataFrame, + c_tf_idf: csr_matrix, + topics: Mapping[str, List[Tuple[str, float]]] + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract topics + + Arguments: + topic_model: A BERTopic model + documents: All input documents + c_tf_idf: Not used + topics: The candidate topics as calculated with c-TF-IDF + + Returns: + updated_topics: Updated topic representations + """ + matcher = Matcher(self.model.vocab) + matcher.add("Pattern", self.pos_patterns) + + candidate_topics = {} + for topic, values in topics.items(): + keywords = list(zip(*values))[0] + + # Extract candidate documents + candidate_documents = [] + for keyword in keywords: + selection = documents.loc[documents.Topic == topic, :] + selection = selection.loc[selection.Document.str.contains(keyword), "Document"] + if len(selection) > 0: + for document in selection[:2]: + candidate_documents.append(document) + candidate_documents = list(set(candidate_documents)) + + # Extract keywords + docs_pipeline = self.model.pipe(candidate_documents) + updated_keywords = [] + for doc in docs_pipeline: + matches = matcher(doc) + for _, start, end in matches: + updated_keywords.append(doc[start:end].text) + candidate_topics[topic] = list(set(updated_keywords)) + + # Scikit-Learn Deprecation: get_feature_names is deprecated in 1.0 + # and will be removed in 1.2. Please use get_feature_names_out instead. + if version.parse(sklearn_version) >= version.parse("1.0.0"): + words = list(topic_model.vectorizer_model.get_feature_names_out()) + else: + words = list(topic_model.vectorizer_model.get_feature_names()) + + # Match updated keywords with c-TF-IDF values + words_lookup = dict(zip(words, range(len(words)))) + updated_topics = {topic: [] for topic in topics.keys()} + + for topic, candidate_keywords in candidate_topics.items(): + word_indices = [words_lookup.get(keyword) for keyword in candidate_keywords if words_lookup.get(keyword)] + vals = topic_model.c_tf_idf_[:, np.array(word_indices)][topic + topic_model._outliers] + indices = np.argsort(np.array(vals.todense().reshape(1, -1))[0])[-10:][::-1] + vals = np.sort(np.array(vals.todense().reshape(1, -1))[0])[-10:][::-1] + topic_words = [(words[word_indices[index]], val) for index, val in zip(indices, vals)] + updated_topics[topic] = topic_words + if len(updated_topics[topic]) < self.top_n_words: + updated_topics[topic] += [("", 0) for _ in range(self.top_n_words-len(updated_topics[topic]))] + + return updated_topics diff --git a/bertopic/representation/_textgeneration.py b/bertopic/representation/_textgeneration.py new file mode 100644 index 00000000..ed960005 --- /dev/null +++ b/bertopic/representation/_textgeneration.py @@ -0,0 +1,139 @@ +import pandas as pd +from tqdm import tqdm +from scipy.sparse import csr_matrix +from transformers import pipeline, set_seed +from transformers.pipelines.base import Pipeline +from typing import Mapping, List, Tuple, Any, Union +from bertopic.representation._base import BaseRepresentation + + +DEFAULT_PROMPT = """ +I have a topic described by the following keywords: [KEYWORDS]. +The name of this topic is: +""" + + +class TextGeneration(BaseRepresentation): + """ Text2Text or text generation with transformers + + Arguments: + model: A transformers pipeline that should be initialized as "text-generation" + for gpt-like models or "text2text-generation" for T5-like models. + For example, `pipeline('text-generation', model='gpt2')`. If a string + is passed, "text-generation" will be selected by default. + prompt: The prompt to be used in the model. If no prompt is given, + `self.default_prompt_` is used instead. + NOTE: Use `"[KEYWORDS]"` and `"[DOCUMENTS]"` in the prompt + to decide where the keywords and documents need to be + inserted. + pipeline_kwargs: Kwargs that you can pass to the transformers.pipeline + when it is called. + random_state: A random state to be passed to `transformers.set_seed` + + Usage: + + To use a gpt-like model: + + ```python + from bertopic.representation import TextGeneration + from bertopic import BERTopic + + # Create your representation model + generator = pipeline('text-generation', model='gpt2') + representation_model = TextGeneration(generator) + + # Use the representation model in BERTopic on top of the default pipeline + topic_model = BERTo pic(representation_model=representation_model) + ``` + + You can use a custom prompt and decide where the keywords should + be inserted by using the `[KEYWORDS]` or documents with thte `[DOCUMENTS]` tag: + + ```python + from bertopic.representation import TextGeneration + + prompt = "I have a topic described by the following keywords: [KEYWORDS]. Based on the previous keywords, what is this topic about?"" + + # Create your representation model + generator = pipeline('text2text-generation', model='google/flan-t5-base') + representation_model = TextGeneration(generator) + ``` + """ + def __init__(self, + model: Union[str, pipeline], + prompt: str = None, + pipeline_kwargs: Mapping[str, Any] = {}, + random_state: int = 42): + set_seed(random_state) + if isinstance(model, str): + self.model = pipeline("text-generation", model=model) + elif isinstance(model, Pipeline): + self.model = model + else: + raise ValueError("Make sure that the HF model that you" + "pass is either a string referring to a" + "HF model or a `transformers.pipeline` object.") + self.prompt = prompt if prompt is not None else DEFAULT_PROMPT + self.default_prompt_ = DEFAULT_PROMPT + self.pipeline_kwargs = pipeline_kwargs + + def extract_topics(self, + topic_model, + documents: pd.DataFrame, + c_tf_idf: csr_matrix, + topics: Mapping[str, List[Tuple[str, float]]] + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract topic representations and return a single label + + Arguments: + topic_model: A BERTopic model + documents: Not used + c_tf_idf: Not used + topics: The candidate topics as calculated with c-TF-IDF + + Returns: + updated_topics: Updated topic representations + """ + # Extract the top 4 representative documents per topic + if self.prompt != DEFAULT_PROMPT and "[DOCUMENT]" in self.prompt: + repr_docs_mappings, _, _ = topic_model._extract_representative_docs(c_tf_idf, documents, topics, 500, 4) + else: + repr_docs_mappings = {topic: None for topic in topics.keys()} + + updated_topics = {} + for topic, topic_words in tqdm(topics.items(), disable=not topic_model.verbose): + + # Prepare prompt + prompt = self._create_prompt(repr_docs_mappings[topic], topic, topics) + + # Extract result from generator and use that as label + topic_description = self.model(prompt, **self.pipeline_kwargs) + topic_description = [(description["generated_text"].replace(prompt, ""), 1) for description in topic_description] + + if len(topic_description) < 10: + topic_description += [("", 0) for _ in range(10-len(topic_description))] + + updated_topics[topic] = topic_description + + return updated_topics + + def _create_prompt(self, docs, topic, topics): + keywords = ", ".join(list(zip(*topics[topic]))[0]) + prompt = "" + + # Use the default prompt and replace keywords + if self.prompt == DEFAULT_PROMPT: + prompt += self.prompt.replace("[KEYWORDS]", keywords) + + # Use a prompt that leverages either keywords or documents in + # a custom location + else: + if "[KEYWORDS]" in self.prompt: + prompt += self.prompt.replace("[KEYWORDS]", keywords) + if "[DOCUMENTS]" in self.prompt: + to_replace = "" + for doc in docs: + to_replace += f"- {doc[:255]}\n" + prompt += self.prompt.replace("[DOCUMENTS]", to_replace) + + return prompt diff --git a/bertopic/representation/_zeroshot.py b/bertopic/representation/_zeroshot.py new file mode 100644 index 00000000..eddd3fd8 --- /dev/null +++ b/bertopic/representation/_zeroshot.py @@ -0,0 +1,99 @@ +import pandas as pd +from transformers import pipeline +from transformers.pipelines.base import Pipeline +from scipy.sparse import csr_matrix +from typing import Mapping, List, Tuple, Any +from bertopic.representation._base import BaseRepresentation + + +class ZeroShotClassification(BaseRepresentation): + """ Zero-shot Classification on topic keywords with candidate labels + + Arguments: + candidate_topics: A list of labels to assign to the topics if they + exceed `min_prob` + model: A transformers pipeline that should be initialized as + "zero-shot-classification". For example, + `pipeline("zero-shot-classification", model="facebook/bart-large-mnli")` + pipeline_kwargs: Kwargs that you can pass to the transformers.pipeline + when it is called. NOTE: Use `{"multi_label": True}` + to extract multiple labels for each topic. + min_prob: The minimum probability to assign a candidate label to a topic + + Usage: + + ```python + from bertopic.representation import ZeroShotClassification + from bertopic import BERTopic + + # Create your representation model + candidate_topics = ["space and nasa", "bicycles", "sports"] + representation_model = ZeroShotClassification(candidate_topics, model="facebook/bart-large-mnli") + + # Use the representation model in BERTopic on top of the default pipeline + topic_model = BERTopic(representation_model=representation_model) + ``` + """ + def __init__(self, + candidate_topics: List[str], + model: str = "facebook/bart-large-mnli", + pipeline_kwargs: Mapping[str, Any] = {}, + min_prob: float = 0.8 + ): + self.candidate_topics = candidate_topics + if isinstance(model, str): + self.model = pipeline("zero-shot-classification", model=model) + elif isinstance(model, Pipeline): + self.model = model + else: + raise ValueError("Make sure that the HF model that you" + "pass is either a string referring to a" + "HF model or a `transformers.pipeline` object.") + self.pipeline_kwargs = pipeline_kwargs + self.min_prob = min_prob + + def extract_topics(self, + topic_model, + documents: pd.DataFrame, + c_tf_idf: csr_matrix, + topics: Mapping[str, List[Tuple[str, float]]] + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract topics + + Arguments: + topic_model: Not used + documents: Not used + c_tf_idf: Not used + topics: The candidate topics as calculated with c-TF-IDF + + Returns: + updated_topics: Updated topic representations + """ + # Classify topics + topic_descriptions = [" ".join(list(zip(*topics[topic]))[0]) for topic in topics.keys()] + classifications = self.model(topic_descriptions, self.candidate_topics, **self.pipeline_kwargs) + + # Extract labels + updated_topics = {} + for topic, classification in zip(topics.keys(), classifications): + topic_description = topics[topic] + + # Multi-label assignment + if self.pipeline_kwargs.get("multi_label"): + topic_description = [] + for label, score in zip(classification["labels"], classification["scores"]): + if score > self.min_prob: + topic_description.append((label, score)) + + # Single label assignment + elif classification["scores"][0] > self.min_prob: + topic_description = [(classification["labels"][0], classification["scores"][0])] + + # Make sure that 10 items are returned + if len(topic_description) == 0: + topic_description = topics[topic] + elif len(topic_description) < 10: + topic_description += [("", 0) for _ in range(10-len(topic_description))] + updated_topics[topic] = topic_description + + return updated_topics diff --git a/docs/algorithm/algorithm.md b/docs/algorithm/algorithm.md index 45594f88..2fa39bd1 100644 --- a/docs/algorithm/algorithm.md +++ b/docs/algorithm/algorithm.md @@ -26,12 +26,15 @@ After going through the visual overview, this code overview demonstrates the alg ```python -from sentence_transformers import SentenceTransformer from umap import UMAP from hdbscan import HDBSCAN +from sentence_transformers import SentenceTransformer from sklearn.feature_extraction.text import CountVectorizer -from bertopic.vectorizers import ClassTfidfTransformer + from bertopic import BERTopic +from bertopic.representation import KeyBERTInspired +from bertopic.vectorizers import ClassTfidfTransformer + # Step 1 - Extract embeddings embedding_model = SentenceTransformer("all-MiniLM-L6-v2") @@ -48,14 +51,18 @@ vectorizer_model = CountVectorizer(stop_words="english") # Step 5 - Create topic representation ctfidf_model = ClassTfidfTransformer() +# Step 6 - (Optional) Fine-tune topic representations with +# a `bertopic.representation` model +representation_model = KeyBERTInspired() + # All steps together topic_model = BERTopic( - embedding_model=embedding_model, # Step 1 - Extract embeddings - umap_model=umap_model, # Step 2 - Reduce dimensionality - hdbscan_model=hdbscan_model, # Step 3 - Cluster reduced embeddings - vectorizer_model=vectorizer_model, # Step 4 - Tokenize topics - ctfidf_model=ctfidf_model, # Step 5 - Extract topic words - diversity=0.5 # Step 6 - Diversify topic words + embedding_model=embedding_model, # Step 1 - Extract embeddings + umap_model=umap_model, # Step 2 - Reduce dimensionality + hdbscan_model=hdbscan_model, # Step 3 - Cluster reduced embeddings + vectorizer_model=vectorizer_model, # Step 4 - Tokenize topics + ctfidf_model=ctfidf_model, # Step 5 - Extract topic words + representation_model=representation_model # Step 6 - (Optional) Fine-tune topic represenations ) ``` @@ -128,17 +135,26 @@ Then, we take the logarithm of one plus the average number of words per class `A In the `ClassTfidfTransformer`, there are a few parameters that might be worth exploring, including an option to perform additional BM-25 weighting. You can find more information about that [here](https://maartengr.github.io/BERTopic/getting_started/ctfidf/ctfidf.html). -### **6. (Optional) Maximal Marginal Relevance** -After having generated the c-TF-IDF representations, we have a set of words that describe a collection of documents. -Technically, this does not mean that this collection of words describes a coherent topic. In practice, we will -see that many of the words do describe a similar topic but some words will, in a way, overfit the documents. For -example, if you have a set of documents that are written by the same person whose signature will be in the topic -description. -
-To improve the coherence of words, Maximal Marginal Relevance was used to find the most coherent words -without having too much overlap between the words themselves. This results in the removal of words that do not contribute -to a topic. -
-You can also use this technique to diversify the words generated in the topic representation. At times, many variations -of the same word can end up in the topic representation. To reduce the number of synonyms, we can increase the diversity -among words whilst still being similar to the topic representation. +### **6. (Optional) Fine-tune Topic representation** +After having generated the c-TF-IDF representations, we have a set of words that describe a collection of documents. c-TF-IDF +is a method that can quickly generate accurate topic representations. However, with the fast developments in NLP-world, new +and exciting methods are released weekly. In order to keep up with what is happening, there is the possibility to further fine-tune +these c-TF-IDF topics using GPT, T5, KeyBERT, Spacy, and other techniques. Many are implemented in BERTopic for you to use and play around with. + +More specifically, we can consider the c-TF-IDF generated topics to be candidate topics. They each contain a set of keywords and +representative documents that we can use to further fine-tune the topic representations. Having a set of representative documents +for each topic is huge advantage as it allows for fine-tuning on a reduced number of documents. This reduces computation for +large models as they only need to operate on that small set of representative documents for each topic. As a result, +large language models like GPT and T5 becomes feasible in production settings and typically take less wall time than the dimensionality reduction +and clustering steps. + +The following models are implemented in `bertopic.representation`: + +* `MaximalMarginalRelevance` +* `PartOfSpeech` +* `KeyBERTInspired` +* `ZeroShotClassification` +* `TextGeneration` +* `Cohere` +* `OpenAI` +* `LangChain` \ No newline at end of file diff --git a/docs/algorithm/default.svg b/docs/algorithm/default.svg index 206e10d8..15a8b739 100644 --- a/docs/algorithm/default.svg +++ b/docs/algorithm/default.svg @@ -1,41 +1,49 @@ - - - - - - -SBERT - - - - - -UMAP - - - - - -HDBSCAN - - - - - -CountVectorizer - - - - - -c-TF-IDF -Weighting scheme -Tokenizer -Clustering -Dimensionality Reduction -Embeddings - - - - + + + + + + +SBERT + + + + + +UMAP + + + + + +HDBSCAN + + + + + +CountVectorizer + + + + + +c-TF-IDF + + + + + +Optional Fine-tuning +Weighting scheme +Tokenizer +Clustering +Dimensionality Reduction +Embeddings + + +Fine-tune Representations + + + diff --git a/docs/algorithm/modularity.svg b/docs/algorithm/modularity.svg index a8596c70..5eda6def 100644 --- a/docs/algorithm/modularity.svg +++ b/docs/algorithm/modularity.svg @@ -1,226 +1,255 @@ - - - - - - -SBERT - - - - - -SpaCy - - - - - -Transformers - - - - - - - - - - - - - - - - - - - - - - - - -UMAP - - - - - -PCA - - - - - -TruncatedSVD - - - - - - - - -HDBSCAN - - - - - -CountVectorizer - - - - - -Jieba - - - - - -POS - - - - - -k-Means - - - - - -BIRCH - - - - - - - - - - - - -Embeddings -Dimensionality reduction -Clustering -Tokenizer -Weighting scheme - - - - - -SpaCy - - - - - -PCA - - - - - -k-Means - - - - - -CountVectorizer - - - - - -c-TF-IDF - - - - - -c-TF-IDF - - - - - -c-TF-IDF + MMR - - - - - -c-TF-IDF + BM25 - - - - - -TF-IDF - - - - - -TruncatedSVD - - - - - -BIRCH - - - - - -CountVectorizer - - - - - -c-TF-IDF + MMR - + + + + + + + + + +SBERT + + + + + +SpaCy + + + + + +Transformers + + + + + + + + + + + + + + + + + + + +Embeddings + + + + + +UMAP + + + + + +PCA + + + + + +TruncatedSVD + + + +Dimensionality Reduction + + + + + +HDBSCAN + + + + + +k-Means + + + + + +BIRCH + + + +Clustering + + + + + +CountVectorizer + + + + + +Jieba + + + + + +POS + + + +Tokenizer + + + + + +SpaCy + + + + + +PCA + + + + + +k-Means + + + + + +CountVectorizer + + + + + +c-TF-IDF + + + +Weighting scheme + + + + + +c-TF-IDF + + + + + +c-TF-IDF + BM25 + + + + + +c-TF-IDF + Normalization + + + +Representation Tuning +(optional) + + + + + +GPT / T5 + + + + + +KeyBERT + + + + + +MMR + + + + + +TF-IDF + + + + + +TruncatedSVD + + + + + +BIRCH + + + + + +CountVectorizer + + + + + +c-TF-IDF + BM25 + + + + + +GPT + - + - + - + - + - + - + - + - + diff --git a/docs/api/mmr.md b/docs/api/mmr.md deleted file mode 100644 index 8f41ed68..00000000 --- a/docs/api/mmr.md +++ /dev/null @@ -1,3 +0,0 @@ -# `Maximal Marginal Relevance` - -::: bertopic._mmr.mmr diff --git a/docs/api/representation/base.md b/docs/api/representation/base.md new file mode 100644 index 00000000..42384c29 --- /dev/null +++ b/docs/api/representation/base.md @@ -0,0 +1,3 @@ +# `BaseRepresentation` + +::: bertopic.representation._base.BaseRepresentation diff --git a/docs/api/representation/cohere.md b/docs/api/representation/cohere.md new file mode 100644 index 00000000..2301eea4 --- /dev/null +++ b/docs/api/representation/cohere.md @@ -0,0 +1,3 @@ +# `Cohere` + +::: bertopic.representation._cohere.Cohere diff --git a/docs/api/representation/generation.md b/docs/api/representation/generation.md new file mode 100644 index 00000000..39ba1739 --- /dev/null +++ b/docs/api/representation/generation.md @@ -0,0 +1,3 @@ +# `TextGeneration` + +::: bertopic.representation._textgeneration.TextGeneration diff --git a/docs/api/representation/keybert.md b/docs/api/representation/keybert.md new file mode 100644 index 00000000..8a10e08f --- /dev/null +++ b/docs/api/representation/keybert.md @@ -0,0 +1,3 @@ +# `KeyBERTInspired` + +::: bertopic.representation._keybert.KeyBERTInspired diff --git a/docs/api/representation/langchain.md b/docs/api/representation/langchain.md new file mode 100644 index 00000000..272b517b --- /dev/null +++ b/docs/api/representation/langchain.md @@ -0,0 +1,3 @@ +# `LangChain` + +::: bertopic.representation._langchain.LangChain diff --git a/docs/api/representation/mmr.md b/docs/api/representation/mmr.md new file mode 100644 index 00000000..afff1a00 --- /dev/null +++ b/docs/api/representation/mmr.md @@ -0,0 +1,3 @@ +# `MaximalMarginalRelevance` + +::: bertopic.representation._mmr.MaximalMarginalRelevance diff --git a/docs/api/representation/openai.md b/docs/api/representation/openai.md new file mode 100644 index 00000000..623dde9a --- /dev/null +++ b/docs/api/representation/openai.md @@ -0,0 +1,3 @@ +# `OpenAI` + +::: bertopic.representation.OpenAI diff --git a/docs/api/representation/pos.md b/docs/api/representation/pos.md new file mode 100644 index 00000000..4ec1eb17 --- /dev/null +++ b/docs/api/representation/pos.md @@ -0,0 +1,3 @@ +# `PartOfSpeech` + +::: bertopic.representation._pos.PartOfSpeech diff --git a/docs/api/representation/zeroshot.md b/docs/api/representation/zeroshot.md new file mode 100644 index 00000000..cd602591 --- /dev/null +++ b/docs/api/representation/zeroshot.md @@ -0,0 +1,3 @@ +# `ZeroShotClassification` + +::: bertopic.representation._zeroshot.ZeroShotClassification diff --git a/docs/changelog.md b/docs/changelog.md index 1e51fdcf..bda81116 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,239 @@ hide: # Changelog +## **Version 0.14.0** +*Release date: 14 February, 2023* + +

Highlights:

+ +* Fine-tune [topic representations](https://maartengr.github.io/BERTopic/getting_started/representation/representation.html) with `bertopic.representation` + * Diverse range of models, including KeyBERT, MMR, POS, Transformers, OpenAI, and more!' + * Create your own prompts for text generation models, like GPT3: + * Use `"[KEYWORDS]"` and `"[DOCUMENTS]"` in the prompt to decide where the keywords and and set of representative documents need to be inserted. + * Chain models to perform fine-grained fine-tuning + * Create and customize your represention model +* Improved the topic reduction technique when using `nr_topics=int` +* Added `title` parameters for all graphs ([#800](https://github.com/MaartenGr/BERTopic/issues/800)) + + +

Fixes:

+ +* Improve documentation ([#837](https://github.com/MaartenGr/BERTopic/issues/837), [#769](https://github.com/MaartenGr/BERTopic/issues/769), [#954](https://github.com/MaartenGr/BERTopic/issues/954), [#912](https://github.com/MaartenGr/BERTopic/issues/912), [#911](https://github.com/MaartenGr/BERTopic/issues/911)) +* Bump pyyaml ([#903](https://github.com/MaartenGr/BERTopic/issues/903)) +* Fix large number of representative docs ([#965](https://github.com/MaartenGr/BERTopic/issues/965)) +* Prevent stochastisch behavior in `.visualize_topics` ([#952](https://github.com/MaartenGr/BERTopic/issues/952)) +* Add custom labels parameter to `.visualize_topics` ([#976](https://github.com/MaartenGr/BERTopic/issues/976)) +* Fix cuML HDBSCAN type checks by [@FelSiq](https://github.com/FelSiq) in [#981](https://github.com/MaartenGr/BERTopic/pull/981) + +

API Changes:

+* The `diversity` parameter was removed in favor of `bertopic.representation.MaximalMarginalRelevance` +* The `representation_model` parameter was added to `bertopic.BERTopic` + +
+ +

Representation Models

+ +Fine-tune the c-TF-IDF representation with a variety of models. Whether that is through a KeyBERT-Inspired model or GPT-3, the choice is up to you! + + + +
+ + +

KeyBERTInspired

+ +The algorithm follows some principles of [KeyBERT](https://github.com/MaartenGr/KeyBERT) but does some optimization in order to speed up inference. Usage is straightforward: + +![keybertinspired](https://user-images.githubusercontent.com/25746895/216336376-d2c4e5d6-6cf7-435c-904c-fc195aae7dcd.svg) + +```python +from bertopic.representation import KeyBERTInspired +from bertopic import BERTopic + +# Create your representation model +representation_model = KeyBERTInspired() + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +![keybert](https://user-images.githubusercontent.com/25746895/218417161-bfd5980e-43c7-498a-904a-b6018ba58d45.svg) + +

PartOfSpeech

+ +Our candidate topics, as extracted with c-TF-IDF, do not take into account a keyword's part of speech as extracting noun-phrases from all documents can be computationally quite expensive. Instead, we can leverage c-TF-IDF to perform part of speech on a subset of keywords and documents that best represent a topic. + +![partofspeech](https://user-images.githubusercontent.com/25746895/216336534-48ff400e-72e1-4c50-9030-414576bac01e.svg) + + +```python +from bertopic.representation import PartOfSpeech +from bertopic import BERTopic + +# Create your representation model +representation_model = PartOfSpeech("en_core_web_sm") + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +![pos](https://user-images.githubusercontent.com/25746895/218417198-41c19b5c-251f-43c1-bfe2-0a480731565a.svg) + + +

MaximalMarginalRelevance

+ +When we calculate the weights of keywords, we typically do not consider whether we already have similar keywords in our topic. Words like "car" and "cars" +essentially represent the same information and often redundant. We can use `MaximalMarginalRelevance` to improve diversity of our candidate topics: + +![mmr](https://user-images.githubusercontent.com/25746895/216336697-558f1409-8da3-4076-a21b-d87eec583ac7.svg) + + +```python +from bertopic.representation import MaximalMarginalRelevance +from bertopic import BERTopic + +# Create your representation model +representation_model = MaximalMarginalRelevance(diversity=0.3) + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +![mmr (1)](https://user-images.githubusercontent.com/25746895/218417234-88b145e2-7293-43c0-888c-36abe469a48a.svg) + +

Zero-Shot Classification

+ +To perform zero-shot classification, we feed the model with the keywords as generated through c-TF-IDF and a set of candidate labels. If, for a certain topic, we find a similar enough label, then it is assigned. If not, then we keep the original c-TF-IDF keywords. + +We use it in BERTopic as follows: + +```python +from bertopic.representation import ZeroShotClassification +from bertopic import BERTopic + +# Create your representation model +candidate_topics = ["space and nasa", "bicycles", "sports"] +representation_model = ZeroShotClassification(candidate_topics, model="facebook/bart-large-mnli") + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +![zero](https://user-images.githubusercontent.com/25746895/218417276-dcef3519-acba-4792-8601-45dc7ed39488.svg) + +

Text Generation: 🤗 Transformers

+ +Nearly every week, there are new and improved models released on the 🤗 [Model Hub](https://huggingface.co/models) that, with some creativity, allow for +further fine-tuning of our c-TF-IDF based topics. These models range from text generation to zero-classification. In BERTopic, wrappers around these +methods are created as a way to support whatever might be released in the future. + +Using a GPT-like model from the huggingface hub is rather straightforward: + +```python +from bertopic.representation import TextGeneration +from bertopic import BERTopic + +# Create your representation model +representation_model = TextGeneration('gpt2') + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +![hf](https://user-images.githubusercontent.com/25746895/218417310-2b0eabc7-296d-499d-888b-0ab48a65a2fb.svg) + + +

Text Generation: Cohere

+ +Instead of using a language model from 🤗 transformers, we can use external APIs instead that +do the work for you. Here, we can use [Cohere](https://docs.cohere.ai/) to extract our topic labels from the candidate documents and keywords. +To use this, you will need to install cohere first: + +```bash +pip install cohere +``` + +Then, get yourself an API key and use Cohere's API as follows: + +```python +import cohere +from bertopic.representation import Cohere +from bertopic import BERTopic + +# Create your representation model +co = cohere.Client(my_api_key) +representation_model = Cohere(co) + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +![cohere](https://user-images.githubusercontent.com/25746895/218417337-294cb52a-93c9-4fd5-b981-29b40e4f0c1e.svg) + + +

Text Generation: OpenAI

+ +Instead of using a language model from 🤗 transformers, we can use external APIs instead that +do the work for you. Here, we can use [OpenAI](https://openai.com/api/) to extract our topic labels from the candidate documents and keywords. +To use this, you will need to install openai first: + +``` +pip install openai +``` + +Then, get yourself an API key and use OpenAI's API as follows: + +```python +import openai +from bertopic.representation import OpenAI +from bertopic import BERTopic + +# Create your representation model +openai.api_key = MY_API_KEY +representation_model = OpenAI() + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +![openai](https://user-images.githubusercontent.com/25746895/218417357-cf8c0fab-4450-43d3-b4fd-219ed276d870.svg) + + +

Text Generation: LangChain

+ +[Langchain](https://github.com/hwchase17/langchain) is a package that helps users with chaining large language models. +In BERTopic, we can leverage this package in order to more efficiently combine external knowledge. Here, this +external knowledge are the most representative documents in each topic. + +To use langchain, you will need to install the langchain package first. Additionally, you will need an underlying LLM to support langchain, +like openai: + +```bash +pip install langchain, openai +``` + +Then, you can create your chain as follows: + +```python +from langchain.chains.question_answering import load_qa_chain +from langchain.llms import OpenAI +chain = load_qa_chain(OpenAI(temperature=0, openai_api_key=MY_API_KEY), chain_type="stuff") +``` + +Finally, you can pass the chain to BERTopic as follows: + +```python +from bertopic.representation import LangChain + +# Create your representation model +representation_model = LangChain(chain) + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + + ## **Version 0.13.0** *Release date: 4 January, 2023* diff --git a/docs/faq.md b/docs/faq.md index ca45654b..0f7f9b71 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -121,7 +121,7 @@ from cuml.manifold import UMAP # Create instances of GPU-accelerated UMAP and HDBSCAN umap_model = UMAP(n_components=5, n_neighbors=15, min_dist=0.0) -hdbscan_model = HDBSCAN(min_samples=10, gen_min_span_tree=True) +hdbscan_model = HDBSCAN(min_samples=10, gen_min_span_tree=True, prediction_data=True) # Pass the above models to be used in BERTopic topic_model = BERTopic(umap_model=umap_model, hdbscan_model=hdbscan_model) @@ -246,7 +246,7 @@ from cuml.manifold import UMAP # Create instances of GPU-accelerated UMAP and HDBSCAN umap_model = UMAP(n_components=5, n_neighbors=15, min_dist=0.0) -hdbscan_model = HDBSCAN(min_samples=10, gen_min_span_tree=True) +hdbscan_model = HDBSCAN(min_samples=10, gen_min_span_tree=True, prediction_data=True) # Pass the above models to be used in BERTopic topic_model = BERTopic(umap_model=umap_model, hdbscan_model=hdbscan_model) diff --git a/docs/getting_started/clustering/clustering.md b/docs/getting_started/clustering/clustering.md index 7f847cdd..45e885f6 100644 --- a/docs/getting_started/clustering/clustering.md +++ b/docs/getting_started/clustering/clustering.md @@ -98,7 +98,7 @@ we can use [cuML](https://rapids.ai/start.html#rapids-release-selector) to speed from bertopic import BERTopic from cuml.cluster import HDBSCAN -hdbscan_model = HDBSCAN(min_samples=10, gen_min_span_tree=True) +hdbscan_model = HDBSCAN(min_samples=10, gen_min_span_tree=True, prediction_data=True) topic_model = BERTopic(hdbscan_model=hdbscan_model) ``` diff --git a/docs/getting_started/clustering/clustering.svg b/docs/getting_started/clustering/clustering.svg index bf36ce61..7c7b3ad7 100644 --- a/docs/getting_started/clustering/clustering.svg +++ b/docs/getting_started/clustering/clustering.svg @@ -1,47 +1,53 @@ - - - - - - -SBERT - - - - - -UMAP - - - - - -HDBSCAN - - - - - -CountVectorizer - - - - - -c-TF-IDF - - - - - -k-Means - - - - - -BIRCH - - - + + + + + + +SBERT + + + + + +UMAP + + + + + +HDBSCAN + + + + + +CountVectorizer + + + + + +c-TF-IDF + + + + + +Optional Fine-tuning + + + + + +k-Means + + + + + +BIRCH + + + diff --git a/docs/getting_started/ctfidf/ctfidf.svg b/docs/getting_started/ctfidf/ctfidf.svg index ef5299c3..7aa36a19 100644 --- a/docs/getting_started/ctfidf/ctfidf.svg +++ b/docs/getting_started/ctfidf/ctfidf.svg @@ -1,47 +1,53 @@ - - - - - - -SBERT - - - - - -UMAP - - - - - -HDBSCAN - - - - - -c-TF-IDF + 
BM25 - - - - - -CountVectorizer - - - - - -c-TF-IDF - - - - - -c-TF-IDF + MMR - - - + + + + + + +SBERT + + + + + +UMAP + + + + + +HDBSCAN + + + + + +c-TF-IDF + BM25 + + + + + +CountVectorizer + + + + + +c-TF-IDF + + + + + +Optional Fine-tuning + + + + + +c-TF-IDF + Normalization + + + diff --git a/docs/getting_started/dim_reduction/dimensionality.svg b/docs/getting_started/dim_reduction/dimensionality.svg index 58413491..6d347cb8 100644 --- a/docs/getting_started/dim_reduction/dimensionality.svg +++ b/docs/getting_started/dim_reduction/dimensionality.svg @@ -1,47 +1,53 @@ - - - - - - -SBERT - - - - - -UMAP - - - - - -PCA - - - - - -TruncatedSVD - - - - - - - - -HDBSCAN - - - - - -CountVectorizer - - - - - -c-TF-IDF + + + + + + +SBERT + + + + + +UMAP + + + + + +PCA + + + + + +TruncatedSVD + + + + + + + + +HDBSCAN + + + + + +CountVectorizer + + + + + +c-TF-IDF + + + + + +Optional Fine-tuning diff --git a/docs/getting_started/embeddings/embeddings.md b/docs/getting_started/embeddings/embeddings.md index 0840f598..4973704f 100644 --- a/docs/getting_started/embeddings/embeddings.md +++ b/docs/getting_started/embeddings/embeddings.md @@ -173,33 +173,7 @@ topic_model = BERTopic(embedding_model=pipe) be able to support online learning then you might want to explore the [scikit-partial](https://github.com/koaning/scikit-partial) project. Moreover, since this backend does not generate representations on a word level, - it does not support the `diversity` parameter. - -### **Word + Document Embeddings** -You might want to be using different language models for creating document and word embeddings. For example, -while SentenceTransformers might be great in embedding sentences and documents, you might prefer to use -FastText to create the word embeddings. - -```python -from bertopic.backend import WordDocEmbedder -import gensim.downloader as api -from sentence_transformers import SentenceTransformer - -# Word embedding model -ft = api.load('fasttext-wiki-news-subwords-300') - -# Document embedding model -embedding_model = SentenceTransformer("all-MiniLM-L6-v2") - -# Create a model that uses both language models and pass it through BERTopic -word_doc_embedder = WordDocEmbedder(embedding_model=embedding_model, word_embedding_model=ft) -topic_model = BERTopic(embedding_model=word_doc_embedder) -``` - -!!! note - The word embeddings are only created when applying MMR. In other words, - to use this feature, you will have to select a value for `diversity` between 0 and 1 when - instantiating BERTopic. + it does not support the `bertopic.representation` models. ### **Custom Backend** If your backend or model cannot be found in the ones currently available, you can use the `bertopic.backend.BaseEmbedder` class to diff --git a/docs/getting_started/embeddings/embeddings.svg b/docs/getting_started/embeddings/embeddings.svg index 96cfae15..a982c53f 100644 --- a/docs/getting_started/embeddings/embeddings.svg +++ b/docs/getting_started/embeddings/embeddings.svg @@ -1,97 +1,103 @@ - - - - - - -SpaCy - - - - - -SBERT - - - - - -Transformers - - - - - - - - - - - - - - - - - - - - - - - - -UMAP - - - - - -HDBSCAN - - - - - -CountVectorizer - - - - - -c-TF-IDF + + + + + + +SpaCy + + + + + +SBERT + + + + + +Transformers + + + + + + + + + + + + + + + + + + + + + + + + +UMAP + + + + + +HDBSCAN + + + + + +CountVectorizer + + + + + +c-TF-IDF + + + + + +Optional Fine-tuning - + - + - + - + - + - + - + - + diff --git a/docs/getting_started/outlier_reduction/outlier_reduction.md b/docs/getting_started/outlier_reduction/outlier_reduction.md index b40944ca..8033ce7b 100644 --- a/docs/getting_started/outlier_reduction/outlier_reduction.md +++ b/docs/getting_started/outlier_reduction/outlier_reduction.md @@ -103,6 +103,21 @@ new_topics = topic_model.reduce_outliers(docs, topics, strategy="embeddings") reduction process for the `"embeddings"` strategy as it will prevent re-calculating the document embeddings. +### **Chain Strategies** + +Since the `.reduce_outliers` function does not internally update the topics, we can easily try out different strategies but also chain them together. +You might want to do a first pass with the `"c-tf-idf"` strategy as it is quite fast. Then, we can perform the `"distributions"` strategy on the +outliers that are left since this method is typically much slower: + +```python +# Use the "c-TF-IDF" strategy with a threshold +new_topics = topic_model.reduce_outliers(docs, new_topics , strategy="c-tf-idf", threshold=0.1) + +# Reduce all outliers that are left with the "distributions" strategy +new_topics = topic_model.reduce_outliers(docs, topics, strategy="distributions") +``` + + ## **Update Topics** After generating our updated topics, we can feed them back into BERTopic in one of two ways. We can either update the topic representations themselves based on the documents that now belong to new topics or we can only update the topic frequency without updating the topic representations themselves. @@ -122,19 +137,6 @@ topic_model.update_topics(docs, topics=new_topics) As seen above, you will only need to pass the documents on which the model was trained including the new topics that were generated using one of the above four strategies. - -### **Update Topic Frequency** - -```python -import pandas as pd -topic_model.topics_ = new_topics -documents = pd.DataFrame({"Document": docs, "Topic": new_topics}) -topic_model._update_topic_size(documents) -``` - -topic_model.get_topic_info() - - ### **Exploration** When you are reducing the number of topics, it might be worthwhile to iteratively visualize the results in order to get an intuitive understanding of the effect of the above four strategies. Making use of `.visualize_documents`, we can quickly iterate over the different strategies and view their effects. Here, an example will be shown on how to approach such a pipeline. diff --git a/docs/getting_started/representation/cohere.svg b/docs/getting_started/representation/cohere.svg new file mode 100644 index 00000000..f343c935 --- /dev/null +++ b/docs/getting_started/representation/cohere.svg @@ -0,0 +1,13 @@ + + + meat | organic | food | beef | emissions | eat | of | eating | is the | explosion | atmosphere | eruption | kilometers | of | immune | system | your | cells | my | and | is | the | how | of moon | earth | lunar | tides | the | water | orbit | base | moons eu | european | democratic | vote | parliament | member | union plastic | plastics | tons | pollution | waste | microplastics | polymers +Default Representation + Organic food Exploding planets How your immune system works How tides work How democratic is the European Union? Plastic pollution +Cohere + + + + + + + diff --git a/docs/getting_started/representation/hf.svg b/docs/getting_started/representation/hf.svg new file mode 100644 index 00000000..11838507 --- /dev/null +++ b/docs/getting_started/representation/hf.svg @@ -0,0 +1,13 @@ + + meat | organic | food | beef | emissions | eat | of | eating | is the | explosion | atmosphere | eruption | kilometers | of | immune | system | your | cells | my | and | is | the | how | of moon | earth | lunar | tides | the | water | orbit | base | moons eu | european | democratic | vote | parliament | member | union plastic | plastics | tons | pollution | waste | microplastics | polymers + +Default Representation + beef volcanoes immune system earth european union cotton +🤗 Transformers + + + + + + + diff --git a/docs/getting_started/representation/keybert.svg b/docs/getting_started/representation/keybert.svg new file mode 100644 index 00000000..fe6d02c4 --- /dev/null +++ b/docs/getting_started/representation/keybert.svg @@ -0,0 +1,13 @@ + + meat | organic | food | beef | emissions | eat | of | eating | is the | explosion | atmosphere | eruption | kilometers | of | immune | system | your | cells | my | and | is | the | how | of moon | earth | lunar | tides | the | water | orbit | base | moons eu | european | democratic | vote | parliament | member | union plastic | plastics | tons | pollution | waste | microplastics | polymers + +Default Representation + organic | meat | foods | crops | beef | produce | food | diet | cows | eating explosion | explodes | eruptions | eruption | blast | volcanoes | volcanic immune | immunology | antibodies | disease | cells | infection | cell | system moon | moons | lunar | tides | tidal | gravity | orbit | satellites | earth | orbits eu | democracy | european | democratic | parliament | governments | voting plastics | plastic | pollution | microplastics | environmental | polymers | bpa +KeyBERT-Inspired + + + + + + + diff --git a/docs/getting_started/representation/keybertinspired.svg b/docs/getting_started/representation/keybertinspired.svg new file mode 100644 index 00000000..5bb8b91b --- /dev/null +++ b/docs/getting_started/representation/keybertinspired.svg @@ -0,0 +1,23 @@ + + + n +Topic + +Extract representative documents + +Embed candidate keywords + +Compare embedded keywords with embedded documents + +Embed and average documents + + + + + + + +Extract candidate keywords +Compare c-TF-IDF sampled documents with the topic c-TF-IDF. +Extract top n words per topic based on their c-TF-IDF scores + diff --git a/docs/getting_started/representation/mmr.svg b/docs/getting_started/representation/mmr.svg new file mode 100644 index 00000000..8c8553d7 --- /dev/null +++ b/docs/getting_started/representation/mmr.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/getting_started/representation/mmr_output.svg b/docs/getting_started/representation/mmr_output.svg new file mode 100644 index 00000000..58279d75 --- /dev/null +++ b/docs/getting_started/representation/mmr_output.svg @@ -0,0 +1,13 @@ + + meat | organic | food | beef | emissions | eat | of | eating | is the | explosion | atmosphere | eruption | kilometers | of | immune | system | your | cells | my | and | is | the | how | of moon | earth | lunar | tides | the | water | orbit | base | moons eu | european | democratic | vote | parliament | member | union plastic | plastics | tons | pollution | waste | microplastics | polymers + +Default Representation + meat | organic | beef | emissions | health | pesticides | foods | farming | conventional explosion | atmosphere | eruption | eruptions | crust | volcanoes | earthquakes immune | system | cells | immunology | adaptive | body | memory | antibodies moon | lunar | tides | moons | surface | gravity | tide | meters | oceans | dust eu | democratic | vote | parliament | citizen | laws | institutions | influence | nations plastics | tons | pollution | waste | microplastics | polymers | ocean | bpa | cotton +MaximalMarginalRelevance + + + + + + + diff --git a/docs/getting_started/representation/openai.svg b/docs/getting_started/representation/openai.svg new file mode 100644 index 00000000..f7a0f327 --- /dev/null +++ b/docs/getting_started/representation/openai.svg @@ -0,0 +1,13 @@ + + meat | organic | food | beef | emissions | eat | of | eating | is the | explosion | atmosphere | eruption | kilometers | of | immune | system | your | cells | my | and | is | the | how | of moon | earth | lunar | tides | the | water | orbit | base | moons eu | european | democratic | vote | parliament | member | union plastic | plastics | tons | pollution | waste | microplastics | polymers + +Default Representation + Organic vs Conventional Food: Environmental and Health Considerations Volcanic Eruptions and Impacts The Immune System: Understanding and Boosting Immunity The Moon's Tides and Orbit Phenomena Democracy in the European Union Plastic Pollution and its environmental impact +OpenAI + + + + + + + diff --git a/docs/getting_started/representation/partofspeech.svg b/docs/getting_started/representation/partofspeech.svg new file mode 100644 index 00000000..bac96efe --- /dev/null +++ b/docs/getting_started/representation/partofspeech.svg @@ -0,0 +1,17 @@ + + + n +Topic + +Extract documents that contain at least one keyword + +Sort keywords by their c-TF-IDF value + +Use the POS matcher on those documents to generate new candidate keywords + + + + + +Extract candidate keywords + diff --git a/docs/getting_started/representation/pos.svg b/docs/getting_started/representation/pos.svg new file mode 100644 index 00000000..78d20b48 --- /dev/null +++ b/docs/getting_started/representation/pos.svg @@ -0,0 +1,14 @@ + + meat | organic | food | beef | emissions | eat | of | eating | is the | explosion | atmosphere | eruption | kilometers | of | immune | system | your | cells | my | and | is | the | how | of moon | earth | lunar | tides | the | water | orbit | base | moons eu | european | democratic | vote | parliament | member | union plastic | plastics | tons | pollution | waste | microplastics | polymers + +Default Representation + meat | organic | food | beef | emissions | most | health | pesticides | production explosion | atmosphere | eruption | kilometers | eruptions | fireball | super immune | system | cells | immunology | adaptive | body | memory | cell moon | earth | lunar | tides | water | orbit | base | moons | surface | gravity democratic | vote | parliament | member | union | states | national | countries plastic | plastics | tons | pollution | waste | microplastics | polymers | bag + +PartOfSpeech + + + + + + + diff --git a/docs/getting_started/representation/representation.md b/docs/getting_started/representation/representation.md new file mode 100644 index 00000000..a071761e --- /dev/null +++ b/docs/getting_started/representation/representation.md @@ -0,0 +1,415 @@ +One of the core components of BERTopic is its Bag-of-Words representation and weighting with c-TF-IDF. This method is fast and can quickly generate a number of keywords for a topic without depending on the clustering task. As a result, topics can easily and quickly be updated after training the model without the need to re-train it. +Although these give good topic representations, we may want to further fine-tune the topic representations. + +As such, there are a number of representation models implemented in BERTopic that allows for further fine-tuning of the topic representations. These are optional +and are **not used by default**. You are not restrained by the how the representation can be fine-tuned, from GPT-like models to fast keyword extraction +with KeyBERT-like models: + + + +For each model below, an example will be shown on how it may change or improve upon the default topic keywords that are generated. The dataset used in these examples can be found [here](https://www.kaggle.com/datasets/maartengr/kurzgesagt-transcriptions). + +## **KeyBERTInspired** + +After having generated our topics with c-TF-IDF, we might want to do some fine-tuning based on the semantic +relationship between keywords/keyphrases and the set of documents in each topic. Although we can use a centroid-based +technique for this, it can be costly and does not take the structure of a cluster into account. Instead, we leverage +c-TF-IDF to create a set of representative documents per topic and use those as our updated topic embedding. Then, we calculate +the similarity between candidate keywords and the topic embedding using the same embedding model that embedded the documents. + +
+
+--8<-- "docs/getting_started/representation/keybertinspired.svg" +
+
+ +Thus, the algorithm follows some principles of [KeyBERT](https://github.com/MaartenGr/KeyBERT) but does some optimization in +order to speed up inference. Usage is straightforward: + +```python +from bertopic.representation import KeyBERTInspired +from bertopic import BERTopic + +# Create your representation model +representation_model = KeyBERTInspired() + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +
+
+--8<-- "docs/getting_started/representation/keybert.svg" +
+
+ +## **PartOfSpeech** +Our candidate topics, as extracted with c-TF-IDF, do not take into account a keyword's part of speech as extracting noun-phrases from +all documents can be computationally quite expensive. Instead, we can leverage c-TF-IDF to perform part of speech on a subset of +keywords and documents that best represent a topic. + +
+
+--8<-- "docs/getting_started/representation/partofspeech.svg" +
+
+ +More specifically, we find documents that contain the keywords from our candidate topics as calculated with c-TF-IDF. These documents serve +as the representative set of documents from which the Spacy model can extract a set of candidate keywords for each topic. +These candidate keywords are first put through Spacy's POS module to see whether they match with the `DEFAULT_PATTERNS`: + +```python +DEFAULT_PATTERNS = [ + [{'POS': 'ADJ'}, {'POS': 'NOUN'}], + [{'POS': 'NOUN'}], + [{'POS': 'ADJ'}] +] +``` + +These patterns follow Spacy's [Rule-Based Matching](https://spacy.io/usage/rule-based-matching). Then, the resulting keywords are sorted by +their respective c-TF-IDF values. + +```python +from bertopic.representation import PartOfSpeech +from bertopic import BERTopic + +# Create your representation model +representation_model = PartOfSpeech("en_core_web_sm") + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +
+
+--8<-- "docs/getting_started/representation/pos.svg" +
+
+ +You can define custom POS patterns to be extracted: + +```python +pos_patterns = [ + [{'POS': 'ADJ'}, {'POS': 'NOUN'}], + [{'POS': 'NOUN'}], [{'POS': 'ADJ'}] +] +representation_model = PartOfSpeech("en_core_web_sm", pos_patterns=pos_patterns) +``` + + +## **MaximalMarginalRelevance** +When we calculate the weights of keywords, we typically do not consider whether we already have similar keywords in our topic. Words like "car" and "cars" +essentially represent the same information and often redundant. + +
+
+--8<-- "docs/getting_started/representation/mmr.svg" +
+
+ + + +To decrease this redundancy and improve the diversity of keywords, we can use an algorithm called Maximal Marginal Relevance (MMR). MMR considers the similarity of keywords/keyphrases with the document, along with the similarity of already selected keywords and keyphrases. This results in a selection of keywords +that maximize their within diversity with respect to the document. + + +```python +from bertopic.representation import MaximalMarginalRelevance +from bertopic import BERTopic + +# Create your representation model +representation_model = MaximalMarginalRelevance(diversity=0.3) + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +
+
+--8<-- "docs/getting_started/representation/mmr_output.svg" +
+
+ +## **Zero-Shot Classification** + +For some use cases, you might already have a set of candidate labels that you would like to automatically assign to some of the topics. +Although we can use guided or supervised BERTopic for that, we can also use zero-shot classification to assign labels to our topics. +For that, we can make use of 🤗 transformers on their models on the [model hub](https://huggingface.co/models?pipeline_tag=zero-shot-classification&sort=downloads). + +To perform this classification, we feed the model with the keywords as generated through c-TF-IDF and a set of candidate labels. +If, for a certain topic, we find a similar enough label, then it is assigned. If not, then we keep the original c-TF-IDF keywords. + +We use it in BERTopic as follows: + +```python +from bertopic.representation import ZeroShotClassification +from bertopic import BERTopic + +# Create your representation model +candidate_topics = ["space and nasa", "bicycles", "sports"] +representation_model = ZeroShotClassification(candidate_topics, model="facebook/bart-large-mnli") + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +
+
+--8<-- "docs/getting_started/representation/zero.svg" +
+
+ +## **Text Generation** + +Text generation models, like GPT-3 and the well-known ChatGPT, are becoming more and more capable of generating sensible output. +For that purpose, a number of models are exposed in BERTopic that allow topic labels to be created based on candidate documents and keywords +for each topic. These candidate documents and keywords are created from BERTopic's c-TF-IDF calculation. A huge benefit of this is that we can +describe a topic with only a few documents and we therefore do not need to pass all documents to the text generation model. Not only speeds +this the generation of topic labels up significantly, you also do not need a massive amount of credits when using an external API, such as Cohere or OpenAI. + + +!!! tip Tip + You can access the default prompts of these models with `representation_model.default_prompt_` + +### **🤗 Transformers** + +Nearly every week, there are new and improved models released on the 🤗 [Model Hub](https://huggingface.co/models) that, with some creativity, allow for +further fine-tuning of our c-TF-IDF based topics. These models range from text generation to zero-classification. In BERTopic, wrappers around these +methods are created as a way to support whatever might be released in the future. + +Using a GPT-like model from the huggingface hub is rather straightforward: + +```python +from bertopic.representation import TextGeneration +from bertopic import BERTopic + +# Create your representation model +representation_model = TextGeneration('gpt2') + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +You can use a custom prompt and decide where the keywords should +be inserted by using the `[KEYWORDS]` or documents with the `[DOCUMENTS]` tag: + +```python +from transformers import pipeline +from bertopic.representation import TextGeneration + +prompt = "I have a topic described by the following keywords: [KEYWORDS]. Based on the previous keywords, what is this topic about?"" + +# Create your representation model +generator = pipeline('text2text-generation', model='google/flan-t5-base') +representation_model = TextGeneration(generator) +``` + +
+
+--8<-- "docs/getting_started/representation/hf.svg" +
+
+ +As can be seen from the example above, if you would like to use a `text2text-generation` model, you will to +pass a `transformers.pipeline` with the `"text2text-generation"` parameter. + + +### **Cohere** + +Instead of using a language model from 🤗 transformers, we can use external APIs instead that +do the work for you. Here, we can use [Cohere](https://docs.cohere.ai/) to extract our topic labels from the candidate documents and keywords. +To use this, you will need to install cohere first: + +```bash +pip install cohere +``` + +Then, get yourself an API key and use Cohere's API as follows: + +```python +import cohere +from bertopic.representation import Cohere +from bertopic import BERTopic + +# Create your representation model +co = cohere.Client(my_api_key) +representation_model = Cohere(co) + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +
+
+--8<-- "docs/getting_started/representation/cohere.svg" +
+
+ +You can also use a custom prompt: + +```python +prompt = """ +I have topic that contains the following documents: [DOCUMENTS]. +The topic is described by the following keywords: [KEYWORDS]. +Based on the above information, can you give a short label of the topic? +""" +representation_model = Cohere(co, prompt=prompt) +``` + +### **OpenAI** + +Instead of using a language model from 🤗 transformers, we can use external APIs instead that +do the work for you. Here, we can use [OpenAI](https://openai.com/api/) to extract our topic labels from the candidate documents and keywords. +To use this, you will need to install openai first: + +`pip install openai` + +Then, get yourself an API key and use OpenAI's API as follows: + +```python +import openai +from bertopic.representation import OpenAI +from bertopic import BERTopic + +# Create your representation model +openai.api_key = MY_API_KEY +representation_model = OpenAI() + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +
+
+--8<-- "docs/getting_started/representation/openai.svg" +
+
+ +You can also use a custom prompt: + +```python +prompt = "I have the following documents: [DOCUMENTS]. What topic do they contain?" +representation_model = OpenAI(prompt=prompt) +``` + +### **LangChain** + +[Langchain](https://github.com/hwchase17/langchain) is a package that helps users with chaining large language models. +In BERTopic, we can leverage this package in order to more efficiently combine external knowledge. Here, this +external knowledge are the most representative documents in each topic. + +To use langchain, you will need to install the langchain package first. Additionally, you will need an underlying LLM to support langchain, +like openai: + +```bash +pip install langchain, openai +``` + +Then, you can create your chain as follows: + +```python +from langchain.chains.question_answering import load_qa_chain +from langchain.llms import OpenAI +chain = load_qa_chain(OpenAI(temperature=0, openai_api_key=my_openai_api_key), chain_type="stuff") +``` + +Finally, you can pass the chain to BERTopic as follows: + +```python +from bertopic.representation import LangChain + +# Create your representation model +representation_model = LangChain(chain) + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` + +You can also use a custom prompt: + +```python +prompt = "What are these documents about? Please give a single label." +representation_model = LangChain(chain, prompt=prompt) +``` + +!!! note Note + The prompt does not make use of `[KEYWORDS]` and `[DOCUMENTS]` tags as + the documents are already used within langchain's `load_qa_chain`. + + +## **Chain Models** + +All of the above models can make use of the candidate topics, as generated by c-TF-IDF, to further fine-tune the topic representations. For example, `MaximalMarginalRelevance` takes the keywords in the candidate topics and re-ranks them. Similarly, the keywords in the candidate topic can be used as the input for GPT-prompts in `OpenAI`. + +Although the default candidate topics are generated by c-TF-IDF, what if we were to chain these models? For example, we can use `MaximalMarginalRelevance` to improve upon the keywords in each topic before passing them to `OpenAI`. + +This is supported in BERTopic by simply passing a list of representation models when instantation the topic model: + +```python +from bertopic.representation import MaximalMarginalRelevance, OpenAI +from bertopic import BERTopic +import openai + +# Create your representation models +openai.api_key = MY_API_KEY +openai_generator = OpenAI() +mmr = MaximalMarginalRelevance(diversity=0.3) +representation_models = [mmr, openai_generator] + +# Use the chained models +topic_model = BERTopic(representation_model=representation_models) +``` + +## **Custom Model** + +Although several representation models have been implemented in BERTopic, new technologies get released often and we should not have to wait until they get implemented in BERTopic. Therefore, you can create your own representation model and use that to fine-tune the topics. + +The following is the basic structure for creating your custom model. Note that it returns the same topics as the those +calculated with c-TF-IDF: + +```python +from bertopic.representation._base import BaseRepresentation + + +class CustomRepresentationModel(BaseRepresentation): + def __init__(self): + pass + + def extract_topics(self, topic_model, documents, c_tf_idf, topics + ) -> Mapping[str, List[Tuple[str, float]]]: + """ Extract topics + + Arguments: + topic_model: The BERTopic model + documents: A dataframe of documents with their related topics + c_tf_idf: The c-TF-IDF matrix + topics: The candidate topics as calculated with c-TF-IDF + + Returns: + updated_topics: Updated topic representations + """ + updated_topics = topics.copy() + return updated_topics +``` + +Then, we can use that model as follows: + +```python +from bertopic import BERTopic + +# Create our custom representation model +representation_model = CustomRepresentationModel() + +# Pass our custom representation model to BERTopic +topic_model = BERTopic(representation_model=representation_model) +``` + +There are a few things to take into account when creating your custom model: + +* It needs to have the exact same parameter input: `topic_model`, `documents`, `c_tf_idf`, `topics`. +* You can change the `__init__` however you want, it does not influence the underlying structure +* Make sure that `updated_topics` has the exact same structure as `topics`: + * For example: `updated_topics = {"1", [("space", 0.9), ("nasa", 0.7)], "2": [("science", 0.66), ("article", 0.6)]`} + * Thus, it is a dictionary where each topic is represented by a list of keyword,value tuples. +* Lastly, make sure that `updated_topics` contains at least 5 keywords, even if they are empty: `[("", 0), ("", 0), ...]` \ No newline at end of file diff --git a/docs/getting_started/representation/representation.svg b/docs/getting_started/representation/representation.svg new file mode 100644 index 00000000..3344dfd4 --- /dev/null +++ b/docs/getting_started/representation/representation.svg @@ -0,0 +1,53 @@ + + + + + + +SBERT + + + + + +UMAP + + + + + +HDBSCAN + + + + + +GPT / T5 + + + + + +CountVectorizer + + + + + +c-TF-IDF + + + + + +KeyBERT + + + + + +MMR + + + + diff --git a/docs/getting_started/representation/zero.svg b/docs/getting_started/representation/zero.svg new file mode 100644 index 00000000..0248dae4 --- /dev/null +++ b/docs/getting_started/representation/zero.svg @@ -0,0 +1,13 @@ + + meat | organic | food | beef | emissions | eat | of | eating | is the | explosion | atmosphere | eruption | kilometers | of | immune | system | your | cells | my | and | is | the | how | of moon | earth | lunar | tides | the | water | orbit | base | moons eu | european | democratic | vote | parliament | member | union plastic | plastics | tons | pollution | waste | microplastics | polymers + +Default Representation + Organic food the | explosion | atmosphere | eruption | kilometers | of Your immune system moon | earth | lunar | tides | the | water | orbit | base | moons eu | european | democratic | vote | parliament | member | union plastic | plastics | tons | pollution | waste | microplastics | polymers +ZeroShotClassification + + + + + + + diff --git a/docs/getting_started/tips_and_tricks/tips_and_tricks.md b/docs/getting_started/tips_and_tricks/tips_and_tricks.md index 11db646b..94805576 100644 --- a/docs/getting_started/tips_and_tricks/tips_and_tricks.md +++ b/docs/getting_started/tips_and_tricks/tips_and_tricks.md @@ -2,9 +2,10 @@ ## **Document length** -As a default, we are using sentence-transformers to embed our documents. However, as the name implies, the embedding model works best for either sentences or paragraphs. This means that whenever you have a set of documents, where each documents contains several paragraphs, BERTopic will struggle getting accurately extracting a topic from that document. Several paragraphs typically means several topics and BERTopic will assign only one topic to a document. +As a default, we are using sentence-transformers to embed our documents. However, as the name implies, the embedding model works best for either sentences or paragraphs. This means that whenever you have a set of documents, where each documents contains several paragraphs, the document is truncated and the topic model is only trained on a small part of the data. + +One way to solve this issue is by splitting up longer documents into either sentences or paragraphs before embedding them. Another solution is to approximate the [topic distributions](https://maartengr.github.io/BERTopic/getting_started/distribution/distribution.html) of topics after having trained your topic model. -Therefore, it is advised to split up longer documents into either sentences or paragraphs before embedding them. That way, BERTopic will have a much easier job identifying topics in isolation. ## **Removing stop words** At times, stop words might end up in our topic representations. This is something we typically want to avoid as they contribute little to the interpretation of the topics. However, removing stop words as a preprocessing step is not advised as the transformer-based embedding models that we use need the full context in order to create accurate embeddings. @@ -31,10 +32,22 @@ ctfidf_model = ClassTfidfTransformer(reduce_frequent_words=True) topic_model = BERTopic(ctfidf_model=ctfidf_model) ``` +Lastly, we can use a KeyBERT-Inspired model to reduce the appearance of stop words. This also often improves the topic representation: + +```python +from bertopic.representation import KeyBERTInspired +from bertopic import BERTopic + +# Create your representation model +representation_model = KeyBERTInspired() + +# Use the representation model in BERTopic on top of the default pipeline +topic_model = BERTopic(representation_model=representation_model) +``` ## **Diversify topic representation** After having calculated our top *n* words per topic there might be many words that essentially -mean the same thing. As a little bonus, we can use the `diversity` parameter in BERTopic to +mean the same thing. As a little bonus, we can use `bertopic.representation.MaximalMarginalRelevance` in BERTopic to diversity words in each topic such that we limit the number of duplicate words we find in each topic. This is done using an algorithm called Maximal Marginal Relevance which compares word embeddings with the topic embedding. @@ -43,18 +56,23 @@ We do this by specifying a value between 0 and 1, with 0 being not at all divers ```python from bertopic import BERTopic -topic_model = BERTopic(diversity=0.2) +from bertopic.representation import MaximalMarginalRelevance + +representation_model = MaximalMarginalRelevance(diversity=0.2) +topic_model = BERTopic(representation_model=representation_model) ``` Since MMR is using word embeddings to diversify the topic representations, it is necessary to pass the embedding model to BERTopic if you are using pre-computed embeddings: ```python from bertopic import BERTopic +from bertopic.representation import MaximalMarginalRelevance from sentence_transformers import SentenceTransformer sentence_model = SentenceTransformer("all-MiniLM-L6-v2") embeddings = sentence_model.encode(docs, show_progress_bar=False) -topic_model = BERTopic(embedding_model=sentence_model, diversity=0.2) +representation_model = MaximalMarginalRelevance(diversity=0.2) +topic_model = BERTopic(embedding_model=sentence_model, representation_model=representation_model) ``` @@ -69,11 +87,6 @@ topic_term_matrix = topic_model.c_tf_idf_ words = topic_model.vectorizer_model.get_feature_names() ``` -!!! note - This only works if you have set `diversity=None`, for all other values the top *n* are - further optimized using MMR which is not represented in the topic-term matrix as it does - not optimize the entire matrix. - ## **Pre-compute embeddings** Typically, we want to iterate fast over different versions of our BERTopic model whilst we are trying to optimize it to a specific use case. To speed up this process, we can pre-compute the embeddings, save them, @@ -148,7 +161,7 @@ from cuml.manifold import UMAP # Create instances of GPU-accelerated UMAP and HDBSCAN umap_model = UMAP(n_components=5, n_neighbors=15, min_dist=0.0) -hdbscan_model = HDBSCAN(min_samples=10, gen_min_span_tree=True) +hdbscan_model = HDBSCAN(min_samples=10, gen_min_span_tree=True, prediction_data=True) # Pass the above models to be used in BERTopic topic_model = BERTopic(umap_model=umap_model, hdbscan_model=hdbscan_model) @@ -210,6 +223,37 @@ topic_model = BERTopic(embedding_model=pipe) As a result, the entire package and resulting model can be run quickly on the CPU and no GPU is necessary! +## **WordCloud** +To minimize the number of dependencies in BERTopic, it is not possible to generate wordclouds out-of-the-box. However, +there is a minimal script that you can use to generate wordclouds in BERTopic. First, you will need to install +the [wordcloud](https://github.com/amueller/word_cloud) package with `pip install wordcloud`. Then, run the following code +to generate the wordcloud for a specific topic: + +```python +from wordcloud import WordCloud +import matplotlib.pyplot as plt + +def create_wordcloud(model, topic): + text = {word: value for word, value in model.get_topic(topic)} + wc = WordCloud(background_color="white", max_words=1000) + wc.generate_from_frequencies(text) + plt.imshow(wc, interpolation="bilinear") + plt.axis("off") + plt.show() + +# Show wordcloud +create_wordcloud(topic_model, topic=1) +``` + +![](wordcloud.jpg) + + +!!! tip Tip + To increase the number of words shown in the wordcloud, you can increase the `top_n_words` + parameter when instantiating BERTopic. You can also increase the number of words in a topic + after training the model using `.update_topics()`. + + ## **Finding similar topics between models** Whenever you have trained seperate BERTopic models on different datasets, it might diff --git a/docs/getting_started/tips_and_tricks/wordcloud.jpg b/docs/getting_started/tips_and_tricks/wordcloud.jpg new file mode 100644 index 0000000000000000000000000000000000000000..04db575030ab0b934a3bf4f92973fefe6b4361a4 GIT binary patch literal 90359 zcmbTd1yodD^glXuO3uI_DLq5T&?(�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 literal 0 HcmV?d00001 diff --git a/docs/getting_started/vectorizers/vectorizers.svg b/docs/getting_started/vectorizers/vectorizers.svg index be3ee4ab..1e2d3642 100644 --- a/docs/getting_started/vectorizers/vectorizers.svg +++ b/docs/getting_started/vectorizers/vectorizers.svg @@ -1,47 +1,53 @@ -<svg width="445" height="208" viewBox="0 0 445 208" fill="none" xmlns="http://www.w3.org/2000/svg"> -<rect x="132" y="170" width="118" height="38" fill="#64B5F6"/> -<rect x="224" y="160" width="20" height="8" fill="#64B5F6"/> -<rect x="196" y="160" width="20" height="8" fill="#64B5F6"/> -<rect x="168" y="160" width="20" height="8" fill="#64B5F6"/> -<rect x="140" y="160" width="20" height="8" fill="#64B5F6"/> -<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="158.256" y="197.939">SBERT</tspan></text> -<rect x="132" y="130" width="118" height="38" fill="#E57373"/> -<rect x="224" y="120" width="20" height="8" fill="#E57373"/> -<rect x="196" y="120" width="20" height="8" fill="#E57373"/> -<rect x="168" y="120" width="20" height="8" fill="#E57373"/> -<rect x="140" y="120" width="20" height="8" fill="#E57373"/> -<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="161.254" y="157.939">UMAP</tspan></text> -<rect x="132" y="90" width="118" height="38" fill="#4DB6AC"/> -<rect x="224" y="80" width="20" height="8" fill="#4DB6AC"/> -<rect x="196" y="80" width="20" height="8" fill="#4DB6AC"/> -<rect x="168" y="80" width="20" height="8" fill="#4DB6AC"/> -<rect x="140" y="80" width="20" height="8" fill="#4DB6AC"/> -<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="141.342" y="117.939">HDBSCAN</tspan></text> -<rect y="50" width="118" height="38" fill="#FFD54F"/> -<rect x="92" y="40" width="20" height="8" fill="#FFD54F"/> -<rect x="64" y="40" width="20" height="8" fill="#FFD54F"/> -<rect x="36" y="40" width="20" height="8" fill="#FFD54F"/> -<rect x="8" y="40" width="20" height="8" fill="#FFD54F"/> -<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="13" font-weight="bold" letter-spacing="0em"><tspan x="6.34619" y="74.1606">CountVectorizer</tspan></text> -<rect x="132" y="50" width="118" height="38" fill="#FFD54F"/> -<rect x="224" y="40" width="20" height="8" fill="#FFD54F"/> -<rect x="196" y="40" width="20" height="8" fill="#FFD54F"/> -<rect x="168" y="40" width="20" height="8" fill="#FFD54F"/> -<rect x="140" y="40" width="20" height="8" fill="#FFD54F"/> -<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="164.73" y="77.9395">Jieba</tspan></text> -<rect x="132" y="10" width="118" height="38" fill="#90A4AE"/> -<rect x="224" width="20" height="8" fill="#90A4AE"/> -<rect x="196" width="20" height="8" fill="#90A4AE"/> -<rect x="168" width="20" height="8" fill="#90A4AE"/> -<rect x="140" width="20" height="8" fill="#90A4AE"/> -<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="146.938" y="37.9395">c-TF-IDF</tspan></text> -<rect x="327" y="50" width="118" height="38" fill="#FFD54F"/> -<rect x="419" y="40" width="20" height="8" fill="#FFD54F"/> -<rect x="391" y="40" width="20" height="8" fill="#FFD54F"/> -<rect x="363" y="40" width="20" height="8" fill="#FFD54F"/> -<rect x="335" y="40" width="20" height="8" fill="#FFD54F"/> -<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="365.385" y="77.9395">POS</tspan></text> -<circle cx="266.5" cy="68.5" r="5.5" fill="black"/> -<circle cx="285.5" cy="68.5" r="5.5" fill="black"/> -<circle cx="307.5" cy="68.5" r="5.5" fill="black"/> +<svg width="445" height="248" viewBox="0 0 445 248" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect x="132" y="210" width="118" height="38" fill="#64B5F6"/> +<rect x="224" y="200" width="20" height="8" fill="#64B5F6"/> +<rect x="196" y="200" width="20" height="8" fill="#64B5F6"/> +<rect x="168" y="200" width="20" height="8" fill="#64B5F6"/> +<rect x="140" y="200" width="20" height="8" fill="#64B5F6"/> +<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="158.256" y="237.939">SBERT</tspan></text> +<rect x="132" y="170" width="118" height="38" fill="#E57373"/> +<rect x="224" y="160" width="20" height="8" fill="#E57373"/> +<rect x="196" y="160" width="20" height="8" fill="#E57373"/> +<rect x="168" y="160" width="20" height="8" fill="#E57373"/> +<rect x="140" y="160" width="20" height="8" fill="#E57373"/> +<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="161.254" y="197.939">UMAP</tspan></text> +<rect x="132" y="130" width="118" height="38" fill="#4DB6AC"/> +<rect x="224" y="120" width="20" height="8" fill="#4DB6AC"/> +<rect x="196" y="120" width="20" height="8" fill="#4DB6AC"/> +<rect x="168" y="120" width="20" height="8" fill="#4DB6AC"/> +<rect x="140" y="120" width="20" height="8" fill="#4DB6AC"/> +<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="141.342" y="157.939">HDBSCAN</tspan></text> +<rect y="90" width="118" height="38" fill="#FFD54F"/> +<rect x="92" y="80" width="20" height="8" fill="#FFD54F"/> +<rect x="64" y="80" width="20" height="8" fill="#FFD54F"/> +<rect x="36" y="80" width="20" height="8" fill="#FFD54F"/> +<rect x="8" y="80" width="20" height="8" fill="#FFD54F"/> +<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="13" font-weight="bold" letter-spacing="0em"><tspan x="6.34619" y="114.161">CountVectorizer</tspan></text> +<rect x="132" y="90" width="118" height="38" fill="#FFD54F"/> +<rect x="224" y="80" width="20" height="8" fill="#FFD54F"/> +<rect x="196" y="80" width="20" height="8" fill="#FFD54F"/> +<rect x="168" y="80" width="20" height="8" fill="#FFD54F"/> +<rect x="140" y="80" width="20" height="8" fill="#FFD54F"/> +<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="164.73" y="117.939">Jieba</tspan></text> +<rect x="132" y="50" width="118" height="38" fill="#90A4AE"/> +<rect x="224" y="40" width="20" height="8" fill="#90A4AE"/> +<rect x="196" y="40" width="20" height="8" fill="#90A4AE"/> +<rect x="168" y="40" width="20" height="8" fill="#90A4AE"/> +<rect x="140" y="40" width="20" height="8" fill="#90A4AE"/> +<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="146.938" y="77.9395">c-TF-IDF</tspan></text> +<rect x="132" y="10" width="118" height="38" fill="#3F51B5"/> +<rect x="224" width="20" height="8" fill="#3F51B5"/> +<rect x="196" width="20" height="8" fill="#3F51B5"/> +<rect x="168" width="20" height="8" fill="#3F51B5"/> +<rect x="140" width="20" height="8" fill="#3F51B5"/> +<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="14" font-weight="bold" letter-spacing="0em"><tspan x="161.065" y="26.0576">Optional&#10;</tspan><tspan x="150.271" y="43.0576">Fine-tuning</tspan></text> +<rect x="327" y="90" width="118" height="38" fill="#FFD54F"/> +<rect x="419" y="80" width="20" height="8" fill="#FFD54F"/> +<rect x="391" y="80" width="20" height="8" fill="#FFD54F"/> +<rect x="363" y="80" width="20" height="8" fill="#FFD54F"/> +<rect x="335" y="80" width="20" height="8" fill="#FFD54F"/> +<text fill="white" xml:space="preserve" style="white-space: pre" font-family="Tahoma" font-size="20" font-weight="bold" letter-spacing="0em"><tspan x="365.385" y="117.939">POS</tspan></text> +<circle cx="266.5" cy="108.5" r="5.5" fill="black"/> +<circle cx="285.5" cy="108.5" r="5.5" fill="black"/> +<circle cx="307.5" cy="108.5" r="5.5" fill="black"/> </svg> diff --git a/docs/index.md b/docs/index.md index b96ff873..8c7b2932 100644 --- a/docs/index.md +++ b/docs/index.md @@ -104,7 +104,7 @@ Think! It is the SCSI card doing... 49 49_windows_drive_dos_file windows By default, the main steps for topic modeling with BERTopic are sentence-transformers, UMAP, HDBSCAN, and c-TF-IDF run in sequence. However, it assumes some independence between these steps which makes BERTopic quite modular. In other words, BERTopic not only allows you to build your own topic model but to explore several topic modeling techniques on top of your customized topic model: -<iframe width="1200" height="500" src="https://user-images.githubusercontent.com/25746895/205490350-cd9833e7-9cd5-44fa-8752-407d748de633.mp4 +<iframe width="1200" height="500" src="https://user-images.githubusercontent.com/25746895/218420473-4b2bb539-9dbe-407a-9674-a8317c7fb3bf.mp4 " title="BERTopic Overview" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> You can swap out any of these models or even remove them entirely. Starting with the embedding step, you can find out how to do this [here](https://maartengr.github.io/BERTopic/getting_started/embeddings/embeddings.html) and more about the underlying algorithm and assumptions [here](https://maartengr.github.io/BERTopic/algorithm/algorithm.html). diff --git a/mkdocs.yml b/mkdocs.yml index 680edc9f..609c4e6e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,6 +25,7 @@ nav: - Clustering: getting_started/clustering/clustering.md - Vectorizers: getting_started/vectorizers/vectorizers.md - c-TF-IDF: getting_started/ctfidf/ctfidf.md + - (Optional) Representation: getting_started/representation/representation.md - Variations: - Topic Distributions: getting_started/distribution/distribution.md - Topics per Class: getting_started/topicsperclass/topicsperclass.md @@ -38,7 +39,6 @@ nav: - FAQ: faq.md - API: - BERTopic: api/bertopic.md - - MMR: api/mmr.md - Sub-models: - Backends: - Base: api/backends/base.md @@ -50,6 +50,17 @@ nav: - Vectorizers: - cTFIDF: api/ctfidf.md - OnlineCountVectorizer: api/onlinecv.md + - Topic Representation: + - Base: api/representation/base.md + - MaximalMarginalRelevance: api/representation/mmr.md + - KeyBERT: api/representation/keybert.md + - PartOfSpeech: api/representation/pos.md + - Text Generation: + - 🤗 Transformers: api/representation/generation.md + - LangChain: api/representation/langchain.md + - Cohere: api/representation/cohere.md + - OpenAI: api/representation/openai.md + - Zero-shot Classification: api/representation/zeroshot.md - Plotting: - Barchart: api/plotting/barchart.md - Documents: api/plotting/documents.md diff --git a/setup.py b/setup.py index dac87073..a53dd9e9 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,6 @@ "tqdm>=4.41.1", "sentence-transformers>=0.4.1", "plotly>=4.7.0", - "pyyaml<6.0" ] flair_packages = [ @@ -53,7 +52,7 @@ setup( name="bertopic", packages=find_packages(exclude=["notebooks", "docs"]), - version="0.13.0", + version="0.14.0", author="Maarten P. Grootendorst", author_email="maartengrootendorst@gmail.com", description="BERTopic performs topic Modeling with state-of-the-art transformer models.", diff --git a/tests/conftest.py b/tests/conftest.py index a775a6bc..893181dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,7 @@ from sklearn.cluster import KMeans, MiniBatchKMeans from sklearn.decomposition import PCA, IncrementalPCA from bertopic.vectorizers import OnlineCountVectorizer +from bertopic.representation import KeyBERTInspired from bertopic.cluster import BaseCluster from bertopic.dimensionality import BaseDimensionalityReduction from sklearn.linear_model import LogisticRegression @@ -60,6 +61,14 @@ def custom_topic_model(documents, document_embeddings, embedding_model): model = BERTopic(umap_model=umap_model, hdbscan_model=hdbscan_model, embedding_model=embedding_model, calculate_probabilities=True).fit(documents, document_embeddings) return model +@pytest.fixture(scope="session") +def representation_topic_model(documents, document_embeddings, embedding_model): + umap_model = UMAP(n_neighbors=15, n_components=6, min_dist=0.0, metric='cosine', random_state=42) + hdbscan_model = HDBSCAN(min_cluster_size=3, metric='euclidean', cluster_selection_method='eom', prediction_data=True) + keybert_model = KeyBERTInspired() + model = BERTopic(umap_model=umap_model, hdbscan_model=hdbscan_model, embedding_model=embedding_model, representation_model=keybert_model, + calculate_probabilities=True).fit(documents, document_embeddings) + return model @pytest.fixture(scope="session") def reduced_topic_model(custom_topic_model, documents): diff --git a/tests/test_bertopic.py b/tests/test_bertopic.py index 3b6d4f47..2482a088 100644 --- a/tests/test_bertopic.py +++ b/tests/test_bertopic.py @@ -2,7 +2,7 @@ import pytest -@pytest.mark.parametrize('model', [('kmeans_pca_topic_model'), ('custom_topic_model'), ('merged_topic_model'), ('reduced_topic_model'), ('online_topic_model'), ('supervised_topic_model')]) +@pytest.mark.parametrize('model', [('kmeans_pca_topic_model'), ('custom_topic_model'), ('merged_topic_model'), ('reduced_topic_model'), ('online_topic_model'), ('supervised_topic_model'), ('representation_topic_model')]) def test_full_model(model, documents, request): """ Tests the entire pipeline in one go. This serves as a sanity check to see if the default settings result in a good separation of topics. @@ -62,7 +62,7 @@ def test_full_model(model, documents, request): nr_topics = 2 if nr_topics < 2 else nr_topics - 1 topic_model.reduce_topics(documents, nr_topics=nr_topics) - assert len(topic_model.get_topic_freq()) == nr_topics + topic_model._outliers + assert len(topic_model.get_topic_freq()) == nr_topics assert len(topic_model.topics_) == len(topics) # Test update topics @@ -76,7 +76,8 @@ def test_full_model(model, documents, request): original_topic = topic_model.get_topic(1)[:10] assert topic != updated_topic - assert topic == original_topic + if topic_model.representation_model is not None: + assert topic != original_topic # Test updating topic labels topic_labels = topic_model.generate_topic_labels(nr_words=3, topic_prefix=False, word_length=10, separator=", ") diff --git a/tests/test_representation/test_get.py b/tests/test_representation/test_get.py index 6cc3f826..631a026f 100644 --- a/tests/test_representation/test_get.py +++ b/tests/test_representation/test_get.py @@ -67,18 +67,11 @@ def test_get_representative_docs(model, request): unique_topics = set(topic_model.topics_) topics_in_mapper = set(np.array(topic_model.topic_mapper_.mappings_)[:, -1]) - assert len(all_docs) == len(topic_model.topic_sizes_.keys()) - topic_model._outliers - assert len(all_docs) == len(topics_in_mapper) - topic_model._outliers - assert len(all_docs) == topic_model.c_tf_idf_.shape[0] - topic_model._outliers - assert len(all_docs) == len(topic_model.topic_labels_) - topic_model._outliers - - if model == "merged_topic_model": - assert len([True for docs in all_docs.values() if len(docs) == 6]) == 2 - assert len([True for docs in all_docs.values() if len(docs) == 9]) == 1 - elif model == "reduced_topic_model": - assert any([True if len(docs) == 3 else False for docs in all_docs.values()]) - else: - assert all([True if len(docs) == 3 else False for docs in all_docs.values()]) + assert len(all_docs) == len(topic_model.topic_sizes_.keys()) + assert len(all_docs) == len(topics_in_mapper) + assert len(all_docs) == topic_model.c_tf_idf_.shape[0] + assert len(all_docs) == len(topic_model.topic_labels_) + assert all([True if len(docs) == 3 else False for docs in all_docs.values()]) topics = set(list(all_docs.keys())) diff --git a/tests/test_representation/test_representations.py b/tests/test_representation/test_representations.py index 8f246be9..9c7c0eb5 100644 --- a/tests/test_representation/test_representations.py +++ b/tests/test_representation/test_representations.py @@ -89,7 +89,7 @@ def test_extract_topics_custom_cv(model, documents, request): ('merged_topic_model'), ('reduced_topic_model'), ('online_topic_model')]) -@pytest.mark.parametrize("reduced_topics", [1, 2, 4, 10]) +@pytest.mark.parametrize("reduced_topics", [2, 4, 10]) def test_topic_reduction(model, reduced_topics, documents, request): topic_model = copy.deepcopy(request.getfixturevalue(model)) old_topics = copy.deepcopy(topic_model.topics_) diff --git a/tests/test_sub_models/test_mmr.py b/tests/test_sub_models/test_mmr.py deleted file mode 100644 index b0052807..00000000 --- a/tests/test_sub_models/test_mmr.py +++ /dev/null @@ -1,25 +0,0 @@ - -import pytest -import numpy as np -from bertopic._mmr import mmr - - -@pytest.mark.parametrize("words,diversity", - [(['stars', 'star', 'starry', 'astronaut', 'astronauts'], 0), - (['stars', 'spaceship', 'nasa', 'skies', 'sky'], 1)]) -def test_mmr(words, diversity): - candidates = mmr(doc_embedding=np.array([5, 5, 5, 5]).reshape(1, -1), - word_embeddings=np.array([[1, 1, 2, 2], - [1, 2, 4, 7], - [4, 4, 4, 4], - [4, 4, 4, 4], - [4, 4, 4, 4], - [1, 1, 9, 3], - [5, 3, 5, 8], - [6, 6, 6, 6], - [6, 6, 6, 6], - [5, 8, 7, 2]]), - words=['space', 'nasa', 'stars', 'star', 'starry', 'spaceship', - 'sky', 'astronaut', 'astronauts', 'skies'], - diversity=diversity) - assert candidates == words