为了账号安全,请及时绑定邮箱和手机立即绑定

产品分析中的线性优化技巧

解决背包问题(即Knapsack问题)

这张图片是由DALL-E 3生成的

这可能听起来有点出人意料,但在本文中,我想谈谈背包问题(Knapsack Problem),这个经典的优化问题已经被研究了一个多世纪。根据Wikipedia,该问题的定义如下:

假设你有一堆物品,每个物品都有一个(重量)和一个(价值),确定要选择哪些物品,使得总重量不超过限制同时总价值最大化。这样总价值尽可能高。

虽然产品分析师可能不会亲自打包背包,但这种数学模型背后对我们许多的工作都高度相关。在产品分析中,有许多背包问题(Knapsack Problem)的现实应用。下面举几个例子:

  • 市场营销活动: 市场团队的预算和资源有限,需要在不同的渠道和区域开展活动。他们需要在遵守现有限制的前提下,最大化关键绩效指标(KPI),比如新用户数量或收入。
  • 优化零售空间: 零售商的实体店空间有限,希望通过优化产品陈列布局来增加收入。
  • 产品发布优先级: 在推出新产品的过程中,运营团队可能资源有限,需要优先考虑特定市场的需求。

这样的任务非常普遍,许多分析师经常会碰到这样的任务。所以在本文中,我将探讨解决这些问题的不同方法,从简单的技巧到更高级的技巧,比如线性规划。

另一个我选择这个话题的原因是,线性规划是规定性分析中最强大的工具之一——这种分析侧重于为利益相关者提供可操作的选择,以便做出明智的决定。因此,我认为,对于任何分析师来说,它都是一项必不可少的技能。

一个案例

让我们直接跳进我们要研究的案例。想象一下,我们是营销团队的成员,正在策划下个月的活动。我们的目标是在有限的营销预算下,最大化提升关键绩效指标(KPIs),比如新增用户数量和收入。

我们已经估计了各种市场营销活动在不同国家和渠道的可能结果。如下是我们得到的数据:

  • country — 我们可以进行一些促销活动的市场;
  • channel — 获取用户的方式,例如社交媒体或影响者活动;
  • users — 一个月内预计新增用户数;
  • cs_contacts — 新用户生成的客户支持联系次数;
  • marketing_spending — 活动花费;
  • revenue — 从新增客户中获得的第一年收益。

请注意,该数据集是合成并随机生成的,所以不要尝试从中推断任何有关市场的信息。

首先,我计算了总体统计数据,来了解总体情况。

我们来确定最合适的营销活动组合,在不超过3000万的营销预算的前提下,确保收入最大化。

暴力法

初看之下,这个问题似乎很简单:我们可以通过计算选择最佳营销活动组合。然而,这可能是个不小的挑战。

总共有 62 个片段,每个片段可以选择包含或排除,从而产生 2⁶² 种可能的组合,约 4.6*10¹⁸ 种组合,——一个天文般的大数字。

为了更好地理解计算上的可行性,我们来看看15个片段的小子集,并估算一下一次迭代需要多少时间。

    import itertools  
    import pandas as pd  
    import tqdm  

    # 读取数据  
    df = pd.read_csv('marketing_campaign_estimations.csv', sep = '\t')  
    # 将df的'segment'列设置为df的'country'和'channel'列的值连接,中间用' - '分隔  
    df['segment'] = df.country + ' - ' + df.channel  

    # 计算组合  
    combinations = []  
    segments = list(df.segment.values)[:15]  
    print('共有多少个段:', len(segments))  

    for num_items in range(len(segments) + 1):  
      combinations.extend(  
          itertools.combinations(segments, num_items)  
      )  
    print('共有多少个组合:', len(combinations))  

    tmp = []  
    for selected in tqdm.tqdm(combinations):  
        tmp_df = df[df.segment.isin(selected)]  
        tmp.append(  
            {  
            'selected_segments': ', '.join(selected),  
            'users': tmp_df['users'].sum(),  
            'cs_contacts': tmp_df['cs_contacts'].sum(),  
            'marketing_spending': tmp_df['market_spending'].sum(),  
            'revenue': tmp_df['revenue'].sum()  
            }  
        )  

    # 共有15个段  
    # 共有32768个组合

处理15个片段大约花了4秒,平均每秒大约能处理7000次迭代。按照这个估算,我们来算算处理完全部62个片段需要多长时间。

    2**62 / 7000 / 3600 / 24 / 365  
    # 20,890,800.6

如果用暴力破解,大约需要2090万年才能得到答案——显然这不可行。

执行时间完全取决于段落的数量。只需移除一个片段就可以把时间减半。我们来看看合并这些部分的方法。

通常,小的片段比大的片段多,所以将它们合并是一个合乎逻辑的做法。需要注意的是,这种方法可能会降低准确性,因为多个片段被合并成一个。不过,这仍然可能提供一个“勉强够用”的解决方案。

为了简单化处理,我们来将所有贡献不足总收入0.1%的部分合并。

df['收入份额'] = df.revenue / df.revenue.sum() * 100  
df['分段组'] = list(map(  
    lambda x, y: x if y >= 0.1 else '其他',  # 如果份额大于等于10%
    df.segment,  
    df['收入份额']  
))  

print(df[df['分段组'] == '其他'].收入份额.sum())  # 打印segment_group为'其他'的所有收入份额的总和
# 收入份额: 0.53  
print(df['分段组'].nunique())  # 计算segment_group的唯一值数量
# 52

采用这种方法,我们将十个段落合并为一个,代表总收入的0.53%(可能的误差)。剩余52个段落后,我们可以在20.4K年内找到答案。尽管这是显著的进步,但仍然不够充分。

您可以考虑针对特定任务量身定制的其他启发式算法。例如,如果您的约束条件是一个比率(例如,接触率 = CS接触 / 用户 ≤ 5%),您可以将所有满足约束条件的段分组,因为最优解将包含所有这些段。但在我们的情况中,没有看到额外的方法来减少段,因此暴力法似乎不太实际。

说到底,如果组合的数量相对较少,并且暴力枚举可以在合理的时间内执行,这种方法就非常理想。开发简单,结果也十分准确。

简单的做法:看看哪些部分性能最佳

由于暴力法计算所有组合不切实际,让我们考虑一个更简单的办法来处理这个问题。

一种可能的方法是专注于表现最佳的部分。我们可以通过计算每花费一美元产生的收入来评估各部分的表现,然后根据这个比率对所有活动进行排序,并选择在营销预算范围内表现最好的部分。咱们就干吧。

    df['收入每花费'] = df.收入 / df.营销支出  
    df = df.sort_values(by='收入每花费', ascending=False)  
    df['累计花费'] = df['营销支出'].cumsum()  
    selected_df = df[df['累计花费'] <= 30000000]  
    print(selected_df.shape[0])  
    # 48   
    print(selected_df.收入.sum()/1000000)  
    # 107.92

通过这种方法,我们选了48个项目,并获得约1.08亿美元的收入。

不幸的是,虽然这种逻辑听起来合理,但它并不是最大化收入的最佳选择。我们来看一个简单的例子,只涉及三个营销活动。

采用顶级市场的方法,我们会选择法国并实现6800万美元的收入。如果我们选择另外两个市场,我们可以获得更好的结果,即9750万美元。最关键的是,我们的算法不仅优化最大收入,还力求所选细分市场的数量最小。因此,这种方法可能无法带来最佳结果,尤其是它无法同时考虑多重限制。

线性规划模型

由于所有简单的办法都失败了,我们必须回到基础,重新审视这个问题的理论基础。幸运的是,背包问题已经经过了多年的深入研究,我们可以在几秒内而不是几年内解决它。

我们正在尝试解决的问题是一个整数编程的例子,而整数编程事实上是线性编程的一个子集。

我们稍后会讨论这个,但首先,让我们了解优化过程中的关键概念。每个优化问题都包含:

  • 决策变量:模型中可以调整的参数,通常代表我们想要控制的决策点或杠杆。
  • 目标函数:我们希望最大化或最小化的目标。
  • 约束条件:对决策变量施加的限制条件,定义了它们的可能取值。这些约束条件规定了决策变量的可行取值范围。
  • 例如,确保团队不能出现负数的工作小时数。

有了这些基本概念,我们可以这样定义线性规划:满足以下条件的情况。

  • 目标函数和所有约束都是线性的,
  • 决策变量是实数。

整数规划和线性规划非常相似,一个主要的区别在于一些或全部的决策变量必须是整数。虽然这看上去只是一个小小的改动,但实际上它对求解方法有很大影响,需要比线性规划中使用的方法更复杂的方法。一个常用的技术是分支定界法。这里我们不会深入探讨理论,但你总能在网上找到更详细的解释。

对于线性优化领域,我更喜欢使用广泛使用的Python包PuLP。不过,还有其他选择,例如Python MIPPyomo。我们可以通过pip来安装PuLP。

    ! pip install pulp
# 安装 pulp 库,这一步会从 Python 包索引下载并安装 pulp 包。

现在,是时候将我们的任务定义为一个数学优化问题了。以下是步骤:

  • 定义我们要调整的决策变量(调节杠杆)。
  • 确定我们要优化的目标变量(我们想要优化的目标)。
  • 制定优化过程中必须满足的条件(约束条件)。

我们一步一步来。首先,我们得创建问题对象并设定我们的目标,在我们的情况下是最大化。

    from pulp import *  
    problem = LpProblem("营销活动", "最大化")
    定义了一个名为‘营销活动’的问题,目标是最大化

下一步是定义决策变量——在优化过程中可以调整的参数。我们主要决定是否进行市场营销活动。因此,可以将其建模为每个细分市场的二元变量(0或1),以表示是否进行市场营销活动。让我们用PuLP库来做这件事。

segments = range(df.shape[0])  # 段落范围定义为从0到df的行数
selected = LpVariable.dicts('Selected', segments, cat='Binary')  # 定义selected为二进制变量,'Selected'为变量名,segments为定义的范围

之后,就该调整目标函数了。正如我们之前讨论过的,我们希望最大化收入。总收入将是所有选定细分市场的收入之和(其中 decision_variable = 1)。因此,我们可以用以下公式来表示:每个细分市场的预计收入与决策变量的乘积之和。

这段代码是原始的英文代码,不需要翻译。
problem += lpSum(
  selected[i] * list(df['revenue'].values)[i] 
  for i in segments
)

我们先从一个简单的约束开始,比如说,我们的市场花费必须低于3000万美金。

problem += lpSum(
    selected[i] * df['marketing_spending'].values[i]
    for i in segments
) <= 30 * 10**6  # 将所有选定的市场支出加起来,不超过3000万

小提示:你可以打印 problem 再检查一下目标函数和约束条件。

现在我们已经定义好了一切,我们可以运行优化过程并分析结果。

问题解决函数被调用了

优化运行只需要不到一秒钟,这比穷举法所需的数千年来快得多,有了显著的改进。

结果:找到最优解

目标:110162662.21000001  
枚举节点:4  
迭代次数:76  
CPU时间(秒):0.02  
运行时间(秒):0.02

让我们把模型执行的结果保存到数据框中,具体来说就是保存每个片段是否被选中的决策变量。

df['selected'] = list(map(lambda x: x.value(), selected.values()))  
print(df[df.selected == 1].revenue.sum()/10**6)  
# 110.16 百万

简直就像是魔法,让你可以迅速得到答案。另外,值得注意的是,与我们最初的方法相比,收入更高:1.1016亿美元对比1.0792亿美元。

我们使用一个只有一个约束条件的简单例子来测试整数规划,我们还可以进一步扩展它的应用。比如,我们还可以为我们的IT团队增加额外的约束条件,以确保我们的运营团队能够健康地应对需求。

  • CS联系人的数量不超过5K
  • 联系率(每用户CS联系人数量)≤ 0.042
    # 定义问题模型  
    problem_v2 = LpProblem("Marketing_campaign_v2", LpMaximize)  

    # 决策变量  
    segments = range(df.shape[0])   
    selected = LpVariable.dicts("Selected", segments, cat="Binary")  

    # 目标函数定义  
    problem_v2 += lpSum(  
      selected[i] * list(df['revenue'].values)[i]   
      for i in segments  
    )  

    # 约束条件  
    problem_v2 += lpSum(  
        selected[i] * df['marketing_spending'].values[i]  
        for i in segments  
    ) <= 30 * 10**6  # 营销预算约束  

    problem_v2 += lpSum(  
        selected[i] * df['cs_contacts'].values[i]  
        for i in segments  
    ) <= 5000  # 客户接触次数约束  

    problem_v2 += lpSum(  
        selected[i] * df['cs_contacts'].values[i]  
        for i in segments  
    ) <= 0.042 * lpSum(  
        selected[i] * df['users'].values[i]  
        for i in segments  
    )  # 客户接触次数占比限制  

    # 运行优化过程  
    problem_v2.solve()

代码很直接,唯一稍微复杂的地方是把比率约束转换成更简单的线性形式,这。

另一个可能的限制因素是限制选项数量,例如限制为10个。这样的限制在规范分析中可能会非常有用,例如当你需要选择前N个最具影响力的关键领域时。

    # 定义问题为  
    problem_v3 = LpProblem("市场活动_v2", LpMaximize)  

    # 定义决策变量  
    segments = range(df.shape[0])   
    selected = LpVariable.dicts("Selected", segments, cat="Binary")  

    # 定义目标函数  
    problem_v3 += lpSum(  
      selected[i] * list(df['revenue'].values)[i]   
      for i in segments  
    )  

    # 设置约束条件  
    problem_v3 += lpSum(  
        selected[i] * df['marketing_spending'].values[i]  
        for i in segments  
    ) <= 30 * 10**6  

    problem_v3 += lpSum(  
        selected[i] for i in segments  
    ) <= 10  

    # 运行优化过程  
    problem_v3.solve()  
    df['selected'] = list(map(lambda x: x.value(), selected.values()))  
    # 将选中的值转换为实际数值  
    print(df.selected.sum())  
    # 输出结果为10

另一个可能的选择是修改我们的目标函数。我们一直在追求收入的最大化,但如果我们也想同时提升收入和新用户的数量。为此,我们只需稍微调整一下我们的目标函数即可。

让我们考虑最佳的方法。我们可以计算收入和新用户数量的总和,并力求最大化的总和。然而,由于收入平均比新用户数量高1000倍,因此结果可能偏向于最大化收入。为了使这些指标更具可比性,我们将收入和用户数量归一化,使其更便于比较。然后,我们将目标函数定义为这两个比率的加权总和。我会给收入和用户数量分配相等的权重(0.5),但你可以根据需要调整权重以更强调其中一个指标。

    # 定义问题
    problem_v4 = LpProblem("市场营销活动_v2", LpMaximize)

    # 决策变量定义
    segments = range(df.shape[0])
    selected = LpVariable.dicts("Selected", segments, cat="二元") # 变量类型,二元

    # 目标函数定义
    problem_v4 += (
        0.5 * lpSum(
            selected[i] * df['revenue'].values[i] / df['revenue'].sum()
            for i in segments
        )
        + 0.5 * lpSum(
            selected[i] * df['users'].values[i] / df['users'].sum()
            for i in segments
        )
    )

    # 限制条件
    problem_v4 += lpSum(
        selected[i] * df['marketing_spending'].values[i]
        for i in segments
    ) <= 30 * 10**6

    # 运行优化过程
    problem_v4.solve()
    # 将优化结果存储在df['selected']列中
    df['selected'] = list(map(lambda x: x.值(), selected的值))

我们获得了0.6131的最佳目标函数值,收入达到1.0436亿美元,新增用户13.6万。

就这样!我们学会了如何用整数规划解决各种最优化问题。

你可以在GitHub上找到完整的代码:GitHub

总结

在这篇文章中,我们探讨了解决Knapsack Problem的不同方法及其在产品分析中的应用。Knapsack Problem是指背包问题。

  • 我们最初采取了暴力求解的方法,但很快意识到这会耗费过多的时间。
  • 接下来,我们尝试用直觉的方法,简单地选择表现最佳的部分,但这种方法却得到了错误的结果。
  • 最后,我们转向了整数规划技术,学习如何将产品任务转化为优化模型并有效求解。

希望这样,你有了另一个有用的分析工具箱中的一个工具。

非常感谢您读这篇文章。希望这篇文章对您有所启发。如果您有任何问题或想发表评论,请在评论区留言哦。

参考

除非另有说明,所有图片均由作者创作。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消