Aller au contenu

Mettez les graphes au service de la data

Vous avez maintenant une boîte à outils complète : parcours, composantes, tri topologique, arbres, chemins, flots. Mais à quoi tout cela sert-il quand on travaille en data science ? À beaucoup plus de choses qu’on ne le croit. Les graphes organisent les pipelines de données, ont fait la fortune d’un moteur de recherche, alimentent les systèmes de recommandation et structurent la connaissance que consultent aujourd’hui les modèles de langage.

Ce chapitre fait le tour de ces usages, en reliant chacun aux notions des chapitres précédents. Notre fil rouge : une librairie en ligne rennaise, avec son site web, ses clients, ses livres et ses données.

Chaque nuit, la librairie recalcule ses stocks, ses ventes de la veille et ses recommandations. Ces traitements dépendent les uns des autres : c’est exactement le pipeline du chapitre 4. Les orchestrateurs de workflows (Airflow, Dagster, Prefect) demandent de décrire un traitement comme un DAG (graphe orienté sans circuit) de tâches, puis ils :

  • vérifient l’absence de circuit à la déclaration (un DAG avec une dépendance circulaire est refusé) ;
  • lancent les tâches dans un ordre topologique, en exécutant en parallèle celles d’un même niveau ;
  • en cas d’échec, relancent uniquement la tâche fautive et ses descendants (au sens du chapitre 3), sans tout recalculer.

Les outils de transformation SQL comme dbt font de même avec les tables : chaque modèle déclare les tables qu’il lit, dbt en déduit le graphe des dépendances et reconstruit les tables dans l’ordre. Ce même graphe sert à la traçabilité des données (data lineage) : si une colonne source est corrompue, les descendants de cette table dans le graphe sont exactement les tableaux de bord à invalider.

ventesclientscalendriernett_ventesnett_clientsjointurefeaturesentrainementevaluationtableau_de_bord← la tâche échoueon ne relance queses descendants(5 tâches sur 10)en gris :déjà faites,inchangéesnett_clientsjointurefeaturesentrainementevaluationtableau_de_bordventesclientscalendriernett_ventesnett_clientsjointurefeaturesentrainementevaluationtableau_de_bord
Si nett_clients échoue, l'orchestrateur ne relance qu'elle et ses descendants ; les autres résultats restent valables.

Les bibliothèques d’apprentissage profond utilisent elles aussi un DAG. Quand vous écrivez perte = ((w * x + b - y) ** 2).mean() en PyTorch, la bibliothèque enregistre un graphe de calcul : chaque opération est un sommet, relié aux opérations qui produisent ses entrées. La rétropropagation du gradient parcourt ensuite ce graphe dans l’ordre topologique inverse, de la perte vers les paramètres. C’est le tri topologique du chapitre 4, au cœur de l’entraînement de tous les réseaux de neurones (voir le cours d’apprentissage automatique).

Comment classer des pages web par importance ? L’idée de PageRank, à l’origine de Google, est qu’une page est importante si des pages importantes pointent vers elle. La définition semble circulaire, mais elle se formalise très bien avec une marche aléatoire.

Imaginez un internaute qui navigue au hasard. À chaque étape :

  • avec probabilité dd (typiquement d=0,85d = 0{,}85), il clique sur un des liens de la page, choisi uniformément ;
  • avec probabilité 1−d1 - d, il se téléporte sur une page quelconque du site, choisie uniformément.

Le PageRank d’une page est la proportion du temps que cet internaute passe sur elle, à long terme :

PR(p)=1−dn+d∑q→pPR(q)d+(q)PR(p) = \frac{1 - d}{n} + d \sum_{q \to p} \frac{PR(q)}{d^+(q)}
  • nn est le nombre de pages ;
  • la somme porte sur les pages qq qui ont un lien vers pp ;
  • d+(q)d^+(q) est le nombre de liens sortants de qq (son demi-degré extérieur) : une page partage son importance entre toutes celles qu’elle cite.

Mathématiquement, c’est la distribution stationnaire d’une chaîne de Markov (voir le cours de modèles stochastiques). La téléportation joue un rôle crucial : elle rend la chaîne irréductible, même si le graphe du web n’est pas fortement connexe (chapitre 3), ce qui garantit que la distribution stationnaire existe et est unique.

On part d’un PageRank uniforme (1/n1/n partout) et on applique la formule en boucle jusqu’à stabilisation : c’est la méthode de la puissance (power iteration).

02468101214161820itération0,00,10,20,30,40,50,6PageRankC ≈ 0,394A ≈ 0,373B ≈ 0,196D ≈ 0,0381re itération :C bondit à 0,569D : seulement la téléportation, 0,15 / 4 = 0,0375
Partie de 0,25 partout, la méthode de la puissance oscille quelques itérations puis se stabilise ; D, que personne ne cite, tombe tout de suite à la téléportation seule.
accueilPR ≈ 0,373blogPR ≈ 0,196cataloguePR ≈ 0,394promoPR ≈ 0,038taille du disque ∝ PageRankABCD
Le catalogue et l'accueil se renvoient leur importance ; la page promo, que rien ne cite, garde seulement sa part de téléportation.
def pagerank(liens, d=0.85, iterations=100):
"""liens : page -> liste des pages citées. Méthode de la puissance."""
pages = list(liens)
n = len(pages)
pr = {p: 1 / n for p in pages}
for _ in range(iterations):
pr = {p: (1 - d) / n + d * sum(pr[q] / len(liens[q]) for q in pages if p in liens[q])
for p in pages}
return pr
site = {"A": ["B", "C"], "B": ["C"], "C": ["A"], "D": ["C"]}
print({p: round(v, 3) for p, v in pagerank(site).items()})
# {'A': 0.373, 'B': 0.196, 'C': 0.394, 'D': 0.038}

Au-delà du web, PageRank et ses variantes mesurent l’influence dans un réseau social, repèrent les articles scientifiques les plus structurants dans un graphe de citations, ou détectent les comptes centraux d’un réseau de fraude. C’est l’une des mesures de centralité ; d’autres existent, comme la centralité d’intermédiarité (betweenness), qui compte combien de plus courts chemins passent par un sommet (un point de passage obligé, proche de la notion de point d’articulation du chapitre 2).

La librairie veut suggérer des livres à ses clients. Représentons ses ventes par un graphe biparti : d’un côté les clients, de l’autre les livres, et une arête quand un client a acheté un livre. Aucune arête ne relie deux clients ni deux livres.

ClientLivres achetés
U1L1, L2
U2L1, L2, L3
U3L2, L4
U4L3

Une idée simple et étonnamment efficace : recommander à un client les livres qu’on atteint depuis lui par un chemin de longueur 3, client → livre → autre client → autre livre. Autrement dit : « les clients qui ont acheté les mêmes livres que vous ont aussi acheté… ». Plus il y a de tels chemins vers un livre, plus il est recommandé.

clientslivresdépart1. U1 → livre acheté2. → autre client3. → autre livreU1U2U3U4L1L2L3L4score 2score 1
Les trois chemins U1 → livre → client → livre : deux mènent à L3 (via U2), un à L4 (via U3).

Algébriquement, si BB est la matrice d’adjacence clients-livres, le nombre de chemins de longueur 3 est donné par le produit BB⊤BB B^\top B : c’est le principe du filtrage collaboratif fondé sur les voisins. Les systèmes industriels raffinent l’idée avec des marches aléatoires (quelques pas au hasard depuis le client, en comptant les livres les plus visités, à la manière d’un PageRank personnalisé), ou en apprenant des représentations vectorielles des sommets (embeddings).

Un graphe de connaissances (knowledge graph) stocke des faits sous la forme de triplets (sujet, prédicat, objet), c’est-à-dire d’arcs étiquetés entre entités :

  • (« Les Misérables », écrit_par, Victor Hugo) ;
  • (Victor Hugo, né_à, Besançon) ;
  • (« Les Misérables », genre, roman historique) ;
  • (Besançon, situé_en, France).
« Les Misérables »Victor HugoBesançonFranceroman historique« Les Misérables »Victor HugoBesançonFranceroman historiqueécrit_parné_àgenresitué_en
Les quatre triplets forment un petit graphe orienté dont chaque arc porte un prédicat.

C’est un multigraphe orienté dont les arcs portent un type. Wikidata en contient plus d’un milliard, et les moteurs de recherche s’en servent pour afficher les encadrés d’information. La norme RDF décrit ces triplets, et le langage SPARQL permet de les interroger ; les bases orientées graphes comme Neo4j utilisent un autre langage, Cypher.

Répondre à « quels romans ont été écrits par des auteurs nés en France ? » revient à chercher des chemins d’une forme donnée : livre → écrit_par → auteur → né_à → ville → situé_en → France. Les requêtes récursives (« toutes les sous-catégories de roman, à n’importe quelle profondeur ») sont des calculs de descendants, donc de fermeture transitive (chapitre 3). C’est aussi exactement ce que fait un programme Prolog quand il déduit des faits nouveaux à partir de règles (voir le cours de programmation logique).

Les graphes de connaissances connaissent un regain d’intérêt avec les modèles de langage : dans les approches de type GraphRAG, on extrait des entités et des relations d’un corpus de documents, on les organise en graphe, puis on fournit au modèle les sous-graphes pertinents pour répondre à une question. Le graphe apporte des faits vérifiables et des liens explicites, là où la recherche par similarité de textes les perd.

Pour finir, voici quelques outils que vous croiserez en data et qui prolongent ce cours.

QuestionMéthodeLien avec le cours
Quels groupes de sommets sont très liés entre eux ?détection de communautés (algorithme de Louvain, maximisation de la modularité)généralise les composantes connexes
Quels sommets sont centraux ?PageRank, centralité de degré, d’intermédiaritédegrés, plus courts chemins
Comment représenter un sommet par un vecteur ?plongements (node2vec, marches aléatoires)parcours, marches aléatoires
Comment apprendre sur des données en graphe ?réseaux de neurones sur graphes (GNN) : chaque sommet agrège l’information de ses voisinslistes d’adjacence, voisinages
Comment regrouper des points proches ?clustering hiérarchique à lien simplearbre couvrant minimal (chapitre 5)

networkx suffit pour explorer des graphes de quelques centaines de milliers de sommets. Au-delà, on passe à des outils spécialisés (igraph, graph-tool, les bases orientées graphes, ou les bibliothèques de GNN comme PyTorch Geometric).

import networkx as nx
web = nx.DiGraph([("A", "B"), ("A", "C"), ("B", "C"), ("C", "A"), ("D", "C")])
print(nx.pagerank(web, alpha=0.85)) # alpha joue le rôle de d
ventes = nx.Graph([("U1", "L1"), ("U1", "L2"), ("U2", "L1"), ("U2", "L2"),
("U2", "L3"), ("U3", "L2"), ("U3", "L4"), ("U4", "L3")])
print(nx.is_bipartite(ventes)) # True
perso = nx.pagerank(ventes, personalization={"U1": 1}) # PageRank personnalisé depuis U1
print(sorted((l for l in perso if l.startswith("L")), key=perso.get, reverse=True))
communautes = nx.community.louvain_communities(ventes, seed=0) # détection de communautés
  • Les DAG organisent les pipelines de données (Airflow, dbt) et les graphes de calcul des réseaux de neurones : ordre topologique pour l’exécution, niveaux pour le parallélisme, descendants pour la traçabilité.
  • PageRank classe les pages par une marche aléatoire avec téléportation : PR(p)=(1−d)/n+d∑q→pPR(q)/d+(q)PR(p) = (1 - d)/n + d \sum_{q \to p} PR(q)/d^+(q), calculé par la méthode de la puissance.
  • La recommandation s’appuie sur un graphe biparti utilisateurs-produits : compter les chemins de longueur 3 ou faire des marches aléatoires personnalisées, en normalisant par les degrés.
  • Un graphe de connaissances stocke des triplets (sujet, prédicat, objet) ; l’interroger revient à chercher des chemins, et il sert aujourd’hui à ancrer les réponses des modèles de langage.
  • Communautés, centralités, plongements et GNN prolongent les notions de ce cours à l’échelle des grands réseaux.

Exercice · Une itération de PageRank

facile

Un site a trois pages avec les liens A → B, B → C, C → A et C → B. Avec d=0,85d = 0{,}85 et un PageRank initial de 1/31/3 partout, calculez une itération. Quelle page semble la plus importante ?

Voir la solution

Le terme de téléportation vaut 0,15/3=0,050{,}15 / 3 = 0{,}05. C a deux liens sortants, A et B en ont un chacun.

  • PR(A)=0,05+0,85×1/32≈0,05+0,142=0,192PR(A) = 0{,}05 + 0{,}85 \times \tfrac{1/3}{2} \approx 0{,}05 + 0{,}142 = 0{,}192 (seul C pointe vers A) ;
  • PR(B)=0,05+0,85×(13+1/32)=0,05+0,425=0,475PR(B) = 0{,}05 + 0{,}85 \times \big(\tfrac{1}{3} + \tfrac{1/3}{2}\big) = 0{,}05 + 0{,}425 = 0{,}475 (A et C pointent vers B) ;
  • PR(C)=0,05+0,85×13≈0,333PR(C) = 0{,}05 + 0{,}85 \times \tfrac{1}{3} \approx 0{,}333 (seul B pointe vers C).

La somme vaut bien 1. Après une itération, B est en tête : c’est la seule page citée deux fois. (À convergence, on obtient environ 0,397 pour B, 0,388 pour C et 0,215 pour A : C remonte, car il reçoit tout le PageRank de B.)

En code

liens = {"A": ["B"], "B": ["C"], "C": ["A", "B"]}
d, n = 0.85, len(liens)
def iteration(pr):
return {p: (1 - d) / n + d * sum(pr[q] / len(liens[q]) for q in liens if p in liens[q])
for p in liens}
pr = iteration({p: 1 / n for p in liens})
print({p: round(v, 3) for p, v in pr.items()}, round(sum(pr.values()), 3))
# {'A': 0.192, 'B': 0.475, 'C': 0.333} 1.0
for _ in range(100): # méthode de la puissance jusqu'à convergence
pr = iteration(pr)
print({p: round(v, 3) for p, v in pr.items()})
# {'A': 0.215, 'B': 0.397, 'C': 0.388}

Exercice · Recommander à un autre client

moyen

Avec le tableau des ventes de la librairie, calculez les scores de recommandation par chemins de longueur 3 pour le client U3. Que recommandez-vous ?

Voir la solution

U3 a acheté L2 et L4. Les clients qui partagent un livre avec lui : via L2, U1 et U2 ; via L4, personne d’autre. Chemins vers des livres que U3 n’a pas :

  • U3 → L2 → U1 → L1 ;
  • U3 → L2 → U2 → L1 ;
  • U3 → L2 → U2 → L3.

Scores : L1 = 2, L3 = 1. On recommande L1 en priorité. Remarquez que tout passe par L2, le livre le plus acheté (trois clients) : c’est typiquement le cas où une normalisation par le degré de L2 serait utile pour ne pas surpondérer les livres populaires.

En code

import numpy as np
clients, livres = ["U1", "U2", "U3", "U4"], ["L1", "L2", "L3", "L4"]
B = np.array([[1, 1, 0, 0], # U1 : L1, L2
[1, 1, 1, 0], # U2 : L1, L2, L3
[0, 1, 0, 1], # U3 : L2, L4
[0, 0, 1, 0]]) # U4 : L3
chemins = B @ B.T @ B # nombre de chemins client -> livre de longueur 3
u3 = clients.index("U3")
scores = {l: int(chemins[u3, j]) for j, l in enumerate(livres) if B[u3, j] == 0}
print(scores) # {'L1': 2, 'L3': 1} : on recommande L1

Exercice · Choisir le bon outil

difficile

Pour chaque besoin de la librairie, indiquez la modélisation par un graphe et l’algorithme du cours à utiliser.

  1. Le traitement de nuit a échoué à l’étape jointure : quelles tâches faut-il relancer ?
  2. Livrer des colis depuis l’entrepôt vers tous les points relais en minimisant le temps de trajet vers chacun.
  3. Relier les points relais par un réseau de navettes, en minimisant la longueur totale des lignes.
  4. Savoir combien de colis par jour peuvent partir de l’entrepôt vers la ville, compte tenu du nombre de camions sur chaque axe.
  5. Repérer, dans le graphe « tel livre cite tel livre », des groupes de livres qui se citent tous mutuellement.
Voir la solution
  1. Le pipeline est un DAG ; il faut relancer jointure et tous ses descendants (un parcours depuis jointure), dans un ordre topologique : jointure, features, entrainement, evaluation, tableau_de_bord.
  2. Graphe orienté valué par les temps (positifs) : Dijkstra depuis l’entrepôt, qui donne l’arborescence des plus courts chemins vers tous les points relais.
  3. Graphe non orienté valué par les distances : arbre couvrant de coût minimal (Kruskal ou Prim).
  4. Réseau de transport avec des capacités (colis par jour sur chaque axe) : flot maximal par Ford-Fulkerson ; la coupe minimale indique les axes à renforcer.
  5. Graphe orienté des citations : composantes fortement connexes (Tarjan ou Kosaraju). Pour des groupes « très liés » sans exiger l’accessibilité mutuelle, on utiliserait plutôt une détection de communautés.

En code

pipeline = {
"ventes": ["nett_ventes"], "clients": ["nett_clients"], "calendrier": ["features"],
"nett_ventes": ["jointure"], "nett_clients": ["jointure"],
"jointure": ["features", "tableau_de_bord"], "features": ["entrainement"],
"entrainement": ["evaluation"], "evaluation": ["tableau_de_bord"], "tableau_de_bord": [],
}
# Un DFS lancé depuis la tâche en échec visite exactement ses descendants ;
# ses dates de fin, lues à l'envers, donnent un ordre topologique pour les relancer.
fin, vus = [], set()
def explorer(x):
vus.add(x)
for y in pipeline[x]:
if y not in vus:
explorer(y)
fin.append(x) # x est terminé : tous ses descendants le sont déjà
explorer("jointure")
print(fin[::-1])
# ['jointure', 'features', 'entrainement', 'evaluation', 'tableau_de_bord']