O setor de e-commerce no Brasil representa um domínio dinâmico e em expansão, caracterizado por desafios logísticos únicos devido à vasta extensão territorial e desigualdades regionais em infraestrutura. Este projeto aborda o problema de compreender os padrões operacionais e comportamentais no e-commerce brasileiro, utilizando o Brazilian E-Commerce Public Dataset by Olist, disponível no Kaggle (https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce). Para plataformas de marketplace e vendedores em geral, os insights derivados dessa análise podem otimizar estratégias de precificação, logística e retenção de clientes, contribuindo para a eficiência econômica em um mercado projetado para crescer.
Essa análise beneficiará potenciais clientes, como gerentes de e-commerce, ao fornecer evidências quantitativas para decisões informadas, como alocação de recursos logísticos ou campanhas de marketing segmentadas.
O dataset Olist é composto por nove arquivos CSV, coletados de transações reais no Brasil entre setembro de 2016 e outubro de 2018, com propósito original de facilitar análises acadêmicas e profissionais sobre o e-commerce. Originalmente, inclui mais de 100 variáveis distribuídas em tabelas relacionais, cobrindo aspectos como clientes (e.g., CEP, estado), geolocalização (lat/lon), pedidos (status, timestamps), itens de pedido (preço, frete), pagamentos (tipo, valor), reviews (score, comentário), produtos (categoria, dimensões), vendedores (CEP, estado) e tradução de categorias. Peculiaridades incluem NAs em timestamps de entrega (indicando pedidos incompletos), outliers em preços/fretes (possíveis bugs ou promoções extremas), e nomes de cidades não padronizados (com acentos, abreviações). Valores ausentes em reviews são comuns, refletindo opcionalidade de comentários.
readr: Para leitura eficiente de arquivos CSV,
otimizando desempenho com grandes datasets.dplyr: Para manipulação de dados via pipe (%>%),
facilitando filtros, mutates e joins.lubridate: Para operações em datas/timestamps,
essencial para análises temporais.stringr e stringi: Para processamento de
strings, como normalização de nomes de cidades.skimr: Para resumos iniciais de datasets, identificando
tipos e NAs.tidyr: ’Útil em agregações.ggplot2: Para visualizações.scales: Para formatação (moeda, datas) em plots.leaflet: Para mapas interativos, integrando coordenadas
geográficas.geosphere: Para cálculos de distância.library(readr)
library(dplyr)
library(lubridate)
library(stringr)
library(stringi)
library(skimr)
library(tidyr)
library(ggplot2)
library(scales)
library(leaflet)
library(geosphere)
customers <- read_csv("olist_customers_dataset.csv")
geolocation <- read_csv("olist_geolocation_dataset.csv")
order_items <- read_csv("olist_order_items_dataset.csv")
order_payments <- read_csv("olist_order_payments_dataset.csv")
order_reviews <- read_csv("olist_order_reviews_dataset.csv")
orders <- read_csv("olist_orders_dataset.csv")
products <- read_csv("olist_products_dataset.csv")
sellers <- read_csv("olist_sellers_dataset.csv")
category_translation <- read_csv("product_category_name_translation.csv")
brazil_states_regex <- "\\b(ac|ap|am|pa|ro|rr|to|al|ba|ce|ma|pb|pe|pi|rn|se|go|mt|ms|df|es|mg|rj|sp|pr|rs|sc)\\b"
regex_sudeste <- "\\b(sp|rj|mg|es)\\b"
regex_sul <- "\\b(pr|rs|sc)\\b"
regex_nordeste <- "\\b(ba|pe|ce|rn|pb|pi|al|se|ma)\\b"
regex_centro_oeste <- "\\b(go|mt|ms|df)\\b"
regex_norte <- "\\b(pa|am|ac|ro|rr|ap|to)\\b"Iniciei com skimr para avaliar estruturas, tipos de
variáveis e NAs, identificando questões como ausências em timestamps de
pedidos (e.g., entregas pendentes) e dimensões de produtos. Vejam a
baixo o exemplo de orders.
| Name | orders |
| Number of rows | 99441 |
| Number of columns | 8 |
| _______________________ | |
| Column type frequency: | |
| character | 3 |
| POSIXct | 5 |
| ________________________ | |
| Group variables | None |
Variable type: character
| skim_variable | n_missing | complete_rate | min | max | empty | n_unique | whitespace |
|---|---|---|---|---|---|---|---|
| order_id | 0 | 1 | 32 | 32 | 0 | 99441 | 0 |
| customer_id | 0 | 1 | 32 | 32 | 0 | 99441 | 0 |
| order_status | 0 | 1 | 7 | 11 | 0 | 8 | 0 |
Variable type: POSIXct
| skim_variable | n_missing | complete_rate | min | max | median | n_unique |
|---|---|---|---|---|---|---|
| order_purchase_timestamp | 0 | 1.00 | 2016-09-04 21:15:19 | 2018-10-17 17:30:18 | 2018-01-18 23:04:36 | 98875 |
| order_approved_at | 160 | 1.00 | 2016-09-15 12:16:38 | 2018-09-03 17:40:06 | 2018-01-19 11:36:13 | 90733 |
| order_delivered_carrier_date | 1783 | 0.98 | 2016-10-08 10:34:01 | 2018-09-11 19:48:28 | 2018-01-24 16:10:58 | 81018 |
| order_delivered_customer_date | 2965 | 0.97 | 2016-10-11 13:46:32 | 2018-10-17 13:22:46 | 2018-02-02 19:28:10 | 95664 |
| order_estimated_delivery_date | 0 | 1.00 | 2016-09-30 00:00:00 | 2018-11-12 00:00:00 | 2018-02-15 00:00:00 | 459 |
Nomes de cidades possuiam diversas inconsistências (acentos, maiúsculas/minúsculas, abreviações de estados anexadas, etc…), potencialmente causando duplicatas em agregações geográficas. Foi desenvolvida uma função para padronização, removendo pontuações e invalidando entradas inválidas, garantindo consistência lógica para análises subsequentes.
normalize_city <- function(cities) {
cities %>%
str_to_lower() %>%
str_remove(., paste0("\\s+", brazil_states_regex, "$")) %>%
str_replace(., ".+@.+\\..+", NA_character_) %>%
str_extract(., "^[^/\\\\,]+") %>%
str_remove_all(., "\\(.*?\\)") %>%
str_replace_all(., "d'|d´|d`", "d'") %>%
str_remove_all(., "[\\.'ºª´\\^~]") %>%
str_remove_all(., "^[\\.]+") %>%
str_replace_all(., "-", " ") %>%
str_squish() %>%
str_replace(., paste0("^", brazil_states_regex, "$"), NA_character_)
}
customers <- customers %>% mutate(customer_city = normalize_city(customer_city))
sellers <- sellers %>% mutate(seller_city = normalize_city(seller_city))
geolocation <- geolocation %>% mutate(geolocation_city = normalize_city(geolocation_city))O dataset geolocation continha múltiplas entradas por prefixo de CEP, o que é correto, porém interfere na visualização de mapas. Agreguei por média de lat/lon para obter pontos representativos, reduzindo ruído e facilitando visualizações espaciais sem perda significativa de precisão.
Em produtos, NAs em dimensões foram imputados com medianas para preservar distribuições centrais. Em order_items, filtrei outliers acima 99% em preço/frete para mitigar impactos de valores extremos em estatísticas descritivas.
products <- products %>%
mutate(
product_length_cm = ifelse(is.na(product_length_cm), median(product_length_cm, na.rm = TRUE), product_length_cm),
product_height_cm = ifelse(is.na(product_height_cm), median(product_height_cm, na.rm = TRUE), product_height_cm),
product_width_cm = ifelse(is.na(product_width_cm), median(product_width_cm, na.rm = TRUE), product_width_cm),
product_weight_g = ifelse(is.na(product_weight_g), median(product_weight_g, na.rm = TRUE), product_weight_g)
)
order_items <- order_items %>%
filter(price < quantile(price, 0.99, na.rm = TRUE) & freight_value < quantile(freight_value, 0.99, na.rm = TRUE))Integrai a tradução para inglês via left_join, substituindo nomes originais para consistência analítica, mantendo foco em categorias padronizadas.
Cada pedido pode ter 1 ou mais itens e formas de pagamento, então
agreguei pagamentos somando valores totais e selecionei tipo principal
para evitar duplicatas. Para reviews, algo parecido acontece, pois um
cliente pode enviar mais de uma review por pedido, então selecionei a
mais recente e imputei sem_comentario em comentários e
títulos vazios.
payments_agg_valor <- order_payments %>%
group_by(order_id) %>%
summarise(valor_total_pedido = sum(payment_value, na.rm = TRUE))
payments_agg_tipo <- order_payments %>%
filter(payment_sequential == 1) %>%
select(order_id, tipo_pagamento_principal = payment_type)
payments_final <- payments_agg_valor %>%
left_join(payments_agg_tipo, by = "order_id")reviews_final <- order_reviews %>%
mutate(
review_comment_message = replace_na(review_comment_message, "sem_comentario"),
review_comment_title = replace_na(review_comment_title, "sem_comentario"),
review_creation_date = as_datetime(review_creation_date)
) %>%
group_by(order_id) %>%
arrange(desc(review_creation_date)) %>%
slice(1) %>%
ungroup() %>%
select(order_id, review_score, review_comment_message)Construí três tabelas via joins: tb_comercial (vendas/produtos), tb_logistica (com distância e estimativas de entrega), tb_clientes (agregação por cliente único). Durante o join foram selecionadas apenas as colunas necessárias para cada tabela analítica.
tb_comercial <- order_items %>%
select(order_id, order_item_id, product_id, seller_id, price, freight_value) %>%
left_join(orders %>% select(order_id, order_status, order_purchase_timestamp), by = "order_id") %>%
left_join(products, by = "product_id") %>%
left_join(sellers %>% select(seller_id, seller_city, seller_state), by = "seller_id") %>%
left_join(payments_final, by = "order_id")geo_cust <- geolocation_per_zip_code %>%
rename(lat_cli = geolocation_lat, lng_cli = geolocation_lng)
geo_sell <- geolocation_per_zip_code %>%
rename(lat_venda = geolocation_lat, lng_venda = geolocation_lng)
tb_logistica <- order_items %>%
select(order_id, order_item_id, seller_id, freight_value) %>%
left_join(orders, by = "order_id") %>%
left_join(sellers %>% select(seller_id, seller_zip_code_prefix, seller_state), by = "seller_id") %>%
left_join(customers %>% select(customer_id, customer_zip_code_prefix, customer_state), by = "customer_id") %>%
left_join(geo_sell, by = c("seller_zip_code_prefix" = "geolocation_zip_code_prefix")) %>%
left_join(geo_cust, by = c("customer_zip_code_prefix" = "geolocation_zip_code_prefix")) %>%
mutate(distancia_km = distHaversine(matrix(c(lng_venda, lat_venda), ncol = 2),
matrix(c(lng_cli, lat_cli), ncol = 2)) / 1000)tb_clientes <- orders %>%
left_join(customers, by = "customer_id") %>%
left_join(payments_final, by = "order_id") %>%
left_join(reviews_final, by = "order_id") %>%
group_by(customer_unique_id) %>%
summarise(
cidade = first(customer_city),
estado = first(customer_state),
total_pedidos = n(),
total_gasto = sum(valor_total_pedido, na.rm = TRUE),
data_primeira_compra = min(order_purchase_timestamp),
data_ultima_compra = max(order_purchase_timestamp),
nota_media = mean(review_score, na.rm = TRUE)
) %>%
ungroup()Criei variáveis derivadas: volume/densidade em produtos para correlações logísticas; métricas temporais e classificações RFM(Recência, Frequência e Valor Monetário) em clientes para calcular a recência de compra. Ao final, removi colunas que não seriam substituidas pelas novas variaveis.
tb_comercial <- tb_comercial %>%
mutate(
volume_produto_cm3 = product_length_cm * product_height_cm * product_width_cm,
densidade_produto = product_weight_g / volume_produto_cm3
)tb_logistica <- tb_logistica %>%
mutate(
tempo_processamento_horas = as.numeric(difftime(order_approved_at, order_purchase_timestamp, units = "hours")),
tempo_entrega_total_dias = as.numeric(difftime(order_delivered_customer_date, order_purchase_timestamp, units = "days")),
atraso_entrega_dias = as.numeric(difftime(order_delivered_customer_date, order_estimated_delivery_date, units = "days")),
status_sla = case_when(
atraso_entrega_dias > 0 ~ "Atrasado",
TRUE ~ "No Prazo/Adiantado"
)
)data_ref <- max(orders$order_purchase_timestamp, na.rm = TRUE) + days(1)
tb_clientes <- tb_clientes %>%
mutate(
tipo_cliente = ifelse(total_pedidos > 1, "Recorrente", "Novo"),
recencia_dias = as.numeric(difftime(data_ref, data_ultima_compra, units = "days")),
ticket_medio = total_gasto / total_pedidos
)get_region <- function(state) {
case_when(
str_detect(str_to_lower(state), regex_norte) ~ "Norte",
str_detect(str_to_lower(state), regex_nordeste) ~ "Nordeste",
str_detect(str_to_lower(state), regex_centro_oeste) ~ "Centro-Oeste",
str_detect(str_to_lower(state), regex_sudeste) ~ "Sudeste",
str_detect(str_to_lower(state), regex_sul) ~ "Sul",
TRUE ~ "Outros"
)
}
tb_clientes <- tb_clientes %>% mutate(regiao = get_region(estado))
tb_logistica <- tb_logistica %>% mutate(regiao_cliente = get_region(customer_state), regiao_vendedor = get_region(seller_state))
tb_comercial <- tb_comercial %>% mutate(regiao_vendedor = get_region(seller_state))Após limpeza, as tabelas analíticas estão prontas. Aqui, um resumo consolidado via tabela para variáveis numéricas em tb_logistica (exemplo), destacando tipos, estatísticas e NAs remanescentes.
Para variáveis categóricas, computamos distribuições separadamente. Por exemplo, para status_sla:
Variáveis categóricas como status_sla exibem distribuições balanceadas (92.3% no prazo), enquanto distâncias variam de 0 a 8687.6328988 km, com média 591 km.
Agreguei por mês para revelar tendências. O volume cresce exponencialmente, com pico em Nov/2017 (Black Friday).
vendas_temporal <- tb_comercial %>%
mutate(mes_ano = floor_date(order_purchase_timestamp, "month")) %>%
group_by(mes_ano) %>%
summarise(
total_pedidos = n_distinct(order_id),
total_receita = sum(price, na.rm = TRUE)
) %>%
filter(mes_ano >= "2017-01-01" & mes_ano <= "2018-08-01")
# Cálculo do percentual do pico
vendas_2017 <- vendas_temporal %>% filter(lubridate::year(mes_ano) == 2017)
pico_percent <- round(max(vendas_2017$total_pedidos) / sum(vendas_2017$total_pedidos) * 100, 1)
ggplot(vendas_temporal, aes(x = mes_ano, y = total_pedidos)) +
geom_line(color = "#2c3e50", size = 1) +
geom_point(color = "#c0392b", size = 2) +
geom_smooth(method = "loess", se = FALSE, color = "gray", linetype = "dashed") +
theme_minimal() +
labs(title = "Evolução Mensal do Volume de Pedidos",
x = "Mês", y = "Número de Pedidos") +
scale_x_datetime(date_breaks = "2 months", date_labels = "%b/%y")Mapa interativo via leaflet revela concentração no Sudeste, com amostra de 5000 para performance.
leaflet(tb_logistica %>% sample_n(5000)) %>%
addTiles() %>%
addCircleMarkers(lng = ~lng_cli, lat = ~lat_cli, radius = 1, color = "blue", opacity = 0.5) %>%
addCircleMarkers(lng = ~lng_venda, lat = ~lat_venda, radius = 1, color = "red", opacity = 0.5)Resumo regional via group_by e pivot_longer destaca desigualdades: Norte com frete médio 1.9x Sudeste e taxa de atraso 2% maior.
resumo_regional_long <- resumo_regional %>%
pivot_longer(cols = -regiao_cliente, names_to = "metrica", values_to = "valor")
ggplot(resumo_regional_long, aes(x = regiao_cliente, y = valor, fill = regiao_cliente)) +
geom_col() +
facet_wrap(~metrica, scales = "free_y") +
theme_minimal() +
labs(title = "Métricas Logísticas por Região", x = "Região", y = "Valor")Top 10 categorias por receita.
top_categorias <- tb_comercial %>%
group_by(product_category_name) %>%
summarise(receita = sum(price, na.rm = TRUE)) %>%
top_n(10, receita)
ggplot(top_categorias, aes(x = reorder(product_category_name, receita), y = receita)) +
geom_col(fill = "#2c3e50") +
coord_flip() +
theme_minimal() +
labs(title = "Top Categorias por Receita", x = "Categoria", y = "Receita (R$)") +
scale_y_continuous(labels = scales::dollar_format(prefix = "R$ ", big.mark = ".", decimal.mark = ","))Proporção de recorrentes 3.1%, via pie chart.
ggplot(fidelidade, aes(x = "", y = prop, fill = tipo_cliente)) +
geom_bar(stat = "identity") +
coord_polar("y") +
theme_void() +
labs(title = "Proporção de Clientes por Tipo")ticket_reg <- tb_clientes %>%
group_by(regiao) %>%
summarise(ticket_medio = mean(ticket_medio, na.rm = TRUE)) %>%
filter(regiao != "Outros")
ggplot(ticket_reg, aes(x = reorder(regiao, ticket_medio), y = ticket_medio, fill = regiao)) +
geom_col() +
theme_minimal() +
labs(title = "Ticket Médio por Região", x = "Região", y = "Ticket (R$)")Insights até o momento: Crescimento sazonal com pico Black Friday; desigualdades logísticas no Norte/Nordeste (frete/tempo elevados); categorias como health_beauty dominando receita; baixa recorrência (3.1%); correlações frete-distância.