Natural Language Processing: A Walkthrough with Great Expectations
Concepts, code and practice questions from the improved notebook (NLP_improved.ipynb), applied to Charles Dickens’s novel.
| Area | Improvement |
|---|---|
| Setup | One import cell, all NLTK data downloaded in a loop, configurable file path |
| Cleaning | Dash artifacts like —mrs fixed; ’s and n't fragments filtered by a clear rule |
| Stop words | Stored in a set; no longer shadows the stopwords module |
| Order | POS tagging now precedes lemmatization, since lemmatizers need tags |
| Lemmatization | POS-aware; proper nouns (e.g. Jaggers) are protected |
| N-grams | Counted within sentences, plus PMI collocations |
| Sentiment | Each sentence scored once (not four times); chapter and book-level trends |
| Sentences | Abbreviation splits (Mrs.) repaired |
| Practice | Quick checks, conceptual questions and coding exercises, all with answers |
Natural Language Processing (NLP) teaches computers to read and extract meaning from human language. Text is messy (punctuation, capitalization, ambiguity), so the work follows a pipeline: load → split into units → clean → count, tag or score → visualize. We run it on Great Expectations (1861), a long novel with a rich cast, which lets us check whether the statistics recover its characters and tone.
1. Setup and libraries
import os, re
from collections import Counter
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import nltk
from nltk.corpus import stopwords, wordnet
from nltk.stem import WordNetLemmatizer
from nltk.tokenize import sent_tokenize
from nltk.util import ngrams
from nltk.collocations import BigramCollocationFinder, BigramAssocMeasures
from wordcloud import WordCloud
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
TEXT_FILE = "great_expectations.txt" # change to your copy of the novel
| Library | Purpose |
|---|---|
nltk | Tokenizers, corpus readers, stop words, lemmatizer, POS tagger, collocations |
pandas, numpy | Tables of sentences and scores; numeric helpers |
matplotlib, seaborn | Plotting (seaborn sits on top of matplotlib) |
wordcloud | Word-cloud images, bigger = more frequent |
vaderSentiment | Rule-based sentiment scoring |
seaborn/ssl imports of the original are now used or made an optional, commented workaround for SSL download errors.Downloading NLTK resources
for pkg in ["punkt", "punkt_tab", "stopwords", "wordnet", "omw-1.4",
"averaged_perceptron_tagger", "averaged_perceptron_tagger_eng"]:
nltk.download(pkg, quiet=True)
NLTK ships its code separately from its data. Punkt models find sentence and word boundaries; stopwords are word lists; WordNet is a lexical database for lemmatization; the perceptron tagger assigns parts of speech (newer NLTK versions need the _eng file).
2. Loading the text
with open(TEXT_FILE, "r", encoding="utf-8") as fh:
filedata = fh.read()
print(f"{len(filedata):,} characters")
print(filedata[:500])
The file is read into one Python string. Always give encoding="utf-8": Dickens’s text has curly quotes and em dashes that break on some systems otherwise. Printing a slice is a sanity check.
The text has hard line breaks in mid-sentence. They come from file layout, not grammar, so they must be handled before splitting sentences (section 9).
3. Corpus and its structure
from nltk.corpus.reader.plaintext import PlaintextCorpusReader
folder = os.path.dirname(os.path.abspath(TEXT_FILE))
corpus = PlaintextCorpusReader(folder, os.path.basename(TEXT_FILE), encoding="utf-8")
raw_text = corpus.raw()
corpus_stats = pd.Series({"files": len(corpus.fileids()),
"paragraphs": len(corpus.paras()), "sentences": len(corpus.sents()),
"words": len(corpus.words())})
paragraphs: 3908 sentences: 9190 words: 226639
The reader builds the hierarchy paragraphs → sentences → words. These descriptive statistics are the first thing to check in any text dataset. The reader uses its own rules, so later counts will not match exactly.
4. Tokenization
father, ’, s).spaced_text = raw_text.replace("—", " — ")
tokens_raw = nltk.word_tokenize(spaced_text)
Total tokens: 225604 ['Chapter', 'I', '.', 'My', 'father', '’', 's', 'family', 'name', ...]
—Mrs stays glued together and appears as the bogus token —mrs, as in the original.Quick check: token vs type?
Show answer
A token is one occurrence of a word (thousands of occurrences of the). A type is a distinct word form (the is one type). Tokens measure text length; types measure vocabulary size.5. Cleaning: punctuation, case and short words
We apply the normalization steps one at a time and record the token count after each, so each effect is visible.
TITLES = {"mr", "mrs"}
WORD_RE = re.compile(r"[a-z]+(?:['’-][a-z]+)*")
def is_valid(tok):
"""True for alphabetic words of 3+ letters (or a title)."""
if tok in TITLES:
return True
return len(tok) > 2 and not tok.endswith("n't") and WORD_RE.fullmatch(tok) is not None
tokens_no_punct = [t for t in tokens_raw if any(c.isalnum() for c in t)]
tokens_lower = [t.lower().rstrip(".") for t in tokens_no_punct]
tokens_words = [t for t in tokens_lower if is_valid(t)]
| Step | What it does |
|---|---|
| Remove punctuation | Keeps tokens with at least one letter or digit. In the original run, tokens fell from 225,604 to 187,354 (about 17% were punctuation). |
| Lower-case | Merges The, the, THE. Also strips the period from titles (Mr. → mr). |
| Real words only | Letters, optionally joined by a hyphen or apostrophe (great-aunt), at least 3 letters. Titles are kept because they matter here. Fragments like s and n't are dropped. |
mr. A regex rule plus a small title whitelist is more precise than a bare length filter.Quick check: why can lower-casing be a bad idea?
Show answer
Case carries information. In named-entity recognition, Apple (company) vs apple (fruit) matters. VADER also reads capitalization (GREAT is stronger than great), so sentiment is scored on the original sentences.6. Frequency distributions
FreqDist is a dictionary-like object with .most_common() and .plot().freq_all = nltk.FreqDist(tokens_words)
print("Top 20 words:", freq_all.most_common(20))
[('the', 8119), ('and', 6956), ('that', 3022), ('was', 2809), ('you', 2214), ('had', 2087), ...]
The top words are grammatical glue and would top the list for almost any English text. That motivates stop-word removal.
7. Stop words
stop_words = set(stopwords.words("english"))
tokens_nostop = [t for t in tokens_words if t not in stop_words]
In the original run this shrank the data to 82,143 tokens, so more than half of the words were stop words. Character names now appear (Joe, Pip, Herbert, Havisham, Wemmick, Estella…), but dialogue words such as said and would still dominate.
Customizing the list
custom_stop_words = {"said", "say", "would", "could", "come"}
stop_words |= custom_stop_words # set union
tokens_clean = [t for t in tokens_nostop if t not in stop_words]
Stop-word lists are domain-dependent: in a novel, said is noise; in a study of dialogue it might be the main signal. The original removal of these words left 79,353 tokens.
set gives fast membership tests, and the variable is named stop_words so it no longer overwrites the imported stopwords module. say is added because lemmatizing turns said into say.Quick check: why add said and would?
Show answer
They are frequent but carry no topical meaning (said marks dialogue, would is a modal verb). Removing them lets names and themes surface. Standard lists are generic, so tune them to your corpus.8. Visualizing word frequency
The notebook now plots frequencies three ways.
8.1 Bar chart
top = pd.DataFrame(freq_clean.most_common(25), columns=["word", "count"])
sns.barplot(data=top, y="word", x="count", color="steelblue")
A bar chart is the easiest way to compare exact counts of the top words.
8.2 Cumulative frequency plot
freq_clean.plot(50, cumulative=True)
A cumulative plot shows a running total. A steep early rise that flattens means a few words account for a large share of the text, typical of language (Zipf’s law).
8.3 Word cloud
cloud = WordCloud(stopwords=stop_words, max_words=100, background_color="white",
width=900, height=450).generate(" ".join(tokens_clean))
plt.imshow(cloud, interpolation="bilinear"); plt.axis("off")
A word cloud sizes each word by frequency. " ".join(...) turns the token list back into one string, which WordCloud expects. It is good for a quick impression, not for precise comparison.
Quick check: what does a steep-then-flat cumulative curve tell you?
Show answer
A few very frequent words account for a large share of all tokens, while the rest form a long tail of rarer words.9. Sentences and part-of-speech tagging
9.1 Splitting into sentences
POS tagging and sentiment work best on whole sentences, so we build a table with one row per sentence, chapter by chapter (the novel has 59 chapters).
parts = re.split(r"(?m)^Chapter [IVXLC]+\.\s*$", raw_text)
chapter_texts = parts[1:]
ABBREV_END = re.compile(r"\b(Mr|Mrs|Ms|Dr|St)\.$")
def split_sentences(text):
text = re.sub(r"\s+", " ", text.replace("—", " — ")).strip()
merged, carry = [], ""
for s in sent_tokenize(text):
s = f"{carry} {s}".strip()
if ABBREV_END.search(s):
carry = s; continue
merged.append(s); carry = ""
if carry: merged.append(carry)
return merged
rows = [(ch, s) for ch, txt in enumerate(chapter_texts, start=1)
for s in split_sentences(txt)]
df = pd.DataFrame(rows, columns=["chapter", "sentence"])
Mr./Mrs. are merged back (the original split …my sister,—Mrs. from Joe Gargery, who married the blacksmith.). A pandas DataFrame (a table) holds one sentence per row, now with a chapter column.9.2 Part-of-speech (POS) tagging
| Tag | Meaning | Tag | Meaning |
|---|---|---|---|
| NN / NNS | common noun, singular / plural | VB | verb, base form |
| NNP / NNPS | proper noun, singular / plural | VBD / VBN | verb, past tense / past participle |
| JJ | adjective | VBG / VBZ / VBP | verb: -ing / 3rd person / other present |
| RB | adverb | PRP | personal pronoun |
| IN | preposition | DT | determiner |
sample = df.loc[1, "sentence"]
nltk.pos_tag(nltk.word_tokenize(sample))
# tag every sentence in context
tagged_sents = [nltk.pos_tag(nltk.word_tokenize(s)) for s in df["sentence"]]
tag_counts = Counter(tag for sent in tagged_sents for _, tag in sent)
father → PRP (pronoun), which is wrong. Taggers use neighbouring words as clues, so they need the full, unfiltered sentence. Tag first, filter afterwards.The notebook then charts the 12 most common tags, and lists the most frequent nouns, adjectives and verbs with a helper:
def top_by_pos(prefixes, n=10):
c = Counter()
for sent in tagged_sents:
for tok, tag in sent:
t = tok.lower().rstrip(".")
if tag.startswith(prefixes) and is_valid(t) and t not in stop_words:
c[t] += 1
return c.most_common(n)
top_by_pos(("NN",)); top_by_pos(("JJ",)); top_by_pos(("VB",))
Quick check: why tag whole sentences rather than the filtered token list?
Show answer
Taggers use neighbouring words and word order to choose a tag. Removing stop words and punctuation destroys that context, which is why father was mis-tagged as a pronoun.10. Lemmatization
lemmatizer = WordNetLemmatizer()
lemmatizer.lemmatize("called") # 'called' (assumed noun)
lemmatizer.lemmatize("called", pos="v") # 'call'
lemmatizer.lemmatize("better", pos="a") # 'good'
lemmatizer.lemmatize("mice") # 'mouse'
WordNetLemmatizer looks words up in WordNet. It assumes every word is a noun unless told otherwise, so verbs like called, saw and came were left unchanged in the original, and the surname Jaggers became jagger.
def wn_pos(tag):
if tag.startswith("J"): return wordnet.ADJ
if tag.startswith("V"): return wordnet.VERB
if tag.startswith("R"): return wordnet.ADV
return wordnet.NOUN
def lemmatize_sentence(tagged):
out = []
for tok, tag in tagged:
t = tok.lower().rstrip(".")
if not is_valid(t) or t in stop_words:
continue
lemma = t if tag in ("NNP", "NNPS") else lemmatizer.lemmatize(t, wn_pos(tag))
if lemma not in stop_words:
out.append(lemma)
return out
sent_lemmas = [lemmatize_sentence(s) for s in tagged_sents] # one list per sentence
lemmas = [w for s in sent_lemmas for w in s] # flat list for the book
Quick check: stemming vs lemmatization?
Show answer
Lemmatization returns real dictionary words and uses POS information, so it is more accurate but slower. Stemming is a fast rule-based cut (e.g. Porter) that may output non-words, which is often fine for search indexing or huge corpora.11. N-grams and collocations
def count_ngrams(n, top=10):
c = Counter(g for sent in sent_lemmas for g in ngrams(sent, n))
return c.most_common(top)
count_ngrams(2, 15); count_ngrams(3, 15)
ngrams() over the whole filtered book, so a pair could glue the last word of one sentence to the first of the next. Counter (standard library) tallies each n-gram.In the original run, the top bigrams were miss havisham (299), mr. jagger (214), mr. wopsle (110), mr. pumblechook (89), dear boy (62) and mrs. joe (60). Top trigrams included pale young gentleman (19), mill pond bank (12) and three jolly bargeman (7). Your new counts will differ somewhat because of the improved cleaning.
Collocations with PMI
Raw counts favour pairs that include very common words. Collocations use PMI (pointwise mutual information) to find pairs that occur together more often than chance would predict, which highlights true phrases and names.
finder = BigramCollocationFinder.from_documents(sent_lemmas)
finder.apply_freq_filter(10) # ignore pairs seen fewer than 10 times
for pair in finder.nbest(BigramAssocMeasures().pmi, 20):
print(" ".join(pair))
Quick check: how many trigrams are in a text of N tokens?
Show answer
N − 2. In general N − n + 1 n-grams, since the window cannot start in the last n − 1 positions.Quick check: why can high-PMI bigrams be dominated by rare words?
Show answer
PMI rewards pairs whose words almost always appear together, even if both are rare. That is why a minimum-frequency filter is applied first.12. Sentiment analysis (VADER)
analyzer = SentimentIntensityAnalyzer()
scores = df["sentence"].apply(analyzer.polarity_scores).apply(pd.Series)
df = pd.concat([df.drop(columns=["neg", "neu", "pos", "compound"], errors="ignore"), scores], axis=1)
| Score | Meaning |
|---|---|
neg, neu, pos | Share of the sentence that is negative / neutral / positive (they sum to about 1). |
compound | Overall score from −1 (most negative) to +1 (most positive). |
polarity_scores four times per sentence.Classifying sentences two ways
strict = pd.Series({"positive": (df["compound"] > 0).sum(),
"negative": (df["compound"] < 0).sum(),
"neutral": (df["compound"] == 0).sum()})
df["label"] = np.select([df["compound"] >= 0.05, df["compound"] <= -0.05],
["positive", "negative"], default="neutral")
The strict rule matches the original (any positive score is positive). The VADER thresholds treat scores between −0.05 and 0.05 as neutral, which is the recommended convention. The original strict run gave:
| Class (strict) | Sentences | Share |
|---|---|---|
| Positive (> 0) | 2,748 | 38.5% |
| Negative (< 0) | 2,071 | 29.0% |
| Neutral (= 0) | 2,312 | 32.4% |
The spike at 0 is made of sentences with no words in VADER’s lexicon, which therefore score exactly neutral. Ignoring it, scores spread widely on both sides.
Sentiment over the course of the book
chapter_sent = df.groupby("chapter")["compound"].mean() # average per chapter
df["compound"].rolling(300).mean() # smoothed sentence-level arc
df.nlargest(5, "compound")[["chapter", "compound", "sentence"]] # most positive
df.nsmallest(5, "compound")[["chapter", "compound", "sentence"]] # most negative
Averaging per chapter and smoothing across sentences reveals the story’s emotional arc. Listing the most extreme sentences is a useful sanity check on whether the scores make sense.
Quick check: what does a compound score of exactly 0.0 mean, and why so many?
Show answer
No word in the sentence was found in VADER’s lexicon (or positive and negative cancelled exactly). Many narrative sentences contain only factual words, creating the large spike.Quick check: why might VADER misjudge this novel?
Show answer
Its lexicon and rules were tuned on modern social-media text. Victorian vocabulary, irony, long sentences and words whose meaning has shifted can be mis-scored, so treat results as rough trends, not precise measurements.13. Characters across the book
Because proper nouns were kept intact, we can see where each main character is mentioned. In a dispersion plot, each mark is one occurrence at that position in the text.
characters = ["pip", "joe", "estella", "havisham", "magwitch", "jaggers", "herbert", "wemmick", "biddy"]
nltk.Text(lemmas).dispersion_plot(characters)
Compare when each character first appears and where mentions cluster, then check whether that matches the story as you know it (for example, Magwitch is largely absent from the opening chapters).
14. Pipeline summary
summary = pd.DataFrame({"tokens": step_counts})
summary["removed vs previous"] = -summary["tokens"].diff().fillna(0).astype(int)
| Stage | What changed |
|---|---|
| 1. Raw tokens | Words plus punctuation (225,604 in the original run) |
| 2. Punctuation removed | Only tokens with letters or digits remain |
| 3. Lower-cased | Case normalized, title periods stripped |
| 4. Real words, 3+ letters | Fragments and short words dropped (titles kept) |
| 5. Stop words removed | Common words dropped |
| 6. Custom stop words removed | said, say, would, could, come dropped |
| 7. Lemmatized (POS-aware) | Words reduced to base form; uses sentence-by-sentence tokenization, so the count differs slightly from step 6 |
Key lessons
| 1 | Clean in stages and inspect each stage; most NLP errors come from silent preprocessing problems (—mrs, ’s). |
| 2 | Order matters: tag POS and score sentiment on unfiltered sentences; use heavily cleaned tokens for frequencies, word clouds and n-grams. |
| 3 | Domain knowledge matters: tune stop words, and be careful with tools built for a different kind of text. |
| 4 | Lemmatize with POS information and protect proper nouns. |
Glossary
| Corpus | A collection of text used for analysis. |
| Token / Type | One occurrence of a word / a distinct word form. |
| Stop word | A very common word removed because it carries little meaning. |
| Lemma | The dictionary base form of a word. |
| N-gram | A run of n consecutive tokens. |
| Collocation / PMI | A word pair that co-occurs more than chance predicts, and the statistic that measures it. |
| POS tag | A label for a word’s grammatical category. |
| Compound score | VADER’s overall sentiment from −1 to +1. |
15. Practice questions
Part A: Conceptual questions
1. List the main steps of the text pipeline in this notebook, in order.
Show answer
Load text → tokenize → remove punctuation → lower-case → drop short/non-word tokens → remove stop words → (frequencies / word cloud) → POS-tag in context → lemmatize → n-grams → sentiment scoring.2. Which is larger for a typical book: tokens or types? Why?
Show answer
Tokens. Every occurrence is a token, but repeated words collapse into one type, so types ≤ tokens.3. Why is a set better than a list for stop words?
Show answer
Membership tests are O(1) on average for a set but O(n) for a list. With about 180 stop words and roughly 190,000 tokens, the set is much faster.4. Jaggers became jagger in the original. Why, and how was it fixed?
Show answer
WordNet treated the trailing s as a plural ending and removed it. Now proper nouns (NNP/NNPS) skip lemmatization, so jaggers is preserved.5. Which technique finds the most distinctive phrases rather than the most frequent?
Show answer
Collocation analysis with PMI (and a minimum-frequency filter), which finds pairs that co-occur more than chance predicts.6. Interpret: neg=0.0, neu=0.8, pos=0.2, compound=0.42.
Show answer
About 80% of the sentence is neutral and 20% positive, with no negative content. The compound of 0.42 is clearly positive (above the 0.05 threshold) but not strongly so.7. Name two reasons a word cloud alone is a poor basis for conclusions.
Show answer
(1) It encodes only frequency, with no exact values. (2) Its look depends on preprocessing choices such as the stop-word list and the word limit. It also ignores context and word order.Part B: Coding exercises
Try each in a notebook cell first. All use variables created earlier in NLP_improved.ipynb, so run it from the top.
1. Compute the type–token ratio of tokens_clean. What does a higher value suggest?
Show solution
ttr = len(set(tokens_clean)) / len(tokens_clean)
print(f"Type-token ratio: {ttr:.3f}")Higher means a more varied vocabulary. TTR falls as texts get longer, so compare only texts of similar length.2. Show the 15 most common words of 8 letters or more.
Show solution
long_words = nltk.FreqDist(t for t in tokens_clean if len(t) >= 8)
print(long_words.most_common(15))3. Find the 10 most common trigrams that do not contain mr or mrs.
Show solution
c = Counter(g for sent in sent_lemmas for g in ngrams(sent, 3)
if not {"mr", "mrs"} & set(g))
for g, n in c.most_common(10):
print(" ".join(g), n)4. Create a word cloud using only the adjectives.
Show solution
adjectives = [t.lower().rstrip(".") for sent in tagged_sents for t, tag in sent
if tag.startswith("JJ") and is_valid(t.lower().rstrip("."))
and t.lower() not in stop_words]
cloud = WordCloud(background_color="white", width=900, height=450).generate(" ".join(adjectives))
plt.figure(figsize=(11, 6)); plt.imshow(cloud, interpolation="bilinear"); plt.axis("off"); plt.show()5. Which chapter has the most negative average sentiment, and which the most positive?
Show solution
chapter_sent = df.groupby("chapter")["compound"].mean()
print("Most negative:", chapter_sent.idxmin(), round(chapter_sent.min(), 3))
print("Most positive:", chapter_sent.idxmax(), round(chapter_sent.max(), 3))6. Compare average sentiment of sentences mentioning Estella with those mentioning Joe.
Show solution
for name in ["Estella", "Joe"]:
mask = df["sentence"].str.contains(rf"\b{name}\b")
print(f"{name}: {mask.sum()} sentences, mean compound = {df.loc[mask, 'compound'].mean():.3f}")7. Challenge: write top_words(chapter_number, n=10) returning the n most frequent lemmas of one chapter.
Show solution
def top_words(chapter_number, n=10):
idx = df.index[df["chapter"] == chapter_number]
c = Counter(w for i in idx for w in sent_lemmas[i])
return c.most_common(n)
print(top_words(1))
print(top_words(59))Going further
| 1 | Replace VADER with a transformer sentiment model (e.g. Hugging Face) and compare the chapter arcs. |
| 2 | Extract named entities (people, places) with spaCy and compare with our proper-noun counts. |
| 3 | Use TF-IDF per chapter to find words distinctive to each chapter. |
| 4 | Build a simple topic model (LDA) with gensim or scikit-learn. |