From 2fbfe053823b4137cc12ac25d6f1a48f84caf367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 9 Oct 2026 11:21:02 +0200 Subject: [PATCH 01/29] Only show top N terms when they are non-zero --- turftopic/container.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/turftopic/container.py b/turftopic/container.py index de7df18b..9e77a84a 100644 --- a/turftopic/container.py +++ b/turftopic/container.py @@ -75,7 +75,13 @@ def _top_terms( if not positive: terms.append(list(vocab[lowest])) else: - terms.append(list(vocab[highest])) + terms.append( + [ + vocab[i] + for i in highest + if (np.isfinite(component[i]) and (component[i] > 0)) + ] + ) return terms def get_top_words( From 8fc32dbda9e81db465a7c6aa35f87b2d85f3483c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 9 Oct 2026 11:21:44 +0200 Subject: [PATCH 02/29] Save merge history in SensTopic --- turftopic/models/senstopic.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/turftopic/models/senstopic.py b/turftopic/models/senstopic.py index 497ef6a1..8d316ffd 100644 --- a/turftopic/models/senstopic.py +++ b/turftopic/models/senstopic.py @@ -313,11 +313,13 @@ def partial_fit_transform( random_state=self.random_state, ) new_doc_topic = new_decomp.fit_transform(embeddings) - self.decomposition, merge_history = self.decomposition.merge_with( - new_decomp, - merge_method=merge_method, - match_threshold=match_threshold, - weighted=weighted, + self.decomposition, self.merge_history_ = ( + self.decomposition.merge_with( + new_decomp, + merge_method=merge_method, + match_threshold=match_threshold, + weighted=weighted, + ) ) if isinstance(merge_method, str) and (merge_method == "keep_first"): n_diff = self.decomposition.n_components - self.n_components_ From 08405e04987b6158dc015a8a948a972e83cb3983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Thu, 15 Oct 2026 18:17:57 +0200 Subject: [PATCH 03/29] Added basic implementation of training for VSLDA --- turftopic/models/_vslda.py | 1344 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1344 insertions(+) create mode 100644 turftopic/models/_vslda.py diff --git a/turftopic/models/_vslda.py b/turftopic/models/_vslda.py new file mode 100644 index 00000000..150c1dca --- /dev/null +++ b/turftopic/models/_vslda.py @@ -0,0 +1,1344 @@ +"""This is a highly simplified NumPy only implemenation taken from here: https://github.com/izumi-lab/vSLDA/blob/main/src/models/vmf_sentence_lda.py""" + +import math +import warnings +from typing import Sequence + +import numpy as np +from scipy.special import digamma as _digamma +from scipy.special import ive +from scipy.special import ive as _ive +from tqdm import trange + + +def calculate_avg_ll_vmf_from_encoded( + encoded_corpus: Sequence[np.ndarray], + topic_assignments: Sequence[Sequence[int]], + topic_means: np.ndarray, + kappa_per_topic: np.ndarray, +) -> float: + """Compute average vMF log-likelihood from pre-encoded unit vectors.""" + d_dim = float(topic_means.shape[1]) + v = d_dim / 2.0 - 1.0 + kappa_safe = np.clip( + np.asarray(kappa_per_topic, dtype=np.float64), 1e-12, None + ) + ive_val = np.maximum(ive(v, kappa_safe), 1e-300) + log_c = ( + v * np.log(kappa_safe) + - (d_dim / 2.0) * math.log(2.0 * math.pi) + - (np.log(ive_val) + kappa_safe) + ) + scaled_topic_means = np.asarray(kappa_per_topic, dtype=np.float64)[ + :, None + ] * np.asarray( + topic_means, + dtype=np.float64, + ) + total_ll = 0.0 + total_count = 0 + for enc, doc_topics in zip(encoded_corpus, topic_assignments): + arr = np.asarray(enc) + if arr.size == 0: + continue + if arr.ndim == 1: + arr = arr.reshape(1, -1) + topics = np.asarray(doc_topics, dtype=np.int64) + if topics.size == 0: + continue + doc_ll, doc_count = accumulate_doc_average_log_likelihood( + encoded_doc=arr, + assignments=topics, + log_c_per_topic=log_c, + scaled_topic_means=scaled_topic_means, + ) + if doc_count <= 0: + continue + total_ll += float(doc_ll) + total_count += int(doc_count) + if total_count == 0: + return float("nan") + + return total_ll / total_count + + +def sample_doc_topic_assignments( + assignments: np.ndarray, + counts: np.ndarray, + log_lik_doc: np.ndarray, + alpha: np.ndarray, + uniforms: np.ndarray, +) -> None: + num_topics = int(counts.shape[0]) + for i in range(assignments.shape[0]): + old_k = int(assignments[i]) + if 0 <= old_k < num_topics: + counts[old_k] -= 1 + + log_post = np.log(counts.astype(np.float64) + alpha) + log_lik_doc[i] + log_post -= log_post.max() + post = np.exp(log_post) + post_sum = post.sum() + if not np.isfinite(post_sum) or post_sum <= 0.0: + new_k = min(int(uniforms[i] * num_topics), num_topics - 1) + else: + threshold = float(uniforms[i]) * float(post_sum) + cumsum = 0.0 + new_k = num_topics - 1 + for k in range(num_topics): + cumsum += float(post[k]) + if threshold <= cumsum: + new_k = k + break + + assignments[i] = new_k + counts[new_k] += 1 + + +def accumulate_doc_assignment_statistics( + encoded_doc: np.ndarray, + assignments: np.ndarray, + log_mixture_weights: np.ndarray, + scaled_component_means: np.ndarray, + nk: np.ndarray, + nk_comp: np.ndarray, + r: np.ndarray, +) -> None: + num_topics = int(nk.shape[0]) + num_components = int(nk_comp.shape[1]) + limit = min(int(encoded_doc.shape[0]), int(assignments.shape[0])) + if num_components == 1: + for i in range(limit): + k = int(assignments[i]) + if k < 0 or k >= num_topics: + continue + nk[k] += 1.0 + nk_comp[k, 0] += 1.0 + r[k, 0] += encoded_doc[i] + return + + for i in range(limit): + k = int(assignments[i]) + if k < 0 or k >= num_topics: + continue + x = encoded_doc[i] + log_resp = log_mixture_weights[k] + scaled_component_means[k] @ x + log_resp -= log_resp.max() + resp = np.exp(log_resp) + resp_sum = float(resp.sum()) + if (not np.isfinite(resp_sum)) or resp_sum <= 0.0: + resp = np.full( + num_components, 1.0 / num_components, dtype=np.float64 + ) + else: + resp /= resp_sum + nk[k] += 1.0 + nk_comp[k] += resp + r[k] += resp[:, None] * x[None, :] + + +def accumulate_doc_average_log_likelihood( + encoded_doc: np.ndarray, + assignments: np.ndarray, + log_c_per_topic: np.ndarray, + scaled_topic_means: np.ndarray, +) -> tuple[float, int]: + num_topics = int(log_c_per_topic.shape[0]) + total_ll = 0.0 + total_count = 0 + limit = min(int(encoded_doc.shape[0]), int(assignments.shape[0])) + for i in range(limit): + k = int(assignments[i]) + if k < 0 or k >= num_topics: + continue + total_ll += float( + log_c_per_topic[k] + scaled_topic_means[k] @ encoded_doc[i] + ) + total_count += 1 + return total_ll, total_count + + +def mean_resultant_length(kappa, dim: int) -> np.ndarray: + """A_M(kappa) = I_{M/2}(kappa) / I_{M/2-1}(kappa) via exponentially scaled Bessel functions.""" + order = float(dim) / 2.0 - 1.0 + kappa_arr = np.asarray(kappa, dtype=np.float64) + return _ive(order + 1.0, kappa_arr) / _ive(order, kappa_arr) + + +def solve_kappa_newton( + rbar: float, + dim: int, + kappa0: float, + *, + tol: float = 1e-12, + max_iter: int = 100, +) -> float: + """Exact maximum-likelihood concentration: the root of A_M(kappa) = rbar. + + Newton's method from ``kappa0`` (normally the Banerjee et al. 2005 approximation) with + A_M'(kappa) = 1 - A_M(kappa)^2 - (M - 1) A_M(kappa) / kappa (Sra 2012). Two or three + iterations reach machine precision at the embedding dimensions used here. + """ + kappa = float(kappa0) + if not np.isfinite(kappa) or kappa <= 0.0: + return kappa + a = float(mean_resultant_length(kappa, dim)) + residual = a - float(rbar) + for _ in range(max_iter): + if abs(residual) <= tol: + break + derivative = 1.0 - a * a - (float(dim) - 1.0) * a / kappa + candidate = kappa - residual / derivative + if not np.isfinite(candidate) or candidate <= 0.0: + candidate = 0.5 * kappa + kappa = float(candidate) + a = float(mean_resultant_length(kappa, dim)) + residual = a - float(rbar) + return kappa + + +class VMFLDATrainer: + """Trainer for vMF-Sentence-LDA. + + - If num_components == 1: single vMF per topic. + - If num_components > 1: mixture of vMFs per topic. + """ + + @staticmethod + def _should_run_periodic_step( + *, + iteration: int, + total_iterations: int, + every: int, + ) -> bool: + return ((iteration + 1) % every == 0) or ( + iteration == total_iterations - 1 + ) + + def __init__( + self, + embeddings: list[np.ndarray], + num_topics: int, + alpha: float | Sequence[float] | None = None, + kappa: float = 10.0, + num_components: int = 1, + max_kappa: float = 10_000.0, + pre_normalize_transform: str = "none", + whitening_eps: float = 1e-5, + algorithm_variant: str | None = None, + saem_burn_in: int | None = 5, + saem_decay: float = 1.0, + kappa_solver: str = "newton", + ) -> None: + """ + Args: + num_topics: + Number of topics K. + alpha: + Dirichlet hyperparameter. + - None: initialize with standard symmetric 50/K. + - float: symmetric Dirichlet with that value. + - sequence length K: asymmetric Dirichlet. + kappa: + Default κ value used as a fallback when estimation is unstable. + num_components: + Number of vMF components per topic C. + C=1: single vMF per topic. + C>1: mixture of vMFs per topic. + max_kappa: + Finite positive upper bound applied to learned κ values. + pre_normalize_transform: + Embedding transform applied after encode and before L2 normalization. + One of: {"none", "mean_center", "whitening"}. + whitening_eps: + Positive stabilizer used only when pre_normalize_transform="whitening". + saem_burn_in: + Stochastic-approximation EM (Kuhn & Lavielle 2004): after this many outer + iterations T_0 the E-step statistics (n_k, r_k) are averaged with step size + gamma_t = (t - T_0)^(-saem_decay) and alpha is frozen (the default, T_0 = 5). + None keeps the plain MCEM (each M-step uses the statistics of its own + E-step, gamma == 1). + saem_decay: + Exponent a in (1/2, 1] of the step size (a > 1/2 gives sum gamma^2 < inf). + kappa_solver: + "newton" (default): the exact root of A_M(kappa) = rbar, started from the + closed-form approximation of Banerjee et al. (2005); "banerjee": that + approximation itself. + """ + self.num_topics = int(num_topics) + self.num_documents = len(embeddings) + self.embedding_size = embeddings[0].shape[1] + self.embedding_dtype = embeddings[0].dtype + + self.alpha = self._init_alpha(alpha) + self.kappa_default = float(kappa) + self.max_kappa = float(max_kappa) + if not np.isfinite(self.max_kappa) or self.max_kappa <= 0.0: + raise ValueError("max_kappa must be finite and > 0") + if not np.isfinite(self.kappa_default) or self.kappa_default <= 0.0: + raise ValueError("kappa must be finite and > 0") + if self.kappa_default > self.max_kappa: + raise ValueError("kappa must be <= max_kappa") + self.saem_burn_in = None if saem_burn_in is None else int(saem_burn_in) + if self.saem_burn_in is not None and self.saem_burn_in < 0: + raise ValueError("saem_burn_in must be >= 0 or None") + self.saem_decay = float(saem_decay) + if not (0.5 < self.saem_decay <= 1.0): + raise ValueError("saem_decay must lie in (1/2, 1]") + self.kappa_solver = str(kappa_solver) + if self.kappa_solver not in {"banerjee", "newton"}: + raise ValueError("kappa_solver must be 'banerjee' or 'newton'") + self._sa_state: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None + self.saem_gamma: list[float] = [] + self.saem_objective: list[float] = [] + self._last_estimated_kappa_max: float | None = None + self._last_clipped_topic_ids: list[int] = [] + self.algorithm_variant = ( + None if algorithm_variant is None else str(algorithm_variant) + ) + # Number of mixture components per topic (C) + self.num_components = int(num_components) + if self.num_components <= 0: + raise ValueError("num_components must be >= 1") + + # Topic counts n_k (for compatibility / inspection) + self.topic_counts = np.zeros(self.num_topics, dtype=np.int32) + # Topic-by-document counts n_{k,d} + self.topic_counts_per_doc = np.zeros( + (self.num_topics, self.num_documents), dtype=np.int32 + ) + # Topic assignment per sentence: topic_assignments[d][i] = topic id of i-th sentence in document d + self.topic_assignments: list[np.ndarray] = [] + # Sum of sentence vectors per topic s_k = Σ x_{d,i} + # (for compatibility; in mixture case we also maintain per-component sums separately in M-step) + self.sum_topic_vectors = np.zeros( + (self.num_topics, self.embedding_size), + dtype=self.embedding_dtype, + ) + # Effective topic mean directions μ_k (unit vectors, "average" over mixture components) + self.topic_means = np.zeros( + (self.num_topics, self.embedding_size), + dtype=self.embedding_dtype, + ) + # Mixture parameters (C components per topic) + # mixture_weights[k, c] = π_{k|c} + self.mixture_weights = np.full( + (self.num_topics, self.num_components), + 1.0 / self.num_components, + dtype=np.float64, + ) + # component_means[k, c, :] = μ_{k|c} + self.component_means = np.zeros( + (self.num_topics, self.num_components, self.embedding_size), + dtype=self.embedding_dtype, + ) + # Topic concentration parameters κ_k (shared across components of topic k) + self.kappa_per_topic = np.full( + self.num_topics, self.kappa_default, dtype=np.float64 + ) + # Cache for log C_M(κ_k) to avoid recomputation in the E-step + self._log_c_per_topic = np.zeros(self.num_topics, dtype=np.float64) + self._log_mixture_weights = np.zeros( + (self.num_topics, self.num_components), dtype=np.float64 + ) + self._scaled_topic_means = np.zeros( + (self.num_topics, self.embedding_size), dtype=np.float64 + ) + self._scaled_component_means = np.zeros( + (self.num_topics, self.num_components, self.embedding_size), + dtype=np.float64, + ) + # History of average log-likelihood + self.average_ll: list[float] = [] + # First initialize as single-vMF per topic (to get reasonable μ_k, κ_k, z) + self._initialize_single_vmf(embeddings) + # Then, if C>1, initialize mixture bases around μ_k + if self.num_components > 1: + self._init_mixture_from_topic_means() + self._refresh_density_caches() + + # ------------------------------------------------------------------------- + # Internal helpers + # ------------------------------------------------------------------------- + def _init_alpha(self, alpha: float | Sequence[float] | None) -> np.ndarray: + """ + Initialize asymmetric Dirichlet alpha vector. + Standard default is alpha_k = 50 / K when alpha is None. + """ + if alpha is None: + val = 50.0 / float(self.num_topics) + return np.full(self.num_topics, val, dtype=np.float64) + if isinstance(alpha, (int, float, np.floating, np.integer)): + return np.full(self.num_topics, float(alpha), dtype=np.float64) + + arr = np.asarray(alpha, dtype=np.float64).reshape(-1) + if arr.size != self.num_topics: + raise ValueError( + f"alpha must be length {self.num_topics}, got {arr.size}" + ) + if np.any(arr <= 0.0) or not np.all(np.isfinite(arr)): + raise ValueError("alpha must be positive and finite") + return arr.copy() + + def _update_alpha_fixed_point( + self, max_iter: int = 100, tol: float = 1e-5, min_alpha: float = 1e-3 + ) -> bool: + """ + Update asymmetric alpha via Minka's fixed-point iteration. + Returns True if converged within tol, False otherwise. + """ + counts = np.asarray(self.topic_counts_per_doc, dtype=np.float64) + if counts.ndim != 2 or counts.shape[0] != self.num_topics: + return False + doc_lengths = counts.sum(axis=0) + if doc_lengths.size == 0: + return False + + eps = float(min_alpha) + alpha = np.asarray(self.alpha, dtype=np.float64).copy() + alpha = np.clip(alpha, eps, None) + + for _ in range(max_iter): + alpha0 = float(alpha.sum()) + if not np.isfinite(alpha0) or alpha0 <= 0.0: + break + + denom = np.sum(_digamma(doc_lengths + alpha0) - _digamma(alpha0)) + if not np.isfinite(denom) or denom <= 0.0: + break + + numer = np.sum( + _digamma(counts + alpha[:, None]) - _digamma(alpha)[:, None], + axis=1, + ) + if not np.all(np.isfinite(numer)): + break + + alpha_new = alpha * (numer / denom) + alpha_new = np.clip(alpha_new, eps, None) + + rel_change = np.max(np.abs(alpha_new - alpha) / (alpha + eps)) + alpha = alpha_new + if rel_change < tol: + self.alpha = alpha + return True + + self.alpha = alpha + return False + + def _log_vmf_normalization_const(self, kappa: np.ndarray) -> np.ndarray: + """ + Compute log C_M(kappa) for the vMF distribution. + + log C_M(kappa) = (M/2 - 1) * log kappa - (M/2) * log(2π) - log I_{M/2-1}(kappa) + """ + kappa = np.asarray(kappa, dtype=np.float64) + kappa_safe = np.clip(kappa, 1e-12, None) + d_dim = float(self.embedding_size) + v = d_dim / 2.0 - 1.0 + + ive_val = np.maximum(_ive(v, kappa_safe), 1e-300) + log_iv = np.log(ive_val) + kappa_safe + return ( + v * np.log(kappa_safe) + - (d_dim / 2.0) * math.log(2.0 * math.pi) + - log_iv + ) + + def _refresh_log_c_cache(self) -> None: + """Refresh cached log C_M(κ_k) values after κ updates.""" + self._log_c_per_topic = self._log_vmf_normalization_const( + self.kappa_per_topic + ) + + def _refresh_density_caches(self) -> None: + """Refresh topic-side caches used by E-step and inference.""" + self._refresh_log_c_cache() + self._scaled_topic_means = ( + self.kappa_per_topic[:, None] * self.topic_means + ) + self._scaled_component_means = ( + self.kappa_per_topic[:, None, None] * self.component_means + ) + self._log_mixture_weights = np.log(self.mixture_weights + 1e-12) + + def _random_topic_vector(self, n_sentences: int) -> np.ndarray: + return np.random.randint( + self.num_topics, + size=int(n_sentences), + dtype=np.int32, + ) + + def _update_topic_mean_and_kappa_single(self, embeddings, k: int) -> None: + """ + Update mean direction μ_k and concentration κ_k for topic k + in the single-vMF (C=1) case. + + Uses: + - s_k (sum of vectors) to compute μ_k + - Banerjee-style approximation to estimate κ_k + """ + s_k = self.sum_topic_vectors[k] + n_k = int(self.topic_counts[k]) + + norm_s = np.linalg.norm(s_k) + if norm_s == 0.0 or n_k == 0: + v = np.random.randn(self.embedding_size) + v /= np.linalg.norm(v) + 1e-12 + self.topic_means[k] = np.asarray(v, dtype=self.embedding_dtype) + self.kappa_per_topic[k] = self.kappa_default + return + + # Mean direction μ_k + self.topic_means[k] = np.asarray( + s_k / (norm_s + 1e-12), + dtype=self.embedding_dtype, + ) + + # If only one point is assigned, kappa estimation is unstable; use default + if n_k <= 1: + self.kappa_per_topic[k] = self.kappa_default + return + + # Mean resultant length l_k = ||s_k|| / n_k + l_k = norm_s / (n_k + 1e-12) + l_k = float(np.clip(l_k, 1e-6, 1.0 - 1e-6)) + + d = self.embedding_size + # Approximate κ from l_k (single vMF; standard approximation) + numerator = l_k * (d - l_k**2) + denominator = 1.0 - l_k**2 + kappa_est = numerator / (denominator + 1e-12) + + self.kappa_per_topic[k] = self._sanitize_kappa_estimate(kappa_est) + + def _sanitize_kappa_estimate( + self, + estimate: float, + *, + topic_id: int | None = None, + record_diagnostics: bool = False, + ) -> float: + """Return a valid bounded concentration and optionally record clipping.""" + + value = float(estimate) + if not np.isfinite(value) or value <= 0.0: + return self.kappa_default + if record_diagnostics: + if ( + self._last_estimated_kappa_max is None + or value > self._last_estimated_kappa_max + ): + self._last_estimated_kappa_max = value + if value > self.max_kappa and topic_id is not None: + self._last_clipped_topic_ids.append(int(topic_id)) + return min(value, self.max_kappa) + + def _initialize_single_vmf(self, embeddings) -> None: + """ + Initialize vMF-LDA with a single vMF per topic: + - Randomly assign each sentence in each document to a topic. + - Compute s_k, μ_k, κ_k from the assignments. + This serves as a good starting point even when num_components > 1. + """ + self.topic_assignments = [] + self.topic_counts[:] = 0 + self.topic_counts_per_doc[:] = 0 + self.sum_topic_vectors[:] = 0.0 + self.topic_means[:] = 0.0 + for d, enc in enumerate(embeddings): + n_sent = enc.shape[0] + topics = self._random_topic_vector(n_sent) + self.topic_assignments.append(topics) + if topics.size > 0: + self.topic_counts_per_doc[:, d] = np.bincount( + topics, + minlength=self.num_topics, + ).astype(np.int32, copy=False) + for x, k in zip(enc, topics): + if x.shape[0] != self.embedding_size: + continue + self.topic_counts[k] += 1 + self.sum_topic_vectors[k] += x + for k in range(self.num_topics): + self._update_topic_mean_and_kappa_single(embeddings, k) + if self.num_components == 1: + self.component_means[k, 0] = self.topic_means[k] + self.mixture_weights[k, 0] = 1.0 + self._refresh_density_caches() + + def _init_mixture_from_topic_means(self, embeddings) -> None: + """ + Initialize mixture components (C > 1) around the single-vMF topic means. + + - π_{k|c} is set to 1/C. + - μ_{k|c} is a small random perturbation around μ_k. + """ + eps = 1e-6 + for k in range(self.num_topics): + base = self.topic_means[k] + if np.linalg.norm(base) == 0.0: + base = np.random.randn(self.embedding_size) + base /= np.linalg.norm(base) + eps + for c in range(self.num_components): + v = base + 0.01 * np.random.randn(self.embedding_size) + v /= np.linalg.norm(v) + eps + self.component_means[k, c] = np.asarray( + v, + dtype=self.embedding_dtype, + ) + self.mixture_weights[k] = 1.0 / self.num_components + + def _random_init_topic(self, embeddings, k: int) -> None: + """ + Randomly initialize mixture parameters for topic k. + Used when no data is assigned to topic k in an EM iteration. + """ + eps = 1e-6 + self.mixture_weights[k] = 1.0 / self.num_components + for c in range(self.num_components): + v = np.random.randn(self.embedding_size) + v /= np.linalg.norm(v) + eps + self.component_means[k, c] = np.asarray( + v, + dtype=self.embedding_dtype, + ) + self.kappa_per_topic[k] = self.kappa_default + eff = self.component_means[k].mean(axis=0) + eff_norm = np.linalg.norm(eff) + if eff_norm > 0: + eff /= eff_norm + self.topic_means[k] = np.asarray(eff, dtype=self.embedding_dtype) + self.topic_counts[k] = 0 + self.sum_topic_vectors[k] = 0.0 + + def _init_topic_from_vector( + self, embeddings, k: int, x: np.ndarray + ) -> None: + arr = np.asarray(x, dtype=np.float64) + norm = np.linalg.norm(arr) + if not np.isfinite(norm) or norm <= 0.0: + self._random_init_topic(k) + return + + direction = arr / (norm + 1e-12) + self.mixture_weights[k] = 1.0 / self.num_components + for c in range(self.num_components): + if c == 0: + v = direction + else: + v = direction + 0.01 * np.random.randn(self.embedding_size) + v /= np.linalg.norm(v) + 1e-12 + self.component_means[k, c] = np.asarray( + v, + dtype=self.embedding_dtype, + ) + + eff = self.component_means[k].mean(axis=0) + eff_norm = np.linalg.norm(eff) + if eff_norm > 0.0: + eff /= eff_norm + self.topic_means[k] = np.asarray(eff, dtype=self.embedding_dtype) + self.kappa_per_topic[k] = self.kappa_default + + def _recompute_doc_topic_counts(self) -> None: + """ + Recompute document–topic counts n_{k,d} from current topic_assignments. + + This ensures that in the E-step we really use N_{dk}^{(-n)} from the + previous state of z. + """ + self.topic_counts_per_doc[:] = 0 + for d, doc_topics in enumerate(self.topic_assignments): + topics = np.asarray(doc_topics, dtype=np.int64) + if topics.size == 0: + continue + valid = topics[(topics >= 0) & (topics < self.num_topics)] + if valid.size == 0: + continue + self.topic_counts_per_doc[:, d] = np.bincount( + valid, + minlength=self.num_topics, + ).astype(np.int32, copy=False) + + def _recompute_topic_counts_from_doc_counts(self) -> None: + self.topic_counts[:] = self.topic_counts_per_doc.sum(axis=1).astype( + np.int32, + copy=False, + ) + + def _get_empty_topics(self, *, min_count: int = 1) -> np.ndarray: + counts = np.asarray( + self.topic_counts_per_doc.sum(axis=1), dtype=np.int64 + ) + return np.where(counts < int(min_count))[0] + + def _find_repair_donor_sentence( + self, + *, + protected_topics: set[int], + min_topic_count_for_repair: int, + ) -> tuple[int, int, int] | None: + counts = self.topic_counts_per_doc.sum(axis=1).astype(np.int64) + candidate_topics = np.argsort(-counts) + for donor_topic_value in candidate_topics: + donor_topic = int(donor_topic_value) + if donor_topic in protected_topics: + continue + if counts[donor_topic] <= min_topic_count_for_repair: + continue + + locations: list[tuple[int, int]] = [] + for d, assignments in enumerate(self.topic_assignments): + arr = np.asarray(assignments) + if arr.size == 0: + continue + sentence_indices = np.where(arr == donor_topic)[0] + locations.extend((d, int(i)) for i in sentence_indices) + if locations: + selected = locations[np.random.randint(len(locations))] + return selected[0], selected[1], donor_topic + + return None + + def _repair_empty_topics( + self, + embeddings, + *, + min_topic_count_for_repair: int = 1, + ) -> dict[str, object]: + self._recompute_doc_topic_counts() + self._recompute_topic_counts_from_doc_counts() + target_topics = [ + int(topic) + for topic in self._get_empty_topics( + min_count=min_topic_count_for_repair + ) + ] + repaired: list[int] = [] + failed: list[int] = [] + moves: list[dict[str, int]] = [] + protected_topics: set[int] = set() + for target_topic in target_topics: + donor = self._find_repair_donor_sentence( + protected_topics=protected_topics, + min_topic_count_for_repair=min_topic_count_for_repair, + ) + if donor is None: + failed.append(target_topic) + continue + doc_index, sentence_index, old_topic = donor + self.topic_assignments[doc_index][sentence_index] = target_topic + self.topic_counts_per_doc[old_topic, doc_index] -= 1 + self.topic_counts_per_doc[target_topic, doc_index] += 1 + self._init_topic_from_vector( + embeddings, + target_topic, + self.encoded_corpus[doc_index][sentence_index], + ) + repaired.append(target_topic) + protected_topics.add(target_topic) + moves.append( + { + "target_topic": int(target_topic), + "old_topic": int(old_topic), + "doc_index": int(doc_index), + "sentence_index": int(sentence_index), + } + ) + self._recompute_topic_counts_from_doc_counts() + if repaired: + self._refresh_density_caches() + return { + "num_targets": len(target_topics), + "num_repaired": len(repaired), + "num_failed": len(failed), + "target_topics": target_topics, + "repaired_topics": repaired, + "failed_topics": failed, + "moves": moves, + } + + # ------------------------------------------------------------------------- + # vMF log density (for Gibbs sampling over topics) + # ------------------------------------------------------------------------- + def log_vmf_density_tables(self, x: np.ndarray) -> np.ndarray: + """ + Compute log of unnormalized vMF mixture density for all topics for a single unit vector x. + + - If num_components == 1: + log p_k ∝ log C_M(κ_k) + κ_k μ_k^T x + - If num_components > 1: + log p_k ∝ log C_M(κ_k) + log ∑_c π_{k|c} exp(κ_k μ_{k|c}^T x) + + The normalization constant C_M(κ_k) penalizes large κ_k and is + required for fair topic comparisons. + """ + arr = np.asarray(x, dtype=np.float64) + if arr.ndim != 1: + raise ValueError(f"x must be 1D, got shape {arr.shape}") + if self.num_components == 1: + return self._log_c_per_topic + self._scaled_topic_means @ arr + # Mixture of vMFs per topic + # component_means: (K, C, D) + # x: (D,) + # dots[k, c] = μ_{k|c}^T x + scores = np.tensordot( + self._scaled_component_means, arr, axes=([2], [0]) + ) + log_comp = self._log_mixture_weights + scores + # log-sum-exp over mixture components + m = log_comp.max(axis=1, keepdims=True) # (K, 1) + mix_log = m + np.log( + np.exp(log_comp - m).sum(axis=1, keepdims=True) + 1e-12 + ) # (K, 1) + return self._log_c_per_topic + mix_log.ravel() # (K,) + + def log_vmf_density_matrix(self, x: np.ndarray) -> np.ndarray: + """Compute topic log-likelihoods for a batch of unit vectors.""" + arr = np.asarray(x, dtype=np.float64) + if arr.size == 0: + return np.zeros((0, self.num_topics), dtype=np.float64) + if arr.ndim == 1: + arr = arr.reshape(1, -1) + if arr.shape[1] != self.embedding_size: + raise ValueError( + f"Expected embedding dim {self.embedding_size}, got {arr.shape[1]}" + ) + if self.num_components == 1: + return ( + arr @ self._scaled_topic_means.T + + self._log_c_per_topic[None, :] + ) + scores = np.einsum( + "nd,kcd->nkc", + arr, + self._scaled_component_means, + optimize=True, + ) + log_comp = scores + self._log_mixture_weights[None, :, :] + m = log_comp.max(axis=2, keepdims=True) + mix_log = m + np.log( + np.exp(log_comp - m).sum(axis=2, keepdims=True) + 1e-12 + ) + return self._log_c_per_topic[None, :] + mix_log[..., 0] + + def _component_responsibilities(self, k: int, x: np.ndarray) -> np.ndarray: + """ + Compute responsibilities weight_{ic} for a given topic k and vector x. + + weight_ic ∝ π_{k|c} * vMF(x | μ_{k|c}, κ_k) + ∝ π_{k|c} * exp(κ_k μ_{k|c}^T x) + + Because κ_k is shared across components of topic k, c_M(κ_k) cancels + between numerator and denominator. + """ + if self.num_components == 1: + return np.array([1.0], dtype=np.float64) + log_comp = ( + self._log_mixture_weights[k] + self._scaled_component_means[k] @ x + ) + m = log_comp.max() + comp = np.exp(log_comp - m) + s = comp.sum() + if not np.isfinite(s) or s <= 0.0: + return np.full( + self.num_components, + 1.0 / self.num_components, + dtype=np.float64, + ) + return comp / s + + def _ensure_document_assignments( + self, doc_index: int, n_sentences: int + ) -> None: + if doc_index >= len(self.topic_assignments): + topics = self._random_topic_vector(n_sentences) + self.topic_assignments.append(topics) + self.topic_counts_per_doc[:, doc_index] = 0 + if topics.size > 0: + self.topic_counts_per_doc[:, doc_index] = np.bincount( + topics, + minlength=self.num_topics, + ).astype(np.int32, copy=False) + return + + if len(self.topic_assignments[doc_index]) != n_sentences: + topics = self._random_topic_vector(n_sentences) + self.topic_assignments[doc_index] = topics + self.topic_counts_per_doc[:, doc_index] = 0 + if topics.size > 0: + self.topic_counts_per_doc[:, doc_index] = np.bincount( + topics, + minlength=self.num_topics, + ).astype(np.int32, copy=False) + + def _sample_topic_assignment( + self, + doc_index: int, + sentence_index: int, + log_lik: np.ndarray, + ) -> None: + old_k = self.topic_assignments[doc_index][sentence_index] + if 0 <= old_k < self.num_topics: + self.topic_counts_per_doc[old_k, doc_index] -= 1 + + counts = ( + self.topic_counts_per_doc[:, doc_index].astype(float) + self.alpha + ) + log_post = np.log(counts) + log_lik + log_post -= log_post.max() + post = np.exp(log_post) + post_sum = post.sum() + if not np.isfinite(post_sum) or post_sum <= 0.0: + post[:] = 1.0 / self.num_topics + else: + post /= post_sum + + new_k = int(np.random.choice(self.num_topics, p=post)) + self.topic_assignments[doc_index][sentence_index] = new_k + self.topic_counts_per_doc[new_k, doc_index] += 1 + + def _accumulate_assignment_statistics( + self, + *, + encoded_docs: Sequence[np.ndarray], + sample_assignments: Sequence[Sequence[int]], + nk: np.ndarray, + nk_comp: np.ndarray, + r: np.ndarray, + ) -> None: + for d in range(self.num_documents): + enc = encoded_docs[d] + if enc.size == 0 or d >= len(sample_assignments): + continue + assignments = np.asarray(sample_assignments[d], dtype=np.int32) + if assignments.size == 0: + continue + limit = min(enc.shape[0], assignments.shape[0]) + if limit <= 0: + continue + accumulate_doc_assignment_statistics( + encoded_doc=enc[:limit], + assignments=assignments[:limit], + log_mixture_weights=self._log_mixture_weights, + scaled_component_means=self._scaled_component_means, + nk=nk, + nk_comp=nk_comp, + r=r, + ) + + def _compute_assignment_statistics( + self, + *, + encoded_docs: Sequence[np.ndarray], + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + k_topics = self.num_topics + c_components = self.num_components + d_dim = self.embedding_size + nk = np.zeros(k_topics, dtype=np.float64) + nk_comp = np.zeros((k_topics, c_components), dtype=np.float64) + r = np.zeros((k_topics, c_components, d_dim), dtype=np.float64) + self._accumulate_assignment_statistics( + encoded_docs=encoded_docs, + sample_assignments=self.topic_assignments, + nk=nk, + nk_comp=nk_comp, + r=r, + ) + return nk, nk_comp, r + + def _run_e_step( + self, + *, + encoded_docs: Sequence[np.ndarray], + num_sweeps: int, + num_samples: int, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + k_topics = self.num_topics + c_components = self.num_components + d_dim = self.embedding_size + nk = np.zeros(k_topics, dtype=np.float64) + r = np.zeros((k_topics, c_components, d_dim), dtype=np.float64) + nk_comp = np.zeros((k_topics, c_components), dtype=np.float64) + kept_sample_count = 0 + for sweep in range(num_sweeps): + for d in range(self.num_documents): + enc = encoded_docs[d] + if enc.size == 0: + continue + self._ensure_document_assignments(d, enc.shape[0]) + log_lik_doc = self.log_vmf_density_matrix(enc) + counts_doc = np.array( + self.topic_counts_per_doc[:, d], + dtype=np.int32, + copy=True, + ) + uniforms = np.random.random(enc.shape[0]).astype( + np.float64, copy=False + ) + sample_doc_topic_assignments( + assignments=self.topic_assignments[d], + counts=counts_doc, + log_lik_doc=log_lik_doc, + alpha=self.alpha, + uniforms=uniforms, + ) + self.topic_counts_per_doc[:, d] = counts_doc + + if sweep >= num_sweeps - num_samples: + self._accumulate_assignment_statistics( + encoded_docs=encoded_docs, + sample_assignments=self.topic_assignments, + nk=nk, + nk_comp=nk_comp, + r=r, + ) + kept_sample_count += 1 + + sample_count = float(max(kept_sample_count, 1)) + if sample_count > 1.0: + nk /= sample_count + nk_comp /= sample_count + r /= sample_count + return nk, nk_comp, r + + def _apply_m_step_updates( + self, + embeddings, + *, + nk: np.ndarray, + nk_comp: np.ndarray, + r: np.ndarray, + ) -> None: + c_components = self.num_components + d_dim = self.embedding_size + eps = 1e-12 + self._last_estimated_kappa_max = None + self._last_clipped_topic_ids = [] + + for k in range(self.num_topics): + n_k = nk[k] + if n_k <= 0: + self._random_init_topic(embeddings, k) + continue + + pi_k = nk_comp[k] / (n_k + eps) + if not np.isfinite(pi_k).all() or pi_k.sum() <= 0.0: + pi_k = np.full( + c_components, 1.0 / c_components, dtype=np.float64 + ) + else: + pi_k /= pi_k.sum() + self.mixture_weights[k] = pi_k + + lengths: list[float] = [] + for c in range(c_components): + rkc = r[k, c] + norm_rkc = np.linalg.norm(rkc) + if norm_rkc == 0.0: + v = np.random.randn(d_dim) + v /= np.linalg.norm(v) + eps + self.component_means[k, c] = np.asarray( + v, + dtype=self.embedding_dtype, + ) + else: + self.component_means[k, c] = np.asarray( + rkc / (norm_rkc + eps), + dtype=self.embedding_dtype, + ) + lengths.append(float(norm_rkc)) + + r_k = 0.0 + if lengths: + r_k = float(sum(lengths) / (n_k + eps)) + r_k = float(np.clip(r_k, 1e-6, 1.0 - 1e-6)) + + if n_k <= 1 or r_k <= 0.0: + self.kappa_per_topic[k] = self.kappa_default + else: + numerator = r_k * d_dim - r_k**3 + denominator = 1.0 - r_k**2 + kappa_est = numerator / (denominator + eps) + if self.kappa_solver == "newton": + kappa_est = solve_kappa_newton(r_k, d_dim, kappa_est) + self.kappa_per_topic[k] = self._sanitize_kappa_estimate( + kappa_est, + topic_id=k, + record_diagnostics=True, + ) + + eff = ( + self.mixture_weights[k][:, None] * self.component_means[k] + ).sum(axis=0) + norm_eff = np.linalg.norm(eff) + if norm_eff > 0.0: + eff /= norm_eff + self.topic_means[k] = np.asarray(eff, dtype=self.embedding_dtype) + self.sum_topic_vectors[k] = np.asarray( + r[k].sum(axis=0), + dtype=self.embedding_dtype, + ) + + self.topic_counts[:] = self.topic_counts_per_doc.sum(axis=1).astype( + np.int32, + copy=False, + ) + self._refresh_density_caches() + + # ------------------------------------------------------------------------ + # SAEM: stochastic-approximation averaging of the E-step statistics + # ------------------------------------------------------------------------ + def saem_step_size(self, t: int) -> float: + """gamma_t of outer iteration t (1-based): 1 during burn-in, (t - T_0)^(-a) after.""" + if self.saem_burn_in is None or t <= self.saem_burn_in: + return 1.0 + return float(t - self.saem_burn_in) ** (-self.saem_decay) + + def _saem_update( + self, + gamma: float, + nk: np.ndarray, + nk_comp: np.ndarray, + r: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """s_t = s_{t-1} + gamma_t (S_t - s_{t-1}); with gamma == 1 this is S_t itself.""" + if self._sa_state is None or gamma >= 1.0: + state = (nk, nk_comp, r) + else: + s_nk, s_nk_comp, s_r = self._sa_state + state = ( + s_nk + gamma * (nk - s_nk), + s_nk_comp + gamma * (nk_comp - s_nk_comp), + s_r + gamma * (r - s_r), + ) + self._sa_state = state + return state + + def _saem_objective(self, nk: np.ndarray, r: np.ndarray) -> float: + """Q(Delta; s) = sum_k { n_k log C_M(kappa_k) + kappa_k mu_k . r_k } at the M-step input.""" + log_c = self._log_vmf_normalization_const(self.kappa_per_topic) + r_sum = np.asarray(r, dtype=np.float64).sum(axis=1) + dots = np.einsum( + "km,km->k", np.asarray(self.topic_means, dtype=np.float64), r_sum + ) + return float( + np.sum( + np.asarray(nk, dtype=np.float64) * log_c + + self.kappa_per_topic * dots + ) + ) + + def _update_alpha_if_needed( + self, + *, + iteration: int, + estimate_alpha: bool, + alpha_update_every: int, + alpha_max_iter: int, + alpha_tol: float, + alpha_min_value: float, + ) -> tuple[bool, bool | None]: + if not estimate_alpha or ((iteration + 1) % alpha_update_every != 0): + return False, None + + converged = self._update_alpha_fixed_point( + max_iter=alpha_max_iter, + tol=alpha_tol, + min_alpha=alpha_min_value, + ) + alpha = np.asarray(self.alpha, dtype=np.float64) + num_at_floor = int(np.sum(alpha <= alpha_min_value + 1e-12)) + if not converged: + warnings.warn( + f"Alpha update did not converge within {alpha_max_iter} iterations.", + ) + return True, converged + + def _compute_average_log_likelihood( + self, *, encoded_docs: Sequence[np.ndarray] + ) -> float: + if self.num_components > 1: + ave_ll = self._compute_average_mixture_log_likelihood( + encoded_docs=encoded_docs + ) + else: + ave_ll = calculate_avg_ll_vmf_from_encoded( + encoded_corpus=encoded_docs, + topic_assignments=self.topic_assignments, + topic_means=self.topic_means, + kappa_per_topic=self.kappa_per_topic, + ) + self.average_ll.append(ave_ll) + return ave_ll + + def _compute_average_mixture_log_likelihood( + self, *, encoded_docs: Sequence[np.ndarray] + ) -> float: + total_ll = 0.0 + total_count = 0 + for enc, doc_topics in zip( + encoded_docs, self.topic_assignments, strict=False + ): + arr = np.asarray(enc, dtype=np.float64) + if arr.size == 0: + continue + if arr.ndim == 1: + arr = arr.reshape(1, -1) + topics = np.asarray(doc_topics, dtype=np.int64) + if topics.size == 0: + continue + limit = min(arr.shape[0], topics.shape[0]) + if limit <= 0: + continue + scores = self.log_vmf_density_matrix(arr[:limit]) + for row_index, topic_index in enumerate(topics[:limit]): + if 0 <= topic_index < self.num_topics: + total_ll += float(scores[row_index, topic_index]) + total_count += 1 + if total_count == 0: + return float("nan") + return total_ll / total_count + + def _run_training_iteration( + self, + embeddings, + *, + iteration: int, + num_sweeps: int, + num_samples: int, + estimate_alpha: bool, + alpha_update_every: int, + alpha_max_iter: int, + alpha_tol: float, + alpha_min_value: float, + repair_empty_topics: bool, + min_topic_count_for_repair: int, + compute_avg_log_likelihood: bool, + ): + encoded_docs = embeddings + nk, nk_comp, r = self._run_e_step( + encoded_docs=encoded_docs, + num_sweeps=num_sweeps, + num_samples=num_samples, + ) + if repair_empty_topics: + self._repair_empty_topics( + embeddings, + min_topic_count_for_repair=min_topic_count_for_repair, + ) + # SAEM step: after burn-in the M-step sees the averaged statistics + t = iteration + 1 + gamma = self.saem_step_size(t) + nk, nk_comp, r = self._saem_update(gamma, nk, nk_comp, r) + self._apply_m_step_updates(embeddings, nk=nk, nk_comp=nk_comp, r=r) + saem_objective = self._saem_objective(nk, r) + self.saem_gamma.append(float(gamma)) + self.saem_objective.append(float(saem_objective)) + alpha_frozen = self.saem_burn_in is not None and t > self.saem_burn_in + alpha_updated, alpha_converged = self._update_alpha_if_needed( + iteration=iteration, + estimate_alpha=bool(estimate_alpha) and not alpha_frozen, + alpha_update_every=alpha_update_every, + alpha_max_iter=alpha_max_iter, + alpha_tol=alpha_tol, + alpha_min_value=alpha_min_value, + ) + if compute_avg_log_likelihood: + avg_log_likelihood = self._compute_average_log_likelihood( + encoded_docs=encoded_docs + ) + else: + avg_log_likelihood = ( + float(self.average_ll[-1]) if self.average_ll else float("nan") + ) + avg_log_likelihood_sec = 0.0 + + # ------------------------------------------------------------------------- + # EM-style learning (E-step: Gibbs for z, M-step: update mixture parameters) + # ------------------------------------------------------------------------- + def sample( + self, + embeddings, + num_iterations: 10, + num_sweeps: int = 20, + num_samples: int = 8, + *, + estimate_alpha: bool = True, + alpha_update_every: int = 1, + alpha_max_iter: int = 100, + alpha_tol: float = 1e-5, + alpha_min_value: float = 1e-3, + repair_empty_topics: bool = True, + min_topic_count_for_repair: int = 1, + avg_log_likelihood_every: int = 1, + invariant_check_every: int = 1, + ) -> None: + """ + Run EM-style learning for vMF-Sentence-LDA. + Each EM iteration t: + E-step: + - Keep topic parameters (μ_{k|c}, κ_k, π_{k|c}) fixed. + - Recompute n_{k,d} from current z. + - For each document d, run one sweep of Gibbs sampling over sentences: + p(z_{d,i} = k | z_{d,-i}, α, Δ) + ∝ (N_{dk}^{(-i)} + α_k) * vMF_mixture(x_{d,i} | Δ_k) + M-step: + - Given new topic assignments z, recompute mixture parameters: + R_{k|c} = Σ_i weight_{ic} x_i + μ_{k|c} = R_{k|c} / ||R_{k|c}|| + π_{k|c} = (1 / N_k) Σ_i weight_{ic} + r_k = (1 / N_k) Σ_c ||R_{k|c}|| + κ_k ≈ (r_k M - r_k^3) / (1 - r_k^2) + Args: + num_iterations: Number of EM iterations. + num_sweeps: Number of Gibbs sweeps per EM iteration (ζ). + num_samples: Number of samples to keep from the last sweeps (B). + At the end of each iteration, compute the average log-likelihood. + """ + if num_sweeps < 1: + raise ValueError("num_sweeps must be >= 1") + if num_samples < 1: + raise ValueError("num_samples must be >= 1") + if alpha_update_every < 1: + raise ValueError("alpha_update_every must be >= 1") + if not np.isfinite(alpha_min_value) or alpha_min_value <= 0.0: + raise ValueError("alpha_min_value must be positive and finite") + if min_topic_count_for_repair < 1: + raise ValueError("min_topic_count_for_repair must be >= 1") + if avg_log_likelihood_every < 1: + raise ValueError("avg_log_likelihood_every must be >= 1") + if invariant_check_every < 1: + raise ValueError("invariant_check_every must be >= 1") + if num_samples > num_sweeps: + num_samples = num_sweeps + for it in trange(num_iterations, desc="Running Training iterations."): + compute_avg_log_likelihood = self._should_run_periodic_step( + iteration=it, + total_iterations=num_iterations, + every=avg_log_likelihood_every, + ) + self._run_training_iteration( + embeddings, + iteration=it, + num_sweeps=num_sweeps, + num_samples=num_samples, + estimate_alpha=estimate_alpha, + alpha_update_every=alpha_update_every, + alpha_max_iter=alpha_max_iter, + alpha_tol=alpha_tol, + alpha_min_value=alpha_min_value, + repair_empty_topics=repair_empty_topics, + min_topic_count_for_repair=min_topic_count_for_repair, + compute_avg_log_likelihood=compute_avg_log_likelihood, + ) + + def get_num_documents(self) -> int: + """Return the number of documents used to train the model.""" + if not hasattr(self, "topic_counts_per_doc"): + raise AttributeError( + "topic_counts_per_doc is not available. " + "Make sure you have run `sample(...)` before calling this method." + ) + return int(self.topic_counts_per_doc.shape[1]) From 70178ec3840807a8b25c6ca811ee5b1737d2604a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 16 Oct 2026 12:00:58 +0200 Subject: [PATCH 04/29] Added implementation for VSLDA --- turftopic/models/vslda/__init__.py | 3 + .../models/{_vslda.py => vslda/_trainer.py} | 53 ++++-- turftopic/models/vslda/vslda.py | 176 ++++++++++++++++++ 3 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 turftopic/models/vslda/__init__.py rename turftopic/models/{_vslda.py => vslda/_trainer.py} (96%) create mode 100644 turftopic/models/vslda/vslda.py diff --git a/turftopic/models/vslda/__init__.py b/turftopic/models/vslda/__init__.py new file mode 100644 index 00000000..d6d95df8 --- /dev/null +++ b/turftopic/models/vslda/__init__.py @@ -0,0 +1,3 @@ +from turftopic.models.vslda.vslda import vSLDA + +__all__ = ["vSLDA"] diff --git a/turftopic/models/_vslda.py b/turftopic/models/vslda/_trainer.py similarity index 96% rename from turftopic/models/_vslda.py rename to turftopic/models/vslda/_trainer.py index 150c1dca..a1733689 100644 --- a/turftopic/models/_vslda.py +++ b/turftopic/models/vslda/_trainer.py @@ -229,7 +229,8 @@ def __init__( saem_burn_in: int | None = 5, saem_decay: float = 1.0, kappa_solver: str = "newton", - ) -> None: + random_state: int | None = None, + ): """ Args: num_topics: @@ -269,7 +270,8 @@ def __init__( self.num_documents = len(embeddings) self.embedding_size = embeddings[0].shape[1] self.embedding_dtype = embeddings[0].dtype - + self.random_state = random_state + self.rng = np.random.default_rng(self.random_state) self.alpha = self._init_alpha(alpha) self.kappa_default = float(kappa) self.max_kappa = float(max_kappa) @@ -463,7 +465,7 @@ def _refresh_density_caches(self) -> None: self._log_mixture_weights = np.log(self.mixture_weights + 1e-12) def _random_topic_vector(self, n_sentences: int) -> np.ndarray: - return np.random.randint( + return self.rng.integers( self.num_topics, size=int(n_sentences), dtype=np.int32, @@ -483,7 +485,7 @@ def _update_topic_mean_and_kappa_single(self, embeddings, k: int) -> None: norm_s = np.linalg.norm(s_k) if norm_s == 0.0 or n_k == 0: - v = np.random.randn(self.embedding_size) + v = self.rng.randn(self.embedding_size) v /= np.linalg.norm(v) + 1e-12 self.topic_means[k] = np.asarray(v, dtype=self.embedding_dtype) self.kappa_per_topic[k] = self.kappa_default @@ -578,10 +580,10 @@ def _init_mixture_from_topic_means(self, embeddings) -> None: for k in range(self.num_topics): base = self.topic_means[k] if np.linalg.norm(base) == 0.0: - base = np.random.randn(self.embedding_size) + base = self.rng.randn(self.embedding_size) base /= np.linalg.norm(base) + eps for c in range(self.num_components): - v = base + 0.01 * np.random.randn(self.embedding_size) + v = base + 0.01 * self.rng.randn(self.embedding_size) v /= np.linalg.norm(v) + eps self.component_means[k, c] = np.asarray( v, @@ -597,7 +599,7 @@ def _random_init_topic(self, embeddings, k: int) -> None: eps = 1e-6 self.mixture_weights[k] = 1.0 / self.num_components for c in range(self.num_components): - v = np.random.randn(self.embedding_size) + v = self.rng.randn(self.embedding_size) v /= np.linalg.norm(v) + eps self.component_means[k, c] = np.asarray( v, @@ -627,7 +629,7 @@ def _init_topic_from_vector( if c == 0: v = direction else: - v = direction + 0.01 * np.random.randn(self.embedding_size) + v = direction + 0.01 * self.rng.randn(self.embedding_size) v /= np.linalg.norm(v) + 1e-12 self.component_means[k, c] = np.asarray( v, @@ -696,7 +698,7 @@ def _find_repair_donor_sentence( sentence_indices = np.where(arr == donor_topic)[0] locations.extend((d, int(i)) for i in sentence_indices) if locations: - selected = locations[np.random.randint(len(locations))] + selected = locations[self.rng.randint(len(locations))] return selected[0], selected[1], donor_topic return None @@ -895,7 +897,7 @@ def _sample_topic_assignment( else: post /= post_sum - new_k = int(np.random.choice(self.num_topics, p=post)) + new_k = int(self.rng.choice(self.num_topics, p=post)) self.topic_assignments[doc_index][sentence_index] = new_k self.topic_counts_per_doc[new_k, doc_index] += 1 @@ -974,7 +976,7 @@ def _run_e_step( dtype=np.int32, copy=True, ) - uniforms = np.random.random(enc.shape[0]).astype( + uniforms = self.rng.random(enc.shape[0]).astype( np.float64, copy=False ) sample_doc_topic_assignments( @@ -1037,7 +1039,7 @@ def _apply_m_step_updates( rkc = r[k, c] norm_rkc = np.linalg.norm(rkc) if norm_rkc == 0.0: - v = np.random.randn(d_dim) + v = self.rng.standard_normal(size=(d_dim,)) v /= np.linalg.norm(v) + eps self.component_means[k, c] = np.asarray( v, @@ -1342,3 +1344,30 @@ def get_num_documents(self) -> int: "Make sure you have run `sample(...)` before calling this method." ) return int(self.topic_counts_per_doc.shape[1]) + + def infer_topic_proportions( + self, + embeddings: list[np.ndarray], + ) -> list[np.ndarray]: + sentence_posteriors = [] + for d, encoded_doc in enumerate(embeddings): + enc = np.asarray(encoded_doc, dtype=np.float64) + if enc.size == 0: + sentence_posteriors.append( + np.zeros((0, self.num_topics), dtype=np.float64) + ) + continue + if enc.ndim == 1: + enc = enc.reshape(1, -1) + log_lik_doc = self.log_vmf_density_matrix(enc) + probs = np.copy(log_lik_doc) + probs -= probs.max(axis=1, keepdims=True) + probs = np.exp(probs) + row_sums = probs.sum(axis=1, keepdims=True) + invalid = ~np.isfinite(row_sums) | (row_sums <= 0.0) + row_sums[invalid] = 1.0 + probs /= row_sums + if np.any(invalid): + probs[invalid.ravel()] = 1.0 / self.num_topics + sentence_posteriors.append(probs) + return sentence_posteriors diff --git a/turftopic/models/vslda/vslda.py b/turftopic/models/vslda/vslda.py new file mode 100644 index 00000000..ff94dae0 --- /dev/null +++ b/turftopic/models/vslda/vslda.py @@ -0,0 +1,176 @@ +import warnings +from datetime import datetime +from functools import partial +from typing import Literal, Optional, Union + +import numpy as np +from rich.console import Console +from sklearn.feature_extraction.text import CountVectorizer + +from turftopic.base import ContextualModel, Encoder +from turftopic.feature_importance import ( + npmi, +) +from turftopic.late import ( + LateSentenceTransformer, + Offsets, + flatten_repr, + unflatten_repr, +) +from turftopic.models.vslda._trainer import VMFLDATrainer +from turftopic.vectorizers.default import default_vectorizer + + +class vSLDA(ContextualModel): + """von-Mises distributed sentence LDA. + + ```python + from turftopic import vSLDA + corpus: list[str] = ["some text", "more text", ...] + + model = vSLDA(10).fit(corpus) + model.print_topics() + ``` + + Parameters + ---------- + n_components: int + Number of topics. + encoder: str or SentenceTransformer + Model to encode documents/terms, all-MiniLM-L6-v2 is the default. + vectorizer: CountVectorizer, default None + Vectorizer used for term extraction. + Can be used to prune or filter the vocabulary. + random_state: int, default None + Random state to use so that results are exactly reproducible. + trf_kwargs: dict, default None + Keyword arguments to apply when loading the Encoder model. + encode_kwargs: dict, default None + Keyword arguments to apply encoding documents with the encoder. + + Attributes + ---------- + """ + + def __init__( + self, + n_components: Union[int, Literal["auto"]], + encoder: Union[ + Encoder, str + ] = "sentence-transformers/all-MiniLM-L6-v2", + vectorizer: Optional[CountVectorizer] = None, + random_state: Optional[int] = None, + n_iter: int = 25, + window_size: Optional[int] = None, + step_size: Optional[int] = None, + batch_size: int = 32, + trf_kwargs=None, + encode_kwargs=None, + ): + self.n_components = n_components + self.encoder = encoder + self.encode_kwargs = encode_kwargs + self.trf_kwargs = trf_kwargs + self.random_state = random_state + self.window_size = window_size + self.batch_size = batch_size + self.n_iter = n_iter + self.step_size = step_size + self.load_encoder() + self.encoder_.__class__ = LateSentenceTransformer + if vectorizer is None: + self.vectorizer = default_vectorizer() + else: + self.vectorizer = vectorizer + + def encode_late( + self, raw_documents: list[str] + ) -> tuple[np.ndarray, list[Offsets]]: + if self.window_size is None: + embeddings, offsets = self.encoder_.encode_tokens( + raw_documents, batch_size=self.batch_size + ) + return embeddings, offsets + # If the window_size is specified, but not step_size, we set the step size to the window size + # Thereby getting non-overlapping windows + step_size = ( + self.window_size if self.step_size is None else self.step_size + ) + embeddings, offsets = self.encoder_.encode_windows( + raw_documents, + batch_size=self.batch_size, + window_size=self.window_size, + step_size=step_size, + ) + return embeddings, offsets + + def ragged_vectorize( + self, + raw_documents: list[str], + offsets: Offsets, + train_vectorizer=False, + ): + if train_vectorizer: + self.vectorizer.fit(raw_documents) + dtm = [] + for doc, offs in zip(raw_documents, offsets): + _dtm = self.vectorizer.transform( + [doc[start_ind:end_ind] for start_ind, end_ind in offs] + ) + dtm.append(_dtm) + return dtm + + def fit_transform( + self, + raw_documents, + y=None, + embeddings: Optional[list[np.ndarray]] = None, + offsets: Optional[Offsets] = None, + ) -> np.ndarray: + console = Console() + with console.status("Fitting model") as status: + if (embeddings is None) or (offsets is None): + status.update("Encoding documents") + embeddings, offsets = self.encode_late(raw_documents) + console.log("Documents encoded.") + self.embeddings = embeddings + status.update("Extracting terms.") + ragged_dtm = self.ragged_vectorize( + raw_documents, offsets=offsets, train_vectorizer=True + ) + console.log("Term extraction done.") + status.update("Fitting model using EM.") + self.trainer_ = VMFLDATrainer(embeddings, self.n_components) + self.trainer_.sample(embeddings, num_iterations=self.n_iter) + self.doc_topic_matrix = self.trainer_.infer_topic_proportions( + embeddings + ) + console.log("Model fitted.") + status.update("Estimating term importances.") + flat_dtm, _ = flatten_repr(ragged_dtm) + flat_topics, _ = flatten_repr(self.doc_topic_matrix) + self.components_ = npmi(flat_topics, flat_dtm) + console.log("Model fitting done.") + return self.doc_topic_matrix, offsets + + def transform( + self, raw_documents, embeddings: Optional[np.ndarray] = None + ) -> np.ndarray: + """Infers topic importances for new documents based on a fitted model. + + Parameters + ---------- + raw_documents: iterable of str + Documents to fit the model on. + embeddings: ndarray of shape (n_documents, n_dimensions), optional + Precomputed document encodings. + + Returns + ------- + ndarray of shape (n_dimensions, n_topics) + Document-topic matrix. + """ + if embeddings is None: + embeddings, offsets = self.encode_late(raw_documents) + doc_topic_matrix = self.trainer_.infer_topic_proportions(embeddings) + return doc_topic_matrix, offsets From a5652c73800304f226985467d0ae1869eb28ad1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 16 Oct 2026 12:59:03 +0200 Subject: [PATCH 05/29] Extended the Late interaction model API --- turftopic/late.py | 234 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 214 insertions(+), 20 deletions(-) diff --git a/turftopic/late.py b/turftopic/late.py index 03f7d248..c50c6102 100644 --- a/turftopic/late.py +++ b/turftopic/late.py @@ -1,15 +1,15 @@ import itertools import warnings +from abc import abstractmethod from functools import partial -from typing import Callable, Iterable, Optional, Union +from typing import Callable, Optional, Union import numpy as np import scipy.sparse as spr import torch -from sentence_transformers import SentenceTransformer +from sentence_transformers import MultiVectorEncoder, SentenceTransformer from sklearn.base import TransformerMixin from sklearn.preprocessing import normalize -from tqdm import trange from turftopic.base import ContextualModel @@ -17,6 +17,28 @@ Lengths = list[int] +def sliding_window( + token_embeddings: list[np.ndarray], + token_offsets: Offsets, + step_size: int, + window_size: int, +) -> tuple[list[np.ndarray], Offsets]: + window_embeddings = [] + window_offsets = [] + for emb, offs in zip(token_embeddings, token_offsets): + _offsets = [] + _embeddings = [] + for start_index in range(0, len(emb), step_size): + end_index = start_index + window_size + window_emb = np.mean(emb[start_index:end_index], axis=0) + off = offs[start_index:end_index] + _embeddings.append(window_emb) + _offsets.append((off[0][0], off[-1][1])) + window_embeddings.append(normalize(np.stack(_embeddings))) + window_offsets.append(_offsets) + return window_embeddings, window_offsets + + class LateSentenceTransformer(SentenceTransformer): """SentenceTransformer model that can produce token and window-level embeddings. Its output can be used by topic models that can use multi-vector document representations. @@ -147,20 +169,12 @@ def encode_windows( batch_size=batch_size, show_progress_bar=show_progress_bar, ) - window_embeddings = [] - window_offsets = [] - for emb, offs in zip(token_embeddings, token_offsets): - _offsets = [] - _embeddings = [] - for start_index in range(0, len(emb), step_size): - end_index = start_index + window_size - window_emb = np.mean(emb[start_index:end_index], axis=0) - off = offs[start_index:end_index] - _embeddings.append(window_emb) - _offsets.append((off[0][0], off[-1][1])) - window_embeddings.append(normalize(np.stack(_embeddings))) - window_offsets.append(_offsets) - return window_embeddings, window_offsets + return sliding_window( + token_embeddings, + token_offsets, + window_size=window_size, + step_size=step_size, + ) def flatten_repr( @@ -180,8 +194,17 @@ def flatten_repr( lengths: list[int] Length of each document in the corpus. """ - lengths = [r.shape[0] for r in repr] - return np.concatenate(repr, axis=0), lengths + if len(repr) == 0: + return np.array([]) + if isinstance(repr[0], np.ndarray): + lengths = [r.shape[0] for r in repr] + return np.concatenate(repr, axis=0), lengths + if spr.issparse(repr[0]): + lengths = [r.shape[0] for r in repr] + return spr.vstack(repr), lengths + else: + lengths = [len(r) for r in repr] + return list(itertools.chain.from_iterable(repr)), lengths def unflatten_repr( @@ -267,7 +290,177 @@ def get_document_chunks( return chunks -class LateWrapper(ContextualModel, TransformerMixin): +class LateModel(ContextualModel, TransformerMixin): + """Should have the following attributes: + + window_size: int, default None + Size of the sliding window to average tokens over. + If None, documents will be represented at a token level. + step_size: int, default None + Step size of the window. + If (step_size == None) or (step_size == window_size), then windows are separate. + If step_size < window_size, windows will overlap. + If step_size > window_size, there will be gaps between the windows. + In this case, we throw a warning, as this is probably unintended behaviour. + pooling: Callable, default None + Indicates whether and how to pool document-topic matrices. + If None, multi-vector topic proportions are returned in a ragged array. + If Callable, multiple vectors are averaged with the callable in each document. + You could for example take the mean by specifying `pooling=np.nanmean`. + """ + + def load_encoder(self): + if isinstance(self.encoder, str): + if self.trf_kwargs is None: + trf_kwargs = dict() + else: + trf_kwargs = self.trf_kwargs + self.encoder_ = MultiVectorEncoder(self.encoder, **trf_kwargs) + self._encoder_preloaded = False + elif isinstance(self.encoder, SentenceTransformer): + self.encoder_ = self.encoder + # This is a nasty trick, and I should probably find something better in the future, + # but this casts a sentence transformer directly into a LateSentenceTransformer + self.encoder_.__class__ = LateSentenceTransformer + else: + self.encoder_ = self.encoder + self._encoder_preloaded = True + + @staticmethod + def flatten_repr(repr): + return flatten_repr(repr) + + @staticmethod + def unflatten_repr(flat_repr, lengths): + return unflatten_repr(flat_repr, lengths) + + @staticmethod + def pool_flat(flat_repr, lengths, agg=np.nanmean): + return pool_flat(flat_repr, lengths, agg) + + def pool(self, doc_topic_matrix: list[np.ndarray], lengths): + if self.pooling is None: + return doc_topic_matrix, lengths + else: + return pool_flat(doc_topic_matrix, lengths, self.pooling) + + def ragged_vectorize( + self, + raw_documents: list[str], + offsets: Offsets, + train_vectorizer=False, + ): + if train_vectorizer: + self.vectorizer.fit(raw_documents) + dtm = [] + for doc, offs in zip(raw_documents, offsets): + _dtm = self.vectorizer.transform( + [doc[start_ind:end_ind] for start_ind, end_ind in offs] + ) + dtm.append(_dtm) + return dtm + + def encode_tokens(self, raw_documents: list[str]): + if isinstance(self.encoder_, MultiVectorEncoder): + embeddings = self.encoder_.encode( + raw_documents, convert_to_numpy=True, **self.encode_kwargs + ) + offsets = self.encoder_.tokenizer( + raw_documents, return_offsets_mapping=True, verbose=False + )["offset_mapping"] + offsets = [ + offs[: len(embs)] for offs, embs in zip(offsets, embeddings) + ] + return embeddings, offsets + else: + embeddings, offsets = self.encoder_.encode_tokens( + raw_documents, **self.encode_kwargs + ) + return embeddings, offsets + + def encode_documents(self, raw_documents: list[str]): + if isinstance(self.encoder_, MultiVectorEncoder): + embeddings, offsets = self.encode_tokens(raw_documents) + flat_emb, lengths = flatten_repr(embeddings) + return pool_flat(flat_emb, lengths) + else: + return self.encoder_.encode(raw_documents, **self.encode_kwargs) + + def encode_late( + self, raw_documents: list[str] + ) -> tuple[np.ndarray, list[Offsets]]: + token_embeddings, token_offsets = self.encode_tokens(raw_documents) + if self.window_size is None: + return token_embeddings, token_offsets + # If the window_size is specified, but not step_size, we set the step size to the window size + # Thereby getting non-overlapping windows + step_size = ( + self.window_size if self.step_size is None else self.step_size + ) + return sliding_window( + token_embeddings, + token_offsets, + window_size=self.window_size, + step_size=step_size, + ) + + @abstractmethod + def fit_transform( + self, + raw_documents, + y=None, + embeddings: Optional[list[np.ndarray]] = None, + offsets: Optional[Offsets] = None, + ) -> tuple[list[np.ndarray], Offsets] | np.ndarray: + """Fits model and infers topic importances for each document. + + Parameters + ---------- + raw_documents: iterable of str + Documents to fit the model on. + y: None + Ignored, exists for sklearn compatibility. + embeddings: ndarray of shape (n_documents, n_dimensions), optional + Ragged array of document encodings. + offsets: Offsets, optional + Character offset for each unit in the document. + + Returns + ------- + (list[np.ndarray], Offsets) or ndarray + Document-topic matrix. + If pooling = None, this should be a ragged array with offsets, + otherwise a regular matrix. + """ + pass + + def fit( + self, + raw_documents, + y=None, + embeddings: Optional[list[np.ndarray]] = None, + offsets: Optional[Offsets] = None, + ): + """Fits model on the given corpus. + + Parameters + ---------- + raw_documents: iterable of str + Documents to fit the model on. + y: None + Ignored, exists for sklearn compatibility. + embeddings: list[ndarray], optional + Ragged array of document encodings. + offsets: Offsets, optional + Character offset for each unit in the document. + """ + self.fit_transform( + raw_documents, y, embeddings=embeddings, offsets=offsets + ) + return self + + +class LateWrapper(LateModel): """Wraps existing Turftopic model so that they can accept and create multi-vector document representations. @@ -310,6 +503,7 @@ def __init__( self.pooling = pooling self.window_size = window_size self.step_size = step_size + self.encode_kwargs = self.model.encode_kwargs def encode_late( self, raw_documents: list[str] From 04934de65245d0f58bf1ab60364994ad73262211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 16 Oct 2026 12:59:51 +0200 Subject: [PATCH 06/29] Added lexical-semantic feature importance from Topeax" --- turftopic/feature_importance.py | 45 +++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/turftopic/feature_importance.py b/turftopic/feature_importance.py index 6bd99969..76de68a9 100644 --- a/turftopic/feature_importance.py +++ b/turftopic/feature_importance.py @@ -210,7 +210,7 @@ def ctf_idf( def npmi( doc_topic_matrix: np.ndarray, doc_term_matrix: spr.csr_matrix, - smoothing: int = 5, + smoothing: int = 2, ) -> np.ndarray: """Uses normalized pointwise mutual information between clusters and terms to calculate term importance scores. @@ -225,7 +225,7 @@ def npmi( Document-topic matrix of shape (n_documents, n_topics) doc_term_matrix: np.ndarray Document-term matrix of shape (n_documents, vocab_size) - smoothing: int, default 5 + smoothing: int, default 2 Alpha parameter of the symmetric Dirichlet-multinomial. Corresponds to assuming that each term and cluster occurred this many more times than the observed. @@ -257,3 +257,44 @@ def npmi( denominator = -(log_p_wt.T - np.log2(p_t)).T res = numerator / denominator return res + + +def combined_lexical_semantic( + doc_topic_matrix: np.ndarray, + doc_term_matrix: spr.csr_matrix, + topic_embeddings: np.ndarray, + vocab_embeddings: np.ndarray, + smoothing: int = 2, +): + """Uses the combined lexical-semantic term importance from Kardos (2026). + + Parameters + ---------- + doc_topic_matrix: np.ndarray + Document-topic matrix of shape (n_documents, n_topics) + doc_term_matrix: np.ndarray + Document-term matrix of shape (n_documents, vocab_size) + smoothing: int, default 2 + Alpha parameter of the symmetric Dirichlet-multinomial. + Corresponds to assuming that each term and cluster + occurred this many more times than the observed. + + Returns + ------- + ndarray of shape (n_topics, vocab_size) + Term importance matrix. + + References + ---------- + Kardos, M. (2026, January 29). Topeax -- An Improved Clustering Topic Model with Density Peak Detection and Lexical-Semantic Term Importance. Retrieved from https://arxiv.org/abs/2601.21465 + """ + lexical_components = npmi( + doc_topic_matrix, doc_term_matrix, smoothing=smoothing + ) + semantic_components = cosine_similarity(topic_embeddings, vocab_embeddings) + # Transforming to positive values from 0 to 1 + # Then taking geometric average of the two values + components = np.sqrt( + ((1 + lexical_components) / 2) * ((1 + semantic_components) / 2) + ) + return components From e472b6ea20ad2b8070981d1e76662a8b73fdb74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 16 Oct 2026 13:00:15 +0200 Subject: [PATCH 07/29] Added term importance and new late API to vSLDA --- turftopic/models/vslda/vslda.py | 118 +++++++++++++++++++------------- 1 file changed, 70 insertions(+), 48 deletions(-) diff --git a/turftopic/models/vslda/vslda.py b/turftopic/models/vslda/vslda.py index ff94dae0..cf98eda2 100644 --- a/turftopic/models/vslda/vslda.py +++ b/turftopic/models/vslda/vslda.py @@ -1,5 +1,3 @@ -import warnings -from datetime import datetime from functools import partial from typing import Literal, Optional, Union @@ -7,21 +5,38 @@ from rich.console import Console from sklearn.feature_extraction.text import CountVectorizer -from turftopic.base import ContextualModel, Encoder +from turftopic.base import Encoder from turftopic.feature_importance import ( + combined_lexical_semantic, + ctf_idf, + fighting_words, npmi, + soft_ctf_idf, ) from turftopic.late import ( - LateSentenceTransformer, + LateModel, Offsets, - flatten_repr, - unflatten_repr, ) from turftopic.models.vslda._trainer import VMFLDATrainer from turftopic.vectorizers.default import default_vectorizer +FEATURE_IMPORTANCE_METHODS = { + "soft-c-tf-idf": soft_ctf_idf, + "c-tf-idf": ctf_idf, + "fighting-words": fighting_words, + "npmi": partial(npmi, smoothing=2), + "lexical-semantic": partial(combined_lexical_semantic, smoothing=2), +} +FeatureImportance = Literal[ + "soft-c-tf-idf", + "c-tf-idf", + "npmi", + "fighting-words", + "lexical-semantic", +] -class vSLDA(ContextualModel): + +class vSLDA(LateModel): """von-Mises distributed sentence LDA. ```python @@ -63,7 +78,8 @@ def __init__( n_iter: int = 25, window_size: Optional[int] = None, step_size: Optional[int] = None, - batch_size: int = 32, + pooling=None, + feature_importance: FeatureImportance = "npmi", trf_kwargs=None, encode_kwargs=None, ): @@ -73,52 +89,56 @@ def __init__( self.trf_kwargs = trf_kwargs self.random_state = random_state self.window_size = window_size - self.batch_size = batch_size self.n_iter = n_iter self.step_size = step_size self.load_encoder() - self.encoder_.__class__ = LateSentenceTransformer if vectorizer is None: self.vectorizer = default_vectorizer() else: self.vectorizer = vectorizer - - def encode_late( - self, raw_documents: list[str] - ) -> tuple[np.ndarray, list[Offsets]]: - if self.window_size is None: - embeddings, offsets = self.encoder_.encode_tokens( - raw_documents, batch_size=self.batch_size + if feature_importance not in FEATURE_IMPORTANCE_METHODS: + valid = list(FEATURE_IMPORTANCE_METHODS.keys()) + raise ValueError( + f"{feature_importance} not in list of valid feature importance methods: {valid}" ) - return embeddings, offsets - # If the window_size is specified, but not step_size, we set the step size to the window size - # Thereby getting non-overlapping windows - step_size = ( - self.window_size if self.step_size is None else self.step_size - ) - embeddings, offsets = self.encoder_.encode_windows( - raw_documents, - batch_size=self.batch_size, - window_size=self.window_size, - step_size=step_size, - ) - return embeddings, offsets - def ragged_vectorize( + @property + def topic_embeddings(self): + return self.trainer_.topic_means + + def estimate_components( self, - raw_documents: list[str], - offsets: Offsets, - train_vectorizer=False, - ): - if train_vectorizer: - self.vectorizer.fit(raw_documents) - dtm = [] - for doc, offs in zip(raw_documents, offsets): - _dtm = self.vectorizer.transform( - [doc[start_ind:end_ind] for start_ind, end_ind in offs] - ) - dtm.append(_dtm) - return dtm + feature_importance: Optional[FeatureImportance] = None, + doc_topic_matrix=None, + doc_term_matrix=None, + ) -> np.ndarray: + feature_importance = feature_importance or self.feature_importance + imp_fn = FEATURE_IMPORTANCE_METHODS[feature_importance] + doc_topic_matrix = ( + doc_topic_matrix + if doc_topic_matrix is not None + else self.doc_topic_matrix + ) + doc_term_matrix = ( + doc_term_matrix + if doc_term_matrix is not None + else self.doc_term_matrix + ) + if feature_importance != "lexical-semantic": + self.components_ = imp_fn(doc_topic_matrix, doc_term_matrix) + return self.components_ + vocab = self.get_vocab() + if getattr(self, "vocab_embeddings", None) is None or ( + self.vocab_embeddings.shape[0] != vocab.shape[0] + ): + self.vocab_embeddings = self.encode_vocabulary_items(vocab) + self.components_ = imp_fn( + doc_topic_matrix, + doc_term_matrix, + self.topic_embeddings, + self.vocab_embeddings, + ) + return self.components_ def fit_transform( self, @@ -147,11 +167,13 @@ def fit_transform( ) console.log("Model fitted.") status.update("Estimating term importances.") - flat_dtm, _ = flatten_repr(ragged_dtm) - flat_topics, _ = flatten_repr(self.doc_topic_matrix) - self.components_ = npmi(flat_topics, flat_dtm) + flat_dtm, _ = self.flatten_repr(ragged_dtm) + flat_topics, _ = self.flatten_repr(self.doc_topic_matrix) + self.estimate_components( + doc_topic_matrix=flat_topics, doc_term_matrix=flat_dtm + ) console.log("Model fitting done.") - return self.doc_topic_matrix, offsets + return self.pool(self.doc_topic_matrix, offsets) def transform( self, raw_documents, embeddings: Optional[np.ndarray] = None From 3b6a2d2cb8ef6ffb0ae1b780f3ff2ca90317e625 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 16 Oct 2026 13:33:49 +0200 Subject: [PATCH 08/29] Bugfixes --- turftopic/models/vslda/_trainer.py | 4 +++- turftopic/models/vslda/vslda.py | 24 +++++++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/turftopic/models/vslda/_trainer.py b/turftopic/models/vslda/_trainer.py index a1733689..78178957 100644 --- a/turftopic/models/vslda/_trainer.py +++ b/turftopic/models/vslda/_trainer.py @@ -1,4 +1,6 @@ -"""This is a highly simplified NumPy only implemenation taken from here: https://github.com/izumi-lab/vSLDA/blob/main/src/models/vmf_sentence_lda.py""" +""" +This is a highly simplified NumPy only implemenation taken from here: https://github.com/izumi-lab/vSLDA/blob/main/src/models/vmf_sentence_lda.py +""" import math import warnings diff --git a/turftopic/models/vslda/vslda.py b/turftopic/models/vslda/vslda.py index cf98eda2..6d80dbb0 100644 --- a/turftopic/models/vslda/vslda.py +++ b/turftopic/models/vslda/vslda.py @@ -58,18 +58,34 @@ class vSLDA(LateModel): Can be used to prune or filter the vocabulary. random_state: int, default None Random state to use so that results are exactly reproducible. + n_iter: int, default 25 + Number of iterations to run with the EM algorithm. + window_size: int, default None + Size of the sliding window to average tokens over. + If None, documents will be represented at a token level. + step_size: int, default None + Step size of the window. + If (step_size == None) or (step_size == window_size), then windows are separate. + If step_size < window_size, windows will overlap. + If step_size > window_size, there will be gaps between the windows. + In this case, we throw a warning, as this is probably unintended behaviour. + pooling: Callable, default None + Indicates whether and how to pool document-topic matrices. + If None, multi-vector topic proportions are returned in a ragged array. + If Callable, multiple vectors are averaged with the callable in each document. + You could for example take the mean by specifying `pooling=np.nanmean`. + feature_importance: FeatureImportance, default 'npmi' + Feature importance method to use. trf_kwargs: dict, default None Keyword arguments to apply when loading the Encoder model. encode_kwargs: dict, default None Keyword arguments to apply encoding documents with the encoder. - Attributes - ---------- """ def __init__( self, - n_components: Union[int, Literal["auto"]], + n_components: int, encoder: Union[ Encoder, str ] = "sentence-transformers/all-MiniLM-L6-v2", @@ -91,6 +107,7 @@ def __init__( self.window_size = window_size self.n_iter = n_iter self.step_size = step_size + self.pooling = pooling self.load_encoder() if vectorizer is None: self.vectorizer = default_vectorizer() @@ -101,6 +118,7 @@ def __init__( raise ValueError( f"{feature_importance} not in list of valid feature importance methods: {valid}" ) + self.feature_importance = feature_importance @property def topic_embeddings(self): From 6d0f6ca6a8fafc55a80e65a3b19f7d35aefd9f98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 16 Oct 2026 13:34:40 +0200 Subject: [PATCH 09/29] added vSLDA to init" --- turftopic/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/turftopic/__init__.py b/turftopic/__init__.py index 7137c7d9..fea26fe5 100644 --- a/turftopic/__init__.py +++ b/turftopic/__init__.py @@ -15,6 +15,7 @@ from turftopic.models.keynmf import KeyNMF from turftopic.models.senstopic import SensTopic from turftopic.models.topeax import Topeax +from turftopic.models.vslda import vSLDA from turftopic.serialization import load_model try: @@ -42,4 +43,5 @@ "S3", "SensTopic", "ConceptVectorProjection", + "vSLDA", ] From f92fda63226e6e53d316c17d938bd00e407d6b0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 16 Oct 2026 14:36:46 +0200 Subject: [PATCH 10/29] Fixed issues with encoding --- turftopic/late.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/turftopic/late.py b/turftopic/late.py index c50c6102..f162860d 100644 --- a/turftopic/late.py +++ b/turftopic/late.py @@ -361,10 +361,11 @@ def ragged_vectorize( return dtm def encode_tokens(self, raw_documents: list[str]): + encode_kwargs = dict(convert_to_numpy=True) + if self.encode_kwargs is not None: + encode_kwargs = self.encode_kwargs if isinstance(self.encoder_, MultiVectorEncoder): - embeddings = self.encoder_.encode( - raw_documents, convert_to_numpy=True, **self.encode_kwargs - ) + embeddings = self.encoder_.encode(raw_documents, **encode_kwargs) offsets = self.encoder_.tokenizer( raw_documents, return_offsets_mapping=True, verbose=False )["offset_mapping"] @@ -374,17 +375,20 @@ def encode_tokens(self, raw_documents: list[str]): return embeddings, offsets else: embeddings, offsets = self.encoder_.encode_tokens( - raw_documents, **self.encode_kwargs + raw_documents, **encode_kwargs ) return embeddings, offsets def encode_documents(self, raw_documents: list[str]): + encode_kwargs = dict(convert_to_numpy=True) + if self.encode_kwargs is not None: + encode_kwargs = self.encode_kwargs if isinstance(self.encoder_, MultiVectorEncoder): embeddings, offsets = self.encode_tokens(raw_documents) flat_emb, lengths = flatten_repr(embeddings) return pool_flat(flat_emb, lengths) else: - return self.encoder_.encode(raw_documents, **self.encode_kwargs) + return self.encoder_.encode(raw_documents, **encode_kwargs) def encode_late( self, raw_documents: list[str] From 4def95fdb06839b028d09b806c23c235be9ffd0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 16 Oct 2026 14:50:43 +0200 Subject: [PATCH 11/29] Added NMF and LDA --- turftopic/__init__.py | 4 + turftopic/models/bow/lda.py | 152 ++++++++++++++++++++++++++++++++++++ turftopic/models/bow/nmf.py | 108 +++++++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 turftopic/models/bow/lda.py create mode 100644 turftopic/models/bow/nmf.py diff --git a/turftopic/__init__.py b/turftopic/__init__.py index fea26fe5..8b0c5971 100644 --- a/turftopic/__init__.py +++ b/turftopic/__init__.py @@ -2,6 +2,8 @@ from turftopic._datamapplot import build_datamapplot from turftopic.base import ContextualModel from turftopic.error import NotInstalled +from turftopic.models.bow.lda import LDA +from turftopic.models.bow.nmf import NMF from turftopic.models.cluster import ( BERTopic, ClusteringTopicModel, @@ -44,4 +46,6 @@ "SensTopic", "ConceptVectorProjection", "vSLDA", + "LDA", + "NMF", ] diff --git a/turftopic/models/bow/lda.py b/turftopic/models/bow/lda.py new file mode 100644 index 00000000..6b4bcb06 --- /dev/null +++ b/turftopic/models/bow/lda.py @@ -0,0 +1,152 @@ +from typing import Optional + +import numpy as np +from rich.console import Console +from sklearn.decomposition import LatentDirichletAllocation +from sklearn.feature_extraction.text import CountVectorizer + +from turftopic.base import ContextualModel +from turftopic.vectorizers.default import default_vectorizer + + +class LDA(ContextualModel): + """Turftopic wrapper around sklearns's implementation of Latent Dirichlet Allocation. + Note that LDA is a Bag-of-words model and NOT a contextual model and uses static word representations! + The encoder and passed embeddings will be ignored. + For interpretation of hyperparameters refer to [sklearn's documentation](https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.LatentDirichletAllocation.html) + + ```python + from turftopic import LDA + corpus: list[str] = ["some text", "more text", ...] + + model = LDA(10).fit(corpus) + model.print_topics() + ``` + + Parameters + ---------- + n_components: int + Number of topics. + encoder: None + Ignored. + vectorizer: CountVectorizer, default None + Vectorizer used for term extraction. + Can be used to prune or filter the vocabulary. + random_state: int, default None + Random state to use so that results are exactly reproducible. + trf_kwargs: dict, default None + Ignored. + encode_kwargs: dict, default None + Ignored. + + """ + + def __init__( + self, + n_components: int, + encoder=None, + vectorizer: Optional[CountVectorizer] = None, + random_state: Optional[int] = None, + doc_topic_prior=None, + topic_word_prior=None, + learning_method="batch", + learning_decay=0.7, + learning_offset=10.0, + max_iter=10, + batch_size=128, + evaluate_every=-1, + total_samples=1000000.0, + perp_tol=0.1, + mean_change_tol=0.001, + max_doc_update_iter=100, + n_jobs=None, + verbose=0, + trf_kwargs=None, + encode_kwargs=None, + ): + self.n_components = n_components + self.encoder = encoder + self.encode_kwargs = encode_kwargs + self.trf_kwargs = trf_kwargs + self.random_state = random_state + if vectorizer is None: + self.vectorizer = default_vectorizer() + else: + self.vectorizer = vectorizer + self.doc_topic_prior = doc_topic_prior + self.topic_word_prior = topic_word_prior + self.learning_method = learning_method + self.learning_decay = learning_decay + self.learning_offset = learning_offset + self.max_iter = max_iter + self.batch_size = batch_size + self.evaluate_every = evaluate_every + self.total_samples = total_samples + self.perp_tol = perp_tol + self.mean_change_tol = mean_change_tol + self.max_doc_update_iter = max_doc_update_iter + self.n_jobs = n_jobs + self.verbose = verbose + + @property + def components_(self): + return self.model_.components_ + + def fit_transform( + self, + raw_documents, + y=None, + embeddings: Optional[np.ndarray] = None, + ) -> np.ndarray: + console = Console() + with console.status("Fitting model") as status: + status.update("Extracting terms.") + self.doc_term_matrix = self.vectorizer.fit_transform(raw_documents) + console.log("Term extraction done.") + status.update("Fitting the model") + self.model_ = LatentDirichletAllocation( + self.n_components, + random_state=self.random_state, + doc_topic_prior=self.doc_topic_prior, + topic_word_prior=self.topic_word_prior, + learning_method=self.learning_method, + learning_decay=self.learning_decay, + learning_offset=self.learning_offset, + max_iter=self.max_iter, + batch_size=self.batch_size, + evaluate_every=self.evaluate_every, + total_samples=self.total_samples, + perp_tol=self.perp_tol, + mean_change_tol=self.mean_change_tol, + max_doc_update_iter=self.max_doc_update_iter, + n_jobs=self.n_jobs, + verbose=self.verbose, + ) + self.doc_topic_matrix = self.model_.fit_transform( + self.doc_term_matrix + ) + console.log("Model fitting done.") + self.top_documents = self.get_top_documents( + raw_documents, document_topic_matrix=self.doc_topic_matrix + ) + return self.doc_topic_matrix + + def transform( + self, raw_documents, embeddings: Optional[np.ndarray] = None + ) -> np.ndarray: + """Infers topic importances for new documents based on a fitted model. + + Parameters + ---------- + raw_documents: iterable of str + Documents to fit the model on. + embeddings: None + Ignored. + + Returns + ------- + ndarray of shape (n_dimensions, n_topics) + Document-topic matrix. + """ + doc_term_matrix = self.vectorizer.transform(raw_documents) + return self.model_.transform(doc_term_matrix) diff --git a/turftopic/models/bow/nmf.py b/turftopic/models/bow/nmf.py new file mode 100644 index 00000000..8f485fcf --- /dev/null +++ b/turftopic/models/bow/nmf.py @@ -0,0 +1,108 @@ +from typing import Optional + +import numpy as np +from rich.console import Console +from sklearn.decomposition import NMF as _NMF +from sklearn.feature_extraction.text import CountVectorizer + +from turftopic.base import ContextualModel +from turftopic.vectorizers.default import default_vectorizer + + +class NMF(ContextualModel): + """Turftopic wrapper around sklearns's implementation of Non-negative Matrix Factorization. + Note that NMF is a Bag-of-words model and NOT a contextual model and uses static word representations! + The encoder and passed embeddings will be ignored. + + ```python + from turftopic import NMF + corpus: list[str] = ["some text", "more text", ...] + + model = NMF(10).fit(corpus) + model.print_topics() + ``` + + Parameters + ---------- + n_components: int + Number of topics. + encoder: None + Ignored. + vectorizer: CountVectorizer, default None + Vectorizer used for term extraction. + Can be used to prune or filter the vocabulary. + random_state: int, default None + Random state to use so that results are exactly reproducible. + trf_kwargs: dict, default None + Ignored. + encode_kwargs: dict, default None + Ignored. + + """ + + def __init__( + self, + n_components: int, + encoder=None, + vectorizer: Optional[CountVectorizer] = None, + random_state: Optional[int] = None, + trf_kwargs=None, + encode_kwargs=None, + ): + self.n_components = n_components + self.encoder = encoder + self.encode_kwargs = encode_kwargs + self.trf_kwargs = trf_kwargs + self.random_state = random_state + if vectorizer is None: + self.vectorizer = default_vectorizer() + else: + self.vectorizer = vectorizer + + @property + def components_(self): + return self.model_.components_ + + def fit_transform( + self, + raw_documents, + y=None, + embeddings: Optional[np.ndarray] = None, + ) -> np.ndarray: + console = Console() + with console.status("Fitting model") as status: + status.update("Extracting terms.") + self.doc_term_matrix = self.vectorizer.fit_transform(raw_documents) + console.log("Term extraction done.") + status.update("Fitting the model") + self.model_ = _NMF( + self.n_components, random_state=self.random_state + ) + self.doc_topic_matrix = self.model_.fit_transform( + self.doc_term_matrix + ) + console.log("Model fitting done.") + self.top_documents = self.get_top_documents( + raw_documents, document_topic_matrix=self.doc_topic_matrix + ) + return self.doc_topic_matrix + + def transform( + self, raw_documents, embeddings: Optional[np.ndarray] = None + ) -> np.ndarray: + """Infers topic importances for new documents based on a fitted model. + + Parameters + ---------- + raw_documents: iterable of str + Documents to fit the model on. + embeddings: None + Ignored. + + Returns + ------- + ndarray of shape (n_dimensions, n_topics) + Document-topic matrix. + """ + doc_term_matrix = self.vectorizer.transform(raw_documents) + return self.model_.transform(doc_term_matrix) From f62f77f5add7b8b57595283019596697f43e838d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Fri, 16 Oct 2026 15:11:24 +0200 Subject: [PATCH 12/29] Bumped sbert dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3e236ef9..d916b944 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ requires-python = ">=3.10" dependencies = [ "numpy>=1.23.0", "scikit-learn>=1.3.0,<2.0.0", - "sentence-transformers>=2.2.0", + "sentence-transformers>=6.0.0", "torch>=2.1.0,<3.0.0", "scipy>=1.10.0,<2.0.0", "rich>=13.6.0,<14.0.0", From 3c334c1164aedd297d4d9fef40f26ecc75c3bb29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Tue, 20 Oct 2026 09:50:13 +0200 Subject: [PATCH 13/29] Fixed transform API for vSDLA --- turftopic/models/vslda/vslda.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/turftopic/models/vslda/vslda.py b/turftopic/models/vslda/vslda.py index 6d80dbb0..5aac509e 100644 --- a/turftopic/models/vslda/vslda.py +++ b/turftopic/models/vslda/vslda.py @@ -180,9 +180,6 @@ def fit_transform( status.update("Fitting model using EM.") self.trainer_ = VMFLDATrainer(embeddings, self.n_components) self.trainer_.sample(embeddings, num_iterations=self.n_iter) - self.doc_topic_matrix = self.trainer_.infer_topic_proportions( - embeddings - ) console.log("Model fitted.") status.update("Estimating term importances.") flat_dtm, _ = self.flatten_repr(ragged_dtm) @@ -191,26 +188,17 @@ def fit_transform( doc_topic_matrix=flat_topics, doc_term_matrix=flat_dtm ) console.log("Model fitting done.") - return self.pool(self.doc_topic_matrix, offsets) + return self.transform( + raw_documents, embeddings=embeddings, offsets=offsets + ) def transform( - self, raw_documents, embeddings: Optional[np.ndarray] = None + self, + raw_documents, + embeddings: Optional[np.ndarray] = None, + offsets: Optional[Offsets] = None, ) -> np.ndarray: - """Infers topic importances for new documents based on a fitted model. - - Parameters - ---------- - raw_documents: iterable of str - Documents to fit the model on. - embeddings: ndarray of shape (n_documents, n_dimensions), optional - Precomputed document encodings. - - Returns - ------- - ndarray of shape (n_dimensions, n_topics) - Document-topic matrix. - """ if embeddings is None: embeddings, offsets = self.encode_late(raw_documents) doc_topic_matrix = self.trainer_.infer_topic_proportions(embeddings) - return doc_topic_matrix, offsets + return self.pool(doc_topic_matrix, offsets) From 09c9bf122ee75085fa9b49a4b814565d1ebc0f24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 21 Oct 2026 15:58:29 +0200 Subject: [PATCH 14/29] Added gliformer-based concept vectorizer --- turftopic/vectorizers/gliformer.py | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 turftopic/vectorizers/gliformer.py diff --git a/turftopic/vectorizers/gliformer.py b/turftopic/vectorizers/gliformer.py new file mode 100644 index 00000000..b97a51c4 --- /dev/null +++ b/turftopic/vectorizers/gliformer.py @@ -0,0 +1,80 @@ +import numpy as np +from sklearn.feature_extraction.text import CountVectorizer + +try: + from gliformer import GLiFormer + from pydantic import BaseModel +except ModuleNotFoundError as e: + raise ModuleNotFoundError("Install GliFormer.") from e + + +class Topic(BaseModel): + topics: list[str] + + +class ConceptVectorizer(CountVectorizer): + def __init__( + self, + model_path: str = "knowledgator/gliformer-base-v1", + schema=None, + max_length: int | None = None, + *, + input="content", + encoding="utf-8", + decode_error="strict", + strip_accents=None, + max_df=1.0, + min_df=1, + max_features=None, + vocabulary=None, + binary=False, + dtype=np.int64, + ): + self.schema = schema + self.model_path = model_path + self.schema = schema + if schema is None: + self._schema = {"topic": Topic} + else: + self._schema = schema + self.model = GLiFormer.from_pretrained( + self.model_path, + load_tokenizer=True, + max_length=max_length, + ) + super().__init__( + input=input, + encoding=encoding, + decode_error=decode_error, + strip_accents=strip_accents, + max_df=max_df, + min_df=min_df, + max_features=max_features, + vocabulary=vocabulary, + binary=binary, + dtype=dtype, + ) + + def extract_concepts(self, text: str) -> list[str]: + try: + records = self.model.structure( + text, + self._schema, + ) + concepts = records["topic"][0]["topics"] + concepts = list( + set( + [ + " ".join(marker.split()).title() + for marker in concepts + if (len(marker.split()) < 4) + and all(part.isalnum() for part in marker.split()) + ] + ) + ) + return concepts + except (KeyError, IndexError): + return [] + + def build_tokenizer(self): + return self.extract_concepts From eb0d53d0e524678b5fbf3cb48fa869c1ea2e4263 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 21 Oct 2026 17:24:32 +0200 Subject: [PATCH 15/29] bugfix --- turftopic/vectorizers/gliformer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/turftopic/vectorizers/gliformer.py b/turftopic/vectorizers/gliformer.py index b97a51c4..e110dfdb 100644 --- a/turftopic/vectorizers/gliformer.py +++ b/turftopic/vectorizers/gliformer.py @@ -42,6 +42,7 @@ def __init__( load_tokenizer=True, max_length=max_length, ) + self.max_length = max_length super().__init__( input=input, encoding=encoding, From 77b8d9879abacaecc27f8bf05d5d06fa410a7d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 21 Oct 2026 17:24:50 +0200 Subject: [PATCH 16/29] Extensions and bugfixes to hierarchical API --- turftopic/hierarchical.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/turftopic/hierarchical.py b/turftopic/hierarchical.py index 8060570d..36ee2459 100644 --- a/turftopic/hierarchical.py +++ b/turftopic/hierarchical.py @@ -207,10 +207,7 @@ def get_words(self, top_k: int = 10) -> list[tuple[str, float]]: imp = self.word_importance[most_important] return list(zip(words, imp)) - @property - def description(self) -> str: - """Returns a high level description of the topic with its path in the tree - and top words.""" + def _get_description(self, top_k: int = 10): if not len(self.path): path = "Root" else: @@ -228,6 +225,12 @@ def description(self) -> str: console.print(stylized, end="") return capture.get() + @property + def description(self) -> str: + """Returns a high level description of the topic with its path in the tree + and top words.""" + return self._get_description() + @property def _simple_desc(self) -> str: if not len(self.path): @@ -249,9 +252,9 @@ def _build_tree( max_depth: Optional[int] = None, ) -> Tree: if tree is None: - tree = Tree(self.description) + tree = Tree(self._get_description(top_k=top_k)) else: - tree = tree.add(self.description) + tree = tree.add(self._get_description(top_k=top_k)) out_of_depth = (max_depth is not None) and (self.level >= max_depth) if out_of_depth: if self.children is not None: @@ -259,7 +262,7 @@ def _build_tree( return tree if self.children is not None: for child in self.children: - child._build_tree(tree, max_depth=max_depth) + child._build_tree(tree, max_depth=max_depth, top_k=top_k) return tree def print_tree( @@ -433,7 +436,7 @@ def clear(self): self.children = None return self - def divide(self, n_subtopics: int, **kwargs): + def divide(self, n_subtopics: int | None = None, **kwargs): """Divides current node into smaller subtopics. Only works when the underlying model is a divisive hierarchical model. @@ -452,7 +455,7 @@ def divide(self, n_subtopics: int, **kwargs): ) from e return self - def divide_children(self, n_subtopics: int, **kwargs): + def divide_children(self, n_subtopics: int | None = None, **kwargs): """Divides all children of the current node to smaller topics. Only works when the underlying model is a divisive hierarchical model. From 357c028c8abf24e0b8514f69aa181914b32e3896 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 21 Oct 2026 17:25:10 +0200 Subject: [PATCH 17/29] Added multilevel model to SensTopic --- turftopic/models/senstopic.py | 62 +++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/turftopic/models/senstopic.py b/turftopic/models/senstopic.py index 8d316ffd..5784c0e2 100644 --- a/turftopic/models/senstopic.py +++ b/turftopic/models/senstopic.py @@ -16,6 +16,7 @@ from turftopic.base import ContextualModel, Encoder from turftopic.dynamic import DynamicTopicModel from turftopic.encoders.multimodal import MultimodalEncoder +from turftopic.hierarchical import TopicNode from turftopic.models._snmf import SNMF from turftopic.multimodal import ( ImageRepr, @@ -34,6 +35,15 @@ ) +def init_snmf_auto(X, sparsity=1.0, random_state=None): + n_components_ = optimize_n_components( + partial(bic_snmf, X=X, sparsity=sparsity), + min_n=1, + verbose=True, + ) + return SNMF(n_components_, sparsity=sparsity, random_state=random_state) + + def bic_snmf( n_components: int, sparsity: float, X, random_state: int = 42 ) -> float: @@ -94,6 +104,8 @@ class SensTopic(ContextualModel, DynamicTopicModel, MultimodalModel): L1 penalty applied to document-topic proportions. Higher values push the model to assign fewer topics to a single document, while lower values will distribute topics across documents. + multilevel: bool, default False + If True, the model will also contain subtopics for each topic. trf_kwargs: dict, default None Keyword arguments to apply when loading the Encoder model. encode_kwargs: dict, default None @@ -113,6 +125,7 @@ def __init__( ] = "combined", random_state: Optional[int] = None, sparsity: float = 1, + multilevel: bool = False, trf_kwargs=None, encode_kwargs=None, ): @@ -130,6 +143,7 @@ def __init__( self.max_iter = max_iter self.random_state = random_state self.sparsity = sparsity + self.multilevel = multilevel def estimate_components( self, feature_importance: Literal["axial", "angular", "combined"] @@ -217,8 +231,56 @@ def fit_transform( ) self.document_topic_matrix = doc_topic console.log("Model fitting done.") + self.hierarchy = TopicNode.create_root( + self, self.components_, self.document_topic_matrix + ) + if self.multilevel: + status.update("Estimating subtopics.") + self.add_subtopics() + console.log("Subtopics estimated.") return doc_topic + def add_subtopics(self): + node = self.hierarchy + doc_topic_matrix = np.stack( + [child.document_topic_vector for child in node.children] + ).T + self.submodels_ = [] + labels = doc_topic_matrix.argmax(axis=1) + for i_child, old_child in enumerate(node.children): + _emb = self.embeddings[labels == i_child] + if self.n_components == "auto": + snmf = init_snmf_auto( + _emb, + sparsity=self.sparsity, + random_state=self.random_state, + ) + else: + snmf = SNMF( + n_components=self.n_components, + sparsity=self.sparsity, + random_state=self.random_state, + ) + if snmf.n_components == 1: + continue + self.submodels_.append(snmf) + _dt = snmf.fit_transform(_emb) + components = snmf.transform(self.vocab_embeddings).T + doc_topic = snmf.transform(self.embeddings) + old_child.children = [] + for topic_id, comp, doc_top in zip( + np.arange(components.shape[0]), components, doc_topic.T + ): + old_child.children.append( + TopicNode( + self, + path=(*old_child.path, topic_id), + word_importance=comp, + document_topic_vector=doc_top, + children=None, + ) + ) + def partial_fit_transform( self, raw_documents, From 2d558875067ef73e2f64bd230f2aeefb7a59a935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Tue, 27 Oct 2026 14:15:11 +0100 Subject: [PATCH 18/29] Added __init__ file to bow module --- turftopic/models/bow/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 turftopic/models/bow/__init__.py diff --git a/turftopic/models/bow/__init__.py b/turftopic/models/bow/__init__.py new file mode 100644 index 00000000..e69de29b From bc36ec1d72d23f23481c89671233ba61767698b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Tue, 27 Oct 2026 16:19:10 +0100 Subject: [PATCH 19/29] Updated documentation --- docs/LDA.md | 77 +++++++++++++ docs/NMF.md | 79 +++++++++++++ docs/benchmark.md | 4 + docs/c_top2vec.md | 18 +++ docs/images/lda_plate.png | Bin 0 -> 26433 bytes docs/images/vslda_plate.png | Bin 0 -> 35307 bytes docs/late_interaction.md | 45 +------- docs/model_overview.md | 23 +++- docs/vSLDA.md | 222 ++++++++++++++++++++++++++++++++++++ mkdocs.yml | 26 +++-- 10 files changed, 441 insertions(+), 53 deletions(-) create mode 100644 docs/LDA.md create mode 100644 docs/NMF.md create mode 100644 docs/images/lda_plate.png create mode 100644 docs/images/vslda_plate.png create mode 100644 docs/vSLDA.md diff --git a/docs/LDA.md b/docs/LDA.md new file mode 100644 index 00000000..6ea098c4 --- /dev/null +++ b/docs/LDA.md @@ -0,0 +1,77 @@ +# Latent Dirichlet Allocation + +LDA is one of the most well known and widely used topic models. +It is a bag-of-words topic model that models topics as word distributions drawn from a Dirichlet prior, and documents as mixtures of topics, with the mixture weights also having a Dirichlet prior. + +
+ +
Figure 1: Plate notation of LDA..
Figure from Blei et al. (2003)
+
+ + +!!! note + The Turftopic implementation of LDA is a thin wrapper around the scikit-learn implementation mainly for convenience and benchmarking. + We recommend that you refer to [their documentation](https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.LatentDirichletAllocation.html) for further details. + +## Usage + +```python +from turftopic import LDA + +documents = [...] + +model = LDA(10) +doc_topic_matrix = model.fit_transform(documents) +model.print_topics() +``` + +!!! tip + Since LDA is a BoW topic model, it does not handle stop words particularly well. + We recommend that you use a vectorizer that removes stop words or all too frequent words: + ```python + from turftopic import LDA + from sklearn.feature_extraction.text import CountVectorizer + + model = LDA(10, vectorizer=CountVectorizer(min_df=10, stop_words="english", max_df=0.8)) + ``` + +## Citation + +Please cite the authors of the paper, and Turftopic when using LDA: + +```bibtex +@article{Blei2003, + author = {Blei, David M. and Ng, Andrew Y. and Jordan, Michael I.}, + title = {Latent dirichlet allocation}, + year = {2003}, + issue_date = {3/1/2003}, + publisher = {JMLR.org}, + volume = {3}, + number = {null}, + issn = {1532-4435}, + abstract = {We describe latent Dirichlet allocation (LDA), a generative probabilistic model for collections of discrete data such as text corpora. LDA is a three-level hierarchical Bayesian model, in which each item of a collection is modeled as a finite mixture over an underlying set of topics. Each topic is, in turn, modeled as an infinite mixture over an underlying set of topic probabilities. In the context of text modeling, the topic probabilities provide an explicit representation of a document. We present efficient approximate inference techniques based on variational methods and an EM algorithm for empirical Bayes parameter estimation. We report results in document modeling, text classification, and collaborative filtering, comparing to a mixture of unigrams model and the probabilistic LSI model.}, + journal = {J. Mach. Learn. Res.}, + month = mar, + pages = {993–1022}, + numpages = {30} +} + +@article{ + Kardos2025, + title = {Turftopic: Topic Modelling with Contextual Representations from Sentence Transformers}, + doi = {10.21105/joss.08183}, + url = {https://doi.org/10.21105/joss.08183}, + year = {2025}, + publisher = {The Open Journal}, + volume = {10}, + number = {111}, + pages = {8183}, + author = {Kardos, Márton and Enevoldsen, Kenneth C. and Kostkan, Jan and Kristensen-McLachlan, Ross Deans and Rocca, Roberta}, + journal = {Journal of Open Source Software} +} +``` + + +## API Reference + +::: turftopic.models.bow.lda.LDA diff --git a/docs/NMF.md b/docs/NMF.md new file mode 100644 index 00000000..b0dd7c58 --- /dev/null +++ b/docs/NMF.md @@ -0,0 +1,79 @@ +# Nonnegative Matrix Factorization + +NMF is a factor-based topic model of bag-of-words or tf-idf representations. +It treats topics as latent factors that explain variance in word counts/importances in documents. +Factors are strictly positive, hence the name. + +!!! tip + NMF is an older topic model with substantially less interpretable topics than modern approaches, and with no contextual understanding. + If you want to use a model that is more powerful but similar to NMF, we recommend you try out [KeyNMF](KeyNMF.md) or [SensTopic](SensTopic.md). + +
+ +
Figure 1: Depiction of Nonnegative Matrix Factorization.
By Qwertyus - Own work, CC BY-SA 3.0, https://commons.wikimedia.org/w/index.php?curid=29114677
+
+ + + +## Usage + +```python +from turftopic import NMF + +documents = [...] + +model = NMF(10) +doc_topic_matrix = model.fit_transform(documents) +model.print_topics() +``` + +!!! tip + Since NMF is a BoW topic model, it does not handle stop words particularly well. + We recommend that you use a vectorizer that removes stop words or all too frequent words, potentially with tf-idf weighting + ```python + from turftopic import LDA + from sklearn.feature_extraction.text import TfidfVectorizer + + model = NMF(10, vectorizer=TfidfVectorizer(min_df=10, stop_words="english", max_df=0.8)) + ``` + +## Citation + +Please cite the authors of the implementation, and Turftopic when using NMF: + +```bibtex +@article{Cedric2011, + author = {Févotte, Cédric and Idier, Jérôme}, + title = {Algorithms for Nonnegative Matrix Factorization with the β-Divergence}, + journal = {Neural Computation}, + volume = {23}, + number = {9}, + pages = {2421-2456}, + year = {2011}, + month = {09}, + abstract = {This letter describes algorithms for nonnegative matrix factorization (NMF) with the β-divergence (β-NMF). The β-divergence is a family of cost functions parameterized by a single shape parameter β that takes the Euclidean distance, the Kullback-Leibler divergence, and the Itakura-Saito divergence as special cases (β = 2, 1, 0 respectively). The proposed algorithms are based on a surrogate auxiliary function (a local majorization of the criterion function). We first describe a majorization-minimization algorithm that leads to multiplicative updates, which differ from standard heuristic multiplicative updates by a β-dependent power exponent. The monotonicity of the heuristic algorithm can, however, be proven for β ∈ (0, 1) using the proposed auxiliary function. Then we introduce the concept of the majorization-equalization (ME) algorithm, which produces updates that move along constant level sets of the auxiliary function and lead to larger steps than MM. Simulations on synthetic and real data illustrate the faster convergence of the ME approach. The letter also describes how the proposed algorithms can be adapted to two common variants of NMF: penalized NMF (when a penalty function of the factors is added to the criterion function) and convex NMF (when the dictionary is assumed to belong to a known subspace).}, + issn = {0899-7667}, + doi = {10.1162/NECO_a_00168}, + url = {https://doi.org/10.1162/NECO_a_00168}, + eprint = {https://direct.mit.edu/neco/article-pdf/23/9/2421/852282/neco_a_00168.pdf}, +} + +@article{ + Kardos2025, + title = {Turftopic: Topic Modelling with Contextual Representations from Sentence Transformers}, + doi = {10.21105/joss.08183}, + url = {https://doi.org/10.21105/joss.08183}, + year = {2025}, + publisher = {The Open Journal}, + volume = {10}, + number = {111}, + pages = {8183}, + author = {Kardos, Márton and Enevoldsen, Kenneth C. and Kostkan, Jan and Kristensen-McLachlan, Ross Deans and Rocca, Roberta}, + journal = {Journal of Open Source Software} +} +``` + + +## API Reference + +::: turftopic.models.bow.lda.LDA diff --git a/docs/benchmark.md b/docs/benchmark.md index 7f48d773..a580b2d7 100644 --- a/docs/benchmark.md +++ b/docs/benchmark.md @@ -1,5 +1,9 @@ # Model Leaderboard +!!! note + The leaderboard is currently out of date, as multiple models have been implemented since the benchmark was last run, and we are currently working on new evaluation methods. + We are doing our best to keep up with the influx of models, and will update the leaderboard as soon as possible. + To aid you in choosing the best model for your use case, we have made a topic model benchmark and leaderboard. The benchmark consists of all English P2P clustering tasks from the most recent version of [MTEB](https://huggingface.co/spaces/mteb/leaderboard), plus a tweet and a news dataset, as these are not present in MTEB. diff --git a/docs/c_top2vec.md b/docs/c_top2vec.md index f8f6ab35..3c72d67d 100644 --- a/docs/c_top2vec.md +++ b/docs/c_top2vec.md @@ -37,6 +37,24 @@ doc_topic_matrix = model.fit_transform(corpus) model.print_topics() ``` + +| Topic ID | Highest Ranking | +| - | - | +| -1 | caused atheism organization, genocide caused atheism, atheism organization, atheism, subject political atheists, alt atheism, caused atheism, political atheists organization, subject amusing atheists, amusing atheists | +| 166 | atheists organization, political atheists organization, christian morality organization, caused atheism organization, morality organization, atheism organization, atheists organization california, subject amusing atheists, cwru edu article, alt atheism | +| 172 | biblical, read bible, caused atheism, agnostics, caused atheism organization, atheists agnostics, christianity, alt atheism, atheism, christian morality organization | +| 173 | objective morality, morality, subject christian morality, christian morality, natural morality, say christian morality, morality organization, christian morality organization, behavior moral, moral | +| 175 | atheism, atheism organization, caused atheism organization, atheists agnostics, caused atheism, subject political atheists, alt atheism, genocide caused atheism, subject amusing atheists, amusing atheists | +| 176 | rushdie islamic law, subject rushdie islamic, islamic genocide, islamic law, genocide caused atheism, subject islamic, islamic law organization, islamic genocide organization, rushdie islamic, islamic authority | + +You might also observe that the output of this model is a regular document-topic matrix, and isn't ragged. +```python +print(doc_topic_matrix.shape) +# (1024, 6) +``` + +This is because this way the model has the same API, as other Turftopic models, and works the same way as the top2vec package, making migration easier. + ## Citation Please cite Angelov and Inkpen (2024) and Turftopic when using C-Top2Vec in publications: diff --git a/docs/images/lda_plate.png b/docs/images/lda_plate.png new file mode 100644 index 0000000000000000000000000000000000000000..72909fb53233268f62b6b4ab80abe9125ce7e42e GIT binary patch literal 26433 zcmeGEX*iZ&*aeI$jWR|VBP3%8WmcI&GKb6(N@R}A4Q7%lO=g8uXd-2vDrGL33{fa$ zE<^FIegB^4c>bUMU*50peH1V-Qe(cH4PIvvn3HKs_j(T>IY2xZ;pRHYxQQ~OY_v9li5OdN{6?fYO;7F^4^af=}zn z&55)7coLS@%Z@y+NSIKbQyB@YI(XYah-Et;dF4oTFmKzhPF{J3<#M7Zuap`ljsJeN z=8_QQGClzhbtcM7_#A%>mvkZHbW zVq|o_p+UW&sflmz-mOzpQ}aK|`m^%$*FUwoWbxP~TPIaSQc^NZHG99IA=85g4{T_~ z92^`To#Of&3M(3*7U^d z*JA_@T`6$eziR)Dy>2CrEnBUg`!n6XecQv+vo`C%1&=?!tRL@TWJ+|ROme)Txp|aC z_212v0dsXJ!p0syKkU)HCbc$I)NJd!9ce97Z)E!i-rci2(f3^b!=sZIets<5^n;g= zZ^~bRhl%Or#aLE}x}F|Z-|yYEA0D5wKY3DEVJvm?=FI~?e#GuPeBEyD`&-O!#pn(` zJ|pR>&Dd3%k2CV{B;YeOO-<_pe~&YzW2s!4 z%&=3nu6`_Ycc=+lB`GW0yg2vc?P5m$G$S)JwXLmf@5hf0^$~P`sciZ9w$)Ouj>6Ts z-XpDSl6F+|JSum6ar>L;>7IjiYg1BEYOp>F3kx~PxG&wj4z_Wj^;p<-%(>I+tGo&$ zm1KPLlY=i_y;Ak?khD$Y<4cmGjNC=w@v5~{i;IiB4;(j%iHXg}urMXkQ&Pg?#4lkY zjl3G_d5V;YDQrwv^lL9P7SyIMNUIETvDMPrAYyt~NFh2Z>J>Z59felf+8Y&oM#F3> z1MGi~zt8bDxnEw+_HXIW!J(%3sn34>(XG6EIbKa9L5cjq&x7sRYI0}B*^WzH8|8ZZ z;`Qr`@$qzLetuXhD=XU}udZ%#iKHT}TlZ}$V=iq|{PO9kcY$qn6|SYBqif9142i*J zGPz@-?L*JRl*&wE*XY`pFUQKu%k5rY-F;Qk{`@ExuVx(ExjRRS_#`C_d}#jfI`^uD zb@_Vfb&323)$=zbr>DmwhV8p!lxF`wEN7sd518EeRqLjfpI?lx3)|Ph484TK4vma zsn0gCif8F(s+6Cd*-1-Ft3Gn#Vt?q$wx1s!*Wa@&vvYM7Q;5XH7cxkWNy-QdqtZ=R zNTvQR@b29^67twS>iLXJ9f4m&F+P*~NIsF@-rT?(b4p4}GpFYo>fMw(_cB>i5 z!pzyD>gq-fB#a8I{!P|1uqpTmt?w4~`QE)zQ&UqeXuf2W1y8^bB!G?k>M(McOV9oI z7caETEiGePJG5@dx;J4voyR)H>S6u-DEHctwS5=OzBinaA)jw{+`Ye6_wZ}Ot~>HK z1L>Pz^U#0!FtquxkGh6NHdd3%z`ec9-S(z}FU`}ZPjif;qDHrQq9UDqn53&7BFpGz z;oC6ko-p)2(q?}CnQ!mpKQY>QNe0#g*TypAr7Yi~jQPc09)r0Q8hZMOva+(635{ph zu3xWv`!+Ry7Pr}*(Rxrl@1Bm7=Wy43w#i_=jH5nH&)aP$ZF?A&SKgN@yFJ4@Ver!JP~wFP)L*`QQFo>cg7Wzo zK9;R%8S($VF}Up#7YiBd-;y)=^t)zCZf>s5Y0DVO!?Pk@6H`);^AG(>W;OeoeB_Dd z!=^h=OB&{W^h^y#^4uAIa;xBE*X{C9`e@XAoa zK7(62TTBYFDq8Xn#aoGwgbQFaFlkQIRQw;i{o2r#z-xxa zwDW&Y#ko6YKj>n}Pzk1CW@g^wp^U1aF9h+RV;c^BLHTtCXuwK{#>cPl0~*50m$NBEVLq~v~kdtO>PIuZ$q zo9CGmqO?S2U9yttYt*PYsIyi9dsK*_vWxrwrp}u8} zSy=%Af%dMUkIB(dkLY-wMn~vmhqYW$Z;xz^O1rhwSQ)Y{HwzwI$k<5B~lbv@r}D_I+47?X^J9y=l`XCFBDu zcjNEeDnT3YV+Z^H?u5M7aa*?D&(n?6ReTbwZd_>nvcS2L=r!ymUo4ZK7Z*os({VXU z>k!%4+8G-a=EkX{kB()=dd$o-rHsTHqFhsj3F>F0^vCOPks7>k56;v4)HJ@ z@%bkuK9V*O+EFS!VaPT<=+4*~BJ9^Dyy9 zt6AEK_YVX%Gc&i$?Y%W#7rqHU-ul0=s;J4@I=sgw;6+anZSt$HOZ+t*9Zc93UjuF! zZDnO$4|Mc@Pv~RYyM{nB~s(21_w@sw;y z3G|AA1z#(DQENk~s|vnh#^JKdn{LU=$qir_LU{FaUdfsnY0I^*k0`J@x;8UF7iF&* z#V_w?Oy>5bT@&sBK}u}RJj-)?0`_#YG*df${mYz%Epp;>#U7M9(<)i91KHWxqi>7S zD35fI1g(YMlo(fL;G3HJqEG$brid&IZTJ>cMeP@}YJbF?`wO#qk^OG{2trv5Katt#B-wI;Eq+ zayeV|-#PQCF2NJGat?;NySqO+)kFVZ2+sHvCnY7Fzk63qPF_CxzoI~Su35LK%pp;O zQ!`d#A4)b1g)VZ$to-A+w)^|jwwme3wzbcymWxi`vXXJ_Wi&N4mGc>84EXcw-h-tM zC(gaDHNbM)lVA2NH=^n$QW8+QF2myOUAvD@sn9M+(d`}_lsmg~Rkfp%s-FGYAiKA# zPY187Yq`b!YT?eeVi|`f+BIv|c&Jy_J#=m>3HcXrQ#sI-RQ$^IBzEozUI-bBJ(PNP zU|gP~H6u6tEiIQEqqK9Y`mtktu5NDOz9(`H-`HFG<_#Sk=U$d{Rdw}=bAor7nNHr? zEM#M)p~@3*M^R1xWJE;7RVhcB)vH&l=bvB+S3UQ&C9S@#O`q~jf&^CO#j~9CH#Jyn z6;vKQ)oXl=d8h7vfLV`;K4o(Yil=E2NNIBEn}gTf;z=9 z@?tU`>Lke>IB^9M;Mt5|fcgW@OGnldadr=>;ib^Blk5Ix#coC$!pINPP4n3#BOo8p-$5@22p6x8VV zd;I9^&yTFYWE2q2^*Aafrf#72g6Gs1jdbPI#3-#h|G`zOk5Alo2h5(JWs{@=*xHhw z>m@8KOrFoQ*yhbD4fP!dD41>QrmbT8lXo6&FEW$RW++TaVeuL3TYv!GrU06mw1y1K$e(lT8X+GeDpqH^NnlU=}}Yuy&R z<_I{rhtNDZYT;ILjF!2L_0^_(b19fkcz6Yv9b4|GXol( z%U7;Muo2v;ry1VXCF2Rl~6FZ&-V~f%(y+ zwMdC*3D3vJzX_~XotqsqCT;kR9cy@bd8xv%SsY%a2m`L`ogHRoO1${rEri9zHIE

}V6EHS5CJ|wD`m{LB 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 literal 0 HcmV?d00001 diff --git a/docs/images/vslda_plate.png b/docs/images/vslda_plate.png new file mode 100644 index 0000000000000000000000000000000000000000..c74153b6d967a41aa211689b3856263e1c73614a GIT binary patch literal 35307 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 literal 0 HcmV?d00001 diff --git a/docs/late_interaction.md b/docs/late_interaction.md index f054f188..8127ed68 100644 --- a/docs/late_interaction.md +++ b/docs/late_interaction.md @@ -4,8 +4,8 @@ Late interaction, or multi-vector models use token representations from a Senten This can be particularly useful for clustering models, as they, by default assign one topic to a single document, but when accessing token representations, can assign topics on a per-token basis. !!! info - There are currently no native late-interaction models in Turftopic, meaning models that explicitly model token representations in the context of a document. - We are currently working on implementing such models, but for the time being, wrappers are included, that can force regular models to use embeddings of higher granularity. + The only model natively supporting multi-vector embedding models right now is [vSLDA](vSLDA.md). + We are currently working on implementing multiple such models, but for the time being, wrappers are included, that can force regular models to use embeddings of higher granularity. **Visualization utilities** are also on the way. ## Encoding Tokens, and Ragged Array Manipulation @@ -163,46 +163,11 @@ enough and\nlistened to the arguments, I could ... 0.878 > ******************************* 0.647793 5.664651e-17 ... 1.805073e-19 4.612731e-04 ``` -## C-Top2Vec - -Contextual Top2Vec [(Angelov and Inkpen, 2024)](https://aclanthology.org/2024.findings-emnlp.790/) is a late-interaction topic model, that uses windowed representations. -The model is essentially the same as wrapping a regular Top2vec model in `LateWrapper`, but we provide a convenience class in Turftopic, so that it's easy for you to initialize this model. -It comes pre-loaded with the following features: - - - Same hyperparameters as in Angelov and Inkpen (2024) - - Phrase-vectorizer that finds regular phrases based on PMI - - `LateSentenceTransformer` by default, you can specify any model. - -Our implementation is much more flexible than the original top2vec package, and you might be able to use much more powerful or novel embedding models. - -```python -from turftopic import CTop2Vec - -model = CTop2Vec(n_reduce_to=5) -doc_topic_matrix = model.fit_transform(corpus) - -model.print_topics() -``` - - -| Topic ID | Highest Ranking | -| - | - | -| -1 | caused atheism organization, genocide caused atheism, atheism organization, atheism, subject political atheists, alt atheism, caused atheism, political atheists organization, subject amusing atheists, amusing atheists | -| 166 | atheists organization, political atheists organization, christian morality organization, caused atheism organization, morality organization, atheism organization, atheists organization california, subject amusing atheists, cwru edu article, alt atheism | -| 172 | biblical, read bible, caused atheism, agnostics, caused atheism organization, atheists agnostics, christianity, alt atheism, atheism, christian morality organization | -| 173 | objective morality, morality, subject christian morality, christian morality, natural morality, say christian morality, morality organization, christian morality organization, behavior moral, moral | -| 175 | atheism, atheism organization, caused atheism organization, atheists agnostics, caused atheism, subject political atheists, alt atheism, genocide caused atheism, subject amusing atheists, amusing atheists | -| 176 | rushdie islamic law, subject rushdie islamic, islamic genocide, islamic law, genocide caused atheism, subject islamic, islamic law organization, islamic genocide organization, rushdie islamic, islamic authority | - -You might also observe that the output of this model is a regular document-topic matrix, and isn't ragged. -```python -print(doc_topic_matrix.shape) -# (1024, 6) -``` +## API Reference -This is because this way the model has the same API, as other Turftopic models, and works the same way as the top2vec package, making migration easier. +### Abstract Base Class -## API Reference +::: turftopic.late.LateModel ### Encoder diff --git a/docs/model_overview.md b/docs/model_overview.md index 04e1d34c..0a8c8043 100644 --- a/docs/model_overview.md +++ b/docs/model_overview.md @@ -18,20 +18,31 @@ It is quite important that you choose the right topic model for your use case. | Model | Summary | Strengths | Weaknesses | | - | - | - | - | -| [Topeax](Topeax.md) | Density peak detection + Gaussian mixture approximation | Cluster quality, Topic quality, Stability, Automatic n-topics | Underestimates N topics, Slower, No inference for new documents | +| *Factorization Models* | | | | | [KeyNMF](KeyNMF.md) | Keyword importance estimation + matrix factorization | Reliability, Topic quality, Scalability to large corpora and long documents | Automatic topic number detection, Multilingual performance, Sometimes includes stop words | | [SensTopic(BETA)](SensTopic.md) | Regularized Semi-nonnegative matrix factorization in embedding space | Very fast, High quality topics and clusters, Can assign multiple soft clusters to documents, GPU support | Automatic n-topics is not too good | +| [$S^3$](s3.md) | Semantic axis discovery in embedding space | Fastest, Human-readable topics | Axes can be very unintuitive, Manual n-topics | +| *Probabilistic Models* | | | | +| [Topeax](Topeax.md) | Density peak detection + Gaussian mixture approximation | Cluster quality, Topic quality, Stability, Automatic n-topics | Underestimates N topics, Slower, No inference for new documents | | [GMM](GMM.md) | Soft clustering with Gaussian Mixtures and soft-cTF-IDF | Reliability, Speed, Cluster quality | Manual n-topics, Lower quality keywords, [Curse of dimensionality](https://en.wikipedia.org/wiki/Curse_of_dimensionality) | +| [vSLDA](vSLDA.md) | Reconstruct token/window/sentence representations using von-Mises components. | Contextual understanding, theoretical correctness | Manual n-topics, Can be slow with too granular representations | +| *Neural Models* | | | | | [FASTopic](FASTopic.md) | Neural topic modelling with Dual Semantic-relation Reconstruction | High quality topics and clusters, GPU support | Very slow, Memory hungry, Manual n-topics | -| [$S^3$](s3.md) | Semantic axis discovery in embedding space | Fastest, Human-readable topics | Axes can be very unintuitive, Manual n-topics | -| [BERTopic and Top2Vec](clustering.md) | Embed -> Reduce -> Cluster | Flexible, Feature rich | Slow, Unreliable and unstable, Wildly overestimates number of clusters, Low topic and cluster quality | | [AutoEncodingTopicModel](ctm.md) | Discover topics by generating BoW with a variational autoencoder | GPU-support | Slow, Sometimes low quality topics | +| *Clustering Models* | | | | +| [ClusteringTopicModel](clustering.md) | Embed -> Reduce -> Cluster | Flexible, many featuress | *Highly depends on construction* | +| [BERTopic](clustering.md) | UMAP+HDBSCAN+c-TF-IDF | Flexible, many features | Slow, Unstable, Wildly overestimates number of clusters, Low topic and cluster quality | +| [Top2Vec](clustering.md) | UMAP+HDBSCAN+semantic term importance | Flexible, many features, high quality topics | Slow, Unstable, Wildly overestimates number of clusters, Low cluster quality | +| [C-Top2Vec](clustering.md) | Top2Vec on contextual windows | High topic quality, contextual understanding | Slow, Unstable, Wildly overestimates number of clusters | +| *Bag-of-words models* | | | | +| [LDA](LDA.md) | Generative model of bag-of-words representations. | Reliable, well known limitations | Slow, low quality | +| [NMF](NMF.md) | Decomposes BoW representations using Non-negative Matrix Factorization. | Reliable, fast, well known limitations | Low quality | Different models will naturally be good at different things, because they conceptualize topics differently for instance: -- `BERTopic`, `Top2Vec`, `GMM` and `Topeax` find **clusters** of documents and treats those as topics +- `BERTopic`, `Top2Vec`, `GMM` and `Topeax` find **clusters/components** of documents and treats those as topics - `KeyNMF`, `SensTopic`, `FASTopic` and `AutoEncodingTopicModel` conceptualize topics as latent nonnegative **factors** that generate the documents. - `SemanticSignalSeparation`($S^3$) conceptualizes topics as **semantic axes**, along which topics are distributed. @@ -52,6 +63,10 @@ Some models are also capable of being used in a dynamic context, some can be fit | **[GMM](GMM.md)** | :heavy_check_mark: | :x: | :heavy_check_mark: | :x: | :heavy_check_mark: | :heavy_check_mark: | :x: | | **[AutoEncodingTopicModel](ctm.md)** | :heavy_check_mark: | :x: | :x: | :x: | :heavy_check_mark: | :heavy_check_mark: | :x: | | **[FASTopic](fastopic.md)** | :heavy_check_mark: | :x: | :x: | :x: | :heavy_check_mark: | :heavy_check_mark: | :x: | +| [C-Top2Vec](clustering.md) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | :x: | +| **[LDA](LDA.md)** | :heavy_check_mark: | :x: | :x: | :x: | :heavy_check_mark: | :x: | :x: | +| **[NMF](NMF.md)** | :heavy_check_mark: | :x: | :x: | :x: | :heavy_check_mark: | :x: | :x: | +| **[vSLDA](vSLDA.md)** | :heavy_check_mark: | :x: | :x: | :x: | :heavy_check_mark: | :x: | :x: | ## Model API Reference diff --git a/docs/vSLDA.md b/docs/vSLDA.md new file mode 100644 index 00000000..caa98a57 --- /dev/null +++ b/docs/vSLDA.md @@ -0,0 +1,222 @@ +# vSLDA + +vSDLA (Kobayashi et al., 2026) is a late-interaction probabilistic topic model that models as mixtures of von-Mises distributed embeddings. +The model is based on Latent Dirichlet Allocation, except its outcome distribution is a better fit for L2-normalized neural embeddings. + + +

+ +
Figure 1: Plate notation of the vSLDA model.
Figure from Kobayashi et al. (2026)
+
+ +## Usage + +vSDLA is natively a multi-vector/late-interaction topic model. +If you intend to use the model effectively you should make yourself familiar with [Turftopic's ragged array API](late_interaction.md). + +The authors originally intend the model to be used on sentence level, but in Turftopic, one can use vSLDA on contextual windows or tokens. + +!!! quote "Choose granularity and pooling" + === "Token-level w/o Pooling" + + ```python + from turftopic import vSLDA + + documents = [...] + + # pooling and window_size are default parameters, + # you don't have to specify that they are None + model = vSLDA(10, window_size=None, pooling=None) + + # The doc-topic matrix is a list of token-topic matrixes: (n_windows, n_topics) + # offsets contains the character offsets of each token in (start, end) tuples, + doc_topic_matrix, offsets = model.fit_transform(documents) + + model.print_topics() + ``` + + === "Window-level w/o Pooling" + + ```python + from turftopic import vSLDA + + documents = [...] + + model = vSLDA(10, window_size=20, pooling=None) + + # The doc-topic matrix is a list of window-topic matrixes shape: (n_windows, n_topics) + # offsets contains the character offsets of each window in (start, end) tuples, + doc_topic_matrix, offsets = model.fit_transform(documents) + + model.print_topics() + ``` + + === "Token-level w Pooling" + + ```python + import numpy as np + from turftopic import vSLDA + + documents = [...] + + # We will use np.nanmean to pool token-level topic probabilities + # One can also use other pooling techniques that respect the nature of probabilities + # better + model = vSLDA(10, pooling=np.nanmean) + + # The doc-topic matrix is a regular matrix, shape: (n_docs, n_topics) + # No offsets are returned when pooling is specify + doc_topic_matrix, offsets = model.fit_transform(documents) + + model.print_topics() + ``` + +### Term importance estimation + +vSLDA, similar to most other models in Turftopic, estimates term importances post-hoc. +Kobayashi et al. (2026) used NPMI for this purpose, which is also the default behaviour in Turftopic. +By default, however, Turftopic adds a smoothing term to the probability estimates instead of using frequencies, as very infrequent terms could dominate topics when one uses naive estimates. +Additionally, one can use a variety of different term importance estimation methods, some of which are embedding-based. + +To read more about the different term importance estimation techniques, read our documentation page on [Clustering Topic Models](clustering.md) and [Topeax](Topeax.md). + +!!! quote "Choose a term importance estimation method" + === "NPMI (default)" + + ```python + from turftopic import vSLDA + + documents = [...] + + model = vSLDA(10, feature_importance="npmi") + doc_topic_matrix, offsets = model.fit_transform(documents) + model.print_topics() + ``` + + === "Lexical-Semantic (recommended)" + + [Kardos (2026)](https://arxiv.org/abs/2601.21465) introduced a mixed method for estimating term importances in probabilistic embedding-based topic models, + which combines NPMI, a term based measure, and a cosine distance based semantic measure. + In our experience, this term importance method tends to be most robust and produces most interpretable topics, but more research remains to be done in this area. + + ```python + from turftopic import vSLDA + + documents = [...] + + model = vSLDA(10, feature_importance="lexical-semantic") + doc_topic_matrix, offsets = model.fit_transform(documents) + model.print_topics() + ``` + + === "c-TF-IDF" + + ```python + from turftopic import vSLDA + + documents = [...] + + model = vSLDA(10, feature_importance="c-tf-idf") + doc_topic_matrix, offsets = model.fit_transform(documents) + model.print_topics() + ``` + + === "Fighting Words" + + ```python + from turftopic import vSLDA + + documents = [...] + + model = vSLDA(10, feature_importance="fighting-words") + doc_topic_matrix, offsets = model.fit_transform(documents) + model.print_topics() + ``` + +### Choice of embedding model + +The authors used `all-MiniLM-L6-v2` for most of their investigations, which is, incidentally, the default encoder model in Turftopic. +In addition, they they benchmarked MiniLM against two larger embedding models (`all-mpnet-base-v2` and `bge-base-en-v1.5`) and found no improvement. + +It has to be noted, that none of the above models were developed for multi-vector tasks. +In Turftopic, vSLDA can be used with any embedding model, including `MultiVectorEncoder` and `SentenceTransformer` models. + +!!! quote "Choose encoder model" + === "`all-MiniLM-L6-v2` (default)" + + ```python + from turftopic import vSLDA + + documents = [...] + + model = vSLDA(10, encoder="all-MiniLM-L6-v2") + doc_topic_matrix, offsets = model.fit_transform(documents) + model.print_topics() + ``` + + === "`jinaai/jina-embeddings-v5-text-small`" + + ```python + import torch + from turftopic import vSLDA + + documents = [...] + + model = vSLDA( + 10, + encoder="jinaai/jina-embeddings-v5-text-small", + trf_kwargs=dict( + trust_remote_code=True, + device="cpu", + model_kwargs={"dtype": torch.bfloat16} + ), + encode_kwargs=dict(task="clustering") + ) + doc_topic_matrix, offsets = model.fit_transform(documents) + model.print_topics() + ``` + + === "LateOn (native multi-vector retrieval model)" + + ```python + from turftopic import vSLDA + + documents = [...] + + model = vSLDA(10, encoder="lightonai/LateOn") + doc_topic_matrix, offsets = model.fit_transform(documents) + model.print_topics() + ``` + +## References + +Please cite the original authors and Turftopic, when using vSLDA: + +```bibtex +@misc{kobayashi2026vmfsentenceldaspherical, + title={vMF Sentence LDA: A Spherical Topic Model over Sentence Embeddings}, + author={Ryotaro Kobayashi and Yuri Murayama and Kiyoshi Izumi}, + year={2026}, + eprint={2610.05095}, + archivePrefix={arXiv}, + primaryClass={cs.CL}, + url={https://arxiv.org/abs/2610.05095}, +} + +@article{ + Kardos2025, + title = {Turftopic: Topic Modelling with Contextual Representations from Sentence Transformers}, + doi = {10.21105/joss.08183}, + url = {https://doi.org/10.21105/joss.08183}, + year = {2025}, + publisher = {The Open Journal}, + volume = {10}, + number = {111}, + pages = {8183}, + author = {Kardos, Márton and Enevoldsen, Kenneth C. and Kostkan, Jan and Kristensen-McLachlan, Ross Deans and Rocca, Roberta}, + journal = {Journal of Open Source Software} +} +``` + +## API Reference +::: turftopic.models.vslda.vslda.vSLDA diff --git a/mkdocs.yml b/mkdocs.yml index 97810a67..57d0d889 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,15 +31,23 @@ nav: - Topic Models
(Overview and Performance): - Model Overview: model_overview.md - Model Leaderboard: benchmark.md - - Semantic Signal Separation (S³): s3.md - - SensTopic (BETA): SensTopic.md - - KeyNMF: KeyNMF.md - - Topeax: Topeax.md - - GMM: GMM.md - - Clustering Models (BERTopic & Top2Vec): clustering.md - - C-Top2Vec: c_top2vec.md - - Autoencoding Models (ZeroShotTM & CombinedTM): ctm.md - - FASTopic: FASTopic.md + - Factorization Models: + - Semantic Signal Separation (S³): s3.md + - SensTopic (BETA): SensTopic.md + - KeyNMF: KeyNMF.md + - Probabilistic Models: + - Topeax: Topeax.md + - GMM: GMM.md + - vSLDA: vSLDA.md + - Clustering Models: + - ClusteringTopicModel (BERTopic & Top2Vec): clustering.md + - C-Top2Vec: c_top2vec.md + - Neural Models: + - Autoencoding Models (ZeroShotTM & CombinedTM): ctm.md + - FASTopic: FASTopic.md + - Bag-of-words Models: + - LDA: LDA.md + - NMF: NMF.md - Other Models
(e.g. Sentiment Analysis): - Concept Vector Projection (Continuous Sentiment Scoring): cvp.md - Embeddings and Encoders
(Transformer Models): encoders.md From 1b319e67695fa19e7e7ec09d4cd97fc92c15ce97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Tue, 27 Oct 2026 16:21:01 +0100 Subject: [PATCH 20/29] Added new models to tests --- tests/test_integration.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 8888f219..6b0e4dfc 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -12,6 +12,8 @@ from turftopic import ( GMM, + LDA, + NMF, AutoEncodingTopicModel, ClusteringTopicModel, CTop2Vec, @@ -21,6 +23,7 @@ SensTopic, Topeax, load_model, + vSLDA, ) from turftopic.late import LateSentenceTransformer @@ -77,6 +80,8 @@ def generate_dates( FASTopic(3, batch_size=None, encoder=trf), SensTopic(encoder=trf), Topeax(encoder=trf), + LDA(), + NMF(), ] dynamic_models = [ @@ -93,7 +98,7 @@ def generate_dates( online_models = [KeyNMF(3, encoder=trf)] -late_models = [CTop2Vec(encoder=trf)] +late_models = [CTop2Vec(encoder=trf), vSLDA(encoder=trf, pooling=np.nanmean)] @pytest.mark.parametrize("model", dynamic_models) From cd44cdb7dfd593afa97e455b040490896e135971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Tue, 27 Oct 2026 16:28:01 +0100 Subject: [PATCH 21/29] Added n_components to tests --- tests/test_integration.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 6b0e4dfc..9568d039 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -80,8 +80,8 @@ def generate_dates( FASTopic(3, batch_size=None, encoder=trf), SensTopic(encoder=trf), Topeax(encoder=trf), - LDA(), - NMF(), + LDA(3), + NMF(3), ] dynamic_models = [ @@ -98,7 +98,10 @@ def generate_dates( online_models = [KeyNMF(3, encoder=trf)] -late_models = [CTop2Vec(encoder=trf), vSLDA(encoder=trf, pooling=np.nanmean)] +late_models = [ + CTop2Vec(encoder=trf), + vSLDA(3, encoder=trf, pooling=np.nanmean), +] @pytest.mark.parametrize("model", dynamic_models) From db71247b2def72837119b8ab9afcd14bbf0d541d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 28 Oct 2026 08:46:03 +0100 Subject: [PATCH 22/29] Fixed some issues with encode_kwargs in late models --- turftopic/late.py | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/turftopic/late.py b/turftopic/late.py index f162860d..50ccb230 100644 --- a/turftopic/late.py +++ b/turftopic/late.py @@ -39,6 +39,17 @@ def sliding_window( return window_embeddings, window_offsets +def to_numpy(arr: torch.Tensor | np.ndarray) -> np.ndarray: + if isinstance(arr, np.ndarray): + return arr + if torch.is_tensor(arr): + return arr.float().numpy(force=True) + warnings.warn( + f"Array is neither torch or numpy but {type(arr)}. Returning without conversion. This might cause issues." + ) + return arr + + class LateSentenceTransformer(SentenceTransformer): """SentenceTransformer model that can produce token and window-level embeddings. Its output can be used by topic models that can use multi-vector document representations. @@ -65,8 +76,7 @@ def encode( def _encode_tokens( self, texts, - batch_size=32, - show_progress_bar=True, + **kwargs, ) -> tuple[list[np.ndarray], list[Offsets]]: """ Returns @@ -80,8 +90,7 @@ def _encode_tokens( token_embeddings = self.encode( texts, output_value="token_embeddings", - batch_size=batch_size, - show_progress_bar=show_progress_bar, + **kwargs, ) offsets = self.tokenizer( texts, return_offsets_mapping=True, verbose=False @@ -90,7 +99,7 @@ def _encode_tokens( offs[: len(embs)] for offs, embs in zip(offsets, token_embeddings) ] token_embeddings = [ - embs.float().numpy(force=True) + to_numpy(embs) for embs in token_embeddings if torch.is_tensor(embs) ] @@ -99,8 +108,7 @@ def _encode_tokens( def encode_tokens( self, sentences: list[str], - batch_size: int = 32, - show_progress_bar: bool = True, + **kwargs, ): """Produces contextual token embeddings over all documents. @@ -124,8 +132,7 @@ def encode_tokens( # which normally happens to document embeddings. token_embeddings, offsets = self._encode_tokens( sentences, - batch_size=batch_size, - show_progress_bar=show_progress_bar, + **kwargs, ) token_embeddings = [normalize(emb) for emb in token_embeddings] return token_embeddings, offsets @@ -133,10 +140,9 @@ def encode_tokens( def encode_windows( self, sentences: list[str], - batch_size: int = 32, window_size: int = 50, step_size: int = 40, - show_progress_bar: bool = True, + **kwargs, ): """Produces contextual embeddings for a sliding window of tokens similar to C-Top2Vec. @@ -166,8 +172,7 @@ def encode_windows( """ token_embeddings, token_offsets = self._encode_tokens( sentences, - batch_size=batch_size, - show_progress_bar=show_progress_bar, + **kwargs, ) return sliding_window( token_embeddings, @@ -514,7 +519,7 @@ def encode_late( ) -> tuple[np.ndarray, list[Offsets]]: if self.window_size is None: embeddings, offsets = self.model.encoder.encode_tokens( - raw_documents, batch_size=self.batch_size + raw_documents, batch_size=self.batch_size, **self.encode_kwargs ) return embeddings, offsets # If the window_size is specified, but not step_size, we set the step size to the window size @@ -527,6 +532,7 @@ def encode_late( batch_size=self.batch_size, window_size=self.window_size, step_size=step_size, + **self.encode_kwargs, ) return embeddings, offsets From c7bb3651cccab46be2d5e9a497b4418b43ff7eaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 28 Oct 2026 14:07:24 +0100 Subject: [PATCH 23/29] Fixed encoding when encode_kwargs is None --- turftopic/late.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/turftopic/late.py b/turftopic/late.py index 50ccb230..f43b789b 100644 --- a/turftopic/late.py +++ b/turftopic/late.py @@ -481,8 +481,6 @@ class LateWrapper(LateModel): ---------- model Turftopic model to turn into late-interaction model. - batch_size: int, default 32 - Batch size of the transformer. window_size: int, default None Size of the sliding window to average tokens over. If None, documents will be represented at a token level. @@ -502,13 +500,11 @@ class LateWrapper(LateModel): def __init__( self, model: TransformerMixin, - batch_size: Optional[int] = 32, window_size: Optional[int] = None, step_size: Optional[int] = None, pooling: Optional[Callable] = None, ): self.model = model - self.batch_size = batch_size self.pooling = pooling self.window_size = window_size self.step_size = step_size @@ -517,9 +513,10 @@ def __init__( def encode_late( self, raw_documents: list[str] ) -> tuple[np.ndarray, list[Offsets]]: + encode_kwargs = dict() if encode_kwargs is None else encode_kwargs if self.window_size is None: embeddings, offsets = self.model.encoder.encode_tokens( - raw_documents, batch_size=self.batch_size, **self.encode_kwargs + raw_documents, **encode_kwargs ) return embeddings, offsets # If the window_size is specified, but not step_size, we set the step size to the window size From d484cf4eff4f888549e89e9ea1eb20558590d680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 28 Oct 2026 14:16:14 +0100 Subject: [PATCH 24/29] Bugfix in vSLDA --- turftopic/models/vslda/vslda.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/turftopic/models/vslda/vslda.py b/turftopic/models/vslda/vslda.py index 5aac509e..64d1e31c 100644 --- a/turftopic/models/vslda/vslda.py +++ b/turftopic/models/vslda/vslda.py @@ -183,6 +183,9 @@ def fit_transform( console.log("Model fitted.") status.update("Estimating term importances.") flat_dtm, _ = self.flatten_repr(ragged_dtm) + self.doc_topic_matrix = self.trainer_.infer_topic_proportions( + embeddings + ) flat_topics, _ = self.flatten_repr(self.doc_topic_matrix) self.estimate_components( doc_topic_matrix=flat_topics, doc_term_matrix=flat_dtm From 2d57bc642126c090dbcee3792e8f5abddfc7501e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 28 Oct 2026 14:31:53 +0100 Subject: [PATCH 25/29] Fixed encode_kwargs not referring to anything --- turftopic/late.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/turftopic/late.py b/turftopic/late.py index f43b789b..7ef7ed4f 100644 --- a/turftopic/late.py +++ b/turftopic/late.py @@ -513,7 +513,9 @@ def __init__( def encode_late( self, raw_documents: list[str] ) -> tuple[np.ndarray, list[Offsets]]: - encode_kwargs = dict() if encode_kwargs is None else encode_kwargs + encode_kwargs = ( + dict() if self.encode_kwargs is None else self.encode_kwargs + ) if self.window_size is None: embeddings, offsets = self.model.encoder.encode_tokens( raw_documents, **encode_kwargs From 51dbb7c94fb79446c63b781bfe6a2b9826acc741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 28 Oct 2026 14:47:22 +0100 Subject: [PATCH 26/29] Fixed even more errors with late API --- pyproject.toml | 2 ++ turftopic/late.py | 6 +++--- turftopic/vectorizers/gliformer.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d916b944..dea3c123 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ snowball = ["snowballstemmer>=2.0.0,<3.0.0"] topic-wizard = ["topic-wizard>1.0.0,<2.0.0"] umap-learn = ["umap-learn>=0.5.5,<1.0.0"] conjugate = ["conjugate-models>=0.14.0"] +gliformer = ["gliformer>=0.1.0"] docs = [ "griffe==0.40.0", "mkdocs==1.6.1", @@ -65,6 +66,7 @@ dev = [ "mkdocstrings-python==1.8.0", "griffe==0.40.0", "datasets>=4.3.0" + "gliformer>=0.1.0", ] [build-system] diff --git a/turftopic/late.py b/turftopic/late.py index 7ef7ed4f..1c01ebcf 100644 --- a/turftopic/late.py +++ b/turftopic/late.py @@ -343,10 +343,11 @@ def unflatten_repr(flat_repr, lengths): def pool_flat(flat_repr, lengths, agg=np.nanmean): return pool_flat(flat_repr, lengths, agg) - def pool(self, doc_topic_matrix: list[np.ndarray], lengths): + def pool(self, doc_topic_matrix: list[np.ndarray], offsets): if self.pooling is None: - return doc_topic_matrix, lengths + return doc_topic_matrix, offsets else: + _m, lengths = self.flatten_repr(doc_topic_matrix) return pool_flat(doc_topic_matrix, lengths, self.pooling) def ragged_vectorize( @@ -528,7 +529,6 @@ def encode_late( ) embeddings, offsets = self.model.encoder.encode_windows( raw_documents, - batch_size=self.batch_size, window_size=self.window_size, step_size=step_size, **self.encode_kwargs, diff --git a/turftopic/vectorizers/gliformer.py b/turftopic/vectorizers/gliformer.py index e110dfdb..4f318c0c 100644 --- a/turftopic/vectorizers/gliformer.py +++ b/turftopic/vectorizers/gliformer.py @@ -5,7 +5,7 @@ from gliformer import GLiFormer from pydantic import BaseModel except ModuleNotFoundError as e: - raise ModuleNotFoundError("Install GliFormer.") from e + raise ModuleNotFoundError("GliFormer not installed. Install it by running pip install turftopic[gliformer].") from e class Topic(BaseModel): From 7754ef5e2aa8c6f9b1897edd8bbfe58691fe3c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 28 Oct 2026 14:50:49 +0100 Subject: [PATCH 27/29] Added missing comma --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dea3c123..a6c32fbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ dev = [ "mkdocstrings==0.22.0", "mkdocstrings-python==1.8.0", "griffe==0.40.0", - "datasets>=4.3.0" + "datasets>=4.3.0", "gliformer>=0.1.0", ] From 1c757493c02d310bd51f01c5290d18f01df85e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Wed, 28 Oct 2026 15:11:01 +0100 Subject: [PATCH 28/29] Attempted fixing late wrapper --- turftopic/late.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/turftopic/late.py b/turftopic/late.py index 1c01ebcf..94879139 100644 --- a/turftopic/late.py +++ b/turftopic/late.py @@ -348,7 +348,7 @@ def pool(self, doc_topic_matrix: list[np.ndarray], offsets): return doc_topic_matrix, offsets else: _m, lengths = self.flatten_repr(doc_topic_matrix) - return pool_flat(doc_topic_matrix, lengths, self.pooling) + return pool_flat(_m, lengths, self.pooling) def ragged_vectorize( self, @@ -531,7 +531,7 @@ def encode_late( raw_documents, window_size=self.window_size, step_size=step_size, - **self.encode_kwargs, + **encode_kwargs, ) return embeddings, offsets From 2d2787583cca9e8cee70ef3562c6199ea69e86e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Kardos?= Date: Thu, 29 Oct 2026 09:05:43 +0100 Subject: [PATCH 29/29] Version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a6c32fbd..83556a36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ profile = "black" [project] name = "turftopic" -version = "0.28.0" +version = "0.29.0" description = "Topic modeling with contextual representations from sentence transformers." authors = [ { name = "Márton Kardos ", email = "martonkardos@cas.au.dk" }