BERT Sentiment Model
Fine-tune a BERT transformer language model for cryptocurrency-domain-specific sentiment classification, training on a labeled corpus of crypto news headlines and social media posts to accurately capture domain-specific jargon, sarcasm, and market sentiment nuances.
BERT Sentiment Model — Sentiment & NLP
Category: Sentiment & NLP | Subcategory: NLP
What This Notebook Does
VADER and TextBlob are rule-based models trained on general text. BERT (Bidirectional Encoder Representations from Transformers) is a deep learning model that understands context — the word "down" in "Bitcoin is down" vs "markets went down then recovered" carries different sentiment signals, and BERT captures this distinction.
This notebook:
- Explains how BERT-based sentiment models work at a conceptual level
- Loads a pre-trained BERT sentiment classifier from HuggingFace Hub (no training required)
- Builds a batch inference pipeline for classifying crypto text at scale
- Compares BERT outputs against VADER on the same crypto texts
- Demonstrates zero-shot classification as an alternative when no labeled data exists
- Benchmarks inference speed and discusses GPU vs CPU trade-offs
BERT vs VADER — When to Use Which?
| VADER | BERT | |
|---|---|---|
| Speed | Very fast (~50k texts/sec) | Slower (~100-500 texts/sec on GPU) |
| Accuracy on crypto | Moderate | Higher (context-aware) |
| Needs GPU | No | Recommended |
| Handles slang/emojis | Yes | Partial (depends on tokenizer) |
| Best for | Real-time feeds | Batch analysis, research |
Colab Tip: Go to Runtime → Change runtime type → GPU before running this notebook. Inference is 10-20x faster on GPU.
Section 1 — Install & Import
We use HuggingFace transformers and datasets. The pipeline abstraction in transformers handles tokenization, model forward pass, and output decoding in a single call — ideal for getting started without deep PyTorch knowledge.
!pip install transformers torch datasets vaderSentiment pandas matplotlib seaborn tqdm --quietimport torch
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from transformers import pipeline, AutoTokenizer, AutoModelForSequenceClassification
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
from tqdm.auto import tqdm
import time
import re
%matplotlib inline
plt.rcParams['figure.figsize'] = (12, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
DEVICE = 0 if torch.cuda.is_available() else -1
DEVICE_NAME = 'GPU' if DEVICE == 0 else 'CPU'
print(f'Running on: {DEVICE_NAME}')
if DEVICE == 0:
print(f'GPU: {torch.cuda.get_device_name(0)}')Running on: CPU
Section 2 — How BERT Sentiment Works
BERT is a transformer encoder pre-trained on billions of words. It builds a deep contextual representation of each token. For sentiment classification, a lightweight classification head (a linear layer) is added on top and trained on labeled sentiment data.
Text: "Bitcoin just crashed — selling everything"
↓
Tokenizer: [CLS] bitcoin just crashed — selling everything [SEP]
↓
BERT Encoder (12 layers of attention)
↓
[CLS] token embedding (captures whole-sentence meaning)
↓
Linear head → [Negative: 0.92, Neutral: 0.05, Positive: 0.03]
↓
Label: NEGATIVE (confidence: 92%)
Choosing a Model
HuggingFace Hub has hundreds of sentiment models. For crypto text we evaluate:
cardiffnlp/twitter-roberta-base-sentiment-latest— trained on tweets, good for short informal textdistilbert-base-uncased-finetuned-sst-2-english— fast, general purposeProsusAI/finbert— financial domain (covered in notebook 117)
We use twitter-roberta as the primary model since crypto social text most closely resembles Twitter language.
# ─── MODEL CONFIGURATION ────────────────────────────────────────────────────
PRIMARY_MODEL = 'cardiffnlp/twitter-roberta-base-sentiment-latest'
FAST_MODEL = 'distilbert-base-uncased-finetuned-sst-2-english'
BATCH_SIZE = 32 # Reduce to 16 if you hit CUDA OOM errors
MAX_TOKEN_LEN = 128 # Max tokens per text (truncate longer texts)
# ────────────────────────────────────────────────────────────────────────────
def load_sentiment_pipeline(model_name: str, device: int = -1) -> pipeline:
"""
Load a HuggingFace text-classification pipeline.
The pipeline handles tokenization and inference in one call.
Downloads model weights on first use (~500MB for RoBERTa).
Parameters
----------
model_name : HuggingFace model identifier
device : 0 for GPU, -1 for CPU
Returns
-------
transformers.Pipeline object
"""
print(f'Loading model: {model_name} on {"GPU" if device == 0 else "CPU"}...')
t0 = time.time()
nlp = pipeline(
task='text-classification',
model=model_name,
device=device,
truncation=True,
max_length=MAX_TOKEN_LEN,
return_all_scores=True
)
print(f'Model loaded in {time.time() - t0:.1f}s')
return nlp
bert_pipeline = load_sentiment_pipeline(PRIMARY_MODEL, DEVICE)Loading model: cardiffnlp/twitter-roberta-base-sentiment-latest on CPU...
Loading weights: 0%| | 0/201 [00:00<?, ?it/s]
[transformers] [1mRobertaForSequenceClassification LOAD REPORT[0m from: cardiffnlp/twitter-roberta-base-sentiment-latest Key | Status | | ----------------------------+------------+--+- roberta.pooler.dense.bias | UNEXPECTED | | roberta.pooler.dense.weight | UNEXPECTED | | Notes: - UNEXPECTED: can be ignored when loading from different task/architecture; not ok if you expect identical arch.
Model loaded in 1.6s
Section 3 — Sample Crypto Dataset
We build a representative test corpus of crypto texts covering a range of sentiment intensities. In production, replace this with scraped Reddit/Telegram/YouTube data from the earlier notebooks.
SAMPLE_TEXTS = [
# Strongly positive
'Bitcoin just broke ATH! This bull run is just getting started, we are going to $200k!',
'Ethereum 2.0 staking yields are incredible. Best passive income in crypto right now.',
'Just bought more BTC on this dip. Thank you bears for the discount 🙏',
'Institutional adoption is accelerating. BlackRock ETF approval is massive for the space.',
'DeFi TVL hit a new record. This ecosystem is unstoppable.',
# Moderately positive
'Crypto markets are looking stable today, slight upward trend across the board.',
'Bitcoin dominance rising — historically a good sign before altseason.',
'Layer 2 solutions are finally delivering on their promises. Fees are much lower.',
# Neutral / informational
'Bitcoin volume was below average today at 24 billion USD.',
'The Federal Reserve meets next week. Traders watching for rate decision signals.',
'Ethereum gas fees averaged 15 gwei over the past 24 hours.',
'BTC is currently trading at $65,400, down 0.3% from yesterday.',
# Moderately negative
'Crypto markets pulled back today after regulatory news from the SEC.',
'Bitcoin struggling to hold support at $60k. Bears testing the level again.',
'The altcoin market looks weak. Most coins are bleeding vs BTC.',
# Strongly negative
'CRASH! BTC down 15% in one hour. Exchange liquidations everywhere. This is a disaster.',
'Another exchange hack. $300M stolen. This is why I dont trust centralized platforms.',
'The crypto market is dead. Retail is gone. Only whales manipulating price now.',
'SEC is destroying crypto innovation. These regulations will kill the entire industry.',
'Absolute bloodbath today. Lost 40% of my portfolio. I am done with crypto forever.',
# Ambiguous / sarcastic
'Oh great, another 10% down day. Totally unexpected 🙄',
'Sure, just buy the dip they said. Great advice.',
'Bitcoin is a hedge against inflation... at -60% YTD 😂'
]
sample_df = pd.DataFrame({'text': SAMPLE_TEXTS})
print(f'Sample corpus: {len(sample_df)} texts')
sample_df.head(5)Sample corpus: 23 texts
| text | |
|---|---|
| 0 | Bitcoin just broke ATH! This bull run is just ... |
| 1 | Ethereum 2.0 staking yields are incredible. Be... |
| 2 | Just bought more BTC on this dip. Thank you be... |
| 3 | Institutional adoption is accelerating. BlackR... |
| 4 | DeFi TVL hit a new record. This ecosystem is u... |
Section 4 — Batch Inference Pipeline
Running inference text-by-text is slow. Batching sends multiple texts to the model in one forward pass, leveraging GPU parallelism. The batch_size parameter controls how many texts are processed simultaneously — larger batches are faster but use more memory.
The twitter-roberta model outputs three labels: positive, neutral, negative with a confidence score for each. We extract all three scores.
def preprocess_for_bert(text: str, max_chars: int = 512) -> str:
"""
Minimal preprocessing before BERT tokenization.
BERT handles raw text well — we only remove URLs and excessive whitespace.
Preserving punctuation and capitalization is important for context.
Parameters
----------
text : Raw text string
max_chars: Truncate to this many characters before tokenization
Returns
-------
Lightly cleaned text string
"""
if not isinstance(text, str):
return ''
text = re.sub(r'http\S+|www\.\S+', '', text)
text = re.sub(r'\s+', ' ', text).strip()
return text[:max_chars]
def run_bert_batch_inference(
texts: list[str],
nlp_pipeline,
batch_size: int = 32
) -> pd.DataFrame:
"""
Run batch sentiment inference using a HuggingFace pipeline.
Returns a DataFrame with one row per input text and columns
for each label's confidence score plus the winning label.
Parameters
----------
texts : List of text strings to classify
nlp_pipeline : Loaded HuggingFace pipeline
batch_size : Number of texts per inference batch
Returns
-------
DataFrame with columns per label score + bert_label + bert_confidence
"""
cleaned = [preprocess_for_bert(t) for t in texts]
t0 = time.time()
all_results = []
for i in tqdm(range(0, len(cleaned), batch_size), desc='BERT inference'):
batch = cleaned[i : i + batch_size]
outputs = nlp_pipeline(batch, batch_size=len(batch))
# The pipeline returns List[List[Dict]] when return_all_scores=True and batching.
# We need to append each inner list (scores for one text) individually.
for text_output in outputs:
all_results.append(text_output)
elapsed = time.time() - t0
print(f'Processed {len(texts)} texts in {elapsed:.2f}s ({len(texts)/elapsed:.0f} texts/sec)')
rows = []
for result in all_results:
# result is now correctly List[Dict], e.g., [{'label': 'LABEL_0', 'score': 0.9}, {'label': 'LABEL_1', 'score': 0.1}]
row = {item['label'].lower().replace('label_', ''): item['score'] for item in result}
top = max(result, key=lambda x: x['score'])
row['bert_label'] = top['label'].lower().replace('label_', '')
row['bert_confidence'] = top['score']
rows.append(row)
return pd.DataFrame(rows)Section 5 — BERT vs VADER Comparison
A key question in building a crypto sentiment pipeline: do BERT and VADER agree? Where they disagree on high-confidence predictions, the discrepancy is analytically interesting — often BERT catches sarcasm or nuanced context that VADER misses.
import pandas as pd
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
from tqdm.auto import tqdm
import time
import re
vader = SentimentIntensityAnalyzer()
def preprocess_for_bert(text: str, max_chars: int = 512) -> str:
if not isinstance(text, str):
return ''
text = re.sub(r'http\S+|www\.\S+', '', text)
text = re.sub(r'\s+', ' ', text).strip()
return text[:max_chars]
def run_bert_batch_inference(
texts: list[str],
nlp_pipeline,
batch_size: int = 32
) -> pd.DataFrame:
cleaned = [preprocess_for_bert(t) for t in texts]
all_results = []
t0 = time.time()
for i in tqdm(range(0, len(cleaned), batch_size), desc='BERT inference'):
batch = cleaned[i : i + batch_size]
outputs = nlp_pipeline(batch, batch_size=len(batch))
if not outputs: continue
# Standardize output to List[List[Dict]]
if isinstance(outputs[0], dict):
# Case: Pipeline returned List[Dict] (one dict per text)
for item in outputs:
all_results.append([item])
elif isinstance(outputs[0], list):
# Case: Pipeline returned List[List[Dict]] (multiple scores per text)
all_results.extend(outputs)
elapsed = time.time() - t0
print(f'Processed {len(texts)} texts in {elapsed:.2f}s')
rows = []
for result in all_results:
# Extract all available scores in the list
row = {item['label'].lower().replace('label_', ''): item['score'] for item in result}
# Determine top label
top = max(result, key=lambda x: x['score'])
row['bert_label'] = top['label'].lower().replace('label_', '')
row['bert_confidence'] = top['score']
rows.append(row)
return pd.DataFrame(rows)
def add_vader_comparison(df: pd.DataFrame, text_col: str = 'text') -> pd.DataFrame:
df = df.copy()
df['vader_compound'] = df[text_col].apply(lambda t: vader.polarity_scores(str(t))['compound'])
df['vader_label'] = df['vader_compound'].apply(
lambda c: 'positive' if c >= 0.05 else ('negative' if c <= -0.05 else 'neutral')
)
df['models_agree'] = df['bert_label'] == df['vader_label']
print(f"BERT vs VADER agreement: {df['models_agree'].mean() * 100:.1f}%")
return df
# Execution
bert_scores = run_bert_batch_inference(sample_df['text'].tolist(), bert_pipeline, BATCH_SIZE)
result_df = pd.concat([sample_df.reset_index(drop=True), bert_scores.reset_index(drop=True)], axis=1)
compared_df = add_vader_comparison(result_df)
print(f'\nDisagreements ({len(compared_df[~compared_df["models_agree"]])} cases):')
compared_df[~compared_df['models_agree']][['text', 'bert_label', 'bert_confidence', 'vader_label', 'vader_compound']].head(10)BERT inference: 0%| | 0/1 [00:00<?, ?it/s]
Processed 23 texts in 6.35s BERT vs VADER agreement: 73.9% Disagreements (6 cases):
| text | bert_label | bert_confidence | vader_label | vader_compound | |
|---|---|---|---|---|---|
| 0 | Bitcoin just broke ATH! This bull run is just ... | positive | 0.970161 | negative | -0.5242 |
| 4 | DeFi TVL hit a new record. This ecosystem is u... | positive | 0.975244 | negative | -0.2023 |
| 12 | Crypto markets pulled back today after regulat... | negative | 0.541046 | neutral | 0.0000 |
| 13 | Bitcoin struggling to hold support at $60k. Be... | negative | 0.536603 | neutral | -0.0258 |
| 20 | Oh great, another 10% down day. Totally unexpe... | negative | 0.842874 | positive | 0.6249 |
| 22 | Bitcoin is a hedge against inflation... at -60... | neutral | 0.567989 | positive | 0.4404 |
def plot_bert_vader_comparison(df: pd.DataFrame) -> None:
"""
Scatter plot comparing BERT confidence vs VADER compound score.
Points are colored by BERT label.
Parameters
----------
df : DataFrame with both BERT and VADER scores
"""
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Left: Scatter — VADER compound vs BERT (positive conf - negative conf)
bert_score = df.get('positive', pd.Series(0, index=df.index)) - df.get('negative', pd.Series(0, index=df.index))
colors = df['bert_label'].map({'positive': '#2ecc71', 'neutral': '#95a5a6', 'negative': '#e74c3c'})
axes[0].scatter(df['vader_compound'], bert_score, c=colors, s=80, alpha=0.8, edgecolors='white')
axes[0].axhline(0, color='gray', linestyle='--', linewidth=0.8)
axes[0].axvline(0, color='gray', linestyle='--', linewidth=0.8)
axes[0].set_xlabel('VADER Compound Score')
axes[0].set_ylabel('BERT Score (pos − neg confidence)')
axes[0].set_title('VADER vs BERT Agreement\n(points far from diagonal = disagreements)', fontsize=11, fontweight='bold')
# Right: Confusion matrix style label comparison
cross = pd.crosstab(df['vader_label'], df['bert_label'],
rownames=['VADER'], colnames=['BERT'])
sns.heatmap(cross, annot=True, fmt='d', cmap='Blues', ax=axes[1],
linewidths=0.5, cbar=False)
axes[1].set_title('Label Agreement Heatmap', fontsize=11, fontweight='bold')
plt.suptitle('BERT vs VADER Sentiment Comparison', fontsize=13)
plt.tight_layout()
plt.show()
plot_bert_vader_comparison(compared_df)Section 6 — Zero-Shot Classification
What if you want custom sentiment categories beyond positive/neutral/negative? For example: bullish, bearish, FUD, hype, regulatory_risk.
Zero-shot classification uses a Natural Language Inference (NLI) model to score any text against any label you define — without any training data. Under the hood it frames the task as: does this text entail the hypothesis "this text is about [label]"?
def run_zero_shot_classification(
texts: list[str],
candidate_labels: list[str],
model_name: str = 'facebook/bart-large-mnli',
device: int = -1
) -> pd.DataFrame:
"""
Classify texts against custom labels using zero-shot NLI.
No labeled training data is needed — labels are provided as strings.
Returns a score for each label for each input text.
Parameters
----------
texts : List of text strings to classify
candidate_labels : List of category strings (e.g., ['bullish', 'bearish', 'FUD'])
model_name : Zero-shot NLI model from HuggingFace
device : 0 for GPU, -1 for CPU
Returns
-------
DataFrame with one row per text and columns for each label's score
"""
zsc = pipeline('zero-shot-classification', model=model_name, device=device)
rows = []
for text in tqdm(texts, desc='Zero-shot classification'):
result = zsc(text[:400], candidate_labels=candidate_labels)
row = dict(zip(result['labels'], result['scores']))
row['top_label'] = result['labels'][0]
rows.append(row)
return pd.DataFrame(rows)
CRYPTO_LABELS = ['bullish price prediction', 'bearish price prediction',
'regulatory risk', 'technical analysis', 'market news']
# Use a small subset to save quota
zsc_results = run_zero_shot_classification(
SAMPLE_TEXTS[:10], CRYPTO_LABELS, device=DEVICE
)
pd.concat([pd.Series(SAMPLE_TEXTS[:10], name='text'), zsc_results], axis=1)config.json: 0%| | 0.00/1.15k [00:00<?, ?B/s]
model.safetensors: 0%| | 0.00/1.63G [00:00<?, ?B/s]
Loading weights: 0%| | 0/515 [00:00<?, ?it/s]
tokenizer_config.json: 0%| | 0.00/26.0 [00:00<?, ?B/s]
vocab.json: 0%| | 0.00/899k [00:00<?, ?B/s]
merges.txt: 0%| | 0.00/456k [00:00<?, ?B/s]
tokenizer.json: 0%| | 0.00/1.36M [00:00<?, ?B/s]
Zero-shot classification: 0%| | 0/10 [00:00<?, ?it/s]
| text | bullish price prediction | market news | technical analysis | bearish price prediction | regulatory risk | top_label | |
|---|---|---|---|---|---|---|---|
| 0 | Bitcoin just broke ATH! This bull run is just ... | 0.715904 | 0.263167 | 0.017892 | 0.001679 | 0.001359 | bullish price prediction |
| 1 | Ethereum 2.0 staking yields are incredible. Be... | 0.106884 | 0.720681 | 0.088652 | 0.045794 | 0.037988 | market news |
| 2 | Just bought more BTC on this dip. Thank you be... | 0.099579 | 0.199806 | 0.065889 | 0.613201 | 0.021523 | bearish price prediction |
| 3 | Institutional adoption is accelerating. BlackR... | 0.070982 | 0.868012 | 0.027476 | 0.018551 | 0.014979 | market news |
| 4 | DeFi TVL hit a new record. This ecosystem is u... | 0.176623 | 0.693450 | 0.093350 | 0.023418 | 0.013159 | market news |
| 5 | Crypto markets are looking stable today, sligh... | 0.348446 | 0.609750 | 0.034985 | 0.003319 | 0.003500 | market news |
| 6 | Bitcoin dominance rising — historically a good... | 0.703514 | 0.274194 | 0.019441 | 0.001697 | 0.001154 | bullish price prediction |
| 7 | Layer 2 solutions are finally delivering on th... | 0.089153 | 0.772178 | 0.052937 | 0.041821 | 0.043912 | market news |
| 8 | Bitcoin volume was below average today at 24 b... | 0.029864 | 0.808163 | 0.075543 | 0.038590 | 0.047839 | market news |
| 9 | The Federal Reserve meets next week. Traders w... | 0.067819 | 0.779573 | 0.046740 | 0.061030 | 0.044838 | market news |
Section 7 — Inference Speed Benchmark
When deciding between models in production, inference speed matters. This section benchmarks the two models on 100 identical texts.
def benchmark_models(
texts: list[str],
models: dict,
device: int = -1
) -> pd.DataFrame:
"""
Benchmark inference speed of multiple HuggingFace sentiment models.
Parameters
----------
texts : List of text strings to use as benchmark input
models : Dictionary of {model_name: model_display_name}
device : 0 for GPU, -1 for CPU
Returns
-------
DataFrame with timing results per model
"""
results = []
for model_id, display_name in models.items():
print(f'Benchmarking: {display_name}...')
nlp = load_sentiment_pipeline(model_id, device)
t0 = time.time()
_ = nlp(texts, batch_size=32)
elapsed = time.time() - t0
results.append({
'model': display_name,
'texts_processed': len(texts),
'total_seconds': round(elapsed, 2),
'texts_per_sec': round(len(texts) / elapsed)
})
del nlp
if torch.cuda.is_available():
torch.cuda.empty_cache()
return pd.DataFrame(results)
MODELS_TO_BENCHMARK = {
'distilbert-base-uncased-finetuned-sst-2-english': 'DistilBERT (fast)',
'cardiffnlp/twitter-roberta-base-sentiment-latest': 'Twitter-RoBERTa'
}
benchmark_texts = (SAMPLE_TEXTS * 5)[:100]
bench_df = benchmark_models(benchmark_texts, MODELS_TO_BENCHMARK, DEVICE)
bench_dfBenchmarking: DistilBERT (fast)... Loading model: distilbert-base-uncased-finetuned-sst-2-english on CPU...
config.json: 0%| | 0.00/629 [00:00<?, ?B/s]
model.safetensors: 0%| | 0.00/268M [00:00<?, ?B/s]
Loading weights: 0%| | 0/104 [00:00<?, ?it/s]
tokenizer_config.json: 0%| | 0.00/48.0 [00:00<?, ?B/s]
vocab.txt: 0%| | 0.00/232k [00:00<?, ?B/s]
Model loaded in 7.9s Benchmarking: Twitter-RoBERTa... Loading model: cardiffnlp/twitter-roberta-base-sentiment-latest on CPU...
Loading weights: 0%| | 0/201 [00:00<?, ?it/s]
[transformers] [1mRobertaForSequenceClassification LOAD REPORT[0m from: cardiffnlp/twitter-roberta-base-sentiment-latest Key | Status | | ----------------------------+------------+--+- roberta.pooler.dense.bias | UNEXPECTED | | roberta.pooler.dense.weight | UNEXPECTED | | Notes: - UNEXPECTED: can be ignored when loading from different task/architecture; not ok if you expect identical arch.
Model loaded in 0.7s
| model | texts_processed | total_seconds | texts_per_sec | |
|---|---|---|---|---|
| 0 | DistilBERT (fast) | 100 | 5.18 | 19 |
| 1 | Twitter-RoBERTa | 100 | 7.06 | 14 |
Section 8 — Export & Summary
compared_df.to_csv('bert_sentiment_results.csv', index=False)
print(f'Results saved: bert_sentiment_results.csv ({len(compared_df)} rows)')
print(f'\nColumns: {list(compared_df.columns)}')Results saved: bert_sentiment_results.csv (23 rows) Columns: ['text', 'positive', 'bert_label', 'bert_confidence', 'neutral', 'negative', 'vader_compound', 'vader_label', 'models_agree']
Section 9 — Summary & Next Steps
Key Takeaways
- BERT is context-aware — it catches sarcasm and negation better than VADER
- Batch inference dramatically improves throughput — always batch on GPU
- Model choice matters — Twitter-RoBERTa outperforms general models on social text
- Zero-shot enables custom category classification without labeled data