Aproximando o cálculo de features entre treinamento e fornecimento de modelos

Como usamos um pacote Python para geração de features na nossa API de fornecimento de modelos de machine learning para facilitar a entrega de novas features

Gabriela Melo
Legiti
10 min readJun 9, 2021

--

Also available in English

Photo by Clément Hélardot on Unsplash

A Legiti sempre foi, desde o primeiro dia, uma empresa focada em machine learning. Diferentemente de empresas onde machine learning aparece como uma possível ferramenta para melhorar serviços e processos conforme a empresa se desenvolve, na Legiti machine learning sempre esteve presente no centro do nosso produto, e é a fonte do valor que entregamos para os nossos clientes.

Mas o que nós temos em comum com qualquer outra startup no seu início é que começamos com um time bem pequeno; e se você está tentando implementar uma variedade de soluções tecnológicas complexas envolvendo coleta, processamento, armazenamento e modelagem de dados, com um número limitado de pessoas, você vai rapidamente aprender que será necessário fazer as coisas de uma maneira que diminua a quantidade de trabalho e de recursos necessários para entregar melhorias de produto.

Nesse post, vou explicar como nós construímos um pacote Python para geração de features para ser utilizado na nossa API de avaliação de pedidos. Essa API é o ponto central do nosso produto, já que é ela que provê a interface para nossos clientes enviarem requisições de avaliação de fraude.

Nosso pacote de geração de features nos fornece uma solução simples e efetiva para ajudar no problema, um tanto comum em machine learning: ser necessário re-implementar o cálculo de features entre treinamento e fornecimento do modelo. Essa situação atrasa o fluxo inteiro de desenvolvimento de features e o deploy de novos modelos, e normalmente significa que diferentes pessoas ou times têm que estar envolvidos na entrega de uma nova feature. Além disso, diferenças entre desenvolvimento de features para treinamento e cálculo de features durante o fornecimento de modelos podem levar a diferentes valores de features sendo utilizados durante essas etapas, o que pode ter um impacto negativo na qualidade do modelo.

Vou começar esse post explicando a situação que tínhamos, o que vai fornecer uma base para entendermos o que tivemos que construir. Em seguida, vou falar sobre como era a nossa primeira solução, e a partir daí nós vamos chegar em como o nosso pacote de geração de features é e como ele funciona.

Geração de Features

Se você está construindo um modelo de machine learning que será utilizado para inferência online — isso é, ele não vai ser utilizado para a geração de predições em lote, mas sim para receber requisições uma a uma e fornecer predições individualmente para cada requisição — você vai precisar fornecer seu modelo de uma maneira na qual ele possa receber essas requisições e responder com as predições ou decisões. Nos cenários mais frequentes, os dados de entrada que você vai receber — vamos chamá-los de dados crus — não estão no formato que o modelo espera: seu modelo precisa de features que são geradas a partir daqueles dados crus. Isso significa que você vai ter algum código entre eles e o uso do modelo para transformar os dados crus em features para seu modelo.

Normalmente você vai ter cientistas de dados iterando nessas features, tentando achar novas ideias que possam ajudar o seu modelo. Esse ciclo de iteração acontece com o uso de datasets com os quais você poderá treinar e avaliar seu modelo. Entretanto, como mencionado no parágrafo anterior, esse modo de geração de features é diferente daquele quando você está atendendo requisições. Nesse caso, features são geradas para um lote inteiro de dados, enquanto que para fornecimento de modelos as features são geradas separadamente para cada requisição. Isso faz com que o ambiente e cenários nos quais ocorrem o treinamento do modelo e a inferência sejam bem diferentes entre si.

Além disso, eu mencionei que o fornecimento de modelos normalmente ocorre a partir de uma API, mas o código de experimentação talvez esteja dentro de um Jupyter Notebook, e o código de treinamento pode estar num script que é executado manualmente ou orquestrado na forma de um processo. Muitas vezes o código experimental e de treinamento de modelo podem não estar no mesmo repositório que o fornecimento dos modelos, e cientistas de dados podem não estar familiarizados com código de API e frameworks de fornecimento para produção.

Outro ponto é que, dependendo do tipo de modelo e solução que você está construindo, o desenvolvimento de código para essas features não vai ser algo a ser feito uma vez só; você vai querer iterar no modelo, usando novas features ou mesmo mudando como está calculando features já existentes. Dessa forma, isso pode ser uma tarefa constante e iterativa na qual você estará trabalhando.

Todos esses pontos contribuem para o surgimento de alguns problemas ao redor do código de geração de features. Você precisa iterativamente desenvolver features, tentando rapidamente validar o valor das mesmas e entregá-las em produção, mas essas duas etapas de escrever features para experimentos e colocá-las em produção podem ser dois pedaços bem diferentes do ciclo de vida do desenvolvimento de features.

Um problema adicional que surge quando você tem diferentes códigos em experimentação e produção é que você pode acabar tendo uma situação chamada em inglês de training-serving skew (poderia ser traduzido como enviesamento entre treinamento e fornecimento). Isso acontece quando você gera features ligeiramente diferentes devido a diferenças nos códigos que geram as features, e isso pode ter impacto nas decisões que estão sendo tomadas em produção. Isso não somente significa que você pode acabar tomando decisões piores do que aquelas que o seu modelo poderia tomar caso tivesse recebido valores corretos de features, mas também que a performance que você observa nos seus experimentos e treinamentos pode não ser um bom indicador para a performance que será observada em produção.

Essa diferença entre como features são desenvolvidas para treinamento e fornecimento de modelo adicionam um espaço entre o trabalho experimental de um cientista de dados e o código que provê predições em produção para os seus usuários, e, aqui na Legiti, nós queríamos — e precisávamos — aproximar essas duas etapas para que pudéssemos entregar adições ou modificações de features tranquilamente e necessitando a menor quantidade de pessoas e trabalho possível. Nós também precisávamos de uma confiança alta no código que estávamos colocando em produção, já que esse é o principal produto que nós entregamos, então nós não queríamos possibilitar que training-serving skew ocorresse. Na próxima seção eu vou falar um pouco mais sobre o primeiro sistema que nós tivemos, como esses problemas apareceram nele, e como os resolvemos.

Primeira Implementação de Fornecimento de Modelos

Nosso serviço de avaliação de pedidos era necessário como parte de nosso MVP (Minimum Viable Product), então esse código foi escrito bem no começo da Legiti. O código inicial havia sido desenvolvido após termos um modelo “v0” gerado. Esse modelo havia sido treinado num Jupyter Notebook, do qual um arquivo pickle saiu, e então esse modelo serializado precisou ir para nossa aplicação de avaliação de pedidos (já que optamos por fornecer nossos modelos embutindo-os no serviço de avaliação). Naquele momento, o código de cálculo de features foi implementado direto no serviço de avaliação de pedidos, por uma pessoa diferente daquela que havia implementado o código de treinamento do modelo, e sem ter nenhum código compartilhado entre como os dados haviam sido extraídos, preparados, e utilizados para o treinamento do modelo e como estavam sendo implementados nesse serviço de avaliação. Além disso, para calcular as features no nosso serviço de avaliação, nós utilizamos dados não somente do corpo da requisição, mas também do nosso banco de dados, por meio de queries com informações da requisição, para buscar dados de pedidos relacionados. Esse código de queries também foi re-implementado no serviço de avaliação.

Quando nosso primeiro cientista de dados se juntou ao time e começou a trabalhar em melhorias para o modelo, várias novas features foram criadas, e features que haviam sido parte do modelo anterior foram modificadas. Rapidamente o código do nosso serviço de avaliação de pedidos ficou completamente desatualizado. Se não tivéssemos percebido algumas das mudanças nas implementações das features ou queries, acabaríamos tendo introduzido training-serving skew — um dos problemas que listei acima.

Conforme pegamos a tarefa de deixar o serviço de avaliação novamente atualizado em relação aos nossos modelos, nós percebemos que, se toda vez que nós tivéssemos uma nova features desenvolvida pelo nosso cientista de dados nós tivéssemos que re-implementar o código para trazer aquela feature para o nosso serviço de avaliação, nós entraríamos num ciclo infinito de tentar manter aquele serviço atualizado em relação aos modelos sendo treinados — que é outro dos problemas que citei na seção anterior.

Um benefício que tínhamos é que todo o nosso código de geração de features para experimentação e treinamento já estava sendo compartilhado. Todos os nossos experimentos eram rodados a partir de scripts Python utilizando código em módulos Python (não estávamos utilizando Jupyter Notebooks para isso), então nós não teríamos dificuldades em ter que fazer nossos cientistas de dados gerarem código de geração de features e arquivos Python ao invés de notebooks (já que eles já estavam trabalhando dessa maneira) e nós só precisaríamos achar uma maneira de melhorar o compartilhamento de código entre essas etapas e a de inferência (nosso código de inferência estava em outro repositório, então eles não faziam parte da mesma base de código). Além disso, nós iríamos precisar dos mesmos formatos de DataFrames tanto para treinamento quanto para inferência — apesar de o tamanho desses DataFrames ser extremamente menor em inferência em requisições em produção — para que também não tivéssemos nenhum problema com o compartilhamento de código de geração de features entre esses dois ambientes.

Dessa forma, nós precisávamos de uma maneira de utilizar o mesmo código para inferência e treinamento, mas nós não precisaríamos introduzir nenhuma outra modificação ao nosso ciclo de desenvolvimento (como uma migração de Jupyter Notebooks para Python scripts) para poder fazê-lo. Isso nos motivou a criar um pacote de Python a ser fornecido pelo PyPI, que seria compartilhado entre experimentação/treinamento e inferência. Isso iria remover a necessidade de re-implementação de código, já que esse código seria implementado uma vez e compartilhado entre os dois ambientes. Na próxima seção, eu irei entrar em mais detalhes sobre como esse pacote e seu ciclo de desenvolvimento funcionam.

Pacote de Geração de Features

O pacote de Python que construímos é uma biblioteca que agora é utilizada durante a experimentação, treinamento e fornecimento de modelos na Legiti. A maior parte do trabalho para criarmos esse pacote consistiu em reconhecer quais pedaços do código eram necessários tanto para treinamento quanto para inferência e, então, em mover esses pedaços de código para uma pasta separada no nosso repositório, que seria publicada como nosso pacote. Esse pacote consiste principalmente de código que aceita DataFrames do Pandas contendo dados que buscamos de nosso banco de dados como entrada e então executa todos os passos necessários para termos as features que podem então ser passadas aos nossos modelos.

Nós também utilizamos esse pacote para compartilhar código das queries que precisamos executar. Obviamente, o tamanho desses dados é diferente entre cada um desses ambientes: durante treinamento precisamos de todos os dados; para inferência precisamos apenas de uma quantidade limitada de dados (somente aqueles que precisamos para gerar as features para cada requisição). Para termos essa flexibilidade, estamos utilizando templates do JinjaSQL para podermos diferenciar as queries que rodam em treinamento e fornecimento dos modelos. Dessa maneira, podemos ter a mesma estrutura da query, com apenas alguns filtros adicionais durante o fornecimento de modelos para limitar a quantidade de dados que é retornada. Compartilhando esse código temos a garantia de que os DataFrames gerados a partir das queries possuem os formatos corretos, prontos para serem passados para os próximos passos da nossa pipeline para geração de valores de features.

Durante experimentação e treinamento, o código do pacote está no mesmo repositório que o resto do nosso código utilizado para essas etapas, então é muito fácil iterar neste código durante o desenvolvimento de uma nova feature — nós podemos simplesmente importar o código do pacote na forma de módulos Python, já que ele está no nosso PYTHONPATH. Para o fornecimento de modelos, o código é encapsulado e publicado no nosso serviço PyPI interno, e a partir de lá podemos utilizá-lo no nosso serviço de avaliação; nós o instalamos como qualquer outro requisito da aplicação.

Nossos modelos serializados são armazenados no formato pickle e também são colocados no nosso pacote. Esse é um jeito simples de garantir a compatibilidade entre features e modelos, pois o código de geração de features que iremos usar em produção vai corresponder à versão atual do modelo, já que eles estão no mesmo pacote. Testes de integração também ajudam a garantir que isso é sempre verdade.

Esse pacote é publicado automaticamente em merges para a branch main do nosso repositório, por meio do CircleCI. Para o versionamento, temos um script bem simples que automaticamente incrementa o número da versão a cada vez que o pacote é publicado. Nosso principal objetivo com isso era ter algo que relaciona unicamente commits e versões de pacotes, sem que nossos desenvolvedores tivessem que se preocupar com o rastreamento manual disso, e ele tem funcionado bem para isso. Para nosso servidor PyPI interno, fazemos uso de uma solução simples, mas que atende às nossas necessidades. Estamos utilizando o PyPICloud, rodando em uma EC2 da AWS, e utilizando o S3 da AWS como backend.

Conclusões

Ter esse pacote de geração de features foi fundamental para a entrega contínua de novas features conforme nós implementávamos nossos primeiros modelos e tentávamos validar o product-market fit e os resultados que conseguiríamos trazer para nossos clientes. Ele nos permitiu, tendo um time extremamente pequeno em relação à quantidade de peças que compõem nossa solução, conseguir adicionar novas features rapidamente, mudar como features são calculadas sem ter problemas, e tudo isso enquanto preveníamos training-serving skew. Esse pacote se tornou uma das peças centrais da qualidade da solução que nós entregamos com nosso serviço de avaliação de pedidos.

Mas é claro que nossa jornada estava longe do seu fim quando implementamos e começamos a utilizar esse pacote. Nossa solução começou a sofrer problemas de latência na resposta das requisições devido ao tempo que era necessário para calcular todas as features que nós fornecemos aos nossos modelos. Fique ligado para a parte 2 dessa série de posts, na qual iremos contar sobre como utilizamos uma feature store construída internamente para nos ajudar a combater esses problemas de latência!

--

--