[1]:
# Install hypertools (run this first on Colab)
import importlib.util
if importlib.util.find_spec('hypertools') is None:
    %pip install -q "hypertools[interactive]"

# This tutorial also uses sentence-transformers (not a HyperTools dep);
# self-install it so a top-to-bottom run works headlessly too.
if importlib.util.find_spec('sentence_transformers') is None:
    %pip install -q sentence-transformers

Mapping Wikipedia with modern text embeddings

Text embedding models map documents into high-dimensional vector spaces where nearby vectors correspond to semantically related documents. In this tutorial we’ll embed HyperTools’ sample corpus of Wikipedia articles using a modern text embedding model (`BAAI/bge-small-en-v1.5 <https://huggingface.co/BAAI/bge-small-en-v1.5>`__), untangle the embedding space with UMAP, discover article groupings with soft (Gaussian mixture) clustering, and explore the resulting “map” of Wikipedia in 3D.

This tutorial uses one extra library that is not installed with HyperTools. You can install it by running:

%pip install sentence-transformers

(The animation step at the end also uses ffmpeg to convert the exported movie into a GIF.)

[2]:
%matplotlib inline
import os
os.environ['HF_HUB_DISABLE_PROGRESS_BARS'] = '1'
os.environ['HF_HUB_VERBOSITY'] = 'error'
os.environ['TOKENIZERS_PARALLELISM'] = 'false'

import warnings
warnings.filterwarnings('ignore')

import numpy as np
from sentence_transformers import SentenceTransformer

import hypertools as hyp

Loading the sample Wikipedia corpus

HyperTools ships with a sample corpus of Wikipedia articles, available through hyp.load('wiki'). The loader returns the raw data directly – a list containing one array of documents, where each entry is the (preprocessed) text of one Wikipedia article.

[3]:
wiki = hyp.load('wiki')
articles = [str(doc) for doc in wiki[0].ravel()]

print(f'number of articles: {len(articles)}')
print(f'average article length: {np.mean([len(a) for a in articles]):0.0f} characters')
print('\nfirst article (excerpt):\n', articles[0][:250], '...')
number of articles: 3136
average article length: 18048 characters

first article (excerpt):
 a gun salute is the most commonly recognized of the customary gun salutes that are performed by the firing of cannons or artillery as a military honorthe custom stems from naval tradition where a warship would fire its cannons harmlessly out to sea u ...

Embedding the articles

We’ll embed each article with `BAAI/bge-small-en-v1.5 <https://huggingface.co/BAAI/bge-small-en-v1.5>`__, a compact but powerful sentence-embedding model that maps each document onto a 384-dimensional vector. The model reads at most 512 tokens per document, so we truncate each article to its first 2,000 characters (typically about 350-450 tokens, comfortably within the limit) – this also speeds up encoding considerably, and the opening of a Wikipedia article is nearly always a summary of the whole article anyway.

[4]:
truncated = [a[:2000] for a in articles]

model = SentenceTransformer('BAAI/bge-small-en-v1.5')
embeddings = model.encode(truncated, batch_size=64, show_progress_bar=False)
embeddings.shape
[4]:
(3136, 384)

Mapping the corpus with UMAP and soft clustering

Now we can draw the map – in a single call. Passing reduce='UMAP' projects the 384-dimensional embeddings down to 3D using UMAP, a nonlinear method that is particularly good at untangling text embedding spaces. Passing cluster={'model': 'GaussianMixture', 'n_clusters': 10} fits a 10-component Gaussian mixture model to the embeddings and colors each article by its blend of cluster memberships, automatically: mixture models yield soft assignments, and HyperTools renders each article’s color as an exact per-point blend of the cluster colors, weighted by that article’s mixture proportions. Articles deep inside a cluster take on its pure color, while articles between clusters get smoothly intermediate hues. The '.' format string together with markersize=2 plots each article as a small dot.

UMAP is stochastic: re-fitting it gives a different layout on every call unless it is seeded. We pass random_state=42 (via the dict form of reduce=) so that repeated calls – the static map here and the animated version below – produce the same layout.

[5]:
fig = hyp.plot(embeddings, '.', markersize=2,
               reduce={'model': 'UMAP', 'kwargs': {'random_state': 42}},
               cluster={'model': 'GaussianMixture', 'n_clusters': 10},
               title='Wikipedia articles (BGE embeddings + UMAP)')
../_images/tutorials_wikipedia_embeddings_8_0.png

Each dot is one Wikipedia article. Articles about related topics form clumps and filaments, and the blended colors show where topics shade into one another.

Taking a spin around the map

A static projection of a 3D point cloud always hides something. Setting animate='spin' slowly rotates the camera once around the map (over 10 seconds, at 30 frames per second) so we can inspect it from every angle. We export the animation as an MP4 – compact and fast to render – and then convert it to a GIF with ffmpeg for inline display.

[6]:
fig, ani = hyp.plot(embeddings, '.', markersize=2,
               reduce={'model': 'UMAP', 'kwargs': {'random_state': 42}},
               cluster={'model': 'GaussianMixture', 'n_clusters': 10},
               animate='spin', duration=10, rotations=1, frame_rate=30,
               save_path='wikipedia_embeddings_spin.mp4', show=False)

print(f"mp4: {os.path.getsize('wikipedia_embeddings_spin.mp4')/1e6:0.1f} MB")
mp4: 2.3 MB
[7]:
import shutil
import subprocess

if shutil.which('ffmpeg'):
    filters = ('fps=30,scale=420:-1:flags=lanczos,split [a][b]; '
               '[a] palettegen=max_colors=64 [p]; '
               '[b][p] paletteuse=dither=bayer:bayer_scale=3')
    subprocess.run(['ffmpeg', '-y', '-i', 'wikipedia_embeddings_spin.mp4',
                    '-vf', filters, 'wikipedia_embeddings_spin.gif'],
                   check=True, capture_output=True)
    os.remove('wikipedia_embeddings_spin.mp4')
    print(f"gif: {os.path.getsize('wikipedia_embeddings_spin.gif')/1e6:0.1f} MB")
else:
    print('ffmpeg not found -- skipping GIF conversion; the animation '
          'is saved as wikipedia_embeddings_spin.mp4 instead.')
gif: 6.5 MB

Spinning 3D map of the Wikipedia article embeddings

(The animation above is loaded from ``wikipedia_embeddings_spin.gif``, written by the cell above; if your environment does not render it inline, open the file directly.)

Live keywords: mapping fresh articles by category (GH #187)

The corpus above is a fixed snapshot bundled with HyperTools. We can also build a corpus on the fly: pick a set of keywords grouped into categories, fetch the current live article for each keyword straight from Wikipedia via the `Wikipedia-API <https://github.com/martin-majlis/Wikipedia-API>`__ package, embed them with the same model as above, and plot the resulting point cloud colored by keyword category – with a volumetric density overlay showing where each category’s articles cluster in embedding space.

[8]:
import importlib.util
if importlib.util.find_spec('wikipediaapi') is None:
    %pip install -q wikipedia-api
[9]:
import wikipediaapi

wiki_api = wikipediaapi.Wikipedia(
    user_agent='hypertools-tutorial (https://github.com/ContextLab/hypertools)',
    language='en')

# a keyword set spanning four unrelated categories
keyword_groups = {
    'neuroscience': ['Neuroscience', 'Neuron', 'Cerebral cortex', 'Synapse',
                      'Hippocampus'],
    'astronomy': ['Astronomy', 'Black hole', 'Galaxy', 'Exoplanet', 'Nebula'],
    'music': ['Jazz', 'Classical music', 'Rock music', 'Hip hop music',
              'Opera'],
    'sports': ['Basketball', 'Soccer', 'Tennis', 'Swimming', 'Cricket'],
}

titles, live_texts, categories = [], [], []
for category, keywords in keyword_groups.items():
    for kw in keywords:
        page = wiki_api.page(kw)
        if not page.exists():
            print(f'  (skipping {kw!r}: no such article)')
            continue
        titles.append(kw)
        # the summary is already a clean, self-contained excerpt of the
        # live article -- fall back to the full text for unusually short
        # summaries
        live_texts.append(page.summary if len(page.summary) > 200
                           else page.text)
        categories.append(category)

print(f'fetched {len(titles)} live articles across '
      f'{len(keyword_groups)} categories:')
for title, category in zip(titles, categories):
    print(f'  [{category}] {title}')

fetched 20 live articles across 4 categories:
  [neuroscience] Neuroscience
  [neuroscience] Neuron
  [neuroscience] Cerebral cortex
  [neuroscience] Synapse
  [neuroscience] Hippocampus
  [astronomy] Astronomy
  [astronomy] Black hole
  [astronomy] Galaxy
  [astronomy] Exoplanet
  [astronomy] Nebula
  [music] Jazz
  [music] Classical music
  [music] Rock music
  [music] Hip hop music
  [music] Opera
  [sports] Basketball
  [sports] Soccer
  [sports] Tennis
  [sports] Swimming
  [sports] Cricket

Embedding and mapping the live articles

We reuse the same BAAI/bge-small-en-v1.5 model from above to embed the live articles, then plot them with hue= set to each article’s keyword category – hyp.plot assigns each category a distinct color automatically.

[10]:
live_truncated = [t[:2000] for t in live_texts]
live_embeddings = model.encode(live_truncated, batch_size=16,
                               show_progress_bar=False)
live_embeddings.shape

[10]:
(20, 384)
[11]:
fig = hyp.plot(live_embeddings, '.', markersize=20,
               reduce={'model': 'UMAP', 'kwargs': {'random_state': 42}},
               hue=categories, legend=True,
               title='Live Wikipedia keywords, colored by category')
../_images/tutorials_wikipedia_embeddings_18_0.png

Volumetric density overlay

Passing density=True alongside hue= adds a translucent volumetric KDE “glow” around each colored group (GH #108, #191), making it easier to see where each category’s articles concentrate in the embedding space – even with a modest number of live-fetched articles.

Because we seeded UMAP above, this plot has the same layout as the previous one – just with the density overlay added.

[12]:
fig = hyp.plot(live_embeddings, '.', markersize=20,
               reduce={'model': 'UMAP', 'kwargs': {'random_state': 42}},
               hue=categories, legend=True, density=True,
               title='Live Wikipedia keywords + volumetric density overlay')
../_images/tutorials_wikipedia_embeddings_20_0.png

Next steps

Try a larger embedding model (e.g. BAAI/bge-base-en-v1.5), a different number of mixture components, or swap UMAP for t-SNE (reduce='TSNE'). You can also explore the map interactively with backend='plotly', or use hyp.plot(..., explore=True) to hover over individual articles.