# --- # 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: 13–17s 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")