Applied Projects — Chapter 25 Companion

Mini-Project 1 · Headline Sentiment & the Next Day's Move

Chapter 25 taught a machine to read restaurant reviews. Here we point the same machinery at finance. You get 900 market headlines, each labelled positive, neutral or negative, each stamped with what the stock did the next day. Two questions. First: can the bag of words read a headline? Second — the finance question — if sentiment is known, is tomorrow's move known? You will build the whole pipeline yourself: clean, count, classify, then trade the sentiment and count the damage. Every number in the solution comes from a real run on the downloadable file.

🧭 Mini-Project · ~60 min 📖 Uses Chapter 25 Mostly Core Volatile 📝 Quiz · 30 MCQs

🎯 Learning Objectives

1. The Brief

You work on the news desk of a small fund. Every evening the desk collects the day's headlines. The portfolio manager asks two things:

Question A is Chapter 25 again, on new text. Question B is the reason quants care about NLP at all — and the reason most of them are disappointed. You will answer both with numbers.

📝
Light by design. This is a companion to Chapter 25, not a full applied project. One dataset, one notebook, one evening. If you want the full treatment — feature engineering, leakage hunts, template bugs, walk-forward testing — Applied Project 1 is waiting at the end.

2. The Dataset

The corpus is synthetic — generated from finance vocabulary with a fixed random seed, so every student downloads the exact same 900 rows and the solution's numbers reproduce on your machine. It is honest about being synthetic: 72 distinct words survive cleaning, real financial newswires use tens of thousands. Treat it as a flight simulator, not a cockpit.

📝
Download the data: financial_headlines.tsv — 900 headlines, 5 columns, ~80 KB, tab-separated. The link is a relative path, so it works from your local copy and from the live site. Put the file next to your script.
ColumnValuesMeaning
Date2025-01-02 … 2025-07-31150 business days
TickerAAPL, MSFT, JPM, XOM, JNJ, WMTthe company the headline names
Headlinetextone sentence of financial news
Sentimentpositive · neutral · negativethe label for Question A
NextDayMoveUp · Flat · Downthe stock's move the next day — the target for Question B

Three examples (real rows from the file):

HeadlineSentimentNextDayMove
AAPL raises its dividend in morning tradingpositiveUp
AAPL surges on strong guidance, but AAPL falls short of revenue forecasts ahead of the fed meetingneutralFlat
MSFT meets market expectations in morning tradingneutralDown

Note the second row. A human calls it neutral because it argues with itself. Remember Chapter 25 §11.5: two-signal headlines are exactly the ones word-count models get wrong. You will meet this row again.

3. Your Job

  1. Explore. Load the TSV (pd.read_csv with sep='\t'). Count the classes. How many positive, negative, neutral? Plot or print value_counts(). Note the imbalance — one class is only ~19% of the rows.
  2. Clean. Apply Chapter 25's five steps to every headline: re.sub to letters, lowercase, split, drop stop words (keep "not" — this is finance, negation is everywhere), Porter-stem. Build the corpus.
  3. Classify. CountVectorizer, 80/20 split (stratify by label, random_state=0), then try MultinomialNB — the natural pair for word counts — alongside GaussianNB and LogisticRegression. Record all three accuracies.
  4. Read the errors. For MultinomialNB, print the 3 × 3 confusion matrix and per-class precision/recall. Find two headlines it got wrong and explain each in one sentence.
  5. Re-weigh. Swap to TfidfVectorizer and rerun MultinomialNB. Better or worse than raw counts here? Chapter 25's GaussianNB preferred counts. One representation, two models, two verdicts — explain.
  6. Trade. Turn sentiment into positions: predicted negative → short (Down), neutral → stay out (Flat), positive → long (Up). Score the rule against the true NextDayMove on the test set. Compare against the majority baseline (always predict the most common move). Also compute, from the full dataset, the honest cross-tab: given true sentiment, how often is the next day Up / Flat / Down?
  7. Verdict. Write three sentences to the portfolio manager: does the fund trade on this? What would you need to change your answer?

4. Solution & Real Results

Work the steps first. Then read the code walkthrough below — it explains every block, with special attention to the one line that changes everything: where y stops being two outputs and becomes three. The full script with all outputs waits in the toggle underneath.

4.1 The code, line by line

Block 1 · Load and look.

ds = pd.read_csv('financial_headlines.tsv', sep='\t')
print(ds['Sentiment'].value_counts())
# positive 366 · negative 365 · neutral 169

Read the tab-separated file — the same pattern as Chapter 25's restaurant data. Then count the classes before touching anything. The counts tell you two things: the classes are almost balanced at the top, and neutral is the minority at 19%. Every later choice — stratified split, per-class metrics — comes from this one print.

Block 2 · The line where two outputs become three.

y = ds['Sentiment'].map({'negative': 0, 'neutral': 1, 'positive': 2}).values

This is the pivotal line of the project. Models speak numbers, not words — so the three text labels must become three numbers: negative → 0, neutral → 1, positive → 2. .map() walks the column and swaps each label for its number; .values hands over the raw NumPy array.

Compare Chapter 25. There, the label column already held numbers — y = dataset.iloc[:, -1].values — and only two of them: 0 (not liked) or 1 (liked). Here the .map() line is new, and it changes everything downstream:

Chapter 25 (restaurant)This project (headlines)
y built byread straight from the file, already 0/1.map() from three words to 0/1/2
Classes23
predict() returns0 or 10, 1 or 2
predict_proba() columns2 — P(not liked), P(liked), summing to 13 — P(neg), P(neu), P(pos), summing to 1
Confusion matrix2 × 23 × 3
classification_report1 row per class3 rows — the neutral row is the one to read
Trading rule—three actions: 0→Down, 1→Flat, 2→Up

Nothing else in the pipeline changes. The vectoriser does not care. train_test_split does not care. MultinomialNB sees three classes and quietly builds three per-word probability tables instead of two, plus three class priors in the ratio 366 : 365 : 169. One line in, three outputs everywhere.

Block 3 · The cleaning loop.

corpus = []
for i in range(len(ds)):
    r = re.sub('[^a-zA-Z]', ' ', ds['Headline'][i]).lower().split()
    corpus.append(' '.join(ps.stem(w) for w in r if w not in set(sw)))

Chapter 25's five steps, compressed. One line per review: strip non-letters, lowercase, split, drop stop words ("not" kept), stem, rejoin. Out of 900 headlines comes a 72-word vocabulary — this small number matters later, when we resist believing the 1.0000.

Block 4 · The counts, and a new word: stratify.

X = CountVectorizer().fit_transform(corpus).toarray()   # (900, 72)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=0, stratify=y)    # 720 / 180

The bag of words as always. The split carries one argument Chapter 25 never needed: stratify=y. Random shuffling can shortchange a 19% class — the test set might get too few or too many neutrals. Stratifying forces the train and test sets to keep the class shares: 73 / 34 / 73 in the test, matching 366 : 365 : 169. Chapter 25's classes were 500/500, so plain splitting was safe there. With a minority class, stratify.

Block 5 · Train — three likelihood tables.

m = MultinomialNB().fit(X_train, y_train)
y_pred = m.predict(X_test)          # each headline → 0, 1 or 2

Fit on 720 headlines. Because y_train holds three values, the model learns three likelihood tables — how often each of the 72 words appears under negative, under neutral, under positive — plus the three priors. Predict gives every unseen headline one of the three outputs: a 3 × 3 confusion matrix follows automatically.

Block 6 · If you want the three probabilities.

p = m.predict_proba(X_test)   # shape (180, 3) — rows sum to 1

Each row is the model's full vote: P(negative), P(neutral), P(positive). predict() simply takes the largest of the three. And the row itself opens Chapter 25 §11's extension: if the largest probability is small, the model is unsure — send that headline to a human instead of a trade (quiz question 26).

Block 7 · The finance half — the aligned re-split.

idx = np.arange(len(ds))
_, idx_te = train_test_split(idx, test_size=0.2, random_state=0, stratify=y)
move = ds['NextDayMove'].map({'Down': 0, 'Flat': 1, 'Up': 2}).values
yte_move = move[idx_te]

The subtle block. y_pred sits in the order X_test was shuffled — but which original rows landed there? Split the indices 0…899 with the identical parameters, and the same shuffle happens: idx_te[k] is the original row number of X_test[k]. Now the moves can be fetched for exactly the test headlines. Note the second .map() — the same trick as Block 2, three text moves to 0/1/2.

Block 8 · The rule, and why it is one line.

pred_move = y_pred                                  # 0→Down, 1→Flat, 2→Up
print((pred_move == yte_move).mean())               # 0.4500

The trading rule is just y_pred — because the two encodings were chosen to line up: sentiment 0 (negative) predicts move 0 (Down), 1 → Flat, 2 → Up. That alignment is a design choice, not luck. A more explicit script would write {0:0, 1:1, 2:2} so a future reader sees the mapping. Score it: the rule is right on 45.0% of test days, against a 41.1% majority baseline — the thin-edge story of Section 4's results.

📝
One line, three outputs — the summary: Chapter 25 read two pre-labelled numbers from the file. Here .map({'negative': 0, 'neutral': 1, 'positive': 2}) creates the three-class target, and everything downstream — probabilities, confusion matrix, per-class report, the trading rule — grows from 2 to 3 automatically. The only genuinely new habit the third class forces is stratify=y.
View Solution / Walkthrough
# ---- Mini-Project 1: headline sentiment & the next day's move ----
import numpy as np, pandas as pd, re, nltk
from nltk.corpus import stopwords
from nltk.stem.porter import PorterStemmer
nltk.download('stopwords', quiet=True)
sw = stopwords.words('english'); sw.remove('not')     # keep negation
ps = PorterStemmer()

ds = pd.read_csv('financial_headlines.tsv', sep='\t')
print(ds['Sentiment'].value_counts())
# positive 366 · negative 365 · neutral 169  (neutral = 18.8% — the minority)

corpus = []
for i in range(len(ds)):
    r = re.sub('[^a-zA-Z]', ' ', ds['Headline'][i]).lower().split()
    corpus.append(' '.join(ps.stem(w) for w in r if w not in set(sw)))

from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import GaussianNB, MultinomialNB
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, accuracy_score, classification_report

y = ds['Sentiment'].map({'negative': 0, 'neutral': 1, 'positive': 2}).values
X = CountVectorizer().fit_transform(corpus).toarray()      # 900 x 72
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=0, stratify=y)       # 720 / 180

for model in [MultinomialNB(), GaussianNB(), LogisticRegression(max_iter=2000)]:
    model.fit(X_train, y_train)
    print(type(model).__name__,
          round(accuracy_score(y_test, model.predict(X_test)), 4))
# MultinomialNB      0.9167
# GaussianNB         0.9944
# LogisticRegression 1.0000   (72 words — see the reading below)

m = MultinomialNB().fit(X_train, y_train)
y_pred = m.predict(X_test)
print(confusion_matrix(y_test, y_pred))
# [[63 10  0]      <- true negative
#  [ 1 30  3]      <- true neutral
#  [ 0  1 72]]     <- true positive
print(classification_report(y_test, y_pred,
      target_names=['neg', 'neu', 'pos'], digits=3))

# step 5: TF-IDF
Xt = TfidfVectorizer().fit_transform(corpus).toarray()
Xtr2, Xte2, ytr2, yte2 = train_test_split(
    Xt, y, test_size=0.2, random_state=0, stratify=y)
print('MNB on TF-IDF:',
      round(accuracy_score(yte2,
          MultinomialNB().fit(Xtr2, ytr2).predict(Xte2)), 4))   # 0.9389

# step 6: trade the sentiment  (0->Down, 1->Flat, 2->Up)
idx = np.arange(len(ds))
_, idx_te = train_test_split(idx, test_size=0.2, random_state=0, stratify=y)
move = ds['NextDayMove'].map({'Down': 0, 'Flat': 1, 'Up': 2}).values
yte_move = move[idx_te]
print('rule accuracy :', round((y_pred == yte_move).mean(), 4))   # 0.4500
counts = np.unique(yte_move, return_counts=True)
print('majority move :', round(counts[1].max() / len(yte_move), 4))  # 0.4111
print(pd.crosstab(ds['Sentiment'], ds['NextDayMove'], normalize='index').round(3))

Question A — can it read a headline? Yes, well. MultinomialNB scores 0.9167. Per class: negative precision 0.984 / recall 0.863, neutral precision 0.732 / recall 0.882, positive 0.960 / 0.986. The minority class is the hardest, exactly as Chapter 25 promised — and exactly why you were asked for per-class numbers, not one accuracy.

Two real errors worth reading. (1) "XOM falls short of revenue forecasts" (true negative) was called neutral — four times in four phrasings. Why? "revenue forecasts" also appears in the neutral template "meets revenue forecasts", and in this small corpus the frequent shared object out-votes the verb that carries the meaning. (2) "WMT plunges after a profit warning, but WMT raises its dividend" (true neutral) was called negative. The mixed "…, but …" headline carries two full signals; the counter adds both and takes the stronger side. Chapter 25 §11.5, replayed on Wall Street.

The model comparison needs a caveat. GaussianNB hit 0.9944 and logistic 1.0000 — on a 72-word vocabulary. With so few words, a linear model can memorise the boundary between "beats" and "meets". Resist the leaderboard instinct: the interesting model here is the one whose errors you can explain. Also note the representation flip: TF-IDF lifted MultinomialNB to 0.9389 (it tames exactly those over-frequent shared objects) — while in Chapter 25, TF-IDF hurt GaussianNB. Representation quality is model-dependent. That sentence is worth remembering.

Question B — is there a trade? Barely, and no. The rule "follow the sentiment" is right on 0.4500 of test days. The majority baseline (always "Up") gets 0.4111. The edge exists — but it is four points, on synthetic data, before costs. The honest cross-tab over all 900 rows:

True sentimentDownFlatUp
negative51.2%17.3%31.5%
neutral27.8%42.0%30.2%
positive27.0%18.3%54.6%

Read it plainly. After positive news the stock still fails to rise 45% of the time. After negative news it still refuses to fall 49% of the time. And that is with true sentiment — your classifier adds its own 8% error on top. Markets price obvious news in seconds; by the time a bag of words reads tonight's headline, the trade is gone. This is Applied Project 1's lesson arriving from a different direction: the honest edge on public text is thin. Trade it with costs and slippage and it is negative.

How the corpus was built (so you can reproduce or improve it): headlines are composed from slot grammar — a ticker, a class-leaning verb phrase (beats / meets / misses + shared objects like "revenue forecasts"), optional time-of-day noise, occasional leaning suffixes, and 45% of neutrals built as mixed "positive-clause, but negative-clause" sentences. Next-day moves are drawn with a deliberately weak dependence on sentiment (≈ 55% / 45% / 42% alignment). Seed 42. The signal is planted — the lesson (how fast the edge decays from label → model → trade) is real.

⚠ Volatile content — Reviewed: October 2026 · Next review: January 2027

5. Where Real Data Lives

When the synthetic corpus feels too easy, here is where the real thing lives. All three are free; all three are messier than anything in this book.

SourceWhat you getWhere
Financial PhraseBank (Malo et al.) ~4,800 real financial-news sentences, human-annotated positive / neutral / negative — the standard benchmark for this exact task huggingface.co/datasets/takala/financial_phrasebank
Daily News for Stock Market Prediction (aaron7sun) top Reddit world-news headlines per day, 2008–2016, aligned with DJIA up/down labels kaggle.com/datasets/aaron7sun/stocknews · HF mirror
yfinance real prices and volumes — join any headline set to actual next-day returns pypi.org/project/yfinance (used in Applied Project 1)
⚠
Warning — timestamps are the whole game: before joining headlines to returns, check when each headline was published and when you could have traded. A headline stamped 16:05 cannot inform a trade at today's close. Get this wrong and you will discover a wonderful strategy that works only in the past. Chapter 3's leakage rules apply double in finance.

6. The Honest Limits

🎯 Key Takeaways

1

The Chapter 25 pipeline transfers unchanged: clean → count → classify worked at 0.917 on finance headlines with zero new machinery.

2

Read errors, not accuracy: shared objects ("revenue forecasts") out-voted the telling verb; mixed "…, but …" headlines fooled the counter both ways.

3

Representation is model-dependent: TF-IDF helped MultinomialNB (0.939) here and hurt GaussianNB in Chapter 25.

4

Sentiment → returns is a thin edge: 45.0% vs a 41.1% baseline, on planted data, before costs. Markets price obvious news in seconds.

5

Next stop for real work: Financial PhraseBank for labels, yfinance for returns — and a timestamp audit before you join them.

Test Your Understanding

30 questions on the project: the data, the pipeline, the real results, the errors, and the finance verdict. Choose one answer per question, then check your answers.

Q1.This mini-project asks two questions. They are:

Q2.Why does the project ship a synthetic corpus instead of real headlines?

Q3.The cleaning step keeps "not" out of the stop-word removal because:

Q4.The primary classifier chosen for the pipeline is:

Q5.(Real) MultinomialNB on raw counts scored:

Q6.(Real) The weakest per-class precision belonged to:

Q7.(Real) "XOM falls short of revenue forecasts" (negative) was misread as neutral. The explanation:

Q8.(Real) The mixed headline "…plunges after a profit warning, but …raises its dividend" (true neutral) was predicted:

Q9.(Real) Swapping CountVectorizer for TfidfVectorizer changed MultinomialNB from 0.917 to:

Q10.Chapter 25's GaussianNB preferred raw counts over TF-IDF. Here TF-IDF helped MultinomialNB. The general lesson:

Q11.The trading rule tested in step 6 is:

Q12.(Real) The sentiment rule's accuracy on the 180 test days, and the majority baseline:

Q13.(Real) From the full-data cross-tab: after a positive headline, the stock fails to go Up roughly:

Q14.The finance verdict written to the portfolio manager:

Q15.Why does even perfect sentiment fail to give a strong trade here?

Q16.The class imbalance in the corpus is:

Q17.Because one class is a minority, the report to trust most is:

Q18.(Real) GaussianNB scored 0.994 and logistic 1.000 on this corpus. The correct reaction:

Q19.How many distinct words survive cleaning?

Q20.The split used in the solution is:

Q21.A more honest split for the trading question would be:

Q22.The standard real-world benchmark for exactly this three-class task is:

Q23.The Kaggle dataset that pairs daily world-news headlines with DJIA up/down labels is:

Q24.To join headlines to actual next-day returns you also need:

Q25.The timestamp warning in Section 5 says:

Q26.A reasonable extension from Chapter 25 §11 for this corpus would be:

Q27.The corpus generator deliberately made 45% of neutral headlines mixed ("positive clause, but negative clause") because:

Q28.The leaning suffixes in the generator (", though analysts remain cautious") exist to:

Q29.Applied Project 1 (decision trees on real market data) found a similar honest result. In one line, that shared lesson is:

Q30.If you rerun this project on the Financial PhraseBank and accuracy drops to ~0.75, the right conclusion is: