数据STUDIO

颠覆认知!这个特征很重要,但不是个好特征!

Image

Image

“重要”和 “好”不是同义词

在机器学习中,特征重要性这一概念作为模型可解释性的最基本类型被广泛使用。例如,在递归特征消除(RFE)中,它被用来迭代放弃模型中最不重要的特征。

然而,我们大多数人对它存在一种误解。

一个特征很重要并不意味着它对模型有益! 事实上,当我们说一个特征很重要时,并不意味着它对模型有益。

当我们说某个特征很重要时,这仅仅意味着该特征对模型预测的贡献很大。但我们应该考虑到,这种贡献可能是错误的。

举个简单的例子:一位数据科学家不小心忘记了模型特征中的客户 ID。该模型将客户 ID 作为高度预测的特征。因此,该特征将具有很高的特征重要性,即使它实际上会使模型变得更糟,因为它无法在未见过的数据上很好地工作。

为了更清楚地说明问题,我们需要区分两个概念:

  • 预测贡献:预测结果中有多大一部分是由于特征造成的;这相当于特征重要性。
  • 误差贡献:预测误差的哪一部分是由于模型中存在特征造成的。

在本文中,云朵君将和大家一起了解如何计算这些量,以及如何利用它们来挖掘出关于预测模型的价值(并对其进行改进)。

首先看个实例

假设我们建立了一个模型,可以根据人们的工作、年龄和国籍来预测他们的收入。现在,我们使用该模型对三个人进行预测。

shap_values = pd.DataFrame([
  [65,  12,   8],
  [72,   3, -22],
  [78, -23,  15]], columns=["job", "age", "nationality"])
y_pred = shap_values.sum(axis=1).rename("income (predicted)")
y_true = pd.Series([74, 68, 58], name="income (true)")

abs_error = (y_true - y_pred).abs().rename("absolute error")

这样,我们就有了实际值、模型预测和由此产生的误差:

Image
实际值、模型预测和绝对误差(单位:k$ 千美元)

计算 "预测贡献"

有了预测模型后,我们总是可以将模型预测分解为单个特征带来的贡献。这可以通过 SHAP 值来实现。
用 SHAP 可视化解释机器学习模型实用指南(上)
用 SHAP 可视化解释机器学习模型实用指南(下)

因此,假设这些是相对于我们的模型的 SHAP 值,用于三个个体。

Image
模型预测的 SHAP 值(单位:k$ 千美元)

SHAP 值的主要特性是它们是相加的。这意味着,通过求取每一行的总和,我们就可以得到模型对该个体的预测值。例如,如果我们取第二行:72k$ +3k$ -22k$ = 53k$ 这正是模型对第二个人的预测值。

现在,SHAP 值是一个很好的指标,它可以说明某个特征对我们的预测有多重要。事实上,SHAP 值(绝对值)越高,该特征对特定个体预测的影响就越大。这里是绝对 SHAP 值,因为这里的符号并不重要:如果一个特征能使预测结果上升或下降,那么它就同样重要。

因此,特征的预测贡献等于该特征的绝对 SHAP 值的平均值。如果将 SHAP 值存储在 Pandas 数据帧中,那么操作就非常简单了:

prediction_contribution = shap_values.abs().mean()
Image
预测贡献

可以看出,工作显然是最重要的特征,因为它平均占最终预测结果的 71.67 千美元。国籍和年龄分别是第二和第三重要的特征。

然而,某一特征占最终预测的相关部分这一事实并不能说明该特征的性能。为了考虑这一点,我们需要计算 "误差贡献"。

计算 "误差贡献"

假设我们想要回答以下问题:如果模型没有 job 这一特征,它会做出怎样的预测?SHAP 值可以回答这个问题。事实上,由于它们是相加的,因此只需从模型的预测值中减去与特征 job 相关的 SHAP 值即可。

对每个特征重复这一过程。在 Pandas 中

y_pred_wo_feature = shap_values.apply(lambda feature: y_pred - feature)
Image
如果去掉相应特征,将得到的预测值

也就是说,如果没有job这个特征,那么模型会预测第一个人的收入为 20k $,第二个人的收入为-19k $ ,第三个人的收入为-8k $。相反,如果我们没有 age 这一特征,模型将预测第一个人获得 73k $ ,第二个人获得  50k $,以此类推。

如果去掉不同的特征,每个人的预测结果都会有很大的不同。因此,预测误差也会大不相同。可以很容易地计算出它们:

abs_error_wo_feature = y_pred_wo_feature.apply(lambda feature: (y_true - feature).abs())
Image
去掉相应特征后的绝对误差

这些误差是去除相应特征后的误差。直观地说,如果误差很小,那么去除该特征就不会有问题,甚至对模型有利。如果误差很大,那么去除特征就不是一个好主意。

我们可以计算完整模型的误差与没有该特征时的误差之间的差值:

error_diff = abs_error_wo_feature.apply(lambda feature: abs_error - feature)
Image
模型误差与没有该特征时的误差之差

如果这个数字为:

  • 负数,则该特征的存在会导致预测误差减小,因此该特征对该观测结果非常有效!
  • 正数,则该特征的存在会导致预测误差增大,因此该特征对该观测结果不利。

我们可以将 "误差贡献 "计算为每个特征值的平均值。在 Pandas 中

error_contribution = error_diff.mean()
Image
误差贡献

如果该值为正,则意味着平均而言,模型中存在该特征会导致更高的误差。因此,如果没有该特征,预测结果一般会更好。换句话说,该特征弊大于利!

相反,该值越负,则该特征对预测越有利,因为它的存在会导致较小的误差。

预测黄金回报率

下面,我将使用来自 Pycaret 的数据集。该数据集名为 "Gold",包含金融数据的时间序列。

from pycaret.datasets import get_data
df = get_data("gold", verbose=False)
df *= 100

target = "Gold_T+22"

features = [f for f in df.columns if f != target]
argsort = np.argsort([f.split("T-")[0] + "T-" + "{:2.1f}".format(float(f.split("T-")[1])).zfill(4) for f in features])
features = [features[arg] for arg in argsort]

Image
数据集样本。特征均以百分比表示,因此 -4.07 表示收益率为 -4.07%。

这些特征包括观察时刻前 22 天、14 天、7 天和 1 天("T-22"、"T-14"、"T-7"、"T-1")的金融资产收益率。以下是所有用作预测特征的金融资产的列表:

for enum, f in enumerate(sorted(set([f.split("_")[0] for f in features]))):
  print(f.ljust(20), end="")
  if enum % 4 == 3:
    print("\n", end="")
Image
可用资产列表。每种资产的观测时间分别为-22、-14、-7 和-1

我们总共有 120 个特征。

我们的目标是提前 22 天预测黄金价格(收益)("Gold_T+22")。

fig, ax = plt.subplots()
ax.hist(df[target], bins="fd")
ax.grid(); ax.set_axisbelow(True)
ax.tick_params(axis="both", which="major", labelsize=12)
ax.set_xlabel("Gold Return (%) - 22 Days Ahead", fontsize=12)
Image
变量直方图

加载数据集后,我执行了以下步骤:

  1. 随机分割整个数据集:33% 的行在训练数据集中,另外 33% 在验证数据集中,剩下的 33% 在测试数据集中。
  2. 在训练数据集上训练 LightGBM。
  3. 使用上一步训练的模型对训练、验证和测试数据集进行预测。
  4. 使用 Python 库 "shap" 计算训练、验证和测试数据集的 SHAP 值。
  5. 使用我们在上一段中看到的代码,计算每个特征在每个数据集(训练集、验证集和测试集)上的预测贡献值和误差贡献值。
np.random.seed(123)
fold = np.random.choice(["trn", "val", "tst"], p=[1/3, 1/3, 1/3], size=len(df))

ix_trn = fold == "trn"
ix_val = fold == "val"
ix_tst = fold == "tst"

def get_preds_shaps(df, features, target, ix_trn):
  model = LGBMRegressor().fit(df.loc[ix_trn, features], df.loc[ix_trn, target])
  preds = pd.Series(model.predict(df[features]), index=df.index)
  shap_explainer = TreeExplainer(model)
  shaps = pd.DataFrame(
    data=shap_explainer.shap_values(df[features]),
    index=df.index,
    columns=features)
  return preds, shaps

    def get_feature_contributions(y_true, y_pred, shap_values):
  """Compute prediction contribution and error contribution for each feature."""

  prediction_contribution = shap_values.abs().mean().rename("prediction_contribution")

  abs_error = (y_true - y_pred).abs()
  y_pred_wo_feature = shap_values.apply(lambda feature: y_pred - feature)
  abs_error_wo_feature = y_pred_wo_feature.apply(lambda feature: (y_true - feature).abs())
  error_contribution = abs_error_wo_feature.apply(lambda feature: abs_error - feature).mean().rename("error_contribution")

        return prediction_contribution, error_contribution

  preds, shaps = get_preds_shaps(df=df, features=features, target=target, ix_trn=ix_trn)

prediction_contribution_trn, error_contribution_trn = get_feature_contributions(
  y_true=df.loc[ix_trn, target], 
  y_pred=preds.loc[ix_trn], 
  shap_values=shaps.loc[ix_trn, :]
)

prediction_contribution_val, error_contribution_val = get_feature_contributions(
  y_true=df.loc[ix_val, target], 
  y_pred=preds.loc[ix_val], 
  shap_values=shaps.loc[ix_val, :]
)

contributions_trn = pd.concat([prediction_contribution_trn, error_contribution_trn], axis=1)
contributions_val = pd.concat([prediction_contribution_val, error_contribution_val], axis=1)

比较预测贡献率和误差贡献率

比较一下训练数据集中的误差贡献和预测贡献。使用散点图,因此点代表模型的 120 个特征。

fig, ax = plt.subplots()

ax.scatter(contributions_trn["prediction_contribution"], contributions_trn["error_contribution"], s=80, color="orange", edgecolors="black")
ax.hlines(y=0, xmin=0, xmax=contributions_trn["prediction_contribution"].max(), ls="--", color="black")
ax.tick_params(axis="both", which="major", labelsize=12)
ax.grid(); ax.set_axisbelow(True)
ax.set_title("Train Dataset", fontsize=12)
ax.set_xlabel("Prediction Contribution", fontsize=12)
ax.set_ylabel("Error Contribution", fontsize=12)

Image
预测贡献与误差贡献(训练数据集)

在训练集中,预测贡献与误差贡献之间存在高度负相关。

这是有道理的:由于模型是在训练数据集上学习的,因此它倾向于将高重要性(即高预测贡献率)赋予那些能大大降低预测误差(即高负误差贡献率)的特征。

事实上,对我们来说真正重要的是验证数据集。验证数据集是我们对特征在新数据上的表现的最佳代表。因此,在验证集上进行同样的比较。

fig, ax = plt.subplots()

ax.scatter(contributions_val["prediction_contribution"], contributions_val["error_contribution"], s=80, color="orange", edgecolors="black")
ax.hlines(y=0, xmin=0, xmax=contributions_val["prediction_contribution"].max(), ls="--", color="black")
ax.tick_params(axis="both", which="major", labelsize=12)

ax.grid()
ax.set_axisbelow(True)
ax.set_title("Validation Dataset", fontsize=12)
ax.set_xlabel("Prediction Contribution", fontsize=12)
ax.set_ylabel("Error Contribution", fontsize=12)

Image
预测贡献与误差贡献对比图(验证数据集)

从这幅图中,我们可以提取出一些更有趣的信息。

图中右下方的特征被我们的模型正确地赋予了很高的重要性,因为它们实际上减少了预测误差。

此外,请注意 "Gold_T-22"(观察期前 22 天黄金的回归)与模型赋予它的重要性相比,效果非常好。这意味着这个特征可能拟合不足。由于黄金是我们试图预测的资产("Gold_T+22"),因此这一信息尤为有趣。

另一方面,误差贡献大于 0 的特征会使我们的预测结果更差。例如,"美国债券 ETF_T-1"平均使模型的预测结果改变了 0.092%(预测贡献),但它导致模型的预测结果比没有该特征时平均差了 0.013%(误差贡献)。

我们可以认为,所有误差贡献率(与其预测贡献率相比)较高的特征都可能是过度拟合,或者总的来说,它们在训练集和验证集中的表现不同。

哪些特征的误差贡献最大。

contributions_val.sort_values("error_contribution", ascending=False).round(4).head(10)
Image
按错误贡献递减特征排序

现在是误差贡献最小的特征:

contributions_val.sort_values("error_contribution", ascending=True).round(4).head(10)
Image
按错误贡献增加特征排序

有趣的是,我们可以观察到,所有误差贡献较大的特征都是相对于 T-1(观测时刻前 1 天)而言的,而几乎所有误差贡献较小的特征都是相对于 T-22(观测时刻前 22 天)而言的。

这似乎表明,最近的特征容易过度拟合,而时间较远的特征往往概括性较好。

请注意,如果没有 "误差贡献"(Error Contribution),我们永远也不会知道这一洞察力。

使用误差贡献的 RFE

传统的递归特征消除(RFE)方法基于去除不重要的特征。这相当于先去除预测贡献较小的特征。

rfe_prediction = pd.DataFrame(dtype=float)
features_curr = features.copy()
feature_drop = None

for iteration in tqdm(range(len(features))):

    preds, shaps = get_preds_shaps(df=df, features=features_curr, target=target, ix_trn=ix_trn)
  prediction_contribution, error_contribution = get_feature_contributions(
    y_true=df.loc[ix_val, target], 
    y_pred=preds.loc[ix_val], 
    shap_values=shaps.loc[ix_val, :]
  )

  rfe_prediction.loc[iteration, "feature_drop"] = feature_drop
  rfe_prediction.loc[iteration, "n_features"] = len(features_curr)
  rfe_prediction.loc[iteration, "contrib"] = prediction_contribution.min()
  rfe_prediction.loc[iteration, "mae_trn"] = mean_absolute_error(df.loc[ix_trn, target], preds.loc[ix_trn])
  rfe_prediction.loc[iteration, "mae_val"] = mean_absolute_error(df.loc[ix_val, target], preds.loc[ix_val])
  rfe_prediction.loc[iteration, "mae_tst"] = mean_absolute_error(df.loc[ix_tst, target], preds.loc[ix_tst])
  rfe_prediction.loc[iteration, "r2_trn"] = r2_score(df.loc[ix_trn, target], preds.loc[ix_trn])
  rfe_prediction.loc[iteration, "r2_val"] = r2_score(df.loc[ix_val, target], preds.loc[ix_val])
  rfe_prediction.loc[iteration, "r2_tst"] = r2_score(df.loc[ix_tst, target], preds.loc[ix_tst])

    feature_drop = prediction_contribution.idxmin()
  features_curr.remove(feature_drop)

然而,根据我们在上一段中所说的,先去除误差贡献最大的特征更有意义。

rfe_error = pd.DataFrame(dtype=float)
features_curr = features.copy()
feature_drop = None

for iteration in tqdm(range(len(features))):

    preds, shaps = get_preds_shaps(df=df, features=features_curr, target=target, ix_trn=ix_trn)
  prediction_contribution, error_contribution = get_feature_contributions(
    y_true=df.loc[ix_val, target], 
    y_pred=preds.loc[ix_val], 
    shap_values=shaps.loc[ix_val, :]
  )

  rfe_error.loc[iteration, "feature_drop"] = feature_drop
  rfe_error.loc[iteration, "n_features"] = len(features_curr)
  rfe_error.loc[iteration, "contrib"] = error_contribution.max()
  rfe_error.loc[iteration, "mae_trn"] = mean_absolute_error(df.loc[ix_trn, target], preds.loc[ix_trn])
  rfe_error.loc[iteration, "mae_val"] = mean_absolute_error(df.loc[ix_val, target], preds.loc[ix_val])
  rfe_error.loc[iteration, "mae_tst"] = mean_absolute_error(df.loc[ix_tst, target], preds.loc[ix_tst])
  rfe_error.loc[iteration, "r2_trn"] = r2_score(df.loc[ix_trn, target], preds.loc[ix_trn])
  rfe_error.loc[iteration, "r2_val"] = r2_score(df.loc[ix_val, target], preds.loc[ix_val])
  rfe_error.loc[iteration, "r2_tst"] = r2_score(df.loc[ix_tst, target], preds.loc[ix_tst])

    feature_drop = error_contribution.idxmax()
  features_curr.remove(feature_drop)

为了验证我们的直觉是否正确,让我们比较一下这两种方法:

  • 传统的 RFE:先去除无用的特征(预测贡献最低)。
  • 我们的 RFE:首先去除有害特征(误差贡献最高)。

验证集上的结果:

fig, ax = plt.subplots()

idxmin_prediction = rfe_prediction["mae_val"].idxmin()
idxmin_error = rfe_error["mae_val"].idxmin()

ax.plot(-rfe_prediction.head(max_iter_show)["n_features"], rfe_prediction.head(max_iter_show)["mae_val"], lw=3, color="blue", label="RFE - Prediction\nContribution")
ax.plot(-rfe_error.head(max_iter_show)["n_features"], rfe_error.head(max_iter_show)["mae_val"], lw=3, color="orange", label="RFE - Error\nContribution")
ax.scatter(-rfe_prediction.loc[idxmin_prediction,"n_features"], rfe_prediction.loc[idxmin_prediction,"mae_val"], s=200, marker="o", facecolors="none", edgecolors="red", lw=3, zorder=100)
ax.scatter(-rfe_error.loc[idxmin_error,"n_features"], rfe_error.loc[idxmin_error,"mae_val"], s=200, marker="o", facecolors="none", edgecolors="red", lw=3, zorder=100)
ax.set_title("Validation Set", fontsize=12)
ax.set_ylabel("Mean Absolute Error", fontsize=12)
ax.set_xlabel("N Features", fontsize=12)
ax.set_xticklabels([-int(x) for x in ax.get_xticks()])
ax.grid()
ax.legend(loc='center left', bbox_to_anchor=(1, 0.5), fontsize=12)
ax.tick_params(axis="both", which="major", labelsize=12)

fig.savefig("rfe_val.png", dpi=200, bbox_inches="tight")

Image
两种策略在验证集上的平均绝对误差

每种方法的最佳迭代都被圈了起来:传统 RFE 的模型有 19 个特征(蓝线),而我们的 RFE 模型有 17 个特征(橙线)。

总体看来,我们的方法效果很好:与去除预测贡献最大的特征相比,去除误差贡献最大的特征导致的 MAE 一直较小。

不过,你可能会认为这种方法之所以效果良好,只是因为我们过度拟合了验证集。毕竟,我们感兴趣的是在测试集上得到的结果。

在测试集上进行同样的比较。

fig, ax = plt.subplots()

idxmin_prediction = rfe_prediction["mae_val"].idxmin()
idxmin_error = rfe_error["mae_val"].idxmin()

ax.plot(-rfe_prediction.head(max_iter_show)["n_features"], rfe_prediction.head(max_iter_show)["mae_tst"], lw=3, color="blue", label="RFE - Prediction\nContribution")
ax.plot(-rfe_error.head(max_iter_show)["n_features"], rfe_error.head(max_iter_show)["mae_tst"], lw=3, color="orange", label="RFE - Error\nContribution")
ax.scatter(-rfe_prediction.loc[idxmin_prediction,"n_features"], rfe_prediction.loc[idxmin_prediction,"mae_tst"], s=200, marker="o", facecolors="none", edgecolors="red", lw=3, zorder=100)
ax.scatter(-rfe_error.loc[idxmin_error,"n_features"], rfe_error.loc[idxmin_error,"mae_tst"], s=200, marker="o", facecolors="none", edgecolors="red", lw=3, zorder=100)
ax.set_title("Test Set", fontsize=12)
ax.set_ylabel("Mean Absolute Error", fontsize=12)
ax.set_xlabel("N Features", fontsize=12)
ax.set_xticklabels([-int(x) for x in ax.get_xticks()])
ax.grid()
ax.legend(loc='center left', bbox_to_anchor=(1, 0.5), fontsize=12)
ax.tick_params(axis="both", which="major", labelsize=12)

Image
两种策略在测试集上的平均绝对误差。

结果与前一结果类似。即使两条线之间的距离较小,移除误差贡献度最高的模型所获得的平均绝对误差(MAE)也明显优于移除预测贡献度最低的模型所获得的平均绝对误差(MAE)。

既然我们选择了在验证集上 MAE 最小的模型,那么让我们看看它们在测试集上的结果:

  • RFE-Prediction Contribution(19 个特征)。测试集上的 MAE:2.04.
  • RFE-误差贡献(17 个特征)。测试集上的 MAE:1.94:1.94.

因此,与传统的 RFE 相比,使用我们的方法得到的最佳 MAE 高出 5%!

结论

特征重要性的概念在机器学习中扮演着重要角色。然而,"重要性" 的概念常常被误认为是 "好"。

为了区分这两个方面,我们引入了两个概念:预测贡献和误差贡献。这两个概念都基于验证数据集的 SHAP 值,在文章中我们看到了计算它们的 Python 代码。

我们还在一个真实的金融数据集(其中的任务是预测黄金价格)上对它们进行了尝试,并证明基于误差贡献的递归特征消除与传统的基于预测贡献的递归特征消除相比,平均绝对误差提高了 5%。

🏴‍☠️宝藏级🏴‍☠️ 原创公众号『数据STUDIO』内容超级硬核。公众号以Python为核心语言,垂直于数据科学领域,包括可戳👉Python|MySQL|数据分析|数据可视化|机器学习与数据挖掘|爬虫 等,从入门到进阶!

长按👇关注- 数据STUDIO -设为星标,干货速递ImageImage