from IPython.display import Image
%matplotlib inlineCheck package version (optional)¶
Add folder to path in order to load from the check_packages.py script.
# (Removed local-repo sys.path hack; not needed on Colab.)
Check recommended package versions.
# Ensure optional packages used later in this notebook are installed,
# and report installed versions (no external helper script needed on Colab).
import importlib.metadata as importlib_metadata
import subprocess, sys
def _pip_install(pkg):
r = subprocess.run([sys.executable, '-m', 'pip', 'install', '-q', pkg])
if r.returncode != 0:
# Some environments (e.g. Debian-managed Python) require this flag.
subprocess.run([sys.executable, '-m', 'pip', 'install', '-q',
'--break-system-packages', pkg], check=True)
for pkg in ['pyprind', 'nltk']:
try:
importlib_metadata.version(pkg)
except importlib_metadata.PackageNotFoundError:
_pip_install(pkg)
for pkg in ['numpy', 'pandas', 'scikit-learn', 'pyprind', 'nltk']:
try:
print(f'{pkg}: {importlib_metadata.version(pkg)}')
except importlib_metadata.PackageNotFoundError:
print(f'{pkg}: NOT INSTALLED')
numpy: 2.0.2
pandas: 2.2.2
scikit-learn: 1.6.1
pyprind: 2.11.3
nltk: 3.9.1
Watermark (optional)¶
Optional watermark extension is a small IPython notebook plugin that I developed to make the code reproducible.
You can install watermark Jupyter extension via
conda install watermark -c conda-forge or
pip install watermark For more information, please see: https://
import subprocess, sys
try:
import watermark # noqa: F401
except ImportError:
r = subprocess.run([sys.executable, '-m', 'pip', 'install', '-q', 'watermark'])
if r.returncode != 0:
# Some environments (e.g. Debian-managed Python) require this flag.
subprocess.run([sys.executable, '-m', 'pip', 'install', '-q',
'--break-system-packages', 'watermark'], check=True)
%load_ext watermark
%watermark -a "Sebastian Raschka, Johnny Yu" -u -d -v -p numpy,pandas,matplotlib,scipy,sklearn
Author: Sebastian Raschka, Johnny Yu
Last updated: 2026-08-13
Python implementation: CPython
Python version : 3.12.13
IPython version : 7.34.0
numpy : 2.0.2
pandas : 2.2.2
matplotlib: 3.10.0
scipy : 1.16.3
sklearn : 1.6.1
Table of Contents¶
8.1.Preparing the IMDb movie review data for text processing
8.3.Training a logistic regression model for document classification
8.4.Working with bigger data – online algorithms and out-of-core learning
8.1.Preparing the IMDb movie review data for text processing¶
We are going to work with a dataset of 50,000 movie reviews from the Internet Movie Database (IMDb) and build a predictor that can distinguish between positive and negative reviews.
8.1.1.Obtaining the IMDb movie review dataset¶
The IMDB movie review set can be downloaded from http://
If you are working with Linux or MacOS X, open a new terminal window,
cdinto the download directory and execute:tar -zxf aclImdb_v1.tar.gzIf you are working with Windows, download an archiver such as 7Zip to extract the files from the download archive.
# Added for Colab: automatically download and extract the IMDb dataset
# instead of requiring a manual download, since a fresh Colab runtime
# won't have the 'aclImdb' folder from a prior manual download.
import os
import tarfile
import urllib.request
if not os.path.isdir('aclImdb'):
archive_path = 'aclImdb_v1.tar.gz'
# Try the original Stanford host first, then a known mirror if that fails
# (the Stanford server can be slow/unreliable under load).
candidate_urls = [
'http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz',
'http://d2l-data.s3-accelerate.amazonaws.com/aclImdb_v1.tar.gz',
]
if not os.path.isfile(archive_path):
last_err = None
for url in candidate_urls:
try:
print(f'Downloading {url} ...')
urllib.request.urlretrieve(url, archive_path)
last_err = None
break
except Exception as e:
print(f' failed: {e}')
last_err = e
if last_err is not None:
raise RuntimeError(
'Could not download the IMDb dataset from any known source.'
) from last_err
print('Extracting aclImdb_v1.tar.gz (this can take a few minutes)...')
with tarfile.open(archive_path) as tar:
tar.extractall()
print('Done.')
else:
print("'aclImdb/' already present, skipping download.")
Downloading http://ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz ...
Extracting aclImdb_v1.tar.gz (this can take a few minutes)...
/tmp/ipykernel_2158/2049473475.py:35: DeprecationWarning: Python 3.14 will, by default, filter extracted tar archives and reject files or modify their metadata. Use the filter argument to control this behavior.
tar.extractall()
Done.
8.1.2.Preprocessing the movie dataset into more convenient format¶
In the following section, we will be reading the movie reviews into a pandas DataFrame object, which can take up to 10 minutes on a standard desktop computer.
To visualize the progress and estimated time until completion, we will use the Python Progress Indicator (PyPrind, https://
We first initialized a new progress bar object, pbar, with 50,000 iterations, which was the number of documents we were going to read in. Using the nested for loops, we iterated over the train and test subdirectories in the main aclImdb directory and read the individual text files from the pos and neg subdirectories that we eventually appended to the df pandas DataFrame, together with an integer class label (1 = positive and 0 = negative).
import pyprind
import pandas as pd
import os
import sys
from packaging import version
# Change the `basepath` to the directory of the unzipped movie dataset
basepath = 'aclImdb'
labels = {'pos': 1, 'neg': 0}
# If the progress bar does not show, change stream=sys.stdout to stream=2
# pbar = pyprind.ProgBar(50000, stream=sys.stdout)
pbar = pyprind.ProgBar(50000, stream=2)
df = pd.DataFrame()
for s in ('test', 'train'):
for l in ('pos', 'neg'):
path = os.path.join(basepath, s, l)
for file in sorted(os.listdir(path)):
with open(os.path.join(path, file),
'r', encoding='utf-8') as infile:
txt = infile.read()
if version.parse(pd.__version__) >= version.parse("1.3.2"):
x = pd.DataFrame([[txt, labels[l]]], columns=['review', 'sentiment'])
df = pd.concat([df, x], ignore_index=False)
else:
df = df.append([[txt, labels[l]]],
ignore_index=True)
pbar.update()
df.columns = ['review', 'sentiment']0% [##############################] 100% | ETA: 00:00:00
Total time elapsed: 00:01:27
Since the class labels in the assembled dataset are sorted, we will now shuffle the DataFrame using the permutation function from the np.random submodule—this will be useful for splitting the dataset into training and test datasets in later sections, when we will stream the data from our local drive directly.
import numpy as np
if version.parse(pd.__version__) >= version.parse("1.3.2"):
df = df.sample(frac=1, random_state=0).reset_index(drop=True)
else:
np.random.seed(0)
df = df.reindex(np.random.permutation(df.index))Optional: Saving the assembled data as CSV file for convenience:
df.to_csv('movie_data.csv', index=False, encoding='utf-8')Quickly confirm that we have successfully saved the data in the right format by reading in the CSV and printing an excerpt of the first three examples:
import pandas as pd
df = pd.read_csv('movie_data.csv', encoding='utf-8')
# The following is necessary on some computers
df = df.rename(columns={"0": "review", "1": "sentiment"})
df.head(3)df.shape(50000, 2)Note¶
If you have problems with creating the movie_data.csv, you can find a download a zip archive at
https://
8.2.Introducing the bag-of-words model¶
Bag-of-words model: allows us to represent text as numerical feature vectors.
The idea behind bag-of-words:
We create a vocabulary of unique tokens
Ex: words, from the entire set of documents, as tokens
We construct a feature vector from each document that contains the counts of how often each word occurs in the particular document.
Since the unique words in each document represent only a small subset of all the words in the bag-of-words vocabulary, the feature vectors will mostly consist of zeros, which is why we call them sparse.
8.2.1.Transforming documents into feature vectors¶
To construct a bag-of-words model based on the word counts in the respective documents, we can use the CountVectorizer class implemented in scikit-learn.
CountVectorizer: takes an array of text data and constructs the bag-of-words model for us.text data can be documents or sentences
By calling the fit_transform method on CountVectorizer, we constructed the vocabulary of the bag-of-words model and transformed the following three sentences into sparse feature vectors:
'The sun is shining''The weather is sweet''The sun is shining, the weather is sweet, and one and one is two'
Result explanation
As you can see from executing the preceding command, the vocabulary is stored in a Python dictionary that maps the unique words to integer indices.
For example,
'the'is at the index position6;'sun'is at the index position4.
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
count = CountVectorizer()
docs = np.array([
'The sun is shining',
'The weather is sweet',
'The sun is shining, the weather is sweet, and one and one is two'])
bag = count.fit_transform(docs)
print(count.vocabulary_){'the': 6, 'sun': 4, 'is': 1, 'shining': 3, 'weather': 8, 'sweet': 5, 'and': 0, 'one': 2, 'two': 7}
Next, let’s print the feature vectors that we just created.
Result explanation
Each index position in the feature vectors shown here corresponds to the integer values that are stored as dictionary items in the CountVectorizer vocabulary.
For example,
'and'is at the index position0, which means the first column of the array represents the count of'and'. And the first column[0 0 2]means'and'occurs0times in the first document; occurs0times in the second document; and occurs2times in the third document.values in the feature vector = raw term frequencies = : the number of times a term, , occurs in a document, .
Note that, in the bag-of-words model, the word or term order in a sentence or document does not matter. The order in which the term frequencies appear in the feature vector is derived from the vocabulary indices, which are usually assigned alphabetically.
print(bag.toarray())[[0 1 0 1 1 0 1 0 0]
[0 1 0 0 0 1 1 0 1]
[2 3 2 1 1 1 2 1 1]]
N-gram models
The choice of the number, n, in the n-gram model depends on the particular application.
For example, n-grams of size 3 and 4 yield good performances in the anti-spam filtering of email messages.
Concept of n-gram representation,:
1-gram: “the”, “sun”, “is”, “shining”
2-gram: “the sun”, “sun is”, “is shining”
CountVectorizerclass in scikit-learn uses 1-gram by default.
8.2.2.Assessing word relevancy via term frequency-inverse document frequency¶
np.set_printoptions(precision=2)When we are analyzing text data, we often encounter words that occur across multiple documents from both classes. These frequently occurring words typically don’t contain useful or discriminatory information.
term frequency-inverse document frequency (tf-idf)
Downweigh these frequently occurring words in the feature vectors.
Defined as the product of the term frequency and the inverse document frequency:
= term frequency
= inverse document frequency:
: total number of documents
: number of documents, , that contain the term
Adding the constant 1 to the denominator (optional): serves the purpose of assigning a non-zero value to terms that occur in none of the training examples.
The : to ensure that low document frequencies are not given too much weight.
Code implementation of tf-idf: TfidfTransformer¶
The scikit-learn library implements yet another transformer, the TfidfTransformer class, which takes the raw term frequencies from the CountVectorizer class as input and transforms them into tf-idfs.
Result explanation
As you saw in the previous subsection, the word 'is' (the second column of the array) had the largest term frequency in the third document, being the most frequently occurring word. However, after transforming the same feature vector into tf-idfs, the word 'is' is now associated with a relatively small tf-idf (0.45) in the third document, since it is also present in the first and second document and thus is unlikely to contain any useful discriminatory information.
from sklearn.feature_extraction.text import TfidfTransformer
tfidf = TfidfTransformer(use_idf=True,
norm='l2',
smooth_idf=True)
print(tfidf.fit_transform(count.fit_transform(docs))
.toarray())[[0. 0.43 0. 0.56 0.56 0. 0.43 0. 0. ]
[0. 0.43 0. 0. 0. 0.56 0.43 0. 0.56]
[0.5 0.45 0.5 0.19 0.19 0.19 0.3 0.25 0.19]]
Math of TfidfTransformer¶
However, if we’d manually calculated the tf-idfs of the individual terms in our feature vectors, we would have noticed that TfidfTransformer calculates the tf-idfs slightly differently compared to the standard textbook equations that we defined previously.
The equation for the inverse document frequency implemented in scikit-learn is computed as follows:
Similarly, the tf-idf computed in scikit-learn deviates slightly from the default equation we defined earlier:
Note that the " +1 " in the previous idf equation is due to setting smooth_idf=True in the previous code example, which is helpful for assigning zero weight (that is, ) to terms that occur in all documents.
While it is also more typical to normalize the raw term frequencies before calculating the tf-idfs, the TfidfTransformer class normalizes the tf-idfs directly. By default (norm= '12'), scikit-learn’s TfidfTransformer applies the L2-normalization, which returns a vector of length 1 by dividing an unnormalized feature vector, , by its L2-norm:
Math example of TfidfTransformer¶
To make sure that we understand how TfidfTransformer works, let’s walk through an example and calculate the tf-idf of the word 'is' in the third document. The word 'is' has a term frequency of 3 in the third document, and the document frequency of this term is 3 since the term 'is' occurs in all three documents . Thus, we can calculate the inverse document frequency as follows:
Now, in order to calculate the tf-idf, we simply need to add 1 to the inverse document frequency and multiply it by the term frequency:
tf_is = 3
n_docs = 3
idf_is = np.log((n_docs+1) / (3+1))
tfidf_is = tf_is * (idf_is + 1)
print(f'tf-idf of term "is" = {tfidf_is:.2f}')tf-idf of term "is" = 3.00
If we repeated this calculation for all terms in the third document, we’d obtain the following tf-idf vectors: [3.39,3.0,3.39,1.29,1.29,1.29,2.0,1.69,1.29]. However, notice that the values in this feature vector are different from the values that we obtained from TfidfTransformer that we used previously.
tfidf = TfidfTransformer(use_idf=True, norm=None, smooth_idf=True)
raw_tfidf = tfidf.fit_transform(count.fit_transform(docs)).toarray()[-1]
raw_tfidfarray([3.39, 3. , 3.39, 1.29, 1.29, 1.29, 2. , 1.69, 1.29])The final step that we are missing in this tf-idf calculation is the L2-normalization, which can be applied as follows:
l2_tfidf = raw_tfidf / np.sqrt(np.sum(raw_tfidf**2))
l2_tfidfarray([0.5 , 0.45, 0.5 , 0.19, 0.19, 0.19, 0.3 , 0.25, 0.19])8.2.3.Cleaning text data¶
Before we build our bag-of-words model, it is to clean the text data by stripping it of all unwanted characters. To illustrate why this is important, let’s display the last 50 characters from the first document in the reshuffled movie review dataset.
Result explanation
As you can see here, the text contains HTML markup as well as punctuation and other non-letter characters. While HTML markup does not contain many useful semantics, punctuation marks can represent useful, additional information in certain NLP contexts. However, for simplicity, we will now remove all punctuation marks except for emoticon characters, such as :), since those are certainly useful for sentiment analysis.
df.loc[0, 'review'][-50:]To accomplish this task, we will use Python’s regular expression (regex) library, re.
Via the first regex, <[^>]*>, in the preceding code section, we tried to remove all of the HTML markup from the movie reviews. Although many programmers generally advise against the use of regex to parse HTML, this regex should be sufficient to clean this particular dataset. Since we are only interested in removing HTML markup and do not plan to use the HTML markup further, using regex to do the job should be acceptable. However, if you prefer to use sophisticated tools for removing HTML markup from text, you can take a look at Python’s HTML parser module, which is described at https://
After we removed the HTML markup, we used a slightly more complex regex to find emoticons, which we temporarily stored as emoticons. Next, we removed all non-word characters from the text via the regex [\W]+ and converted the text into lowercase characters. Eventually, we added the temporarily stored emoticons to the end of the processed document string. Additionally, we removed the nose character (- in :-)) from the emoticons for consistency.
import re
def preprocessor(text):
text = re.sub(r'<[^>]*>', '', text)
emoticons = re.findall(r'(?::|;|=)(?:-)?(?:\)|\(|D|P)',
text)
text = (re.sub(r'[\W]+', ' ', text.lower()) +
' '.join(emoticons).replace('-', ''))
return text
Although the addition of the emoticon characters to the end of the cleaned document strings may not look like the most elegant approach, we must note that the order of the words doesn’t matter in our bag-of-words model if our vocabulary consists of only one-word tokens. But before we talk more about the splitting of documents into individual terms, words, or tokens, let’s confirm that our preprocessor function works correctly.
preprocessor(df.loc[0, 'review'][-50:])preprocessor("</a>This :) is :( a test :-)!")Lastly, since we will make use of the cleaned text data over and over again during the next sections, let’s now apply our preprocessor function to all the movie reviews in our DataFrame.
df['review'] = df['review'].apply(preprocessor)8.2.4.Processing documents into tokens¶
After successfully preparing the movie review dataset, we now need to think about how to split the text corpora into individual elements. tokenizing documents
Two ways to do the tokenization
One way to tokenize documents is to split them into individual words by splitting the cleaned documents at their whitespace characters.
In the context of tokenization, another useful technique is word stemming, which is the process of transforming a word into its root form. It allows us to map related words to the same stem. The Natural Language Toolkit (NLTK, http://www.nltk.org) for Python implements the Porter stemming algorithm, which we will use in the following code section. To install the NLTK, you can simply execute conda install nltk or pip install nltk.
from nltk.stem.porter import PorterStemmer
porter = PorterStemmer()
# tokenization by splitting
def tokenizer(text):
return text.split()
# tokenization by porter stemming algorithm
def tokenizer_porter(text):
return [porter.stem(word) for word in text.split()]Split the documents into individual words by splitting the cleaned documents at their whitespace characters.
tokenizer('runners like running and thus they run')['runners', 'like', 'running', 'and', 'thus', 'they', 'run']Using the PorterStemmer from the nltk package, we modified our tokenizer function to reduce words to their root form, which was illustrated by the simple preceding example where the word 'running' was stemmed to its root form 'run'.
Result explanation
While stemming can create non-real words, such as 'thu' (from 'thus'), as shown in the previous example, a technique called lemmatization aims to obtain the canonical (grammatically correct) forms of individual words—the so-called lemmas. However, lemmatization is computationally more difficult and expensive compared to stemming and, in practice, it has been observed that stemming and lemmatization have little impact on the performance of text classification.
tokenizer_porter('runners like running and thus they run')['runner', 'like', 'run', 'and', 'thu', 'they', 'run']Before we jump into training a machine learning model using the bag-of-words model, let’s briefly talk about another useful topic called stop word removal. Stop words are simply those words that are extremely common in all sorts of texts and probably bear no (or only a little) useful information that can be used to distinguish between different classes of documents.
Examples of stop words are is, and, has, and like.
Removing stop words can be useful if we are working with raw or normalized term frequencies rather than tf-idfs, which already downweight the frequently occurring words.
To remove stop words from the movie reviews, we will use the set of 127 English stop words that is available from the NLTK library, which can be obtained by calling the nltk.download function.
import nltk
nltk.download('stopwords')[nltk_data] Downloading package stopwords to /root/nltk_data...
[nltk_data] Unzipping corpora/stopwords.zip.
TrueAfter we download the stop words set, we can load and apply the English stop word set.
from nltk.corpus import stopwords
stop = stopwords.words('english')
[w for w in tokenizer_porter('a runner likes running and runs a lot')
if w not in stop]['runner', 'like', 'run', 'run', 'lot']8.3.Training a logistic regression model for document classification¶
In this section, we will train a logistic regression model to classify the movie reviews into positive and negative reviews based on the bag-of-words model. First, we will divide the DataFrame of cleaned text documents into 25,000 documents for training and 25,000 documents for testing.
X_train = df.loc[:25000, 'review'].values
y_train = df.loc[:25000, 'sentiment'].values
X_test = df.loc[25000:, 'review'].values
y_test = df.loc[25000:, 'sentiment'].valuesNext, we will use a GridSearchCV object to find the optimal set of parameters for our logistic regression model using 5-fold stratified cross-validation. The following bullet list is the details of the following code.
TfidfVectorizerCountVectorizer+TfidfTransformer(introduced in previous section)
param_gridconsisted of two parameter dictionaries:1st dictionary:
TfidfVectorizerwith its default settings (use_idf=True,smooth_idf=True, andnorm= '12') to calculate the tf-idfs.2nd dictionary: set those parameters to
use_idf=False,smooth_idf=False, andnorm=Nonein order to train a model based on raw term frequencies.
LogisticRegression(classifier): uses the LIBLINEAR solver as it can perform better than the default choice ('lbfgs') for relatively large datasets.GridSearchCV: we restricted ourselves to a limited number of parameter combinations, since the number of feature vectors, as well as the large vocabulary, can make the grid search computationally quite expensive. (Using a standard desktop computer, our grid search may take 5-10 minutes to complete.)cv=5: 5-fold stratified cross-validationn_jobs=-1: highly recommend setting, to utilize all available cores on your machine and speed up the grid search
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.model_selection import GridSearchCV
tfidf = TfidfVectorizer(strip_accents=None,
lowercase=False,
preprocessor=None)
"""
param_grid = [{'vect__ngram_range': [(1, 1)],
'vect__stop_words': [stop, None],
'vect__tokenizer': [tokenizer, tokenizer_porter],
'clf__penalty': ['l1', 'l2'],
'clf__C': [1.0, 10.0, 100.0]},
{'vect__ngram_range': [(1, 1)],
'vect__stop_words': [stop, None],
'vect__tokenizer': [tokenizer, tokenizer_porter],
'vect__use_idf':[False],
'vect__norm':[None],
'clf__penalty': ['l1', 'l2'],
'clf__C': [1.0, 10.0, 100.0]},
]
"""
small_param_grid = [{'vect__ngram_range': [(1, 1)],
'vect__stop_words': [None],
'vect__tokenizer': [tokenizer, tokenizer_porter],
'clf__penalty': ['l2'],
'clf__C': [1.0, 10.0]},
{'vect__ngram_range': [(1, 1)],
'vect__stop_words': [stop, None],
'vect__tokenizer': [tokenizer],
'vect__use_idf':[False],
'vect__norm':[None],
'clf__penalty': ['l2'],
'clf__C': [1.0, 10.0]},
]
lr_tfidf = Pipeline([('vect', tfidf),
('clf', LogisticRegression(solver='liblinear'))])
gs_lr_tfidf = GridSearchCV(lr_tfidf, small_param_grid,
scoring='accuracy',
cv=5,
verbose=1,
n_jobs=-1)After the grid search has finished, we can print the best parameter set.
Results explanation
As you can see in the preceding output, we obtained the best grid search results using the regular tokenizer without Porter stemming, no stop word library, and tf-idfs in combination with a logistic regression classifier that uses L2-regularization with the regularization strength of 10.0.
gs_lr_tfidf.fit(X_train, y_train)Fitting 5 folds for each of 8 candidates, totalling 40 fits
/usr/local/lib/python3.12/dist-packages/sklearn/feature_extraction/text.py:517: UserWarning: The parameter 'token_pattern' will not be used since 'tokenizer' is not None'
warnings.warn(
Using the best model from this grid search, let’s print the average 5-fold cross-validation accuracy scores on the training dataset and the classification accuracy on the test dataset.
Results explanation
The results reveal that our machine learning model can predict whether a movie review is positive or negative with 90 percent accuracy.
print(f'Best parameter set: {gs_lr_tfidf.best_params_}')
print(f'CV Accuracy: {gs_lr_tfidf.best_score_:.3f}')Best parameter set: {'clf__C': 10.0, 'clf__penalty': 'l2', 'vect__ngram_range': (1, 1), 'vect__stop_words': None, 'vect__tokenizer': <function tokenizer at 0x7a955b0711c0>}
CV Accuracy: 0.897
clf = gs_lr_tfidf.best_estimator_
print(f'Test Accuracy: {clf.score(X_test, y_test):.3f}')Test Accuracy: 0.899
Start comment:¶
Please note that gs_lr_tfidf.best_score_ is the average k-fold cross-validation score. I.e., if we have a GridSearchCV object with 5-fold cross-validation (like the one above), the best_score_ attribute returns the average score over the 5-folds of the best model. To illustrate this with an example:
from sklearn.linear_model import LogisticRegression
import numpy as np
from sklearn.model_selection import StratifiedKFold
from sklearn.model_selection import cross_val_score
np.random.seed(0)
np.set_printoptions(precision=6)
y = [np.random.randint(3) for i in range(25)]
X = (y + np.random.randn(25)).reshape(-1, 1)
cv5_idx = list(StratifiedKFold(n_splits=5, shuffle=False).split(X, y))
lr = LogisticRegression()
cross_val_score(lr, X, y, cv=cv5_idx)array([0.6, 0.4, 0.6, 0.2, 0.6])By executing the code above, we created a simple data set of random integers that shall represent our class labels. Next, we fed the indices of 5 cross-validation folds (cv3_idx) to the cross_val_score scorer, which returned 5 accuracy scores -- these are the 5 accuracy values for the 5 test folds.
Next, let us use the GridSearchCV object and feed it the same 5 cross-validation sets (via the pre-generated cv3_idx indices):
from sklearn.model_selection import GridSearchCV
lr = LogisticRegression()
gs = GridSearchCV(lr, {}, cv=cv5_idx, verbose=3).fit(X, y)Fitting 5 folds for each of 1 candidates, totalling 5 fits
[CV 1/5] END ..................................., score=0.600 total time= 0.0s
[CV 2/5] END ..................................., score=0.400 total time= 0.0s
[CV 3/5] END ..................................., score=0.600 total time= 0.0s
[CV 4/5] END ..................................., score=0.200 total time= 0.0s
[CV 5/5] END ..................................., score=0.600 total time= 0.0s
As we can see, the scores for the 5 folds are exactly the same as the ones from cross_val_score earlier.
Now, the best_score_ attribute of the GridSearchCV object, which becomes available after fitting, returns the average accuracy score of the best model:
gs.best_score_np.float64(0.48)As we can see, the result above is consistent with the average score computed with cross_val_score.
lr = LogisticRegression()
cross_val_score(lr, X, y, cv=cv5_idx).mean()np.float64(0.48)8.4.Working with bigger data - online algorithms and out-of-core learning¶
# This cell is not contained in the book but
# added for convenience so that the notebook
# can be executed starting here, without
# executing prior code in this notebook
import os
import gzip
if not os.path.isfile('movie_data.csv'):
if not os.path.isfile('movie_data.csv.gz'):
print('Please place a copy of the movie_data.csv.gz'
'in this directory. You can obtain it by'
'a) executing the code in the beginning of this'
'notebook or b) by downloading it from GitHub:'
'https://github.com/rasbt/machine-learning-book/'
'blob/main/ch08/movie_data.csv.gz')
else:
with gzip.open('movie_data.csv.gz', 'rb') as in_f, \
open('movie_data.csv', 'wb') as out_f:
out_f.write(in_f.read())If you executed the code examples in the previous section, you may have noticed that it could be computationally quite expensive to construct the feature vectors for the 50,000-movie review dataset during a grid search. In many real-world applications, it is not uncommon to work with even larger datasets that can exceed our computer’s memory. In order to deal with large dataset, we will now apply a technique called out-of-core learning, which allows us to work with such large datasets by fitting the classifier incrementally on smaller batches of a dataset.
Stochastic gradient descent (SGD) is an optimization algorithm that updates the model’s weights using one example at a time. In this section, we will make use of the partial_fit function of SGDClassifier in scikit-learn to stream the documents directly from our local drive and train a logistic regression model using small mini-batches of documents.
First, we will define a tokenizer function that cleans the unprocessed text data from the movie_data.csv file that we constructed at the beginning of this chapter and separates it into word tokens while removing stop words.
Next, we will define a generator function, stream_docs, that reads in and returns one document at a time.
import numpy as np
import re
from nltk.corpus import stopwords
# The `stop` is defined as earlier in this chapter
# Added it here for convenience, so that this section
# can be run as standalone without executing prior code
# in the directory
stop = stopwords.words('english')
def tokenizer(text):
text = re.sub(r'<[^>]*>', '', text)
emoticons = re.findall(r'(?::|;|=)(?:-)?(?:\)|\(|D|P)', text)
text = re.sub(r'[\W]+', ' ', text.lower()) +\
' '.join(emoticons).replace('-', '')
tokenized = [w for w in text.split() if w not in stop]
return tokenized
def stream_docs(path):
with open(path, 'r', encoding='utf-8') as csv:
next(csv) # skip header
for line in csv:
text, label = line[:-3], int(line[-2])
yield text, label
To verify that our stream_docs function works correctly, let’s read in the first document from the movie_data.csv file, which should return:
a tuple consisting of the review text (
"In 1974, the teenager ...")the corresponding class label (
1)
next(stream_docs(path='movie_data.csv'))('"In 1974, the teenager Martha Moxley (Maggie Grace) moves to the high-class area of Belle Haven, Greenwich, Connecticut. On the Mischief Night, eve of Halloween, she was murdered in the backyard of her house and her murder remained unsolved. Twenty-two years later, the writer Mark Fuhrman (Christopher Meloni), who is a former LA detective that has fallen in disgrace for perjury in O.J. Simpson trial and moved to Idaho, decides to investigate the case with his partner Stephen Weeks (Andrew Mitchell) with the purpose of writing a book. The locals squirm and do not welcome them, but with the support of the retired detective Steve Carroll (Robert Forster) that was in charge of the investigation in the 70\'s, they discover the criminal and a net of power and money to cover the murder.<br /><br />""Murder in Greenwich"" is a good TV movie, with the true story of a murder of a fifteen years old girl that was committed by a wealthy teenager whose mother was a Kennedy. The powerful and rich family used their influence to cover the murder for more than twenty years. However, a snoopy detective and convicted perjurer in disgrace was able to disclose how the hideous crime was committed. The screenplay shows the investigation of Mark and the last days of Martha in parallel, but there is a lack of the emotion in the dramatization. My vote is seven.<br /><br />Title (Brazil): Not Available"',
1)We will now define a function, get_minibatch, that will take a document stream from the stream_docs function (introduced in the later code block) and return a particular number of documents specified by the size parameter.
def get_minibatch(doc_stream, size):
docs, y = [], []
try:
for _ in range(size):
text, label = next(doc_stream)
docs.append(text)
y.append(label)
except StopIteration:
return None, None
return docs, yUnfortunately, we can’t use CountVectorizer for out-of-core learning since it requires holding the complete vocabulary in memory. Also, TfidfVectorizer needs to keep all the feature vectors of the training dataset in memory to calculate the inverse document frequencies. However, another useful vectorizer for text processing implemented in scikit-learn is HashingVectorizer. HashingVectorizer is data-independent and makes use of the hashing trick via the 32-bit MurmurHash3 function by Austin Appleby (you can find more information about MurmurHash at MurmurHash).
We initialized HashingVectorizer with our tokenizer function and set the number of features to 2**21. Furthermore, we reinitialized a logistic regression classifier by setting the loss parameter of SGDClassifier to 'log'. Note that by choosing a large number of features in HashingVectorizer, we reduce the chance of causing hash collisions, but we also increase the number of coefficients in our logistic regression model.
from sklearn.feature_extraction.text import HashingVectorizer
from sklearn.linear_model import SGDClassifier
vect = HashingVectorizer(decode_error='ignore',
n_features=2**21,
preprocessor=None,
tokenizer=tokenizer)clf = SGDClassifier(loss='log_loss', random_state=1)
doc_stream = stream_docs(path='movie_data.csv')
Now comes the really interesting part—having set up all the complementary functions, we can start the out-of-core learning using the following code.
Again, we made use of the PyPrind package to estimate the progress of our learning algorithm. We initialized the progress bar object with 45 iterations and, in the following for loop, we iterated over 45 mini-batches of documents where each mini-batch consists of 1,000 documents.
import pyprind
pbar = pyprind.ProgBar(45)
classes = np.array([0, 1])
for _ in range(45):
X_train, y_train = get_minibatch(doc_stream, size=1000)
if not X_train:
break
X_train = vect.transform(X_train)
clf.partial_fit(X_train, y_train, classes=classes)
pbar.update()0% [##############################] 100% | ETA: 00:00:00
Total time elapsed: 00:00:34
Having completed the incremental learning process, we will use the last 5,000 documents to evaluate the performance of our model.
Results explanation
As you can see, the accuracy of the model is approximately 87 percent, slightly below the accuracy that we achieved in the previous section using the grid search for hyperparameter tuning. However, out-of-core learning is very memory efficient, and it took less than a minute to complete.
X_test, y_test = get_minibatch(doc_stream, size=5000)
X_test = vect.transform(X_test)
print(f'Accuracy: {clf.score(X_test, y_test):.3f}')Accuracy: 0.868
Finally, we can use the last 5,000 documents to update our model.
clf = clf.partial_fit(X_test, y_test)8.5.Topic modeling¶
Topic modeling describes the broad task of assigning topics to unlabeled text documents.
For example, a typical application is the categorization of documents in a large text corpus of newspaper articles. In applications of topic modeling, we then aim to assign category labels to those articles, for example, sports, finance, world news, politics, and local news.
Thus, in the context of the broad categories of machine learning, we can consider topic modeling as a clustering task, a subcategory of unsupervised learning.
In this section, we will discuss a popular technique for topic modeling called latent Dirichlet allocation (LDA). However, note that while latent Dirichlet allocation is often abbreviated as LDA, it is not to be confused with linear discriminant analysis, a supervised dimensionality reduction technique.
8.5.1.Decomposing text documents with Latent Dirichlet Allocation¶
Since the mathematics behind LDA is quite involved and requires knowledge of Bayesian inference, we will approach this topic from a practitioner’s perspective and interpret LDA using layman’s terms.
LDA is a generative probabilistic model that tries to find groups of words that appear frequently together across different documents. These frequently appearing words represent our topics, assuming that each document is a mixture of different words. The input to an LDA is the bag-of-words model that we discussed earlier in this chapter.
Given a bag-of-words matrix as input, LDA decomposes it into two new matrices:
A document-to-topic matrix
A word-to-topic matrix
optimal bag-of-words matrix (with lowest possible error) = document-to-topic matrix word-to-topic matrix
In practice, we are interested in those topics that LDA found in the bag-of-words matrix. The only downside may be that we must define the number of topics beforehand—the number of topics is a hyperparameter of LDA that has to be specified manually.
8.5.2.Latent Dirichlet Allocation with scikit-learn¶
In this subsection, we will use the LatentDirichletAllocation class implemented in scikit-learn to decompose the movie review dataset and categorize it into different topics. In the following example, we will restrict the analysis to 10 different topics, but readers are encouraged to experiment with the hyperparameters of the algorithm to further explore the topics that can be found in this dataset.
First, we are going to load the dataset into a pandas DataFrame using the local movie_data.csv file of the movie reviews that we created at the beginning of this chapter.
import pandas as pd
df = pd.read_csv('movie_data.csv', encoding='utf-8')
# the following is necessary on some computers:
df = df.rename(columns={"0": "review", "1": "sentiment"})
df.head(3)Next, we are going to use the already familiar CountVectorizer to create the bag-of-words matrix as input to the LDA.
top_words='english'Use scikit-learn’s built-in English stop word library for convenience.
max_df=.1Set the maximum document frequency of words to be considered to 10% to exclude words that occur too frequently across documents.
This makes it less likely to be associated with a specific topic category of a given document.
max_features=5000Limit the number of words to be considered to the most frequently occurring 5,000 words, which can limit the dimensionality of this dataset to improve the inference performed by LDA.
However, both max_d f=.1 and max_features=5000 are hyperparameter values chosen arbitrarily, and readers are encouraged to tune them while comparing the results.
from sklearn.feature_extraction.text import CountVectorizer
count = CountVectorizer(stop_words='english',
max_df=.1,
max_features=5000)
X = count.fit_transform(df['review'].values)The following code example demonstrates how to fit a LatentDirichletAllocation estimator to the bag-of-words matrix and infer the 10 different topics from the documents (n_components=10).
By setting learning_method='batch', we let the lda estimator do its estimation based on all available training data (the bag-of-words matrix) in one iteration, which is slower than the alternative 'online' learning method, but can lead to more accurate results (setting learning_method='online' is analogous to online or mini-batch learning).
from sklearn.decomposition import LatentDirichletAllocation
lda = LatentDirichletAllocation(n_components=10,
random_state=123,
learning_method='batch')
X_topics = lda.fit_transform(X)After fitting the LDA, we now have access to the components_ attribute of the lda instance, which stores a matrix containing the word importance (10, 5000) for each of the 10 topics in increasing order.
lda.components_.shape(10, 5000)To analyze the results, let’s print the five most important words for each of the 10 topics. Note that the word importance values are ranked in increasing order. Thus, to print the top five words, we need to sort the topic array in reverse order.
n_top_words = 5
feature_names = count.get_feature_names_out()
for topic_idx, topic in enumerate(lda.components_):
print(f'Topic {(topic_idx + 1)}:')
print(' '.join([feature_names[i]
for i in topic.argsort()\
[:-n_top_words - 1:-1]]))Topic 1:
horror worst script effects budget
Topic 2:
dvd watched video music guy
Topic 3:
war american series history documentary
Topic 4:
game killer murder thriller crime
Topic 5:
kids comedy episode series school
Topic 6:
family woman mother beautiful feel
Topic 7:
role performance comedy john plays
Topic 8:
action horror john effects dr
Topic 9:
book version original read music
Topic 10:
action wife father police james
Based on reading the five most important words for each topic, you may guess that the LDA identified the following topics:
Generally bad movies (not really a topic category)
Movies about families
War movies
Art movies
Crime movies
Horror movies
Comedy movie reviews
Movies somehow related to TV shows
Movies based on books
Action movies
To confirm that the categories make sense based on the reviews, let’s plot three movies from the horror movie category (horror movies belong to category 6 at index position 5).
Results explanation
We printed the first 300 characters from the top three horror movies. The reviews—even though we don’t know which exact movie they belong to—sound like reviews of horror movies (however, one might argue that Horror movie #2 could also be a good fit for topic category 1: Generally bad movies).
horror = X_topics[:, 5].argsort()[::-1]
for iter_idx, movie_idx in enumerate(horror[:3]):
print(f'\nHorror movie #{(iter_idx + 1)}:')
print(df['review'][movie_idx][:300], '...')
Horror movie #1:
"Come Undone" appears to elicit a lot of opinions among the contributors to this forum. Granted, it's a film that promises a take on gay life, as most viewers expect and somehow, it gets away from that promise into an introspective view at a young man's soul. The film has a way of staying with us ev ...
Horror movie #2:
The subject of children being terminally ill is difficult and saddening but 'The Cure' successfully portrays the idea that it doesn't have to be all doom and gloom and, if anything, children need to have hope and delight in their lives if they are to find peace before the end. It is also a film of r ...
Horror movie #3:
I knew absolutely nothing about Chocolat before my viewing of it. I didn't know anything about the story, the cast, the director, or anything about the film's history. All I knew was it was a highly-acclaimed French film. Had I known more, I probably wouldn't have viewed the picture with an open min ...
Readers may ignore the next cell.
# This cell converts the notebook to a plain .py script using a helper script
# from the book's companion repo. It isn't needed to run the notebook itself,
# and the helper script isn't present on Colab, so it's skipped here.
# ! python ../.convert_notebook_to_script.py --input ch08.ipynb --output ch08.py