Costruire un Classificatore di Testi – NLP di base in Python [6.1 di 9]

Costruire un classificatore di testi in Python

Grazie ai 5 passi effettuati in precedenza, abbiamo adesso tutto quello che serve per costruire un classificatore di testi in Python, una vera e propria applicazione in Machine Learning! Si tratta di un sistema che, dato un nuovo testo (ad esempio il post di un blog), è capace di classificarlo all’interno di una categoria di argomenti (come “Sport”, “Scienza”, “Politica”, …).

Questo articolo è parte di una serie introduttiva al Natural Language Processing (NLP) di base in Python. La serie è composta da 9 argomenti e con questo articolo trattiamo l’argomento numero 6:

  1. Tokenization
  2. Stemming
  3. Lemmatization
  4. Splitting
  5. Bag-of-words
  6. Text classifier
    1. Costruzione del classificatore <— Questo articolo
    2. Refactoring: funzioni
    3. Refactoring: oggetti
  7. Gender classifier
  8. Sentiment analysis
    1. Sentiment analysis con NLTK
  9. Topic modelling

Rappresentazione: Term Frequency – Inverse Document Frequency (TF-IDF)

Nell’articolo sulla bag-of-words (step 5) abbiamo descritto il tema della “rappresentazione” come un pilastro del machine learning, che ci consente di trovare delle caratteristiche, dette feature, con cui descrivere in maniera numerica tutte le osservazioni del nostro dataset. Non è dunque un caso se il primo punto da affrontare per costruire il nostro classificatore di testi in Python è proprio quello della rappresentazione.

TF: Term Frequency

Sempre nello stesso articolo, abbiamo imparato che la bag-of-words è una rappresentazione che usa come feature le parole del vocabolario. In particolare, nel costruire la matrice nota come document-term matrix, abbiamo riportato tutti i documenti del corpus per riga e le parole del vocabolario per colonna. Ciascuna cella (i,j) riportava poi il numero di occorrenze della parola (colonna j) nel documento (riga i). Benissimo, il valore inserito nella cella prende il nome di Term Frequency, o tf in breve, che letteralmente si traduce appunto “Frequenza del Termine”. Le term frequency è una metrica che misura dunque il numero di occorrenze di una certa parola in un certo documento. Il suo valore minimo è zero (parola assente nel documento) ed il suo valore massimo è pari al numero totale di parole del documento (caso puramente teorico di un documento che ripete sempre la stessa parola).

La tf è dunque funzione sia del termine (t) sia del documento (d), per questo scriviamo tf = tf(t,d).

IDF: Inverse Document Frequency

La idf è una metrica nuova per noi, possiamo considerarla come una sorta di normalizzazione della tf. Per comprendere il suo significato partiamo dalla document frequency, df, che misura il numero di documenti in cui è presente una certa parola rispetto al numero totale di documenti del corpus (che indichiamo con N). Il suo valore minimo si ha per quelle parole che sono assenti da tutti i documenti ed è pari a 0 (0/N), mentre il massimo si ha per quelle parole che sono presenti in tutti i documenti ed è pari a 1 (N/N). La inverse document frequency, idf, è esattamente l’inverso.

Significatività

Provate a pensare al caso di una parola presente in tutti i documenti.

Quanto pensate che tale parola sia importante per capire a quale categoria appartiene un documento in esame?

Pensateci bene, provate a fare un esempio per evitare che l’astrazione resti su un piano troppo teorico. Stiamo dicendo che una certa parola è presente nei documenti che parlano di Sport, in quelli che parlano di Scienza, in quelli che parlano di Politica, e così via per tutte le categorie della nostra tassonomia.

La risposta è che queste parole sono molto poco utili ai fini della classificazione.

Vi assicuro che parole del genere esistono, ve ne viene in mente qualcuna? Per loro stessa definizione sono le parole più comuni di una certa lingua. Per l’italiano una di queste è senz’altro l’articolo “il”. Dunque ve lo chiedo di nuovo: sapere che il documento che dovete classificare contiene la parola “il” è utile per capire di cosa parla il documento? La risposta penso sia ovvia.

Dall’altro lato, se il documento contiene la parola “traversone” pensate che sia utile? A meno di immaginare il “traversone” come il maschile di una grande traversa, si tratta di un termine abbastanza specialistico, che viene utilizzato in ambito calcistico (e da quanto mi dice google si usa anche nelle costruzioni e nelle ferrovie). E’ estremamente improbabile che tale termine si usi in articoli che parlano di Politica, Scienza e probabilmente qualsiasi altra cosa che non sia calcio (o costruzioni o ferrovie :D). Quindi la risposta è sì, questo termine è molto utile ai fini della classificazione.

A questo punto non dovrebbe arrivare come una sorpresa il fatto che in molti considerano la idf come un modo di misurare la significatività di una parola: se la parola è presente in tutti i documenti (min della idf) allora sarà poco significativa ai fini della classificazione, se è presente in pochi documenti (alta idf) allora sarà molto significativa. (Probabilmente bisognerebbe fare qualche modifica per avere una definizione rigorosamente valida, ma il senso penso che sia chiaro già così.)

Formula

Esistono diverse varianti della formula esatta con cui calcolare la idf, tutte con lo stesso significato di base. La formula a cui facciamo riferimento noi è la seguente:

\rm{idf(t)} = \log \left[ \frac{N}{\rm{df(t)} + 1} \right]

dove:

  • N è il numero totale di documenti nel corpus
  • t indica la parola “term”, intesa come variabile
  • df(t) indica la “document frequency” del termine t

nota:

  • abbiamo aggiunto “+ 1” al denominatore nella idf per non ignorare del tutto i termini che compaiono in tutti i documenti (ricordiamo che il log di 1 è zero).

TF-IDF

La tf-idf non è nient’altro che il prodotto delle due metriche, cioè:

\rm{tf-idf(t, d)} = \rm{tf(t,d)} \times \rm{idf(t)}

La cella (i,j) della nostra document-term matrix andrà dunque a contenere non più le occorrenze (come fatto in precedenza), ma il prodotto sopra definito, cioè la tf-idf.

Dati per il classificatore di testi

Negli articoli di questa serie introduttiva al NLP di base in Python, abbiamo quasi sempre lavorato con un solo documento di testo per volta; quando serviva un corpus, abbiamo spezzato un lungo documento in tante parti. Adesso abbiamo bisogno di un vero e proprio corpus, cioè un insieme di documenti ciascuno con il proprio contenuto di senso compiuto. Piuttosto che costruirlo noi, guardiamo a sklearn per una soluzione già pronta, e sklearn non ci delude 🙂 Ha infatti un corpus di quasi ventimila documenti, suddivisi in 20 categorie. Perfetto!

E’ arrivato il momento di passare al codice Python:

# Import a dataset from sklearn
from sklearn.datasets import fetch_20newsgroups
input_dataset = fetch_20newsgroups(subset='all')
# This dataset includes 
# - 18.846 Documents (text data, split into two subsets: 'train' and 'test') 
# - 20 Classes (document category) 
print(f'Total number of documents in the dataset = {len(input_dataset.data)}')
print(f'Total number of classes in the dataset = {len(input_dataset.target_names)}')
print(f'List of all of the classes:\n\t{input_dataset.target_names}')

Come vedete ho modificato il codice per formattare il testo da stampare a schermo. Utilizzo adesso le cosiddette “f-string”, una novità introdotta con Python 3.6 che aumenta la leggibilità del codice. Output:

Total number of documents in the dataset = 18846
Total number of classes in the dataset = 20
List of all of the classes:
    ['alt.atheism', 'comp.graphics', 'comp.os.ms-windows.misc', 'comp.sys.ibm.pc.hardware', 'comp.sys.mac.hardware', 'comp.windows.x', 'misc.forsale', 'rec.autos', 'rec.motorcycles', 'rec.sport.baseball', 'rec.sport.hockey', 'sci.crypt', 'sci.electronics', 'sci.med', 'sci.space', 'soc.religion.christian', 'talk.politics.guns', 'talk.politics.mideast', 'talk.politics.misc', 'talk.religion.misc']

Per i nostri scopi non è necessario lavorare con tutte e 20 le categorie, possiamo invece considerare un subset ad esempio di 5 categorie. Creiamo allora un dizionario, le cui chiavi sono i nomi delle categorie usate nel corpus ed i cui valori sono nei nuovi nomi che usiamo noi per chiamare le categorie:

category_map = {'misc.forsale': 'Sales', 'rec.motorcycles': 'Motorcycles', 'rec.sport.baseball': 'Baseball', 
                'sci.crypt': 'Cryptography', 'sci.space': 'Space'}

Per importare il dataset usando solo le categorie che abbiamo scelto si usa questo comando:

dataset = fetch_20newsgroups(categories=category_map.keys())

Dizionari in Python

Abbiamo già incontrato altre volte i “dizionari” come strutture dati in Python (step 3 e step 5), ma non ne abbiamo ancora dato una descrizione. Rimediamo subito! Un dizionario è una struttura dati, proprio come una lista, ma ha la speciale caratteristica che i suoi elementi sono delle coppie. Il primo elemento della coppia è chiamato “chiave” (“key” in inglese), il secondo elemento della coppia è chiamato “valore” (“value” in inglese). L’intero dizionario è racchiuso tra parentesi graffe, mentre per le lista come sappiamo si usano le parentesi quadre. La chiave ed il valore sono separati dai due punti. Il valore di una certa chiave può a sua volta essere un’altra struttura dati complessa, come ad esempio una lista, oppure anche un nuovo dizionario. Ecco un esempio di un semplice dizionario che contiene due elementi:

gatti = {'gatto 1': 'Luna', 'gatto 2': 'Kiki'}

Attenzione: gli elementi all’interno di un dizionario non sono ordinati! Per poter accedere agli elementi di un dizionario si usa il valore della chiave, e.g. per accedere al valore della ‘gatto 1’ del dizionario ‘gatti’ dobbiamo scrivere gatti['gatto 1']:

>>>gatti['gatto 1']
'Luna'

Rappresentazione o Calcolo delle feature

Abbiamo definito il dataset (da sklearn, con 5 categorie) e come vogliamo rappresentare ciascun documento (bag-of-words con tf-idf). Adesso dobbiamo calcolare la rappresentazione scelta sul dataset selezionato. Il procedimento per calcolare la tf-idf è molto simile a quanto fatto nell’articolo sulla bag-of-words. L’unica differenza è che in quell’articolo ci eravamo fermati alla tf, adesso dobbiamo fare un passo in più ed includere anche la idf.

Calcolo del vocabolario e della tf con CountVectorizer

Seguendo quanto fatto con la bag-of-words, usiamo il metodo fit_transform della classe CountVectorizer per identificare il vocabolario del corpus e per creare la document-term matrix (con l’occorrenza delle parole):

# Use CountVectorizer to get the tf
from sklearn.feature_extraction.text import CountVectorizer
dataset = fetch_20newsgroups(categories=category_map.keys())
features = CountVectorizer()
X_tf = features.fit_transform(dataset.data)

Abbiamo usato la lettera “X”, perché in data science la convenzione usuale è quella di usare “X” per le feature e “y” per i target (che nel nostro caso sono le categorie). Ricordiamo che fit_transform compie due operazioni:

  1. fit per identificare/imparare il vocabolario con tutte le parole usate in tutti i documenti del corpus;
  2. transform per calcolare la document-term matrix (dtm) inserendo nelle celle l’occorrenza della parola nel documento.

Utilizziamo la bellissima 🙂 funzione che abbiamo creato nell’articolo 5 per visualizzare un estratto della dtm (riportiamo solo i primi 13 documenti per questioni di impaginazione, nella console si vedono tutti):

from NLP_base_library import print_dtm

words_to_print = 5
start_index = 10000 # index of the first word/feature to print
print_dtm(features, X_tf, [start_index, start_index+words_to_print])
print(f"\nDimensions of the dataset: {X_tf.shape}")
# shape = documents x words (observations x features)
-------------------------------------------------------------------------------------------------------------------------------------------
#    |   Word   | Doc-01 | Doc-02 | Doc-03 | Doc-04 | Doc-05 | Doc-06 | Doc-07 | Doc-08 | Doc-09 | Doc-10 | Doc-11 | Doc-12 | Doc-13 | ...
-------------------------------------------------------------------------------------------------------------------------------------------
10001|    bug   |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   1    | ...
10002| bugaboos |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    | ...
10003| bugfixes |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    | ...
10004|  bugged  |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    | ...
10005|  bugger  |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    |   0    | ...
-------------------------------------------------------------------------------------------------------------------------------------------
Dimensions of the dtm: (2968, 40605)

Calcolo della tf-idf con TfidfTransformer

Passiamo adesso a calcolare la tf-idf. Per fare questo usiamo un’altra classe di sklearn, che si chiama TfidfTransformer. Poi usiamo il suo metodo fit_transform, passando come input il risultato dell’operazione precedente:

from sklearn.feature_extraction.text import TfidfTransformer

tfidf_transformer = TfidfTransformer()
X_tfidf = tfidf_transformer.fit_transform(X_tf)
print_dtm(features, X_tfidf, [start_index, start_index+words_to_print])
-------------------------------------------------------------------------------------------------------------------------------------------
  #  |   Word   | Doc-01 | Doc-02 | Doc-03 | Doc-04 | Doc-05 | Doc-06 | Doc-07 | Doc-08 | Doc-09 | Doc-10 | Doc-11 | Doc-12 | Doc-13 | ...
-------------------------------------------------------------------------------------------------------------------------------------------
10001|    bug   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |0.10376935094266815 | ...
10002| bugaboos |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   | ...
10003| bugfixes |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   | ...
10004|  bugged  |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   | ...
10005|  bugger  |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   |  0.0   | ...
-------------------------------------------------------------------------------------------------------------------------------------------

Possiamo notare come i valori della dtm non siano più degli interi (come è giusto che sia per la tf), ma dei float (come è giusto che sia per la tf-idf).

Abbiamo completato la nostra rappresentazione. Adesso ogni elemento del dataset, cioè ogni documento del corpus, è descritto con un vettore di numeri, ciascun numero indica il valore di tf-idf per una parola del vocabolario del corpus. Notiamo che in totale il nostro vocabolario contiene 40.605 parole (originate da un corpus di 2.968 documenti) e che la maggior parte dei valori per ciascun documento è zero.

Addestramento (train) del classificatore di testi

Siamo finalmente pronti ad addestrare il nostro primo modello di machine learning! Lo faremo uando l’algoritmo di classificazione Naive Bayes. Normalmente un data scientist addestra molteplici algoritmi e poi confronta i risultati tra di loro per valutare il modello complessivamente migliore. Per i fini della nostra analisi basta un solo algoritmo. Ancora una volta usiamo sklearn.

from sklearn.naive_bayes import MultinomialNB
our_1st_text_classifier = MultinomialNB()
our_1st_text_classifier.fit(X_tfidf, dataset.target)
# dataset.target is a list that contains the category index, according to the dataset.target_names list

Per addestrare un modello si usa il metodo fit di MultinomialNB, che richiede 2 input:

  1. la dtm (non importa con quale metrica, noi passeremo la tf-idf, ma uno è libero di usare anche la tf, o di inventarsi un’altra rappresentazione ancora, l’algoritmo funziona lo stesso, certo la qualità dei risultati sarà diversa!);
  2. il nome della classe a cui appartiene ciascun documento/vettore della dtm (anche noto come “etichetta”, o “label” in inglese).

Et voilà, il nostro classificatore di testi in Python è pronto! Ancora una volta, è servita una sola riga di codice. Il classificatore è già stato addestrato ed è pronto per essere usato.

Previsioni (nuove classificazioni)

Dato che abbiamo addestrato l’algoritmo, questo significa che abbiamo a disposizione tutto quello che serve per poter fare delle previsioni su nuovi documenti. In questo caso, fare delle previsioni significa classificare documenti che non sono ancora stati classificati.

Dataset di test

Ma quali documenti usiamo per fare la classificazione?

Tra le categorie che il nostro classificatore di testi sa riconoscere c’è anche “Space”, allora ho pensato di prendere degli estratti a caso dal sito della NASA e dell’ESA, usarli come input per il nosto classificatore e vedere come li classifica.

NASA_data = ["Each year, NASA’s International Space Apps Challenge, or Space Apps, engages thousands of people around "
"the world to work with the agency’s open source data in a 48-hour sprint. Since its start in 2012, Space "
"Apps has grown from 25 local events in 17 countries to more than 250 local events in 87 countries and "
"territories. In 2020, the program engaged 26,000 people. Teams of technologists, scientists, designers, "
"entrepreneurs, artists, and others collaborate to answer some of the most pressing challenges on Earth "
"and in space."]
# ref:
# NASA Website: https://www.nasa.gov/press-release/nasa-s-10th-space-apps-challenge-increases-global-participation
# post title: "NASA’s 10th Space Apps Challenge Increases Global Participation"
# extract: second paragraph only
ESA_data = ["Developed under an ESA Partnership Project with satellite operator Eutelsat and prime manufacturer "
"Airbus, Eutelsat Quantum has pioneered a new generation of satellites with the European space industry."
"The flexible software-defined satellite – which will be used by governments and in mobility and data "
"markets – was launched on board an Ariane 5 on 30 July from Europe’s Spaceport in French Guiana."
"It has since reached geostationary orbit some 36 000 km above Earth, where the spacecraft systems "
"checkout was successfully completed."]
# ref:
# ESA website: https://www.esa.int/Applications/Telecommunications_Integrated_Applications/Reprogrammable_satellite_launched
# post title: "Reprogrammable satellite launched"
# extract: first 3 sentences only

Classificazione

Ora che abbiamo anche i nostri documenti da classificare, ho una domanda per voi.

Da un punto di vista logico (e non informatico) quali passi dobbiamo fare per poter applicare il nostro classificatore di testi?

Vi invito a dedicare almeno una trentina di secondi a pensarci un po’ su. Poi continuate la lettura.

Ecco lo schema di come procedere:

  1. trovare il vettore che rappresenta il documento da classificare. Per fare questo serve:
    1. trovare le parole del nostro vocabolario che sono presenti nel documento in esame;
    2. per ogni parola trovata, calcolare il suo valore di tf-idf (la idf è funzione solo del termine ed è stata calcolata durante l’addestramento);
  2. passare il vettore trovato al punto (1) come input al modello addestrato, che applicherà ciò che ha imparato durante il suo addestramento (cioè va a vedere in quali categorie si usano di solito le parole con i punteggi di tf-idf passati in input) e restituirà in output la categoria a cui appartiene il documento rappresentato dal valore di input.

Procediamo ad applicare questi passaggi in Python:

test_data = [NASA_data, ESA_data]
for doc in test_data:
    # 1.1. find the words and their occurences (note that we only use transform, no need for fit now)
    X_test_tf = features.transform(doc)
    # 1.2 compute the tf-idf values (note that we only use transform, no need for fit now)
    X_test_tfidf = tfidf_transformer.transform(X_test_tf)

    # 2. make the prediction
    predicted_category = our_1st_text_classifier.predict(X_test_tfidf)
    # Print the output
    print('\nInput: {0}...\nPredicted category: {1}'.
    format(doc[0][:100], category_map[dataset.target_names[predicted_category[0]]]))

Tre note su questo codice:

  1. come già indicato nei commenti, adesso usiamo solo il metodo transform (e non fit_transform), non abbiamo bisogno di una nuova fase di addestramento (non lo vogliamo proprio!). Il metodo trasform usa il vocabolario conservato dentro “features”;
  2. per applicare un modello addestrato si usa il metodo predict del modello stesso;
  3. il risultato di predict è un numero intero, cioè l’indice della categoria a cui si stima appartenga il documento. Per risalire al nome della categoria usiamo target_names.

Risultato:

Input: Each year, NASA’s International Space Apps Challenge, or Space Apps, engages thousands of people aro...
Predicted category: Space
Input: Developed under an ESA Partnership Project with satellite operator Eutelsat and prime manufacturer A...
Predicted category: Space

Come vedete il classificatore di testi in Python ha funzionato alla perfezione 🙂 ed ha classificato entrambi i nuovi documenti come appartenenti alla categoria Spazio. Vale la pena ribadire che i due documenti su cui abbiamo effettuato la previsione sono assolutamente indipendenti da quelli usati per l’addestramento del classificatore. Hanno anche un formato molto diverso (newsgroup vs blog), quindi forse siamo anche stati fortunati ad avere una classificazione corretta. O no?

Perché non provate voi a fare dei test con dei nuovi contenuti? Riuscite a trovare altri testi per cui la classificazione è corretta? O per cui è invece sbagliata? Ricordatevi che il modello è stato addestrato su 5 categorie e su documenti in inglese.

Conclusioni

Complimenti 🤝

Con questo articolo avete costruito il vostro primo classificatore di testi in Python, si tratta di un sistema che usa il machine learning per capire a quale di 5 categorie appartiene un qualsiasi documento (in inglese). Avete anche gli strumenti per poter estendere il classificatore a 20 categorie invece che 5 (vi basta fare un nuovo addestramento con documenti di tutte e 20 le categorie). A dirla tutta, potete personalizzare il sistema come volete voi, anche per l’italiano e per altre categorie, tutto ciò che vi serve è un corpus di documenti già etichettato 🙂

Ripercorriamo con ordine quanto abbiamo fatto ed imparato:

  1. Definizione della rappresentazione tf-idf
  2. Import di un dataset da sklearn
  3. Calcolo delle feature
  4. Addestramento dell’algoritmo
  5. Previsione (classificazione) su nuovi documenti

L’articolo stesso contiene già il codice che abbiamo prodotto. In ogni caso, il codice per il classificatore di testi, così come tutto quello realizzato finora, è disponbile in modalità Open Source in questo repository pubblico su GitHub.

Per chiunque volesse saperne di più sul tema della classificazione dei testi, in rete si trovano tantissime altre risorse e tutorial. Qui mi limito a fornire dei riferimenti per approfondire gli strumenti usati nel presente tutorial:

  1. Documentazione ufficiale di Scikit Learn riguardo:
    1. il dataset 20newsgroup: https://scikit-learn.org/stable/modules/generated/sklearn.datasets.fetch_20newsgroups.html
    2. la classe TfidfTransformer: https://scikit-learn.org/stable/modules/generated/sklearn.feature_extraction.text.TfidfTransformer.html#sklearn.feature_extraction.text.TfidfTransformer
    3. l’algoritmo Multinomial Naive Bayes: https://scikit-learn.org/stable/modules/generated/sklearn.naive_bayes.MultinomialNB.html
  2. Documentazione ufficiale di Python sui dizionari: https://docs.python.org/3/tutorial/datastructures.html

Il prossimo articolo è ancora dedicato al tema del classificatore di testi in Python e vedremo come organizzare meglio il codice che abbiamo creato qua.

Modifiche

Il codice usato in questo articolo è stato aggiornato il 27 ottobre 2021 in seguito alla modifica della funzione print_dtm e all’introduzione della funzione classify_doclist, effettuate nello step 6.2.