Stemming – NLP di base in Python [2 di 9]

Stemming

Lo stemming è un processo che riconduce una parola dalla sua forma flessa alla radice o tema (in questo post si userà il termine “tema”). Ad esempio, le parole “correre”, “corro”, “corriamo”, “correremo” vengono tutte ricondotte al termine “corr”, il tema appunto. Altri esempi riguardano le parole al singolare, plurale, maschile e femminile, per cui ad esempio “bambino”, “bambini”, “bambina” e “bambine” vengono tutti ricondotti al tema “bambin”. Immagino che non vi sorprenderò se vi dico che il tema potrebbe non essere una parola di senso compiuto, anzi mi sa che non lo è quasi mai 😛

Lo stemming è una procedura utilizzata spesso durante la fase di preparazione di un testo per le analisi successive. Questo avviene, ad esempio, nella classificazione dei testi (che affronteremo nell’argomento 6 di questa serie introduttiva sull’NLP, Natural Language Processing), dove l’intento è quello di capire quale sia l’argomento di cui tratta un certo testo. Credo che sia intuitivo pensare che se in un testo sono presenti le parole “correre”, “corro”, “corriamo” e “correremo” non si stia parlando di 4 argomenti diversi, ma invece di uno solo: la corsa, che potremmo associare appunto al tema “corr”. Lo stemming serve anche per aiutare i computer a fare operazioni come questa (classificazione di testi) che per noi esseri umani sono abbastanza intuitive.

Lo stemming è il secondo passo di questa serie introduttiva in 9 passi sull’NLP (Natural Language Processing) in Python. Nel primo abbiamo affrontato la tokenization, creando 3 diversi tokenizer in Python, ed abbiamo anche dato le indicazioni riguardanti la configurazione tecnica e i termini di utilizzo del codice.

Questi sono i temi previsti per la serie:

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

Snowball Stemmer

Dato che non voglio intrattenervi con parole e parole, passiamo all’azione e realizziamo subito uno stemmer in Python! Continuiamo ad usare il pacchetto NLTK, da cui importiamo lo stemmer chiamato “Snowball”. Si tratta di una classe e non di una semplice funzione (abbiamo già visto le classi nello step 1 sui tokenizer). Per questo motivo dobbiamo prima creare un’istanza della classe e poi applicare un suo metodo sul testo di input. Vediamo come:

# import the stemmer from NLTK
from nltk.stem import SnowballStemmer

# ### Quick example ###
# create an instance of the Snowball stemmer, optimised for the Italian language
stemmer_snowball = SnowballStemmer('italian')
# create two example word-lists
eg1 = ['correre', 'corro', 'corriamo', 'correremo']
eg2 = ['bambino', 'bambini', 'bambina', 'bambine']
# merge the lists together
eg_list = []
eg_list.extend(eg1)
eg_list.extend(eg2)
# do a for loop on both lists, apply the stemmer to each word and print the result
print('\n= Quick stemming example =')
print('Input words: {}\nCorresponding stemmed words:'.format(eg_list))
for word in eg_list:
    print('\t- {}'.format(stemmer_snowball.stem(word)))

Nel codice precedente abbiamo dato in pasto al nostro stemmer le parole usate poco fa negli esempi. Vediamo quale tema è stato identificato per ciascuna parola:

= Quick stemming example =
Input words: ['correre', 'corro', 'corriamo', 'correremo', 'bambino', 'bambini', 'bambina', 'bambine']
Corresponding stemmed words:
    - corr
    - corr
    - corr
    - corr
    - bambin
    - bambin
    - bambin
    - bambin

Lo stemmer Snowball ha funzionato come previsto, restituendo “corr” come tema per tutte le parole della lista di esempio 1 e “bambin” per la lista 2.

Ambiguità “naturale”

Risalire al tema di una parola a partire dalla sua forma flessa non è sempre semplice e, per quanto sia possibile definire regole ben precise, sappiamo bene che il linguaggio naturale presenta sempre diverse eccezioni a quasi tutte le regole.

Per quanto pensi che in morfologia ad ogni forma flessa corrisponda un solo tema (o radice), in data science non è sempre così. Questo significa che possono esserci diversi temi corretti per la stessa forma flessa, perché in data science di solito la scelta di cosa sia corretto o meno dipende anche dal significato della parola (quindi dovremmo scomodare non solo la morfologia ma anche la semantica).

Mi spiego con un esempio. Se vi fornisco la lista di parole “esplodo”, “esplodono”, “esploderanno”, voi a quale tema pensate? Probabilmente “esplod” (io almeno ho pensato a questa, ed anche Snowball stemmer). Se poi vi fornissi le parole “esplosione”, “esplosiva”, “esploso” che tema scegliereste? Probabilmente “esplos”. Snowball stemmer, invece, restituisce come tema “esplosion” per la prima e “esplos” per le altre due. Qui iniziano le domande …

  • E’ corretto assegnare ad “esplosione” un tema diverso rispetto a “esplosivo”?
  • E’ corretto assegnare temi diversi “esplodo” e “esploso”?

A me sembra che ad entrambe le domande si possa rispondere con “si” o “no” in base al contesto dell’analisi. In ogni caso, non siamo qui alla ricerca di una risposta teorica nell’ambito della morfologia (che magari esiste), bensì volevo solo mettere in evidenza come lo stemming in data science non si riduca all’applicazione di un’unica regola deterministica ed universalmente accettata. Se volete che lo stemming che usate nelle vostre analisi di NLP sia utile, allora dovrete analizzare con cura i testi su cui lo applicate ed i risultati che vi restituisce. Non è un caso se in letteratura esistono diversi articoli ed algoritmi sullo stemming che, a loro volta, hanno dato origine a diversi pacchetti per i vari linguaggi di programmazione. Solo in NLTK, ad esempio, ci sono 3 diverse classi di stemmer.

Nel primo articolo di questa serie, abbiamo visto che anche un’operazione semplice come la tokenization (“ridurre un testo all’insieme delle sue parole”) può avere interpretazioni diverse per un computer. Figuriamoci dunque cosa può succedere quando vogliamo ridurre una parola dalla forma flessa al tema!

Dipendenza dalla lingua

Come se non bastasse, il livello di flessione delle parole dipende dalla lingua a cui quelle parole appartengono. Non sono un esperto di linguistica (quindi mi auguro di non venire smentito immediatamente :P) ma mi sembra che l’italiano abbia un numero di forme flesse maggiore dell’inglese che, a sua volta, ne ha più del cinese. Come mai lo penso? Beh, prendete gli aggettivi ad esempio, in inglese ed in cinese non esistono aggettivi singolari, plurali, maschili o femminili (tranne eccezioni, ovviamente). Se consideriamo l’aggettivo qualificativo “rosso”, in italiano abbiamo le 4 forme flesse “rosso”, “rossi”, “rossa”, “rosse”; in inglese ne abbiamo una sola “red”; in cinese una sola “hóng sè”. Lo stesso vale se consideriamo i verbi, per i quali le forme flesse variano in base al numero totale di combinazioni di tempi, modi e persone. Va da sé che in italiano ce ne sono molte di più che in inglese, che a sua volta ne ha più del cinese (che, almeno nell’uso comune, ha un solo tempo, modo e persona. Comunque, forse è meglio tenere il cinese da parte …).

Non deve dunque stupirci se non esiste uno stemmer universale, uno cioè che vada bene per tutte le lingue. Per questo motivo, quando in precedenza abbiamo usato lo Snowball stemmer, abbiamo passato il parametro “italian”.

Nella prossima sezione (praticamente fra 2 righe) useremo altri due stemmer, Porter e Lancaster. In questo caso non indicheremo nessuna lingua, non perché siano stemmer universali, ma perché tali stemmer funzionano solo per una lingua (qual è secondo voi?).

Porter Stemmer e Lancaster Stemmer

Il pacchetto NLTK offre 3 diverse classi per lo stemming. Abbiamo già utilizzato lo Snowball, usiamo adesso gli altri due, Porter e Lancaster. Procediamo ad applicare tutti e tre gli stemmer ad uno stesso testo di input. Usiamo quello che abbiamo introdotto nel primo articolo della serie, cioè una citazione di Dean Karnazes (atleta, runner). Tuttavia, dovremo prendere la citazione in inglese e non la traduzione in italiano, in quanto gli stemmer Porter e Lancaster sono entrambi specifici per l’inglese.

Di seguito riporto il codice che esegue le seguenti operazioni:

  1. importa tutti e 3 gli stemmer ed il RegexpTokenizer,
  2. carica il testo di input dal documento chiamato “text_run_eng.txt” (lo trovate nel repository su github),
  3. lo tokenizza per parole (come abbiamo fatto nel passo 1),
  4. applica i 3 stemmer a ciascuna parola,
  5. e mostra a schermo la parola originaria seguita dai 3 temi, per ciascuna parola.
from nltk.stem import PorterStemmer, SnowballStemmer, LancasterStemmer
from nltk.tokenize import RegexpTokenizer

# ### Prepare input data ###
# read data from file
filename = 'text_run_eng.txt'
with open(filename, 'r') as reader:
    input_raw_text = reader.read()
    print('\nInput raw text is: \n{}'.format(input_raw_text))
# split data into words
custom_tokenizer = RegexpTokenizer('\w+')
word_list = custom_tokenizer.tokenize(input_raw_text)

# ### Stemming ###
# Create Stemmer instances
stemmer_porter = PorterStemmer()
stemmer_lancaster = LancasterStemmer()
stemmer_snowball = SnowballStemmer('english')
# stem each word and print results
print('\nStemming {} words with Porter, Snowball and Lancaster respectively.'.format(len(word_list)))
for word in word_list:
    print('{0}: {1}, {2}, {3}'.format(
        word,
        stemmer_porter.stem(word),
        stemmer_snowball.stem(word),
        stemmer_lancaster.stem(word))
)

Piccola nota sul comando print: il numero inserito dentro le parentesi graffe indica l’indice della variabile da mostrare. Come al solito in Python gli indici iniziano da zero. In questo caso abbiamo 4 variabili, quindi gli indici vanno da 0 ad 3.

Vediamo subito il risultato, almeno per le prime parole del testo di input:

Input raw text is: 
I run because if I didn’t, I’d be sluggish and glum and spend too much time on the couch. I run to breathe the fresh air. I run to explore. I run to escape the ordinary. I run…to savor the trip along the way. Life becomes a little more vibrant, a little more intense. I like that.

Stemming 60 words with Porter, Snowball and Lancaster respectively.
I: I, i, i
run: run, run, run
because: becaus, becaus, becaus
if: if, if, if
I: I, i, i
didn: didn, didn, didn
t: t, t, t
I: I, i, i
d: d, d, d
be: be, be, be
sluggish: sluggish, sluggish, slug

Questa non è la sede opportuna per entrare nel dettaglio dei tre algoritmi e delle loro differenze, ed io non sono la persona adatta a farlo. Tuttavia, è comunque utile notare che, già con queste poche parole, emergono alcune differenze tra i vari stemmer.

  • Lancaster ha scelto un tema diverso per la parola “sluggish” rispetto agli altri due;
  • Porter ha lasciato “I” in maiuscolo, mentre Snowball e Lancaster la portano in minuscolo, “i”.
  • Attenzione: meglio non generalizzare troppo e pensare che questo significhi che Porter lasci le parole sempre in maiuscolo! Tra le parole successive c’è infatti “Life” (in maiuscolo) che viene mappata da Porter su “life”, così come fa anche Snowball, mentre Lancaster la mappa sul tema “lif” (in minuscolo e senza la vocale finale).

L’importanza della visualizzazione

Secondo voi, la modalità in cui sono state visualizzate le informazioni a schermo nell’ultimo riquadro è la migliore possibile? Riuscite a pensare ad una visualizzazione più efficace? Dove “più efficace” significa “che rende più agevole (e veloce) il confronto tra le parole originali (in forma flessa) e quelle finali (tema)”?

Mostrare i dati in maniera da capire velocemente le informazioni che si stanno trasmettendo è un passaggio molto importante in un progetto di data science. In questo caso, l’obiettivo della nostra analisi è di confrontare gli stemmer tra di loro ed anche rispetto alla parola originaria.

Io suggerisco di utilizzare una formattazione di tipo tabellare e per farlo useremo semplicemente un diverso tipo di layout con print.

# ### Visualisation ###
# Improve the visualisation for easier stemmer comparison
# Use a Table style, with 4 columns and as many rows as words
# for each row we print the original word + the 3 stemmed words.
print('\nStemming {} words\n'.format(len(word_list)))
header = ['Original Word', 'Porter', 'SnowBall', 'Lancaster']
row_format = '{:<15}:' + '{:^15} -' * 2 + '{:^15}|'
# 1st column: align left, use 15 characters plus a colon ":"
# 2nd + 3rd columns: align center, use 15 characters plus " -"
# 4th column: align center, use 15 characters plus a pipe "|"
print(row_format.format(*header)) # print all elements of the header list
                                  # *<list> performs an unpacking of the list
print('-'*66) # print a kind of separation line, between header and rows
# print each word
for word in word_list:
    stemmed_words = [stemmer_porter.stem(word),
                     stemmer_snowball.stem(word),
                     stemmer_lancaster.stem(word)]
print(row_format.format(word, *stemmed_words))
# print again the header, at the bottom of the table
print('-'*66)
print(row_format.format(*header))

Vediamo subito il risultato:

Stemming 60 words

Original Word  :    Porter      -   SnowBall     -   Lancaster   |
------------------------------------------------------------------
I              :       I        -       i        -       i       |
run            :      run       -      run       -      run      |
because        :    becaus      -    becaus      -    becaus     |
if             :      if        -      if        -      if       |
I              :       I        -       i        -       i       |
didn           :     didn       -     didn       -     didn      |
t              :       t        -       t        -       t       |
I              :       I        -       i        -       i       |
d              :       d        -       d        -       d       |
be             :      be        -      be        -      be       |
sluggish       :   sluggish     -   sluggish     -     slug      |

Che ve ne pare? Non pensate che adesso sia più semplice e veloce confrontare i dati? Si potrebbe dire “anche l’occhio vuole la sua parte”, ma in realtà questa non è semplicemente una composizione “carina” da vedere, bensì il risultato di un tentativo di rendere l’analisi più semplice e veloce.

Passiamo adesso a spiegare il codice.

Le spiegazioni in realtà sono già nel codice stesso, sottoforma di commento. Qui mi limito ad aggiungere una spiegazione circa l’uso delle parentesi graffe in print, in particolare rispetto a quello che scriviamo a sinistra e a destra dei due punti dentro le parentesi graffe. Abbiamo già visto come a sinistra dei due punti si riporta l’indice della variabile che si vuole stampare (se non si scrive niente, Python prende le variabili nell’ordine in cui sono riportate nel format); a destra dei due punti invece ho inserito qualcosa tipo “<15”, il primo carattere indica l’allineamento del testo (“<” sta per “a sinistra”, mentre “^” sta per “al centro”, riuscite ad indovicare cosa si usa per allineare a destra?), il successivo numero indica invece quanto spazio riservare per scrivere la variabile (in questo caso 15 caratteri).

Il codice potrebbe apparire un minimo contorto in quanto ho creato una variabile, row_format, dove ho inserito il layout da utilizzare. Questo è comodo in quanto ogni volta che uso tale variabile in print, verrà applicato sempre lo stesso layout. Ho inoltre usato l’espressione *header per fare il cosiddetto “unpacking” della lista, in questo modo non c’è bisogno di ripetere esplicitamente ogni singolo elemento della lista.

Come possiamo migliorare?

Considerato tutto quello che abbiamo già fatto, l’articolo potrebbe benissimo finire qua. Tuttavia, per quei pochi interessati, ci sono un paio di passi ancora che mi piacerebbe fare, per dare maggiore valore ai vari pezzi di codice che abbiamo scritto finora.

Rimuovere i duplicati

Dato che siamo interessati a confrontare tra loro i vari stemmer, non credete che sia superfluo applicarli più volte alla stessa identica parola? E’ dunque utile rimuovere i duplicati dalla nostra lista di parole. Ci sono vari modi di farlo, il più semplice che mi viene in mente è di usare il comando set. Questo comando crea un gruppo di elementi a partire da una lista ed in automatico esclude eventuali elementi duplicati. Poi dobbiamo usare il comando list per trasformare il gruppo di nuovo in una lista.

word_list_unique = list(set(word_list))

In questo modo il totale di parole su cui applicare lo stemming è andato da 60 (quelle nel nostro file di input) a 38 (le parole uniche).

Rimuovere le parole corte

Un passo utile da fare in quasi tutte le analisi NLP è di rimuovere le parole poco significative. Possiamo partire dai dati (cioè le parole in forma flessa) per definire quale tipo di parole potrebbe essere poco significativo. In questo caso, guardando le nostre 38 parole uniche, penso che potrebbe essere utile escludere tutte le parole composte da 1, 2 o 3 caratteri. Si tratta di parole molto corte e mi sa che qualunque stemmer le mapperebbe su se stesse, e.g. “and” che viene mappato su “and”.

Sfrutto questa occasione per utilizzare le famose “List Comprehension” di Python, si tratta di un particolare modo di costruire le liste.

word_list_final = [x for x in word_list_unique if len(x) > 3]

La List Comprehension è tutto ciò che c’è alla destra dell’uguale. In questo caso particolare stiamo dicendo a Python di creare una lista partendo dagli elementi della lista word_list_unique e di prendere poi solo quelli la cui lunghezza è superiore a 3 (quindi escludiamo tutti quelli di lunghezza pari o inferiore a 3).

Grazie a quest’altro passaggio passiamo da 38 parole a 24.

Piccola perla di Data Science: l’importanza di ripulire il dataset

Grazie a questi due piccoli accorgimenti abbiamo ridotto le parole da analizzare di oltre la metà, andando da 60 a 24. Questo significa che, se analizziamo la nostra “bella” tabella che riporta le forme flesse ed i temi senza filtrare le parole, rischiamo di impiegare il 50% circa del nostro tempo su dati potenzialmente inutili, comunemente noti come “rumore”.

Se invece analizziamo la tabella costruita sul dataset dopo averlo ripulito, allora non solo l’analisi sarà più breve, ma probabilmente anche di migliore qualità dato che sarà più facile dedurre informazioni nuove ed utili, avendo meno distrazioni (rumore) tra una parola utile e l’altra.

Conclusioni

Questo è il secondo articolo di una serie di introduzione all’NLP (Natural Language Processing) in Python. La serie affronta in tutto 9 argomenti, il primo è stato la tokenization, il secondo è lo stemming.

In questo articolo

  • come prima cosa abbiamo visto cosa sia lo stemming,
  • ed abbiamo poi subito creato il nostro primo stemmer con la classe Snowball di NLTK.
  • Questo ci ha consentito di toccare con mano quanto lo stemming possa essere complicato, data l’ambiguità insita nel linguaggio naturale e le differenze tra le varie lingue.
  • Abbiamo poi utilizzato altri 2 stemmer messi a disposizione da NLTK: il Lancaster stemmer ed il Porter stemmer.

Questo conlude gli argomenti legati allo stemming in sé. Dopo abbiamo affrontato altri due elementi molto importanti in ambito data science:

Vi ricordo che tutto il codice utilizzato per questa serie è condiviso secondo i canoni del software Open Source e lo trovate nel mio repository pubblico su GitHub. Invito tutti a scaricarlo e modificarlo tutte le volte che volete 🙂

Come al solito, sarò molto lieto di ricevere qualsiasi vostro dubbio, domanda o suggerimento.

Ecco un paio di link utili sugli stemmer per chi vuole approfondire: