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

512 lines
16 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]
# # Transformer Fine-Tuning: FinBERT vs ModernBERT
#
# **Chapter 10: From Text to Features - The Transformer Breakthrough**
#
# **Docker image**: `ml4t-gpu`
#
# **Section Reference**: See Sections 10.4, 10.5 for conceptual discussion
#
# ## Purpose
# This notebook demonstrates the complete fine-tuning workflow for financial
# sentiment classification using the Hugging Face Trainer API. We measure how
# fine-tuning changes accuracy versus the pre-trained-checkpoint result reported
# in NB03, and compare a domain-specific checkpoint (FinBERT) to two general
# checkpoints (DeBERTa-v3, ModernBERT) on the same PhraseBank split.
#
# ## Learning Objectives
# After completing this notebook, you will be able to:
# - Fine-tune a pre-trained Transformer for sequence classification
# - Use the Hugging Face Trainer API with custom metrics
# - Compare domain-specific vs general-purpose pre-trained models
# - Interpret training dynamics (loss curves, early stopping)
# - Evaluate models with accuracy, F1, and confusion matrices
#
# ## Cross-References
# - **Upstream**: `sentiment_evolution.py` (baseline methods), Chapter 5 (text data)
# - **Downstream**: Chapter 10 (text features in ML pipelines)
# - **Related**: `shap_nlp_interpretation.py` (model interpretability)
#
# ## Hardware Requirements
# - **GPU recommended**: ~5 min on GPU, 30+ min on CPU
# - **Memory**: ~4GB GPU memory for FinBERT
# %%
"""Transformer Fine-Tuning — fine-tune FinBERT, DeBERTa-v3, and ModernBERT for financial sentiment classification."""
import json
import time
import warnings
import evaluate
import matplotlib.pyplot as plt
import numpy as np
import polars as pl
import seaborn as sns
import torch
from datasets import Dataset, DatasetDict
from sklearn.metrics import confusion_matrix
from sklearn.model_selection import train_test_split
from transformers import (
AutoModelForSequenceClassification,
AutoTokenizer,
DataCollatorWithPadding,
EarlyStoppingCallback,
Trainer,
TrainingArguments,
)
from transformers import (
set_seed as set_transformers_seed,
)
from data import load_financial_phrasebank
from utils.paths import get_chapter_dir
from utils.reproducibility import set_global_seeds
warnings.filterwarnings("ignore")
# %% tags=["parameters"]
SEED = 42
MAX_TRAIN_STEPS = -1 # -1 = train full epochs
# %%
# Reproducibility — set_global_seeds covers Python random / NumPy / Torch.
# transformers Trainer uses its own RNG that needs explicit seeding.
set_global_seeds(SEED)
set_transformers_seed(SEED)
CONFIG = {
"random_seed": SEED,
"dataset": {
"name": "takala/financial_phrasebank",
"subset": "sentences_allagree",
"test_size": 0.15,
"val_size": 0.15,
},
"models": {
"finbert": {
"model_id": "ProsusAI/finbert",
"description": "FinBERT fine-tuned on Financial PhraseBank sentiment",
},
"deberta": {
"model_id": "microsoft/deberta-v3-small",
"description": "DeBERTa-v3 with disentangled attention",
},
"modernbert": {
"model_id": "answerdotai/ModernBERT-base",
"description": "ModernBERT with 8,192 token context length",
},
},
"training": {
"learning_rate": 2e-5,
"batch_size": 16,
"num_epochs": 3,
"weight_decay": 0.01,
"warmup_ratio": 0.1,
"max_length": 128,
"early_stopping_patience": 2,
},
}
print("=" * 70)
print("EXPERIMENT CONFIGURATION")
print("=" * 70)
print(json.dumps(CONFIG, indent=2))
print("=" * 70)
# Check for GPU
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f"\nUsing device: {device}")
# %% [markdown]
# ## 2. Load Financial PhraseBank Dataset
# %%
df = load_financial_phrasebank(agreement="100")
print(f"Dataset size: {len(df):,}")
# %%
# Prepare train/val/test splits (convert to pandas for sklearn, then back to Polars)
df_pd = df.to_pandas()
train_pd, temp_pd = train_test_split(
df_pd, test_size=0.3, random_state=SEED, stratify=df_pd["label"]
)
val_pd, test_pd = train_test_split(
temp_pd, test_size=0.5, random_state=SEED, stratify=temp_pd["label"]
)
train_df = pl.from_pandas(train_pd)
val_df = pl.from_pandas(val_pd)
test_df = pl.from_pandas(test_pd)
print(f"Train: {len(train_df)}, Val: {len(val_df)}, Test: {len(test_df)}")
# %% [markdown]
# ### Create HuggingFace DatasetDict
# Convert Polars DataFrames to HuggingFace datasets (requires pandas at the boundary).
# %%
def create_dataset_dict(train_df: pl.DataFrame, val_df: pl.DataFrame, test_df: pl.DataFrame):
return DatasetDict(
{
"train": Dataset.from_pandas(
train_df.select(["sentence", "label"]).to_pandas(), preserve_index=False
),
"validation": Dataset.from_pandas(
val_df.select(["sentence", "label"]).to_pandas(), preserve_index=False
),
"test": Dataset.from_pandas(
test_df.select(["sentence", "label"]).to_pandas(), preserve_index=False
),
}
)
dataset = create_dataset_dict(train_df, val_df, test_df)
# %% [markdown]
# ## 3. Model Configuration and Training Setup
# %%
# Model configurations
MODELS = {
"FinBERT": "ProsusAI/finbert",
"DeBERTa-v3": "microsoft/deberta-v3-small",
"ModernBERT": "answerdotai/ModernBERT-base",
}
# Label mappings
id2label = {0: "negative", 1: "neutral", 2: "positive"}
label2id = {"negative": 0, "neutral": 1, "positive": 2}
# %% [markdown]
# ### Tokenization
# Tokenize inputs without padding — `DataCollatorWithPadding` handles dynamic padding per batch.
# %%
def tokenize_function(examples, tokenizer, max_length=128):
return tokenizer(
examples["sentence"],
truncation=True,
max_length=max_length,
# Note: Don't pad here; use DataCollatorWithPadding for dynamic padding
)
# %% [markdown]
# ### Metrics Computation
# Custom metric function for the Trainer API — computes accuracy and macro F1.
# %%
accuracy_metric = evaluate.load("accuracy")
f1_metric = evaluate.load("f1")
def compute_metrics(eval_pred):
logits, labels = eval_pred
predictions = np.argmax(logits, axis=-1)
acc = accuracy_metric.compute(predictions=predictions, references=labels)
f1 = f1_metric.compute(predictions=predictions, references=labels, average="macro")
return {"accuracy": acc["accuracy"], "f1": f1["f1"]}
# %%
# Training function
def fine_tune_model(model_name: str, model_path: str, dataset: DatasetDict) -> dict:
"""Fine-tune a transformer model for sentiment classification."""
print(f"\n{'=' * 60}")
print(f"Fine-tuning: {model_name}")
print(f"Model: {model_path}")
print(f"{'=' * 60}")
# Load tokenizer and model
tokenizer = AutoTokenizer.from_pretrained(model_path)
# Handle models without pad token (proper approach for encoder-only models)
# Encoder models like BERT don't have eos_token; use [PAD] or add one
if tokenizer.pad_token is None:
if tokenizer.eos_token is not None:
tokenizer.pad_token = tokenizer.eos_token
else:
# For BERT-like models, add [PAD] token
tokenizer.add_special_tokens({"pad_token": "[PAD]"})
model = AutoModelForSequenceClassification.from_pretrained(
model_path,
num_labels=3,
id2label=id2label,
label2id=label2id,
ignore_mismatched_sizes=True,
)
# Resize embeddings if we added a new token
model.resize_token_embeddings(len(tokenizer))
# Handle pad token in model config
if model.config.pad_token_id is None:
model.config.pad_token_id = tokenizer.pad_token_id
# Create data collator for dynamic padding (more efficient than max_length padding)
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
# Tokenize dataset
tokenized = dataset.map(
lambda x: tokenize_function(x, tokenizer),
batched=True,
remove_columns=["sentence"],
)
# Training arguments - save checkpoints under chapter output directory
chapter_dir = get_chapter_dir(10)
output_dir = chapter_dir / "output" / "bert_finetuning" / model_name.lower().replace("-", "_")
output_dir.mkdir(parents=True, exist_ok=True)
# Use CONFIG values consistently
train_config = CONFIG["training"]
num_epochs = train_config["num_epochs"]
batch_size = train_config["batch_size"]
train_size = len(tokenized["train"])
max_steps = MAX_TRAIN_STEPS
# Build training arguments dict with version-compatible parameter names
# transformers 4.36+ uses eval_strategy, older versions use evaluation_strategy
eval_strat_key = "eval_strategy" # Default to newer API
save_strat_key = "save_strategy"
try:
# Test if the newer API is available
import inspect
sig = inspect.signature(TrainingArguments)
if "evaluation_strategy" in sig.parameters and "eval_strategy" not in sig.parameters:
eval_strat_key = "evaluation_strategy"
save_strat_key = "save_strategy"
except Exception:
pass # Use defaults
# Compute eval_steps and save_steps ensuring save_steps is a multiple of eval_steps
# (required by load_best_model_at_end)
if max_steps > 0:
eval_steps = max(10, max_steps // 5)
# save_steps must be a multiple of eval_steps
save_steps = eval_steps * 2 # evaluate twice, save once
else:
eval_steps = None
save_steps = None
training_kwargs = {
"output_dir": str(output_dir),
"num_train_epochs": num_epochs,
"max_steps": max_steps,
"per_device_train_batch_size": batch_size,
"per_device_eval_batch_size": batch_size * 2,
"warmup_steps": min(50, max(10, train_size // batch_size // 4)),
"weight_decay": train_config["weight_decay"],
"logging_steps": max(10, train_size // batch_size // 3),
eval_strat_key: "steps" if max_steps > 0 else "epoch",
"eval_steps": eval_steps,
save_strat_key: "steps" if max_steps > 0 else "epoch",
"save_steps": save_steps,
"load_best_model_at_end": True,
"metric_for_best_model": "f1",
"greater_is_better": True,
"report_to": "none", # Disable wandb/tensorboard
"fp16": torch.cuda.is_available(),
}
training_args = TrainingArguments(**training_kwargs)
# Create trainer with data collator for dynamic padding
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized["train"],
eval_dataset=tokenized["validation"],
data_collator=data_collator,
compute_metrics=compute_metrics,
callbacks=[EarlyStoppingCallback(early_stopping_patience=2)],
)
# Train
start_time = time.time()
train_result = trainer.train()
train_time = time.time() - start_time
# Evaluate on test set
test_results = trainer.evaluate(tokenized["test"])
# Get predictions for confusion matrix
predictions = trainer.predict(tokenized["test"])
y_pred = np.argmax(predictions.predictions, axis=-1)
y_true = predictions.label_ids
return {
"model_name": model_name,
"accuracy": test_results["eval_accuracy"],
"f1": test_results["eval_f1"],
"train_time": train_time,
"num_params": sum(p.numel() for p in model.parameters()),
"y_pred": y_pred,
"y_true": y_true,
"train_loss": train_result.training_loss,
}
# %% [markdown]
# ## 4. Fine-Tune Models
# %%
results = {name: fine_tune_model(name, path, dataset) for name, path in MODELS.items()}
# %% [markdown]
# ## 5. Results Comparison
# %%
# Summary table
summary_df = pl.DataFrame(
[
{
"Model": r["model_name"],
"Accuracy": f"{r['accuracy']:.1%}",
"F1 (macro)": f"{r['f1']:.3f}",
"Parameters": f"{r['num_params'] / 1e6:.1f}M",
"Train Time": f"{r['train_time']:.0f}s",
}
for r in results.values()
]
)
summary_df
# %%
# Visualization: Performance comparison
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# Accuracy/F1 comparison
ax = axes[0]
models = list(results.keys())
accuracies = [results[m]["accuracy"] for m in models]
f1_scores = [results[m]["f1"] for m in models]
x = np.arange(len(models))
width = 0.35
ax.bar(x - width / 2, accuracies, width, label="Accuracy", color="#0a1628")
ax.bar(x + width / 2, f1_scores, width, label="F1 (macro)", color="#D4A84B")
ax.set_ylabel("Score")
ax.set_title("Fine-Tuned Model Performance on Financial PhraseBank")
ax.set_xticks(x)
ax.set_xticklabels(models)
ax.legend()
ax.set_ylim(0, 1)
# Training time comparison
ax = axes[1]
times = [results[m]["train_time"] for m in models]
bars = ax.bar(models, times, color="#1a2d4a")
ax.set_ylabel("Training Time (seconds)")
ax.set_title("Training Time on RTX 3090")
for bar in bars:
height = bar.get_height()
ax.annotate(
f"{height:.0f}s",
xy=(bar.get_x() + bar.get_width() / 2, height),
xytext=(0, 3),
textcoords="offset points",
ha="center",
va="bottom",
)
plt.show()
# %%
# Confusion matrices for each model
n_models = len(results)
fig, axes = plt.subplots(1, n_models, figsize=(5 * n_models, 4))
fig.suptitle("Fine-Tuned Model Confusion Matrices", fontsize=12, y=1.02)
labels = ["negative", "neutral", "positive"]
for ax, (name, r) in zip(axes, results.items(), strict=False):
cm = confusion_matrix(r["y_true"], r["y_pred"])
sns.heatmap(
cm,
annot=True,
fmt="d",
cmap="Blues",
xticklabels=labels,
yticklabels=labels,
ax=ax,
)
ax.set_title(f"{name}\nAcc: {r['accuracy']:.1%}")
ax.set_xlabel("Predicted")
ax.set_ylabel("Actual")
plt.show()
# %% [markdown]
# ## Key Takeaways
#
# ### Measured results
# - FinBERT fine-tuned: accuracy 97.4%, F1 (macro) 0.958
# - DeBERTa-v3 fine-tuned: accuracy 95.3%, F1 (macro) 0.939
# - ModernBERT fine-tuned: accuracy 95.9%, F1 (macro) 0.953
# - FinBERT pre-trained vs fine-tuned on this PhraseBank subset: 93.2% (NB03) → 97.4%
# - Training time per model on RTX 3090: 1317s at batch size 16
#
# ### Fine-tuning effect on FinBERT
# Fine-tuning on the ~1,600-sentence training split changes FinBERT's accuracy on
# the PhraseBank test set from 93.2% (the pre-trained FinBERT-tone checkpoint
# measured in NB03 on the same `sentences_allagree` split) to 97.4%. The
# pre-trained checkpoint already performs well — PhraseBank is in-domain for it —
# and the fine-tuning step recovers most of the remaining headroom.
#
# ### Patterns in the run
# - FinBERT has both the highest accuracy (97.4%) and the highest macro F1
# (0.958). Differences between the three fine-tuned models are within a few
# percentage points on this PhraseBank test set.
# - DeBERTa-v3 finishes at 95.3% accuracy / 0.939 F1; its disentangled-attention
# design targets longer contexts that this single-sentence task does not
# exercise.
# - All three models converge within 2-3 epochs under the early-stopping rule.
#
# ### Scope of these numbers
# - These results are for a stratified random split of Financial PhraseBank
# ("sentences_allagree") with seed 42. PhraseBank is sentence-level financial
# news; the notebook does not measure cross-domain transfer to other text
# sources (compare NB06 for the cross-dataset evaluation on FinMarBa) nor
# downstream alpha (NB07, NB08, NB09).
# - The Hugging Face Trainer API used here handles the training loop, evaluation
# schedule, and checkpointing for all three models.
# %%
# Structured per-model summary for chapter cross-reference
for r in results.values():
print(f"\n{r['model_name']}:")
print(f" Accuracy: {r['accuracy']:.1%}")
print(f" F1 (macro): {r['f1']:.3f}")
print(f" Parameters: {r['num_params'] / 1e6:.1f}M")
print(f" Train time: {r['train_time']:.0f}s")