Which products are most often purchased on the Olist platform and which fail to find customers?
The Olist dataset covers a Brazilian e-commerce marketplace from 2016–2018 with over 110,000 orders, 32,951 unique products, and 74 product categories.
We approach this from two angles:
library(tidyverse)
library(readr)
library(ggplot2)
library(scales)
library(cluster)
library(factoextra)
library(randomForest)
library(caret)
library(knitr)
library(kableExtra)
library(RColorBrewer)
library(ggrepel)
library(gridExtra)# Load core datasets
order_items <- read_csv("C:/Users/USER/Downloads/olist dataset/olist_order_items_dataset.csv")
products <- read_csv("C:/Users/USER/Downloads/olist dataset/olist_products_dataset.csv")
orders <- read_csv("C:/Users/USER/Downloads/olist dataset/olist_orders_dataset.csv")
translations <- read_csv("C:/Users/USER/Downloads/olist dataset/product_category_name_translation.csv")
# Translate category names to English
products <- products %>%
left_join(translations, by = "product_category_name") %>%
mutate(category = coalesce(product_category_name_english, product_category_name))
# Sales per product
product_sales <- order_items %>%
group_by(product_id) %>%
summarise(
units_sold = n(),
total_revenue = sum(price, na.rm = TRUE),
avg_price = mean(price, na.rm = TRUE),
avg_freight = mean(freight_value, na.rm = TRUE),
.groups = "drop"
)
# Sales per category
category_sales <- product_sales %>%
left_join(products %>% select(product_id, category), by = "product_id") %>%
group_by(category) %>%
summarise(
total_units = sum(units_sold),
total_revenue = sum(total_revenue),
n_products = n(),
avg_price = mean(avg_price, na.rm = TRUE),
.groups = "drop"
) %>%
arrange(desc(total_units))
cat("Loaded successfully:\n")## Loaded successfully:
## Order items: 112650
## Products: 32951
## Categories: 74
# Distribution of sales per product
sales_dist <- product_sales %>%
mutate(sales_bucket = case_when(
units_sold == 1 ~ "1 sale (one-timers)",
units_sold <= 5 ~ "2–5 sales",
units_sold <= 20 ~ "6–20 sales",
units_sold <= 50 ~ "21–50 sales",
TRUE ~ "50+ sales (bestsellers)"
)) %>%
mutate(sales_bucket = factor(sales_bucket, levels = c(
"1 sale (one-timers)", "2–5 sales", "6–20 sales",
"21–50 sales", "50+ sales (bestsellers)"
)))
bucket_summary <- sales_dist %>%
count(sales_bucket) %>%
mutate(pct = n / sum(n) * 100)
ggplot(bucket_summary, aes(x = sales_bucket, y = n, fill = sales_bucket)) +
geom_col(width = 0.7) +
geom_text(aes(label = paste0(comma(n), "\n(", round(pct, 1), "%)")),
vjust = -0.4, size = 3.5, fontface = "bold") +
scale_fill_brewer(palette = "RdYlGn", direction = 1) +
scale_y_continuous(labels = comma, expand = expansion(mult = c(0, 0.15))) +
labs(
title = "The Long Tail: Most Products Sell Only Once",
subtitle = "55% of all products recorded only a single sale in the entire period",
x = NULL, y = "Number of Products",
caption = "Source: Olist dataset 2016–2018"
) +
theme_minimal(base_size = 13) +
theme(legend.position = "none",
plot.title = element_text(face = "bold", size = 15),
axis.text.x = element_text(angle = 15, hjust = 1))Key insight: Over 55% of products sold only once in the entire dataset period. This is classic long-tail e-commerce behaviour — a small number of products carry the bulk of volume.
top15 <- category_sales %>% slice_head(n = 15)
ggplot(top15, aes(x = reorder(category, total_units), y = total_units)) +
geom_col(fill = "#2196F3", width = 0.75) +
geom_text(aes(label = comma(total_units)), hjust = -0.15, size = 3.5) +
coord_flip() +
scale_y_continuous(labels = comma, expand = expansion(mult = c(0, 0.15))) +
labs(
title = "Top 15 Product Categories by Units Sold",
subtitle = "Bed, bath & table leads — household and lifestyle categories dominate",
x = NULL, y = "Units Sold",
caption = "Source: Olist dataset 2016–2018"
) +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold", size = 15))bottom15 <- category_sales %>% slice_tail(n = 15)
ggplot(bottom15, aes(x = reorder(category, total_units), y = total_units)) +
geom_col(fill = "#F44336", width = 0.75) +
geom_text(aes(label = total_units), hjust = -0.3, size = 3.5) +
coord_flip() +
scale_y_continuous(expand = expansion(mult = c(0, 0.2))) +
labs(
title = "Bottom 15 Categories — Failing to Find Customers",
subtitle = "Fashion, music, security & services: fewer than 50 sales across entire period",
x = NULL, y = "Units Sold",
caption = "Source: Olist dataset 2016–2018"
) +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold", size = 15))Key insight: Categories like pc_gamer,
fashion_childrens_clothes,
security_and_services, and cds_dvds_musicals
had almost no sales. These are either wrong fit for the platform or
suffer from poor discoverability.
We want to group products into natural sales behaviour segments based on: - Units sold - Average price - Total revenue - Freight-to-price ratio (logistics cost burden)
# Build product feature matrix
cluster_data <- product_sales %>%
left_join(products %>% select(product_id, category,
product_photos_qty,
product_description_lenght,
product_weight_g), by = "product_id") %>%
mutate(
freight_ratio = avg_freight / (avg_price + 0.01) # avoid div by 0
) %>%
select(product_id, units_sold, avg_price, total_revenue, freight_ratio) %>%
drop_na()
# Log-transform skewed variables
cluster_matrix <- cluster_data %>%
select(-product_id) %>%
mutate(across(everything(), ~ log1p(.x))) %>%
scale()
cat("Products ready for clustering:", nrow(cluster_matrix), "\n")## Products ready for clustering: 32951
set.seed(42)
# Elbow method on a sample for speed
sample_idx <- sample(nrow(cluster_matrix), min(5000, nrow(cluster_matrix)))
sample_mat <- cluster_matrix[sample_idx, ]
wss <- map_dbl(1:10, function(k) {
kmeans(sample_mat, centers = k, nstart = 10, iter.max = 50)$tot.withinss
})
elbow_df <- tibble(k = 1:10, wss = wss)
ggplot(elbow_df, aes(x = k, y = wss)) +
geom_line(color = "#2196F3", size = 1.2) +
geom_point(size = 4, color = "#2196F3") +
geom_vline(xintercept = 4, linetype = "dashed", color = "#F44336", alpha = 0.7) +
annotate("text", x = 4.3, y = max(wss)*0.85, label = "k = 4\n(chosen)",
color = "#F44336", size = 4) +
labs(
title = "Elbow Method — Choosing Number of Clusters",
x = "Number of Clusters (k)", y = "Within-cluster Sum of Squares"
) +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold"))set.seed(42)
km <- kmeans(cluster_matrix, centers = 4, nstart = 25, iter.max = 100)
cluster_data$cluster <- as.factor(km$cluster)
# Cluster profiles (back-transformed means)
cluster_profiles <- cluster_data %>%
group_by(cluster) %>%
summarise(
n_products = n(),
avg_units = round(mean(units_sold), 1),
avg_price = round(mean(avg_price), 1),
avg_revenue = round(mean(total_revenue), 1),
avg_freight_r = round(mean(freight_ratio), 3),
.groups = "drop"
)
# Label clusters based on profile
cluster_labels <- c(
"1" = "Niche / Low Volume",
"2" = "Mid-range Steady",
"3" = "Bestsellers",
"4" = "High-price Low Volume"
)
# Auto-assign labels by avg_units rank
ranked <- cluster_profiles %>%
arrange(avg_units) %>%
mutate(label = c("Dead Weight", "Low Performers", "Mid-range Steady", "Bestsellers"))
cluster_data <- cluster_data %>%
left_join(ranked %>% select(cluster, label), by = "cluster")
ranked %>%
select(label, n_products, avg_units, avg_price, avg_revenue, avg_freight_r) %>%
kable(
caption = "Cluster Profiles — Product Sales Segments",
col.names = c("Segment", "# Products", "Avg Units Sold",
"Avg Price (R$)", "Avg Revenue (R$)", "Freight Ratio")
) %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"))| Segment | # Products | Avg Units Sold | Avg Price (R\() </th> <th style="text-align:right;"> Avg Revenue (R\)) | Freight Ratio | |
|---|---|---|---|---|---|
| Dead Weight | 13880 | 1.5 | 69.5 | 97.8 | 0.272 |
| Low Performers | 9560 | 1.7 | 339.6 | 549.6 | 0.111 |
| Mid-range Steady | 5316 | 2.0 | 23.0 | 41.7 | 0.853 |
| Bestsellers | 4195 | 15.4 | 108.1 | 1611.0 | 0.276 |
# PCA for 2D visualisation (no extra packages needed)
pca_result <- prcomp(cluster_matrix, scale. = FALSE)
pca_df <- as.data.frame(pca_result$x[, 1:2])
pca_df$cluster <- cluster_data$cluster
pca_df$label <- cluster_data$label
# Sample for readable plot
set.seed(42)
pca_sample <- pca_df[sample(nrow(pca_df), min(3000, nrow(pca_df))), ]
ggplot(pca_sample, aes(x = PC1, y = PC2, color = label)) +
geom_point(alpha = 0.4, size = 1.2) +
stat_ellipse(level = 0.85, linewidth = 1) +
scale_color_manual(values = c("Dead Weight"="#F44336", "Low Performers"="#FF9800",
"Mid-range Steady"="#2196F3", "Bestsellers"="#4CAF50")) +
labs(
title = "Product Clusters — PCA Projection",
subtitle = "Each point is a product; axes are principal components of sales features",
color = "Segment", x = "PC1", y = "PC2"
) +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold", size = 15))cluster_data %>%
count(label) %>%
mutate(pct = n / sum(n) * 100) %>%
ggplot(aes(x = reorder(label, n), y = n, fill = label)) +
geom_col(width = 0.7) +
geom_text(aes(label = paste0(comma(n), " (", round(pct,1), "%)")),
hjust = -0.1, size = 4) +
coord_flip() +
scale_fill_manual(values = c("Dead Weight"="#F44336","Low Performers"="#FF9800",
"Mid-range Steady"="#2196F3","Bestsellers"="#4CAF50")) +
scale_y_continuous(expand = expansion(mult = c(0, 0.2))) +
labs(title = "Products by Segment", x = NULL, y = "Number of Products") +
theme_minimal(base_size = 13) +
theme(legend.position = "none",
plot.title = element_text(face = "bold"))Can we predict whether a product will be a high-seller based on its features?
We define high-seller as a product in the top 25% by units sold (the “Bestseller” zone).
# Join product features + sales
rf_data <- product_sales %>%
left_join(products %>% select(
product_id, category,
product_photos_qty,
product_description_lenght,
product_weight_g,
product_length_cm,
product_height_cm,
product_width_cm
), by = "product_id") %>%
drop_na() %>%
mutate(
# Target: top 25% by units sold = high seller
high_seller = factor(ifelse(units_sold >= quantile(units_sold, 0.75), "Yes", "No")),
# Features
freight_ratio = avg_freight / (avg_price + 0.01),
log_price = log1p(avg_price),
volume_cm3 = product_length_cm * product_height_cm * product_width_cm,
# Top category flag
top_category = factor(ifelse(category %in%
c("bed_bath_table","health_beauty","sports_leisure",
"furniture_decor","computers_accessories"), "top5", "other"))
) %>%
select(high_seller, log_price, freight_ratio, product_photos_qty,
product_description_lenght, product_weight_g, volume_cm3, top_category)
cat("RF dataset rows:", nrow(rf_data), "\n")## RF dataset rows: 32340
## Class balance:
##
## No Yes
## 23445 8895
set.seed(42)
train_idx <- createDataPartition(rf_data$high_seller, p = 0.75, list = FALSE)
train_set <- rf_data[train_idx, ]
test_set <- rf_data[-train_idx, ]
cat("Train:", nrow(train_set), "| Test:", nrow(test_set), "\n")## Train: 24256 | Test: 8084
set.seed(42)
rf_model <- randomForest(
high_seller ~ .,
data = train_set,
ntree = 300,
mtry = 3,
importance = TRUE
)
print(rf_model)##
## Call:
## randomForest(formula = high_seller ~ ., data = train_set, ntree = 300, mtry = 3, importance = TRUE)
## Type of random forest: classification
## Number of trees: 300
## No. of variables tried at each split: 3
##
## OOB estimate of error rate: 26.74%
## Confusion matrix:
## No Yes class.error
## No 16756 828 0.04708826
## Yes 5658 1014 0.84802158
pred <- predict(rf_model, test_set)
conf_mat <- confusionMatrix(pred, test_set$high_seller, positive = "Yes")
# Print key metrics
metrics <- tibble(
Metric = c("Accuracy", "Sensitivity (Recall)", "Specificity", "Precision", "F1 Score"),
Value = c(
conf_mat$overall["Accuracy"],
conf_mat$byClass["Sensitivity"],
conf_mat$byClass["Specificity"],
conf_mat$byClass["Precision"],
conf_mat$byClass["F1"]
)
) %>%
mutate(Value = round(Value, 3))
metrics %>%
kable(caption = "Random Forest — Test Set Performance") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"), full_width = FALSE)| Metric | Value |
|---|---|
| Accuracy | 0.737 |
| Sensitivity (Recall) | 0.144 |
| Specificity | 0.962 |
| Precision | 0.587 |
| F1 Score | 0.231 |
imp_df <- importance(rf_model) %>%
as.data.frame() %>%
rownames_to_column("Feature") %>%
arrange(desc(MeanDecreaseGini))
ggplot(imp_df, aes(x = reorder(Feature, MeanDecreaseGini), y = MeanDecreaseGini)) +
geom_col(fill = "#4CAF50", width = 0.7) +
geom_text(aes(label = round(MeanDecreaseGini, 1)), hjust = -0.2, size = 3.8) +
coord_flip() +
scale_y_continuous(expand = expansion(mult = c(0, 0.2))) +
labs(
title = "What Makes a Product Sell? — Feature Importance",
subtitle = "Mean Decrease in Gini — higher = more important for classification",
x = NULL, y = "Mean Decrease Gini"
) +
theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold", size = 15))insights <- tibble(
`#` = 1:6,
Finding = c(
"The top 5 categories (bed_bath_table, health_beauty, sports_leisure, furniture_decor, computers_accessories) account for ~40% of all units sold.",
"55% of products sold only once — the platform has extreme long-tail distribution typical of large marketplaces.",
"Watches & gifts and health_beauty generate disproportionate revenue relative to units sold — high average price boosts margins.",
"Fashion (childrens, sport, female), music, and security & services are nearly invisible — likely wrong platform fit.",
"Product price (log_price) is the strongest predictor of whether a product will sell well — mid-range pricing outperforms both budget and premium extremes.",
"High freight ratio (shipping cost vs price) is a negative signal — products expensive to ship relative to their price struggle to find buyers."
),
Recommendation = c(
"Prioritise inventory and promotional spend in these core categories.",
"Investigate one-time-sale products — are they mispriced, poorly described, or hard to find?",
"Expand premium gifting and beauty categories — higher margin with moderate volume.",
"Consider de-listing or restructuring fashion & music sub-categories — or investigate platform discoverability issues.",
"Guide new sellers toward competitive mid-range price points; avoid extreme ends.",
"Help sellers optimise packaging weight and dimensions to reduce freight costs and improve conversion."
)
)
insights %>%
kable(caption = "Business Insights & Recommendations") %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE) %>%
column_spec(2, width = "45%") %>%
column_spec(3, width = "45%")| # | Finding | Recommendation |
|---|---|---|
| 1 | The top 5 categories (bed_bath_table, health_beauty, sports_leisure, furniture_decor, computers_accessories) account for ~40% of all units sold. | Prioritise inventory and promotional spend in these core categories. |
| 2 | 55% of products sold only once — the platform has extreme long-tail distribution typical of large marketplaces. | Investigate one-time-sale products — are they mispriced, poorly described, or hard to find? |
| 3 | Watches & gifts and health_beauty generate disproportionate revenue relative to units sold — high average price boosts margins. | Expand premium gifting and beauty categories — higher margin with moderate volume. |
| 4 | Fashion (childrens, sport, female), music, and security & services are nearly invisible — likely wrong platform fit. | Consider de-listing or restructuring fashion & music sub-categories — or investigate platform discoverability issues. |
| 5 | Product price (log_price) is the strongest predictor of whether a product will sell well — mid-range pricing outperforms both budget and premium extremes. | Guide new sellers toward competitive mid-range price points; avoid extreme ends. |
| 6 | High freight ratio (shipping cost vs price) is a negative signal — products expensive to ship relative to their price struggle to find buyers. | Help sellers optimise packaging weight and dimensions to reduce freight costs and improve conversion. |
| Analysis | Method | Key Output |
|---|---|---|
| Sales distribution | Descriptive | 55% of products sold only once |
| Category ranking | Bar charts | Top 5 categories = 40% of volume |
| Revenue vs volume | Scatter | Health & beauty + watches = price premium |
| Product segmentation | K-Means (k=4) | 4 segments: Dead Weight → Bestsellers |
| Sales prediction | Random Forest | ~80%+ accuracy; price & freight ratio matter most |
Bottom line: Olist’s sales are highly concentrated. A handful of categories and a small share of products drive the majority of volume and revenue. Most products sit in a “long tail” that rarely converts. Platform strategy should focus on surfacing high-potential mid-range products while reconsidering investment in categories that chronically underperform.
Analysis by Abdussomad Olayiwola, Akash M, Hue, Nathaniel YO, Kaan Ö | Spinnaker Program | Warsaw June 2026