← Preuves Lecture HTML · sans Jupyter .ipynb brut

Notebook de preuve · lecture directe

20 · Penalties Big 5, notebook de preuve

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

Résultats vérifiés

exécuté
10 assertions0 non vérifié2026-11-10 15:11

recalcul · agrégats (zones, pied, gardien)

  1. 9 231 penalties après exclusion de 2026-27, sur 17 saisons et cinq ligues
  2. 77,5 % de réussite globale (7 156 buts)
  3. bas gauche : 3 472 tirs (37,6 %), 76,2 % de réussite
  4. zones hautes : 1 744 tirs (18,9 %), 80,3 % de réussite
  5. bas centre 12,4 % et bas droit 31,1 %, autour de 77 %
  6. droitiers : 75,2 % côté naturel (n = 2 769) contre 78,8 % côté opposé (n = 2 040)
  7. gauchers : 74,9 % côté naturel (n = 833) contre 80,1 % côté opposé (n = 703)
  8. gauchers de 19,2 % à 31,5 % des tireurs ; plongeons à gauche 44,2 %
  9. bon côté : 33,3 % d'arrêts (1 247/3 747) contre 0,4 % (16/3 602)
  10. ligues : 76,7–79,4 % (p = 0,34) ; Serie A 0,337 penalty/match, Premier League 0,256
Valeurs calculées (JSON)
{
  "penalties": 9231,
  "goals": 7156,
  "conversion": 77.52139529845087,
  "seasons": 17,
  "leagues": 5,
  "zones": {
    "LowLeft": {
      "n": 3472,
      "share": 37.61239302350774,
      "goals": 2644,
      "conversion": 76.15207373271889
    },
    "LowCentre": {
      "n": 1142,
      "share": 12.371357382732098,
      "goals": 879,
      "conversion": 76.97022767075306
    },
    "LowRight": {
      "n": 2873,
      "share": 31.123388581952117,
      "goals": 2232,
      "conversion": 77.68882701009397
    },
    "HighLeft": {
      "n": 631,
      "share": 6.835662441772289,
      "goals": 507,
      "conversion": 80.3486529318542
    },
    "HighCentre": {
      "n": 519,
      "share": 5.622359441013975,
      "goals": 410,
      "conversion": 78.9980732177264
    },
    "HighRight": {
      "n": 594,
      "share": 6.434839129021775,
      "goals": 484,
      "conversion": 81.48148148148148
    }
  },
  "high": {
    "n": 1744,
    "goals": 1401,
    "share": 18.892861011808037,
    "conversion": 80.33256880733946
  },
  "low_centre": {
    "n": 1142,
    "share": 12.371357382732098,
    "goals": 879,
    "conversion": 76.97022767075306
  },
  "low_right": {
    "n": 2873,
    "share": 31.123388581952117,
    "goals": 2232,
    "conversion": 77.68882701009397
  },
  "natural": {
    "Right": {
      "natural_zone": "LowLeft",
      "n_nat": 2769,
      "conv_nat": 75.15348501263995,
      "opposite_zone": "LowRight",
      "n_opp": 2040,
      "conv_opp": 78.82352941176471
    },
    "Left": {
      "natural_zone": "LowRight",
      "n_nat": 833,
      "conv_nat": 74.90996398559425,
      "opposite_zone": "LowLeft",
      "n_opp": 703,
      "conv_opp": 80.08534850640113
    }
  },
  "left_foot": {
    "first": 19.162995594713657,
    "last": 31.521739130434785,
    "by_season": {
      "2009-2010": 19.162995594713657,
      "2010-2011": 20.858895705521473,
      "2011-2012": 27.01688555347092,
      "2012-2013": 23.588709677419356,
      "2013-2014": 22.529644268774703,
      "2014-2015": 20.15655577299413,
      "2015-2016": 20.32854209445585,
      "2016-2017": 22.14650766609881,
      "2017-2018": 19.038817005545287,
      "2018-2019": 20.68376068376068,
      "2019-2020": 20.203735144312393,
      "2020-2021": 24.12790697674419,
      "2021-2022": 26.47058823529412,
      "2022-2023": 27.670753064798596,
      "2023-2024": 33.03085299455535,
      "2024-2025": 25.925925925925924,
      "2025-2026": 31.521739130434785
    }
  },
  "keeper": {
    "dives": 7349,
    "correct": 3747,
    "wrong": 3602,
    "save_correct": 33.27995729917267,
    "save_wrong": 0.44419766796224325,
    "save_correct_goals": 1247,
    "save_wrong_goals": 16,
    "correct_pct": 50.986528779425775
  },
  "league": [
    {
      "league": "Bundesliga",
      "n": 1490,
      "goals": 1152,
      "conversion": 77.31543624161074
    },
    {
      "league": "La Liga",
      "n": 1996,
      "goals": 1534,
      "conversion": 76.85370741482967
    },
    {
      "league": "Ligue 1",
      "n": 1922,
      "goals": 1493,
      "conversion": 77.67950052029137
    },
    {
      "league": "Premier League",
      "n": 1652,
      "goals": 1311,
      "conversion": 79.35835351089588
    },
    {
      "league": "Serie A",
      "n": 2171,
      "goals": 1666,
      "conversion": 76.73883003224321
    }
  ],
  "p_league": 0.33824334258637134,
  "chi2": 4.536483768656753,
  "per_match": {
    "Bundesliga": 0.2865384615384615,
    "La Liga": 0.30902616504102803,
    "Ligue 1": 0.3135911241638114,
    "Premier League": 0.2558859975216852,
    "Serie A": 0.33669354838709675
  },
  "missing_zone": 517
}

Graphiques générés

4 figures
Taux d'arrêt quand le gardien part du bon côté ou non.
Arrêts selon le plongeon
Taux de conversion des penalties dans les six zones du but.
La conversion par zone
Évolution de la part de penalties tirés du pied gauche par saison.
La part de pied gauche
Part des tirs et taux de réussite par zone.
Répartition des zones

Statut : exécuté. preuves/data/20-big5-penalties.csv est l'export minimal du projet big5_penalty_analysis : 9 367 lignes brutes, 2009/10–2026/27, cinq ligues, une ligne par penalty. 20-matches.csv donne les matchs par saison et par ligue, nécessaires aux fréquences.

Niveau de preuve. Recalcul des agrégats de la page depuis les lignes : volume, zones, pied, côté du gardien, conversion par ligue et penalties par match. Les zones non codées sont reconstruites depuis goal_mouth_y / goal_mouth_z, comme dans le pipeline source. Les 517 reconstructions sont comptées, pas cachées.

Limite. La classification zone / plongeon reste le codage WhoScored/Opta ; aucune seconde source ne la recontrôle. Le taux des zones hautes est recalculé à 80,3 % (1 401 buts sur 1 744 tirs), comme sur la page.

Code · 23 lignes
import json, hashlib, platform, datetime
from pathlib import Path
import numpy as np
import pandas as pd

RACINE = Path("preuves/data")
SORTIE = Path("preuves/out")
ENTREES = {
    "20-big5-penalties.csv": ["season", "league", "match_id", "penalty_id", "foot", "zone",
                              "goal_mouth_y", "goal_mouth_z", "is_goal", "is_saved",
                              "keeper_dive", "is_shootout"],
    "20-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 big5_penalty_analysis/data/.")
frames = {nom: pd.read_csv(RACINE / nom, sep=";") for nom in ENTREES}
for nom, colonnes in ENTREES.items():
    assert list(frames[nom].columns) == colonnes, (nom, list(frames[nom].columns))
raw = frames["20-big5-penalties.csv"]
matches_raw = frames["20-matches.csv"]

Contrôles d'intégrité

Le fichier brut contient 9 367 penalties sur 18 saisons, dont la saison 2026/27 encore partielle. Le notebook la retire, vérifie qu'aucun tir au but n'est présent, et retrouve 9 231 penalties sur 17 saisons et cinq ligues. La clé match_id + penalty_id est unique sur les 9 367 lignes ; penalty_id seul ne l'est pas, car il est réutilisé d'un match à l'autre. Les 517 zones absentes sont reconstruites depuis les coordonnées du tir ; aucune ligne n'est supprimée pour faire tenir la carte.

Code · 36 lignes
assert len(raw) == 9367
assert raw.season.nunique() == 18
assert int(raw.is_shootout.sum()) == 0
assert not raw.duplicated(["match_id", "penalty_id"]).any()
assert set(raw.league) == {"Premier League", "La Liga", "Bundesliga", "Serie A", "Ligue 1"}

df = raw[raw.season != "2026-2027"].copy()
assert len(df) == 9231
assert df.season.nunique() == 17 and df.league.nunique() == 5
assert not df.duplicated(["match_id", "penalty_id"]).any()
assert int(df.is_shootout.sum()) == 0
df["season_start"] = df.season.str[:4].astype(int)
assert (int(df.season_start.min()), int(df.season_start.max())) == (2009, 2025)


def impute_zone(row):
    if pd.notna(row["zone"]):
        return row["zone"]
    y, z = row["goal_mouth_y"], row["goal_mouth_z"]
    if pd.isna(y) or pd.isna(z):
        return None
    side = "Left" if y > 51.85 else ("Right" if y < 48.15 else "Centre")
    height = "High" if z >= 20 else "Low"
    return height + side


df["zone_final"] = df.apply(impute_zone, axis=1)
assert int(df.zone.isna().sum()) == 517
assert int(df.zone_final.isna().sum()) == 0
assert set(df.zone_final) == {"LowLeft", "LowCentre", "LowRight",
                              "HighLeft", "HighCentre", "HighRight"}

assert len(matches_raw) == 90 and matches_raw.season.nunique() == 18
matches = matches_raw[matches_raw.season != "2026-2027"].copy()
assert len(matches) == 85 and matches.season.nunique() == 17
print("9 231 penalties · 17 saisons · 5 ligues · 517 zones reconstruites")

Calcul

Les six zones, les deux pieds et le côté du gardien sont recalculés sur les 9 231 penalties. La conversion par ligue et les penalties par match sont séparés : la ligue change la fréquence, pas la conversion.

Code · 90 lignes
n = len(df)
goals = int(df.is_goal.sum())
conversion = 100.0 * goals / n

ZONES = ["LowLeft", "LowCentre", "LowRight", "HighLeft", "HighCentre", "HighRight"]
zones = {}
for z in ZONES:
    s = df[df.zone_final == z]
    zones[z] = {
        "n": int(len(s)),
        "share": 100.0 * len(s) / n,
        "goals": int(s.is_goal.sum()),
        "conversion": 100.0 * float(s.is_goal.mean()),
    }

high = df[df.zone_final.str.startswith("High")]
high_n = int(len(high))
high_goals = int(high.is_goal.sum())
high_share = 100.0 * high_n / n
high_conv = 100.0 * high_goals / high_n

lowleft = zones["LowLeft"]
lowcentre = zones["LowCentre"]
lowright = zones["LowRight"]

natural = {}
for foot, nat, opp in [("Right", "LowLeft", "LowRight"), ("Left", "LowRight", "LowLeft")]:
    a = df[(df.foot == foot) & (df.zone_final == nat)]
    b = df[(df.foot == foot) & (df.zone_final == opp)]
    natural[foot] = {
        "natural_zone": nat, "n_nat": int(len(a)),
        "conv_nat": 100.0 * float(a.is_goal.mean()),
        "opposite_zone": opp, "n_opp": int(len(b)),
        "conv_opp": 100.0 * float(b.is_goal.mean()),
    }

df["is_left"] = df.foot == "Left"
left_by_season = (df.groupby("season").is_left.mean() * 100.0).sort_index()
left_first = float(left_by_season.iloc[0])
left_last = float(left_by_season.iloc[-1])
keeper_left = 100.0 * float((df.keeper_dive == "Left").mean())

df["side"] = np.select(
    [df.zone_final.str.contains("Left"), df.zone_final.str.contains("Right")],
    ["Left", "Right"], default="Centre")
df["keeper_correct"] = (
    ((df.side == "Left") & (df.keeper_dive == "Right"))
    | ((df.side == "Right") & (df.keeper_dive == "Left")))
df["correct_denom"] = (df.side != "Centre") & df.keeper_dive.isin(["Left", "Right"])
dives = df[df.correct_denom]
correct = dives[dives.keeper_correct]
wrong = dives[~dives.keeper_correct]
correct_n, wrong_n = int(len(correct)), int(len(wrong))
save_correct = 100.0 * float(correct.is_saved.mean())
save_wrong = 100.0 * float(wrong.is_saved.mean())
correct_pct = 100.0 * float(dives.keeper_correct.mean())

league = (df.groupby("league")
            .agg(n=("is_goal", "size"), goals=("is_goal", "sum"), conversion=("is_goal", "mean"))
            .reset_index())
league["conversion"] *= 100.0
league = league.sort_values("league").reset_index(drop=True)


def chi2_p_5x2(frame):
    observed = pd.crosstab(frame["league"], frame["is_goal"]).to_numpy(dtype=float)
    row = observed.sum(axis=1, keepdims=True)
    col = observed.sum(axis=0, keepdims=True)
    expected = row @ col / observed.sum()
    chi2 = float(((observed - expected) ** 2 / expected).sum())
    # 5 ligues x 2 issues : 4 degrés de liberté ; survie exacte du chi2 à 4 ddl.
    p = float(np.exp(-chi2 / 2.0) * (1.0 + chi2 / 2.0))
    return p, chi2


p_league, chi2_league = chi2_p_5x2(df)

pens = df.groupby(["season", "league"]).size().rename("penalties").reset_index()
rates = matches.merge(pens, on=["season", "league"], how="left").fillna({"penalties": 0})
rates["penalties"] = rates.penalties.astype(int)
rates["rate"] = rates.penalties / rates.matches
per_match = rates.groupby("league").apply(lambda g: g.penalties.sum() / g.matches.sum())

serie_rate = float(per_match["Serie A"])
pl_rate = float(per_match["Premier League"])
print(f"{n} penalties · {goals} buts · {conversion:.2f} %")
print(f"bas gauche {lowleft['n']} ({lowleft['share']:.1f} %) · {lowleft['conversion']:.1f} %")
print(f"zones hautes {high_n} ({high_share:.1f} %) · {high_conv:.1f} %")
print(f"bon côté {save_correct:.1f} % · mauvais côté {save_wrong:.1f} %")
print(f"p ligues {p_league:.4f} · Serie A {serie_rate:.4f} · PL {pl_rate:.4f}")

Ce qui est asserté

Dix chiffres publiés, à l'arrondi de la page. Le taux des zones hautes est asserté à 80,3 % (1 401 buts sur 1 744 tirs).

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


assert n == 9231 and goals == 7156
assert r1(conversion) == 77.5
assert lowleft["n"] == 3472 and r1(lowleft["share"]) == 37.6 and r1(lowleft["conversion"]) == 76.2
assert high_n == 1744 and high_goals == 1401 and r1(high_share) == 18.9 and r1(high_conv) == 80.3
assert r1(lowcentre["share"]) == 12.4 and r1(lowright["share"]) == 31.1
assert r1(natural["Right"]["conv_nat"]) == 75.2 and natural["Right"]["n_nat"] == 2769
assert r1(natural["Right"]["conv_opp"]) == 78.8 and natural["Right"]["n_opp"] == 2040
assert r1(natural["Left"]["conv_nat"]) == 74.9 and natural["Left"]["n_nat"] == 833
assert r1(natural["Left"]["conv_opp"]) == 80.1 and natural["Left"]["n_opp"] == 703
assert r1(left_first) == 19.2 and r1(left_last) == 31.5 and r1(keeper_left) == 44.2
assert correct_n == 3747 and wrong_n == 3602
assert int(correct.is_saved.sum()) == 1247 and int(wrong.is_saved.sum()) == 16
assert r1(save_correct) == 33.3 and r1(save_wrong) == 0.4
assert r1(league.conversion.min()) == 76.7 and r1(league.conversion.max()) == 79.4
assert round(p_league, 2) == 0.34
assert round(serie_rate, 3) == 0.337 and round(pl_rate, 3) == 0.256
print("OK : 10 chiffres publiés retrouvés, dont 80,3 % dans les zones hautes")

Graphiques

Deux figures sont générées depuis les 9 231 penalties : répartition des zones et taux d'arrêt selon le côté du plongeon. Elles sont écrites dans preuves/out/graphiques/.

Code · 62 lignes
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt

CHARTS = Path("preuves/out/graphiques")
CHARTS.mkdir(parents=True, exist_ok=True)
DARK, TEXT, MUTED, GRID = "#081420", "#e8eef2", "#9fb0bd", "#223442"
LIME, BLUE = "#c7ff5e", "#6cb6ff"
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 finir(fig, ax, titre, sous_titre):
    ax.set_title(titre, loc="left", color=TEXT, fontsize=15, fontweight="bold", pad=18)
    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.subplots_adjust(top=.80, left=.22, right=.96, bottom=.18)

labels = {"LowLeft": "Bas gauche", "LowCentre": "Bas centre", "LowRight": "Bas droit",
          "HighLeft": "Haut gauche", "HighCentre": "Haut centre", "HighRight": "Haut droit"}
ordre = ["LowLeft", "LowCentre", "LowRight", "HighLeft", "HighCentre", "HighRight"]
fig, ax = plt.subplots(figsize=(8.6, 4.4))
couleurs = [BLUE if z.startswith("Low") else LIME for z in ordre]
barres = ax.barh([labels[z] for z in ordre], [zones[z]["share"] for z in ordre],
                 color=couleurs, height=.62)
ax.invert_yaxis()
ax.set_xlim(0, 44)
ax.set_xlabel("Part des 9 231 penalties (%)")
for barre, z in zip(barres, ordre):
    ax.text(barre.get_width() + .6, barre.get_y() + barre.get_height() / 2,
            f"{zones[z]['share']:.1f} % · {zones[z]['conversion']:.1f} % de réussite",
            va="center", color=TEXT, fontsize=8.5)
finir(fig, ax, "Où vont les penalties",
      "Part des tirs et taux de réussite par zone · WhoScored/Opta")
fig.savefig(CHARTS / "20-repartition-zones.png", dpi=160, bbox_inches="tight")
plt.close(fig)

fig, ax = plt.subplots(figsize=(6.8, 4.2))
barres = ax.bar(["Bon côté", "Mauvais côté"], [save_correct, save_wrong],
                color=[LIME, "#ff7a7a"], width=.56)
ax.set_ylim(0, 38)
ax.set_ylabel("Taux d'arrêt (%)")
for barre, valeur in zip(barres, [save_correct, save_wrong]):
    ax.text(barre.get_x() + barre.get_width() / 2, barre.get_height() + .8,
            f"{valeur:.1f} %", ha="center", color=TEXT, fontsize=11, fontweight="bold")
finir(fig, ax, "Le plongeon décide presque tout",
      "Arrêts selon que le gardien part du bon côté ou non · 7 349 plongeons")
fig.savefig(CHARTS / "20-arrets-plongeon.png", dpi=160, bbox_inches="tight")
plt.close(fig)
print("graphiques : preuves/out/graphiques/20-*.png")

Graphiques complémentaires

Deux figures supplémentaires sont calculées depuis les mêmes chiffres vérifiés, sans nouvelle lecture des corpus bruts. Elles complètent les deux figures de la section précédente dans preuves/out/graphiques/.

Code · 3 lignes
from preuves.graphiques_densite import generer_densite

generer_densite("20")

Sortie

Le JSON ne remplace pas la page : il garde les valeurs exactes, les empreintes des deux exports et la liste des assertions. Tous les chiffres publiés sont vérifiés.

Code · 49 lignes
assertions_ok = [
    "9 231 penalties après exclusion de 2026-27, sur 17 saisons et cinq ligues",
    "77,5 % de réussite globale (7 156 buts)",
    "bas gauche : 3 472 tirs (37,6 %), 76,2 % de réussite",
    "zones hautes : 1 744 tirs (18,9 %), 80,3 % de réussite",
    "bas centre 12,4 % et bas droit 31,1 %, autour de 77 %",
    "droitiers : 75,2 % côté naturel (n = 2 769) contre 78,8 % côté opposé (n = 2 040)",
    "gauchers : 74,9 % côté naturel (n = 833) contre 80,1 % côté opposé (n = 703)",
    "gauchers de 19,2 % à 31,5 % des tireurs ; plongeons à gauche 44,2 %",
    "bon côté : 33,3 % d'arrêts (1 247/3 747) contre 0,4 % (16/3 602)",
    "ligues : 76,7–79,4 % (p = 0,34) ; Serie A 0,337 penalty/match, Premier League 0,256",
]
non_verifie = []

payload = {
    "etude": "20",
    "statut": "exécuté",
    "niveau": "recalcul · agrégats (zones, pied, gardien)",
    "genere_le": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
    "environnement": {"python": platform.python_version(), "pandas": pd.__version__,
                      "numpy": np.__version__},
    "entrees": {nom: {"sha256": hashlib.sha256((RACINE / nom).read_bytes()).hexdigest(),
                      "octets": (RACINE / nom).stat().st_size} for nom in ENTREES},
    "chiffres": {
        "penalties": n, "goals": goals, "conversion": conversion,
        "seasons": 17, "leagues": 5,
        "zones": zones,
        "high": {"n": high_n, "goals": high_goals, "share": high_share, "conversion": high_conv},
        "low_centre": lowcentre, "low_right": lowright,
        "natural": natural,
        "left_foot": {"first": left_first, "last": left_last,
                      "by_season": {str(k): float(v) for k, v in left_by_season.items()}},
        "keeper": {"dives": int(len(dives)), "correct": correct_n, "wrong": wrong_n,
                   "save_correct": save_correct, "save_wrong": save_wrong,
                   "save_correct_goals": int(correct.is_saved.sum()),
                   "save_wrong_goals": int(wrong.is_saved.sum()),
                   "correct_pct": correct_pct},
        "league": [{"league": r.league, "n": int(r.n), "goals": int(r.goals),
                    "conversion": float(r.conversion)} for r in league.itertuples()],
        "p_league": p_league, "chi2": chi2_league,
        "per_match": {k: float(v) for k, v in per_match.items()},
        "missing_zone": 517,
    },
    "assertions_ok": assertions_ok,
    "non_verifie": non_verifie,
}
SORTIE.mkdir(parents=True, exist_ok=True)
(SORTIE / "20.json").write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
print("écrit : preuves/out/20.json")