Em um post anterior, contei como usamos Multi-Armed Bandits para rodar testes A/B adaptativos no nosso CRM. Aquele post terminou prometendo recompensas mais inteligentes e bandits contextuais. Não foi o que aconteceu. O que veio depois foi diferente e, para mim, mais interessante: paramos de tratar o e-mail como uma coisa só.
Um e-mail não é um átomo, é uma molécula: assunto, nome do remetente, título, call-to-action, corpo do texto. Quando você testa e-mails inteiros, está testando combinações dessas partes. E combinação multiplica rápido. Esta série conta a história dos Dynamic Templates: e-mails montados na hora do envio a partir de pools de variantes por slot, com um bandit cuidando de cada slot. A Parte 1 é sobre a ideia central e a arquitetura que segura ela de pé.
A série tem três partes:
- Parte 1 (esta): o conceito, a explosão combinatória e a arquitetura: onde os pesos ficam, como o envio consulta eles e que formato tem o loop de feedback.
- Parte 2: as estratégias de alocação: probability matching contra regras proporcionais, a matemática e os modos de falha de cada uma, como escolher a recompensa de cada slot e o que precisa estar no lugar para rodar Thompson Sampling em produção sem susto.
- Parte 3: o ciclo de vida: como saber que um teste terminou, os vereditos estatísticos, promover vencedores e aposentar um experimento pronto virando template comum.
De Variantes A/B para Templates com Slots
O teste A/B clássico numa ferramenta de CRM é assim: você escreve dois (ou cinco) e-mails completos, divide o tráfego e espera. Funciona, mas tem um detalhe chato: cada variante é um e-mail inteiro. Se você quer testar três assuntos e dois botões de CTA, ou escreve seis e-mails na mão, um para cada combinação, ou testa em sequência e reza para os aprendizados se somarem.
Um Dynamic Template vira isso do avesso. O template tem slots, que são espaços para as partes que a gente quer testar: assunto, nome do remetente, título, texto e botão de CTA. Cada slot tem um pool de variantes: cinco assuntos, cinco títulos, cinco CTAs, por exemplo. Na hora do envio o sistema escolhe uma variante por slot e monta o e-mail. Cada cliente pode receber uma combinação diferente, e todo envio sai marcado com a variante que preencheu cada slot. Resultado: o dado de performance volta no nível do componente, não do e-mail.
A Explosão Combinatória
Aqui está o problema que faz o teste por slot ser diferente de verdade, e não só mais bonito.
Imagine um template com $K = 5$ slots e $V = 5$ variantes cada. O número de e-mails distintos que dá para montar é:
$$V^K = 5^5 = 3{.}125$$
Se você tratar cada combinação como um braço de um bandit gigante (a formulação "correta", em algum sentido), precisa de dado suficiente por combinação para conseguir ranquear. Nosso otimizador segura um componente em exploração uniforme até todo braço ter no mínimo 500 envios (falo mais das travas de cold-start na Parte 2). Com 3.125 braços, isso dá mais de 1,5 milhão de envios antes do sistema ter permissão de concluir qualquer coisa. Para o público de uma campanha típica, isso não é aprender devagar, é não aprender. E piora exponencialmente a cada slot novo.
Então a gente não faz isso. A gente assume que os slots são independentes e quebra o problema em cinco bandits separados, de cinco braços cada.
$$V \times K = 5 \times 5 = 25 \text{ braços em vez de } 3{.}125$$
São ~125× menos coisas para estimar. E a decomposição é melhor do que essa razão sugere, por causa do reaproveitamento de amostra: cada e-mail enviado é uma tentativa em todos os cinco bandits ao mesmo tempo. Um envio dá uma observação para o bandit do assunto, uma para o do título, uma para o do CTA, e assim por diante. Na formulação por combinação, esse mesmo envio alimentava um braço só, de 3.125.
Claro que independência é uma suposição. Como o sistema mistura os slots livremente na hora do envio, uma variante que só funciona em um par específico (um assunto "50% de desconto!" em cima de um corpo que nunca fala de desconto) é justamente o tipo de interação que a decomposição não vê. Cada slot é julgado pela performance média dele, considerando tudo que os outros slots serviram junto.
Na prática isso sai barato, por dois motivos:
- Contrato de autoria. Como qualquer combinação pode ir ao ar, quem monta os pools mantém cada variante coerente com as variantes dos outros slots. Os pares que se contradizem quase nunca chegam ao pool.
- Interação é efeito de segunda ordem. Quando escapa alguma, ela é pequena perto do efeito principal de um bom assunto ou de um bom CTA. E modelar interação explicitamente custaria exatamente a explosão combinatória da qual a gente está fugindo.
Na Parte 2 dá para ver como o desenho da recompensa deixa essa suposição mais confortável do que parece.
Medir, Recompensar, Realocar
Com os slots virando bandits independentes, a feature toda roda num loop simples. A gente mede como cada variante performa. Uma recompensa transforma essas medições em uma alocação, que é um conjunto de percentuais, um por variante. E na hora do envio esses percentuais decidem quanto volume cada variante leva. Variante melhor ganha mais volume. Como o loop recalcula a cada poucas horas, conforme abertura e clique chegam, a alocação vai atrás da evidência.
É essa a ideia inteira. O resto deste post é sobre rodar esse loop com segurança: onde os números ficam, o que acontece com um envio quando eles faltam e como a medição chega até o otimizador. (Como a recompensa transforma contagem em alocação é a Parte 2 inteira, e é a parte estatisticamente interessante.)
O Loop de Feedback
Falta a última peça: o loop que reajusta os pesos, mais o pipeline de medição que alimenta ele. Todo evento de tracking de todo e-mail (envio, entrega, abertura, clique) sai marcado com os IDs das variantes que preencheram cada slot e passa pelo mesmo pipeline em tempo real do post sobre Multi-Armed Bandits: o Amazon SES joga os eventos via SNS num stream do Kinesis, um job de streaming no Databricks grava em Delta em cerca de um minuto e as camadas medallion agregam tudo numa tabela gold com contagem de 30 dias no grão (ação, slot, variante): enviado, aberto, clicado, por variante, por slot. Essa tabela é toda a visão de mundo do otimizador.
O loop é um job agendado que roda a cada 3 horas. A recompensa é atrasada de qualquer jeito, porque clique chega por horas depois do envio, então recalcular mais rápido não compraria muita coisa, e alocação por bandit aguenta bem peso um pouco velho. Cada execução lê o gold, recalcula a distribuição de cada ação e escreve uma versão nova na tabela de pesos, da qual é o único escritor. Depois da semente uniforme inicial não tem ajuste manual: para intervir, você remove uma variante ou declara um vencedor, as duas coisas na Parte 3. O algoritmo dentro do loop é o assunto da Parte 2. Aqui o que importa é o formato:
A leitura desses pesos é deliberadamente segura, um fail open: na hora do envio o executor escolhe a variante através de um resolver com cache e, se o peso estiver faltando ou a leitura falhar, ele cai para escolha uniforme e loga o erro. A camada de otimização pode rebaixar um envio para uniforme, que é onde todo teste começa afinal, mas nunca pode bloquear o envio.
Três coisas do loop que vale destacar:
- Ele lista as variantes a partir da ação, não a partir do dado. Essa é sutil e importante. Uma variante que recebeu zero envio produz zero linha de analytics, ou seja, fica invisível no dado de performance. Se o loop tirasse o universo de variantes da contagem, uma variante sem tráfego nunca se recuperaria: sem envio → sem linha → sem peso → sem envio. O payload da ação é a fonte da verdade sobre o que existe, e a contagem entra por left join, com zeros e tudo.
- Ele é stateless. O loop nunca lê o peso anterior: ele recalcula a distribuição inteira a partir da contagem acumulada, toda execução. O peso anterior já está implícito no dado de qualquer forma, porque foi ele que definiu quais variantes acumularam envio. Recálculo sem estado significa zero acúmulo de desvio, zero procedimento de recuperação de estado corrompido e execução fácil de reproduzir.
- Ele tem kill-switch. Uma flag roda o loop em modo só-cálculo: ele loga a distribuição que escreveria, sem escrever. Assim qualquer mudança de algoritmo se prova contra dado de produção real antes de ser dona de um envio.
O Que Temos Até Aqui
Nesse ponto a máquina está montada: template com slot, pool de variantes por slot, tabela de pesos semeada uniforme e de propriedade do loop, resolver fail-open no caminho do envio, pipeline de medição em tempo real e um loop de feedback agendado e stateless.
Falta só o algoritmo que decide os números. E "transformar contagem em percentual" acaba sendo um problema de design bem interessante: a resposta da teoria é instável de um jeito sutil, a resposta estável ignora confiança e a recompensa que cada slot deveria otimizar não é a óbvia. Isso é a Parte 2. Até lá.