Classificatore di Genere – NLP di base in Python [7 di 9]

Classificatore di Genere

In questo articolo costruiamo insieme un classificatore di genere in Python. Per essere più precisi, scriviamo codice Python per determinare automaticamente se un nome è maschile o femminile.
Questo articolo è parte di una serie introduttiva sul Natural Language Processing (NLP) in Python. La serie è composta da 9 argomenti, questo è  l’argomento numero 7:

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

Cosa è un classificatore di genere?

Ormai abbiamo preso un bel po’ di confidenza con il concetto di “classificatore”. Infatti, gli ultimi 3 articoli (Costruire un Classificatore di Testi [6.1 di 9]Classificatore di Testi con le Funzioni [6.2 di 9]Classificatore di Testi con gli Oggetti [6.3 di 9]) sono stati dedicati alla costruzione di un classificatore di testi, vale a dire un sistema automatizzato per capire di cosa tratta un certo documento (senza bisogno di leggerlo ovviamente!). Per poterlo fare, abbiamo avuto bisogno di un corpus di documenti etichettato, vale a dire che per ogni documento del corpus sapevamo l’argomento e inoltre potevamo accedere al testo integrale del documento.

Adesso realizziamo un classificatore di altro tipo, ma sempre nell’ambito dell’NLP. Realizziamo un classificatore di genere, cioè un sistema per identificare se un nome è maschile o femminile analizzando le ultime lettere del nome stesso. Quindi abbiamo bisogno di un dataset di nomi e che riporti anche il genere di ciascun nome.

Dati

Per il classificatore di documenti abbiamo usato il dataset 20newsgroups di sklearn; per il classificatore di genere, invece, usiamo il dataset names di NLTK. Per poter utilizzare il corpus, dobbiamo prima scaricarlo e poi importarlo all’interno del codice. Ci sono due modi di scaricare un dataset, a seconda da dove lo facciamo (come abbiamo già visto nel primo articolo della serie: Tokenization [1 di 9]):

  1. da terminale: python -m nltk.downloader names
  2. dalla console di Python: nltk.download(‘names’)

Una volta scaricato il dataset, lo troveremo in nltk_data/coprora/names. Nel caso specifico di questo corpus, nel path indicato troviamo due file di testo, chiamati female.txt e male.txt; nel primo vi sono i nomi femminili, nel secondo i nomi maschili. Attenzione: i nomi sono in inglese.

Tramite alcuni comandi specifici, possiamo ottenere i nomi dei file ed il loro contenuto direttamente da Python, come mostrato nel seguente codice:

from nltk.corpus import names

# 1. Get the data
# We use the dataset "names" from NLTK package.
# filenames can be obtained with the method "fileids"
# each text file is just a sequence of names, one name on each line
filenames = names.fileids()
# access the names inside the files with the method "words"
female_names = names.words(filenames[0])
male_names = names.words(filenames[1])
# print values
print(f'Filenames: {filenames}')
random.seed(108)
print(f'Female names: {random.sample(female_names, 10)}')
print(f'Male names: {random.sample(male_names, 10)}')

Di seguito il contenuto delle tre variabili (output del codice precedente). Per i nomi mostriamo 10 elementi a caso, scelti usando la funzione random.sample. Impostiamo il seed ad un valore fisso e (in questo caso 108) in modo che i 10 elementi “a caso” siano sempre gli stessi, altrimenti per ciascuno di noi che fa girare il codice compaiono nomi diversi (se vi va di fare una prova, commentate la riga con il comando random.seed e vedrete che ad ogni run del codice il print mostrerà nomi diversi):

Filenames: ['female.txt', 'male.txt']
Female names: ['Constantia', 'Calida', 'Lyda', 'Isahella', 'Lorilyn', 'Emily', 'Britani', 'Joana', 'Fedora', 'Eda']
Male names: ['Ferd', 'Zacharia', 'Jed', 'Erhart', 'Christie', 'Erek', 'Brewster', 'Schuyler', 'Micky', 'Cecil']

Se non fosse chiaro, il dataset contiene nomi inglesi.

Trasformazione dei dati

I dati sono pronti, o no? Beh, quasi. E’ vero che abbiamo tutti i contenuti che servono, ma questo non basta quasi mai. Una grande parte del lavoro del data scientist in effetti sta proprio in questa fase di trasformazione dei dati. Trasformazione per cosa? Sostanzialmente per due motivi: da un lato per facilitare l’esplorazione dei dati e la loro eventuale integrazione con altre sorgenti; dall’altro lato per compatibilità con gli algoritmi che s’intende utilizzare.

Dataframe in pandas

Un pacchetto estremamente comune per chi fa data science con Python è pandas. Con pandas i dati vengono di solito gestiti in tabelle chiamate dataframe. Procediamo dunque ad importare questo pacchetto e creiamo un dataframe con i dati del nostro dataset (nome e genere).

import pandas as pd

# 2. Transform the Data
# Create a dataframe with 2 columns: name + gender
gender_names = pd.DataFrame({'name': female_names+male_names, 
                             'gender': ['female']*len(female_names) + ['male']*len(male_names)})
# print some general stats
print('\nDataframe overview:')
print(gender_names.describe())

Grazie al comando DataFrame di pandas possiamo creare agevolmente il dataframe per il nostro classificatore di genere, che in questo caso chiamiamo gender_names. In questo dataframe ci sono 2 colonne, la prima si chiama “name” la seconda “gender”. Ci sono diversi modi di creare un dataframe, in questo caso lo facciamo passando un dizionario. Il dizionario deve avere come chiave il nome della colonna, e come valore una lista con il contenuto della colonna.

Analizziamo più da vicino come sono state create le liste per i valori delle chiavi del dizionario. Per la colonna “name” facciamo la somma di due liste: female_names + male_names. Questa somma è a sua volta una lista, che contiene prima tutti i nomi femminili e poi tutti quelli maschili. Nella colonna “gender” vogliamo il genere del nome, quindi vogliamo la stringa “female” per i nomi femminili e la stringa “male” per i nomi maschili. Dato che nella tabella ci sono prima tutti i nomi di donna e poi tutti i nomi di uomo, dobbiamo costruire una lista che abbia prima tanti “female” quanto sono i nomi di donna, e poi tanti “male” quanti sono i nomi di uomo. Per fare questo usiamo il codice riportato nella riga 6.

Overview di un dataframe con describe

Una volta che i nostri dati sono dentro il dataframe possiamo subito godere di alcuni benefici. Il metodo describe, ad esempio, fornisce automaticamente una descrizione quantitativa delle colonne. Ecco il suo output nel nostro caso:

Dataframe overview:
        name  gender
count   7944    7944
unique  7579       2
top      Sal  female
freq       2    5001

Esplicitiamo di seguito le informazioni che ricaviamo per ciascuna riga del describe:

  1. count: entrambe le colonne hanno 7944 elementi
  2. unique: ci sono 7579 nomi unici e 2 generi unici
  3. top: il nome più frequente è “Sal”, il genere più frequente è quello “femminile”
  4. freq: il numero di occorrenze del nome più frequente (“Sal”) è 2, e del genere più frequente (“femminile”) è 5001
    1. Dato che ci sono 7579 nomi univoci (365 in meno rispetto al totale) e quello più frequente ha due occorrenze, deduco che ci sono esattamente 365 nomi che compaiono due volte (ma non ho verificato, perché non lo fate voi?).
    2. Dato che ci sono 7944 elementi, 2 generi unici e 5001 nomi sono femminili, deduco che ci sono 2943 nomi classificati come maschili (questo l’ho verificato :P)

Qualora le nostre colonne fossero state numeriche e non categoriche (stringhe) come in questo caso, allora il describe ci avrebbe fornito altre informazioni, quali media, deviazione standard e quantili.

Creazione delle feature

Siamo adesso pronti a creare la nostra feature, vale a dire quella grandezza che vogliamo che il classificatore di genere usi per stimare appunto il genere. Decidiamo di usare le ultime 3 lettere del nome. Quindi abbiamo bisogno di calcolare le ultime 3 lettere per ciascuno dei nostri nomi del dataframe. Questo è abbastanza semplice, perché Python tratta le stringhe un po’ come delle liste, cioè la stringa ciao può essere vista come una lista che contiene come elementi le lettere della parola “ciao”, quindi ['c', 'i', 'a', 'o'].

Procediamo dunque a calcolare questa feature e l’aggiungiamo al nostro dataframe come una nuova colonna, che chiamiamo “name3” per ovvi motivi.

# 3. Create the features
# our feature = latest 3 letter of the name
# to be stored in the dataframe as a column named name3
letters = 3
name3 = [name[-letters:] for name in gender_names['name']]
gender_names['name3'] = name3
print('\nFirst lines of the dataframe:')
print(gender_names.head())

Il calcolo della feature viene fatto con una sola riga di codice (la numero 5), grazie alla possibilità di usare un numero negativo nello slicing della lista e alla list comprehension per fare il ciclo for. Spendiamo due parole in più sul primo punto giusto per essere più chiari (mentre le list comprehension le abbiamo già discusse nell’articolo sullo stemming [2 di 9]). Se avessimo una lista che si chiama “ingredienti”, e due interi positivi “a” e “b”, allora con il comando ingredienti[a:b] accediamo alla porzione della lista che va dall’elemento con indice a fino a quello con indice b-1. Invece, con il comando ingredienti[a:] accediamo alla porzione della lista che va dall’elemento con indice a fino alla fine. Cosa vi aspettate che succeda se usassimo ingredienti[-a:]? Molto semplice, la porzione di lista a cui accediamo arriva sempre fino alla fine, ma per definire l’indice del punto di partenza non si conta dall’inizio, bensì dalla fine! Quindi “-1” significa l’ultimo termine, “-2” il penultimo, e così via.

Con l’ultima riga decidiamo di dare un’occhiata ai primi 5 elementi del dataframe, eccoli:

First lines of the dataframe:
      name  gender name3
0  Abagael  female   ael
1  Abagail  female   ail
2     Abbe  female   bbe
3    Abbey  female   bey
4     Abbi  female   bbi

Una funzione per calcolare la feature

Per rendere il codice più modulare, suggerisco eseguire il calcolo della feature all’interno di una funzione dedicata, che andrà ad arricchire la libreria che stiamo costruendo insieme durante questa serie. Colgo l’occasione anche per applicare un piccolo miglioramento al calcolo della feature: portare tutto il testo in minuscolo. Infatti, potrebbe capitare che ci siano nomi di 3 lettere, in quel caso la prima sarebbe in maiuscolo. Eseguire il calcolo della feature in una funzione dedicata è una buona idea anche in termini di mantenibilità ed espandibilità del codice (come abbiamo già visto nell’articolo Classificatore di Testi con le Funzioni [6.2 di 9]).

Ecco di seguito il codice della funzione da aggiungere nel nostro pacchetto NLP_base_library.py:

def get_latest_letters(word, N):
    result = word[-N:].lower()
    return result

Di conseguenza il nostro codice principale in 7_gender_classifier.py diventa:

from NLP_base_library import get_latest_letters

name3 = [get_latest_letters(name, letters) for name in gender_names['name']]
gender_names['name3'] = name3

Input per il classificatore di genere

Sarò sincero, quando ho iniziato a srivere l’articolo questa sezione non c’era, perché normalmente quando i dati sono in un dataframe c’è poco altro da fare per iniziare la fase di train dell’algoritmo. Qui però siamo davanti ad una piccola complicazione, dovuta al fatto che l’NLP non è proprio il più semplice dei argomenti con cui cimentarsi sul machine learning!

Il linguaggio tecnico di NLTK

Andiamo con ordine. Intanto definiamo l’algoritmo da usare nel nostro classificatore di genere. Suggerisco di usare Naive Bayes. Questo algoritmo (così come molti altri) si aspetta di ricevere in input delle feature numeriche. Noi cosa abbiamo come feature? Nel nostro caso, le feature sono le ultime 3 lettere di un nome, dunque le feature sono delle stringhe, non dei numeri. Normalmente, questa situazione viene affrontata facendo il cosiddetto “encoding” delle feature, in cui ad ogni stringa viene associato un numero. Si tratta di un argomento non banale che non intendo trattare in questa serie. Qualora decidessimo di utilizzare l’implementazione di Naive Bayes presente in sklearn, allora dovremmo effettuare l’encoding. Se invece usiamo Naive Bayes presente in NLTK non c’è bisogno di fare l’encoding, dobbiamo però preparare i dati in modo diverso rispetto al solito. Ecco, adesso faremo proprio questo.

Per capire in che modo preparare i dati, conviene richiamare il linguaggio tipico di NLTK. Non temete, è molto semplice e gli elementi chiave sono i seguenti:

  • token: la parola di partenza, nel nostro caso il nome;
  • feature: ciò che viene usato per addestrare l’algoritmo, nel nostro caso le ultime 3 lettere del nome;
  • category/class: i valori possibili del target, nel nostro caso il genere maschile o femminile;
  • featureset: struttura dati che si riferisce al token e contiene tutte le feature ad esso associate. Si tratta di un dizionario che contiene il nome delle feature ed il loro valore. Con un esempio sarà tutto più chiaro: nel nostro caso abbiamo una sola feature, che possiamo chiamare “name3”, ed il featureset per il token Alice è: {'name3': 'ice'} (ovviamente possiamo chiamare la feature come vogliamo, l’importante è usare sempre lo stesso nome per ogni token);
  • feature detector/extractor: funzione che restituisce il featureset del token passato in input.

Costruiamo l’input

Siamo ora pronti per definire l’input da usare per il training del classificatore.

L’input è una lista che contiene una tupla di 2 elementi per ciascun token. La tupla contiene il featureset come primo elemento e la classe come secondo. Esempio: per il nome Abagael la tupla è ({'name3': 'ael'}, female) (per chi non lo sapesse Abagael è un nome di genere femminile).

Procediamo a calcolare la lista che serve per il training. Per farlo creiamo prima un’altra colonna nel dataframe, dove salviamo la tupla appena definita (in totale avremo adesso 4 colonne).

# 4. Build the model

feature_name = 'name3'
train_tuple = []
for name3, gender in zip(gender_names['name3'], gender_names['gender']):
    featureset = {feature_name: name3}
    train_tuple.append((featureset, gender))
gender_names['train_tuple'] = train_tuple
print('\nFirst rows of the full dataframe:')
print(gender_names.head())

Nell’output possiamo vedere che il risultato è quello atteso:

First rows of the full dataframe:
      name  gender name3                 train_tuple
0  Abagael  female   ael  ({'name3': 'ael'}, female)
1  Abagail  female   ail  ({'name3': 'ail'}, female)
2     Abbe  female   bbe  ({'name3': 'bbe'}, female)
3    Abbey  female   bey  ({'name3': 'bey'}, female)
4     Abbi  female   bbi  ({'name3': 'bbi'}, female)

Train del classificatore di genere

Come introdotto nell’articolo Classificatore di Testi con le Funzioni [6.2 di 9], quando si crea un modello di Machine Learning si suddivide il dataset in due parti: una la si usa per l’addestramento ed una per il test (questo principio viene poi ripetuto in maniera “intelligente” nella cosiddetta K-fold Cross Validation, che però non è oggetto di studio in questa serie). Procediamo dunque, utilizzando una specifica funzione di sklearn, train_test_split.

from sklearn.model_selection import train_test_split

# Create a dataframe for the model following the usual ML naming convention
X = gender_names['train_tuple'].copy()
y = gender_names['gender'].copy()

# split the dataset in train and test
test_size = 0.2
random_seed = 351
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=test_size, random_state=random_seed)

Secondo una convenzione non scritta del ML, si usano le variabili X e y per indicare rispettivamente le feature ed il target del modello, da cui le righe 4 e 5 del codice. L’ultima riga è quella che fa lo split; train_test_split restituisce 4 output, in quanto suddivide sia X sia y in 2 parti, una per train ed una per test. Il parametro test_size indica la dimensione percentuale del dataset di test rispetto al totale dei dati, il parametro random_state serve per rendere i risultati riproducibili (dato che lo split del dataset avviene a caso).

Ad essere pignoli, noi non stiamo seguendo la convenzione al 100%, in quanto nella nostra X ci sono sia le feature (ultime 3 lettere del nome) sia il target (valore della classe), cosa che rende la variabile y praticamente inutile. Questo lo facciamo perché siamo costretti dalla libreria Naive Bayes di NLTK che richiede i dati di input in questa maniera. Ho deciso comunque di seguire per quanto possibile l’approccio standard perché penso che lo ritroverete molte volte nel vostro lavoro di Data Scientist.

A questo punto non resta che addestrare il modello del classificatore di genere. Dopo aver preparato tutto in maniera così puntigliosa, questo ormai è un gioco a ragazzi e lo facciamo con una singola e semplice riga di codice (che, come vedete, usa solo X e non y):

from nltk import NaiveBayesClassifier

gender_classifier = NaiveBayesClassifier.train(X_train.tolist())

Unica accortezza: ricordiamoci che Naive Bayes in NLTK richiede una lista come input e non un dataframe, quindi trasformiamo la colonna X_train in una lista e la passiamo in input.

Test del classificatore di genere

Benissimo, modello realizzato. Adesso come facciamo ad usarlo per vedere se funziona? Semplice, usiamo il metodo classify dell’oggetto dove abbiamo conservato il nostro classificatore di genere. Tale metodo richiede come input il featureset del token sul quale vogliamo lavorare. Nella variabile X_test abbiamo già tutti i dati che ci servono. Eseguiamo allora il test sul primo elemento disponibilie:

# 5. Test the model
# Test the classifier on the first name of the test dataset (not used for training)
featureset_of_test1 = X_test.tolist()[0][0]
test1 = gender_classifier.classify(featureset_of_test1)
print(f'Token: {gender_names.loc[X_test.index[0], "name"]}, Featureset: {featureset_of_test1} '
f'==> Prediction: {test1} (true value: {X_test.tolist()[0][1]})')

Come vedere dalla riga 3 servono due parentesi quadre con zero per accedere al featurest. Con la prima identifichiamo il primo elemento della lista X_test (cioè il primo nome di test), con la seconda identifichiamo il primo elemento della tupla (cioè il featureset). Nell’ultima riga ho voluto stampare a schermo un riepilogo con tutte le informazioni relative al dato su cui stiamo applicando il classificatore, cioè il token (nome), il featureset (le ultime 3 lettere del nome), la previsione (output del classificatore) ed il true value (valore corretto, che in questo caso è noto a priori).

Token: Doretta, Featureset: {'name3': 'tta'} ==> Prediction: female (true value: female)

In questo caso l’output del classificatore di genere è stato corretto. Possiamo dunque dedurre che sarà corretto sempre? Ovviamente no, per questo motivo testiamo il classificatore su tutti i nomi del dataset di test e calcoliamo il “classification rate”, cioè il numero di volte che ha fatto la previsione corretta rispetto al totale delle previsioni.

Abbiamo sicuramente le competenze per poter scrivere il codice per fare tutte queste operazioni, ma perché farlo quando NLTK ha una funzione realizzata apposta per questo?

from nltk.classify import accuracy

# Compute accuracy, performing test over the whole test dataset, using the nltk accuracy function
accuracy_clf1 = accuracy(gender_classifier, X_test.tolist())
print(f'Overall classification rate (when using latest 3 letters to predict) = {100*accuracy_clf1:5.3f}%.')

Risultato:

Overall classification rate (when using latest 3 letters to predict) = 75.393%.

Valori più informativi

Prima di concludere, vorrei usare con voi un comando molto interessante di NLTK, show_most_informative_features, che consente di vedere immediatamente quali sono i valori più informativi delle feature usate nel classificatore. Nel nostro caso la feature sono le ultime 3 lettere del nome. Ecco di seguito il comando (solo 1 riga di codice) ed a seguire l’output.

gender_classifier.show_most_informative_features()
Most Informative Features
                   name3 = 'nne'          female : male   =     29.2 : 1.0
                   name3 = 'ard'            male : female =     25.6 : 1.0
                   name3 = 'ana'          female : male   =     24.0 : 1.0
                   name3 = 'ita'          female : male   =     22.7 : 1.0
                   name3 = 'son'            male : female =     18.8 : 1.0
                   name3 = 'vin'            male : female =     17.0 : 1.0
                   name3 = 'ert'            male : female =     15.7 : 1.0
                   name3 = 'lle'          female : male   =     14.2 : 1.0
                   name3 = 'old'            male : female =     13.9 : 1.0
                   name3 = 'ria'          female : male   =     12.3 : 1.0

Questo mostra che il valore ‘nne’ è quello più informativo. Viene riportata anche una misura dell’informazione: i nomi che finiscono in ‘nne’ sono femminili 29.2 volte più spesso che maschili.

Lavoro concluso. Complimenti 🙂

Abbiamo realizzato un sistema capace di classificare automaticamente un nome in maschile o femminile in base alle sue ultime 3 lettere. Il classificatore di genere ha un’accuratezza del 75% circa, cioè le sue previsioni sono corrette 3 volte su 4.

Bonus: Generalizziamo

Lo ammetto, mentre scrivevo il codice per questo articolo mi sono un po’ lasciato prendere la mano ed ho scritto molto più del necessario. Ma una volta che il codice è stato scritto (e testato) ho pensato che era un peccato non condividerlo! Per questo motivo ho creato questa sezione bonus, only for the bravest 😉

Bonus 1: Quante lettere scegliere?

Nel nostro modello usiamo come feature le ultime 3 lettere del nome. Ma perché 3 e non 4? Magari se usiamo più lettere il classificatore di genere potrà raggiungre un’accuracy migliore? Domanda legittima, come possiamo fare per verificarlo? Il modo migliore è con un test sul campo. Intendo dunque creare un nuovo classificatore di genere che usa come feature le ultime 4 lettere del nome anziché le ultime 3.

Uhm … e se volessi provare con 5 o 6? A questo punto suggerisco di generalizzare al caso in cui usiamo le ultime N lettere del nome. Costruiamo un ciclo for e in ciascun ciclo calcoliamo la feature, facciamo il training e calcoliamo l’accuratezza. Alla fine del loop avremo un valore di accuracy per ogni valore di N e potremo scegliere il valore di N che ha la massima accuracy! Bello no?

Prima di proseguire con la lettura, vi invito a buttare giù quattro righe per provare ad implementare la strategia appena descritta. Non deve essere necessariamente codice Python funzionante, va bene anche pseudocodice con carta e penna.

Classificatore con le ultime N lettere del nome

Di seguito trovate la mia versione.

print(f'Letters => Classification rate')
for letters in range(1, 11):
    # compute the feature values
    feature_name = 'name-'+str(letters)
    new_col = []
    for name in gender_names['name']:
        new_col.append(get_latest_letters(name, letters))
    gender_names[feature_name] = new_col # add a column for each value of N
    # if we don't want that, we can overwrite the feature always on the same column.
    # build the train_tuple
    train_tuple = []
    for feature, gender in zip(gender_names[feature_name], gender_names['gender']):
        featureset = {feature_name: feature}
        train_tuple.append((featureset, gender))
    gender_names['train_tuple'] = train_tuple
    # split train-test
    X = gender_names['train_tuple'].copy()
    # y = gender_names['gender'].copy()  # not needed with nltk Naive Bayes
    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=test_size, random_state=random_seed)
    # train the model
    clf_loop = NaiveBayesClassifier.train(X_train.tolist())
    # evaluate classification rate
    accuracy_clf_loop = accuracy(clf_loop, X_test.tolist())
    print(f'{letters:7} => {100 * accuracy_clf_loop:5.3f}%')

In questa versione si usano i valori di N da 1 a 10. N = 1 significa che la feature è solo l’ultima lettera del nome. N = 10 significa che prendiamo le ultime 10 lettere del nome. Qualora il nome fosse più corto di N lettere si prende il nome intero.

Ecco i risultati mostrati a schermo dal codice sopra riportato:

Letters => Classification rate
      1 => 74.072%
      2 => 77.659%
      3 => 75.393%
      4 => 70.296%
      5 => 65.198%
      6 => 62.429%
      7 => 60.919%
      8 => 60.478%
      9 => 60.227%
     10 => 60.227%

Da cui si deduce che quando N = 2, l’accuracy ha il valore massimo (vi invito a riportare i valori in un grafico).

Piccola nota: i valori esatti di accuracy dipendono da come suddividiamo il dataset in train e test. Per questo motivo ho ripetuto l’intera procedura per 3 diversi valori di random_state, e quindi tre diversi modi di suddividere i dati in train e test (sempre con train_size = 0.2). I valori di accuracy sono leggermente diversi ogni volta, ma l’ordine dei primi tre è sempre lo stesso. Quindi N = 2 è sempre risultato quello con la massima accuracy, N = 3 quello con la seconda e N = 1 quello con la terza.

Valori di feature più informativi

Ecco i valori più informativi per questo nuovo modello:

Most Informative Features
        latest-2-letters = 'na'           female : male   =     86.3 : 1.0
        latest-2-letters = 'ta'           female : male   =     39.1 : 1.0
        latest-2-letters = 'ia'           female : male   =     35.5 : 1.0
        latest-2-letters = 'us'             male : female =     33.4 : 1.0
        latest-2-letters = 'ra'           female : male   =     32.5 : 1.0
        latest-2-letters = 'rd'             male : female =     28.6 : 1.0
        latest-2-letters = 'sa'           female : male   =     28.4 : 1.0
        latest-2-letters = 'rt'             male : female =     28.1 : 1.0
        latest-2-letters = 'ld'             male : female =     20.2 : 1.0
        latest-2-letters = 'os'             male : female =     17.1 : 1.0

Questi valori sono da confrontare con quelli del modello con le ultime 3 lettere che abbiamo calcolato prima. Come vedete adesso i valori in cima alla lista sono molto più significativi di prima. Per maggiore chiarezza, rileggiamo insieme il significato per un paio di questi valori:

  1. i nomi del dataset di train che finiscono in ‘na’ sono femminili 86.3 volte più spesso di quanto siano maschili;
  2. i nomi del dataset di train che finiscono in ‘us’ sono maschili 33.4 volte più spesso di quanto siano femminili.

Bonus 2: e se volessi più di una feature?

Normalmente i modelli di Machine Learning non dipendono da una sola feature, mi sembrava giusto dunque generalizzare il nostro semplice esempio aumentando il numero di feature. Cos’altro potremmo usare come feature per il modello del nostro classificatore di genere?

Vi viene in mente qualcosa?

Beh, ad esempio la lunghezza del nome (numero totale di lettere), oppure il numero di vocali, o di consonanti, … Onestamente non ho idea se esistano davvero relazioni di questo tipo, ma come già anticipato il nostro obiettivo è quello di generalizzare la procedura, quindi procedo con la più semplice 🙂 feature 1 = ultime due lettere del nome, feature 2 = lunghezza del nome. Scriveremo il codice in modo che il modello sia potenzialmente estendibile a piacere. Così ognuno potrà utilizzare le feature che ritiene più opportune!

Questa generalizzazione è più complessa della precedente, ma anche molto più divertente! Procediamo per gradi.

Dizionario di configurazione

Prima di tutto definiamo una variabile con la configurazione desiderata. Suggerisco di usare un dizionario, dove andiamo ad inserire tutte le informazioni che ci saranno utili. Come sapete, a me piace illustrare il procedimento reale che seguo durante la programmazione, che è fatto di continue iterazioni (o avanti-e-indietro se preferite), ma per evitare che questo articolo diventi lungo quanto un’enciclopedia riporterò direttamente il risultato finale.

Nel dizionario di configurazione servono 3 tipi di informazioni:

  1. i nomi delle feature che vogliamo usare;
  2. i nomi delle funzioni (già esistenti) che calcolano le feature;
  3. la lista dei parametri che ciascuna di queste funzioni richiede per poter funzionare (in aggiunta al token).

Nel nostro caso il dizionario di configurazione è il seguente:

feature_configuration = {'latest-2-letters': {'function': get_latest_letters, 'parameters': [2]},
                         'total-letters': {'function': get_size, 'parameters': []}}

Le chiavi del dizionario di configurazione sono i nomi delle feature. I valori sono a loro volta dei dizionari e contengono sempre due chiavi: il nome della funzione e una lista con i parametri che quella funzione accetta (in aggiunta al token). Si suppone che, per ogni feature, già esista la funzione che la calcola. Per latest-2-letters è la stessa di prima, per total-letters la creiamo adesso. E’ imbarazzantemente semplice, ma come sapete il focus non è sulla feature quanto sulla procedura di generalizzazione.

def get_size(name):
    return len(name)

Piccola nota: in questo caso la funzione non accetta alcun parametro oltre il token, per questo motivo nel dizionario di configurazione il valore della chiave parameters per get_size è una lista vuota.

Due funzioni per fare tutto (il resto)

Cosa facciamo con questo dizionario di configurazione? Lo passiamo ad una funzione che ha il compito di costruire il trainset da usare con l’algoritmo. Come sappiamo, tale trainset è una lista di tuple di 2 elementi: il primo elemento è il featureset del token, il secondo elemento è la classe del token.

Ecco la funzione desiderata:

def build_trainset(input_dataframe, token_column, label_column, configuration_dictionary):
    trainset = []
    for token, label in zip(input_dataframe[token_column], input_dataframe[label_column]):
        name_featureset = feature_extractor(token, configuration_dictionary)
        trainset.append((name_featureset, label))
    return trainset

La riga 4 calcola il featureset. La riga 5 costruisce la tupla. Il ciclo for di righe 3-5 costruisce la tupla per ciascun token. Come vedete da riga 4, per calcolare le feature si usa la funzione feature extractor (come da linguaggio di NLTK), che non abbiamo ancora creato.

Facciamolo subito:

def feature_extractor(token, feature_configuration):
    featureset = {}
    for feat in feature_configuration:
        f = feature_configuration[feat]['function']
        params = feature_configuration[feat]['parameters']
        featureset[feat] = f(token, *params)
    return featureset

Qui siamo arrivati al core, questa funzione non richiama più nient’altro. Qui si combinano tutte le informazioni che sono presenti nel dizionario di configurazione per calcolare tutte le feature per il singolo token che viene passato in input. La dicitura *params di riga 6 è il modo di espandere una lista, praticamente equivale a indicare uno per uno tutti gli elmenti della lista params.

Riporto di seguito un esempio di featureset ed i primi 2 elementi del trainset, in modo da rendere più chiaro il procedimento:

{'latest-2-letters': 'el', 'total-letters': 7}  # featureset for token 'Abagael'
[({'latest-2-letters': 'el', 'total-letters': 7}, 'female'), ({'latest-2-letters': 'il', 'total-letters': 7}, 'female')]  # first 2 elements of the trainset

Il divertimento è finito 🙁 Adesso che abbiamo il nostro trainset, non c’è niente di nuovo da fare, non ci resta che fare di nuovo lo split train-test, addestrare il modello e calcolare l’accuracy.

Mettendo tutto insieme

Riporto di seguito l’intero codice relativo a questa generalizzazione, a partire dalla definizione del dizionario di configurazione fino al calcolo dell’accuracy:

from NLP_base_library import get_latest_letters, build_trainset, get_size

feature_configuration = {'latest-2-letters': {'function': get_latest_letters, 'parameters': [2]},
                         'total-letters': {'function': get_size, 'parameters': []}}
input_dataset = gender_names[['name', 'gender']].copy()
trainset = build_trainset(input_dataset, feature_configuration)
X_train, X_test, y_train, y_test = train_test_split(trainset, trainset, test_size=test_size, random_state=random_seed)
classifier_gen = NaiveBayesClassifier.train(X_train)
accuracy_clf_gen = accuracy(classifier_gen, X_test)
print(f'features = {[key for key in feature_configuration]} -> '
      f'Overall classification rate = {100*accuracy_clf_gen:5.3f}%.')
classifier_gen.show_most_informative_features()

e questo il suo output a schermo:

Features = ['latest-2-letters', 'total-letters'] -> Overall classification rate = 78.037%.

Abbiamo ottenuto un’accuracy più alta rispetto al modello con una sola feature: 78.037% vs 77.659%. Secondo voi questo modello con due feature è migliore del primo? Quale usereste se doveste portarne uno in produzione per operare su nuovi nomi?

Vi lascio con questa domanda aperta 😛

(Suggerimento: provate a dare un’occhiata ai valori delle feature più informativi)

Conclusioni

In questo articolo abbiamo costruito in Python un classificatore di genere basato sul nome, un sistema cioè capace di capire automaticamente se un nome è maschile o femminile, a partire delle ultime lettere del nome stesso.

  1. Abbiamo utilizzato un dataset con quasi 8000 nomi (inglesi) presente nella libreria NLTK
  2. Abbiamo poi trasformato i dati inserendoli in un dataframe di pandas
  3. Abbiamo scritto una funzione per calcolare la feature su cui costruire il modello
  4. Abbiamo descritto il linguaggio di NLTK e portato i dati nel formato richiesto da NaiveBayesClassifier di NLTK
  5. ed infine abbiamo calcolato il classification rate del modello finale tramite la funzione accuracy di NLTK.

Siamo poi andati avanti con due sezioni bonus sulla generalizzazione di quanto fatto:

  1. Abbiamo cercato e trovato il numero migliore di ultime lettere N del nome da usare nel classificatore. Per fare questo abbiamo creato un classificatore per ciascun valore di N da 1 a 10 (il valore migliore è risultao N=2, con 78% di accuracy).
  2. Siamo poi andati ancora oltre per creare un modello che possa avere un qualsiasi numero di feature.
    1. per fare questo abbiamo creato un dizionario di configurazione, con tutte le informazioni del caso riguardo le feature e come calcolarle
    2. e poi abbiamo creato altre funzioni di supporto che leggono le istruzioni su come operare dal dizionario di configurazione.

Come al solito, tutto il codice che abbiamo generato è riportato in questo stesso articolo. Inoltre, il codice realizzato in tutta la serie è disponibile in questo repository pubblico su GitHub con licenza Open Source. Nel repo trovate anche le docstring per le funzioni. Attenzione: in occasione del push del codice di questo articolo, ho anche modificato la gestione dei file, creando le cartelle code, data e docs.

Link utili

Di seguito alcuni link utili per chi volesse approfondire alcuni degli argomenti toccati in questo articolo:

  1. Tre link legati a NLTK:
    1. corpora (incluso names): https://www.nltk.org/howto/corpus.html
    2. classificare in NLTK: https://www.nltk.org/api/nltk.classify.html
    3. modulo Naive Bayes: https://www.nltk.org/api/nltk.classify.naivebayes.html
  2. Spiegazione del Teorma di Bayes, su cui si basa l’algoritmo Naive Bayes: https://it.wikipedia.org/wiki/Teorema_di_Bayes
  3. Due link su pandas:
    1. documentazione ufficiale di pandas.DataFrame: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html
    2. famoso tutorial per  imprare le basi di pandas in 10 minuti (non è vero! ne servono almeno 30): https://pandas.pydata.org/docs/user_guide/10min.html
  4. Qui un articolo sul perché si usa random state https://towardsdatascience.com/why-do-we-set-a-random-state-in-machine-learning-models-bb2dc68d8431