Olá Devs!
Quando trabalhamos com dados, é necessário que, além de desenvolver todas as transformações necessárias para que os dados estejam prontos para serem utilizados na execução de análises, tenhamos uma maneira de validar a correção e validade dos dados segundo as regras que foram determinadas.
Uma maneira automatizada e eficiente de fazer isso é através da criação de testes unitários que validem os dados de acordo com as regras estabelecidas.
Vamos começar???
Obtendo os Dados
Para este artigo, vamos carregar dados que apresentam alguns erros e vamos construir os testes unitários para valida-los.
import pandas as pd
df = pd.read_csv('https://media.githubusercontent.com/media/labeduc/datasets/main/testes/problematic_data.csv')
Aqui podemos ver uma amostra dos dados:
df.sample(5)
Unnamed: 0 | ID | Name | Age | Salary | Join_Date | Category | |
---|---|---|---|---|---|---|---|
16 | 16 | 17 | Name17 | 56 | 4700 | 2023-05-31 | Category C |
12 | 12 | 13 | Name13 | 74 | 4300 | 2023-01-31 | Category B |
42 | 42 | 43 | Name43 | 74 | 7300 | 2025-07-31 | Category A |
13 | 13 | 14 | Name14 | 35 | 4400 | 2023-02-28 | Category B |
17 | 17 | 18 | Name18 | 35 | 4800 | 2023-06-30 | Category A |
Para iniciar o nosso processo de validação, precisamos realizar a primeira inspeção nos dados. Para isso, a biblioteca Pandas nos dá algumas funções bem interessantes.
# A função info() exibe informações sobre o DataFrame,
# incluindo o tipo de dados de cada coluna,
# valores não nulos e uso de memória.
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 50 entries, 0 to 49
Data columns (total 7 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Unnamed: 0 50 non-null int64
1 ID 50 non-null int64
2 Name 40 non-null object
3 Age 50 non-null object
4 Salary 50 non-null int64
5 Join_Date 50 non-null object
6 Category 50 non-null object
dtypes: int64(3), object(4)
memory usage: 2.9+ KB
Podemos perceber 2 situações que demandam maior verificação:
- A coluna Name tem 10 valores nulos, o que pode ser um problema para as regra de negócio;
- A coluna Age (idade) tem todas as linhas preenchidas, mas seu tipo, em vez de ser um valor do tipo inteiro, é do tipo objeto, o que infica possível problema nos dados.
O que a função info não nos mostra é a presença de valores duplicados, ou até mesmo uma linha inteira duplicada. Vamos então aprender como conseguir essas informações.
Tipos de Validação
Dataset está vazio
A proprieadade empty
do DataFrame nos informa se o DataFrame está vazio ou não. Se o DataFrame estiver vazio, a propriedade retornará True
, caso contrário, retornará False
.
vazio = df.empty
print(f"{'' if vazio else 'Não'} está vazio")
Não está vazio
Coluna está vazia
A propriedade empty
do DataFrame nos informa se a coluna está vazia ou não. Se a coluna estiver vazia, a propriedade retornará True
, caso contrário, retornará False
.
vazio = df['Name'].empty
print(f" Coluna Name {'' if vazio else 'Não'} está vazia")
Coluna Name Não está vazia
Verificando Valores Nulos
Este teste visa descobrir se existem dados faltando em nosso dataset. Podemos testar de maneira geral ou coluna a coluna. Para isso, utilizamos a função isnull()
que retorna True
para valores nulos e False
para valores não nulos.
# Testando se existe algum valor nulo
valores_nulos = df.isnull().values.any()
print(f"{'' if valores_nulos else 'Náo'} Existem Valores Nulos.")
Existem Valores Nulos.
# O teste pode ser feito para uma coluna específica
valores_nulos = df['Name'].isnull().values.any()
print(f"A coluna Name {'tem' if valores_nulos else 'não tem'} valores nulos.")
A coluna Name tem valores nulos.
# O teste pode ser feito para uma coluna específica
valores_nulos = df['Age'].isnull().values.any()
print(f"A coluna Age {'tem' if valores_nulos else 'não tem'} valores nulos.")
A coluna Age não tem valores nulos.
Verificando os tipos de dados
Este teste visa verificar se o tipo de dados de uma coluna em todas as linhas do seu dataset é consistente com o objetivo de uso desta coluna. Por exemplo, vimos nos exemplos de dados, que a coluna Age está identificada com o tipo de dados objeto, o que certamente nos causará problemas se quisermos calcular a média de idade de nosso dataset, pois é um indicativo de que em alguma linha desta coluna, o valor não é numérico. Podemos fazer uma inspeção manual, já que estamos falando apenas de 50 linhas, mas vamos aprender a fazer isso de maneira automatizada.
# Testando se a coluna Age é do tipo numérico
# A função isna() retorna um DataFrame de valores booleanos que indicam se um elemento é um número ou não.
eh_numero = df['Age'].isna().values.any()
print(f"A coluna Age {'é' if eh_numero else 'não é'} do tipo numérico.")
A coluna Age não é do tipo numérico.
Mas que valor é este? Vamos usar outra função para descobrir.
# A função unique() retorna uma matriz de valores exclusivos em uma coluna.
# A função tolist() converte a matriz em uma lista.
print(f" Valores únicos de Age: {df['Age'].unique().tolist()}")
Valores únicos de Age: ['46', '19', '30', '60', '69', '36', '64', '48', '53', '52', '32', '74', '35', '56', '49', 'Unknown', '57', '44', '54', '28', '41', '39', '62', '21', '71', '42', '38', '22', '59', '55']
Ao usar a função unique()
, podemos descobrir quais são os valores únicos de uma coluna. Se a coluna tiver um tipo de dados numérico, a função retornará uma lista de valores únicos. Se a coluna tiver um tipo de dados não numérico, a função retornará uma lista de strings. Existem uma ou mais linhas com o valor ‘Unknown’ na coluna Age, o que causa o comportamento que vimos anteriormente. Esse é mais um dos problemas a serem corrigidos, que o nosso teste unitário vai nos ajudar a identificar.
Outros Tipos de Validação
Veja abaixo alguns outros tipos de validação comum em testes de dados
Teste contra valores
Neste tipo de teste, verificamos se as colunas do nosso dataset respeitam, por exemplo, valores mínimos, máximos, conjunto especificos e limitados de opções, se obedecem a uma lógica dependente de outras colunas, etc.
Apenas maiores de 40 anos
# Primeiro vamos corrigir os unknown
df['Age'] = (df['Age'].apply(lambda x: 40 if x == 'Unknown' else x)).astype(int)
# Agora fazemos a validação
menores_de_quarenta = df.query('Age < 40').empty
print(f"{'Não Temos' if menores_de_quarenta else 'Temos'} menores de quarenta")
Temos menores de quarenta
Verifica contra Lista de Valores
#
categoria_invalida = (df['Category'].apply(lambda x: x not in ['Category A', 'Category B', 'Category C'])).empty
print(f"{'Não tem' if categoria_invalida else 'Tem'} categorias invalidas.")
Tem categorias invalidas.
EDA
EDA
é a sigla para Exploratory Data Analysis
, que em português significa Análise Exploratória de Dados
. Este tipo de teste visa verificar se os dados estão de acordo com o esperado, ou seja, se estão dentro de um intervalo esperado, se não há outliers, se a distribuição dos dados está correta, etc. Ou seja, é uma análise mais aprofundada dos dados, que fazem validações mais complexas e de cunho estatístico.
Para nos ajudar com essa análise, podemos utilizar a função describe()
do Pandas, que nos dá um resumo estatístico dos dados.
df.describe()
Unnamed: 0 | ID | Age | Salary | |
---|---|---|---|---|
count | 50.00000 | 50.00000 | 50.000000 | 50.000000 |
mean | 24.50000 | 25.50000 | 46.320000 | 5310.000000 |
std | 14.57738 | 14.57738 | 14.618188 | 1653.351574 |
min | 0.00000 | 1.00000 | 19.000000 | 2000.000000 |
25% | 12.25000 | 13.25000 | 36.500000 | 4025.000000 |
50% | 24.50000 | 25.50000 | 46.000000 | 5350.000000 |
75% | 36.75000 | 37.75000 | 56.000000 | 6675.000000 |
max | 49.00000 | 50.00000 | 74.000000 | 8000.000000 |
Como podemos ver, a função describe()
nos dá um resumo estatístico dos dados numéricos, como a média, desvio padrão, mínimo, máximo, etc. Com essas informações, podemos fazer validações mais complexas, como verificar se a média de idade está dentro de um intervalo esperado, se a distribuição dos dados está correta, etc. Mas, como podemos ver, a coluna Age não está sendo considerada como numérica, o que nos impede de fazer essas validações. Vamos corrigir isso.
# A correção aplicada foi a substituição dos valores 'Unknown' por 40 e a conversão para inteiro.
# Por que 40? Porque é um valor que não altera a média e a mediana dos dados.
df['Age'] = df['Age'].apply(lambda x: 40 if x == 'Unknown' else x).astype(int)
df.describe()
Unnamed: 0 | ID | Age | Salary | |
---|---|---|---|---|
count | 50.00000 | 50.00000 | 50.000000 | 50.000000 |
mean | 24.50000 | 25.50000 | 46.320000 | 5310.000000 |
std | 14.57738 | 14.57738 | 14.618188 | 1653.351574 |
min | 0.00000 | 1.00000 | 19.000000 | 2000.000000 |
25% | 12.25000 | 13.25000 | 36.500000 | 4025.000000 |
50% | 24.50000 | 25.50000 | 46.000000 | 5350.000000 |
75% | 36.75000 | 37.75000 | 56.000000 | 6675.000000 |
max | 49.00000 | 50.00000 | 74.000000 | 8000.000000 |
Bom, agora que temos isso resolvido, vamos ao próximo passo: rodar o EDA. O EDA pode ser feito manualmente, mas vamos aprender a fazer isso de maneira automatizada. Para essa análise mais automatizada, vamos usar três ferramentas: jupyter-summarytools
, sweetviz
e dtale
.
Jupyter-summarytools
É a versão mais bonita do describe()
. Ele nos dá um resumo estatístico dos dados, mas de uma maneira mais visual e interativa. Para instalar, basta rodar o comando !pip install jupyter-summarytools
no seu Jupyter Notebook.
from summarytools import dfSummary
dfSummary(df)
T_e3ff2 thead>tr>th {
text-align: left;
}
T_e3ff2_row0_col0, #T_e3ff2_row1_col0, #T_e3ff2_row2_col0, #T_e3ff2_row3_col0, #T_e3ff2_row4_col0, #T_e3ff2_row5_col0, #T_e3ff2_row6_col0 {
text-align: left;
font-size: 12px;
vertical-align: middle;
width: 5%;
max-width: 50px;
min-width: 20px;
}
T_e3ff2_row0_col1, #T_e3ff2_row1_col1, #T_e3ff2_row2_col1, #T_e3ff2_row3_col1, #T_e3ff2_row4_col1, #T_e3ff2_row5_col1, #T_e3ff2_row6_col1 {
text-align: left;
font-size: 12px;
vertical-align: middle;
width: 15%;
max-width: 200px;
min-width: 100px;
word-break: break-word;
}
T_e3ff2_row0_col2, #T_e3ff2_row1_col2, #T_e3ff2_row2_col2, #T_e3ff2_row3_col2, #T_e3ff2_row4_col2, #T_e3ff2_row5_col2, #T_e3ff2_row6_col2 {
text-align: left;
font-size: 12px;
vertical-align: middle;
width: 30%;
min-width: 100px;
}
T_e3ff2_row0_col3, #T_e3ff2_row1_col3, #T_e3ff2_row2_col3, #T_e3ff2_row3_col3, #T_e3ff2_row4_col3, #T_e3ff2_row5_col3, #T_e3ff2_row6_col3 {
text-align: left;
font-size: 12px;
vertical-align: middle;
width: 25%;
min-width: 100px;
}
T_e3ff2_row0_col4, #T_e3ff2_row1_col4, #T_e3ff2_row2_col4, #T_e3ff2_row3_col4, #T_e3ff2_row4_col4, #T_e3ff2_row5_col4, #T_e3ff2_row6_col4 {
text-align: left;
font-size: 12px;
vertical-align: middle;
width: 20%;
min-width: 150px;
}
T_e3ff2_row0_col5, #T_e3ff2_row1_col5, #T_e3ff2_row2_col5, #T_e3ff2_row3_col5, #T_e3ff2_row4_col5, #T_e3ff2_row5_col5, #T_e3ff2_row6_col5 {
text-align: left;
font-size: 12px;
vertical-align: middle;
width: 10%;
}
df
Dimensions: 50 x 7
Duplicates: 0
No | Variable | Stats / Values | Freqs / (% of Valid) | Graph | Missing |
---|---|---|---|---|---|
1 |
Unnamed: 0 [int64] |
Mean (sd) : 24.5 (14.6) min < med < max: 0.0 < 24.5 < 49.0 IQR (CV) : 24.5 (1.7) |
50 distinct values | 0 (0.0%) |
|
2 |
ID [int64] |
Mean (sd) : 25.5 (14.6) min < med < max: 1.0 < 25.5 < 50.0 IQR (CV) : 24.5 (1.7) |
50 distinct values | 0 (0.0%) |
|
3 |
Name [object] |
1. nan 2. Name1 3. Name38 4. Name28 5. Name29 6. Name31 7. Name32 8. Name33 9. Name34 10. Name36 11. other |
10 (20.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 31 (62.0%) |
10 (20.0%) |
|
4 |
Age [int64] |
Mean (sd) : 46.3 (14.6) min < med < max: 19.0 < 46.0 < 74.0 IQR (CV) : 19.5 (3.2) |
30 distinct values | 0 (0.0%) |
|
5 |
Salary [int64] |
Mean (sd) : 5310.0 (1653.4) min < med < max: 2000.0 < 5350.0 < 8000.0 IQR (CV) : 2650.0 (3.2) |
48 distinct values | 0 (0.0%) |
|
6 |
Join_Date [object] |
1. 2022-01-31 2. 2025-02-28 3. 2024-04-30 4. 2024-05-31 5. 2024-06-30 6. 2024-07-31 7. 2024-08-31 8. 2024-09-30 9. 2024-10-31 10. 2024-11-30 11. other |
1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 1 (2.0%) 40 (80.0%) |
0 (0.0%) |
|
7 |
Category [object] |
1. Category A 2. Category B 3. Category C 4. No Category |
19 (38.0%) 15 (30.0%) 13 (26.0%) 3 (6.0%) |
0 (0.0%) |
Sweetviz
O Sweetviz é uma ferramenta que nos dá um relatório completo dos dados, com gráficos e tabelas que nos ajudam a entender melhor os dados. Para instalar, basta rodar o comando !pip install sweetviz
no seu Jupyter Notebook. Ele é muito fácil de usar, basta rodar o comando sweetviz.analyze([seu_dataframe])
e ele vai gerar um relatório completo dos seus dados.
import sweetviz as sv
my_report = sv.analyze(df)
# Exibindo o relatório no próprio notebook
# Existem outras opções de saída, como HTML e JSON.
my_report.show_notebook()
D-Tale
O D-Tale é uma ferramenta que nos dá um relatório completo dos dados, com gráficos e tabelas que nos ajudam a entender melhor os dados. Para instalar, basta rodar o comando !pip install dtale
no seu Jupyter Notebook. Ele é muito fácil de usar, basta rodar o comando dtale.show([seu_dataframe])
e ele vai gerar um relatório completo dos seus dados.
import dtale
import dtale.app as dtale_app
dtale_app.USE_COLAB = True
dtale.show(df)
Infelizmente, não podemos ver o resultado aqui, mas você pode rodar no seu Jupyter Notebook ou Google Colab e ver o resultado.
Criando os testes unitários
Agora que sabemos sobre alguns dos tipos de testes que podemos aplicar aos nossos dados, vamos aprender como organizar isso de uma forma prática.
A idéia é englobar os testes aprendidos em funções que podem ser chamadas a qualquer momento, assim a cada alteração que fazemos no dataset, podemos validar o mesmo.
Em primeiro lugar, englobamos os testes que fizemos em funções.
def teste_nulos(data_frame, coluna=None):
"""Verifica se o DataFrame ou uma Coluna específica possui valores nulos.
Returns:
True se houver valores nulos, False caso contrário.
"""
if coluna is None:
return data_frame.isnull().values.any()
else:
return data_frame[coluna].isnull().values.any()
def teste_eh_numero(data_frame, coluna):
"""Verifica se os valores de uma coluna são numéricos.
Returns:
True se algum dos valores não é numérico, False caso contrário.
"""
from pandas.api.types import is_numeric_dtype
return is_numeric_dtype(data_frame[coluna])
def teste_vazio(data_frame, coluna=None):
"""Verifica se o DataFrame ou uma Coluna específica está vazio.
Returns:
True se estiver vazio, False caso contrário.
"""
if coluna is None:
return data_frame.empty
else:
return data_frame[coluna].empty
def teste_condicional(data_frame, condicao):
"""Verifica se o DataFrame atende a uma condição.
Returns:
True se atender a condicão, False caso contrário.
"""
result = data_frame.query(condicao)
return not(result.empty)
def teste_valores(data_frame, coluna, valores):
"""Verifica se os valores de uma coluna estão contidos em uma lista.
Returns:
True se estiver na lista, False caso contrário.
"""
result = data_frame[coluna].apply(lambda x: x in valores).any()
return result
A próxima etapa é criar uma função que irá chamar todas essas funções utilizando o comando assert
O comando assert
é utilizado para verificar se uma expressão é verdadeira. Se a expressão for verdadeira, o programa continua a execução normalmente. Se a expressão for falsa, o programa lança uma exceção do tipo AssertionError
.
def run_unit_test(data_frame):
try:
assert teste_nulos(data_frame) == False, 'Existem valores nulos'
assert teste_nulos(data_frame, 'Name') == False, 'Existem valores nulos na coluna Name'
assert teste_eh_numero(data_frame, 'Age'), 'A coluna Age não é do tipo numérico'
assert teste_vazio(data_frame) == False, 'O data_frame está vazio'
assert teste_condicional(data_frame, 'Age < 40') == True, 'Não tem menores de quarenta'
assert teste_valores(data_frame, 'Category', ['Category A', 'Category B', 'Category C']) == True, 'Categoria Invalida'
print('Testes finalizados com sucesso.')
except AssertionError as e:
print(e)
Tendo criado a função, agora só resta executa-la, observar as falhas, aplicar as correções e rodar os testes unitários novamente, até que todos passem.
1a Execução
run_unit_test(df)
Existem valores nulos
Para determinar isso, podemos apenas chamar a função info() do dataframe, que nos dá informações sobre o dataset, como o número de linhas, colunas, tipos de dados, etc.
df.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 50 entries, 0 to 49
Data columns (total 7 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Unnamed: 0 50 non-null int64
1 ID 50 non-null int64
2 Name 40 non-null object
3 Age 50 non-null int64
4 Salary 50 non-null int64
5 Join_Date 50 non-null object
6 Category 50 non-null object
dtypes: int64(4), object(3)
memory usage: 2.9+ KB
O campo Name está com problema. Vamos corrigir isso.
df['Name'] = df['Name'].fillna('Desconhecido')
Agora, vamos para a segunda execução.
2a Execução
run_unit_test(df)
Testes finalizados com sucesso.
Agora, é a coluna Age que está com problema. Vamos corrigir isso.
df['Age'] = pd.to_numeric(df['Age'])
Vamos para a 3a execução.
3a Execução
run_unit_test(df)
Testes finalizados com sucesso.
Vamos ver os valores possíveis do campo Category.
df['Category'].unique().tolist()
['Category B', 'Category C', 'Category A', 'No Category']
Temos um No Category ali que está fazendo o teste falhar. Vamos corrigir isso. Mas qual seria a melhor correção? Trocar o valor por um dos válidos ou corrigir o teste? A resposta dependerá do contexto do negócio. Aqui, vamos assumir que corrigir o teste é a melhor alternativa.
def run_unit_test(data_frame):
try:
assert teste_nulos(data_frame) == False, "Existem valores nulos"
assert (
teste_nulos(data_frame, "Name") == False
), "Existem valores nulos na coluna Name"
assert teste_eh_numero(data_frame, "Age"), "A coluna Age não é do tipo numérico"
assert teste_vazio(data_frame) == False, "O data_frame está vazio"
assert (
teste_condicional(data_frame, "Age < 40") == True
), "Não tem menores de quarenta"
assert (
teste_valores(
data_frame, "Category", ["Category A", "Category B", "Category C", "No Category"]
)
== True
), "Categoria Invalida"
print("Testes finalizados com sucesso.")
except AssertionError as e:
print(e)
4a Execução
run_unit_test(df)
Testes finalizados com sucesso.
Agora sim, finalizamos o nosso processo de testar os dados. Agora, temos um dataset que está de acordo com as regras de negócio e podemos utilizá-lo para fazer análises.
Conclusão
Neste artigo, aprendemos como fazer testes unitários em dados utilizando a biblioteca Pandas. Vimos que é possível fazer testes simples, como verificar se o dataset está vazio, se uma coluna está vazia, se existem valores nulos, se os tipos de dados estão corretos, etc. Também vimos que é possível fazer testes mais complexos, como verificar se os valores de uma coluna estão dentro de um intervalo esperado, se obedecem a uma lógica dependente de outras colunas, etc.
Aprendemos também como organizar esses testes em funções e como criar uma função que chama todas essas funções e verifica se os testes passaram ou não. Com isso, podemos garantir que os dados estão de acordo com as regras de negócio e que podemos utilizá-los para fazer análises.
Mas é importante lembrar que os testes unitários não são a única forma de garantir a qualidade dos dados. É importante também fazer uma análise exploratória dos dados, verificar se os dados estão de acordo com o esperado, se não há outliers, se a distribuição dos dados está correta, etc. E, é claro, é importante também fazer validações manuais, para garantir que os dados estão corretos.
Um abraço e até a próxima,
Walter.