stefan-jansen--machine-learning-for-trading
700 行
25 KiB
Python
700 行
25 KiB
Python
# ---
|
|
# jupyter:
|
|
# jupytext:
|
|
# cell_metadata_filter: tags,-all
|
|
# text_representation:
|
|
# extension: .py
|
|
# format_name: percent
|
|
# format_version: '1.3'
|
|
# jupytext_version: 1.19.3
|
|
# kernelspec:
|
|
# display_name: Python 3 (ipykernel)
|
|
# language: python
|
|
# name: python3
|
|
# ---
|
|
|
|
# %% [markdown]
|
|
# # Sentiment Analysis Evolution: TF-IDF → Word2Vec → Transformers
|
|
#
|
|
# **Chapter 10: From Text to Features - The Transformer Breakthrough**
|
|
# **Section Reference**: See Sections 10.2, 10.3, 10.5 for conceptual discussion
|
|
#
|
|
# **Docker image**: `ml4t-py312`
|
|
#
|
|
# > **Docker required**: This notebook uses `gensim`, which has no Python 3.14 support.
|
|
# > Run with:
|
|
# > ```bash
|
|
# > docker compose --profile py312 run --rm py312 python 10_text_feature_engineering/03_sentiment_evolution.py
|
|
# > ```
|
|
#
|
|
# ## Purpose
|
|
# This notebook demonstrates the evolution of text representation for sentiment
|
|
# classification, comparing three approaches on the Financial PhraseBank dataset.
|
|
# We show how each generation of NLP techniques addresses limitations of its
|
|
# predecessors, culminating in Transformer-based models.
|
|
#
|
|
# ## Learning Objectives
|
|
# After completing this notebook, you will be able to:
|
|
# - Implement TF-IDF vectorization for document classification
|
|
# - Use pre-trained static embeddings (GloVe) for document-level features
|
|
# - Apply a pre-trained Transformer (FinBERT) without task-specific fine-tuning
|
|
# - Compare accuracy and F1 scores across NLP paradigms
|
|
# - Interpret confusion matrices to identify class-specific errors
|
|
# - Understand why pre-trained models need fine-tuning for new tasks
|
|
#
|
|
# ## Prerequisites
|
|
# - Sections 10.1–10.4 of the chapter (TF-IDF, static embeddings, Transformers).
|
|
# - Financial PhraseBank `sentences_allagree` subset on disk (loaded via
|
|
# `data.load_financial_phrasebank`).
|
|
#
|
|
# ## Related Notebooks
|
|
# - `01_word2vec_training.py` — Skip-gram mechanics on the same corpus.
|
|
# - `04_bert_finetuning.py` — fine-tunes FinBERT on PhraseBank (this notebook
|
|
# shows the pre-fine-tuning baseline).
|
|
|
|
# %%
|
|
"""Sentiment Analysis Evolution — compare TF-IDF, Word2Vec, and Transformer approaches on Financial PhraseBank."""
|
|
|
|
import json
|
|
import warnings
|
|
|
|
import gensim.downloader as api
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
import polars as pl
|
|
import seaborn as sns
|
|
import torch
|
|
from sklearn.feature_extraction.text import TfidfVectorizer
|
|
from sklearn.linear_model import LogisticRegression
|
|
from sklearn.metrics import accuracy_score, confusion_matrix, f1_score
|
|
from sklearn.model_selection import train_test_split
|
|
from transformers import pipeline
|
|
from transformers import set_seed as set_transformers_seed
|
|
|
|
from data import load_financial_phrasebank as load_financial_phrasebank_canonical
|
|
from utils.paths import get_chapter_dir
|
|
from utils.reproducibility import set_global_seeds
|
|
|
|
warnings.filterwarnings("ignore", category=UserWarning)
|
|
|
|
# %% tags=["parameters"]
|
|
# Production defaults — Papermill can override for fast CI runs.
|
|
SEED = 42
|
|
MAX_SAMPLES = 0 # 0 = use the full sentences_allagree subset
|
|
FINBERT_TEST_SAMPLES = 0 # 0 = run FinBERT on the entire stratified test split
|
|
|
|
# %%
|
|
# Reproducibility — set_global_seeds covers Python random / NumPy / Torch.
|
|
# transformers has its own RNG (used by Trainer + pipelines) that needs explicit seeding.
|
|
set_global_seeds(SEED)
|
|
set_transformers_seed(SEED)
|
|
|
|
CONFIG = {
|
|
"random_seed": SEED,
|
|
"test_size": 0.2,
|
|
"dataset": {
|
|
"name": "takala/financial_phrasebank",
|
|
"subset": "sentences_allagree",
|
|
"description": "Financial PhraseBank — 100% annotator agreement subset (2,264 sentences)",
|
|
},
|
|
"tfidf": {
|
|
"max_features": 5000,
|
|
"ngram_range": (1, 2),
|
|
"min_df": 2,
|
|
},
|
|
"glove": {
|
|
"model": "glove-wiki-gigaword-100",
|
|
"dim": 100,
|
|
},
|
|
"finbert": {
|
|
"model_id": "yiyanghkust/finbert-tone",
|
|
"description": "FinBERT fine-tuned on analyst reports for sentiment (NOT PhraseBank)",
|
|
"tokenizer_id": "yiyanghkust/finbert-tone",
|
|
"max_length": 512,
|
|
"labels": {"Negative": 0, "Neutral": 1, "Positive": 2},
|
|
},
|
|
}
|
|
|
|
print("=" * 70)
|
|
print("EXPERIMENT CONFIGURATION")
|
|
print("=" * 70)
|
|
print(json.dumps(CONFIG, indent=2))
|
|
print("=" * 70)
|
|
|
|
# %% [markdown]
|
|
# ## 2. Load Financial PhraseBank Dataset
|
|
#
|
|
# The Financial PhraseBank (Malo et al., 2014) consists of English-language
|
|
# sentences from financial news, each labelled positive / negative / neutral
|
|
# by 5–8 annotators. We use the `sentences_allagree` subset — the 2,264
|
|
# sentences where every annotator picked the same label, i.e., the
|
|
# highest-precision portion of the corpus.
|
|
|
|
# %%
|
|
|
|
|
|
def load_financial_phrasebank() -> pl.DataFrame:
|
|
"""Load Financial PhraseBank from canonical local storage (sentences_allagree)."""
|
|
return load_financial_phrasebank_canonical()
|
|
|
|
|
|
df = load_financial_phrasebank()
|
|
print(f"Loaded {len(df):,} sentences")
|
|
print("\nLabel distribution:")
|
|
print(df.group_by("label").len().sort("label"))
|
|
|
|
if MAX_SAMPLES > 0 and len(df) > MAX_SAMPLES:
|
|
per_label = max(MAX_SAMPLES // df["label"].n_unique(), 1)
|
|
df = (
|
|
df.sort(["label", "sentence"])
|
|
.group_by("label", maintain_order=True)
|
|
.head(per_label)
|
|
.sort("sentence")
|
|
)
|
|
print(f"Reduced dataset for test run: {len(df):,} sentences")
|
|
|
|
# Map numeric labels to text
|
|
label_map = {0: "negative", 1: "neutral", 2: "positive"}
|
|
df = df.with_columns(pl.col("label").replace_strict(label_map).alias("sentiment"))
|
|
|
|
# %%
|
|
# ============================================================================
|
|
# SANITY CHECKS
|
|
# ============================================================================
|
|
# These checks ensure the data and label mapping are correct before training.
|
|
|
|
print("\n" + "=" * 70)
|
|
print("DATASET SANITY CHECKS")
|
|
print("=" * 70)
|
|
|
|
# 1. Class distribution in full dataset
|
|
print("\n1. CLASS DISTRIBUTION (Full Dataset)")
|
|
class_counts = df.group_by("label").len().sort("label")
|
|
print(class_counts)
|
|
|
|
# Majority class baseline
|
|
total = len(df)
|
|
majority_row = class_counts.sort("len", descending=True).row(0)
|
|
majority_label = majority_row[0] # label column
|
|
majority_count = majority_row[1] # len column
|
|
majority_baseline = majority_count / total
|
|
print(f"\nMajority class: {label_map[majority_label]} (label={majority_label})")
|
|
print(f"Majority baseline accuracy: {majority_baseline:.1%}")
|
|
|
|
# 2. Label mapping verification
|
|
print("\n2. LABEL MAPPING VERIFICATION")
|
|
print(" Dataset labels → Our labels:")
|
|
for numeric, text in label_map.items():
|
|
print(f" {numeric} → {text}")
|
|
|
|
# Assert label mapping matches FinBERT expectations
|
|
finbert_label_map = {"Negative": 0, "Neutral": 1, "Positive": 2}
|
|
assert label_map == {0: "negative", 1: "neutral", 2: "positive"}, "Label mapping mismatch!"
|
|
print(" [OK] Label mapping verified")
|
|
|
|
# 3. Sample sentences by class
|
|
print("\n3. SAMPLE SENTENCES BY CLASS")
|
|
for label_id, label_name in label_map.items():
|
|
sample = df.filter(pl.col("label") == label_id).head(1)["sentence"][0]
|
|
print(f" {label_name}: '{sample[:80]}...'")
|
|
|
|
print("\n" + "=" * 70)
|
|
|
|
# %%
|
|
# Train/test split (convert Polars columns to numpy for sklearn)
|
|
X_train, X_test, y_train, y_test = train_test_split(
|
|
df["sentence"].to_numpy(),
|
|
df["label"].to_numpy(),
|
|
test_size=CONFIG["test_size"],
|
|
random_state=SEED,
|
|
stratify=df["label"].to_numpy(),
|
|
)
|
|
|
|
# Print split details
|
|
print("\n" + "=" * 70)
|
|
print("TRAIN/TEST SPLIT DETAILS")
|
|
print("=" * 70)
|
|
print("Split protocol: Stratified random split (preserves class proportions)")
|
|
print(f"Test size: {CONFIG['test_size']} ({CONFIG['test_size'] * 100:.0f}%)")
|
|
print(f"Random seed: {SEED}")
|
|
print(f"\nTrain samples: {len(X_train):,}")
|
|
print(f"Test samples: {len(X_test):,}")
|
|
|
|
if FINBERT_TEST_SAMPLES > 0 and len(X_test) > FINBERT_TEST_SAMPLES:
|
|
sample_idx = np.random.choice(len(X_test), FINBERT_TEST_SAMPLES, replace=False)
|
|
X_test_finbert = X_test[sample_idx]
|
|
y_test_finbert = y_test[sample_idx]
|
|
print(f"FinBERT evaluation sample: {len(X_test_finbert):,}")
|
|
else:
|
|
X_test_finbert = X_test
|
|
y_test_finbert = y_test
|
|
|
|
# %%
|
|
# Class distribution in each split.
|
|
train_counts = dict(zip(*np.unique(y_train, return_counts=True), strict=False))
|
|
test_counts = dict(zip(*np.unique(y_test, return_counts=True), strict=False))
|
|
pl.DataFrame(
|
|
{
|
|
"class": [label_map[i] for i in sorted(train_counts.keys())],
|
|
"train": [train_counts[i] for i in sorted(train_counts.keys())],
|
|
"test": [test_counts[i] for i in sorted(train_counts.keys())],
|
|
}
|
|
)
|
|
|
|
# %% [markdown]
|
|
# ## 3. TF-IDF + Logistic Regression (Lexical Baseline)
|
|
#
|
|
# The simplest baseline: represent documents as weighted term frequencies,
|
|
# then train a linear classifier. TF-IDF captures word importance but cannot
|
|
# understand semantic similarity or context.
|
|
|
|
# %%
|
|
# TF-IDF vectorization
|
|
tfidf = TfidfVectorizer(
|
|
max_features=5000,
|
|
ngram_range=(1, 2),
|
|
min_df=2,
|
|
stop_words="english",
|
|
)
|
|
|
|
X_train_tfidf = tfidf.fit_transform(X_train)
|
|
X_test_tfidf = tfidf.transform(X_test)
|
|
|
|
print(f"TF-IDF feature dimension: {X_train_tfidf.shape[1]}")
|
|
|
|
# Train logistic regression
|
|
lr_tfidf = LogisticRegression(max_iter=1000, random_state=SEED)
|
|
lr_tfidf.fit(X_train_tfidf, y_train)
|
|
|
|
# Evaluate
|
|
y_pred_tfidf = lr_tfidf.predict(X_test_tfidf)
|
|
acc_tfidf = accuracy_score(y_test, y_pred_tfidf)
|
|
f1_tfidf = f1_score(y_test, y_pred_tfidf, average="macro")
|
|
|
|
print("\nTF-IDF + Logistic Regression:")
|
|
print(f" Accuracy: {acc_tfidf:.1%}")
|
|
print(f" F1 (macro): {f1_tfidf:.3f}")
|
|
|
|
# %% [markdown]
|
|
# ## 4. Static Embeddings (GloVe) + Logistic Regression
|
|
#
|
|
# Static embeddings map words to dense vectors that capture semantic similarity.
|
|
# We use GloVe (Global Vectors for Word Representation) pre-trained on Wikipedia/Gigaword.
|
|
# We average word vectors to create document representations, then train a classifier.
|
|
# Limitation: each word has ONE vector regardless of context.
|
|
|
|
# %% [markdown]
|
|
# ### Load GloVe Embeddings
|
|
# Load pre-trained GloVe vectors for document representation.
|
|
|
|
# %%
|
|
from gensim.utils import simple_preprocess
|
|
|
|
|
|
def get_embedding_model():
|
|
"""Load pre-trained GloVe embedding model."""
|
|
# Use 100-dim GloVe vectors trained on Wikipedia + Gigaword
|
|
model_name = "glove-wiki-gigaword-100"
|
|
print(f"Loading {model_name}...")
|
|
return api.load(model_name)
|
|
|
|
|
|
# %% [markdown]
|
|
# ### Document Vector Computation
|
|
# Average word vectors to create a fixed-size document representation.
|
|
|
|
|
|
# %%
|
|
def document_vector(doc: str, model, dim: int = 100) -> np.ndarray:
|
|
"""Compute document vector as average of word vectors.
|
|
|
|
Uses gensim's simple_preprocess for consistent tokenization:
|
|
- Lowercases text
|
|
- Removes punctuation and special characters
|
|
- Filters very short/long tokens
|
|
"""
|
|
# Use gensim's tokenizer for cleaner preprocessing
|
|
words = simple_preprocess(doc, deacc=True, min_len=2, max_len=15)
|
|
vectors = []
|
|
for word in words:
|
|
if word in model:
|
|
vectors.append(model[word])
|
|
if vectors:
|
|
return np.mean(vectors, axis=0)
|
|
return np.zeros(dim)
|
|
|
|
|
|
# %%
|
|
# Load model
|
|
glove_model = get_embedding_model()
|
|
embed_dim = glove_model.vector_size
|
|
|
|
# Compute document vectors
|
|
print("Computing document vectors...")
|
|
X_train_emb = np.array([document_vector(doc, glove_model, embed_dim) for doc in X_train])
|
|
X_test_emb = np.array([document_vector(doc, glove_model, embed_dim) for doc in X_test])
|
|
|
|
print(f"GloVe document dimension: {X_train_emb.shape[1]}")
|
|
|
|
# Train logistic regression
|
|
lr_glove = LogisticRegression(max_iter=1000, random_state=SEED)
|
|
lr_glove.fit(X_train_emb, y_train)
|
|
|
|
# Evaluate
|
|
y_pred_glove = lr_glove.predict(X_test_emb)
|
|
acc_glove = accuracy_score(y_test, y_pred_glove)
|
|
f1_glove = f1_score(y_test, y_pred_glove, average="macro")
|
|
|
|
print("\nGloVe + Logistic Regression:")
|
|
print(f" Accuracy: {acc_glove:.1%}")
|
|
print(f" F1 (macro): {f1_glove:.3f}")
|
|
|
|
# %% [markdown]
|
|
# ## 5. FinBERT Pre-trained (No Task Fine-tuning)
|
|
#
|
|
# Transformers learn contextual representations that vary with surrounding words.
|
|
# FinBERT (yiyanghkust/finbert-tone) is pre-trained on financial text and already
|
|
# has a sentiment classification head. However, it was trained on analyst reports,
|
|
# which have different phrasing patterns than Financial PhraseBank's news sentences.
|
|
# Same labels (positive/negative/neutral), different text distribution.
|
|
#
|
|
# **Critical Note**: This is NOT "zero-shot" since FinBERT-tone already has a
|
|
# sentiment classification head trained on analyst reports. We're testing
|
|
# **cross-dataset transfer** without task-specific fine-tuning on PhraseBank.
|
|
# The poor performance demonstrates **distribution shift**, not model quality.
|
|
|
|
# %%
|
|
# ============================================================================
|
|
# FINBERT CHECKPOINT IDENTITY
|
|
# ============================================================================
|
|
# Explicit documentation for reproducibility and reviewer scrutiny.
|
|
|
|
print("\n" + "=" * 70)
|
|
print("FINBERT CHECKPOINT DETAILS")
|
|
print("=" * 70)
|
|
print(f"Model ID: {CONFIG['finbert']['model_id']}")
|
|
print(f"Tokenizer ID: {CONFIG['finbert']['tokenizer_id']}")
|
|
print(f"Max Length: {CONFIG['finbert']['max_length']}")
|
|
print(f"Description: {CONFIG['finbert']['description']}")
|
|
print("\nLabel mapping (FinBERT → our numeric labels):")
|
|
for label, idx in CONFIG["finbert"]["labels"].items():
|
|
print(f" {label} → {idx}")
|
|
print("\nThis checkpoint was fine-tuned on analyst reports, not PhraseBank;")
|
|
print("differences vs the TF-IDF / GloVe baselines below reflect distribution shift,")
|
|
print("not a like-for-like comparison of model quality.")
|
|
print("=" * 70)
|
|
|
|
|
|
def get_finbert_predictions(texts: list[str], batch_size: int = 32) -> np.ndarray:
|
|
"""Get sentiment predictions from FinBERT.
|
|
|
|
Uses the yiyanghkust/finbert-tone model which is already fine-tuned
|
|
for financial sentiment classification on analyst reports.
|
|
|
|
Returns:
|
|
Array of predictions mapped to our label scheme (0=neg, 1=neu, 2=pos)
|
|
"""
|
|
model_id = CONFIG["finbert"]["model_id"]
|
|
tokenizer_id = CONFIG["finbert"]["tokenizer_id"]
|
|
max_length = CONFIG["finbert"]["max_length"]
|
|
|
|
# Use GPU if available
|
|
device = 0 if torch.cuda.is_available() else -1
|
|
|
|
# Create pipeline with explicit parameters
|
|
classifier = pipeline(
|
|
"sentiment-analysis",
|
|
model=model_id,
|
|
tokenizer=tokenizer_id,
|
|
device=device,
|
|
truncation=True,
|
|
max_length=max_length,
|
|
)
|
|
|
|
# Map FinBERT labels to our numeric labels
|
|
label_to_id = CONFIG["finbert"]["labels"]
|
|
|
|
predictions = []
|
|
for i in range(0, len(texts), batch_size):
|
|
batch = texts[i : i + batch_size]
|
|
results = classifier(batch)
|
|
for r in results:
|
|
predictions.append(label_to_id[r["label"]])
|
|
|
|
return np.array(predictions)
|
|
|
|
|
|
print("\nRunning FinBERT inference...")
|
|
y_pred_finbert = get_finbert_predictions(X_test_finbert.tolist())
|
|
|
|
acc_finbert = accuracy_score(y_test_finbert, y_pred_finbert)
|
|
f1_finbert = f1_score(y_test_finbert, y_pred_finbert, average="macro")
|
|
|
|
print("\nFinBERT (Pre-trained on analyst reports, no PhraseBank fine-tuning):")
|
|
print(f" Accuracy: {acc_finbert:.1%}")
|
|
print(f" F1 (macro): {f1_finbert:.3f}")
|
|
|
|
# Per-class diagnostic to understand failure patterns
|
|
from sklearn.metrics import classification_report
|
|
|
|
print("\nPer-class breakdown (explains why cross-dataset transfer struggles):")
|
|
print(
|
|
classification_report(
|
|
y_test_finbert, y_pred_finbert, target_names=["negative", "neutral", "positive"]
|
|
)
|
|
)
|
|
|
|
# %% [markdown]
|
|
# ## 6. Results Comparison
|
|
|
|
# %%
|
|
# Summary table
|
|
results = pl.DataFrame(
|
|
{
|
|
"Method": ["TF-IDF + LR", "GloVe + LR", "FinBERT (pre-trained)"],
|
|
"Accuracy": [acc_tfidf, acc_glove, acc_finbert],
|
|
"F1 (macro)": [f1_tfidf, f1_glove, f1_finbert],
|
|
}
|
|
).with_columns(
|
|
pl.col("Accuracy").map_elements(lambda x: f"{x:.1%}", return_dtype=pl.String),
|
|
pl.col("F1 (macro)").map_elements(lambda x: f"{x:.3f}", return_dtype=pl.String),
|
|
)
|
|
|
|
print("\n" + "=" * 50)
|
|
print("COMPARISON SUMMARY")
|
|
print("=" * 50)
|
|
print(results)
|
|
print("=" * 50)
|
|
|
|
# Relative change calculation (negative = regression vs the lexical baseline).
|
|
relative_change = (acc_finbert - acc_tfidf) / acc_tfidf * 100
|
|
direction = "above" if relative_change >= 0 else "below"
|
|
print(
|
|
f"\nFinBERT (pre-trained) accuracy is {abs(relative_change):.1f}% {direction} the TF-IDF baseline."
|
|
)
|
|
|
|
# %%
|
|
# Confusion matrices
|
|
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
|
|
fig.suptitle("Sentiment Classification Confusion Matrices", fontsize=12, y=1.02)
|
|
labels = ["negative", "neutral", "positive"]
|
|
|
|
for ax, (name, y_pred) in zip(
|
|
axes,
|
|
[("TF-IDF", y_pred_tfidf), ("GloVe", y_pred_glove), ("FinBERT (pre-trained)", y_pred_finbert)],
|
|
strict=False,
|
|
):
|
|
y_true = y_test_finbert if "FinBERT" in name else y_test
|
|
cm = confusion_matrix(y_true, y_pred)
|
|
sns.heatmap(
|
|
cm,
|
|
annot=True,
|
|
fmt="d",
|
|
cmap="Blues",
|
|
xticklabels=labels,
|
|
yticklabels=labels,
|
|
ax=ax,
|
|
)
|
|
ax.set_title(name)
|
|
ax.set_xlabel("Predicted")
|
|
ax.set_ylabel("Actual")
|
|
|
|
plt.show()
|
|
|
|
# %%
|
|
# Bar chart comparison
|
|
fig, ax = plt.subplots(figsize=(8, 5))
|
|
|
|
methods = ["TF-IDF + LR", "GloVe + LR", "FinBERT (pre-trained)"]
|
|
accuracies = [acc_tfidf, acc_glove, acc_finbert]
|
|
f1_scores = [f1_tfidf, f1_glove, f1_finbert]
|
|
|
|
x = np.arange(len(methods))
|
|
width = 0.35
|
|
|
|
bars1 = ax.bar(x - width / 2, accuracies, width, label="Accuracy", color="#0a1628")
|
|
bars2 = ax.bar(x + width / 2, f1_scores, width, label="F1 (macro)", color="#D4A84B")
|
|
|
|
ax.set_ylabel("Score")
|
|
ax.set_title("Sentiment Classification Method Comparison")
|
|
ax.set_xticks(x)
|
|
ax.set_xticklabels(methods)
|
|
ax.legend()
|
|
ax.set_ylim(0, 1)
|
|
|
|
# Add value labels
|
|
for bar in bars1:
|
|
height = bar.get_height()
|
|
ax.annotate(
|
|
f"{height:.1%}",
|
|
xy=(bar.get_x() + bar.get_width() / 2, height),
|
|
xytext=(0, 3),
|
|
textcoords="offset points",
|
|
ha="center",
|
|
va="bottom",
|
|
)
|
|
|
|
for bar in bars2:
|
|
height = bar.get_height()
|
|
ax.annotate(
|
|
f"{height:.3f}",
|
|
xy=(bar.get_x() + bar.get_width() / 2, height),
|
|
xytext=(0, 3),
|
|
textcoords="offset points",
|
|
ha="center",
|
|
va="bottom",
|
|
)
|
|
|
|
plt.show()
|
|
|
|
# %% [markdown]
|
|
# ## What the Comparison Shows
|
|
#
|
|
# The comparison table above is the chapter's headline result for §10.1, §10.2,
|
|
# and the lead-in to §10.4. Three patterns to read off it:
|
|
#
|
|
# - **TF-IDF + Logistic Regression** is a hard baseline. With bigram features
|
|
# and stop-word removal, lexical signal alone classifies most sentences
|
|
# correctly because financial news vocabulary (`profit`, `loss`, `revenue`,
|
|
# `narrowed`, `tumbled`) is highly polarised.
|
|
# - **GloVe averages add semantic similarity** — synonyms cluster, so a held-
|
|
# out phrase like "earnings retreated" benefits from proximity to training
|
|
# examples about "profits falling". The gain over TF-IDF is modest because
|
|
# the document-vector mean throws away word order and negation.
|
|
# - **FinBERT-tone evaluated without task-specific fine-tuning** depends
|
|
# strongly on which PhraseBank subset is in scope. On `sentences_allagree`
|
|
# (this notebook's current subset — only sentences where every annotator
|
|
# agreed), FinBERT outperforms both lexical baselines because the high-
|
|
# agreement labels are the cleanest signal and the pre-trained head can
|
|
# transfer well. On the larger mixed-agreement subset that the chapter
|
|
# §10.4 table reports, FinBERT underperforms TF-IDF — the same checkpoint
|
|
# degrades on noisier labels because its training distribution is analyst-
|
|
# report tone, not journalistic news. This is "pre-trained, no task-
|
|
# specific fine-tuning" — not "zero-shot" in the prompted-LLM sense,
|
|
# because the classification head already exists.
|
|
#
|
|
# The next code cells quantify each contrast with the actual run; the
|
|
# follow-up notebook `04_bert_finetuning.py` shows what fine-tuning recovers
|
|
# on the harder mixed-agreement subset.
|
|
|
|
# %%
|
|
# Structured output for automated extraction
|
|
print("=" * 70)
|
|
print("KEY STATISTICS FOR CHAPTER PROSE")
|
|
print("=" * 70)
|
|
print("\nDataset: Financial PhraseBank")
|
|
print(f"Train/Test split: {len(X_train)}/{len(X_test)}")
|
|
print(f"\nTF-IDF + LR: Accuracy={acc_tfidf:.1%}, F1={f1_tfidf:.3f}")
|
|
print(f"GloVe + LR: Accuracy={acc_glove:.1%}, F1={f1_glove:.3f}")
|
|
print(f"FinBERT (pre-trained): Accuracy={acc_finbert:.1%}, F1={f1_finbert:.3f}")
|
|
print(f"\nRelative change (FinBERT vs TF-IDF): {relative_change:.1f}%")
|
|
|
|
# %%
|
|
# Save results for chapter integration - both markdown and JSON artifacts
|
|
output_dir = get_chapter_dir(10) / "output" / "sentiment_evolution"
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Save structured JSON artifact (for reproducibility verification)
|
|
results_artifact = {
|
|
"config": CONFIG,
|
|
"dataset": {
|
|
"name": CONFIG["dataset"]["name"],
|
|
"subset": CONFIG["dataset"]["subset"],
|
|
"total_samples": len(df),
|
|
"train_samples": len(X_train),
|
|
"test_samples": len(X_test),
|
|
"finbert_test_samples": len(X_test_finbert),
|
|
"majority_baseline_accuracy": float(majority_baseline),
|
|
},
|
|
"results": {
|
|
"tfidf_lr": {"accuracy": float(acc_tfidf), "f1_macro": float(f1_tfidf)},
|
|
"glove_lr": {"accuracy": float(acc_glove), "f1_macro": float(f1_glove)},
|
|
"finbert_pretrained": {
|
|
"accuracy": float(acc_finbert),
|
|
"f1_macro": float(f1_finbert),
|
|
"note": "Cross-dataset transfer (trained on analyst reports, tested on news)",
|
|
},
|
|
},
|
|
}
|
|
|
|
json_file = output_dir / "results.json"
|
|
with open(json_file, "w") as f:
|
|
json.dump(results_artifact, f, indent=2)
|
|
|
|
# %%
|
|
# Save markdown summary
|
|
results_file = output_dir / "results.md"
|
|
with open(results_file, "w") as f:
|
|
f.write("# Sentiment Evolution Results\n\n")
|
|
f.write("## Experiment Configuration\n\n")
|
|
f.write(f"- Dataset: {CONFIG['dataset']['name']} ({CONFIG['dataset']['subset']})\n")
|
|
f.write(f"- Train/Test split: {len(X_train)}/{len(X_test)} (stratified, seed={SEED})\n")
|
|
f.write(f"- Majority baseline: {majority_baseline:.1%}\n\n")
|
|
f.write("## Performance Comparison\n\n")
|
|
f.write("| Method | Accuracy | F1 (macro) |\n")
|
|
f.write("|--------|----------|------------|\n")
|
|
f.write(f"| Majority Baseline | {majority_baseline:.1%} | - |\n")
|
|
f.write(f"| TF-IDF + LR | {acc_tfidf:.1%} | {f1_tfidf:.3f} |\n")
|
|
f.write(f"| GloVe + LR | {acc_glove:.1%} | {f1_glove:.3f} |\n")
|
|
f.write(f"| FinBERT (pre-trained*) | {acc_finbert:.1%} | {f1_finbert:.3f} |\n")
|
|
f.write("\n*FinBERT-tone: trained on analyst reports, NOT Financial PhraseBank\n")
|
|
f.write("\n## Key Finding\n\n")
|
|
f.write(f"FinBERT vs TF-IDF change: {relative_change:+.1f}%\n")
|
|
f.write("\n## Critical Insight\n\n")
|
|
if acc_finbert < acc_tfidf:
|
|
f.write("Pre-trained FinBERT (without PhraseBank fine-tuning) **underperforms** TF-IDF.\n")
|
|
f.write(
|
|
"This demonstrates **distribution shift**: same sentiment labels, "
|
|
"different text domains.\n"
|
|
)
|
|
f.write("FinBERT-tone was trained on analyst reports (formal, technical language),\n")
|
|
f.write("while PhraseBank contains financial news sentences (journalistic style).\n")
|
|
else:
|
|
f.write(
|
|
f"Pre-trained FinBERT (without PhraseBank fine-tuning) **outperforms** TF-IDF "
|
|
f"by {relative_change:+.1f}% on this subset.\n"
|
|
)
|
|
f.write(
|
|
"On the high-agreement subset the labels are clean enough that the pre-trained "
|
|
"FinBERT head transfers well, and the analyst-report-trained classifier picks "
|
|
"up the polarised sentence-level financial vocabulary directly.\n"
|
|
)
|
|
f.write(
|
|
"The §10.4 chapter table reports the larger mixed-agreement subset, where the "
|
|
"same checkpoint degrades because labels are noisier and analyst-report tone "
|
|
"and journalistic style diverge more visibly.\n"
|
|
)
|
|
f.write("\n## Model Details\n\n")
|
|
f.write(f"- FinBERT checkpoint: `{CONFIG['finbert']['model_id']}`\n")
|
|
f.write(f"- Max length: {CONFIG['finbert']['max_length']} tokens\n")
|
|
|
|
print("\nResults saved to:")
|
|
print(f" - {results_file}")
|
|
print(f" - {json_file}")
|
|
|
|
# %% [markdown]
|
|
# ## Key Takeaways
|
|
#
|
|
# 1. **TF-IDF + logistic regression** reaches 83.2% accuracy / 0.742 macro F1 on
|
|
# this `sentences_allagree` split, setting a hard baseline that the
|
|
# embedding-based methods must clear.
|
|
#
|
|
# 2. **GloVe averages do not beat TF-IDF on this split** (80.6% accuracy /
|
|
# 0.709 macro F1). Averaging static word vectors discards word order and
|
|
# negation, and the resulting document representation loses information that
|
|
# the n-gram features preserve.
|
|
#
|
|
# 3. **Pre-trained transformer transfer is subset-dependent**: on the high-
|
|
# agreement PhraseBank subset used here, FinBERT outperforms the lexical
|
|
# baselines because clean labels expose the value of pre-training. On the
|
|
# larger mixed-agreement subset (the §10.4 table), the same checkpoint
|
|
# underperforms TF-IDF — distribution shift between analyst-report training
|
|
# text and journalistic test text matters more when label noise grows.
|
|
#
|
|
# 4. **Distribution shift is the key lesson**: same labels (positive/neutral/
|
|
# negative) do not mean same text distribution, and the gap between subsets
|
|
# here illustrates that label-quality and text-domain gaps interact. Always
|
|
# validate on your target domain *and* your target label-quality regime.
|
|
#
|
|
# **Next**: See `04_bert_finetuning` for how fine-tuning transforms performance.
|