Nous allons créer un réeau entre Webtoon en mesurant la proximité par les publics partagés (nombre d’utilisateurs ayant commenté les 2 webtoons)
3.1 Matrice User/Webtoon
Nous allons créer une SparseMatrix recensant le nombre de commentateurs communs pour chaque paires de webtoon
# Table binaire user-webtoon : un couple user/webtoon unique = 1uw <- comm %>%distinct(user, titre) %>%mutate(x =1L) #data.frame [175153;3]# Indexation users <-sort(unique(uw$user))toons <-sort(unique(uw$titre))print(paste0("on compte ",length(users)," utilisateurs pour ",length(toons)," webtoons."))
[1] "on compte 96986 utilisateurs pour 211 webtoons."
uw <- uw %>%mutate(i =match(user, users), # i=rang de l'individu dans la liste usersj =match(titre, toons) # j=rang du webtoon dans la liste toons )# Matrice sparse (users , webtoons)X <-sparseMatrix(i = uw$i,j = uw$j,x = uw$x,dims =c(length(users), length(toons)),dimnames =list(users, toons)) #si l'utilisateur i a commenté le webtoon j alors x(ij) vaut 1, sinon 0 (symbolisé par un ".")
3.2 Similarité cosinus entre Webtoons
Maintenant que nous avons construit la Matrice sparse recensant tous les couples user/Webtoon effectifs, nous pouvons construire un indice de similarité (Similarité cosinus) entre Webtoons. Si deux webtoons sont commentés par un nombre important d’utilisateurs identiques alors ils seront proches.
#Le passage par la similarité cosinus permet de mesurer la proximité relative des publics des oeuvres en neutralisant l'effet de la popularité de certaines oeuvres.# Co-commentaires : W = t(X) %*% XW <-crossprod(X) # 211 x 211 (si 211 webtoons)# d = sqrt(diag(W)) = norme de chaque colonne (taille du public, racine)d <-sqrt(diag(W))# On retire les auto-liensdiag(W) <-0# Cosine similarity : COS[i,j] = W[i,j] / ( sqrt(W[i,i]) * sqrt(W[j,j]) )COS <- W / (d %o% d) # SécuritéCOS[is.na(COS)] <-0diag(COS) <-0# Résumésummary(as.numeric(COS))
Min. 1st Qu. Median Mean 3rd Qu. Max.
0.000000 0.008674 0.015196 0.019623 0.025895 0.167705
3.3 Construction du réseau par k-voisins et clustering
Avec la projection webtoon–webtoon basée sur publics partagés et un seuil de similarité relativement permissif, Louvain renvoie 4 communautés assez larges (52/66/25/68). Si on monte le seuil (de 0.02 à 0.055), le graphe devient beaucoup trop dispersé. Nous décidons donc de faire plutôt une sélection des top-k voisins (ici avec k=10 mais les résultats sont relativement stables avec k=8 ou k=12).
k <-10# Edge list : pour chaque webtoon i, garder ses k plus fortes similaritésedgelist_k <-do.call(rbind, lapply(1:nrow(COS), function(i) { w <- COS[i, ] # w est le vecteur des similarités entre le webtoon i et les autres webtoon. Donc w[j] est la similarité cosinus entre les webtoons i et j w[i] <-0# sécurité : on supprime les auto-liens j <-order(w, decreasing =TRUE)[1:k] # indices des k plus prochesdata.frame( #renvoie un tableau de k lignesfrom =rownames(COS)[i],to =colnames(COS)[j],w = w[j] )})) %>%#les tableaux de k-lignes vont s'empiler grâce au do.call de début de formulefilter(w >0) %>%# enlever les éventuels poids nulsmutate(a =pmin(from, to), b =pmax(from, to) # réécrit la paire dans le même sens (A,B) et (B,A) -> (A,B). Puisque ici il s'agit de variable texte, le min et le max renvoient à un ordre alphabétique ) %>%group_by(a, b) %>%# regroupe les doublons non orientéssummarise(w =max(w), .groups ="drop") %>%# garde 1 arête A—B, poids = max. On a fini de désorienté les arêtes.rename(from = a, to = b)# Graphe non orientég <-graph_from_data_frame(edgelist_k, directed =FALSE, vertices = toons)E(g)$weight <- edgelist_k$w# Diagnosticsprint(paste0(gorder(g)," sommets"))
Pour chaque Webtoon, nous pouvons afficher ses webtoons les plus proches
webtoon_cible <-"100"#ou n'importe quel autre titreneighbors_tbl <- edgelist_k %>%filter(from == webtoon_cible | to == webtoon_cible) %>%mutate(other =ifelse(from == webtoon_cible, to, from)) %>%arrange(desc(w)) %>%select(webtoon_cible = other, cos = w)%>%rename("titre"=webtoon_cible) %>%left_join(webtoon_cluster, by ="titre")%>%left_join(genre, by ="titre")cluster_cible <- webtoon_cluster %>%filter(titre == webtoon_cible) %>%pull(cluster)genre_cible <- genre %>%filter(titre == webtoon_cible) %>%pull(genre)neighbors_tbl %>%slice_head(n =10) %>%gt() %>%tab_header(title =paste0("Les 10 voisins du webtoon ", webtoon_cible),subtitle =paste0("Webtoon de genre ",genre_cible," appartenant au cluster ", cluster_cible," (partition Louvain, similarité cosinus des publics)" ) )
Les 10 voisins du webtoon 100
Webtoon de genre Action appartenant au cluster 1 (partition Louvain, similarité cosinus des publics)
titre
cos
cluster
genre
Ultra-Alternate Character
0.08351464
1
Action
Night Of Silence
0.07063240
1
Action
Solo Auto-Hunting
0.06544113
1
Action
The Retreats
0.06499172
1
Action
Multi-skill Collector
0.05343246
1
Action
SAMADHI
0.05086688
1
Action
Les pirates d'abord
0.04731112
6
Comedie
Plaza Wars
0.04339023
1
Action
Noise from upstairs
0.04312738
1
Thriller
The Final Raid Boss
0.03550589
1
Action
Cette analyse nous permet de voir si les voisins les plus proches d’un webtoon appartiennent majoritairement au même genre que celui-ci. Il permet également de repérer les ponts entre différents clusters.
Par exemple, le webtoon “100”, de genre “Action”, a pour voisins des webtoons du genre “Action” principalement (7/10) et 9 de ses 10 voisins appartiennent (par construction) au même cluster. Mais un de ses voisins (“Les pirates d’abord”), de genre “Comédie”, appartient à un autre cluster. Il y a ici un pont entre le cluster 1 et le cluster 7.
Nous pouvons également étudié le contenu en genre des clusters. Cela permet d’étudier leur degré d’homogénéité/hétérogénéité de genre.
dist_genre_cluster <- comm %>%distinct(titre, cluster, genre_rec) %>%# 1 ligne = 1 webtooncount(cluster, genre_rec, name ="n") %>%group_by(cluster) %>%mutate(pct =round(n /sum(n) *100,2)) %>%ungroup() %>%arrange(cluster, desc(n))dist_genre_cluster %>%gt(groupname_col ="cluster") %>%tab_header(title ="Distribution des genres par cluster de webtoons",subtitle ="en % des titres") %>%cols_label(genre_rec ="Genre",n ="Nombre de webtoons",pct ="% dans le cluster" )
Distribution des genres par cluster de webtoons
en % des titres
Genre
Nombre de webtoons
% dans le cluster
1
Action
13
59.09
Thriller
5
22.73
Drama
2
9.09
Fantastique/SF/Horreur
2
9.09
2
Romance
34
75.56
Fantastique/SF/Horreur
6
13.33
Drama
4
8.89
Thriller
1
2.22
3
Romance
15
46.88
Fantastique/SF/Horreur
11
34.38
Drama
6
18.75
4
Romance
11
45.83
Drama
3
12.50
Fantastique/SF/Horreur
3
12.50
Comedie
2
8.33
Tranche_de_vie/Sport
2
8.33
Thriller
2
8.33
Action
1
4.17
5
Romance
26
60.47
Drama
6
13.95
Fantastique/SF/Horreur
6
13.95
Thriller
3
6.98
Action
1
2.33
Tranche_de_vie/Sport
1
2.33
6
Fantastique/SF/Horreur
7
26.92
Comedie
6
23.08
Romance
6
23.08
Tranche_de_vie/Sport
4
15.38
Action
1
3.85
Drama
1
3.85
Thriller
1
3.85
7
Romance
18
94.74
Fantastique/SF/Horreur
1
5.26
Par exemple, tandis que le cluster 4 contient près de 85% de titres de genre “Romance” (très homogène), le cluster 6 est formé par des titres de genre divers et aucun genre n’y regroupe plus d’un quart des titres.
4. Réseau user/user
La question centrale à laquelle cette construction vise à répondre est la suivante : À l’intérieur d’un même cluster de webtoons (donc d’un même espace de lectures partageant un important nombre de commentateurs), existe-t-il des sous-groupes d’utilisateurs qui partagent plusieurs œuvres et qui se distinguent par leurs manières de commenter (types de commentaires, registres lexicaux) ?
Il nous faut donc:
Repérer des communautés d’utilisateurs dans un espace de lecture déjà homogènes (clusters de webtoon)
Caractériser ces communautés en fonction du type de commentaires voire des termes abordés ou encore des registres lexicaux utilisés.
Si nous établissons des spécificités pour certaines communautés notre analyse ne nous permettra pas de déterminer le sens de la causalité : la communauté d’utilisateurs est-elle formée par ou forme-t-elle la communauté de commentaires ?
Nous allons donc repérer les communautés d’utilisateurs en fonction de l’importance de leur relation. La relation est ici mesurée par le fait de commenter des webtoons communs au sein d’un cluster de webtoon. La relation mesure donc, non pas nécessairement une interaction directe, mais une proximité de lectures commentées. Il s’agit d’une relation non orientée mais valuée (nombre de webtoons commentés en commun).
4.1 Construction du réseau user/user pour chaque cluster de Webtoon
#on définit ici les paramètres du modèle. On peut changer les paramètres sans avoir à réécrire tout le modèle.chunk_size <-20# nombre de titres traités par "paquet" ;selon la RAMmax_users_per_titre <-Inf# sert de garde-fou : au-delà, un titre génère trop de paires (quadratique). Ici on laisse Inf mais on pourrait mettre 500 par exemple. Cela excluerait 3 titres très populairesmin_titre_partage <-3# seuil : nb minimum de webtoons communs pour garder un lien user/user. Si on change le seuil, inutile de relancer tout le code. On peut repartir au 4.2min_edges_analyse <-10#nb minimum de liens dans un cluster de webtoon pour executer une analyse des communautés d'utilisateursout_dir <-"//ad.univ-lille.fr/Personnels/Homedir1/4273/Documents/cours25_26/Master2 ENSP/textometrie/dossier_exam"
Nous allons commencer par établir une boucle sur les clusters de Webtoon afin d’obtenir les liens entre utilisateurs
for (cluster_cible insort(unique(webtoon_cluster$cluster))) {## titres du cluster titres_cluster <-as.data.table(webtoon_cluster)[cluster == cluster_cible, titre]## user–titre restreint au cluster (binaire : un user compte 1 fois par titre) uw <-unique(as.data.table(comm)[titre %in% titres_cluster, .(user, titre)])## nb d'users uniques par titre n_u <- uw[, .(n_users =uniqueN(user)), by = titre]## filtrer les titres trop populaires (paramètre max_users_per_titre) titres_ok <- n_u[n_users <= max_users_per_titre, titre] #inutile si Inf dans les paramètres uw <- uw[titre %in% titres_ok]## partition de la liste en chunk titres_ok <-sort(unique(uw$titre)) chunks <-split(titres_ok, ceiling(seq_along(titres_ok) / chunk_size)) #découpe titres_ok en x chunks. l'objet chunks est donc une liste de vecteurs de titres. Chaque vecteur, sauf éventuellement le dernier, contient chunk_size titres## BOUCLE : POUR CHAQUE chunk, GÉNÉRER LES PAIRES user/user ET LES COMPTER tmp_files <-character(length(chunks))for (c inseq_along(chunks)) { titres_chunk <- chunks[[c]] uwc <- uw[titre %in% titres_chunk]setkey(uwc, titre) # clé sur titre pour accélérer la jointure# Self-join sur "titre" : pour chaque titre, on associe tous les users entre eux (on fait le produit cartésien par titre) pairs <- uwc[uwc, on ="titre", allow.cartesian =TRUE, nomatch =0L] #allow.cartesian=TRUE car par défaut R ne le fera pas car ça produit beaucoup de lignes #nomatch=0L supprime les lignes sans correspondance. Puisque c'est un self-join ça ne devrait aps arriver.# On ne garde qu'une seule orientation (user < i.user) pour :# - éviter les doublons (A,B) et (B,A)# - éviter les auto-liens (A,A)# On garde aussi "titre" pour compter explicitement les titres en commun pairs <- pairs[user < i.user, .(titre, u1 = user, u2 = i.user)]#On a donc maintenant, pour chaque webtoon,toutes les paires possibles d’utilisateurs ayant commenté un même titre. # On compte le nombre de TITRES DISTINCTS en commun DANS CE CHUNK edges_chunk <- pairs[, .(w =uniqueN(titre)), by = .(u1, u2)]# On écrit un fichier temporaire pour ce paquet (pour ne pas tout garder en RAM) tmp_path <-file.path(out_dir, sprintf("tmp_edges_cl%02d_chunk%03d.csv", cluster_cible, c)) #chemin de type (out_dir,"tmp_edges_cl06_chunk%002.csv)fwrite(edges_chunk, tmp_path) #sauvegarde edges_chunk sur le disque à tmp_path tmp_files[c] <- tmp_path # stocke le chemin dans le vecteur tmp_files à l'indice ccat("Chunk", c, "/", length(chunks), "écrit :", tmp_path, "\n") }## agrégation des paquets tmp_files_ok <- tmp_files[file.exists(tmp_files)] #au cas où il n'y ait aucune paire dans un chunk et donc que le fichier ne soit pas créé edges_all <-rbindlist(lapply(tmp_files_ok, fread)) #on charge chaque tableau lapply(fread) et on les colle (rbindlist) edges_all <- edges_all[, .(w =sum(w)), by = .(u1, u2)] # agrégation finale : somme des poids par paire final_path <-file.path(out_dir, sprintf("edges_user_user_cluster%02d.csv", cluster_cible))fwrite(edges_all, final_path)cat("Edge list finale écrite :", final_path, "\n")# (Optionnel) Nettoyer les fichiers temporaires avant le nouveau passage de la bouclefile.remove(tmp_files_ok)}
4.2 Caractéristiques des liens et des communautés pour chaque cluster
Il s’agit ici de faire un premier diagnostic de la structure des réseaux user/user pour chaque cluster de webtoons. En fonction des résultats de ce diagnostic on passera soit à l’étape suivante (production du résultat final) soit à un ajustement des paramètres du modèle, en particulier min_titre_partage et min_edges_analyse.
dossier <- out_dirfiles <-list.files(dossier, pattern ="edges_user_user_cluster", full.names =TRUE)for (f in files) {cat("\n============================\n")cat("Fichier:", basename(f), "\n") ELU <-fread(f)# filtre liens "solides" ELU2 <- ELU[w >= min_titre_partage]cat("Liens bruts:", nrow(ELU), " | Liens w>=", min_titre_partage, ":", nrow(ELU2), "\n")# s'il n'y pas assez de liens après filtre, on s'arrête làif (nrow(ELU2) < min_edges_analyse) {cat("Trop peu de liens après filtre -> cluster suivant\n")next } g <-graph_from_data_frame(ELU2, directed =FALSE)E(g)$weight <- ELU2$w comp <-components(g) cl <-cluster_louvain(g, weights =E(g)$weight) sz <-sizes(cl)cat("Noeuds:", gorder(g),"| Aretes:", gsize(g),"| Densite:", round(edge_density(g), 4),"| Composantes:", comp$no,"| Max comp:", max(comp$csize),"| Nb comms:", length(sz), "\n")cat("Top tailles:", paste(head(sort(sz, decreasing =TRUE), 10), collapse =", "), "\n")}
============================
Fichier: edges_user_user_cluster01.csv
Liens bruts: 1157959 | Liens w>= 3 : 750
Noeuds: 186 | Aretes: 750 | Densite: 0.0436 | Composantes: 1 | Max comp: 186 | Nb comms: 6
Top tailles: 56, 55, 31, 26, 13, 5
============================
Fichier: edges_user_user_cluster02.csv
Liens bruts: 10606379 | Liens w>= 3 : 23708
Noeuds: 1307 | Aretes: 23708 | Densite: 0.0278 | Composantes: 1 | Max comp: 1307 | Nb comms: 5
Top tailles: 369, 353, 314, 162, 109
============================
Fichier: edges_user_user_cluster03.csv
Liens bruts: 14359751 | Liens w>= 3 : 22878
Noeuds: 956 | Aretes: 22878 | Densite: 0.0501 | Composantes: 1 | Max comp: 956 | Nb comms: 6
Top tailles: 188, 184, 180, 144, 139, 121
============================
Fichier: edges_user_user_cluster04.csv
Liens bruts: 66899793 | Liens w>= 3 : 114823
Noeuds: 2009 | Aretes: 114823 | Densite: 0.0569 | Composantes: 1 | Max comp: 2009 | Nb comms: 5
Top tailles: 553, 522, 517, 278, 139
============================
Fichier: edges_user_user_cluster05.csv
Liens bruts: 59217516 | Liens w>= 3 : 193617
Noeuds: 3562 | Aretes: 193617 | Densite: 0.0305 | Composantes: 1 | Max comp: 3562 | Nb comms: 5
Top tailles: 1173, 907, 682, 508, 292
============================
Fichier: edges_user_user_cluster06.csv
Liens bruts: 6101314 | Liens w>= 3 : 11825
Noeuds: 671 | Aretes: 11825 | Densite: 0.0526 | Composantes: 1 | Max comp: 671 | Nb comms: 5
Top tailles: 259, 194, 94, 86, 38
============================
Fichier: edges_user_user_cluster07.csv
Liens bruts: 16428106 | Liens w>= 3 : 29174
Noeuds: 875 | Aretes: 29174 | Densite: 0.0763 | Composantes: 1 | Max comp: 875 | Nb comms: 5
Top tailles: 287, 245, 170, 102, 71
Deux seuils ont été testés pour définir les liens entre utilisateurs : au moins deux titres partagés (w≥2) et au moins trois titres partagés (w≥3). Dans les deux cas, les réseaux restent connectés (une composante principale unique), ce qui indique l’existence d’un noyau de publics communs au sein de chaque cluster. Le seuil w≥3 réduit fortement le nombre de liens et d’utilisateurs retenus (par exemple dans le cluster 7 le passage du minimum de titres partagés de 2 à 3 fait passer le nombre de noeuds de 2620 à 875), conduisant à une analyse centrée sur un noyau dur d’utilisateurs commentant les mêmes titres. w≥2 permet une couverture plus large des publics. Autrement dit avec w≥2 on établit une cartographie générale des communautés tandis qu’avec w≥3 on peut effectuer une analyse d’un noyeau dur, de communautés “fortes”.
Nous utiliserons les communautés “fortes” (w≥3) pour mener des comparaisons de discours.
4.3 Communautés d’utilisateurs par clusters (Louvain)
Lorsque le diagnostic indique des structures de réseaux jugées suffisamment robustes et interprétables, les partitions communautaires sont produites et conservées pour les analyses ultérieures. C’est ce que nous faisons dans cette section.
for (f in files) { ELU <-fread(f) ELU2 <- ELU[w >= min_titre_partage]# g si vide, on saute pour éviter un bugif (nrow(ELU2) <1) {cat("NEXT (aucun lien solide) :", basename(f), "\n")next } g <-graph_from_data_frame(ELU2, directed =FALSE)if (gsize(g) <1) {cat("NEXT (graphe sans arêtes) :", basename(f), "\n")next }E(g)$weight <- ELU2$w cl <-cluster_louvain(g, weights =E(g)$weight)# table user avec communauté de users memb <-data.table(user =names(membership(cl)),commu_user =as.integer(membership(cl)))# nom de sortie (on garde le numéro du cluster dans le nom) out <-sub("edges_user_user_", "users_commu_user_", f)out <-sub("\\.csv$", paste0("_wge", min_titre_partage, ".csv"), out) #on aurait pu faire un sprintf mais il aurait fallu extraire le numéro de cluster avant, cela aurait été plus compliquéfwrite(memb, out)cat("Ecrit:", basename(out), "| lignes =", nrow(memb), "\n")}