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.
🎯 Learning Objectives
- Rebuild the Chapter 25 pipeline end to end on new text: clean, bag of words, classify — without a template to copy
- Classify three sentiment classes and read the 3 × 3 confusion matrix, per-class precision and recall
- Explain the model's real errors: shared vocabulary out-voting the telling word, and mixed "…, but …" headlines
- Turn sentiment into a trading rule and evaluate it honestly against a majority baseline
- State the finance lesson: even a good sentiment classifier is a weak trading signal — and why
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 (NLP): can a bag-of-words model read a headline and label it positive, neutral or negative for the company it names?
- Question B (finance): if we know the sentiment tonight, do we know the stock's direction tomorrow? In other words — is there a trade here?
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.
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.
| Column | Values | Meaning |
|---|---|---|
Date | 2025-01-02 … 2025-07-31 | 150 business days |
Ticker | AAPL, MSFT, JPM, XOM, JNJ, WMT | the company the headline names |
Headline | text | one sentence of financial news |
Sentiment | positive · neutral · negative | the label for Question A |
NextDayMove | Up · Flat · Down | the stock's move the next day — the target for Question B |
Three examples (real rows from the file):
| Headline | Sentiment | NextDayMove |
|---|---|---|
| AAPL raises its dividend in morning trading | positive | Up |
| AAPL surges on strong guidance, but AAPL falls short of revenue forecasts ahead of the fed meeting | neutral | Flat |
| MSFT meets market expectations in morning trading | neutral | Down |
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
- Explore. Load the TSV (
pd.read_csvwithsep='\t'). Count the classes. How many positive, negative, neutral? Plot or printvalue_counts(). Note the imbalance — one class is only ~19% of the rows. - Clean. Apply Chapter 25's five steps to
every headline:
re.subto letters, lowercase, split, drop stop words (keep "not" — this is finance, negation is everywhere), Porter-stem. Build the corpus. - Classify.
CountVectorizer, 80/20 split (stratify by label,random_state=0), then tryMultinomialNB— the natural pair for word counts — alongsideGaussianNBandLogisticRegression. Record all three accuracies. - 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.
- Re-weigh. Swap to
TfidfVectorizerand rerun MultinomialNB. Better or worse than raw counts here? Chapter 25's GaussianNB preferred counts. One representation, two models, two verdicts — explain. - Trade. Turn sentiment into positions:
predicted negative → short (Down), neutral → stay out (Flat),
positive → long (Up). Score the rule against the true
NextDayMoveon 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? - 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 by | read straight from the file, already 0/1 | .map() from three words to 0/1/2 |
| Classes | 2 | 3 |
predict() returns | 0 or 1 | 0, 1 or 2 |
predict_proba() columns | 2 — P(not liked), P(liked), summing to 1 | 3 — P(neg), P(neu), P(pos), summing to 1 |
| Confusion matrix | 2 × 2 | 3 × 3 |
classification_report | 1 row per class | 3 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.
.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 sentiment | Down | Flat | Up |
|---|---|---|---|
| negative | 51.2% | 17.3% | 31.5% |
| neutral | 27.8% | 42.0% | 30.2% |
| positive | 27.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.
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.
| Source | What you get | Where |
|---|---|---|
| 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) |
6. The Honest Limits
- 72 words. The corpus is synthetic and tiny. Real financial English has tens of thousands of words, tickers, numbers ("AAPL falls 3% after Q3 miss") and entities. Expect far below 0.92 there.
- The labels are clean. Real annotators disagree on financial sentiment roughly a third of the time — the PhraseBank ships "all-agree" and "75%-agree" variants for exactly this reason.
- The moves are planted. The 55% alignment is a design choice. In real markets the measurable edge of public sentiment is smaller, decays after minutes, and is negative after costs.
- One headline, one day, one universe. No position sizing, no risk model, no transaction costs, no look-ahead audit. The full treatment is Applied Project 1's job.
🎯 Key Takeaways
The Chapter 25 pipeline transfers unchanged: clean → count → classify worked at 0.917 on finance headlines with zero new machinery.
Read errors, not accuracy: shared objects ("revenue forecasts") out-voted the telling verb; mixed "…, but …" headlines fooled the counter both ways.
Representation is model-dependent: TF-IDF helped MultinomialNB (0.939) here and hurt GaussianNB in Chapter 25.
Sentiment → returns is a thin edge: 45.0% vs a 41.1% baseline, on planted data, before costs. Markets price obvious news in seconds.
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: