Predicting Batter Swing Probability Using Pitch Characteristics and Game Context¶


embed-resources: true echo: false warning: false message: false¶

Introduction¶

Baseball analytics has become an important tool for teams that looking for a competitive edge, particularly in understanding the interaction between pitchers and batters. One key aspect of this interaction is whether a batter chooses to swing at a given pitch, which can influence the outcome of an at-bat. Unlike many traditional classification problems, this task is more about accurate probability estimation rather than simple prediciton, as team rely on well-calibrated probabilities to inform strategic decisions, and poorly calibrated models can lead to misleading conclusions about pitch effectiveness. This report investigates whether machine learning models can effectively estimate the probability that a batter swings at a pitch using pitch characteristics and game context data. The dataset consists of pitch-level observations from Zac Gallen during teh 2023 MLB season, including features such as pitch type, velocity, location, movement, and situational variables like count and base runners. The goal is to develop a well-calibrated model that produces reliable probability estimates, enabling better decision-making for pitch strategies in real-world scenarios.

Methods¶

In [1]:
# imports
import pandas as pd
import numpy as np
from joblib import dump
import matplotlib.pyplot as plt
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.calibration import CalibratedClassifierCV
from sklearn.metrics import brier_score_loss
from sklearn.calibration import calibration_curve
from sklearn.ensemble import HistGradientBoostingClassifier

Data¶

In [2]:
# load data
swing_train = pd.read_csv(
    "swing-train.csv",
)
swing_test = pd.read_csv(
    "swing-test.csv",
)

The dataset contains pitch-level information from Zac Gallen during the 2023 MLB regular season. Each observation represents a single pitch and includes the following variables:

Category Variable Type Description
Response swing int64 Whether or not the batter swung (1) or took (0).
Fully Pitcher Controlled pitch_name object The name of the pitch type to be thrown.
Mostly Pitcher Controlled release_extension float64 Release extension of pitch in feet as tracked by Statcast.
Mostly Pitcher Controlled release_pos_x float64 Horizontal release position of the ball measured in feet from the catcher’s perspective.
Mostly Pitcher Controlled release_pos_y float64 Release position of pitch measured in feet from the catcher’s perspective.
Mostly Pitcher Controlled release_pos_z float64 Vertical release position of the ball measured in feet from the catcher’s perspective.
Somewhat Pitcher Controlled release_speed float64 Velocity of the pitch thrown (miles per hour).
Somewhat Pitcher Controlled release_spin_rate float64 Spin rate of pitch tracked by Statcast (revolutions per minute).
Somewhat Pitcher Controlled spin_axis float64 The spin axis in the 2D X-Z plane in degrees from 0 to 360, such that 180 represents a pure backspin fastball and 0 degrees represents a pure topspin (12-6) curveball.
Somewhat Pitcher Controlled plate_x float64 Horizontal position of the ball when it crosses home plate from the catcher’s perspective (feet).
Somewhat Pitcher Controlled plate_z float64 Vertical position of the ball when it crosses home plate from the catcher’s perspective (feet).
Downstream Pitcher Controlled pfx_x float64 Horizontal movement in feet from the catcher’s perspective.
Downstream Pitcher Controlled pfx_z float64 Vertical movement in feet from the catcher’s perspective.
Situational Information balls int64 Pre-pitch number of balls in count.
Situational Information strikes int64 Pre-pitch number of strikes in count.
Situational Information on_3b int64 Indicator (0 or 1) for runner on 3rd base.
Situational Information on_2b int64 Indicator (0 or 1) for runner on 2nd base.
Situational Information on_1b int64 Indicator (0 or 1) for runner on 1st base.
Situational Information outs_when_up int64 Pre-pitch number of outs.
Fixed Batter Information stand object Side of the plate batter is standing, either L (left) or R (right).
Fixed Batter Information sz_top float64 Top of the batter’s strike zone set by the operator when the ball is halfway to the plate (feet).
Fixed Batter Information sz_bot float64 Bottom of the batter’s strike zone set by the operator when the ball is halfway to the plate (feet).

Shape of the dataset:

In [3]:
# summary statistics
swing_train.shape
Out[3]:
(2663, 22)

The proportion of whether or not the batter swung or took:

In [4]:
swing_train["swing"].value_counts(normalize=True)
Out[4]:
swing
0    0.522719
1    0.477281
Name: proportion, dtype: float64

The proportion of each pitch is swung at or not:

In [5]:
swing_train.groupby("pitch_name")["swing"].value_counts(normalize=True)
Out[5]:
pitch_name       swing
4-Seam Fastball  0        0.529096
                 1        0.470904
Changeup         1        0.536313
                 0        0.463687
Cutter           0        0.548523
                 1        0.451477
Knuckle Curve    0        0.537125
                 1        0.462875
Slider           0        0.503876
                 1        0.496124
Name: proportion, dtype: float64

My summary statistics show that the dataset contains 2,663 pitches and 21 features. I computed the proportion of pitches where the batter swung or did not swing, finding that swings occur in approximately 47.7% of pitches, while batters take pitches about 52.3% of the time, indicating a fairly balanced response variable. I also examined how swing behavior varies by pitch type, observing that certain pitches, such as changeups, are more likely to induce swings compared to others like cutters and knuckle curves. These differences suggest that pitch type plays an important role in influencing batter decisions and will likely be an important feature for modeling swing probability.

In [6]:
#| fig-cap: "Figure 1. Swing Rate by Pitch Type in the Training Data"
# exploratory visualization
swing_rate = swing_train.groupby("pitch_name")["swing"].mean()

swing_rate.plot(kind="bar")

plt.xlabel("Pitch Type")
plt.ylabel("Swing Rate")
plt.title("Average Swing Probability by Pitch Type")

plt.xticks(rotation=45)

plt.show()
No description has been provided for this image

Models¶

In [7]:
# process data for ML
# create X and y for train
X_train = swing_train.drop("swing", axis=1)
y_train = swing_train["swing"]

# create X and y for test
X_test = swing_test.drop("swing", axis=1)
y_test = swing_test["swing"]
In [8]:
# train models
X_train = swing_train.drop("swing", axis=1).fillna(0)
y_train = swing_train["swing"]

cat_cols = ["pitch_name", "stand"]

preprocessor = ColumnTransformer(
    [("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols)],
    remainder="passthrough"
)

base_model = Pipeline([
    ("preprocessor", preprocessor),
    ("model", HistGradientBoostingClassifier(max_iter=200, max_depth=5, random_state=42))
])

model = CalibratedClassifierCV(base_model, method="sigmoid", cv=5)

model.fit(X_train, y_train)
Out[8]:
CalibratedClassifierCV(cv=5,
                       estimator=Pipeline(steps=[('preprocessor',
                                                  ColumnTransformer(remainder='passthrough',
                                                                    transformers=[('cat',
                                                                                   OneHotEncoder(handle_unknown='ignore'),
                                                                                   ['pitch_name',
                                                                                    'stand'])])),
                                                 ('model',
                                                  HistGradientBoostingClassifier(max_depth=5,
                                                                                 max_iter=200,
                                                                                 random_state=42))]))
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Parameters
estimator estimator: estimator instance, default=None

The classifier whose output need to be calibrated to provide more
accurate `predict_proba` outputs. The default classifier is
a :class:`~sklearn.svm.LinearSVC`.

.. versionadded:: 1.2
Pipeline(step...m_state=42))])
cv cv: int, cross-validation generator, or iterable, default=None

Determines the cross-validation splitting strategy.
Possible inputs for cv are:

- None, to use the default 5-fold cross-validation,
- integer, to specify the number of folds,
- :term:`CV splitter`,
- an iterable yielding (train, test) splits as arrays of indices.

For integer/None inputs, if ``y`` is binary or multiclass,
:class:`~sklearn.model_selection.StratifiedKFold` is used. If ``y`` is
neither binary nor multiclass, :class:`~sklearn.model_selection.KFold`
is used.

Refer to the :ref:`User Guide <cross_validation>` for the various
cross-validation strategies that can be used here.

.. versionchanged:: 0.22
``cv`` default value if None changed from 3-fold to 5-fold.
5
method method: {'sigmoid', 'isotonic', 'temperature'}, default='sigmoid'

The method to use for calibration. Can be:

- 'sigmoid', which corresponds to Platt's method (i.e. a binary logistic
regression model).
- 'isotonic', which is a non-parametric approach.
- 'temperature', temperature scaling.

Sigmoid and isotonic calibration methods natively support only binary
classifiers and extend to multi-class classification using a One-vs-Rest (OvR)
strategy with post-hoc renormalization, i.e., adjusting the probabilities after
calibration to ensure they sum up to 1.

In contrast, temperature scaling naturally supports multi-class calibration by
applying `softmax(classifier_logits/T)` with a value of `T` (temperature)
that optimizes the log loss.

For very uncalibrated classifiers on very imbalanced datasets, sigmoid
calibration might be preferred because it fits an additional intercept
parameter. This helps shift decision boundaries appropriately when the
classifier being calibrated is biased towards the majority class.

Isotonic calibration is not recommended when the number of calibration samples
is too low ``(≪1000)`` since it then tends to overfit.

.. versionchanged:: 1.8
Added option 'temperature'.
'sigmoid'
n_jobs n_jobs: int, default=None

Number of jobs to run in parallel.
``None`` means 1 unless in a :obj:`joblib.parallel_backend` context.
``-1`` means using all processors.

Base estimator clones are fitted in parallel across cross-validation
iterations.

See :term:`Glossary <n_jobs>` for more details.

.. versionadded:: 0.24
None
ensemble ensemble: bool, or "auto", default="auto"

Determines how the calibrator is fitted.

"auto" will use `False` if the `estimator` is a
:class:`~sklearn.frozen.FrozenEstimator`, and `True` otherwise.

If `True`, the `estimator` is fitted using training data, and
calibrated using testing data, for each `cv` fold. The final estimator
is an ensemble of `n_cv` fitted classifier and calibrator pairs, where
`n_cv` is the number of cross-validation folds. The output is the
average predicted probabilities of all pairs.

If `False`, `cv` is used to compute unbiased predictions, via
:func:`~sklearn.model_selection.cross_val_predict`, which are then
used for calibration. At prediction time, the classifier used is the
`estimator` trained on all the data.
Note that this method is also internally implemented in
:mod:`sklearn.svm` estimators with the `probabilities=True` parameter.

.. versionadded:: 0.24

.. versionchanged:: 1.6
`"auto"` option is added and is the default.
'auto'
Fitted attributes
Name Type Value
calibrated_classifiers_ calibrated_classifiers_: list (len() equal to cv or 1 if `ensemble=False`)

The list of classifier and calibrator pairs.

- When `ensemble=True`, `n_cv` fitted `estimator` and calibrator pairs.
`n_cv` is the number of cross-validation folds.
- When `ensemble=False`, the `estimator`, fitted on all the data, and fitted
calibrator.

.. versionchanged:: 0.24
Single calibrated classifier case when `ensemble=False`.
list [<sklearn.cali...002A168B13770>, <sklearn.cali...002A168B4B750>, <sklearn.cali...002A168B4B890>, <sklearn.cali...002A168B36B10>, ...]
classes_ classes_: ndarray of shape (n_classes,)

The class labels.
ndarray[int64](2,) [0,1]
feature_names_in_ feature_names_in_: ndarray of shape (`n_features_in_`,)

Names of features seen during :term:`fit`. Only defined if the
underlying estimator exposes such an attribute when fit.

.. versionadded:: 1.0
ndarray[object](21,) ['pitch_name','release_extension','release_pos_x',...,'stand','sz_top', 'sz_bot']
n_features_in_ n_features_in_: int

Number of features seen during :term:`fit`. Only defined if the
underlying estimator exposes such an attribute when fit.

.. versionadded:: 0.24
int 21
Parameters
transformers transformers: list of tuples

List of (name, transformer, columns) tuples specifying the
transformer objects to be applied to subsets of the data.

name : str
Like in Pipeline and FeatureUnion, this allows the transformer and
its parameters to be set using ``set_params`` and searched in grid
search.
transformer : {'drop', 'passthrough'} or estimator
Estimator must support :term:`fit` and :term:`transform`.
Special-cased strings 'drop' and 'passthrough' are accepted as
well, to indicate to drop the columns or to pass them through
untransformed, respectively.
columns : str, array-like of str, int, array-like of int, array-like of bool, slice or callable
Indexes the data on its second axis. Integers are interpreted as
positional columns, while strings can reference DataFrame columns
by name. A scalar string or int should be used where
``transformer`` expects X to be a 1d array-like (vector),
otherwise a 2d array will be passed to the transformer.
A callable is passed the input data `X` and can return any of the
above. To select multiple columns by name or dtype, you can use
:obj:`make_column_selector`.
[('cat', ...)]
remainder remainder: {'drop', 'passthrough'} or estimator, default='drop'

By default, only the specified columns in `transformers` are
transformed and combined in the output, and the non-specified
columns are dropped. (default of ``'drop'``).
By specifying ``remainder='passthrough'``, all remaining columns that
were not specified in `transformers`, but present in the data passed
to `fit` will be automatically passed through. This subset of columns
is concatenated with the output of the transformers. For dataframes,
extra columns not seen during `fit` will be excluded from the output
of `transform`.
By setting ``remainder`` to be an estimator, the remaining
non-specified columns will use the ``remainder`` estimator. The
estimator must support :term:`fit` and :term:`transform`.
Note that using this feature requires that the DataFrame columns
input at :term:`fit` and :term:`transform` have identical order.
'passthrough'
sparse_threshold sparse_threshold: float, default=0.3

If the output of the different transformers contains sparse matrices,
these will be stacked as a sparse matrix if the overall density is
lower than this value. Use ``sparse_threshold=0`` to always return
dense. When the transformed output consists of all dense data, the
stacked result will be dense, and this keyword will be ignored.
0.3
n_jobs n_jobs: int, default=None

Number of jobs to run in parallel.
``None`` means 1 unless in a :obj:`joblib.parallel_backend` context.
``-1`` means using all processors. See :term:`Glossary <n_jobs>`
for more details.
None
transformer_weights transformer_weights: dict, default=None

Multiplicative weights for features per transformer. The output of the
transformer is multiplied by these weights. Keys are transformer names,
values the weights.
None
verbose verbose: bool, default=False

If True, the time elapsed while fitting each transformer will be
printed as it is completed.
False
verbose_feature_names_out verbose_feature_names_out: bool, str or Callable[[str, str], str], default=True

- If True, :meth:`ColumnTransformer.get_feature_names_out` will prefix
all feature names with the name of the transformer that generated that
feature. It is equivalent to setting
`verbose_feature_names_out="{transformer_name}__{feature_name}"`.
- If False, :meth:`ColumnTransformer.get_feature_names_out` will not
prefix any feature names and will error if feature names are not
unique.
- If ``Callable[[str, str], str]``,
:meth:`ColumnTransformer.get_feature_names_out` will rename all the features
using the name of the transformer. The first argument of the callable is the
transformer name and the second argument is the feature name. The returned
string will be the new feature name.
- If ``str``, it must be a string ready for formatting. The given string will
be formatted using two field names: ``transformer_name`` and ``feature_name``.
e.g. ``"{feature_name}__{transformer_name}"``. See :meth:`str.format` method
from the standard library for more info.

.. versionadded:: 1.0

.. versionchanged:: 1.6
`verbose_feature_names_out` can be a callable or a string to be formatted.
True
['pitch_name', 'stand']
Parameters
handle_unknown handle_unknown: {'error', 'ignore', 'infrequent_if_exist', 'warn'}, default='error'

Specifies the way unknown categories are handled during :meth:`transform`.

- 'error' : Raise an error if an unknown category is present during transform.
- 'ignore' : When an unknown category is encountered during
transform, the resulting one-hot encoded columns for this feature
will be all zeros. In the inverse transform, an unknown category
will be denoted as None.
- 'infrequent_if_exist' : When an unknown category is encountered
during transform, the resulting one-hot encoded columns for this
feature will map to the infrequent category if it exists. The
infrequent category will be mapped to the last position in the
encoding. During inverse transform, an unknown category will be
mapped to the category denoted `'infrequent'` if it exists. If the
`'infrequent'` category does not exist, then :meth:`transform` and
:meth:`inverse_transform` will handle an unknown category as with
`handle_unknown='ignore'`. Infrequent categories exist based on
`min_frequency` and `max_categories`. Read more in the
:ref:`User Guide <encoder_infrequent_categories>`.
- 'warn' : When an unknown category is encountered during transform
a warning is issued, and the encoding then proceeds as described for
`handle_unknown="infrequent_if_exist"`.

.. versionchanged:: 1.1
`'infrequent_if_exist'` was added to automatically handle unknown
categories and infrequent categories.

.. versionadded:: 1.6
The option `"warn"` was added in 1.6.
'ignore'
categories categories: 'auto' or a list of array-like, default='auto'

Categories (unique values) per feature:

- 'auto' : Determine categories automatically from the training data.
- list : ``categories[i]`` holds the categories expected in the ith
column. The passed categories should not mix strings and numeric
values within a single feature, and should be sorted in case of
numeric values.

The used categories can be found in the ``categories_`` attribute.

.. versionadded:: 0.20
'auto'
drop drop: {'first', 'if_binary'} or an array-like of shape (n_features,), default=None

Specifies a methodology to use to drop one of the categories per
feature. This is useful in situations where perfectly collinear
features cause problems, such as when feeding the resulting data
into an unregularized linear regression model.

However, dropping one category breaks the symmetry of the original
representation and can therefore induce a bias in downstream models,
for instance for penalized linear classification or regression models.

- None : retain all features (the default).
- 'first' : drop the first category in each feature. If only one
category is present, the feature will be dropped entirely.
- 'if_binary' : drop the first category in each feature with two
categories. Features with 1 or more than 2 categories are
left intact.
- array : ``drop[i]`` is the category in feature ``X[:, i]`` that
should be dropped.

When `max_categories` or `min_frequency` is configured to group
infrequent categories, the dropping behavior is handled after the
grouping.

.. versionadded:: 0.21
The parameter `drop` was added in 0.21.

.. versionchanged:: 0.23
The option `drop='if_binary'` was added in 0.23.

.. versionchanged:: 1.1
Support for dropping infrequent categories.
None
sparse_output sparse_output: bool, default=True

When ``True``, it returns a SciPy sparse matrix/array
in "Compressed Sparse Row" (CSR) format.

.. versionadded:: 1.2
`sparse` was renamed to `sparse_output`
True
dtype dtype: number type, default=np.float64

Desired dtype of output.
<class 'numpy.float64'>
min_frequency min_frequency: int or float, default=None

Specifies the minimum frequency below which a category will be
considered infrequent.

- If `int`, categories with a smaller cardinality will be considered
infrequent.

- If `float`, categories with a smaller cardinality than
`min_frequency * n_samples` will be considered infrequent.

.. versionadded:: 1.1
Read more in the :ref:`User Guide <encoder_infrequent_categories>`.
None
max_categories max_categories: int, default=None

Specifies an upper limit to the number of output features for each input
feature when considering infrequent categories. If there are infrequent
categories, `max_categories` includes the category representing the
infrequent categories along with the frequent categories. If `None`,
there is no limit to the number of output features.

.. versionadded:: 1.1
Read more in the :ref:`User Guide <encoder_infrequent_categories>`.
None
feature_name_combiner feature_name_combiner: "concat" or callable, default="concat"

Callable with signature `def callable(input_feature, category)` that returns a
string. This is used to create feature names to be returned by
:meth:`get_feature_names_out`.

`"concat"` concatenates encoded feature name and category with
`feature + "_" + str(category)`.E.g. feature X with values 1, 6, 7 create
feature names `X_1, X_6, X_7`.

.. versionadded:: 1.3
'concat'
Parameters
max_iter max_iter: int, default=100

The maximum number of iterations of the boosting process, i.e. the
maximum number of trees for binary classification. For multiclass
classification, `n_classes` trees per iteration are built.
200
max_depth max_depth: int or None, default=None

The maximum depth of each tree. The depth of a tree is the number of
edges to go from the root to the deepest leaf.
Depth isn't constrained by default.
5
random_state random_state: int, RandomState instance or None, default=None

Pseudo-random number generator to control the subsampling in the
binning process, and the train/validation data split if early stopping
is enabled.
Pass an int for reproducible output across multiple function calls.
See :term:`Glossary <random_state>`.
42
loss loss: {'log_loss'}, default='log_loss'

The loss function to use in the boosting process.

For binary classification problems, 'log_loss' is also known as logistic loss,
binomial deviance or binary crossentropy. Internally, the model fits one tree
per boosting iteration and uses the logistic sigmoid function (expit) as
inverse link function to compute the predicted positive class probability.

For multiclass classification problems, 'log_loss' is also known as multinomial
deviance or categorical crossentropy. Internally, the model fits one tree per
boosting iteration and per class and uses the softmax function as inverse link
function to compute the predicted probabilities of the classes.
'log_loss'
learning_rate learning_rate: float, default=0.1

The learning rate, also known as *shrinkage*. This is used as a
multiplicative factor for the leaves values. Use ``1`` for no
shrinkage.
0.1
max_leaf_nodes max_leaf_nodes: int or None, default=31

The maximum number of leaves for each tree. Must be strictly greater
than 1. If None, there is no maximum limit.
31
min_samples_leaf min_samples_leaf: int, default=20

The minimum number of samples per leaf. For small datasets with less
than a few hundred samples, it is recommended to lower this value
since only very shallow trees would be built.
20
l2_regularization l2_regularization: float, default=0

The L2 regularization parameter penalizing leaves with small hessians.
Use ``0`` for no regularization (default).
0.0
max_features max_features: float, default=1.0

Proportion of randomly chosen features in each and every node split.
This is a form of regularization, smaller values make the trees weaker
learners and might prevent overfitting.
If interaction constraints from `interaction_cst` are present, only allowed
features are taken into account for the subsampling.

.. versionadded:: 1.4
1.0
max_bins max_bins: int, default=255

The maximum number of bins to use for non-missing values. Before
training, each feature of the input array `X` is binned into
integer-valued bins, which allows for a much faster training stage.
Features with a small number of unique values may use less than
``max_bins`` bins. In addition to the ``max_bins`` bins, one more bin
is always reserved for missing values. Must be no larger than 255.
255
categorical_features categorical_features: array-like of {bool, int, str} of shape (n_features) or shape (n_categorical_features,), default='from_dtype'

Indicates the categorical features.

- None : no feature will be considered categorical.
- boolean array-like : boolean mask indicating categorical features.
- integer array-like : integer indices indicating categorical
features.
- str array-like: names of categorical features (assuming the training
data has feature names).
- `"from_dtype"`: dataframe columns with dtype "Categorical" and "Enum" are
considered to be categorical features. The input must be a dataframe that
is supported by narwhals (or supports it): :func:`narwhals.from_native` must
work. This is the case, for instance, for pandas and polars DataFrames.

For each categorical feature, there must be at most `max_bins` unique
categories. Negative values for categorical features encoded as numeric
dtypes are treated as missing values. All categorical values are
converted to floating point numbers. This means that categorical values
of 1.0 and 1 are treated as the same category.

Read more in the :ref:`User Guide <categorical_support_gbdt>`.

.. versionadded:: 0.24

.. versionchanged:: 1.2
Added support for feature names.

.. versionchanged:: 1.4
Added `"from_dtype"` option.

.. versionchanged:: 1.6
The default value changed from `None` to `"from_dtype"`.
'from_dtype'
monotonic_cst monotonic_cst: array-like of int of shape (n_features) or dict, default=None

Monotonic constraint to enforce on each feature are specified using the
following integer values:

- 1: monotonic increase
- 0: no constraint
- -1: monotonic decrease

If a dict with str keys, map feature to monotonic constraints by name.
If an array, the features are mapped to constraints by position. See
:ref:`monotonic_cst_features_names` for a usage example.

The constraints are only valid for binary classifications and hold
over the probability of the positive class.
Read more in the :ref:`User Guide <monotonic_cst_gbdt>`.

.. versionadded:: 0.23

.. versionchanged:: 1.2
Accept dict of constraints with feature names as keys.
None
interaction_cst interaction_cst: {"pairwise", "no_interactions"} or sequence of lists/tuples/sets of int, default=None

Specify interaction constraints, the sets of features which can
interact with each other in child node splits.

Each item specifies the set of feature indices that are allowed
to interact with each other. If there are more features than
specified in these constraints, they are treated as if they were
specified as an additional set.

The strings "pairwise" and "no_interactions" are shorthands for
allowing only pairwise or no interactions, respectively.

For instance, with 5 features in total, `interaction_cst=[{0, 1}]`
is equivalent to `interaction_cst=[{0, 1}, {2, 3, 4}]`,
and specifies that each branch of a tree will either only split
on features 0 and 1 or only split on features 2, 3 and 4.

See :ref:`this example<ice-vs-pdp>` on how to use `interaction_cst`.

.. versionadded:: 1.2
None
warm_start warm_start: bool, default=False

When set to ``True``, reuse the solution of the previous call to fit
and add more estimators to the ensemble. For results to be valid, the
estimator should be re-trained on the same data only.
See :term:`the Glossary <warm_start>`.
False
early_stopping early_stopping: 'auto' or bool, default='auto'

If 'auto', early stopping is enabled if the sample size is larger than
10000 or if `X_val` and `y_val` are passed to `fit`. If True, early stopping
is enabled, otherwise early stopping is disabled.

.. versionadded:: 0.23
'auto'
scoring scoring: str or callable or None, default='loss'

Scoring method to use for early stopping. Only used if `early_stopping`
is enabled. Options:

- str: see :ref:`scoring_string_names` for options.
- callable: a scorer callable object (e.g., function) with signature
``scorer(estimator, X, y)``. See :ref:`scoring_callable` for details.
- `None`: :ref:`accuracy <accuracy_score>` is used.
- 'loss': early stopping is checked w.r.t the loss value.
'loss'
validation_fraction validation_fraction: int or float or None, default=0.1

Proportion (or absolute size) of training data to set aside as
validation data for early stopping. If None, early stopping is done on
the training data.
The value is ignored if either early stopping is not performed, e.g.
`early_stopping=False`, or if `X_val` and `y_val` are passed to fit.
0.1
n_iter_no_change n_iter_no_change: int, default=10

Used to determine when to "early stop". The fitting process is
stopped when none of the last ``n_iter_no_change`` scores are better
than the ``n_iter_no_change - 1`` -th-to-last one, up to some
tolerance. Only used if early stopping is performed.
10
tol tol: float, default=1e-7

The absolute tolerance to use when comparing scores. The higher the
tolerance, the more likely we are to early stop: higher tolerance
means that it will be harder for subsequent iterations to be
considered an improvement upon the reference score.
1e-07
verbose verbose: int, default=0

The verbosity level. If not zero, print some information about the
fitting process. ``1`` prints only summary info, ``2`` prints info per
iteration.
0
class_weight class_weight: dict or 'balanced', default=None

Weights associated with classes in the form `{class_label: weight}`.
If not given, all classes are supposed to have weight one.
The "balanced" mode uses the values of y to automatically adjust
weights inversely proportional to class frequencies in the input data
as `n_samples / (n_classes * np.bincount(y))`.
Note that these weights will be multiplied with sample_weight (passed
through the fit method) if `sample_weight` is specified.

.. versionadded:: 1.2
None

The swing prediction task was treated as a binary classification problem, where the goal is to predict whether a batter swings at a pitch or not. A Histogram-Based Gradient Boosting classifier was used due to its strong performance on structured data and its ability to capture nonlinear relationships between pitch characteristics and batter behavior. The model was trained with 200 boosting iterations and a maximum tree depth of 5, allowing it to learn complex patterns while maintaining generalization. Since the dataset contains both categorical and numerical features, a preprocessing pipeline was implemented using a ColumnTransformer, where categorical variables such as pitch type and batter stance were encoded using one-hot encoding, and numerical features were passed through unchanged. To ensure that the predicted probabilities are reliable, the model was further calibrated using CalibratedClassifierCV with sigmoid calibration and 5-fold cross-validation. The model was trained on the full training dataset and then used to generate predictions on unseen test data to evaluate its performance.

Results¶

In [9]:
def calibration_error(y_true, y_prob, type="expected", n_bins=10):
    bins = np.linspace(0.0, 1.0, n_bins + 1)
    binids = np.searchsorted(bins[1:-1], y_prob)

    bin_sums = np.bincount(binids, weights=y_prob, minlength=len(bins))
    bin_true = np.bincount(binids, weights=y_true, minlength=len(bins))
    bin_total = np.bincount(binids, minlength=len(bins))

    nonzero = bin_total != 0
    prob_true = bin_true[nonzero] / bin_total[nonzero]
    prob_pred = bin_sums[nonzero] / bin_total[nonzero]

    if type == "max":
        calibration_error = np.max(np.abs(prob_pred - prob_true))
    elif type == "expected":
        bin_error = np.abs(prob_pred - prob_true) * bin_total[nonzero]
        calibration_error = np.sum(bin_error) / len(y_true)

    return calibration_error
In [10]:
# report model metrics
y_prob = model.predict_proba(X_test)[:, 1]

ece = calibration_error(y_test, y_prob, type="expected")
mce = calibration_error(y_test, y_prob, type="max")
brier = brier_score_loss(y_test, y_prob)

print("Test ECE:", ece)
print("Test MCE:", mce)
print("Test Brier Score:", brier)
Test ECE: 0.049543578316742845
Test MCE: 0.10550399929390131
Test Brier Score: 0.17850285813298739
In [11]:
#| fig-cap: "Figure 2. Calibration Plot for Swing Probability Model on the Test Dataset"
# summary figure
# function to plot a calibration_plot
def plot_calibration_plot(y_true, y_prob):

    # generate "data" for calibration plot
    prob_true, prob_pred = calibration_curve(
        y_true,
        y_prob,
        n_bins=10,
        pos_label=1,
    )

    # create a figure and axis object with a specific size
    fig, ax = plt.subplots()

    # plot the calibration curve
    ax.plot(
        prob_pred,
        prob_true,
        "s-",
        label="Learned Classifier",
        color="#1D58A7",
    )

    # plot the diagonal "perfect" line
    ax.plot(
        [0, 1],
        [0, 1],
        "--",
        label="Perfect Calibration",
        color="#F5821E",
    )

    # set the plot title and axis labels
    ax.set_title("Calibration Plot")
    ax.set_xlabel("Mean Predicted Value")
    ax.set_ylabel("Fraction of Positives")

    # add a grid
    ax.grid(
        True,
        color="lightgrey",
        linewidth=0.75,
        linestyle="--",
    )

    # fix aspect ratio
    ax.set_aspect(
        "equal",
        adjustable="box",
    )

    # show the legend
    ax.legend()

    # show the plot
    plt.show()

plot_calibration_plot(y_test, y_prob)
No description has been provided for this image

Overall, the results indicate that the model provides strong performance in estimating swing probabilities. The model achieved a low Brier Score of approximately 0.178, indicating that the predicted probabilities are close to the true outcomes. Additionally, the Expected Calibration Error (ECE) of about 0.049 and Maximum Calibration Error (MCE) of about 0.105 both fall well below the required thresholds, demonstrating that the model’s probability estimates are well-calibrated. The calibration plot further supports this, as the predicted probabilities closely follow the ideal diagonal line, with only minor deviations at lower probability ranges. Together, these results suggest that the model produces reliable and accurate probability estimates, making it effective for predicting batter swing behavior.

Discussion¶

Based on the results presented above, I would recommend using this model in practice as part of a decision-support system for pitching strategy. The model demonstrates strong performance in estimating swing probabilities, achieving low Brier Score, Expected Calibration Error (ECE), and Maximum Calibration Error (MCE), all of which indicate that the predicted probabilities are both accurate and well-calibrated. This is especially important in this application, as the goal is not simply to classify whether a batter will swing, but to provide reliable probability estimates that coaches and analysts can use to make informed decisions about pitch selection.

One important benefit of this model is that it can consistently evaluate how different pitch characteristics and game situations influence swing behavior. This allows analysts to identify which pitch types and locations are more likely to induce swings, providing actionable insights for pitchers like Zac Gallen. Because the model is well-calibrated, a predicted probability (e.g., 0.7) can be interpreted meaningfully as a 70% chance of a swing, which is critical when making strategic decisions. In practice, the model could be used to guide pitch selection, helping pitchers choose pitches that maximize the likelihood of inducing weak contact or swings in unfavorable situations for the batter.

However, several limitations should also be considered. While the model performs well overall, it is not perfectly calibrated across all probability ranges, and small deviations in the calibration plot suggest that predictions may be less reliable in certain regions. Errors in probability estimation can have real consequences; for example, overestimating swing probability could lead a pitcher to throw a pitch designed to induce a swing when the batter is more likely to take, potentially resulting in a ball or walk. Conversely, underestimating swing probability might cause missed opportunities to exploit aggressive hitters. Additionally, the model is trained on data from a single pitcher, which limits its generalizability to other pitchers or teams. It also does not incorporate more detailed batter-specific tendencies or contextual factors such as game pressure, which may influence swing decisions.

For these reasons, the model should not be used as a fully automated decision-maker without oversight. Instead, it would be most effective as part of a broader analytics system, where its probability estimates are used alongside expert judgment and additional scouting information. This approach allows the model to enhance decision-making while reducing the risk of relying on imperfect predictions.

Overall, the model demonstrates that machine learning can effectively estimate swing probabilities using pitch-level data. While it is not without limitations, it provides reliable and interpretable probability estimates that can support pitching strategy, improve decision-making, and offer a meaningful competitive advantage when used appropriately in a real-world baseball analytics setting.