Classificatore di Testi con le Funzioni – NLP di base in Python [6.2 di 9]

Classificatore di testi con le funzioni

In questo articolo costruiamo insieme un classificatore di testi con le funzioni in Python. Nel precedente articolo abbiamo già costruito un classificatore di testi in Python, adesso modifichiamo la struttura di quel codice in modo da utilizzare delle funzioni custom. Faremo anche un paio di altri interventi: uno legato al machine learning, l’altro legato ad un bug trovato nel codice precedente (per quanto uno s’impegni, c’è sempre qualcosa che va storto :P).

Ricordo che questo articolo è parte di una serie sul Natural Language Processing (NLP) di base in Python. La serie è composta da 9 argomenti, questo è il secondo articolo per l’argomento numero 6:

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

Le nostre funzioni

Ma cosa saranno mai queste “funzioni”? Beh, le abbiamo già trattate nello step 3 sulla Lemmatization, dove abbiamo creato la nostra prima funzione all’interno del codice principale. Nello step 4 (sullo Splitting) e step 5 (sulla Bag-of-words) abbiamo poi creato altre funzioni e le abbiamo inserite in un file a parte (NLP_base_library.py), creando di fatto una libreria che può essere usata anche da altri programmi.

In maniera analoga, adesso creiamo delle nuove funzioni e le inseriamo nella nostra personale libreria (o modulo), che continua così ad arricchirsi sempre più. Tali funzioni renderanno il codice più leggibile e lineare, di conseguenza il codice sarà più semplice da evolvere e da mantenere (sarà più semplice trovare bug, risolvere i bug, apportare modifiche migliorative, eccetera).

Il nostro punto di partenza è il classificatore di testi che abbiamo realizzato insieme nel precedente articolo. Per comodità riportiamo di seguito l’intero codice con dei commenti minimali:

from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import CountVectorizer, TfidfTransformer
from NLP_base_library import print_dtm
from sklearn.naive_bayes import MultinomialNB

# 1. DATA 
# import a dataset from sklearn
input_dataset = fetch_20newsgroups(subset='all')
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}')
# Let's work with a subgroup of 5 categories
category_map = {'misc.forsale': 'Sales', 'rec.motorcycles': 'Motorcycles', 'rec.sport.baseball': 'Baseball',
                'sci.crypt': 'Cryptography', 'sci.space': 'Space'}
dataset = fetch_20newsgroups(categories=category_map.keys())
# 2. REPRESENTATION
# - Use CountVectorizer to get the tf
features = CountVectorizer()
X_tf = features.fit_transform(dataset.data)
print(f"\nDimensions of the dtm: {X_tf.shape}")
# Show the DTM using the functions created in article #5
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])
#  - Use TfidfTransformer to get the tf-idf
tfidf_transformer = TfidfTransformer()
X_tfidf = tfidf_transformer.fit_transform(X_tf)
print(f"\nDimensions of the dtm: {X_tfidf.shape}")
print_dtm(features, X_tfidf, [start_index, start_index+words_to_print])

# 3. TRAIN
our_1st_text_classifier = MultinomialNB()
our_1st_text_classifier.fit(X_tfidf, dataset.target)

# 4. PREDICTION
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
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
test_data = [NASA_data, ESA_data]
for doc in test_data:
    X_test_tf = features.transform(doc)
    X_test_tfidf = tfidf_transformer.transform(X_test_tf)
    predicted_category = our_1st_text_classifier.predict(X_test_tfidf)
    print('\nInput: {0}...\nPredicted category: {1}'.
          format(doc[0][:100], category_map[dataset.target_names[predicted_category[0]]]))

Come potete notare, le operazioni nel codice si sviluppano seguendo 4 blocchi funzionali successivi: (1) data, (2) representation, (3) train e (4) prediction.

Per costruire il nostro classificatore di testi con le funzioni procediamo in ordine ed analizziamo un blocco alla volta, per vedere dove e come creare le nostre funzioni. Questo aiuterà a migliorare il codice nel suo complesso.

Prima funzione: get_show_20ng

La prima sezione è quella in cui viene importato il dataset 20newsgroup per addestrare l’algoritmo. Oltre ad importare il dataset, vengono anche mostrate alcune statistiche di base. Considerato che questa è un operazione che ripetiamo più volte durante l’intera esecuzione del codice, procediamo a creare una funzione che esegue entrambe queste operazioni, magari rendendo opzionale il calcolo delle statistiche. Chiamiamo la funzione get_show_20ng e l’aggiungiamo in fondo al file NLP_base_library.py. Ricordiamoci sempre di indentare il contenuto della funzione.

from sklearn.datasets import fetch_20newsgroups

def get_show_20ng(stats=True, **fetch20ng_parameters):
    newsgroup_dataset = fetch_20newsgroups(**fetch20ng_parameters)
    if stats:
        print(f'\tTotal number of documents in the dataset = {len(newsgroup_dataset.data)}')
        print(f'\tTotal number of classes in the dataset = {len(newsgroup_dataset.target_names)}')
        print(f'\tList of all of the classes:\n\t\t{newsgroup_dataset.target_names}\n')
    return newsgroup_dataset

Questa semplice funzione fa due cose:

  1. importa il dataset usando il comando reso disponibile da sklearn (cioè fetch_20newsgroups che si trova dentro il modulo sklearn.datasets),
  2. mostra a schermo 3 informazioni, ma solo se stats = True.

Il contenuto della funzione è quasi un copia e incolla del codice che usavamo in precedenza nel file principale (vedi le righe 8-11 e poi 15). Ci sono solo due cose in più: (1) un check sul valore di stats (impostato a True di default) per decidere se mostrare o meno le statistiche e (2) l’utilizzo di due strani asterischi per il scondo parametro di input …

Variabili multiple in Python: *args e **kwargs

Nonostante la semplicità della funzione, c’è un elemento molto interessante e di grande importanza generale in Python. Si tratta dei due asterischi **, usati nel secondo parametro di input **fetch20ng_parameters. Questa scrittura indica che può essere passato un numero variabile di parametri e che tali parametri sono di tipo keyword, cioè coppie chiave-valore, che vengono salvati in un dizionario. Se avessimo usato un solo asterisco *, allora la funzione avrebbe accettato un numero variabile di parametri di tipo non keyword, quindi non chiave-valore, bensì solo valore, salvati in una lista.

Perché stiamo usando i due asterischi **?

All’interno della nostra funzione get_show_20newsg è presente la funzione di sklearn fetch_20newsgroups. Noi vogliamo che, quando qualcuno chiama la nostra funzione, sia possibile passare in input anche tutti i parametri che accetta la funzione di sklearn. Ad esempio, fetch_20newsgroups accetta subset = "all" come coppia chiave-valore di input. Bene, grazie ai due asterischi facciamo in modo che la nostra funzione accetti questo parametro di input e che poi lo possa usare per chiamare fetch_20newsgroups. Lo stesso vale anche per tutti gli altri parametri che accetta fetch_20newsgroups, come ad esempio categories, shuffle e random_state. Non abbiamo dunque bisogno di ripetere uno per uno tutti i parametri accettati da fetch_20newsgroups.

Attenzione al numero di asterischi

Nei tutorial di Python che si trovano in giro, si utilizzano spesso le espressioni *args e **kwargs, la prima indica un insieme variabile di valori, la seconda un insieme variabile di coppie chiave-valore. Tali espressioni sono usate così spesso che uno finisce per credere che siano dei termini riservati, in realtà l’unica cosa che conta è il numero di asterischi; ciò che segue è semplicemente il nome che noi decidiamo di dare al raggruppamento di variabili. Nel caso della nostra funzione, l’ho chiamato fetch20ng_parameters perché tale insieme è di fatto l’insieme dei parametri che intendiamo passare in input alla funzione fetch_20newsgroups.

Docstring

Le funzioni sono piccoli frammenti di codice pensati per essere utilizzati più volte, sia all’interno dello stesso progetto di lavoro sia in altri progetti. Spesso il riutilizzo avviene molto tempo dopo che la funzione è stata creata. Per questo motivo è davvero importante che le funzioni siano accompagnate da un’adeguata documentazione. La best practice è quella di usare le cosiddette “docstring”, che non sono altro che commenti distribuiti su più righe. L’inizio e la fine di una docstring è indicato dal simbolo di 3 doppi apici """. Tali commenti sono di solito composti da due parti. Nella prima parte si inserisce una descrizione della funzione nella sua generalità, nella seconda invece si descrivono uno per uno i parametri di input accettati ed il risultato che la funzione ritorna.

Documentare il codice tramite docstring è una pratica così diffusa che esistono diversi formati con vere e proprie regole da seguire per scrivere le docstring (ad esempio reStructuredText o NumPy/SciPy docstrings). Ci sono dei sistemi (ad esempio Sphinx) che leggono le docstring e creano in automatico dei documenti .pdf o .html con la documentazione di tutte le funzioni del modulo. Nel repostiory di questa serie trovate un pdf creato automaticamente con Sphinx proprio per le funzioni presenti nella nostra libreria NLP_base_library.py (vi consiglio di guardarlo dopo però, altrimenti vi spoilerate tutte le funzioni che stiamo creando in questo articolo 😉 ).

Tutto questo va ben aldilà degli scopi di questa serie introduttiva all’NLP in Python, però valeva la pena quanto meno introdurre l’argomento, anche perché d’ora in poi ho intenzione di seguire le best practice e inserire le docstring in tutte le nuove funzioni che creiamo 🙂 Se vi interessa saperne di più, trovate alcuni link utili in fondo all’articolo.

Ecco dunque come appare la funzione per la data import che abbiamo appena scritto, con inclusa anche la docstring (in formato reStructuredText, compatibile con Sphinx):

def get_show_20ng(stats=True, **fetch20ng_parameters):
    """
    This function is used to get the "20_newsgroup" dataset and print out some basic statistics about it.

    The dataset is obtained via sklearn.datasets.fetch_20newsgroups. All parameters that can be passed to
    fetch_20newsgroups can also be passed to the current function. This allows, among other things, to decide which
    part of the dataset to import.

    In addition, the current function accepts an extra parameter ("stats") to decide whether or not to show the stats.

    :param stats: use this to choose to show or not some statistics about the dataset
    :type stats: bool, default=True
    :param fetch20ng_parameters: one or more of the named parameters accepted by sklearn.datasets.fetch_20newsgroups
    :type fetch20ng_parameters: keyword parameters of several types

    :return: the "20newsgroups" dataset
    :rtype: dictionary-like object, with the attributes of the "20newsgroups" dataset
    """
    newsgroup_dataset = fetch_20newsgroups(**fetch20ng_parameters)
    if stats:
        print(f'\tTotal number of documents in the dataset = {len(newsgroup_dataset.data)}')
        print(f'\tTotal number of classes in the dataset = {len(newsgroup_dataset.target_names)}')
        print(f'\tList of all of the classes:\n\t\t{newsgroup_dataset.target_names}\n')
    return newsgroup_dataset

Train e Test dataset

Spesso quando si crea un modello di machine learning (ML) si sente parlare di dataset di train o di test. Il concetto è davvero molto semplice e potente al tempo stesso. A me piace accostarlo al cosiddetto “uovo di Colombo”.

Il dilemma

Si tratta della soluzione al problema di valutare la qualità di un modello di ML. Immaginate di aver costruito il vostro modello, addestrandolo su tutti i dati che avete a disposizione (esattamente quello che abbiamo fatto nell’articolo precedente). Come fate per valutare la bontà del modello? Da un lato, fare delle previsioni sui dati usati per l’addestramento sarebbe quasi come imbrogliare; ma dall’altro se le facciamo su un documento mai visto prima, allora come facciamo a sapere se la sua classificazione è corretta? Abbiamo bisogno di sapere la categoria corretta di quel documento per poter dire se il classificatore ha risposto adeguatatamente o no. Ma se conosciamo già la classificazione del documento, allora a cosa serve avere un classificatore?

La soluzione

Un bel dilemma, eh? La soluzione è molto semplice e consiste nel suddividere il dataset a disposizione in due gruppi: uno viene usato per l’addestramento e l’altro per la verifica; il primo è il cosiddetto dataset di train, il secondo di test.

Il parametro subset di fetch_20newsgroup serve proprio a questo. Tale parametro accetta 3 valori possibili: “all” (per selezionare tutti i dati), “train” (per selezionare solo la parte di addestramento, 60% dell’intero dataset) e “test” (per selezionare solo la parte di verifica, 40% dell’intero dataset).

Nel precedente articolo, abbiamo addestrato il classificatore sull’intero dataset e poi, per testarlo, siamo andati a cercare due nuovi documenti in rete. Volevamo degli articoli mai visti prima dal classificatore e che appartenessero ad una delle 5 categorie di addestramento. Alla fine abbiamo scelto la categoria “Space” ed usato un articolo dal blog della NASA ed uno del blog dell’ESA. Adesso, invece, eseguiamo l’addestramento solo su un sottoinsieme del dataset iniziale (cioè sul dataset di train) e poi valutiamo il classificatore sui documenti esclusi dal training (cioè sul dataset di test). Per costruzione, conosciamo la categoria di appartenenza di ciascuno dei documenti del dataset di test, quindi possiamo confrontare la categoria stimata dal classificatore con quella corretta.

Prima di farlo, continuiamo nella nostra missione di costruire un classificatore di testi con le funzioni. Vediamo dunque se c’è qualche altra funzione da aggiungere!

Seconda funzione: create_tfidf_dtm

Il secondo blocco del nostro codice principale è quello in cui si calcola la rappresentazione del modello di ML. Nel nostro caso si tratta di calcolare la document-term matric (DTM) con la metrica tf-idf (che sta per “term frequency – inverse document frequency”, introdotta nell’articolo sulla bag-of-words).

Nel precedente articolo, per creare la matrice tf-idf abbiamo dovuto eseguire una serie di passi:

  • estrarre il vocabolario dai documenti del dataset (con il metodo transform di CountVectorizer)
  • calcolare la tf (con il metodo fit di CountVectorizer)

Questi primi due passi si possono eseguire insieme con il metodo fit_transform di CountVectorizer. L’ultimo passo è poi di

  • calcolare la tf-idf (con il metodo fit_transform di TfidfTransformer)

Questa sembra proprio la situazione ideale da gestire con una nuova funzione! Perché? Ma perché possiamo fare tutti questi passi insieme mettendoli dentro un’unica funzione. Tra l’altro abbiamo già il codice pronto! Vedi blocco 2, righe 17-30.

Ecco la funzione, ovviamente con tanto di docstring 😉 Subito dopo ci sono i miei commenti sugli output.

def create_tfidf_dtm(dataset):
    """
    Given an input dataset, this function computes the tf-idf matrix and returns the features, the tf_idf transformer
    and the tfidf matrix. The returned quantities are useful to train ML models and make predictions.

    :param list dataset: dataset from where to extract features and compute the tf-idf metric. It is a list of strings.

    :return: tfidf, features, tfidf_transformer. tfidf is the document term matrix (dtm) for the input dataset;
        features is a rich object that, among other things, contains the dataset vocabulary; and tfidf_transformer is
        another rich object, it is needed to compute the tf-idf for new documents.
    :rtype: csr matrix, CountVectorizer object, TfidfTransformer object
    """
    # extract the vocabulary (fit) and compute the tf, i.e. create the tf matrix (transform)
    features = CountVectorizer()
    tf = features.fit_transform(dataset)
    # create the tf-idf matrix
    tfidf_transformer = TfidfTransformer()
    tfidf = tfidf_transformer.fit_transform(tf)
    return tfidf, features, tfidf_transformer

Output

Cosa darà in output la funzione?

Come già evidenziato, la funziona nasce per calcolare la tf-idf, così la scelta più naturale dell’output sarebbe la DTM con la tf-idf. Tuttavia, gli oggetti di questa funzione contengono altre informazioni utili. Ricordiamoci inoltre che tutto quello che viene usato dentro una funzione resta dentro la funzione; se lo vogliamo usare anche all’esterno, allora dobbiamo restituirlo nella sezione return. Per questo motivo è utile ritornare in output altri due oggetti, in modo che siano disponibili per le altre operazioni che si possano voler fare nel codice principale, ad esempio fare delle previsioni! Suggerisco dunque di restituire nel complesso queste tre grandezze in output:

  1. tfidf –> questa è la DTM con la tf-idf ed è esattamente quello che volevamo
  2. features –> contiene le parole del vocabolario del dataset usato per addestrare il modello, questo serve poi per fare le previsioni
  3. tfidf_transformer –> oggetto che serve per calcolare la tf-idf, anche questo serve per le previsioni

Terza funzione: classify_doclist

Il terzo blocco di operazioni riguarda l’addestramento del modello di ML, ma questo è già ridotto ai minimi termini! Sono solo 2 righe (vedi righe 32 e 33 del codice riportato ad inizio articolo). Non c’è proprio spazio per usare una funzione anche qua. E’ invece il caso di farne una per la parte delle previsioni, cioè quando classifichiamo un nuovo documento.

Come ben sappiamo il nostro modello di ML è stato addestrato sulla tf-idf, quindi per poter classificare un nuovo documento ha bisogno di conoscere la sua tf-idf. Nella prima versione del nostro classificatore avevamo una serie di operazioni per fare prima il calcolo della tf-idf e poi chiamare il classificatore per fare la previsione. Non sarebbe bello, invece, passare al classificatore soltanto il documento e lasciare che si occupi lui di calcolare la tf-idf? Beh, formalmente questo non è possibile, ma possiamo ottenere lo stesso risultato creando una funzione 🙂 All’interno di questa funzione calcoliamo la tf-idf e poi facciamo la classificazione.

Input

Quali parametri dareste in input a tale funzione? Secondo me ne servono 4. Il primo ve lo dico io: il classificatore addestrato. Quali sono gli altri 3? Uno è proprio banale, gli altri un po’ meno.

Pensateci un po’ su e poi continuate la lettura.

Ecco la lista dei 4 input necessari:

  1. modello di ML già addestrato
  2. lista dei documenti da classificare
  3. oggetto features, per il vocabolario
  4. oggetto TdidfTransformer, per calcolare la tf-idf

Ecco di seguito la funzione per classificare una lista di documenti:

def classify_doclist(doc_list, classifier, features, tfidf_transformer):
    """
    This function classifies a series of input documents, according to the provided classifier, features and tfidf
    transformer. For each input document, the category (target) index is returned.

    :param doc_list: list of documents to classify, each item of the list is a string
    :type doc_list: list
    :param features: it contains the vocabulary used to train the classifier
    :type features: :class:`CountVectorizer`
    :param tfidf_transformer: it contains the idf calculated on the dataset used to train the classifier
    :type tfidf_transformer: :class:`TfidfTransformer`
    :param classifier: trained model to classify new text
    :type classifier: fitted model

    :return: predicted_categories = list of integers, one for each input text; each integer is the index of the
             predicted category
    """
    predicted_categories = []
    for doc in doc_list:
        tf = features.transform([doc])
        X_tfidf = tfidf_transformer.transform(tf)
        # We can now predict the output categories
        predicted_categories.append(classifier.predict(X_tfidf)[0])
    return predicted_categories

Output

Cosa ritorna in output la nostra nuova funzione?

Questo non cambia rispetto alla precedente versione del codice: si tratta dell’indice della categoria di appartenenza. Dato che stiamo classificando una serie di documenti (e non un documento singolo), in output avremo una lista che contiene un indice per ciascuno dei documenti di input (e non un indice singolo).

E con questa terza funzione il nostro classificatore di testi con le funzioni inizia a prendere davvero forma :).

Nota tecnica, sulla retro-compatibilità

La funzione che abbiamo appena creato accetta in input una lista di stringhe, quindi il singolo elemento della lista di input è una stringa ed è su questo che si fa la classificazione. Nella versione precedente del classificatore, invece, la classificazione era fatta su una lista e non su una stringa (vedi riga 54 del codice riportato ad inizio pagina). Per questo motivo, i blogpost della NASA e dell’ESA erano all’interno di parentesi quadre, quindi una lista di un elemento (vedi righe 36 e 44).

In conseguenza di ciò, dobbiamo fare una modifica al codice precedente. Dobbiamo trasformare i due testi su cui fare la classificazione (cioè i due blog post di NASA ed ESA) in stringhe semplici, mentre adesso sono delle liste con dentro una stringa.

Quarta funzione: compute_lists_overlap per l’accuracy

Su quali testi suggerite di usare la funzione classify_doclist che abbiamo appena creato?

Vi ricordate quando abbiamo introdotto il tema di sudividere il dataset iniziale in due parti (train e test)? Beh, è arrivato il momento di sfruttare quella suddivisione. Il dataset di train lo abbiamo utilizzato per addestrare il modello, adesso usiamo il dataset di test per valutarne la qualità.

Come si misura la qualità di un modello di machine learning? Domanda da un milione di dollari! Non esiste una risposta universale. Ci sono tanti modi di valutare un modello ed anche tante metriche. Qua ci limiteremo ad usare il modo forse più intuitivo per un classificatore, cioè il concetto di “classification rate” o “accuracy”. Si tratta di una metrica che misura il rapporto tra le classificazioni corrette e tutte le classificazioni eseguite.

Nel nostro caso abbiamo un classificatore di testi. Immaginiamo di applicarlo a 1000 documenti. Se per 852 di questi documenti la categoria restituita dal classificatore è quella corretta, diremo allora che l’accuracy del nostro classificatore è dell’85.2%.

Come calcolare l’accuracy

E’ chiaro dove vogliamo arrivare, ma come si fa?

Beh, molti elementi sono già pronti. Abbiamo il classificatore, abbiamo il dataset di test ed abbiamo una funzione che fa le classificazioni. Quindi il primo passo è usare classify_doclist su tutti i documenti del dataset di test. Questo conclude la prima metà del lavoro. La metà rimanente è quella di confrontare le classificazioni effettuate con quelle corrette. Indovinate un po’ dove facciamo questo confronto? Ma in una nuova funzione ovviamente! 😀

Le categorie corrette si trovano nell’attributo target del dataset di test (questo già lo sapevamo ed abbiamo usato questa informazione quando abbiamo costruito il classifiatore; in quel caso abbiamo usato il dataset di train, vedi riga 33). Quindi, tutto ciò che deve fare la nostra funzione è confrontare due liste: da una parte la lista che contiene gli indici delle categorie previste dal modello, dall’altra la lista che contiene gli indici delle categorie corrette.

Nel creare tale funzione cerchiamo di generalizzare quanto più possibile, in modo che possa essere usata anche in altri contesti. Creiamo una funzione che accetta in input due liste e che poi le confronta per vedere quanti sono gli elementi in comune. In particolare, la funzione confronta gli elementi delle due liste che si trovano nella stessa posizione, per vedere se sono uguali. Se lo sono allora si tratta di una classificazione corretta, altrimento no. Alla fine si calcola il rapporto tra le classificazioni corrette e tutte le classificazioni e si ritorna questo valore.

def compute_lists_overlap(list1, list2):
    """
    Use this function to compute the relative number of common elements (overlap) between two lists. The lists must
    have the same number of elements. For each index, the function checks whether two elements of the lists are the
    same. It returns the number of same elements over the total number of elements.

    This function can be used to compute the classification accuracy, by providing the list of predicted categories
    and the list of correct categories.

    :param list1: First list to check, e.g. it could be the list with the values predicted by the classifier
    :type list1: list
    :param list2: Second list to check, e.g. it could be the list with the correct values
    :type list2: list
    :return: Relative number of common elements between the (2) input lists
    :rtype: float
    """
    # Check list size
    size1 = len(list1)
    if not len(list2) == size1:
        print('The two input lists should have the same size')
        print(f'Instead we have: list 1 (e.g. predictions) = {size1}, list 2 (e.g. targets) = {len(list2)}')
        return
    # Compute the overlap, i.e. number of common elements
    right = 0
    wrong = 0
    for pred, tar in zip(list1, list2):
        if pred == tar:
            right += 1
    accuracy = right / size1
    # # use list comprehension to compute the overlap
    # common2 = [i for i in range(size1) if list1[i] == list2[i]]  # contains the indexes of common elements
    # accuracy2 = len(common2)/size1
    # common3 = [1 for x in zip(list1, list2) if x[0] == x[1]]  # contains a 1 for each element in common
    # accuracy3 = len(common3)/size1
    return accuracy

Potete notare che, in fondo alla funzione, ho inserito un paio di metodi alternativi, che utilizzano le list comprehension. Questione di gusti.

Bonus: print_dtm

Mentre lavoravo a questo articolo, mi sono accorto che la nostra bella funzione print_dtm aveva bisogno di almeno un paio di ritocchi, così l’ho modificata ed ho anche creato la relativa docstring. Ecco i due interventi:

  1. migliorare il formato dell’output in caso di valori decimali. La funzione è stata scritta quando la DTM conteneva la tf, quindi i valori delle celle erano degli interi. Adesso invece la DTM contiene la tf-idf ed i valori delle celle sono dei numeri decimali. Ho dunque (a) modificato il formato di output in modo da controllare il numero di cifre decimali del float che vengono mostrate e (b) aumentato il numero di caratteri totali per mostrare il contenuto di una cella.
  2. consentire di filtrare la matrice anche per colonna (documenti). Nella prima versione, la funzione consentiva di selezionare quali righe/parole della DTM mostrare. Ho pensato che poteva essere utile scegliere anche quali colonne/documenti. In entrambi i casi, bisogna scegliere il valore di partenza e quante righe o colonne sucessive si vogliono mostrare.

La modifica 2, cioè il filtro per colonna, ha cambiato i parametri che accetta la funzione in input, dato che adesso bisogna specificare quali colonne mostrare. Per questo motivo ho modificato tutti i codici che chiamano questa funzione, cioè il codice prodotto nello step 5 (bag-of-words) e nello step 6.1 (classificatore).

Analizzando in dettagli i valori mostrati a schermo nella matrice, mi sono anche accorto di un bug! Non so se qualcuno l’aveva notato, ma c’era un errore nei valori che venivano mostrati nelle celle della matrice. Sono così intervenuto e la nostra funzione adesso è davvero più bella e brava che mai!

Mettendo tutto insieme

Siamo arrivati alla fine dei quattro blocchi di operazioni eseguite dal nostro codice ed abbiamo creato ben quattro nuove funzioni.  Ora sì che possiamo dire di avere “un classificatore di testi con le funzioni” 😛 Adesso è arrivato il momento di mettere tutto insieme e di valutare il risultato dei nostri sforzi!

from NLP_base_library import print_dtm, get_show_20ng, create_tfidf_dtm, classify_doclist, compute_list_overlap
from sklearn.naive_bayes import MultinomialNB

# DATA (new function 1)
# use a function to get the dataset and print basic statistics about it
print('Importing the whole dataset (for all categories):')
input_dataset = get_show_20ng(subset='all')
category_map = {'misc.forsale': 'Sales', 'rec.motorcycles': 'Motorcycles',
                'rec.sport.baseball': 'Baseball', 'sci.crypt': 'Cryptography',
                'sci.space': 'Space'}
print('Importing the train dataset (for 5 categories):')  # 60%
training_data = get_show_20ng(subset='train', categories=category_map.keys(), shuffle=True, random_state=7)

# REPRESENTATION (new function 2 + improve existing function)
X_train_tfidf, features, tfidf_transformer = create_tfidf_dtm(training_data.data)
# print the tf-idf
words_to_print = 5
first_word_index = 6631  # index of the first word/feature to print (start from 0)
docs_to_print = 6
first_doc_index = 5  # index of the first document to print (start from 0)
print(f'Printing an extract of the dtm (for {words_to_print} words and {docs_to_print} documents)')
print_dtm(features, X_train_tfidf, [first_word_index, first_word_index + words_to_print],
          [first_doc_index, first_doc_index + docs_to_print])

# ML MODEL (new function 3 and 4)
# - Train (no new function)
# Train a Naive Bayes classifier on the training data (no need for a function here)
our_classifier = MultinomialNB().fit(X_train_tfidf, training_data.target)
# - Evaluate (new function 3 and 4)
print('Importing the test dataset')
test_dataset = get_show_20ng(subset='test', categories=category_map.keys())  # 40%
test_data = test_dataset.data
# Use a new function to classify all documents in the test dataset
print('Evaluating the classifier ...')
predicted_categories = classify_doclist(test_data, our_classifier, features, tfidf_transformer)
# Use a new function to compute the classification rate
correct_categories = test_dataset.target
accuracy = compute_list_overlap(predicted_categories, correct_categories)
print(f'Number of correct classifications over total classifications (classifier accuracy) = {100*accuracy:5.2f}%')

Come vi sembra? Io sono molto soddisfatto del risultato ottenuto 🙂 Anche l’accuracy del 94.99% non è niente male 😉

Il codice principale è adesso più corto e lineare e, di conseguenza, più leggibile. La libreria si è triplicata in dimensione, passando da 2 a 6 funzioni in totale. Cinque di queste sono ben documentate con docstring; alcune sono anche abbastanza generali e quindi facilmente riutilizzabili in futuro su altri progetti.

Nel suo complesso, il codice è adesso più facile da capire. Questo, a sua volta, rende alcune cose molto importanti più semplici e agevoli di prima, come ad esempio: trovare e risolvere i bug, trovare ed implementare spunti di miglioramento, spiegare il codice ad altri.

Conclusioni

Con questo articolo abbiamo proprio fatto un bel refactoring del codice! Abbiamo cioè apportato delle modifiche molto importanti alla struttura del codice senza intaccare le funzionalità dello stesso. In altre parole: il codice fa ancora le stesse cose di prima (classificazione di testi), ma adesso lo fa con una migliore struttura interna (con le funzioni). Abbiamo creato un classificatore di testi con le funzioni.

  1. Abbiamo creato 4 nuove funzioni
    1. get_show_20ng per importare il dataset 20_newsgroup da sklearn e mostrare delle statistiche di base
    2. create_tfidf_dtm per calcolare la document-term matrix con la rappresentazione tf-idf
    3. classify_doclist per classificare una lista di documenti
    4. compute_list_overlap per calcolare l’accuracy del classificatore (o più in generale la sovrapposizione tra gli elementi di due liste ordinate)
  2. abbiamo introdotto le docstring
  3. abbiamo aggiunto un paio di funzionalità e risolto un bug della nostra funzione preferita: print_dtm
  4. abbiamo imparato ad usare *args e **kwargs per passare un numero variabile di parametri ad una funzione
  5. abbiamo anche introdotto un importante concetto di machine learning: la suddivisione del dataset in train e test per la valutazione di un modello.

Davvero tanta roba: complimenti 🤝!

Tutto il codice che abbiamo generato è riporato in questo stesso articolo e tutto quello realizzato finora nella serie è disponibile in questo repository pubblico su GitHub con licenza Open Source. Tra l’altro, sono stati aggiornati anche un paio di file generati in precedenza (5.1_bag_of_words.py e 6.1_text_classifier.py).

Di seguito alcuni link utili per chi volesse approfondire o recuperare dei dettagli aggiuntivi (la rete è piena di tutorial su questi temi di base):

  1. *args e **kwargs https://realpython.com/python-kwargs-and-args/
  2. Docstring Formats https://realpython.com/documenting-python-code/#docstring-formats
  3. Docstrings in Sphinx https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html
  4. Sphinx https://sphinx-rtd-tutorial.readthedocs.io/en/latest/index.html

Buone letture e, se vorrete, ci becchiamo al prossimo articolo!