This report describes delivery-performance patterns among delivered Olist orders. Each observation supplied to k-means is one order. The clustering features are delivery duration, performance against the promised date, and the order’s mean review score.
The analysis is descriptive. Cluster membership identifies groups of similar orders in these variables; it does not establish that delivery performance caused a particular review score.
# Read only the order columns needed for this analysis.
orders <- read_csv(
"olist_orders_dataset.csv",
col_types = cols_only(
order_id = col_character(),
customer_id = col_character(),
order_status = col_character(),
order_purchase_timestamp = col_datetime(),
order_delivered_customer_date = col_datetime(),
order_estimated_delivery_date = col_datetime()
)
)
# Read only the review identifier and score, keeping review_score numeric.
reviews <- read_csv(
"olist_order_reviews_dataset.csv",
col_types = cols_only(
order_id = col_character(),
review_score = col_double()
)
)
# Read customer location fields, with identifiers retained as character data.
customers <- read_csv(
"olist_customers_dataset.csv",
col_types = cols_only(
customer_id = col_character(),
customer_state = col_character(),
customer_city = col_character()
)
)
Some orders have more than one review record. Joining those records
directly to orders would duplicate orders and incorrectly give them
extra weight. Therefore, reviews are first collapsed to exactly one row
per order_id.
# Group all review records belonging to the same order.
reviews_by_order <- reviews %>%
group_by(order_id) %>%
# Calculate one mean score per order and ignore missing review values.
summarise(
review_score = mean(review_score, na.rm = TRUE),
.groups = "drop"
) %>%
# Convert NaN to NA when an order has no usable score after aggregation.
mutate(review_score = if_else(is.nan(review_score), NA_real_, review_score))
# Confirm that the collapsed table has no duplicated order identifiers.
stopifnot(!anyDuplicated(reviews_by_order$order_id))
The required three-table chain is orders → reviews → customers. In code, joining three tables requires two join operations. Delivered orders remain one row each because both joined lookup tables now have unique keys.
# Start with delivered orders because the requested population is completed deliveries.
analysis_orders <- orders %>%
filter(order_status == "delivered") %>%
# First join: attach the one-row-per-order mean review score.
left_join(reviews_by_order, by = "order_id") %>%
# Second join: attach the customer's state and city.
left_join(customers, by = "customer_id") %>%
# Engineer elapsed delivery time in days, allowing fractional days.
mutate(
delivery_days = as.numeric(
difftime(
order_delivered_customer_date,
order_purchase_timestamp,
units = "days"
)
),
# Positive delay_days means late; negative delay_days means early.
delay_days = as.numeric(
difftime(
order_delivered_customer_date,
order_estimated_delivery_date,
units = "days"
)
)
) %>%
# Apply the specifically required missing-value filter for delivery measures.
filter(!is.na(delivery_days), !is.na(delay_days))
# k-means cannot accept missing values, so remove orders without a usable review.
cluster_orders <- analysis_orders %>%
filter(!is.na(review_score))
# Verify that each order remains a single clustering observation after the joins.
stopifnot(!anyDuplicated(cluster_orders$order_id))
# Record the number of usable order-level observations for the narrative.
n_cluster_orders <- nrow(cluster_orders)
After filtering, the clustering dataset contains 96,470 delivered orders, with each order appearing once.
# Select the three required numeric features and convert them to a plain matrix.
cluster_features <- cluster_orders %>%
select(delivery_days, delay_days, review_score) %>%
as.matrix()
# Standardise every feature to mean 0 and standard deviation 1.
# This is essential because k-means uses Euclidean distances: without scaling,
# variables measured in days could dominate review_score merely due to units.
cluster_features_scaled <- scale(cluster_features)
# Confirm that scaling did not create non-finite values.
stopifnot(all(is.finite(cluster_features_scaled)))
Both the elbow and average silhouette methods are inspected. Silhouette calculation requires pairwise distances, whose memory use grows approximately with the square of the number of observations. A reproducible sample of up to 3,000 orders is therefore used only to diagnose k. The final k-means model is fitted to every eligible order.
# Limit the diagnostic sample to a memory-safe size for silhouette distances.
diagnostic_n <- min(3000L, nrow(cluster_features_scaled))
# Reset the seed so the diagnostic sample is identical whenever the report runs.
set.seed(1)
# Sample row positions without replacement for the k diagnostics.
diagnostic_rows <- sample(
seq_len(nrow(cluster_features_scaled)),
size = diagnostic_n,
replace = FALSE
)
# Extract the same scaled features for the reproducible diagnostic sample.
k_diagnostic_data <- cluster_features_scaled[diagnostic_rows, , drop = FALSE]
# Draw the within-cluster sum-of-squares curve used for the elbow assessment.
fviz_nbclust(
k_diagnostic_data,
kmeans,
method = "wss",
k.max = 8,
nstart = 25
) +
labs(
title = "Elbow method for selecting k",
x = "Number of clusters (k)",
y = "Total within-cluster sum of squares"
) +
theme_minimal()
# Draw the mean silhouette curve, where larger values indicate clearer separation.
fviz_nbclust(
k_diagnostic_data,
kmeans,
method = "silhouette",
k.max = 8,
nstart = 25
) +
labs(
title = "Silhouette method for selecting k",
x = "Number of clusters (k)",
y = "Average silhouette width"
) +
theme_minimal()
The elbow curve shows diminishing improvement after a small number of clusters, while the silhouette diagnostic favours a relatively simple solution. This report chooses k = 3 as a transparent compromise between statistical separation and business interpretability: it permits an early/strong group, a typical group, and a late/weak group without creating many small segments that are difficult to communicate. This remains an analytical judgement rather than an objectively true number of clusters.
# Store the chosen number of clusters explicitly for auditability.
chosen_k <- 3L
# Reset the seed immediately before fitting the final model.
set.seed(1)
# Fit k-means to every eligible order, using 25 random starts for stability.
kmeans_fit <- kmeans(
cluster_features_scaled,
centers = chosen_k,
nstart = 25
)
# Attach the integer cluster label to its corresponding order-level observation.
clustered_orders <- cluster_orders %>%
mutate(cluster = factor(kmeans_fit$cluster))
# Summarise the original, unscaled variables so profiles retain business meaning.
cluster_profile_raw <- clustered_orders %>%
group_by(cluster) %>%
summarise(
n = n(),
mean_delivery_days = mean(delivery_days),
mean_delay_days = mean(delay_days),
mean_review_score = mean(review_score),
.groups = "drop"
)
# Identify the relatively worst cluster: latest first, then lowest review score.
worst_cluster_id <- cluster_profile_raw %>%
arrange(desc(mean_delay_days), mean_review_score) %>%
slice(1) %>%
pull(cluster) %>%
as.character()
# Identify the relatively strongest cluster: earliest first, then highest score.
best_cluster_id <- cluster_profile_raw %>%
arrange(mean_delay_days, desc(mean_review_score)) %>%
slice(1) %>%
pull(cluster) %>%
as.character()
# Give each cluster a concise relative interpretation based on its profile.
cluster_names <- cluster_profile_raw %>%
mutate(
cluster_name = case_when(
as.character(cluster) == worst_cluster_id ~
"slower, later, less satisfied",
as.character(cluster) == best_cluster_id ~
"faster, earlier, more satisfied",
TRUE ~ "typical timing, mixed satisfaction"
),
cluster_label = paste0("Cluster ", cluster, ": ", cluster_name)
) %>%
select(cluster, cluster_name, cluster_label)
# Attach the business-readable name to every order.
clustered_orders <- clustered_orders %>%
left_join(cluster_names, by = "cluster")
# Produce the requested final profile table, rounded only for presentation.
cluster_profile <- cluster_profile_raw %>%
left_join(cluster_names, by = "cluster") %>%
select(
cluster,
cluster_name,
n,
mean_delivery_days,
mean_delay_days,
mean_review_score
) %>%
arrange(cluster) %>%
mutate(
mean_delivery_days = round(mean_delivery_days, 2),
mean_delay_days = round(mean_delay_days, 2),
mean_review_score = round(mean_review_score, 2)
)
# Display the requested cluster summary in a compact HTML table.
knitr::kable(
cluster_profile,
caption = "Order-level cluster profiles in original measurement units",
col.names = c(
"Cluster",
"Interpretive name",
"Orders (n)",
"Mean delivery days",
"Mean delay days",
"Mean review score"
)
)
| Cluster | Interpretive name | Orders (n) | Mean delivery days | Mean delay days | Mean review score |
|---|---|---|---|---|---|
| 1 | typical timing, mixed satisfaction | 15760 | 11.94 | -12.25 | 2.07 |
| 2 | faster, earlier, more satisfied | 74719 | 10.69 | -12.75 | 4.75 |
| 3 | slower, later, less satisfied | 5991 | 37.44 | 11.21 | 1.98 |
The labels are relative descriptions of this dataset:
# Plot cluster means so the chart represents the profile rather than dense raw points.
ggplot(
cluster_profile_raw %>% left_join(cluster_names, by = "cluster"),
aes(
x = mean_delay_days,
y = mean_review_score,
colour = cluster_label,
size = n
)
) +
# Add a vertical reference line at zero: right of zero indicates average lateness.
geom_vline(xintercept = 0, linetype = "dashed", colour = "grey55") +
# Draw one point per cluster, sized by its number of orders.
geom_point(alpha = 0.9) +
# Label points directly to make the chart understandable without colour alone.
geom_text(
aes(label = paste0("Cluster ", cluster)),
vjust = -1.2,
show.legend = FALSE
) +
scale_colour_manual(values = cluster_colours[seq_len(chosen_k)]) +
scale_size_continuous(range = c(6, 13)) +
labs(
title = "Cluster profile: delivery timing and customer reviews",
subtitle = "Points are cluster means; point size represents order count",
x = "Mean delay versus estimated date (days)",
y = "Mean review score (1–5)",
colour = "Order cluster",
size = "Orders"
) +
theme_minimal()
# Restrict extreme tails in the display so the central distribution remains readable.
delay_plot_limits <- quantile(
clustered_orders$delay_days,
probs = c(0.01, 0.99),
na.rm = TRUE
)
# Compare the delay distribution across the named order clusters.
ggplot(
clustered_orders,
aes(x = cluster_label, y = delay_days, fill = cluster_label)
) +
# Use boxplots because delay_days is skewed and contains meaningful outliers.
geom_boxplot(outlier.alpha = 0.08, width = 0.65) +
# Mark the on-time boundary at zero days.
geom_hline(yintercept = 0, linetype = "dashed", colour = "grey35") +
# Zoom without discarding data used to calculate the boxplots.
coord_cartesian(ylim = delay_plot_limits) +
scale_fill_manual(values = cluster_colours[seq_len(chosen_k)]) +
labs(
title = "Delivery performance differs markedly across clusters",
subtitle = "Negative values are early; positive values are late",
x = "Order cluster",
y = "Days delivered relative to estimate",
fill = "Order cluster"
) +
theme_minimal() +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 20, hjust = 1)
)
States are compared using the share of their orders in each cluster. To reduce unstable rankings based on small samples, the ranking includes only states with at least 200 eligible delivered orders.
# Set and document the minimum number of orders needed for state comparisons.
minimum_state_orders <- 200L
# Count eligible clustered orders for each state and cluster combination.
state_cluster_counts <- clustered_orders %>%
filter(!is.na(customer_state)) %>%
count(customer_state, cluster, cluster_label, name = "cluster_orders")
# Calculate the total eligible order count within each state.
state_totals <- state_cluster_counts %>%
group_by(customer_state) %>%
summarise(state_orders = sum(cluster_orders), .groups = "drop")
# Convert counts to within-state shares and retain sufficiently large states.
state_cluster_mix <- state_cluster_counts %>%
left_join(state_totals, by = "customer_state") %>%
mutate(cluster_share = cluster_orders / state_orders) %>%
filter(state_orders >= minimum_state_orders)
# Rank eligible states by the share assigned to the relatively worst cluster.
state_ranking <- state_cluster_mix %>%
filter(as.character(cluster) == worst_cluster_id) %>%
arrange(desc(cluster_share))
# Preserve that ranking as the state order used in the chart.
state_levels <- state_ranking$customer_state
# Convert state to an ordered factor so the worst-cluster ranking is visible.
state_cluster_mix <- state_cluster_mix %>%
mutate(customer_state = factor(customer_state, levels = rev(state_levels)))
# Display the five states with the largest share in the relatively worst cluster.
knitr::kable(
state_ranking %>%
select(customer_state, state_orders, cluster_share) %>%
slice_head(n = 5) %>%
mutate(cluster_share = round(100 * cluster_share, 1)),
caption = paste0(
"States with the highest share of the relatively worst cluster (n ≥ ",
minimum_state_orders,
" orders)"
),
col.names = c("State", "Eligible orders", "Worst-cluster share (%)")
)
| State | Eligible orders | Worst-cluster share (%) |
|---|---|---|
| AL | 397 | 26.4 |
| MA | 717 | 18.8 |
| SE | 335 | 18.8 |
| PA | 946 | 18.0 |
| CE | 1279 | 15.9 |
# Plot within-state proportions so states of different sizes can be compared fairly.
ggplot(
state_cluster_mix,
aes(x = customer_state, y = cluster_share, fill = cluster_label)
) +
# Stacked bars sum to 100% because cluster_share is calculated within each state.
geom_col(width = 0.75) +
coord_flip() +
# Format proportions as percentages without loading an additional package.
scale_y_continuous(labels = function(x) paste0(round(100 * x), "%")) +
scale_fill_manual(values = cluster_colours[seq_len(chosen_k)]) +
labs(
title = "Order-cluster mix by customer state",
subtitle = paste0(
"States with at least ",
minimum_state_orders,
" eligible delivered orders; ranked by worst-cluster share"
),
x = "Customer state",
y = "Share of the state's eligible orders",
fill = "Order cluster"
) +
theme_minimal() +
theme(legend.position = "bottom")
Timing and satisfaction move together descriptively. The relatively late cluster also has the lowest mean review score, whereas the relatively early cluster has the highest. This is an association within delivered orders and should not be interpreted as proof that lateness caused the lower scores.
Delivery outcomes are heterogeneous rather than uniform. The cluster profiles separate orders with materially different delivery durations and performance against the estimate. Operational teams could use these segments as a starting point for investigating carriers, routes, product types, or fulfilment processes, but the clustering alone does not identify the underlying mechanism.
The share of the relatively worst cluster varies by state. Among states with at least 200 eligible orders, the geographic mix is not identical. This may help prioritise further diagnostic work, but it should not be read as evidence that a state itself causes poorer service.
The k-means analysis asks, “What kinds of delivered orders appear in the data?” It is unsupervised: no outcome label directs the clustering, and the result describes groups with similar delivery and review characteristics.
The random forest instead asks, “Using only information available when an order is placed, can the model predict whether that order will arrive late?” This is supervised learning because the historical late/on-time outcome trains the model. It also ranks predictors by their usefulness for prediction, which can guide further operational investigation.
These are complementary rather than interchangeable questions. Clustering describes realised delivery experiences, whereas supervised learning tests advance prediction and ranks potential warning signals.
# Build the supervised dataset from the existing delivered-order table.
rf_data <- analysis_orders %>%
mutate(
# Define the required binary outcome from realised delivery performance.
is_late = factor(
ifelse(delay_days > 0, "late", "on_time"),
levels = c("on_time", "late")
),
# Measure the delivery time promised when the customer placed the order.
promised_days = as.numeric(
difftime(
order_estimated_delivery_date,
order_purchase_timestamp,
units = "days"
)
),
# Represent seasonal ordering patterns using the purchase month.
purchase_month = factor(format(order_purchase_timestamp, "%m")),
# Represent weekly ordering patterns using the purchase weekday.
purchase_wday = factor(weekdays(order_purchase_timestamp)),
# Treat state as a categorical location predictor rather than a number.
customer_state = factor(customer_state)
) %>%
# Remove rows that cannot supply a target or one of the required predictors.
filter(
!is.na(is_late),
!is.na(promised_days),
!is.na(customer_state)
) %>%
# Keep only the target and information available at order time.
select(
is_late,
customer_state,
promised_days,
purchase_month,
purchase_wday
)
# Post-delivery variables are deliberately excluded to prevent target leakage.
# In particular, delay_days defines the target, while delivery_days and
# review_score are known only at or after delivery and cannot be valid predictors.
# randomForest permits factor predictors with at most 53 levels.
# customer_state has about 27 levels, so it is safely within that limit.
stopifnot(nlevels(rf_data$customer_state) <= 53)
# Confirm that the modelling frame contains no missing values.
stopifnot(!anyNA(rf_data))
# Calculate the observed class balance for interpretation.
rf_class_balance <- rf_data %>%
count(is_late, name = "orders") %>%
mutate(share = orders / sum(orders))
# Display the target distribution before model fitting.
knitr::kable(
rf_class_balance %>%
mutate(share = round(100 * share, 1)),
caption = "Class distribution in the random-forest modelling frame",
col.names = c("Delivery outcome", "Orders", "Share (%)")
)
| Delivery outcome | Orders | Share (%) |
|---|---|---|
| on_time | 88644 | 91.9 |
| late | 7826 | 8.1 |
Most delivered orders are early or on time, making late
delivery the minority class. Consequently, overall accuracy is
not sufficient: a model could predict nearly everything as on time,
achieve apparently strong accuracy, and still fail at the business task
of identifying late orders. The evaluation therefore reports recall for
the "late" class explicitly.
A leaked model using delay_days,
delivery_days, or review_score would
effectively be given information from or after the event it is supposed
to predict. Its accuracy would be meaningless for advance operational
use because those values do not exist when a new order is placed.
# Set the seed immediately before sampling to reproduce the same split.
set.seed(1)
# Randomly select 70% of rows for training without replacement.
train_rows <- sample(
seq_len(nrow(rf_data)),
size = floor(0.70 * nrow(rf_data)),
replace = FALSE
)
# Fit the model only on the training observations.
rf_train <- rf_data[train_rows, , drop = FALSE]
# Reserve the remaining 30% as unseen test observations.
rf_test <- rf_data[-train_rows, , drop = FALSE]
# Verify that neither outcome class was lost during the random split.
stopifnot(all(levels(rf_data$is_late) %in% unique(rf_train$is_late)))
stopifnot(all(levels(rf_data$is_late) %in% unique(rf_test$is_late)))
# Reset the seed so the forest itself is reproducible independently of the split.
set.seed(1)
# Train 200 classification trees using only the training set.
rf_model <- randomForest(
is_late ~ .,
data = rf_train,
ntree = 200,
importance = TRUE
)
# Generate class predictions for observations not used to train the model.
rf_predictions <- predict(
rf_model,
newdata = rf_test,
type = "class"
)
# Cross-tabulate predicted classes in rows against actual classes in columns.
rf_confusion <- table(
Predicted = rf_predictions,
Actual = rf_test$is_late
)
# Display the full confusion matrix so both error directions remain visible.
rf_confusion
## Actual
## Predicted on_time late
## on_time 26337 2247
## late 208 149
# Calculate the proportion of all test observations classified correctly.
rf_accuracy <- sum(diag(rf_confusion)) / sum(rf_confusion)
# Calculate minority-class recall: correctly predicted late orders / all late orders.
rf_late_recall <- rf_confusion["late", "late"] /
sum(rf_confusion[, "late"])
# Combine the two headline metrics into a compact audit table.
rf_metrics <- data.frame(
metric = c("Overall test accuracy", "Recall for late deliveries"),
value = c(rf_accuracy, rf_late_recall)
)
# Present metrics as percentages while retaining numeric calculations above.
knitr::kable(
rf_metrics %>%
mutate(value = paste0(round(100 * value, 1), "%")),
caption = "Random-forest performance on the held-out 30% test set",
col.names = c("Metric", "Result")
)
| Metric | Result |
|---|---|
| Overall test accuracy | 91.5% |
| Recall for late deliveries | 6.2% |
The model achieves 91.5% overall test accuracy, but its recall for late deliveries is 6.2%. The recall figure is the more demanding operational measure: it states the share of genuinely late test orders that the model successfully flags. The difference between accuracy and late-class recall demonstrates why the imbalanced target cannot be judged by accuracy alone.
# Draw the random forest's standard permutation and node-purity importance plots.
varImpPlot(
rf_model,
main = "Random-forest predictor importance for delivery lateness"
)
# Extract numeric importance values for a transparent ranking in the prose.
rf_importance <- importance(rf_model)
# Use mean decrease in accuracy because it measures predictive loss after shuffling.
rf_importance_ranking <- data.frame(
predictor = rownames(rf_importance),
mean_decrease_accuracy = rf_importance[, "MeanDecreaseAccuracy"],
row.names = NULL
) %>%
arrange(desc(mean_decrease_accuracy))
# Store the two leading predictors for reproducible inline interpretation.
most_important_predictor <- rf_importance_ranking$predictor[1]
second_important_predictor <- rf_importance_ranking$predictor[2]
# Display the ranking used for the written interpretation.
knitr::kable(
rf_importance_ranking %>%
mutate(mean_decrease_accuracy = round(mean_decrease_accuracy, 2)),
caption = "Predictor importance ranked by mean decrease in accuracy",
col.names = c("Predictor", "Mean decrease in accuracy")
)
| Predictor | Mean decrease in accuracy |
|---|---|
| customer_state | 93.58 |
| promised_days | 71.70 |
| purchase_month | 47.83 |
| purchase_wday | 21.12 |
The importance ranking places customer_state first and promised_days second. Importance means that predictions become less accurate when a variable’s information is disrupted; it does not mean that the variable causes lateness.
If customer_state ranks highly, the result is consistent
with structural logistics differences associated with customer location,
including distance from the São Paulo seller hub and carrier-network
coverage. This would corroborate the clustering result in which the
“slower, later, less satisfied” cluster is more
concentrated in particular states: two different methods would then
triangulate on geography as a useful delivery-performance signal.
If promised_days ranks highly, it suggests that the
length of the quoted delivery window is associated with late-delivery
risk. This could reflect systematically optimistic estimates on more
difficult routes or products, but the model cannot establish that the
estimate itself causes lateness. Month and weekday importance would
similarly indicate predictive seasonality or operational timing
patterns, not causal calendar effects.
The two learning paradigms produce one integrated argument. K-means identifies distinct realised delivery experiences, including a relatively slower, later, and less-satisfied segment whose geographic mix varies across states. The random forest then asks whether lateness can be anticipated using only order-time information and shows which available signals contribute most to that prediction. Together, the methods suggest that delivery risk is structured rather than random, while still requiring operational investigation before any causal or policy conclusion is drawn.
review_score runs from 1 to 5 and is ordinal, yet means,
scaling, and Euclidean distance treat the gaps between adjacent scores
as equal.Codex generated the initial RMarkdown scaffold, including the data-joining, feature-engineering, clustering, visualisation, and reporting structure, and later added the random-forest prediction and evaluation scaffold. Before submission, the student checked or modified the following: