Press "Enter" to skip to content

找出数据中最不寻常的部分

如何利用常识和机器学习找到需要关注的片段

Klara Kulikova 在 Unsplash 上的照片

分析师通常需要找到“有趣”的片段 — 那些我们可以集中精力以获得最大潜在影响的片段。例如,确定哪些客户细分对流失率有最显著的影响可能是有趣的。或者你可以试着了解哪些类型的订单会影响客户支持工作量和公司的收入。

当然,我们可以查看图表来找到这样的显著特征。但是这可能很耗时,因为我们通常要追踪几十甚至上百个客户的特征。不仅如此,我们还需要考虑不同因素的组合,这可能导致组合爆炸。对于这样的任务,使用一个框架会非常有帮助,因为它可以节省你数小时的分析时间。

在本文中,我想与您分享两种找到最显著数据片段的方法:

  • 基于常识和基本数学,
  • 基于机器学习 — 我们在 Wise 公司的数据科学团队开源了一个名为 Wise Pizza 的库,只需三行代码就可以给您提供答案。

案例:银行客户的流失率

您可以在 GitHub 上找到此示例的完整代码。

我们将以银行客户流失率的数据作为示例。该数据集可以在 Kaggle 上找到,采用 CC0:公共领域许可证。

我们将尝试使用不同的方法来找到对流失率影响最大的片段:图表、常识和机器学习。但是让我们从数据预处理开始。

找出数据中最不寻常的部分 四海 第2张

数据集列出了客户及其特征:信用评分、居住国家、年龄和性别、账户余额等。对于每个客户,我们还知道他们是否流失 — 参数 exited

我们的主要目标是找到对流失客户数量影响最大的客户细分。然后,我们可以尝试了解这些用户群体特定的问题。如果我们专注于解决这些细分的问题,我们将对流失客户数量产生最显著的影响。

为了简化计算和解释,我们将将细分定义为一组过滤器,例如 gender = 男性gender = 男性,country = 英国

我们将使用离散特征进行工作,因此我们必须转换连续指标,如 agebalance。为此,我们可以查看分布并定义合适的分组。例如,让我们看一下年龄。

作者提供的图表

分桶连续特征的代码示例

def get_age_group(a):    if a < 25:        return '18 - 25'    if a < 35:        return '25 - 34'    if a < 45:        return '35 - 44'    if a < 55:        return '45 - 54'    if a < 65:        return '55 - 64'    return '65+'raw_df['age_group'] = raw_df.age.map(get_age_group)

在数据中寻找有趣片段的最直接方法是查看可视化图表。我们可以使用条形图或热力图来查看根据一个或两个维度划分的流失率。

让我们来看一下年龄和流失率之间的相关性。35岁以下的客户流失率较低 — 不到10%。而处于45到64岁之间的客户,保留率最差 — 几乎有一半的客户流失。

作者提供的图表

让我们再添加一个参数(性别)来尝试找到更复杂的关系。条形图无法显示二维关系,因此我们切换到热力图。

所有年龄组的女性流失率都较高,因此性别是一个有影响力的因素。

作者提供的图表

这样的可视化可以提供很多有见地的信息,但是这种方法还存在一些问题:

  • 我们没有考虑段的大小。
  • 查看所有可能的特征组合可能会耗费很多时间。
  • 在一个图表中可视化超过两个维度是具有挑战性的。

所以让我们继续使用更结构化的方法,帮助我们获得一个有估计影响的有趣段的优先级列表。

常识方法

假设

我们如何计算为特定段修复问题的潜在影响?我们可以将其与较低流失率的“理想”情况进行比较。

找出数据中最不寻常的部分 四海 第6张

你可能会想知道如何估计流失率的基准。有几种方法可以做到这一点:

  • 市场上的基准:您可以尝试搜索您领域中产品的典型流失率水平。
  • 产品中的高绩效段:通常情况下,您会有一些表现较好的段(例如,您可以按国家或平台进行分割),您可以将它们用作基准。
  • 平均值:最保守的方法是查看全局平均值,并估计达到所有段平均流失率的潜在影响。

我们采用数据集中的平均流失率(20.37%)作为基准。

列出所有可能的段

下一步是构建所有可能的段。我们的数据集有十个维度,每个维度有3-6个唯一值。组合的总数约为1.2M。即使我们只有几个维度和不同的值,这看起来计算成本也很高。在实际任务中,通常会有几十个特征和唯一值。

我们肯定需要考虑一些性能优化。否则,我们可能需要花费几个小时等待结果。以下是一些减少计算量的提示:

  • 首先,我们不需要构建所有可能的组合。将深度限制在4-6是合理的。您的产品团队应该专注于由42个不同过滤器定义的用户段的可能性非常低。
  • 其次,我们可以定义我们感兴趣的效果的大小。假设我们希望至少将留存率提高1个百分点。这意味着我们对小于所有用户的1%的大小的段不感兴趣。因此,如果段的大小低于此阈值,我们可以停止进一步分割段,这将减少操作的数量。
  • 最后,您可以在实际数据集中显着减少数据大小和计算资源的使用。为此,您可以将每个维度的所有小特征组合成一个其他组。例如,有数百个国家,每个国家的用户份额通常遵循与许多其他实际数据关系相同的齐普夫定律。因此,您将有许多用户份额小于所有用户的1%的国家。正如我们之前讨论的,我们对这样的小用户群不感兴趣,我们可以将它们全部分组为一个段国家=其他,以便简化计算。
作者提供的图表

我们将使用递归来构建最多max_depth个过滤器的所有组合。我喜欢这个计算机科学的概念,因为在许多情况下,它可以优雅地解决复杂的问题。不幸的是,数据分析师很少面临编写递归代码的需要——在我的10年数据分析经验中,我只记得有三个任务。

递归的概念非常简单——在执行过程中,函数调用自身。当你处理层次结构或图形时,它非常方便。如果你想了解更多关于Python中递归的内容,请阅读这篇文章。

我们的高级概念如下:

  • 我们从整个数据集和没有过滤器开始。
  • 然后我们尝试添加一个过滤器(如果段大小足够大且我们还没有达到最大深度),并将我们的函数应用于它。
  • 重复上一步,直到条件不再满足。
num_metric = 'exited'denom_metric = 'total'max_depth = 4def convert_filters_to_str(f):    lst = []    for k in sorted(f.keys()):        lst.append(str(k) + ' = ' + str(f[k]))            if len(lst) != 0:        return ', '.join(lst)    return ''def raw_deep_dive_segments(tmp_df, filters):    # return segment    yield {        'filters': filters,        'numerator': tmp_df[num_metric].sum(),        'denominator': tmp_df[denom_metric].sum()    }        # if we haven't reached max_depth then we can dive deeper    if len(filters) < max_depth:        for dim in dimensions:            # check if this dimensions has already been used            if dim in filters:                continue            # deduplication of possible combinations            if (filters != {}) and (dim < max(filters.keys())):                continue                  for val in tmp_df[dim].unique():                next_tmp_df = tmp_df[tmp_df[dim] == val]                # checking if segment size is big enough                if next_tmp_df[denom_metric].sum() < min_segment_size:                    continue                next_filters = filters.copy()                next_filters[dim] = val                                 # executing function for subsequent segment                for rec in raw_deep_dive_segments(next_tmp_df, next_filters):                    yield rec# aggregating all segments for dataframesegments_df = pd.DataFrame(list(raw_deep_dive_segments(df, {})))

结果是我们得到了大约10K个段。现在我们可以计算每个段的预估效果,过滤具有负效果的段,并查看具有最高潜在影响的用户群体。

baseline_churn = 0.2037segments_df['churn_share'] = segments_df.churn/segments_df.totalsegments_df['churn_est_reduction'] = (segments_df.churn_share - baseline_churn)\    *segments_df.totalsegments_df['churn_est_reduction'] = segments_df['churn_est_reduction']\  .map(lambda x: int(round(x)))filt_segments_df = segments_df[segments_df.churn_est_reduction > 0]\    .sort_values('churn_est_reduction', ascending = False).set_index('segment')

它应该是一个能给出所有答案的完美解决方案。但是等一下,有太多重复和相邻的段。我们能减少重复并只保留最有信息的用户群吗?

找出数据中最不寻常的部分 四海 第8张

整理

让我们来看几个例子。

子段age_group = 45–54, gender = Male的流失率低于age_group = 45–54。添加一个gender = Male的过滤器并不能使我们更接近具体的问题。所以我们可以消除这种情况。

找出数据中最不寻常的部分 四海 第9张

下面的例子显示了相反的情况:子段的流失率显著更高,并且更重要的是,子段中包括80%来自父节点的流失客户。在这种情况下,消除credit_score_group = poor, tenure_group = 8+的段是合理的,因为主要问题在于is_active_member = 0群体。

找出数据中最不寻常的部分 四海 第10张

让我们过滤掉那些不那么有趣的片段。

import statsmodels.stats.proportion# 获取所有父子对def get_all_ancestors_recursive(filt):    if len(filt) > 1:        for dim in filt:            cfilt = filt.copy()            cfilt.pop(dim)            yield cfilt            for f in get_all_ancestors_recursive(cfilt):                yield f                def get_all_ancestors(filt):    tmp_data = []    for f in get_all_ancestors_recursive(filt):        tmp_data.append(convert_filters_to_str(f))    return list(set(tmp_data))tmp_data = []for f in tqdm.tqdm(filt_segments_df['filters']):    parent_segment = convert_filters_to_str(f)    for af in get_all_ancestors(f):        tmp_data.append(            {                'parent_segment': af,                'ancestor_segment': parent_segment            }        )        full_ancestors_df = pd.DataFrame(tmp_data)# 过滤子节点中的流失率较低filt_child_segments = []for parent_segment in tqdm.tqdm(filt_segments_df.index):    for child_segment in full_ancestors_df[full_ancestors_df.parent_segment == parent_segment].ancestor_segment:        if child_segment in filt_child_segments:            continue                churn_diff_ci = statsmodels.stats.proportion.confint_proportions_2indep(            filt_segments_df.loc[parent_segment][num_metric],            filt_segments_df.loc[parent_segment][denom_metric],            filt_segments_df.loc[child_segment][num_metric],            filt_segments_df.loc[child_segment][denom_metric]        )                if churn_diff_ci[0] > -0.00:            filt_child_segments.append(                {                    'parent_segment': parent_segment,                    'child_segment': child_segment                }            )            filt_child_segments_df = pd.DataFrame(filt_child_segments)filt_segments_df = filt_segments_df[~filt_segments_df.index.isin(filt_child_segments_df.child_segment.values)]# 过滤父节点中的流失率较低filt_parent_segments = []for child_segment in tqdm.tqdm(filt_segments_df.index):    for parent_segment in full_ancestors_df[full_ancestors_df.ancestor_segment == child_segment].parent_segment:        if parent_segment not in filt_segments_df.index:            continue                    churn_diff_ci = statsmodels.stats.proportion.confint_proportions_2indep(            filt_segments_df.loc[parent_segment][num_metric],            filt_segments_df.loc[parent_segment][denom_metric],            filt_segments_df.loc[child_segment][num_metric],            filt_segments_df.loc[child_segment][denom_metric]        )        child_coverage = filt_segments_df.loc[child_segment][num_metric]/filt_segments_df.loc[parent_segment][num_metric]        if (churn_diff_ci[1] < 0.00) and (child_coverage >= 0.8):            filt_parent_segments.append(                {                    'parent_segment': parent_segment,                    'child_segment': child_segment                }            )              filt_parent_segments_df = pd.DataFrame(filt_parent_segments)filt_segments_df = filt_segments_df[~filt_segments_df.index.isin(filt_parent_segments_df.parent_segment.values)]

现在我们有了大约4K个有趣的片段。在这个玩具数据集中,我们在顶部进行了这样的整理后几乎看不到差异。然而,在现实数据中,这些努力通常是值得的。

找出数据中最不寻常的部分 四海 第11张

根本原因

我们可以做的最后一件事是只保留我们片段的根节点,这些片段是根本原因,其他片段都包含在其中。如果您想深入研究根本原因之一,请查看子节点。

为了只获取根本原因,我们需要消除所有在最终有趣片段列表中具有父节点的片段。

root_segments_df = filt_segments_df[~filt_segments_df.index.isin(    full_ancestors_df[full_ancestors_df.parent_segment.isin(        filt_segments_df.index)].ancestor_segment    )]

所以这就是,现在我们有了一份要关注的用户组列表。我们在顶部只得到了一维片段,因为在数据中很少有复杂的关系,只有几个特征能够解释全部效果。

找出数据中最不寻常的部分 四海 第12张

讨论如何解释结果是至关重要的。我们得到了一个估计影响的客户段列表。我们的估计是基于这样一个假设:我们可以降低整个段的流失率,以达到基准水平(在我们的示例中是平均值)。因此,我们估计了为每个用户组解决问题的影响。

您必须记住,这种方法只能给您一个关注哪些用户组的高层次视图。它并不考虑是否可能完全修复这些问题。

我们已经编写了很多代码来获得结果。也许有另一种使用数据科学和机器学习解决这个任务的方法,不需要这么多的工作。

披萨时间

实际上,还有另一种方法。我们在 Wise 公司的数据科学团队开发了一个名为 Wise Pizza 的库,可以在眨眼之间找到最有趣的段落。它是根据 Apache 2.0 许可证开源的,所以您也可以在您的任务中使用它。

如果您想了解更多关于 Wise Pizza 库的信息,请不要错过 Egor 在数据科学节上的演示。

应用 Wise Pizza

这个库很容易使用。您只需要写几行代码,并指定您想要的结果中的维度和段数。

# pip install wise_pizza - 进行安装import wise_pizza# 构建模型 sf = wise_pizza.explain_levels(    df=df,    dims=dimensions,    total_name="exited",    size_name="total",    max_depth=4,    min_segments=15,    solver="lasso")# 绘制图表sf.plot(width=700, height=100, plot_is_static=False)
作者的图表

结果,我们还得到了一个最有趣的段落列表以及它们对我们产品流失的潜在影响。这些段落与我们之前使用的方法得到的类似。然而,影响估计有很大不同。为了正确解释 Wise Pizza 的结果并理解差异,我们需要更详细地讨论它的工作原理。

工作原理

该库基于 Lasso 和 LP 求解器。如果我们简化一下,该库类似于独热编码,为段落添加标志(与我们之前计算的相同),然后使用 Lasso 回归以流失率作为目标变量。

找出数据中最不寻常的部分 四海 第14张

正如您可能记得的机器学习知识,Lasso 回归倾向于具有许多零系数,选择少数重要因素。Wise Pizza 找到适当的 alpha 系数以用于 Lasso 回归,以便您将获得指定数量的段落作为结果。

要了解有关修订 Lasso(L1)和 Ridge(L2)正则化的更多信息,请参阅该文章。

如何解释结果

影响被估计为系数和段大小的乘积。

找出数据中最不寻常的部分 四海 第15张

因此,正如您所看到的,它与我们之前估计的完全不同。常识方法估计了为用户组完全解决问题的影响,而 Wise Pizza 的影响则显示了其他选定段落的增量效果。

这种方法的优势是您可以总结不同的效果。然而,在结果解释时需要准确,因为每个段落的影响取决于其他选定的段落,因为它们可能存在相关性。例如,在我们的案例中,我们有三个相关的段落:

  • age_group = 45-54
  • num_of_products = 1, age_group = 44–54
  • is_active_member = 1, age_group = 44–54

age_group = 45–54的影响把握了整个年龄组的潜在影响,而其他年龄组则估计了来自特定子组的额外影响。这种依赖关系可能会导致结果差异显著,这取决于min_segments参数,因为您将拥有不同的最终细分集和它们之间的相关性。

关注整个画面并正确解释Wise Pizza的结果非常重要。否则,您可能会得出错误的结论。

我非常感谢这个库,它是从数据中快速获取见解和深入研究首个段落候选的无价工具。然而,如果我需要进行机会评估和更加稳健的分析,以与我的产品团队共享我们关注点的潜在影响,我仍然会使用一种常识性的方法和合理的基准,因为这样更容易解释。

TL;DR

  1. 在数据中找到有趣的切片是分析师的常见任务(尤其是在发现阶段)。幸运的是,您不需要制作数十个图表来解决此类问题。有更为全面和易于使用的框架。
  2. 您可以使用Wise Pizza机器学习库快速了解对平均值有最显著影响的细分,并比较两个数据集之间的差异。我通常使用它来获取有意义的维度和细分的第一个列表。
  3. 机器学习方法可以让您在一眨眼之间获得高层次的视图和优先级。然而,我建议您关注结果的解释,并确保您和您的利益相关者完全理解。然而,如果您需要对修复整个用户组的问题可能对关键绩效指标产生的潜在影响进行稳健估计,那么最好使用基于算术的老式常识方法。

非常感谢您阅读本文。我希望对您有所启发。如果您有任何后续问题或评论,请随时在评论区留言。

Leave a Reply

Your email address will not be published. Required fields are marked *