Practice · Baseball · Simulated for practice

Pitch movement and whiffs

Eight invented pitchers and about 4,800 pitches: type, velocity, horizontal break, induced vertical break, location, swing and whiff. The raw material for pitch comps, arsenal reports and a whiff model.

The data

4,795 rows, 9 columns.

pitcherstring
Invented pitcher name
pitch_typestring
FF four-seam, SI sinker, SL slider, CH changeup, CU curveball, FC cutter
release_speednumber · mph
Speed out of the hand
hb_innumber · in
Horizontal break, arm side positive
ivb_innumber · in
Induced vertical break (movement against gravity alone)
plate_xnumber · ft
Horizontal location at the plate, catcher's view, 0 is the middle
plate_znumber · ft
Height at the plate
swinginteger · 0/1
The batter swung
whiffinteger · 0/1
The batter swung and missed

Simulated for practice. Simulated for practice by AthlyticZ (agents/sim_data.py, seed 20261109). License CC BY 4.0. Generated from a documented model with realistic distributions; every name is invented.

Reference chart

The chart, and the code that draws it.

Edit the code and press Run to recompute it in your browser.

This chart is the code's result. Run recomputes it in your browser.
Horizontal break against induced vertical break, one cloud per pitch type, contours of where each pitch lives. Pick a pitcher; hover for the averages.

Edit me: Change the sample size from 18 to 120 per pitch and run again, or add a column to the summary.

Data: Pitch movement and whiffs (pitch_movement.csv). Simulated for practice by AthlyticZ (agents/sim_data.py, seed 20261109). License CC BY 4.0.

The code runs in your browser on webR or Pyodide; nothing you type leaves the page.

Starter code

import pandas as pd

pitches = pd.read_csv("data/pitch_movement.csv")
pitches.groupby(["pitcher", "pitch_type"]).size().unstack(fill_value=0)
Three questions

Easy, medium, hard.

Try each before opening the hint; open the worked solution only after your own attempt.

  1. Easy

    Build each pitcher's arsenal: usage, average velocity, horizontal break and induced vertical break for every pitch type.

    Hint

    Group by pitcher and pitch type, aggregate, then turn counts into shares within each pitcher with a grouped transform.

    Worked solution
    import pandas as pd
    
    p = pd.read_csv("data/pitch_movement.csv")
    arsenal = (p.groupby(["pitcher", "pitch_type"], as_index=False)
                 .agg(n=("hb_in", "size"), velo=("release_speed", "mean"), hb=("hb_in", "mean"), ivb=("ivb_in", "mean"))
                 .assign(usage=lambda d: d.n / d.groupby("pitcher").n.transform("sum"))
                 .round(2))
    print(arsenal.head(10).to_string(index=False))

    Output, from running this code on the dataset when the page was built

    pitcher pitch_type   n  velo    hb    ivb  usage
    Caden Okonkwo-Reyes         CH 112 84.48 14.54   7.35   0.17
    Caden Okonkwo-Reyes         FF 270 94.94  9.45  14.39   0.40
    Caden Okonkwo-Reyes         SI 123 91.98 15.87   9.86   0.18
    Caden Okonkwo-Reyes         SL 165 86.98 -5.92   1.74   0.25
        Felix Dunmarrow         CH 100 84.96 13.59   5.84   0.17
        Felix Dunmarrow         CU  74 78.57 -9.14 -12.68   0.13
        Felix Dunmarrow         FF 254 94.55  6.47  16.90   0.43
        Felix Dunmarrow         SL 158 84.94 -5.39  -0.29   0.27
      Isaac Vantongeren         CH 131 84.65 13.71   5.95   0.21
      Isaac Vantongeren         FF 325 92.92  4.38  14.10   0.51
  2. Medium

    Which two pitchers throw the most similar slider? Standardize velocity and the two breaks across all sliders, and find the closest pair of pitcher averages.

    Hint

    Average each pitcher's sliders, z-score the three columns, then compute all pairwise distances with scipy.spatial.distance.pdist and take the smallest.

    Worked solution
    import numpy as np
    import pandas as pd
    from scipy.spatial.distance import pdist, squareform
    
    p = pd.read_csv("data/pitch_movement.csv").query("pitch_type == 'SL'")
    avg = p.groupby("pitcher")[["release_speed", "hb_in", "ivb_in"]].mean()
    z = (avg - avg.mean()) / avg.std()
    d = pd.DataFrame(squareform(pdist(z)), index=avg.index, columns=avg.index)
    np.fill_diagonal(d.values, np.inf)
    a, b = np.unravel_index(np.argmin(d.values), d.shape)
    print(f"most similar sliders: {d.index[a]} and {d.columns[b]}, distance {d.values[a, b]:.2f}")
    print(avg.loc[[d.index[a], d.columns[b]]].round(1).to_string())

    Output, from running this code on the dataset when the page was built

    most similar sliders: Caden Okonkwo-Reyes and Luis Vantongeren, distance 0.91
                         release_speed  hb_in  ivb_in
    pitcher                                          
    Caden Okonkwo-Reyes           87.0   -5.9     1.7
    Luis Vantongeren              86.8   -4.8     2.2
  3. Hard

    Model whiffs: on swings only, fit a logistic regression of whiff on pitch type, velocity, both breaks and location. Hold out 30 percent and compare the log loss with a model that knows only the overall whiff rate.

    Hint

    One-hot the pitch type, describe location with an in-zone flag and how far below two feet the pitch was, and use scikit-learn's train_test_split and log_loss.

    Worked solution
    import numpy as np
    import pandas as pd
    from sklearn.linear_model import LogisticRegression
    from sklearn.metrics import log_loss
    from sklearn.model_selection import train_test_split
    
    s = pd.read_csv("data/pitch_movement.csv").query("swing == 1")
    X = pd.get_dummies(s.pitch_type, prefix="pt").assign(
        velo=s.release_speed, hb=s.hb_in, ivb=s.ivb_in,
        in_zone=(s.plate_x.abs() <= 0.83) & s.plate_z.between(1.5, 3.5), below=(2.0 - s.plate_z).clip(lower=0)).astype(float)
    Xtr, Xte, ytr, yte = train_test_split(X, s.whiff, test_size=0.3, random_state=11, stratify=s.whiff)
    m = LogisticRegression(max_iter=2000).fit((Xtr - Xtr.mean()) / Xtr.std(), ytr)
    p_model = m.predict_proba((Xte - Xtr.mean()) / Xtr.std())[:, 1]
    p_base = np.full(len(yte), ytr.mean())
    print(f"held-out log loss: model {log_loss(yte, p_model):.4f}, overall rate {log_loss(yte, p_base):.4f}")

    Output, from running this code on the dataset when the page was built

    held-out log loss: model 0.6002, overall rate 0.6031
Extension

Bring in an AI agent.

Ask an agent to turn your medium answer into a function that returns the three closest comps for any pitcher and pitch type, with tests. Then ask it to explain, from your hard answer's coefficients, which pitch type and location raise whiff odds most; check the sign of each claim against the fitted model before you accept it.

Where the method is taught

The Armchair Scout. Every dataset in the practice library names its session the same way.

Keep up

One email when something new arrives.

A short welcome note, a few session picks over two weeks, then only the updates you signed up for. Reply stop any time.