Slotting Architecture · 11 min read

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.

Co-occurrence graph of SKUs with lift-weighted edges resolved into a co-location group A weighted graph of five SKU nodes labelled A through E on the left. Solid edges join pairs whose lift is at or above the minimum threshold of three: A to B at lift 4.2, A to C at lift 3.1, and B to C at lift 3.5. These three nodes fall inside a shaded ellipse marking a high-lift family. Two dashed edges are pruned: C to E at lift 1.2 is below threshold, and A to D at lift 5.0 is flagged rare because the pair count is too low to trust. An arrow leads from the shaded family to an output panel that emits the co-location group of SKUs A, B, and C under the thresholds minimum support 0.02, minimum lift 3.0, and maximum group size 4, then reserves them a block of adjacent bins. Co-occurrence graph → co-location group high-lift family lift 4.2 3.1 3.5 1.2 · pruned 5.0 · rare A B C D E rare item Co-location group { A, B, C } min_support 0.02 · min_lift 3.0 max_group_size 4 → reserve adjacent bin block lift ≥ min_lift · kept below threshold or rare · pruned
SKUs joined by lift-weighted edges. Only edges at or above the minimum-lift threshold with enough support survive; the surviving clique becomes a bounded co-location group.

Prerequisites

Confirm each of these before mining a live order history:

  • Python 3.10+ — the implementation uses list[...] generics, frozenset keys, and a dataclass for the config. A hand-rolled counter keeps it dependency-free; swap in mlxtend’s apriori/fpgrowth if 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_support of 0.02 corresponds 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

  1. Count items and pairs in one pass. item_count tracks how often each SKU appears; pair_count uses combinations(sorted(basket), 2) so every unordered pair in a basket is tallied once. Sorting the basket first makes frozenset keys canonical and keeps the counts symmetric.
  2. Filter on support and raw count together. A pair must clear both min_support (a relative floor) and min_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.
  3. Compute confidence and lift. Confidence is count / item_count[a] — the conditional P(b|a). Lift is support divided by the product of the two marginal probabilities: lift > 1 means 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.
  4. Keep only pairs clearing every threshold. A pair survives to become a family edge only if it passes support, count, min_confidence, and min_lift. Sorting the survivors by lift descending means the group builder consumes the most trustworthy edges first.
  5. 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_count floor 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_size cap 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.