Files
2026-07-13 13:26:28 +08:00

700 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ---
# 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.110.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 58 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.