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")