From c98a1d0c6ed8f115a4830f093f230203aa6efb85 Mon Sep 17 00:00:00 2001 From: Marvin Scham Date: Sun, 8 Feb 2026 22:43:53 +0100 Subject: [PATCH] BERTopic cleanup --- README.md | 14 + bertopic/bertopic_autotune.py | 59 +- bertopic/bertopic_autotune_sorter.py | 6 +- bertopic/combined_score_distribution.png | Bin 0 -> 16930 bytes bertopic/nb_bertopic.py | 38 +- bertopic/nb_bertopic_lowprep.py | 44 +- bertopic/output/autotune.json | 1298 ++++++++++++++++++++++ bertopic/output/visualization.html | 2 +- 8 files changed, 1400 insertions(+), 61 deletions(-) create mode 100644 README.md create mode 100644 bertopic/combined_score_distribution.png create mode 100644 bertopic/output/autotune.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..c75509e --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Masterthesis, praktischer Anteil + +## Jupyter Notebooks "rehydrieren" + +Damit keine unnötigen Jupyter Outputs etc. im Versionsmanagement landen, gibt es das Skript `convert_jupytext.sh`, welches nur den notwendigen Quelltext in ein `.py` File schreibt. Mit demselben Skript kann dieser Schritt wieder umgekehrt werden, also ein Jupyter Notebook aus dem Python-File geschrieben werden. + +Das Skript sollte also immer vor dem Committen von Änderungen mit `py` als erstes Argument ausgeführt werden. + +Verwendung: + +```bash +./convert_jupytext.sh py # Jupyter Notebook -> Python +./convert_jupytext.sh nb # Python -> Jupyter Notebook +``` diff --git a/bertopic/bertopic_autotune.py b/bertopic/bertopic_autotune.py index ada91fe..d4c21f9 100644 --- a/bertopic/bertopic_autotune.py +++ b/bertopic/bertopic_autotune.py @@ -3,6 +3,8 @@ import traceback import numpy as np import pandas as pd +from bertopic.representation import KeyBERTInspired +from bertopic.vectorizers import ClassTfidfTransformer from hdbscan import HDBSCAN from sentence_transformers import SentenceTransformer from sklearn.feature_extraction.text import CountVectorizer @@ -12,55 +14,50 @@ from sklearn.model_selection import ParameterGrid from umap import UMAP from bertopic import BERTopic -from bertopic.representation import KeyBERTInspired -from bertopic.vectorizers import ClassTfidfTransformer param_grid = { - "nr_topics": [45, 50, 55], - "min_topic_size": [30, 40, 50], - "n_gram_max": [3], - "min_document_frequency": [1, 2], - "n_neighbors": [15], - "n_components": [2], - "min_dist": [0.1], - "top_n_words": [10], + "n_gram_max": [2, 3], # Vectorization + "min_document_frequency": [1], # Vectorization + "min_samples": [10, 25], # HDBSCAN + "min_topic_size": [10, 20, 30, 40, 50], # HDBSCAN + "n_neighbors": [15], # UMAP + "n_components": [2, 5], # UMAP + "min_dist": [0.01, 0.1], # UMAP + "nr_topics": ["auto"], # Topic Modeling + "top_n_words": [10, 13, 15, 17, 20], # Topic Modeling } -def calculate_metrics(topic_model, embedder, top_n_words=5): +def calculate_metrics(topic_model, embedder, top_n_words=10): # Get topic words topic_words = [] for topic_id in range(len(topic_model.get_topic_info()) - 1): words = [word for word, _ in topic_model.get_topic(topic_id)] topic_words.append(words[:top_n_words]) + # Pre-compute embeddings for all unique words + all_words = list(set(word for words in topic_words for word in words)) + word_embeddings = embedder.encode(all_words) + embedding_map = {word: emb for word, emb in zip(all_words, word_embeddings)} + # Coherence coherence_scores = [] for words in topic_words: - embeddings = embedder.encode(words) + embeddings = np.array([embedding_map[word] for word in words]) sim_matrix = cosine_similarity(embeddings) np.fill_diagonal(sim_matrix, 0) - coherence_scores.append(np.mean(sim_matrix)) + mean_sim = np.mean(sim_matrix[np.triu_indices(sim_matrix.shape[0], k=1)]) + coherence_scores.append(mean_sim) overall_coherence = np.mean(coherence_scores) # Diversity all_topic_words = [word for topic in topic_words for word in topic] diversity = len(set(all_topic_words)) / len(all_topic_words) - # Inter-topic distance - topic_embeddings = [ - np.mean(embedder.encode(words), axis=0) for words in topic_words - ] - topic_distance = pairwise_distances(topic_embeddings, metric="cosine") - avg_distance = np.mean(topic_distance[np.triu_indices_from(topic_distance, k=1)]) - res = { "coherence": float(str(overall_coherence)[:6]), "diversity": float(str(diversity)[:6]), - "inter_topic_distance": float(str(avg_distance)[:6]), - "combined_score": float( - str(0.6 * overall_coherence + 0.2 * diversity + 0.2 * avg_distance)[:6] - ), + "combined_score": float(str(0.7 * overall_coherence + 0.3 * diversity)[:6]), } print(res) return res @@ -85,6 +82,7 @@ def auto_tune_bertopic(texts, embedding_model, param_grid): print(f"Total parameter combinations: {len(param_list)}") for params in param_list: + print(f"Testing param combination no. {len(history) + 1}/{len(param_list)}...") try: print(f"Testing params: {params}") ctfidf_model = ClassTfidfTransformer(reduce_frequent_words=True) @@ -143,18 +141,27 @@ def auto_tune_bertopic(texts, embedding_model, param_grid): traceback.print_exc() continue - return best_model, best_params, best_score, history + with open("output/autotune.json", "w") as f: + json.dump(history, f, indent=2) + + return best_model, best_params, best_score SPECIAL_CHARS = ["\n", "\\n"] MIN_REVIEW_WORDS = 5 -reviews = pd.read_csv("data.tab", sep="\t").review.to_list() +print("Loading reviews...") +reviews = pd.read_csv("../data/original/reviews.tab", sep="\t").review.to_list() +print("Running light preprocessing...") for schar in SPECIAL_CHARS: reviews = [ review.replace(schar, " ") if isinstance(review, str) else review for review in reviews ] + +print("Filtering short reviews...") reviews = [review for review in reviews if len(str(review).split()) >= MIN_REVIEW_WORDS] + +print("Staring auto-tuning...") print(auto_tune_bertopic(reviews, "all-MiniLM-L6-v2", param_grid)) diff --git a/bertopic/bertopic_autotune_sorter.py b/bertopic/bertopic_autotune_sorter.py index 9da3eca..720f1b9 100644 --- a/bertopic/bertopic_autotune_sorter.py +++ b/bertopic/bertopic_autotune_sorter.py @@ -2,12 +2,12 @@ import json import matplotlib.pyplot as plt -with open("history.json", "r") as f: +with open("output/autotune.json", "r") as f: history = json.load(f) -history = sorted(history, key=lambda x: x["metrics"]["combined_score"], reverse=True) +history = sorted(history, key=lambda x: x["metrics"]["combined_score"], reverse=False) -with open("history_sorted.json", "w") as f: +with open("output/autotune_sorted.json", "w") as f: json.dump(history, f, indent=2) diff --git a/bertopic/combined_score_distribution.png b/bertopic/combined_score_distribution.png new file mode 100644 index 0000000000000000000000000000000000000000..a18463fd0cbce9961bd18ea4f4c83f58926f801d GIT binary patch literal 16930 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
+}));
\ No newline at end of file