Desvendando os efeitos da validação cruzada

Explorando a importância das técnicas da validação cruzada em modelos de machine learning

Edson Junior
Data Hackers
14 min readSep 10, 2023

--

Photo by Markus Winkler on Unsplash

Conteúdo:

→ Introdução
→ Técnica Holdout — Divisão em treino e teste

1. Treinamento do modelo
→ Abordagem com validação cruzada

1. K-Fold
2. Stratified K-Fold
3. Grouped K-Fold
4. Leave One Out Cross Validation
5. Aplicação prática

→ Conclusão
→ Referências

Introdução

Suponha que você esteja utilizando machine learning com o objetivo de treinar um modelo que seja capaz de classificar três diferentes tipos de vinhos (0, 1 e 2). Na primeira etapa será adotada a técnica Holdout (divisão em treino e teste convencional) e na segunda etapa será abordada a Validação Cruzada, na qual será explicado o que é, tipos mais comuns, além da realização de uma aplicação prática. O conjunto de dados utilizado é o “load_wine”, pertencente a biblioteca scikit-learn (sklearn), possuindo variáveis como: teor alcoólico do vinho, quantidade de magnésio, intensidade da cor, quantidade de ácido, entre outros.

Em Python, estes dados podem ser obtidos da seguinte maneira:

# importando a função que carrega os dados de vinhos
from sklearn.datasets import load_wine

# Mostrando informações como dados de variáveis explicativas,
# distribuição das classes, nomes de colunas, etc
load_wine()

Assim, é possível atribuir as variáveis independentes ao objeto “X” e as classes de vinhos ao objeto “y” :

# Trazendo as variáveis independentes
X = load_wine()['data']

# Trazendo as classes dos vinhos (0, 1 ou 2)
y = load_wine()['target']

# Trazendo os nomes das variáveis independentes
colunas = load_wine()['feature_names']

Com isso, pode ser definido um dataframe contendo as features dos vinhos e as classes correspondentes:

# importando a biblioteca de tratamento de dados
import pandas as pd

# Agora que temos os dados das variáveis independentes e do target,
# podemos criar um dataframe
df = pd.DataFrame(data = X, columns = colunas)
df['Classes'] = y

# Mostrando as 5 primeiras linhas do dataset
df.head()

As cinco primeiras linhas do dataset são apresentadas a seguir:

Fonte: Próprio autor

O tamanho do conjunto de dados, contendo número de linhas e colunas, pode ser obtido através do método shape, como é apresentado a seguir:

Fonte: Próprio autor

Assim, a base contém 178 linhas e 14 colunas. Além disso, é possível ter conhecimento da proporção das classes presentes na base:

# Conhecendo a proporção das classes de vinho no dataset
df['Classes'].value_counts(1)

O que acaba originando:

Fonte: Próprio autor

Ou seja, dos 178 registros da base, a classe 1 possui uma proporção de 0.398876, contra 0.331461 da classe 0 e 0.269663 da classe 2.

Técnica Holdout — Divisão em treino e teste

A divisão do conjunto de dados original em treino e teste (também conhecida como técnica Holdout) é uma prática fundamental em machine learning, tendo por objetivo avaliar o desempenho do modelo. Assim, enquanto o conjunto de treino é utilizado para treinar o modelo, o conjunto de teste avalia o quão bem ele consegue generalizar para dados não vistos, medindo o desempenho do modelo para dados reais, de produção.

Em Python, esta separação pode ser feita da seguinte forma:

# Importando a função que divide o dataset em treino e teste
from sklearn.model_selection import train_test_split

# Separando os dados em treino e teste
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size = 0.2, random_state = 42, stratify = y)

# Obtendo a quantidade de linhas e colunas do conjunto de treino e teste
print(f"Quantidade de linhas e colunas para os dados de treino: {X_train.shape}")
print(f"Quantidade de linhas e colunas para os dados de teste: {X_test.shape}")

Desta forma, são obtidas 142 instâncias para os dados de treino e 36 para os dados de teste:

Fonte: Próprio autor

Na função train_test_split o parâmetro random_state tem o objetivo de manter o padrão de aleatoriedade, pois a função divide os dados aleatoriamente. O parâmetro stratify mantem a proporção entre as classes de vinhos do conjunto original nos conjuntos de treino e teste. Isso evita que o conjunto de treino ou de teste fique com uma quantidade menor de amostras de classes minoritárias, o que pode levar a uma representação inadequada das classes e afetar o desempenho do modelo.

Para mostrar que o parâmetro stratify realmente funciona, pode-se comparar as representações das classes nos conjuntos de treino e teste.

Para o conjunto de treino:

# Transformando o array y_train em uma série para analisar a 
# proporção das classes no conjunto de treino
pd.Series(y_train).value_counts(1)

O que gera as seguintes proporções:

Fonte: Próprio autor

A proporção das classes para o conjunto de treino está praticamente a mesma do conjunto original!!

Já para o conjunto de teste:

# Transformando o array y_test em uma série para analisar a 
# proporção das classes no conjunto de teste
pd.Series(y_test).value_counts(1)

São obtidas as proporções:

Fonte: Próprio autor

A proporção das classes para o conjunto de teste também está praticamente a mesma do conjunto original!!

Treinamento do modelo

Para dar início a esta etapa, será definido como modelo uma Regressão Logística. Assim como na regressão linear, na regressão logística também há o somatório do produto das features pelos seus respectivos pesos mais o bias (viés). Desta forma, variáveis com escalas muito diferentes podem ter impacto desigual no modelo. Para isto não acontecer as variáveis podem ser escalonadas através da função StandardScaler.

No processo de escalonamento, todas as colunas serão passadas para uma distribuição normal, garantindo que as características estejam em uma mesma escala numérica.

# A regressão logística exige que as variáveis estejam escalonadas
# (na mesma ordem de grandeza).
# Para realizar o escalonamento é importada a função StandardScaler.
from sklearn.preprocessing import StandardScaler

# Instanciando o StandardScaler
scaler = StandardScaler()

# Calcula os parâmetros da escala e aplica aos dados de treino, transformando-os
X_train = scaler.fit_transform(X_train)
# Com os parâmetros já calculados, os dados de teste são escalonados
X_test = scaler.transform(X_test)

Com os dados de treino e teste escalonados, pode-se instanciar o modelo, treiná-lo e obter as previsões do modelo.

# importando a função da regressão logística
from sklearn.linear_model import LogisticRegression

# instanciando e treinando o modelo
modelo = LogisticRegression()
modelo.fit(X_train, y_train)

# Obtendo as probabilidades das classes previstas
y_pred_proba = modelo.predict_proba(X_test)

# Obtendo as previsões do modelo
y_pred = modelo.predict(X_test)

Como este problema de classificação é multiclasse (mais de duas classes no target), para obter as métricas de precisão e revocação através das funções precision_score e recall_score, respectivamente, deve-se calcular para cada uma das classes e realizar a média. Isso pode ser feito através do parâmetro average = “macro”, como no exemplo abaixo.

precisao = precision_score(y_test, y_pred, average = "macro")
revocacao = recall_score(y_test, y_pred, average = "macro")

Porém, o parâmetro average = “macro” é eficiente somente quando as classes estiverem balanceadas, pois considera que cada classe contribui igualmente para a métrica final.

Quando as classes forem desbalanceadas, como é o caso desta análise, é recomendado inserir o parâmetro average = “weighted”, pois fornece uma métrica agregada que leva em conta o desequilíbrio das classes. Isto é feito atribuindo um peso às métricas da classe proporcional à quantidade de amostras da classe.

# importando as funções para calcular a precisão e a revocação
from sklearn.metrics import precision_score, recall_score

# importando a função para calcular a acurácia do modelo
from sklearn.metrics import accuracy_score

precisao = precision_score(y_test, y_pred, average = "weighted")
revocacao = recall_score(y_test, y_pred, average = "weighted")
acuracia = accuracy_score(y_test, y_pred)

print(f"A precisão foi de: {precisao:.3f}")
print(f"A revocação foi de: {revocacao:.3f}")
print(f"A acurácia foi de: {acuracia:.3f}")

Neste caso, foram obtidos os seguintes valores para as métricas:

Fonte: Próprio autor

Abordagem com validação cruzada

Nesta etapa será abordada a mesma análise anterior, porém agora com o uso da técnica de validação cruzada. Mas afinal, do que se trata este recurso?

Na etapa anterior foi explicado sobre a importância de se dividir a base de dados em conjunto de treino e teste, que seria para treinar o modelo e avaliar o seu desempenho em um conjunto de dados nunca visto, simulando um ambiente de produção. É aqui que entra a validação cruzada, como uma técnica ainda mais confiável para medir o quão boa são as previsões. Trata-se de uma forma de treinar e testar o modelo diversas vezes em dados diferentes, o que aumenta a capacidade de generalização do algoritmo e fazendo com que as previsões se tornem mais precisas.

Existem alguns diferentes tipos de validação cruzada e aqui serão abordados alguns deles.

K-Fold

Imagine que a barra azul abaixo represente todo o conjunto de dados disponível, ou seja, as 178 intâncias do problema de classificação de vinhos.

Fonte: Próprio autor

A técnica k-fold é a forma mais básica de validação cruzada. Neste caso, ao invés de dividir os dados em apenas duas partes (treino e teste), o conjunto de dados é dividido em k partes iguais (sendo k um número inteiro), chamadas de folds. Desta forma, o modelo é treinado e testado k vezes, sendo que em cada etapa são utilizados k-1 folds para treino e 1 fold para teste.

Por exemplo, caso seja escolhido k = 5, o modelo será treinado 5 vezes, com os dados divididos em 5 partes iguais, da seguinte forma:

Fonte: Próprio autor
Fonte: Próprio autor
Fonte: Próprio autor
Fonte: Próprio Autor
Fonte: Próprio autor

O desempenho do modelo será mensurado em cada uma destas cinco rodadas e a métrica final será uma média das métricas obtidas em cada iteração, como indicado no exemplo a seguir.

Fonte: Próprio autor

Stratified K-Fold

A técnica stratified k-fold é uma variação da k-fold vista anteriormente. A diferença é que neste caso a técnica é utilizada quando se tem classes desbalanceadas no conjunto de dados. Isso se deve ao fato de que a proporção entre as classes é preservada em cada um dos folds.

Utilizando a validação cruzada em bases desbalanceadas, caso não seja garantido que a distribuição das classes seja mantida em cada um dos k folds, o modelo pode acabar sendo treinado com um viés e apresentar um desempenho irreal. O uso da técnica stratified k-fold garante uma boa representatividade dos dados e faz com que seja obtido uma avaliação mais justa e precisa do modelo.

Grouped K-Fold

O Grouped k-fold pode ser utilizado quando os registros da base pertencem a grupos ou clusters que não podem ser separados em treino e teste. Neste caso, instâncias de um mesmo grupo estariam todas em um mesmo fold, o que ajuda a evitar vazamento de dados entre os folds.

Um exemplo seria um modelo de classificação para um problema de estudo clínico em que os registros de um paciente deve ser mantidos sempre dentro de um mesmo fold, evitando que dados de pacientes diferentes se misturem e gerem resultados enganosos.

Leave One Out Cross Validation

A técnica Leave One Out Cross Validation (LOOCV) é uma técnica de validação cruzada utilizada quando se trata de pequenos conjuntos de dados. Neste caso, cada iteração é composta por: uma instância da base de dados sendo o conjunto de teste e o restante das observações compondo o conjunto de treinamento. Desta forma, o processo vai se repetindo até que todos os registros tenham sido o conjunto de teste.

A vantagem acaba sendo justamente o fato de todos os registros se passarem tanto pelo treinamento quanto pelo teste, podendo gerar um resultado menos tendencioso. A desvantagem é que é esta técnica é cara computacionalmente, devido ao número de iterações (por isso é recomendada apenas para conjuntos de dados pequenos).

Utilizar a técnica LOOCV é semelhante ao utilizar o k-fold e definir k como sendo o tamanho do conjunto de dados (quantidade de registros).

Aplicação prática

Para aplicação prática da validação cruzada foi escolhido utilizar a Stratified k-fold no problema da classificação de vinhos, já que o conjunto de dados é desbalanceado.

Para começar, são importadas as bibliotecas necessárias:

# importando a biblioteca Numpy, para manipulações algébricas
import numpy as np

# importando as funções Stratified K-Fold
from sklearn.model_selection import StratifiedKFold

# Para realizar o escalonamento é importada a função StandardScaler.
from sklearn.preprocessing import StandardScaler

# importando a função da regressão logística
from sklearn.linear_model import LogisticRegression

# importando as funções para calcular a precisão e a revocação
from sklearn.metrics import precision_score, recall_score

# importando a função para calcular a acurácia do modelo
from sklearn.metrics import accuracy_score

Em seguida, é definido o número de folds e inicializada a função StratifiedKFold. Além disso, são criadas listas vazias que serão úteis para armazenar os valores das métricas precisão, revocação e acurácia em cada um dos folds. É também instanciado o StandardScaler, para normalizar as variáveis (já que está sendo utilizada uma regressão logística).

Na função StratifiedKFold, o parâmetro n_splits indica o número de folds, shuffle = True indica que os dados são embaralhados antes de serem dividos em folds e random_state = 42 define um padrão de aleatoriedade.

# Definindo o número de folds
k = 5

# Inicializando a função StratifiedKFold
folds = StratifiedKFold(n_splits=k, shuffle=True, random_state=42)

# Criando listas para armazenar os valores de precisão, revocação e
# acurácia em cada fold
precisoes = list()
revocacoes = list()
acuracias = list()

# Instanciando o StandardScaler
scaler = StandardScaler()

# Transformando X e y em respectivamente, um dataframe e uma série do pandas.
# Isto é feito para se ter acesso aos índices de cada instância.

X = df.drop(columns = ["Classes"], axis = 1)
y = df['Classes']

Aqui começam as iterações de fato. Para cada rodada são definidas as intâncias do conjunto de treino e teste levadas em consideração, há o escalonamento das variáveis, além do modelo ser instanciado e treinado. As métricas precisão, revocação e acurácia são calculadas e seus valores apresentados para cada fold.

# Será aplicado o método "split" no objeto folds, que retornará uma lista 
# com os índices das instâncias que pertencem ao conjunto de treino e
# outra com os índices das instâncias que pertencem ao conjunto de teste

for k, (train_index, test_index) in enumerate(folds.split(X, y)):
print("=-"*6 + f"Fold: {k+1}" + "-="*6)

# Dividindo os dados em treino e teste para cada um dos folds
X_train, X_test = X.iloc[train_index], X.iloc[test_index]
y_train, y_test = y[train_index], y[test_index]
# train_index e test_index: São os índices das instâncias do conjunto
# de treino e teste, respectivamente, selecionados em cada um dos folds

# Escalonando os dados. Todas as colunas serão passadas para uma
# distribuição normal, garantindo que as características estejam
# em uma mesma escala numérica
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)

# instanciando e treinando o modelo
modelo = LogisticRegression()
modelo.fit(X_train, y_train)

# Obtendo as probabilidades das classes previstas
y_pred_proba = modelo.predict_proba(X_test)

# Obtendo as previsões do modelo
y_pred = modelo.predict(X_test)

# Calculando a precisão, revocação e acurácia para o fold em questão
precisao = precision_score(y_test, y_pred, average = "weighted")
revocacao = recall_score(y_test, y_pred, average = "weighted")
acuracia = accuracy_score(y_test, y_pred)

# Armazenando as precisões, revocações e acurácias nas listas criadas
precisoes.append(precisao)
revocacoes.append(revocacao)
acuracias.append(acuracia)

# Exibindo as métricas para cada um dos folds
print(f"Precisão: {precisao:.3f}")
print(f"Revocação: {revocacao:.3f}")
print(f"Acurácia: {acuracia:.3f}")

Com as métricas calculadas em cada um dos folds, é hora de calcular a média e desvio padrão das listas que contém as acurácias, precisões e revocações.

# Transformando as listas precisões, revocações, acurácias em arrays, 
# para fazer operações matemáticas
precisoes = np.array(precisoes)
revocacoes = np.array(revocacoes)
acuracias = np.array(acuracias)

# Calculando a média de todas as precisões, revocações e acurácias
media_precisao = np.mean(precisoes)
media_revocacao = np.mean(revocacoes)
media_acuracia = np.mean(acuracias)

# Calculando o desvio padrão de todas as precisões, revocações e acurácias
std_precisao = np.std(precisoes)
std_revocacao = np.std(revocacoes)
std_acuracia = np.std(acuracias)

# Exibindo a média das precisões e revocações
print(f"Média da precisão: {media_precisao:.3f} +/- {std_precisao:.3f}")
print(f"Média da revocação: {media_revocacao:.3f} +/- {std_revocacao:.3f}")
print(f"Média da acurácia: {media_acuracia:.3f} +/- {std_acuracia:.3f}")

Os resultados das métricas são apresentados a seguir:

Fonte: Próprio autor

Neste caso, observa-se que as métricas precisão, revocação e acurácia tiveam melhores índices com relação a análise sem validação cruzada, o que não ocorrerá sempre. Mas independente de ser melhor ou pior, pode-se afirmar que são resultados mais robustos e confiáveis.

IMPORTANTE: Para a aplicação prática da técnica k-fold, basta trocar a importação e inicialização da função StratifiedKFold pela KFold, os parâmetros serão os mesmos.

.
.
.
# importando a funções K-fold
from sklearn.model_selection import KFold
.
.
.
# Inicializando a função K-fold
folds = Fold(n_splits=k, shuffle=True, random_state=42)
.
.
.

No caso da LOOCV, basta utilizar a técnica KFold e inserir em n_splits o número de registros da base de dados. Neste caso, como temos 178 registros no conjunto de dados, então n_splits = 178.

.
.
.
# importando a funções K-fold
from sklearn.model_selection import KFold
.
.
.
# Inicializando a função K-fold
folds = Fold(n_splits=178, shuffle=True, random_state=42)
.
.
.

No caso da Grouped K-Fold, importa-se e inicializa-se a GroupKFold, mantendo apenas n_splits como parâmetro.

.
.
.
# importando a função Grouped K-Fold
from sklearn.model_selection import GroupKFold
.
.
.
# Inicializando a função Grouped K-fold
folds = GroupKFold(n_splits=k)
.
.
.

Na iteração dos folds, ao utilizar folds.split, passar como argumento também a variável da base de dados que identifica o grupo/cluster de cada registro .

    .
.
.
for k, (train_index, test_index) in enumerate(folds.split(X, y, grupos)):
print("=-"*6 + f"Fold: {k+1}" + "-="*6)
# Dividindo os dados em treino e teste para cada um dos folds
X_train, X_test = X.iloc[train_index], X.iloc[test_index]
y_train, y_test = y[train_index], y[test_index]
.
.
.

Conclusão

  1. As técnicas de validação cruzada fornecem estimativas mais robustas e confiáveis do desempenho do modelo, pois os dados são treinados e testados em várias partições diferentes.
  2. Ajudam a avaliar o quão boa é a capacidade do modelo generalizar para dados nunca vistos.
  3. Contribuem na identificação de overfitting, permitindo o ajuste adequado do modelo.
  4. Mesmo possuindo um conjunto de dados pequeno a validação cruzada é acessível. Neste caso, todas as instâncias são utilizadas como treinamento e teste em algum momento, aproveitando o máximo de cada observação. Porém, pode ser computacionalmente custoso.
  5. Há uma redução do viés da seleção de dados, já que há o treinamento e teste do modelo em diferentes partes, ao contrário da divisão única em treino e teste (método holdout) que possibilita que a divisão seja enviesada e afete a capacidade de generalização do modelo.
  6. Com o uso da técnica Stratfied K-Fold, pode-se garantir que a proporção entre as classes seja preservada em cada um dos folds.
  7. Com a validação cruzada é possível avaliar a consistência do modelo perante todos os folds disponíveis

Referências

1: Clube de assinaturas Anwar Hermuche

2: Clube de assinaturas André Yukio

3: StatQuest — Canal do Youtube

--

--