All projects

Personal project · Football tracking data

11track

Sprint science applied to football. From a full professional match of tracking data, 11track measures how each player sprints, how explosive they are and how fast they could go, and whether they spend those sprints attacking or defending.

Sep 2026

A sprint profile for every player, from one match

11track

Overview

Professional clubs track every player 25 times a second, and mostly use it to count: kilometres covered, sprints made. 11track asks a sprinter's question of the same data. Not how much a player ran, but how they run: how hard they can push off from a standstill, how fast they are built to go, and when in the game they spend those sprints.

The method comes from sprint science. Morin and colleagues showed in 2021 (Journal of Biomechanics) that a player's acceleration–speed profile, normally measured with a dedicated sprint test, can be recovered from match data alone: take the hardest acceleration a player produced at each speed, and draw a straight line through them. 11track applies it to a full professional match released openly by Metrica Sports, then joins the tracking with the event data to see whether each sprint happened with the ball or chasing it.

The groundwork follows Laurie Shaw's Friends of Tracking tutorials, the open series that taught many analysts to work with exactly this Metrica match: turning positions into metres on the pitch, smoothing them with a Savitzky–Golay filter one half at a time, and building a physical summary for each player. 11track picks up where those tutorials stop: acceleration, the sprint profile, and the tactical context of every sprint.

What I built

From raw positions to speed and acceleration

  • Positions smoothed with a Savitzky–Golay filter, separately for each half and for each stretch a player is on the pitch, so substitutions and half time never get blurred together.
  • Tracking glitches removed: anything above 37.8 km/h, faster than any footballer runs, plus half a second either side, and accelerations above 6 m/s².

A physical report for every player

  • Distance, metres per minute, high-speed running above 19.8 km/h and sprints above 25.2 km/h held for at least a second, the thresholds football sports science uses.

The sprint profile, measured in the match

  • The in-situ acceleration–speed profile of Morin et al. (2021): the two hardest accelerations in every 0.2 m/s speed band, a straight line fitted above 3 m/s, points more than two standard deviations away dropped.
  • A0, the theoretical maximum acceleration, and S0, the theoretical top speed, for 24 outfield players, with fits under r² = 0.6 flagged as unreliable instead of shown as fact.

Tracking joined with events

  • Possession rebuilt from the event stream (the team of the last on-ball action has the ball), so every sprinted metre is labelled with or without the ball.
  • Each of the 24 shots put in its physical context: how many attackers sprinted in the ten seconds before it.

One anonymised match is a proof of concept, not a scouting report. Profiles become reliable when pooled over several games, and a player who never ran flat out gets a top speed that is an extrapolation, which the page flags every time it happens.

Player cards

The one-page report a performance coach could read the morning after the match. Pick a player: ordered by sprint distance, most first.

Inside the notebook

Read-only · outputs from sprint_profiling_tracking_data.ipynb

Six cells from the analysis: reading the tracking files, turning positions into speed and acceleration, the physical report, the sprint profiles, the squad map, and the possession split that gives every sprint its meaning.

In [ ]:
BASE = "https://raw.githubusercontent.com/metrica-sports/sample-data/master/data/Sample_Game_1/"

def load_tracking(team):
    f = f"{DATA}_RawTrackingData_{team}_Team.csv"
    hdr = pd.read_csv(f, nrows=3, header=None).iloc[2].tolist()
    …
    df = pd.read_csv(f, skiprows=3, header=None, names=cols)
    df[xs] = (df[xs] - 0.5) * PITCH_X          # metres, origin at centre spot
    df[ys] = -(df[ys] - 0.5) * PITCH_Y
    return df.set_index("Frame")

home, away = load_tracking("Home"), load_tracking("Away")
events = pd.read_csv(f"{DATA}_RawEventsData.csv")
print(home.shape, away.shape, events.shape)
In [ ]:
def kinematics(df, team):
    …
        for per, g in df.groupby("Period"):          # never smooth across half time
            …
                # contiguous segments only (substitutions create gaps)
                for seg in np.split(idx, np.where(np.diff(idx) > 1)[0] + 1):
                    vx[seg] = savgol_filter(x[seg], 25, 3, deriv=1, delta=1/FPS)
                    vy[seg] = savgol_filter(y[seg], 25, 3, deriv=1, delta=1/FPS)
            s = np.hypot(vx, vy)
            bad = np.isnan(s) | (s > 10.5)          # > 37.8 km/h: above elite top speed -> artefact
            bad = np.convolve(bad, np.ones(25), mode="same") > 0   # also drop 0.5 s around each artefact
            …
            a = np.gradient(s_sm, 1/FPS)
            a[np.abs(a) > 6] = np.nan               # implausible (>6 m/s^2 sustained)

kin = {**kinematics(home, "Home"), **kinematics(away, "Away")}
print(len(kin), "players processed")
In [ ]:
HSR, SPR = 5.5, 7.0          # 19.8 km/h and 25.2 km/h
rows = []
for p, k in kin.items():
    s = k["speed"].fillna(0).to_numpy()
    minutes = k["speed"].notna().sum() / FPS / 60
    if minutes < 20: continue
    dist = s.sum() / FPS
    rows.append(dict(player=p, minutes=round(minutes, 1), distance_km=round(dist/1000, 2),
        hsr_m=round(s[s > HSR].sum()/FPS), sprint_m=round(s[s > SPR].sum()/FPS),
        sprints=count_efforts(s, SPR), vmax_kmh=round(np.nanmax(k["speed"])*3.6, 1)))
phys = pd.DataFrame(rows).sort_values("sprint_m", ascending=False)
In [ ]:
def as_profile(k):
    d = k.dropna(); d = d[(d.speed > 3) & (d.acc > 0)]
    d = d.assign(bin=(d.speed // 0.2) * 0.2)
    top = d.sort_values("acc", ascending=False).groupby("bin").head(2)
    for _ in range(2):                              # drop points > 2 SD from the fit
        m, c = np.polyfit(top.speed, top.acc, 1)
        res = top.acc - (m * top.speed + c)
        top = top[np.abs(res) < 2 * res.std()]
    m, c = np.polyfit(top.speed, top.acc, 1)
    r2 = np.corrcoef(top.speed, top.acc)[0, 1] ** 2
    return dict(A0=c, S0=-c/m, r2=r2, pts=top)

prof = {p: (None if p in GOALKEEPERS else as_profile(kin[p])) for p in phys.player}
…
plt.savefig("as_profiles.png")
In [ ]:
ok = phys[phys.r2 >= 0.6]   # keep only reliable fits
c = ["#7A0C0C" if p.startswith("Home") else "#1f4e79" for p in ok.player]
ax.scatter(ok.S0_ms, ok.A0_ms2, c=c, s=60)
ax.axvline(ok.S0_ms.median(), ls=":", c="grey"); ax.axhline(ok.A0_ms2.median(), ls=":", c="grey")
ax.set(xlabel="S0 – theoretical max speed (m/s)", ylabel="A0 – theoretical max acceleration (m/s²)")
plt.savefig("as_map.png")
In [ ]:
on_ball = events[events.Type.isin(["PASS", "SHOT", "SET PIECE", "RECOVERY", "BALL LOST", "CHALLENGE"])]
poss = pd.Series(np.nan, index=home.index, dtype=object)
for _, e in on_ball.iterrows():
    poss.loc[e["Start Frame"]] = e.Team            # the last team to touch it has it
for _, e in events[events.Type == "BALL OUT"].iterrows():
    poss.loc[e["Start Frame"]] = "Dead"
poss = poss.ffill().replace("Dead", np.nan)
…
ctx["share_out_of_poss"] = (ctx.sprint_out_poss_m / (ctx.sprint_in_poss_m + ctx.sprint_out_poss_m)).round(2)