Este artigo discute um método para calcular a canibalização de um aplicativo móvel com base no teste A / B clássico. Nesse caso, as ações-alvo são consideradas e avaliadas como parte do processo de redistribuição de uma fonte de publicidade (Direct, Criteo, AdWords UAC e outras) em comparação com as ações-alvo no grupo em que o anúncio foi desativado.
O artigo fornece uma visão geral dos métodos clássicos para comparar amostras independentes com uma breve base teórica e uma descrição das bibliotecas utilizadas, incluindo descreve brevemente a essência do método de inicialização e sua implementação na biblioteca do FaceBook Bootstrapped, bem como os problemas que surgem na prática ao aplicar essas técnicas e como resolvê-las.
As evidências são ofuscadas ou não são fornecidas para manter um acordo de não divulgação.
No futuro, pretendo complementar e modificar levemente este artigo à medida que novos fatos aparecerem, para que esta versão possa ser considerada o primeiro lançamento. Ficaria muito grato pelos comentários e críticas.
1. Introdução
Canibalização é o processo de fluxo de tráfego, completo e direcionado, de um canal para outro.
Os profissionais de marketing geralmente usam esse indicador como um coeficiente K adicional no cálculo do CPA: o CPA calculado é multiplicado por 1 + K. Nesse caso, CPA significa o custo total de atração de tráfego / número de ações segmentadas que são monetizadas diretamente, ou seja, que geraram lucro real - por exemplo, uma chamada direcionada e / ou monetizada indiretamente - por exemplo, aumentando o volume do banco de dados de anúncios, aumentando o público-alvo e assim por diante.
Quando canais gratuitos (por exemplo, visitas de SERPs orgânicos, cliques em links em sites gratuitos para uso) são canibalizados por pagos (direto, Adwords em vez de orgânicos, publicidade em feeds de redes sociais em vez de clicar em anúncios, é gratuito colocados em grupos e assim por diante), isso acarreta riscos de perda financeira; portanto, é importante conhecer a taxa de canibalização.
No nosso caso, a tarefa era calcular a canibalização de transições "orgânicas" para o aplicativo por transições da rede de publicidade da Criteo. A vigilância é um dispositivo ou um uid de usuário (GAID / ADVID e IDFA).
Preparação da experiência
Você pode preparar o público para o experimento dividindo os usuários na interface do sistema analítico do AdJust em grupos para isolar aqueles que verão anúncios de uma determinada rede de publicidade (amostra de controle) e aqueles que não receberão anúncios usando GAID ou ADVID e IDFA, respectivamente (O AdJust fornece a API do Construtor de públicos-alvo). Em seguida, na amostra de controle, você pode incluir uma campanha publicitária na rede de publicidade estudada no experimento.
Observo por mim mesmo que, como parece intuitivamente, a seguinte implementação do experimento seria mais competente neste caso: selecionar quatro grupos - aqueles que tiveram o redirecionamento desativado de todos os canais (1), como o grupo experimental, e aqueles que tiveram somente redirecionamento ativado com Criteo (2); aqueles que tiveram apenas o redirecionamento desativado com o Criteo (3), aqueles que tiveram todos os redirecionamentos (4) ativados. Seria possível calcular (1) / (2), tendo recebido o valor real de campanhas de canibalização da rede Criteo para transições "orgânicas" para o aplicativo e (3) / (4), tendo recebido o Criteo de canibalização no ambiente "natural" (afinal, a Criteo, obviamente também pode canibalizar outros canais pagos). O mesmo experimento deve ser repetido para outras redes de anúncios, a fim de descobrir o impacto de cada uma delas; em um mundo ideal, seria bom investigar a canibalização cruzada entre todas as principais fontes pagas que compõem a maior participação no tráfego total, mas levaria muito tempo (tanto para preparar experimentos do ponto de vista de desenvolvimento quanto para avaliar os resultados), o que causaria críticas por meticulosidade irracional.
De fato, nosso experimento foi realizado nas condições (3) e (4), as amostras foram divididas na proporção de 10% a 90%, o experimento foi realizado por 2 semanas.
Preparação e verificação de dados
Antes de iniciar qualquer estudo, uma etapa importante é o pré-treinamento e a limpeza de dados competentes.
Note-se que, de fato, os dispositivos ativos para o período experimental foram 2 vezes menos (42,5% e 50% dos grupos controle e experimental, respectivamente) do que os dispositivos nas amostras iniciais completas, o que é explicado pela natureza dos dados:
- Em primeiro lugar (e esse é o principal motivo), a seleção para redirecionar do Adjust contém os identificadores de todos os dispositivos que já instalaram o aplicativo, ou seja, aqueles que não estão mais em uso e aqueles com os quais o aplicativo já estava excluído
- segundo, não é necessário que todos os dispositivos tenham feito login no aplicativo durante o experimento.
No entanto, calculamos a canibalização com base nos dados de uma amostra completa. Para mim, pessoalmente, a correção de tal cálculo ainda parece um ponto discutível - em geral, na minha opinião, é mais correto limpar todos aqueles que desinstalaram o aplicativo e não o instalaram pelas tags correspondentes, bem como aqueles que não fazem login no aplicativo há mais de um ano - esse período de tempo em que o usuário pode alterar o dispositivo; menos - dessa forma, para o experimento, os usuários que não mudaram para o aplicativo, mas puderam fazê-lo, poderiam ser removidos da seleção se mostrássemos anúncios na rede Criteo. Quero observar que, em um mundo bom, todas essas negligências e suposições forçadas devem ser investigadas e verificadas separadamente, mas vivemos em um mundo rápido e furioso.
No nosso caso, é importante verificar os seguintes pontos:
- Verificamos a interseção em nossas amostras iniciais - experimental e controle. Em um experimento implementado corretamente, essas interseções não devem ser, no entanto, no nosso caso, houve várias duplicatas da amostra experimental no controle. No nosso caso, a participação dessas duplicatas no volume total de dispositivos envolvidos no experimento foi pequena e, portanto, negligenciamos essa condição. Se houver> 1% de duplicatas, o experimento deve ser considerado incorreto e um segundo experimento deve ser realizado, após a limpeza prévia dos duplicados.
- Verificamos que os dados do experimento foram realmente afetados - o redirecionamento deve ser desativado na amostra experimental (pelo menos com o Criteo, no experimento definido corretamente - de todos os canais); portanto, é necessário verificar a ausência de DeviceID do experimento no redirecionamento com o Criteo. No nosso caso, o DeviceID do grupo experimental, no entanto, caiu no redirecionamento, mas havia menos de 1%, o que é insignificante.
Avaliação direta do experimento
Consideraremos a alteração nas seguintes métricas de destino: absoluto - o número de chamadas e relativo - o número de chamadas por usuário nos grupos de controle (anúncios vistos na rede Criteo) e experimental (anúncios foram desativados). No código abaixo, os dados variáveis referem-se à estrutura pandas.DataFrame, formada a partir dos resultados de uma amostra experimental ou de controle.
Existem métodos paramétricos e não paramétricos para avaliar a significância estatística da diferença de valores em amostras não relacionadas. Os critérios de avaliação paramétrica fornecem maior precisão, mas têm limitações em sua aplicação - em particular, uma das principais condições é que os valores medidos para as observações na amostra sejam distribuídos normalmente.
1. O estudo da distribuição de valores nas amostras para normalidade
A primeira etapa é examinar as amostras existentes para o tipo de distribuição de valores e a igualdade das variações das amostras usando testes padrão - os critérios Kolmogorov-Smirnov e Shapiro-Wilks e o teste de Bartlett implementado na biblioteca sklearn.stats, assumindo p-value = 0.05:
Além disso, para uma avaliação visual dos resultados, você pode usar a função 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)

Você pode ler o histograma assim: 10 vezes na amostra houve uma conversão de 0,08, 1 - 0,14. Isso não diz nada sobre o número de dispositivos como observações para qualquer um dos indicadores de conversão.
No nosso caso, a distribuição do valor do parâmetro em valores absolutos e em relativo (o número de chamadas para o dispositivo) nas amostras não é normal.
Nesse caso, você pode usar o teste não paramétrico de Wilcoxon implementado na biblioteca sklearn.stats padrão ou tentar trazer a distribuição de valores nas amostras para a forma normal e aplicar um dos critérios paramétricos - teste t de Student ou teste de Shapiro-Wilks.
2. Métodos para reduzir a distribuição dos valores nas amostras para a forma normal
2.1 Sub-bucketsUma abordagem para trazer a distribuição ao normal é o método sub-bucket. Sua essência é simples, e a seguinte tese matemática é a base teórica: de acordo com o teorema clássico do limite central, a distribuição das médias tende a normal - a soma de n variáveis aleatórias independentes distribuídas identicamente tem uma distribuição próxima do normal e, de forma equivalente, a distribuição das médias amostrais da primeira n aleatória independente distribuída identicamente quantidades tende ao normal. Portanto, podemos dividir os buckets existentes em sub-bucket'y e, consequentemente, considerando os valores médios de sub-bucket'y para cada um dos bucket'ov, podemos obter uma distribuição quase normal:
Pode haver muitas opções para dividir, tudo depende da imaginação e dos princípios morais do desenvolvedor - você pode coletar aleatoriamente honestamente ou usar o hash do balde original, levando em consideração o mecanismo de emissão no esquema.
No entanto, na prática, de várias dezenas de lançamentos de código, recebemos a distribuição normal apenas uma vez, ou seja, esse método não é garantido nem estável.
Além disso, a proporção de ações de destino e usuários em relação ao número total de ações e usuários no subconjunto pode não ser consistente com os backets iniciais, portanto, você deve primeiro verificar se a proporção é mantida.
data[data['calls'] > 0].device_id.nunique()/data.device_id.nunique()
No processo dessa verificação, descobrimos que as taxas de conversão para sub-baldes em relação à seleção original não são preservadas. Como precisamos garantir adicionalmente a consistência da taxa de compartilhamento de chamadas nas amostras de saída e de origem, usamos o balanceamento de classe, adicionando ponderação para que os dados sejam selecionados separadamente por subgrupos: separadamente de observações com ações de destino e separadamente de observações sem ações de destino na proporção correta. Além disso, no nosso caso, as amostras foram distribuídas de maneira desigual; intuitivamente, parece que a média não deve mudar, mas como a não uniformidade das amostras afeta a variação não é óbvia a partir da fórmula de dispersão. Para esclarecer se a diferença no tamanho das amostras afeta o resultado, é utilizado o critério Xi-quadrado - se for detectada uma diferença estatisticamente significativa, será amostrado um quadro de dados maior com um tamanho menor:
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)
Na saída, obtemos dados balanceados em tamanho e consistentes com as taxas de conversão iniciais, as métricas estudadas (calculadas para valores médios para sub-bucket) nas quais elas já estão distribuídas normalmente, que podem ser vistas visualmente e pelos resultados da aplicação dos critérios de teste já conhecidos por nós. normalidade (com valor-p> = 0,05). Por exemplo, 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)
Agora, o teste t pode ser aplicado à média sobre os sub-buckets (portanto, não é device_id, não é um dispositivo, mas um sub-bucket que atua como uma observação).
Depois de garantir que as mudanças sejam estatisticamente significativas, podemos, com a consciência limpa, fazer o que começamos: calcular a canibalização:
(data_exp.groupby(['subbucket']).calls.avg() - data_cntrl.groupby(['subbucket']).calls.avg() )/ data_exp.groupby(['subbucket']).calls.avg()
O denominador deve ser tráfego sem anúncios, ou seja, experimental.
3. Método de Bootstrap
O método bootstrap é uma extensão do método sub-bucket e representa sua versão mais avançada e aprimorada; uma implementação de software desse método em Python pode ser encontrada na biblioteca de inicialização do Facebook.
Resumidamente, a ideia de bootstrap pode ser descrita da seguinte maneira: um método nada mais é do que um construtor de amostras geradas de maneira semelhante aos métodos de sub-bucket aleatoriamente, mas com possíveis repetições. Podemos dizer a colocação da população em geral (se é que se pode chamar a amostra original) com o retorno. Na saída, as médias (ou medianas, quantidades, etc.) são formadas a partir das médias para cada uma das subamostras geradas.
Os principais métodos da biblioteca do FaceBook Bootstrap :
bootstrap()
- implementa um mecanismo para a formação de subamostras; retorna limite inferior (percentil 5) e limite superior (percentil 95) por padrão; para retornar uma distribuição discreta nesse intervalo, você deve definir o parâmetro
return_distribution = True (é gerado pela função auxiliar
generate_distributions () ).
Você pode especificar o número de iterações usando o parâmetro
num_iterations , no qual as subamostras serão geradas, e o número de subamostras
iteration_batch_size para cada iteração. Na saída de
generate_distributions () , uma amostra será gerada com um tamanho igual ao número de iterações
num_iterations ,
cujos elementos serão a média dos valores das amostras de
iteration_batch_size calculadas em cada iteração. Com grandes volumes de amostras, os dados podem não caber mais na memória; portanto, nesses casos, é aconselhável reduzir o valor de
iteration_batch_size .
Exemplo : deixe a amostra original ser 2.000.000;
num_iterations = 10.000,
iteration_batch_size = 300. Em cada uma das 10.000 iterações, 300 listas de 2.000.000 de itens serão armazenadas na memória.
A função também permite a computação paralela em vários núcleos do processador, em vários threads, configurando o número necessário usando o parâmetro
num_threads .
bootstrap_ab()
executa as mesmas ações que a função
bootstrap () descrita acima, no entanto, além disso, a agregação de valores médios também é executada pelo método especificado em
stat_func - a partir dos valores de
num_iterations . A seguir, a métrica especificada no parâmetro compare_func é calculada e a significância estatística é estimada.
compare_functions
- uma classe de funções que fornece ferramentas para a formação de métricas para avaliação:
compare_functions.difference() compare_functions.percent_change() compare_functions.ratio() compare_functions.percent_difference()
stats_functions
- uma classe de funções a partir da qual o método de agregação da métrica estudada é selecionado:
stats_functions.mean stats_functions.sum stats_functions.median stats_functions.std
Como
stat_func, você pode usar uma função personalizada definida pelo usuário, por exemplo:
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 fato,
(test_stat - ctrl_stat) / test_stat é a fórmula para calcular nossa canibalização.
Como alternativa, ou com a finalidade de um experimento prático, você pode obter inicialmente distribuições usando o
bootstrap () , verificar a significância estatística das diferenças nas métricas de destino usando o teste t e aplicar as manipulações necessárias a elas.
Um exemplo de como a distribuição normal de "qualidade" pode ser obtida usando este método:

Documentação mais detalhada pode ser encontrada na
página do repositório .
No momento, isso é tudo sobre o que eu queria (ou consegui) falar. Tentei descrever brevemente, mas claramente, os métodos usados e o processo de sua implementação. É possível que as metodologias exijam ajustes, por isso serei grato por comentários e críticas.
Também quero agradecer aos meus colegas por sua ajuda na preparação deste trabalho. Se o artigo receber feedback predominantemente positivo, indicarei aqui seus nomes ou apelidos (mediante acordo prévio).
Muitas felicidades a todos! :)
PS
Caro canal do campeonato , a tarefa de avaliar os resultados dos testes A / B é uma das mais importantes em ciência de dados, porque nem um lançamento de um novo modelo de ML na produção está completo sem A / B. Talvez seja hora de organizar uma competição para desenvolver um sistema para avaliar os resultados dos testes A / B? :)