Da Decisão à Decolagem: Treinando um Modelo de Propensão para Reservas de Voos

Daniel Soares
12 min readDec 11, 2023

Propensão de compra é um conceito relevante quando se fala em companhias aéreas, pois pode ser aplicado em diferentes frentes desse tipo de negócio. Hoje, iremos focar na que representa o core business desse segmento: a compra de passagens aéreas.

Fonte: De Malas para o Mundo

Para isso, trabalharemos com o problema de negócio abaixo.

Nos dias de hoje, os clientes estão mais habilitados do que nunca, pois têm acesso a uma riqueza de informações na ponta dos dedos. Essa é uma das razões pelas quais o ciclo de compras é muito diferente do que costumava ser. Hoje, se você espera que um cliente compre seus voos de férias quando eles entram no aeroporto, você já perdeu! Ser reativo nessa situação não é o ideal; as companhias aéreas devem ser proativas para adquirir clientes antes de embarcarem em suas férias. Devido a isso, a British Airways — companhia aérea britânica — deseja identificar os clientes com uma maior propensão de compra. Ela compartilhou os dados com o time de Data Science, e espera que ele os ajude com esse problema.

🧐 Business Understanding

Essa é a fase mais importante do projeto, pois não há como resolver algo que não conhecemos, não é mesmo?

Como temos um problema de propensão, irei assumir que o time de negócio está interessado nos scores. Em consequência disso, usaremos a log loss como métrica de avaliação, pois ela penaliza previsões com probabilidades anormais, que seriam um problema no nosso contexto.

🔍 Data Understanding

Aqui nós vamos definir quais dados utilizar e verificar a disponibilidade dos mesmos. Como o nosso problema envolve o booking de passagens aéreas, seria interessante usar uma tabela de vendas. Essa tabela poderia conter informações dos clientes (e.g idade, sexo) e características da compra (valor, data do voo, rota e se a compra foi efetuada ou não).

Como a tabela já foi previamente fornecida, não há necessidade de verificar se a mesma existe ou não. Um ponto que seria interessante de entender é qual a periodicidade do dado, pois isso implicaria diretamente no processo de implantação.

👨‍🔧 Data Preparation

Nessa etapa, iremos preparar os dados para a etapa de modelagem. É aqui onde faremos todo o pré-processamento. Vamos dar uma olhada no nosso conjunto de dados:

Visão das primeiras linhas e colunas. Fonte: Autor
Shape dos dados. Fonte: Autor

Obs: para uma visão completa do conjunto, acesse o notebook no GitHub.

A princípio temos um conjunto relativamente pequeno, contendo 50 mil linhas e 14 colunas. Por não possuir uma coluna de ID, os dados não podem ser atribuídos a um usuário, o que aumenta as chances de existirem dados duplicados no nosso conjunto. Devido a isso, remover as duplicatas é uma das primeiras ações a serem tomadas.

Info dos dados. Fonte: Autor

Verificando a info do nosso conjunto através do método info() do Pandas, podemos ver que não existem dados ausentes. Além disso, também é possível notar que o tipo de cada coluna corresponde aos valores que ela carrega, tornando desnecessário a alteração no tipo das mesmas. Apesar de já estarem no formato correto, optarei por transformar as colunas wants_extra_baggage, wants_preferred_seat e wants_in_flight_meals em categóricas, pois irá facilitar na hora de montar os pipelines.

Investigando um pouco cada variável, podemos identificar que as colunas route e booking_origin possuem alta cardinalidade e valores raros. Uma alta cardinalidade, dependendo de como é tratada, pode aumentar significativamente o tempo de treinamento de um algoritmo. Já a presença de valores raros pode gerar erros durante o treinamento dos algoritmos, pois durante a divisão em treino e teste, caso tais valores estejam presentes apenas no segundo conjunto, o modelo não reconhecerá a categoria, já que a mesma não estava presente no seu conjunto de treino.

Coluna route à esquerda e coluna booking_origin à direita. Fonte: Autor

Para lidar com o problema de valores raros, iremos combinar as categorias com menos de 8 registros em uma única categoria de nome others. O problema da cardinalidade será tratado com um encoder específico para essas colunas, o CountEncoder, que transformará os dados categóricos em numéricos sem afetar a quantidade total de features.

Explorando os dados numéricos, podemos encontrar outliers naturais em três colunas, fazendo com que não seja necessário a exclusão ou alteração de tais valores. É possível perceber que todas as colunas possuem uma boa variação de valores, tornando desnecessária a exclusão das mesmas por baixa variabilidade.

Boxplot de cada variável que apresentou outliers. Fonte: Autor

Partindo para a análise do equilíbrio de classes na nossa coluna alvo, podemos ver que, apesar de possuir um número alto de registros que correspondem à classe minoritária, se trata de um problema com classes desbalanceadas.

Contagem de registros por valor do target. Fonte: Autor

Para finalizar essa etapa, vamos criar novas features a partir do nosso conjunto original. Devido a extensão do código, disponibilizarei o mesmo abaixo com devidas considerações, caso necessário.

# Criando função para determinar em que parte do dia o voo ocorre
func_time_of_day = lambda hour: 'morning' if hour >= 6 and hour < 12 else ('afternoon' if hour >= 12 and hour < 18 else 'night')

# Aplicando a função
dados['time_of_day'] = dados['flight_hour'].apply(func_time_of_day)

# Calculando a hora de chegada
dados['arrival_hour'] = dados['flight_hour'] + dados['flight_duration']

# Criando função para verificar se o voo chega no mesmo dia
func_same_day = lambda hour: 'yes' if hour <= 24 else 'no'

# Aplicando a função
dados['same_day_arrival'] = dados['arrival_hour'].apply(func_same_day)

# Criando função para checar se a viagem vai ocorrer no final de semana
func_weekend = lambda day: 'yes' if day == 'Sat' or day == 'Sun' else 'no'

# Aplicando a função
dados['weekend_trip'] = dados['flight_day'].apply(func_weekend)

# Calculando o tempo de estadia em meses
dados['length_of_stay_months'] = dados['length_of_stay'] / 30

# calculando o tempo de estadia em semanas
dados['length_of_stay_years'] = dados['length_of_stay'] / 360

# Calculando a quantidade total de benefícios solicitados
dados['total_benefits'] = dados['wants_extra_baggage'] + dados['wants_preferred_seat'] + dados['wants_in_flight_meals']

# Calculando o lead por passageiro
dados['lead_per_passenger'] = dados['purchase_lead'] / dados['num_passengers']

# Criando listas com os países de cada continente
# Aqui vai uma consideração: embora a maior parte do território da Rússia
# esteja localizada na Ásia, a maior parte da população está localizada na
# Europa, por isso a Rússia está na lista de países europeus.

america = ["Canada", "United States", "Mexico", "Brazil", "Argentina",
"Colombia", "Peru", "Venezuela", "Chile", "Ecuador", "Bolivia",
"Paraguay", "Uruguay", "Guyana", "Suriname", "French Guiana",
"Guatemala", "Honduras", "El Salvador", "Nicaragua", "Costa Rica",
"Panama", "Cuba", "Jamaica", "Haiti", "Dominican Republic",
"Puerto Rico", "Bahamas", "Trinidad and Tobago", "Barbados",
"Grenada", "Saint Vincent and the Grenadines", "Saint Lucia",
"Antigua and Barbuda", "Saint Kitts and Nevis", "Belize",
"Guadeloupe", "Martinique", "Aruba", "Curacao", "Bonaire",
"Sint Eustatius", "Saba", "Turks and Caicos Islands",
"Cayman Islands", "British Virgin Islands",
"United States Virgin Islands", "Montserrat", "Anguilla",
"Saint Pierre and Miquelon", "Greenland", "Bermuda"]

asia = ["China", "India", "Indonesia", "Pakistan", "Bangladesh",
"Japan", "Philippines", "Vietnam", "Turkey", "Iran", "Thailand",
"Myanmar", "South Korea", "Iraq", "Afghanistan", "Uzbekistan",
"Malaysia", "Yemen", "Nepal", "Sri Lanka", "Kazakhstan", "Syria",
"Cambodia", "Jordan", "Azerbaijan", "United Arab Emirates",
"Tajikistan", "Israel", "Laos", "Lebanon", "Turkmenistan",
"Singapore", "Oman", "State of Palestine", "Kuwait", "Mongolia",
"Armenia", "Qatar", "Bahrain", "Timor-Leste", "Cyprus", "Bhutan",
"Maldives", "Brunei", "Northern Cyprus", "Taiwan", "Hong Kong", "Macao",
"Saudi Arabia", "Macau", "Myanmar (Burma)"]

europe = ["Russia", "Germany", "United Kingdom", "France", "Italy", "Spain",
"Ukraine", "Poland", "Romania", "Netherlands", "Belgium",
"Czech Republic", "Greece", "Portugal", "Sweden", "Hungary",
"Belarus", "Austria", "Serbia", "Switzerland", "Bulgaria", "Denmark",
"Finland", "Slovakia", "Norway", "Ireland", "Croatia", "Moldova",
"Bosnia and Herzegovina", "Lithuania", "Albania", "North Macedonia",
"Slovenia", "Latvia", "Estonia", "Montenegro", "Luxembourg", "Malta",
"Iceland", "Andorra", "Monaco", "Liechtenstein", "San Marino",
"Vatican City"]

africa = ["Nigeria", "Ethiopia", "Egypt", "Democratic Republic of the Congo",
"South Africa", "Tanzania", "Kenya", "Uganda", "Algeria", "Sudan",
"Morocco", "Angola", "Mozambique", "Ghana", "Madagascar", "Cameroon",
"Cote d'Ivoire", "Niger", "Burkina Faso", "Mali", "Malawi", "Zambia",
"Senegal", "Chad", "Somalia", "Zimbabwe", "Rwanda", "Tunisia",
"Guinea", "Benin", "Sierra Leone", "Libya", "Eritrea", "Togo",
"Mauritania", "Namibia", "Gambia", "Botswana", "Gabon", "Lesotho",
"Equatorial Guinea", "Seychelles", "Djibouti", "Comoros",
"Cabo Verde", "Sao Tome and Principe", "Mauritius", "Réunion"]

oceania = ["Australia", "Papua New Guinea", "New Zealand", "Fiji",
"Solomon Islands", "Vanuatu", "Samoa", "Kiribati", "Tonga",
"Tuvalu", "Nauru", "Marshall Islands", "Palau", "Micronesia",
"Federated States of Micronesia"]

# Criando função para determinar o continente de cada país
func_continent = lambda country: 'america' if country in america else ('asia' if country in asia else ('europe' if country in europe else ('africa' if country in africa else ('oceania' if country in oceania else 'other'))))

# Aplicando a função
dados['continent_booking_origin'] = dados['booking_origin'].apply(func_continent)

# Criando função para determinar se a pessoa irá viajar sozinha ou não
func_solo_flight = lambda passengers: 'yes' if passengers == 1 else 'no'

# Aplicando a função
dados['solo_flight'] = dados['num_passengers'].apply(func_solo_flight)

# Criando função para buscar a cidade de origem
func_origin_city = lambda route: route if route == "other" else route[0:3]
func_destiny_city = lambda route: route if route == "other" else route[3:6]

# Buscando a cidade de origem e a de destino
dados['Origin_City'] = dados.route.apply(func_origin_city)
dados['Destiny_City'] = dados.route.apply(func_destiny_city)

# Como última consideração, criamos novos dados com as colunas que possuíam
# alta cardinalidade, por isso, essas colunas também serão incluídas no
# pipeline que será tratado com o CountEncoder

🤖 MODELLING

Essa etapa é onde entra o Machine Learning, é aqui onde treinaremos o nosso algoritmo para testar se o mesmo pode ou não resolver o problema estabelecido durante o processo de Business Understanding.

Para essa fase, a ideia é usar uma combinação de pipelines, loop, dicionários e MLflow. Os dicionários nos ajudarão na rotulagem dos experimentos.

Exemplo do uso dos dicionários. Fonte: Autor

Com os pipelines, poderemos ter um controle maior sobre os tratamentos que serão aplicados nos dados, além de nos ajudar a evitar o problemático data leakage.

Exeplo do uso dos pipelines. Fonte: Autor

Com o uso conjunto das técnicas citadas anteriormente, chegaremos a tela abaixo, onde a tag das combinações que geraram aquele modelo, assim como suas métricas, estarão registradas.

Parte superior da tabela contendo os melhores experimentos gerados. Fonte: Autor

Apesar da Random Forest usando CatBoostEncoder ter sido o modelo com a menor log loss, seguiremos com o segundo colocado, pois o tempo de treinamento da Floresta Aleatória indica que a loss gerada por ela pode não ser confiável.

Seguindo com o XGBoost + CatBoostEncoder, partimos para a fase final da modelagem, a tunagem de hiperparâmetros. Aqui, optaremos por usar o optuna, método de otimização bayesiana, que testa parâmetros diferentes de forma iterativa.

Função usada para o tunning. Fonte: Autor

Após o tunning, a loss média anterior, que era de aproximadamente 0.50, passou para aproximadamente 0.36, o que representa uma redução de 0.14 na métrica de avaliação do modelo.

Loss após o tunning. Fonte: Autor

🤨 EVALUATION

A etapa de avaliação é onde decidimos se os resultados obtidos são suficientes para que o problema de negócio seja solucionado.

Levando em consideração a log loss obtida, podemos considerar que é um score satisfatório, mas com espaço para melhorias. Entretanto, o foco aqui será em outra coisa: a calibragem das probabilidades geradas pelo modelo.

Como estamos desenvolvendo um projeto em que o foco são as probabilidades, essa etapa se torna fundamental, pois diferentemente da Regressão Logística, que retorna probabilidades já calibradas, o XGBoost não o faz.

Para concluir essa fase, iremos testar três métodos diferentes de calibragem: Platt Scaling, Isotonic Regression e Spline Calibration. Antes de aplicar tais métodos, vamos ver como as previsões do nosso modelo se comportam:

Previsões descalibradas. Fonte: Autor

Olhando para o gráfico de confiabilidade, podemos perceber que até aproximadamente 0.4, as previsões estão boas, mas após isso, elas variam bastante, fazendo com que as previsões acima do valor citado não sejam confiáveis. Vamos seguir para o nosso primeiro método de calibração: o Platt Scaling.

O Platt Scaling consiste em treinar uma regressão logística com os scores gerados pelo modelo, no nosso caso o XGBoost, e o target. Dessa forma, os scores serão calibrados.

Previsões calibradas com Platt Scaling. Fonte: Autor

Para o nosso caso, essa alternativa não ajudou muito, pois previsões mais altas continuam não confiáveis, apresentando intervalos de confiança que vão de 0 a 1. Vamos ao segundo método: a Isotonic Regression.

Essa técnica de calibração combina árvores de decisão e classificadores bayesianos para calibrar as probabilidades.

Previsões calibradas com Isotonic Regression. Fonte: Autor

Aplicando a Isotonic Regression, já podemos observar resultados melhores. Apesar de reduzir o intervalo das previsões de 0 — 0.65 para 0 — 0.40, as probabilidades se tornaram bem mais confiáveis se comparadas com as anteriores. Por fim, vamos ao último método: Splinne Calib.

Essa técnica consiste em usar um polinômio cúbico para ajustar as previsões do modelo para suas previsões reais. O Splinne Calib foi proposto pelo criador do pacote Ml-insights e pode ser conferido nesse paper.

Previsões calibradas com Splinne Calib. Fonte: Autor

Podemos observar que obtivemos um gráfico de probabilidade semelhante ao gerado pela Isotonic Regression, mas com intervalos de confiança menores, o que indica que os valores gerados por ele estão mais próximos do perfeito. Como conclusão, usaremos o Splinne Calib como calibrador de probabilidades.

🙌 DEPLOYMENT

Essa é a fase de implementação, onde nosso modelo é levado para a produção. Apesar dessa fase não estar presente no projeto proposto, iremos explorar como nossa solução poderia ser implementada.

O primeiro passo é entender como os nossos scores de probabilidade serão utilizados. Vamos supor que, em conjunto com a área de negócios e também levando em conta o trabalho inferencial realizado, a seguinte solução foi considerada a ideal:

Plano de ação. Fonte: Autor

Com o plano de ação em mãos, é hora de implantar a solução. Um ponto importante para entender nessa fase é com que frequência os novos dados irão chegar. Como o nosso conjunto possui dados de bookings completos e incompletos dos clientes, e que um cliente pode comprar um bilhete a qualquer momento, podendo ser instantes após o outro ou com um longo intervalo, penso que um deploy em batch com runs diárias seria o ideal.

Outro ponto que fortalece a ideia anterior é que mesmo que um cliente decida não comprar a passagem no momento, ele pode ficar aberto à compra do bilhete. Isso faz com que o tempo para que os benefícios sejam entregues não seja algo crítico, podendo acontecer um, dois ou até três dias após a tentativa de compra.

Arquitetura AWS proposta. Fonte: Autor

A arquitetura proposta aqui é simples. Carregaríamos o nosso modelo e o calibrador para um bucket S3, e de forma diária, carregaríamos novos dados do nosso servidor para o mesmo bucket. Poderíamos automatizar o processo de carregamento usando um Cron Job ou até mesmo uma Dag no Airflow.

Após os novos dados chegarem no S3, uma lambda seria ativada e iniciaria o EMR, que iria tratar, criar as novas features e realizar as predições nos dados assim como foi feito na etapa de modelagem. Optei por colocar o EMR aqui por não saber a volumetria dos dados que irão chegar, e independente de ter um baixo ou alto volume, o EMR é escalável, podendo lidar bem com qualquer uma das situações.

Após a etapa anterior, as predições seriam concatenadas aos registros dos clientes em questão e seriam salvas em um bucket S3. Logo após isso, uma lambda seria ativada, separando os clientes de acordo com o que está indicado no plano de ação e ativando o SES, que enviaria os e-mails de forma automatizada.

CONCLUSÃO

E chegamos ao fim desse projeto, com uma solução satisfatória, mas com espaço para melhorias. Esse projeto é apenas uma parte de outras duas que compõem um projeto maior, feito com a colaboração de meus colegas Henrique W. Franco, Ingo Reichert Junior e Edson Junior. Você pode checar o projeto completo através do GitHub.

Para mais conteúdos como esse, me siga aqui ou conecte-se comigo através do LinkedIn.

Até a próxima!

--

--