Testando modelos de Machine Learning

Bruno Accioli
Fretebras Tech
Published in
15 min readDec 29, 2022
A broken robot
Foto tirada por Rock’n Roll Monkey no Unsplash

Você gastou semanas trabalhando em um modelo e os resultados são animadores. As métricas alcançadas deixam todo mundo com altas expectativas. Então você age rápido para realizar o deploy do seu modelo para que todo mundo comece a utilizá-lo. Semanas depois… As métricas em produção estão longe do que eram nos experimentos de modelagem. Você investiga e descobre que era só um bug no pré-processamento, corrige e sobe a correção para produção novamente. Problema resolvido, certo? Não exatamente…

Bem, agora a equipe de nógico que estava super animada para utilizar as previsões do modelo já não está mais tão animada assim, e começa a se perguntar se talvez não é melhor voltar a usar aquele método simples antigo que era utilizado antes do modelo ser desenvolvido. Você tenta convecê-los de que está tudo bem agora, mas será que está mesmo? Como aumentamos a segurança ao afirmar que o modelo tá fazendo o esperado? E mais, o que eu faço para evitar que outros problemas em outras partes do código sejam lavadas ao ambiente de produção?

Uma das práticas que garantem a confiabilidade de um código é a adoção de testes. Eles ajudam a avaliar de forma automatizada se um código está fazendo o que é esperado que ele faça e já são amplamente adotados no escopo de softwares tradicionais. É comum que haja uma esteira de deploy em que testes unitários são executados e caso algum falhe o deploy é interrompido, ficando claro se uma modificação de código quebrou alguma parte do sistema como um todo.

É certo que a adoção de testes não garante que erros nunca acontecerão, mas o ganho é claro: a maioria dos erros são identificados antes de subir o código em produção.

Não é muito diferente implementar testes para softwares comuns e para sistemas com modelos de machine learning. Mas precisamos ter claro o que queremos testar e como. Para a criação dos testes utilizando python, uma das bibliotecas mais populares é o pytest. Devido sua ampla documentação, base de usuários e facilidade de uso escolhemos ele como framework para a implementação dos testes.

Nesse guia, vamos mostrar como utilizamos testes para aumentar a confiabilidade dos modelos de machine learning desenvolvidos aqui na Frete. O código completo de exemplo que vai ser utilizado nesse guia está disponível no github através desse link.

Vamos assumir aqui que o modelo de machine learning será disponibilizado através de uma API flask, já que é provavelmente a forma mais comum de disponibilziar modelos de machine learning, seja em um serviço de nuvem ou em um servidor local. Mas as mesmas ideias podem ser aplicadas utilizado bibliotecas diferentes para a construção das APIs ou diferentes biliotecas para a construção dos testes.

Problema de exemplo — Prevendo a renda

Para ilustrar como testar uma API com um modelo de machine learning, vamos utilizar o dataset Adult Census Income, que é um conjunto de dados público e muito popular para o estudo de problemas de classificação. A tarefa consiste em prever se um indivíduo possui uma renda anual acima de $50.000 ou não dadas algumas carcterísticas como país de origem, nível de educação, idade, etc.

O modelo treinado com o conjunto de dados se trata de uma Random Forest, que utiliza as seguintes variáveis:

  • ‘native_country’: País de origem
  • ‘marital_status’: status de relacionamento
  • ‘sex’: sexo
  • ‘capital_gain’: Ganho de capital proveniente de outras fontes de renda além do salário (investimentos)
  • ‘capital_loss’: Perda de capital proveniente investimentos
  • ‘hours_per_week’: horas trabalhadas na semana
  • ‘age’: Idade
  • ‘education_num’: Quantidade de anos de estudo

A partir dessas variáveis é realizado o feature engineering antes para que as variáveis categóricas estejam prontas para serem utilizadas no treinamento do modelo. As seguintes variáveis foram criadas:

  • ‘is_male’: Variável binária que indica se o indivíduo é um homem ou mulher.
  • ‘is_married’: Variável binária que indica se o indivíduo está casado ou não.
  • ‘native_usa’: Variável binária que indica se o país de origem é os Estados Unidos.
  • ‘native_latin_america’: Variável binária que indica se o país de origem é um país latino-americano.
  • ‘native_europe’: Variável binária que indica se o país de origem é um país europeu.
  • ‘native_asia’: Variável binária que indica se o país de origem é um país asiático.

Após o pré-processamento, as variáveis categóricas originais (‘native_country’, ‘marital_status’ e ‘sex’) puderam ser eliminadas. Uma boa prática para realizar o deploy de um modelo é buscar manter o código organizado para facilitar o entendimento, manutenção e criação de testes. Por isso, as funções de pré-processamento que serão aplicadas quando for solicitado uma previsão ao modelo foram organizadas em funções dentro de um módulo python nomeado ‘preprocessing.py’ com o seguinte código:

import pandas as pd
import numpy as np

def preprocess_input_data(example: dict) -> pd.DataFrame:
dataset = pd.DataFrame([example])
dataset = create_native_location_features(dataset)
dataset = encode_binary_features(dataset)

input_colums = ['native_usa', 'native_latin_america', 'native_europe', 'native_asia',
'is_male', 'is_married', 'hours_per_week', 'age', 'education_num',
'capital_gain', 'capital_loss']
dataset = dataset[input_colums]
return dataset

def create_native_location_features(dataset: pd.DataFrame) -> pd.DataFrame:
latin_american_countries = ['Cuba', 'Jamaica', 'Mexico',
'Puerto-Rico', 'Honduras', 'Haiti',
'El-Salvador', 'Guatemala', 'Nicaragua',
'Columbia', 'Ecuador', 'Peru', 'Dominican-Republic']

european_countries = ['England', 'Canada',
'Germany', 'Italy', 'Poland', 'Portugal',
'France', 'Yugoslavia', 'Scotland',
'Greece', 'Ireland', 'Hungary', 'Holand-Netherlands']

asian_countries = ['India', 'Iran', 'Philippines',
'Cambodia', 'Thailand', 'Laos',
'Taiwan', 'China', 'Japan', 'Yugoslavia', 'Vietnam', 'Hong']

dataset = (
dataset.assign(native_usa=lambda _df: (_df['native_country']=='United-States').astype(int),
native_latin_america=lambda _df: (_df['native_country'].isin(latin_american_countries)).astype(int),
native_europe=lambda _df: (_df['native_country'].isin(european_countries)).astype(int),
native_asia=lambda _df: (_df['native_country'].isin(asian_countries)).astype(int))
)
return dataset


def encode_binary_features(dataset: pd.DataFrame) -> pd.DataFrame:
dataset = (
dataset.assign(is_male=lambda _df: (_df['sex'] == "Male").astype(int),
is_married=lambda _df: _df['marital_status'].str.startswith('Married').astype(int))
)
return dataset

Uma pequena API flask com apenas um endpoint (‘/invocations’) foi construída para que seja possível solicitar a realização de previsões ao modelo:

import joblib
import json
import logging as log
import flask
from preprocessing import preprocess_input_data


app = flask.Flask(__name__)


@app.route('/invocations', methods = ['POST'])
def invocations():
"""
Realiza a inferencia de um unico exemplo.
"""

if flask.request.content_type != 'application/json':
msg = f'Content type "{flask.request.content_type}" não é suportado. Content type deve ser "application/json".'
log.error(msg)
json_output = json.dumps(msg)
return flask.Response(response=json_output, status=415, mimetype='application/json')

try:
input_json = flask.request.get_json(force=True)
input_data = preprocess_input_data(input_json)

model = joblib.load('model/model.joblib')
prediction = model.predict(input_data)

json_output = json.dumps({"income > 50k": int(prediction[0])})
status_code = 200
except Exception as exp:
msg = "Erro no ao realizar a previsão"
log.exception(msg)
json_output = json.dumps(msg)
status_code = 500

return flask.Response(response=json_output, status=status_code, mimetype='application/json')

if __name__=='__main__':
app.run()

Testanto o modelo

Existem algumas principais fontes de erro em sistemas de machine learning que fazem com que as previsões do modelo em produção sejam diferentes das previsões do modelo desenvolvido em ambiente de desenvolvimento. São elas:

  • Pré-processamento: Frequentemente o código no jupyter notebook onde os experimentos de modelagem foram desenvolvidos precisam ser organizados em funções e empacotados em módulos python para realizar o deploy do modelo. Nesse processo, pipelines de transformação dos dados podem sofrer alterações que podem gerar resultados diferentes em produção do que as pipelines desenvolvidas nos experimentos.
  • Diferença entre ambientes: Para que o modelo produza os mesmos resultados em produção, é necessário que o ambiente de produção possua a mesma versão do python e as mesmas versões das bibliotecas utilizadas no desenvolvimento. Ambientes um pouco diferentes podem gerar resultados diferentes ainda que estejam rodando o mesmo código.

Pensando nesses pontos de falha, organizamos os nossos testes aqui da seguinte forma:

  • Testes de funções: consistem em testes unitários em que as funções de pré-processamento são avalidas uma a uma. Para entradas conhecidas de uma determinada função, as saídas também devem ser sempre as mesmas.
  • Testes de predição: Também são testes unitários. Porém nesses testes simulamos a API flask em operação e requisições no endpoint de inferência da API com entradas conhecidas e avaliamos se as saídas esperadas são obtidas.
  • Testes de endpoint: São testes de integração. Isso significa que eles rodam logo após a API de inferência estar no ar e servem para garantir que no ambiente de produção também são obtidas as mesmas respostas para entradas conhecidas.

O projeto possui a seguinte estrutura de pastas:

Estrutura de pastas do projeto
Estrutura de pastas do projeto

conftest.py

O conftest.py é um arquivo que serve principalmente para a definir as fixtures no pytest. Fixtures são estruturas que servem para guardar dados estáticos que podem ser acessados nas funções de teste.

É bem simples definir uma fixture no pytest. Nesse guia, as fixtures serão basicamente usadas para definir os dados de entradas e saídas esperadas dos nossos cenários de teste. Abaixo estão todas as fixtures definidas:

import sys
import pathlib
import pytest
import pandas as pd

ROOT_PATH = pathlib.Path(__file__).parents[1]
code_directory = ROOT_PATH/'deployment'

if str(code_directory) not in sys.path:
sys.path.append(str(code_directory))

from api import app

# Utilizado somente nos testes de endpoint
def pytest_addoption(parser):
parser.addoption("--endpoint", type=str, default=None, help="Endereco do endpoint para realizar os testes.")

# Utilizado somente nos testes de endpoint
@pytest.fixture
def endpoint_address(request):
endpoint_address = request.config.getoption("--endpoint")
if endpoint_address is not None and not endpoint_address.startswith('http://'):
endpoint_address = f"http://{endpoint_address}"
return endpoint_address

@pytest.fixture
def input_json_1():
input_json = {
"native_country": "Peru",
"marital_status": "Never-married",
"sex": "Male",
"capital_gain": 0,
"capital_loss": 0,
"hours_per_week": 43,
"age": 25,
"education_num": 13
}
return input_json

@pytest.fixture
def input_json_preprocessed_1():
input_json_preprocessed = pd.DataFrame(
[{
"native_usa": 0,
"native_latin_america": 1,
"native_europe": 0,
"native_asia": 0,
"is_male": 1,
"is_married": 0,
"capital_gain": 0,
"capital_loss": 0,
"hours_per_week": 43,
"age": 25,
"education_num": 13
}]
)
return input_json_preprocessed

@pytest.fixture
def output_json_1():
output_json = {
"income > 50k": 0
}
return output_json

@pytest.fixture
def input_country_cuba():
return pd.DataFrame([{"native_country": "Cuba"}])

@pytest.fixture
def input_country_england():
return pd.DataFrame([{"native_country": "England"}])

@pytest.fixture
def input_country_japan():
return pd.DataFrame([{"native_country": "Japan"}])

@pytest.fixture
def input_country_usa():
return pd.DataFrame([{"native_country": "United-States"}])

@pytest.fixture
def input_country_cuba_preprocessed():
return pd.DataFrame([{"native_usa": 0, "native_latin_america": 1, "native_europe": 0, "native_asia":0}])

@pytest.fixture
def input_country_england_preprocessed():
return pd.DataFrame([{"native_usa": 0, "native_latin_america": 0, "native_europe": 1, "native_asia":0}])

@pytest.fixture
def input_country_japan_preprocessed():
return pd.DataFrame([{"native_usa": 0, "native_latin_america": 0, "native_europe": 0, "native_asia":1}])

@pytest.fixture
def input_country_usa_preprocessed():
return pd.DataFrame([{"native_usa": 1, "native_latin_america": 0, "native_europe": 0, "native_asia":0}])

@pytest.fixture
def input_male():
return pd.DataFrame([{"sex": "Male", "marital_status":""}])

@pytest.fixture
def input_female():
return pd.DataFrame([{"sex": "Female", "marital_status":""}])

@pytest.fixture
def input_male_preprocessed():
return pd.DataFrame([{"is_male": 1}])

@pytest.fixture
def input_female_preprocessed():
return pd.DataFrame([{"is_male": 0}])

@pytest.fixture
def input_married():
return pd.DataFrame([{"sex": "", "marital_status":"Married-civ-spouse"}])

@pytest.fixture
def input_not_married():
return pd.DataFrame([{"sex": "", "marital_status":"Separated"}])

@pytest.fixture
def input_married_preprocessed():
return pd.DataFrame([{"is_married": 1}])

@pytest.fixture
def input_not_married_preprocessed():
return pd.DataFrame([{"is_married": 0}])

# Utilizado somente nos testes de endpoint
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client

Testando funções de pré-processamento

Esses testes vão permitir verificar se as etapas de pré-processamento geram os resultados esperados.

Uma vez que temos os dados de entradas e saídas esperadas dos cenários de teste definidos no ‘conftest.py’, podemos começar a implementar casos de teste para as funções de pré-processamento definidar em ‘preprocessing.py’.

No pytest, testes podem ser implementados por meio de funções. Para que essas funções sejam automaticamente identificadas como testes elas devem estar em um arquivo cujo nome comece com a palavra ‘test_’ ou termine com ‘_test.py’. Além disso, as funções que implementam os testes devem começar com a palavra ‘test’. Dessa forma, o pytest identifica todos os testes implementados sem precisar que o usuário indique de forma explícita os arquivos e funções de teste.

Abaixo vemos como os testes foram implementados para as funções de pré-processamento:

import sys
import pathlib
from pandas.testing import assert_frame_equal
from pytest import mark
from pytest_lazyfixture import lazy_fixture

root_path = pathlib.Path(__file__).parents[1]
preprocessing_directory = root_path/'deployment'

if str(preprocessing_directory) not in sys.path:
sys.path.append(str(preprocessing_directory))

from preprocessing import preprocess_input_data
from preprocessing import create_native_location_features
from preprocessing import encode_binary_features


@mark.parametrize(
"input_json, input_json_preprocessed_expected",
[(lazy_fixture('input_json_1'), lazy_fixture('input_json_preprocessed_1'))]
)
def test_preprocess_input_data(input_json, input_json_preprocessed_expected):
input_json_preprocessed = preprocess_input_data(input_json)
assert_frame_equal(input_json_preprocessed, input_json_preprocessed_expected,
check_dtype=False, check_like=True)


@mark.parametrize(
"input_country, input_country_preprocessed_expected",
[(lazy_fixture('input_country_cuba'), lazy_fixture('input_country_cuba_preprocessed')),
(lazy_fixture('input_country_england'), lazy_fixture('input_country_england_preprocessed')),
(lazy_fixture('input_country_japan'), lazy_fixture('input_country_japan_preprocessed')),
(lazy_fixture('input_country_usa'), lazy_fixture('input_country_usa_preprocessed'))]
)
def test_create_native_location_features(input_country, input_country_preprocessed_expected):
columns = ["native_usa", "native_latin_america", "native_europe", "native_asia"]
input_country_preprocessed = create_native_location_features(input_country)
assert_frame_equal(input_country_preprocessed[columns], input_country_preprocessed_expected[columns],
check_dtype=False, check_like=True)


@mark.parametrize(
"input_sex, input_sex_preprocessed_expected",
[(lazy_fixture('input_male'), lazy_fixture('input_male_preprocessed')),
(lazy_fixture('input_female'), lazy_fixture('input_female_preprocessed'))]
)
def test_encode_binary_features_sex(input_sex, input_sex_preprocessed_expected):
column = ['is_male']
input_sex_preprocessed = encode_binary_features(input_sex)
assert_frame_equal(input_sex_preprocessed[column], input_sex_preprocessed_expected[column],
check_dtype=False, check_like=True)


@mark.parametrize(
"input_marital_status, input_marital_status_preprocessed_expected",
[(lazy_fixture('input_married'), lazy_fixture('input_married_preprocessed')),
(lazy_fixture('input_not_married'), lazy_fixture('input_not_married_preprocessed'))]
)
def test_encode_binary_features_marital_status(input_marital_status, input_marital_status_preprocessed_expected):
column = ['is_married']
input_marital_status_preprocessed = encode_binary_features(input_marital_status)
assert_frame_equal(input_marital_status_preprocessed[column], input_marital_status_preprocessed_expected[column],
check_dtype=False, check_like=True)

No código acima, foram implementados funções para testar as 3 funções de pré-processamento presentes no arquivo ‘preprocessing.py’.

As funções de teste foram parametrizadas com a anotação ‘@pytest.mark.parametrize’, o que significa que podemos realizar vários testes com a mesma função de teste somente alterando as entradas e saídas esperadas da função. O plugin pytest-lazy-fixture nos permite passar como parâmetros fixtures dos casos de teste. Portanto, ao passar o comando ‘pytest_lazyfixture.lazy_fixture(‘input_json_1’)’ na parametrização, o pytest entende que a função de teste deverá utilizar a fixture ‘input_json_1’ definida no ‘conftest.py’ como uma das entradas do teste.

No fim de cada função de teste é comum ter o comando ‘assert’ do python, que verifica se duas variáveis são iguais e gera uma exceção caso não sejam, fazendo com que o teste falhe. Porém, em projetos de machine learning é comum utilizar o pandas para fazer o pré-processamento, e o pandas possui funções de assert (‘assert_frame_equal’ e ‘assert_series_equal’) que nos permitem ter mais controle ao comparar se dois pandas dataframes ou pandas series são iguais ou não (podemos indicar se a ordem das colunas precisa ser idêntica por exempo).

Testes de predição

Uma vez que tenhamos nos certificado de que as etapas de pré-processamento estão fazendo o esperado, avaliamos se o modelo está realizando as previsões esperadas.

Aqui podem ser feitos testes de vários tipos e que façam sentido de acordo com o problema. O mais simples e geral dos testes é garantir que para entradas fixas avaliadas no momento de desenvolvimento do modelo, as previsões do modelo são as esperadas.

Alguns motivos para estes testes falharem é a não utilização correta das funções de pré-processamento (etapas executadas em ordem errada ou etapas que nem sequer foram executadas), e também a implementação incorreta do endpoint que realiza as previsões na API.

Abaixo temos os testes de predição que foram implementados para o exemplo em questão:

import json
from pytest import mark
from pytest_lazyfixture import lazy_fixture


@mark.parametrize(
"input_json, output_json_expected, status_code_expected",
[(lazy_fixture('input_json_1'), lazy_fixture('output_json_1'), 200)]
)
def test_invocations(client, input_json, output_json_expected, status_code_expected):
request_answer = client.post('/invocations', data=json.dumps(input_json), content_type='application/json')
output_json = request_answer.json
assert output_json == output_json_expected

Na função de teste acima, só foi desenvolvido 1 caso de teste com entradas, saídas esperadas e status code esperado, porém encorajamos que mais casos de testes sejam implementados em soluções reais. O lado bom é que não precisaríamos implementar novas funções para criar novos testes, apenas implementar no ‘conftest.py’ fixtures com entradas e previsões esperadas e utilizá-las dentro da anotação ‘@pytest.mark.parametrize’ de parametrização de entradas para a função de teste.

Nos argumentos da função de teste ‘test_invocations’, temos uma outra fixture chamada ‘client’ que não está parametrizada. Esta fixture está definida da seguinte forma no conftest.py:

from api import app

@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client

Essa é uma fixture especial que nos permite simular o funcionamento da nossa API flask para cenários de teste. Quando ela está como argumento de uma função de teste, um objeto ‘client’ fica disponível dentro da função de teste. Esse objeto possui funções (‘.post()’, ‘.get()’, etc) que simulam requisições nos endpoints da API, sem precisar rodar a API em um servidor web.

Testes de endpoint

Uma vez que nossa API passa nos testes de função e testes de predição, podemos finalmente subi-la e disponibilizar nossa API, certo? Talvez não…

Apesar dos serviços de nuvem facilitarem muito nosso processo de disponibilização da API, ainda assim precisamos definir o que precisa ter no ambiente produtivo. Isso geralmente é feito através uma imagem docker com o python instalado e um requirements.txt que define as bibliotecas necessárias para rodar nossa API. Isso vai garantir que nosso ambiente de produção é identico ao ambiente de desenvolvimento.

Infelizmente, não é incomum que essas definições de ambiente apresentem problemas. Por vezes, podemos especificar a versão errada da biblioteca que implementa o modelo treinado e a API em produção acabar não conseguindo carregar o modelo ou até produzindo previsões diferentes. Podemos definir a versão errada do python e isso gerar problemas na hora de instalar as bibliotecas presentes no requirements.txt ou até acabar esquecendo de incluir alguma biblioteca necessária para rodar a API. Enfim, os erros que podem acontecer ainda são muitos, e eles podem ser óbvios e gerar exceções, ou podem ser silenciosos e só produzirem previsões incoerentes.

Portanto, como uma última verificação podemos utilizar os testes de endpoint. Eles são muito parecidos com os testes de predição, mas ocorrem fazendo requisições reais a um endpoint disponível. Sendo assim, podemos disponibilizar a API em um ambiente de testes e depois realizar requisições a esse endpoint e verificar se as respotas são as esperadas. Esse tipo de teste costuma ser chamado de teste de integração em outros contextos.

Abaixo vemos como podemos implementar os testes de endpoint:

import requests
import pytest
from pytest import mark
from pytest_lazyfixture import lazy_fixture


@pytest.mark.endpoint
@mark.parametrize(
"input_json, output_json_expected, status_code_expected",
[
(lazy_fixture('input_json_1'), lazy_fixture('output_json_1'), 200)
]
)
def test_endpoint_invocations(endpoint_address, input_json, output_json_expected, status_code_expected):
if endpoint_address is None:
raise Exception("Argument '--endpoint' was not defined")

request = requests.post(f'{endpoint_address}/invocations', json=input_json)
output_json = request.json()

output = (request.status_code, output_json)
output_expected = (status_code_expected, output_json_expected)
assert (output == output_expected)

Os testes de endpoint são muito parecidos com os testes de predição, mas em vez de usar a fixture ‘client’ para simular requisições, utilizamos a biblioteca requests para realizar requisições reais.

Para a função de teste de predição, utilizamos um recurso do pytest que nos permite categorizar os testes segundo marcações customizadas com a anotação ‘@pytest.mark.nome_da_marcacao’. Na próxima seção ficará mais claro, mas isso vai ser útil para podermos selecionar somente os testes de função e de predição para executar antes de publicar a API, e após a publicação da API poderemos selecionar para a execução somente os testes de endpoint.

Além dos argumentos parametrizados de entradas, saídas esperadas e status code esperados, utilizamos uma outra fixture chamada ‘endpoint_address’. Se olharmos no ‘conftest.py’, encontramos a definição dessa fixture e uma outra definição importante:

import pytest

def pytest_addoption(parser):
parser.addoption("--endpoint", type=str, default=None, help="Endereco do endpoint para realizar os testes.")

@pytest.fixture
def endpoint_address(request):
endpoint_address = request.config.getoption("--endpoint")
if endpoint_address is not None and not endpoint_address.startswith('http://'):
endpoint_address = f"http://{endpoint_address}"
return endpoint_address

A função ‘pytest_addoption’ é uma função especial do pytest que nos permite criar argumentos para a execução dos testes, semelhante como é feito com a biblioteca argparse. Definimos um argumento ‘endpoint’ que serve para passarmos o endreço onde a API estará rodando. Esse endpoint deverá ser passado pelo usuário quando os testes de endpoint forem executados.

A fixture ‘endpoint_address’ tem como argumento uma fixture especial do pytest chamada ‘request’. A fixture request nos permite acessar algumas informações durante a execução dos testes, dentre elas podemos resgatar valores de argumentos passados pelo usuário no momento da execução do teste. Isso é útil pois quando implementamos os testes provavelmente não saberemos qual será o endereço em que a API será publicada, e por isso deixamos como responsabilidade de quem executar os testes definir o endereço da API que será testada. Tudo que a fixture ‘endpoint_address’ faz é extrair o valor do argumento ‘endpoint’ que precisaremos usar nas funções dos testes de endpoint.

Executando os testes

Após implementarmos os testes, vamos querer executá-los para saber se nosso modelo passa em todos os testes. Aqui vale a sugestão de utilizar um outro plugin muito útil do pytest chamado pytest-cov. Ele permite calcular a métrica de coverage, que é basicamente a porcentagem do nosso código que é executado em pelo menos 1 dos nossos testes. Uma métrica alta de coverage não é uma garantia de uma boa testagem, mas quando está baixa certamente é um mau sinal.

Vamos executar os testes em 2 etapas. Primeiro, vamos executar os testes de função e predição antes de subir a API em um ambiente de teste. Isso pode ser feito através do comando:

pytest -vv -m "not endpoint" --cov=deployment

No comando acima, temos alguns argumentos que valem a pena explicar. O argumento ‘-vv’ indica que queremos uma alta verbosidade na exceução dos testes, ou seja, queremos todos os detalhes na saída do terminal durante a execução. Também poderíamos utilizar ‘-v’ para obter menos detalhes ou até omitir esse argumento para uma execução com o mínimo de detalhes.

O argumento ‘-m “not endpoint” ’ serve para selecionarmos todos os testes que não estejam marcados com a anotação ‘@pytest.mark.endpoint’ dos testes de endpoint. Desta forma, selecionamos todos os testes menos os de endpoint.

O argumento ‘ — cov=deployment’ é um argumento do pytest-cov e indica que só queremos medir a métrica de coverage dos arquivos dentro da pasta deployment (api.py e preprocessing.py). Ele gera uma saída com as informações de cobertura do código como mostra abaixo. Ainda poderíamos utilizar o argumento ‘ — cov-report html:cov_html’ se desejarmos um relatório em HTML da cobertura do código (inclusive para saber quais linhas nunca são executadas nos nossos testes).

Relatório de cobertura do código gerado através do plugin pytest-cov
Relatório de cobertura do código gerado através do plugin pytest-cov

Além das métricas de cobertura, conseguimos saber se os testes passaram com sucesso, conforme mostra abaixo.

Resultado dos testes após a execução
Resultado dos testes após a execução

Por fim, poderíamos subir a API em um ambiente de testes idêntico ao que será o ambiente de produção, disponibilizando um endpoint de teste. E assim, poderíamos rodar os testes de endpoint com o seguinte comando:

pytest -vv -m "endpoint" --endpoint localhost:5000

No comando acima utilizamos o argumento ‘-m “endpoint”’ para selecionar somente os testes de endpoint para execução. E passamos o argumento customizado ‘ — endpoint localhost:5000’ que definimos no ‘conftest.py’ na função ‘pytest_addoption’ para passar o endereço da API no ambiente de teste.

Pronto! Nossa aplicação passou em todos os testes. Dessa forma ficamos mais seguros de que nosso modelo está fazendo as predições da forma esperada e assim subir em produção. Aqui na Frete, ainda automatizamos a execução dos testes, portanto toda vez que que é feito uma atualização no repositório os testes são executados, mas isso é assunto para um outro post…

Abaixo deixo uma excelente referência que fala sobre testes dentro do escopo de soluções de ciência de dados:

https://henriqueajnb.github.io/data-science-escalavel/README.html

--

--