Executive Summary
What sells together across 800 orders.
The strongest product pairing is Coffee + Milk: shoppers who buy Coffee also buy Milk 100% of the time, with a lift of 6.67—they appear together 6.67 times more often than chance predicts. This pair shows up in 15% of the 800 orders. The single most-bought item is Reusable Bag, present in 100% of orders, followed by Bread at 30%. These co-purchase patterns are actionable for cross-sell prompts, bundling offers, and shelf placement; they reveal what sells together, not proof that one item drives demand for the other.
Analysis Overview
Market basket analysis of 800 orders and 15 distinct items.
Market basket analysis identifies which products land in the same order, measured by three metrics. Support is the share of all orders containing both items—how common the pairing is. Confidence is directional: of orders with the first item, the percentage that also contain the second. Lift compares observed co-occurrence to chance: a lift of 1 means independence; above 1 means they sell together more than random; below 1 means less. Coffee + Milk, for example, has a lift of 6.67, meaning they co-occur 6.67 times more often than if purchases were independent. To act: bundle high-lift pairs, use high-confidence rules for "frequently bought together" prompts, or place associated items near each other in layout. These patterns show co-purchase behavior, not causation.
Data Quality
Basket reconstruction, deduplication, and item lumping.
The dataset loaded 2,611 item rows that grouped into 800 orders with 15 distinct products. No rows were removed; all 2,611 were retained. The average basket contained 3.26 items, calculated by counting each product once per order even if bought multiple times. This clean reconstruction ensures pairing analysis reflects genuine co-purchase patterns without duplication or missing data, establishing a reliable foundation for lift and confidence calculations across the 101 qualifying product pairs.
Most-Bought Items
Share of orders containing each Product (top 15).
Reusable Bag dominates, appearing in 100% of orders, followed by Bread at 30%, Butter at 25%, and Yogurt at 16.1%. The remaining items—Coffee, Milk, Sugar, Jam, Apple, Cereal, Cheese, Tea, Eggs, and Juice—each appear in 13.2% to 15% of orders. This frequency distribution sets the baseline for lift: a pairing of two already-common items must show high raw co-occurrence to beat chance, while two moderately common items that consistently land together signal a strong association. Bread's 30% prevalence makes it a natural candidate for bundling with lower-frequency items like Apple (13.9%) or Tea (13.6%).
Top Product Pairs by Lift
Product pairs ranked by lift, with support and confidence.
| Item A | Item B | Support PCT | Confidence PCT | Lift |
|---|---|---|---|---|
| Coffee | Milk | 15 | 100 | 6.67 |
| Butter | Bread | 18.75 | 75 | 2.5 |
| Cheese | Jam | 3.38 | 24.8 | 1.75 |
| Banana | Yogurt | 3.75 | 25 | 1.55 |
| Jam | Coffee | 2.88 | 20.4 | 1.36 |
| Jam | Milk | 2.88 | 20.4 | 1.36 |
| Eggs | Sugar | 2.62 | 19.8 | 1.34 |
| Apple | Bread | 5.5 | 39.6 | 1.32 |
| Juice | Butter | 4.38 | 33 | 1.32 |
| Eggs | Tea | 2.38 | 17.9 | 1.32 |
| Tea | Bread | 5.38 | 39.4 | 1.31 |
| Cheese | Yogurt | 2.88 | 21.1 | 1.31 |
| Cheese | Banana | 2.62 | 19.3 | 1.28 |
| Cereal | Coffee | 2.62 | 19.1 | 1.27 |
| Cereal | Milk | 2.62 | 19.1 | 1.27 |
The top 15 product pairs by lift reveal two standouts: Coffee + Milk at 6.67 and Butter + Bread at 2.5. Coffee + Milk appears in 15% of orders with 100% confidence—every Coffee buyer also takes Milk. Butter + Bread lands in 18.75% of orders with 75% confidence: three-quarters of Butter buyers also buy Bread. The next tier—Cheese + Jam, Banana + Yogurt, Jam + Coffee/Milk—all lift between 1.75 and 1.36 but on much smaller support (2.88% to 3.75%), making them less robust for bundling. Reading lift and support together prevents over-relying on fragile pairings: high lift on low support is brittle, while moderate lift on solid support is actionable.
Cross-Sell Recommendations
Buy-this-recommend-that rules ranked by confidence.
| If They Buy | Recommend | Confidence PCT | Lift |
|---|---|---|---|
| Coffee | Milk | 100 | 6.67 |
| Milk | Coffee | 100 | 6.67 |
| Butter | Bread | 75 | 2.5 |
| Bread | Butter | 62.5 | 2.5 |
| Apple | Bread | 39.6 | 1.32 |
| Tea | Bread | 39.4 | 1.31 |
| Juice | Bread | 37.7 | 1.26 |
| Cereal | Bread | 33.6 | 1.12 |
| Juice | Butter | 33 | 1.32 |
| Sugar | Bread | 32.2 | 1.07 |
| Eggs | Bread | 32.1 | 1.07 |
| Jam | Bread | 31 | 1.03 |
| Apple | Butter | 30.6 | 1.23 |
| Sugar | Butter | 29.7 | 1.19 |
| Eggs | Butter | 29.2 | 1.17 |
Coffee → Milk and Milk → Coffee both show 100% confidence and 6.67 lift—the strongest directional recommendation for a "frequently bought together" prompt or bundle. Butter → Bread follows at 75% confidence and 2.5 lift, with the reverse (Bread → Butter) at 62.5% confidence and the same 2.5 lift. Apple, Tea, and Juice buyers show 39.6%, 39.4%, and 37.7% confidence respectively for Bread, all with modest lift (1.26–1.32). These rules identify which items to highlight when a customer adds the first product to their cart. The highest-confidence rules—Coffee/Milk and Butter/Bread—warrant front-and-center prompts; lower-confidence rules work better as secondary suggestions or shelf adjacencies.
Methodology
Statistical methodology and diagnostics for Market Basket — What Sells Together
Statistical Method
Standard-library analysis: which products are bought together? Give it a transaction log — one row per item in each order — and it mines the classic retail associations: the most-bought items, every product pair ranked by lift (how much more often they co-occur than chance), and the directional cross-sell rules (buy A, recommend B) with their confidence. Built for cross-sell, bundling, and store or menu layout. Works on any dataset: map an order/basket ID column and an item column.
- Each row is one item in one order; the order ID groups items into a basket
- A product counted more than once in the same order still counts once toward that basket
- Co-occurrence is association, not causation — a promotion or placement can create a pairing
- Pairwise only — it finds pairs, not three-or-more-item combinations
- Items beyond the top 40 by frequency are folded into Other and excluded from pair mining
- A pairing can be an artifact of a bundle, promotion, or shelf placement rather than natural affinity
Analysis Code
Complete R source code for this analysis
Market Basket — What Sells Together
Mines classic retail associations from a transaction log (one row per item in each order): the most-bought items, every product pair ranked by lift with support and confidence, and the directional cross-sell rules for bundling and layout.
Why This Method?
Pairwise co-occurrence is the interpretable heart of market basket analysis. Support says how common a combination is, confidence says how reliably one item follows another, and lift says whether that pairing beats chance. Base R counting recovers all three exactly — no arules dependency — and the pair is the unit retailers actually act on.
What This Analysis Covers
- The most-bought items (share of orders containing each)
- Product pairs ranked by lift, with support and confidence
- Directional cross-sell rules: buy this, recommend that
Standard Library
Platform standard-library module (LAT-1441): runs on ANY transaction log via the semantic mapping {order_id, item}. All narrative is derived from the user's own column names and computed values.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Core Analysis Pipeline
Step 1: Discover the two mapped columns
initial_rows <- nrow(df)
for (k in c("order_id", "item")) {
if (!k %in% names(df)) {
stop(sprintf("column_mapping must map '%s' to a column in the transaction log.", k))
}
}
name_order <- humanize_semantic("order_id", col_map)
name_item <- humanize_semantic("item", col_map)Step 2: Clean — drop blank order/item rows, coerce to character
oid <- trimws(as.character(df[["order_id"]]))
itm <- trimws(as.character(df[["item"]]))
bad <- is.na(oid) | oid == "" | is.na(itm) | itm == ""
n_bad_rows <- sum(bad)
oid <- oid[!bad]; itm <- itm[!bad]
final_rows <- length(oid)
rows_removed <- initial_rows - final_rows
if (final_rows < 1) {
stop(sprintf("No usable rows: every row was missing an %s or an %s.",
name_order, name_item))
}Step 3: Reconstruct baskets — unique items per order, drop empties
basket_list <- split(itm, oid)
basket_list <- lapply(basket_list, unique)
basket_list <- basket_list[lengths(basket_list) >= 1]
n_orders <- length(basket_list)
if (n_orders < 10) {
stop(sprintf("Only %s reconstructed from %s — market basket analysis needs at least 10 orders.",
plural(n_orders, "basket"), name_order))
}Step 4: Item frequencies (baskets containing each item)
all_items <- unlist(basket_list, use.names = FALSE) # each item once per basket (deduped)
item_freq0 <- sort(table(all_items), decreasing = TRUE)
n_distinct_items <- length(item_freq0) # original distinct count
if (n_distinct_items < 2) {
stop(sprintf("Only one distinct %s appears across the orders — pairing needs at least two distinct items.",
name_item))
}Step 5: Lump items beyond the top 40 into "Other" for pair-mining sanity
The 40 most frequent items stay as themselves; everything rarer is folded into a single "Other" bucket that is REPORTED but held out of pair mining (a mixed bucket would pair with everything and mean nothing).
MAX_ITEMS <- 40
keep_items <- names(item_freq0)[seq_len(min(MAX_ITEMS, n_distinct_items))]
lumped_items <- setdiff(names(item_freq0), keep_items)
n_lumped <- length(lumped_items)
if (n_lumped > 0) {
basket_list <- lapply(basket_list, function(b) {
b[b %in% lumped_items] <- "Other"
unique(b)
})
}Frequencies + average basket recomputed AFTER lumping.
all_items2 <- unlist(basket_list, use.names = FALSE)
item_freq <- sort(table(all_items2), decreasing = TRUE)
avg_basket <- mean(lengths(basket_list))
item_freq_df <- data.frame(
item = names(item_freq),
orders_pct = round(100 * as.integer(item_freq) / n_orders, 1),
stringsAsFactors = FALSE
)
top_items_df <- head(item_freq_df, 15)Most-bought real item (skip the synthetic "Other" bucket if it leads).
real_freq_df <- item_freq_df[item_freq_df$item != "Other", , drop = FALSE]
top_item <- real_freq_df$item[1]
top_item_pct <- real_freq_df$orders_pct[1]Step 6a: The pairable universe — top-40 real items, "Other" excluded
pairable <- setdiff(names(item_freq), "Other")
pairable <- pairable[seq_len(min(MAX_ITEMS, length(pairable)))]
k <- length(pairable)Step 6b: Co-occurrence via a basket-by-item incidence matrix
M <- matrix(0L, nrow = n_orders, ncol = k, dimnames = list(NULL, pairable))
for (o in seq_len(n_orders)) {
hit <- basket_list[[o]][basket_list[[o]] %in% pairable]
if (length(hit)) M[o, hit] <- 1L
}
Co <- crossprod(M) # k x k co-occurrence; diagonal = item basket count
cnt <- diag(Co) # baskets containing each pairable itemSupport floor: at least 2 baskets, and at least 0.5% of orders.
min_support_orders <- max(2L, as.integer(ceiling(0.005 * n_orders)))Step 7: Score every qualifying unordered pair
a_vec <- character(0); b_vec <- character(0); co_vec <- integer(0)
if (k >= 2) {
for (i in seq_len(k - 1)) {
for (j in (i + 1):k) {
co_ij <- Co[i, j]
if (co_ij >= min_support_orders) {
a_vec <- c(a_vec, pairable[i])
b_vec <- c(b_vec, pairable[j])
co_vec <- c(co_vec, co_ij)
}
}
}
}
n_qual <- length(co_vec)
no_assoc <- n_qual == 0
empty_rules <- data.frame(item_a = character(0), item_b = character(0),
support_pct = numeric(0), confidence_pct = numeric(0),
lift = numeric(0), stringsAsFactors = FALSE)
empty_cross <- data.frame(if_they_buy = character(0), recommend = character(0),
confidence_pct = numeric(0), lift = numeric(0),
stringsAsFactors = FALSE)
if (no_assoc) {
pairs_all <- NULL
rules_df <- empty_rules
cross_sell_df <- empty_cross
top_pair <- NULL
} else {
cnt_a <- as.integer(cnt[a_vec]); cnt_b <- as.integer(cnt[b_vec])
support <- co_vec / n_orders
conf_ab <- co_vec / cnt_a # P(item_b | item_a)
conf_ba <- co_vec / cnt_b # P(item_a | item_b)
lift <- (co_vec * n_orders) / (cnt_a * cnt_b)Orient each undirected pair so item_a is the stronger antecedent.
swap <- conf_ba > conf_ab
ia <- ifelse(swap, b_vec, a_vec)
ib <- ifelse(swap, a_vec, b_vec)
conf_dir <- pmax(conf_ab, conf_ba)
pairs_all <- data.frame(
item_a = ia, item_b = ib,
support = support,
support_pct = round(100 * support, 2),
confidence = conf_dir,
confidence_pct = round(100 * conf_dir, 1),
lift = round(lift, 2),
co = as.integer(co_vec),
stringsAsFactors = FALSE
)Rank by lift, breaking ties by support so common, reliable pairs win.
pairs_all <- pairs_all[order(-pairs_all$lift, -pairs_all$support), , drop = FALSE]
rownames(pairs_all) <- NULL
rules_df <- head(pairs_all[, c("item_a", "item_b", "support_pct",
"confidence_pct", "lift")], 15)
rownames(rules_df) <- NULLStrongest actionable rule: highest lift among pairs clearing support.
top_pair <- pairs_all[1, , drop = FALSE]Directional rules for cross-sell — both directions of each pair, kept only when lift > 1 (a genuine positive association), ranked by confidence. This drops "recommend the item that's in every basket".
dir_df <- data.frame(
if_they_buy = c(a_vec, b_vec),
recommend = c(b_vec, a_vec),
confidence_pct = round(100 * c(conf_ab, conf_ba), 1),
lift = round(c(lift, lift), 2),
stringsAsFactors = FALSE
)
dir_df <- dir_df[dir_df$lift > 1, , drop = FALSE]
if (nrow(dir_df) > 0) {
dir_df <- dir_df[order(-dir_df$confidence_pct, -dir_df$lift), , drop = FALSE]
cross_sell_df <- head(dir_df, 15)
} else {
cross_sell_df <- empty_cross
}
rownames(cross_sell_df) <- NULL
}Step 8: Metrics + json answer
metrics <- list(
`Orders` = as.integer(n_orders),
`Distinct Items` = as.integer(n_distinct_items),
`Avg Basket Size` = round(avg_basket, 2),
`Qualifying Pairs` = as.integer(n_qual),
`Top Pairing` = if (no_assoc) "none" else paste0(top_pair$item_a, " + ", top_pair$item_b),
`Top Pairing Lift` = if (no_assoc) 0 else top_pair$lift
)
json_output <- list(
answer = if (no_assoc) {
paste0(
"Across ", plural(n_orders, "order"), " (average basket ",
round(avg_basket, 1), " items), no product pair cleared the support floor of ",
plural(min_support_orders, "basket"),
", so no strong associations were found. The most-bought ", name_item,
" is ", top_item, ", in ", top_item_pct, "% of orders."
)
} else {
paste0(
"Across ", plural(n_orders, "order"), ", the strongest product pairing is ",
top_pair$item_a, " and ", top_pair$item_b, ": shoppers who buy ",
top_pair$item_a, " also buy ", top_pair$item_b, " ", top_pair$confidence_pct,
"% of the time, a lift of ", top_pair$lift, " (bought together ", top_pair$lift,
" times more often than chance). ", plural(n_qual, "pair"),
" cleared the support floor. The most-bought ", name_item, " is ",
top_item, ", in ", top_item_pct, "% of orders."
)
},
cards = lapply(
c("tldr", "overview", "preprocessing", "top_items",
"association_rules", "cross_sell"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows, rows_removed = rows_removed,
name_order = name_order, name_item = name_item,
n_orders = n_orders, n_distinct_items = n_distinct_items,
avg_basket = avg_basket, n_bad_rows = n_bad_rows, n_lumped = n_lumped,
item_freq_df = item_freq_df, top_items_df = top_items_df,
top_item = top_item, top_item_pct = top_item_pct,
pairs_all = pairs_all, rules_df = rules_df, cross_sell_df = cross_sell_df,
top_pair = top_pair, no_assoc = no_assoc,
min_support_orders = min_support_orders,
metrics = metrics, json_output = json_output
)
}
# Card: tldr (tldr)
card_tldr <- function(shared, df, params) {
if (shared$no_assoc) {
text <- paste0(
"Across ", plural(shared$n_orders, "order"), " (average basket ",
round(shared$avg_basket, 1), " items), no product pair appeared together in at least ",
plural(shared$min_support_orders, "basket"),
", so no strong co-purchase associations were found. The single most-bought ",
shared$name_item, " is ", shared$top_item, ", appearing in ",
shared$top_item_pct, "% of orders. With more transactions or a more ",
"concentrated catalog, clearer pairings tend to emerge."
)
} else {
tp <- shared$top_pair
text <- paste0(
"Across ", plural(shared$n_orders, "order"), " with an average basket of ",
round(shared$avg_basket, 1), " items, the strongest product pairing is ",
tp$item_a, " and ", tp$item_b, ": shoppers who buy ", tp$item_a,
" also buy ", tp$item_b, " ", tp$confidence_pct, "% of the time, a lift of ",
tp$lift, " — they land in the same basket ", tp$lift,
" times more often than if the two were bought independently. The most-bought ",
shared$name_item, " overall is ", shared$top_item, ", in ",
shared$top_item_pct, "% of orders. These are co-purchase patterns to act on for ",
"cross-sell, bundling, and layout; they show what sells together, not proof that one item drives the other."
)
}
list(
title = "Executive Summary",
description = paste0("What sells together across ", plural(shared$n_orders, "order"), "."),
metrics = shared$metrics,
text = text
)
}