将IsolationForest决策分数转换为概率的算法

6
我正在寻找创建通用函数来将sklearn's IsolationForestdecision_scores输出转换为真实概率[0.0, 1.0]
我已经了解并阅读了原始论文,从数学上理解该函数的输出不是概率,而是每个基本估计器构建孤立异常路径长度的平均值。
问题: 我想将该输出转换为以元组(x,y)形式表示的概率,其中x=P(anomaly)y=1-x
当前方法:
def convert_probabilities(predictions, scores):
    from sklearn.preprocessing import MinMaxScaler

    new_scores = [(1,1) for _ in range(len(scores))]

    anomalous_idxs = [i for i in (range(len(predictions))) if predictions[i] == -1]
    regular_idxs = [i for i in (range(len(predictions))) if predictions[i] == 1]

    anomalous_scores = np.asarray(np.abs([scores[i] for i in anomalous_idxs]))
    regular_scores = np.asarray(np.abs([scores[i] for i in regular_idxs]))

    scaler = MinMaxScaler()

    anomalous_scores_scaled = scaler.fit_transform(anomalous_scores.reshape(-1,1))
    regular_scores_scaled = scaler.fit_transform(regular_scores.reshape(-1,1))

    for i, j in zip(anomalous_idxs, range(len(anomalous_scores_scaled))):
        new_scores[i] = (anomalous_scores_scaled[j][0], 1-anomalous_scores_scaled[j][0])
    
    for i, j in zip(regular_idxs, range(len(regular_scores_scaled))):
        new_scores[i] = (1-regular_scores_scaled[j][0], regular_scores_scaled[j][0])

    return new_scores

modified_scores = convert_probabilities(model_predictions, model_decisions)

最小化、可重现的示例

import pandas as pd
from sklearn.datasets import make_classification, load_iris
from sklearn.ensemble import IsolationForest
from sklearn.decomposition import PCA
from sklearn.model_selection import train_test_split

# Get data
X, y = load_iris(return_X_y=True, as_frame=True)
anomalies, anomalies_classes = make_classification(n_samples=int(X.shape[0]*0.05), n_features=X.shape[1], hypercube=False, random_state=60, shuffle=True)
anomalies_df = pd.DataFrame(data=anomalies, columns=X.columns)

# Split into train/test
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.15, random_state=60)

# Combine testing data
X_test['anomaly'] = 1
anomalies_df['anomaly'] = -1
X_test = X_test.append(anomalies_df, ignore_index=True)
y_test = X_test['anomaly']
X_test.drop('anomaly', inplace=True, axis=1)

# Build a model
model = IsolationForest(n_jobs=1, bootstrap=False, random_state=60)

# Fit it
model.fit(X_train)

# Test it
model_predictions = model.predict(X_test)
model_decisions = model.decision_function(X_test)

# Print results
for a,b,c in zip(y_test, model_predictions, model_decisions):
    print_str = """
    Class: {} | Model Prediction: {} | Model Decision Score: {}
    """.format(a,b,c)

    print(print_str)

问题

modified_scores = convert_probabilities(model_predictions, model_decisions)

# Print results
for a,b in zip(model_predictions, modified_scores):
    ans = False
    if a==-1:
        if b[0] > b[1]:
            ans = True
        else:
            ans = False
    elif a==1:
        if b[1] > b[0]:
            ans=True
        else:
            ans=False
    print_str = """
    Model Prediction: {} | Model Decision Score: {} | Correct: {}
    """.format(a,b, str(ans))

    print(print_str)

展示一些奇怪的结果,例如:

Model Prediction: 1 | Model Decision Score: (0.17604259932311161, 0.8239574006768884) | Correct: True
Model Prediction: 1 | Model Decision Score: (0.7120367886017022, 0.28796321139829784) | Correct: False
Model Prediction: 1 | Model Decision Score: (0.7251531538304419, 0.27484684616955807) | Correct: False
Model Prediction: -1 | Model Decision Score: (0.16776449326185877, 0.8322355067381413) | Correct: False
Model Prediction: 1 | Model Decision Score: (0.8395087028516501, 0.1604912971483499) | Correct: False

模型预测: 1 | 模型决策分数: (0.0, 1.0) | 正确: 真

预测值为-1(异常)但概率仅为37%,或者预测值为1(正常)但概率为26%,这怎么可能呢?

请注意,这是一个玩具数据集,有标签,但无监督异常检测算法显然不会使用标签。


你是否绘制了校准曲线?或者尝试使用保序回归进行校准?参考 https://scikit-learn.org/stable/modules/calibration.html - Jon Nordby
这怎么可能实现呢?因为这不是真正的分类,而是一种无监督的方法。@JonNordby - artemis
一个人必须使用带标签的验证集(但不是带标签的训练集)。 - Jon Nordby
3个回答

0

虽然几个月后,这个问题有了答案。

2011年发表了一篇论文,试图在这个主题上展示研究,将异常分数统一为概率。

事实上,pyod库有一个常见的predict_proba方法,它提供了使用这种统一方法的选项。

这是实现代码(受他们的源码的影响):

def convert_probabilities(data, model):
    decision_scores = model.decision_function(data)
    probs = np.zeros([data.shape[0], int(model.classes)])
    pre_erf_score = ( decision_scores - np.mean(decision_scores) ) / ( np.std(decision_scores) * np.sqrt(2) )
    erf_score = erf(pre_erf_score)
    probs[:, 1] = erf_score.clip(0, 1).ravel()
    probs[:, 0] = 1 - probs[:, 1]
    return probs

(供参考,pyod确实有孤立森林的实现

你好,有个问题想问一下,当你使用convert_probabilities函数时,是否遇到了"IndexError: too many indices for array: array is 1-dimensional, but 2 were indexed"的错误?我尝试使用该函数时也收到了这个错误信息。 - GSA
我并没有——也许你可以发布一些示例数据之类的问题,我可以试着回答吗?@GSA - artemis

0

这里有三个不同的问题。首先,不能保证从IsolationForest获得的分数越低,样本是异常值的概率就越高。我的意思是,如果对于一堆样本,您在(-0.3:-0.2)(0.1:0.2)范围内获得model_decision分数,那并不一定意味着第一批样本的异常值概率更高(但通常情况下是如此)。

第二个问题是将分数映射到概率的实际映射函数。因此,假设较低的分数对应于较低的正常样本概率(以及样本为异常值的概率更高),则从分数到概率的映射不一定是线性函数(例如MinMaxScaler)。你可能需要为你的数据找到自己的函数。可以像@Jon Nordby建议的那样使用分段线性函数。我个人更喜欢使用逻辑函数将分数映射到概率。在这种情况下,由于model_decisions围绕零居中,并且负值表示异常,因此使用它可能特别有益。因此,你可以使用类似以下的内容:
def logf(x, alfa=10): 
    return 1/(1 + np.exp( -alfa * x ))

用于将分数映射到概率的函数。Alpha参数控制值在决策边界周围的紧密程度。再次强调,这不一定是最佳的映射函数,只是我喜欢使用的一种。

最后一个问题与第一个问题有关,并可能回答了您的问题。即使通常得分与不是异常的概率相关,也不能保证对所有样本都是如此。因此,得分为0.1的某个点可能是异常值,而得分为-0.1的点可能是被错误地检测为异常的正常点。样本是否为异常是通过model_decisions小于零来判断的。对于得分接近零的样本,出错的概率更高。


Alpha参数控制值在决策边界周围的紧密程度。你不需要了解模型的决策边界才能适当地执行此操作吗? - artemis
不,模型的决策边界是零(由“IsolationForest”设置)。Alpha控制“宽度”:logf(-0.1,1)= 0.47logf(-0.1,10)= 0.269 - igrinis

-1

为什么会发生这种情况

您观察到的荒谬概率是因为您对内点和异常值拟合了不同的缩放器。结果是,如果您的决策分数范围是[0.5, 1.5]用于内点,则将这些分数映射为概率[0, 1]。此外,如果决策分数的范围是[-1.5, -0.5]用于异常值,则将这些分数同样映射为概率[0, 1]。如果决策分数为1.5-0.5时,您最终会将内点的概率设置为1。显然这不是您想要的,您希望决策分数为-0.5的观察结果具有比决策分数为1.5的观察结果更低的概率。

第一选项

第一个解决方案是为所有分数拟合一个单独的缩放器。这将极大地简化您的转换函数,如下所示:

def convert_probabilities(predictions, scores):

    scaler = MinMaxScaler()

    scores_scaled = scaler.fit_transform(scores.reshape(-1,1))
    new_scores = np.concatenate((1-scores_scaled, scores_scaled), axis=1)

    return new_scores

这将是一个元组,包含(是异常值的概率,是正常值的概率)并具有所需的属性。

此方法的局限性

这种方法的主要局限性之一是不能保证异常值和正常值之间的概率截断点为0.5,尽管这是最直观的选择。你可能会出现这样的情况:"如果是正常值的概率小于60%,则该模型预测它是异常值"。

第二个选项

第二个选项更接近你想做的事情。你确实为每个类别拟合了一个缩放器,但与你所做的不同的是,两个缩放器不会返回相同范围内的值。你可以将异常值设置为缩放到[0, 0.5],将正常值设置为缩放到[0.5, 1]。这样做的好处是,在0.5创建了一个直观的决策边界,所有概率高于此线的都是正常值,反之亦然。然后看起来像这样:

def convert_probabilities(predictions, scores):

    scaler_inliers = MinMaxScaler((0.5, 1))
    scaler_outliers = MinMaxScaler((0, 0.5))

    scores_inliers_scaled = scaler_inliers.fit_transform(scores[predictions == 1].reshape(-1,1))
    scores_outliers_scaled = scaler_outliers.fit_transform(scores[predictions == -1].reshape(-1,1))
    scores_scaled = np.zeros((len(scores), 1))
    scores_scaled[predictions == 1] = scores_inliers_scaled
    scores_scaled[predictions == -1] = scores_outliers_scaled
    new_scores = np.concatenate((1-scores_scaled, scores_scaled), axis=1)

    return new_scores

这种方法的限制

主要限制在于如何将两个标量值重新组合在一起。在上面的代码示例中,两者都连接在0.5处,这意味着“最佳异常值”和“最差内部值”的概率相同为0.5。然而,它们的决策分数并不相同。因此,一种选择是将缩放范围更改为[0, 0.49][0.51, 1]之类的范围,但是正如您所看到的,这变得更加武断。


但是将所有分数一起缩放并不起作用,这就是为什么我尝试将它们分开的原因。使用这种方法,我仍然会遇到P(异常)< 1-P(异常)的情况,但预测结果却是-1(异常)。 - artemis
我添加了另一种解决方案选项,我相信它可以解决那个问题。 - MaximeKan
感谢更新。我相信主要关注点是,无论数字的分布如何,我们都知道更多的正数==更高的内点概率,更多的负数==更高的异常值概率。困难在于理解如何映射这些概率,考虑到模型已经学习到的决策边界。我无法想象如何做到这一点。 - artemis
@wundermahn,我不太确定您目前正在寻找什么。我提供的答案纠正了您在问题描述中提到的问题。就像我提到的那样,它们并不理想,但是鉴于孤立森林是一种非概率算法,没有正确回答它的方法。因此,您可能想出的任何解决方案都将存在缺陷,并且高度主观。 - MaximeKan
当然。孤立森林并不是概率性的。我在问题中已经指出了这一点。为了得出真正的“概率”,模型学习到的决策边界将需要被考虑,以解决您之前提出的一个问题。问题的目标是产生一个输出,提供概率会是什么的地图。尽管如此,我还是很感谢您的时间。 - artemis

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接