Skip to content

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 or SensTopic.

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

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

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:

@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

Bases: 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

from turftopic import LDA
corpus: list[str] = ["some text", "more text", ...]

model = LDA(10).fit(corpus)
model.print_topics()

Parameters:

Name Type Description Default
n_components int

Number of topics.

required
encoder

Ignored.

None
vectorizer Optional[CountVectorizer]

Vectorizer used for term extraction. Can be used to prune or filter the vocabulary.

None
random_state Optional[int]

Random state to use so that results are exactly reproducible.

None
trf_kwargs

Ignored.

None
encode_kwargs

Ignored.

None
Source code in turftopic/models/bow/lda.py
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
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)

transform(raw_documents, embeddings=None)

Infers topic importances for new documents based on a fitted model.

Parameters:

Name Type Description Default
raw_documents

Documents to fit the model on.

required
embeddings Optional[ndarray]

Ignored.

None

Returns:

Type Description
ndarray of shape (n_dimensions, n_topics)

Document-topic matrix.

Source code in turftopic/models/bow/lda.py
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
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)