Una parte fundamental de la ejecución de un experimento es asignar una unidad experimental (por ejemplo, un cliente) a un tratamiento específico (variante del botón de pago, marco de notificación push de marketing). A menudo, esta asignación debe cumplir las siguientes condiciones:
- Tiene que ser aleatorio.
- Debe ser estable. Si el cliente vuelve a la pantalla, debe estar expuesto a la misma variante del widget.
- Es necesario recuperarlo o generarlo muy rápidamente.
- Debe estar disponible después de la asignación real para poder analizarlo.
Cuando las organizaciones comienzan su recorrido de experimentación, un patrón común es generar previamente las asignaciones, almacenarlas en una base de datos y luego recuperarlas en el momento de la asignación. Este es un método perfectamente válido y funciona muy bien cuando estás empezando. Sin embargo, a medida que comienzas a aumentar el volumen de clientes y experimentos, este método se vuelve cada vez más difícil de mantener y usar de manera confiable. Debes gestionar la complejidad del almacenamiento, asegurarte de que las asignaciones sean realmente aleatorias y recuperar la asignación de manera confiable.
El uso de “espacios hash” ayuda a resolver algunos de estos problemas a gran escala. Es una solución muy sencilla, pero no es tan conocida como debería. Este blog es un intento de explicar la técnica. Hay enlaces a código en diferentes lenguajes al final. Sin embargo, si lo desea, también puede hacerlo directamente. Saltar al código aquí.
Estamos realizando un experimento para probar qué variante de una barra de progreso en nuestra aplicación para clientes genera la mayor interacción. Hay tres variantes: Control (la experiencia predeterminada), Variante A y Variante B.
Tenemos 10 millones de clientes que usan nuestra aplicación cada semana y queremos asegurarnos de que a estos 10 millones de clientes se les asigne aleatoriamente una de las tres variantes. Cada vez que el cliente vuelva a la aplicación, debería ver la misma variante. Queremos que el control se asigne con una probabilidad del 50 %, la variante 1 se asigne con una probabilidad del 30 % y la variante 2 se asigne con una probabilidad del 20 %.
probability_assignments = {"Control": 50, "Variant 1": 30, "Variant 2": 20}
Para simplificar las cosas, comenzaremos con 4 clientes. Estos clientes tienen identificadores que usamos para referirnos a ellos. Estos identificadores son generalmente GUID (algo así como "b7be65e3-c616-4a56-b90a-e546728a6640") o números enteros (como 1019222, 1028333). Cualquiera de estos tipos de ID funcionaría, pero para que sea más fácil seguir las cosas, simplemente asumiremos que estos ID son: “Cliente1”, “Cliente2”, “Cliente3”, “Cliente4”.
Este método se basa principalmente en el uso de algoritmos hash que cuentan con algunas propiedades muy deseables. Los algoritmos hash toman una cadena de longitud arbitraria y la asignan a un “hash” de longitud fija. La forma más sencilla de entender esto es mediante algunos ejemplos.
Una función hash toma una cadena y la asigna a un espacio hash constante. En el ejemplo siguiente, una función hash (en este caso md5) toma las palabras: “Hola”, “Mundo”, “Hola Mundo” y “Hola Mundo” (note la L mayúscula) y las asigna a una cadena alfanumérica de 32 caracteres.
Algunas cosas importantes a tener en cuenta:
- Los hashes tienen todos la misma longitud.
- Una pequeña diferencia en la entrada (L mayúscula en lugar de L minúscula) cambia el hash.
- Los hashes son una cadena hexadecimal, es decir, están compuestos por los números del 0 al 9 y las primeras seis letras (a, b, c, d, e y f).
Podemos usar esta misma lógica y obtener hashes para nuestros cuatro clientes:
import hashlibrepresentative_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'}
Las cadenas hexadecimales son simplemente representaciones de números en base 16. Podemos convertirlos a números enteros en base 10.
⚠️ Una nota importante aquí: rara vez necesitamos usar el hash completo. En la práctica (por ejemplo, en el código vinculado), usamos una parte mucho más pequeña del hash (los primeros 10 caracteres). Aquí usamos el hash completo para facilitar un poco las explicaciones.
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}
Hay dos propiedades importantes de estos números enteros:
- Estos números enteros son estable:Dada una entrada fija (“Cliente1”), el algoritmo hash siempre dará la misma salida.
- Estos números enteros son distribuido uniformemente:Este no ha sido explicado todavía y se aplica principalmente a funciones hash criptográficas (como md5). La uniformidad es un requisito de diseño para estas funciones hash. Si no estuvieran distribuidas uniformemente, las posibilidades de colisiones (obtener el mismo resultado para diferentes entradas) serían mayores y debilitarían la seguridad del hash. Hay algunas exploraciones del uniformidad propiedad.
Ahora que tenemos una representación entera de cada ID que es estable (siempre tiene el mismo valor) y distribuido uniformementePodemos usarlo para realizar una tarea.
Volviendo a nuestras asignaciones de probabilidad, queremos asignar clientes a variantes con la siguiente distribución:
{"Control": 50, "Variant 1": 30, "Variant 2": 20}
Si tuviéramos 100 ranuras, podríamos dividirlas en 3 grupos, donde la cantidad de ranuras representa la probabilidad que queremos asignar a ese grupo. Por ejemplo, en nuestro ejemplo, dividimos el rango de números enteros 0-99 (100 unidades) en 0-49 (50 unidades), 50-79 (30 unidades) y 80-99 (20 unidades).
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_rangesdivide_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)]
Ahora bien, si asignamos un cliente a una de las 100 ranuras de forma aleatoria, la distribución resultante debería ser igual a la distribución deseada. Otra forma de pensarlo es que, si elegimos un número al azar entre 0 y 99, hay un 50 % de posibilidades de que esté entre 0 y 49, un 30 % de posibilidades de que esté entre 50 y 79 y un 20 % de posibilidades de que esté entre 80 y 99.
El único paso que queda es asignar los números enteros de los clientes que generamos a una de estas cien ranuras. Para ello, extraemos los dos últimos dígitos del número entero generado y los utilizamos como asignación. Por ejemplo, los dos últimos dígitos del cliente 1 son 97 (puede consultar el diagrama a continuación). Esto cae en el tercer grupo (variante 2) y, por lo tanto, el cliente se asigna a la variante 2.
Repetimos este proceso de forma iterativa para cada cliente. Cuando terminemos con todos nuestros clientes, deberíamos encontrar que la distribución final será la esperada: el 50 % de los clientes tienen el control, el 30 % en la variante 1 y el 20 % en la variante 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 Nonepartitions = 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'}
El Gist vinculado tiene una réplica de lo anterior para 1.000.000 de clientes donde podemos observar que los clientes están distribuidos en las proporciones esperadas.
# resulting proportions from a simulation on 1 million customers.
{'Variant 1': 0.299799, 'Variant 2': 0.199512, 'Control': 0.500689