Indice

Matrici testuali come grafi

Le matrici testuali possono essere analizzate ricorrendo alle misure e agli strumenti della network analysis (ampiezza, grado, centralità, densità, distanze, grappoli ecc.), in quanto una matrice delle co-occorrenze può essere direttamente trasformata in un grafo e rappresentata come network (matrice di adiacenza):

Altre strutture di dati di uso comune nell'analisi testuale sono i dataframe dei link fra termini a due a due (edgelist), anch'essi direttamente interpretabili come grafi.

Testi e dati degli esempi, dove necessario anonimizzati, sono disponibili qui (file: es_twitter.rda). Gli script che seguono presuppongono che testi e dati siano in una sottocartella “dati” del progetto.

Con Quanteda

La funzione textplot_network() di uno dei pacchetti di Quanteda (quanteda.textplots) costruisce automaticamente il network a partire da una matrice testuale (termini documenti o delle cooccoorenze):

Le matrici testuali

# pacchetti
library(tidyverse)
library(quanteda)
quanteda_options(language_stemmer ="italian") 
library(quanteda.textplots)
 
# dati
load("dati/es_twitter.rda")

Costruiamo con Quanteda una matrice delle cooccorrenze (fcm: features cooccorrence matrix) degli hashtag, a partire da un dataset di messaggi di Twitter:

 
rt.fcm <- rt2 %>% 
  mutate(text = str_replace_all(text, "[\'’]", "' ")) %>% 
  corpus() %>% 
  # tokenizzazione
  tokens(remove_punct = T) %>% 
  # matrice documenti termini
  dfm() %>% 
  # selezione dei soli hashtags
  dfm_select("#*") %>% 
  # soglia di occorrenze
  dfm_trim(min_termfreq = 4) %>%  
  # matrice delle cooccorrenze
  fcm()

Consideriamo i primi dieci termini della matrice:

rt.fcm[1:10,1:10]
Feature co-occurrence matrix of: 10 by 10 features.
                        features
features                 #greenpass #greenpassday #greenpassrendeliberi ...
  #greenpass                      4           303                    52 ...
  #greenpassday                   0            10                   205 ...
  #greenpassrendeliberi           0             0                     2 ...
  #greenpassobbligatorio          0             0                     0 ...
  #portichiusi                    0             0                     0 ...
  #portualiditrieste              0             0                     0 ...
  #lilianasegre                   0             0                     0 ...
  #nogreenpass                    0             0                     0 ...
  #libertà                        0             0                     0 ...
  #portualitrieste                0             0                     0 ...

I termini costituiscono sia le righe che le colonne della matrice (nello stesso ordine). I valori delle celle rappresentano le co-occorrenze, ovvero il numero delle volte in cui due termini sono compresenti nello stesso messaggio.

I termini saranno i nodi del network, mentre le cooccorrenze verranno rappresentate dalle linee che collegano i nodi.

textplot_network

Applichiamo ora la funzione textplot_network(), che produrrà anche il grafico dei termini. Due termini (o categorie, nel caso dei network semantici) che co-occorrono in uno stesso testo sono legati come due nodi di una rete. Lo spessore dei link (edges) fra i nodi (vertices) è proporzionale al valore delle celle.

set.seed(345)
textplot_network(rt.fcm[1:10,1:10]) +
  coord_equal()

Note:

I nodi sono 9, anziché 10, perché la funzione elimina i nodi “isolati”, con frequenza inferiore a una soglia data.

set.seed(345)
textplot_network(rt.fcm[1:10,1:10], 
                 omit_isolated = F) +
  coord_equal()

as.igraph

In alternativa, è possibile – con la funzione as.igraph() di Quanteda (quanteda.textplots) – trasformare la matrice in un grafo di igraph, per esportarlo in un formato standard, e lavorarci con altre librerie o software.

g <- as.igraph(rt.fcm[1:10,1:10], 
               weighted = T)
g
IGRAPH 6832679 DNW- 9 26 -- 
+ attr: name (v/c), frequency (v/n), weight (e/n)
+ edges from 6832679 (vertex names):
 [1] #greenpass            ->#greenpassday         
 [2] #greenpass            ->#greenpassrendeliberi 
 [3] #greenpassday         ->#greenpassrendeliberi 
 [4] #greenpass            ->#greenpassobbligatorio
 [5] #greenpassday         ->#greenpassobbligatorio
 [6] #greenpassrendeliberi ->#greenpassobbligatorio
 [7] #greenpassday         ->#portichiusi          
 [8] #greenpass            ->#portualiditrieste    
+ ... omitted several edges

Che potrà essere poi stampato con plot():

# margini
par(mar=c(0,0,0,0))
 
set.seed(345)
plot(g,
     asp = 1)


Script di esempio

text_network_quanteda.R
# pacchetti
library(tidyverse)
library(quanteda)
quanteda_options(language_stemmer ="italian") 
library(quanteda.textplots)
 
# dati
load("dati/es_twitter.rda")
 
# matrice delle co-occorrenze
rt.fcm <- rt2 %>% 
  mutate(text = str_replace_all(text, "[\'’]", "' ")) %>% 
  corpus() %>% 
  # tokenizzazione
  tokens(remove_punct = T) %>% 
  # matrice documenti termini
  dfm() %>% 
  # selezione dei soli hashtags
  dfm_select("#*") %>% 
  # soglia di occorrenze
  dfm_trim(min_termfreq = 4) %>%  
  # matrice delle cooccorrenze
  fcm()
 
# network
set.seed(345)
textplot_network(rt.fcm[1:10,1:10]) +
  coord_equal()
 
# grafo igraph
g <- as.igraph(rt.fcm[1:10,1:10], 
               weighted = T)

Con igraph

Grafo non orientato

Il network precedente, diversamente dal primo, è orientato, ovvero presenta - ad esempio - una freccia che va da #portichiusi a #portualitrieste e una che va in senso inverso.

Se stiamo lavorando con le semplici cooccorrenze di termini, preferiremo con tutta probabilità un grafo non orientato (diverso sarà invece il caso dei network concettuali o semantici).

Per operare questa trasformazione (ed altre) sul grafo, possiamo usare le funzioni del pacchetto igraph.

library(igraph)
 
# trasformo il grafo in un grafo non orientato
g <- g %>% 
  as.undirected()
g
IGRAPH d340f6b UNW- 9 26 -- 
+ attr: name (v/c), frequency (v/n), weight (e/n)
+ edges from d340f6b (vertex names):
 [1] #greenpass            --#greenpassday         
 [2] #greenpass            --#greenpassrendeliberi 
 [3] #greenpassday         --#greenpassrendeliberi 
 [4] #greenpass            --#greenpassobbligatorio
 [5] #greenpassday         --#greenpassobbligatorio
 [6] #greenpassrendeliberi --#greenpassobbligatorio
 [7] #greenpassday         --#portichiusi          
 [8] #greenpass            --#portualiditrieste    
+ ... omitted several edges
par(mar=c(0,0,0,0))
set.seed(345)
plot(g,
     asp = 1)

Il grafo contiene 9 nodi, 26 link, ed una serie di attributi relativi ai nodi e ai link, ereditati dalla matrice di partenza, così come il nome (che in plot() viene interpretato come etichetta dei nodi). Gli attributi dei nodi sono accessibili mediante le funzioni V(g) (per i nodi) e E(g) (per i link).

V(g)$name
[1] "#greenpass"             "#greenpassday"          "#greenpassrendeliberi" 
[4] "#greenpassobbligatorio" "#portichiusi"           "#portualiditrieste"    
[7] "#lilianasegre"          "#nogreenpass"           "#portualitrieste"  

o le frequenze dei termini:

# frequenze dei termini
V(g)$frequency
[1]  302 1678  205  301   31  275   51  187  126
# pesi (co-occorrenze)
E(g)$weight
 [1] 303  52 205  82 303  58  31  47 278  43  64  19  16  51  41 187  26  31
[19]  39  16  28 126  16  37  21  24

La possibilità di aggiungere e modificare le proprietà di nodi e link è di fondamentale importanza per l'analisi di queste reti.

Da una matrice di adiacenza (cooccorrenze)

Un grafo può essere costruito dalla matrice delle cooccorrenze anche con la funzione graph_from_adjacency_matrix() di igraph1). Torniamo all'esempio precedente:

graph_from_adjacency_matrix(rt.fcm[1:10,1:10])
IGRAPH b9aaf63 DN-- 10 2248 -- 
+ attr: name (v/c)
+ edges from b9aaf63 (vertex names):
 [1] #greenpass->#greenpass    #greenpass->#greenpass   
 [3] #greenpass->#greenpass    #greenpass->#greenpass   
 [5] #greenpass->#greenpassday #greenpass->#greenpassday
 [7] #greenpass->#greenpassday #greenpass->#greenpassday
 [9] #greenpass->#greenpassday #greenpass->#greenpassday
[11] #greenpass->#greenpassday #greenpass->#greenpassday
[13] #greenpass->#greenpassday #greenpass->#greenpassday
[15] #greenpass->#greenpassday #greenpass->#greenpassday
+ ... omitted several edges

Volendo un grafo non orientato, ponderato e semplificato:

g2 <- graph_from_adjacency_matrix(rt.fcm[1:10,1:10],
                                  # pesi
                                  weighted = T) %>% 
  # non orientato
  as.undirected() %>% 
  # semplificato
  simplify() 
g2
IGRAPH 0776c35 UNW- 10 42 -- 
+ attr: name (v/c), weight (e/n)
+ edges from 0776c35 (vertex names):
 [1] #greenpass   --#greenpassday         
 [2] #greenpass   --#greenpassrendeliberi 
 [3] #greenpass   --#greenpassobbligatorio
 [4] #greenpass   --#portichiusi          
 [5] #greenpass   --#portualiditrieste    
 [6] #greenpass   --#lilianasegre         
 [7] #greenpass   --#nogreenpass          
 [8] #greenpass   --#libertà              
+ ... omitted several edges

Possiamo recuperare le informazioni sulle frequenze dei termini dai metadati della matrice, ed aggiungerle come attributo dei nodi:

V(g2)$frequency <- meta(rt.fcm, "margin", "object")[1:10]

Nel grafico che segue, vediamo un esempio di uso degli attributi per modificare l'output:

set.seed(123)
par(mar = c(0,0,0,0))
plot(g2,
     edge.curved = 0.2,
     asp = 1,
     vertex.frame.color = "transparent",
     # dimensione dei nodi proporzionali alle frequenze
     vertex.size = V(g2)$frequency/max(V(g2)$frequency)*40,
     # spessore dei link proporzionali ai pesi
     edge.width = E(g2)$weight/max(E(g2)$weight)*10
     )

Grafo da un dataframe

Una lista di link (edgelist) si presenta in questo modo:

# trasformiamo la matrice dell'esempio precedente
df <- tidytext::tidy(rt.fcm[1:10,1:10]) %>% 
  rename(term1 = document,
         term2 = term,
         weight = count)
df
# A tibble: 52 × 3
   term1                  term2                  weight
   <chr>                  <chr>                   <dbl>
 1 #greenpass             #greenpass                  4
 2 #greenpass             #greenpassday             303
 3 #greenpassday          #greenpassday              10
 4 #greenpass             #greenpassrendeliberi      52
 5 #greenpassday          #greenpassrendeliberi     205
 6 #greenpassrendeliberi  #greenpassrendeliberi       2
 7 #greenpass             #greenpassobbligatorio     82
 8 #greenpassday          #greenpassobbligatorio    303
 9 #greenpassrendeliberi  #greenpassobbligatorio     58
10 #greenpassobbligatorio #greenpassobbligatorio      2
# … with 42 more rows

Il primo termine viene considerato come il nodo da cui parte il link, il secondo termine come quello di arrivo.

Importante è anche che la colonna con i valori delle co-occorrenze si chiami “weight”, affinché venga interpretato come sistema di pesi dalla funzione graph_from_data_frame(), e il grafo risulti ponderato.

graph_from_data_frame(df, 
                      # non orientato
                      directed = F) %>% 
  # semplificato
  simplify()
</code
 
<code rsplus>
IGRAPH f0bcd60 UNW- 10 42 -- 
+ attr: name (v/c), weight (e/n)
+ edges from f0bcd60 (vertex names):
 [1] #greenpass   --#greenpassday          #greenpass   --#greenpassrendeliberi 
 [3] #greenpass   --#greenpassobbligatorio #greenpass   --#portichiusi          
 [5] #greenpass   --#portualiditrieste     #greenpass   --#lilianasegre         
 [7] #greenpass   --#nogreenpass           #greenpass   --#libertà              
 [9] #greenpass   --#portualitrieste       #greenpassday--#greenpassrendeliberi 
[11] #greenpassday--#greenpassobbligatorio #greenpassday--#portichiusi          
[13] #greenpassday--#portualiditrieste     #greenpassday--#lilianasegre         
[15] #greenpassday--#nogreenpass           #greenpassday--#libertà              
+ ... omitted several edges

In questo caso, vogliamo un grafo non orientato, quindi specifichiamo l'argomento directed = F, e semplifichiamo.

Script di esempio

text_network_igraph.R
# pacchetti
library(tidyverse)
library(quanteda)
library(igraph)
 
# dati
load("dati/es_twitter.rda")
 
# matrice delle co-occorrenze
rt.fcm <- rt2 %>% 
  mutate(text = str_replace_all(text, "[\'’]", "' ")) %>% 
  corpus() %>% 
  # tokenizzazione
  tokens(remove_punct = T) %>% 
  # matrice documenti termini
  dfm() %>% 
  # selezione dei soli hashtags
  dfm_select("#*") %>% 
  # soglia di occorrenze
  dfm_trim(min_termfreq = 4) %>%  
  # matrice delle cooccorrenze
  fcm()
 
# dalla matrice di adiacenza
g2 <- graph_from_adjacency_matrix(rt.fcm[1:10,1:10],
                                  # pesi
                                  weighted = T) %>% 
  # non orientato
  as.undirected() %>% 
  # semplificato
  simplify() 
 
V(g2)$frequency <- meta(rt.fcm, "margin", "object")[1:10]
 
set.seed(123)
par(mar = c(0,0,0,0))
plot(g2,
     edge.curved = 0.2,
     asp = 1,
     vertex.frame.color = "transparent",
     # dimensione dei nodi proporzionali alle frequenze
     vertex.size = V(g2)$frequency/max(V(g2)$frequency)*40,
     # spessore dei link proporzionali ai pesi
     edge.width = E(g2)$weight/max(E(g2)$weight)*10
     )
 
# dal dataframe dell'edgelist
# trasformiamo la matrice dell'esempio precedente
df <- tidytext::tidy(rt.fcm[1:10,1:10]) %>% 
  rename(term1 = document,
         term2 = term,
         weight = count)
 
graph_from_data_frame(df, 
                      # non orientato
                      directed = F) %>% 
  # semplificato
  simplify()
1)
Quanteda prevede la costruzione di matrici di co-occorrenze (features cooccurrence matrix, fcm()). Se si usa tm, la matrice documenti-termini andrà trasformata “a mano” in una matrice termini-termini.