Capitolo 1 Esempio Sepolcri

1.1 Introduzione

Questo capitolo introduce alcuni concetti di base e le principali definizioni utili a comprendere le procedure dell’analisi testuale con R, seguendo l’esempio di un testo noto, complesso, e relativamente breve, “I sepolcri” di Ugo Foscolo.

In realtà, non è semplice presentare la procedura di analisi, perché i campi di applicazione dell’analisi quantitativa dei testi sono vari e numerosi, così come le tecniche e le procedure di analisi: una sintesi di massima delle possibili opzioni è presentata in Figura 3.

Figura 3: La costruzione della base dati: workflow

Nei capitoli successivi, verranno presentati alcuni degli strumenti per l’analisi testuale e del contenuto che si basano sulle matrici documenti-termini (Figura 4) e su quelle delle co-occorrenze.

Matrice documenti-termini, o documenti-forme

Figura 4: Matrice documenti-termini, o documenti-forme

Per costruire la matrice testuale, e dunque procedere all’analisi, è necessario definire righe e colonne, ovvero le unità di contesto (i “documenti”; § 1.3) e le unità di analisi di testo (le “forme”; § 1.4), attraverso la riduzione/normalizzazione dei testi.

1.2 Testo e corpus

1.2.1 Il testo

Con il termine “testo” indichiamo l’insieme delle parole (inclusi punteggiatura e altri segni; cfr. § 1.4) che compongono uno scritto (documento).

Il testo dell’esempio illustrato in questo capitolo è quello dell’ode I Sepolcri, così come disponibile in WikiSource, e di cui riportiamo solo la prima strofa.

All’ ombra de’ cipressi e dentro l’ urne
Confortate di pianto è forse il sonno
Della morte men duro? Ove più il Sole
Per me alla terra non fecondi questa
Bella d’ erbe famiglia e d’ animali,
E quando vaghe di lusinghe innanzi
A me non danzeran l’ ore future,
Né da te, dolce amico, udrò più il verso
E la mesta armonia che lo governa,
Né più nel cor mi parlerà lo spirto
Delle vergini Muse e dell’ Amore,
Unico spirto a mia vita raminga,
Qual fia ristoro a’ dì perduti un sasso
Che distingua le mie dalle infinite

I testi possono essere importati in vari modi, che verranno illustrati nel terzo capitolo. In questo caso, il file del formato txt presente in WikiSource è stato scaricato, controllato e corretto, e infine importato con la funzione del pacchetto readtext [Benoit e Obeng (2024)]1.

library(readtext)
# importazione del testo
sepolcri <- readtext("dati/sepolcri.txt")

1.2.2 Il corpus

Un corpus è l’insieme dei testi oggetto dell’analisi. Il nostro esempio consta di un solo testo, ma volessimo analizzare tutte le opere di Ugo Foscolo, sarebbe preferibile organizzare tutti i testi in un corpus.

Nel campo del NLP, si tratta di uno dei formati più comuni di organizzazione dei dati, ed è utilizzato da diversi pacchetti di R. Il vantaggio principale di questo formato, è che i testi vengono organizzati insieme ai relativi metadati, e ai metadati del corpus.

In Quanteda (Benoit et al. 2023), il corpus è infatti pensato per restare il riferimento stabile dei testi originari, non per il pre-trattamento dei testi (capitolo 2; solitamente necessario almeno per correggere errori ortografici), né per le successive trasformazioni dei testi.

library(quanteda)
# imposto la lingua italiana
quanteda_options(language_stemmer ="italian")
# costruzione del corpus
sep.corpus <- corpus(sepolcri)

1.3 Unità di contesto: documenti e frammenti

La statistica testuale si basa fondamentalmente su frequenze e co-occorrenze delle parole. Ma cosa si intende per “co-occorrenza” o “vicinanza” di due parole? Nel nostro esempio, le parole “ombra” del primo verso e “risplenderà” dell’ultimo co-occorrono? Sono vicine o sono lontane?

Per dare una risposta sensata a domande di questo genere, è necessario prendere alcune decisioni sul contesto delle parole: l’ode, la strofa, le opere di Foscolo, ecc.

I testi potranno dunque essere segmentati in frammenti, che saranno le unità di contesto rispetto alle quali saranno valutate molte delle misure statistiche utilizzate.

# segmentazione in Quanteda
sep.corpus.s <- corpus_reshape(sep.corpus, to = "sentences")

Nel caso del nostro esempio, abbiamo diviso il testo originale in frammenti corrispondenti alle frasi (divise da segni di interpunzione forti). Si considerino i primi tre:

Corpus consisting of 3 documents.
sepolcri.txt.1 :
"All' ombra de'  cipressi e dentro l' urne Confortate di pianto è forse il sonno ..."

sepolcri.txt.2 :
"Ove più il Sole Per me alla terra non fecondi questa Bella d' erbe famiglia e d'..."

sepolcri.txt.3 :
"Vero è ben, Pindemonte!"

Ogni frammento — sono in tutto 56 — viene ora indicizzato come un documento separato (sepolcri.txt.1, sepolcri.txt.2, sepolcri.txt.3, ecc.).

1.4 Unità di analisi dei testi: parole, token e types

Le prime domande che solitamente si pongono a livello descrittivono sono: quanto è lungo il testo? Quante parole contiene? Quali sono le parole più frequenti?

Una prima risposta può essere data contando i caratteri del testo, ad esempio con la funzione nchar() :

nchar(sep.corpus)
sepolcri.txt 
       10929 

Il numero di caratteri — come forse si avrà avuto modo di vedere utilizzando le statistiche dei documenti di Word — può però essere conteggiato tenendo conto o meno degli spazi (il risultato di ncharli include).

Ancora meno immediato è definire cosa significhi contare le “parole”.

1.4.1 Definizioni

Bolasco (2013) definisce “parola” come il termine convenzionale generico utilizzato per identificare l’unità di analisi del testo.

I 10.793 caratteri possono essere distinti in 2.363 occorrenze (o tokens) di 1.096 forme o types (inclusi numeri, segni di punteggiatura e altri caratteri),

summary(sep.corpus)
Corpus consisting of 1 document, showing 1 document:

         Text Types Tokens Sentences
 sepolcri.txt  1096   2363        56

dove — riprendendo ancora le definizioni del glossario di Bolasco (2013):

  • il token è la singola occorrenza o replica di un type;
  • il type è il tipo di occorrenza scandita dal parsing del testo … “. I type vengono anche chiamati forme (grafiche) o grafie.

Le parole, comunemente intese, sono i token esclusi i segni di punteggiatura, mentre lafrequenza di una parola, è il numero di occorrenze del type.

Chiariamo meglio con un esempio tratto dal testo della canzone di Achille Lauro presentata a Sanremo 2020.

Sì Noi sì Noi che qui Siamo soli qui Noi sì Soli qui Fai di me quel che vuoi sono qui

In questa frase — scelta proprio perché contiene molte ripetizioni, ed è priva di punteggiatura — i tokens sono 212 (notare che il corpus in questo caso non serve e non viene costruito):

library(tidyverse)
# creiamo un vettore carattere con il testo
testo <- "Sì Noi sì Noi che qui Siamo soli
qui Noi sì Soli qui Fai di me 
quel che vuoi sono qui"

# tokenizziamo il corpus
testo %>% 
  tokens()                                  
Tokens consisting of 1 document.
text1 :
 [1] "Sì"    "Noi"   "sì"    "Noi"   "che"   "qui"   "Siamo" "soli" 
 [9] "qui"   "Noi"   "sì"    "Soli" 
[ ... and 9 more ]

Le forme (i types) sono invece 14:

testo %>% tokens() %>% 
  types()
 [1] "Sì"    "Noi"   "sì"    "che"   "qui"   "Siamo" "soli"  "Soli" 
 [9] "Fai"   "di"    "me"    "quel"  "vuoi"  "sono" 

ulteriormente riducibili a 12 operando una prima normalizzazione, ovvero eliminando le maiuscole:

testo %>% tolower() %>% 
  tokens() %>% 
  types()
 [1] "sì"    "noi"   "che"   "qui"   "siamo" "soli"  "fai"   "di"   
 [9] "me"    "quel"  "vuoi"  "sono" 

Per estensione o ampiezza in occorrenze di questo testo intendiamo l’insieme dei token (21 “parole”, \(N\)), mentre i 12 type rappresentano l’ampiezza del suo vocabolario (\(V\)).

La distribuzione di frequenza delle parole (il numero di occorrenze dei types) è infine quella rapprentata in Tabella 1.

Tabella 1: Distribuzione di frequenza dei types
N
qui 4
3
noi 3
che 2
soli 2
siamo 1
fai 1
di 1
me 1
quel 1
vuoi 1
sono 1

Tornando ai Sepolcri, possiamo confrontare l’ampiezza del testo (\(N\), 2.363) — ciò che comunemente si intende per “lunghezza” — con quella del vocabolario (\(V\), 1.096)

# tokens e types
summary(sep.corpus)
Corpus consisting of 1 document, showing 1 document:

         Text Types Tokens Sentences
 sepolcri.txt  1096   2363        56

Dal summary del corpus segmentato (summary(sep.corpus.s)) possiamo costruire un grafico che mette a confronto ampiezza e vocabolario dei segmenti:

Numero di 'parole' nei segmenti

Figura 5: Numero di ‘parole’ nei segmenti

1.4.2 Parsing e token

L’operazione che scompone il testo in token si chiama parsing, o tokenizzazione.

I confini delle parole sono convenzionalmente rappresentati dagli spazi che li dividono e dai segni di punteggiatura. Poiché — anche prescindendo dalle lingue orientali — lingue diverse hanno convenzioni diverse per quanto riguarda l’uso dei segni di punteggiatura, le parole composte, gli apostrofi, i trattini ecc., il parsing dei testi realizzato con strumenti diversi può portare a risultati diversi. Con Quanteda:

tokens("All'ombra de' cipressi e dentro l'urne Confortate", 
       remove_punct = T, )
Tokens consisting of 1 document.
text1 :
[1] "All'ombra"  "de"         "cipressi"   "e"          "dentro"    
[6] "l'urne"     "Confortate"

Come si vede, l’apostrofo non viene riconosciuto come confine di parola. Nel quarto capitolo, verranno illustrate diverse soluzioni per operare il corretto parsing del testo in italiano.

1.5 Trattamento del lessico e normalizzazione

Una prima normalizzazione dei testi avviene già in fase di tokenizzazione, e riguarda:

  • numeri;
  • segni di punteggiatura;
  • simboli, quali “#” e “$”.

Per la gran parte delle analisi, però, questo trattamento non è sufficiente. Nella distribuzione di frequenze del testo della canzone in Tabella 1, si noterà ad esempio che due forme del verbo essere, siamo e sono, vengono conteggiate separatamente, mentre potremmo volerle considerarle insieme, come forme del verbo essere.

Le 15 forme più frequenti dei Sepolcri, rappresentate nel grafico in Figura 6, non sono significative dal punto di visto del contenuto, essendo tutte articoli, preposizioni e congiunzioni, ovvero di parole vuote (stopwords).

I Sepolcri. Le quindici forme più frequenti

Figura 6: I Sepolcri. Le quindici forme più frequenti

Nelle analisi testuali e del contenuto, vengono solitamente applicate alcune procedure di normalizzazione dei testi (capitoli 5 e 6), che riguardano:

  • l’eliminazione delle parole vuote, o stopwords: parole vuote prive di un significato autonomo, come gli articoli o le congiunzioni;
  • il trattamento delle maiuscole, identificando (o meno) le entità significative, ovvero i nomi propri (di persona, di cosa o di luogo);
  • il trattamento delle forme flesse;
  • l’individuazione e il trattamento dei poliformi, o multiword (come “Presidente della Repubblica”, o “anche se”).

Il trattamento più opportuno dipende dal tipo e dall’ampiezza dei testi, nonché dal tipo e dagli obiettivi dell’analisi.

Il testo del primo segmento dei Sepolcri è:

“All’ ombra de’ cipressi e dentro l’ urne Confortate di pianto è forse il sonno Della morte men duro?”

I tokens, esclusa la punteggiatura, sono:

Tokens consisting of 1 document.
sepolcri.txt.1 :
 [1] "All"        "ombra"      "de"         "cipressi"   "e"         
 [6] "dentro"     "l"          "urne"       "Confortate" "di"        
[11] "pianto"     "è"          "forse"      "il"         "sonno"     
[16] "Della"      "morte"      "men"        "duro"      

Tolte le maiuscole e le parole vuote (stopwords), abbiamo:

Tokens consisting of 1 document.
sepolcri.txt.1 :
 [1] "ombra"      "cipressi"   "dentro"     "urne"       "confortate"
 [6] "pianto"     "forse"      "sonno"      "morte"      "men"       
[11] "duro"      

Infine, dopo aver ridotto le forme flesse (in questo caso con la lemmatizzazione; vedi capitolo 4), il testo risulta normalizzato in questo modo:

Tokens consisting of 1 document.
sepolcri.txt.1 :
 [1] "ombra"      "cipresso"   "dentro"     "urna"       "confortare"
 [6] "pianto"     "forse"      "sonno"      "morte"      "meno"      
[11] "duro"      

Le forme flesse (o lessemi) sono state sostituite dalle forme canoniche (o lemmi), ovvero dalla «forma di citazione convezionale di un lessema in un dizionario» (Bolasco 2013). Quindi, nel nostro esempio:

  • cipressi è stato sostituito da cipresso;
  • urne da urna

e così via.

Una volta normalizzato il testo dell’intera ode, i 15 lemmi più frequenti sono quelli rappresentati nel grafico in Figura 7, certamente più informativo di quello precedente.

I 15 lemmi più frequenti

Figura 7: I 15 lemmi più frequenti

In R, lemmatizzazione e tagging grammaticale possono essere effettuate grazie alle librerie di TreeTagger (Helmut Schmid 1994), disponibile nel pacchetto koRpus (Michalke 2021) e di spaCy (Benoit e Matsuo 2023), disponibile in Quanteda.

Nelle fasi che vanno dal parsing alla normalizzazione del testo diventano fondamentali non solo le decisioni dell’analista, ma anche le risorse linguistiche disponibili — dizionari e liste.

1.6 Dalla matrice testuale all’analisi

1.6.1 Frequenze

Completati questi passaggi, abbiamo una matrice delle forme normalizzate, a partire dalle quali è possibile costruire distribuzioni di frequenze e grafici.

Ad esempio, per avere una distribuzione di frequenze come quella in Tabella 1, scriveremo:

testo %>% 
  # tokens
  tokens() %>% 
  # matrice documenti-forme
  dfm() %>% 
  # distribuzione di frequenza
  topfeatures(12) 

Anche le wordcloud risulteranno più significative se costruite con un numero ridotto di lemmi, scelti in quanto più frequenti e rilevanti (Figura 8).

I Sepolcri: wordcloud dei 35 lemmi più frequenti

Figura 8: I Sepolcri: wordcloud dei 35 lemmi più frequenti

1.6.2 Co-occorrenze

Le co-occorrenze fra le forme possono essere studiate ricorrendo diverse misure di associazione, similarità o distanza. Vediamo ad esempio, nella Tabella 2, quali sono le parole più associate al termine amore, in termini di coseno quadrato.

Tabella 2: Associazioni con il lemma amore ( prox = cosine )
Lemmi Indice
dolce 0,6708204
osso 0,4472136
grande 0,4472136
patria 0,4472136
udire 0,4000000
caro 0,4000000
terra 0,3162278
0,2236068
mandare 0,2236068
lungo 0,2236068

Sempre con gli strumenti di R, sarà possibile rappresentare e studiare tali associazioni con gli strumenti dedicati ai grafi e alla network analysis (Figura 9).

A tale scopo, ho usato qui igraph (Csardi e Nepusz 2006; Csárdi et al. 2024).

Grafo delle associazioni

Figura 9: Grafo delle associazioni

Nella Figura 10 il testo viene rappresentato come un network.

I Sepolcri: network dei lemmi con frequenza pari almeno a 4

Figura 10: I Sepolcri: network dei lemmi con frequenza pari almeno a 4

1.6.3 Analisi multidimensionale

Naturalmente, a partire dalle matrici testuali è possibile realizzare diversi tipi di analisi multivariata.

In Figura 11, ad esempio, è rappresentato il risultato del multidimensional scaling.

Multidimensional Scaling

Figura 11: Multidimensional Scaling

1.7 Sintesi: i passi operativi nei tre principali pacchetti

In generale, per passare dai documenti da analizzare alla matrice testuale, sono previsti i seguenti step:

  1. organizzazione della base dati (corpus o dataframe: §1.2; ed eventuale segmentazione: §1.3)
  2. parsing dei testi (tokenizzazione: §1.4)
  3. normalizzazione (§1.5)
  4. costruzione della matrice

1.7.1 Quanteda

L’esempio illustrato nel capitolo ha fatto uso di questo pacchetto. Rivediamo di seguito i passi salienti della procedura.

Figura 12: Quanteda: workflow

Pacchetti:

library(readtext)
library(quanteda)
quanteda_options(language_stemmer ="italian")
  1. Il primo passo è importare il testo (capitolo 3, §3.2):
sepolcri <- readtext("dati/sepolcri.txt")

Date le caratteristiche del parser interno utilizzato da (quasi) tutti i pacchetti, suggerisco di correggere il testo, inserendo uno spazio dopo gli apostrofi non seguiti da uno spazio (cfr. capitolo 4, §4.2):

# correzione degli apostrofi
sepolcri <- sepolcri %>% 
  mutate(text = str_replace_all(text, "[\'’](?!\\s)", "' "))
  1. Costruiamo il corpus (capitolo 3, §3.3):
# corpus
sep.corpus <- corpus(sepolcri) 

e poi

  1. passiamo alla tokenizzazione (capitolo 4, §4.4.1), eliminando punteggiatura, numeri e simboli (le impostazioni generalmente adottate, anche se in questo caso non troviamo numeri e simboli):
# tokens 
sep.tokens <- sep.corpus %>% 
  tokens(remove_punct = TRUE,
         remove_numbers = TRUE,
         remove_symbols = TRUE)
  1. e procediamo alla normalizzazione (capitolo ??). Consideriamo solo le due operazioni di base, ovvero l’eliminazione delle stopword:
# eliminazione delle stopwords per italiano
sep.tokens <- sep.tokens %>% 
  tokens_remove(stopwords("it"))

e lo stemming:

sep.tokens <- sep.tokens %>% 
  tokens_wordstem()
  1. Infine, dopo aver costruito la matrice:
# matrice
sep.dfm <- dfm(sep.tokens)
  1. possiamo procedere con l’analisi dei dati, a partire dalla distribuzione delle frequenze (capitolo 5):
library(quanteda.textstats)
textstat_frequency(sep.dfm) %>% 
  head(10)
   feature frequency rank docfreq group
1        d        26    1       1   all
2       de        16    2       1   all
3      ove        15    3       1   all
4     amor         9    4       1   all
5     mort         8    5       1   all
6       nè         8    5       1   all
7      sol         7    7       1   all
8     tomb         7    7       1   all
9     terr         6    9       1   all
10      te         6    9       1   all

Alcune parole vuote non sono incluse nella lista standard. Vedremo nel seguito come usare liste personali.

1.7.2 Tidytext

L’approccio di Tidytext (Robinson e Silge 2023) è alquanto diverso, in quanto i termini vengono organizzati in un dataframe, in cui le righe (i casi) corrispondono alle singole occorrenze, e in colonna sono riportate le informazioni relative (ad esempio i metadati). In questo modo, si ha una piena compatibilità con le funzioni degli altri pacchetti del Tidyverse (Wickham 2023).

Nello stesso tempo, è possibile passare da questo approccio a quello basato sulle matrici testuali trasformando i dataframe in matrici delle co-occorrenze o anche documenti-termini.

Figura 13: Tidytext: workflow

Pacchetti:

library(tidytext)
  1. L’importazione del testo viene effettuata come per Quanteda, ovvero:
sepolcri <- readtext("dati/sepolcri.txt")
# correzione degli apostrofi
sepolcri <- sepolcri %>% 
  mutate(text = str_replace_all(text, "[\'’](?!\\s)", "' "))
  1. Il passo successivo è la tokenizzazione (capitolo 4, § 4.5.4; punteggiatura, numeri e simboli vengono eliminati per default):
# tokens 
sep.tokens <- as_tibble(sepolcri) %>% 
  unnest_tokens(output = word, input = text)
  1. Normalizzazione. Tidytext lavora sempre e solo con dataframe, e presuppone spesso l’utilizzo delle funzioni dei pacchetti del tidyverse.

Per l’eliminazione delle stopwords, ad esempio, ricorre alla funzione anti_join di dplyr, che richiede una lista organizzata in dataframe.

Dal momento che la lista di stopword adatta è disponibile solo per l’inglese, dobbiamo crearne una. Usiamo la lista di Quanteda:

# dataframe delle stopwords
it_stopwords <- data.frame(word = stopwords::stopwords("italian"),
                           lexicon = "custom")

La funzione anti_join() confronta le parole contenute nei due dataframe ed elimina quelle presenti nel secondo:

# eliminazione delle stopword
sep.tokens <- sep.tokens %>% 
  anti_join(it_stopwords)

Per lo stemming, utilizziamo mutate() di dplyr e il pacchetto degli stemmer SnowballC [Bouchet-Valat (2023)]3:

sep.tokens <- sep.tokens %>% 
  mutate(word = SnowballC::wordStem(word, "it"))
  1. Anche per l’analisi dei dati, essendo i token organizzati in dataframe, possiamo usare le funzioni del tidyverse. Useremo ad esempio count() per avere la distribuzione delle frequenze (cfr. capitolo 5, § 3.4):
sep.tokens %>% 
  count(word) %>% arrange(desc(n)) %>% 
  head(10)
# A tibble: 10 × 2
   word      n
   <chr> <int>
 1 d        26
 2 de       16
 3 ove      15
 4 amor      9
 5 mort      8
 6 nè        8
 7 sol       7
 8 tomb      7
 9 cant      6
10 patr      6

Per usare altre funzioni specifiche per l’analisi testuale, il dataframe potrà essere trasformato o esportato in matrici testuali compatibili con altri pacchetti. L’esportazione dei metadati dei documenti collegabili alle matrici testuali non è però automatica e può essere alquanto laboriosa.

1.7.3 tm

Figura 14: tm: workflow

library(tm)

Il primo passo, l’importazione del testo, può avvenire in vari modi (capitolo 3, § 3.5). In questo caso, partiremo dal dataframe importato sopra:

sepolcri.tm <- sepolcri %>% 
  # indichiamo il tipo di origine dei dati
  DataframeSource() %>% 
  # corpus
  VCorpus(readerControl = list(language = "it-IT"))

Per normalizzare il testo, eliminiamo la punteggiatura e i numeri, e trasformiamo il testo in minuscolo (capitolo ??):

sepolcri.tm <- sepolcri.tm  %>% 
  # eliminiamo la punteggiatura
  tm_map(removePunctuation) %>% 
  # eliminiamo i numeri
  tm_map(removeNumbers) %>% 
  # trasformiamo il testo in minuscolo
  tm_map(content_transformer(tolower))

Poi eliminiamo le stopwords (la lista inclusa in tm è la stessa inclusa in Quanteda):

sepolcri.tm <- sepolcri.tm  %>% 
  tm_map(removeWords, stopwords("italian")) 

E infine procediamo allo stemming, con una funzione personalizzata (o per meglio dire con un workaround):

# funzione personalizzata per lo stemming
stemming <- function(x) SnowballC::wordStem(words(x), language = "italian")

sepolcri.tm <- sepolcri.tm %>% 
  tm_map(content_transformer(stemming))

A questo punto, al netto di altri interventi di normalizzazione, possiamo passare a costruire la matrice (di default, non verranno incluse le parole composte da uno o due caratteri):

sepolcri.tdm <- TermDocumentMatrix(sepolcri.tm)

E questa è la lista dei dieci termini più frequenti:

findMostFreqTerms(sepolcri.tdm, 10)
$sepolcri.txt
 ove amor mort  sol tomb cant patr sott terr amic 
  15    9    8    7    7    6    6    6    6    5 

  1. Vedi la voce pacchetti.↩︎

  2. Carichiamo qui i pacchetti del Tidyverse con il comando library(tidyverse).
    Per l’uso dell’operatore di forward piping (%>%), cfr. pipe operator.↩︎

  3. Che è quello utilizzato anche dagli altri pacchetti in background, per così dire, ma che non viene installato insieme a Tidytext↩︎