Nesse post, vamos criar um classificador de comentários tóxicos a partir de uma base de dados disponibilizada em uma competição do Kaggle. Serão utilizadas técnicas chamadas de bag-of-words e tf-idf.
A internet é muitas vezes considerada “terra de ninguém”. E um dos problemas mais comums são comentários tóxicos, para não usar outro adjetivo. A moderação desses comentários é uma atividade que surge naturalmente. No entando, pode ser inviável analisar individualmente todos comentários de uma grande plataforma, por exemplo.
Nesse sentido, pode ser útil criar um classificador automático. Dessa forma, será exemplificado como criar um classificador no R. Para isso, utilizaremos os dados do desafio do Kaggle Toxic Comment Classification Iniciaremos, com uma solução mais simples e em outros posts tentaremos utilizar técnicas mais sofisticadas. Uma lista de soluções pode ser encontrada no fórum da competição.
Aqui estão os pacotes que utilizaremos neste post:
library(tidyverse)
library(text2vec)
library(purrr)
library(glmnet)
Os dados podem ser baixados neste link. Precisaremos de três arquivos: train.csv
, test.csv
e test_labels.csv
. O arquivo test_labels.csv
contém os valores que deveriam ter sido previstos na competição. O Kaggle libera esse arquivo depois que a competição é finalizada. Esse arquivo será útil para avaliarmos o nosso modelo.
# Defina aqui o caminho para a pasta que estão os dados
caminho_dados <- 'dados/'
list.files(caminho_dados)
[1] "subm.csv" "test.csv" "test_labels.csv"
[4] "train.csv"
Lendo os dados de treinamento:
train <- read_csv(file.path(caminho_dados, 'train.csv'))
dim(train)
[1] 159571 8
head(train)
# A tibble: 6 x 8
id comment_text toxic severe_toxic obscene threat insult
<chr> <chr> <int> <int> <int> <int> <int>
1 0000~ "Explanatio~ 0 0 0 0 0
2 0001~ D'aww! He m~ 0 0 0 0 0
3 0001~ Hey man, I'~ 0 0 0 0 0
4 0001~ "\"\nMore\n~ 0 0 0 0 0
5 0001~ You, sir, a~ 0 0 0 0 0
6 0002~ "\"\n\nCong~ 0 0 0 0 0
# ... with 1 more variable: identity_hate <int>
O arquivo train.csv
possui 8 colunas e 159.571 observações. A primeira variável é o id, a coluna comment_text
traz o texto do comentário e há mais 6 colunas que indicam se comentário foi classificado em uma ou mais categoria de comentário tóxico.
O que seria um comentário tóxico? Quem quiser pode pular essa parte…Tentei mostrar um comentário mais suave.
set.seed(2031)
train %>%
filter(toxic == 1) %>%
sample_n(1) %>%
pull(comment_text)
[1] "Wikipedia is too serious an encyclopedia to accommodate your ignorance."
A figura abaixo apresenta a proporção de comentários classificados como “tóxico” por categoria de toxicidade. Uma categoria é denominada de “tóxico”, mas estou usando o termo tóxico pra classificar comentário de qualquer uma das categorias.
train %>%
summarise_if(is.numeric, mean) %>%
gather(key = classe, value = proporcao) %>%
ggplot(aes(x = classe, y = proporcao)) +
geom_col(fill = "#F56613F1") +
scale_y_continuous(labels = scales::percent) +
theme_minimal() +
labs(
title = "Proporção de Comentários Tóxicos por Categoria",
y = "Proporção",
x = "Categoria"
)
O primeiro ponto importante é que existe um desbalanceamento nos dados. Ou seja, a proporção de comentários tóxicos é baixa, principalmente na classe threat
.
Precisamos converter os textos dos comentários em variáveis (features) que serão utilizadas para treinar o nosso classificador. Uma técnica comum é denominada de bag-of-words, que consiste em criar uma variável para cada palavra do vocabulário e contar quantas vezes aquelas palavra aparece em cada “documento”. Também é possível usar n-gramas, que são combinações de n palavras.
Antes, vamos definir uma função que será usada para preprocessar os comentários.
limpa_texto <- function(comment){
tolower(comment) %>%
# espaço entre os caracteres especiais
str_replace_all("([“”¨«»®´·º½¾¿¡§£₤‘’~])", " \\1 ") %>%
# espaço entre as pontuações
str_replace_all("([[:punct:]])", " \\1 ") %>%
# substitui quebra de linha
str_replace_all("\\n", ' ')
}
limpa_texto("teste de processamento!!\n!!")
[1] "teste de processamento ! ! ! ! "
Para fazer a vetorização dos comentários, será utilizado o pacote text2vec
. O código abaixo cria o vocabulário. A função space_tokenizer
é utilizada como separador de termos para o dicionário. Além disso, são removidas as chamadas stopwords, que são palavras que não adicionam valor na nossa análise.
it_train <- itoken(
train$comment_text,
preprocessor = limpa_texto,
tokenizer = word_tokenizer,
id = train$id,
progressbar = FALSE
)
vocabulario <- create_vocabulary(it_train,
stopwords = tm::stopwords('en'))
vocabulario
Number of docs: 159571
174 stopwords: i, me, my, myself, we, our ...
ngram_min = 1; ngram_max = 1
Vocabulary:
term term_count doc_count
1: praguepost 1 1
2: ht1b 1 1
3: specialiststhe 1 1
4: bucheon 1 1
5: wiselius 1 1
---
186770: page 46568 27842
186771: wikipedia 48627 26997
186772: article 57776 32157
186773: t 61126 38759
186774: s 72705 40795
O nosso vocabulário possui mais 200.000 palavras. Cada palavra, potencialmente, irá virar uma variável. No entanto, é comum reduzir o vocabulário com o intuito de eficiência. Para reduzir o vocabulário vamos utilizar a função prune_vocabulary()
. Além da redução de dimensionalidade do problema, palavras que aparecem em quase todos comentários ou quase nunca, dificilmente serão úteis para separar se o post é tóxico ou não. Abaixo fazemos a redução do vocabulário.
vocabulario <- prune_vocabulary(
vocabulary = vocabulario,
term_count_min = 5,
doc_proportion_max = 0.9,
vocab_term_max = 20000
)
vocabulario
Number of docs: 159571
174 stopwords: i, me, my, myself, we, our ...
ngram_min = 1; ngram_max = 1
Vocabulary:
term term_count doc_count
1: s 72705 40795
2: t 61126 38759
3: article 57776 32157
4: wikipedia 48627 26997
5: page 46568 27842
---
19996: militancy 15 13
19997: cas 15 14
19998: sandra 15 15
19999: pandyan 15 3
20000: smoked 15 12
Por fim, é criado o vetorizador.
toxic_vectorizer <- vocab_vectorizer(vocabulary = vocabulario)
O vetorizador é usado na função create_dtm()
para criar nossa matriz de features. Como a maioria dos elementos da matriz serão zero, utilizamos o formato dgCMatrix
para reduzir a necessidade de memória.
train_dtm <- create_dtm(
it = it_train,
vectorizer = toxic_vectorizer,
type = 'dgCMatrix'
)
dim(train_dtm)
[1] 159571 20000
Assim, é criada uma matriz com 159571 linhas (número de comentários) e 20000 colunas (número de variáveis). Essa seria a matriz de features que utilizaríamos no nosso modelo.
A técnica de bag-of-words permanece com o problema de simplesmente analisar a frequência de cada palavra em um comentário. Todavia, é preciso considerar que, se uma palavra aparece comumente em muitos comentários, ela não pode agregar muito valor ao modelo. Para atenuar esse problema, usualmente é utilizada uma técnica chamada TF-IDF (term frequency-inverse document frequency). Neste método, a frequência de um termo calculada no bag-of-words é multiplicada por um termo denominado idf (inverse document frequency). O idf é calculado da seguinte forma:
\[ IDF(t, d) = \log \frac{N_d}{1 + df(d,t)} \]
\(N_d\) é o número de documento e \(df(d,t)\) é número de documentos em que o termo \(t\) aparece. Assim, o valor da variável será calculando multiplicando o número de vezes que a palavra \(w\) apareceu do documento \(d\) pela quantidade \(IDF(t,d)\).
Para calcular a matriz de features utilizando o TF-IDF, é criado um novo objeto e são utilizados os dados de treinamento para calcular os valores necessários e transformar os nossos dados. Mais a frente, iremos aplicar apenas a transformação aos dados de teste.
tfidf <- TfIdf$new(
norm = 'l2'
)
train_dtm_tfidf = fit_transform(train_dtm, tfidf)
Repetimos o mesmo processo para criar uma bag-of-characters. No entanto, vamos usar n-grams que são combinações de n caracters. Utilizamos combinações de 2 a 6 caracteres e limitamos nosso vocabulário em 20.000 termos.
it_train_char <- itoken(
train$comment_text,
preprocessor = limpa_texto,
tokenizer = char_tokenizer,
id = train$id,
progressbar = FALSE
)
vocabulario_char <- create_vocabulary(it_train_char,
ngram = c(2, 6))
vocabulario_char <- prune_vocabulary(
vocabulary = vocabulario_char,
term_count_min = 5,
doc_proportion_max = 0.9,
vocab_term_max = 20000
)
toxic_vectorizer_char <- vocab_vectorizer(vocabulary = vocabulario_char)
tfidf_char <- TfIdf$new(
norm = 'l2'
)
train_dtm_char <- create_dtm(
it = it_train_char,
vectorizer = toxic_vectorizer_char,
type = 'dgCMatrix'
) %>%
fit_transform(tfidf_char)
train_dtm_tfidf <- Matrix::cBind(train_dtm_tfidf, train_dtm_char)
Aqui, iremos treinar um classificador para cada categoria. Será utilizado o modelo elastic-net que está disponível no pacote glmnet
. Basicamente, utilizaremos uma regressão logística com uma regularização que combina penalidades do tipo \(l_1\) e \(l_2\). Os parâmetros são obtidos resolvendo o seguinte problema:
\[\min_{\beta_0, \beta} \frac{1}{N}\sum_{i=1}^N w_i l(y_i, \beta_0 + \beta^Tx_i) + \lambda[(1-\alpha)||\beta||^2_2 + \alpha||\beta||_1],\] em que \(N\) é o número de observações, \(w_i\), \(y_i\) e \(x_i\) são, respectivamente, o peso, o target e o vetor de features da i-ésima observação. A função \(l(.)\) é o negativo do log-likelihood, que depende da distribuição assumida. \(\beta_0\), \(\beta^T\) são parâmetros que serão estimados. Adicionalmente, \(\lambda\) e \(\alpha\) são hiperparâmetros que não são obtidos diretamente pela solução do problema. O parâmetro \(\lambda\) controla o nível de regularização e o parâmetro \(\alpha\) controla a combinação entre os dois tipos de regularização. O parâmetro \(\lambda\) será obtido via validação cruzada (5 folds) e o será fixado em 0.5.
A função abaixo faz o cross-validation e retorna um data.frame com algumas informações, além do modelo treinado.
classificador_toxico <- function(categoria, alpha, nfolds = 5){
print(categoria)
set.seed(303022)
folds <- train %>%
group_by(!!categoria) %>%
mutate(fold = sample(1:nfolds, n(), replace = T)) %>%
pull(fold)
# Cálculo do peso de cada observação
# observações como y = 1 entrarão com um peso maior
data_weights <- ifelse(train[[categoria]] == "1",
(1/table(train[[categoria]])[2]) * 0.5,
(1/table(train[[categoria]])[1]) * 0.5)
fit <- cv.glmnet(
x = train_dtm_tfidf,
y = train[[categoria]],
family = "binomial",
alpha = alpha,
weights = data_weights,
type.measure = "auc",
foldid = folds ,
maxit = 1000
)
resultado <- tibble(
categoria = categoria,
alpha = alpha,
lambda = fit$lambda.min,
auc = max(fit$cvm)
)
list(
resultado = resultado,
modelo = fit
)
}
Utilizaremos o pacote purrr
para criar um classificador para cada categoria e coletar o modelo treinado e os resultados da validação.
cv_grid <- expand.grid(
categoria = c("toxic", "severe_toxic", "obscene",
"threat", "insult", "identity_hate"),
alpha = 0.5,
stringsAsFactors = FALSE
)
resultados_cv <- pmap(
cv_grid,
classificador_toxico
)
[1] "toxic"
[1] "severe_toxic"
[1] "obscene"
[1] "threat"
[1] "insult"
[1] "identity_hate"
modelos <- map(resultados_cv, "modelo")
resultados <- map_df(resultados_cv, "resultado") %>%
mutate(modelo = modelos) %>%
group_by(categoria) %>%
filter(auc == max(auc))
Aqui, vemos quais foram os melhores valores encontrados de \(\lambda\) em termos de AUC (area under the curve) que foi a métrica escolhida na competição.
resultados %>%
select(-modelo) %>%
group_by(categoria) %>%
filter(auc == max(auc)) %>%
knitr::kable()
categoria | alpha | lambda | auc |
---|---|---|---|
toxic | 0.5 | 0.0033502 | 0.9766353 |
severe_toxic | 0.5 | 0.0071474 | 0.9868013 |
obscene | 0.5 | 0.0046081 | 0.9897352 |
threat | 0.5 | 0.0049738 | 0.9863266 |
insult | 0.5 | 0.0044995 | 0.9803934 |
identity_hate | 0.5 | 0.0076102 | 0.9776454 |
O AUC médio foi de 0.9829229.
Por fim, vamos avaliar os modelos treinados na base de teste. Para cada comentário, são realizadas seis predições, uma para cada categoria. O score final será a média do AUC entre todas as categorias.
Aqui, vamos preparar os dados. Os comentários da base de teste serão vetorizados com base no vocabulário da base de treinamento.
test <- read_csv('dados/test.csv')
it_test <- itoken(
test$comment_text,
preprocessor = limpa_texto,
tokenizer = word_tokenizer,
id = test$id,
progressbar = FALSE
)
it_test_char <- itoken(
test$comment_text,
preprocessor = limpa_texto,
tokenizer = char_tokenizer,
id = test$id,
progressbar = FALSE
)
test_dtm_tfidf <- create_dtm(it_test, toxic_vectorizer) %>%
transform(tfidf) %>%
cbind(.,
create_dtm(it_test_char, toxic_vectorizer_char) %>%
transform(tfidf_char))
Abaixo, vemos os dados dos targets na base de teste.
test_labels <- read_csv('dados/test_labels.csv')
head(test_labels)
# A tibble: 6 x 7
id toxic severe_toxic obscene threat insult identity_hate
<chr> <int> <int> <int> <int> <int> <int>
1 00001cee341~ -1 -1 -1 -1 -1 -1
2 00002478678~ -1 -1 -1 -1 -1 -1
3 00013b17ad2~ -1 -1 -1 -1 -1 -1
4 00017563c3f~ -1 -1 -1 -1 -1 -1
5 00017695ad8~ -1 -1 -1 -1 -1 -1
6 0001ea8717f~ 0 0 0 0 0 0
Os valores -1
indicam que aqueles comentários não foram utilizados no cálculo do score final. Dessa forma, teremos que desconsiderá-los na avaliação do modelo.
auc <- c()
for(i in resultados$categoria){
# recupera o modelo para a categoria i
modelo <- resultados %>%
filter(categoria == i) %>%
pull(modelo)
modelo <- modelo[[1]]$glmnet.fit
# Valor do parâmetro lambda
lambda <- resultados %>%
filter(categoria == i) %>%
pull(lambda)
# Cria indicador se a observação entra ou não no cálculo do score
valido <- test_labels[[i]] != -1
# Predições
pred <- predict(modelo, test_dtm_tfidf, s = lambda, type = "response")[,1]
# Valores observados
y <- test_labels[[i]]
# Cálculo do AUC
auc <- c(auc, MLmetrics::AUC(pred[valido], y[valido]))
}
names(auc) <- resultados$categoria
auc
toxic severe_toxic obscene threat insult
0.9618778 0.9833989 0.9757907 0.9878752 0.9701873
identity_hate
0.9813508
O AUC médio na base de teste é igual a 0.9767. Este é o nosso resultado! Na plataforma do Kaggle, obtemos os seguintes resultados (ligeiramente diferentes):
Obviamente, esse resultado não faz cócegas nos resultados obtidos pelos primeiros lugares. No entanto, considerando a classe de modelos lineares, nossos resultados são razoáveis. Inclusive, comparáveis aos resultados dos principais kernel para algoritmos que não está na categoria deep learning.
A ideia é apresentar no futuro, para esse mesmo conjunto de dados, resultados utilizando técnicas de deep learning, mas por hoje é só! Espero que o post tenha trazido algo de útil.
For attribution, please cite this work as
Alencar (2018, Nov. 18). Bag of Posts: Classificando Comentários Tóxicos com o R. Retrieved from https://paulofelipe.github.io/bag_of_posts/posts/2018-10-24-classificando-comentrios-txicos/
BibTeX citation
@misc{alencar2018classificando, author = {Alencar, Paulo Felipe}, title = {Bag of Posts: Classificando Comentários Tóxicos com o R}, url = {https://paulofelipe.github.io/bag_of_posts/posts/2018-10-24-classificando-comentrios-txicos/}, year = {2018} }