DIRTopic (ditm) Topic Model
I undertook this project during my internship in 2026 at the very company that I had left before beginning the HLT program, at which time the company had no inkling of undertaking any projects relating to NLP or language modeling. I had maintained good relations with the company, who was eager to bring me in to serve as the primary NLP engineer for the project.
Goals
The goal of the project was to identify the salient topics among a timestamped corpus and to identify changes in the topic prominence over time. The output is presented to a portfolio manager to assess any rises or falls of such topic prominence such that they might inform approaches to investment decisions.
Thus the base requirements of the project were:
- Identify topics among the corpus
- Represent changes in topic prominence over time
Additionally, the project needed to be:
- Scalable to new corpora that may be differently formatted
- Maintainable and extensible
- Accessible by the lay end-user audience
Approach
At the time that I joined the project, the state-of-the-art BERTopic (Grootendorst, 2022) had already been selected as the basis of the model. While BERTopic itself had been unfamiliar to me at the time that I began, my prior experience with vector clustering using DBSCAN—in a project that grew as part of LING 582 (Advanced Statistical NLP)—lended itself directly to the clustering component of BERTopic.
I inherited a basic outline of such a model, although I still needed to:
- Design the project to ensure extensibility and maintainability
- Assess the suitability of BERTopic components
- Calibrate the model
- Create a prominence-over-time visualization
While I was the expert in the room—so to speak—on language modeling, I met regularly with team members and stakeholders (daily and weekly, respectively) to consult on the progress of the project.
Corpus
At the time that I began, it was known that the production model would be fed from a database holding the corpus. It was also expected that the corpus would be expanded to different sources; the original corpus source considered was composed of company-internal emails, but there were discussions of also incorporating meeting transcripts. At the onset, however, the data available was a limited sample of emails in CSV format.
The course LING 508 (Computational Techniques for Linguists) in particular was instrumental, as it equipped me to understand how to handle the needed separation of concerns in the design.
I chose a composite-leaf design pattern for the corpus in order to allow for drop-in replacements of corpus sources as well as allowing for additional corpora to be included in the composite without affecting the API treatment of the corpus as a whole.
Each Corpus, then, has components that can be similarly-readily changed:
Loader: Abstract Class. Specifies the data source and retrieves therefromCsvLoader: Reads from a given path and normalizes columns. Email-specific, used in early testing, adaptable if needed.DbLoader: Abstract Class. Contains the SQLAlchemy connection and query, with subclasses to define relevant tables and data-source-specific normalization.EmailDbLoaderTranscriptDbLoader
Preprocessor: Abstract Class. Performs corpus-type-specific cleaning. Performs tokenization/lemmatization.EmailPreprocessorTranscriptPreprocessor
The Corpus contains a check to ensure the Loader and Preprocessor
match for content type.
classDiagram
direction LR
namespace Loaders {
class Loader {
<<abstract class>>
load()
}
class CSVLoader {
path :: str
}
class DbLoader {
db_url
engine
table
tablecols
}
class EmailDbLoader {
}
class TranscriptDbLoader {
}
}
namespace Corpora {
class CorpusComponent {
<<abstract class>>
documents
documents_untokenized
timestamps
load()
build_stopwords()
preprocess_docs()
frame()
}
class Corpus {
loader :: Loader
preprocessor :: Preprocessor
min_tokens :: int
}
class CorpusComposite {
corpora :: list[CorpusComponent]
}
}
namespace Preprocessing {
class Preprocessor {
<<abstract class>>
clean_text()
lemmatize()
}
class EmailPreprocessor {
}
class TranscriptPreprocessor {
}
}
CorpusComponent <|-- Corpus
CorpusComponent <|-- CorpusComposite
CorpusComposite o-- "1..*" CorpusComponent
Corpus *-- Loader
Corpus *-- Preprocessor
Loader <|-- CSVLoader
Loader <|-- DbLoader
DbLoader <|-- EmailDbLoader
DbLoader <|-- TranscriptDbLoader
Preprocessor <|-- EmailPreprocessor
Preprocessor <|-- TranscriptPreprocessor
A more exhaustive UML diagram is provided at the bottom of this page.
Preprocessing
One function of Preprocessor is to clean corpus documents of various
formatting and uninformative text. This is achieved primarily through
regex, and I inherited much of the email-cleaning regex at the time that I
began, though I later amended it. Once transcripts were introduced, we were
fortunate in that they are consistently formatted, which made it
straightforward to strip it of text signalling interlocutors and of
the moderator’s sections, which are uninformative.
The Preprocessor also holds a SpaCy pipeline—used for tokenization
and lemmatization—as a class attribute of the parent class (thus only
one SpaCy model is loaded).
Preprocessor.lemmatize() is defined in the parent class
to ensure that the same process is applied to all corpora.
In addition to splitting documents on sentences, tokenizing, lemmatizing,
and excluding stop words and select NER tags,
a filter of custom stop words is applied, which is defined in .nlp_filters
of the module.
There were attempts, prior to my start, to build stop words from email
senders and recipients, although this added a lot of complexity and was
likely redundant with excluding PERSON NER tags; thus it was abandoned.
The custom stop word list remained to mitigate topics forming of common
and uninformative words.
At my start it had also been decided, somewhat arbitrarily,
to exclude ORG and GPE NER tags.
While my intuition was that these would be informative for the model’s purpose,
the filter remained in place until late in development. After
consulting with the stakeholder and testing the output, I removed this
exclusion.
Given that the use of SpaCy’s model was not dependent on ites pretrained
vectors, en_core_web_sm is used. Motivated by computational performance,
parser is excluded and replaced with sentencizer:
NLP = spacy.load(config.SPACY_MODEL, exclude = [ "parser" ])
NLP.add_pipe("sentencizer")
After lemmatization, the Corpus also has a min_tokens parameter, which
filters documents below a certain threshold,
thereby excluding very short documents whose vectors may be saturated by
a single token (when set to 2). Under consideration that emails tend to
be comprised of short messages, this was kept low (2).
The transcripts’ min_tokens was set a bit higher,
both due to consideration that spoken sentences in a formal setting would
be less constrained and due to the transcripts corpus being far larger
than anticipated, which presented memory contraints (setting this
from 2 to 4 removed about 13% of documents without inviting concern
for a loss in meaningful documents).
BERTopic
My very first task—even before officially starting the internship role—was to become familiar with BERTopic. In was in this capacity that I was uniquely qualified to manage this project; that is, through the course of the HLT program, I had become sufficiently familar with underlying concepts related to those used in BERTopic (DBSCAN, vector embeddings, transformer embeddings, machine learning, Tf-Idf) to handle the model in an informed manner.
The bertopic Python package is built with a number of defaults that can be
used “out of the box,” with the caveat that even some defaults
(namely UMAP dimensionality-reduction) are prepared in advance and fed to the
model; this separation was held, whereas the modular components used were
mostly the BERTopic defaults, while the specific hyperparameters were
calibrated through testing and assessing the results via mlflow.
Below, I discuss the more notable components and the considerations made while calibrating them.
Sentence embeddings
The BERTopic default for generating sentence embeddings
is SentenceTransformers with the model all-MiniLM-L6-v2.
While I saw no impetus to deviate from this, it was a deliberate decision
to feed the embedding model the lemmatized form of the documents
rather than the unaltered text.
The reason for this was empirical. While I had seen it suggested that
a transformer embedding model, being trained on natural language,
would more accurately represent the meaning of the original documents,
testing revealed that the output of the BERTopic model showed clusters
that were more focused on sentiment than content. By contrast, using
lemmatized documents resulted in clear content labels. Thus, we used the
latter because it was more suitable to our needs.
SentenceTransformers also served to help motivate the decision to split
the corpus sources into sentence-level documents before embedding
and clustering. This was an early consideration that I had, based on
concerns about inconsistent corpus-document size and about
the semantics of long documents being semantically diluted.
The model all-MiniLM-L6-v2 has a maximum input token length of 256 tokens,
where any tokens beyond that would have been silently discarded and lost
from the model. This would have been especially problematic in the Transcripts
corpus, where file sizes vastly exceed this. Thus, all documents in the
model were split on sentences. A natural consequence of this is a much
greater number of document vectors.
UMAP
UMAP (McInnes et al., 2020) is the default algorithm of
dimensionality-reduction in BERTopic, which is necessary because HDBSCAN
is adversely affected by the curse of dimensionality, familiar to me from
my exposure thereto in INFO 521 (Machine Learning), and all-MiniLM-L6-v2
outputs vectors of dimensionality 384.
We settled on UMAP’s hyperparameters via experimentation and subjective
evaluation with mlflfow.
One noteworthy change was to set UMAP’s default spectral initialization to PCA. This was motivated by memory limitations after introducing the sizable transcripts corpus, leading to UMAP’s spectral eigen-decomposition met memory limits. Using PCA initialization avoided this without presenting concerns about model performance.
HDBSCAN
The clustering algorithm is the means by which documents are grouped into semantically-similar clusters, and is thus how topic groups are identified. BERTopic uses HDBSCAN (Malzer and Baum, 2020) by default, due to its ability to handle clusters of varying densities.
At the onset, clustering presented a major computational bottleneck;
a single run—on the original small corpus sample without splitting
documents on sentences— took well over 24 hours on our hardware.
This bottleneck was enirely eliminated by setting HDBSCAN’s prediction_data
and BERTopic’s calculate_probabilities
(which is dependent on the former) to False.
These were originally included as motivated by the stakeholder’s curiosity,
but unneeded for the final model.
The primary parameters for calibrating HDBSCAN were:
-
min_cluster_size: The intuitive parameter, sets the minimum size of a final cluster. A major lever in the resulting clusters, we tested a number of possible values and experimented with setting it as a proportion of corpus size. Generally, largermin_cluster_sizeresulted in more attractive topics, but there was a ceiling that, when exceeded, led to crashes from memory limits. The cause of this was notmin_cluster_sizeitself, but the fact thatmin_samplesdefaults to the value ofmin_cluster_size, leading to an excess of memory usage when building its neighbor array. -
min_samples: The minimum size of a core point. After raisingmin_cluster_sizeand keeping this parameter low, memory concerns were mitigated while producing reasonably topic clusters. -
cluster_selection_epsilon: A setting that allows merging of cluster regions, this served to mitigate overly-granular clusters whenmin_cluster_sizewas relatively small. After switching to a highmin_cluster_sizeand lowmin_samples, values higher than 0.0 resulted in topics that were too general. -
cluster_selection_method: Dictates treatment of HDBSCAN’s hierarchical cluster tree."eom"(Excess of Mass) generally resulted in large, broad topics while"leaf"kept desirable specificity.
Topic Labels
An innovation of BERTopic is c-Tf-Idf, a modification of Tf-Idf that treats clusters (topics) as individual documents, thus identifying the words that most characterize those topics in relatoin to others.
The only modification to defaults here was to set
reduce_frequent_words, which, as the name implies, lessens the prominence
of words that appear commonly throughout the corpus but are not stop words.
This had a favorable affect on the labels generated.
Similarly, many labels ended up with some redundancy.
Adding Maximal Marginal Relevance as a Representation Model helped to
reduce such redundancy, though the effect was not as apparent as
reduce_frequent_words. A low-stakes change, different diversity values
were not deeply explored.
Visualization
Clusters
In early stages of the project, the 2-dimensional plot provided by
BERTopic.visualize_documents() was useful in demonstrating to the
stakeholder the effects of HDBSCAN clustering.
As the project progressed and these effect were already clear,
the computational and time cost of
an additional UMAP reduction to 2 dimensions outweighed any benefit.
This became especially true after the time visualization, discussed below,
was introduced. As such, the 2d plot was removed and is not included here.
A simple bar plot, meanwhile, conveys much of the same information in an arguably more-straightforward representation, with minimal cost.
Below is an example of the topic-size distribution from a late test model. For confidentiality, topic labels have been removed from the tooltip.
Topics over time
Aside from the topic model itself, the project had the goal of presenting how these topics become more or less prominent over time.
While BERTopic includes a topics_over_time visualization,
it is not well-suited to this project.
It primarily concerns how the words used within a given topic may change
over time, which is not a concern here.
Meanwhile, the included visualization plots raw frequency,
which is problematic because large topics dominate.
Much to my begrudging chagrin—but at the encouragement of my employer— the superpowered search-engine known as Claude produced a suggestion for an alternative: to borrow from (Monroe et al., 2008) to use a log-odds ratio with an informative Dirichlet prior. I thus reviewed the paper and the appropriateness of the application of this method to the goals of the project. Claude produced this calculation and the visualization.
While the Fightin’ Words paper concerned identifying words’ prominence between separate corpora, this adaptation of that method compares a topic’s share in a period to its own baseline of k periods. Comparing a topic’s share rather than raw counts helps to mitigate the effect of quiet periods, while the comparison of a topic to itself helps to represent that topic’s own change.
Because the share is relative to the entire corpus in the same period, drops in other topics could result in an apparent increase in a given topic, and vice-versa.
The Monroe et al. calculation produces a z-score for each topic’s share
relative to its baseline. To determine which topics are flagged,
ditm.viz.topic_changes (which stands alone as a product of Claude)
sets a threshold by generating n_perm random shuffles of topic labels and
keeping the highest z per permutation, setting the threshold at the level
quantile of those highest random scores.
Then those flagged topic cells are plotted according to the strength of their log-odds ratio (in either direction; red indicates a rise and blue indicates a fall), resulting in a plot representing topic prominence over time.
Below is a sample timescale visualization. For compactness, only the top 20 topic changes are shown. For confidentiality, topic labels have been removed; however, I am at liberty to highlight a topic whose prominence was expected and served as an indication of the visualization working as expected: The top rows, seeing a sharp increase in prominence in early 2020, represent topics relating to the pandemic, vaccination, and remote work.
Other Challenges
Computational Hurdles
Computational limitations characterized the main difficulties during the course of the project, though this did not prove insurmountable.
Difficulties with memory arose when expanding the corpus with more source documents
The discovery of setting a low min_samples was a happy accident while
trying to force errors to validate error-handling.
Future of the Project
The state of the project at the time of the conclusion of my internship
Streamlit
classDiagram
direction LR
namespace Microsoft {
class MSExchange {
User[*]
}
class User {
Inbox
}
class MSGraph {
export_email()
}
}
namespace Database {
class SQLServerDatabase {
}
class AzureDatabase {
}
}
namespace Loaders {
class Loader {
<<abstract class>>
load(datewindow)
}
class CSVLoader {
path :: str
}
class DbLoader {
db_url
engine
table
tablecols
}
class EmailDbLoader {
}
class TranscriptDbLoader {
}
}
namespace Corpora {
class CorpusComponent {
<<abstract class>>
documents
documents_untokenized
timestamps
load()
build_stopwords()
preprocess_docs()
frame()
}
class Corpus {
loader :: Loader
preprocessor :: Preprocessor
datewindow :: DateWindow
min_tokens :: int
}
class CorpusComposite {
corpora :: list[CorpusComponent]
}
class DateWindow {
<<frozen dataclass>>
start
end
data_start
}
}
namespace Preprocessing {
class Preprocessor {
<<abstract class>>
clean_text()
lemmatize()
}
class EmailPreprocessor {
}
class TranscriptPreprocessor {
}
}
CorpusComponent <|-- Corpus
CorpusComponent <|-- CorpusComposite
CorpusComposite o-- "1..*" CorpusComponent
Corpus *-- Loader
Corpus *-- Preprocessor
Corpus --> DateWindow
Loader ..> DateWindow : load(datewindow)
Loader <|-- CSVLoader
Loader <|-- DbLoader
DbLoader <|-- EmailDbLoader
DbLoader <|-- TranscriptDbLoader
Preprocessor <|-- EmailPreprocessor
Preprocessor <|-- TranscriptPreprocessor
AzureDatabase -- DbLoader
SQLServerDatabase -- DbLoader
MSExchange o-- "*" User
MSGraph -- MSExchange
MSGraph -- AzureDatabase
MSGraph -- SQLServerDatabase
namespace Model {
class BERTopic {
<<bertopic.BERTopic>>
fit_transform(documents, embeddings)
}
class VectorReduction {
<<umap.UMAP>>
fit_transform(embeddings)
}
class NoOpReduction {
<<BaseDimensionalityReduction>>
}
class Clustering {
<<hdbscan.HDBSCAN>>
min_cluster_size
min_samples
cluster_selection_epsilon
cluster_selection_method
}
class EmbeddingModel {
<<sentence_transformers.SentenceTransformer>>
"all-MiniLM-L6-v2"
encode(docs)
}
class Weighting {
<<ClassTfidfTransformer>>
c-TF-IDF
}
class Tokenizer {
<<sklearn.CountVectorizer>>
ngram_range
min_df
max_df
}
class Representation {
<<MaximalMarginalRelevance>>
diversity = 0.2
}
}
BERTopic o-- NoOpReduction
BERTopic o-- EmbeddingModel
BERTopic o-- Clustering
BERTopic o-- Weighting
BERTopic o-- Tokenizer
BERTopic o-- Representation
namespace params {
class CorpusSpec {
<<frozen dataclass>>
years
baseline_quarters
min_tokens_email
min_tokens_transcript
}
class UmapParams {
<<frozen dataclass>>
n_neighbors
n_components
min_dist
metric
random_state
init
verbose
}
class HdbscanParams {
<<frozen dataclass>>
min_cluster_size
min_samples
cluster_selection_epsilon
cluster_selection_method
prediction_data
__post_init__()
}
class VectorizerParams {
<<frozen dataclass>>
ngram_range
min_df
max_df
}
}
CorpusSpec ..> DateWindow : datewindow(display_years)
CorpusSpec ..> Corpus : min_tokens
UmapParams -- VectorReduction
HdbscanParams -- Clustering
VectorizerParams -- Tokenizer
namespace Pipeline {
class pipeline {
<<ditm.pipeline>>
pipeline(corpus_spec, umap_params, hdbscan_params, vectorizer_params, cache_dir)
returns metrics, artifactpaths
}
class Cache {
<<ditm.util>>
get_or_make(path, make_fn, save_fn, load_fn)
make_runhash(parts)
timeit()
}
class NlpFilters {
<<ditm.nlp_filters>>
STOPWORDS
NER
}
class viz {
<<ditm.viz>>
topic_changes()
topic_sizes()
}
class TopicChanges {
z
ratio
threshold
heatmap()
}
class flow {
<<ditm.flow>>
sweeps
one MLflow run per combination
failed runs tagged and skipped
}
class main {
<<ditm.__main__>>
default specs
one MLflow run
failures tagged then re-raised
}
class MLflow {
log_params()
log_metrics()
log_artifact()
}
}
pipeline ..> CorpusSpec
pipeline ..> UmapParams
pipeline ..> HdbscanParams
pipeline ..> VectorizerParams
pipeline ..> CorpusComposite : documents, timestamps
pipeline ..> EmbeddingModel : encode()
pipeline ..> VectorReduction : reduce embeddings
pipeline ..> BERTopic : fit_transform()
pipeline ..> Cache : cached corpus, embeddings, UMAP
pipeline ..> NlpFilters : hashed into cache keys
pipeline ..> viz
viz --> TopicChanges
flow ..> pipeline
flow ..> MLflow
flow ..> Cache : make_runhash
flow ..> NlpFilters : hashed into run name
main ..> pipeline
main ..> MLflow
References
-
2022Maarten Grootendorst. 2022. BERTopic: Neural topic modeling with a class-based TF-IDF procedure.
@misc{grootendorst2022, title = {BERTopic: Neural topic modeling with a class-based TF-IDF procedure}, author = {Grootendorst, Maarten}, year = {2022}, eprint = {2203.05794}, archiveprefix = {arXiv}, primaryclass = {cs.CL}, url = {https://arxiv.org/abs/2203.05794}, arxiv = {arxiv:2203.05794} } -
2020Claudia Malzer and Marcus Baum. 2020. A Hybrid Approach To Hierarchical Density-based Cluster Selection. In 2020 IEEE International Conference on Multisensor Fusion and Integration for Intelligent Systems (MFI), pages 223–228. IEEE.
@inproceedings{malzer2020, title = {A Hybrid Approach To Hierarchical Density-based Cluster Selection}, url = {http://dx.doi.org/10.1109/MFI49285.2020.9235263}, doi = {10.1109/mfi49285.2020.9235263}, booktitle = {2020 IEEE International Conference on Multisensor Fusion and Integration for Intelligent Systems (MFI)}, publisher = {IEEE}, author = {Malzer, Claudia and Baum, Marcus}, year = {2020}, month = sep, pages = {223–228}, arxiv = {arxiv:1911.02282} } -
2020Leland McInnes, John Healy, and James Melville. 2020. UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction.
@misc{mhm2020, title = {UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction}, author = {McInnes, Leland and Healy, John and Melville, James}, year = {2020}, eprint = {1802.03426}, archiveprefix = {arXiv}, primaryclass = {stat.ML}, url = {https://arxiv.org/abs/1802.03426}, arxiv = {arxiv:1802.03426} } -
2008Burt L. Monroe, Michael Colaresi, and Kevin M. Quinn. 2008. Fightin’ Words: Lexical Feature Selection and Evaluation for Identifying the Content of Political Conflict. Political Analysis, 16:372–403.
@article{mcq2008, title = {Fightin' Words: Lexical Feature Selection and Evaluation for Identifying the Content of Political Conflict}, author = {Monroe, Burt L. and Colaresi, Michael and Quinn, Kevin M.}, journal = {Political Analysis}, year = {2008}, volume = {16}, pages = {372 - 403}, url = {https://api.semanticscholar.org/CorpusID:9792267}, doi = {10.1093/pan/mpn018} }