译者 | 朱先忠
审校 | 重楼
异常值检测是一种无监督的机器学习任务,用于识别给定数据集中的异常(即“异常观测”)。在大量现实世界中,当我们的可用数据集已经被异常“污染”时,异常值检测任务对于整个机器学习环节来说是非常有帮助的。当前,开源框架Scikit learn已经实现了几种异常值检测算法,在我们持有未受污染的基准数据的情况下,我们也可以使用这些算法进行新颖检测。这是一项半监督任务,可以预测新的观测结果是否为异常值。
概述
本文中,我们将比较的四种异常值检测算法如下:
- 椭圆包络(Elliptic Envelope)适用于低维正态分布数据。顾名思义,它使用多元正态分布来创建一个距离度量指标,以便将异常值与内部值区分开来。
- 局部异常因子(Local Outlier Factor)是一种针对观测的局部密度与其邻居的局部密度的比较。密度比其邻居低得多的观测被视为异常值。
- 具有随机梯度下降的单类支持向量机(One-Class Support Vector Machine with Stochastic Gradient Descent)是一类SVM的O(n)近似解。请注意,O(n²)数量级的单类SVM在我们的小示例数据集上运行良好,但对于您的实际应用场景来说可能不切实际。
- 孤立森林(Isolation Forest)是一种基于树的方法,其中异常值通过随机拆分方法比使用内部值方法可实现更快的隔离。
由于我们的任务是无监督的;所以,我们没有基本事实来比较这些算法的准确性。相反,我们想看看他们的结果(尤其是球员排名方面)彼此之间的差异,并对他们的行为和局限性获得一些直觉,这样我们就可以知道什么时候更喜欢其中一名球员而不是另一名。
接下来,让我们使用2023年美国职业棒球大联盟(MLB)赛季击球手表现的两个指标来比较其中的一些技术吧:
- 上垒率(OBP),击球手每次上垒时(通过击球、保送或投球命中)上垒的速度
- Slugging(SLG),每次击球的平均总垒数
击球手表现还有许多更复杂的指标,例如OBP加SLG(OPS)、基本平均加权(wOBA)和调整后的加权跑动(WRC+)。然而,我们将看到,除了常用和易于理解之外,OBP和SLG具有适度的相关性和近似正态分布,使它们非常适合这种比较。
数据集准备
我们使用pybalball包来获取击球数据。该Python包接受MIT许可,能够取得来自于Fangraphs.com、Baseball-Reference.com和其他来源的数据;当然,这些来源反过来又是从美国职业棒球大联盟获得的官方记录数据。
我们使用pybalball的2023年击球统计数据,可以通过batting_stats(FanGraphs网站)或batting_stasts_bref(权威网站Baseball Reference)获得。事实证明,FanGraphs中的球员名称格式更正确,但Baseball Reference中的球员团队和联盟在交易球员的情况下格式更好。为了获得一个具有更高可读性的数据集,我们实际上需要合并三个表:FanGraphs、Baseball Reference和键查询。
from pybaseball import (cache, batting_stats_bref, batting_stats, playerid_reverse_lookup) import pandas as pd cache.enable() # 重新运行时避免不必要的请求 MIN_PLATE_APPEARANCES = 200 # 为了可读性和合理的默认排序顺序 df_bref = batting_stats_bref(2023).query(f"PA >= {MIN_PLATE_APPEARANCES}" ).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=MIN_PLATE_APPEARANCES) 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"\nCorrelation: {df[['OBP','SLG']].corr()['SLG']['OBP']:.3f}")
让我们使用以下方法来可视化上面的联合分布:
- 球员散点图,全国职业棒球联盟(NL)对美国联盟(AL)着色
- 球员的二元核密度估计器(KDE)图,用高斯核平滑散射图以估计密度
- 每个指标的边际KDE图
import matplotlib.pyplot as plt import seaborn as sns g = 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("On-base percentage vs. Slugging\n2023 season, min " f"{MIN_PLATE_APPEARANCES} plate appearances" ) 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()两种方法,但算法之间也有例外,也有不同之处。我们将单独考虑每个算法,但我们将每个算法拟合到每个球员(m=453)的属性矩阵(n=2)中。然后,我们不仅会为每个球员打分,还会为每个属性范围内的值网格打分,以帮助我们可视化预测函数。
为了可视化决策边界,我们需要采取以下步骤:
- 创建输入特征值的二维网格。
- 将decision_function应用于网格上的每个点,这需要拆开网格。
- 将预测重新成形为一个网格。
- 绘制预测。
在此,我们将使用200x200大小的网格来覆盖现有的观测结果和一些填充,但您可以将网格调整到所需的速度和分辨率。
import numpy as np X = df[["OBP","SLG"]].to_numpy() GRID_RESOLUTION = 200 disp_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.shape grid_unstacked = np.c_[xx.ravel(), yy.ravel()]
椭圆包络
椭圆包络的形状由数据的协方差矩阵确定,该协方差矩阵给出了特征i在主对角线[i,i]上的方差以及特征i和j在[i,j]位置上的协方差。由于协方差矩阵对异常值敏感,因此该算法使用最小协方差行列式(MCD)估计器,该估计器被推荐用于单峰和对称分布,通过随机混洗后的random_state输入参数以获得再现性。请注意,这个稳健的协方差矩阵稍后将再次派上用场。
因为我们想比较他们排名中的异常值分数,而不是二元异常值/内部分类,所以我们决定使用decision_function函数为球员打分。
from sklearn.covariance import EllipticEnvelope ell = 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是您的观察计数。根据这个规则,我们得到了一个接近20的K(这恰好是LOF的默认K)。您可以分别增加或减少K以减少过拟合或欠拟合。K = int(np.sqrt(X.shape[0])) print(f"Using K={K} nearest neighbors.")
选择一个距离指标
:请注意,我们的特征是相关的,并且具有不同的方差,因此欧几里得距离不是很有意义。我们将使用Mahalanobis距离,这种距离更有助于体现特征比例和相关性。在计算马氏距离时,我们将使用鲁棒性强的协方差矩阵。如果我们还没有通过椭圆包络计算它,那么我们可以直接计算它。
from scipy.spatial.distance import pdist, squareform # 如果我们还没有椭圆包络,我们可以计算鲁棒性强的协方差: # from sklearn.covariance import MinCovDet # robust_cov = MinCovDet().fit(X).covariance_ # 但我们可以从椭圆包络中重复使用它: robust_cov = ell.covariance_ print(f"Robust covariance matrix:\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"Mahalanobis distance matrix of size {D_mahal.shape}, " f"e.g.:\n{np.round(D_mahal[:5,:5],3)}...\n...\n")
拟合局部异常因子
:请注意,使用自定义距离矩阵需要将metric=“precomputed”传递给构造函数,然后将距离矩阵本身传递给fit方法。(有关更多详细信息,请参阅官方文档:https://scikit-learn.org/stable/modules/generated/sklearn.neighbors.LocalOutlierFactor.html#sklearn.neighbors.LocalOutlierFactor.fit)还要注意,与其他算法不同,使用LOF时,我们被指示不要使用score_samples方法对现有观测值进行评分;该方法应仅用于新颖性检测。
from sklearn.neighbors import LocalOutlierFactor lof = 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 cdist D_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的文档所建议的那样,我们将缩放后的输入管道传输到Nyström核近似中。
from sklearn.pipeline import make_pipeline from sklearn.preprocessing import RobustScaler from sklearn.kernel_approximation import Nystroem from sklearn.linear_model import SGDOneClassSVM suv = 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)
孤立森林
这种基于树的隔离测量方法执行随机递归划分。如果隔离给定观测所需的平均分裂次数较低,则该观测被视为更强的候选异常值。与随机森林和其他基于树的模型一样,孤立森林并不假设特征是正态分布的,也不要求对其进行缩放。默认情况下,它构建100棵树。我们的示例仅使用两个特征,因此并没有启用特征采样。
from sklearn.ensemble import IsolationForest iso = 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_text from sklearn.preprocessing import QuantileTransformer N_QUANTILES = 8 # 每个图表有这么多颜色断点 N_CALLOUTS=15 # 为每个图表标记这么多顶部异常值 fig, axs = plt.subplots(2, 2, figsize=(12, 12), sharex=True, sharey=True) fig.suptitle("Comparison of Outlier Identification Algorithms",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) # Averaging the ranks is arbitrary; we just need a countdown order df["Rank_avg"] = df[model_rank_vars].mean(axis=1) print("Counting down to the greatest outlier...\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) )
分析与结论
看起来上面四种实现方案在如何定义异常值方面基本保持一致,但在分值和易用性方面存在一些明显差异。
椭圆包络方法
:在椭圆的短轴周围有更窄的轮廓,因此它倾向于突出那些与特征之间的整体相关性相反的有趣球员。例如,在这种算法下,光芒队的外野手JoséSiri更像是一个异类,因为他的SLG很高(第88百分位),而OBP很低(仅为第5百分位)。椭圆包络在没有配置的情况下也很容易使用,并且它提供了具有鲁棒性的协方差矩阵。如果您有低维数据,并且合理地期望它是正态分布的(通常情况并非如此),那么您可能想先尝试这种简单的方法。
单分类算法
:具有更均匀分布的轮廓,因此它倾向于比椭圆包络更强调沿整体相关方向的观测。全明星一垒手Freddie Freeman(道奇队)和Yandy Diaz(光芒队)在该算法下的排名比其他算法下的更靠前,因为他们的SLG和OBP都很出色(Freeman分别为第99和97百分位,Diaz分别为第98和95百分位)。值得注意的是,RBF内核需要一个额外的步骤来进行标准化,但在这个简单的例子中,它似乎也能很好地工作,而无需进行微调。
局部异常因子
:在前面提到的“优秀集群”上出现了一个小的双峰轮廓(在图表中几乎看不到)。由于道奇队的外野手/二垒手穆基·贝茨周围都是其他优秀的击球手,包括弗里曼、约丹·阿尔瓦雷斯和小罗纳德·阿库尼亚,他在LOF下仅排名第20,而在其他算法下排名第10或更强。相反,勇士队外野手Marcel Ozuna的SLG和OBP略低于Betts;但在LOF下,他更像是一个异类,因为他的邻近密度较小。另外,值得注意的是,LOF实现是最耗时的,因为我们要创建的是用于拟合和评分的稳健的距离矩阵。当然,我们本可以花一些时间来调整K。
孤立森林
:倾向于强调在特征空间的角落进行观察,因为分割分布在特征之间。替补捕手奥斯汀·赫奇斯于2023年效力于海盗队和流浪者队,并于2024年与守护者队签约。他具有很强的防守能力,但在SLG和OBP中都是最差的击球手(至少有200次板上击球)。赫奇斯可以在OBP或OPS上单独分离,使他成为最强的异类。隔离森林是唯一一种没有将大谷昭平列为最强异常值的算法:由于大谷昭平被小罗纳德·阿库尼亚在OBP中淘汰;因此,大谷昭和阿库尼亚只能在一个特征上进行单独分离。与常见的基于监督树的学习器一样,隔离森林不进行外推分析,这使得它更适合于拟合受污染的数据集进行异常值检测,而不是拟合无异常的数据集用于新颖性检测(在这种情况下,它不会比现有观察更有力地对新的异常值进行评分)。
尽管“隔离森林”开箱即用效果很好,但它未能将大谷昭平列为棒球(可能是所有职业运动)中最大的异常值,这说明了任何异常值检测器的主要局限性:用于拟合它的数据。
需要说明的是,在我们的实验数据中,我们不仅省略了防守数据(对不起,奥斯汀·赫奇斯),也没有考虑投球数据。因为投手们甚至不再尝试击球了……除了Ohtani,他的赛季包括棒球史上第二好的打击率(BAA)和第11好的平均得分(ERA)(至少投了100局),一场完整的比赛被淘汰,以及一场他三振十名击球手并击出两支全垒打的比赛。
有人认为大谷昭平是一个模仿人类的高级外星人,但似乎更有可能有两个模仿同一个人的高级外星人。不幸的是,其中一人刚刚做了肘部手术,2024年不会投球了……但另一人刚刚签署了一份创纪录的10年7亿美元的合同。哈哈,得益于异常值检测,现在我们终于可以明白其中有关的原因了!
译者介绍
朱先忠,51CTO社区编辑,51CTO专家博客、讲师,潍坊一所高校计算机教师,自由编程界老兵一枚。
原文标题:
Comparing Outlier Detection Methods
,作者:John Andrews