Сравнение методов обнаружения выбросов
Сопоставление методов обнаружения выбросов
Использование статистики битвы из сезона 2023 года в Мэйджор Лиг Бейзбола
Обнаружение выбросов – это задача обучения без учителя для выявления аномалий (необычных наблюдений) в заданном наборе данных. Эта задача полезна во многих случаях реального мира, когда наш набор данных уже «заражен» аномалиями. Scikit-learn реализует несколько алгоритмов обнаружения выбросов, и в случаях, когда у нас есть неиспорченная базовая линия, мы также можем использовать эти алгоритмы для обнаружения новизны, полусупервизионная задача, предсказывающая, являются ли новые наблюдения выбросами.
Обзор
Четыре алгоритма обнаружения выбросов, которые мы сравним, это:
- Эллиптическая оболочка подходит для нормально распределенных данных с низкой размерностью. Как следует из названия, она использует многомерное нормальное распределение для создания меры расстояния, разделяющей выбросы от внутренних значений.
- Локальный фактор выброса сравнивает локальную плотность наблюдения с плотностью его соседей. Наблюдения с намного более низкой плотностью, чем у их соседей, считаются выбросами.
- Одноклассовая метод опорных векторов (SVM) с стохастическим градиентным спуском (SGD) является приближенным решением O(n) One-Class SVM. Обратите внимание, что O(n²) One-Class SVM хорошо работает на нашем небольшом примере набора данных, но может быть непрактичным в вашем конкретном случае использования.
- Изолирующий лес является подходом на основе деревьев, где выбросы быстрее изолированы случайными разбиениями, чем внутренние значения.
Поскольку наша задача неконтролируемая, у нас нет основы сравнения точности этих алгоритмов. Вместо этого мы хотим увидеть, как их результаты (особенно рейтинги игроков) отличаются друг от друга и получить некоторое интуитивное представление о их поведении и ограничениях, чтобы мы могли знать, когда предпочтительнее одно перед другим.
Давайте сравним несколько из этих методов, используя две метрики успеваемости битов сезона Мэйджор Лиг Бейзбола (MLB) 2023 года:
- Визуализация потока торговли на картах Python – Часть I Двусторонние карты потока торговли
- Зачем нам вообще нужны нейронные сети?
- Стратегия совместной оптимизации ПП/ЖП для крупных языковых моделей (ЯМ)
- Процент достижения базы (OBP), скорость, с которой битер достигает базы (ударяя, ходя или получая попадание)
- Слаг (SLG), среднее количество общего числа баз в проходе
Существует множество более сложных метрик успеваемости битеров, включая ОBP плюс SLG (OPS), взвешенное среднее достижение базы (wOBA) и скорректированный весовой рейтинг созданных бегов (WRC+). Однако мы увидим, что в дополнение к тому, что они широко используются и легко понятны, ОBP и SLG умеренно коррелированы и примерно нормально распределены, что делает их хорошо подходящими для этого сравнения.
Подготовка набора данных
Мы используем пакет pybaseball
для получения данных о битве. Этот пакет Python имеет лицензию MIT и возвращает данные из Fangraphs.com, Baseball-Reference.com и других источников, которые в свою очередь получили официальные записи из Мэйджор Лиг Бейзбола.
Мы используем статистику 2023 года по ударам в бейсболе от pybaseball, которую можно получить, используя функции batting_stats
(FanGraphs) или batting_stats_bref
(Baseball Reference). Оказывается, имена игроков отформатированы более правильно в FanGraphs, но команды и лиги игроков от Baseball Reference лучше форматированы в случае с трейдами. Для улучшения читаемости нам на самом деле нужно объединить три таблицы: FanGraphs, Baseball Reference и таблицу для поиска ключей.
from pybaseball import (cache, batting_stats_bref, batting_stats, playerid_reverse_lookup)import pandas as pdcache.enable() # избегаем лишних запросов при повторном запускеМИНИМАЛЬНОЕ_КОЛИЧЕСТВО_ПОЯВЛЕНИЙ_НА_ПЛИТЕ = 200# для повышения читаемости и установки разумного значения сортировкиdf_bref = batting_stats_bref(2023).query(f"PA >= {МИНИМАЛЬНОЕ_КОЛИЧЕСТВО_ПОЯВЛЕНИЙ_НА_ПЛИТЕ}" ).rename(columns={"Lev":"League", "Tm":"Team"} )df_bref["League"] = \ df_bref["League"].str.replace("Maj-","").replace("AL,NL","NL/AL" ).astype('category')df_fg = batting_stats(2023, qual=МИНИМАЛЬНОЕ_КОЛИЧЕСТВО_ПОЯВЛЕНИЙ_НА_ПЛИТЕ)key_mapping = \ playerid_reverse_lookup(df_bref["mlbID"].to_list(), key_type='mlbam' )[["key_mlbam","key_fangraphs"] ].rename(columns={"key_mlbam":"mlbID", "key_fangraphs":"IDfg"} )df = df_fg.drop(columns="Team" ).merge(key_mapping, how="inner", on="IDfg" ).merge(df_bref[["mlbID","League","Team"]], how="inner", on="mlbID" ).sort_values(["League","Team","Name"])
Исследование данных
Сначала заметим, что эти метрики отличаются по среднему и дисперсии и умеренно коррелируют друг с другом. Также мы замечаем, что каждая метрика является достаточно симметричной, с медианой, близкой к среднему значению.
print(df[["OBP","SLG"]].describe().round(3))print(f"\nКорреляция: {df[['OBP','SLG']].corr()['SLG']['OBP']:.3f}")
OBP SLGcount 362.000 362.000mean 0.320 0.415std 0.034 0.068min 0.234 0.22725% 0.300 0.36750% 0.318 0.41475% 0.340 0.460max 0.416 0.654Корреляция: 0.630
Давайте визуализируем эту совместную дистрибуцию с помощью:
- Диаграммы рассеяния игроков, окрашенных по Национальной лиге (NL) и Американской лиге (AL)
- Бивариантного графика оценки плотности ядра (KDE) игроков, который сглаживает диаграмму рассеяния с помощью гауссовой функции для оценки плотности
- Маргинальных графиков KDE для каждой метрики
import matplotlib.pyplot as pltimport seaborn as snsg = sns.JointGrid(data=df, x="OBP", y="SLG", height=5)g = g.plot_joint(func=sns.scatterplot, data=df, hue="League", palette={"AL":"blue","NL":"maroon","NL/AL":"green"}, alpha=0.6 )g.fig.suptitle("Процент удержания на плате относительно процента успешных ударов\nСезон 2023 года, минимум " f"{МИНИМАЛЬНОЕ_КОЛИЧЕСТВО_ПОЯВЛЕНИЙ_НА_ПЛИТЕ} появлений на плате" )g.figure.subplots_adjust(top=0.9)sns.kdeplot(x=df["OBP"], color="orange", ax=g.ax_marg_x, alpha=0.5)sns.kdeplot(y=df["SLG"], color="orange", ax=g.ax_marg_y, alpha=0.5)sns.kdeplot(data=df, x="OBP", y="SLG", ax=g.ax_joint, color="orange", alpha=0.5 )df_extremes = df[ df["OBP"].isin([df["OBP"].min(),df["OBP"].max()]) | df["OPS"].isin([df["OPS"].min(),df["OPS"].max()]) ]for _,row in df_extremes.iterrows(): g.ax_joint.annotate(row["Name"], (row["OBP"], row["SLG"]),size=6, xycoords='data', xytext=(-3, 0), textcoords='offset points', ha="right", alpha=0.7)plt.show()
В верхнем правом углу диаграммы рассеяния показано скопление превосходства в попадании, соответствующее тяжелым верхним хвостам распределений SLG и OBP. Эта небольшая группа отличается в возможности попадания на базу и нанесении дополнительных ударов по мячу. Как мы считаем их выбросами (из-за их отдаленности от большинства популяции игроков) по отношению к внутреним точкам (из-за их близости друг к другу) зависит от определения, применяемого нашим выбранным алгоритмом.
Применение алгоритмов обнаружения выбросов
Обычно, алгоритмы обнаружения выбросов в библиотеке scikit-learn имеют методы fit()
и predict()
, но есть и исключения, а также различия между алгоритмами в их аргументах. Мы рассмотрим каждый алгоритм индивидуально, но мы подгоним каждый к матрице признаков (n=2) для каждого игрока (m=453). Затем мы оценим не только каждого игрока, но и сетку значений, охватывающую диапазон каждого признака, чтобы помочь нам визуализировать предсказательную функцию.
Для визуализации границ решений необходимо выполнить следующие действия:
- Создать 2D-сетку значений функций ввода
meshgrid
. - Применить
decision_function
к каждой точке наmeshgrid
, что требует разбора сетки. - Вернуть форму предсказаний обратно в сетку.
- Построить предсказания.
Мы будем использовать сетку размером 200×200 для охвата существующих наблюдений плюс некоторого запаса, но вы можете настроить сетку в соответствии с вашей желаемой скоростью и разрешением.
import numpy as npX = df[["OBP","SLG"]].to_numpy()GRID_RESOLUTION = 200disp_x_range, disp_y_range = ( (.6*X[:,i].min(), 1.2*X[:,i].max()) for i in [0,1] )xx, yy = np.meshgrid(np.linspace(*disp_x_range, GRID_RESOLUTION), np.linspace(*disp_y_range, GRID_RESOLUTION) )grid_shape = xx.shapegrid_unstacked = np.c_[xx.ravel(), yy.ravel()]
Эллиптическая оболочка
Форма эллиптической оболочки определяется ковариационной матрицей данных, которая задает дисперсию признака i
на главной диагонали [i,i]
и ковариацию признаков i
и j
в позициях [i,j]
. Поскольку ковариационная матрица чувствительна к выбросам, этот алгоритм использует оценку линейной комбинации наименьших ковариаций (MCD), которая рекомендуется для унимодальных и симметричных распределений, с перемешиванием, определенным входным аргументом random_state
для воспроизводимости. Эта устойчивая ковариационная матрица снова пригодится нам позднее.
Поскольку мы хотим сравнить оценки выбросов в их ранжировании, а не в бинарной классификации выброс/внутренние точки, мы используем decision_function
для оценки игроков.
from sklearn.covariance import EllipticEnvelopeell = EllipticEnvelope(random_state=17).fit(X)df["outlier_score_ell"] = ell.decision_function(X)Z_ell = ell.decision_function(grid_unstacked).reshape(grid_shape)
Локальный фактор выброса
Этот подход к измерению изоляции основан на k-ближайших соседях (KNN). Мы вычисляем общую дистанцию от каждого наблюдения до его ближайших соседей для определения локальной плотности, а затем сравниваем локальную плотность каждого наблюдения с плотностью его соседей. Наблюдения с низкой локальной плотностью по сравнению с плотностью их соседей считаются выбросами.
Выбор числа соседей для включения: В KNN, правило хорошего тона состоит в том, чтобы выбрать K = sqrt(N), где N – количество наблюдений. Исходя из этого правила, мы получаем K, близкое к 20 (которое, кстати, является значением K по умолчанию для LOF). Вы можете увеличить или уменьшить K, чтобы уменьшить переобучение или недообучение соответственно.
K = int(np.sqrt(X.shape[0]))print(f"Использование K={K} ближайших соседей.")
Использование K=19 ближайших соседей.
Выбор меры расстояния: Обратите внимание, что наши признаки коррелируют и имеют разные дисперсии, поэтому евклидово расстояние не очень содержательно. Мы воспользуемся расстоянием Махаланобиса, которое учитывает масштаб признаков и их корреляцию.
При расчете расстояния Махаланобиса мы будем использовать устойчивую оценку ковариации. Если бы мы не рассчитали ее уже с помощью эллиптической огибающей, мы могли бы рассчитать ее непосредственно.
from scipy.spatial.distance import pdist, squareform# Если у нас еще не было эллиптической огибающей,# мы могли бы рассчитать устойчивую оценку ковариации:# from sklearn.covariance import MinCovDet# robust_cov = MinCovDet().fit(X).covariance_# Но мы можем просто использовать ее из эллиптической огибающей:robust_cov = ell.covariance_print(f"Устойчивая оценка ковариации:\n{np.round(robust_cov,5)}\n")inv_robust_cov = np.linalg.inv(robust_cov)D_mahal = squareform(pdist(X, 'mahalanobis', VI=inv_robust_cov))print(f"Матрица расстояний Махаланобиса размером {D_mahal.shape}, " f"например:\n{np.round(D_mahal[:5,:5],3)}...\n...\n")
Устойчивая оценка ковариации:[[0.00077 0.00095] [0.00095 0.00366]]Матрица расстояний Махаланобиса размером (362, 362), например:[[0. 2.86 1.278 0.964 0.331] [2.86 0. 2.63 2.245 2.813] [1.278 2.63 0. 0.561 0.956] [0.964 2.245 0.561 0. 0.723] [0.331 2.813 0.956 0.723 0. ]]......
Подгонка метода локального выброса: Обратите внимание, что при использовании пользовательской матрицы расстояний мы должны передать metric="precomputed"
конструктору, а затем саму матрицу расстояний методу fit
. (Подробнее см. документацию для получения дополнительной информации.)
Также обратите внимание, что в отличие от других алгоритмов, при использовании LOF нас инструктируют не использовать метод score_samples
для оценки существующих наблюдений; этот метод следует использовать только для обнаружения новизны.
from sklearn.neighbors import LocalOutlierFactorlof = LocalOutlierFactor(n_neighbors=K, metric="precomputed", novelty=True ).fit(D_mahal)df["outlier_score_lof"] = lof.negative_outlier_factor_
Создание границы решений: Поскольку мы использовали настраиваемую метрику расстояния, нам также необходимо вычислить это метрическое расстояние между каждой точкой в сетке и исходными наблюдениями. Ранее мы использовали пространственную меру pdist
для парных расстояний между каждым элементом одного набора, но теперь мы используем cdist
, чтобы вернуть расстояния от каждого элемента первого набора в каждый элемент второго набора.
from scipy.spatial.distance import cdistD_mahal_grid = cdist(XA=grid_unstacked, XB=X, metric='mahalanobis', VI=inv_robust_cov )Z_lof = lof.decision_function(D_mahal_grid).reshape(grid_shape)
Машина опорных векторов (SGD-One-Class SVM)
SVM используют трюк с ядерной функцией, чтобы преобразовать признаки в пространство более высокой размерности, где можно идентифицировать разделяющую гиперплоскость. Для ядра радиальной базисной функции (RBF) требуется стандартизация входных данных, но, как отмечено в документации по StandardScaler
, этот масштабировщик чувствителен к выбросам, поэтому мы используем RobustScaler
. Мы перенаправим масштабированные входные данные в аппроксимацию ядра Нистрёма, как предлагает документация по SGDOneClassSVM
.
from sklearn.pipeline import make_pipelinefrom sklearn.preprocessing import RobustScalerfrom sklearn.kernel_approximation import Nystroemfrom sklearn.linear_model import SGDOneClassSVMsuv = make_pipeline( RobustScaler(), Nystroem(random_state=17), SGDOneClassSVM(random_state=17)).fit(X)df["outlier_score_svm"] = suv.decision_function(X)Z_svm = suv.decision_function(grid_unstacked).reshape(grid_shape)
Isolation Forest
Этот подход на основе деревьев к измерению изоляции выполняет случайное рекурсивное разделение. Если среднее количество разбиений, необходимых для изоляции определенного наблюдения, низкое, то это наблюдение считается более кандидатом-выбросом. Как и Random Forests и другие модели на основе деревьев, Isolation Forest не предполагает, что признаки имеют нормальное распределение или требуют их масштабирования. По умолчанию строится 100 деревьев. В нашем примере используются только два признака, поэтому мы не включаем выборку признаков.
from sklearn.ensemble import IsolationForestiso = IsolationForest(random_state=17).fit(X)df["outlier_score_iso"] = iso.score_samples(X)Z_iso = iso.decision_function(grid_unstacked).reshape(grid_shape)
Результаты: изучение границ решений
Обратите внимание, что прогнозы этих моделей имеют различные распределения. Мы применяем QuantileTransformer
, чтобы сделать их более визуально сопоставимыми на определенной сетке. Из документации следует:
Обратите внимание, что эта трансформация является нелинейной. Она может исказить линейные корреляции между переменными, измеряемыми в одной и той же шкале, но делает переменные, измеренные в разных шкалах, более прямо сравнимыми.
from adjustText import adjust_textfrom sklearn.preprocessing import QuantileTransformerN_QUANTILES = 8 # Это столько же цветных секций в диаграммеN_CALLOUTS=15 # На этой диаграмме обозначаются столько же всплывающих значений вверхуfig, axs = plt.subplots(2, 2, figsize=(12, 12), sharex=True, sharey=True)fig.suptitle("Сравнение алгоритмов идентификации выбросов",size=20)fig.supxlabel("On-Base Percentage (OBP)")fig.supylabel("Slugging (SLG)")ax_ell = axs[0,0]ax_lof = axs[0,1]ax_svm = axs[1,0]ax_iso = axs[1,1]model_abbrs = ["ell","iso","lof","svm"]qt = QuantileTransformer(n_quantiles=N_QUANTILES)for ax, nm, abbr, zz in zip( [ax_ell,ax_iso,ax_lof,ax_svm], ["Elliptic Envelope","Isolation Forest", "Local Outlier Factor","One-class SVM"], model_abbrs, [Z_ell,Z_iso,Z_lof,Z_svm] ): ax.title.set_text(nm) outlier_score_var_nm = f"outlier_score_{abbr}" qt.fit(np.sort(zz.reshape(-1,1))) zz_qtl = qt.transform(zz.reshape(-1,1)).reshape(zz.shape) cs = ax.contourf(xx, yy, zz_qtl, cmap=plt.cm.OrRd.reversed(), levels=np.linspace(0,1,N_QUANTILES) ) ax.scatter(X[:, 0], X[:, 1], s=20, c="b", edgecolor="k", alpha=0.5) df_callouts = df.sort_values(outlier_score_var_nm).head(N_CALLOUTS) texts = [ ax.text(row["OBP"], row["SLG"], row["Name"], c="b", size=9, alpha=1.0) for _,row in df_callouts.iterrows() ] adjust_text(texts, df_callouts["OBP"].values, df_callouts["SLG"].values, arrowprops=dict(arrowstyle='->', color="b", alpha=0.6), ax=ax )plt.tight_layout(pad=2)plt.show()for var in ["OBP","SLG"]: df[f"Pctl_{var}"] = 100*(df[var].rank()/df[var].size).round(3)model_score_vars = [f"outlier_score_{nm}" for nm in model_abbrs] model_rank_vars = [f"Rank_{nm.upper()}" for nm in model_abbrs]df[model_rank_vars] = df[model_score_vars].rank(axis=0).astype(int) # Усреднение рангов произвольно; нам только нужен обратный порядокdf["Rank_avg"] = df[model_rank_vars].mean(axis=1)print("Обратный отсчет до наибольшего выброса...\n")print( df.sort_values("Rank_avg",ascending=False ).tail(N_CALLOUTS)[["Name","AB","PA","H","2B","3B", "HR","BB","HBP","SO","OBP", "Pctl_OBP","SLG","Pctl_SLG" ] + [f"Rank_{nm.upper()}" for nm in model_abbrs] ].to_string(index=False))
Обратный отсчет до самого яркого представителя... Имя AB PA H 2B 3B HR BB HBP SO OBP Pctl_OBP SLG Pctl_SLG Rank_ELL Rank_ISO Rank_LOF Rank_SVM Austin Barnes 178 200 32 5 0 2 17 2 43 0.256 2.6 0.242 0.6 19 7 25 12 J.D. Martinez 432 479 117 27 2 33 34 2 149 0.321 52.8 0.572 98.1 15 18 5 15 Yandy Diaz 525 600 173 35 0 22 65 8 94 0.410 99.2 0.522 95.4 13 15 13 10 Jose Siri 338 364 75 13 2 25 20 2 130 0.267 5.5 0.494 88.4 8 14 15 13 Juan Soto 568 708 156 32 1 35 132 2 129 0.410 99.2 0.519 95.0 12 13 11 11 Mookie Betts 584 693 179 40 1 39 96 8 107 0.408 98.6 0.579 98.3 7 10 20 7 Rob Refsnyder 202 243 50 9 1 1 33 5 47 0.365 90.5 0.317 6.6 5 19 2 14 Yordan Alvarez 410 496 120 24 1 31 69 13 92 0.407 98.3 0.583 98.6 6 9 18 6 Freddie Freeman 637 730 211 59 2 29 72 16 121 0.410 99.2 0.567 97.8 9 11 9 8 Matt Olson 608 720 172 27 3 54 104 4 167 0.389 96.5 0.604 99.2 11 6 7 9 Austin Hedges 185 212 34 5 0 1 11 2 47 0.234 0.3 0.227 0.3 10 1 4 3 Aaron Judge 367 458 98 16 0 37 88 0 130 0.406 98.1 0.613 99.4 3 5 6 4Ronald Acuna Jr. 643 735 217 35 4 41 80 9 84 0.416 100.0 0.596 98.9 2 3 10 2 Corey Seager 477 536 156 42 0 33 49 4 88 0.390 97.0 0.623 99.7 4 4 3 5 Shohei Ohtani 497 599 151 26 8 44 91 3 143 0.412 99.7 0.654 100.0 1 2 1 1
Анализ и выводы
Похоже, что четыре реализации в основном согласны в определении выбросов, но существуют заметные различия в оценках и также в простоте использования.
Эллиптическая Оболочка имеет более узкие контуры вокруг малой оси эллипса, поэтому она склонна выделять интересных игроков, которые идут вразрез общей корреляции между характеристиками. Например, вне поля игрок Рэйс Хосе Сири рассматривается как выброс по этому алгоритму из-за его высокого SLG (88-й процентиль) по сравнению с низким OBP (5-й процентиль), что соответствует агрессивному бэтеру, который сильно бьет по краевым мячам, и либо отправляет их в даль, либо не достигает контакта.
Эллиптическая Оболочка также проста в использовании без дополнительной настройки и обеспечивает робастную ковариационную матрицу. Если у вас низкоразмерные данные и разумные ожидания, что они будут нормально распределены (что часто не является случаем), вы можете попробовать этот простой подход в первую очередь.
One-class SVM имеет более равномерно распределенные контуры, поэтому он склонен подчеркивать наблюдения вдоль общего направления корреляции больше, чем Эллиптическая Оболочка. Звездные первые бейсмены Фредди Фримен (Доджерс) и Янди Диаз (Рэйс) более сильно оцениваются по этому алгоритму, чем по другим, так как их SLG и OBP оба отличные (99-й и 97-й процентиль для Фримена, 99-й и 95-й для Диаза).
Для ядра RBF требовался дополнительный этап стандартизации, но оно, кажется, хорошо работает на этом простом примере без заточки.
Local Outlier Factor обнаружил упомянутый ранее “кластер превосходства” с помощью небольшого бимодального контура (едва заметного на графике). Поскольку игроки Доджерс Мукки Беттс и Рональд Акуна-младший окружены другими отличными бэтерами, включая Фримена и Йордана Альвареса, он оценивается только как 20-й наиболее сильный выброс по LOF, в отличие от других алгоритмов, где оценка была 10-ой или даже выше. Напротив, игрок Рейс Марселл Озуна имеет несколько более низкий SLG и значительно более низкий OBP, чем Беттс, но по LOF он является более явным выбросом из-за меньшей плотности его окружения.
Реализация LOF была самой трудоемкой, так как мы создали качественные матрицы расстояний для подгонки и оценки. Мы также могли бы потратить некоторое время на настройку параметра K.
Isolation Forest склонен подчеркивать наблюдения в углах пространства характеристик, так как разделения распределены по характеристикам. Запасной ловец Остин Хеджес, который играл за Пайретс и Рэйнджерс в 2023 году и подписал контракт с Гардианс на 2024 год, является сильным игроком в обороне, но является самым слабым бэтером (с не менее чем 200 появлениями на плате) по обоим характеристикам SLG и OBP. Хеджес может быть особо выделен в единственном разделении либо по OBP, либо по OPS, что делает его наиболее явным выбросом. Isolation Forest – единственный алгоритм, который не оценил Шохея Отани как самого явного выброса: поскольку Отани уступил Акуна-младшему в OBP, и тот и другой могут быть выделены в единственном разделении только по одному из признаков.
Как и с обычными обучаемыми деревьями, Isolation Forest не экстраполирует, поэтому лучше всего подходит для подгонки к зараженному набору данных для обнаружения выбросов, чем для подгонки к набору данных без аномалий для обнаружения новшеств (где он не будет присваивать более сильных оценок новым выбросам, чем уже существующим наблюдениям).
Хотя Isolation Forest хорошо сработал сразу из коробки, его невозможность оценить Шохея Отани как самого большого выброса в бейсболе (и, вероятно, во всех профессиональных спортах) показывает основное ограничение любого детектора выбросов: данные, которые вы используете для его подгонки.
Мы не только исключили статистику по обороне (извините, Остин Хеджес), мы также не стали включать статистику по питчингу. Потому что металлом больше не стреляют… за исключением Отани, чей сезон включал второй по лучшему батту на матч (BAA) и 11-е место по заработанным очкам (ERA) в бейсболе (минимально 100 иннингов), комплит-гейм шатдаун и матч, в котором он сделал 10 забить и выполнил два хоум-рана.
Была высказана предположения, что Шохей Отани – продвинутый инопланетянин, притворяющийся человеком, но кажется, что более вероятно, что есть два продвинутых инопланетянина, притворяющихся одним и тем же человеком. К сожалению, один из них только что перенес операцию на локте и не будет бросать в 2024 году… но другой только что заключил рекордный контракт на 10 лет на сумму 700 миллионов долларов. И благодаря обнаружению выбросов, теперь мы можем понять почему!