Initialisation · 00:00:00

Tom LEFRERE · Data Scientist

Des données. Un signal.

0%
FR EN

← Portfolio

· css · data · html · jupyter · numpy · pandas · playwright · plotly · python

Esport Stats, une petite enquête data sur les scènes pros

Un projet perso parti d’une question un peu bête (qui sont les plus jeunes ? qui sont les plus vieux ?) et qui a fini en petit rapport éditorial avec du Python, du Plotly, un PDF imprimable, et quelques pièges méthodologiques en chemin. Alors, pour le coup, voilà comment j&

Esport Stats, une petite enquête data sur les scènes pros

Un projet data fait sur un week-end, parti d’une question un peu bête sur l’âge des joueurs pros, et qui a fini en rapport éditorial de 15 pages. C’est un de ces trucs qu’on commence le samedi soir en se disant « bon, vite fait », et évidemment pas du tout.

Alors, pour le coup, tout est parti d’une curiosité un soir. Je me demandais, c’est quelque chose qu’on entend souvent sans vérifier, si c’était vrai que les scènes FPS cassaient les carrières tôt, et que Dota 2 était peuplé de vétérans qui traînent depuis dix ans. Je voulais une réponse chiffrée, propre, et tant qu’à faire, pas trop moche à regarder. Du coup, j’ai sorti un notebook, je me suis dit que je réglais ça en deux heures, et évidemment, ça a pris un peu plus.

Le point de départ

L’idée initiale, c’était de scraper Liquipedia, sortir un bar chart, on n’en parle plus. J’ai vite changé d’avis en tombant sur PandaScore, qui est une API e-sport plutôt bien faite, notamment pour qui veut jouer avec des données structurées sans se retrouver à parser du HTML instable. 1000 requêtes par heure sur le plan gratuit, des endpoints propres pour les joueurs, les équipes, les tournois. C’est largement suffisant pour ce genre d’exercice.

J’ai retenu six jeux, c’est-à-dire League of Legends, Counter-Strike, Dota 2, Valorant, Rainbow 6 Siege et Overwatch. L’idée étant de ne regarder que le tier S, donc l’élite (LCK, LEC, VCT Masters, BLAST Major, The International, ce genre de choses), et uniquement les joueurs actifs. Pas de semi-pro, pas de joueurs retirés qui traînent encore dans la base. C’est un portrait de 2026, pas un recensement.

Les premières embûches

Les premières heures ont été une petite traversée de pièges classiques, c’est-à-dire ce genre de trucs qui te font douter de l’API avant que tu réalises que tu t’étais juste trompu toi-même.

  • Le champ birthday qui semblait absent des réponses, alors qu’en fait PandaScore omet simplement les champs null. Faker a bien sa date de naissance, un joueur de tier 3 anonyme ne l’a pas, et c’est normal. J’ai perdu une heure avant de comprendre ça.
  • Les slugs de l’API qui ne sont pas ceux des jeux. Dans la liste, c’est cs-go, mais dans l’URL il faut écrire /csgo/. Pareil pour dota-2 qui devient /dota2/. C’est un petit détail, mais ça coûte trois 404 avant de tilter.
  • Pas de champ tier sur les ligues, mais oui sur les tournois. Ce qui fait que le filtre utile, c’est filter[tier]=s appliqué aux tournois, pas aux ligues. Une fois qu’on a compris ça, ça déroule.
  • Le rate limit évidemment, qui tombe vite quand on explore en tâtonnant. J’ai tapé les 1000 calls en une session, ce qui a été l’occasion d’ajouter un cache disque JSON sur chaque requête. Résultat, les re-runs sont instantanés, et c’est clairement cinq minutes de code qui m’ont fait gagner beaucoup de temps après.

Le pipeline

L’architecture finale tient en trois scripts Python, et l’idée étant de pouvoir les enchaîner sans se poser de questions. Le premier, etl.py, interroge PandaScore et sort quatre CSV. Il y a players_top.csv pour les joueurs actifs uniques, tournaments.csv pour les métadonnées des tournois (avec le vainqueur), participants.csv pour le lien tournoi-équipe, et surtout rosters.csv, qui est la vraie clé de voûte du projet parce qu’il contient les rosters historiques, donc qui a joué quoi quand. J’y reviens plus bas, c’est ce fichier qui a permis de corriger l’erreur méthodo dont je parle après.

Le deuxième, viz.py, transforme les CSV en un rapport HTML éditorial. C’est là que j’ai essayé de sortir un peu du côté « dashboard utilitaire » pour aller vers quelque chose de plus lisible. Typographie sérif pour les titres (Cormorant Garamond), sans-serif pour le corps (Inter), une palette crème et terracotta, des captions en italique. Plotly thémé pour que les charts soient cohérents avec le reste. Onze charts au total, avec une table DataTables triable, et des insights auto-générés en tête de rapport (genre « Dota 2 est la scène la plus mature, 2.5 ans de plus qu’Overwatch »).

Le troisième, pdf.py, utilise Playwright pour charger le HTML dans un Chromium headless, attendre la fin du rendu Plotly, appliquer le CSS @media print, et sortir un PDF de 15 pages. Ce n’est pas la partie la plus triviale, j’y reviens aussi.

Le piege méthodo à ne pas laisser passer

C’est la partie dont je suis le plus content d’avoir vu passer, parce que franchement, elle aurait pu passer inaperçue. Dans la première version du rapport, il y avait un chart d’évolution de l’âge médian année par année, très propre visuellement. Sauf qu’il était linéaire par construction, c’est-à-dire que je prenais la cohorte actuelle et je reculais dans le temps en faisant année - date_de_naissance. Chaque joueur vieillissant d’exactement un an par an, la médiane bougeait mécaniquement d’un an. Zéro information réelle, mais évidemment, ça a l’air d’en dire beaucoup.

Ce qui m’a sauvé, c’est une question du « client » (moi-même en l’occurrence, en relecture à froid) qui m’a dit que ça avait l’air un peu trop beau. J’ai regardé à nouveau, et oui, c’était un artefact mathématique pur. Réflexe important, du coup, qu’on n’a pas toujours : se demander si un chart qu’on trouve parlant pourrait être construit autrement qu’en reflétant une vérité.

La correction est venue d’une petite découverte côté API. L’endpoint /tournaments/{id}/teams ne renvoie pas les rosters actuels des équipes qui ont joué, il renvoie les rosters au moment du tournoi, ce qui change tout. En sauvant une table rosters.csv avec une ligne par (tournoi, équipe, joueur), on peut calculer pour chaque année qui a vraiment joué et quel était son âge à ce moment-là. Chaque année a du coup sa propre cohorte, les nouveaux entrent, les anciens sortent, et le chart devient réellement informatif.

Résultat, Dota 2 a gagné environ 2 ans de médiane depuis 2020, R6 Siege en a pris 3, Counter-Strike est stable, Valorant aussi. C’est le genre de résultat qui correspond au ressenti de la communauté, ce qui est rassurant après un gros bug méthodo comme celui-là. Même si, évidemment, ça reste à prendre avec une certaine prudence : on ne couvre pas toutes les années à la même profondeur, notamment sur les très vieux tournois.

Ce que le rapport révèle

Quelques résultats que je trouve intéressants, même si je précise évidemment qu’il s’agit d’un snapshot ponctuel et pas d’une vérité absolue.

  • Dota 2 est la scène la plus mature, avec 26.8 ans de moyenne, et Overwatch la plus jeune, à 24.3 ans. 2.5 ans d’écart, c’est considérable à l’échelle d’une carrière pro, et ça recoupe notamment la durée de vie des jeux dans l’écosystème (Dota 2 tourne depuis 2013, Overwatch a redémarré plus récemment avec OW2).
  • La scène la plus internationale, au sens de l’indice de Shannon, est Counter-Strike (0.82). La plus homogène, c’est Overwatch (0.63), très dominée par la Corée et les États-Unis. Ce qui n’est pas surprenant pour qui suit un peu la scène, mais c’est toujours pas mal de l’avoir chiffré.
  • 62 % des joueurs de Counter-Strike tier S viennent de la région EMEA. C’est la concentration régionale record du dataset.
  • Sur League of Legends, les rosters vainqueurs sont en moyenne 0.4 an plus jeunes que la scène. L’écart est léger, mais mesurable. C’est pas de quoi faire une grande théorie, mais c’est un petit signal.
  • Le doyen du dataset c’est TaZ (39 ans, Counter-Strike), le benjamin c’est TaiLung (15 ans, Dota 2). Fourchette de 24 ans dans le même écosystème pro, voilà.

Les visualisations

Le rapport est structuré en cinq parties plus une annexe. La première partie sur l’âge des pros, avec un bar chart des moyennes, un ridgeline plot des distributions (plus éditorial qu’un violin plot classique, c’est un détail mais ça change la lecture), et une table des doyens et benjamins. La deuxième partie sur l’évolution des scènes, avec les cohortes de naissance empilées et la fameuse courbe temporelle corrigée.

La troisième partie est la plus dense, c’est la géographie. Une carte monde choroplèthe, un top 15 des pays représentés, les parts régionales par jeu (EMEA, Americas, Asia, Oceania), des small multiples pour le top 6 des pays par discipline, un Sankey des migrations (joueur né dans un pays, signé dans une équipe d’un autre pays), l’indice de diversité de Shannon, et des mini-cartes indépendantes par jeu. C’est peut-être un peu trop, mais c’était difficile de choisir.

La quatrième partie est sur la performance. Âge moyen des rosters vainqueurs versus l’ensemble, et un top 3 des écuries par discipline, normalisé par le nombre de tournois du jeu. Normaliser, c’est une évidence quand on y pense, mais au départ j’avais mis un top brut, ce qui faisait que Counter-Strike écrasait tout simplement parce qu’il y a trois fois plus de tournois que sur LoL. Pour le coup, c’est typiquement le genre de biais qu’on repère en regardant le chart et en se disant « attends, ça raconte pas l’histoire que je veux ».

La cinquième partie, c’est une tuile par jeu avec les chiffres-clés et la composition des rôles. Et en annexe, une petite comparaison avec le sport traditionnel : NBA 26.4, Premier League 27.1, NFL 26.6, ATP 27.3, esport 25.4. Les rosters e-sport d’élite sont bien plus jeunes que la plupart des sports pros, mais l’écart est moins spectaculaire qu’on pourrait le croire, notamment si on isole des scènes comme Dota 2 qui sont au niveau de la Premier League.

Le rendu PDF

Sortir un PDF propre depuis du Plotly, c’est pas du tout trivial, c’est quelque chose que je n’avais pas mesuré au départ. Les charts sont des SVG interactifs, dimensionnés au moment du premier rendu dans le navigateur. Du coup, si on passe simplement en print, la mise en page casse, parce que le chart garde la largeur d’origine (typiquement 1200px de viewport HTML) alors que la page A4 fait 794px.

La recette qui marche avec Playwright, pour le coup, c’est de charger le HTML, d’attendre que tous les .js-plotly-plot aient leur .main-svg, puis d’appeler page.emulate_media("print") pour activer le CSS print, ensuite de réduire le viewport à la largeur A4 (794px), et là, de forcer un Plotly.Plots.resize() sur chaque chart pour qu’il se recalcule. On attend une seconde et demie que la relayout se stabilise, puis page.pdf(). C’est un peu bricolé, mais ça donne un résultat propre.

Côté CSS, le print a ses propres règles : empiler les deux-colonnes en une seule (les charts prennent toute la largeur), masquer la chrome DataTables qui n’a aucun sens hors ligne, imposer des sauts de page propres entre les grandes parties, réduire les hauteurs pour qu’un chart tienne avec sa légende et sa note méthodologique sur une seule page. Ce qui fait beaucoup de petits ajustements avant d’obtenir un PDF qu’on a envie d’ouvrir jusqu’au bout.

Ce qui reste pour la V2

Deux pistes demandent plus de temps de calcul API et partiront en V2. D’abord la longévité de carrière, qui nécessite d’interroger /players/{id}/tournaments pour chaque joueur. On parle de 2600 requêtes, soit environ trois heures de fetch en respectant le rate limit. L’hypothèse serait que les carrières sont très courtes sur les FPS, et bien plus longues sur les MOBA. Ce serait intéressant à chiffrer.

Ensuite la rotation des rosters, c’est-à-dire le pourcentage de joueurs qui changent d’équipe d’une année sur l’autre. C’est une métrique souvent commentée par la communauté, notamment autour des transferts d’intersaison, mais assez rarement chiffrée proprement. Ça devrait être faisable sans trop de calls supplémentaires, vu qu’on a déjà le rosters.csv.

La stack technique

Rien de très exotique pour le coup. Python 3.13 avec requests, pandas, numpy, pycountry, python-dotenv. Plotly pour tous les charts, avec un thème simple_white personnalisé. DataTables.js pour la table interactive (c’est léger et ça fait le boulot). Playwright plus Chromium headless pour le rendu PDF fidèle. Et PandaScore comme source unique de données.

Ce que ce projet m’a appris

Trois trucs m’ont marqué, plus ou moins liés au sujet même.

Le premier, c’est qu’un chart qui paraît clean peut cacher un biais méthodologique majeur. Le réflexe de se demander « est-ce que cette ligne pourrait être construite autrement qu’en reflétant une vérité ? », c’est quelque chose qui vaut son pesant d’or. Mon chart linéaire aurait pu passer pour un insight, alors que c’était un pur artefact. Du coup, je m’efforce de plus en plus de faire ce genre de relecture à froid, idealement le lendemain, parce qu’à chaud on est un peu trop fier de son résultat pour le critiquer honnêtement.

Le deuxième, c’est la différence entre un dashboard utilitaire et un rapport éditorial. Ce qui fait que ça tient à très peu de choses en fait : une typo sérif pour les titres, une palette muted, des captions en italique, des sauts de page soignés. Mais ça change la perception du lecteur du tout au tout. C’est un détail de forme qui déplace la catégorie mentale dans laquelle le document est rangé.

Le troisième, c’est plus bateau mais je le note quand même : il y a toujours un rate limit quelque part. Ajouter un cache disque dès le début de l’exploration, c’est cinq minutes de code qui en font gagner cinquante. Je le sais, j’essaie de le faire, et j’oublie quand même une fois sur deux.

Le rapport éditorial complet (15 pages) esport-stats-report.pdf · 2.8 MB

Projet bouclé en une soirée et quelques heures en plus. Avec l’API payante de PandaScore on ouvrirait d’autres horizons (plus d’années d’historique, plus de calls pour la longévité), mais même en gratuit, c’est une base solide pour qui veut jouer avec les données e-sport. Et honnêtement, si vous cherchez une idée de projet data à la fois riche, faisable, et suffisamment originale pour tenir dans un CV, c’est typiquement le genre de sujet que je recommanderais. Voilà.