Coupled block graphs — defensive positioning tool¶

Question: in an organized block, does the defense cover the attack's passing graph, or only the ball?

Uses defense proximity graphs (λ₂ of the combinatorial Laplacian), attack Delaunay danger, Hungarian marking, and supra-Laplacian coupling onto high-danger attack nodes. Sampling is 2 Hz inside {low_block, medium_block, high_block} only.

Baselines this has to beat: OOP width × length, nearest defender to the ball, and SkillCorner overall_pressure.

Sample limit: 20 matches. Phases are nested in matches, so naive CIs overstate precision.

In [1]:
import matplotlib
matplotlib.use('module://matplotlib_inline.backend_inline')
from matplotlib_inline.backend_inline import set_matplotlib_formats
set_matplotlib_formats('png')
from pathlib import Path
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.collections import LineCollection

import coupled_formation as cf
from coupled_formation import (
    DATA_DIR, OUT_DIR, load_match_meta, load_frame_geometry,
    possession_timeseries,
)

MATCH_ID = "1925299"  # Brisbane vs Perth — shots in low/medium block
PHASE_START, PHASE_END = 17583, 17981
FRAME = 17780

OUT_DIR.mkdir(parents=True, exist_ok=True)
meta = load_match_meta(DATA_DIR / MATCH_ID, MATCH_ID)
print(meta.home_name, "vs", meta.away_name, "| pitch", meta.pitch_length, "x", meta.pitch_width)
print("player_id field confirmed as match players[].id")

import matplotlib as mpl
mpl.rcParams['figure.dpi'] = 100
mpl.rcParams['savefig.dpi'] = 100
Brisbane FC vs Perth Glory | pitch 105.0 x 68.0
player_id field confirmed as match players[].id

Pitch snapshot — both graphs and Hungarian marking¶

Blue = defense proximity edges (weight > 0.05). Orange = attack Delaunay edges (width ∝ danger). Dotted = Hungarian assignment. Free attackers (assignment > 8 m or not goal-side) are highlighted in red.

In [2]:
row = load_frame_geometry(MATCH_ID, FRAME)
assert row is not None, f"no geometry at frame {FRAME}"
g = row["_geometry"]
def_xy, att_xy = g["_def_xy"], g["_att_xy"]
w_def, edges, dangers, occluded = g["_w_def"], g["_edges"], g["_dangers"], g["_occluded"]
marking = g["_marking"]
free = set(marking["free_idx"])
half_l, half_w = meta.pitch_length / 2, meta.pitch_width / 2

fig, ax = plt.subplots(figsize=(8, 5.2), dpi=100)
ax.set_facecolor("#f4f7f2")
ax.add_patch(plt.Rectangle((-half_l, -half_w), meta.pitch_length, meta.pitch_width,
                            fill=False, ec="#333", lw=1.2))
ax.plot([0, 0], [-half_w, half_w], color="#888", lw=0.8, ls="--")
segs, alphas = [], []
n = len(def_xy)
for i in range(n):
    for j in range(i + 1, n):
        if w_def[i, j] > 0.05:
            segs.append([def_xy[i], def_xy[j]])
            alphas.append(float(w_def[i, j]))
if segs:
    lc = LineCollection(segs, colors=[(0.15, 0.35, 0.7, min(1.0, 0.25 + a)) for a in alphas], linewidths=1.2)
    ax.add_collection(lc)
asegs, acols, alws = [], [], []
for k, (i, j) in enumerate(edges):
    asegs.append([att_xy[i], att_xy[j]])
    dng = float(dangers[k])
    alws.append(0.6 + 2.5 * dng)
    acols.append((0.85, 0.35, 0.05, 0.25 if occluded[k] else 0.85))
if asegs:
    ax.add_collection(LineCollection(asegs, colors=acols, linewidths=alws))
for p in marking["pairs"]:
    d, a = def_xy[p["def_idx"]], att_xy[p["att_idx"]]
    ax.plot([d[0], a[0]], [d[1], a[1]], color="#444", ls=":", lw=0.9, alpha=0.7)
ax.scatter(def_xy[:, 0], def_xy[:, 1], c="#1f4e79", s=55, zorder=5, label="defense")
for i, xy in enumerate(att_xy):
    if i in free:
        ax.scatter(xy[0], xy[1], c="#c0392b", s=90, zorder=6, edgecolors="k", linewidths=0.8)
    else:
        ax.scatter(xy[0], xy[1], c="#e67e22", s=55, zorder=5)
ax.scatter([], [], c="#e67e22", s=55, label="attack (marked)")
ax.scatter([], [], c="#c0392b", s=90, edgecolors="k", label="free attacker")
ax.set_xlim(-half_l - 2, half_l + 2)
ax.set_ylim(-half_w - 2, half_w + 2)
ax.set_aspect("equal")
title = (
    f"{meta.home_name} vs {meta.away_name} · frame {row['frame']} · {row['block_type']}\n"
    f"λ₂(def)={row['lambda2_defense']:.3f}  free={row['n_free_attackers']}  "
    f"uncovered danger={row['uncovered_danger']:.2f}  struct. pressure={row['structural_pressure']:.2f}"
)
ax.set_title(title)
ax.legend(loc="upper right", fontsize=8, framealpha=0.9)
ax.set_xlabel("x (m, origin at centre)")
ax.set_ylabel("y (m)")
plt.tight_layout()
from IPython.display import display
display(fig)
plt.close(fig)
print("phase", row["frame_start"], "–", row["frame_end"], "| lead_to_shot", row["lead_to_shot"])
print("attacking_side", row["attacking_side"], "| TIP", row["team_in_possession"])
No description has been provided for this image
phase 17583 – 17981 | lead_to_shot True
attacking_side right_to_left | TIP Brisbane FC

Time series through one shot possession¶

Same Brisbane low-block phase (frame_start=17583). Watch λ₂ (defense connectivity) and uncovered attack danger.

In [3]:
ts = possession_timeseries(MATCH_ID, PHASE_START, PHASE_END)
print(f"{len(ts)} sampled frames in phase {PHASE_START}–{PHASE_END}")
fig, axes = plt.subplots(2, 1, figsize=(8, 4.5), dpi=100, sharex=True)
t = (ts["frame"] - ts["frame"].iloc[0]) / 10.0
axes[0].plot(t, ts["lambda2_defense"], color="#1f4e79", lw=1.5)
axes[0].set_ylabel("λ₂ defense")
axes[0].set_title("Defense algebraic connectivity through possession")
axes[0].axvline((FRAME - ts["frame"].iloc[0]) / 10.0, color="#888", ls="--", lw=0.8, label="snapshot frame")
axes[0].legend(fontsize=8)
axes[1].plot(t, ts["uncovered_danger"], color="#c0392b", lw=1.5, label="uncovered danger")
axes[1].plot(t, ts["structural_pressure"], color="#27ae60", lw=1.2, alpha=0.85, label="structural pressure")
axes[1].set_ylabel("score")
axes[1].set_xlabel("seconds from phase start")
axes[1].legend(fontsize=8, loc="upper right")
axes[1].set_title("Uncovered attack danger and structural pressure")
plt.tight_layout()
from IPython.display import display
display(fig)
plt.close(fig)
print(ts[["frame", "lambda2_defense", "uncovered_danger", "n_free_attackers",
          "structural_pressure", "oop_width", "nearest_defender_to_ball"]].describe().round(3))
78 sampled frames in phase 17583–17981
No description has been provided for this image
           frame  lambda2_defense  uncovered_danger  n_free_attackers  \
count     78.000           78.000            78.000            78.000   
mean   17777.500            0.816             2.383             5.628   
std      113.303            0.476             0.458             1.260   
min    17585.000            0.106             1.292             3.000   
25%    17681.250            0.455             1.945             5.000   
50%    17777.500            0.713             2.487             6.000   
75%    17873.750            0.961             2.786             6.000   
max    17970.000            1.861             3.216             8.000   

       structural_pressure  oop_width  nearest_defender_to_ball  
count               78.000     78.000                    78.000  
mean                 1.805     26.099                     5.038  
std                  0.744      2.929                     2.505  
min                  0.446     21.122                     0.385  
25%                  1.133     23.610                     3.071  
50%                  1.855     26.099                     4.960  
75%                  2.364     28.587                     6.821  
max                  3.839     31.076                    11.208  

Team summary across 20 matches¶

Defending-team means of graph metrics and shot rate against, from analysis/output/team_summary.csv.

In [4]:
teams = pd.read_csv(OUT_DIR / "team_summary.csv")
summary = json.load(open(OUT_DIR / "contrast_summary.json"))
display_cols = [
    "match_id", "defending_team", "n_phases", "shot_rate_against",
    "mean_lambda2_defense", "mean_uncovered_danger", "mean_n_free_attackers",
    "mean_structural_pressure", "mean_oop_area", "mean_nearest_defender_to_ball",
]
print(teams[display_cols].sort_values("shot_rate_against", ascending=False).head(12).to_string(index=False))
print()
print("Shot rates by block (graph-sampled phases):")
for b, v in summary["shot_rate_by_block"].items():
    print(f"  {b}: {v['shot_rate']:.1%} ({v['n_shot']}/{v['n_phases']})")
print()
print("Features whose residual (after width/length/nearest-defender/pressure + block) CI excludes 0:")
print(" ", summary["features_beating_baseline"] or "(none)")
print("Features that do NOT beat the box-and-pressure baseline:")
print(" ", summary["features_not_beating_baseline"])
 match_id  defending_team  n_phases  shot_rate_against  mean_lambda2_defense  mean_uncovered_danger  mean_n_free_attackers  mean_structural_pressure  mean_oop_area  mean_nearest_defender_to_ball
  1959846  Melbourne V FC       140           0.207143              0.251845               1.911864               5.657132                  1.626632    1800.556985                       6.092460
  1927964 Wellington P FC       150           0.200000              0.143010               2.290897               6.175012                  1.607887    2026.337577                       6.375202
  1996435 Adelaide United       164           0.195122              0.141615               2.037747               5.627453                  1.761177    1938.644499                       5.550999
  2011166 Wellington P FC       143           0.188811              0.200135               2.047245               5.193934                  1.983392    1750.696453                       5.478573
  1959846  Western United       159           0.176101              0.154749               2.047040               5.586464                  1.816387    1837.509741                       5.545037
  2006363     CC Mariners       173           0.173410              0.246317               2.118338               5.656162                  1.779362    1702.965608                       5.720346
  1996436    Macarthur FC       119           0.168067              0.151494               2.124519               5.338771                  1.691303    1770.861099                       5.851349
  1996435       Sydney FC       150           0.153333              0.182537               2.126665               5.992843                  1.801118    1839.616909                       5.700682
  1986691  Western Sydney       137           0.153285              0.244521               1.958399               5.506127                  1.646656    1591.310512                       6.223015
  2017461  Melbourne V FC        85           0.152941              0.203178               2.028530               5.571630                  1.816643    1729.999785                       5.640844
  1874553 Adelaide United       103           0.145631              0.139508               2.038191               5.473265                  1.783686    2009.167654                       5.466609
  1925299     Perth Glory       175           0.142857              0.295637               2.073764               5.848034                  1.740868    1588.160552                       6.536022

Shot rates by block (graph-sampled phases):
  low_block: 18.7% (299/1599)
  medium_block: 9.3% (256/2763)
  high_block: 6.0% (72/1193)

Features whose residual (after width/length/nearest-defender/pressure + block) CI excludes 0:
  ['mean_lambda2_supra']
Features that do NOT beat the box-and-pressure baseline:
  ['mean_lambda2_defense', 'mean_n_free_attackers', 'mean_uncovered_danger', 'mean_structural_pressure', 'mean_structural_pressure_frac', 'lambda2_drop_pre2s']

Note on baselines and the 20-match limit¶

  • Box baselines win the headline. OOP width and width×length separate shot vs non-shot phases more cleanly than defense λ₂, free attackers, or uncovered danger once you residualize. In low block, mean OOP area is about 148 m² smaller in shot phases (bootstrap CI excludes 0).
  • Pressure is informative but collinear with the question. overall_pressure AUC ≈ 0.57 for shots; that is on-ball pressure, not coverage of the rest of the graph.
  • One residual association: mean_lambda2_supra retains a small residual difference after baselines (CI excludes 0), but primary structural-pressure and uncovered-danger features do not.
  • Pre-shot fracturing check: overall, λ₂ does not fall more in the last 2 s before shots than before sterile phases (diff shot−nons ≈ −0.022; if anything the opposite). By-block CIs include zero.
  • n = 20 matches. Phases are nested in matches. Treat every CI as exploratory.