Comment analyser le sentiment d’articles de blogues avec R ?
Ce billet te guide à travers un projet complet d’analyse de sentiments avec le package R sentometrics : préparation des données JSON, transformation en corpus, calcul des polarités avec le lexique FEEL, agrégation temporelle et visualisations interactives par site, pays et entités.
J’ai présenté ce projet au CAAMD en 2019, inspiré d’un atelier de Keven Bluteau à R à Québec 2019. L’idée : prendre des articles de blogues et de nouvelles qui mentionnent « Google », extraits de Webhose.io, et appliquer une analyse de sentiments de bout en bout avec R.
Le package sentometrics (mélange de sentiment et d’économétrie) permet de calculer, d’agréger et de prédire à partir de sentiments textuels. Il est construit sur
quanteda et data.table, avec glmnet et caret pour la modélisation.
graph TD
A[Fichiers JSON Webhose.io] --> B[générer_core_df: uuid, site, pays, texte]
A --> C[générer_entities_df: personnes, organisations, lieux]
B --> D[Base SQLite: table core]
C --> E[Base SQLite: table entities]
D --> F[Collecter + variables indicatrices<br>top sites, pays, types]
E --> F
F --> G[sento_corpus: textes + dates + contrôles]
G --> H[compute_sentiment: lexique FEEL]
H --> I[sento_measures: agrégation temporelle]
I --> J[Visualisation: ggplot2 + plotly<br>par site, pays, entité]
🌘 Préparation des données
Les données brutes sont des fichiers JSON structurés par Webhose.io. Chaque fichier contient un article avec son identifiant unique (uuid), sa date de publication, son texte, ses métadonnées (site, pays) et ses entités nommées (personnes, organisations, lieux).
On commence par charger les bibliothèques nécessaires :
library("jsonlite")
library("tidyverse")
library("RSQLite")
library("DBI")
library("lubridate")
Un fichier JSON type ressemble à ceci :
blog_exemple <- jsonlite::read_json("google_news_blogs/blogs/blogs_0000001.json")
blog_exemple$uuid # identifiant
blog_exemple$published # date
blog_exemple$text # contenu
On définit deux fonctions pour structurer les données en dataframe.
La première extrait les colonnes de base (coeur) :
generer_core_df <- function(json_contents) {
tibble(
uuid = json_contents$uuid %>% coalesce(""),
site = json_contents$thread$site %>% coalesce(""),
site_type = json_contents$thread$site_type %>% coalesce(""),
country = json_contents$thread$country %>% coalesce(""),
published = lubridate::as_datetime(json_contents$thread$published) %>%
coalesce(ISOdate(1900, 1, 1)),
title_full = json_contents$thread$title_full %>% coalesce(""),
text = json_contents$text %>% coalesce("")
)
}
La seconde extrait les entités nommées sous forme longue (uuid, type, nom) :
extract_names <- function(list_entities) {
name_entities <- list_entities %>% sapply(FUN = function(x) x$name)
if (length(name_entities) > 0) return(name_entities)
else return(NA)
}
generer_entities_df <- function(json_contents) {
this_df <- bind_rows(
tibble(uuid = json_contents$uuid,
entity_type = "persons",
entity = json_contents$entities$persons %>% extract_names),
tibble(uuid = json_contents$uuid,
entity_type = "organizations",
entity = json_contents$entities$organizations %>% extract_names),
tibble(uuid = json_contents$uuid,
entity_type = "locations",
entity = json_contents$entities$locations %>% extract_names)
)
na.omit(this_df)
}
On crée ensuite une base SQLite et on y insère les données de tous les fichiers JSON :
con <- dbConnect(drv = RSQLite::SQLite(), dbname = "google_news.sqlite")
dbCreateTable(con, "core", core_df)
dbCreateTable(con, "entities", entities_df)
traiter_json <- function(file_path) {
json_contents <- jsonlite::read_json(file_path)
core_df <- generer_core_df(json_contents)
entities_df <- generer_entities_df(json_contents)
dbAppendTable(con, "core", core_df)
dbAppendTable(con, "entities", entities_df)
}
Le traitement passe en boucle sur tous les fichiers blogs et news :
file_blogs <- list.files("google_news_blogs/blogs", pattern = "*.json", full.names = TRUE)
file_news <- list.files("google_news_blogs/news", pattern = "*.json", full.names = TRUE)
for (file_blog in file_blogs) {
traiter_json(file_blog)
}
for (file_article in file_news) {
traiter_json(file_article)
}
🌘 Transformation et formatage
Une fois la base remplie, on charge sentometrics et on se connecte pour transformer les données en corpus :
library("sentometrics")
con <- dbConnect(drv = RSQLite::SQLite(), dbname = "google_news.sqlite")
On récupère les 10 sites et pays les plus fréquents pour créer des variables indicatrices :
top_10_sites <- tbl(con, "core") %>%
select(site) %>% group_by(site) %>% count() %>%
arrange(desc(n)) %>% head(10) %>% collect()
top_10_country <- tbl(con, "core") %>%
select(country) %>%
mutate(country = ifelse(country == "", "XX", country)) %>%
group_by(country) %>% count() %>%
arrange(desc(n)) %>% head(10) %>% collect()
On crée un décompte d’entités par article, transformé en format large :
entities_count <- tbl(con, "entities") %>%
group_by(uuid, entity_type) %>% count() %>% collect()
entities_count_t <- entities_count %>%
reshape2::dcast(uuid ~ paste0("entity_", entity_type),
fun.aggregate = sum, value.var = "n")
On assemble le tout dans un objet sento_corpus via la fonction sento_corpus(). Chaque document contient le texte (titre + corps), la date, et les variables de contrôle (site, pays, type, entités) :
core_features_corpus <- tbl(con, "core") %>% collect() %>%
transmute(
id = uuid,
date = lubridate::as_datetime(published),
texts = paste(title_full, text, sep = "\n"),
site_01 = ifelse(site == top_10_sites$site[1], 1, 0),
site_02 = ifelse(site == top_10_sites$site[2], 1, 0),
# ... jusqu'à site_10
is_blog = ifelse(site_type == "blogs", 1, 0),
country_01 = ifelse(country == top_10_country$country[1], 1, 0),
# ... jusqu'à country_10
) %>%
left_join(entities_count_t, by = c("id" = "uuid")) %>%
sento_corpus()
saveRDS(core_features_corpus, "core_features_corpus.RDS")
🌘 Analyse des sentiments
On charge le corpus et on définit les lexiques. Le package sentometrics embarque plusieurs lexiques de sentiment ; on utilise FEEL_en_tr (lexique anglais) :
data("list_valence_shifters", package = "sentometrics")
data("list_lexicons", package = "sentometrics")
lexIn <- list_lexicons["FEEL_en_tr"]
valIn <- list_valence_shifters[["en"]]
l1 <- sento_lexicons(lexIn, valIn)
Le calcul des sentiments se fait au niveau du document avec compute_sentiment(). On peut tester sur un échantillon réduit :
corpusSample <- quanteda::corpus_sample(core_features_corpus, size = 200)
c_sentiments_sample <- compute_sentiment(
x = corpusSample,
lexicons = l1,
how = "counts",
nCore = 8
)
Pour l’analyse complète, on agrège les sentiments dans le temps avec sento_measures(). On configure l’agrégation avec ctr_agg() :
c_control_compute <- ctr_agg(
howWithin = "proportional",
howDocs = "equal_weight",
howTime = "equal_weight",
lag = 7,
by = "day"
)
c_sentiments <- sento_measures(
sento_corpus = core_features_corpus,
lexicons = l1,
ctr = c_control_compute
)
🌘 Visualisation des résultats
On convertit les mesures en data.table et on trace les sentiments par site, par pays et par type d’entité.
Sentiment par site :
c_measures <- as.data.table(c_sentiments)
c_measures_melt <- c_measures %>%
select(date, starts_with("FEEL_en_tr--site")) %>%
`colnames<-`(c("date", top_10_sites$site)) %>%
melt(id = "date", variable.name = "site")
plot_site <- ggplot(data = c_measures_melt,
aes(x = date, y = value, colour = site)) +
geom_line()
ggplotly(plot_site)
Sentiment par pays :
c_measures_melt <- c_measures %>%
select(date, starts_with("FEEL_en_tr--country")) %>%
`colnames<-`(c("date", top_10_country$country)) %>%
melt(id = "date", variable.name = "country")
plot_country <- ggplot(data = c_measures_melt,
aes(x = date, y = value, colour = country)) +
geom_line()
ggplotly(plot_country)
Sentiment par entité (personnes, organisations, lieux) :
c_measures_melt <- c_measures %>%
select(date, starts_with("FEEL_en_tr--entity")) %>%
melt(id = "date", variable.name = "entity")
plot_entity <- ggplot(data = c_measures_melt,
aes(x = date, y = value, colour = entity)) +
geom_line()
ggplotly(plot_entity)
Les graphiques interactifs (via plotly) permettent d’explorer l’évolution du sentiment dans le temps et de comparer les tendances entre sites, pays ou catégories d’entités.
🌘 Pour aller plus loin
Le package sentometrics permet aussi de modéliser ces séries de sentiments avec une régression Elastic Net (via sento_model()). Tu peux configurer les hyperparamètres avec ctr_model() et entraîner un modèle prédictif à partir des mesures agrégées.
Les fichiers source de ce projet sont disponibles dans le dépôt :
🌘 Version Quarto
Ce document a été converti au format Quarto (code non exécuté — nécessite RSQLite et sentometrics) :