Usare BERT per il topic modeling in italiano (Python)

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)

esempio di una Bag of Word con tre documenti (recensioni in questo caso)

I problemi

  1. 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.
  2. 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.
  3. 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

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()

Output dell'analisi2. I colori identificano alcuni topic individuati

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.

quantity of topics

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!

topic explained

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