# Working with the Bag-of-Words representation¶

The bow module in tmtoolkit contains several functions for working with Bag-of-Words (BoW) representations of documents. It’s divided into two sub-modules: bow.bow_stats and bow.dtm. The former implements several statistics and transformations for BoW representations, the latter contains functions to create and convert sparse or dense document-term matrices (DTMs).

Most of the functions in both sub-modules accept and/or return sparse DTMs. The previous chapter contained a section about what sparse DTMs are and how they can be generated with tmtoolkit.

## An example document-term matrix¶

Before we start with the bow.dtm module, we will generate a sparse DTM from a small example corpus.

[1]:

import random
random.seed(20191113)   # to make the sampling reproducible

import numpy as np
np.set_printoptions(precision=5)

from tmtoolkit.corpus import Corpus

corpus = Corpus.from_builtin_corpus('english-NewsArticles').sample(5)


Let’s have a look at a sample document:

[2]:

print(corpus['NewsArticles-2058'][:227])

Merkel: 'Only if Europe is doing well, will Germany be doing well'

Ahead of meeting her fellow European leaders at a summit in Brussels, German Chancellor Angela Merkel has reiterated her government's call for unity in the EU.


We employ a preprocessing pipeline that removes a lot of information from our original data in order to obtain a very condensed DTM.

[3]:

from tmtoolkit.preprocess import TMPreproc

preproc = TMPreproc(corpus)
preproc.pos_tag() \
.lemmatize() \
.filter_for_pos('N') \
.tokens_to_lowercase() \
.remove_special_chars_in_tokens() \
.clean_tokens(remove_shorter_than=2) \
.remove_common_tokens(5, absolute=True) # remove tokens that occur in all documents
preproc.tokens_datatable

[3]:

 doc position token meta_pos ▪▪▪▪ ▪▪▪▪▪▪▪▪ ▪▪▪▪ ▪▪▪▪ 0 NewsArticles-119 0 nhs NNP 1 NewsArticles-119 1 nhs NNP 2 NewsArticles-119 2 pledge NN 3 NewsArticles-119 3 prime NNP 4 NewsArticles-119 4 minister NNP 5 NewsArticles-119 5 david NNP 6 NewsArticles-119 6 cameron NNP 7 NewsArticles-119 7 theresa NNP 8 NewsArticles-119 8 may NNP 9 NewsArticles-119 9 government NN 10 NewsArticles-119 10 people NNS 11 NewsArticles-119 11 access NN 12 NewsArticles-119 12 gps NNP 13 NewsArticles-119 13 day NNS 14 NewsArticles-119 14 week NN ⋮ ⋮ ⋮ ⋮ ⋮ 919 NewsArticles-3665 344 author NN 920 NewsArticles-3665 345 al NNP 921 NewsArticles-3665 346 jazeera NNP 922 NewsArticles-3665 347 editorial NN 923 NewsArticles-3665 348 policy NN
[4]:

preproc.n_docs, preproc.vocabulary_size

[4]:

(5, 530)


We fetch the document labels and vocabulary and convert them to NumPy arrays, because such arrays allow advanced indexing methods such as boolean indexing.

[5]:

doc_labels = np.array(preproc.doc_labels)
doc_labels

[5]:

array(['NewsArticles-119', 'NewsArticles-1206', 'NewsArticles-2058',
'NewsArticles-3016', 'NewsArticles-3665'], dtype='<U17')

[6]:

vocab = np.array(preproc.vocabulary)
vocab[:10]  # only showing the first 10 tokens here

[6]:

array(['abuse', 'access', 'accession', 'accusation', 'act', 'addition',
dtype='<U20')


Finally, we fetch the sparse DTM:

[7]:

dtm = preproc.dtm
dtm

[7]:

<5x530 sparse matrix of type '<class 'numpy.int32'>'
with 596 stored elements in Compressed Sparse Row format>


We now have a sparse DTM dtm, an array of document labels doc_labels that represent the rows of the DTM and an array of vocabulary tokens vocab that represent the columns of the DTM. We will use this data for the remainder of the chapter.

## The bow.dtm module¶

This module is quite small. There are two functions to convert a DTM to a datatable or DataFrame: dtm_to_datatable() and dtm_to_dataframe(). Note that the generated datatable or DataFrame is dense, i.e. it uses up (much) more memory than the input DTM.

Let’s generate a datatable via dtm_to_datatable() from our DTM, the document labels and the vocabulary:

[8]:

from tmtoolkit.bow.dtm import dtm_to_datatable

dtm_to_datatable(dtm, doc_labels, vocab)

[8]:

 _doc abuse access accession accusation act addition address addressing administration … year yes york yucel yucellast ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ 0 NewsArticles-119 0 1 0 0 0 0 0 0 0 … 0 0 0 0 0 1 NewsArticles-1206 0 0 0 0 0 0 0 0 0 … 0 0 0 0 0 2 NewsArticles-2058 0 0 1 1 0 0 0 1 0 … 2 1 0 1 1 3 NewsArticles-3016 1 0 0 0 0 0 0 0 0 … 0 0 1 0 0 4 NewsArticles-3665 0 1 0 0 1 1 1 0 1 … 1 0 0 0 0

We can see that a row _doc with the document labels was created and that the vocabulary tokens become the column names. dtm_to_dataframe() works the same way.

You can combine tmtoolkit with Gensim. The bow.dtm module provides several functions to convert data between both packages:

## The bow.bow_stats module¶

This module provides several statistics and transformations for sparse or dense DTMs.

### Document lengths, document and term frequencies, token co-occurrences¶

Let’s start with the doc_lengths() function, which simply gives the number of tokens per document (i.e. the row-wise sum of the DTM):

[9]:

from tmtoolkit.bow.bow_stats import doc_lengths

doc_lengths(dtm)

[9]:

array([ 36,  37, 338, 164, 349])


The returned array is aligned to the document labels doc_labels so we can see that the last document, “NewsArticles-3665”, is the one with the most tokens. Or to do it computationally:

[10]:

doc_labels[doc_lengths(dtm).argmax()]

[10]:

'NewsArticles-3665'


While doc_lengths() gives the row-wise sum across the DTM, term_frequencies() gives the column-wise sum. This means it returns an array of the length of the vocabulary’s size where each entry in that array reflects the number of occurrences of the respective vocabulary token (aka term).

Let’s calculate that measure, get its maximum and the vocabulary token(s) for that maximum value:

[11]:

from tmtoolkit.bow.bow_stats import term_frequencies

term_freq = term_frequencies(dtm)
(term_freq.max(), vocab[term_freq == term_freq.max()])

[11]:

(24, array(['medium'], dtype='<U20'))


It’s also possible to calculate the proportional frequency, i.e. normalize the counts by the overall number of tokens via proportions=True:

[12]:

term_prop = term_frequencies(dtm, proportions=True)
vocab[term_prop >= 0.01]

[12]:

array(['candidate', 'eu', 'macron', 'medium', 'merkel', 'refugee'],
dtype='<U20')


The function doc_frequencies() returns how often each token in the vocabulary occurs at least n times per document. You can control n per parameter min_val which is set to 1 by default. The returned array is aligned with the vocabulary. Here, we calculate the document frequency with the default value min_val=1, extract the maximum document frequency and see which of the tokens in the vocab array reach the maximum document frequency:

[13]:

from tmtoolkit.bow.bow_stats import doc_frequencies

df = doc_frequencies(dtm)
max_df = df.max()
max_df, vocab[df == max_df]

[13]:

(4, array(['minister'], dtype='<U20'))


It turns out that the maximum document frequency is 4 and only the token “minister” reaches that document frequency. This means only “minister” is mentioned across 4 documents at least once (because min_val is 1). Remember that during preprocessing, we removed all tokens that occur across all five documents, hence there can’t be a vocabulary token with a document frequency of 5.

Let’s see which vocabulary tokens occur within a single document at least 10 times:

[14]:

df = doc_frequencies(dtm, min_val=10)
vocab[df > 0]

[14]:

array(['candidate', 'eu', 'macron', 'medium', 'merkel', 'refugee'],
dtype='<U20')


We can also calculate the co-document frequency or token co-occurrence matrix via codoc_frequencies(). This measures how often each pair of vocabulary tokens occurs at least n times together in the same document. Again, you can control n per parameter min_val which is set to 1 by default. The result is a sparse matrix of shape vocabulary size by vocabulary size. The columns and rows give the pairs of tokens from the vocabulary.

Let’s generate a co-document frequency matrix and convert it to a dense representation, because our further operations don’t support sparse matrices.

A co-document frequency matrix is symmetric along the diagonal, because co-occurrence between a pair (token1, token2) is always the same as between (token2, token1). We want to filter out the duplicate pairs and for that use np.triu() to take only the upper triangle of the matrix, i.e. set all values in the lower triangle including the matrix diagonal to zero (k=1 does this):

[15]:

from tmtoolkit.bow.bow_stats import codoc_frequencies

codoc_mat = codoc_frequencies(dtm).todense()
codoc_upper = np.triu(codoc_mat, k=1)
codoc_upper

[15]:

array([[0, 0, 0, ..., 1, 0, 0],
[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 1, 1],
...,
[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 0, 1],
[0, 0, 0, ..., 0, 0, 0]], dtype=int64)


Now we create a list that contains the pairs of tokens that occur together in at least two documents (codoc_upper > 1) together with their co-document frequency:

[16]:

interesting_pairs = [(vocab[t1], vocab[t2], codoc_upper[t1, t2])
for t1, t2 in zip(*np.where(codoc_upper > 1))]
interesting_pairs[:10]  # showing only the first ten pairs

[16]:

[('access', 'channel', 2),
('access', 'day', 2),
('access', 'minister', 2),
('access', 'news', 2),
('anyone', 'attack', 2),
('anyone', 'brexit', 2),
('anyone', 'comment', 2),
('anyone', 'consensus', 2),
('anyone', 'country', 2),
('anyone', 'deal', 2)]


### Generate sorted lists and datatables according to term frequency¶

When working with DTMs, it’s often helpful to rank terms per document according to their frequency. This is what sorted_terms() does for you. It further allows to specify the sorting order (the default is descending order via ascending=False) and several limits:

• lo_thresh for the minimum term frequency

• hi_thresh for the maximum term frequency

• top_n for the maximum number of terms per document

Let’s display the top three tokens per document by frequency:

[17]:

from tmtoolkit.bow.bow_stats import sorted_terms

sorted_terms(dtm, vocab, top_n=3)

[17]:

[[('derbyshire', 2), ('bbc', 2), ('victoria', 2)],
[('car', 3), ('collision', 3), ('road', 3)],
[('merkel', 14), ('eu', 12), ('refugee', 10)],
[('politics', 7), ('party', 6), ('farron', 5)],
[('medium', 24), ('candidate', 18), ('macron', 15)]]


The output is a list for each document (this means the output is aligned with the document labels doc_labels), with three pairs of (token, frequency) each. It’s also possible to get this data as datatable via sorted_terms_datatable(), which gives a better overview and also includes labels for the documents. It accepts the same parameters for sorting and limitting the results:

[18]:

from tmtoolkit.bow.bow_stats import sorted_terms_datatable

sorted_terms_datatable(dtm, vocab, doc_labels, top_n=3)

[18]:

 doc token value ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ 0 NewsArticles-119 derbyshire 2 1 NewsArticles-119 bbc 2 2 NewsArticles-119 victoria 2 3 NewsArticles-1206 car 3 4 NewsArticles-1206 collision 3 5 NewsArticles-1206 road 3 6 NewsArticles-2058 merkel 14 7 NewsArticles-2058 eu 12 8 NewsArticles-2058 refugee 10 9 NewsArticles-3016 politics 7 10 NewsArticles-3016 party 6 11 NewsArticles-3016 farron 5 12 NewsArticles-3665 medium 24 13 NewsArticles-3665 candidate 18 14 NewsArticles-3665 macron 15
[19]:

sorted_terms_datatable(dtm, vocab, doc_labels, lo_thresh=5)

[19]:

 doc token value ▪▪▪▪ ▪▪▪▪ ▪▪▪▪ 0 NewsArticles-2058 merkel 14 1 NewsArticles-2058 eu 12 2 NewsArticles-2058 refugee 10 3 NewsArticles-2058 germany 8 4 NewsArticles-2058 turkey 7 5 NewsArticles-2058 country 7 6 NewsArticles-2058 europe 6 7 NewsArticles-3016 politics 7 8 NewsArticles-3016 party 6 9 NewsArticles-3665 medium 24 10 NewsArticles-3665 candidate 18 11 NewsArticles-3665 macron 15 12 NewsArticles-3665 france 9 13 NewsArticles-3665 election 8 14 NewsArticles-3665 le 7 15 NewsArticles-3665 coverage 6

### Term frequency–inverse document frequency transformation (tf-idf)¶

Term frequency–inverse document frequency transformation (tf-idf) is a matrix transformation that is often applied to DTMs in order to reflect the importance of a token to a document. The bow_stats module provides the function tfidf() for this. When the input is a sparse matrix, and the calculation supports operating on sparce matrices, the output will also be a sparse matrix, which means that the tf-idf transformation is implemented in a very memory-efficient way.

Let’s apply tf-idf to our DTM using the default way:

[20]:

from tmtoolkit.bow.bow_stats import tfidf

tfidf_mat = tfidf(dtm)
tfidf_mat

[20]:

<5x530 sparse matrix of type '<class 'numpy.float64'>'
with 596 stored elements in COOrdinate format>


We can see that the output is a sparse matrix. Let’s have a look at its values:

[21]:

tfidf_mat.todense()

[21]:

matrix([[0.     , 0.02725, 0.     , ..., 0.     , 0.     , 0.     ],
[0.     , 0.     , 0.     , ..., 0.     , 0.     , 0.     ],
[0.     , 0.     , 0.00371, ..., 0.     , 0.00371, 0.00371],
[0.00764, 0.     , 0.     , ..., 0.00764, 0.     , 0.     ],
[0.     , 0.00281, 0.     , ..., 0.     , 0.     , 0.     ]])


Of course we can also pass this matrix to sorted_terms_datatable() and observe that some rankings have changed in comparison to the untransformed DTM:

[22]:

sorted_terms_datatable(tfidf_mat, vocab, doc_labels, top_n=3)

[22]:

 doc token value ▪▪▪▪ ▪▪▪▪ ▪▪▪▪▪▪▪▪ 0 NewsArticles-119 nhs 0.0695979 1 NewsArticles-119 victoria 0.0695979 2 NewsArticles-119 bbc 0.0695979 3 NewsArticles-1206 road 0.101575 4 NewsArticles-1206 collision 0.101575 5 NewsArticles-1206 car 0.101575 6 NewsArticles-2058 merkel 0.0518896 7 NewsArticles-2058 refugee 0.037064 8 NewsArticles-2058 eu 0.0348223 9 NewsArticles-3016 politics 0.0534716 10 NewsArticles-3016 farron 0.038194 11 NewsArticles-3016 party 0.035884 12 NewsArticles-3665 medium 0.0861499 13 NewsArticles-3665 candidate 0.0646124 14 NewsArticles-3665 macron 0.0538437

The tf-idf matrix is calculated from a DTM $$D$$ as $$\text{tf}(D) \text{idf}(D)$$.

There are different variants for how to calculate the term frequency $$\text{tf}(D)$$ and the inverse document frequency $$\textit{idf(D)}$$. tmtoolkit contains several functions that implement some of these variants. For $$\text{tf()}$$ these are:

• tf_binary(): binary term frequency matrix (matrix contains 1 whenever a term occurred in a document, else 0)

• tf_proportions(): proportional term frequency matrix (term counts are normalized by document length)

• tf_log(): log-normalized term frequency matrix (by default $$\log(1 + D)$$)

• tf_double_norm(): double-normalized term frequency matrix $$K + (1-K) \cdot \frac{D}{\text{rowmax}(D)}$$, where $$\text{rowmax}(D)$$ is a vector containing the maximum term count per document

As you can see, all the term frequency functions are prefixed with a tf_. There are also two variants for $$\text{idf()}$$:

• idf(): calculates $$\log(\frac{a + N}{b + \text{df}(D)})$$ where $$a$$ and $$b$$ are smoothing constants, $$N$$ is the number of documents and $$\text{df}(D)$$ calculates the document frequency

• idf_probabilistic(): calculates $$\log(a + \frac{N - \text{df}(D)}{\text{df}(D)})$$

The term frequency functions always return a sparse matrix if possible and if the input is sparse. Let’s try out two term frequency functions:

[23]:

from tmtoolkit.bow.bow_stats import tf_binary, tf_proportions

tf_binary(dtm).todense()

[23]:

matrix([[0, 1, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 0, 0, 0],
[0, 0, 1, ..., 0, 1, 1],
[1, 0, 0, ..., 1, 0, 0],
[0, 1, 0, ..., 0, 0, 0]])

[24]:

tf_proportions(dtm).todense()

[24]:

matrix([[0.     , 0.02778, 0.     , ..., 0.     , 0.     , 0.     ],
[0.     , 0.     , 0.     , ..., 0.     , 0.     , 0.     ],
[0.     , 0.     , 0.00296, ..., 0.     , 0.00296, 0.00296],
[0.0061 , 0.     , 0.     , ..., 0.0061 , 0.     , 0.     ],
[0.     , 0.00287, 0.     , ..., 0.     , 0.     , 0.     ]])


Just like the document frequency function doc_frequencies(), the inverse document frequency functions also return a vector with the same length as the vocabulary. Let’s use these functions and have a look at the inverse document frequency of certain tokens:

[25]:

from tmtoolkit.bow.bow_stats import idf, idf_probabilistic

idf_vec = idf(dtm)
list(zip(vocab, idf_vec))[:10]

[25]:

[('abuse', 1.252762968495368),
('access', 0.9808292530117262),
('accession', 1.252762968495368),
('accusation', 1.252762968495368),
('act', 1.252762968495368),
('affiliation', 1.252762968495368)]

[26]:

probidf_vec = idf_probabilistic(dtm)

list(zip(vocab, probidf_vec))[:10]

[26]:

[('abuse', 1.6094379124341003),
('access', 0.9162907318741551),
('accession', 1.6094379124341003),
('accusation', 1.6094379124341003),
('act', 1.6094379124341003),
('affiliation', 1.6094379124341003)]


Note that due to our very small sample, there’s not much variation in the inverse document frequency values.

By default, tfidf() uses tf_proportions() and idf() to calculate the tf-idf matrix. You can plug in other functions to get other variants of tf-idf:

[27]:

from tmtoolkit.bow.bow_stats import tf_double_norm

# we also set a "K" parameter for "tf_double_norm"
tfidf_mat2 = tfidf(dtm, tf_func=tf_double_norm,
idf_func=idf_probabilistic, K=0.25)
tfidf_mat2

[27]:

array([[0.40236, 0.57268, 0.40236, ..., 0.40236, 0.40236, 0.40236],
[0.40236, 0.22907, 0.40236, ..., 0.40236, 0.40236, 0.40236],
[0.40236, 0.22907, 0.48858, ..., 0.40236, 0.48858, 0.48858],
[0.5748 , 0.22907, 0.40236, ..., 0.5748 , 0.40236, 0.40236],
[0.40236, 0.25771, 0.40236, ..., 0.40236, 0.40236, 0.40236]])

[28]:

sorted_terms_datatable(tfidf_mat2, vocab, doc_labels, top_n=3)

[28]:

 doc token value ▪▪▪▪ ▪▪▪▪ ▪▪▪▪▪▪▪▪ 0 NewsArticles-119 derbyshire 1.60944 1 NewsArticles-119 programme 1.60944 2 NewsArticles-119 nhs 1.60944 3 NewsArticles-1206 car 1.60944 4 NewsArticles-1206 collision 1.60944 5 NewsArticles-1206 road 1.60944 6 NewsArticles-2058 merkel 1.60944 7 NewsArticles-2058 refugee 1.26456 8 NewsArticles-2058 germany 1.09212 9 NewsArticles-3016 politics 1.60944 10 NewsArticles-3016 farron 1.26456 11 NewsArticles-3016 world 0.919679 12 NewsArticles-3665 medium 1.60944 13 NewsArticles-3665 candidate 1.30767 14 NewsArticles-3665 macron 1.15678

Once we have generated a DTM, we can use it for topic modeling. The next chapter will show how tmtoolkit can be used to evaluate the quality of your model, export essential information from it and visualize the results.