Cosine Similarity Search for new documents using Scikit-Learn

Some time ago I was working in a project of similarity search (i.e. bring similar items based on text) using Scikit-Learn and one topic that it’s not covered in the documentation is: What if I got new data? How can I calculate the similarity of this new text?

Some lovely souls of Stack Overlow gave me the tip and I’m sharing it:

# Source: https://stackoverflow.com/questions/44862712/td-idf-find-cosine-similarity-between-new-document-and-dataset/44863365#44863365
import pandas as pd
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
# Generate DF
df = \
pd.DataFrame({'jobId' : [1, 2, 3, 4, 5],
'serviceId' : [99, 88, 77, 66, 55],
'text' : ['Ich hätte gerne ein Bild an meiner Wand.',
'Ich will ein Bild auf meinem Auto.',
'Ich brauche ein Bild auf meinem Auto.',
'Ich brauche einen Rasenmäher für meinen Garten.',
'Ich brauche einen Maler, der mein Haus streicht.'
]})
# Show DF
df
# Vectorizer to convert a collection of raw documents to a matrix of TF-IDF features
vectorizer = TfidfVectorizer()
# Learn vocabulary and idf, return term-document matrix.
tfidf = vectorizer.fit_transform(df['text'].values.astype('U'))
# Array mapping from feature integer indices to feature name
words = vectorizer.get_feature_names()
# Compute cosine similarity between samples in X and Y.
similarity_matrix = cosine_similarity(tfidf, tfidf)
# Matrix product
similarity_matrix
# Instead of using fit_transform, you need to first fit
# the new document to the TFIDF matrix corpus like this:
queryTFIDF = TfidfVectorizer().fit(words)
# We can check that using a new document text
query = 'Mähen Sie das Gras in meinem Garten, pflanzen Sie Blumen in meinem Garten.'
# Now we can 'transform' this vector into that matrix shape by using the transform function:
queryTFIDF = queryTFIDF.transform([query])
# As we transformed our query in a tfidf object
# we can calculate the cosine similarity in comparison with
# our pevious corpora
cosine_similarities = cosine_similarity(queryTFIDF, tfidf).flatten()
# Get most similar jobs based on next text
related_product_indices = cosine_similarities.argsort()[:11:1]
related_product_indices
# array([3, 2, 1, 4, 0])
Cosine Similarity Search for new documents using Scikit-Learn

RESTful API para servicing de modelos de Machine Learning com R, H2O.ai e Plumber

Em algum momento todo cientista de dados ou engenheiro de machine learning já se deparou com enquetes e blog posts com a seguinte pergunta: “Para ambientes de produção, qual é melhor R ou Python?“.

Na maioria das vezes a linguagem Python sempre leva uma vantagem neste quesito; seja por conta da sua facilidade de aprendizado, ou (especulo eu) o fato que muitos usuários não entendem a diferença entre uma linguagem de uso geral e para uma linguagem de programação com objetivo de computação científica em script.

Existem inúmeros recursos que detonam o uso da linguagem R em produção por inúmeros fatores, alguns destes muito justos como:

  • Grande parte dos usuários não têm background em desenvolvimento de software;
  • Devido ao ponto anterior, não existe na comunidade uma cultura de práticas como gestão de dependências, testes, tratamento de erros e logging (mesmo com boas ferramentas para fazer tudo isso);
  • Argumentos ocultos na linguagem, como o inacreditável stringAsFactors = TRUE que só foi corrigido agora na versão 4.0.0 (i.e. não tem retrocompatibilidade!). Em outras palavras, um bug virou feature e um update de major version é necessário para corrigir um comportamento na linguagem por um erro de design da linguagem (uma boa explicação para isso está aqui neste post);
  • A falta de familiaridade dos usuários do R com pacotes/software que poderiam garantir uma maior robustez em termos de produtos de dados/inferência em produção como packrat para checkpointing e Docker para setup de front-end.

Contudo, em termos práticos nem sempre é possível que todos os cientistas de dados, analistas e demais usuários migrem para o Python devido à inúmeros motivos (e.g. custo de migração, custo de treino de pessoal, riscos de negócios para de retirar algo de produção, etc.).

Com isso, parte dos usuários em R acabam sem ter acesso a formas de colocar os seus modelos em produção e o mais importante: realizar o servicing desses modelos (i.e. receber requisições e dar respostas com as predições em uma plataforma que vai ser, a grosso modo, como uma espécie de serviço web em que a API vai servir para comunicar duas aplicações).

A ideia desse post é ajudar estas pessoas a terem o poder de subir uma RESTful APIs em produção para fazer o servicing desses modelos, e com uma ajuda de um time de infraestrutura esses códigos podem ser colocados em um servidor ou em uma imagem Docker, e assim estar disponível para outras aplicações.

Mas para dar um pouco mais de realismo no nosso exemplo, vamos usar um exemplo de um Banco chamado de Layman Brothers [N1] que tem como objetivo entregar um serviço de Machine Learning que informa se um cliente vai entrar em uma situação de atraso de pagamento ou não. E para isso vamos usar (apenas para fins de simplicidade) o AutoML para realizar o treinamento deste modelo.

AutoML (Automatic Machine Learning)

Para quem não sabe o conceito de AutoML (Automatic Machine Learning, ou treinamento automático de Machine Learning) é o processo de automação de todo o pipeline de treinamento de modelos de machine learning através do treinamento de inúmeros modelos dentro de um limite de tempo ou condição de parada (e.g. AUC, RMSE, Recall, Precision, etc).

Isto permite que mesmo pessoas não especialistas em Data Science e Machine Learning apenas passem os dados para o AutoML, e este realiza inúmeros treinamentos com varias combinações de algoritmos dentro de um determinado limite de tempo.

A ideia aqui é simplificar o processo de treinamento de ponta a ponta, fazendo o treino de inúmeros modelos simples ou a combinação de vários algoritmos (XGBoost, Deep Learning, GLM, GBM, etc.) com varias combinações de hiperparâmetros.

Em outras palavras, ao invés de haver um ser humano testando manualmente diversas combinações, o AutoML já faz tudo isso.

Em alguns casos, os modelos do AutoML chegam até mesmo bater cientistas de dados em leaderboards do Kaggle, como podemos ver no exemplo abaixo em que a Erin LeDell com apenas 100 minutos de treinamento no AutoML conseguiu ficar em 8º lugar em um Hackathon no Kaggle:

AutoML no R com H2O.ai

No treinamento do nosso modelo de Default Prediction (ou previsão de calotes para os mais simplistas) do Layman Brothers, nós vamos usar a linguagem R e a implementação do AutoML no H2O (eu já postei alguns tutoriais e considerações sobre essa ferramenta aqui no blog anteriormente, vale a pena conferir).

No nosso caso vamos usar o AutoML to H2O devido ao fato de que além da ferramenta usar os algoritmos mais comuns, a implementação do AutoML no H2O tem também a opção de Stacked Ensembles de todos os modelos previamente treinados, e de quebra nos dá o leaderboard dos melhores modelos.

Para o treinamento do nosso modelo vamos usar os dados do nosso Layman Brothers no AutoML.

A estrutura do projeto terá 5 pastas com nomes autoexplicativos: (1) api, (2) data, (3) logs, (4) models e (5) src (onde ficará o código fonte). O caminho pode deve ser alterado (recomendável) e como não estamos usando um conjunto de dados que está no projeto mas sim em um endereço do GitHub, a pasta data é dispensável.

Antes de mais nada, vamos carregar da biblioteca de logging e usar os caminhos padrão como constantes para armazenar os nossos objetos:

if (!require('logger')) install.packages('logger'); library('logger')
start_time_pipeline <- Sys.time()
log_debug('Training pipeline start time – {start_time_pipeline}')
# Local directories
ROOT_DIR <- getwd()
PROJECT_DIR <-
'r-api-data-hackers'
DATA_DIR <- 'data'
MODELS_DIR <- 'models'
API_DIR <- 'api'
LOGS_DIR <- 'logs'
get_artifact_path <- function(file_name,
artifact_dir,
root_dir=ROOT_DIR,
project_dir=PROJECT_DIR){
artifact_path <-
file.path(root_dir,
project_dir,
artifact_dir,
file_name)
return (artifact_path)
}
logging_file_path <-
get_artifact_path("training_pipeline_auto_ml.log", LOGS_DIR)
log_appender(appender_file(logging_file_path))
log_layout(layout_glue_colors)
log_threshold(DEBUG)
log_info('Start logging')

Com o log criado, vamos agora instalar o H2O direto do CRAN.

install_dependencies <- function(){
package_url_logger <- 'https://cran.rstudio.com/bin/macosx/el-capitan/contrib/3.6/logger_0.1.tgz'
package_url_h2o <- 'https://cran.rstudio.com/bin/macosx/el-capitan/contrib/3.6/h2o_3.30.0.1.tgz'
package_url_cluster <- 'https://cran.rstudio.com/bin/macosx/el-capitan/contrib/3.6/cluster_2.1.0.tgz'
package_url_dplyr <- 'https://cran.rstudio.com/bin/macosx/el-capitan/contrib/3.6/dplyr_0.8.5.tgz'
package_url_tidyverse <- 'https://cran.rstudio.com/bin/macosx/el-capitan/contrib/3.6/tidyverse_1.3.0.tgz'
log_debug('logger CRAN URL: {package_url_logger}')
log_debug('h2o CRAN URL: {package_url_h2o}')
log_debug('cluster CRAN URL: {package_url_cluster}')
log_debug('dplyr CRAN URL: {package_url_dplyr}')
log_debug('tidyverse CRAN URL: {package_url_tidyverse}')
packages_urls <- c(
package_url_logger,
package_url_dplyr,
package_url_cluster,
package_url_tidyverse,
package_url_h2o
)
for(url in packages_urls)
{for(package_url in url)
log_info('Installing {} package')
{install.packages(package_url, repos=NULL, type='source')}
log_info('Package {package_url} installation finished')
}
}
log_info('Start installing dependencies')
install_dependencies()
log_info('Dependencies installed')
packageVersion_logger <- packageVersion('logger')[1]
packageVersion_h2o <- packageVersion('h2o')[1]
packageVersion_cluster <- packageVersion('cluster')[1]
packageVersion_dplyr <- packageVersion('dplyr')[1]
packageVersion_tidyverse <- packageVersion('tidyverse')[1]
log_debug('logger Version: {packageVersion_logger}')
log_debug('h2o Version: {packageVersion_h2o}')
log_debug('cluster Version: {packageVersion_cluster}')
log_debug('dplyr Version: {packageVersion_dplyr}')
log_debug('tidyverse Version: {packageVersion_tidyverse}')
log_info('Loading packages')
packages <- c(
"logger",
"h2o",
"cluster",
"dplyr",
"tidyverse")
invisible(lapply(packages, library, character.only = TRUE))
log_info('Packages loaded')
session_info_base_packages <- sessionInfo()$basePkgs
log_info('Session Info Base Packages: {session_info_base_packages}')
session_info_loaded_packages <- sessionInfo()$loadedOnly
log_debug('Session Info Loaded Packages: {session_info_loaded_packages}')

Um ponto que tem que ser levado em consideração aqui, é que eu estou realizando a instalação dos pacotes direto do CRAN devido ao fato de que, ao menos pra mim, as ferramentas de gestão de dependências do R não tem uma usabilidade boa em comparação com o Homebrew, npm e até mesmo o pip.

Dependências instaladas, vamos iniciar o nosso H2O cluster:

log_info('Initializing H2O')
host = "localhost"
host_port = 54321
cpus = 1
memory_size = "7g"
log_debug('H2O Cluster host: {host}')
log_debug('H2O Cluster host port: {host_port}')
log_debug('H2O Cluster Number CPUs: {cpus}')
log_debug('H2O Cluster Memory Size allocated: {memory_size}')
h2o.init(
ip = host,
port = host_port,
nthreads = cpus,
max_mem_size = memory_size
)
cluster_status <- h2o.clusterStatus()
log_debug('H2O Cluster Status Info: {cluster_status}')

No nosso caso vamos usar todos os CPUs das máquinas que por ventura estiverem no cluster (cpus=-1). Como eu estou rodando em uma máquina apenas, eu vou limitar o tamanho da memória em 7Gb.

Cluster iniciado, vamos agora fazer a carga dos nossos dados no H2O, separar os datasets de treinamento e teste, e determinar as variáveis [N2] que vamos usar no treinamento dos modelos:

log_debug('Load data')
layman_brothers_url =
"https://raw.githubusercontent.com/fclesio/learning-space/master/Datasets/02%20-%20Classification/default_credit_card.csv"
layman_brothers.hex = h2o.importFile(path = layman_brothers_url,
destination_frame = "layman_brothers.hex")
log_debug('Data loaded')
log_debug('Transform default variable to factor')
layman_brothers.hex$DEFAULT = as.factor(layman_brothers.hex$DEFAULT)
log_debug('Construct test and train sets using sampling')
layman_brothers.split <- h2o.splitFrame(data = layman_brothers.hex,
ratios = 0.90, seed =42)
layman_brothers.train <- layman_brothers.split[[1]]
layman_brothers.test <- layman_brothers.split[[2]]
qty_samples_train <- nrow(layman_brothers.train)
qty_samples_test <- nrow(layman_brothers.test)
log_debug('Training set with {qty_samples_train} records')
log_debug('Test set with {qty_samples_test} records')
log_debug('Set predictor and response variables')
y = "DEFAULT"
x = c(
"LIMIT_BAL"
,"SEX"
,"EDUCATION"
,"MARRIAGE"
,"AGE"
,"PAY_0"
,"PAY_2"
,"PAY_3"
,"PAY_4"
,"PAY_5"
,"PAY_6"
,"BILL_AMT1"
,"BILL_AMT2"
,"BILL_AMT3"
,"BILL_AMT4"
,"BILL_AMT5"
,"BILL_AMT6"
,"PAY_AMT1"
,"PAY_AMT2"
,"PAY_AMT3"
,"PAY_AMT4"
,"PAY_AMT5"
,"PAY_AMT6")

Agora que os nossos dados estão carregados, vamos realizar o treinamento usando o AutoML:

log_debug('Run AutoML for model training')
start_time <- Sys.time()
aml <-
h2o.automl(x=x,
y=y,
training_frame = layman_brothers.train,
validation_frame = layman_brothers.test,
max_models = 20,
nfolds = 5,
stopping_metric = c("AUC"),
project_name = "data-hackers-auto-ml",
sort_metric = c("AUC"),
verbosity = "warn",
seed = 42
)
end_time <- Sys.time()
log_debug('AutoML training ended')
time_elapsed <- end_time start_time
log_debug('Time elapsed – {time_elapsed}')

No nosso caso, vamos usar no máximo 20 modelos (max_models = 20), com o AutoML fazendo o Cross Validation com 5 partições (nfolds = 5), travando semente randômica em 42 (seed = 42) e com o AUC como métrica que vai ser a referência no treinamento para determinar qual modelo é melhor (sort_metric = c("AUC")).

Existem inúmeras outras opções que podem ser configuradas, mas vamos usar estas para fins de simplicidade.

Após o treinamento, podemos armazenar as informações do leaderboard no log; ou verificar no console:

lb <- aml@leaderboard
for (model_auto_ml in 1:nrow(lb)){
auto_ml_model_id <-
as.list(lb$model_id)[model_auto_ml][1]
auto_ml_auc <-
as.list(lb$auc)[model_auto_ml][1]
auto_ml_logloss <-
as.list(lb$logloss)[model_auto_ml][1]
auto_ml_aucpr <-
as.list(lb$aucpr)[model_auto_ml][1]
auto_ml_mean_per_class_error <-
as.list(lb$mean_per_class_error)[model_auto_ml][1]
auto_ml_rmse <-
as.list(lb$rmse)[model_auto_ml][1]
auto_ml_mse <-
as.list(lb$mse)[model_auto_ml][1]
log_info("AutoML – model_id: {auto_ml_model_id} – auc: {auto_ml_auc} – logloss: {auto_ml_logloss} – aucpr: {auto_ml_aucpr} – mean_per_class_error: {auto_ml_mean_per_class_error} – rmse: {auto_ml_rmse} – mse: {auto_ml_mse}")
}
log_info("AutoML Winning Model – model_id: {aml@leader@model_id} – algorithm: {aml@leader@algorithm} – seed: {aml@leader@parameters$seed} – metalearner_nfolds: {aml@leader@parameters$metalearner_nfolds} – training_frame: {aml@leader@parameters$training_frame} – validation_frame: {aml@leader@parameters$validation_frame}")
model_file_path <-
get_artifact_path("", MODELS_DIR)
log_info("Model destination path: {model_file_path}")
model_path <- h2o.saveModel(object=aml@leader,
path=model_file_path,
force=TRUE)
log_info("Model artifact path: {model_path}")
end_time_pipeline <- Sys.time()
log_debug('Training pipeline end time – {end_time_pipeline}')
time_elapsed_pipeline <- end_time_pipeline start_time_pipeline
log_debug('Training pipeline time elapsed – {time_elapsed_pipeline[1]} mins')
log_debug('Training pipeline finished')

Se tudo ocorreu bem aqui, no final teremos o modelo vencedor serializado na pasta models pronto para ser usado pela nossa RESTful API [N4].

Para ler as informações do log durante o treinamento do modelo, basta apenas abrir o arquivo training_pipeline_auto_ml.log no sistema operacional, ou executar o comando $ tail -F training_pipeline_auto_ml.log durante a execução.

Isso pode ajudar, por exemplo, a ter o registro de quanto cada fase esta levando para acontecer. Caso a pessoa responsável pelo script queira, podem ser aplicados tratamentos de erros no código e posterior logging desses erros para facilitar a depuração de qualquer problema que venha acontecer.

Com o nosso modelo treinado e serializado, vamos agora subir o nosso endpoint [N3].

Configuração do endpoint da RESTful API no Plumber

Para o servicing dos nossos modelos, vamos usar o Plumber que é uma ferramenta que converte código em R em uma web API [N4]. No nosso caso, vamos usar o Plumber como ferramenta para subir a nossa API e fazer o servicing do modelo [N3].

Primeiramente vamos configurar o nosso endpoint. Resumidamente, um endpoint é um caminho de URL que vai comunicar-se com uma API [N4]. Este arquivo será chamado de endpoint.r.

Este endpoint vai ser responsável por fazer a ligação do nosso arquivo em que estará a nossa função de predição (falaremos sobre ele mais tarde) e as requisições HTTP que a nossa API vai receber.

Vamos colocar aqui também um arquivo de log, neste caso chamado de automl_predictions.log em que vamos registrar todas as chamadas neste endpoint.

library(plumber)
library('logger')
ROOT_DIR <- getwd()
PROJECT_DIR <-
'r-api-data-hackers'
API_DIR <- 'api'
LOGS_DIR <- 'logs'
api_path_object <-
file.path(ROOT_DIR,
PROJECT_DIR,
API_DIR,
"api.R")
logging_file_path <-
file.path(ROOT_DIR,
PROJECT_DIR,
LOGS_DIR,
"automl_predictions.log")
log_appender(appender_file(logging_file_path))
log_layout(layout_glue_colors)
log_threshold(DEBUG)
convert_empty <- function(string) {
if (string == "") {
""
} else {
string
}
}
r <- plumb(api_path_object)
r$registerHooks(
list(
preroute = function() {
# Start timer for log info
tictoc::tic()
},
postroute = function(req, res) {
end <- tictoc::toc(quiet = TRUE)
log_info('REMOTE_ADDR: {convert_empty(req$REMOTE_ADDR)}, HTTP_USER_AGENT: "{convert_empty(req$HTTP_USER_AGENT)}", HTTP_HOST: {convert_empty(req$HTTP_HOST)}, REQUEST_METHOD: {convert_empty(req$REQUEST_METHOD)}, PATH_INFO: {convert_empty(req$PATH_INFO)}, request_status: {convert_empty(res$status)}, RESPONSE_TIME: {round(end$toc – end$tic, digits = getOption("digits", 5))}')
}
)
)
r
r$run(host="127.0.0.1", port=8000, swagger=TRUE)
view raw endpoint.r hosted with ❤ by GitHub

Os mais atentos repararam que existem 3 funções neste endpoint. A primeira é a convert_empty que vai somente colocar um traço caso alguma parte das informações da requisição estiverem vazias.

A segunda é a função r$registerHooks que é oriunda de um objeto do Plumber e vai registrar todas as informações da requisição HTTP como o IP que está chamando a API, o usuário, e o tempo de resposta da requisição.

A terceira e ultima função é função r$run que vai determinar o IP em que a API vai receber as chamadas (host="127.0.0.1") a porta (port=8000) e se a API vai ter o Swagger ativo ou não (swagger=TRUE). No nosso caso vamos usar o Swagger para fazer os testes com a nossa API e ver se o serviço está funcionando ou não.

Esta vai ser o ultimo script que será executado, e mais tarde vamos ver como ele pode ser executado sem precisarmos entrar no RStudio ou demais IDEs.

Contudo, vamos agora configurar a nossa função de predição dentro do Plumber.

Configuração da função de predição dentro no Plumber

No nosso caso, vamos criar o arquivo chamado api.R. Este arquivo vai ser usado para (a) pegar os dados da requisição, (b) fazer um leve processamento nestes dados, (c) passar os mesmos para o modelo, (d) pegar o resultado e devolver para a o endpoint.

Esse arquivo vai ser referenciado no nosso exemplo, na linha 16 do arquivo endpoint.r.

Contudo, vamos agora entender cada uma das partes do arquivo api.r.

Aos moldes do que foi feito anteriormente, vamos iniciar o nosso arquivo buscando o caminho em que o nosso modelo esta salvo para posteriormente fazer a carga do mesmo em memória (linha 27 – “StackedEnsemble_AllModels_AutoML_20200428_181354") e posteriormente vamos iniciar o nosso logging (linha 27 "api_predictions.log").

No neste exemplo, o modelo serializado é o “StackedEnsemble_AllModels_AutoML_20200428_181354" que foi o melhor do leaderboard do AutoML.

Na linha 34, fazemos a carga do modelo em memória e a contar deste ponto o mesmo está pronto para receber dados para realizar as suas predições.

library('logger')
library(h2o)
library('data.table')
h2o.init()
ROOT_DIR <- getwd()
PROJECT_DIR <-
'r-api-data-hackers'
MODELS_DIR <- 'models'
API_DIR <- 'api'
LOGS_DIR <- 'logs'
model_path_object <-
file.path(ROOT_DIR,
PROJECT_DIR,
MODELS_DIR,
"StackedEnsemble_AllModels_AutoML_20200428_181354")
logging_file_path <-
file.path(ROOT_DIR,
PROJECT_DIR,
LOGS_DIR,
"api_predictions.log")
log_appender(appender_file(logging_file_path))
log_layout(layout_glue_colors)
log_threshold(DEBUG)
log_info('Load saved model')
saved_model <-
h2o.loadModel(model_path_object)
log_info('Model loaded')

Logging e modelo carregado, agora entra a parte em que vamos configurar as variáveis que serão recebidas pelo modelo. No nosso caso, temos o seguinte código:

#* Return the prediction from Laymans Brothers Bank Model
#* @param LIMIT_BAL
#* @param SEX
#* @param EDUCATION
#* @param MARRIAGE
#* @param AGE
#* @param PAY_0
#* @param PAY_2
#* @param PAY_3
#* @param PAY_4
#* @param PAY_5
#* @param PAY_6
#* @param BILL_AMT1
#* @param BILL_AMT2
#* @param BILL_AMT3
#* @param BILL_AMT4
#* @param BILL_AMT5
#* @param BILL_AMT6
#* @param PAY_AMT1
#* @param PAY_AMT2
#* @param PAY_AMT3
#* @param PAY_AMT4
#* @param PAY_AMT5
#* @param PAY_AMT6
#* @post /prediction

Os caracteres #* significam que estamos informado os parâmetros que serão passados para a função.

Abaixo temos o comando #* @post /prediction que, a grosso modo, vai ser o nome da pagina que vai receber o método POST. [N4]

Agora que temos as variáveis que o modelo vai receber devidamente declaradas para o Plumber (ou seja, a nossa API é capaz de receber os dados através das requisições), vamos criar a função que vai receber os dados e vai realizar a predição:

function(LIMIT_BAL, SEX, EDUCATION, MARRIAGE,
AGE, PAY_0, PAY_2, PAY_3, PAY_4, PAY_5,
PAY_6, BILL_AMT1, BILL_AMT2, BILL_AMT3,
BILL_AMT4, BILL_AMT5, BILL_AMT6, PAY_AMT1,
PAY_AMT2, PAY_AMT3, PAY_AMT4, PAY_AMT5, PAY_AMT6) {
LIMIT_BAL <- as.numeric(LIMIT_BAL)
SEX <- as.numeric(SEX)
EDUCATION <- as.numeric(EDUCATION)
MARRIAGE <- as.numeric(MARRIAGE)
AGE <- as.numeric(AGE)
PAY_0 <- as.numeric(PAY_0)
PAY_2 <- as.numeric(PAY_2)
PAY_3 <- as.numeric(PAY_3)
PAY_4 <- as.numeric(PAY_4)
PAY_5 <- as.numeric(PAY_5)
PAY_6 <- as.numeric(PAY_6)
BILL_AMT1 <- as.numeric(BILL_AMT1)
BILL_AMT2 <- as.numeric(BILL_AMT2)
BILL_AMT3 <- as.numeric(BILL_AMT3)
BILL_AMT4 <- as.numeric(BILL_AMT4)
BILL_AMT5 <- as.numeric(BILL_AMT5)
BILL_AMT6 <- as.numeric(BILL_AMT6)
PAY_AMT1 <- as.numeric(PAY_AMT1)
PAY_AMT2 <- as.numeric(PAY_AMT2)
PAY_AMT3 <- as.numeric(PAY_AMT3)
PAY_AMT4 <- as.numeric(PAY_AMT4)
PAY_AMT5 <- as.numeric(PAY_AMT5)
PAY_AMT6 <- as.numeric(PAY_AMT6)
log_debug('Generate data.table with converted variables')
predict_objects <- data.frame(
LIMIT_BAL = c(LIMIT_BAL),
SEX = c(SEX),
EDUCATION = c(EDUCATION),
MARRIAGE = c(MARRIAGE),
AGE = c(AGE),
PAY_0 = c(PAY_0),
PAY_2 = c(PAY_2),
PAY_3 = c(PAY_3),
PAY_4 = c(PAY_4),
PAY_5 = c(PAY_5),
PAY_6 = c(PAY_6),
BILL_AMT1 = c(BILL_AMT1),
BILL_AMT2 = c(BILL_AMT2),
BILL_AMT3 = c(BILL_AMT3),
BILL_AMT4 = c(BILL_AMT4),
BILL_AMT5 = c(BILL_AMT5),
BILL_AMT6 = c(BILL_AMT6),
PAY_AMT1 = c(PAY_AMT1),
PAY_AMT2 = c(PAY_AMT2),
PAY_AMT3 = c(PAY_AMT3),
PAY_AMT4 = c(PAY_AMT4),
PAY_AMT5 = c(PAY_AMT5),
PAY_AMT6 = c(PAY_AMT6),
stringsAsFactors = FALSE
)
log_debug('Convert to H20.ai Object…')
predict_objects <-
as.h2o(predict_objects)
log_debug('Make prediction…')
prediction <-
h2o.predict(object = saved_model,
newdata = predict_objects)
prediction <- as.data.table(prediction)
log_debug('Default: {prediction}')
return(prediction)
}

Essa é uma função simples em R que vai receber como argumentos, as variáveis que foram declaradas anteriormente para o Plumber.

Entre as linhas 8-30 eu fiz a conversão de todas as variáveis para numérico por um motivo simples: No momento em que eu passo a função direto (sem as conversões) o Plumber não faz a verificação de tipagem das variáveis antes de passar para o modelo.

Por causa desse problema eu perdi algumas horas tentando ver se havia alguma forma de fazer isso direto no Plumber, e até tem; mas no meu caso eu preferi deixar dentro da função e ter o controle da conversão lá. Na minha cabeça, eu posso deixar o tratamento de erro dentro da própria função e ao menos tentar algumas conversões, se for o caso. Mas aí vai da escolha de cada um.

Entre a linhas 34 e 59 eu construo o data.table, para posteriormente nas linhas 62 e 63 converter como objeto do H2O.ai.

Essa conversão torna-se necessária, pois os modelos do H2O.ai, até a presente versão, só aceitam objetos de dados dentro do seu próprio formato.

Finalmente entre as linhas 62 e 70 realizamos a predição propriamente dita, e retornamos a nossa predição na função.

Em seguida tem uma segunda função que pega o corpo da requisição (body) e mostra os valores no console (esses valores podem ser gravados no log também).

function(req) {
raw_body = req$postBody
print(raw_body)
}
view raw api-request-values.r hosted with ❤ by GitHub

E assim temos os nossos arquivos endpoint.r e api.r criados em que, em termos simples os arquivos tem os seguintes objetivos:

  • api.R: Eu tenho o modelo carregado em memória, eu recebo os dados, eu faço o tratamento desses inputs, jogo no modelo e devolvo uma predição. E de quebra, eu sou responsável por falar quais parâmetros o modelo vai receber.
  • endpoint.r: Eu subo a API, recebo as informações de quem está fazendo a requisição como IP e usuário, e faço a referência ao api.R que vai fazer a parte difícil da predição.

No seu caso, se voce já tiver o seu modelo, basta apenas trabalhar nos arquivos api.R e endpoint.r, e adaptar os inputs com os seus dados e colocar o seu modelo de machine learning em memória.

Agora que temos os nossos arquivos, vamos subir a nossa API.

Inicializando a RESTful API

Com os nossos arquivos da API e do nosso endpoint devidamente configurados, para inicializar a nossa API podemos executar o arquivo endpoint.R dentro do R Studio.

Entretanto, como estamos falando em um ambiente de produção, fazer isso manualmente não é prático, principalmente me um ambiente em que mudanças estão sendo feitas de forma constante.

Dessa forma, podemos inicializar essa API executando o seguinte comando na linha de comando (terminal para os usuários de Linux/MacOS):

$ R < /<<YOUR-PATH>>/r-api-data-hackers/api/endpoint.R --no-save

Na execução desse comando, teremos no terminal a seguinte imagem:

Com esse comando precisamos apenas dos arquivos nos diretórios para inicializar a nossa RESTful API que ser inicializada no endereço http://127.0.0.1:8000.

Contudo, acessando essa URL no browser não vai aparecer nada, e para isso vamos usar o Swagger para realizar os testes. Para isso, vamos acessar no nosso browser o endereço: http://127.0.0.1:8000/__swagger__/

No browser teremos uma tela semelhante a esta:

Para realizar a predição via a interface do Swagger, vamos clicar no ícone verde escrito POST. Veremos uma tela semelhante a esta:

Em seguida, vamos clicar no botão escrito “Try it out” e preencha as informações dos campos que declaramos como parâmetros lá no arquivo endpoint.r:

No final, após todas as informações estarem preenchidas, clique no botão azul que contem a palavra execute:

Clicando neste botão, podemos ver o resultado da nossa predição no response body:

O corpo dessa resposta de requisição que mandamos para a URL tem as seguintes informações:

[
  {
    "predict": "1",
    "p0": 0.5791,
    "p1": 0.4209
  }
]

Ou seja, dentro desses valores passados na requisição, o modelo do banco Layman Brothers previu que o cliente vai entrar na situação de default (ou dar o calote). Se quisermos trabalhar com as probabilidades, o modelo da essas informações na resposta, sendo que o cliente tem a probabilidade de 58% de dar o calote, contra 42% de probabilidade de não dar o calote.

Mas para os leitores que não morreram ate aqui, alguns deles podem perguntar: “Poxa Flavio, mas os clientes não vão entrar na nossa pagina swagger e fazer a requisição. Como uma aplicação em produção vai fazer o uso desse modelo?

Lembram que eu falei que essa RESTful API seria, a grosso modo, uma espécie de serviço web? Então o ponto aqui é que a aplicação principal, i.e. Plataforma do nosso banco Layman Brothers que vai receber as informações de credito, vai passar essas informações para a nossa RESTful API que está fazendo o servicing dos modelos via requisições HTTP e a nossa API vai devolver os valores da mesma maneira que vimos no corpo da mensagem anterior.

Trazendo para termos mais concretos: No momento em que a sua RESTful API está rodando, o seu modelo está pronto para ser requisitado pela aplicação principal.

Essa chamada HTTP pode ser feita copiando o comando curl que está sendo informado pelo Swagger, como podemos ver na imagem abaixo:

Neste caso, para simular a chamada que a aplicação principal do Layman Brothers tem que fazer, vamos copiar o seguinte comando curl:

curl -X POST "http://127.0.0.1:8000/prediction?PAY_AMT6=1000&PAY_AMT5=2000&PAY_AMT4=300&PAY_AMT3=200&PAY_AMT2=450&PAY_AMT1=10000&BILL_AMT6=300&BILL_AMT5=23000&BILL_AMT4=24000&BILL_AMT3=1000&BILL_AMT2=1000&BILL_AMT1=1000&PAY_6=200&PAY_5=200&PAY_4=200&PAY_3=200&PAY_2=200&PAY_0=2000&AGE=35&MARRIAGE=1&EDUCATION=1&SEX=1&LIMIT_BAL=1000000" -H "accept: application/json"

Após copiarmos esse comando, vamos colar no terminal, e executar apertando a tecla enter. Teremos o seguinte resultado:

Ou seja, recebemos o mesmo resultado que executamos no Swagger. Sucesso.

Para ler os nossos logs posteriormente, basta executarmos o comando tail -F api_predictions.log dentro da pasta logs como abaixo, temos o seguinte resultado:

Aqui temos todas as informações que colocamos para serem registradas no arquivo de logs. Dessa forma, caso esse processo seja automatizado, pode ser feito uma depuração ou auditoria dos resultados, casa seja necessário.

Existem duas versões desse código no GitHub. Essa versão light está no repositório r-api-data-hackers e a versão mais completa, está no repositório r-h2o-prediction-rest-api.

CONSIDERAÇÕES FINAIS

O objetivo aqui neste foi mostrar de um passo a passo como cientistas de dados, estatísticos, e demais interessados podem subir uma RESTful API inteiramente usando código R.

O projeto em si, dentro da perspectiva de codificação em produção tem muitas limitações como tratamento de erro, segurança, logging, tratamento das requisições e das respostas no log, e subir tudo isso em um ambiente mais isolado, como por exemplo no Docker.

Entretanto, acho que depois desse tutorial muitos problemas relativos à parte prática de colocar modelos de machine learning em produção no R podem, no mínimo ser endereçados e com isso dar mais poder aos cientistas de dados que desenvolvem em R e demais interessados.

NOTAS

  • [N1] – Nome sem nenhuma ligação com a realidade.
  • [N2] – As variáveis SEX (gênero), MARRIAGE (se o/a cliente é casado(a) ou não) e AGE (idade) estão apenas para fins de demonstração como qualquer outra variável. No mundo real, idealmente essas variáveis seriam totalmente eliminadas para não trazer vieses discriminatórios nos modelos e demais problemas éticos.
  • [N3] – Existem inúmeras opções para subir a API em produção através de hosting que nada mais são do que serviços pagos que garantem parte da infraestrutura e cuidam de algumas questões de segurança e autenticação como a Digital Ocean, o RStudio Connect, e existem alguns recursos para fazer o hosting do Plumber em imagens Docker. No nosso caso, vamos assumir que essa API vai ser colocada em produção em uma maquina em rede na qual um analista de infraestrutura ou um cientista de dados possa fazer o deployment dessa API.
  • [N4] – Embora o objetivo deste post seja “fazer funcionar primeiro, para depois entender” é de extrema importância o entendimento dos aspectos ligados as nomenclaturas o que faz cada parte da arquitetura REST. Existem ótimos recursos para isso como aqui, aqui, aqui, e aqui

REFERÊNCIAS

RESTful API para servicing de modelos de Machine Learning com R, H2O.ai e Plumber

Funções com Multiprocessing para processamento de textos

Quem acompanhou o post A small journey in the valley of Natural Language Processing and Text Pre-Processing for German language acompanhou um pouco dos desafios de modelar um classificador de textos em alemão.

No entanto uma coisa que me salvou na parte de pre-processing foi que eu praticamente usei o multiprocessing para paralelizar o pré-processamento na coluna de texto e isso me salvou um tempo incrível (relembrando: eu tinha 1+ milhão de registros de texto, com 250 palavras média por registro (com um desvio padrão de 700, tudo isso usando biblioteca interna).

import time
import numpy as np
import pandas as pd
import nlp_pre_processing
# An internal NLP lib to process text
nlp = nlp_pre_processing_library.NLPPreprocessor()
# Multiprocessing library that uses pool
# to distribute the task for all processors
from multiprocessing import Pool
print(f'Start processing…: {(time.time() start_time)}')
# Tracking the time
start_time = time.time()
# Number of partitions that
# the Pandas Dataframe will be
# splited to parallel processing
num_partitions = 20
# Number of cores that will be used
# more it's better
num_cores = 16
print(f'Partition Number: {num_partitions} – Number of Cores: {num_cores}…')
def main_process_pipeline(df, func):
"""
Function that will split the dataframe
and process all those parts in a n number
of processors
Args:
df (Pandas dataframe): Dataframe that will be splited
func (function): Python function that will be executed in parallel
Returns:
df: Dataframe with all parts concatenated after the function be applied
"""
df_split = np.array_split(df, num_partitions)
pool = Pool(num_cores)
df = pd.concat(pool.map(func, df_split))
pool.close()
pool.join()
return df
def pre_process_wrapper(df):
""" Will take the Dataframe and apply a function using lambda"""
df['text'] = df['text'].apply(lambda text: nlp.pre_processing_pipeline(text))
return df
# Unite the Dataframe and the Wrapper
processed_df = main_process_pipeline(df, pre_process_wrapper)
print(f'Processing finished in seconds: {(time.time() start_time)}')
view raw multiprocessing.py hosted with ❤ by GitHub

É isso: Simples e tranquilo.

Funções com Multiprocessing para processamento de textos

Classification Report do Scikit-Learn em Dataframe

Não sei se só eu tinha esse problema, mas eu encontrei essa solução no Stack Overflow.

def get_classification_report(y_test, y_pred):
'''Source: https://stackoverflow.com/questions/39662398/scikit-learn-output-metrics-classification-report-into-csv-tab-delimited-format''&#39;
from sklearn import metrics
report = metrics.classification_report(y_test, y_pred, output_dict=True)
df_classification_report = pd.DataFrame(report).transpose()
df_classification_report = df_classification_report.sort_values(by=['f1-score'], ascending=False)
return df_classification_report
Resultado final.
Classification Report do Scikit-Learn em Dataframe

Security in Machine Learning Engineering: A white-box attack and simple countermeasures

Some weeks ago during a security training for developers provided by Marcus from Hackmanit (by the way, it’s a very good course that goes in some topics since web development until vulnerabilities of NoSQL and some defensive coding) we discussed about some white box attacks in web applications (e.g.attacks where the offender has internal access in the object) I got a bit curious to check if there’s some similar vulnerabilities in ML models. 

After running a simple script based in [1],[2],[3] using Scikit-Learn, I noticed there’s some latent vulnerabilities not only in terms of objects but also in regarding to have a proper security mindset when we’re developing ML models. 

But first let’s check a simple example.

A white-box attack in a Scikit-Learn random forest object

I have a dataset called Layman Brothers that consists in a database of loans that I did grab from internet (if someone knows the authors let me know to give the credit) that contains records regarding consumers of a bank that according some variables indicates whether the consumer defaulted or not. This is a plain vanilla case of classification and for a matter of simplicity I used a Random Forest to generate a classification model. 

The main points in this post it’s check what kind of information the Scikit-Learn object (model) reveals in a white-box attack and raises some simple countermeasures to reduce the attack surface in those models.

After ran the classifier, I serialized the Random Forest model using Pickle. The model has the following performance against the test set:

# Accuracy: 0.81
# status
# 0 8071
# 1 929
view raw test-set-results.py hosted with ❤ by GitHub

Keep attention in those numbers because we’re going to talk about them later on in this post. 

In a quick search in internet the majority of applications that uses Scikit-Learn for production environments deals with a pickled (or serialized model in Joblib) object that it’s hosted in a machine or S3 bucket and an API REST take care to do the servicing of this model. The API receives some parameters in the request and bring back some response (the prediction result). 

In our case, the response value based on the independent variables (loan features) will be defaulted {1}or not {0}. Quite straightforward.  

Having access in the Scikit-Learn object I noticed that the object discloses valuable pieces of information that in the hands of a white-box attacker could be potentially very damaging for a company. 

Loading the pickled object, we can check all classes contained in a model:

So, we have a model with 2 possible outcomes, {0} and {1}. From the perspective of an attacker we can infer that this model has some binary decision containing a yes {1}or no {0}decision. 

I need to confess that I expected to have only a read accessin this object (because the Scikit-Learn documentation gives it for grant), but I got surprised when I discovered that I can write in the objecti.e. overriding the training object completely. I made that using the following snippet:

# Load model from Pickle
model_rf_reload_pkl = pickle.load(open(‘model_rf.pkl’, ‘rb’))
# Displays prediction classes
model_rf_reload_pkl.classes_
# >>> array([0, 1])

One can noticed that with this command I changed all the possible classes of the model only using a single numpy array and hereafter this model will contain only the outcome {1}.

Just for a matter of exemplification I ran the same function against the test dataset to get the results and I got the following surprise:

# Actual against test set
# Accuracy: 0.2238888888888889
# status
# 1 9000
# Previous against test set
# Accuracy: 0.8153333333333334
# status
# 0 8071
# 1 929
view raw comparison.py hosted with ❤ by GitHub

In this simple example we moved more than 8k records to the wrong class. It’s unnecessary to say how damaging this could be in production in some critical domain like that. 

If we do a simple mental exercise, where this object could be a credit score application, or some classifier for in a medical domain, or some pre-order of some market stocks; we can see that it brings a very cold reality that we’re not close to be safe doing the traditional ML using the popular tools. 

In the exact moment that we Machine Learning Engineers or Data Scientists just run some scripts without even think in terms of vulnerabilities and security, we’re exposing our employers, business and exposing ourselves in such liability risk that can cause a high and unnecessary damage because of the lack of a better security thinking in ML models/objects. 

After that, I opened an issue/question in Scikit-Learn project to check the main reason why this type of modification it’s possible. Maybe I was missing something that was thought by the developers during the implementation phase. My issue in the project can be seeing below:

And I got the following response:

Until the day when this post was published there’s no answer for my last question about this potential vulnerability in a parameter that should not be changed after model training.

This is a simple white-box attack that can interfere directly in the model object itself. Now let’s pretend that we’re not an attacker in the object, but we want to explore other attack surfaces and check which valuable information those models can give for us. 

Models revealing more than expected

Using the same object, I’ll explore the same attributes that is given in the docs to check if we’re able to fetch more information from the model and how this information can be potentially useful.

First, I’ll try to see the number of estimators:

print(fNumber of Estimators: {len(model_rf_reload_pkl.estimators_)}’)
# >>> Number of Estimators: 10
view raw estimators.py hosted with ❤ by GitHub

This Random Forest training should be not so complex, because we have only 10 estimators (i.e. 10 different trees) and grab all the complexities of this Random Forest won’t be so hard to a mildly motivated attacker. 

I’ll grab a single estimator to perform a quick assessment in the tree (estimator) complexity:

model_rf_reload_pkl.estimators_[5]
# >>> DecisionTreeClassifier(class_weight=None, criterion='gini',
# max_depth=5,max_features='auto',
# max_leaf_nodes=5, min_impurity_decrease=0.0,
# min_impurity_split=None, min_samples_leaf=100,
# min_samples_split=2, min_weight_fraction_leaf=0.0,
# presort=False, random_state=1201263687,
# splitter='best')
view raw estimators-info.py hosted with ❤ by GitHub

Then this tree it’s not using a class_weight to perform training adjustments if there’s some unbalance in the dataset. As an attacker with this piece of information, I know that if I want to perform attacks in this model, I need to be aware to alternate the classes during my requests. 

It means that if I get a single positive result, I can explore in alternate ways without being detected as the requests are following by a non-weighted distribution.

Moving forward we can see that this tree has only 5 levels of depth (max_depth) with a maximum 5 leaf nodes (max_leaf_nodes) with a minimum of 100 records per leaf (min_samples_leaf).

It means that even with such depth I can see that this model can concentrate a huge amount of cases in some leaf nodes (i.e. low depth with limited number of leaf nodes). As an attacker maybe I don’t could not have access in the number of transactions that Layman Brothers used in the training, but I know that the tree it’s simple and it’s not so deep. 

In other words, it means that my search space in terms of parameters won’t be so hard because with a small number of combinations I can easily get a single path from the root until the leaf node and explore it.

As an attacker I would like to know how many features one estimator contains. The point here is if I get the features and their importance, I can prune my search space and concentrate attack efforts only in the meaningful features. To know how many features one estimator contains, I need to run the follow snippet:

# Extract single tree
estimator = model_rf_reload_pkl.estimators_[5]
print(fNumber of Features: {estimator.n_features_}’)
# >> Number of Features: 9
view raw tree-features.py hosted with ❤ by GitHub

As we can see, we have only 9 parameters that were used in this model. Then, my job as an attacker could not be better. This is a model of dreams for an attacker. 

But 9 parameters can be big enough in terms of a search space. To prune out some non-fruitful attacks of my search space, I’ll try to check which variables are relevant to the model. With this information I can reduce my search space and go directly in the relevant part of the attack surface. For that let’s run the following snippet:

features_list = [str(x + 0) for x in range(estimator.n_features_)]
features_list
# >>> ['0', '1', '2', '3', '4', '5', '6', '7', '8']
importances = estimator.feature_importances_
indices = np.argsort(importances)
plt.figure(1)
plt.title(‘Feature Importances’)
plt.barh(range(len(indices)), importances[indices], color=b’, align=center’)
plt.yticks(range(len(indices)), indices)
plt.xlabel(‘Relative Importance’)

Let me explain: I did grab the number of the features and put an id for each one and after that I checked the relative importance using np.argsort()to assess the importance of those variables. 

As we can see I need only to concentrate my attack in the features in the position [4][3]and [5]. This will reduce my work in tons because I can discard other 6 variables and the only thing that I need to do it’s just tweak 3 knobs in the model. 

But trying something without a proper warmup could be costly for me as an attacker and I can lose the momentum to perform this attack (e.g. the ML team can update the model, someone can identify the threat and remove the model and rollback an old artifact, etc).

To solve my problem, I’ll check the values of one of trees and use it as a warmup before to do the attack. To check those values, I’ll run the following code that will generate the complete tree:

from sklearn.tree import export_graphviz
# Export as dot file
export_graphviz(estimator, out_file=tree.dot’,
feature_names = features_list,
rounded = True, proportion = False,
precision = 2, filled = True)
# Convert to png using system command (requires Graphviz)
from subprocess import call
call([‘dot’, ‘Tpng’, ‘tree.dot’, ‘o’, ‘tree.png’, ‘Gdpi=600’])
# Display in jupyter notebook
from IPython.display import Image
Image(filename =tree.png’)
view raw plot-tree.py hosted with ❤ by GitHub

Looking the tree graph, it’s clearer that to have our Loan in Layman Brothers always with false {0}in the defaultvariable we need to tweak the values in {Feature 3<=1.5 && Feature 5<=-0.5 && Feature 4<=1.5}.

Doing a small recap in terms of what we discovered: (i) we have the complete model structure, (ii) we know which features it’s important or not, (iii) we know the complexity of the tree, (iv) which features to tweak and (v) the complete path how to get our Loan in Layman Brothers bank always approved and game the system. 

With this information until the moment that the ML Team changes the model, as an attacker I can explore the model using all information contained in a single object. 

As discussed before this is a simple white-box approach that takes in consideration the access in the object. 

The point that I want to make here it’s how a single object can disclose a lot for a potential attacker and ML Engineers must be aware about it.

Some practical model security countermeasures

There are some practical countermeasures that can take place to reduce the surface attack or at least make it harder for an attacker. This list it’s not exhaustive but the idea here is give some practical advice for ML Engineers of what can be done in terms of security and how to incorporate that in ML Production Pipelines. Some of those countermeasures can be:

  • Consistency Checks on the object/model: If using a model/object it’s unavoidable, one thing that can be done is load this object and using some routine check (i) the value of some attributes (e.g. number of classes in the model, specific values of the features, tree structure, etc), (ii) get the model accuracy against some holdout dataset (e.g. using a holdout dataset that has 85% of accuracyand raise an error in any different value), (iii) object size and last modification.  

  • Use “false features: False features will be some information that can be solicited in the request but in reality, won’t be used in the model. The objective here it’s to increase the complexity for an attacker in terms of search space (e.g.a model can use only 9 features, but the API will request 25 features (14 false features)). 

  • Model requests monitoring: Some tactics can be since monitoring IP requests in API, cross-check requests based in some patterns in values, time intervals between requests.

  • Incorporate consistency checks in CI/CD/CT/CC: CI/CD it’s a common term in ML Engineering but I would like to barely scratch two concepts that is Continuous Training (CT) and Continuous Consistency (CC). In Continuous Training the model will have some constant routine of training during some period of time in the way that using the same data and model building parameters the model will always produce the same results.  In Continuous Consistency it’s an additional checking layer on top of CI/CD to assess the consistency of all parameters and all data contained in ML objects/models. In CC if any attribute value got different from the values provided by the CT, the pipeline will break, and someone should need to check which attribute it’s inconsistent and investigate the root cause of the difference.

  • Avoid expose pickled models in any filesystem (e.g. S3) where someone can have access:  As we saw before if someone got access in the ML model/objects, it’s quite easy to perform some white-box attacks, i.e.no object access will reduce the exposure/attack surface.

  • If possible, encapsulates the coefficients in the application code and make it private: The heart of those vulnerabilities it’s in the ML object access. Remove those objects and incorporates only model coefficients and/or rules in the code (using private classes) can be a good way out to disclose less information about the model.

  • Incorporate the concept of Continuous Training in ML Pipeline: The trick here it’s to change the model frequently to confuse potential attackers (e.g.different positions model features between the API and ML object, check the reproducibility of results (e.g.accuracy, recall, F1 Score, etc) in the pipeline).

  • Use heuristics and rules to prune edge cases: Attackers likes to start test their search space using some edge (absurd) cases and see if the model gives some exploitable results and fine tuning on top of that. Some heuristics and/or rules in the API side can catch those cases and throw a cryptic error to the attacker make their job quite harder.

  • Talk its silver, silence its gold: One of the things that I learned in security it’s less you talk about what you’re doing in production, less you give free information to attackers. This can be harsh but its a basic countermeasure regarding social engineering. I saw in several conferences people giving details about the training set in images like image sizing in the training, augmentation strategies, pre-checks in API side, and even disclosing the lack of strategies to deal with adversarial examples. This information itself can be very useful to attackers in order to give a clear perspective of model limitations. If you want to talk about your solution talk more about reasons (why) and less in terms of implementation (how). Telling in Social Media that I keep all my money underneath my pillow, my door has only a single lock and I’ll arrive from work only after 8PM do not make my house safer. Remember: Less information = less exposure. 

Conclusion

I’ll try to post some countermeasures in a future post, but I hope that as least this post can shed some light in the ML Security. 

There’s a saying in aviation culture (a good example of industry security-oriented) that means “the price of safety it’s the eternal vigilance” and I hope that hereafter more ML Engineers and Data Scientists can be more vigilant about ML and security. 

As usual, all codes and notebooks are in Github.

Security in Machine Learning Engineering: A white-box attack and simple countermeasures

O Campeonato Brasileiro está ficando mais injusto? (UPDATE FINAL)

Introdução

Como o Campeonato Brasileiro terminou oficialmente neste domingo com o Flamengo campeão e com todas as rodadas encerradas, vamos novamente realizar a mesma pergunta que inicei no meu post que indaga: “O Campeonato Brasileiro está ficando mais injusto?

Mais uma vez fui na Wikipedia, e atualizei os dados já incluindo o ano de 2019.

A desigualdade estrutural no Campeonato Brasileiro é uma tendência?

Na primeira análise feita aqui no blog eu cheguei a conclusão de que Sim. Com o uso do Coeficiente de Gini como métrica para mensurar se há uma desigualdade estrutural mostrou que existem sim elementos latentes dessa desigualdade.

Dado a campanha excepcional do Flamengo que não só bateu o recorde de números de pontos em uma única edição, como também se considerarmos apenas os jogos fora de casa o Flamengo ainda sim não seria rebaixado (37 jogos – 11v 4e 4d) (Fonte: Tiago Vinhoza).

Mas como estamos falando aqui da variância da distribuição dos pontos dentro desta edição Brasileirão um fato colocado pelo @Impedimento foi que esta edição teve a Menor pontuação efetivamente conquistada por um time que se salvou: 39 (Ceará) sendo que eram necessários apenas 37 para se salvar. Ou como disse o Tiago Vinhoza, uma estratégia só de empates em todos os jogos já seria suficiente para se salvar.

Sem mais demora, vamos rodar os mesmos scripts agora com os dados atualizados.

Ranking de desigualdade entre todas as edições do Brasileirão usando o Coeficiente de Gini

Diferentemente da nossa última análise em que a edição de 2018 (Palmeiras campeão) era a mais desigual até então, essa edição com o Flamengo Campeão teve um aumento de 17% ((1 – (0.1449/0.1746)) x 100) na desigualdade em relação ao número de pontos em 2018, o que mostra que a tendência dessa desigualdade veio pra ficar.

Assim como fizemos anteriormente, vamos olhar com calma essa edição de 2019 para verificar alguns fatos interessantes:

Aos moldes da nossa última análise, vamos ver alguns pontos da tabela final:

  • O Flamengo finalmente furou a impressionante barreira dos 90 pontos em campeonato de 20 times; o que é para efeitos de comparação é o mesmo número de pontos que o Real Madrid e/ou Barcelona fazem em temporadas avassaladoras no Campeonato Espanhol o que indica que houve uma disparidade muito grande dentro de campo;
  • Como já colocado acima, uma estratégia de apenas empates (38 pontos) já seria o suficiente para sair do rebaixamento;
  • O Avaí foi o saco de pancadas desse brasileirão em que cedeu 72 pontos (24 * 3) ao longo da tabela para inúmeros times, e perdeu 22 pontos em empates (ou tirou de outros times). Alem disso tivemos além do Avaí mais 4 times com mais de 20 derrotas (Botafogo, CSA, e Chapecoense) o que possivelmente pode ter contribuído para essa desigualdade de pontos.
  • Tivemos na verdade 4 Campeonatos: Campeonato 1 que eu chamaria de “Passeio Flamenguista”; Campeonato 2 que seria “Briga pelo Vice estrelando Santos e Palmeiras”; e Campeonato 3 “Vagas na Libertadores e Sulamericana”; e o último campeonato (4) que seria “Quem vai ser rebaixado com o CSA, Chape e Avaí?”; e
  • Entre o 7o Colocado (Internacional) até o penúltimo colocado (Chapecoense) a maior diferença de pontos foram de 4 pontos.

Em linhas gerais o que podemos ver é que tivemos alguns blocos de times com um determinado número de pontos, mas o grosso de todos os pontos foram para os times de cima, em especial o Campeão e os dois vice campeões.

Vamos agora olhar a evolução dessa desigualdade ao longo do tempo, e comparar com as edições anteriores.

Na minha última análise eu tinha feito a seguinte consideração:

Algo surpreendente é que os vales costumam acontecer nos anos ímpares e os picos nos anos pares. Isso talvez seja explicado por algum efeito externo, tal como as Olimpíadas e Copa do Mundo que ocorrem em anos pares. E uma hipótese bem fraca, mas ainda sim é uma hipótese.

Ou seja: O Brasileirão desse ano não somente mostrou que essa era uma hipótese que não faz mais sentido, como mostra agora que é um outlier dentro de todas as edições de pontos corridos, em termos de desigualdade dos times.

Para suavizar um pouco esse efeito, vamos considerar aos moldes do post anterior apenas uma média móvel considerando um recuo de 3 anos.

Olhando com mais calma, podemos até mesmo pensar na hipótese de que Não foi o ano de 2019 que foi um outlier, mas os anos de 2017 e 2009 que são os verdadeiros outliers em relação à desigualdade.

Aos moldes do post anterior, vamos remover o campeão e o pior time de todas as temporadas e recalcular novamente.

Mesmo removendo o Avaí (pior time) e o Campeão (Flamengo) ainda sim o campeonato de 2019 continua o mais desigual de todos os tempos.

Vamos gerar o gráfico apenas para verificar se a tendência do aumento da desigualdade permanece ou não.

Olhando o Coeficiente de Gini removendo o campeão e o pior time, podemos ver que ainda temos a tendência de aumento da desigualdade dentro da liga.

Considerações finais

Se tivermos que responder a nossa pergunta principal que foi “O brasileirão está ficando mais injusto ao longo do tempo?” a resposta seria:

“Sim. Existe uma esigualdade estrutural no Campeonato Brasileiro com uma tendência de alta, sendo que a edição de 2019 foi a mais desigual de todas.”.

Aqui eu vou tomar a liberdade de realizar algumas considerações em relação ao que eu penso que pode estar acontecendo no Campeonato:

Por inspiração de uma interação que eu tive no Twitter com Tiago Vinhoza – @tiagotvv eu vou coletar os dados das ligas européias desde a década de 90 e analisar se essa desigualdade acontece nas outras ligas do mundo e comparar com a liga Brasileira.

Como sempre os dados e o código estão no GitHub.

Inequality in the Premier League – Çınar Baymul

An Analysis Of Parity Levels In Soccer – Harvard Sports

Which Sports League has the Most Parity? – Harvard Sports

Major League Soccer and the Effect of Egalitarianism – Harvard Sports

The Gini Coefficient as a Measure of League Competitiveness and Title Uncertainty – Australia Sports Betting

Mourão, P. R., & Teixeira, J. S. (2015). Gini playing soccer. Applied Economics, 47(49), 5229-5246

How “fair” are European soccer leagues? Gini index applied to points distribution of 5 soccer leagues between 2000 and 2015 – r/soccer

Footballomics: Estimating League Disparity Performance with a Point-Rank Gini Index – Christoforos Nikolaou

O Campeonato Brasileiro está ficando mais injusto? (UPDATE FINAL)

O Campeonato Brasileiro está ficando mais injusto?

Uma análise exploratória usando o Coeficiente de Gini

Todos os dados e a análise completa pode ser encontrada no repositório brasileirao-gini. Lá tem todas as instruções para executar passo a passo a análise.

Introdução

Uma das coisas boas da era digital é que com o uso do WhatsApp podemos ter as nossas mesas redondas virtuais com os nossos amigos não importa o quanto estamos longe estamos deles. E dentro de um dessas resenhas virtuais estavamos discutindo o ótimo trabalho do treinador do Flamengo na atual temporada (i.e. aspectos relativos de como ele mudou positivamente o time).

Porém, apareceu um tópico relevante durante o debate que foi a hipótese que na medida em que entra mais dinheiro no campeonato e nos times maiores o Brasileirão vai ficando cada vez mais injusto com um conjunto de times ganhando muitos jogos e os times pequenos ficarem relegados a meros coadjuvantes dentro da liga.

Em outras palavras, isso significa que o campeonato sempre é disputado pelos mesmos clubes com poderio econômico e por causa dessa disparidade a competitividade poderia estar diminuindo ao longo do tempo.

Isso levantou a questão dentro do nosso grupo que foi: O brasileirão está ficando mais injusto ao longo do tempo?

E é essa pergunta que eu vou tentar responder no final desse post.

Como checar se há uma desigualdade estrutural no Campeonato Brasileiro?

E para responder inicialmente essa pregunta eu vou usar o Coeficiente de Gini que é uma métrica para medir a dispersão estatística que inicialmente foi criada para medir a distribuição de renda e riqueza entre países e é amplamente usada em economia como um importante indicador de monitoramento dessas questões.

O Coeficiente de Gini é usado como medida relativa para benchmark e monitoramento de desigualdade e pobreza através de diversos países e serve como base de analise e desenvolvimento de politicas publicas como você pode conferir nos trabalhos de SobottkaMoreira e autores e pelo Instituto de Pesquisa Econômica Aplicada o IPEA no trabalho de Barros e autores.

Como este post não é para falar em profundidade sobre este indicador eu sugiro a leitura do trabalho original de Conrrado Gini chamado Variabilità e Mutabilità ou este trabalho sobre como esse indicador foi usado de parâmetro para alguns algumas analises de bem estar social no Brasil.

Isto é, eu vou medir de forma simples a variância da distribuição dos pontos dentro de cada uma das edições do Campeonato Brasileiro (que eu vou chamar daqui em diante de Brasileirão) para verificar se há uma desigualdade latente estrutural dentro dos campeonatos e ao longo dos anos.

Algumas considerações prévias sobre algumas limitações sobre a forma de medir essa desigualdade

De acordo com o que coloquei anteriormente podemos fazer de maneira bem simples a mensuração de fatores importantes como desigualdade econômica através da renda e/ou riqueza usando o Coeficiente de Gini, e aqui eu vou usar uma alegoria simples de como eu vou aplicar isso nos dados do Brasileirão.

Se esse índice serve de alguma maneira medir a desigualdade entre países considerando a sua riqueza ou mesmo a sua renda, trazendo para o mundo do futebol podemos usar como representação dos dados os pontos ganhos de um time dentro de uma temporada como se fosse a renda e aplicar o Coeficiente de Gini regular não somente dentro de um ano especifico da liga mas também monitorar essa desigualdade ao longo do tempo.

Para isso eu vou usar a base de dados de todos os resultados do Brasileirão desde 2003 até 2018 extraídos da Wikipédia. E aqui eu tenho que fazer duas considerações que são:

São algumas limitações importantes que devem ser consideradas, dado que o número de pontos em disputa foi modificado e um ajuste é necessário.

Cabe ressaltar que o Coeficiente de Gini e da análise em si possuem inúmeras limitações que devem ser entendidas como:

  • Não levar o “Fator Tradição” em consideração a um efeito de produtividade dos clubes ao longo do tempo, i.e., um clube grande e antigo carrega uma estrutura financeira/econômica/institucional maior do que os clubes mais novos, e isso pode ser visto na distribuição dos campeões na era dos pontos corridos (Em economia isso seria similar ao efeito de transição de produtividade de uma base instalada produtiva ao longo dos anos);
  • Diferenças econômicas das regiões em que os clubes têm as suas bases;
  • O Coeficiente de Gini olha somente o resultado final sem levar em considerações fatores conjunturais que podem influenciar estes mesmos resultados como gestão, e momento econômico do time, e outros eventos como Olimpíada e Copa do Mundo;
  • O conceito da natureza da geração dos pontos (o que em economia seria a renda) podem ter dinâmicas muito diferentes dado que o índice não captura se os pontos foram gerados através de 3 empates (1 ponto multiplicado por 3) ou por uma vitória e duas derrotas (3 pontos);
  • Analisar a geração de pontos em si ao longo do tempo pode ser bem complicado e não representaria uma comparação plausível. Por exemplo: O Flamengo campeão brasileiro de 2009 seria na melhor das possibilidades um mero quinto colocado em 2014;
  • O índice em si por tratar somente do resultado final, não mostra a transitividade desses pontos ao longo do campeonato. Explico: Se um time nas rodadas finais do campeonato não tem mais nenhum tipo de chance de ir para uma boa competição(Copa Libertadores), de ser rebaixado para divisões inferiores, ou mesmo já conquistou o titulo pode acontecer desses times entrarem com menos disposição para ganhar os jogos. Incentivos financeiros entre os times também podem ocorrerEste artigo da Investopedia fala um pouco sobre esse efeito;

Algumas outras limitações do Coeficiente de Gini podem ser encontradas no trabalho de Tsai, no Working Paper de Osberg, no site do HSRC ou para quem quiser uma critica mais vocal tem esse ensaio do Craig Wright.

Para calcular o Coeficiente de Gini eu usei o código da Olivia Guest apenas para fins de simplicidade, mas qualquer software pode ser usado uma vez que os dados estão disponíveis no repositório.

Como o código têm muito boilerplate code de coisas que eu já fiz no passado e o meu foco é a analise em si, eu não vou comentar o código inteiro.

Vamos dar uma olhada inicial apenas para ver se os dados foram carregados corretamente.

# Imports, 538 theme in the charts and load data
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%pylab inline
plt.style.use('fivethirtyeight')
df_brasileirao = pd.read_csv('dataset-2003-2018.csv', delimiter=';')
print(f'Number of Records: {df_brasileirao.shape[0]} – Number of Columns: {df_brasileirao.shape[1]}')
# Loading Check
df_brasileirao.head(5)
view raw imports.py hosted with ❤ by GitHub

Aparentemente tudo OK com os dados, então eu vou realizar o calculo do Coeficiente de Gini usando todas as edições do Brasileirão.

Ranking de desigualdade entre todas as edições do Brasileirão usando o Coeficiente de Gini

Para quem quiser calcular, basta usar essas duas funções:

# Gini function as PyGini package
def gini(arr, eps=1e-8):
'''
Reference: PyGini (I owe you a beer @o_guest)
https://github.com/mckib2/pygini/blob/master/pygini/gini.py
Calculate the Gini coefficient of a numpy array.
Notes
—–
Based on bottom eq on [2]_.
References
———-
.. [2]_ http://www.statsdirect.com/help/
default.htm#nonparametric_methods/gini.htm
'''
# All values are treated equally, arrays must be 1d and > 0:
arr = np.abs(arr).flatten() + eps
# Values must be sorted:
arr = np.sort(arr)
# Index per array element:
index = np.arange(1, arr.shape[0]+1)
# Number of array elements:
N = arr.shape[0]
# Gini coefficient:
return(np.sum((2*index N 1)*arr))/(N*np.sum(arr))
def get_gini_df(df):
"""Generate DF with Gini Index
Parameters
———-
df : Pandas Dataframe
Dataframe with Brasileirão data
Returns
——-
gini_df : Pandas Dataframe
Returns a Pandas Dataframe with the year, team and gini index
"""
gini_per_year = []
for year in df['year'].unique():
championship_index = gini(np.array(df[df['year'] == year]['points']))
champion = (df[(df['year'] == year) & (df['position'] == 1)]['team'])
gini_per_year.append((year, champion.values[0], round(championship_index, 4)))
gini_df = pd.DataFrame(gini_per_year)
gini_df.columns = ['year', 'team', 'gini']
# Indexing the date field for graph it smoothly
gini_df.set_index('year', inplace=True)
return gini_df
gini_df = get_gini_df(df_brasileirao)
gini_df.sort_values(by=['gini'], ascending=True)
view raw gini-calculation.py hosted with ❤ by GitHub

Usando os dados que foram carregados e o código acima, eu realizei o cálculo do Coeficiente de Gini e obtive a seguinte tabela:

Coeficiente de Gini Calculado com os respectivos campeões de cada edição

Considerando o Coeficiente de Gini como principal métrica de ordenação, podemos ver que a edição do Brasileirão de 2017, 2005 (ambas com o Corinthians campeão) e de 2009 (Flamengo campeão) foram as que tiveram mais igualdade dentro da era dos pontos corridos.

Por outro lado, as edições de 2018 (Palmeiras campeão), 2014 (Cruzeiro campeão) e 2012 (Fluminense campeão) foram as mais desiguais no que se refere a distribuição final dos pontos. Um fato curioso é que se pegarmos as 5 temporadas mais desiguais, veremos o Palmeiras e o Fluminense com 2 títulos cada (respectivamente 2018, 2016 e 2012, 2010).

Essas informações apontam que Corinthians e Flamengo tendem a ganhar as temporadas com a menor distribuição de pontos finais, e quando Palmeiras e Fluminense ganham são geralmente temporadas mais desiguais da perspectiva de distribuição dos pontos no final. Aliaá isso seria uma boa hipótese inicial para ser testada com mais dados.

Vamos olhar os dois extremos que são as temporadas de 2017 (mais igual) e 2018 (mais desigual).

Brasileirão 2017
Brasileirão 2018

Olhando a distribuição dos pontos aqui, podemos ver que a diferença de pontos entre os campeões foi de 8 pontos (80-72) e considerando a distancia do campeão com o quinto colocado nos dois campeonatos, se em 2017 temos uma distância de 15 pontos (72-57) em 2018 temos 17 pontos (80-63), ou seja muito parecida.

Entretanto, sabendo que o campeão sempre têm um pouco de margem considerando apenas esses extremos, se considerarmos apenas a distância entre o vice-campeão e o quinto colocado em 2017 temos apenas 6 pontos (63-57) enquanto em 2018 essa distância vai para 9 pontos (72-63).

Realizando o mesmo exercício entre o campeão e o pior time do campeonato em 2017 temos uma diferença de 36 pontos (72-36) enquanto em 2018 chegamos a expressiva marca de 57 pontos de diferença (80-23). Isso mostra que mesmo entre os piores times ao longo desses dois campeonatos temos a expressiva diferença de 13 pontos (36 (Atlético Goianiense/2017 – 23 (Paraná). Vamos ficar atentos a essas informações, pois eu vou voltar aqui posteriormente.

Agora eu vou gerar o gráfico de como ficou esse Coeficiente de Gini ao longo do tempo.

def get_graph_ts(df, column, title, label):
"""Generate graph of a Time Series
Parameters
———-
df : Pandas Dataframe
Dataframe with Brasileirão data
column : string
Column with the metric to be ploted
title : string
Graph title to be displayed
label : string
Name of the series that will be placed
as legend
Returns
——-
"""
plt.figure(figsize=(20,10))
plt.title(title)
plt.xlabel('Years')
plt.ylabel('Gini Index')
plt.plot(df[column], label=label)
plt.xticks(gini_df.index)
get_graph_ts(gini_df,
'gini',
'Gini Index in Brasileirão',
'Gini Index',
)
view raw graph-gini.py hosted with ❤ by GitHub
Coeficiente de Gini no Brasileirão ao longo do tempo

Em uma primeira análise podemos perceber algumas curiosidades:

  • Ao que parece temos sim uma tendência não tão clara de crescimento da desigualdade, mas com vales e picos bem distintos e considerando que o formato de 20 times têm apenas 12 temporadas completas isso tem que ser levado com cautela;
  • Algo surpreendente é que os vales costumam acontecer nos anos ímpares e os picos nos anos pares. Isso talvez seja explicado por algum efeito externo, tal como as Olimpíadas e Copa do Mundo que ocorrem em anos pares. E uma hipótese bem fraca, mas ainda sim é uma hipótese. (Nota do Autor: Se algueém tiver uma explicação razoável pode me mandar um comentário que eu coloco aqui e dou o crédito);
  • Ao que parece depois de 2011 houve uma subida mais consistente e manutenção desse aumento da desigualdade com o Coeficiente de Gini voltando para baixo de 0.115 apenas em 2017, ou seja, uma janela de 6 anos acima do patamar de 0.115;
  • E falando em 2017, essa temporada ao que parece foi uma total quebra em relação a essa desigualdade, dado que considerando o segundo, terceiro e quarto colocados terminarem com a diferença de apenas 1 ponto3 times ficaram com 43 pontos, com dois deles sendo rebaixados para a segunda divisão.

Entretanto, uma coisa que eu considerei foi que talvez tenha algum efeito que eu chamo de “Última temporada na Série A” ou “Já estou rebaixado mesmo, não tem mais o que fazer” em relação a essa (des)igualdade, dado que sempre saem/entram 4 times por ano (i.e. 20% dos times são trocados todos os anos). Para remover esse potencial efeito, eu vou considerar uma média móvel considerando os 3 últimos campeonatos. Ou seja, sempre haverá a combinação de a) dois anos desiguais e um ano igual e b) dois anos iguais com um ano desigual.

# Some graphs with rolling average
date_range = [2003, 2004, 2005, 2006, 2007,
2008, 2009, 2010, 2011, 2012,
2013, 2014, 2015, 2016, 2017,
2018]
def get_graph_ts_rolling_average(ts, title, window, date_range=date_range):
"""Generate graph of a Time Series with a simple rolling average
Parameters
———-
ts : Pandas Dataframe column
Dataframe column with a metric to be ploted
title : Pandas Dataframe
Graph title to be displayed
window : int
Rolling back window to be considered in the average
date_range : Array
Array to be used in the ploting. Matplotlib has a
very bad way to deal with that, so I need to use this
workaround to place all years properly
Returns
——-
"""
plt.figure(figsize=(20,10))
plt.plot(date_range, ts.rolling(window=window, center=False).mean(), label='gini');
plt.title(f'{title}{window}')
plt.xlabel('Years')
plt.ylabel('Gini Index')
plt.xticks(gini_df.index)
plt.legend()
get_graph_ts_rolling_average(gini_df['gini'],
'Rolling Average in Gini Index in Brasileirão – Rolling Average window=',
3,
)
view raw rolling-gini.py hosted with ❤ by GitHub
Coeficiente de Gini quando consideramos uma janela de 3 edições para computar a média

Realizando essa suavização a grosso modo, podemos ver o efeito desse aumento da desigualdade mais claro, quase que de forma linear de 2009 até 2016 sendo quebrado apenas pela famigerada temporada de 2017, e com a temporada de 2018 sofrendo um efeito claro com esse ajuste.

Anteriormente eu falei sobre essa disparidade dos pontos entre os primeiros colocados, entre os últimos colocados e os campeões em relação aos piores times da liga. Podemos perceber que sempre nestes casos extremos temos o campeão vencendo com uma pequena folga, e com os times subsequentes com algum tipo de disputa em ordens diferentes de magnitude.

Para validar esse ponto de que essa desigualdade está aumentando de uma maneira um pouco mais robusta, eu vou remover os nossos outliers desses campeonatos, ou resumindo: Eu vou tirar o campeão e o pior time da temporada da análise.

(Nota do Autor: Eu sei que existem inúmeras abordagens de como se fazer isso da maneira correta, com recursos quase que infinitos na internet de técnicas (1234 e inclusive com testes estatísticos muito robustos de como se fazer isso da maneira correta, caso seja necessário. Por questões de simplicidade e para reforçar o meu ponto, eu estou removendo dado que dentro dessas situações extremas como eu tenho como resultados a) o campeão com uma leve vantagem no final e b) o pior time da temporada sendo muito pior mesmo, a ideia é ver a igualdade dos outros times no campeonato. Lembrem-se que eu quero ver de maneira geral (des)igualdade da liga e tirar o efeito dos supercampeões e dos vergonhosos sacos de pancada da temporada).)

Dito isto, vou remover esses outliers e calcular novamente o Coeficiente de Gini.

def get_brasileirao_no_outliers(df):
"""Generate a DF removing the champion and the worst team of the championship
Parameters
———-
df : Pandas Dataframe
Dataframe with Brasileirão data
Returns
——-
df_concat : Pandas Dataframe
Returns a Pandas Dataframe without the outliers
"""
df_concat = pd.DataFrame()
for year in df['year'].unique():
pos_min = df[df['year'] == year]['position'].min()
pos_max = df[df['year'] == year]['position'].max()
df_filtered = df[(df['year'] == year) \
& (~df['position'].isin([pos_min, pos_max]))]
df_concat = df_concat.append(df_filtered)
return df_concat
def get_gini(df):
"""Generate a DF with the year and the following Gini Index calculated
Parameters
———-
df : Pandas Dataframe
Dataframe with Brasileirão data
Returns
——-
gini_df : Pandas Dataframe
Returns a Pandas Dataframe with the year, and gini index
"""
gini_per_year = []
for year in df['year'].unique():
championship_index = gini(np.array(df[df['year'] == year]['points']))
gini_per_year.append((year, round(championship_index, 4)))
gini_df = pd.DataFrame(gini_per_year)
gini_df.columns = ['year', 'gini']
# Indexing the date field for graph it smoothly
gini_df.set_index('year', inplace=True)
return gini_df
# Outlier removal
df_brasileirao_no_outliers = get_brasileirao_no_outliers(df_brasileirao)
df_brasileirao_no_outliers_gini = get_gini(df_brasileirao_no_outliers)
df_brasileirao_no_outliers_gini.sort_values(by=['gini'], ascending=True)
Gini Index com o campeão e o último colocado do campeonato removidos

Agora temos algumas mudanças significativas no nosso painel que são:

  • Se antes tínhamos as temporadas de 2017, 2005 e 2009 como as mais iguais, agora tiramos a temporada de 2009 vencida pelo Flamengo, e colocamos a de 2007 vencida pelo São Paulo;
  • Pelo lado das temporadas mais desiguais, no caso tínhamos a ordem de desigualdade pelas temporadas de 2018, 2014, e 2012, agora temos a nova ordem em relação as edições de 2014 (Cruzeiro), 2012 (Fluminense) e 2018 (Palmeiras)

Vamos checar como ficou a tabela das edições mais extremas sem os campeões em 2007 e 2014.

Brasileirão 2007
Brasileirão 2014

Olhando as duas tabelas finais em que removemos o campeão e o pior time, logo de cara podemos reparar que enquanto na temporada de 2014 temos dois vales de diferenças de 7 pontos (4° e 5° e 7° e 8°), no campeonato de 2007 a maior diferença foi de 4 pontos (15° e 16°) e fazendo um pequeno exercício de imaginação podemos dizer que o Paraná que teve 41 pontos em 2007 não teria perigo nenhum de ir para a segunda divisão, se tivesse o mesmo número de pontos no campeonato de 2014. Como eu coloquei anteriormente essa análise de transpor um time ao longo do tempo não é muito valida, porém, mencionei isso apenas para ressaltar a importância da distribuição dos pontos ao invés do número absoluto de pontos no resultado final.

Vamos gerar o gráfico apenas para verificar se a tendência do aumento da desigualdade permanece ou não.

Gini Index ao longo do tempo, removendo o campeão e o pior time da temporada
Gini Index ao longo do tempo, removendo o campeão e o pior time da temporada, considerando a média das 3 últimas edições

Olhando o Coeficiente de Gini removendo o campeão e o pior time, podemos ver que ainda temos a tendência de aumento da desigualdade dentro da liga, seja usando a métrica de forma regular ou aplicando uma média móvel com uma janela de 3 temporadas ao longo do tempo.

Fatos

Ao longo dos dados apresentados nessa análise eu cheguei aos seguintes fatos:

  • Temos uma tendência crescente na desigualdade em relação ao número de pontos entre os times dentro do campeonato brasileiro;
  • Esse crescimento começa de forma mais substancial em 2010;
  • As temporadas mais recentes (2018 e 2017) são respectivamente as temporadas com a maior e a menor desigualdade;
  • Essa tendência do aumento da desigualdade ocorre mesmo removendo o campeão e o pior time da temporada, dado que o campeão nos casos extremos possui uma pequena vantagem em relação ao segundo colocado, e o pior time termina com uma pontuação virtualmente impossível de reverter em uma ocasião de última rodada;
  • Nas temporadas que há uma desigualdade maior existe vales de pontos entre blocos de times, no caso que vimos esses vales foram de 7 pontos (2 vitorias + 1 empate);
  • Nas temporadas mais iguais esses vales de blocos times provavelmente são menores ou em alguns casos inexistentes, no caso observado havia somente um bloco de 4 pontos (1 vitória + 1 empate) e excluindo este fato não havia nenhuma distância maior do que 1 vitória por toda a tabela entre posições imediatamente superiores.

Conclusão e considerações para o futuro

Se tivermos que responder a nossa pergunta principal que foi “O brasileirão está ficando mais injusto ao longo do tempo?” a resposta correta seria:

“Sim. Com o uso do Coeficiente de Gini como métrica para mensurar se há uma desigualdade estrutural mostrou que existem sim elementos latentes dessa desigualdade”.

O leitor mais atento pode repaar que uma coisa que eu tomei muito cuidado aqui foi para não realizar afirmações relativas à competitividade, afirmações relativas às condições financeiras dos clubes, incremento de premiações ou ausência de incentivos para os piores colocados, etc.; dado que estes aspectos são difíceis de mensurar e há pouquíssimos dados disponíveis de maneira confiável para a análise, mas empiricamente talvez podemos realizar algumas afirmações nessas direções.

Em relação a análises futuras existem muitas hipóteses que podem ser testadas como um potencial problema fundamental de competitividade devido ao fato de muitos clubes que não representam uma elite (i.e. muitos times fracos na liga principal) e inclusive existem pautas relativas ao aumento do número dos clubes rebaixados, hipóteses que elencam fatores importantes como a disparidade financeira como um dos potenciais fatores de existir poucos supertimes, há uma hipótese que ganha tração também que é a respeito das cotas financeiras de direitos de exibição de jogos na televisão que compõe grande parte da receita desses clubes que a contar do ano que vem vai punir pesadamente os times que forem rebaixados.

São muitas hipóteses em discussão e muitos aspectos que poderiam ser a razão para o aumento dessa desigualdade. Há um grande número de aspectos que podem ser estudados e no final desse post tem alguns links para referências em outras ligas.

Todos os dados e a análise completa pode ser encontrada no repositório brasileirao-gini. Lá tem todas as instruções para executar passo a passo a análise.

Referências e links úteis

Inequality in the Premier League – Çınar Baymul

An Analysis Of Parity Levels In Soccer – Harvard Sports

Which Sports League has the Most Parity? – Harvard Sports

Major League Soccer and the Effect of Egalitarianism – Harvard Sports

The Gini Coefficient as a Measure of League Competitiveness and Title Uncertainty – Australia Sports Betting

Mourão, P. R., & Teixeira, J. S. (2015). Gini playing soccer. Applied Economics, 47(49), 5229-5246

How “fair” are European soccer leagues? Gini index applied to points distribution of 5 soccer leagues between 2000 and 2015 – r/soccer

Footballomics: Estimating League Disparity Performance with a Point-Rank Gini Index – Christoforos Nikolaou

O Campeonato Brasileiro está ficando mais injusto?

Queridos Cientistas de Dados brasileiros: Por favor, entendam por que Word Cloud é horrível para Análise de textos e tentem TF-IDF + Word N-Grams

Eu sei que o título ficou meio bait, então relevem pois aqui o tom sempre será de moderação.

Eu ja cometi esse erro no passado (essa thread mostra isso bem que como uma fênix burra e teimosa eu continuo fazendo isso), e confesso que quando vemos aquelas nuvens de palavras temos a sensação que somos os melhores analistas do mundo, pois podemos explicar de maneira direta e simples para as pessoas mais leigas no assunto. Afinal quem não gostaria de ser conhecido por ser um excelente storyteller, não é mesmo?

Apesar disso, nessas linhas mal escritas eu vou tentar fazer o meu ponto do por que Word Cloud é um instrumento péssimo para análise de textos/discursos que além de ser falho por usar apenas frequência absoluta das palavras esse método dá muita margem para o que eu chamo de “saltos interpretativos” que mais parecem delírios da cabeça de quem está analisando do que análise propriamente dita.

O problema…

Vira e mexe sempre quando chegam as eleições, ou mesmo algum tipo de pronunciamento de alguém importante via discurso a primeira coisa que vemos é a famosa Word Cloud (i.e. ou Nuvem de Palavras ou Tag Cloud).

Eu mesmo adoro usar Word Cloud em alguns discursos presidenciais como podem ser vistos na minha conta no Github em que eu usei esse método em todos os discursos presidenciais que eu consegui encontrar no Itamaraty, os quais vocês podem ver as vergonhosas imagens abaixo: 

Word Cloud dos discursos do ex-presidente FHC
Word Cloud dos discursos do ex-presidente Lula
Word Cloud dos discursos do ex-presidente Dilma
Word Cloud dos discursos do ex-presidente Temer
Word Cloud dos discursos do presidente Bolsonaro

(PS: eu sei que o código está medonho, mas o dia-dia está massacrante e eu não tive tempo de tirar do ar ainda, mas se alguém quiser continuar só dar o fork e “do it yourself”)

Alguns exemplos do péssimo uso recorrente de Word Cloud podem ser vistos na BBC em relação aos discursos dos presidentes brasileiros na ONU, na Revista Época que fez a mesma coisa, na Folha de São Paulo que fez uma nuvem de palavras com os discursos da ex-presidente da republica Dilma Rousseff e no Estado de Minas que fez o mesmo em relação ao discurso de diplomação do presidente da republica Jair Bolsonaro.

Sempre bom quando temos o Jornalismo de Dados (Data Journalism) (Nota do Autor [1]: Desde o inicio dos tempos eu sempre pensei que todo o jornalismo sempre era baseado em dados, mas isso fica para outro post. Vivendo e aprendendo.) usando ferramentas quantitativas para análise desses dados. Porem, o recurso de Word Cloud diz muito pouco dentro de um contexto geral e da espaço para “saltos interpretativos” que fariam inveja ao Tarot e a Astrologia em termos de picaretagem subjetividade.

Um exemplo claro desses saltos interpretativos pode ser visto neste artigo do Linkedin chamado “Nuvem de palavras: nossos candidatos por eles mesmos” no qual o autor colocou mais aspectos pessoais do que ele achava do que ater-se unicamente ao que foi dito de fato.

No blog do Tarcízio Silva no post “O que se esconde por trás de uma novem de palavras” ele fala de alguns recursos de análise que são interessantes do ponto de vista de análise do discurso, contudo, quando falamos de linguística computacionalrecuperação de informação a Word Cloud revela muito pouco chegando a ser até mesmo ser enganosa dependendo do caso e eu vou mostrar isso mais adiante. 

Em NLP Semântica e Sequência não são tudo, mas são 99%

Entretanto, quando falamos de Natural Processing Language (NLP) o uso de Word Cloud mostra bem pouco dado que como esse método é baseado apenas na combinação Frequência de Palavras + Remoção de Stopwords, e com isso dois principais aspectos da análise de texto são perdidos que são: 

a) A Semântica em relação ao que está sendo dito e como isso se relaciona no corpus como um todo e não baseado em interpretação livre (e.g. A palavra “manga” pode ter diferentes significados se usados em diferentes contextos como em frutas ou de roupas); e

b) A sequência no qual sem ela perde-se a chance de entender a probabilidade das palavras aparecerem dentro de um encadeamento lógico em relação ao que está sendo colocado (e.g. Se tivermos as palavras “bebe”, “mata”, “fome” podemos gerar as sequencias “bebê, mata, fome” ou “fome, mata, bebê” o que são fatos absolutamente diferentes). 

Penso que esses dois exemplos simples fizeram o meu ponto de que o Word Cloud por si só não diz muita coisa e para piorar deixa muito espaço para “saltos interpretativos”.

Vamos ver adiante uma forma simples de fazer uma análise textual usando duas técnicas que são TF-IDF e o Word N-Grams. 

TF-IDF + Word N-Grams = Melhor interpretação = Melhores insights 

Basicamente quando usamos Term Frequency–Inverse Document Frequency (TF-IDF) ao invés de realizarmos simplesmente uma análise da frequência das palavras, estamos colocando um determinado nível de semântica na análise do texto dado que, se ao mesmo tempo consideramos a frequência das palavras (TF), também consideramos a frequência inversa do documento (IDF) em que essa combinação entre TF e IDF vai medir a relevância da palavra de acordo com o grau de informação que ela trás para o texto como um todo.

Ou seja, o TF-IDF faz essa relação de ponderação de ocorrência relativa e grau de informação das palavras dentro de um corpus. Esse gráfico do post “Process Text using TFIDF in Python” do Shivangi Sareen mostra bem o que seria o TF-IDF em relação a um corpus.

Fonte: Process Text using TF-IDF in Python

Essa questão da coocorrência relativa de palavras vai ficar mais clara no exemplo prático que será falado depois. 

Ao passo que o Word Cloud não se preocupa com a sequencia o Word N-Grams vem suprir esse ponto dado que não somente as palavras importam, mas também a probabilidade da sequencia dessas palavras dentro de um determinado contexto linguístico e/ou encadeamento semântico. (Nota do Autor [2]: eu sei que vão ter pessoas me cornetando pelo fato de ser n-Grams apenas, mas este conceito de Word n-Grams eu estou emprestando do Facebook FastText que tem um ótimo paper chamado “Bag of Tricks for Efficient Text Classification” que fala sobre essa implementação de usar sequências locais de palavras (i.e.que vão nos dar pequenos contextos locais concatenados) ao invés de letras para classificação de textos, mas isso é papo para outro post. A propósito eu escrevi sobre o FastText aqui a algum tempo.). 

Esse post não tem nenhuma pretensão de ser uma referência em TF-IDF/Word-NGrams mas eu recomendo fortemente o que eu considero a bíblia do NLP que é o livro “Neural Network Methods for Natural Language Processing” do Yoav Goldberg.

Dito isto, vamos para um exemplo prático de o porquê usar TF-IDF e Word N-Grams mostram muito mais do que Word Cloud e como você pode usar em sua análise de textos.

Exemplo prático: Análise de textos de alguns autores do Instituto Mises Brasil 

A algum tempo eu venho acompanhando a política brasileira da perspectiva de ensaístas de vertentes ligadas ao libertarianismoanarcocapitalismosecessãoautopropriedade e assuntos correlatos; e um fato que me chamou bastante a atenção foi a mudança editorial que vem acontecendo de forma lenta em um dos principais Think tanks liberais do Brasil que é o Instituto Mises Brasil (IMB). (Nota do Autor [3]: Eu penso que a editoria ficou ruim ao longo do tempo e o instituto virou apenas uma parodia sem graça voltada ao desenvolvimentismo, financismo bancário com a redução do foco na liberdade.)

Minha hipótese é que devido à diferenças editoriais sobre assuntos ligados à secessão houve uma ruptura entre o Hélio Beltrão e os Chiocca Brothers (Christiano e Fernando) com a consequente mudança de diretoria editorial no IMB e com a fundação do Instituto Rothbard. Os detalhes podem ser encontrados aqui

Eu estou trabalhando em alguns posts adicionais sobre isso, mas para apenas mostrar o meu ponto sobre Word Cloud eu vou usar como exemplo alguns textos de um autor do IMB chamado Fernando Ulrich.

Olhando rapidamente a linha editorial do Fernando Ulrich podemos ver que ele fala majoritariamente sobre 3 assuntos: Moeda, Bitcoin e Sistema Bancário. E ate mesmo um leitor que começou deste autor ontem sabe disto.

Contudo, gerando uma simples Word Cloud com os textos deste autor temos o seguinte resultado:

Word Cloud de todos os posts do Fernando Ulrich

Podemos ver que as palavras que aparecem mais são: Mercado, ano, Governo, Economia, e com Bitcoin, Moeda e Dinheiro com uma frequência menor (Nota do Autor [4]: Eu intencionalmente não usei nenhum tipo de steeming ou lemmatization pois eu queria ter mais interpretabilidade nessa análise). 

Entretanto, vamos olhar agora os mesmos textos usando TF-IDF:

TF-IDF com os textos do Fernando Ulrich

Vejam que usando as top 30 palavras com o maior TF-IDF score podemos ver que as palavras BitcoinBancosBacen, e Moeda aparecem com um TF-IDF score muito superior do que as palavras do Word Cloud (lembrando: Mercado, ano, Governo, Economia); e como leitor dos artigos do Fernando Ulrich fica claro que ele fala sobre esses assuntos com uma frequência muito maior, fato este que pode ser visto nos vídeos dele em seu excelente canal no Youtube

Agora dentro da perspectiva de mapear as sequencias locais dos textos usando Word NGrams, temos o seguinte resultado com os textos do IMB que o Fernando Ulrich escreveu:

Word N-Grams dos textos do Fernando Ulrich (n=3)

Podemos ver aqui que os temas convergem para assuntos como bancos centrais, sistema bancário, e questões relativas ao sistema de reservas fracionadas, o que converge também com as temáticas do seu canal do Youtube e que todo leitor mais antigo desse autor sabe. 

Conclusão

Como vimos acima, quando vamos realizar a análise de textos de forma a dar uma interpretação sobre o que foi dito e/ou a temática do que está sendo falado, o uso de Word Cloud definitivamente não é o melhor e que a combinação TF-IDF + Word N-Grams geram resultados que representam muito melhor a realidade devido ao fato que essas trazem um nível maior mais semântica e encadeamento das palavras que são na pratica aspectos que representam melhor a realidade do que a frequência propriamente dita.

Notas Finais

Nota Final [1]: Amiguinhos intolerantes dos extremos de ambos os espectros, segurem a emoção nos comentários pois eu tenho um pipeline bem grande de coisas que eu estou fazendo nesse sentido para desagradar muita gente ainda. 

Mesmo em 2019 esse tipo de aviso é importante: Analisar os textos das pessoas não significa estar em linha com o que elas falam ou mesmo algum tipo de endosso das opiniões delas.

Acreditem ou não, existe uma serie de pessoas que têm um modelo mental suficientemente robusto para conseguir ler desde a biografia bilionário que é usado em Fanfic empresarial de palco até conseguir entender a luta de pessoas que mesmo sendo 50,94% de uma população dado o seu grupo racial, essas pessoas não têm um único representante de peso na sociedade como um CEO de multinacional ou alguém de relevância poder executivo e judiciário

Nota Final [2]: Eu sei que tem código envolvido e eu vou compartilhar até o final da próxima semana (ao menos o database eu solto). Eu não compartilhei ainda dado que eu tenho que colocar em um nível mínimo de legibilidade para que as pessoas não pensem que eu chicoteei um macaco até ele conseguir digitar todo o script. 

Nota Final [3]: Tem um monte de erro gramatical, semântico e lógico nesse post e todos eles são meus e eu vou ajustar um dia. Aos poucos eu vou corrigir dado além do fato de que eu fui alfabetizado no Brasil no ensino publico durante os anos 90, tenho como fatores agravantes de que eu estou digitando esse texto em um teclado austríaco (ba dum tss) com um corretor ortográfico em… Alemão. Então, relevem.

Queridos Cientistas de Dados brasileiros: Por favor, entendam por que Word Cloud é horrível para Análise de textos e tentem TF-IDF + Word N-Grams

Facebook FastText – Automatic Hyperparameter optimization with Autotune

Disclaimer: some of the information in this blog post might be incorrect and as FastText it’s very fast-paced to correct and adjust things probably some parts of this post may be can be out-of-date very soon too. If you have some correction or feedback feel free to comment.

I’m finishing some experiments with the new feature of FastText for hyperparametrization for the training time called Autotune.

What is Autotune?

From the press release the description of Autotune is:

[…]This feature automatically determines the best hyperparameters for your data set in order to build an efficient text classifier[…].

[…]FastText then uses the allotted time to search for the hyperparameters that give the best performance on the validation set.[…].

[…]Our strategy to explore various hyperparameters is inspired by existing tools, such as Nevergrad, but tailored to fastText by leveraging the specific structure of models. Our autotune explores hyperparameters by sampling, initially in a large domain that shrinks around the best combinations found over time[…]

Autotune Strategy

Checking the code we can find the search strategy for the Autotune follows:

For all parameters, the Autotuner have an updater (method updateArgGauss()) that considers a random number provided by a Gaussian distribution function (coeff) and set an update number between a single standard deviation (parameters startSigma and endSigma) and based on these values the coefficients have an update.

Each parameter has a specific range for the startSigma and endSigma that it’s fixed in the updateArgGauss method.

Updates for each coefficient can be linear (i.e. updateCoeff + val) or power (i.e. pow(2.0, coeff); updateCoeff * val) and depends from the first random gaussian random number that are inside of standard deviation.

After each validation (that uses a different combination of parameters) one score (f1-score only) it’s stored and the best one will be used to train the full model using the best combination of parameters.

Arguments Range

  • epoch1 to 100
  • learning rate0.01 to 5.00
  • dimensions1 to 1000
  • wordNgrams1 to 5
  • loss: Only softmax
  • bucket size10000 to 10000000
  • minn (min length of char ngram): 1 to 3
  • maxn (max length of char ngram): 1 to minn + 3
  • dsub (size of each sub-vector): 1 to 4

Clarification posted in issues in FastText project.

In terms of metrics for optimization there’s only the f1score and labelf1score metrics.

Advantages

  • In some domains where the FaxtText models are not so critical in terms of accuracy/recall/precision, the Timeboxing optimization can be very useful
  • Extreme simplicity for implementation. It’s just to call more args in the train_supervised()
  • Source code transparent where we can check some of the behaviors
  • The search strategy it’s simple and has some boundaries that cut extreme training parameters (e.g. Learning Rate=10.0, Epoch=10000, WordNGrams=70, etc)

Disadvantages

  • FastText still doesn’t provide any log about the convergence. In that case, maybe a log for each model tested could be nice.
  • Maybe the search strategy could be a bit clarified in terms of boundaries, parameter initialization and so on
  • Boundaries parameters `startSigma` and `endSigma` follow a Gaussian distribution and I think this maybe can be explained in docs
  • Same for the hardcoded parameters that define the boundaries for each parameter. Something like _Based in some empirical tests we got these values. However, you can test a certain amount of combinations an open a PR if you find some good intervals. _
  • Autotune maybe can process in several combination with not so good parameters before starting a good sequence of optimization (i.e. in a search space budget of 100 combinations the first 70 can be not so useful). The main idea of Autotune it’s to be “automatic” but could be useful have some option/configuration to a more broader or optimized configuration.

The Jupyter Notebook can be found in my Github.

Facebook FastText – Automatic Hyperparameter optimization with Autotune

My Personal Holy Trinity for Machine Learning Reproducibility

Short and direct:

ML Flow
Why I do use? (a.k.a What was my pain?)
One of the most painful situations that I faced was spent a huge time coding doing hyperparameter search and track the whole experimental setup. With ML Flow right now the only thing that I need to do it’s just investing time to pre-process the data and choose the algorithm to train; the model serialization, data serialization, packaging it’s all done by MLFlow. A great advantage it is that the best model can be deployed in a REST/API easily instead to use a customized Flask script.


Caveats: I really love Databricks but I think sometimes they’re so fast in their development (sic.) and this can cause some problems, especially if you’re relying on a very stable version and suddenly with some migration you can lose a lot of work (e.g. RDD to Dataframe) because rewrite things again.

Pachyderm
Why I do use? (a.k.a What was my pain?)
Data pre-processing sometimes can be very annoying and there’s a lot of new tools that actually overpromise to solve it, but in reality, it’s only a over-engineer stuff with a good Marketing (see this classic provided by Daniel Molnar to understand what I’m talking about (minute 15:48))

My main wish in the last 5 years it’s package all dirty SQL scripts in a single place just to execute with decent version control using Kubernetes and Docker and throw all ETLs made in Jenkins to trash (a.k.a embrace the dirty, cold, and complex reality of ETL). Nothing less, nothing more.

So, with Pachyderm I can do that.

Caveats: It’s necessary to say that you’ll need to know Docker and embrace all the problems related, and the bug list can be a little frightening.

DVC
Why I do use? (a.k.a What was my pain?)
ML Flow can serialize data and models. But DVC put this reproducibility in another level. With less than 15 commands in bash git-like you can easily serialize one versioning your data, code, and models. You can put the entire ML Pipeline in a single place and rolling back any point in time. In terms of reproducibility I think this is the best all-round tool.

Caveats: In comparison with ML Flow the navigation over the experiments here it’s a little bit hard tricky and demands some time to get used.

My Personal Holy Trinity for Machine Learning Reproducibility

Text Classification Algorithms: A Survey

One of the hardest tasks in Machine Learning it is Text Analysis or Classification. This is due the nature of the text data itself that could contain an arbitrary complexity in terms of vocabulary, semantics, etymological components, morphology, grammar and polysemy to show a few examples.

The following paper from Kowsari et. al. called Text Classification Algorithms: A Survey it is probably one of the best resources – in the practitioners perspective – in Machine Learning.

The paper makes a warp-up about almost all available tools for Text Classification and explains in a clear language the advantages and caveats of all of them. It’s really important to say that the paper considers also the role of embeddings to capture syntatic (position of the word in the text) and semantics (meaning of the words) to enhance the learning task by the algorithms.

This is a mandatory resource for whom needs to apply in a practical way Text Classification and the authors even built a repository in Github to make all the blog post and code of the paper available.

Text Classification Algorithms: A Survey

Abstract: In recent years, there has been an exponential growth in the number of complex documents and texts that require a deeper understanding of machine learning methods to be able to accurately classify texts in many applications. Many machine learning approaches have achieved surpassing results in natural language processing. The success of these learning algorithms relies on their capacity to understand complex models and non-linear relationships within data. However, finding suitable structures, architectures, and techniques for text classification is a challenge for researchers. In this paper, a brief overview of text classification algorithms is discussed. This overview covers different text feature extractions, dimensionality reduction methods, existing algorithms and techniques, and evaluations methods. Finally, the limitations of each technique and their application in real-world problems are discussed.

Kowsari et. al. Link: https://www.mdpi.com/2078-2489/10/4/150
Text Classification Algorithms: A Survey

Tunability, Hyperparameters and a simple Initial Assessment Strategy

Most of the time we completely rely in the default parameters of Machine Learning Algorithm and this fact can hide that sometimes we can make wrong statements about the ‘efficiency’ of some algorithm.

The paper called Tunability: Importance of Hyperparameters of Machine Learning Algorithms from Philipp Probst, Anne-Laure Boulesteix, Bernd Bischl in Journal of Machine Learning Research (JMLR) bring some light in this subject. This is the abstract:

Modern supervised machine learning algorithms involve hyperparameters that have to be set before running them. Options for setting hyperparameters are default values from the software package, manual configuration by the user or configuring them for optimal predictive performance by a tuning procedure. The goal of this paper is two-fold. Firstly, we formalize the problem of tuning from a statistical point of view, define data-based defaults and suggest general measures quantifying the tunability of hyperparameters of algorithms. Secondly, we conduct a large-scale benchmarking study based on 38 datasets from the OpenML platform and six common machine learning algorithms. We apply our measures to assess the tunability of their parameters. Our results yield default values for hyperparameters and enable users to decide whether it is worth conducting a possibly time consuming tuning strategy, to focus on the most important hyperparameters and to choose adequate hyperparameter spaces for tuning.

Probst, Boulesteix, Bischl in Tunability: Importance of Hyperparameters of Machine Learning Algorithms

I recognize that the abstract sounds not so appealing, but the most important part of the text for sure it’s related in one table and one graph about the Tunability, i.e. how tuneable one parameter is according the other default values.

As we can observe in the columns Def.P (package defaults) and Def.O (optimal defaults) even in some vanilla algorithms we have some big differences between them, specially in Part, XGBoost and Ranger.

If we check the variance across this hyper parameters, the results indicates that the problem can be worse that we imagined:

As we can see in a first sight there’s a huge variance in terms of AUC when we talk about the default parameters.

Checking these experiments two big questions arises:

  1. How much inefficiency it’s included in some process of algorithm assessment and selection because for the ‘initial model‘ (that most of the times becomes the last model) because of relying in the default values? and;
  2. Because of this misleading path to consider some algorithm based purely in defaults how many ML implementations out there is underperforming and wasting research/corporate resources (e.g. people’s time, computational time, money in cloud providers, etc…)?

Initial Assessment Strategy

A simple strategy that I use for this particular purpose it’s to use a two-phase hyperparameter search strategy where in the first phase I use to make a small knockout round with all algorithms using Random Search to grab the top 2 or 3 models, and in the second phase I use Grid Search where most of the time I explore a large number of parameters.

According the number of samples that I have in the Test and Validation sets, I usually let the search for at least 24 hours in some local machine or in some cloud provider.

I do that because with this ‘initial‘ assessment we can have a better idea which algorithm will learn more according the data that I have considering dimensionality, selectivity of the columns or complexity of the word embeddings in NLP tasks, data volume and so on.

Conclusion

The paper makes a great job to expose the variance in terms of AUC using default parameters for the practitioners and can give us a better heuristic path in terms to know which parameters are most tunable and with this information in hands we can perform better search strategies to have better implementations of Machine Learning Algorithms.

Tunability, Hyperparameters and a simple Initial Assessment Strategy

Benchmark-ML: Cutting the Big Data Hype

This is the most important benchmark project already done in Machine Learning. I’ll let for you the summary provided:

When I started this benchmark in March 2015, the “big data” hype was all the rage, and the fanboys wanted to do machine learning on “big data” with distributed computing (Hadoop, Spark etc.), while for the datasets most people had single-machine tools were not only good enough, but also faster, with more features and less bugs. I gave quite a few talks at conferences and meetups about these benchmarks starting 2015 and while at the beginning I had several people asking angrily about my results on Spark, by 2017 most people realized single machine tools are much better for solving most of their ML problems. While Spark is a decent tool for ETL on raw data (which often is indeed “big”), its ML libraries are totally garbage and outperformed (in training time, memory footpring and even accuracy) by much better tools by orders of magnitude. Furthermore, the increase in available RAM over the last years in servers and also in the cloud, and the fact that for machine learning one typically refines the raw data into a much smaller sized data matrix is making the mostly single-machine highly-performing tools (such as xgboost, lightgbm, VW but also h2o) the best choice for most practical applications now. The big data hype is finally over.

Github Repo
Benchmark-ML: Cutting the Big Data Hype

Post-training quantization in FastText (or How to shrink your FastText model in 90%)

In one experiment using a very large text database I got at the end of training using train_supervised()in FastText a serialized model with more than 1Gb.

This behavior occurs because the mechanics of FastText deals with all computation embedded in the model itself: label encoding, parsing, TF-IDF transformation, word-embeddings, calculate the WordNGrams using bag-of-tricks, fit, calculate probabilities and the re-application of the label encoding.

As you noticed in a corpus with more than 200.000 words and wordNGrams > 3this can escalate very quickly in terms of storage.

As I wrote before it’s really nice then we have a good model, but the real value comes when you put this model in production; and this productionize machine learning it’s a barrier that separates girls/boy from woman/man.

With a large storage and memory footprint it’s nearly impossible to make production-ready machine learning models, and in terms of high performance APIs large models with a huge memory footprint can be a big blocker in any decent ML Project.

To solve this kind of problem FastText provides a good way to compress the size of the model with little impact in performance. This is called port-training quantization.

The main idea of Quantization it’s to reduce the size of original model compressing the vectors of the embeddings using several techniques since simple truncation or hashing. Probably this paper (Shu, Raphael, and Hideki Nakayama. “Compressing word embeddings via deep compositional code learning.”) it’s one of the best references of this kind of technique.

This is the performance metric of one vanilla model with full model:Recall:0.79

I used the following command in Python for the quantization, model saving and reload:

# Quantize the model
model.quantize(input=None,
                  qout=False,
                  cutoff=0,
                  retrain=False,
                  epoch=None,
                  lr=None,
                  thread=None,
                  verbose=None,
                  dsub=2,
                  qnorm=False,
                 )

# Save Quantized model
model.save_model('model_quantized.bin')

# Model Quantized Load
model_quantized = fastText.load_model('model_quantized.bin')

I made the retraining using the quantized model and I got the following results:

# Training Time: 00:02:46
# Recall: 0.78

info_old_model = os.path.getsize('model.bin') / 1024.0
info_new_model = os.path.getsize('model_quantized.bin') / 1024.0

print(f'Old Model Size (MB): {round(info_old_model, 0)}')
print(f'New Model Size (MB): {round(info_new_model, 0)}')

# Old Model Size (MB): 1125236.0
# New Model Size (MB): 157190.0

As we can see after the shrink in the vanilla model using quantization we had the Recall: 0.78 against 0.79 with a model 9x lighter in terms of space and memory footprint if we need to put this model in production.

Post-training quantization in FastText (or How to shrink your FastText model in 90%)

Reproducibility in FastText

A few days ago I wrote about FastText and one thing that is not clear in docs it’s about how to make the experiments reproducible in a deterministic day.

In default settings of train_supervised() method i’m using the thread parameter with multiprocessing.cpu_count() - 1 as value.

This means that we’re using all the CPUs available for training. As a result, this implies a shorter training time if we’re using multicore servers or machines.

However, this implicates in a totally non-deterministic result because of the optimization algorithm used by fastText (asynchronous stochastic gradient descent, or Hogwildpaper here), the obtained vectors will be different, even if initialized identically.

This very gentle guide of FastText with Gensim states that:

for a fully deterministically-reproducible run, you must also limit the model to a single worker thread (workers=1), to eliminate ordering jitter from OS thread scheduling. (In Python 3, reproducibility between interpreter launches also requires use of the PYTHONHASHSEED environment variable to control hash randomization).

Radim Řehůřek in FastText Model

So for that particular reason the main assumption here it’s even playing in a very stocastic environment of experimentation we’ll consider only the impact of data volume itself and abstract this information from the results, for the reason that this stocastic issue can play for both experiments.

To make reproducible experiments the only thing that it’s needed it’s to change the value of thread parameter from multiprocessing.cpu_count() - 1to 1.

So for the sake of reproducibility the training time will take longer (in my experiments I’m facing an increase of 8000% in the training time.

Reproducibility in FastText

Dica de Python: Dask

Para quem não aguenta mais sofrer com o Pandas e não quer lidar com as inúmeras limitações do Scala o Dask é uma ótima biblioteca para manipulação de dados e computação em Python.

Direto da documentação:

Familiar: Provides parallelized NumPy array and Pandas DataFrame objects

Flexible: Provides a task scheduling interface for more custom workloads and integration with other projects.

Native: Enables distributed computing in pure Python with access to the PyData stack.

Fast: Operates with low overhead, low latency, and minimal serialization necessary for fast numerical algorithms

Scales up: Runs resiliently on clusters with 1000s of cores

Scales down: Trivial to set up and run on a laptop in a single process

Responsive: Designed with interactive computing in mind, it provides rapid feedback and diagnostics to aid humans

Dica de Python: Dask

FastText – A great tool for Text Classification

In some point of time, I’ll post a field report about FastText in a project for Text Classification. My opinion until this moment (16.03.19): For a fast Alpha version of a text classification with robust use of Bag-of-Tricks and WordNGrams it’s amazing in terms of practical results (especially Recall) and speed of development.

Imagem