Criando pipelines para produtização de modelos de machine learning

Do input de dados ao deploy, como operacionalizar o ciclo de vida de um modelo e se tornar um MLE.

Alvaro Leandro Cavalcante Carneiro
Data Hackers

--

Photo by Quinten de Graaf on Unsplash

Dentre as habilidades de um Machine Learning Engineer (MLE), uma das mais importantes é, sem dúvidas, operacionalizar o ciclo de vida dos modelos de aprendizado de máquina que são criados pelo time de ciência de dados. A operacionalização nada mais é do que a criação de um processo estruturado, automatizado e repetível que abrange desde o pré-processamento dos dados até a disponibilização do modelo em produção. Dito isso, este artigo tem como principal objetivo demonstrar na prática o funcionamento e passo a passo para implementação de uma pipeline de MLOps.

Embora a pipeline que será criada seja relativamente simples, eu utilizei o projeto em questão para passar no teste técnico de uma vaga de machine learning engineering em uma empresa grande e bastante conhecida aqui no Brasil (+10k de colaboradores). Por conta disso, acredito que dominar a habilidade de criar um projeto parecido com esse pode ser fundamental para quem deseja entrar nessa área. Durante o artigo, também discutirei mais a respeito do desafio técnico e de como eu fiz para superá-lo.

Caso você fique com dúvidas ou não conheça alguns dos termos ou tecnologias utilizadas neste projeto, recomendo que você tente se aprofundar mais posteriormente para que você consiga ter sucesso em testes e entrevistas futuras.

O Desafio

De maneira resumida, o desafio do teste técnico foi apresentado da seguinte forma:

Um dos cientistas de dados do seu time elaborou um modelo para poder prever qual é a propensão de um usuário do serviço xpto assinar a versão PRO. Um dos principais objetivos do desenvolvimento deste modelo é poder mitigar a evasão de contas, além de identificar usuários não PRO que tem alta probabilidade de se tornarem PRO. […] como Engenheiro de Machine Learning do time, você é responsável por trabalhar na otimização e produtização do modelo. Você deve propor uma arquitetura para a solução que contemple as necessidades de negócio e também esteja em conformidade com as boas práticas de desenvolvimento de produtos de software. Além disso, devem ser contemplados também possíveis automações na configuração de ambiente e na gerência do ciclo de vida de modelos.

Conforme visto, o desafio em si é bastante abrangente. Porém, a principal expectativa é a de criar uma solução seguindo todas as boas práticas de engenharia de software, além de demonstrar entendimento sobre as etapas que envolvem a produtização de um modelo, como a criação de uma pipeline automatizada que possa versionar e garantir seu ciclo de vida. Em outras palavras, o teste visa justamente evidenciar as principais habilidades que um MLE deve possuir.

A solução

Indo direto ao ponto, a pipeline abaixo foi a solução desenvolvida para o teste em questão:

DAG da pipeline resultante do teste técnico.

Todo o código fonte foi desenvolvido em Python, por ser a linguagem mais utilizada na área de dados e, sem dúvidas, a mais importante para se dominar como um MLE. Além disso, fiz o uso de bibliotecas bem comuns como Pandas, Sklearn, NumPy e principalmente o TensorFlow Extended (TFX).

O TFX é uma solução da Google baseada no TensorFlow para criação de pipelines escaláveis para modelos de machine learning. Com essa biblioteca, é possível criar uma sequência de componentes que atuam nas principais etapas do ciclo de vida de um modelo. Além disso, cada um desses componentes também podem ser utilizados individualmente, caso necessário. Ademais, a orquestração dos estágios do ciclo de vida do modelo foi feita através de uma DAG do Airflow, sendo que cada uma das tarefas da DAG representa um dos componentes do TFX.

Por fim, apenas utilizar de tecnologias de ponta sem demonstrar muito propósito ou conhecimento do problema sendo tratado não é suficiente. Por conta disso, eu criei uma pequena documentação em markdown no README do repositório do GitHub, explicando todas as etapas desenvolvidas e decisões tomadas. Irei replicar boa parte desse readme aqui no artigo, mas você pode vê-lo na íntegra no Git.

Instalação e setup do projeto

Todo projeto de software precisa contar com instruções claras a respeito de como executá-lo, e a instalação de dependências é parte fundamental desse processo. Nesse teste técnico, em especial, a empresa deixa claro que a gestão do ambiente é um conhecimento fundamental para a vaga:

“[…] devem ser contemplados também possíveis automações na configuração de ambiente […]”

Por conta disso, documentar e fazer com que a solução criada seja facilmente reproduzível é de extrema importância. Dito isso, acredito que utilizar uma imagem Docker para permitir o build das dependências seria o melhor cenário possível, pela fato de que o Docker é amplamente adotado atualmente em praticamente todas as organizações, e por permitir com que as dependências de um projeto sejam empacotadas em um único contêiner.

Ainda assim, como não tive tempo suficiente para implementar uma imagem Docker, acabei optando pela solução mais simples, a criação de um ambiente virtual (virtualenv). Embora o ambiente virtual não seja tão robusto, ele é extremamente eficiente para isolar as dependências de um projeto Python, evitando conflitos com bibliotecas e versões previamente instaladas no sistema.

Após a criação do ambiente virtual, um simples arquivo de requirements já deve ser o bastante para documentar e permitir a instalação das versões adequadas de cada uma das bibliotecas. É muito importante que você verifique se todas as dependências foram corretamente listadas, garantindo que a pessoa terá sucesso ao executar o projeto. Uma dica útil é tentar executar o código em outro computador ou ambiente “limpo”, a fim de se certificar de que nenhuma dependência foi esquecida.

Caso você queira fazer o setup do projeto na sua máquina, basta seguir as instruções que estão no GitHub.

Execução do projeto

Assim como a instalação, ensinar o passo a passo e a ordem que o projeto deve ser executado é de extrema importância. Uma dica válida nessa etapa é descrever qual a saída ou resultado esperado de cada execução, fazendo com que o usuário saiba que o projeto está se comportando conforme deveria. Por exemplo:

Para executar o script refatorado do treinamento do modelo, basta utilizar o seguinte comando:

python3 src/refactored_code.py

Se tudo funcionou, você deve ver um print com a precisão, recall e KS do modelo.

Refatoração, padronização e boas práticas no código

Nesse teste técnico um código fonte inicial foi fornecido. Esse código, teoricamente desenvolvido por um dos cientistas de dados da equipe, contemplava o pré-processamento, treinamento e validação do modelo estatístico que foi criado.

Dito isso, uma das primeira tarefas que realizei foi refatorar, padronizar e adicionar algumas boas práticas de engenharia de software no script que foi fornecido. Como um profissional de engenharia de machine learning, é essencial que você tenha um bom nível de conhecimento em engenharia de software e programação. Um cientista de dados, por outro lado, precisa ser muito mais proficiente nos âmbitos da matemática, estatística e no conhecimento de modelos de IA. Por conta disso, é bastante comum lidarmos com códigos desenvolvidos pelo time de ciência de dados que não possuem todas as boas práticas de programação, podendo negligenciar alguns aspectos como a performance, segurança, reusabilidade, simplicidade e entre outros elementos de um clean code.

Até é possível encontrar bons cientistas de dados com background em ciência da computação que produzem um código refinado e pronto para produção. Porém, como MLE, a sua principal responsabilidade é garantir que os modelos sejam produtizados, e isso costuma envolver a refatoração do código inicial que foi desenvolvido.

Dito isso, é importante que você leia o código com o máximo de senso crítico, pensando nos pontos que podem ser melhorados. Existem algumas perguntas que você pode se fazer, por exemplo:

  • Existem partes no código que estão se repetindo? Se sim, não vale a pena transformar isso em uma função para evitar repetição?
  • O código está padronizado em um determinado idioma? Embora o inglês seja mais comum, alguns times também optam por padronizar o código para português.
  • Existem variáveis ou imports não utilizados?
  • Existem problemas de lógica ou funções que podem gerar exceptions muito facilmente?
  • O código está simples de ler e dar manutenção?

Enfim, são diversas questões, essa etapa vai muito do seu senso crítico e conhecimento a respeito das práticas de um código limpo. Algo que pode ajudar bastante é utilizar os padrões e boas práticas recomendadas pela linguagem de programação. O Python, por exemplo, adota o PEP8 como o guia de estilo para todo o código produzido.

O guia de estilo dita padrões gerais de nomenclatura, identação, funções, espaçamento e todos os outros fatores que influenciam em como o código é escrito. Além disso, existem diversas ferramentas que podem nos auxiliar no processo de avaliação da qualidade do código baseado no PEP8, como o Pylint. Para usar a biblioteca, basta executar o seguinte comando:

pylint src/desired_python_script.py

Feito isso, a biblioteca vai sugerir pontos de melhoria e dar um nota geral para o código. Para fins de baseline, a qualidade inicial do código fornecido foi de 6.74, conforme mostrado na imagem abaixo:

Nota inicial do código fonte que foi fornecido no teste.

Após as devidas correções e melhorias, o código atingiu uma nota de 8.9, evidenciando que praticamente todos os padrões da linguagem estão sendo seguidos.

Boas práticas de Machine Learning

A principal diferença entre um Software Engineer e um Machine Learning Engineer é, provavelmente, o conhecimento sobre inteligência artificial. Conhecer os pormenores do aprendizado de máquina é essencial para qualquer MLE.

Por conta disso, é bastante óbvio que um teste técnico exija que esse conhecimento seja exercitado de diversas formas. No desafio em questão, o código inicial que foi fornecido apresentava alguns pequenos antipadrões que poderiam passar despercebidos, mas que certamente te darão alguns pontos caso sejam identificados e corrigidos.

Além disso, é sempre interessante propor algumas técnicas alternativas e até mais eficazes para realizar o que foi fornecido no código base. Isso mostra que você tem conhecimento o suficiente na área para reconhecer e propor soluções mais otimizadas e com melhor encaixe para o problema sendo tratado. As seções seguintes explanam as melhorias e novas técnicas que foram sugeridas.

Correções

A primeira correção realizada foi adicionar o “random_state” no método de “train_test_split”, conforme mostrado abaixo:

X_train, X_test, y_train, y_test = train_test_split(
X, target, train_size=0.7, random_state=0) # <---

A ausência dessa seed geradora em operações que possuem uma certa aleatoriedade, como um split de dataset, por exemplo, pode fazer com que os resultados não sejam reproduzíveis, o que costuma ser ruim para a maioria dos modelos estatísticos e pipelines de dados.

Outro ponto importante foi utilizar o método “transform” ao invés do “fit_transform” na pipeline do Sklearn que foi fornecida. As previsões do modelo eram realizadas da seguinte forma:

X_test_ = pipe.named_steps['imputer'].fit_transform(X_test)
X_test__ = pipe.named_steps['center'].fit_transform(X_test_)
X_test___ = pipe.named_steps['pca'].fit_transform(X_test__)
y_test_pred_prob = pipe.named_steps['sgd'].predict_proba(X_test___)[:,1]

Acontece que utilizar o método “fit_transform” faz com que os dados se reajustem à base em questão, o que é um antipadrão por permitir o fit nos conjutos de teste e validação.

Por fim, o PCA foi removido da pipeline, pois a adoção do mesmo não gerou melhorias significativas nos resultados. Além disso, reduzir a dimensionalidade da informação implica na perda de explicabilidade, dificultando o entendimento do modelo. A pipeline do Sklearn estava dessa forma antes da alteração supracitada:

pipe = Pipeline(steps=[
('imputer', SimpleImputer(strategy='mean')), #Only numeric columns
('center', StandardScaler()),
('pca', PCA(n_components=15)), # <---
('sgd', SGDClassifier(loss='log', verbose=5, early_stopping=True, validation_fraction=0.3))
]
)

Melhorias

A primeira melhoria realizada foi reduzir a quantidade de variáveis e simplificar o modelo gerado através da adoção do VIF (Variance Inflation Factor). De maneira simplificada, o VIF mede o índice de colinearidade entre as variáveis do dataset, evidenciando as features que poderiam ser excluídas sem gerar grandes impactos na performance do modelo.

Desse modo, eu criei a função get_colinear_features que itera pelo dataset para trazer todas as features que têm um VIF maior do que 3 (valor significativo de colinearidade). Ao total, 7 variáveis foram excluídas do modelo sem prejudicar os resultados atingidos.

Ademais, embora as variáveis remanescentes não sejam colineares, nem todas precisam fazer parte do modelo, visto que algumas delas possuem pouca influência nos resultados. O gráfico abaixo mostra a importância das variáveis para o modelo de regressão logística em questão:

Importância das variáveis do modelo.

Deste modo, as variáveis menos relevantes foram excluídas progressivamente, reduzindo o conjunto de dados e mantendo o mesmo poder estatístico. Ao final, apenas 6 variáveis foram mantidas.

É evidente que, o real significado dessas variáveis e a conclusão de que se elas fazem realmente sentido ou não (considerando o contexto de negócio e analises estatísticas mais profundas) é algo que pode ser avaliado. Ainda assim, os resultados com o conjunto de dados reduzido foi o mesmo.

Métrica de avaliação

Além das melhoria s supracitadas, também explorei a métrica de avaliação, visto que isso também tem grande influência nos resultados. O modelo inicial chega a atingir uma precisão de 81% e uma revocação de 75%, considerando um limiar de 50% na classificação. Ainda que essas métricas possam orientar a capacidade do modelo em discriminar usuários pagantes e não pagantes, o principal objetivo do projeto, em termos de negócios, é auxiliar na geração de campanhas que consigam incentivar o aumento de inscrições de usuários pagantes e reduzir o “churn” dos que já são.

Dessa maneira, mais do que apenas prever o valor 0 ou 1, precisamos entender a distribuição probabilística gerada nas previsões para uma tomada de decisão assertiva, visto que os clientes serão segmentados pelos que são PRO e que tem uma baixa probabilidade de continuar como PRO, e clientes que não são PRO e que tem uma alta probabilidade de se tornarem PRO.

Baseado nisso, a métrica de KS (Kolmogorov-Smirnov), pode ser a que melhor se encaixa nesse contexto, visto que a mesma tem por objetivo medir a diferença entre duas distribuições probabilísticas. Essa métrica varia de 0 a 1, sendo que 0 representa uma distribuição idêntica de probabilidades, e 1 representa distribuições diferentes.

Assim sendo, maximizar a métrica de KS faz com que o modelo consiga separar o máximo possível a classe 1 da classe 0, aumentando o intervalo de confiança entre os clientes PRO e não PRO. O método calculate_ks_score foi criado para o cálculo da métrica, exibindo um KS inicial de 0,60. Na prática, isso significa que as duas classes já possuem um bom nível de separação entre as suas probabilidades. Para mostrar esse efeito de maneira mais visual, é possível verificar que a média dos valores de probabilidade dos assinantes PRO é de 0,73, enquanto os não PRO é de 0,24. Ao plotar as duas distribuições, obtemos o seguinte gráfico:

Distribuição das probabilidades das classes.

Onde azul é a distribuição probabilística dos PRO e laranja os não PRO.

Criação de pipelines com Airflow e TFX

Depois de todas as melhorias e correções supracitadas, enfim estamos prontos para produtizar nosso modelo em uma pipeline, contemplando as etapas de input de dados, pré-processamento, treinamento e deploy, automatizando o processo, garantindo reprodutibilidade e possibilitando utilizar ferramentas para melhorar o ciclo de vida do mesmo. O código fonte com a definição da pipeline pode ser visto no arquivo tfx_pipeline.py. Cada um dos componenetes que formam a pipeline serão explicados individualmente nas seções posteriores.

Input de dados

A primeira etapa da maioria das pipelines de MLOPs costuma ser o fluxo de ingestão de dados. Para isso, utilizamos um componente chamado gerador de Examples (instâncias de dados), responsável por trazer os dados provenientes de arquivos ou fontes externas para dentro da pipeline. No mundo real, é bem provável que essa etapa tenha uma complexidade muito maior do que a desse projeto, visto que costuma ser necessário a conexão com bancos de dados e/ou provedores Cloud.

Nesse caso, por outro lado, como nosso conjunto de dados está todo contido em um simples CSV, utilizei o CsvExampleGen para ler o arquivo e realizar o split, conversão e particionamento de dados. Por padrão, os dados são divididos em múltiplos spans para melhorar a paralelização das operações. Cada span é composto por duas partes de dados (~66%) utilizados para treinamento e uma parte para teste (~33%). Além disso, os dados são convertidos para o formato binário “TFRecord”, pois é o formato padrão utilizado pelo TensorFlow para manipulação de dados. O código abaixo exemplifica a utilização do componente:

data_root_runtime = data_types.RuntimeParameter(
'data_root', ptype=str, default=data_root)

example_gen = tfx.components.CsvExampleGen(input_base=data_root_runtime)

O caminho do CSV de entrada é um dos parâmetros passados para a pipeline do Airflow. No exemplo acima, utilizamos as configurações pré-definidas do componente, porém, é possível personalizar a proporção de split de dados e a forma como os arquivos resultantes serão salvos, caso necessário.

Gerador de estatísticas

Ter conhecimento a respeito das propriedades dos dados é algo essencial para garantir a qualidade, visto que desvios relevantes em suas propriedades podem afetar significativamente o desempenho de modelos estatísticos.

Nesse sentido, O gerador de estatísticas (StatisticsGen) é um componente que tem por objetivo levantar as principais características dos dados através de suas estatísticas. Para as features numéricas, por exemplo, o componente calcula a média, desvio padrão, valores mínimos e máximos e etc. Para as categóricas, a cardinalidade e a frequência do conjunto de strings é calculada.

Dessa forma, sempre que a pipeline for executada para realizar o retreino do modelo, as estatísticas são calculadas nos novos dados e armazenadas para fins de comparação. Com isso, é possível identificar facilmente qualquer tipo de desvio, outlier ou valor incorreto em cada uma das variáveis. A imagem abaixo mostra algumas das estatísticas levantadas no conjunto de dados em questão:

Resultados do gerador de estatísticas.

O código abaixo exemplifica o processo de geração de estatísticas:

statistics_gen = tfx.components.StatisticsGen(
examples=example_gen.outputs['examples'])

Como é possível observar, os dados de entrada são provenientes do example_gen que foi criado anteriormente, gerando uma relação de dependência entre as etapas.

Gerador de Schema

Para que os componentes do TensorFlow funcionem corretamente na pipeline, é necessário que as propriedades das variáveis sejam conhecidas, como o tipo de dados, shape, e etc. Por conta disso, o SchemaGen é utilizado para definir o Schema das variáveis e permitir que ele seja utilizado nas demais etapas de manipulação dos dados. A imagem abaixo ilustra o schema de algumas das variáveis no dataset em questão:

Schema gerado para o conjunto de dados.

O código abaixo exemplifica o processo de geração do schema:

schema_gen = tfx.components.SchemaGen(
statistics=statistics_gen.outputs['statistics'],
infer_feature_shape=True)

Nesse caso, estou utilizando a inferência automática de schema da ferramenta, fazendo com que o Type e demais propriedades sejam estimadas a partir das estatísticas que foram calculadas anteriormente. Porém, para evitar quaisquer tipos de surpresas futuras geradas por dados ruidosos, também é possível criar e importar um schema utilizando um arquivo no formato protobuf. O exemplo abaixo ilustra um schema sendo criado manualmente:

...
feature {
name: "age"
value_count {
min: 1
max: 1
}
type: FLOAT
presence {
min_fraction: 1
min_count: 1
}
}
feature {
name: "capital-gain"
value_count {
min: 1
max: 1
}
type: FLOAT
presence {
min_fraction: 1
min_count: 1
}
}
...

Embora o protocol buffer não seja um formato tão comum, ele é bastante parecido com um JSON, sendo simples de se criar e manipular.

Validador de dados

Na minha opinião, o componente de validação de dados (ExampleValidator) é um dos mais úteis da pipeline do TFX. Conforme o nome sugere, esse componente tem como principal objetivo identificar anomalias nos dados de treinamento e validação de forma automática. Para que isso seja possível, ele recebe como entrada as estatísticas calculadas previamente e o schema de dados que foi inferido. Com isso, a ferramenta realiza diversas verificações a fim de encontar por desvios relevantes ou expectativas frustradas no conjunto de dados.

Dessa forma, não é necessário visualizar manualmente as estatísticas ou o schema de dados que foi gerado pelas etapas anteriores, visto que o validador de dados irá realizar essa verificação automaticamente para cada novo treinamento, garantindo a qualidade dos dados. O código abaixo exemplifica a etapa de validação:

example_validator = tfx.components.ExampleValidator(
statistics=statistics_gen.outputs['statistics'],
schema=schema_gen.outputs['schema'])

Caso algum desvio ou anomalia seja encontrada, essa etapa da pipeline irá apresentar uma falha no Airflow. Em uma situação de normalidade, esse componente gera os seguintes logs:

Resultados da avaliação dos dados.

Feature engineering

Nem sempre podemos chamar os dados presentes em um banco de dados de features. Muita das vezes, fazemos o input de dados brutos na pipeline, sendo necessário uma etapa de pré-processamento para a aplicação de transformações e a criação de novas variáveis. É exatamente esse o papel do componente Transform, sendo responsável por aplicar toda a etapa de pré-processamento nos dados que foram inputado na pipeline de maneira a deixá-los prontos para serem consumidos pelo modelo estatístico.

Imagine, por exemplo, que você deseja realizar uma normalização Z Score em todos os seus valores numéricos, fazendo com que eles possuam uma média 0 e um desvio padrão de 1. Para isso, o primeiro passo é criar um script python separado da pipeline, vamos chamá-lo de tfx_utils.py. Depois, basta adicionar o código para realizar tal procedimento:

import tensorflow_transform as tft

NUMERICAL_VARS = ['var1', 'var2', 'var3']
LABEL_KEY = 'pro_target'

def preprocessing_fn(inputs):
"""tf.transform's callback function for preprocessing inputs.

Args:
inputs: map from feature keys to raw not-yet-transformed features.

Returns:
Map from string feature key to transformed feature operations.
"""

outputs = {}

for key in NUMERICAL_VARS:
outputs[key] = tft.scale_to_z_score(inputs[key])

outputs[LABEL_KEY] = inputs[LABEL_KEY]

return outputs

O nome da função preprocessing_fn não foi escolhido por acaso, ela é um callback utilizado pelo TFX no componente Transform. Conforme visto, nós iteramos por cada uma das variáveis numéricas, criando um novo dicionário onde a chave é o nome da variável e o valor é a variável transformada. O mesmo processo deve ser feito para todas as outras variáveis do modelo, aplicando as transformações adequadas para cada uma delas.

Ademais, é sempre interessante tentar utilizar as operações de transformação fornecidas pelo próprio TensorFlow, visto que as mesmas costumam ser otimizadas para manipulação de tensores (matrizes multidimensionais). Nesse caso, eu poderia facilmente ter calculado o Z Score manualmente, porém, optei por utilizar o tft.scale_to_z_score para manter as boas práticas de otimização.

Por fim, a etapa de transformação é definida na pipeline usando o seguinte código:

transform = tfx.components.Transform(
examples=example_gen.outputs['examples'],
schema=schema_gen.outputs['schema'],
materialize=False,
module_file='src/tfx_utils.py')

Além de fornecer o caminho do script Python que criamos anteriormente, também é necessário fornecer os dados de entrada através do example_gen e o seu respectivo schema.

Treinamento

Enfim, chegamos no treinamento do modelo. Embora esse estágio seja bastante autoexplicativo, existem algumas etapas que precisam ser realizas ao utilizar o componente Trainer do TFX, são elas:

  • Criar uma função para definir o input de dados;
  • Construir a arquitetura do modelo;
  • Criar uma assinatura para o modelo;
  • Salvar o modelo treinado.

Você pode utilizar o mesmo script Python (tfx_utils.py) para criar o código responsável pelo treinamento, conforme definido abaixo:


def _input_fn():
...


def _build_keras_model():
...


def _get_serve_tf_examples_fn():
...


# TFX Trainer will call this function.
def run_fn(fn_args: tfx.components.FnArgs):
tf_transform_output = tft.TFTransformOutput(fn_args.transform_output)

train_dataset = _input_fn(
fn_args.train_files,
fn_args.data_accessor,
tf_transform_output,
batch_size=_TRAIN_BATCH_SIZE)
eval_dataset = _input_fn(
fn_args.eval_files,
fn_args.data_accessor,
tf_transform_output,
batch_size=_EVAL_BATCH_SIZE)

model = _build_keras_model()
model.fit(
train_dataset,
steps_per_epoch=fn_args.train_steps,
validation_data=eval_dataset,
validation_steps=fn_args.eval_steps)

signatures = {
'serving_default': _get_serve_tf_examples_fn(model, tf_transform_output),
}

model.save(fn_args.serving_model_dir,
save_format='tf', signatures=signatures)

Explicar os pormenores técnicos de cada uma das funções exigiria entrar em muitos detalhes, o que foge do escopo desse artigo. Porém, irei explicá-las brevemente. Para maiores detalhes, sugiro que você verifique a documentação do TFX.

A função de input (_input_fn) tem como objetivo definir a forma como os dados serão consumidos para o treinamento do modelo. Isso geralmente envolve a criação de uma fábrica de dados para acessar os dados brutos, bem como o uso do grafo de pré-processamento criado pelo componente do Transform para garantir que os dados estão prontos para serem utilizados no treinamento.

O _build_keras_model é exatamente o que o nome sugere. Esse método tem como objetivo a criação da arquitetura do modelo utilizando a biblioteca Keras.

Por fim, o _get_serve_tf_examples_fn retorna uma outra função que, por sua vez, define o comportamento do processo de inferência do modelo. As assinaturas são um importante conceito no deploy de modelos utilizando o TensorFlow, pois definem o grafo que será executado uma vez que o modelo é servido em ambiente produtivo. Nesse sentido, é possível personalizar a assinatura para acoplar as funções de pré-processamento junto ao grafo, fazendo com que nenhum código Python adicional seja necessário para o funcionamento do modelo.

Dito isso, fazemos a modificação da nossa assinatura padrão (serving_default) para que, ao salvar o modelo, o arquivo saved_model.pb resultante contenha tudo o que precisamos para disponibilizá-lo. Com isso, é possível inputar dados brutos no modelo, pois o pré-processamento fará parte do próprio grafo de inferência.

Finalmente, utilizamos o código abaixo para adicionar o componente de treinamento à nossa pipeline:

trainer = tfx.components.Trainer(
module_file='src/tfx_utils.py',
examples=example_gen.outputs['examples'],
transform_graph=transform.outputs['transform_graph'],
train_args=tfx.proto.TrainArgs(num_steps=2000),
eval_args=tfx.proto.EvalArgs(num_steps=400))

Avaliação

Uma vez que o modelo foi treinado, a pipeline também deve ter um mecanismo para definir se esse modelo é bom o bastante para ser disponibilizado em produção. Para isso, utilizamos um componente chamado Evaluator.

O Evaluator possui uma série de configurações e hiperparâmetros que têm por principal objetivo fazer a avaliação do modelo seguindo diferentes aspectos. Além de analisar métricas comuns como precisão, acurácia e revocação, este componente adota diversas outras medidas estatísticas, além de fazer estratificações nos dados para avaliar as previsões e resultados de maneira aprofundada.

Além disso, o Evaluator conta com um “subcomponente” chamado de Resolver. Quando habilitado, o Resolver é responsável por comparar novos modelos com uma baseline (geralmente o modelo atual em produção). Para realizar esse comparativo, os dois modelos são avaliados no conjunto de dados de validação e o novo modelo é “abençoado (blessed)” caso atinja os critérios estabelecidos nos hiperparâmetros do Evaluator. Dessa forma, conseguimos assegurar uma pipeline completamente automatizada para retreinamento, sem correr o risco de subir em produção novos modelos com a performance degradada.

Para adicionar a validação em nossa pipeline, basta utilizar o seguinte código:

model_resolver = tfx.dsl.Resolver(
strategy_class=tfx.dsl.experimental.LatestBlessedModelStrategy,
model=tfx.dsl.Channel(type=tfx.types.standard_artifacts.Model),
model_blessing=tfx.dsl.Channel(
type=tfx.types.standard_artifacts.ModelBlessing)).with_id(
'latest_blessed_model_resolver')

eval_config = _get_eval_config()
evaluator = tfx.components.Evaluator(
examples=example_gen.outputs['examples'],
model=trainer.outputs['model'],
baseline_model=model_resolver.outputs['model'],
eval_config=eval_config)

Deploy do modelo

A última etapa da pipeline de MLOPs é a disponibilização do modelo, sendo realizada através do componente Pusher. De forma parecida com o que discutimos no input de dados, o deploy não costuma ser uma etapa simples quando lidamos com pipelines reais, visto que pode ser necessário a conexão com provedores Cloud para disponibilização correta do modelo. Ainda assim, nesse projeto, iremos apenas disponibilizar o modelo final no sistema de arquivos locais, sendo definido pelo seguinte código:

pusher = tfx.components.Pusher(
model=trainer.outputs['model'],
model_blessing=evaluator.outputs['blessing'],
push_destination=tfx.proto.PushDestination(
filesystem=tfx.proto.PushDestination.Filesystem(
base_directory=serving_model_dir)))

Por padrão, esse componente gera uma pasta com o nome do modelo e as versões do mesmo, conforme o exemplo abaixo:

Deploy do modelo no sistema de arquivos locais.

Cada uma das versões possui um arquivo do tipo saved_model.pb compatível com o TensorFlow e o Keras.

Embora a etapa de servir o modelo, ou seja, disponibilizar uma interface (geralmente REST) para permitir que usuários e serviços utilizem as previsões do mesmo, seja essencial, ela não faz necessariamente parte de uma pipeline de MLOPs. Dito isso, o componente de Serving do TFX será discutido em artigos futuros.

Conclusões

Agora que nossa pipeline está pronta, podemos revisitar o esquema que foi mostrado anteriormente, porém, com muito mais entendimento a respeito de cada um dos componentes:

DAG da pipeline resultante do teste técnico.

O que construímos atende quase todos os requisitos de um processo de MLOPs, sendo eles:

  • Tornar o treinamento e validação do modelo reprodutível;
  • Garantir a qualidade dos dados e evitar o fenômeno de data drift;
  • Avaliar de maneira profunda os resultados do modelo, evitando a degradação e atenuando o fenômeno de cencept drift;
  • Realizar o versionamento e deploy do modelo.

Ter o entendimento dessas etapas e a capacidade de construir um fluxo como este é boa parte do que você precisa para ser um bom MLE. Além disso, vale lembrar que existem diversas outras ferramentas e frameworks que podem te auxiliar nesse processo e serem utilizados para construir pipelines robustas.

Em futuros artigos, pretendo discutir mais a respeito de como disponibilizar o modelo em produção por meio de microsserviços REST.

--

--

Alvaro Leandro Cavalcante Carneiro
Data Hackers

MSc. Computer Science | Data Engineer. I write about artificial intelligence, deep learning and programming.