← Preuves Lecture HTML · sans Jupyter .ipynb brut

Notebook de preuve · lecture directe

21 · Les têtes du Big 5, notebook de preuve

Vue générée depuis 21-tetes-big5.ipynb : Markdown, code et sorties. Le fichier brut et la sortie JSON restent téléchargeables.

Résultats vérifiés

exécuté
16 assertions1 non vérifié2026-11-10 17:32

recalcul · agrégats (volume, distance, cadre, livraisons, joueurs)

  1. 131 531 têtes retenues, 486 CSC exclus, sur 17 saisons et cinq ligues
  2. 13 921 buts : 10,6 % de réussite et 17,0 % des 81 860 buts du Big 5
  3. la tête pèse 16,8 % des tirs ; 43,1 % cadrées contre 62,4 % pour le pied
  4. 10,6 % contre 10,4 % : la tête convertit comme un tir du pied
  5. une tête cadrée finit au fond à 24,5 %, un tir cadré du pied à 16,7 %
  6. 4,29 têtes par match en moyenne ; 4,32 en 2009/10, 4,52 en 2025/26
  7. la réussite recule de 11,4 % à 9,5 % entre 2009/10 et 2025/26
  8. distance médiane 8,3 m ; 50,2 % des têtes à 8,3 m ou moins
  9. 21,6 % de réussite à 5,5 m ou moins, 1,9 % au-delà de 16,5 m
  10. 58,2 % des têtes cadrées visent l'axe ; 16,1 % dans l'axe contre 41,0 % sur les côtés
  11. corners : 34,8 % des têtes assistées, 9,8 % de réussite contre 10,0 % pour les centres (p = 0,26)
  12. 124 931 têtes assistées et 7 258 identifiants de passeur ; Payet 50 buts, Kroos 47, Çalhanoğlu 39
  13. Fuchs 16,1 % sur 161 têtes ; Kane 21,0 % sur 267 têtes
  14. buteurs : Ronaldo 67, Lewandowski 62, Kane 56 buts de la tête
  15. clubs : Real Madrid 232, Bayern 230, Inter 203 ; Sochaux 16,0 % sur 357 têtes
  16. championnats : 10,2–11,0 % (p = 0,10) ; Serie A de 12,4 % à 8,8 %
Chiffres déclarés non vérifiés
  • la ligne « Pied » de la comparaison tête / pied est un agrégat de contrôle, non recalculé ligne à ligne dans ce notebook
Valeurs calculées (JSON)
{
  "tetes": 131531,
  "buts": 13921,
  "conversion": 10.583816742821083,
  "tirs": 783173,
  "tirs_archive": 783173,
  "buts_big5": 81860,
  "part_tirs": 16.794629028324522,
  "part_buts": 17.00586366967994,
  "csc": 486,
  "matchs": 30692,
  "saisons": 17,
  "ligues": 5,
  "cadrage": 43.1449620241616,
  "conversion_cadrees": 24.530828737070255,
  "tete_pied": {
    "Tete": {
      "tirs": 131531,
      "buts": 13921,
      "cadres": 56749,
      "conversion": 10.583816742821083,
      "cadrage": 43.1449620241616,
      "conversion_cadres": 24.530828737070255
    },
    "Pied": {
      "tirs": 648310,
      "buts": 67495,
      "cadres": 404335,
      "conversion": 10.410914531628388,
      "cadrage": 62.3675402199565,
      "conversion_cadres": 16.692841332063757
    }
  },
  "repartition_buts": {
    "tete": 13921,
    "autres": 67939
  },
  "saison": [
    {
      "season": "2009-2010",
      "matchs": 1826,
      "tetes": 7881,
      "buts": 900,
      "tetes_par_match": 4.315991237677984,
      "conversion": 11.419870574800152,
      "cadrage": 43.09097830224591,
      "ci_low": 10.736381435533115,
      "ci_high": 12.140953221386305
    },
    {
      "season": "2010-2011",
      "matchs": 1826,
      "tetes": 7856,
      "buts": 860,
      "tetes_par_match": 4.302300109529025,
      "conversion": 10.947046843177189,
      "cadrage": 41.776985743380855,
      "ci_low": 10.275596132175247,
      "ci_high": 11.656672833652259
    },
    {
      "season": "2011-2012",
      "matchs": 1826,
      "tetes": 7666,
      "buts": 879,
      "tetes_par_match": 4.198247535596933,
      "conversion": 11.466214453430734,
      "cadrage": 43.490738325071746,
      "ci_low": 10.772191537720092,
      "ci_high": 12.198838268162728
    },
    {
      "season": "2012-2013",
      "matchs": 1825,
      "tetes": 7510,
      "buts": 900,
      "tetes_par_match": 4.115068493150685,
      "conversion": 11.984021304926765,
      "cadrage": 43.28894806924101,
      "ci_low": 11.268843788938224,
      "ci_high": 12.738071661608409
    },
    {
      "season": "2013-2014",
      "matchs": 1826,
      "tetes": 7483,
      "buts": 820,
      "tetes_par_match": 4.09802847754655,
      "conversion": 10.958171856207404,
      "cadrage": 41.14659895763731,
      "ci_low": 10.270345638081967,
      "ci_high": 11.686063857746019
    },
    {
      "season": "2014-2015",
      "matchs": 1826,
      "tetes": 7291,
      "buts": 825,
      "tetes_par_match": 3.992880613362541,
      "conversion": 11.315320257852147,
      "cadrage": 43.10794129749006,
      "ci_low": 10.608454721018411,
      "ci_high": 12.062929946196293
    },
    {
      "season": "2015-2016",
      "matchs": 1826,
      "tetes": 7232,
      "buts": 790,
      "tetes_par_match": 3.960569550930997,
      "conversion": 10.923672566371682,
      "cadrage": 42.629977876106196,
      "ci_low": 10.225370698763017,
      "ci_high": 11.66346666882115
    },
    {
      "season": "2016-2017",
      "matchs": 1825,
      "tetes": 7708,
      "buts": 860,
      "tetes_par_match": 4.223561643835616,
      "conversion": 11.157239231966788,
      "cadrage": 42.50129735339907,
      "ci_low": 10.473627856714634,
      "ci_high": 11.879549106878603
    },
    {
      "season": "2017-2018",
      "matchs": 1826,
      "tetes": 7621,
      "buts": 793,
      "tetes_par_match": 4.173603504928806,
      "conversion": 10.405458601233434,
      "cadrage": 41.76617241831781,
      "ci_low": 9.739767711294524,
      "ci_high": 11.111047078090737
    },
    {
      "season": "2018-2019",
      "matchs": 1826,
      "tetes": 8308,
      "buts": 837,
      "tetes_par_match": 4.549835706462212,
      "conversion": 10.074626865671641,
      "cadrage": 42.368801155512756,
      "ci_low": 9.445729117233734,
      "ci_high": 10.740430349520302
    },
    {
      "season": "2019-2020",
      "matchs": 1724,
      "tetes": 7408,
      "buts": 722,
      "tetes_par_match": 4.296983758700696,
      "conversion": 9.74622030237581,
      "cadrage": 42.1841252699784,
      "ci_low": 9.09154410660826,
      "ci_high": 10.442624027799795
    },
    {
      "season": "2020-2021",
      "matchs": 1826,
      "tetes": 7722,
      "buts": 816,
      "tetes_par_match": 4.228915662650603,
      "conversion": 10.567210567210568,
      "cadrage": 42.76094276094276,
      "ci_low": 9.901031021605554,
      "ci_high": 11.272605259003669
    },
    {
      "season": "2021-2022",
      "matchs": 1811,
      "tetes": 7894,
      "buts": 819,
      "tetes_par_match": 4.3589177250138045,
      "conversion": 10.374968330377502,
      "cadrage": 42.77932607043324,
      "ci_low": 9.721438972892393,
      "ci_high": 11.067045820218674
    },
    {
      "season": "2022-2023",
      "matchs": 1818,
      "tetes": 8083,
      "buts": 744,
      "tetes_par_match": 4.446094609460946,
      "conversion": 9.20450327848571,
      "cadrage": 44.575034022021526,
      "ci_low": 8.593500242605801,
      "ci_high": 9.854265569945788
    },
    {
      "season": "2023-2024",
      "matchs": 1751,
      "tetes": 8157,
      "buts": 849,
      "tetes_par_match": 4.658480868075386,
      "conversion": 10.408238322912835,
      "cadrage": 45.53144538433248,
      "ci_low": 9.764074802141794,
      "ci_high": 11.089676359993247
    },
    {
      "season": "2024-2025",
      "matchs": 1752,
      "tetes": 7794,
      "buts": 752,
      "tetes_par_match": 4.448630136986301,
      "conversion": 9.648447523736207,
      "cadrage": 45.62483962022068,
      "ci_low": 9.012687174296811,
      "ci_high": 10.323966188859474
    },
    {
      "season": "2025-2026",
      "matchs": 1752,
      "tetes": 7917,
      "buts": 755,
      "tetes_par_match": 4.5188356164383565,
      "conversion": 9.536440570923329,
      "cadrage": 44.52444107616522,
      "ci_low": 8.908922469041075,
      "ci_high": 10.203208241917716
    }
  ],
  "distance": {
    "mediane": 8.299999999999997,
    "part_mediane": 50.24442906995309,
    "bandes": [
      {
        "label": "≤ 5,5 m",
        "n": 23896,
        "part": 18.167580266249022,
        "buts": 5166,
        "conversion": 21.618680950786743
      },
      {
        "label": "5,5–8,3 m",
        "n": 42191,
        "part": 32.07684880370407,
        "buts": 5238,
        "conversion": 12.414970017302268
      },
      {
        "label": "8,3–16,5 m",
        "n": 64851,
        "part": 49.30472664238849,
        "buts": 3506,
        "conversion": 5.406238916901821
      },
      {
        "label": "> 16,5 m",
        "n": 593,
        "part": 0.4508442876584227,
        "buts": 11,
        "conversion": 1.854974704890388
      }
    ]
  },
  "cadre": {
    "n": 52542,
    "axe_part": 58.178219329298464,
    "axe_conversion": 16.101805809997384,
    "cote_conversion": 40.952944388823155,
    "grille": [
      {
        "row": "Haut",
        "col": "Droite",
        "n": 2962,
        "part": 5.63739484602794,
        "conversion": 37.677245104659015
      },
      {
        "row": "Haut",
        "col": "Axe",
        "n": 8675,
        "part": 16.510601042975143,
        "conversion": 18.29394812680115
      },
      {
        "row": "Haut",
        "col": "Gauche",
        "n": 2957,
        "part": 5.627878649461383,
        "conversion": 36.08386878593169
      },
      {
        "row": "Mi-hauteur",
        "col": "Droite",
        "n": 4947,
        "part": 9.415324882950783,
        "conversion": 37.82090155649889
      },
      {
        "row": "Mi-hauteur",
        "col": "Axe",
        "n": 16397,
        "part": 31.20741502036466,
        "conversion": 12.538879063243277
      },
      {
        "row": "Mi-hauteur",
        "col": "Gauche",
        "n": 5110,
        "part": 9.725552891020516,
        "conversion": 40.07827788649706
      },
      {
        "row": "Bas",
        "col": "Droite",
        "n": 2983,
        "part": 5.6773628716074755,
        "conversion": 47.23432785786122
      },
      {
        "row": "Bas",
        "col": "Axe",
        "n": 5496,
        "part": 10.460203265958661,
        "conversion": 23.271470160116447
      },
      {
        "row": "Bas",
        "col": "Gauche",
        "n": 3015,
        "part": 5.738266529633436,
        "conversion": 49.353233830845774
      }
    ]
  },
  "livraisons": [
    {
      "livraison": "Open-play cross",
      "tetes": 51531,
      "buts": 5152,
      "part": 41.24756865789916,
      "conversion": 9.997865362597272
    },
    {
      "livraison": "Corner",
      "tetes": 43436,
      "buts": 4247,
      "part": 34.76799193154621,
      "conversion": 9.777603830923658
    },
    {
      "livraison": "Free kick",
      "tetes": 17560,
      "buts": 1986,
      "part": 14.055758778845924,
      "conversion": 11.309794988610479
    },
    {
      "livraison": "Chipped pass",
      "tetes": 4996,
      "buts": 515,
      "part": 3.999007452113567,
      "conversion": 10.308246597277822
    },
    {
      "livraison": "Other pass",
      "tetes": 3324,
      "buts": 333,
      "part": 2.660668689116392,
      "conversion": 10.018050541516246
    },
    {
      "livraison": "Non-pass",
      "tetes": 1609,
      "buts": 309,
      "part": 1.2879109268316111,
      "conversion": 19.20447482908639
    },
    {
      "livraison": "Long ball",
      "tetes": 1348,
      "buts": 67,
      "part": 1.078995605574277,
      "conversion": 4.970326409495549
    },
    {
      "livraison": "Throw-in",
      "tetes": 640,
      "buts": 98,
      "part": 0.5122827800946123,
      "conversion": 15.3125
    },
    {
      "livraison": "Through ball",
      "tetes": 487,
      "buts": 104,
      "part": 0.389815177978244,
      "conversion": 21.35523613963039
    }
  ],
  "championnats": [
    {
      "league": "Bundesliga",
      "tetes": 24257,
      "buts": 2657,
      "conversion": 10.95353918456528
    },
    {
      "league": "La Liga",
      "tetes": 27515,
      "buts": 2916,
      "conversion": 10.59785571506451
    },
    {
      "league": "Ligue 1",
      "tetes": 24650,
      "buts": 2585,
      "conversion": 10.486815415821502
    },
    {
      "league": "Premier League",
      "tetes": 28319,
      "buts": 3024,
      "conversion": 10.67834316183481
    },
    {
      "league": "Serie A",
      "tetes": 26790,
      "buts": 2739,
      "conversion": 10.223964165733483
    }
  ],
  "p_ligues": 0.1037127670306439,
  "p_corner_cross": 0.2574796336683638,
  "pourvoyeurs": {
    "tetes_assistees": 124931,
    "passeurs": 7258,
    "top": [
      {
        "joueur": "Dimitri Payet",
        "tetes": 467,
        "buts": 50,
        "conversion": 10.70663811563169
      },
      {
        "joueur": "Toni Kroos",
        "tetes": 404,
        "buts": 47,
        "conversion": 11.633663366336634
      },
      {
        "joueur": "Hakan Çalhanoglu",
        "tetes": 395,
        "buts": 39,
        "conversion": 9.873417721518987
      },
      {
        "joueur": "Joshua Kimmich",
        "tetes": 314,
        "buts": 36,
        "conversion": 11.464968152866241
      },
      {
        "joueur": "Mesut Özil",
        "tetes": 274,
        "buts": 36,
        "conversion": 13.138686131386862
      },
      {
        "joueur": "Dani Parejo",
        "tetes": 312,
        "buts": 35,
        "conversion": 11.217948717948717
      },
      {
        "joueur": "Ángel Di María",
        "tetes": 292,
        "buts": 35,
        "conversion": 11.986301369863014
      },
      {
        "joueur": "Antonio Candreva",
        "tetes": 312,
        "buts": 34,
        "conversion": 10.897435897435898
      },
      {
        "joueur": "Kevin De Bruyne",
        "tetes": 302,
        "buts": 33,
        "conversion": 10.927152317880795
      },
      {
        "joueur": "Ivan Rakitic",
        "tetes": 259,
        "buts": 33,
        "conversion": 12.741312741312742
      }
    ]
  },
  "buteurs": {
    "top": [
      {
        "joueur": "Cristiano Ronaldo",
        "tetes": 372,
        "buts": 67,
        "conversion": 18.010752688172044
      },
      {
        "joueur": "Robert Lewandowski",
        "tetes": 469,
        "buts": 62,
        "conversion": 13.219616204690832
      },
      {
        "joueur": "Harry Kane",
        "tetes": 267,
        "buts": 56,
        "conversion": 20.973782771535582
      },
      {
        "joueur": "Olivier Giroud",
        "tetes": 367,
        "buts": 50,
        "conversion": 13.623978201634877
      },
      {
        "joueur": "Fernando Llorente",
        "tetes": 319,
        "buts": 47,
        "conversion": 14.733542319749215
      },
      {
        "joueur": "Edinson Cavani",
        "tetes": 250,
        "buts": 42,
        "conversion": 16.8
      },
      {
        "joueur": "Mario Mandzukic",
        "tetes": 215,
        "buts": 39,
        "conversion": 18.13953488372093
      },
      {
        "joueur": "Aritz Aduriz",
        "tetes": 260,
        "buts": 38,
        "conversion": 14.615384615384615
      },
      {
        "joueur": "Edin Dzeko",
        "tetes": 424,
        "buts": 37,
        "conversion": 8.726415094339623
      },
      {
        "joueur": "Duván Zapata",
        "tetes": 264,
        "buts": 35,
        "conversion": 13.257575757575758
      }
    ]
  },
  "clubs": {
    "top": [
      {
        "club": "Real Madrid",
        "tetes": 1657,
        "buts": 232,
        "conversion": 14.0012070006035
      },
      {
        "club": "Bayern",
        "tetes": 1719,
        "buts": 230,
        "conversion": 13.379872018615474
      },
      {
        "club": "Inter",
        "tetes": 1788,
        "buts": 203,
        "conversion": 11.353467561521253
      },
      {
        "club": "Liverpool",
        "tetes": 1558,
        "buts": 198,
        "conversion": 12.70860077021823
      },
      {
        "club": "Arsenal",
        "tetes": 1395,
        "buts": 186,
        "conversion": 13.333333333333334
      }
    ]
  }
}

Graphiques générés

6 figures
Part des têtes cadrées et réussite dans les neuf zones du but.
Où vont les têtes cadrées
Part des buts du Big 5 marqués de la tête.
17,0 % des buts du Big 5
Conversion, cadrage et conversion des tirs cadrés, tête contre pied.
La tête contre le pied
Taux de réussite des têtes par tranche de distance au but.
La réussite chute avec la distance
Buts inscrits sur les têtes délivrées par les dix meilleurs passeurs.
Les meilleurs pourvoyeurs
Têtes par match et taux de réussite par saison, avec intervalles de confiance à 95 %.
Le volume tient, la réussite recule

Statut : exécuté. preuves/data/21-headers.csv.gz est l'export des 131 531 têtes retenues dans header_analysis : 2009/10–2025/26, cinq championnats, une ligne par tir de la tête. Trois tables de correspondance rendent les noms (21-joueurs.csv, 21-passeurs.csv, 21-equipes.csv), 21-matches.csv donne les matchs par saison et par ligue, et les deux petits fichiers 21-perimetre.csv / 21-tete-pied.csv gardent les exclusions et la comparaison avec le pied.

Niveau de preuve. Recalcul des agrégats publiés depuis les lignes : volume, conversion, cadrage, distance, destination dans le cadre, livraisons, championnats, buteurs, pourvoyeurs et clubs. Aucun chiffre de la page n'est saisi dans le notebook : les 16 assertions portent sur les valeurs recalculées, à la réserve près de la ligne « Pied » de la comparaison, déclarée plus bas. Les noms sont joints par identifiant, jamais tapés dans les classements.

Limites. La destination dans le cadre n'est renseignée que pour les tirs cadrés et une partie des tirs non cadrés : 52 542 têtes sont localisables. Les classements de joueurs imposent un seuil (100 têtes pour les buteurs, 150 pour le rendement des pourvoyeurs) et ne disent rien des têtes non tirées. La ligne « Pied » inclut les penalties et les CSC, comme dans le rapport source : elle mesure la rentabilité brute du geste, pas un périmètre de jeu ouvert identique. La saison 2019/20 est tronquée en Ligue 1 ; la taille et la hauteur de ballon sont absentes de l'archive.

Code · 46 lignes
import datetime
import hashlib
import json
import platform
from pathlib import Path

import numpy as np
import pandas as pd
from scipy.stats import chi2_contingency, linregress, norm

RACINE = Path("preuves/data")
SORTIE = Path("preuves/out")
ENTREES = {
    "21-headers.csv.gz": [
        "season", "league", "team_id", "player_id", "dist", "lat", "situation",
        "delivery", "outcome_raw", "assist_player_id", "is_goal", "is_on_target",
        "goal_mouth_y", "goal_mouth_z", "assist_valid",
    ],
    "21-tete-pied.csv": ["partie", "tirs", "buts", "cadres", "buts_cadres"],
    "21-perimetre.csv": ["cle", "valeur"],
    "21-joueurs.csv": ["player_id", "player", "headers", "goals"],
    "21-passeurs.csv": ["assist_player_id", "assist_player", "headers", "goals"],
    "21-equipes.csv": ["team_id", "team"],
    "21-matches.csv": ["season", "league", "matches"],
}
manquants = [nom for nom in ENTREES if not (RACINE / nom).exists()]
if manquants:
    raise FileNotFoundError(
        "Corpus incomplet, rien n'est calculé. Fichiers attendus dans preuves/data/ : "
        + ", ".join(manquants) + ". Ils sont produits depuis header_analysis/data/.")

for nom, colonnes in ENTREES.items():
    table = pd.read_csv(RACINE / nom, sep=";", nrows=0)
    assert list(table.columns) == colonnes, (nom, list(table.columns))

h = pd.read_csv(
    RACINE / "21-headers.csv.gz", sep=";",
    dtype={"team_id": "int64", "player_id": "int64", "assist_player_id": "Int64"})
tete_pied = pd.read_csv(RACINE / "21-tete-pied.csv", sep=";")
perimetre = pd.read_csv(RACINE / "21-perimetre.csv", sep=";").set_index("cle")["valeur"]
joueurs = pd.read_csv(RACINE / "21-joueurs.csv", sep=";", dtype={"player_id": "int64"})
passeurs = pd.read_csv(RACINE / "21-passeurs.csv", sep=";", dtype={"assist_player_id": "Int64"})
equipes = pd.read_csv(RACINE / "21-equipes.csv", sep=";", dtype={"team_id": "int64"})
matchs = pd.read_csv(RACINE / "21-matches.csv", sep=";")

print(f"{len(h):,} têtes · {h.season.nunique()} saisons · {h.league.nunique()} ligues".replace(",", " "))

Contrôles d'intégrité

L'archive brute contient 783 173 tirs. Après retrait de 2026/27, 132 017 tirs de la tête subsistent, dont 486 contre son camp : ce sont des dégagements défensifs, pas des tirs. Le périmètre retenu est donc de 131 531 têtes, sur 17 saisons et cinq ligues, pour 13 921 buts. Les 85 lignes saison × ligue totalisent 30 692 matchs.

Les identifiants de joueurs et d'équipes sont joints à leurs noms par les tables de correspondance ; aucune liste de noms n'est écrite à la main dans le notebook.

Code · 18 lignes
assert int(perimetre["tirs_archive"]) == 783173
assert int(perimetre["tirs_2009_2026"]) == 783173
assert int(perimetre["tetes_csc"]) == 486
assert int(perimetre["tetes_brutes"]) - int(perimetre["tetes_csc"]) == 131531
assert int(perimetre["tetes_retenues"]) == 131531
assert int(perimetre["buts_big5"]) == 81860
assert int(perimetre["matchs"]) == 30692

assert len(h) == 131531
assert int(h.is_goal.sum()) == 13921
assert h.season.nunique() == 17 and h.league.nunique() == 5
assert h.season.min() == "2009-2010" and h.season.max() == "2025-2026"
assert "2026-2027" not in set(h.season)
assert len(matchs) == 85 and matchs.season.nunique() == 17 and matchs.league.nunique() == 5
assert int(matchs.matches.sum()) == 30692
assert int(h.assist_valid.sum()) == 124931
assert int(h.is_on_target.sum()) == int(tete_pied.loc[tete_pied.partie == "Tete", "cadres"].iloc[0])
print("périmètre vérifié : 131 531 têtes · 486 CSC exclus · 30 692 matchs")

Calcul

La distance au but vaut 100 − x, en unités Opta proches du mètre. Les tranches de distance reprennent les bornes de l'étude : 5,5 m, la médiane observée (8,3 m) et 16,5 m. Pour le cadre, le but occupe environ 44,6–55,4 en largeur et 0–35 en hauteur : les colonnes sont coupées à 47,5 et 52,5, les hauteurs à 10 et 22. Une tête est « cadrée » au sens Opta (is_on_target), et la destination n'est lue que sur les issues Goal, SavedShot et ShotOnPost.

Les classements de joueurs sont recalculés par identifiant, puis nominés. Les buteurs sont filtrés à 100 têtes minimum ; le rendement des pourvoyeurs est regardé à partir de 150 têtes. La comparaison tête / pied vient de 21-tete-pied.csv, un agrégat de contrôle dont la ligne « Tete » est elle-même revérifiée contre les 131 531 lignes.

Code · 138 lignes
def wilson(k, n, z=1.96):
    p = k / n
    den = 1 + z * z / n
    centre = (p + z * z / (2 * n)) / den
    demi = z * np.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / den
    return max(0.0, centre - demi), min(1.0, centre + demi)


n = len(h)
buts = int(h.is_goal.sum())
conversion = 100.0 * buts / n
cadrage = 100.0 * float(h.is_on_target.mean())
conversion_cadrees = 100.0 * float(h.loc[h.is_on_target, "is_goal"].mean())

tete_pied_idx = tete_pied.set_index("partie")
tete, pied = tete_pied_idx.loc["Tete"], tete_pied_idx.loc["Pied"]
assert int(tete["tirs"]) == n and int(tete["buts"]) == buts
conversion_pied = 100.0 * float(pied["buts"]) / float(pied["tirs"])
cadrage_pied = 100.0 * float(pied["cadres"]) / float(pied["tirs"])
conversion_cadrees_pied = 100.0 * float(pied["buts_cadres"]) / float(pied["cadres"])
part_tirs = 100.0 * n / int(perimetre["tirs_2009_2026"])
part_buts = 100.0 * buts / int(perimetre["buts_big5"])

matchs_saison = matchs.groupby("season").matches.sum()
saison = (
    h.groupby("season")
    .agg(tetes=("is_goal", "size"), buts=("is_goal", "sum"), cadres=("is_on_target", "sum"))
    .reset_index()
)
saison["matchs"] = saison.season.map(matchs_saison)
saison["season_start"] = saison.season.str[:4].astype(int)
saison["label"] = saison.season_start.astype(str).str[-2:] + "/" + (saison.season_start + 1).astype(str).str[-2:]
saison["tetes_par_match"] = saison.tetes / saison.matchs
saison["conversion"] = 100.0 * saison.buts / saison.tetes
saison["cadrage"] = 100.0 * saison.cadres / saison.tetes
saison["ci_low"], saison["ci_high"] = zip(
    *[wilson(int(b), int(t)) for b, t in zip(saison.buts, saison.tetes)])
saison["ci_low"] *= 100.0
saison["ci_high"] *= 100.0

masques = [
    h.dist <= 5.5,
    (h.dist > 5.5) & (h.dist <= 8.3),
    (h.dist > 8.3) & (h.dist <= 16.5),
    h.dist > 16.5,
]
libelles = ["≤ 5,5 m", "5,5–8,3 m", "8,3–16,5 m", "> 16,5 m"]
distance = []
for masque, libelle in zip(masques, libelles):
    part = h[masque]
    distance.append({
        "label": libelle, "n": int(len(part)), "part": 100.0 * len(part) / n,
        "buts": int(part.is_goal.sum()), "conversion": 100.0 * float(part.is_goal.mean()),
    })
assert sum(d["n"] for d in distance) == n
mediane = float(h.dist.median())
part_mediane = 100.0 * float((h.dist <= mediane).mean())

cadre = h[h.outcome_raw.isin(["Goal", "SavedShot", "ShotOnPost"])].dropna(
    subset=["goal_mouth_y", "goal_mouth_z"]).copy()
cadre["col"] = np.select(
    [cadre.goal_mouth_y < 47.5, cadre.goal_mouth_y > 52.5], ["Droite", "Gauche"], default="Axe")
cadre["row"] = np.select(
    [cadre.goal_mouth_z < 10, cadre.goal_mouth_z > 22], ["Bas", "Haut"], default="Mi-hauteur")
grille = []
for row in ["Haut", "Mi-hauteur", "Bas"]:
    for col in ["Droite", "Axe", "Gauche"]:
        cellule = cadre[(cadre.row == row) & (cadre.col == col)]
        grille.append({
            "row": row, "col": col, "n": int(len(cellule)),
            "part": 100.0 * len(cellule) / len(cadre),
            "conversion": 100.0 * float(cellule.is_goal.mean()),
        })
axe_part = 100.0 * float((cadre.col == "Axe").mean())
axe_conv = 100.0 * float(cadre.loc[cadre.col == "Axe", "is_goal"].mean())
cote_conv = 100.0 * float(cadre.loc[cadre.col != "Axe", "is_goal"].mean())

assistes = h[h.assist_valid].copy()
livraisons = (
    assistes.groupby("delivery")
    .agg(tetes=("is_goal", "size"), buts=("is_goal", "sum"))
    .reset_index()
)
livraisons["part"] = 100.0 * livraisons.tetes / len(assistes)
livraisons["conversion"] = 100.0 * livraisons.buts / livraisons.tetes
livraisons = livraisons.sort_values("tetes", ascending=False).reset_index(drop=True)
corner = livraisons.set_index("delivery").loc["Corner"]
cross = livraisons.set_index("delivery").loc["Open-play cross"]
p1, p2 = corner.conversion / 100.0, cross.conversion / 100.0
p_pool = (corner.buts + cross.buts) / (corner.tetes + cross.tetes)
se = np.sqrt(p_pool * (1 - p_pool) * (1 / corner.tetes + 1 / cross.tetes))
p_corner_cross = float(2 * (1 - norm.cdf(abs((p1 - p2) / se))))

championnats = (
    h.groupby("league").agg(tetes=("is_goal", "size"), buts=("is_goal", "sum")).reset_index())
championnats["conversion"] = 100.0 * championnats.buts / championnats.tetes
p_ligues = float(chi2_contingency(pd.crosstab(h.league, h.is_goal), correction=False)[1])
serie = h[h.league == "Serie A"].groupby("season").agg(tetes=("is_goal", "size"), buts=("is_goal", "sum"))
serie["conversion"] = 100.0 * serie.buts / serie.tetes
serie_first, serie_last = float(serie.conversion.iloc[0]), float(serie.conversion.iloc[-1])

p = (
    assistes.groupby("assist_player_id")
    .agg(tetes=("is_goal", "size"), buts=("is_goal", "sum")).reset_index()
)
p = p.merge(passeurs[["assist_player_id", "assist_player"]], on="assist_player_id", how="left")
assert p.assist_player.notna().all()
p["conversion"] = 100.0 * p.buts / p.tetes
passeurs_n = int(p.assist_player_id.nunique())
verif_p = passeurs.set_index("assist_player_id").join(p.set_index("assist_player_id")[["tetes", "buts"]])
assert (verif_p.headers == verif_p.tetes).all() and (verif_p.goals == verif_p.buts).all()
top_pourvoyeurs = p.sort_values(["buts", "tetes"], ascending=False).head(10).reset_index(drop=True)
rendement = p[p.tetes >= 150].sort_values(["conversion", "tetes"], ascending=False).reset_index(drop=True)
assert rendement.iloc[0].assist_player == "Christian Fuchs"
fuchs = p[p.assist_player == "Christian Fuchs"].iloc[0]

b = h.groupby("player_id").agg(tetes=("is_goal", "size"), buts=("is_goal", "sum")).reset_index()
b = b.merge(joueurs[["player_id", "player"]], on="player_id", how="left")
assert b.player.notna().all()
verif_j = joueurs.set_index("player_id").join(b.set_index("player_id")[["tetes", "buts"]])
assert (verif_j.headers == verif_j.tetes).all() and (verif_j.goals == verif_j.buts).all()
b["conversion"] = 100.0 * b.buts / b.tetes
buteurs = b[b.tetes >= 100].sort_values(["buts", "tetes"], ascending=False).head(10).reset_index(drop=True)
kane = b[b.player == "Harry Kane"].iloc[0]

c = h.groupby("team_id").agg(tetes=("is_goal", "size"), buts=("is_goal", "sum")).reset_index()
c = c.merge(equipes, on="team_id", how="left")
assert c.team.notna().all()
c["conversion"] = 100.0 * c.buts / c.tetes
top_clubs = c.sort_values(["buts", "tetes"], ascending=False).head(5).reset_index(drop=True)
sochaux = c[c.team == "Sochaux"].iloc[0]

slope, intercept, _, p_conv, _ = linregress(saison.season_start, saison.conversion)
print(f"{n} têtes · {buts} buts · {conversion:.2f} % · {len(cadre)} têtes localisées")
print(f"saison : {saison.tetes_par_match.iloc[0]:.2f} -> {saison.tetes_par_match.iloc[-1]:.2f} têtes/match")
print(f"conversion : {saison.conversion.iloc[0]:.1f} % -> {saison.conversion.iloc[-1]:.1f} %")
print(f"pente {slope * 10:+.2f} pt / 10 ans · p = {p_conv:.2f}")
print(f"pourvoyeurs : {len(assistes)} têtes assistées · {passeurs_n} identifiants")

Ce qui est asserté

Seize chiffres publiés, arrondis comme dans le rapport. La comparaison tête / pied est la seule qui sorte des lignes de têtes : elle est portée par l'agrégat de contrôle, dont la ligne « Tete » est revérifiée plus haut. Les autres valeurs sont toutes recalculées depuis 21-headers.csv.gz.

Code · 33 lignes
def r1(x):
    return round(float(x) + 1e-9, 1)


def r2(x):
    return round(float(x) + 1e-9, 2)


assert n == 131531 and buts == 13921
assert r1(conversion) == 10.6
assert r1(part_buts) == 17.0 and r1(part_tirs) == 16.8
assert r1(cadrage) == 43.1 and r1(cadrage_pied) == 62.4
assert r1(conversion_pied) == 10.4
assert r1(conversion_cadrees) == 24.5 and r1(conversion_cadrees_pied) == 16.7
assert r2(saison.tetes.sum() / saison.matchs.sum()) == 4.29
assert r2(saison.tetes_par_match.iloc[0]) == 4.32 and r2(saison.tetes_par_match.iloc[-1]) == 4.52
assert r1(saison.conversion.iloc[0]) == 11.4 and r1(saison.conversion.iloc[-1]) == 9.5
assert r1(mediane) == 8.3 and r1(part_mediane) == 50.2
assert r1(distance[0]["conversion"]) == 21.6 and r1(distance[3]["conversion"]) == 1.9
assert r1(axe_part) == 58.2 and r1(axe_conv) == 16.1 and r1(cote_conv) == 41.0
assert r1(corner.part) == 34.8 and r1(corner.conversion) == 9.8 and r1(cross.conversion) == 10.0
assert round(p_corner_cross, 2) == 0.26
assert len(assistes) == 124931 and passeurs_n == 7258
assert tuple(int(v) for v in top_pourvoyeurs.buts.head(3)) == (50, 47, 39)
assert r1(fuchs.conversion) == 16.1 and int(fuchs.tetes) == 161
assert r1(kane.conversion) == 21.0 and int(kane.tetes) == 267
assert tuple(int(v) for v in buteurs.buts.head(3)) == (67, 62, 56)
assert tuple(int(v) for v in top_clubs.buts.head(3)) == (232, 230, 203)
assert r1(sochaux.conversion) == 16.0 and int(sochaux.tetes) == 357
assert r1(championnats.conversion.min()) == 10.2 and r1(championnats.conversion.max()) == 11.0
assert round(p_ligues, 2) == 0.10
assert r1(serie_first) == 12.4 and r1(serie_last) == 8.8
print("OK : 16 chiffres publiés retrouvés")

Graphiques

Quatre figures sont générées depuis les 131 531 têtes : volume et réussite par saison, réussite par tranche de distance, destination dans le cadre et meilleurs pourvoyeurs. Elles sont écrites dans preuves/out/graphiques/ et reprises automatiquement par la page HTML.

Code · 142 lignes
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap, Normalize
from matplotlib.patches import Rectangle

CHARTS = Path("preuves/out/graphiques")
CHARTS.mkdir(parents=True, exist_ok=True)
DARK, TEXT, MUTED, GRID = "#081420", "#e8eef2", "#9fb0bd", "#223442"
LIME, BLUE, CORAL, GOLD = "#c7ff5e", "#6cb6ff", "#ff7a7a", "#ffd166"
plt.rcParams.update({
    "font.family": "DejaVu Sans",
    "text.color": TEXT,
    "axes.labelcolor": MUTED,
    "xtick.color": MUTED,
    "ytick.color": MUTED,
    "axes.edgecolor": GRID,
    "axes.facecolor": DARK,
    "figure.facecolor": DARK,
    "savefig.facecolor": DARK,
})


def fr(x, d=1):
    return f"{x:.{d}f}".replace(".", ",")


def finir(fig, ax, titre, sous_titre=None):
    ax.set_title(titre, loc="left", color=TEXT, fontsize=15, fontweight="bold", pad=18)
    if sous_titre:
        fig.text(0.01, 0.94, sous_titre, color=MUTED, fontsize=9, ha="left")
    ax.grid(color=GRID, linewidth=.6, alpha=.65)
    ax.set_axisbelow(True)
    for cote in ("top", "right"):
        ax.spines[cote].set_visible(False)


fig, axes = plt.subplots(1, 2, figsize=(12.6, 4.5))
x = np.arange(len(saison))
axes[0].plot(x, saison.tetes_par_match, color=BLUE, linewidth=2.4, marker="o", markersize=4)
moyenne = saison.tetes.sum() / saison.matchs.sum()
axes[0].axhline(moyenne, color=MUTED, linewidth=1, linestyle="--")
axes[0].text(len(saison) - 1, moyenne + .06, f"moyenne {fr(moyenne, 2)}",
             color=MUTED, fontsize=8.5, ha="right")
axes[0].set_ylabel("têtes / match")
axes[0].set_xticks(x[::2])
axes[0].set_xticklabels(saison.label[::2], fontsize=8.5)
axes[0].set_ylim(saison.tetes_par_match.min() - .12, saison.tetes_par_match.max() + .12)
finir(fig, axes[0], "Le volume ne bouge pas")

axes[1].errorbar(x, saison.conversion,
                 yerr=[saison.conversion - saison.ci_low, saison.ci_high - saison.conversion],
                 fmt="o", color=CORAL, ecolor=GRID, elinewidth=1.3, markersize=4.5, capsize=0)
axes[1].plot(x, intercept + slope * saison.season_start, color=GOLD, linewidth=1.8, linestyle="--")
axes[1].set_ylabel("buts / têtes (%)")
axes[1].set_xticks(x[::2])
axes[1].set_xticklabels(saison.label[::2], fontsize=8.5)
axes[1].set_ylim(saison.conversion.min() - .7, saison.conversion.max() + .7)
p_txt = "< 0,001" if p_conv < .001 else f"{p_conv:.2f}".replace(".", ",")
pente_txt = f"{slope * 10:+.1f}".replace(".", ",")
axes[1].text(.02, .07, f"pente {pente_txt} pt / 10 ans · p {p_txt}",
             transform=axes[1].transAxes, color=GOLD, fontsize=8.5)
finir(fig, axes[1], "La réussite recule")
fig.text(.01, .955, "131 531 têtes · 2009/10–2025/26 · moyenne 4,29 par match · IC 95 % de Wilson",
         color=MUTED, fontsize=9, ha="left")
fig.tight_layout(rect=(0, 0, 1, .91))
fig.savefig(CHARTS / "21-volume-conversion.png", dpi=160, bbox_inches="tight")
plt.close(fig)

fig, ax = plt.subplots(figsize=(8.6, 4.4))
positions = np.arange(len(distance))
barres = ax.barh(positions, [d["conversion"] for d in distance], color=[LIME, BLUE, CORAL, "#b28cff"],
                 height=.6)
ax.invert_yaxis()
ax.set_yticks(positions)
ax.set_yticklabels([d["label"] for d in distance])
ax.set_xlim(0, 27)
ax.set_xlabel("taux de réussite (%)")
for barre, d in zip(barres, distance):
    n_txt = f"{d['n']:,}".replace(",", " ")
    ax.text(barre.get_width() + .5, barre.get_y() + barre.get_height() / 2,
            f"{fr(d['conversion'])} % · {n_txt} têtes",
            va="center", color=TEXT, fontsize=8.5)
finir(fig, ax, "La réussite chute avec la distance",
      f"médiane {mediane:.1f} m · {part_mediane:.1f} % des têtes à {mediane:.1f} m ou moins".replace(".", ","))
fig.subplots_adjust(top=.80, left=.18, right=.96, bottom=.18)
fig.savefig(CHARTS / "21-distance.png", dpi=160, bbox_inches="tight")
plt.close(fig)

fig, ax = plt.subplots(figsize=(8.8, 4.8))
colonnes = ["Droite", "Axe", "Gauche"]
lignes = ["Haut", "Mi-hauteur", "Bas"]
part_max = max(g["part"] for g in grille) / 100
cmap = LinearSegmentedColormap.from_list("cadre", ["#0e1a2c", "#133b66", "#2f7cc4", "#ffd166"])
norm = Normalize(vmin=0, vmax=part_max)
for g in grille:
    i, j = lignes.index(g["row"]), colonnes.index(g["col"])
    couleur = cmap(norm(g["part"] / 100))
    lum = .2126 * couleur[0] + .7152 * couleur[1] + .0722 * couleur[2]
    fg = DARK if lum > .55 else TEXT
    ax.add_patch(Rectangle((j, -i), .97, .97, facecolor=couleur, edgecolor=DARK, linewidth=2))
    ax.text(j + .485, -i + .62, f"{fr(g['part'])} %", ha="center", va="center",
            fontsize=15, fontweight="bold", color=fg)
    ax.text(j + .485, -i + .28, f"{fr(g['conversion'])} % de buts", ha="center", va="center",
            fontsize=8.5, color=fg)
ax.set_xlim(-.1, 3.05)
ax.set_ylim(-2.12, 1.15)
ax.set_xticks([j + .485 for j in range(3)])
ax.set_xticklabels(colonnes)
ax.xaxis.tick_top()
ax.set_yticks([-i + .485 for i in range(3)])
ax.set_yticklabels(lignes)
ax.set_title("Où vont les têtes cadrées", loc="left", color=TEXT, fontsize=15, fontweight="bold", pad=32)
ax.tick_params(length=0)
ax.grid(False)
for cote in ax.spines.values():
    cote.set_visible(False)
fig.text(.01, .93, f"Part des {len(cadre):,} têtes cadrées localisées · réussite dans chaque zone".replace(",", " "),
         color=MUTED, fontsize=9, ha="left")
fig.subplots_adjust(top=.77, left=.10, right=.98, bottom=.10)
fig.savefig(CHARTS / "21-cadre.png", dpi=160, bbox_inches="tight")
plt.close(fig)

fig, ax = plt.subplots(figsize=(8.8, 5.2))
top = top_pourvoyeurs.iloc[::-1]
positions = np.arange(len(top))
barres = ax.barh(positions, top.buts, color=LIME, height=.62)
ax.set_yticks(positions)
ax.set_yticklabels(top.assist_player, fontsize=9)
ax.set_xlim(0, 58)
ax.set_xlabel("buts inscrits sur une tête délivrée")
for barre, ligne in zip(barres, top.itertuples()):
    ax.text(barre.get_width() + .9, barre.get_y() + barre.get_height() / 2,
            f"{int(ligne.buts)} buts · {fr(ligne.conversion)} %",
            va="center", color=TEXT, fontsize=8.5)
finir(fig, ax, "Les meilleurs pourvoyeurs",
      "Buts marqués sur leurs têtes · 124 931 têtes assistées")
fig.subplots_adjust(top=.80, left=.24, right=.96, bottom=.18)
fig.savefig(CHARTS / "21-pourvoyeurs.png", dpi=160, bbox_inches="tight")
plt.close(fig)

print("graphiques : preuves/out/graphiques/21-*.png")

Sortie

Le JSON ne remplace pas la page : il garde les valeurs exactes, les empreintes des sept fichiers d'entrée et la liste des assertions. Tous les chiffres publiés sont vérifiés ; la seule pièce non reconstruite ligne à ligne est la ligne « Pied » de la comparaison tête / pied, et elle est déclarée comme telle.

Code · 135 lignes
assertions_ok = [
    "131 531 têtes retenues, 486 CSC exclus, sur 17 saisons et cinq ligues",
    "13 921 buts : 10,6 % de réussite et 17,0 % des 81 860 buts du Big 5",
    "la tête pèse 16,8 % des tirs ; 43,1 % cadrées contre 62,4 % pour le pied",
    "10,6 % contre 10,4 % : la tête convertit comme un tir du pied",
    "une tête cadrée finit au fond à 24,5 %, un tir cadré du pied à 16,7 %",
    "4,29 têtes par match en moyenne ; 4,32 en 2009/10, 4,52 en 2025/26",
    "la réussite recule de 11,4 % à 9,5 % entre 2009/10 et 2025/26",
    "distance médiane 8,3 m ; 50,2 % des têtes à 8,3 m ou moins",
    "21,6 % de réussite à 5,5 m ou moins, 1,9 % au-delà de 16,5 m",
    "58,2 % des têtes cadrées visent l'axe ; 16,1 % dans l'axe contre 41,0 % sur les côtés",
    "corners : 34,8 % des têtes assistées, 9,8 % de réussite contre 10,0 % pour les centres (p = 0,26)",
    "124 931 têtes assistées et 7 258 identifiants de passeur ; Payet 50 buts, Kroos 47, Çalhanoğlu 39",
    "Fuchs 16,1 % sur 161 têtes ; Kane 21,0 % sur 267 têtes",
    "buteurs : Ronaldo 67, Lewandowski 62, Kane 56 buts de la tête",
    "clubs : Real Madrid 232, Bayern 230, Inter 203 ; Sochaux 16,0 % sur 357 têtes",
    "championnats : 10,2–11,0 % (p = 0,10) ; Serie A de 12,4 % à 8,8 %",
]
non_verifie = [
    "la ligne « Pied » de la comparaison tête / pied est un agrégat de contrôle, "
    "non recalculé ligne à ligne dans ce notebook",
]

payload = {
    "etude": "21",
    "statut": "exécuté",
    "niveau": "recalcul · agrégats (volume, distance, cadre, livraisons, joueurs)",
    "genere_le": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
    "environnement": {
        "python": platform.python_version(),
        "pandas": pd.__version__,
        "numpy": np.__version__,
        "scipy": __import__("scipy").__version__,
    },
    "entrees": {
        nom: {
            "sha256": hashlib.sha256((RACINE / nom).read_bytes()).hexdigest(),
            "octets": (RACINE / nom).stat().st_size,
        }
        for nom in ENTREES
    },
    "chiffres": {
        "tetes": n,
        "buts": buts,
        "conversion": conversion,
        "tirs": int(perimetre["tirs_2009_2026"]),
        "tirs_archive": int(perimetre["tirs_archive"]),
        "buts_big5": int(perimetre["buts_big5"]),
        "part_tirs": part_tirs,
        "part_buts": part_buts,
        "csc": int(perimetre["tetes_csc"]),
        "matchs": int(perimetre["matchs"]),
        "saisons": 17,
        "ligues": 5,
        "cadrage": cadrage,
        "conversion_cadrees": conversion_cadrees,
        "tete_pied": {
            "Tete": {
                "tirs": int(tete["tirs"]), "buts": int(tete["buts"]),
                "cadres": int(tete["cadres"]), "conversion": conversion,
                "cadrage": cadrage, "conversion_cadres": conversion_cadrees,
            },
            "Pied": {
                "tirs": int(pied["tirs"]), "buts": int(pied["buts"]),
                "cadres": int(pied["cadres"]), "conversion": conversion_pied,
                "cadrage": cadrage_pied, "conversion_cadres": conversion_cadrees_pied,
            },
        },
        "repartition_buts": {"tete": buts, "autres": int(perimetre["buts_big5"]) - buts},
        "saison": [
            {
                "season": row.season, "matchs": int(row.matchs), "tetes": int(row.tetes),
                "buts": int(row.buts), "tetes_par_match": float(row.tetes_par_match),
                "conversion": float(row.conversion), "cadrage": float(row.cadrage),
                "ci_low": float(row.ci_low), "ci_high": float(row.ci_high),
            }
            for row in saison.itertuples()
        ],
        "distance": {"mediane": mediane, "part_mediane": part_mediane, "bandes": distance},
        "cadre": {
            "n": len(cadre), "axe_part": axe_part,
            "axe_conversion": axe_conv, "cote_conversion": cote_conv, "grille": grille,
        },
        "livraisons": [
            {
                "livraison": row.delivery, "tetes": int(row.tetes), "buts": int(row.buts),
                "part": float(row.part), "conversion": float(row.conversion),
            }
            for row in livraisons.itertuples()
        ],
        "championnats": [
            {
                "league": row.league, "tetes": int(row.tetes), "buts": int(row.buts),
                "conversion": float(row.conversion),
            }
            for row in championnats.itertuples()
        ],
        "p_ligues": p_ligues,
        "p_corner_cross": p_corner_cross,
        "pourvoyeurs": {
            "tetes_assistees": len(assistes),
            "passeurs": passeurs_n,
            "top": [
                {
                    "joueur": row.assist_player, "tetes": int(row.tetes),
                    "buts": int(row.buts), "conversion": float(row.conversion),
                }
                for row in top_pourvoyeurs.itertuples()
            ],
        },
        "buteurs": {
            "top": [
                {
                    "joueur": row.player, "tetes": int(row.tetes),
                    "buts": int(row.buts), "conversion": float(row.conversion),
                }
                for row in buteurs.itertuples()
            ],
        },
        "clubs": {
            "top": [
                {
                    "club": row.team, "tetes": int(row.tetes),
                    "buts": int(row.buts), "conversion": float(row.conversion),
                }
                for row in top_clubs.itertuples()
            ],
        },
    },
    "assertions_ok": assertions_ok,
    "non_verifie": non_verifie,
}
SORTIE.mkdir(parents=True, exist_ok=True)
(SORTIE / "21.json").write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
print("écrit : preuves/out/21.json")

Graphiques complémentaires

Deux figures supplémentaires sont calculées depuis la sortie JSON vérifiée, sans nouvelle lecture du corpus : la comparaison tête / pied et la part des buts du Big 5 marqués de la tête.

Code · 3 lignes
from preuves.graphiques_densite import generer_densite

generer_densite("21")