这张图片是由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 MIP或Pyomo。我们可以通过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是指背包问题。
- 我们最初采取了暴力求解的方法,但很快意识到这会耗费过多的时间。
- 接下来,我们尝试用直觉的方法,简单地选择表现最佳的部分,但这种方法却得到了错误的结果。
- 最后,我们转向了整数规划技术,学习如何将产品任务转化为优化模型并有效求解。
希望这样,你有了另一个有用的分析工具箱中的一个工具。
参考非常感谢您读这篇文章。希望这篇文章对您有所启发。如果您有任何问题或想发表评论,请在评论区留言哦。
除非另有说明,所有图片均由作者创作。
共同学习,写下你的评论
评论加载中...
作者其他优质文章