How to read this report. Unlike a slide deck, this document is executable: every figure below is generated by the R code shown directly above it (click Hide / Show on any block, or Code ▸ at the top right to toggle all). Nothing is a pre-rendered image — if you change the data and re-knit, the charts change with it. Work flows in three linked parts: (1) diagnose delivery performance, (2) segment sellers with clustering, (3) predict late deliveries with Random Forest & XGBoost.
We load the tidyverse stack plus the modelling/clustering packages,
and define a single house style (theme_olist) and colour
palette reused by every chart.
library(readr) # fast CSV reading
library(dplyr) # data wrangling
library(tidyr) # reshaping
library(ggplot2) # all charts
library(scales) # axis formatting (%, currency, commas)
library(forcats) # factor ordering for ranked bars
library(stringr)
library(lubridate) # timestamp parsing
library(cluster) # pam(), silhouette()
library(xgboost) # gradient boosting
library(randomForest)
library(pROC) # ROC / AUC
library(Matrix) # sparse design matrix
library(knitr)
# PowerPoint-style palette: strong purple / blue / green / orange
PURPLE <- "#6A3D9A"; BLUE <- "#1F78B4"; GREEN <- "#33A02C"
ORANGE <- "#FF7F00"; RED <- "#E31A1C"
PAL <- c(PURPLE, BLUE, GREEN, ORANGE, RED, "#A6CEE3")
# One reusable, minimal theme. NOTE: randomForest masks ggplot2::margin(),
# so we qualify it explicitly.
theme_olist <- function(base = 12) {
theme_minimal(base_size = base) +
theme(
plot.title = element_text(face = "bold", size = rel(1.2)),
plot.subtitle = element_text(colour = "grey35", margin = ggplot2::margin(b = 8)),
panel.grid.minor = element_blank(),
panel.grid.major = element_line(colour = "grey90"),
legend.position = "top"
)
}
theme_set(theme_olist())
set.seed(42) # reproducibility everywhereThis is “how I got the data”. Olist ships as separate CSVs (orders, items, customers, sellers, products, reviews, geolocation). Two rules prevent silent row-duplication on join:
We keep order_status == "delivered" and parse the five
timestamps with truncated = 3 (the estimated date
is date-only and would otherwise fail to parse).
# Resolve the data folder (CSVs may live in "data/" or "data/olist dataset/")
DATA_DIR <- c("data/olist dataset", "data") |>
(\(d) d[file.exists(file.path(d, "olist_orders_dataset.csv"))][1])()
ts <- c("order_purchase_timestamp","order_approved_at","order_delivered_carrier_date",
"order_delivered_customer_date","order_estimated_delivery_date")
orders_raw <- read_csv(file.path(DATA_DIR, "olist_orders_dataset.csv"),
show_col_types = FALSE,
col_types = cols(.default = col_guess(), # force timestamps to character
order_purchase_timestamp = col_character(), order_approved_at = col_character(),
order_delivered_carrier_date = col_character(),
order_delivered_customer_date = col_character(),
order_estimated_delivery_date = col_character()))
items_raw <- read_csv(file.path(DATA_DIR, "olist_order_items_dataset.csv"), show_col_types = FALSE)
customers <- read_csv(file.path(DATA_DIR, "olist_customers_dataset.csv"), show_col_types = FALSE)
sellers <- read_csv(file.path(DATA_DIR, "olist_sellers_dataset.csv"), show_col_types = FALSE)
products <- read_csv(file.path(DATA_DIR, "olist_products_dataset.csv"), show_col_types = FALSE)
translation <- read_csv(file.path(DATA_DIR, "product_category_name_translation.csv"), show_col_types = FALSE)
reviews_raw <- read_csv(file.path(DATA_DIR, "olist_order_reviews_dataset.csv"), show_col_types = FALSE)
geo_raw <- read_csv(file.path(DATA_DIR, "olist_geolocation_dataset.csv"), show_col_types = FALSE)# (1) one mean coordinate per ZIP prefix -> safe to join
geo_zip <- geo_raw |>
group_by(geolocation_zip_code_prefix) |>
summarise(lat = mean(geolocation_lat), lng = mean(geolocation_lng), .groups = "drop") |>
mutate(zip = as.integer(geolocation_zip_code_prefix)) |> select(zip, lat, lng)
# (2) one mean review score per order -> no duplication
rev_order <- reviews_raw |>
group_by(order_id) |>
summarise(review_score = mean(review_score, na.rm = TRUE), .groups = "drop")
# customers / sellers with coordinates attached
customers <- customers |> mutate(zip = as.integer(customer_zip_code_prefix)) |>
left_join(geo_zip, by = "zip") |> rename(cust_lat = lat, cust_lng = lng)
sellers <- sellers |> mutate(zip = as.integer(seller_zip_code_prefix)) |>
left_join(geo_zip, by = "zip") |> rename(sell_lat = lat, sell_lng = lng)
# products + English category + package volume
products <- products |>
left_join(translation, by = "product_category_name") |>
mutate(category_en = coalesce(product_category_name_english, product_category_name, "unknown"),
pkg_volume_cm3 = product_length_cm * product_height_cm * product_width_cm)dbtw <- function(a, b) as.numeric(difftime(a, b, units = "days")) # interval in days
orders <- orders_raw |>
filter(order_status == "delivered") |>
mutate(across(all_of(ts), ~ ymd_hms(., quiet = TRUE, truncated = 3)),
# --- delivery stages (days) ---
approval_time = dbtw(order_approved_at, order_purchase_timestamp),
handling_time = dbtw(order_delivered_carrier_date, order_approved_at),
transit_time = dbtw(order_delivered_customer_date, order_delivered_carrier_date),
total_delivery_time = dbtw(order_delivered_customer_date, order_purchase_timestamp),
promised_window = dbtw(order_estimated_delivery_date, order_purchase_timestamp),
delivery_variance = dbtw(order_delivered_customer_date, order_estimated_delivery_date),
purchase_month = floor_date(order_purchase_timestamp, "month"),
# --- service-level indicators ---
on_time_delivery = order_delivered_customer_date <= order_estimated_delivery_date,
late_delivery = order_delivered_customer_date > order_estimated_delivery_date,
severely_late = delivery_variance > 7,
# --- validity flags: exclude impossible/negative intervals from metrics ---
valid_handling = !is.na(handling_time) & handling_time >= 0,
valid_transit = !is.na(transit_time) & transit_time >= 0,
valid_total = !is.na(total_delivery_time) & total_delivery_time >= 0)
# Straight-line distance (Haversine) — an approximation, NOT road distance
haversine_km <- function(la1, lo1, la2, lo2) { p <- pi/180; R <- 6371
a <- sin((la2-la1)*p/2)^2 + cos(la1*p)*cos(la2*p)*sin((lo2-lo1)*p/2)^2
2 * R * asin(pmin(1, sqrt(a))) }
# Item-level table enriched with product, seller, and order outcomes
items <- items_raw |>
left_join(products, by = "product_id") |>
left_join(sellers |> select(seller_id, seller_state, sell_lat, sell_lng), by = "seller_id")
# Order-level characteristics (number of items/sellers, totals, dominant category)
dominant_cat <- items |> group_by(order_id, category_en) |>
summarise(v = sum(price, na.rm = TRUE), .groups = "drop_last") |>
slice_max(v, n = 1, with_ties = FALSE) |> ungroup() |>
select(order_id, dominant_category = category_en)
order_chars <- items |> group_by(order_id) |>
summarise(number_of_items = n(), number_of_products = n_distinct(product_id),
number_of_sellers = n_distinct(seller_id),
total_order_value = sum(price, na.rm = TRUE),
total_freight_value = sum(freight_value, na.rm = TRUE),
total_item_weight = sum(product_weight_g, na.rm = TRUE),
primary_seller_state = first(seller_state),
sell_lat = first(sell_lat), sell_lng = first(sell_lng), .groups = "drop") |>
left_join(dominant_cat, by = "order_id")
# Master order table (left_join chain) + customer geo + distance + bands
orders_master <- orders |>
left_join(customers |> select(customer_id, customer_state, customer_zip = zip,
cust_lat, cust_lng), by = "customer_id") |>
left_join(order_chars, by = "order_id") |>
left_join(rev_order, by = "order_id") |>
mutate(seller_customer_distance_km = haversine_km(cust_lat, cust_lng, sell_lat, sell_lng),
interstate_delivery = customer_state != primary_seller_state,
distance_band = cut(seller_customer_distance_km,
c(-Inf,100,300,700,1500,Inf),
labels = c("0-100 km","101-300 km","301-700 km","701-1500 km",">1500 km")),
seller_group_cnt = cut(number_of_sellers, c(0,1,2,Inf),
labels = c("1 seller","2 sellers","3+ sellers")))
# Analysis frame: delivered orders with a valid total time
adf <- orders_master |> filter(valid_total)
nrow(adf)## [1] 96470
q <- function(x, p) quantile(x, p, na.rm = TRUE)
kpis <- tibble(
Metric = c("Delivered orders","Mean delivery time (d)","Median delivery time (d)",
"P90 delivery time (d)","P95 delivery time (d)","On-time rate","Late rate",
"Severely-late rate (>7d)","Median handling (d)","Median transit (d)"),
Value = c(nrow(adf),
round(mean(adf$total_delivery_time),1), round(median(adf$total_delivery_time),1),
round(q(adf$total_delivery_time,.90),1), round(q(adf$total_delivery_time,.95),1),
percent(mean(adf$on_time_delivery),0.1), percent(mean(adf$late_delivery),0.1),
percent(mean(adf$severely_late),0.1),
round(median(adf$handling_time[adf$valid_handling]),2),
round(median(adf$transit_time[adf$valid_transit]),2)))
kable(kpis, caption = "Headline delivery KPIs.")| Metric | Value |
|---|---|
| Delivered orders | 96470 |
| Mean delivery time (d) | 12.6 |
| Median delivery time (d) | 10.2 |
| P90 delivery time (d) | 23.1 |
| P95 delivery time (d) | 29.3 |
| On-time rate | 91.9% |
| Late rate | 8.1% |
| Severely-late rate (>7d) | 3.5% |
| Median handling (d) | 1.85 |
| Median transit (d) | 7.1 |
ggplot(filter(adf, total_delivery_time <= 60), aes(total_delivery_time)) +
geom_histogram(binwidth = 1, fill = BLUE, colour = "white", linewidth = .1) +
geom_vline(xintercept = median(adf$total_delivery_time), colour = ORANGE, linewidth = 1) +
scale_y_continuous(labels = comma) +
labs(title = "Most orders arrive in about a week, but the tail runs long",
subtitle = "Total delivery time (purchase → customer), capped at 60 days",
x = "Total delivery time (days)", y = "Orders")ggplot(filter(adf, between(delivery_variance, -40, 40)),
aes(delivery_variance, fill = late_delivery)) +
geom_histogram(binwidth = 1, colour = "white", linewidth = .05) +
geom_vline(xintercept = 0, linetype = "dashed", linewidth = .9) +
scale_fill_manual(values = c(`FALSE` = GREEN, `TRUE` = RED),
labels = c("Early / on-time","Late"), name = NULL) +
scale_y_continuous(labels = comma) +
labs(title = "Olist usually delivers comfortably ahead of its promise",
subtitle = "Variance = actual − estimated date (negative = early). Capped ±40 days",
x = "Delivery variance (days)", y = "Orders")adf |>
transmute(Approval = ifelse(valid_handling, approval_time, NA),
`Seller handling` = ifelse(valid_handling, handling_time, NA),
`Carrier transit` = ifelse(valid_transit, transit_time, NA)) |>
pivot_longer(everything(), names_to = "stage", values_to = "days") |>
filter(!is.na(days), days >= 0, days <= 40) |>
mutate(stage = factor(stage, c("Approval","Seller handling","Carrier transit"))) |>
ggplot(aes(stage, days, fill = stage)) +
geom_boxplot(outlier.alpha = .05, width = .55) +
scale_fill_manual(values = c(PURPLE, ORANGE, BLUE), guide = "none") +
coord_cartesian(ylim = c(0, 30)) +
labs(title = "Carrier transit is the largest — and most variable — stage",
subtitle = "Distribution of each stage in days (outliers > 40d hidden)",
x = NULL, y = "Days")Proves: carrier transit, not seller handling, is where time is spent.
monthly <- adf |> group_by(purchase_month) |>
summarise(orders = n(),
median_dt = median(total_delivery_time),
p90_dt = quantile(total_delivery_time, .90),
on_time = mean(on_time_delivery),
handling = median(handling_time[valid_handling]),
transit = median(transit_time[valid_transit]), .groups = "drop") |>
filter(!is.na(purchase_month), orders >= 50) # drop sparse edge monthsscl <- max(monthly$orders) / max(monthly$median_dt) # dual-axis scaling factor
ggplot(monthly, aes(purchase_month)) +
geom_col(aes(y = orders), fill = BLUE, alpha = .45) +
geom_line(aes(y = median_dt * scl), colour = ORANGE, linewidth = 1.2) +
geom_point(aes(y = median_dt * scl), colour = ORANGE) +
scale_y_continuous("Delivered orders", labels = comma,
sec.axis = sec_axis(~ . / scl, name = "Median delivery time (days)")) +
scale_x_datetime(date_labels = "%b %Y", date_breaks = "2 months") +
labs(title = "Volume grew while median delivery time stayed broadly stable",
subtitle = "Bars = orders; line = median delivery time", x = NULL) +
theme(axis.text.x = element_text(angle = 45, hjust = 1))ggplot(monthly, aes(purchase_month, on_time)) +
geom_line(colour = GREEN, linewidth = 1.2) + geom_point(colour = GREEN) +
geom_hline(yintercept = mean(adf$on_time_delivery), linetype = "dashed", colour = "grey50") +
scale_y_continuous(labels = percent) +
scale_x_datetime(date_labels = "%b %Y", date_breaks = "2 months") +
labs(title = "On-time rate is high but dips in specific months",
subtitle = "Dashed = overall average", x = NULL, y = "On-time rate") +
theme(axis.text.x = element_text(angle = 45, hjust = 1))monthly |> select(purchase_month, Handling = handling, Transit = transit) |>
pivot_longer(-purchase_month, names_to = "stage", values_to = "days") |>
ggplot(aes(purchase_month, days, colour = stage)) +
geom_line(linewidth = 1.2) + geom_point() +
scale_colour_manual(values = c(Handling = ORANGE, Transit = BLUE), name = NULL) +
scale_x_datetime(date_labels = "%b %Y", date_breaks = "2 months") +
labs(title = "Transit dominates handling every single month",
subtitle = "Monthly median handling vs. transit", x = NULL, y = "Median days") +
theme(axis.text.x = element_text(angle = 45, hjust = 1))Correlation between volume and delivery time is weak and not evidence that volume causes delay.
A reusable summary helper keeps every geographic cut consistent:
delivery_metrics <- function(df) df |> summarise(
orders = n(),
median_dt = median(total_delivery_time, na.rm = TRUE),
p90_dt = quantile(total_delivery_time, .90, na.rm = TRUE),
on_time = mean(on_time_delivery, na.rm = TRUE),
late_rate = mean(late_delivery, na.rm = TRUE),
median_freight = median(total_freight_value, na.rm = TRUE),
median_distance = median(seller_customer_distance_km, na.rm = TRUE), .groups = "drop")
MIN_N <- 30 # minimum orders before we rank a state/route
state_rank <- adf |> group_by(customer_state) |> delivery_metrics() |>
filter(orders >= MIN_N)state_rank |>
mutate(customer_state = fct_reorder(customer_state, median_dt)) |>
ggplot(aes(customer_state, median_dt)) +
geom_col(fill = PURPLE) +
geom_text(aes(label = round(median_dt,1)), hjust = -.15, size = 3) +
coord_flip() + scale_y_continuous(expand = expansion(mult = c(0,.1))) +
labs(title = "Northern states wait roughly twice as long as the southeast",
subtitle = "Median total delivery time by customer state (n ≥ 30)",
x = NULL, y = "Median delivery time (days)")state_rank |>
mutate(customer_state = fct_reorder(customer_state, late_rate)) |>
ggplot(aes(customer_state, late_rate)) +
geom_col(fill = ORANGE) +
geom_text(aes(label = percent(late_rate,.1)), hjust = -.1, size = 3) +
coord_flip() + scale_y_continuous(labels = percent, expand = expansion(mult = c(0,.12))) +
labs(title = "Late-delivery rate is far higher outside the southeast",
subtitle = "Share delivered after the estimated date (n ≥ 30)",
x = NULL, y = "Late-delivery rate")route_metrics <- adf |> filter(!is.na(primary_seller_state)) |>
group_by(seller_state = primary_seller_state, customer_state) |>
summarise(orders = n(), median_dt = median(total_delivery_time), .groups = "drop")
top_sell <- adf |> count(primary_seller_state, sort = TRUE) |>
filter(!is.na(primary_seller_state)) |> slice_head(n = 10) |> pull(primary_seller_state)
top_cust <- adf |> count(customer_state, sort = TRUE) |> slice_head(n = 12) |> pull(customer_state)
route_metrics |>
filter(seller_state %in% top_sell, customer_state %in% top_cust, orders >= 10) |>
ggplot(aes(customer_state, fct_rev(seller_state), fill = median_dt)) +
geom_tile(colour = "white") +
geom_text(aes(label = round(median_dt)), size = 2.8, colour = "grey15") +
scale_fill_gradient(low = "#E5F5E0", high = PURPLE, name = "Median days") +
labs(title = "Slowest routes pair southern/southeastern sellers with northern buyers",
subtitle = "Median delivery time by seller (rows) → customer (cols) state; n ≥ 10",
x = "Customer state", y = "Seller state")band <- adf |> filter(!is.na(distance_band)) |> group_by(distance_band) |> delivery_metrics()
ggplot(band, aes(distance_band)) +
geom_col(aes(y = median_dt), fill = BLUE, alpha = .5) +
geom_text(aes(y = median_dt, label = round(median_dt,1)), vjust = -.5, colour = BLUE, size = 3) +
geom_line(aes(y = late_rate * max(band$median_dt)/max(band$late_rate), group = 1),
colour = RED, linewidth = 1.1) +
geom_point(aes(y = late_rate * max(band$median_dt)/max(band$late_rate)), colour = RED) +
scale_y_continuous("Median delivery time (days)",
sec.axis = sec_axis(~ . * max(band$late_rate)/max(band$median_dt),
name = "Late-delivery rate", labels = percent)) +
labs(title = "Delivery time rises steadily with seller–customer distance",
subtitle = "Bars = median time; red line = late rate. Straight-line (Haversine) distance",
x = "Distance band")Proves: delivery time scales with distance; northern/interstate routes are structurally slower. Distance is straight-line, an approximation of road distance.
Seller timestamps are recorded at order level, so these are associations, not strict attribution. We score each seller over its delivered orders:
seller_order <- items |> select(order_id, seller_id, freight_value) |>
left_join(adf |> select(order_id, handling_time, transit_time, valid_handling,
valid_transit, late_delivery, severely_late, review_score),
by = "order_id") |>
filter(!is.na(handling_time) | !is.na(late_delivery)) |>
distinct(seller_id, order_id, .keep_all = TRUE) |>
filter(order_id %in% adf$order_id)
seller_rank <- seller_order |> group_by(seller_id) |>
summarise(orders = n_distinct(order_id),
median_handling = median(handling_time[valid_handling], na.rm = TRUE),
median_transit = median(transit_time[valid_transit], na.rm = TRUE),
late_rate = mean(late_delivery, na.rm = TRUE), .groups = "drop") |>
filter(orders >= MIN_N)seller_rank |> slice_max(median_handling, n = 20) |>
mutate(seller = fct_reorder(str_sub(seller_id,1,8), median_handling)) |>
ggplot(aes(seller, median_handling)) +
geom_col(fill = ORANGE) +
geom_text(aes(label = round(median_handling,1)), hjust = -.15, size = 3) +
coord_flip() + scale_y_continuous(expand = expansion(mult = c(0,.1))) +
labs(title = "A minority of sellers sit on orders for days before handoff",
subtitle = "Top 20 by median handling time (n ≥ 30; IDs truncated)",
x = "Seller", y = "Median handling time (days)")ggplot(seller_rank, aes(orders, late_rate)) +
geom_point(alpha = .4, colour = BLUE) +
geom_smooth(method = "loess", se = FALSE, colour = ORANGE, formula = y ~ x) +
scale_x_log10(labels = comma) + scale_y_continuous(labels = percent) +
labs(title = "High-volume sellers are not systematically worse on lateness",
subtitle = "Each point a seller with n ≥ 30 orders (log x-axis)",
x = "Delivered orders (log)", y = "Late-delivery rate")cat_rank <- adf |> filter(!is.na(dominant_category), dominant_category != "unknown") |>
group_by(dominant_category) |> delivery_metrics() |> filter(orders >= 100)
cat_rank |> slice_max(median_dt, n = 20) |>
mutate(dominant_category = fct_reorder(dominant_category, median_dt)) |>
ggplot(aes(dominant_category, median_dt)) +
geom_col(fill = PURPLE) +
geom_text(aes(label = round(median_dt,1)), hjust = -.15, size = 2.8) +
coord_flip() + scale_y_continuous(expand = expansion(mult = c(0,.1))) +
labs(title = "Bulky & furniture categories are slowest to arrive",
subtitle = "Top 20 categories by median delivery time (n ≥ 100)",
x = NULL, y = "Median delivery time (days)")adf |> filter(!is.na(seller_group_cnt), total_delivery_time <= 60) |>
ggplot(aes(seller_group_cnt, total_delivery_time, fill = seller_group_cnt)) +
geom_boxplot(outlier.alpha = .03, width = .55) +
scale_fill_manual(values = c(GREEN, ORANGE, RED), guide = "none") +
coord_cartesian(ylim = c(0, 40)) +
labs(title = "Multi-seller orders are not slower — if anything slightly faster",
subtitle = "They are rare and concentrate in the fast southeast",
x = NULL, y = "Total delivery time (days)")adf |> filter(!is.na(seller_customer_distance_km),
seller_customer_distance_km <= 3500, total_delivery_time <= 60) |>
slice_sample(n = 15000) |>
ggplot(aes(seller_customer_distance_km, total_delivery_time)) +
geom_point(alpha = .06, colour = BLUE) +
geom_smooth(method = "lm", se = FALSE, colour = RED, formula = y ~ x) +
scale_x_continuous(labels = comma) +
labs(title = "Distance is a clear, near-linear driver of delivery time",
subtitle = "Sampled orders; straight-line distance (approximation, not road distance)",
x = "Seller–customer distance (km)", y = "Total delivery time (days)")rev_adf <- adf |> filter(!is.na(review_score)) |> mutate(rs = round(review_score))
rev_adf |>
mutate(status = cut(delivery_variance, c(-Inf,-0.001,0.001,3,7,Inf),
labels = c("Early","On-time","1-3 days late","4-7 days late",">7 days late"))) |>
group_by(status) |> summarise(mean_review = mean(review_score), .groups = "drop") |>
ggplot(aes(status, mean_review, fill = status)) +
geom_col() + geom_text(aes(label = round(mean_review,2)), vjust = -.4, size = 3.2) +
scale_fill_manual(values = c(GREEN,"#A6D96A","#FDAE61",ORANGE,RED), guide = "none") +
ylim(0,5) +
labs(title = "Each step later costs Olist review stars",
subtitle = "Mean review score by delivery-status bucket (observational association)",
x = NULL, y = "Mean review score")Observational only — delay is associated with worse reviews; not proven to cause them.
We group sellers by operational profile so the fixes can be targeted. Features are all continuous → k-means (with z-scaling) is the primary method, validated with PAM (k-medoids) and compared with Rand / Jaccard / Adjusted Rand. (Gower + hierarchical / k-prototypes would only be needed for mixed data.)
item_seller <- items |>
mutate(distance_km = NA_real_) |>
left_join(adf |> select(order_id, customer_state, cust_lat, cust_lng, handling_time,
transit_time, valid_handling, valid_transit, late_delivery,
severely_late, review_score), by = "order_id") |>
filter(order_id %in% adf$order_id) |>
mutate(distance_km = haversine_km(cust_lat, cust_lng, sell_lat, sell_lng))
seller_feats <- item_seller |> distinct(seller_id, order_id, .keep_all = TRUE) |>
group_by(seller_id) |>
summarise(delivered_orders = n_distinct(order_id),
handling_time_med = median(handling_time[valid_handling], na.rm = TRUE),
transit_time_med = median(transit_time[valid_transit], na.rm = TRUE),
late_rate = mean(late_delivery, na.rm = TRUE),
distance_med_km = median(distance_km, na.rm = TRUE),
review_mean = mean(review_score, na.rm = TRUE), .groups = "drop")
clust_pool <- seller_feats |>
filter(delivered_orders >= 20, !is.na(handling_time_med), !is.na(transit_time_med),
!is.na(distance_med_km), !is.na(review_mean)) |>
mutate(log_orders = log10(delivered_orders))
feat_cols <- c("handling_time_med","transit_time_med","late_rate",
"distance_med_km","log_orders","review_mean")
Xs <- scale(clust_pool[, feat_cols]) # z-score: REQUIRED for k-means
nrow(clust_pool)## [1] 801
set.seed(42)
K <- 4 # chosen for interpretability (see note)
km <- kmeans(Xs, centers = K, nstart = 50)
pm <- pam(Xs, k = K)
# Pair-counting agreement (course definition): Rand = (a+b)/(a+b+c+d), Jaccard = a/(a+c+d)
pair_counts <- function(c1, c2) { s1 <- outer(c1,c1,`==`); s2 <- outer(c2,c2,`==`)
ut <- upper.tri(s1)
list(a = sum(s1[ut]& s2[ut]), b = sum(!s1[ut]&!s2[ut]),
c = sum(s1[ut]&!s2[ut]), d = sum(!s1[ut]& s2[ut])) }
pcn <- pair_counts(km$cluster, pm$clustering)
tibble(Metric = c("Rand Index","Jaccard","Adjusted Rand"),
Value = round(c((pcn$a+pcn$b)/sum(unlist(pcn)),
pcn$a/(pcn$a+pcn$c+pcn$d),
mclust::adjustedRandIndex(km$cluster, pm$clustering)), 3)) |>
kable(caption = "k-means vs PAM agreement — high values mean the segments are real structure.")| Metric | Value |
|---|---|
| Rand Index | 0.867 |
| Jaccard | 0.605 |
| Adjusted Rand | 0.663 |
clust_pool$km <- km$cluster
oh <- median(clust_pool$handling_time_med); ot <- median(clust_pool$transit_time_med)
clust_pool <- clust_pool |> group_by(km) |> mutate(seg_late = mean(late_rate)) |> ungroup() |>
mutate(segment = case_when(
seg_late >= quantile(unique(seg_late), .75) ~ "At-risk / unreliable",
handling_time_med <= oh & transit_time_med <= ot ~ "Fast & local (model)",
transit_time_med > ot & handling_time_med <= oh ~ "Fast handling, long-haul",
TRUE ~ "Mainstream (avg)"))Honest caveat: silhouette values are modest (~0.16–0.23); seller operations form a continuum, so k = 4 is an interpretability choice, not a number the data demands.
clust_pool |> group_by(segment) |>
summarise(across(all_of(feat_cols), mean), .groups = "drop") |>
mutate(across(all_of(feat_cols), ~ as.numeric(scale(.)))) |>
pivot_longer(all_of(feat_cols), names_to = "feature", values_to = "z") |>
mutate(feature = recode(feature, handling_time_med="Handling", transit_time_med="Transit",
late_rate="Late rate", distance_med_km="Distance", log_orders="Volume (log)",
review_mean="Review")) |>
ggplot(aes(feature, segment, fill = z)) +
geom_tile(colour = "white") + geom_text(aes(label = round(z,1)), size = 3) +
scale_fill_gradient2(low = BLUE, mid = "white", high = RED, name = "SD from\nmean") +
labs(title = "Segment fingerprints: what makes each seller group distinct",
subtitle = "Cluster-mean of each feature, standardised across segments",
x = NULL, y = NULL)clust_pool |> group_by(segment) |> summarise(late_rate = mean(late_rate), .groups="drop") |>
mutate(segment = fct_reorder(segment, late_rate)) |>
ggplot(aes(segment, late_rate, fill = segment)) +
geom_col() + geom_text(aes(label = percent(late_rate,.1)), hjust = -.1, size = 3.2) +
scale_fill_manual(values = PAL, guide = "none") +
coord_flip() + scale_y_continuous(labels = percent, expand = expansion(mult = c(0,.15))) +
labs(title = "Late-delivery rate differs sharply across seller segments",
x = NULL, y = "Late-delivery rate")Question: at order time, will an order arrive late? Leakage control: we use only features known at/around order placement — we exclude realised handling and transit times (which would leak the outcome).
model_df <- adf |>
mutate(approval_time = ifelse(is.na(approval_time) | approval_time < 0, 0, approval_time),
purchase_m = month(order_purchase_timestamp),
purchase_d = wday(order_purchase_timestamp),
interstate = as.integer(interstate_delivery),
late = as.integer(late_delivery)) |>
filter(!is.na(promised_window), !is.na(seller_customer_distance_km),
!is.na(customer_state), !is.na(primary_seller_state), !is.na(total_freight_value))
# collapse rare categories so one-hot stays manageable
top_cat <- model_df |> count(dominant_category, sort = TRUE) |> slice_head(n = 15) |> pull(dominant_category)
model_df <- model_df |> mutate(category_grp = ifelse(dominant_category %in% top_cat,
dominant_category, "other"))
feat_df <- model_df |>
transmute(promised_window, distance_km = seller_customer_distance_km,
freight = total_freight_value, order_value = total_order_value,
n_items = number_of_items, n_sellers = number_of_sellers,
weight = total_item_weight, approval_time, interstate,
purchase_m, purchase_d,
customer_state = factor(customer_state),
seller_state = factor(primary_seller_state),
category_grp = factor(category_grp))
y <- model_df$late
set.seed(42)
sel <- sample(seq_len(nrow(feat_df)), 0.8 * nrow(feat_df))
X <- as.matrix(sparse.model.matrix(~ . - 1, feat_df))
Xtr <- X[sel, ]; Xte <- X[-sel, ]; ytr <- y[sel]; yte <- y[-sel]
c(train = length(sel), test = nrow(X) - length(sel), features = ncol(X))## train test features
## 76793 19199 74
dtrain <- xgb.DMatrix(Xtr, label = ytr); dtest <- xgb.DMatrix(Xte, label = yte)
params <- list(objective = "binary:logistic", eval_metric = "auc", eta = .1,
max_depth = 5, subsample = .8, colsample_bytree = .8,
min_child_weight = 5,
scale_pos_weight = sum(ytr == 0)/sum(ytr == 1)) # handle 8% imbalance
set.seed(42)
cv <- xgb.cv(params, dtrain, nrounds = 400, nfold = 5, early_stopping_rounds = 25, verbose = 0)
best_n <- which.max(cv$evaluation_log$test_auc_mean)
xgb_model <- xgb.train(params, dtrain, nrounds = best_n, verbose = 0)
xgb_prob <- predict(xgb_model, dtest)xgb_auc <- as.numeric(auc(roc(yte, xgb_prob, quiet = TRUE)))
rf_auc <- as.numeric(auc(roc(yte, rf_prob, quiet = TRUE)))
xgb_pred <- as.integer(xgb_prob > .5)
bind_rows(
tibble(Model="XGBoost", Accuracy=mean(xgb_pred==yte),
Recall=sum(xgb_pred==1&yte==1)/sum(yte==1), AUC=xgb_auc),
tibble(Model="Random Forest", Accuracy=mean((rf_prob>.5)==yte),
Recall=sum((rf_prob>.5)==1&yte==1)/sum(yte==1), AUC=rf_auc)) |>
mutate(across(where(is.numeric), ~round(.,3))) |>
kable(caption = "Test performance. With ~8% late, AUC & recall matter more than accuracy.")| Model | Accuracy | Recall | AUC |
|---|---|---|---|
| XGBoost | 0.761 | 0.688 | 0.805 |
| Random Forest | 0.921 | 0.046 | 0.778 |
rx <- roc(yte, xgb_prob, quiet = TRUE); rr <- roc(yte, rf_prob, quiet = TRUE)
bind_rows(
tibble(fpr = 1-rx$specificities, tpr = rx$sensitivities, m = sprintf("XGBoost (AUC %.2f)", xgb_auc)),
tibble(fpr = 1-rr$specificities, tpr = rr$sensitivities, m = sprintf("Random Forest (AUC %.2f)", rf_auc))) |>
ggplot(aes(fpr, tpr, colour = m)) +
geom_abline(linetype = "dashed", colour = "grey60") + geom_line(linewidth = 1.1) +
scale_colour_manual(values = c(ORANGE, GREEN), name = NULL) +
scale_x_continuous(labels = percent) + scale_y_continuous(labels = percent) +
labs(title = "Both tree ensembles predict late delivery well above chance",
subtitle = "ROC on the held-out test set (dashed = random)",
x = "False positive rate", y = "True positive rate (recall)")expand.grid(Actual = c("On-time","Late"), Predicted = c("On-time","Late")) |>
mutate(n = c(sum(xgb_pred==0&yte==0), sum(xgb_pred==0&yte==1),
sum(xgb_pred==1&yte==0), sum(xgb_pred==1&yte==1))) |>
group_by(Actual) |> mutate(pct = n/sum(n)) |> ungroup() |>
ggplot(aes(Predicted, fct_rev(Actual), fill = pct)) +
geom_tile(colour = "white") +
geom_text(aes(label = paste0(comma(n), "\n", percent(pct,1))), size = 4, colour = "grey15") +
scale_fill_gradient(low = "#EDE7F6", high = PURPLE, labels = percent, name = "Row %") +
labs(title = "XGBoost confusion matrix — late-delivery prediction",
subtitle = sprintf("Rows = actual, cols = predicted | catches %.0f%% of late orders",
100*sum(xgb_pred==1&yte==1)/sum(yte==1)),
x = "Predicted", y = "Actual")xgb.importance(colnames(Xtr), model = xgb_model) |> as_tibble() |>
slice_max(Gain, n = 20) |> mutate(Feature = fct_reorder(Feature, Gain)) |>
ggplot(aes(Feature, Gain)) +
geom_col(fill = ORANGE) +
geom_text(aes(label = percent(Gain,.1)), hjust = -.1, size = 3) +
coord_flip() + scale_y_continuous(labels = percent, expand = expansion(mult = c(0,.15))) +
labs(title = "Season and promise-window generosity drive late risk most",
subtitle = "Top 20 features by XGBoost gain; one-hot state/category levels shown individually",
x = NULL, y = "Gain (share of total)")Proves: lateness is predictable from order-time info alone (AUC ≈ 0.81). Suggests: season and promise-window calibration are the dominant levers. Test: whether a deployed late-risk score + recalibrated ETAs actually cut the late rate.
Reproducibility: this single .Rmd loads the raw CSVs
and regenerates every table, chart, and model from scratch — there are
no pre-baked images. The standalone batch scripts
delivery_performance_analysis.R,
seller_clustering_analysis.R, and
late_delivery_model.R produce the same outputs as PNG/CSV
files.
## R version 4.6.0 (2026-04-24)
## Platform: aarch64-apple-darwin23
## Running under: macOS Tahoe 26.0.1
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] en_GB/en_GB/en_GB/C/en_GB/en_GB
##
## time zone: Europe/Warsaw
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] knitr_1.51 Matrix_1.7-5 pROC_1.19.0.1
## [4] randomForest_4.7-1.2 xgboost_3.2.1.1 cluster_2.1.8.2
## [7] lubridate_1.9.5 stringr_1.6.0 forcats_1.0.1
## [10] scales_1.4.0 ggplot2_4.0.3 tidyr_1.3.2
## [13] dplyr_1.2.1 readr_2.2.0
##
## loaded via a namespace (and not attached):
## [1] sass_0.4.10 generics_0.1.4 stringi_1.8.7 lattice_0.22-9
## [5] hms_1.1.4 digest_0.6.39 magrittr_2.0.5 evaluate_1.0.5
## [9] grid_4.6.0 timechange_0.4.0 RColorBrewer_1.1-3 fastmap_1.2.0
## [13] jsonlite_2.0.0 mclust_6.1.2 mgcv_1.9-4 purrr_1.2.2
## [17] codetools_0.2-20 jquerylib_0.1.4 cli_3.6.6 rlang_1.2.0
## [21] crayon_1.5.3 splines_4.6.0 bit64_4.8.2 withr_3.0.3
## [25] cachem_1.1.0 yaml_2.3.12 parallel_4.6.0 tools_4.6.0
## [29] tzdb_0.5.0 vctrs_0.7.3 R6_2.6.1 lifecycle_1.0.5
## [33] bit_4.6.0 vroom_1.7.1 pkgconfig_2.0.3 pillar_1.11.1
## [37] bslib_0.11.0 gtable_0.3.6 glue_1.8.1 data.table_1.18.4
## [41] Rcpp_1.1.1-1.1 xfun_0.59 tibble_3.3.1 tidyselect_1.2.1
## [45] farver_2.1.2 nlme_3.1-169 htmltools_0.5.9 labeling_0.4.3
## [49] rmarkdown_2.31 compiler_4.6.0 S7_0.2.2