# --- # 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.