1. Escopo

Muito se ouve falar que O Ensemble Learning ajuda a melhorar os resultados do aprendizado de máquina combinando vários modelos, e que essa abordagem permite a produção de melhor desempenho preditivo em comparação com um único modelo. Neste post, apresento o algoritmo Stacking, combinando alguns modelos interessantes como: Elastic NET, LASSO, Ridge e SVR, Spam.

1.1 Mas afinal, o que Ensemble Learning?

Os Métodos de Ensemble são nada mais que meta-algoritmos que combinam várias técnicas de aprendizado de máquina em um único modelo preditivo para diminuir variação (bagging), viés (boosting) ou melhorar previsões (stacking).

Como esse post é voltado para melhora de previsões, os próximos passos são focados no método de Ensemble Learning via Stacking.

2. Como funciona o Stacking?

O Stacking é uma técnica de Ensemble Learning que combina vários modelos de classificação ou regressão por meio de um meta-classificador ou meta-regressor. Os modelos de nível básico são treinados com base em um conjunto de treinamento completo; o metamodelo é treinado nas saídas do modelo de nível básico como recursos. O nível básico geralmente consiste em diferentes algoritmos de aprendizado e, portanto, os conjuntos de empilhamento geralmente são heterogêneos.

Em outras palavras, basicamente o método Stacking consiste em tomar a amostra inteira, tomar rótulos via modelos de Machine Learning, sem combinação (ou seja, fazer previsões com esses modelos) e posteriormente, utilizando esses rótulos como entradas de um “novo conjunto de dados”, prever a variável resposta dos dados originais.

Talvez, o esquema visual mostrado abaixo possa te ajudar. Perceba que cada modelo simples (em vermelho) é aplicado individualmente aos dados orginais, a fim de prever a variável resposta (criar rótulos). Perceba também que esse rótulo estimado por cada modelo utilizado atuará como uma “variável explicativa” (entradas) para meu novo banco de dados. Por fim, o modelo combinado é apenas aplicar a um modelo qualquer (pode inclusive ser um dos mesmos utilizados individualmente) os dados formados pelos rótulos a fim de predizer o rótulo dos dados originais.

library(visNetwork)
modelos = data.frame(id = 1:7, group = c(rep("Modelo Simples",6),"Modelo Combinado"))
diagrama = data.frame(from = c(1:6), to = 7)
visNetwork(modelos, diagrama, width = "100%") %>%
  visGroups(groupname = "Modelo Combinado", shape = "icon", icon = list(code = "f0c0", size = 75)) %>%
  visGroups(groupname = "Modelo Simples", shape = "icon", icon = list(code = "f007", color = "red")) %>%
  visLegend() %>%
  addFontAwesome()

3. Vamos à aplicação

Aqui, utilizarei um conjunto de dados simulados para comparar a performance da aplicação dos modelos de modo tradicional, contra o método Stacking. Os modelos escolhidos para ilustração dessa abordagem foram: SVR com Kernel Gaussiano, SpAM, Ridge, LASSO, Elastic Net e Floresta Aleatória. A métrica de desempenho utilizada foi a Estatística de Theil (1958). A Estatística proposta por Theil (1958) é uma derivação do estudo sobre o EQM, que pela sua fácil interpretabilidade, torna a comunicação entre corpo técnico e usuários com pouco expertise na área de ciência de dados, mais facilitada. Ahlburg, D. (1984), Pindyck, R. e Rubinfeld, D. (1997) e Makridakis, S.,Wheelwright, S. e Hyndman, R. (1998), são alguns trabalhos interessantes que tratam de aplicações utilizando a decomposição dessa métrica em três parcelas conhecidas como Proporções da Estatística de Theil, representadas da seguinte forma:

\[ U_m = \frac{(\bar{Y}- \bar{\hat{Y}})^2}{\frac{1}{n}\sum_{i=1}^n \left[ Y_i-\hat{Y}_i \right]^2} \ \ ; \ \ U_r = \frac{(\sigma_Y - \sigma_{\hat{Y}})^2}{\frac{1}{n}\sum_{i=1}^n \left[ Y_i-\hat{Y}_i \right]^2} \ \ ; \ \ U_c = \frac{2(1-r)\sigma_Y\sigma_{\hat{Y}}}{ \frac{1}{n}\sum_{i=1}^n \left[ Y_i-\hat{Y}_i \right]^2}\]

Perceba que por construção, \(U_m + U_r + U_c =1\), onde quanto mais \(U_c \approx 1\), melhor a performance do modelo, e consequentemente, menor o viés \(U_m\) e a varibilidade \(U_r\) dos resultados preditos. Ou seja, além da interpretação direta por meio de \(U_c\), a utilização das “Proporções da Estatística de Theil”, permitem a análise sobre o trade-off entre viés e variância.

3.1 Dados Simulados

A idéia foi construir um banco de dados pequeno, porém bastante heterogêneo, permitindo uma melhor análise sobre a diferença em performance entre os dois métodos.

n = 100
x1 = rnorm(n, 3,1)
x2 = rgamma(n,10,4)
x3 = runif(n,3,6)
x4 = rnorm(n,2,1)^2
x5 = log(runif(n,1,2))
y = x1 + x2 +x3 + x4 +x5 
dados = data.frame(x1,x2,x3,x4,x5,y)

3.2 Funções Auxiliares

Aqui, apresento funções auxiliares para aplicação dos modelos. A função medida trás a formulação das Proporções da Estatística de Theil.

varResp = 'y'
Medidas = function(residuos,pred,teste, formula){
  ind = which(colnames(teste) == as.character(formula)[2])
  y = teste[, ind]
  estimados.medios= mean(pred)
  observados.medios= mean(y)
  estimados.desvios = sd(pred)
  observados.desvios = sd(y)
  r = cor(pred,y)
  EQM = mean((residuos)^2)
  numerador.Var = (estimados.medios - observados.medios)^2
  numerador.Vies = (estimados.desvios - observados.desvios)^2
  numerador.cov = 2*(1-r)*estimados.desvios*observados.desvios
  Um = numerador.Vies/ EQM
  Ur = numerador.Var/ EQM
  Uc = numerador.cov/EQM
  return(list(Um = Um,Ur = Ur,Uc = Uc))
}

formula = as.formula(paste(varResp, '~ .')) 

AuxSaida = function(pred, teste, formula, alg){
  ind = which(colnames(teste) == as.character(formula)[2])
  residuos = pred-teste[, ind]
  rs = Medidas(residuos,pred,teste,formula)
  rs[['Classificador']] = alg
  return(do.call(data.frame, rs))
}

3.3 Funções Auxiliares

Aqui, apresento funções referentes ao processo de data splitting. Neste trabalho, fiz a validação baseando-se na validação cruzada k-fold repetida. Para um k=10, e 10 repetições.

ValidCruzada = function(cls, dados, formula, kfolds = 10, ...){
  library(dismo)
  id = kfold(1:nrow(dados), kfolds)
  vcs = data.frame()
  for(i in 1:kfolds){
    treino = dados[id != i, ]
    teste = dados[id == i, ]
    kcls = cls(treino, teste, formula) 
    vcs = rbind(vcs, kcls)
  }
  vcs = vcs[, c(ncol(vcs), 1:(ncol(vcs) - 1))]
  avg = aggregate(. ~ Classificador, data = vcs, FUN = mean)
  std = aggregate(. ~ Classificador, data = vcs, FUN = sd)
  return(list(Media = avg, Desvio = avg, Modelos = vcs))
}

ValidCruzadarep = function(cls, dados, formula, kfolds = 10, reps = 10){
  x = data.frame()
  for(i in 1:reps) x = rbind(x, ValidCruzada(cls, dados, formula, kfolds)$Media) 
  avg = aggregate(. ~ Classificador, data = x, FUN = mean)
  std = aggregate(. ~ Classificador, data = x, FUN = sd)
  return(list(Media = avg, Desvio = std, Modelos = x))
}

3.4 Funções para os Modelos

#SVR 
library(kernlab)
KERNEL2= "rbfdot"
SVR2 = function(treino, teste, formula){
  cls = ksvm(formula, data = treino, kernel = KERNEL2)
  pred = predict(cls, newdata = data.frame(teste))
  return(pred)
}
medSVR2 = function(treino, teste, formula){
  alg = 'SVR - rbfdot' 
  pred = SVR2(treino, teste, formula)
  return(AuxSaida(pred, teste, formula, alg))
}

#SpAM
SPAM = function(treino, teste, formula){
  library(SAM)
  ind.treino = which(colnames(treino) == as.character(formula)[2])
  ind.teste = which(colnames(teste) == as.character(formula)[2])
  dados.x.treino= (treino[,-ind.treino])
  dados.y.treino= (treino[,ind.treino])
  dados.x.teste = (teste[,-ind.teste])
  for(i in 1:ncol(dados.x.treino)) {
    dados.x.treino[,i] <- as.numeric(dados.x.treino[,i])
  }
  for(i in 1:ncol(dados.x.teste)) {
    dados.x.teste[,i] <- as.numeric(dados.x.teste[,i])
  }
  cls = samQL(dados.x.treino,dados.y.treino,p=1)
  pred = predict(object=cls, dados.x.teste)
  pred= matrix(pred$values,nrow= length(teste[,ind.teste]),
               ncol=(length(pred$values)/length(teste[,ind.teste])))
  pred = apply(pred,1, mean)
  return(pred)
}

medSPAM = function(treino, teste, formula){
  alg = 'SpAM'
  pred = SPAM(treino, teste, formula)
  return(AuxSaida(pred, teste, formula, alg))
}

#Ridge
library(glmnet)
RIDGE= function(treino, teste, formula){
  ind.treino = which(colnames(treino) == as.character(formula)[2])
  ind.teste = which(colnames(teste) == as.character(formula)[2])
  dados.x.treino= data.matrix(treino[,-ind.treino])
  dados.y.treino= data.matrix(treino[,ind.treino])
  dados.x.teste = data.matrix(teste[,-ind.teste])
  cls =  cv.glmnet(dados.x.treino,dados.y.treino,alpha = 0,type.measure = "mse")
  lambda= as.numeric(cls$lambda.1se)
  pred = predict(object=cls, s =lambda, newx =dados.x.teste)
  return(pred)
}
medRIDGE = function(treino, teste, formula){
  alg = 'RIDGE' 
  pred = RIDGE(treino, teste, formula)
  return(AuxSaida(pred, teste, formula, alg))
}

#LASSO
library(glmnet)
LASSO= function(treino, teste, formula){
  ind.treino = which(colnames(treino) == as.character(formula)[2])
  ind.teste = which(colnames(teste) == as.character(formula)[2])
  dados.x.treino= data.matrix(treino[,-ind.treino])
  dados.y.treino= data.matrix(treino[,ind.treino])
  dados.x.teste = data.matrix(teste[,-ind.teste])
  cls =  cv.glmnet(dados.x.treino,dados.y.treino,alpha = 1,type.measure = "mse")
  lambda= as.numeric(cls$lambda.1se)
  pred = predict(object=cls, s =lambda, newx =dados.x.teste)
  return(pred)
}
medLASSO = function(treino, teste, formula){
  alg = 'LASSO' 
  pred = LASSO(treino, teste, formula)
  return(AuxSaida(pred, teste, formula, alg))
}

#ELASTIC NET
library(glmnet)
E.NET= function(treino, teste, formula){
  ind.treino = which(colnames(treino) == as.character(formula)[2])
  ind.teste = which(colnames(teste) == as.character(formula)[2])
  dados.x.treino= data.matrix(treino[,-ind.treino])
  dados.y.treino= data.matrix(treino[,ind.treino])
  dados.x.teste = data.matrix(teste[,-ind.teste])
  cls =  cv.glmnet(dados.x.treino,dados.y.treino,alpha = 0.5,type.measure = "mse")
  lambda= as.numeric(cls$lambda.1se)
  pred = predict(object=cls, s =lambda, newx =dados.x.teste)
  return(pred)
}
medE.NET = function(treino, teste, formula){
  alg = 'ELASTIC NET' 
  pred = E.NET(treino, teste, formula)
  return(AuxSaida(pred, teste, formula, alg))
}


#FLORESTAS ALEAT搼㸳RIAS 
Floresta = function(treino, teste, formula){
  library(randomForest) 
  cls = randomForest(formula, data = data.frame(treino),importance= TRUE,
                     proximity=TRUE)
  pred = predict(cls, newdata = data.frame(teste))
  return(pred)
}
medFloresta = function(treino, teste, formula){ 
  alg = 'Floresta Aleat昼㸳ria' 
  pred = Floresta(data.frame(treino), data.frame(teste), formula)
  return(AuxSaida(pred, teste, formula, alg))
}

3.5 Aplicação Tradicional

tabela.de.desempenho = matrix(NA, ncol = 4, nrow = 6)
colnames(tabela.de.desempenho) = c("Modelos","Um","Ur", "Uc")
tabela.de.desempenho = data.frame(tabela.de.desempenho)
medida  = medSVR2
nome.medida = "SVR - Kernel Gaussiano"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho[1,] = c(nome.medida,round(saida$Media[[2]],3), 
                             round(saida$Media[[3]],3),round(saida$Media[[4]],3))
medida  = medE.NET
nome.medida = "Elastic Net"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho[2,] = c(nome.medida, round(saida$Media[[2]],3), 
                             round(saida$Media[[3]],3),round(saida$Media[[4]],3))
medida  = medLASSO
nome.medida = "Lasso"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho[3,] = c(nome.medida,round(saida$Media[[2]],3), 
                             round(saida$Media[[3]],3),round(saida$Media[[4]],3))
medida  = medRIDGE
nome.medida = "Ridge"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho[4,] = c(nome.medida,round(saida$Media[[2]],3), 
                             round(saida$Media[[3]],3),round(saida$Media[[4]],3))
medida  = medSPAM
nome.medida = "SpAM"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho[5,] = c(nome.medida,round(saida$Media[[2]],3), 
                           round(saida$Media[[3]],3),round(saida$Media[[4]],3))
medida  = medFloresta
nome.medida = "Floresta Aleat昼㸳ria"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho[6,] = c(nome.medida, round(saida$Media[[2]],3), 
                             round(saida$Media[[3]],3),round(saida$Media[[4]],3))
tabela.de.desempenho= tabela.de.desempenho[order(tabela.de.desempenho[,4]),]
library(formattable)
tabela.de.desempenho = data.frame(tabela.de.desempenho)
formattable(tabela.de.desempenho, list(Uc =color_tile("white", "red")))
Modelos Um Ur Uc
4 Ridge 0.945 0.119 0.033
5 SpAM 0.754 0.144 0.197
6 Floresta Aleatria 0.695 0.107 0.296
2 Elastic Net 0.671 0.122 0.305
3 Lasso 0.495 0.141 0.46
1 SVR - Kernel Gaussiano 0.333 0.1 0.667

3.6 Aplicação via Stacking

Essa seção está dividia em duas etapas: na primeira construímos o banco de dado, conforme descrito na Seção 1; por fim, na segunda etapa, fazemos tunagem sobre os modelos estudados a fim de encontrar o melhor regressor.

3.6.1 Construção do “Novo Banco de Dados”

library(knitr)
library(kableExtra)
treino = dados
teste = dados 
###########################################################
y_floresta = Floresta(treino, teste, formula)
y_E.NET = c(E.NET(treino, teste, formula))
y_LASSO = c(LASSO(treino, teste, formula))
y_RIDGE = c(RIDGE(treino, teste, formula))
y_SPAM = SPAM(treino, teste, formula)
y_SVR2 = SVR2(treino, teste, formula)
dados.preditos = data.frame('Floresta' = y_floresta, 'E.Net' = y_E.NET,
                 'Lasso' = y_LASSO, 'Ridge' = y_RIDGE, 'Spam' = y_SPAM,
                "SVR" = y_SVR2,"Y" = dados$y)
kable(dados.preditos[1:10,], format = "markdown", digits = 2)
Floresta E.Net Lasso Ridge Spam SVR Y
14.36 14.51 14.50 14.51 14.46 14.17 14.55
15.68 16.12 16.08 16.05 14.91 16.67 16.29
20.63 23.35 23.40 22.73 21.92 23.06 23.51
12.01 12.05 12.04 12.17 12.43 12.09 12.05
9.95 8.10 8.12 8.52 9.99 8.90 7.86
19.60 20.93 20.99 20.46 19.92 21.21 20.99
13.73 14.03 14.03 14.03 14.23 13.63 14.01
12.49 12.56 12.53 12.70 12.73 12.26 12.58
18.77 19.94 19.91 19.66 18.27 19.51 20.16
13.35 13.51 13.53 13.54 13.77 13.30 13.45

3.6.2 Tunagem no modelo meta-regressor.

Quando não sabemos qual modelo atua melhor como meta-regressor é interessagem testar uma série de modelos escolhendo o de melhor performance. Neste caso, fiz uso dos mesmo modelos aplicados anteriormente. Isto facilita a comparação entre os métodos tradicional e stacking, uma vez que estaremos comparando os mesmos modelos, mas aplicados à diferentes algoritmos.

dados = dados.preditos 
varResp = 'Y'
formula = as.formula(paste(varResp, '~ .')) 
tabela.de.desempenho.stacking = matrix(NA, ncol = 4, nrow = 6)
colnames(tabela.de.desempenho.stacking) = c("Modelos","Um","Ur", "Uc")
tabela.de.desempenho.stacking = data.frame(tabela.de.desempenho.stacking)
medida  = medSVR2
nome.medida = "SVR - Kernel Gaussiano"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho.stacking[1,] = c(nome.medida,round(saida$Media[[2]],3), 
                             round(saida$Media[[3]],3),round(saida$Media[[4]],3))
medida  = medE.NET
nome.medida = "Elastic Net"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho.stacking[2,] = c(nome.medida, round(saida$Media[[2]],3), 
                             round(saida$Media[[3]],3),round(saida$Media[[4]],3))
medida  = medLASSO
nome.medida = "Lasso"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho.stacking[3,] = c(nome.medida,round(saida$Media[[2]],3), 
                             round(saida$Media[[3]],3),round(saida$Media[[4]],3))
medida  = medRIDGE
nome.medida = "Ridge"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho.stacking[4,] = c(nome.medida,round(saida$Media[[2]],3), 
                             round(saida$Media[[3]],3),round(saida$Media[[4]],3))
medida  = medSPAM
nome.medida = "SpAM"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho.stacking[5,] = c(nome.medida,round(saida$Media[[2]],3), 
                           round(saida$Media[[3]],3),round(saida$Media[[4]],3))
medida  = medFloresta
nome.medida = "Floresta Aleat昼㸳ria"
saida = ValidCruzadarep(medida, dados, formula, kfolds = 10, reps = 10)
tabela.de.desempenho.stacking[6,] = c(nome.medida, round(saida$Media[[2]],3), 
                             round(saida$Media[[3]],3),round(saida$Media[[4]],3))
tabela.de.desempenho.stacking= tabela.de.desempenho.stacking[order(tabela.de.desempenho.stacking[,4]),]
tabela.de.desempenho.stacking = data.frame(tabela.de.desempenho.stacking)
formattable(tabela.de.desempenho.stacking, list(Uc =color_tile("white", "red")))
Modelos Um Ur Uc
5 SpAM 0.979 0.109 0.011
3 Lasso 0.733 0.111 0.255
1 SVR - Kernel Gaussiano 0.436 0.169 0.488
4 Ridge 0.283 0.114 0.702
2 Elastic Net 0.286 0.104 0.71
6 Floresta Aleatria 0.24 0.121 0.737

4.Comparação dos Resultados

Perceba que podemos destacar um ganho preditivo, gerado principalmente pela redução da parcela da Estatistica de Theil destinada ao viés, entre a melhor aplicação tradicional (Modelo SVR com Kernel Gaussiano) e o melhor método de aprendizagem via Stacking (pelo modelo Floresta Aleatória).

Vale ressaltar que nem sempre o ganho é significativo o sufiente para que o método Stacking seja utilizado. Isto porque, além de elevar o custo computacional, pode resultar em problemas como overffintg. Afinal, aplicar Stacking é elevar a complexidade do modelo final, e quando isso é feito sem uma análise criteriosa pode tornar seu modelo pouco generalista.