进行实验的核心部分是将实验单元(例如顾客)分配到特定的处理条件中(如支付按钮变体、营销推送通知的框架)。这种分配通常需要满足以下条件:
当组织刚开始其实验之旅时,一个常见的模式是预生成分配结果,存储在数据库中,然后在分配时检索。这是一个有效的方法,在起步阶段非常适用。然而,随着顾客和实验规模的增长,这种方法会变得越来越难以维护和可靠地使用。你必须(a)管理存储的复杂性,(b)确保在实验内和跨实验的分配实际上是随机的,(c)可靠地检索分配结果。这些在大规模操作时都非常困难。
使用哈希空间有助于解决这些问题。这是一个简单的解决方案,但并没有被广泛知晓。本文试图解释这一技术。
设置
我们正在运行一个实验,测试我们客户应用程序上的哪种进度条变体能驱动最多的参与度。有三种变体:对照组(默认体验)、变体 A 和变体 B。
我们有 1000 万客户每周使用我们的应用程序,并且我们希望确保这 1000 万客户被随机分配到其中一个变体。每次客户回到应用程序时,他们应该看到相同的变体。我们希望对照组被分配 50% 的概率,变体 A 被分配 30% 的概率,变体 B 被分配 20% 的概率。
probability_assignments = {"Control": 50, "Variant 1": 30, "Variant 2": 20}
为了简化问题,我们将从 4 个顾客开始。这些顾客有我们用来识别他们的 ID。这些 ID 通常是 GUID(例如 "b7be65e3-c616-4a56-b90a-e546728a6640")或整数(例如 1019222, 1028333)。任何这些类型的 ID 都可以使用,但为了更容易理解,我们将假设这些 ID 分别是:“Customer1”、“Customer2”、“Customer3”、“Customer4”。
对顾客ID进行哈希处理
这种方法主要依赖于具有一些非常理想特性的哈希算法。哈希算法可以将任意长度的字符串映射到固定长度的“哈希值”。理解这一点的最简单方法是通过一些例子。
哈希函数将一个字符串映射到一个固定的哈希空间。在下面的例子中,一个哈希函数(在这种情况下是 md5)将“Hello”、“World”、“Hello World”和“Hello WorLd”(注意大写的 L)映射到一个32个字符的字母数字字符串。
需要注意的几点重要事项:
我们可以使用相同的逻辑并为我们的四个顾客生成哈希值:
import hashlib
representative_customers = ["Customer1", "Customer2", "Customer3", "Customer4"]
def get_hash(customer_id):
hash_object = hashlib.md5(customer_id.encode())
return hash_object.hexdigest()
{customer: get_hash(customer) for customer in representative_customers}
# {'Customer1': 'becfb907888c8d48f8328dba7edf6969',
# 'Customer2': '0b0216b290922f789dd3efd0926d898e',
# 'Customer3': '2c988de9d49d47c78f9f1588a1f99934',
# 'Customer4': 'b7ca9bb43a9387d6f16cd7b93a7e5fb0'}
十六进制字符串只是以16进制表示的数字。我们可以将它们转换为10进制的整数。
这里有一个重要提示:我们很少需要使用完整的哈希值。在实际操作中(例如在链接的代码中),我们通常只使用哈希值的一小部分(前10个字符)。在这里,我们使用完整的哈希值是为了使解释更容易理解。
def get_integer_representation_of_hash(customer_id):
hash_value = get_hash(customer_id)
return int(hash_value, 16)
{
customer: get_integer_representation_of_hash(customer)
for customer in representative_customers
}
# {'Customer1': 253631877491484416479881095850175195497,
# 'Customer2': 14632352907717920893144463783570016654,
# 'Customer3': 59278139282750535321500601860939684148,
# 'Customer4': 244300725246749942648452631253508579248}
这些整数有两个重要属性:
现在我们得到了每个 ID 的整数表示,这些表示是稳定且均匀分布的,我们可以使用它们来进行分配。
从整数表示到分配
回到我们的概率分配,我们希望以以下分布将顾客分配给不同的变体:
{"Control": 50, "Variant 1": 30, "Variant 2": 20}
如果我们有100个槽,我们可以将它们分成3个桶,其中槽的数量代表我们希望分配给该桶的概率。例如,在我们的例子中,我们将整数范围0-99(100个单位)分成0-49(50个单位)、50-79(30个单位)和80-99(20个单位)。
def divide_space_into_partitions(prob_distribution):
partition_ranges = []
start = 0
for partition in prob_distribution:
partition_ranges.append((start, start + partition))
start += partition
return partition_ranges
divide_space_into_partitions(prob_distribution=probability_assignments.values())
# note that this is zero indexed, lower bound inclusive and upper bound exclusive
# [(0, 50), (50, 80), (80, 100)]
现在,如果我们将一个顾客随机分配到这100个槽中的一个,那么最终的分布应该与我们预期的分布相等。另一种理解方式是,如果我们在0到99之间随机选择一个数字,那么它有50%的几率在0到49之间,有30%的几率在50到79之间,有20%的几率在80到99之间。
剩下的唯一步骤就是将我们生成的顾客整数映射到这100个槽中的一个。我们通过提取生成的整数的最后两位数字并将其作为分配来实现这一点。例如,顾客1的最后两位数字是97(你可以查看下方的图表)。这落在第三个桶(变体2)中,因此顾客被分配到变体2。
我们对每个顾客重复这个过程。完成所有顾客的分配后,我们应该发现最终的分布与我们预期的一致:50%的顾客在控制组,30%在变体1,20%在变体2。
def assign_groups(customer_id, partitions):
hash_value = get_relevant_place_value(customer_id, 100)
for idx, (start, end) in enumerate(partitions):
if start <= hash_value < end:
return idx
return None
partitions = divide_space_into_partitions(
prob_distribution=probability_assignments.values()
)
groups = {
customer: list(probability_assignments.keys())[assign_groups(customer, partitions)]
for customer in representative_customers
}
# output
# {'Customer1': 'Variant 2',
# 'Customer2': 'Variant 1',
# 'Customer3': 'Control',
# 'Customer4': 'Control'}
在这个例子中,我们可以观察到顾客按照预期的比例分布。
# resulting proportions from a simulation on 1 million customers.
{'Variant 1': 0.299799, 'Variant 2': 0.199512, 'Control': 0.500689
实际考量
在实验中添加“盐值”
实际上,当你在产品的不同部分同时运行多个实验时,通常会在对ID进行哈希之前添加一个“盐值”。这个盐值可以是任何东西:实验名称、实验ID、特性名称等。这确保了在不同的实验中,顾客到桶的映射总是不同的,因为盐值是不同的。这有助于确保顾客不会总是落在相同的桶里。例如,如果在所有实验中控制组总是被分配到前50个桶,你不希望特定的顾客总是落入控制组。这是很容易实现的。
salt_id = "f7d1b7e4-3f1d-4b7b-8f3d-3f1d4b7b8f3d"
customer_with_salt_id = [
f"{customer}{salt_id}" for customer in representative_customers
]
# ['Customer1f7d1b7e4-3f1d-4b7b-8f3d-3f1d4b7b8f3d',
# 'Customer2f7d1b7e4-3f1d-4b7b-8f3d-3f1d4b7b8f3d',
# 'Customer3f7d1b7e4-3f1d-4b7b-8f3d-3f1d4b7b8f3d',
# 'Customer4f7d1b7e4-3f1d-4b7b-8f3d-3f1d4b7b8f3d']
增加分区空间
在这个例子中,我们使用了一个包含100个可能槽(或分区)的空间。如果你想分配精确到小数点后的一位或多位的概率,只需取生成整数的最后n位数字即可。
例如,如果你想分配精确到小数点后两位的概率,你可以取生成整数的最后4位数字。也就是说,下面place_value的值将是10000。
def get_relevant_place_value(customer_id, place_value):
hash_value = get_integer_representation_of_hash(customer_id)
return hash_value % place_value
总结
如果你想在不同的实施环境中生成随机、快速且稳定的分配,你可以使用以下步骤: