How to Mine Order Affinity with Market-Basket Analysis
Two SKUs that appear together in 40% of orders but sit three aisles apart cost you a full extra leg of travel on every one of those orders. Market-basket analysis is how you find those pairs before they cost you a shift of walking: you mine historical order baskets for the SKU combinations that co-occur far more often than chance, then promote the strongest of them into co-location groups that the slotting engine places in adjacent bins. This page shows you how to compute the three metrics that separate a real relationship from a coincidence — support, confidence, and lift — over a set of baskets, and how to turn high-lift pairs into bounded groups without letting a single ubiquitous item drag the whole catalog into one blob. It is the association-mining case of the Family & Affinity Grouping cluster inside the wider Location Assignment & ABC Classification Algorithms system.
Affinity is evidence; lift is the signal that neutralizes ubiquity; a group is a placement unit. Once you have groups, they feed two downstream systems directly. The Slot Assignment Optimization with Solvers layer reads them into its objective so co-picked items are rewarded for landing in adjacent bins, and the Wave & Batch Pick Sequencing layer uses the same pairs to batch orders whose lines already cluster in space. Mine the affinity once; both consume it.
Prerequisites
Confirm each of these before mining a live order history:
- Python 3.10+ — the implementation uses
list[...]generics,frozensetkeys, and adataclassfor the config. A hand-rolled counter keeps it dependency-free; swap inmlxtend’sapriori/fpgrowthif you already run it. - A clean basket source — one row per order (or per pick wave, if you slot for wave concurrency) with the distinct SKUs on it. Duplicates within a basket must be collapsed before mining, or a two-line order inflates its own co-occurrence.
- Enough history — at least a few weeks so seasonal pairs are represented and a
min_supportof0.02corresponds to hundreds of baskets, not a handful. - A velocity tier per SKU — from ABC Classification Tuning, so the group builder can prefer families anchored on high-velocity items when the adjacent-bin budget is tight.
- The constraint layer available — a group is only useful if the bins can hold it, so its output is validated against Weight & Volume Constraint Modeling before placement.
Configuration Block
Every tunable lives in one externalized profile. The three levers that decide behavior are min_support (how common a pair must be before it counts), min_lift (how much stronger than chance it must be), and max_group_size (the cap that stops transitive chains from forming a mega-family no bin block can hold).
# affinity.yaml — market-basket mining tunables
affinity:
min_support: 0.02 # pair must appear in >= 2% of baskets to qualify
min_confidence: 0.30 # P(B|A) floor for a directional rule to be reported
min_lift: 3.0 # co-occur >= 3x chance expectation to be a family edge
min_pair_count: 25 # absolute co-occurrence floor; kills spurious rare-item lift
max_group_size: 4 # cap members per co-location group (bin-block budget)
# Equivalent Python config dict consumed by AffinityConfig
AFFINITY = {
"min_support": 0.02,
"min_confidence": 0.30,
"min_lift": 3.0,
"min_pair_count": 25,
"max_group_size": 4,
}
Implementation
The miner counts single-item and pairwise frequencies in one pass, derives support/confidence/lift per pair, keeps only pairs that clear every threshold, then greedily grows co-location groups from the strongest pairs up to max_group_size. The min_pair_count floor is what stops a pair seen three times — one of them a fluke — from posting an enormous lift and hijacking a group.
from __future__ import annotations
import logging
from dataclasses import dataclass
from itertools import combinations
logger = logging.getLogger("slotting.affinity")
@dataclass(frozen=True)
class AffinityConfig:
min_support: float = 0.02
min_confidence: float = 0.30
min_lift: float = 3.0
min_pair_count: int = 25
max_group_size: int = 4
@dataclass(frozen=True)
class AffinityPair:
a: str
b: str
count: int
support: float
confidence: float
lift: float
def mine_affinity_groups(
baskets: list[set[str]], cfg: AffinityConfig
) -> tuple[list[AffinityPair], list[frozenset[str]]]:
"""Mine co-location groups from order baskets via support/confidence/lift.
Returns the surviving pairs (sorted by lift) and the co-location groups grown
from them, each capped at cfg.max_group_size. A pair must clear min_support,
min_pair_count, min_confidence, and min_lift to become a family edge.
"""
n = len(baskets)
if n == 0:
raise ValueError("no baskets to mine")
item_count: dict[str, int] = {}
pair_count: dict[frozenset[str], int] = {}
for basket in baskets:
for sku in basket:
item_count[sku] = item_count.get(sku, 0) + 1
for a, b in combinations(sorted(basket), 2):
key = frozenset((a, b))
pair_count[key] = pair_count.get(key, 0) + 1
pairs: list[AffinityPair] = []
for key, count in pair_count.items():
a, b = sorted(key)
support = count / n
if support < cfg.min_support or count < cfg.min_pair_count:
continue
confidence = count / item_count[a] # P(b | a)
lift = support / ((item_count[a] / n) * (item_count[b] / n))
if confidence < cfg.min_confidence or lift < cfg.min_lift:
continue
pairs.append(AffinityPair(a, b, count, support, confidence, lift))
pairs.sort(key=lambda p: p.lift, reverse=True)
logger.info("kept %d/%d pairs above thresholds", len(pairs), len(pair_count))
groups: list[set[str]] = []
for p in pairs: # strongest edges first
placed = False
for g in groups:
if (p.a in g or p.b in g) and len(g | {p.a, p.b}) <= cfg.max_group_size:
g.update((p.a, p.b))
placed = True
break
if not placed:
groups.append({p.a, p.b})
frozen = [frozenset(g) for g in groups]
logger.info("emitted %d co-location groups (max_size=%d)",
len(frozen), cfg.max_group_size)
return pairs, frozen
Step-by-Step Walkthrough
- Count items and pairs in one pass.
item_counttracks how often each SKU appears;pair_countusescombinations(sorted(basket), 2)so every unordered pair in a basket is tallied once. Sorting the basket first makesfrozensetkeys canonical and keeps the counts symmetric. - Filter on support and raw count together. A pair must clear both
min_support(a relative floor) andmin_pair_count(an absolute floor). Support alone lets a rare-item pair through in a small dataset; the absolute count is the guardrail against spurious lift. - Compute confidence and lift. Confidence is
count / item_count[a]— the conditionalP(b|a). Lift is support divided by the product of the two marginal probabilities:lift > 1means co-occurrence beats chance, and the large denominator of a ubiquitous item automatically deflates its lift, which is exactly why lift and not raw frequency drives the grouping. - Keep only pairs clearing every threshold. A pair survives to become a family edge only if it passes support, count,
min_confidence, andmin_lift. Sorting the survivors by lift descending means the group builder consumes the most trustworthy edges first. - Grow bounded groups greedily. Each surviving pair either joins an existing group that already contains one of its members — provided the union stays within
max_group_size— or starts a new group. The size cap is deliberate: without it, transitive chaining (A-B, B-C, C-D, …) fuses unrelated families into one blob no adjacent bin block can hold.
Verification
Assert the invariants directly — a genuinely co-picked pair clears the thresholds and lands in a group, and a rare high-lift pair is rejected by the count floor. These checks run on synthetic baskets.
import logging
logging.basicConfig(level=logging.INFO)
# A, B, C co-occur in 30 of 100 baskets; noise fills the rest. D-E appears twice.
baskets: list[set[str]] = (
[{"A", "B", "C"} for _ in range(30)]
+ [{"X", "Y"} for _ in range(40)]
+ [{"Z"} for _ in range(28)]
+ [{"D", "E"} for _ in range(2)]
)
cfg = AffinityConfig(min_support=0.02, min_lift=3.0, min_pair_count=25)
pairs, groups = mine_affinity_groups(baskets, cfg)
kept = {frozenset((p.a, p.b)) for p in pairs}
assert frozenset(("A", "B")) in kept # strong, frequent pair survives
assert frozenset(("D", "E")) not in kept # high lift but only 2 counts -> pruned
assert any({"A", "B", "C"} <= g for g in groups)
print(f"OK - {len(pairs)} pairs, groups={sorted(sorted(g) for g in groups)}")
Sample expected output:
INFO:slotting.affinity:kept 3/5 pairs above thresholds
INFO:slotting.affinity:emitted 1 co-location groups (max_size=4)
OK - 3 pairs, groups=[['A', 'B', 'C']]
Common Pitfalls
- Spurious lift on rare items. Two SKUs seen together three times, and only together, post an astronomical lift while meaning nothing. The
min_pair_countfloor is not optional — without an absolute co-occurrence threshold, your strongest “families” will be statistical noise. Keep it at 25+ and scale it with catalog size. - Basket-size skew. A handful of huge baskets (a store-replenishment order with 300 lines) generates thousands of pairs that swamp genuine two-and-three-item retail affinity. Cap or down-weight baskets above a line-count percentile, or mine wholesale and retail order profiles separately before feeding Wave & Batch Pick Sequencing.
- Transitivity fusing unrelated groups. Greedy chaining on A-B, B-C, C-D will merge four items even if A and D never co-occur. The
max_group_sizecap contains it, but for large catalogs prefer community detection on the lift-weighted graph — the approach the Family & Affinity Grouping cluster builds — over naive union so a weak bridge edge cannot collapse two real families into one. - Mining without a placement check. A perfect group is worthless if the four SKUs cannot fit a contiguous bin run under weight and cube limits. Always validate emitted groups against Weight & Volume Constraint Modeling before handing them to the assignment solver, or you will propose families the racking cannot hold.
Related
- Family & Affinity Grouping — the parent cluster that takes these pairs into a lift-weighted graph and community detection for large catalogs.
- Slot Assignment Optimization with Solvers — the assignment objective that rewards emitted groups for landing in adjacent bins.
- Wave & Batch Pick Sequencing — batches orders whose lines already cluster in space using the same co-occurrence signal.
- Location Assignment & ABC Classification Algorithms — the parent architecture this affinity-mining layer feeds.