Calcul de cannibalisation basé sur le test A / B classique et la méthode bootstrap

Cet article décrit une méthode de calcul de cannibalisation pour une application mobile basée sur le test A / B classique. Dans ce cas, les actions cibles sont considérées et évaluées dans le cadre du processus de réattribution à partir d'une source publicitaire (Direct, Criteo, AdWords UAC et autres) par rapport aux actions cibles du groupe auquel l'annonce a été désactivée.

L'article donne un aperçu des méthodes classiques de comparaison d'échantillons indépendants avec une brève base théorique et une description des bibliothèques utilisées, y compris décrit brièvement l'essence de la méthode bootstrap et son implémentation dans la bibliothèque FaceBook Bootstrapped, ainsi que les problèmes qui se posent dans la pratique lors de l'application de ces techniques, et comment les résoudre.

Les preuves sont soit obscurcies, soit non fournies afin de maintenir un accord de non-divulgation.

À l'avenir, je prévois de compléter et de modifier légèrement cet article à mesure que de nouveaux faits apparaissent, afin que cette version puisse être considérée comme la première version. Je serais reconnaissant pour les commentaires et les critiques.

Présentation


La cannibalisation est le processus de circulation du trafic, complet et ciblé, d'un canal à un autre.

Les spécialistes du marketing utilisent généralement cet indicateur comme coefficient K supplémentaire dans le calcul du CPA: le CPA calculé est multiplié par 1 + K. Dans ce cas, CPA signifie le coût total pour attirer du trafic / le nombre d'actions ciblées qui sont monétisées directement, c'est-à-dire qui ont généré le profit réel - par exemple, un appel ciblé et / ou monétisé indirectement - par exemple, augmenter le volume de la base de données publicitaires, augmenter l'audience, etc.

Lorsque des canaux gratuits (par exemple, visites de SERP organiques, clics sur des liens sur des sites que nous pouvons utiliser gratuitement) sont cannibalisés pour payants (Direct, Adwords au lieu de produits biologiques, publicité dans les flux de réseaux sociaux au lieu de cliquer sur des annonces, est gratuit placés en groupes, etc.), cela comporte des risques de pertes financières, il est donc important de connaître le taux de cannibalisation.

Dans notre cas, il s'agissait de calculer la cannibalisation des transitions "organiques" à l'application par des transitions issues du réseau publicitaire Criteo. La surveillance est un appareil ou un utilisateur (GAID / ADVID et IDFA).

Préparation à l'expérience


Vous pouvez préparer l'audience à l'expérimentation en divisant les utilisateurs du système analytique AdJust en groupes pour isoler ceux qui verront les annonces d'un certain réseau publicitaire (échantillon de contrôle) et ceux qui ne verront pas les annonces utilisant GAID ou ADVID et IDFA, respectivement (AdJust fournit l'API Audience Builder). Ensuite, dans l'échantillon de contrôle, vous pouvez inclure une campagne publicitaire dans le réseau publicitaire étudié dans l'expérience.

Je note de moi-même que, comme il semble intuitivement, l'expérience suivante serait plus compétente dans ce cas: sélectionner quatre groupes - ceux qui avaient retargeting désactivé de tous les canaux (1), comme le groupe expérimental, et ceux qui avaient uniquement le reciblage activé avec Criteo (2); ceux qui n'avaient que le reciblage désactivé avec Criteo (3), ceux qui avaient tous le reciblage (4) activé. Il serait alors possible de calculer (1) / (2), ayant reçu la valeur réelle de la cannibalisation par des campagnes publicitaires du réseau Criteo de transitions «organiques» vers l'application, et (3) / (4), ayant reçu la cannibalisation de Criteo dans l'environnement «naturel» (après tout, Criteo, évidemment peut également cannibaliser d'autres chaînes payantes). La même expérience doit être répétée pour les autres réseaux publicitaires afin de connaître l'impact de chacun d'eux; dans un monde idéal, il serait intéressant d'étudier la cannibalisation croisée entre toutes les sources payantes clés qui constituent la plus grande part du trafic total, mais cela prendrait beaucoup de temps (à la fois pour préparer les expériences du point de vue du développement et pour évaluer les résultats), ce qui entraînerait critique pour une minutie déraisonnable.

En fait, notre expérience a été réalisée dans les conditions (3) et (4), les échantillons ont été divisés dans un rapport de 10% à 90%, l'expérience a été menée pendant 2 semaines.

Préparation et vérification des données


Avant de commencer une étude, une étape importante est la pré-formation compétente et le nettoyage des données.

Il convient de noter qu'en fait, les dispositifs actifs pour la période d'expérience étaient 2 fois moins (respectivement 42,5% et 50% des groupes témoins et expérimentaux) que les dispositifs dans les échantillons initiaux complets, ce qui s'explique par la nature des données:

  1. tout d'abord (et c'est une des principales raisons), la sélection pour le reciblage depuis Adjust contient les identifiants de tous les appareils qui ont déjà installé l'application, c'est-à-dire les appareils qui ne sont plus utilisés et ceux avec lesquels l'application était déjà supprimé
  2. deuxièmement, il n'est pas nécessaire que tous les appareils se soient connectés à l'application pendant l'expérience.

Cependant, nous avons calculé la cannibalisation sur la base des données d'un échantillon complet. Pour moi personnellement, l'exactitude d'un tel calcul est toujours un point discutable - en général, à mon avis, il est plus correct de nettoyer tous ceux qui ont désinstallé l'application et ne l'ont pas installée par les balises correspondantes, ainsi que ceux qui ne se sont pas connectés à l'application depuis plus d'un an - cette période de temps, l'utilisateur peut changer l'appareil; moins - de cette manière, pour l'expérience, les utilisateurs qui ne sont pas passés à l'application, mais qui pourraient le faire, pourraient être supprimés de la sélection si nous leur montrions des annonces sur le réseau Criteo. Je veux noter que dans un bon monde, toutes ces négligences et hypothèses forcées doivent être examinées et vérifiées séparément, mais nous vivons dans un monde où le faire vite et à poil.

Dans notre cas, il est important de vérifier les points suivants:

  1. Nous vérifions l'intersection dans nos échantillons initiaux - expérimentaux et témoins. Dans une expérience correctement mise en œuvre, de telles intersections ne devraient pas être, cependant, dans notre cas, il y avait plusieurs doublons de l'échantillon expérimental dans le contrôle. Dans notre cas, la part de ces doublons dans le volume total des appareils impliqués dans l'expérience était faible; par conséquent, nous avons négligé cette condition. S'il y avait> 1% de doublons, l'expérience devrait être considérée comme incorrecte et une deuxième expérience devrait être effectuée, après avoir préalablement nettoyé les doublons.
  2. Nous vérifions que les données de l'expérience ont vraiment été affectées - le reciblage aurait dû être désactivé dans l'échantillon expérimental (au moins avec Criteo, dans l'expérience correctement définie - de tous les canaux), il est donc nécessaire de vérifier l'absence de DeviceID dans l'expérience de reciblage avec Criteo. Dans notre cas, DeviceID du groupe expérimental est néanmoins tombé dans le reciblage, mais il y en avait moins de 1%, ce qui est négligeable.

Évaluation directe de l'expérience


Nous considérerons le changement dans les métriques cibles suivantes: absolu - le nombre d'appels, et relatif - le nombre d'appels par utilisateur dans les groupes de contrôle (vu les publicités sur le réseau Criteo) et expérimental (les publicités ont été désactivées). Dans le code ci-dessous, les données variables font référence à la structure pandas.DataFrame, qui est formée à partir des résultats d'un échantillon expérimental ou témoin.

Il existe des méthodes paramétriques et non paramétriques pour évaluer la signification statistique de la différence de valeurs dans des échantillons non apparentés. Les critères d'évaluation paramétrique donnent une plus grande précision, mais ont des limites dans leur application - en particulier, l'une des principales conditions est que les valeurs mesurées pour les observations dans l'échantillon soient distribuées normalement.

1. L'étude de la distribution des valeurs dans les échantillons pour la normalité


La première étape consiste à examiner les échantillons existants pour le type de distribution des valeurs et l'égalité des variances des échantillons à l'aide de tests standard - les critères de Kolmogorov-Smirnov et Shapiro-Wilks et le test de Bartlett mis en œuvre dans la bibliothèque sklearn.stats, en prenant p-value = 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])) 

De plus, pour une évaluation visuelle des résultats, vous pouvez utiliser la fonction d'histogramme.

 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) 

image

Vous pouvez lire l'histogramme comme ceci: 10 fois dans l'échantillon, il y a eu une conversion de 0,08, 1 - 0,14. Cela ne dit rien sur le nombre d'appareils comme observations pour aucun des indicateurs de conversion.

Dans notre cas, la distribution de la valeur du paramètre à la fois en valeur absolue et en valeur relative (le nombre d'appels à l'appareil) dans les échantillons n'est pas normale.
Dans ce cas, vous pouvez utiliser soit le test de Wilcoxon non paramétrique implémenté dans la bibliothèque sklearn.stats standard, soit essayer de ramener la distribution des valeurs dans les échantillons à la forme normale et appliquer l'un des critères paramétriques - le test t de Student ou le test Shapiro-Wilks de Student.

2. Méthodes de réduction de la distribution des valeurs dans les échantillons à la forme normale


2.1. Sous-godets

Une approche pour ramener la distribution à la normale est la méthode du sous-ensemble. Son essence est simple et la thèse mathématique suivante est la base théorique: selon le théorème de la limite centrale classique, la distribution des moyennes tend vers la normale - la somme de n variables aléatoires indépendantes identiquement distribuées a une distribution proche de la normale, et, de manière équivalente, la distribution des moyennes d'échantillonnage des n premiers n aléatoires indépendants distribués identiquement les quantités ont tendance à revenir à la normale. Par conséquent, nous pouvons diviser les bucket'es existants en sous-bucket'y et, en conséquence, en prenant les valeurs moyennes de sub-bucket'y pour chacun des bucket'ov, nous pouvons obtenir une distribution proche de la normale:

 #   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 

Il peut y avoir de nombreuses options pour le fractionnement, tout dépend de l'imagination du développeur et des principes moraux - vous pouvez prendre un hasard honnête ou utiliser le hachage du seau d'origine, prenant ainsi en compte le mécanisme pour le publier dans le schéma.

Cependant, dans la pratique, à partir de plusieurs dizaines de lancements de code, nous n'avons reçu la distribution normale qu'une seule fois, c'est-à-dire que cette méthode n'est ni garantie ni stable.

En outre, le rapport des actions cibles et des utilisateurs au nombre total d'actions et d'utilisateurs dans le sous-ensemble peut ne pas être cohérent avec les backets initiaux, vous devez donc d'abord vérifier que le ratio est maintenu.

 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 

Au cours d'une telle vérification, nous avons découvert que les taux de conversion des sous-ensembles par rapport à la sélection d'origine ne sont pas conservés. Étant donné que nous devons en outre garantir la cohérence du ratio de la part des appels dans les échantillons de sortie et de source, nous utilisons l'équilibrage des classes, en ajoutant une pondération afin que les données soient sélectionnées séparément par sous-groupes: séparément des observations avec des actions cibles et séparément des observations sans actions cibles dans la bonne proportion. De plus, dans notre cas, les échantillons ont été répartis de manière inégale; intuitivement, il semble que la moyenne ne devrait pas changer, mais comment la non-uniformité des échantillons affecte la variance n'est pas évidente à partir de la formule de dispersion. Afin de clarifier si la différence de taille des échantillons affecte le résultat, le critère du carré Xi est utilisé - si une différence statistiquement significative est détectée, une base de données plus grande avec une taille plus petite sera échantillonnée:

 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 sortie, nous obtenons des données équilibrées en taille et cohérentes avec les taux de conversion initiaux, les métriques étudiées (calculées pour les valeurs moyennes du sous-bucket) dans lesquelles elles sont déjà réparties normalement, ce qui peut être vu à la fois visuellement et par les résultats de l'application des critères de test déjà connus de nous normalité (avec une valeur p> = 0,05). Par exemple, pour les indicateurs relatifs:

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

Maintenant, le test t peut être appliqué à la moyenne sur les sous-ensembles (donc, ce n'est pas device_id, pas un périphérique, mais un sous-ensemble qui agit comme une observation).

Après nous être assurés que les changements sont statistiquement significatifs, nous pouvons, en toute conscience, faire ce pour quoi nous avons tout commencé - calculer la cannibalisation:

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

Le dénominateur doit être le trafic sans publicité, c'est-à-dire expérimental.

3. Méthode Bootstrap


La méthode bootstrap est une extension de la méthode sous-bucket et représente sa version plus avancée et améliorée; une implémentation logicielle de cette méthode en Python peut être trouvée dans la bibliothèque Facebook Bootstrapped.
En bref, l'idée de bootstrap peut être décrite comme suit: une méthode n'est rien de plus qu'un constructeur d'échantillons générés de manière similaire aux méthodes de sous-ensemble au hasard, mais avec des répétitions possibles. Nous pouvons dire le placement de la population générale (si l'on peut appeler l'échantillon d'origine) avec le retour. En sortie, des moyennes (ou médianes, montants, etc.) sont formées à partir des moyennes pour chacun des sous-échantillons générés.

Les principales méthodes de la bibliothèque FaceBook Bootstrap :
 bootstrap() 
- met en œuvre un mécanisme de formation de sous-échantillons; renvoie la limite inférieure (5 centile) et la limite supérieure (95 centile) par défaut; pour renvoyer une distribution discrète dans cette plage, il est nécessaire de définir le paramètre return_distribution = True (il est généré par la fonction d'assistance generate_distributions () ).

Vous pouvez spécifier le nombre d'itérations à l'aide du paramètre num_iterations , dans lequel les sous-échantillons seront générés, et le nombre de sous-échantillons iteration_batch_size pour chaque itération. A la sortie de generate_distributions () , un échantillon sera généré avec une taille égale au nombre d'itérations num_iterations , dont les éléments seront la moyenne des valeurs des échantillons iteration_batch_size calculées à chaque itération. Avec de grands volumes d'échantillons, les données peuvent ne plus tenir en mémoire, il est donc conseillé dans de tels cas de réduire la valeur de iteration_batch_size .

Exemple : que l'échantillon d'origine soit 2 000 000; num_iterations = 10 000, iteration_batch_size = 300. Ensuite, à chacune des 10 000 itérations, 300 listes de 2 000 000 d'éléments seront stockées en mémoire.

La fonction permet également le calcul parallèle sur plusieurs cœurs de processeur, sur plusieurs threads, en définissant le nombre requis à l'aide du paramètre num_threads .

 bootstrap_ab() 

effectue toutes les mêmes actions que la fonction bootstrap () décrite ci-dessus, cependant, en outre, l'agrégation des valeurs moyennes est également effectuée par la méthode spécifiée dans stat_func - à partir des valeurs de num_iterations . Ensuite, la métrique spécifiée dans le paramètre compare_func est calculée et la signification statistique est estimée.

 compare_functions 

- une classe de fonctions qui fournit des outils pour la formation de paramètres d'évaluation:
 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 
- une classe de fonctions à partir de laquelle la méthode d'agrégation de la métrique étudiée est sélectionnée:
 stats_functions.mean stats_functions.sum stats_functions.median stats_functions.std 

En tant que stat_func, vous pouvez également utiliser une fonction personnalisée définie par l'utilisateur, par exemple:

 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) 

En fait, (test_stat - ctrl_stat) / test_stat est la formule pour calculer notre cannibalisation.

Alternativement, ou dans le but d'une expérience pratique, vous pouvez initialement obtenir des distributions à l'aide de bootstrap () , vérifier la signification statistique des différences dans les métriques cibles à l'aide du test t, puis leur appliquer les manipulations nécessaires.
Un exemple de la façon dont la distribution normale de «qualité» peut être obtenue en utilisant cette méthode:



Une documentation plus détaillée se trouve sur la page du référentiel .

Pour le moment, c'est tout ce dont je voulais (ou j'ai réussi) à parler. J'ai essayé de décrire brièvement mais clairement les méthodes utilisées et le processus de leur mise en œuvre. Il est possible que les méthodologies nécessitent un ajustement, donc je serai reconnaissant pour les commentaires et les critiques.

Je remercie également mes collègues pour leur aide dans la préparation de ce travail. Si l'article reçoit des commentaires majoritairement positifs, j'indiquerai ici leurs noms ou surnoms (par accord préalable).

Meilleurs voeux à tous! :)

PS Dear Championship Channel , la tâche d'évaluer les résultats des tests A / B est l'une des plus importantes en science des données, car aucun lancement d'un nouveau modèle ML en production n'est complet sans A / B. Peut-être qu'il est temps d'organiser un concours pour développer un système d'évaluation des résultats des tests A / B? :)

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


All Articles