Dans cet annexe, nous détaillons la construction de la base de données et nous présentons les codes utilisés pour les analyses.
1 Données
Notre jeux de données se compose de plusieurs tables:
une table des métadonnées.
une table des auteurs associés à ces documents.
une table des éditeurs.
une table avec les textes entiers.
Malheureusement, nous ne pouvons pas partager les textes entiers pour des raisons de droits d’auteurs. Le reste des données sont consultable et téléchargeable dans les tableaux interactifs ci-dessous.
Nous avons utilisés trois sources principales pour la construction de notre base de données des documents et des auteurs. La première source est un document interne à la revue économique qui répertorie les documents publiés dans la Revue Economique entre 1950 et 2019. Ce document comprend plusieurs metadonnées des documents que nous avons exploité: les titres, les auteurs, leur genre, l’année de publication, les numéros spéciaux, le type de document (note de lecture, article, etc.).
Nous avons enrichie cette base de données avec deux autres sources, les deux bibliothèques numériques Persée, qui archive la revue économique entre 1950 et 2000, et Cairn, qui couvre la période de 2001 à aujourd’hui. Nous avons notamment utilisé les API de ces deux bibliothèques pour récupérer les résumés des articles (https://oai.cairn.info/ et https://www.persee.fr/entrepot-oai). Nous avons également eu accès, après demande, aux textes entiers. Ces derniers ne sont malheureusement pas open data. Cairn nous a également donné accès aux citations entrantes et sortantes pour chaque documents.
L’usage de différentes sources permet d’obtenir une base de donnée enrichie mais augmente aussi le risque de doublon, soit par ce que le doublon existe déjà dans les différentes sources utilisées, soit par ce que nous avons fait des erreurs dans notre travail de fusions des différentes sources. L’algorithme ci-dessous vise à identifier des doublons par le biais de la stratégie suivante:
Les documents sont groupés par le premier auteur du document ;
Pour chaque groupe, nous calculons la distance Optimal String Alignment (OSA) des titres. La mesure OSA calcule le nombre d’opération (insertions, deletions, substitutions, and adjacent character transpositions) nécessaires pour rendre parfaitement identiques deux chaines de caractères. Cette méthode est implémenté sur R via le package stringdist(Van der Loo et al. 2014).
Show the code
#' Identify potential duplicates in corpus of documents with at least a title and an author#'#' This function detects potential duplicates in thesis metadata by comparing titles within groups#' of the same author. It calculates string distances between pairs of titles using the Optimal#' String Alignment (OSA) algorithm and filters results based on predefined thresholds.#'#' @param data_dt A `data.table` containing the thesis metadata, with at least three columns:#' - `authors`: Normalized author names used for grouping.#' - `title`: Normalized thesis titles.#' - `id`: Unique identifiers for each thesis.#' @param threshold_distance Numeric. The maximum absolute string distance between two titles for them#' to be considered duplicates.#' @param threshold_normalization Numeric. The maximum normalized string distance (distance divided by#' the product of the title lengths) for two titles to be considered duplicates.#'#' @return A `data.table` containing the following columns:#' - `id`: The identifier for the primary thesis in the duplicate group.#' - `id`: The identifier for the duplicate thesis.#' - `authors`: The author associated with the duplicates.#' - `text1`: The first title in the comparison.#' - `text2`: The second title in the comparison.#' - `distance`: The absolute string distance between the titles.#' - `normalized_distance`: The normalized string distance between the titles.#' If no duplicates are found, the function returns `NULL`.#'#' @details#' The function first groups titles by `authors`, then compares all pairs of titles within each group.#' String distances are calculated using the OSA algorithm, which accounts for single-character#' substitutions, deletions, and transpositions. The results are filtered based on the provided#' thresholds to minimize false positives.#'#' @examples#' # Sample data#' data_dt <- data.table(#' authors = c("smith john", "smith john", "doe jane"),#' title = c("My document Title", "My document titlé", "Another document"),#' id = c("ID1", "ID2", "ID3")#' )#'#' # Detect duplicates with specific thresholds#' find_duplicates(data_dt, threshold_distance = 2, threshold_normalization = 0.05)#'#' @exportfind_duplicates <-function(data_dt, threshold_distance, threshold_normalization, workers) {# as data.table data_dt <-as.data.table(data_dt)# Group data by authors to avoid unnecessary comparisons data_dt <- data_dt[, .(titles =list(title), ids =list(id)), by = authors] data_dt <- data_dt[lengths(titles) >1] # Keep only groups with more than one title for safety (should not be necessary if data is clean)# Define a helper function for processing a single group process_group <-function(titles, ids, author) {# Compare all title pairs within the group comparison <-CJ(titles, titles, sorted =FALSE, unique =TRUE)setnames(comparison, c("text1", "text2"))# comparison <- comparison[text1 <= text2] # Avoid redundant comparisons# Calculate string distance and normalized distance comparison[, distance := stringdist::stringdist(text1, text2, method ="osa")] comparison[, normalized_distance := distance / (str_count(text1) *str_count(text2))]if (nrow(comparison) >0) { comparison[, authors := author] title_match <- comparison %>%as.data.table() %>%merge(data.table(title1 = titles, id_1 = ids), by.x ="text1", by.y ="title1", allow.cartesian =TRUE) %>%merge(data.table(title2 = titles, id_2 = ids), by.x ="text2", by.y ="title2", allow.cartesian =TRUE) %>% .[id_1 != id_2, .(id_1, id_2, authors, text1, text2, distance, normalized_distance)]return(title_match) }return(NULL) }# Set up parallel processingplan(multisession, workers = workers)# Use future_map to parallelize the processing of each group results <-future_map(1:nrow(data_dt),~process_group(titles = data_dt$titles[[.x]],ids = data_dt$ids[[.x]],author = data_dt$authors[.x] ),.progress =TRUE )if(length(results) >0) { results <- results %>%rbindlist()setkey(results, key = id_1) duplicates <- results[normalized_distance < threshold_normalization & distance < threshold_distance, ]setnames(duplicates, "id_1", "id") duplicates <-unique(duplicates)return(duplicates) } else {return(NULL) }}# first delete forthcoming article that are duplicates # remove duplicates and save documents <- documents %>%filter(!issue =="Forthcoming") # select author information used in find_duplicate()authors_info_to_join <- authors %>%select(id_document, authors)# joindocuments_with_authors <- documents %>%left_join(authors_info_to_join, by =c("id"="id_document"))# create metadata for stmdata_to_check <- documents_with_authors %>%filter(type %in%c("varia", "numéro spécial", "")) %>%# nest authorgroup_by(id) %>%mutate(authors_list =list(authors)) %>%mutate(authors =first(authors)) %>%# remove non unique line unique %>%# harmonize authorsmutate(authors =str_remove_all(authors, "[[:punct:]]"),authors =str_to_lower(authors),authors =str_squish(authors)) %>%#remove special cases, regular chroniquesfilter(!title %in%c("Chronique de la pensée économique en Italie","Commentaires","La situation économique","Avant-propos","Introduction","introduction"))duplicates <-find_duplicates(data_dt = data_to_check,threshold_distance =6,threshold_normalization =0.1,workers =4)duplicates <- duplicates %>%group_by(id) %>%mutate(duplicates =list(c(id, id_2)),duplicates =map(duplicates, ~ .x %>%sort()))# Add duplicates to the main metadata tabledocuments <- documents %>%left_join(duplicates %>%select(id, duplicates)) %>%mutate(duplicates =ifelse(duplicates =="NULL", NA_character_, duplicates))duplicates_to_keep <- documents %>%filter(!is.na(duplicates)) %>%# sort unique %>%group_by(duplicates) %>%arrange(!is.na(abstract_fr),# Prioritize rows where abstract_fr is not NAas.numeric(issue),# Prioritize numeric issues (NA if not numeric) issue !="Forthcoming",# Ensure "Forthcoming" is deprioritized.by_group =TRUE) %>%slice(1) %>%# Keep only the first row within each groupungroup() %>%unique()documents <- documents %>%filter(is.na(duplicates)) %>%bind_rows(duplicates_to_keep)# maj authors data removing lines with duplicatesid_to_keep <- documents$idauthors <- authors %>%filter(id_document %in% id_to_keep)saveRDS(documents, here(clean_corpus_path, "documents_no_duplicates.rds"))saveRDS(authors, here(clean_corpus_path, "authors_no_duplicates.rds"))
1.2 Metadonnées
Dans la table des métadonnées, chaque ligne est un document qui possède un identifiant unique, id. Cet identifiant est soit l’identifiant persée ou l’identifiant cairn. Certaines lignes partagent le même id car quelques documents dans la base de donnée originale de la Revue Economique correspondent à une même notice bibliographique dans Persée ou Cairn. Une url est disponible pour chaque document pour consulter la notice Persée ou Cairn en ligne.
Table 1: Données sur les documents de la revue économique
1.3 Auteurs
Dans la table des auteurs, chaque ligne correspond à un auteur d’un document et nous leur avons attribué un identifiant unique id_authors. Chaque auteur est associé à ses documents par un identifiant que nous avons créé id_document. Les auteurs sont identifiés par leur nom, prénom, genre. Pour les documents archivés par persée, nous avons également enrichi la base de donnée avec les informations issues d’idref, une base de données qui recense les informations sur les auteurs de l’enseignement et de la recherche en France.
Figure 4: Pourcentage d’articles en co-écriture par année
Show the code
p <- authors %>%left_join(documents %>%select(id, type), by =c("id_document"="id")) %>%mutate(type =ifelse(str_detect(type, "varia|numéro spécial"), "article", "others")) %>%filter(type =="article") %>%count(authors) %>%count(n) %>%mutate(tooltip =paste("Nombre d'articles publiés:", n, "<br>Nombre d'auteurs:", nn)) %>%ggplot(aes(x = n, y = nn, text = tooltip)) +# light red geom_col(fill = colors[[1]]) +# zoom on the first range of ytheme_light() +labs(x ="Nombre d'articles publiés",y ="Nombre d'auteurs",title ="Uniquement les articles (books reviews et autres exclus)")ggplotly(p, tooltip ="text")
Pour analyser les citations extra-disciplinaires des documents de la revue économique, nous avons utilisé les données de citation de cairn—malheureusement ces données ne sont pas disponibles pour les documents archivés par persée et cairn ne nous autorise pas à les diffuser.
Nous avons classifié à la main les journaux des documents qui sont cités au moins deux fois dans la revue économique, soit 1639 journaux (voir Table 4). Nous avons ensuite classifier ces journaux par disciplines en utilisant le nom du journal, et en consultant le board éditorial de chaque journal en cas d’ambiguïté (i.e. the journal of economics and sociology). Les journaux de finance sont des cas particuliers puisqu’il pourrait légitimement être classifié en économie ou en management. Nous avons donc décidé de créer une catégorie à part.
Nous calculons ensuite la fréquence de chaque discipline au sein des documents, normalisée par le nombre de références. Plus un article cite de références moins ses références comptent.
Show the code
# Normalize references# get fields and journal frequency field <-read_xlsx(here(clean_corpus_path, "ref_journals.xlsx")) #get references cairn_ref <-read_xlsx(here(cairn_data_path,"RECO_31-01-2024_références_sortantes.xlsx"))# join field and references ref_journals <- cairn_ref %>%# renaming for simplicity rename(journal =`Titre revue citée`,year =`Année source`,id =`ID_ARTICLE source`) %>%# keep only id in the official document database filter(id %in% documents$id) %>%# count number of references (use in the chunk later)add_count(id, name ="n_ref") %>%mutate(journal =str_to_lower(journal) %>%str_remove(., "^\\s?the") %>%str_trim(., "both")) %>%# add field and journal frequency left_join(field, by ="journal") %>%select(id, journal, field, year, n, n_ref) %>%filter(!is.na(journal),!is.na(field)) # estimate the normalized frequency of journals ref_journals_normalize_weak <- ref_journals %>%group_by(field, id) %>%reframe(n =n(),n_normalize = n / n_ref,n_ref = n_ref,year = year) %>%unique()
Nous reprenons ensuite la méthodologie de Truc et al. (2023) pour identifier le ratio de citations extra-disciplinaires, c’est à dire le pourcentage de citations en dehors de l’économie par rapport au nombre total de citations. La Figure 8 montre l’évolution de ce ratio pour les disciplines les plus citées (les autres disciplines sont regroupées dans la variable “other”). La Figure 9 montre l’évolution du ratio de citations extra-disciplinaires sur le total des citations.
# plot extra-disciplinary citation for the 12th most important fields # find the 12th field_to_keep <- ref_journals_normalize_weak %>%group_by(field) %>%reframe(sum_n =sum(n_normalize)) %>%arrange(desc(sum_n)) %>%slice_max(sum_n, n =12) %>%distinct(field)data_summary_weak <- ref_journals_normalize_weak %>%# keep the the 12th most represented fields mutate(field =ifelse(field %in% field_to_keep$field, field, "Other")) %>%# estimate the normalized frequency each year group_by(year) %>%mutate(sum_by_year =sum(n_normalize)) %>%group_by(field, year) %>%reframe(sum_by_year_field =sum(n_normalize),sum_by_year = sum_by_year) %>% unique %>%group_by(field, year) %>%mutate(ratio = sum_by_year_field/sum_by_year*100)field_levels <-unique(data_summary_weak$field[data_summary_weak$field !="Other"])# assign color to each field level color_values <-c("Other"="lightgray", "Total"="black",setNames(RColorBrewer::brewer.pal(length(field_levels), "Paired"), field_levels))gg <- data_summary_weak %>%# remove economics filter(field !="Economics") %>%ggplot(aes(x = year, y = ratio, color = field, linetype = field, group = field)) +geom_smooth(se =FALSE, method ='loess', linewidth =1.5, span =0.75) +labs(x ="",y ="% du total des références") +scale_color_manual(values = color_values) +# Apply custom color scaleguides(color =guide_legend(title ="", nrow =4), linetype =guide_legend(title ="", nrow =5)) +theme_light(base_size =16) +theme(legend.position ="bottom") ggsave("references_disciplines.png",device ="png",plot = gg,path = figures_path,width =10,height =8,dpi =300)ggplotly(gg, tooltip =c("x","y","color")) %>%config(displayModeBar =FALSE) %>%layout(xaxis =list(fixedrange =TRUE), yaxis =list(fixedrange =TRUE))
Figure 8: Références en dehors de l’économie par discipline
Show the code
# total extra-disciplinary citations data_summary_weak2 <- data_summary_weak %>%mutate(field =ifelse(str_detect(field, "Economics"), "Intra", "Extra")) %>%group_by(field, year) %>%reframe(ratio =sum(ratio)) %>% unique p <- data_summary_weak2 %>%filter(field =="Extra") %>%mutate(field ="Total") %>%ggplot(aes(x = year, y = ratio, color = field)) +geom_point() +geom_smooth(se=F, method ='loess', span =0.50,linewidth=1.5,alpha =0.2) +scale_color_manual(values = color_values) +labs(color ="",x ="",y ="% du total des références") +theme_light(base_size =15) +theme(legend.position ="none")ggsave("references_extradisciplinaire.png",device ="png",plot = p,path = figures_path,width =10,height =5,dpi =300)# save both plots total <- gg + p + patchwork::plot_layout(guides ="collect", widths =c(3, 2)) &labs(field ="") &theme(legend.position ="bottom") ggsave("references_total_disciplines.png",device ="png",plot = total,path = figures_path,width =11,height =6,dpi =300)# print the last plot ggplotly(p, tooltip =c("x","y"))
Figure 9: Références en dehors de l’économie
Show the code
# Output a table for users field_weak <- ref_journals %>%select(journal, n, field) %>%distinct() %>%# sort by frequency arrange(desc(n)) DT::datatable( field_weak,extensions ='Buttons',options =list(dom ='Blfrtip',buttons =c('excel', 'csv'),pageLength =10 ))
Table 4: Données sur les journaux des documents citant la revue économique
Afin de confirmer ces résultats, nous avons reclassé les journaux en utilisant une classification plus stricte de l’économie. Nous avons considéré que n’importe quel journal utilisant le mot économie dans son titre est un journal d’économie. Nous avons ensuite recalculé le ratio de citations extra-disciplinaires. La tendance haussière reste la même même si (logiquement) le ratio de citations extra-disciplinaires est plus faible.
Figure 10: Références en dehors de l’économie (total, définition stricte de l’économie)
Show the code
# output users with field2 field_strict <- ref_journals %>%# add a stronger definition of economics mutate(field2 =ifelse(str_detect(journal, "[ée]conom"), "Economics", field)) %>%select(journal, n, field2) %>%distinct() %>%arrange(desc(n)) DT::datatable( field_strict,extensions ='Buttons',options =list(dom ='Blfrtip',buttons =c('excel', 'csv'),pageLength =10 ))
Table 5: Données sur les journaux des documents citant la revue économique (définition stricte de l’économie)
3 Modélisation thématique
Le structural topic model est implémenté dans R dans le package stm(Roberts et al. 2013). L’ensemble des informations relatives à cette implémentation est disponible sur le site web dédié. Pour une exploration avancée, l’ensemble du code R est disponible sur le github. Une série d’articles des auteurs présentent le modèle. Roberts, Stewart, and Airoldi (2016) est la présentation la plus complète pour une exploration avancée de l’inférence bayésienne utilisée.
3.1 Pré-traitement
Dans cette section, nous détaillons les étapes suivies pour le prétraitement des données textuelles en vue de leur utilisation dans un topic model. En plus d’un pré-nettoyage et le formatage habituel des données textuelles, nous créons les deux variables indicatrices is_varia et has_female pour la future régression.
Show the code
#' SCRIPT FOR CHOOSING K # Load packages and data source(here::here("scripts","paths_and_packages.R"))source(here::here("scripts", "producing_results", "_functions_for_tm.R"))documents <-readRDS(here(clean_corpus_path, "documents_no_duplicates.rds")) authors <-readRDS(here(clean_corpus_path, "authors_no_duplicates.rds"))full_text <-readRDS(here(clean_corpus_path, "full_text.rds")) %>%mutate(id =str_remove_all(id, "_[:digit:]{4}.txt$")) ### JOINING TABLES #### select relevant authors information authors_info_to_join <- authors %>%select(id_document, authors, gender)# join tablesdocuments_with_authors <- documents %>%left_join(authors_info_to_join, by =c("id"="id_document")) %>%# create dummy variables has female and isvaria group_by(id) %>%mutate(has_female =as.integer(any(gender =="F")),is_varia =ifelse(type =="varia", 1, 0)) %>%# nest author group_by(id) %>%# keep only first author or first title for some id duplicates slice(1) %>%# remove duplicates from joining select(id, authors, title, abstract_fr, year, has_female, is_varia, -gender, type) %>% unique# join fulltext full_text_filtered <- documents_with_authors %>%filter(type %in%c("varia", "numéro spécial")) %>%left_join(full_text, by ="id") %>%# filter na covariatesfilter(!is.na(has_female), !is.na(is_varia))#### PRE-CLEANING #### # using data.table for efficiency # Convert to data.table if not alreadysetDT(full_text_filtered)# Select relevant columnsdf_text <- full_text_filtered[, .(id, authors, title, abstract_fr, text, year, has_female, is_varia)]# Clean abstractsdf_text[, abstract_fr :=str_squish(abstract_fr)]df_text[, abstract_fr :=str_trim(str_remove_all(abstract_fr, "^[Rr]ésumé"))]df_text[, abstract_fr :=str_remove_all(abstract_fr, "Classification JEL.*$")]df_text[, abstract_fr :=str_remove_all(abstract_fr, "JEL [Cc]ode(s)?.*$")]df_text[, abstract_fr :=str_remove_all(abstract_fr, "JEL [Cc]lassification.*$")]df_text[, abstract_fr :=str_remove_all(abstract_fr, "(JEL : D11, L13, Q42.)|(Classification jel : A14, B10, F13)")]df_text[, abstract_fr :=fifelse(is.na(abstract_fr), "", abstract_fr)]# Clean textsdf_text[, text :=str_squish(text)]# remove revue economique in body text df_text[, text :=str_remove_all(text, "Revue économique")]df_text[, text :=str_remove_all(text, "Revue Economique")]df_text[, text :=str_remove_all(text, "REVUE ECONOMIQUE")]df_text[, text :=str_remove_all(text, "REVUE CONOMIQUE")]# remove vol + number df_text[, text :=str_remove_all(text, "vol. [0-9]+")]# remove references bibliographique df_text[, text :=str_remove_all(text, regex("Références bibliographiques.*", ignore_case =TRUE))]df_text[, text :=str_remove_all(text, regex("REFERENCES BIBLIOGRAPHIQUES.*", ignore_case =TRUE))]df_text[, text :=str_remove_all(text, regex("Notes bibliographiques.*", ignore_case =TRUE))]df_text[, text :=str_remove_all(text, regex("Bibliographie .*", ignore_case =TRUE))]df_text[, text :=str_remove_all(text, regex("Bibliography .*", ignore_case =TRUE))]df_text[, text :=str_remove_all(text, regex("APPENDIX .*", ignore_case =TRUE))]# handle french special character df_text[, text :=str_replace_all(text, "fa on", "façon")]df_text[, text :=str_replace_all(text, regex("fran ais", ignore_case =TRUE), "Français")]df_text[, text :=str_replace_all(text, regex("fran e", ignore_case =TRUE), "France")]df_text[, text :=str_replace_all(text, " uvre ", "oeuvre")]df_text[, text :=str_replace_all(text, "e ment ", "ement")]df_text[, text :=str_replace_all(text, " tion ", "tion ")]# Construct final text columndf_text[, text :=tolower(paste(title, ".", abstract_fr, text)), by = id]saveRDS(df_text, here(intermediate_data_path, "df_full_text.rds"), compress =TRUE)#### TOKENIZATION ##### df_text <- readRDS(here(intermediate_data_path, "df_full_text.rds"))# Tokenize sentences while keeping the document IDdf_tokens <- df_text %>%select(id, text) %>%mutate(tokens = tokenizers::tokenize_words(text, lowercase =TRUE,strip_punct =TRUE, # delete punctuation strip_numeric =TRUE, # delete numberssimplify =FALSE)) %>% ungroup %>%unnest(tokens) %>%# Expand token lists into rowsgroup_by(id) %>%mutate(token_id =row_number()) %>%rename(token = tokens) %>%# Rename column for clarityselect(id, token, token_id)#### STOPWORDS ##### prepare stop_wordsstop_words <-bind_rows(get_stopwords(language ="fr", source ="stopwords-iso"),get_stopwords(language ="fr", source ="snowball"),get_stopwords(language ="en", source ="stopwords-iso"),get_stopwords(language ="en", source ="snowball")) %>%distinct(word) %>%pull(word)custom_stop_words <-c("faire","faut","résumé","article","analyse","analyser","analysons","analysent","approche","étude","étudie","étudions","étudient","montrons","montrer","montre","montrent","permettre","permet","permettent","proposer","propose","proposons","proposent","utiliser","mettre","présente","présentons","présenter","role","rôle")stop_words <-c(stop_words, custom_stop_words) # use data.table for efficiency # Convert to data.table if not alreadysetDT(df_tokens)# Remove article contractions, punctuation from tokensdf_tokens[, token :=str_remove_all(token, "^(.*qu|[mjldscn])[\u0027\u2019\u2032\u0060]")]df_tokens[, token :=str_remove_all(token, "[[:punct:]]")]# once, token are cleaned, we can remove stopwords, non-latin characters, digits and one letter charactersdf_tokens <- df_tokens[!token %in% stop_words]df_tokens <- df_tokens[!str_detect(token, "[^\\p{Latin}]")]# df_tokens <- df_tokens[!str_detect(token, "[\u0370-\u03FF]")]df_tokens <- df_tokens[!str_detect(token, "^.*\\d+.*$")]df_tokens <- df_tokens[str_detect(token, "[[:letter:]]")]#### BIGRAMS ##### Create bigramsdf_tokens <- df_tokens[order(id, token_id)] # Ensure correct orderdf_tokens[, bigram :=ifelse(token_id <shift(token_id, type ="lead"), paste(token, shift(token, type ="lead"), sep ="_"), NA), by = .(id)]# filter na and count bigrams, keep only bigrams that appear more than 10 timesbigram_counts <- df_tokens[!is.na(bigram)]bigram_counts <- bigram_counts[, .N, by = .(id, token, bigram)] bigram_counts <- bigram_counts[N >20] # Split bigram into word_1 and word_2bigram_counts[, c("word_1", "word_2") :=tstrsplit(bigram, "_", fixed =TRUE)]# Remove bigrams where either word is a stopwordbigram_counts <- bigram_counts[!(word_1 %in% stop_words | word_2 %in% stop_words)]# Assign a unique window ID to each bigram (acts like `window_id`)bigram_counts[, window_id := .I] # .I is the row number (unique ID), the context window is thus only the bigram itself# Convert to long format (similar to pivot_longer)bigram_long <-melt(bigram_counts, id.vars ="window_id", measure.vars =c("word_1", "word_2"), variable.name ="rank", value.name ="word") %>%as.data.table()# Calculate PMI values#Count occurrences of each wordword_prob <- df_tokens[, .N, by = token]total_tokens <-sum(word_prob$N)word_prob[, prob := N / total_tokens]#Count occurrences of each bigrambigram_prob <- df_tokens[!is.na(bigram), .N, by = bigram]total_bigrams <-sum(bigram_prob$N)bigram_prob[, prob := N / total_bigrams]# Merge word_1 probabilities into bigram table and renamebigram_counts <-merge(bigram_counts, word_prob[, .(token, prob)], by.x ="word_1", by.y ="token", all.x =TRUE)setnames(bigram_counts, "prob", "prob_word_1")# Merge word_2 probabilities into bigram table and renamebigram_counts <-merge(bigram_counts, word_prob[, .(token, prob)], by.x ="word_2", by.y ="token", all.x =TRUE)setnames(bigram_counts, "prob", "prob_word_2")# merge bigram probabilities into bigram table and renamebigram_counts <-merge(bigram_counts, bigram_prob, by ="bigram")setnames(bigram_counts, "prob", "prob_bigram")# compute pmi bigram_counts[, pmi :=log2(prob_bigram / (prob_word_1 * prob_word_2))]# keep only bigrams with pmi > 0bigram_to_keep <- bigram_counts[pmi >0] bigram_to_keep <- bigram_to_keep[, keep_bigram :=TRUE]# Add bigrams to the token listdf_tokens_final <- df_tokens %>%left_join(bigram_to_keep) %>%mutate(token =if_else(keep_bigram, bigram, token, missing = token),token =if_else(lag(keep_bigram), lag(bigram), token, missing = token),token_id =if_else(lag(keep_bigram), token_id -1, token_id, missing = token_id)) %>%distinct(id, token_id, token)# save in term list formatterm_list <- df_tokens_final %>%rename(term = token)saveRDS(term_list, here(intermediate_data_path, "term_list_FULL_TEXT.rds"))#### PREPROCESSING ####term_list <-readRDS(here(intermediate_data_path, "term_list_FULL_TEXT.rds"))# create stm objects diff pre-processingcorpora_in_dfm <-list()corpora_in_stm <-list()treshsholds <-c(1, 5, 10, 15, 20, 30)# manage duplicate id from perséefor (i in1:length(treshsholds)) { term_to_remove <- term_list %>%distinct(id, term) %>%count(term, name ="frequency") %>%filter(frequency <= i) %>%distinct(term) #remove words terms_list_filtered <- term_list %>%filter(!term %in% term_to_remove$term)#transform list of terms into stm object corpus_in_dfm <- terms_list_filtered %>%add_count(term, id) %>%cast_dfm(id, term, n) treshold <- treshsholds[[i]] %>%as.character()# dfm object corpora_in_dfm[[treshold]] <- corpus_in_dfm# stm object with covariate metadata <- terms_list_filtered %>%select(id) %>%left_join(df_text, by ="id") %>%mutate(year =as.integer(year),has_female =as.factor(has_female),is_varia =as.factor(is_varia),has_female =relevel(has_female, ref ="0"),is_varia =relevel(is_varia, ref ="0")) %>%select(id, text, authors, title, abstract_fr, year, has_female, is_varia) %>% unique corpus_in_stm <- quanteda::convert(corpus_in_dfm, to ="stm", docvars = metadata) corpora_in_stm[[treshold]] <- corpus_in_stm}saveRDS(corpora_in_stm, here(intermediate_data_path, "corpora_in_stm_FULL_TEXT.rds"))
Entre 1969 et 2023, 3677 documents possèdent un résumé en français. La Figure 11 montre la distribution des résumés par année sur la période.
Show the code
gg <- df_text %>%count(year) %>%mutate(tooltip =paste("Année:", year, "<br>Count:", n)) %>%ggplot(aes(x =as.integer(year), y = n, text = tooltip)) +geom_col(binwidth =1,fill = colors[[1]]) +labs(x ="", y ="Nombre de documents par année") +theme_light(base_size =15) ggsave("distribution_texts_tm.png",device ="png",plot = gg,path = figures_path,width =8,height =4.5,dpi =300)plotly::ggplotly(gg, tooltip ="text") %>%config(displayModeBar =FALSE)
Figure 11: Distribution des textes par année
La tokenization a été réalisés grâce à la bibliothèque tokenizers. La liste des mots qui en découle est ensuite nettoyée pour supprimer les mots peu informatifs, typiquement certains caractères spéciaux, les chiffres et les mots d’une lettre, ainsi qu’une liste de stopwords. Les tokens sont généralement des unigrams mais nous avons également inclu des bigrams. Nous avons conservé les bigram leur score de Point Mutual Information (PMI):
Le PMI estime les chances d’observer deux mots ensemble par rapport à la probabilité d’observer ces mots indépendamment. Un PMI positif indique que les mots sont plus souvent observés ensemble que séparément. Nous avons conservé les bigrams qui apparaissent plus de 10 fois dans le corpus et présentant un PMI supérieur à 0.
Une pratique standard en modélisation thématique consiste à réduire la liste du vocabulaire en filtrant les mots peu utilisés. Par exemple, il est théoriquement peu utile de conserver les mots utilisés uniquement dans un seul document puisque par construction, une thématique est un ensemble de mots qui tendent à co-occurer ensemble. Filtrer les mots utilisés uniquement par un document ne fait pas perdre pas beaucoup d’information mais augmente le temps computationnel en réduisant la taille du vocabulaire. Filtrer les mots peut également augmenter l’interprétabilité des thématiques. Intuitivement, un mot utilisé dans seulement quelques documents est peu informatif puisque les mots apparaissant rarement ne participent pas significativement à la structuration des thématiques et peuvent introduire du bruit dans l’analyse. Pour tester l’effet de différents filtrages, nous construisons différentes représentations du corpus en filtrant les mots qui apparaissent dans moins de \(N\) documents, avec \(N \in [1, 5, 10, 15, 20, 30]\).
3.2 Choix de K et du prétraitement
A partir de six représentations du corpus, nous cherchons à estimer le nombre de thématique de notre modèle \(K\). Pour déterminer \(K\), nous entraînons une série de modèles avec \(K \in {10, 20, ..., 70}\). Nous estimons un modèle pour chaque valeur de \(K\) (nombre de thématiques) et chaque valeur de \(N\) représentation du corpus (filtrage des mots), soit 42 modèles.
Il n’existe pas de choix non ambigu de \(K\). Ce dernier dépends essentiellement de la question de recherche et d’une évaluation qualitative de différents modèles. Plusieurs métriques quantitatives peuvent cependant aider à restreindre le choix parmi les valeurs de \(K\) possibles. Nous avons utilisés deux métriques: la FREX et la cohérence sémantique pour chacune de ces combinaisons.
La cohérence sémantique mesure la similarité entre les mots d’un thème (Mimno et al. 2011). Similaire à la PMI dans l’esprit, la cohérence sémantique mesure la probabilité de voir deux mots ensemble dans un thème. A partir d’une liste de \(M\) mots les plus probables par thématique, la cohérence d’un topic \(k \in K\) est calculée comme suit:
\[C_k = \sum_{i = 2}^{M} \sum_{j=1}^{i-1} \log\left( \frac{D(w_{i,k}, w_{j,k}) + 1}{D(w_{j,k})} \right)\] Où \(D(w_{i}, w_{j})\) est le nombre de documents où les mots \(w_{i,k}\) et \(w_{j,k}\) apparaissent ensemble au moins une fois et \(D(w_{j})\) est le nombre de documents où le mot \(w_{j,k}\) apparaît au moins une fois. \(M\) est fixé à 10 par défaut et nous avons utilisé cette valeur dans nos calculs. Plus la cohérence est élevée, plus les mots d’une thématique ont tendance à apparaître ensemble. Quand \(K\) est grand, la cohérence des thématiques tend à diminuer.
La FREX (ou FREquent EXclusivity) est une mesure qui partage l’esprit de la célèbre mesure tf-idf et vise à évaluer l’importance d’un mot \(w\) dans un thème \(k\), en tenant compte à la fois de sa fréquence et de son exclusivité (Bischof and Airoldi 2012). Elle est définie par la formule suivante :
Avec \(F\) est le rang normalisé du mot \(w\) en terme de fréquence dans le thème \(k\) et \(E\) est le rang normalisé du mot \(w\) en terme d’exclusivité du mot \(w\) dans le thème \(k\).1. L’exclusivité est la probabilité que le mot \(w\) appartienne à la thématique \(j\), probabilité donné par la distribution \(\beta_{j}\), sur la somme des probabilités \(\beta_{w, 1:K}\), soit \(\frac{\beta_{w,j}}{\sum_1^K\beta_{w,k}}\). La valeur \(\beta_{w,j}\) estime quel est la probabilité que le mot \(w\) appartienne à \(j\). Normalisé, cela mesure à quel point \(w\) est exclusif à \(j\) par rapport aux autres thématiques.
La moyenne harmonique accorde moins d’importance aux mots qui ont un score élévé pour seulement une seule dimension. L’idée est de pénaliser à la fois les mots très fréquents mais peu exclusif et les mots rares peu fréquent mais très exclusif. \(a\) est une variable pondérant l’importance accordée aux deux mesures respectives - \(a\) est fixé à \(0.3\) par défaut et nous avons utilisé cette valeur dans nos calculs.2 La FREX d’une thématique est calculé pour les 10 mots les plus probables de chaque thématiques. Plus la FREX est elevé, plus la thématique est exclusive et considérer de qualité. Quand \(K\) est grand, la FREX d’une thématique tend à augmenter.
Les Figure 12 estime la FREX et l’exlusivité pour différents \(K\) et pour différents prétraitements. Nous avons utilisé la bibliothèque stm pour estimer ces métriques, respectivement les fonctions stm::semanticCoherence, stm::exclusivity. Ces résultats indiquent que, quelque soit le prétraitement, un nombre de thèmes de 50 semble être un bon compromis entre la cohérence sémantique et la FREX.
Nous avons inclus trois covariates: l’année de publication des articles, une variable de genre, has_female et une variable sur le type de publication is_varia. has_female est une variable muette qui indique si un article comprend une auteure is_varia est une variable muette qui indique si un article est un varia (ou sinon un numéro spécial) et nous permettra d’analyser la politique éditoriale de la revue.
gamma <-readRDS(here(intermediate_data_path, "gamma.rds"))beta <-readRDS(here(intermediate_data_path, "beta.rds"))gamma_mean <- gamma %>%group_by(topic, topic_label_prob, topic_label_frex) %>%summarise(gamma =mean(gamma)) %>% ungroup %>%mutate(topic =reorder(topic, gamma)) gg <- gamma_mean %>%ggplot() +geom_segment(aes(x =0, xend = gamma, y = topic, yend = topic),color ="black",size =0.5 ) +geom_text(aes(x = gamma, y = topic, label =paste0("Thématique ", topic, ": ", topic_label_prob) ),size =6,hjust =-.01,nudge_y =0.0005 ) +scale_x_continuous(expand =c(0, 0),limits =c(0, max(gamma_mean$gamma) +0.05) ) +theme_light() +theme(text =element_text(size =20),axis.text.y =element_blank(), # Removes y-axis textaxis.ticks.y =element_blank() # Removes y-axis ticks ) +labs(x ="Prévalences moyennes des thématiques",y =NULL,caption ="\n\n Note: chaque thématique est associée à ses mots les plus probables selon la distribution beta" )ggsave( glue::glue("stm_summary.png"),device ="png",plot = gg,path = figures_path,width =15,height =15,dpi =300)print(gg)
Figure 13: Moyenne de la prévalence des topics par document
Show the code
# Filtrer les 10 documents les plus associés à chaque topicgamma_top10 <- gamma %>%group_by(topic) %>%slice_max(order_by = gamma, n =10) %>%ungroup() %>%select(topic, id, gamma, title, authors) %>%# cut text# mutate(text = str_trunc(text, 500)) arrange(topic, desc(gamma))# Affichage avec DTgamma_top10 %>% DT::datatable(extensions =c('Buttons', 'ColReorder', 'FixedHeader'),options =list(dom ='Bfrtip',buttons =c('excel', 'csv'),pageLength =10,colReorder =TRUE,fixedHeader =TRUE,order =list(list(2, 'desc')),search =list(regex =TRUE, caseInsensitive =TRUE),columnDefs =list(list(width ='500px', targets =3) ) ),filter ="top" )
Table 7: Les 10 documents avec la prévalence la plus importante pour chaque topic
Show the code
# facet wrap gg <- gamma %>%filter(gamma >0.3) %>%ggplot(aes(x = gamma)) +geom_density(alpha =0.5) +# Density plot with transparencyfacet_wrap(~factor(topic, levels =sort(unique(topic))), scales ="free") +theme_light() +labs(x ="Valeur de la prévalence", y ="Densité",caption ="Distribution des prévalences par documents et par thématiques (uniquement pour les valeurs de theta > 0.3)") +theme(legend.position ="none") # Remove redundant legendggsave("distribution_theta.png",device ="png",plot = gg,path =here(figures_path),width =50,height =50,units ="cm",) print(gg)
Show the code
# Filtrer les 10 documents les plus associés à chaque topicbeta_top10 <- beta %>%group_by(topic) %>%slice_max(order_by = beta, n =10) %>%ungroup() # Affichage avec DTbeta_top10 %>% DT::datatable(extensions =c('Buttons', 'ColReorder', 'FixedHeader'),options =list(dom ='Bfrtip',buttons =c('excel', 'csv'),pageLength =10,colReorder =TRUE,fixedHeader =TRUE,order =list(list(2, 'desc')),search =list(regex =TRUE, caseInsensitive =TRUE),columnDefs =list(list(width ='500px', targets =3) ) ),filter ="top" )
graph_layout <-readRDS(here(intermediate_data_path, "graph_layout.rds"))gg <-ggraph(graph_layout, "manual", x = x, y = y) +geom_edge_arc0(aes(# color = cluster_leiden,width = weight), alpha =0.1, strength =0.2, show.legend =FALSE) +scale_edge_width_continuous(range =c(0.1,0.3)) +# scale_edge_colour_identity() +geom_point(aes(x = x, y = y)) +geom_label_repel_interactive(aes(x = x, y = y, # color = cluster_leiden,label = source_id,tooltip = topic_label_prob,data_id = source_id)) +scale_size_continuous(range =c(0.5,3)) +# scale_fill_identity() +theme_void()girafe(ggobj = gg,width_svg =8,height_svg =4.5)
Figure 14: Réseaux des thématiques (spacialisation par Force Atlas 2). Les noeuds sont les thématiques, les liens sont les coefficients de corrélation.
Pour chaque thématique \(k\) et document \(d\), nous cherchons à prédire la prévalence \(\theta_{d,k}\) en fonction des covariables choisies. Nous estimons le modèle suivant:
\[ \theta_{d,k} = \beta_0 + \beta_1 * hasfemale_d + \beta_2 * isvaria_d + \beta_3 * year_d + \epsilon_{d,k} \] Où \(hasfemale\) est une variable muette indiquant si le document a un auteur féminin (1) ou non (0), \(isvaria_d\) est une variable muette indiquant si le document est un varia (1) ou non (0), et \(year_d\) est l’année de publication du document. Nous appliquons une transformation b-spline à l’année pour capturer d’éventuels effets non linéaires.
La transformation b-spline
Dans un modèle linéaire, l’estimateur \(\beta_3\) d’une variable temps comme l’année mesure l’effet marginal d’une année supplémentaire sur la prévalence espérée de la thématique. Cette linéarité ne permet pas d’identifier des effets non-linéaires, par exemple une prévalence importante durant les années 1990 et suivie d’une décroissance. Pour ce faire, nous appliquons une fonction b-spline à l’année pour capturer d’éventuels effets non linéaires Formellement, notre estimateur de la variable \(year_d\) devient une combinaison linéaire de \(n\) estimateurs et de fonctions polynomiale \(B(x)\), aussi appelé dans ce contexte fonctions de base:
Figure 16: Les fonctions de base d’une transformation b-spline pour n = 10
L’inconvénient d’un tel traitement est que les estimateurs \(\alpha_1 ... \alpha_n\) ne sont pas interprétables. Chaque coefficient represente le poids d’une fonction polynomiale \(B_n\) dans la prévalence totale. Plutôt que de se focaliser sur la table de regression, il est plus intéressant de calculer directement la prévalence espérée du modèle pour une valeur de \(year\).
Figure 17 montre le résultat d’une telle estimation pour un topic avec et sans le traitement stm::s(). Chaque point represente la prévalence espérée pour un point donnée dans l’intervalle \([1969:2023]\) (avec le reste des covariates avec une valeur 0 de référence). Si l’estimation sans traitement permet d’identifier l’effet linéaire d’une baisse dans la popularité de cette thématique, l’estimation non linéaire permet d’identifier une évolution beaucoup plus subtile de la prévalence de ce topic dont la popularité s’accroit à la fin du 20ème siècle avant de progressivement diminuer.
4.1 La prise en compte de l’incertitude du modèle de thématiques dans la régression
Le package stm propose également de prendre en compte l’incertitude sur l’estimation de la prévalence dans les régressions de prévalence. Le nombre de prévalences simulées (et donc de régressions) par thématique est fixé à 25 par défaut. Nous avons suivi ce paramétrage. Cette fonctionnalité est intégrée dans la fonction stm::estimateEffect. Pour chaque \(K\) thématiques, 25 régressions estiment la prévalence par document.
Figure 19: Estimate effect of document being varia on topic prevalence
4.5 Prévalences espérées des thématiques par méta-thématiques
Show the code
ee_date <- ee_date %>%select(-label) %>%left_join(metatopics %>%select(topic, label))library(glue)list_metatopics <- metatopics %>%pull(unique(champ))for (metatopic in list_metatopics){list_topics <- metatopics %>%filter(champ == metatopic) %>%pull(topic)# Filter data for the topictopic_per_year <- ee_date %>%filter(topic == list_topics)# Generate the plotgg <- topic_per_year %>%ggplot(aes(x = covariate.value, color =paste0("Thématique ", topic, " : ", label)), linewidth =1) +geom_line(aes(y = estimate), size =1.5) +# geom_line(aes(y = ci.lower), color = "red", linetype = "dashed") +# geom_line(aes(y = ci.upper), color = "red", linetype = "dashed") +geom_hline(yintercept =0, linetype ="dashed") +scale_color_brewer(palette ="Paired") +labs(# title = glue("Meta-thématiques: {metatopic}"),subtile ="Evolution des thématiques",x ="Valeur de la variable année",y ="Prévalence espérée",color ="" ) +theme_light(base_size =15) +theme(legend.position ="bottom",# Positionne la légende en bas du graphiquelegend.text =element_text(size =10)) +guides(color =guide_legend(nrow =ceiling(length(list_topics)/2))) ggsave( glue::glue("year_effect_{metatopic}.png"),device ="png",plot = gg,path = figures_path,width =8,height =4.5,dpi =300)}
Show the code
list_topics_macro <- metatopics %>%filter(champ %in%"Macroéconomie") %>%pull(topic)list_topics_macro_tradi <-c(14, 20, 21, 25, 29, 5)list_topics_macro_modern <- list_topics_macro %>%setdiff(list_topics_macro_tradi)topic_per_year_facet <- ee_date %>%select(-label) %>%filter(topic %in% list_topics_macro) %>%left_join(metatopics, by ="topic") %>%mutate(facet =ifelse(topic %in% list_topics_macro_tradi, "Macroéconomie traditionnelle", "Macroéconomie moderne"),facet =factor(facet, levels =c("Macroéconomie traditionnelle", "Macroéconomie moderne")),topic =factor(topic, levels =c(list_topics_macro_tradi, list_topics_macro_modern)))gg_facet <- topic_per_year_facet %>%ggplot(aes(x = covariate.value,color =paste0("Thématique ", topic, " : ", label) )) +geom_line(aes(y = estimate), size =2) +geom_hline(yintercept =0, linetype ="dashed") +scale_color_brewer(palette ="Paired") +facet_wrap( ~ facet, scales ="fixed") +labs(# title = "Meta-thématiques: Finance et banque, Macroéconométrie et Économie internationale",x ="Valeur de la variable année",y ="Prévalence espérée de la thématique",color ="" ) +theme_light(base_size =15) +theme(legend.position ="bottom",legend.text =element_text(size =16),legend.title =element_text(size =18),legend.key =element_blank(), strip.background =element_rect(colour="white",fill="white"),# color in black the strip backgroundstrip.text =element_text(size =18, color ="black") ) +guides(color =guide_legend(nrow =ceiling(length(list_topics_macro)/2)),legend.spacing.y =unit(0.05, 'cm')) # Place la légende sur une seule ligneggsave("year_effect_facet_plot.png",device ="png",plot = gg_facet,path = figures_path,width =16,height =9,dpi =300)
References
Bischof, Jonathan, and Edoardo M Airoldi. 2012. “Summarizing Topical Content with Word Frequency and Exclusivity.” In Proceedings of the 29th International Conference on Machine Learning (Icml-12), 201–8.
Mimno, David, Hanna Wallach, Edmund Talley, Miriam Leenders, and Andrew McCallum. 2011. “Optimizing Semantic Coherence in Topic Models.” In Proceedings of the 2011 Conference on Empirical Methods in Natural Language Processing, 262–72.
Roberts, Margaret E, Brandon M Stewart, and Edoardo M Airoldi. 2016. “A Model of Text for Experimentation in the Social Sciences.”Journal of the American Statistical Association 111 (515): 988–1003.
Roberts, Margaret E, Brandon M Stewart, Dustin Tingley, Edoardo M Airoldi, et al. 2013. “The Structural Topic Model and Applied Social Science.” In Advances in Neural Information Processing Systems Workshop on Topic Models: Computation, Application, and Evaluation, 4:1–20. 1. Harrahs; Harveys, Lake Tahoe.
Truc, Alexandre, Olivier Santerre, Yves Gingras, and François Claveau. 2023. “The Interdisciplinarity of Economics.”Cambridge Journal of Economics 47 (6): 1057–86.
Van der Loo, Mark PJ et al. 2014. “The Stringdist Package for Approximate String Matching.”R J. 6 (1): 111.
Footnotes
Le rang normalisé est calculé par la fonction de répartition empirique, c’est à dire, intuivement, le nombre de points d’observations inférieur aux points observés sur le nombre total de poi,nts d’observation.↩︎
Ici, nous avons repris la formule de la FREX de l’article. Dans le package stm, la formule est inversée. Le paramètre \(a = 0.7\) mais est associé à \(E\) et \((1-a)\) à \(F\) dans la fonction stm::exclusivity().↩︎