-
Notifications
You must be signed in to change notification settings - Fork 134
/
topic_modeling.Rmd
195 lines (146 loc) · 9.39 KB
/
topic_modeling.Rmd
1
2
3
4
5
6
7
8
9
10
11
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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
---
title: "Topic modeling"
author: "Dmitriy Selivanov"
date: "`r Sys.Date()`"
---
```{r global_options, include=FALSE}
knitr::opts_chunk$set(eval = TRUE, echo=TRUE, warning=FALSE, message=FALSE, cache = TRUE)
set.seed(2016L)
```
Topic modeling is technique to extract abstract topics from a collection of documents. In order to do that input Document-Term matrix usually decomposed into 2 low-rank matrices: document-topic matrix and topic-word matrix.
# Latent Semantic Analysis
Latent Semantic Analysis is the oldest among topic modeling techniques. It decomposes Document-Term matrix into a product of 2 **low rank** matrices $X \approx D \times T$. Goal of LSA is to receive approximation with a [respect to minimize Frobenious norm](https://en.wikipedia.org/wiki/Singular_value_decomposition#Low-rank_matrix_approximation): $error = \left\lVert X - D \times T \right\rVert _F$. Turns out this can be done with **truncated SVD** decomposition.
`text2vec` borrows SVD from [rsparse](https://github.com/dselivanov/rsparse) package and adds convenient interface with an ability to fit model and apply it to new data.
## Example
As usual we will use built-in `text2vec::moview_review` dataset. Let's clean it a bit and create DTM:
```{r}
library(stringr)
library(text2vec)
data("movie_review")
# select 1000 rows for faster running times
movie_review_train = movie_review[1:700, ]
movie_review_test = movie_review[701:1000, ]
prep_fun = function(x) {
# make text lower case
x = str_to_lower(x)
# remove non-alphanumeric symbols
x = str_replace_all(x, "[^[:alpha:]]", " ")
# collapse multiple spaces
x = str_replace_all(x, "\\s+", " ")
}
movie_review_train$review = prep_fun(movie_review_train$review)
it = itoken(movie_review_train$review, progressbar = FALSE)
v = create_vocabulary(it)
v = prune_vocabulary(v, doc_proportion_max = 0.1, term_count_min = 5)
vectorizer = vocab_vectorizer(v)
dtm = create_dtm(it, vectorizer)
```
Now we will perform tf-idf scaling and fit LSA model:
```{r}
tfidf = TfIdf$new()
lsa = LSA$new(n_topics = 10)
# pipe friendly transformation
doc_embeddings = fit_transform(dtm, tfidf)
doc_embeddings = fit_transform(doc_embeddings, lsa)
```
`doc_embeddings` contains matrix with document embeddings (document-topic matrix) and `lsa$components` contains topic-word matrix:
```{r}
dim(doc_embeddings)
dim(lsa$components)
```
Usually we need not only analyze a fixed dataset, but also apply model to new data. For instance we may need to embed unseen documents into the same latent space in order to use their representation in some downstream task (for example classification). `text2vec` keep in mind such task from the very first days of development. We can elegantly **perform exactly the same transformation on the new data** with `transform()` method (and possibly with `%>%` pipe):
```{r}
new_data = movie_review_test
it = itoken(new_data$review, preprocessor = prep_fun, progressbar = FALSE)
new_doc_embeddings = create_dtm(it, vectorizer)
# apply exaxtly same scaling wcich was used in train data
new_doc_embeddings = transform(new_doc_embeddings, tfidf)
# embed into same space as was in train data
new_doc_embeddings = transform(new_doc_embeddings, lsa)
dim(new_doc_embeddings)
```
## Pros and cons
**Pros:**
1. **LSA** is easy to train and tune (no hyperparameters except rank)
1. Embeddings usually work fine in dowstream tasks such as clusterization, classification, regression, similarity-search
**Cons:**
1. Major drawback is that embeddings are **not interpretable** (components might be negative)
1. Could be quite slow to train on **very large** collections of documents
1. The probabilistic model of LSA does not match observed data: LSA assumes that words and documents form a joint Gaussian model (ergodic hypothesis), while a Poisson distribution has been observed
# Latent Dirichlet Allocation
[LDA (Latent Dirichlet Allocation)](https://en.wikipedia.org/wiki/Latent_Dirichlet_allocation) model also decomposes document-term matrix into two low-rank matrices - document-topic distribution and topic-word distribution. Bit it is more complex **non-linear generative model**. We won't go into gory details behind LDA probabilistic model, reader can find a lot of material on the internet. For example [wikipedia article](https://en.wikipedia.org/wiki/Latent_Dirichlet_allocation) is pretty good. We will rather focus on practical details.
There several important hyper-parameters:
1. `n_topics` - Number of latent topics.
1. `doc_topic_prior` - document-topic prior. Normally a number less than 1, e.g. 0.1, to prefer sparse topic distributions, i.e. few topics per document.
1. `topic_word_prior` - topic-word prior. Normally a number much less than 1, e.g. 0.001, to strongly prefer sparse word distributions, i.e. few words per topic.
LDA in `text2vec` is implemented using iterative sampling algorithm - it improves log-likelihood with every pass over the data. So user can set `convergence_tol` parameter for early stopping - algorithm will stop iteration if improvement is not significant. For example setting `lda$fit_transform(x, n_iter = 1000, convergence_tol = 1e-3, n_check_convergence = 10)` will stop earlier if log-likelihood at iteration `n` is within 0.1% of the log-likelihood of iteration `n - 10`.
### Remark on implementation
`text2vec` implementation is based on the state-of-the-art [WarpLDA](https://arxiv.org/abs/1510.08628) sampling algorithm. It has O(1) sampling complexity which means **run-time does not depend on the number of topics**. Current implementation is single-threaded and reasonably fast. However it can be improved in future versions.
### Example
Let us create topic model with `10` topics:
```{r, eval = TRUE}
tokens = tolower(movie_review$review[1:4000])
tokens = word_tokenizer(tokens)
it = itoken(tokens, ids = movie_review$id[1:4000], progressbar = FALSE)
v = create_vocabulary(it)
v = prune_vocabulary(v, term_count_min = 10, doc_proportion_max = 0.2)
vectorizer = vocab_vectorizer(v)
dtm = create_dtm(it, vectorizer, type = "dgTMatrix")
lda_model = LDA$new(n_topics = 10, doc_topic_prior = 0.1, topic_word_prior = 0.01)
doc_topic_distr =
lda_model$fit_transform(x = dtm, n_iter = 1000,
convergence_tol = 0.001, n_check_convergence = 25,
progressbar = FALSE)
```
Now `doc_topic_distr` matrix represents distribution of topics in documents. Each row is document and values are proportions of corresponding topics.
For example topic distribution for first document:
```{r}
barplot(doc_topic_distr[1, ], xlab = "topic",
ylab = "proportion", ylim = c(0, 1),
names.arg = 1:ncol(doc_topic_distr))
```
## Describing topics - top words
Also we can get top words for each topic. They can be sorted by probability of the chance to observe word in a given topic (`lambda = 1`):
```{r}
lda_model$get_top_words(n = 10, topic_number = c(1L, 5L, 10L), lambda = 1)
```
Also top-words could be sorted by "relevance" which also takes into account frequency of word in the corpus (`0 < lambda < 1`). From my experience in most cases setting `0.2 < lambda < 0.4` works best. See [LDAvis: A method for visualizing and interpreting topics](http://nlp.stanford.edu/events/illvi2014/papers/sievert-illvi2014.pdf) paper for details.
```{r}
lda_model$get_top_words(n = 10, topic_number = c(1L, 5L, 10L), lambda = 0.2)
```
## Apply learned model to new data
As with other decompositions we can apply model to new data and obtain document-topic distribution:
```{r}
it = itoken(movie_review$review[4001:5000], tolower, word_tokenizer, ids = movie_review$id[4001:5000])
new_dtm = create_dtm(it, vectorizer, type = "dgTMatrix")
new_doc_topic_distr = lda_model$transform(new_dtm)
```
## Cross-validation and hyper-parameter tuning
One widely used approach for model hyper-parameter tuning is validation of per-word **perplexity** on hold-out set. This is quite easy with `text2vec`.
### Perplexity example
Remember that we've fitted model on first 4000 reviews (learned `topic_word_distribution` which will be fixed during `transform` phase) and predicted last 1000. We can calculate perplexity on these 1000 docs:
```{r}
perplexity(new_dtm, topic_word_distribution = lda_model$topic_word_distribution, doc_topic_distribution = new_doc_topic_distr)
```
The lower perplexity the better. Goal could to find set of hyper-parameters (`n_topics`, `doc_topic_prior`, `topic_word_prior`) which minimize per-word perplexity on hold-out dataset. However it is worth to keep in mind that perplexity is not always correlated with people judgement about topics interpretability and coherence. I personally usually use **LDAvis** for parameter tuning - see next section.
## Visualization
Finally `text2vec` wraps `LDAvis` package in order to provide interactive tool for topic exploration. Usually it worth to play with it in order to find meaningfull model hyperparameters.
```{r, echo=FALSE, warning=FALSE, message=FALSE, eval = TRUE}
lda_model$plot(out.dir = "topic_modeling_files/ldavis", open.browser = FALSE)
```
```{r, eval=FALSE}
lda_model$plot()
```
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>LDAvis</title>
<script src="topic_modeling_files/ldavis/d3.v3.js"></script>
<script src="topic_modeling_files/ldavis/ldavis.js"></script>
<link rel="stylesheet" type="text/css" href="topic_modeling_files/ldavis/lda.css">
</head>
<body>
<div id = "lda"></div>
<script>
var vis = new LDAvis("#lda", "topic_modeling_files/ldavis/lda.json");
</script>
</body>