Sentiment Analysis in Python con NLTK – NLP di base in Python [8.1 di 9]

Sentiment Analysis in Python con NLTK

In questo articolo costruiamo insieme un sistema per fare la sentiment analysis in Python. Per essere più precisi, scriviamo codice Python per determinare automaticamente se la recensione di un film esprime un giudizio positivo o negativo sul film. Per fare questo utilizzeremo l’algoritmo Naive Bayes con una libreria di NLTK.
Questo articolo è parte di una serie introduttiva sul Natural Language Processing (NLP) in Python. La serie è composta da 9 argomenti, questo è il primo articolo relativo all’argomento numero 8:

  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
  8. Sentiment analysis
    1. Sentiment analysis con NLTK <— Questo articolo
  9. Topic modelling

Cosa è la Sentiment Analysis?

Il termine “sentiment analysis” viene utilizzato per indicare lo studio del “sentiment” espresso in un certo contenuto riguardo un certo argomento, come ad esempio le recensioni di un ristorante o i commenti sulle scelte di un politico. Nella sua versione essenziale, la sentiment analysis cerca di capire se il contenuto sotto esame esprime un giudizio positivo o negativo rispetto all’argomento che tratta. Dunque, nel caso delle recensioni dei ristoranti, il “sentiment positivo” significa che il ristorante è piaciuto, mentre il “sentiment negativo” significa che qualcosa è andato storto.

Considerato che al giorno d’oggi la tecnologia ha dato voce quasi a chiunque (prima con i blog, oggi con i social network), la sentiment analyis è diventato una strumento imprescindibile per chiunque voglia conoscere la propria reputazione nel mondo online (e non solo …). Questo vale tanto per i personaggi famosi (che fanno tanto parlare di loro), quanto per le aziende (di qualsiasi dimensione, anche quelle più piccole), ma anche per i politici, le istituzioni, … Ciascuno di loro può utilizzare strumenti di sentiment analiysis per cercare di capire cosa la gente (o i clienti) pensa di loro ed agire di conseguenza.

In effetti, non c’è niente di nuovo da un punto di vista concettuale, la novità sta appunto nella scala. Mentre prima per conoscere la propria reputazione bisognava andare a chiedere in giro, magari con sondaggi, adesso basta raccogliere (ed analizzare) i messaggi che le persone condividono pubblicamente su di noi.

Piano dell’articolo

L’obiettivo di questo articolo è di costruire insieme un sistema di sentiment analysis in Python con NLTK. Per fare questo seguiremo i seguenti macro-passi:

  1. Esplorazione del dataset movie_reviews presente in NLTK
  2. Costruzione di un modello di machine learning basato sull’algoritmo Naive Bayes (tramite NLTK)
  3. Esplorazione del classificatore appena costruito
  4. Addestramento e test finale del classificatore, per il suo uso “in produzione”

1. Esploriamo il dataset movie_reviews

In questo articolo usiamo un dataset presente in NLTK che si chiama movie_reviews, così come nell’ultimo articolo sul classificatore di genere avevamo usato il dataset names. In quel caso volevamo classificare un nome come maschile o femminile, adesso invece vogliamo classificare una recensione di film come positiva o negativa.

Di fronte a questo nuovo dataset sono tante le domande che passano per la testa:

  • Quante recensioni ci sono in tutto?
  • Quante categorie (sentiment) sono utilizzate?
  • Quali sono i nomi dei file delle singole recensioni?
  • Quante recensioni ci sono per ciascuna categoria?

Bene dunque, passiamo subito ad usare Python per rispondere a queste ed altre domande di base per inquadrare meglio questo dataset:

from nltk.corpus import movie_reviews

# STEP 1: Dataset exploration
print('\n=== Step 1: Dataset exploration ===')
print('=== Exploring the Movie Reviews dataset ===')
# - how many files/reviews? 2,000
all_filenames = movie_reviews.fileids()
print(f'\tThe movie reviews dataset contains {len(all_filenames):,} reviews.')
# - how many movie categories (our sentiment)? 2
sentiments = movie_reviews.categories()
print(f'\tAvailable sentiments for each review: {sentiments}')
# - what are the single reviews, use fileids
pos_filenames = movie_reviews.fileids('pos')
neg_filenames = movie_reviews.fileids('neg')
print(f'\tName of the first negative review file: {neg_filenames[0]}')
print(f'\tName of the first positive review file: {pos_filenames[0]}')
# - how many reviews per category/sentiment? 1,000 files for each sentiment
print(f'\tTotal number of negative reviews: {len(neg_filenames):,}')
print(f'\tTotal number of positive reviews: {len(pos_filenames):,}')

E qui di seguito le risposte alle domande:

===  Step 1: Dataset exploration  ===
===  Exploring the Movie Reviews dataset  ===
    The movie reviews dataset contains 2,000 reviews.
    Available sentiments for each review: ['neg', 'pos']
    Name of the first negative review file: neg/cv000_29416.txt
    Name of the first positive review file: pos/cv000_29590.txt
    Total number of negative reviews: 1,000
    Total number of positive reviews: 1,000

Ok, adesso che abbiamo le coordinate di base sul dataset, possiamo soddisfare la curiosità di accedere proprio alle singole review e poterle leggere 🙂 e poi anche calcolare qualche statistica, come il numero di parole in una review, il numero di parole in tutte le review (totali ed uniche), le parole più e meno ricorrenti e magari anche la frequenza di alcune parole specifiche. Ecco il codice per fare tutto questo:

from nltk import FreqDist

# - how to see the words of a specific review
test_review = neg_filenames[0]
print(f'\tReview {test_review} has {len(movie_reviews.words(test_review))} total words.\n '
f'\t\tHere are the first 50 characters:\n\t\t{movie_reviews.open(test_review).read() [:50]}...')
# - how many words (with repetitions) in the dataset? 1,583,820 total words
all_words = movie_reviews.words()
print(f'\tThe movie reviews dataset contains {len(all_words):,} total words.')
# - show some dataset words
print(f'\tThe first 10 words are: {all_words[:10]}')
# - frequency of each word (i.e. total word count)
all_words_counts = FreqDist(all_words)
print(f'\tFrequency of 10 random words: {list(all_words_counts.items())[:10]}')
# - frequency of specific words
print(f'\tFrequency of word \'good\': {all_words_counts["good"]:,}')
print(f'\tFrequency of word \'bad\': {all_words_counts["bad"]:,}')
# - frequency of top/bottom words
print(f'\tFrequency of the 5 most used words: {all_words_counts.most_common(5)}')
print(f'\tFrequency of the 5 least used words: {all_words_counts.most_common()[-5:]}')
# - how many unique words in the dataset? 39,768
vocabulary = set(all_words)
print(f'\tTotal number of unique words: {len(vocabulary):,}')
# dataset exploration ends
print('=== Movie Reviews dataset exploration completed ===')

Risposte:

    Review neg/cv000_29416.txt has 879 total words.
 		Here are the first 50 characters:
        plot : two teen couples go to a church party , dri...
    The movie reviews dataset contains 1,583,820 total words.
    The first 10 words are: ['plot', ':', 'two', 'teen', 'couples', 'go', 'to', 'a', 'church', 'party']
    Frequency of 10 random words: [('plot', 1513), (':', 3042), ('two', 1911), ('teen', 151), ('couples', 27), ('go', 1113), ('to', 31937), ('a', 38106), ('church', 69), ('party', 183)]
    Frequency of word 'good': 2,411
    Frequency of word 'bad': 1,395
    Frequency of the 5 most used words: [(',', 77717), ('the', 76529), ('.', 65876), ('a', 38106), ('and', 35576)]
    Frequency of the 5 least used words: [('tangerine', 1), ('timbre', 1), ('powaqqatsi', 1), ('keyboardist', 1), ('capitalized', 1)]
    Total number of unique words: 39,768
===  Movie Reviews dataset exploration completed  ===

2. Sentiment Analysis in Python con NLTK

Passiamo adesso al core del lavoro: costruiamo un sistema di machine learning capace di identificare il sentiment di una recensione. Lo facciamo costruendo un classificatore di testi che usa due etichette: sentiment positivo e sentiment negativo.

Utilizziamo lo stesso algoritmo usato per costruire il classificatore di genere nel precedente articolo della serie. Non solo usiamo lo stesso algoritmo, Naive Bayes, usiamo anche la stessa classe (NaiveBayesClassifier di NLTK). Di conseguenza, dovremo preparare i dati di input allo stesso modo di come fatto in precedenza. Dato che non si tratta di un’attività né banale né tantomeno intuitiva, ripeto brevemente la forma che devono avere i dati di input.

NaiveBayesClassifier di NLTK viene addestrato su un dataset che è una lista di tuple. Ciascuna tupla è costituita da 2 elementi: il primo elemento è l’insieme di tutte le parole presenti nella review (nella forma “nome parola: True“), il secondo elemento è il sentimento della review. Ricordando il linguaggio tecnico di NLTK possiamo dire che il dataset di input ha una tupla per ciascun token (nel nostro caso token = lista di parole della review), la tupla è composta di due elementi: il primo è il featureset del token, il secondo invece la sua categoria.

Stratification

Abbiamo già visto che, quando si crea un modello di machine learning, i dati a disposizione vengono suddivisi in due parti (vedi articolo sul classificatore di testi con le funzioni). La parte più grande (ad esempio l’80%) si usa per l’addestramento vero e proprio, mentre la parte rimanente viene messa parte per la successiva valutazione del modello. Noi tutto questo lo abbiamo già fatto più volte in questa serie e, per determinare quali dati inserire nel dataset di train, abbiamo usato la funzione train_test_split, indicando di scegliere i dati in maniera casuale. Ecco, questa soluzione, sicuramente molto “democratica”, nasconde però una piccola insidia: nessuno ci assicura che tutte le categorie di sentiment siano adeguatamente rappresentate nel dataset di addestramento. Nel nostro caso, noi vorremmo che il dataset di train contenesse un ugual numero di recensioni positive e negative.

Tale procedura si chiama stratification, ed è così tanto importante (e diffusa) che è già implentata in train_test_split. Per usarla basta semplicemente richiamare il parametro stratify.

Bene dunque, procediamo con il primo pezzo di codice che ci porterà a costruire il nostro modello di machine learning per fare la sentiment analysis con Python:

import pandas as pd
from sklearn.model_selection import train_test_split

# STEP 2: ML MODEL CREATION
print('\n=== Step 2: Create Machine Learning model to find review sentiment ===')
# create dataset with review filename & review sentiment
pos_filenames = movie_reviews.fileids('pos')
neg_filenames = movie_reviews.fileids('neg')
movie_reviews_df = pd.DataFrame({'filename': pos_filenames + neg_filenames,
                                 'sentiment': ['positive'] * len(pos_filenames) + ['negative'] * len(neg_filenames)})
# split the dataframe into two parts, each with EQUAL number of pos and neg reviews
# as usual we follow the classical ML approach,
# although in this case our X contains both features and targets, and we do not need y
test_size = 0.2
random_seed = 409
X_train, X_test, y_train, y_test = train_test_split(movie_reviews_df, movie_reviews_df, test_size=test_size,
                                                    stratify=movie_reviews_df['sentiment'], random_state=random_seed)
# Verify stratification
print(f'\nNumber of train \'observations\': {len(X_train)}')
print(f'Number of reviews per sentiment in the train split:\n {X_train.groupby("sentiment").count()}\n')
print(f'Number of test \'observations\': {len(X_test)}')
print(f'Number of reviews per sentiment in the test split:\n {X_test.groupby("sentiment").count()}\n')

Con questo codice creiamo il dataframe movie_reviews_df che ha tante righe quante recensioni (quindi 2000) e poi ha 2 colonne: nella prima, che abbiamo chiamato “filename”, inseriamo il nome del file della recensione, nella seconda, chiamata “sentiment”, inseriamo il sentiment di quella recensione. Successivamente, eseguiamo lo split sul dataframe usando il parametro stratify; tale parametro deve indicare la colonna da usare per fare la stratificazione, nel nostro caso il sentiment.

Ecco l’output del codice, con tanto di verifica della stratificazione che indica che abbiamo 800 recensioni per sentiment nel dataset di train e 200 recensioni per sentiment nel dataset di test:

===  Step 2: Create Machine Learning model to find review sentiment  ===
Number of train 'observations': 1600
Number of reviews per sentiment in the train split:
            filename
sentiment          
negative        800
positive        800
Number of test 'observations': 400
Number of reviews per sentiment in the test split:
            filename
sentiment          
negative        200
positive        200

Preparazione dell’input

Procediamo a preparare l’input per l’algoritmo, generalizzando la procedura creata nell’articolo precedente, dove avevamo usato le funzioni build_trainset e feature_extractor.

Costruiamo la funzione per creare il trainset in modo che prenda in input l’intero corpus di documenti, a questo aggiungiamo anche la lista dei nomi dei file ed etichette che si vogliono inserire nello specifico trainset da creare. A seguire, definiamo anche la nuova funzione di estrazione feature per il sentiment. Aggiungiamo entrambe queste nuove funzioni nella nostra libreria NLP_base_library.py (nel repository su GitHub trovate anche le docstrings).

def build_trainset_v2(corpus, filenames, labels, feature_extractor_function):
    # Build the input to pass to nltk.NaiveBayesClassifier
    trainset = []
    for filename, label in zip(filenames, labels):
        doc_words = corpus.words(filename)  # list of all the words of a single document of the corpus
        # extract the featureset for the document
        name_featureset = feature_extractor_function(doc_words)
        # build the tuple
        trainset.append((name_featureset, label))
    return trainset


def feature_extractor_sentiment(token):
    # Compute the featureset for the input token (document)
    feature_dict = dict([(word, True) for word in token])
    return feature_dict

Mi sono appena accorto di aver usato il comando zip più volte in questa serie, ma di non averlo mai esplicitamente spiegato!

Rimediamo subito.

Il comando zip prende in input più liste (o iteratori in genere) e restituisce in output un nuovo iteratore (oggetto zip) il cui primo elemento è una tupla fatta da tutti i primi elementi delle liste di input, il secondo elemento è una tupla fatta da tutti i secondi elementi delle liste di input e così via, fino a che non si arriva all’ultimo elemento della lista di input più corta. Ad esempio se a=['cane', 'gatto', 'goblin'] e b=['nero', 'grigio', 'marrone', 'viola'] allora gli elementi di zip(a, b) sono ('cane', 'nero'), ('gatto', 'grigio'), ('goblin', 'marrone').

Anche se forse è già chiaro, preferisco ripetere cosa fanno queste due funzioni, applicandole ad un caso particolare a mo’ di esempio.

La funzione feature_extractor_sentiment calcola la rappresentazione di una review, in particolare ritorna un dizionario le cui chiavi sono le parole della recensione ed i cui valori sono sempre True, per indicare che la parola è presente nella recensione. Ad esempio, con il seguente codice

words_rev1 = movie_reviews.words(X_train['filename'][0])
feature_extractor_sentiment(words_rev1)

otteniamo prima la lista di tutte le parole della prima recensione del dataset di train e poi il dizionario con le varie feature:

['films', 'adapted', 'from', 'comic', 'books', 'have', ...]
{'films': True, 'adapted': True, 'from': True, 'comic': True, 'books': True, 'have': True, ...}

La funzione build_trainset_v2 serve per mettere insieme i pezzi nella struttura dati richiesta dall’implementazione di Naive Bayes fatta in NLTK. In particolare, per ogni recensione si prende il risultato di feature_extractor_sentiment e si aggiunge il sentiment. Questa “aggiunta” è fatta creando una tupla di 2 elementi: il primo è il featureset (cioè il dizionario ritornato da feature_extractor_sentiment), il secondo è il sentiment (una semplice stringa).

Ad esempio, se volessimo costruire il trainset soltanto per la prima recensione del dataset di train (che sappiamo avere un sentiment positivo) possiamo usare questo comando

build_trainset_v2(movie_reviews, [X_train['filename'][0]], ['positive'], feature_extractor_sentiment)

il cui risultato è poi (per comodità di lettura ho usato i tre puntini per indicare tutte le restanti parole della recensione)

[({'films': True, 'adapted': True, 'from': True, 'comic': True, ..., 'drug': True, 'content': True}, 'positive')]

A noi però interessano tutte le recensioni e non solo la prima, per questo motivo dobbiamo usare queste funzioni su tutte le recensioni. Ecco dunque lo snippet di codice che andremo ad inserire nel nostro script principale:

from NLP_base_library import build_trainset_v2, feature_extractor_sentiment 

# Prepare data for training 
fileids_train = X_train['filename'].tolist() 
labels_train = X_train['sentiment'].tolist() 
trainset_train = build_trainset_v2(movie_reviews, fileids_train, labels_train, feature_extractor_sentiment)

Addestramento e test del classificatore

Adesso che abbiamo i nostri input non resta che fare l’addestramento dell’algoritmo.

Poi, costruiamo l’input anche sul dataset di test e lo usiamo per calcolare il classification rate del modello.

Ecco qua il codice per questi ultimi passi:

from nltk import FreqDist, NaiveBayesClassifier
from nltk.classify import accuracy as nltk_accuracy

# train the classifier
classifier = NaiveBayesClassifier.train(trainset_train)
# test the classifier
fileids_test = X_test['filename'].tolist()
labels_test = X_test['sentiment'].tolist()
trainset_test = build_trainset_v2(movie_reviews, fileids_test, labels_test, feature_extractor_sentiment)
print(f'\nClassifier accuracy = {100*nltk_accuracy(classifier, trainset_test)}%')

Ed ecco cosa verrà mostrato a schermo:

Classifier accuracy = 68.0%

E con questo la sentiment analysis in Python è servita. Abbiamo concluso.

Complimenti!

Abbiamo costruito un sistema di machine learning capace di identificare il sentiment di una rcensione in maniera automatica, con un’accuracy dell’ordine del 68%.

3. Esploriamo il classificatore

Per capire un po’ di più di come funziona il nostro classificatore, possiamo esplorare le feature più informative. In questo contesto, per feature “più informative” intendiamo quelle che sono utilizzate principalmente per esprimere uno solo dei due sentiment. In altre parole, si tratta di quelle feature che è più probabile che vengano usate in recensioni di un certo sentiment invece che dell’altro.

Come abbiamo già visto nell’articolo sul classificatore di genere, NLTK fornisce un metodo specifico (show_most_informative_features) che esegue esattamente questo calcolo e poi mostra a schermo le top N feature ed il rapporto delle probabilità (di default mostra le prime 10). Nel nostro caso, basta digitare il seguente comando

# STEP 3: CLASSIFIER FEATURE EXPLORATION
print('\n===  Step 3: Classifier features exploration  ===')
# show the top 10 most informative features overall
classifier.show_most_informative_features() # this will print to terminal the top 10 most informative features

per ottenere il seguente output:

===  Step 3: Classifier features exploration  ===
Most Informative Features
                  seagal = True           negati : positi =     12.3 : 1.0
                  avoids = True           positi : negati =     11.7 : 1.0
               maintains = True           positi : negati =     11.7 : 1.0
                headache = True           negati : positi =     10.3 : 1.0
                   sucks = True           negati : positi =      9.8 : 1.0
               affecting = True           positi : negati =      9.7 : 1.0
               atrocious = True           negati : positi =      9.7 : 1.0
                 conveys = True           positi : negati =      9.7 : 1.0
                  regard = True           positi : negati =      9.7 : 1.0
                  spacey = True           positi : negati =      9.7 : 1.0

La tabella indica che la feature “seagal” con valore True è quella più informativa di tutte. La probabilità che venga usata in una recensione negativa è 12.3 volte più alta che quella che venga usata in una recensione positiva. Nessun’altra feature ha un rapporto così alto.

A seguire, c’è il termine “avoids”, che un rapporto pari a 11.7 a favore del sentiment positivo.

E così via, potete leggere il resto delle parole da voi.

Dato che mi piacciono le statistiche e le analisi, questo tema delle feature più informative mi ha intrigato e così ho iniziato a pormi diverse domande, del tipo “Quali sono le top 3 feature per il sentiment positivo e quali per quello negativo?” e anche “Qual è il livello di informatività di parole come “bad” e “awesome”, che non compaiono nella top 10?” o ancora “Quante sono effettivamente le recensioni che utilizzano queste parole?” (ricordiamoci che il gradi di informatività è qui calcolato in maniera relativa, cioè come un rapporto).

Se per caso ti stai facendo anche tu queste domande, o se più in generale ti interessa conoscere le risposte, allora ti invito a non perdere la sezione bonus di questo articolo, dove riporto appunto le risposte ed il codice usato per ottenerle.

4. Classificatore di produzione

A questo punto del lavoro, un data scientist dovrebbe chiedersi: “il risultato raggiunto è soddisfacente?”.

Se la risposta è “no”, allora bisogna tornare indietro e cercare di migliorare dove serve. Se la risposta è “sì” allora siamo pronti per “andare in produzione”. Per noi, questo significa che dobbiamo addestrare il modello su tutti i dati a disposizione (2000 recensioni) e non più solo sul train dataset (1600 recensioni). Questo possiamo farlo abbastanza agevolmente dato che abbiamo già quasi tutto pronto.

# STEP 4: Go to production
print('\n=== Step 4: Go to production ===')
print("Train classifier on the whole dataset and classify new reviews.")
trainset_all = trainset_train + trainset_test
# train the classifier
full_classifier = NaiveBayesClassifier.train(trainset_all)

In precedenza, abbiamo già preparato sia l’input del dataset di train (per l’addestramento), sia quello del dataset di test (da usare appunto per il test). Adesso quindi non ci basta che “metterli insieme” per avere l’input da usare per il classificatore di produzione. Se usiamo l’operatore somma tra liste in Python non facciamo altro che mettere le due liste insieme una dopo l’altra, come il comando “extend”. Esempio: se abbiamo la lista a = [1, 'z', 3.65] e la lista b = [4.28, 'r', 1], allora a + b è una lsita che contiene gli elementi di a e di b, cioè a+b = [1, 'z', 3.65, 4.28, 'r', 1].

Previsioni su nuovi dati

Adesso che abbiamo il nostro classificatore di produzione, perché non simularne il funzionamento? Perché non provare cioè la nostra sentiment analysis in Python su nuovi dati?

Facciamo finta che arrivano queste 4 nuove recensioni:

# Create new reviews
input_reviews = [
    "It is an amazing movie",
    "This is a dull movie. I would never recommend it to anyone.",
    "The cinematography is pretty great in this movie",
    "The direction was terrible and the story was all over the place"
]

Chiediamo al nostro classificatore di produzione di dirci quale sia il sentiment per ciascuna di queste recensioni.

Ci sono almeno due metodi diversi che possiamo usare per fare questo in NLTK: classify e prob_classify. Entrambi richiedono lo stesso input, cioè le feature della recensione (sempre nel formato “parola: True“), ma restituiscono due output diversi. Il metodo classify restituisce il sentiment della review passata in input, mentre prob_classify restituisce la probabilità per ciascuno dei sentimenti possibili.

print(f"\nPredictions on {len(input_reviews)} new reviews:")
for i, review in enumerate(input_reviews):
    X_features = feature_extractor_sentiment(review.split())
    print(f"\n{i+1}. Input review = '{review}'")
    # Way 1 - returns sentiment
    predicted_sentiment = full_classifier.classify(X_features)
    print(f'\tMethod 1.\n\t\tThis review is classified as {predicted_sentiment}')
    # Way 2 - returns probability for each sentiment
    probdist = full_classifier.prob_classify(X_features)
    pred_sentiment = probdist.max()
    print(f"\tMethod 2.\n\t\tPredicted sentiment: {pred_sentiment}")
    print(f"\t\twith probability: {100*round(probdist.prob(pred_sentiment), 4):5.1f}%")

Con questo codice facciamo un loop su tutte le recensioni da valutare. Poi, per ciascuna recensione usiamo i due metodi. Nel primo caso, metodo classify, mostriamo direttamente l’output ritornato dal metodo. Nel secondo caso, metodo prob_classify, ritorniamo due cose: il sentimento e la probabilità che la recensione abbia proprio quel sentimento.

Piccola nota sul codice. Utilizziamo qua per la prima volta il comando enumerate. Si tratta di un comando molto comodo, che uso nei loop. Prende come input una lista e ritorna in output la stessa lista, con l’aggiunta di un numero intero crescente (a partire da zero) prima di ciascun elemento. Così ad esempio se usiamo enumerate sulla lista a = ['cane', 'gatto', 'goblin'] il risultato sarà un oggetto enumerate i cui elementi sono: (0, 'cane'), (1, 'gatto'), (2, 'goblin').

Attenzione al tipo di variabile ritarnata dai metodi. Il metodo classify ritorna una semplice stringa, che indica sentiment. Il metodo probl_classify, invece, ritorna una “ProbDist with 2 samples”, che è un oggetto della classe nltk.probability.DictionaryProbDist. Tra le altre cose, questa classe ha tre metodi molto utili: il metodo max (usato sopra, vedi riga 10) che ritorna la percentuale massima tra tutte quelle presenti nella distribuzione, il metodo sample (non usato nel nostro codice) che ritorna tutti i sentiment possibili, ed il metodo prob (che useremo più avanti) che ritorna la probabilità di uno specifico sentiment passato come input.

Predictions on 4 new reviews:
1. Input review = 'It is an amazing movie'
    Method 1.
        This review is classified as positive
    Method 2.
        Predicted sentiment: positive
        with probability:  60.8%
2. Input review = 'This is a dull movie. I would never recommend it to anyone.'
    Method 1.
        This review is classified as negative
    Method 2.
        Predicted sentiment: negative
        with probability:  78.7%
3. Input review = 'The cinematography is pretty great in this movie'
    Method 1.
        This review is classified as positive
    Method 2.
        Predicted sentiment: positive
        with probability:  63.8%
4. Input review = 'The direction was terrible and the story was all over the place'
    Method 1.
        This review is classified as negative
    Method 2.
        Predicted sentiment: negative
        with probability:  65.4%

Possiamo vedere che il sentiment è corretto per tutte e 4 le recensioni di prova.

Bonus

Ci sono un paio di argomenti che non hanno trovato posto nel corpo principale di questo articolo, ma che reputo comunque interessanti e per questo ho deciso di inserirli in questa sezione bonus.

  1. il primo riguarda i metodi addizionali per eseguire la classificazione
  2. il secondo invece riguarda un’esplorazione più approfondita delle feature più informative del classificatore

Bonus 1: Classificazione di gruppo

Quando abbiamo fatto le previsioni su nuovi dati, facevo riferimento al fatto che esistono “almeno” due modi di fare le previsioni. Infatti c’è ne sono altri due, che non sono altro che l’estensione di quelli che abbiamo visto al caso in cui si voglia fare una previsione su molte recensioni con un solo comando. Prima infatti, se notate, ogni volta che abbiamo usato i comandi per classificare, li abbiamo usati su una sola recensione. Con i metodi classify_many e prob_classify_many, possiamo classificare più recensioni in una volta sola.

# Perform multiple classifications without loop
input_reviews_wordlist = [feature_extractor_sentiment(review.split()) for review in input_reviews]
# get sentiment
group_classification = full_classifier.classify_many(input_reviews_wordlist)
# get probabilities
group_prob_values = full_classifier.prob_classify_many(input_reviews_wordlist)
group_probs = [round(pdist.prob(sentiment), 3) for (pdist, sentiment) in zip(group_prob_values, group_classification)]
print('\nMultiple reviews classification')
print(f'\tSentiments are = {group_classification}')
print(f'\tProbabilities are = {group_probs}')

Come vedete qua abbiamo prima creato una lista con le feature di tutte e quattro le recensioni di interesse. Poi abbiamo chiamato i due metodi per fare la classificazione salvando i risultati in due variabili separate. Alla fine abbiamo combinato queste due variabili e mostrato a schermo i risultati.

Vi invito a guardare la seconda list comprehension con attenzione (riga 7), perché è un formato che torna molto utile ogni volta che si vogliono combinare insieme elementi di più liste.

Ovviamente otteniamo le stesse probabilità di prima, ma riporto comunque l’output per completezza (ed anche per fare in modo che lo possiate confrontare con il vostro).

Multiple reviews classification
    Sentiments are = ['positive', 'negative', 'positive', 'negative']
    Probabilities are = [0.608, 0.787, 0.638, 0.654]

Bonus 2: Feature più informative

  1. Quali sono le 3 feature più informative per il sentiment positivo e quali per quello negativo?
  2. Qual è il livello di informatività di “awesome”? E di “bad”?
  3. Quante sono le recensioni che utilizzano una certa parola?

Queste sono le tre domande che ci eravamo posti quando stavamo esplorando il classificatore già addestrato.

Purtroppo (o per fortuna, dipende dai punti di vista), non ho trovato metodi in NaiveBayesClassifier di NLTK che potessero rispondere in maniera diretta a queste domande. Allora ho deciso di scriverli da me 🙂 e questo mi ha portato via diverso tempo extra. In compenso mi ha fatto divertire un bel po’ 🙂 (strano modo che ho di divertirmi, penserete voi).

Senza entrare troppo nel merito di come sono state sviluppate, vi riporto di seguito le funzioni che ho realizzato. Vi ricordo che queste funzioni, così come tutto il codice prodotto in questa serie sull’NLP di base in Python, sono condivise su GitHub in questo repository e distribuite con licenza Open Source, siete dunque liberi di utilizzarle per i vostri progetti.

In tutto si tratta 6 funzioni, che si possono suddividere in 3 coppie, in quanto vi sono 2 funzioni per ciascuna delle domande prima indicate. Una funzione di ciascuna coppia serve per calcolare la risposta alla domanda e l’altra serve invece per mostrare il risultato direttamente a schermo.

Nella stesura di queste funzioni sono partito dal contenuto dei metodi most_informative_features e show_most_informative_features della classe NaiveBayesClassifier di NLTK e poi l’ho esteso in base alle mie necessità ed il mio gusto.

Qualora vi interessasse qualche dettaglio in più su queste funzioni, chiedetelo pure nei commenti!

Domanda 1

Quali sono le 3 feature più informative per il sentiment positivo e quali per quello negativo?

def most_informative_features_for_label(clf, req_label, n=10):
    # Compute the n most informative features for the required label.
    allowed_labels = clf._labels
    if req_label not in allowed_labels:
        print(f'The label you asked for, {req_label}, is not present in the classifier.')
        print(f'\tAllowed values for label are: {allowed_labels}.')
        return
    #
    # sort ALL of the features by their informative level
    # The set of (fname, fval) pairs used by this classifier
    features = set()
    # The max & min probability associated with each (fname, fval) pair.  Maps (fname,fval) -> float
    maxprob = defaultdict(lambda: 0.0)
    minprob = defaultdict(lambda: 1.0)
    for (label, fname), probdist in clf._feature_probdist.items():
        for fval in probdist.samples():
            feature = (fname, fval)
            features.add(feature)
            p = probdist.prob(fval)
            maxprob[feature] = max(p, maxprob[feature])  # this is for all labels
            minprob[feature] = min(p, minprob[feature])
            if minprob[feature] == 0:
                features.discard(feature)
    sorted_features = sorted(features, key=lambda x: minprob[(x[0], x[1])] / maxprob[(x[0], x[1])])
    #
    # Print only the ones for the required label
    result = []
    cpdist = clf._feature_probdist
    for (fname, fval) in sorted_features:
        def labelprob(l):
            return cpdist[l, fname].prob(fval)

        labels = sorted(
            (l for l in allowed_labels if fval in cpdist[l, fname].samples()),
            key=lambda element: (-labelprob(element), element),
            reverse=True,
        )
        if len(labels) == 1:
            continue  # ignore (fname, fval) that are present in only 1 label
        l0 = labels[0]
        l1 = labels[-1]
        if l1 == req_label:
            if cpdist[l0, fname].prob(fval) == 0:
                ratio = "INF"
            else:
                ratio = cpdist[l1, fname].prob(fval) / cpdist[l0, fname].prob(fval)
            result.append([fname, fval, l1, l0, ratio])
    return result[:n]


def show_most_informative_features_for_label(clf, label, n=10):
    # Show to terminal the n most informative features for the required label.
    features = most_informative_features_for_label(clf, label, n)
    if features is None:
        return
    print(f'\nTop {n} most informative features for label = {label}:')
    for item in features:
        print(f'{item[0]:>15s} = {str(item[1]):<10s}\t{item[2]:>10} : {item[3]:<10}\t=\t{float(item[4]):5.1f} : 1.0')

Domanda 2

Qual è il livello di informatività di “awesome”? E di “bad”?

def inform_of_one_feature(clf, feature_name, feature_value):
    # Compute the informative level for the required feature and value.
    prob_dist = clf._feature_probdist
    myfeat_probdist = []
    for label in clf._labels:
        try:
            myfeat_probdist.append([label, feature_name, feature_value,
                                    prob_dist[(label, feature_name)].prob(feature_value)])
            if feature_value not in prob_dist[(label, feature_name)].samples():
                print(f'\'{feature_name}\' doesn\'t have \'{feature_value}\' for label \'{label}\'')
                return []
        except:
            print(f"'{feature_name}' is not present for label '{label}'")
            return []
    label_1_prob = myfeat_probdist[0][3]
    label_2_prob = myfeat_probdist[1][3]
    if label_1_prob > label_2_prob:  # 1st sentiment is higher
        label_top = myfeat_probdist[0][0]
        label_bottom = myfeat_probdist[1][0]
        ratio = myfeat_probdist[0][3] / myfeat_probdist[1][3]
    else:
        label_top = myfeat_probdist[1][0]
        label_bottom = myfeat_probdist[0][0]
        ratio = myfeat_probdist[1][3] / myfeat_probdist[0][3]
    result = {'label_top': label_top, 'label_bottom': label_bottom, 'ratio': ratio}
    return result


def show_inform_of_one_feature(clf, feature_name, feature_value):
    # Show to terminal the informative level of the required feature.
    result = inform_of_one_feature(clf, feature_name, feature_value)
    if result is None:
        return
    print(f"\nInformativeness level for feature '{feature_name}' (with value = {feature_value}):")
    print(f"{feature_name:>15} = {str(feature_value):<10}"
          f"\t{result['label_top']:>10} : {result['label_bottom']:<10}"
          f"\t=\t{result['ratio']:5.1f} : 1.0")

Domanda 3

Quante sono le recensioni che utilizzano una certa parola?

def review_count_by_word(clf, word):
    # Compute the number of reviews that contain the required word for each label.
    result = {}
    for label in clf._labels:
        result[label] = clf._feature_probdist[label, word].freqdist()[True]
        # numer of reviews that have fval = True, for fname = word, in the dataset used to train clf
        # see https://www.nltk.org/_modules/nltk/classify/naivebayes.html
    return result


def show_review_count_by_word(clf, word):
    # Show to terminal the number of reviews that contain the required word.
    result = review_count_by_word(clf, word)
    if result is None:
        return
    print(f"\nTotal number of reviews containing the word {word}:")
    for label, count in result.items():
        print(f"\tfor label = {label} \t {count:5d} reviews")

Risposte

Grazie a queste funzioni possiamo adesso rispondere alle domande sopra indicate con le seguenti righe di codice.

n_feat = 3
# show top n_feat words for each sentiment 
show_most_informative_features_for_label(classifier, 'positive', n_feat)
show_most_informative_features_for_label(classifier, 'negative', n_feat)

# show informativeness for some specific words
show_inform_of_one_feature(classifier, 'awesome', True)
show_inform_of_one_feature(classifier, 'awful', True)
show_inform_of_one_feature(classifier, 'bad', True)
show_inform_of_one_feature(classifier, 'outstanding', True)
show_inform_of_one_feature(classifier, 'insulting', True)

# show how many reviews use the top informative word by sentiment
for feature in most_informative_features_for_label(classifier, 'positive', n_feat):
    show_review_count_by_word(classifier, feature[0])
for feature in most_informative_features_for_label(classifier, 'negative', n_feat):
    show_review_count_by_word(classifier, feature[0])
# show how many reviews use the 2 specific words
show_review_count_by_word(classifier, 'awesome')
show_review_count_by_word(classifier, 'bad')

Come vedete il codice prosegue con 3 parti. Dopo aver impostato N=3, nella prima parte (righe 2-4) si mostrano le top N parole per ciascuno dei due sentimenti. Nella seconda (righe 6-11) si mostra l’informatività di 5 parole per le quali ero curioso di conoscere il valore. Nella terza parte (righe 13-20) si fa un loop e, per ciascuna delle top N parole, si indicano il numero di recensioni (di entrambi i sentiment) in cui sono utilizzate. Poi si richiede lo stesso numero per due specifiche parole. Ecco di seguito tutti i risultati.

Top 3 most informative features for label = positive:
      maintains = True      	  positive : negative  	=	 11.7 : 1.0
         avoids = True      	  positive : negative  	=	 11.7 : 1.0
         regard = True      	  positive : negative  	=	  9.7 : 1.0
Top 3 most informative features for label = negative:
         seagal = True      	  negative : positive  	=	 12.3 : 1.0
       headache = True      	  negative : positive  	=	 10.3 : 1.0
          sucks = True      	  negative : positive  	=	  9.8 : 1.0

Informativeness level for feature 'awesome' (with value = True):
        awesome = True      	  positive : negative  	=	  1.9 : 1.0

Informativeness level for feature 'awful' (with value = True):
          awful = True      	  negative : positive  	=	  5.1 : 1.0

Informativeness level for feature 'bad' (with value = True):
            bad = True      	  negative : positive  	=	  1.9 : 1.0

Informativeness level for feature 'outstanding' (with value = True):
    outstanding = True      	  positive : negative  	=	  9.2 : 1.0

Informativeness level for feature 'insulting' (with value = True):
      insulting = True      	  negative : positive  	=	  8.6 : 1.0

Total number of reviews containing the word maintains:
    for label = positive 	    17 reviews
    for label = negative 	     1 reviews

Total number of reviews containing the word avoids:
    for label = negative 	     1 reviews
    for label = positive 	    17 reviews

Total number of reviews containing the word regard:
    for label = positive 	    14 reviews
    for label = negative 	     1 reviews

Total number of reviews containing the word seagal:
    for label = negative 	    18 reviews
    for label = positive 	     1 reviews

Total number of reviews containing the word headache:
    for label = negative 	    15 reviews
    for label = positive 	     1 reviews

Total number of reviews containing the word sucks:
    for label = negative 	    24 reviews
    for label = positive 	     2 reviews

Total number of reviews containing the word awesome:
    for label = positive            16 reviews
    for label = negative             8 reviews

Total number of reviews containing the word bad:
    for label = positive           217 reviews
    for label = negative           411 reviews

Alcune delle parole che per me erano ovvie, come “awesome” per le recensioni positive e “bad” per quelle negative, sono più probabilmente associate a recensioni positive e negative rispettivamente. Ma hanno, entrambe, un “rapporto di informatività” molto basso, pari solo a 1.9 (rispetto al valore massimo di 12.3). Allo stesso tempo, le parole più informative, come “seagal” e “maintains”, sono usate poche volte, rispettivamente, solo in 19 e 18 recensioni su 1600 (contro le 628 di “bad”! ).

Conclusioni

In questo articolo abbiamo costruito un classificatore per fare la sentiment analysis in Python con NLTK, abbiamo cioè creato un sistema capace di capire automaticamente se la recensione di un film è positiva o negativa, in base alla parole che la compongono. Abbiamo seguito una procedura simile a quanto fatto nell’articolo sul classificatore di genere.

  1. Abbiamo utilizzato ed esplorato un dataset con 2000 recensioni (in inglese) presente nella libreria NLTK, equamente suddivise tra recensioni positive e negative
  2. Abbiamo poi eseguito la sentiment analysis in Python con NLTK, costruendo un classificatore con NaiveBayesClassifier e generalizzando le due funzioni usate nel precedente articolo per preparare l’input richiesto da questo classificatore. In particolare abbiamo:
    1. usato la stratification
    2. preparato l’input nel formato richiesto dalla libreria
    3. calcolato il classification rate del nostro modello addestrato
  3. Abbiamo esplorato le feature (parole) più informative del classificatore
  4. ed infine abbiamo creato il classificatore di produzione, che è stato poi testato su nuovi dati.

Ma tutto questo non ci è bastato e siamo poi andati oltre, con due sezioni bonus:

    1. Abbiamo imparato ad fare delle classificazioni di gruppo, tramite i metodi classify_many e prob_classify_many.
    2. Siamo andati in profondità sul tema dell’esplorazione del classificatore, creando 6 nuove funzioni per saperne di più sull’informatività delle feature.

Come al solito, tutto il codice che abbiamo necessario per fare la sentiment analysis in Python con NLTK è anche riportato in questo stesso articolo. Inoltre, il codice di tutta la serie è disponibile in questo repository pubblico su GitHub, con licenza Open Source. Nel repo trovate anche le docstring per le funzioni.

Link utili

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

  1. Riporto i tre link legati a NLTK indicati anche nel precedente articolo perché sono ancora validi:
    1. corpora (incluso movie_reviews): 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. A questi aggiungo questi altri:
    1. modulo nltk.probability https://www.nltk.org/api/nltk.probability.html, che contiene, in particolare:
      1. classe FreqDist (usata nell’esplorazione del dataset) https://www.nltk.org/api/nltk.probability.html?#nltk.probability.FreqDist
      2. classe DictionaryProbDist (usata per fare le previsioni con il classificatore, e poi anche per l’approfondimento sulle feature più informative del classificatore) https://www.nltk.org/api/nltk.probability.html?#nltk.probability.DictionaryProbDist
    2. codice sorgente del metodo most_informative_features (usata nell’approfondimento sulle feature più informative del classificatore) https://www.nltk.org/_modules/nltk/classify/naivebayes.html#NaiveBayesClassifier.most_informative_features