Cálculo de canibalización basado en la prueba clásica A / B y el método bootstrap

Este artículo analiza un método para calcular la canibalización para una aplicación móvil basada en la prueba clásica A / B. En este caso, las acciones objetivo se consideran y evalúan como parte del proceso de reasignación de una fuente publicitaria (Direct, Criteo, AdWords UAC y otras) en comparación con las acciones objetivo en el grupo en el que se deshabilitó el anuncio.

El artículo ofrece una visión general de los métodos clásicos para comparar muestras independientes con una breve base teórica y una descripción de las bibliotecas utilizadas, incluyendo describe brevemente la esencia del método bootstrap y su implementación en la biblioteca FaceBook Bootstrapped, así como los problemas que surgen en la práctica al aplicar estas técnicas y cómo resolverlos.

La evidencia se ofusca o no se proporciona para mantener un acuerdo de confidencialidad.

En el futuro, planeo complementar y modificar ligeramente este artículo a medida que aparezcan nuevos hechos, por lo que esta versión puede considerarse la primera versión. Estaría agradecido por los comentarios y críticas.

Introduccion


La canibalización es el proceso de flujo de tráfico, completo y dirigido, de un canal a otro.

Los especialistas en marketing suelen utilizar este indicador como un coeficiente K adicional para calcular el CPA: el CPA calculado se multiplica por 1 + K. En este caso, CPA significa el costo total de atraer tráfico / la cantidad de acciones dirigidas que se monetizan directamente, es decir, que trajeron el beneficio real, por ejemplo, una llamada dirigida y / o monetizadas indirectamente, por ejemplo, aumentar el volumen de la base de datos de anuncios, aumentar la audiencia, etc.

Cuando los canales gratuitos (por ejemplo, visitas de SERPs orgánicos, clics en enlaces en sitios que son gratuitos para nosotros) se canibalizan por pagos (directos, Adwords en lugar de orgánicos, publicidad en feeds de redes sociales en lugar de hacer clic en anuncios, es gratis colocado en grupos, etc.), esto conlleva riesgos de pérdida financiera, por lo que es importante conocer la tasa de canibalización.

En nuestro caso, la tarea consistía en calcular la canibalización de las transiciones "orgánicas" a la aplicación mediante transiciones desde la red de publicidad de Criteo. La vigilancia es un dispositivo o usuario-usuario (GAID / ADVID e IDFA).

Preparación del experimento


Puede preparar a la audiencia para el experimento dividiendo a los usuarios en la interfaz del sistema analítico de AdJust en grupos para aislar a aquellos que verán anuncios de una determinada red publicitaria (muestra de control) y aquellos que no mostrarán anuncios usando GAID o ADVID e IDFA, respectivamente (AdJust proporciona la API de Audience Builder). Luego, en la muestra de control, puede incluir una campaña publicitaria en la red publicitaria estudiada en el experimento.

Observo por mí mismo que, como parece intuitivamente, la siguiente implementación del experimento sería más competente en este caso: seleccionar cuatro grupos: aquellos que tenían retargeting deshabilitado de todos los canales (1), como el grupo experimental, y aquellos que tenían solo retargeting habilitado con Criteo (2); aquellos que solo tenían retargeting desactivado con Criteo (3), aquellos que tenían todo el retargeting (4) activado. Entonces sería posible calcular (1) / (2), habiendo recibido el valor real de canibalizar las campañas publicitarias de la red Criteo para transiciones "orgánicas" a la aplicación, y (3) / (4), haber recibido canibalizar a Criteo en el entorno "natural" (después de todo, Criteo, obviamente puede canibalizar otros canales pagos también). El mismo experimento debe repetirse para otras redes publicitarias para descubrir el impacto de cada una de ellas; En un mundo ideal, sería bueno explorar la canibalización cruzada entre todas las fuentes pagas clave que conforman la mayor parte del tráfico total, pero tomaría mucho tiempo (tanto para preparar experimentos desde el punto de vista del desarrollo como para evaluar los resultados), lo que causaría crítica por meticulosidad irracional.

De hecho, nuestro experimento se llevó a cabo en las condiciones (3) y (4), las muestras se dividieron en una proporción del 10% al 90%, el experimento se realizó durante 2 semanas.

Preparación y verificación de datos.


Antes de comenzar cualquier estudio, un paso importante es la capacitación previa competente y la limpieza de datos.

Cabe señalar que, de hecho, los dispositivos activos para el período de experimento fueron 2 veces menos (42.5% y 50% de los grupos de control y experimentales, respectivamente) que los dispositivos en las muestras iniciales completas, lo que se explica por la naturaleza de los datos:

  1. en primer lugar (y esta es la razón clave), la selección para reorientar desde Ajustar contiene los identificadores de todos los dispositivos que alguna vez han instalado la aplicación, es decir, aquellos dispositivos que ya no están en uso y aquellos con los que la aplicación ya estaba eliminado
  2. en segundo lugar, no es necesario que todos los dispositivos hayan iniciado sesión en la aplicación durante el experimento.

Sin embargo, calculamos la canibalización en base a los datos de una muestra completa. Para mí personalmente, la exactitud de dicho cálculo todavía parece un punto discutible; en general, en mi opinión, es más correcto limpiar a todos aquellos que desinstalaron la aplicación y no la instalaron con las etiquetas correspondientes, así como a aquellos que no han iniciado sesión en la aplicación durante más de un año. este período de tiempo el usuario podría cambiar el dispositivo; menos: de esta forma, para el experimento, aquellos usuarios que no cambiaron a la aplicación, pero que podían hacerlo, podrían ser eliminados de la selección si les mostramos anuncios en la red de Criteo. Quiero señalar que en un buen mundo, todos estos descuidos forzados y suposiciones deben investigarse y verificarse por separado, pero vivimos en un mundo donde hacerlo rápido y furioso.

En nuestro caso, es importante verificar los siguientes puntos:

  1. Verificamos la intersección en nuestras muestras iniciales: experimental y de control. En un experimento implementado correctamente, tales intersecciones no deberían ser, sin embargo, en nuestro caso, hubo varios duplicados de la muestra experimental en el control. En nuestro caso, la proporción de estos duplicados en el volumen total de dispositivos involucrados en el experimento fue pequeña; por lo tanto, descuidamos esta condición. Si hubo> 1% de duplicados, el experimento debería considerarse incorrecto y debería realizarse un segundo experimento, habiendo limpiado previamente los duplicados.
  2. Verificamos que los datos en el experimento se vieron realmente afectados: el retargeting debería haberse desactivado en la muestra experimental (al menos con Criteo, en el experimento configurado correctamente, de todos los canales), por lo tanto, es necesario verificar la ausencia de DeviceID del experimento en el retargeting con Criteo. En nuestro caso, DeviceID del grupo experimental, sin embargo, cayó en la reorientación, pero hubo menos del 1%, lo que es insignificante.

Evaluación directa del experimento.


Consideraremos el cambio en las siguientes métricas de destino: absoluto: el número de llamadas y relativo: el número de llamadas por usuario en el control (vio anuncios en la red Criteo) y grupos experimentales (los anuncios fueron deshabilitados). En el siguiente código, los datos variables se refieren a la estructura pandas.DataFrame, que se forma a partir de los resultados de una muestra experimental o de control.

Existen métodos paramétricos y no paramétricos para evaluar la significancia estadística de la diferencia de valores en muestras no relacionadas. Los criterios de evaluación paramétrica dan una mayor precisión, pero tienen limitaciones en su aplicación; en particular, una de las condiciones principales es que los valores medidos para las observaciones en la muestra deben distribuirse normalmente.

1. El estudio de la distribución de valores en las muestras para normalidad.


El primer paso es examinar las muestras existentes para el tipo de distribución de valores y la igualdad de las variaciones de las muestras usando pruebas estándar: los criterios de Kolmogorov-Smirnov y Shapiro-Wilks y la prueba de Bartlett implementada en la biblioteca sklearn.stats, tomando el valor p = 0.05:

#    : def norm_test(df, pvalue = 0.05, test_name = 'kstest'): if test_name == 'kstest': st = stats.kstest(df, 'norm') if test_name == 'shapiro': st = stats.shapiro(df) sys.stdout.write('According to {} {} is {}normal\n'.format(test_name, df.name, {True:'NOT ', False:''}[st[1] < pvalue])) #    : def barlett_test(df1, df2, pvalue = 0.05): st = stats.bartlett(df1, df2) sys.stdout.write('Variances of {} and {} is {}equals\n'.format(df1.name, df2.name, {True:'NOT ', False:''}[st[1] < pvalue])) 

Además, para una evaluación visual de los resultados, puede usar la función de histograma.

 data_agg = data.groupby(['bucket']).aggregate({'device_id': 'nunique', 'calls': 'sum'}).fillna(0) data_conv = data_agg['calls_auto']/data_agg['device_id'] data_conv.hist(bins=20) 

imagen

Puede leer el histograma de esta manera: 10 veces en la muestra hubo una conversión de 0.08, 1 - 0.14. Esto no dice nada sobre el número de dispositivos como observaciones para ninguno de los indicadores de conversión.

En nuestro caso, la distribución del valor del parámetro tanto en valores absolutos como en relación (el número de llamadas al dispositivo) en las muestras no es normal.
En este caso, puede usar la prueba no paramétrica de Wilcoxon implementada en la biblioteca estándar sklearn.stats, o tratar de llevar la distribución de valores en las muestras a la forma normal y aplicar uno de los criterios paramétricos: la prueba t de Student o la prueba Shapiro-Wilks.

2. Métodos para reducir la distribución de valores en muestras a la forma normal


2.1. Subcapas

Un enfoque para llevar la distribución a la normalidad es el método del subgrupo. Su esencia es simple, y la siguiente tesis matemática es la base teórica: de acuerdo con el teorema del límite central clásico, la distribución de medias tiende a ser normal: la suma de n variables aleatorias distribuidas idénticamente independientes tiene una distribución cercana a la normal y, de manera equivalente, la distribución de medias muestrales de las primeras n aleatorias distribuidas idénticamente independientes Las cantidades tienden a la normalidad. Por lo tanto, podemos dividir los cubos existentes en subcubetas y, en consecuencia, tomando los valores promedio de subcapas para cada uno de los cubos, podemos obtener una distribución cercana a la normal:

 #   subbucket' data['subbucket'] = data['device_id'].apply(lambda x: randint(0,1000)) # Variant 1 data['subbucket'] = data['device_id'].apply(lambda x: hash(x)%1000) # Variant 2 

Puede haber muchas opciones para dividir, todo depende de la imaginación y los principios morales del desarrollador: puede tomar un azar honesto o usar hash del cubo original, teniendo en cuenta el mecanismo para emitirlo en el esquema.

Sin embargo, en la práctica, de varias docenas de lanzamientos de código, recibimos la distribución normal solo una vez, es decir, este método no está garantizado ni es estable.

Además, la proporción de acciones y usuarios objetivo con respecto al número total de acciones y usuarios en el subgrupo puede no ser coherente con los backets iniciales, por lo que primero debe verificar que se mantenga la proporción.

 data[data['calls'] > 0].device_id.nunique()/data.device_id.nunique() # Total buckets = data.groupby(['bucket']).aggregate({'device_id': 'nunique', 'calls': 'sum'}) buckets[buckets['calls'] > 0].device_id.nunique()/buckets.device_id.nunique() # Buckets subbuckets = data.groupby(['subbucket']).aggregate({'device_id': 'nunique', 'calls': 'sum'}) subbuckets[subbuckets['calls'] > 0].device_id.nunique()/subbuckets.device_id.nunique() # Subbuckets 

En el proceso de dicha verificación, descubrimos que las relaciones de conversión para subgrupos en relación con la selección original no se conservan. Dado que necesitamos garantizar adicionalmente la consistencia de la proporción de la proporción de llamadas en las muestras de salida y de origen, utilizamos el equilibrio de clases, agregando ponderación para que los datos se seleccionen por separado por subgrupos: por separado de las observaciones con acciones objetivo y por separado de las observaciones sin acciones objetivo en la proporción correcta. Además, en nuestro caso, las muestras se distribuyeron de manera desigual; intuitivamente, parece que el promedio no debería cambiar, pero la forma en que la no uniformidad de las muestras afecta la varianza no es obvio a partir de la fórmula de dispersión. Para aclarar si la diferencia en el tamaño de las muestras afecta el resultado, se utiliza el criterio Xi-cuadrado: si se detecta una diferencia estadísticamente significativa, se tomarán muestras de un marco de datos más grande con un tamaño más pequeño:

 def class_arrays_balancer(df1, df2, target = 'calls', pvalue=0.05): df1_target_size = len(df1[df1[target] > 0]) print(df1.columns.to_list()) df2_target_size = len(df2[df2[target] > 0]) total_target_size = df1_target_size + df2_target_size chi2_target, pvalue_target, dof_target, expected_target = chi2_contingency([[df1_target_size, total_target_size], [df2_target_size, total_target_size]]) df1_other_size = len(df1[df1[target] == 0]) df2_other_size = len(df1[df1[target] == 0]) total_other_size = df1_other_size + df2_other_size chi2_other, pvalue_other, dof_other, expected_other = chi2_contingency([[df1_other_size, total_other_size], [df2_other_size, total_other_size]]) df1_target, df2_target, df1_other, df2_other = None, None, None, None if pvalue_target < pvalue: sample_size = min([df1_target_size, df2_target_size]) df1_rnd_indx = np.random.choice(df1_target_size, size=sample_size, replace=False) df2_rnd_indx = np.random.choice(df2_target_size, size=sample_size, replace=False) df1_target = pd.DataFrame((np.asarray(df1[df1[target] == 1])[df1_rnd_indx]).tolist(), columns = df1.columns.tolist()) df2_target = pd.DataFrame((np.asarray(df2[df2[target] == 1])[df2_rnd_indx]).tolist(), columns = df2.columns.tolist()) if p_value_other < pvalue: sample_size = min([df1_other_size, df2_other_size]) df1_rnd_indx = np.random.choice(df1_other_size, size=sample_size, replace=False) df2_rnd_indx = np.random.choice(df2_other_size, size=sample_size, replace=False) df1_other = pd.DataFrame((np.asarray(df1[df1[target] == 0])[df1_rnd_indx]).tolist(), columns = df1.columns.tolist()) df2_other = pd.DataFrame((np.asarray(df2[df2[target] == 0])[df2_rnd_indx]).tolist(), columns = df2.columns.tolist()) df1 = pd.concat([df1_target, df1_other]) df2 = pd.concat([df2_target, df2_other]) return df1, df2 exp_classes, control_classes = class_arrays_balancer(data_exp, data_control) 

En la salida, obtenemos datos equilibrados en tamaño y consistentes con las relaciones de conversión iniciales, las métricas estudiadas (calculadas para los valores promedio para el subgrupo) en las que ya están distribuidas normalmente, lo que puede verse tanto visualmente como por los resultados de aplicar los criterios de prueba que ya conocemos. normalidad (con valor p> = 0.05). Por ejemplo, para indicadores relativos:

 data_conv = (data[data['calls'] > 0].groupby(['subbucket']).calls.sum()*1.0/data.groupby(['subbucket']).device_id.nunique()) data_conv.hist(bins = 50) 

Ahora, la prueba t se puede aplicar al promedio sobre sub-bucket'es (por lo tanto, no es device_id, no es un dispositivo, sino un sub-bucket que actúa como una observación).

Después de asegurarnos de que los cambios son estadísticamente significativos, podemos, con la conciencia tranquila, hacer lo que comenzamos a hacer: calcular la canibalización:

 (data_exp.groupby(['subbucket']).calls.avg() - data_cntrl.groupby(['subbucket']).calls.avg() )/ data_exp.groupby(['subbucket']).calls.avg() 

El denominador debe ser el tráfico sin anuncios, es decir, experimental.

3. Método Bootstrap


El método bootstrap es una extensión del método sub-bucket y representa su versión más avanzada y mejorada; Puede encontrar una implementación de software de este método en Python en la biblioteca de Facebook Bootstrapped.
Brevemente, la idea de bootstrap se puede describir de la siguiente manera: un método no es más que un constructor de muestras generadas de manera similar a los métodos de subgrupos al azar, pero con posibles repeticiones. Podemos decir la ubicación de la población general (si se puede llamar a la muestra original) con el retorno. En la salida, se forman promedios (o medianas, cantidades, etc.) a partir de los promedios para cada una de las submuestras generadas.

Los principales métodos de la biblioteca FaceBook Bootstrap :
 bootstrap() 
- implementa un mecanismo para la formación de submuestras; devuelve el límite inferior (percentil 5) y el límite superior (percentil 95) de forma predeterminada; para devolver una distribución discreta en este rango, debe establecer el parámetro return_distribution = True (lo genera la función auxiliar generate_distributions () ).

Puede especificar el número de iteraciones utilizando el parámetro num_iterations , en el que se generarán submuestras, y el número de submuestras iteration_batch_size para cada iteración. La salida de generate_distributions () generará una muestra con un tamaño igual al número de iteraciones num_iterations , cuyos elementos serán el promedio de los valores de las muestras iteration_batch_size calculadas en cada iteración. Con grandes volúmenes de muestras, es posible que los datos ya no quepan en la memoria, por lo que en tales casos es aconsejable reducir el valor de iteration_batch_size .

Ejemplo : deje que la muestra original sea 2,000,000; num_iterations = 10,000, iteration_batch_size = 300. Luego, en cada una de 10,000 iteraciones, se almacenarán 300 listas de 2,000,000 de elementos en la memoria.

La función también permite la computación paralela en varios núcleos de procesador, en varios hilos, configurando el número requerido usando el parámetro num_threads .

 bootstrap_ab() 

realiza las mismas acciones que la función bootstrap () descrita anteriormente, sin embargo, además, los valores promedio también se agregan mediante el método especificado en stat_func - from num_iterations A continuación, se calcula la métrica especificada en el parámetro compare_func y se estima la significancia estadística.

 compare_functions 

- una clase de funciones que proporciona herramientas para la formación de métricas para la evaluación:
 compare_functions.difference() compare_functions.percent_change() compare_functions.ratio() compare_functions.percent_difference() # difference = (test_stat - ctrl_stat) # percent_change = (test_stat - ctrl_stat) * 100.0 / ctrl_stat # ratio = test_stat / ctrl_stat # percent_difference = (test_stat - ctrl_stat) / ((test_stat + ctrl_stat) / 2.0) * 100.0 

 stats_functions 
- una clase de funciones de las cuales se selecciona el método de agregación de la métrica estudiada:
 stats_functions.mean stats_functions.sum stats_functions.median stats_functions.std 

Como stat_func, también puede usar una función personalizada definida por el usuario, por ejemplo:

 def test_func(test_stat, ctrl_stat): return (test_stat - ctrl_stat)/test_stat bs.bootstrap_ab(test.values, control.values, stats_functions.mean, test_func, num_iterations=5000, alpha=0.05, iteration_batch_size=100, scale_test_by=1, num_threads=4) 

De hecho, (test_stat - ctrl_stat) / test_stat es la fórmula para calcular nuestra canibalización.

Alternativamente, o con el propósito de un experimento práctico, inicialmente puede obtener distribuciones usando bootstrap () , verificar la significación estadística de las diferencias en las métricas objetivo usando la prueba t y luego aplicarles las manipulaciones necesarias.
Un ejemplo de cómo se puede obtener la distribución normal de "calidad" utilizando este método:



Se puede encontrar documentación más detallada en la página del repositorio .

Por el momento, esto es todo de lo que quería (o pude hablar). Traté de describir breve pero claramente los métodos utilizados y el proceso de su implementación. Es posible que las metodologías requieran un ajuste, por lo que agradeceré sus comentarios y revisiones.

También quiero agradecer a mis colegas por su ayuda en la preparación de este trabajo. Si el artículo recibe comentarios predominantemente positivos, indicaré aquí sus nombres o apodos (por acuerdo previo).

¡Mis mejores deseos para todos! :)

PD Estimado Championship Channel , la tarea de evaluar los resultados de las pruebas A / B es una de las más importantes en Data Science, porque ni un lanzamiento de un nuevo modelo ML en producción está completo sin A / B. ¿Quizás es hora de organizar una competencia para desarrollar un sistema para evaluar los resultados de las pruebas A / B? :)

Source: https://habr.com/ru/post/451488/


All Articles