Usare BERT per il topic modeling in italiano (Python)
Quali sono i principali argomenti di un testo o di un insieme di testi?
A volte nel marketing, ma anche in discipline umanistiche come letteratura, storia, sociologia, è necessario analizzare un grande corpus formato da molti documenti. Una delle cose che si vogliono indagare è: “di che cosa parlano questi testi?” e un’altra è “possiamo classificare questi testi in base al loro argomento prevalente?”.
Un esempio di applicazione nel marketing potrebbe essere:
Dato un insieme di conversazioni riguardanti un certo brand, quali sono gli argomenti prevalenti? Quanto pesano sul totale delle conversazioni?
Approccio n.1: il modello LDA
Per capirlo si possono usare varie tecniche. La più comune è la Latent Dirichlet Allocation, introdotta da Blei, Ng e Jordan nel 2002. Questa tecnica ha il pregio di essere versatile: non tiene conto della lingua e delle sue strutture, ma si basa sul modello bag-of-words. In parole semplici: viene creata una matrice avente per colonne tutte le singole parole dei testi e per righe i singoli documenti. I valori al suo interno corrispondono al numero di occorrenze della parola (in colonna) nel documento (in riga).

esempio di una Bag of Word con tre documenti (recensioni in questo caso)
I problemi
- La rimozione delle parole non portatrici di significato è fatta in modo piuttosto grezzo: prima di inserire i dati nella matrice vengono eliminate le parole più frequenti di un certo valore, a cui si aggiunge una lista di “stopwords” — congiunzioni, articoli, preposizioni — definita a priori. Se questa pulizia non è fatta bene, i topic individuati sono di difficile interpretazione.
- Il modello si basa su un numero di topic pre-inserito. Con un corpus poco variegato LDA funziona bene; ma se vogliamo analizzare corpora più ampi (un insieme di lettere, i contenuti pubblicati su un canale social) occorrerà individuare 20, 30, 40 topic diversi.
- Il modello forza ogni documento a un topic. Attribuisce a ogni documento una probabilità di appartenere a un certo topic, il cui totale dà 100. Ci sarà sempre un topic con probabilità maggiore, ma non è detto che in quel documento si parli di alcuno dei topic individuati.
Come ovviare a questi problemi? Usando il meglio dell’intelligenza artificiale di Google: BERT.
Approccio n.2: BERT unito a c-TF-IDF
Scrivo questo articolo perché mi sembra ci sia un vuoto tra i blog italiani su come fare topic modeling usando BERT. È ripreso in gran parte da questo articolo di Maarten Grootendorst, l’ideatore di BERTopic, l’algoritmo che ora generalizzo e “traduco” in italiano.

Output dell’analisi. I colori identificano alcuni topic individuati
1. Dataset + libraries
Il dataset di partenza è un database con più di 12.000 canzoni etichettate come “indie” e “non indie” pubblicate tra il 2000 e il 2020, comprensivo dei testi e di alcune metriche elaborate da Spotify. Importante: i testi sono tutti in minuscolo e privi di punteggiatura.
Iniziamo importando le librerie:
import pandas as pd
from sentence_transformers import SentenceTransformer # NB: potrebbe servire pyTorch installato
import umap # necessario per la dimensionality reduction
import hdbscan # necessario per il clustering
import matplotlib.pyplot as plt
import numpy as np
from sklearn.feature_extraction.text import CountVectorizer
A questo punto carichiamo il dataset:
path = 'C:/Users/User/Downloads/'
filename = 'INDIE DATABASE.csv'
data_raw = pd.read_csv(path + filename)
# seleziono solo le canzoni etichettate come indie
data_indie = data_raw[data_raw['label'] == 'indie']
# creo una lista con tutti i testi
data = list(data_indie['TESTO'])
2. Creazione degli embeddings
Il primo step è la conversione dei documenti in dati numerici. Per farlo usiamo BERT, in grado di estrarre da un testo dei raggruppamenti (embeddings) basati sul contesto della parola. La cosa fantastica è che esistono già molti modelli pre-allenati. Useremo la libreria sentence_transformers, i cui embedding sono di buona qualità a livello di singolo documento.
model = SentenceTransformer('distilbert-multilingual-nli-stsb-quora-ranking')
embeddings = model.encode(data, show_progress_bar=True)
Nota: stiamo usando DistilBERT, un modello pre-allenato multilingue che garantisce un buon trade-off tra velocità e performance. A questo punto i documenti iniziali sono stati trasformati in vettori a 768 dimensioni.
NOTA: con documenti molto grandi potrebbero esserci errori. Prova a suddividerli in singoli paragrafi.
3. Clustering
Vogliamo che i documenti con gli stessi argomenti finiscano nello stesso cluster, così da trovare i topic al loro interno. Prima però bisogna ridurre la dimensionalità degli embeddings, perché molti algoritmi di clustering faticano con dimensionalità elevate.
Parte I — UMAP. Riduciamo la dimensionalità con UMAP, che a differenza di altri algoritmi mantiene bene la struttura locale anche in dimensioni minori. Riduciamo a 5 dimensioni mantenendo il local neighborhood a 15 (attenzione: una dimensionalità troppo bassa perde informazione, troppo alta peggiora il clustering).
umap_embeddings = umap.UMAP(n_neighbors=15,
n_components=5,
metric='cosine').fit_transform(embeddings)
Parte II — HDBSCAN. Procediamo con la clusterizzazione. HDBSCAN è un algoritmo density-based che lavora bene in accoppiata con UMAP e non forza i punti dentro un cluster, ma li considera outlier.
cluster = hdbscan.HDBSCAN(min_cluster_size=15,
metric='euclidean',
cluster_selection_method='eom').fit(umap_embeddings)
4. Data visualization
Usiamo matplotlib:
# Prepare data
umap_data = umap.UMAP(n_neighbors=15, n_components=2, min_dist=0.0, metric='cosine').fit_transform(embeddings)
result = pd.DataFrame(umap_data, columns=['x', 'y'])
result['labels'] = cluster.labels_
# Visualize clusters
fig, ax = plt.subplots(figsize=(20, 10))
outliers = result.loc[result.labels == -1, :]
clustered = result.loc[result.labels != -1, :]
plt.scatter(outliers.x, outliers.y, color='#BDBDBD', s=0.05)
plt.scatter(clustered.x, clustered.y, c=clustered.labels, s=0.05, cmap='hsv_r')
plt.colorbar()

Risulta difficile leggere i topic generati: sono più di 40 e molti documenti non sono stati inseriti in un cluster specifico.
5. Creare i topic
Il primo passo è creare un dataframe documento-topic:
docs_df = pd.DataFrame(data, columns=["Doc"])
docs_df['Topic'] = cluster.labels_
docs_df['Doc_ID'] = range(len(docs_df))
Ma quali sono questi topic? Applicheremo la TF-IDF non su un singolo documento, ma su tutti i documenti aventi in comune un topic. Questa variante, chiamata c-TF-IDF (dove ‘c-’ sta per class-based), tratta l’insieme di documenti di uno stesso topic come un unico grande documento. Lo score riflette così le parole più importanti dell’intero topic.
# uniamo i documenti aventi lo stesso topic
docs_per_topic = docs_df.groupby(['Topic'], as_index=False).agg({'Doc': ' '.join})
# definiamo la funzione c_tf_idf
def c_tf_idf(documents, m, ngram_range=(1, 1)):
count = CountVectorizer(ngram_range=ngram_range, stop_words="english").fit(documents)
t = count.transform(documents).toarray()
w = t.sum(axis=1)
tf = np.divide(t.T, w)
sum_t = t.sum(axis=0)
idf = np.log(np.divide(m, sum_t)).reshape(-1, 1)
tf_idf = np.multiply(tf, idf)
return tf_idf, count
tf_idf, count = c_tf_idf(docs_per_topic.Doc.values, m=len(data))
6. Interpretare i topic
Con queste righe prendiamo le top 20 parole per ogni topic in base al loro c-TF-IDF score:
def extract_top_n_words_per_topic(tf_idf, count, docs_per_topic, n=20):
words = count.get_feature_names()
labels = list(docs_per_topic.Topic)
tf_idf_transposed = tf_idf.T
indices = tf_idf_transposed.argsort()[:, -n:]
top_n_words = {label: [(words[j], tf_idf_transposed[i][j]) for j in indices[i]][::-1] for i, label in enumerate(labels)}
return top_n_words
def extract_topic_sizes(df):
topic_sizes = (df.groupby(['Topic'])
.Doc
.count()
.reset_index()
.rename({"Topic": "Topic", "Doc": "Size"}, axis='columns')
.sort_values("Size", ascending=False))
return topic_sizes
top_n_words = extract_top_n_words_per_topic(tf_idf, count, docs_per_topic, n=20)
topic_sizes = extract_topic_sizes(docs_df); topic_sizes.head(15)
Con topic_sizes vediamo quanto i vari topic sono frequenti. Nota: i valori etichettati con -1 sono documenti a cui non è stato assegnato alcun topic. È un bene o un male? Dipende dalla tua intenzione di ricerca.

Esplorare il contenuto di un topic è semplice. Basta lanciare:
top_n_words[4][:15]
per vedere le 15 parole più importanti del topic 4. Buon divertimento!

NOTA: Qui trovi tutti i codici: BERTopic su GitHub.