EBM 内部原理 - 多分类#

这是描述 EBM 内部原理和如何进行预测的 3 部分系列文章的第 3 部分。对于第 1 部分,请点击此处。对于第 2 部分,请点击此处

在本第 3 部分中,我们将介绍多分类、指定分箱边界、术语排除和未见值。在阅读本部分之前,您应该熟悉第 1 部分第 2 部分中的信息。

# boilerplate
from interpret import show
from interpret.glassbox import ExplainableBoostingClassifier
import numpy as np

from interpret import set_visualize_provider
from interpret.provider import InlineProvider
set_visualize_provider(InlineProvider())
# make a dataset composed of a nominal, an unused feature, and a continuous 
X = [["Peru", "", 7], ["Fiji", "", 8], ["Peru", "", 9], [None, "", None]]
y = [6000, 5000, 4000, 6000] # integer classes

# Fit a classification EBM without interactions
# Specify exact bin cuts for the continuous feature
# Exclude the middle feature during fitting
# Eliminate the validation set to handle the small dataset
ebm = ExplainableBoostingClassifier(
    interactions=0, 
    feature_types=['nominal', 'nominal', [7.25, 9.0]], 
    exclude=[(1,)],
    validation_size=0, outer_bags=1, min_samples_leaf=1, min_hessian=1e-9)
ebm.fit(X, y)
show(ebm.explain_global())
/opt/hostedtoolcache/Python/3.9.21/x64/lib/python3.9/site-packages/interpret/glassbox/_ebm/_ebm.py:812: UserWarning: Missing values detected. Our visualizations do not currently display missing values. To retain the glassbox nature of the model you need to either set the missing values to an extreme value like -1000 that will be visible on the graphs, or manually examine the missing value score in ebm.term_scores_[term_index][0]
  warn(





print(ebm.classes_)
[4000 5000 6000]

与所有 scikit-learn 分类器一样,我们将类别列表作为排序数组存储在 ebm.classes_ 属性中。在此示例中,我们的类别是整数,但我们也接受字符串,如第 2 部分所示。

print(ebm.feature_types)
['nominal', 'nominal', [7.25, 9.0]]

在此示例中,我们将 feature_types 传递给了 ExplainableBoostingClassifier 的 __init__ 函数。根据 scikit-learn 惯例,此信息在 ebm 对象中未作修改地记录下来。

print(ebm.feature_types_in_)
['nominal', 'nominal', 'continuous']

传递给 __init__ 的 feature_types 最终被实际处理为基础特征类型 [‘nominal’, ‘nominal’, ‘continuous’]。

print(ebm.feature_names)
None

在调用 ExplainableBoostingClassifier 的 __init__ 函数时未指定 feature_names,因此按照 scikit-learn 记录未修改的 __init__ 参数的惯例,它被设置为 None。

print(ebm.feature_names_in_)
['feature_0000', 'feature_0001', 'feature_0002']

由于我们未指定特征名称,因此为模型创建了一些默认名称。如果我们向 ExplainableBoostingClassifier 的 __init__ 函数传递了 feature_names,或者使用了带有列名的 Pandas 数据框,那么 ebm.feature_names_in_ 将包含这些名称。遵循 scikit-learn 的 SLEP007 惯例,我们将此信息记录在 ebm.feature_names_in_ 中。

print(ebm.term_features_)
[(0,), (2,)]

在调用 ExplainableBoostingClassifier 的 __init__ 函数时,我们指定了 exclude=[(1,)],这意味着我们排除了模型术语列表中的中间特征。因此,中间特征不在 ebm.term_features_ 中的术语列表内。

print(ebm.term_names_)
['feature_0000', 'feature_0002']

由于 ebm.term_features_ 缺少该特征,ebm.term_names_ 也缺少中间特征。

print(ebm.bins_)
[[{'Fiji': 1, 'Peru': 2}], [], [array([7.25, 9.  ])]]

ebm.bins_ 是一个按特征区分的属性,因此中间特征也在此处列出。但是我们看到,中间特征没有分箱定义,因为它在模型进行预测时不会被考虑。

这些分箱的结构如第 1 部分第 2 部分所述。需要注意的一点是,连续特征的分箱边界与在 ExplainableBoostingClassifier 的 __init__ 函数的 feature_types 参数中指定的分箱边界 [7.25, 9.0] 相同。

同样值得注意的是,指定的最后一个分箱边界恰好等于最大特征值 9.0。在这种特征值与边界值相同的情况下,特征会被放入上一个分箱中。

print(ebm.intercept_)
[-25.67380294 -23.28382745   0.        ]

对于多分类,ebm.intercept_ 是一个数组,其中包含 ebm.classes_ 中每个预测类别的 logit 值。此行为与其它 scikit-learn 多分类器为每个类别生成一个 logit 值的方式相同。

print(ebm.term_scores_[0])
[[-0.72832987  0.13115045  0.32818906]
 [-0.29817331  0.62485909 -0.2641689 ]
 [ 0.51325159 -0.37800477 -0.03201008]
 [ 0.          0.          0.        ]]

ebm.term_scores_[0] 是包含国家名称的名义分类特征的查找表。对于多分类,每个分箱包含一个 logit 数组,每个预测类别对应 1 个 logit 值。在此示例中,每一行对应一个分箱。外部索引有 4 个分箱,内部索引有 3 个类别 logit 值。

缺失值再次被放入第 0 个分箱索引中,如上所示为 3 个 logit 组成的第一行。未见值分箱是最后一行的零。

由于此特征是名义分类特征,我们使用字典 {‘Fiji’: 1, ‘Peru’: 2} 来查找每个分类字符串应使用的 logit 行。

print(ebm.term_scores_[1])
[[-59.21268784 -61.18889948  17.24115462]
 [ 10.2914049   17.07930289   5.88708644]
 [ 17.72146302  26.61089227  -8.22145142]
 [ 31.19981992  17.49870432 -14.90678965]
 [  0.           0.           0.        ]]

ebm.term_scores_[1] 是连续特征的查找表。同样,第 0 个索引和最后一个索引分别用于缺失值和未见值。此特定示例有 5 个分箱,包括第 0 个缺失值分箱索引、来自 2 个边界的三个分区以及未见值分箱索引。每一行都是一个分箱,其中包含 3 个类别 logit 值。

示例代码

此示例代码包含了所有 3 个章节中讨论的所有内容。它可以用作 ExplainableBoostingRegressor 现有 EBM 预测函数或 ExplainableBoostingClassifier 的 predict_proba 函数的直接替代。

from sklearn.utils.extmath import softmax

sample_scores = []
for sample in X:
    # start from the intercept for each sample
    score = ebm.intercept_.copy()
    if isinstance(score, float) or len(score) == 1:
        # regression or binary classification
        score = float(score)

    # we have 2 terms, so add their score contributions
    for term_idx, features in enumerate(ebm.term_features_):
        # indexing into a tensor requires a multi-dimensional index
        tensor_index = []

        # main effects will have 1 feature, and pairs will have 2 features
        for feature_idx in features:
            feature_val = sample[feature_idx]
            bin_idx = 0  # if missing value, use bin index 0

            if feature_val is not None and feature_val is not np.nan:
                # we bin differently for main effects and pairs, so first 
                # get the list containing the bins for different resolutions
                bin_levels = ebm.bins_[feature_idx]

                # what resolution do we need for this term (main resolution, pair
                # resolution, etc.), but limit to the last resolution available
                bins = bin_levels[min(len(bin_levels), len(features)) - 1]

                if isinstance(bins, dict):
                    # categorical feature
                    # 'unseen' category strings are in the last bin (-1)
                    bin_idx = bins.get(feature_val, -1)
                else:
                    # continuous feature
                    try:
                        # try converting to a float, if that fails it's 'unseen'
                        feature_val = float(feature_val)
                        # add 1 because the 0th bin is reserved for 'missing'
                        bin_idx = np.digitize(feature_val, bins) + 1
                    except ValueError:
                        # non-floats are 'unseen', which is in the last bin (-1)
                        bin_idx = -1
        
            tensor_index.append(bin_idx)
        # local_score is also the local feature importance
        local_score = ebm.term_scores_[term_idx][tuple(tensor_index)]
        score += local_score
    sample_scores.append(score)

predictions = np.array(sample_scores)

if hasattr(ebm, 'classes_'):
    # classification
    if len(ebm.classes_) == 2:
        # binary classification

        # softmax expects two logits for binary classification
        # the first logit is always equivalent to 0 for binary classification
        predictions = [[0, x] for x in predictions]
    predictions = softmax(predictions)

if hasattr(ebm, 'classes_'):
    print("probabilities for classes " + str(ebm.classes_))
    print("")
    print(ebm.predict_proba(X))
else:
    print(ebm.predict(X))
print("")
print(predictions)
probabilities for classes [4000 5000 6000]

[[9.99039514e-10 3.96656663e-06 9.99996032e-01]
 [5.01816552e-06 9.99991015e-01 3.96679060e-06]
 [9.99994981e-01 5.01838949e-06 7.75065699e-10]
 [1.54058907e-45 5.50363633e-45 1.00000000e+00]]

[[9.99039514e-10 3.96656663e-06 9.99996032e-01]
 [5.01816552e-06 9.99991015e-01 3.96679060e-06]
 [9.99994981e-01 5.01838949e-06 7.75065699e-10]
 [1.54058907e-45 5.50363633e-45 1.00000000e+00]]