Sentiment & NLP·NLP Models·Intermediate

Topic Modeling Crypto News

Apply topic modeling techniques including Latent Dirichlet Allocation and BERTopic to cryptocurrency news article corpora to algorithmically identify emerging market themes, narrative shifts, and the temporal evolution of dominant market discourse topics.

machine-learningnlpsentiment-analysis

Topic Modeling on Crypto News — Sentiment & NLP

Category: Sentiment & NLP | Subcategory: NLP


What This Notebook Does

Sentiment analysis tells you how people feel. Topic modeling goes deeper — it answers what people are talking about and how discussion shifts over time. When crypto media floods with words like SEC, lawsuit, and compliance, topic modeling detects that regulatory risk is dominating discourse — often before price reacts.

This notebook:

  1. Collects crypto news from RSS feeds (CoinTelegraph, CoinDesk, Decrypt)
  2. Preprocesses text: tokenizes, removes stopwords, lemmatizes
  3. Trains LDA (Latent Dirichlet Allocation) — the foundational probabilistic topic model
  4. Evaluates model quality using coherence scoring
  5. Visualizes topics interactively with pyLDAvis
  6. Applies BERTopic — neural topic modeling using sentence embeddings
  7. Generates trading signals from topic prevalence shifts
  8. Exports labeled results for downstream strategy notebooks

The Core Idea: What Is a Topic Model?

Imagine 10,000 crypto articles. Some discuss exchange hacks, others cover Fed rate policy, others analyze Ethereum upgrades. Topic modeling automatically discovers these themes with no manual labeling:

Article: "Binance freezes withdrawals after suspicious wallet activity"
         ↓  LDA
Topic 0 (Exchange Risk):   81%
Topic 3 (Regulation):      11%
Topic 7 (Market Mood):      8%

Each document is a mixture of topics; each topic is a distribution over words. LDA learns both simultaneously from raw text.

Two Approaches Covered

ModelMethodBest For
LDAWord co-occurrence (probabilistic)Interpretable topics, fast CPU training
BERTopicSentence embeddings + clusteringSemantic precision, no predefined topic count

Prerequisites

  • Basic Python (functions, loops, dicts)
  • Pandas DataFrames
  • (Optional) BERT/transformers for Section 6

Colab Tip: Go to Runtime → Change runtime type → GPU before running Section 6.

Section 1 — Install & Import Dependencies

We install two topic modeling libraries:

  • gensim — battle-tested LDA, fast on CPU, highly interpretable
  • bertopic — modern neural approach; uses sentence-transformers for embeddings, UMAP for dimensionality reduction, and HDBSCAN for clustering

pyLDAvis renders an interactive intertopic distance map directly in Colab.

[12]
!pip install gensim pyLDAvis feedparser bertopic sentence-transformers umap-learn hdbscan nltk requests tqdm --quiet
!python -m nltk.downloader stopwords wordnet punkt -q
<frozen runpy>:128: RuntimeWarning: 'nltk.downloader' found in sys.modules after import of package 'nltk', but prior to execution of 'nltk.downloader'; this may result in unpredictable behaviour
[13]
import gensim
import gensim.corpora as corpora
from gensim.models import LdaMulticore
from gensim.models.coherencemodel import CoherenceModel
import pyLDAvis
import pyLDAvis.gensim_models as gensimvis
import feedparser
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import requests
import re
import nltk
from nltk.corpus import stopwords
from nltk.stem import WordNetLemmatizer
from datetime import datetime, timezone
from dateutil import parser as dateparser
from tqdm.auto import tqdm
import warnings
import logging

warnings.filterwarnings('ignore')
logging.basicConfig(format='%(levelname)s : %(message)s', level=logging.WARNING)
%matplotlib inline
plt.rcParams['figure.figsize'] = (13, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
sns.set_palette('husl')

LEMMATIZER = WordNetLemmatizer()
STOP_WORDS = set(stopwords.words('english'))
CRYPTO_STOPWORDS = {
    'crypto', 'cryptocurrency', 'market', 'price', 'trading',
    'blockchain', 'token', 'coin', 'exchange', 'said', 'says',
    'also', 'would', 'could', 'new', 'one', 'week', 'day'
}
STOP_WORDS |= CRYPTO_STOPWORDS

print('All imports successful.')
All imports successful.

Section 2 — Configuration & Data Collection

We pull headlines from free RSS feeds. No API key required for basic collection.

Optional: Add a free CryptoPanic API key at cryptopanic.com to get structured metadata (bullish/bearish flags, vote counts) on top of raw headlines.

[14]
import pandas as pd
# ── CONFIGURATION ─────────────────────────────────────────────────────────────
NEWS_API_KEY = 'e7bbbd6a37a74ed7bdcd783dbfc7c67f'   # Replace with your NewsAPI key.
N_TOPICS = 10                               # number of LDA topics to discover
RANDOM_SEED = 42

RSS_FEEDS = [
    'https://cointelegraph.com/rss',
    'https://coindesk.com/arc/outboundfeeds/rss/',
    'https://decrypt.co/feed',
    'https://bitcoinmagazine.com/.rss/full/',
]
# ─────────────────────────────────────────────────────────────────────────────


def fetch_rss_news(feed_urls: list, max_per_feed: int = 100) -> pd.DataFrame:
    """
    Fetch news articles from a list of RSS feed URLs.

    Parameters
    ----------
    feed_urls : list of str
        RSS feed URLs to fetch articles from.
    max_per_feed : int
        Maximum number of articles to retrieve per feed.

    Returns
    -------
    pd.DataFrame
        DataFrame with columns: title, summary, published, source, text.
        'text' is the concatenation of title and summary for modeling.
    """
    records = []
    for url in feed_urls:
        try:
            feed = feedparser.parse(url)
            source_name = feed.feed.get('title', url)
            for entry in feed.entries[:max_per_feed]:
                published = None
                if hasattr(entry, 'published'):
                    try:
                        published = dateparser.parse(entry.published).replace(tzinfo=timezone.utc)
                    except Exception:
                        pass
                records.append({
                    'title':   entry.get('title', ''),
                    'summary': entry.get('summary', ''),
                    'published': published,
                    'source':  source_name,
                })
        except Exception as e:
            print(f'Feed failed ({url[:40]}): {e}')

    df = pd.DataFrame(records).dropna(subset=['title'])
    df['text'] = df['title'] + ' ' + df['summary'].fillna('')
    print(f'Fetched {len(df)} articles from {len(feed_urls)} RSS feeds.')
    return df


def fetch_news_api(api_key: str, pages: int = 5) -> pd.DataFrame:
    """
    Fetch structured crypto news from NewsAPI.

    Parameters
    ----------
    api_key : str
        API key for NewsAPI.
    pages : int
        Number of pages/results to retrieve (max 100 articles per page).

    Returns
    -------
    pd.DataFrame
        DataFrame with columns: title, summary, published, source, text.
        Returns an empty DataFrame if no API key is provided or fetching fails.
    """
    if not api_key or api_key == 'YOUR_API_KEY':
        print("No NewsAPI key provided. Skipping fetching from NewsAPI.")
        return pd.DataFrame(columns=['title', 'summary', 'published', 'source', 'text'])

    base_url = 'https://newsapi.org/v2/everything'
    records = []
    for page in range(1, pages + 1):
        params = {'apiKey': api_key, 'q': 'cryptocurrency', 'language': 'en', 'pageSize': 100, 'page': page}
        try:
            resp = requests.get(base_url, params=params, timeout=10)
            resp.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
            for article in resp.json().get('articles', []):
                published = None
                if article.get('publishedAt'):
                    try:
                        published = dateparser.parse(article.get('publishedAt')).replace(tzinfo=timezone.utc)
                    except Exception:
                        pass
                records.append({
                    'title':   article.get('title', ''),
                    'summary': article.get('description', ''),
                    'published': published,
                    'source':  article.get('source', {}).get('name', 'NewsAPI'),
                    'text':    article.get('title', '') + ' ' + (article.get('description', '') or ''),
                })
        except requests.exceptions.RequestException as e:
            print(f'NewsAPI page {page} failed: {e}')
            break
        except Exception as e:
            print(f'Error processing NewsAPI response for page {page}: {e}')
            break

    df = pd.DataFrame(records).dropna(subset=['title'])
    print(f'Fetched {len(df)} articles from NewsAPI.')
    return df


def combine_news_sources(*dfs: pd.DataFrame) -> pd.DataFrame:
    """
    Merge multiple news DataFrames and deduplicate by title.

    Parameters
    ----------
    *dfs : pd.DataFrame
        Any number of news DataFrames sharing the same column schema.

    Returns
    -------
    pd.DataFrame
        Combined, deduplicated DataFrame sorted by published date descending.
    """
    combined = pd.concat(dfs, ignore_index=True)
    combined['_key'] = combined['title'].str.lower().str.strip()
    combined = combined.drop_duplicates(subset='_key').drop(columns='_key')
    combined = combined.sort_values('published', ascending=False).reset_index(drop=True)
    print(f'Combined dataset: {len(combined)} unique articles.')
    return combined


# ── Run collection ─────────────────────────────────────────────────────────────
news_df = fetch_rss_news(RSS_FEEDS, max_per_feed=100)

alt_df = fetch_news_api(NEWS_API_KEY, pages=1)
news_df = combine_news_sources(news_df, alt_df)

print(news_df[['title', 'published', 'source']].head())
Fetched 102 articles from 4 RSS feeds.
Fetched 99 articles from NewsAPI.
Combined dataset: 201 unique articles.
                                               title  \
0  Grayscale applies traditional finance models t...   
1  The bond market is flashing a clear signal on ...   
2  Ryan Salame’s wife to face charges over FTX-fu...   
3  Florida man pleads guilty for promoting $1.8B ...   
4  Live markets: Bitcoin, ether ETFs lose $111 mi...   

                  published                                             source  
0 2026-06-18 09:45:51+00:00                             Cointelegraph.com News  
1 2026-06-18 07:08:27+00:00  CoinDesk: Bitcoin, Ethereum, Crypto News and P...  
2 2026-06-18 06:17:01+00:00                             Cointelegraph.com News  
3 2026-06-18 06:06:20+00:00                             Cointelegraph.com News  
4 2026-06-18 06:02:19+00:00  CoinDesk: Bitcoin, Ethereum, Crypto News and P...  

Section 3 — Text Preprocessing

Raw news text contains HTML tags, URLs, numbers, and punctuation that add noise without adding topic signal. We apply a three-stage pipeline:

Raw text  →  [Clean]  →  [Tokenize]  →  [Lemmatize + Filter]  →  Token list

Lemmatization collapses word forms: running, runs, ran → all become run. This prevents LDA from treating them as different features.

Vocabulary filtering (via no_below / no_above) removes extremely rare words (noise) and extremely common words (uninformative) before modeling.

[15]
def clean_text(text: str) -> str:
    """
    Strip HTML, URLs, numbers, and punctuation from raw text.

    Parameters
    ----------
    text : str
        Raw article text that may contain HTML markup, hyperlinks, etc.

    Returns
    -------
    str
        Lowercased plain text containing only alphabetic characters and spaces.
    """
    text = re.sub(r'<[^>]+>', ' ', text)        # strip HTML tags
    text = re.sub(r'http\S+', ' ', text)         # remove URLs
    text = re.sub(r'[^a-zA-Z\s]', ' ', text)    # keep only letters
    text = re.sub(r'\s+', ' ', text).strip()
    return text.lower()


def tokenize_and_lemmatize(text: str, stop_words: set, min_token_len: int = 3) -> list:
    """
    Clean, tokenize, and lemmatize a single document string.

    Parameters
    ----------
    text : str
        Raw document text.
    stop_words : set of str
        Words to remove (English stopwords + domain-specific terms).
    min_token_len : int
        Discard tokens shorter than this length (removes noise like 'bt', 'th').

    Returns
    -------
    list of str
        Cleaned token list ready to feed into gensim corpus building.
    """
    cleaned = clean_text(text)
    tokens = [
        LEMMATIZER.lemmatize(tok)
        for tok in cleaned.split()
        if tok not in stop_words and len(tok) >= min_token_len
    ]
    return tokens


def build_gensim_corpus(
    tokenized_docs: list,
    no_below: int = 2,
    no_above: float = 0.90
) -> tuple:
    """
    Build a gensim Dictionary and Bag-of-Words corpus from tokenized documents.

    Parameters
    ----------
    tokenized_docs : list of list of str
        Each element is the token list for one document.
    no_below : int
        Remove tokens appearing in fewer than this many documents.
        Filters hapax legomena and rare misspellings.
    no_above : float
        Remove tokens appearing in more than this fraction of all documents.
        Filters de-facto stopwords not caught earlier.

    Returns
    -------
    tuple : (id2word, corpus)
        id2word : gensim.corpora.Dictionary  — word-to-integer mapping
        corpus  : list of list of (int, int) — BoW representation of each doc
    """
    id2word = corpora.Dictionary(tokenized_docs)
    id2word.filter_extremes(no_below=no_below, no_above=no_above)
    corpus = [id2word.doc2bow(doc) for doc in tokenized_docs]
    print(f'Vocabulary: {len(id2word):,} unique tokens')
    print(f'Corpus:     {len(corpus):,} documents')
    return id2word, corpus


# ── Run preprocessing ─────────────────────────────────────────────────────────
print('Tokenizing and lemmatizing...')
tokenized_docs = [
    tokenize_and_lemmatize(text, STOP_WORDS)
    for text in tqdm(news_df['text'].fillna(''))
]
id2word, corpus = build_gensim_corpus(tokenized_docs, no_below=2, no_above=0.90)

print(f'\nSample tokens (article 0): {tokenized_docs[0][:15]}')
Tokenizing and lemmatizing...
  0%|          | 0/201 [00:00<?, ?it/s]
Vocabulary: 672 unique tokens
Corpus:     201 documents

Sample tokens (article 0): ['grayscale', 'applies', 'traditional', 'finance', 'model', 'aave', 'see', 'value', 'grayscale', 'coinshares', 'applying', 'traditional', 'valuation', 'technique', 'asset']

Section 4 — LDA Topic Modeling

Latent Dirichlet Allocation (LDA) is a generative probabilistic model. It assumes:

  • Each document is generated by sampling topics from a document-topic distribution
  • Each word in the document is generated by sampling from the chosen topic's word distribution

LDA works backwards: given observed words, it infers the hidden topic structure.

Choosing the Number of Topics

There is no universally correct n_topics. A good starting range for crypto news is 8–15. Use compute_topic_coherence() to compare models with different topic counts — higher coherence generally means more interpretable, distinct topics.

[16]
def train_lda_model(
    corpus: list,
    id2word,
    n_topics: int = 10,
    passes: int = 15,
    workers: int = 2
) -> LdaMulticore:
    """
    Train a parallelized LDA model using gensim's LdaMulticore.

    Parameters
    ----------
    corpus : list
        BoW corpus from build_gensim_corpus().
    id2word : gensim.corpora.Dictionary
        Word-integer mapping.
    n_topics : int
        Number of latent topics. Start with 10 for general crypto news.
    passes : int
        Training iterations over the full corpus. More passes → better convergence.
    workers : int
        Parallel CPU workers. Set to (number of cores - 1).

    Returns
    -------
    LdaMulticore
        Trained LDA model ready for inference and visualization.
    """
    np.random.seed(RANDOM_SEED)
    model = LdaMulticore(
        corpus=corpus,
        id2word=id2word,
        num_topics=n_topics,
        passes=passes,
        workers=workers,
        random_state=RANDOM_SEED,
        per_word_topics=True,
    )
    print(f'LDA trained: {n_topics} topics, {passes} passes.')
    return model


def compute_topic_coherence(lda_model, tokenized_docs: list, id2word, corpus: list) -> float:
    """
    Compute the c_v coherence score — the standard LDA quality metric.

    Parameters
    ----------
    lda_model : LdaMulticore
        Trained LDA model to evaluate.
    tokenized_docs : list of list of str
        Original tokenized documents (not BoW).
    id2word : gensim.corpora.Dictionary
    corpus : list
        BoW corpus.

    Returns
    -------
    float
        Coherence score. Scores of 0.45–0.65 are typical for news data.
        Higher is better — topics whose top words co-occur frequently score higher.
    """
    cm = CoherenceModel(
        model=lda_model, texts=tokenized_docs,
        dictionary=id2word, coherence='c_v'
    )
    score = cm.get_coherence()
    print(f'Coherence (c_v): {score:.4f}')
    return score


def extract_topic_keywords(lda_model, n_words: int = 12) -> dict:
    """
    Extract the top keywords and their probabilities for each topic.

    Parameters
    ----------
    lda_model : LdaMulticore
        Trained LDA model.
    n_words : int
        Number of top words to return per topic.

    Returns
    -------
    dict
        {topic_id (int): [(word, probability), ...]} for all topics.
    """
    return {
        tid: lda_model.show_topic(tid, topn=n_words)
        for tid in range(lda_model.num_topics)
    }


def assign_dominant_topic(lda_model, corpus: list, news_df: pd.DataFrame) -> pd.DataFrame:
    """
    Append dominant topic ID and probability to each article in the DataFrame.

    Parameters
    ----------
    lda_model : LdaMulticore
        Trained LDA model.
    corpus : list
        BoW corpus aligned with news_df rows.
    news_df : pd.DataFrame
        Original news DataFrame to annotate.

    Returns
    -------
    pd.DataFrame
        news_df with added columns: dominant_topic (int), topic_prob (float).
    """
    rows = []
    for doc_bow in corpus:
        dist = lda_model.get_document_topics(doc_bow, minimum_probability=0)
        top = max(dist, key=lambda x: x[1])
        rows.append({'dominant_topic': top[0], 'topic_prob': round(top[1], 4)})
    result = news_df.copy()
    result['dominant_topic'] = [r['dominant_topic'] for r in rows]
    result['topic_prob']     = [r['topic_prob']     for r in rows]
    return result


def print_topic_summary(topic_keywords: dict, topic_labels: dict = None):
    """
    Print a human-readable summary of discovered topics with top keywords.

    Parameters
    ----------
    topic_keywords : dict
        Output of extract_topic_keywords().
    topic_labels : dict, optional
        {topic_id: 'Human-readable label'} assigned after inspecting keywords.
    """
    print('=' * 65)
    print('DISCOVERED TOPICS')
    print('=' * 65)
    for tid, words in topic_keywords.items():
        label = topic_labels.get(tid, f'Topic {tid}') if topic_labels else f'Topic {tid}'
        top_words = ', '.join([w for w, _ in words[:8]])
        print(f'[{label:<22}] {top_words}')
    print('=' * 65)


# ── Train LDA ─────────────────────────────────────────────────────────────────
lda_model  = train_lda_model(corpus, id2word, n_topics=N_TOPICS, passes=15)
coherence  = compute_topic_coherence(lda_model, tokenized_docs, id2word, corpus)
topic_kws  = extract_topic_keywords(lda_model, n_words=12)
print_topic_summary(topic_kws)

doc_df = assign_dominant_topic(lda_model, corpus, news_df)

# ── Assign human-readable labels after reviewing keywords above ───────────────
TOPIC_LABELS = {
    0: 'Regulatory & Legal',
    1: 'DeFi & Protocols',
    2: 'Macro & Fed Policy',
    3: 'Exchange & Liquidity',
    4: 'Bitcoin & Halvings',
    5: 'NFT & Gaming',
    6: 'Ethereum & Upgrades',
    7: 'Stablecoins & Risk',
    8: 'Institutional Adoption',
    9: 'On-Chain Metrics',
}
doc_df['topic_label'] = doc_df['dominant_topic'].map(TOPIC_LABELS)
print(doc_df[['title', 'dominant_topic', 'topic_prob', 'topic_label']].head(8))
LDA trained: 10 topics, 15 passes.
Coherence (c_v): 0.4721
=================================================================
DISCOVERED TOPICS
=================================================================
[Topic 0               ] world, fifa, cup, robinhood, security, stock, trump, agent
[Topic 1               ] coinbase, bitcoin, stock, perps, ticket, solana, break, say
[Topic 2               ] malware, openai, data, judge, backed, state, elon, musk
[Topic 3               ] laundering, fund, scam, agent, want, hit, launch, billion
[Topic 4               ] polymarket, google, million, federal, bitcoin, bet, prediction, dollar
[Topic 5               ] bitcoin, rate, ftx, kevin, warsh, fed, trump, term
[Topic 6               ] bitcoin, binance, mica, regulatory, trump, attack, backed, state
[Topic 7               ] bitcoin, first, million, magazine, act, stop, portfolio, billionaire
[Topic 8               ] bitcoin, energy, face, illinois, tax, first, magazine, leader
[Topic 9               ] data, card, center, power, bill, hit, high, steam
=================================================================
                                               title  dominant_topic  \
0  Grayscale applies traditional finance models t...               7   
1  The bond market is flashing a clear signal on ...               8   
2  Ryan Salame’s wife to face charges over FTX-fu...               5   
3  Florida man pleads guilty for promoting $1.8B ...               8   
4  Live markets: Bitcoin, ether ETFs lose $111 mi...               5   
5               Here’s what happened in crypto today               3   
6  Strategy's STRC preferred stock hits a record ...               3   
7  Dorsey’s Block says new AI tool handles 15% of...               2   

   topic_prob             topic_label  
0      0.9308      Stablecoins & Risk  
1      0.4954  Institutional Adoption  
2      0.9500            NFT & Gaming  
3      0.9550  Institutional Adoption  
4      0.9182            NFT & Gaming  
5      0.9437    Exchange & Liquidity  
6      0.8714    Exchange & Liquidity  
7      0.9437      Macro & Fed Policy  

Section 5 — Topic Visualization

pyLDAvis: Intertopic Distance Map

The circular visualization shows each topic as a circle where:

  • Circle size = topic prevalence (share of total tokens)
  • Circle position = topic similarity (nearby topics share vocabulary)
  • Right panel = top keywords for the selected topic

Ideally, topics should be large (prevalent) and spread apart (distinct). Overlapping circles suggest the topic count is too high.

[17]
def visualize_lda_interactive(lda_model, corpus: list, id2word):
    """
    Render an interactive pyLDAvis intertopic distance map in Colab.

    Parameters
    ----------
    lda_model : LdaMulticore
        Trained LDA model.
    corpus : list
        BoW corpus.
    id2word : gensim.corpora.Dictionary
        Word dictionary.

    Returns
    -------
    pyLDAvis PreparedData
        Interactive HTML visualization rendered inline in the notebook.

    Notes
    -----
    Use `mds='mmds'` (multi-dimensional scaling) for layout — stable and fast.
    Click a circle to inspect its top terms. Adjust the relevance slider (lambda)
    to weight term frequency vs topic exclusivity.
    """
    pyLDAvis.enable_notebook()
    vis = gensimvis.prepare(lda_model, corpus, id2word, mds='mmds', sort_topics=False)
    return pyLDAvis.display(vis)


def plot_topic_distribution(doc_df: pd.DataFrame, topic_labels: dict) -> None:
    """
    Bar chart of article count per discovered topic.

    Parameters
    ----------
    doc_df : pd.DataFrame
        DataFrame with 'dominant_topic' column from assign_dominant_topic().
    topic_labels : dict
        {topic_id: 'Label'} for axis labeling.
    """
    counts = doc_df['dominant_topic'].value_counts().sort_index()
    labels = [topic_labels.get(i, f'Topic {i}') for i in counts.index]
    palette = sns.color_palette('husl', len(labels))

    fig, ax = plt.subplots(figsize=(12, 5))
    bars = ax.bar(labels, counts.values, color=palette)
    ax.set_xlabel('Topic')
    ax.set_ylabel('Article Count')
    ax.set_title('Article Distribution Across Topics')
    plt.xticks(rotation=35, ha='right')
    for bar, count in zip(bars, counts.values):
        ax.text(bar.get_x() + bar.get_width() / 2,
                bar.get_height() + 0.5, str(count),
                ha='center', va='bottom', fontsize=9)
    plt.tight_layout()
    plt.show()


def plot_topic_trend_over_time(doc_df: pd.DataFrame, topic_labels: dict, top_n: int = 5) -> None:
    """
    Line chart showing how topic prevalence shifts over time.

    Parameters
    ----------
    doc_df : pd.DataFrame
        Must have 'published' (datetime) and 'dominant_topic' columns.
    topic_labels : dict
        {topic_id: 'Label'}.
    top_n : int
        Plot only the top N topics by total article count.

    Notes
    -----
    Spikes in specific topics can act as early signals — e.g., a surge in
    'Regulatory & Legal' articles often precedes increased volatility.
    """
    df = doc_df.dropna(subset=['published']).copy()
    df['date'] = pd.to_datetime(df['published']).dt.date
    top_ids = df['dominant_topic'].value_counts().head(top_n).index
    pivot = (
        df[df['dominant_topic'].isin(top_ids)]
        .groupby(['date', 'dominant_topic'])
        .size()
        .unstack(fill_value=0)
    )
    pivot.columns = [topic_labels.get(c, f'Topic {c}') for c in pivot.columns]

    fig, ax = plt.subplots(figsize=(14, 5))
    pivot.plot(ax=ax, linewidth=2, marker='o', markersize=4)
    ax.set_title('Topic Prevalence Over Time')
    ax.set_xlabel('Date')
    ax.set_ylabel('Article Count')
    ax.legend(loc='upper left', fontsize=9)
    plt.tight_layout()
    plt.show()


# ── Visualize ─────────────────────────────────────────────────────────────────
visualize_lda_interactive(lda_model, corpus, id2word)
plot_topic_distribution(doc_df, TOPIC_LABELS)
plot_topic_trend_over_time(doc_df, TOPIC_LABELS, top_n=5)
cell output
cell output

Section 6 — BERTopic (Advanced: Neural Topic Modeling)

LDA works on word counts — it cannot tell that "Fed hikes rates" and "FOMC raises interest rate" discuss the same topic. BERTopic uses contextual sentence embeddings from sentence-transformers, which encode meaning rather than just word identity.

BERTopic Pipeline

Documents → [Sentence-BERT embeddings] → [UMAP reduction] → [HDBSCAN clustering] → Topics
LDABERTopic
Requires n_topics upfrontYesNo (auto-detects)
Captures word meaningNoYes
Speed on CPUFastSlow (use GPU)
Handles short textsPoorlyWell

Runtime: Enable GPU in Colab for this section — embedding 500 articles takes ~30s on GPU vs ~5min on CPU.

[18]
def train_bertopic_model(texts: list, min_topic_size: int = 8, n_topics=None) -> tuple:
    """
    Train a BERTopic model on raw (un-tokenized) text documents.

    Parameters
    ----------
    texts : list of str
        Raw article texts. BERTopic handles preprocessing internally.
    min_topic_size : int
        Minimum cluster size for HDBSCAN. Larger values → fewer, broader topics.
        For 500 articles try 8–15; for 5000+ articles try 20–50.
    n_topics : int or None
        Force a specific number of topics after clustering (merges small topics).
        Use None for automatic detection.

    Returns
    -------
    tuple : (topic_model, topics, probs)
        topic_model : BERTopic — trained model with built-in visualization methods
        topics      : list of int — topic ID per document (-1 = outlier)
        probs       : np.ndarray — topic probability per document

    Notes
    -----
    Topic -1 contains outlier documents that did not fit any cluster.
    High outlier counts suggest min_topic_size is too large.
    """
    from bertopic import BERTopic
    from sentence_transformers import SentenceTransformer

    embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
    topic_model = BERTopic(
        embedding_model=embedding_model,
        nr_topics=n_topics,
        min_topic_size=min_topic_size,
        verbose=True,
    )
    topics, probs = topic_model.fit_transform(texts)
    n_discovered = len(set(topics)) - (1 if -1 in topics else 0)
    n_outliers   = topics.count(-1) if isinstance(topics, list) else (topics == -1).sum()
    print(f'BERTopic: {n_discovered} topics | {n_outliers} outliers (-1)')
    return topic_model, topics, probs


def get_bertopic_topic_summary(topic_model) -> pd.DataFrame:
    """
    Return a summary DataFrame of all BERTopic topics with representative terms.

    Parameters
    ----------
    topic_model : BERTopic
        Trained BERTopic model.

    Returns
    -------
    pd.DataFrame
        Columns: Topic, Count, Name, Representation.
        Sorted by document count descending. Outlier topic (-1) excluded.
    """
    info = topic_model.get_topic_info()
    info = info[info['Topic'] != -1].sort_values('Count', ascending=False)
    print(info[['Topic', 'Count', 'Name']].head(15).to_string(index=False))
    return info


# ── Run BERTopic (GPU recommended) ────────────────────────────────────────────
raw_texts   = news_df['text'].fillna('').tolist()
bert_model, bert_topics, bert_probs = train_bertopic_model(raw_texts, min_topic_size=8)
bert_summary = get_bertopic_topic_summary(bert_model)

# Built-in BERTopic visualizations
bert_model.visualize_topics()       # intertopic distance map
bert_model.visualize_barchart(top_n_topics=10)  # top words per topic
Loading weights:   0%|          | 0/103 [00:00<?, ?it/s]
2026-06-18 10:07:45,058 - BERTopic - Embedding - Transforming documents to embeddings.
Batches:   0%|          | 0/7 [00:00<?, ?it/s]
2026-06-18 10:07:57,485 - BERTopic - Embedding - Completed ✓
2026-06-18 10:07:57,487 - BERTopic - Dimensionality - Fitting the dimensionality reduction algorithm
2026-06-18 10:07:58,199 - BERTopic - Dimensionality - Completed ✓
2026-06-18 10:07:58,202 - BERTopic - Cluster - Start clustering the reduced embeddings
2026-06-18 10:07:58,220 - BERTopic - Cluster - Completed ✓
2026-06-18 10:07:58,226 - BERTopic - Representation - Fine-tuning topics using representation models.
2026-06-18 10:07:58,267 - BERTopic - Representation - Completed ✓
BERTopic: 5 topics | 66 outliers (-1)
 Topic  Count                                Name
     0     68                      0_the_to_as_in
     1     29                 1_to_and_the_crypto
     2     15 2_bitcoin_bitcoinmagazine_https_com
     3     12  3_prediction_to_polymarket_markets
     4     11                    4_ai_is_that_the

Section 7 — Topic-Based Trading Signals

Raw topic assignments become actionable signals by classifying topics as bullish, bearish, or neutral based on their content, then computing the daily balance of article volume:

net_signal = bullish_articles - bearish_articles

A sustained surge in regulatory articles (bearish topic) before a price drop is a classic use case. This section also detects topic surges — abnormal spikes in a specific topic's article volume using z-score thresholding.

[19]
BULLISH_TOPICS = {'Institutional Adoption', 'Bitcoin & Halvings', 'Ethereum & Upgrades'}
BEARISH_TOPICS = {'Regulatory & Legal', 'Stablecoins & Risk', 'Exchange & Liquidity'}


def compute_daily_topic_signal(
    doc_df: pd.DataFrame,
    bullish_topics: set,
    bearish_topics: set
) -> pd.DataFrame:
    """
    Compute a daily net topic signal from article topic labels.

    Parameters
    ----------
    doc_df : pd.DataFrame
        Must have 'published' and 'topic_label' columns.
    bullish_topics : set of str
        Topic labels associated with positive market conditions.
    bearish_topics : set of str
        Topic labels associated with negative market conditions.

    Returns
    -------
    pd.DataFrame
        Daily rows with columns: date, bullish, bearish, neutral, net_signal,
        normalized_signal (range -1 to +1).
    """
    df = doc_df.dropna(subset=['published', 'topic_label']).copy()
    df['date'] = pd.to_datetime(df['published']).dt.date
    df['signal_type'] = df['topic_label'].apply(
        lambda t: 'bullish' if t in bullish_topics
        else ('bearish' if t in bearish_topics else 'neutral')
    )
    daily = (
        df.groupby(['date', 'signal_type'])
        .size()
        .unstack(fill_value=0)
        .reindex(columns=['bullish', 'bearish', 'neutral'], fill_value=0)
    )
    daily['net_signal']         = daily['bullish'] - daily['bearish']
    total                       = daily['bullish'] + daily['bearish'] + 1
    daily['normalized_signal']  = daily['net_signal'] / total
    return daily.reset_index()


def detect_topic_surge(
    doc_df: pd.DataFrame,
    topic_label: str,
    window_hours: int = 24,
    z_threshold: float = 1.5
) -> pd.DataFrame:
    """
    Detect statistically significant surges in a specific topic's article volume.

    Parameters
    ----------
    doc_df : pd.DataFrame
        Must have 'published' and 'topic_label' columns.
    topic_label : str
        The topic name to monitor (e.g., 'Regulatory & Legal').
    window_hours : int
        Rolling window in hours for baseline computation.
    z_threshold : float
        Z-score threshold above which an hour is flagged as a surge.
        A value of 1.5 catches 93rd-percentile spikes.

    Returns
    -------
    pd.DataFrame
        Hourly article counts with z_score and boolean surge flag.
    """
    df = doc_df[doc_df['topic_label'] == topic_label].dropna(subset=['published']).copy()
    df = df.set_index(pd.to_datetime(df['published'])).sort_index()
    hourly = df.resample('1H').size().rename('count').reset_index()
    hourly.columns = ['datetime', 'count']

    hourly['rolling_mean'] = hourly['count'].rolling(window_hours, min_periods=1).mean()
    hourly['rolling_std']  = hourly['count'].rolling(window_hours, min_periods=1).std().fillna(1).clip(lower=0.1)
    hourly['z_score']      = (hourly['count'] - hourly['rolling_mean']) / hourly['rolling_std']
    hourly['surge']        = hourly['z_score'] > z_threshold

    print(f'[{topic_label}]: {hourly["surge"].sum()} surge events (z > {z_threshold})')
    return hourly


def plot_topic_signal(daily_signal: pd.DataFrame) -> None:
    """
    Visualize the daily net topic signal alongside bullish/bearish article volumes.

    Parameters
    ----------
    daily_signal : pd.DataFrame
        Output from compute_daily_topic_signal().
    """
    fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)

    ax = axes[0]
    colors = ['green' if v >= 0 else 'red' for v in daily_signal['net_signal']]
    ax.bar(daily_signal['date'], daily_signal['net_signal'], color=colors, alpha=0.75)
    ax.axhline(0, color='black', linewidth=0.8)
    ax.set_title('Daily Net Topic Signal  (Bullish Topics − Bearish Topics)')
    ax.set_ylabel('Net Count')

    ax = axes[1]
    ax.bar(daily_signal['date'],  daily_signal['bullish'],  label='Bullish', color='green', alpha=0.65)
    ax.bar(daily_signal['date'], -daily_signal['bearish'],  label='Bearish', color='red',   alpha=0.65)
    ax.axhline(0, color='black', linewidth=0.8)
    ax.set_title('Bullish vs Bearish Article Volume')
    ax.set_ylabel('Article Count')
    ax.legend()

    plt.tight_layout()
    plt.show()


# ── Generate signals ──────────────────────────────────────────────────────────
daily_signal = compute_daily_topic_signal(doc_df, BULLISH_TOPICS, BEARISH_TOPICS)
plot_topic_signal(daily_signal)

surge_df = detect_topic_surge(doc_df, 'Regulatory & Legal', window_hours=24, z_threshold=1.5)
print(surge_df[surge_df['surge']].head())
cell output
[Regulatory & Legal]: 19 surge events (z > 1.5)
                     datetime  count  rolling_mean  rolling_std   z_score  \
124 2026-05-22 18:00:00+00:00      1      0.041667     0.204124  4.694855   
141 2026-05-23 11:00:00+00:00      1      0.083333     0.282330  3.246793   
151 2026-05-23 21:00:00+00:00      1      0.083333     0.282330  3.246793   
240 2026-05-27 14:00:00+00:00      1      0.041667     0.204124  4.694855   
449 2026-06-05 07:00:00+00:00      1      0.041667     0.204124  4.694855   

     surge  
124   True  
141   True  
151   True  
240   True  
449   True  

Section 8 — Export Results

We export two CSV files:

  1. Article-level: each news article with its assigned topic and probability
  2. Daily signals: aggregated daily bullish/bearish/net topic counts

These feed directly into Notebook 121 (sentiment_signal_generator) which combines topic signals with VADER/BERT scores for a unified sentiment trading signal.

[20]
def export_topic_results(
    doc_df: pd.DataFrame,
    daily_signal: pd.DataFrame,
    prefix: str = 'topic_modeling_crypto_news'
) -> None:
    """
    Export article topic assignments and daily signal DataFrames to CSV.

    Parameters
    ----------
    doc_df : pd.DataFrame
        Article-level DataFrame with topic columns from assign_dominant_topic().
    daily_signal : pd.DataFrame
        Daily aggregated signal from compute_daily_topic_signal().
    prefix : str
        Filename prefix for both output CSVs.

    Outputs
    -------
    {prefix}_articles.csv  — article-level topic labels
    {prefix}_signals.csv   — daily bullish/bearish/net signal
    """
    article_cols = ['title', 'published', 'source', 'dominant_topic', 'topic_prob', 'topic_label']
    articles_out = f'{prefix}_articles.csv'
    signals_out  = f'{prefix}_signals.csv'

    doc_df[article_cols].to_csv(articles_out, index=False)
    daily_signal.to_csv(signals_out, index=False)

    print(f'Exported: {articles_out}  ({len(doc_df)} articles)')
    print(f'Exported: {signals_out}   ({len(daily_signal)} daily rows)')


export_topic_results(doc_df, daily_signal)
Exported: topic_modeling_crypto_news_articles.csv  (201 articles)
Exported: topic_modeling_crypto_news_signals.csv   (32 daily rows)

Summary & Next Steps

What We Built

StepOutput
RSS collectionRaw news DataFrame with ~400 articles
PreprocessingTokenized corpus with filtered vocabulary
LDA (10 topics)Topic-labeled articles + coherence score
BERTopicSemantically clustered topics, no predefined count
Trading signalsDaily net bullish/bearish topic balance
Surge detectionZ-score-based alerts on topic volume spikes

Tuning Tips

  • Coherence < 0.40: reduce n_topics or improve preprocessing
  • Topics look similar: increase N_TOPICS or add more domain stopwords
  • BERTopic too many outliers: decrease min_topic_size
  • Signal is noisy: apply a 3-day EMA smoothing on normalized_signal