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:
- Tokenization
- Stemming
- Lemmatization
- Splitting
- Bag-of-words
- Text classifier
- Gender classifier
- Sentiment analysis
- Sentiment analysis con
NLTK
<— Questo articolo
- Sentiment analysis con
- 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:
- Esplorazione del dataset
movie_reviews
presente inNLTK
- Costruzione di un modello di machine learning basato sull’algoritmo Naive Bayes (tramite
NLTK
) - Esplorazione del classificatore appena costruito
- 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.
- il primo riguarda i metodi addizionali per eseguire la classificazione
- 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
- Quali sono le 3 feature più informative per il sentiment positivo e quali per quello negativo?
- Qual è il livello di informatività di “awesome”? E di “bad”?
- 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.
- Abbiamo utilizzato ed esplorato un dataset con 2000 recensioni (in inglese) presente nella libreria
NLTK
, equamente suddivise tra recensioni positive e negative - Abbiamo poi eseguito la sentiment analysis in Python con
NLTK
, costruendo un classificatore conNaiveBayesClassifier
e generalizzando le due funzioni usate nel precedente articolo per preparare l’input richiesto da questo classificatore. In particolare abbiamo:- usato la stratification
- preparato l’input nel formato richiesto dalla libreria
- calcolato il classification rate del nostro modello addestrato
- Abbiamo esplorato le feature (parole) più informative del classificatore
- 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:
-
- Abbiamo imparato ad fare delle classificazioni di gruppo, tramite i metodi
classify_many
eprob_classify_many
. - Siamo andati in profondità sul tema dell’esplorazione del classificatore, creando 6 nuove funzioni per saperne di più sull’informatività delle feature.
- Abbiamo imparato ad fare delle classificazioni di gruppo, tramite i metodi
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:
- Riporto i tre link legati a
NLTK
indicati anche nel precedente articolo perché sono ancora validi:- corpora (incluso
movie_reviews
): https://www.nltk.org/howto/corpus.html - classificare in NLTK: https://www.nltk.org/api/nltk.classify.html
- modulo Naive Bayes: https://www.nltk.org/api/nltk.classify.naivebayes.html
- corpora (incluso
- A questi aggiungo questi altri:
- modulo
nltk.probability
https://www.nltk.org/api/nltk.probability.html, che contiene, in particolare:- classe
FreqDist
(usata nell’esplorazione del dataset) https://www.nltk.org/api/nltk.probability.html?#nltk.probability.FreqDist - 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
- classe
- 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
- modulo