Family & Affinity Grouping for Velocity-Driven Slotting
Velocity tiers decide how important a SKU is; affinity grouping decides what it should sit next to. A pure velocity ranking will happily scatter the three items that appear together in 40% of orders across three different aisles, because each one was scored in isolation. Family and affinity grouping repairs that blind spot by co-locating items that are repeatedly picked in the same order or wave, collapsing a three-stop tour into a single reach. This guide is part of the Location Assignment & ABC Classification Algorithms system, and it exists because relational demand — not just per-SKU throughput — is where the last increment of pick-path efficiency hides once the A/B/C split is already tuned.
The engineering thesis is narrow and testable: co-occurrence is evidence, lift is the signal, community detection turns the signal into families, and the constraint layer decides whether a family can physically sit together. Every section below grounds that chain in a specific metric, threshold, or code path.
What Affinity Grouping Is
Affinity grouping is the process of converting historical order-line transactions into a weighted graph of SKU relationships, then partitioning that graph into product families that become atomic units of slot assignment. Instead of asking “where does SKU 40231 go?”, the slotting engine asks “where does this family of nine co-picked SKUs go, and are there nine adjacent feasible bins to hold them?”.
The unit of evidence is the basket — the set of distinct SKUs on one order (or one pick wave, if you slot for wave concurrency rather than single orders). For every basket you emit the unordered pairs it contains, and the count of a pair across all baskets is its raw co-pick frequency. Raw frequency alone is a trap: a packaging insert or a Class-A battery multi-pack appears in almost every basket, so it co-occurs with everything and would drag the entire catalogue into one useless mega-family. The fix is a normalized association metric:
- Support —
P(A,B), the fraction of baskets containing both. Filters noise; a pair seen twice in a year is not a family. - Lift —
P(A,B) / (P(A)·P(B)). Lift > 1 means the two appear together more than chance predicts; lift ≈ 1 means they are independent. This is the metric that neutralises ubiquitous SKUs, because a high-frequency item has a large denominator. - Jaccard —
co(A,B) / (count(A) + count(B) − co(A,B)). A symmetric similarity useful when basket sizes vary wildly. - Confidence —
P(B|A), directional and useful for asymmetric “razor → blades” relationships, though slotting usually treats adjacency as symmetric.
Three algorithmic variants show up in practice. Market-basket association rules (Apriori / FP-growth) enumerate frequent itemsets directly and are best when you want human-readable rules. Graph community detection (Louvain / greedy modularity) treats SKUs as nodes and lift as edge weight, and scales better to hundreds of thousands of SKUs. Correlation / spectral clustering works on a precomputed affinity matrix when you want a fixed number of families. This page builds the graph-community path, because it produces variable-size families that map naturally onto variable-size bin neighbourhoods, and because it degrades gracefully on the sparse, heavily skewed co-occurrence matrices real order data produces.
Input Data Requirements
Affinity grouping consumes one feed — order-line history — but its quality preconditions are unforgiving, because a single mis-scoped basket definition silently changes every family. Enforce these at the ingestion boundary rather than discovering them in a re-slot that makes travel worse.
| Field | Type | Source | Quality precondition |
|---|---|---|---|
order_id |
str |
WMS order header | Non-null; defines the basket boundary |
sku_id |
str |
Order line | Non-null; excludes packaging/consumable SKUs |
qty |
int |
Order line | > 0; negative rows are returns and must be dropped |
pick_ts |
datetime |
Pick confirmation | Within the analysis window; drives seasonal decay |
line_status |
str |
WMS | SHIPPED only; cancels/backorders distort co-occurrence |
channel |
str |
Order header | Present when families are scoped per fulfilment stream |
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
logger = logging.getLogger("slotting.affinity")
@dataclass(frozen=True)
class OrderLine:
"""One shipped order line — the atomic input to basket construction."""
order_id: str
sku_id: str
qty: int
pick_ts: datetime
channel: Optional[str] = None
@dataclass
class AffinityConfig:
"""Tunable coefficients for scoring and partitioning the SKU graph."""
min_support_count: int = 8 # a pair must co-occur at least this often
min_lift: float = 1.5 # discard edges at or below chance co-occurrence
max_basket_size: int = 40 # cap pathological baskets (kitting/wholesale)
resolution: float = 1.0 # Louvain granularity; >1 => smaller families
max_family_size: int = 12 # bounded by an adjacent-bin neighbourhood
exclude_skus: frozenset[str] = field(default_factory=frozenset) # bags, filler
Step-by-Step Implementation
1. Ingest and Clean Baskets
Reject before you count. Returns, cancellations, and packaging SKUs are not weak signal — they are wrong signal that inflates co-occurrence for items that have no real picking relationship. Collapse each order to its set of distinct pickable SKUs, drop single-SKU baskets (a basket of one produces no pairs), and cap oversized baskets so a 300-line wholesale order does not generate 45,000 spurious pairs that swamp the graph.
from collections import defaultdict
def build_baskets(lines: list[OrderLine], cfg: AffinityConfig) -> list[frozenset[str]]:
"""Collapse order lines into cleaned, de-duplicated SKU baskets."""
grouped: dict[str, set[str]] = defaultdict(set)
for ln in lines:
if ln.qty <= 0 or ln.sku_id in cfg.exclude_skus:
continue
grouped[ln.order_id].add(ln.sku_id)
baskets, dropped = [], 0
for order_id, skus in grouped.items():
if len(skus) < 2:
continue # no pairs to contribute
if len(skus) > cfg.max_basket_size:
dropped += 1
continue # kitting/wholesale outlier — would dominate co-occurrence
baskets.append(frozenset(skus))
logger.info("built %d multi-SKU baskets, dropped %d oversized", len(baskets), dropped)
return baskets
2. Count Co-Occurrence and Marginals
For each basket, emit its unordered pairs with itertools.combinations and accumulate a sparse pair counter alongside per-SKU marginal counts. Keeping both in one pass is what lets the next step compute lift without a second scan over the (potentially billion-row) transaction table.
from itertools import combinations
def count_cooccurrence(
baskets: list[frozenset[str]],
) -> tuple[dict[tuple[str, str], int], dict[str, int], int]:
"""Return pair co-counts, per-SKU marginal counts, and total basket count."""
pair_counts: dict[tuple[str, str], int] = defaultdict(int)
sku_counts: dict[str, int] = defaultdict(int)
for basket in baskets:
for sku in basket:
sku_counts[sku] += 1
for a, b in combinations(sorted(basket), 2):
pair_counts[(a, b)] += 1
logger.info("counted %d distinct pairs over %d SKUs", len(pair_counts), len(sku_counts))
return pair_counts, sku_counts, len(baskets)
3. Score Affinity with Lift
Convert raw co-counts into an edge list weighted by lift, applying the support and lift floors from the config. This is the step that neutralises ubiquitous SKUs: an item present in 90% of baskets has an enormous marginal denominator, so even a high co-count yields a lift near 1 and is pruned. Only pairs that co-occur more than chance survive to become graph edges.
def score_edges(
pair_counts: dict[tuple[str, str], int],
sku_counts: dict[str, int],
n_baskets: int,
cfg: AffinityConfig,
) -> list[tuple[str, str, float]]:
"""Compute lift per pair and keep only edges clearing support + lift floors."""
edges: list[tuple[str, str, float]] = []
for (a, b), co in pair_counts.items():
if co < cfg.min_support_count:
continue
# lift = P(A,B) / (P(A) * P(B)) = N * co / (count(A) * count(B))
lift = (n_baskets * co) / (sku_counts[a] * sku_counts[b])
if lift > cfg.min_lift:
edges.append((a, b, round(lift, 4)))
logger.info("retained %d edges above lift=%.2f", len(edges), cfg.min_lift)
return edges
4. Detect Families with Community Detection
Load the weighted edges into a networkx.Graph and partition it with greedy modularity maximisation (or Louvain, if the python-louvain package is available). Communities are the product families. The resolution parameter is the granularity dial — raise it for tighter, smaller families that fit narrow bin blocks; lower it for broader zones. Families larger than a physical bin neighbourhood are split downstream, never forced into a non-contiguous slot.
import networkx as nx
from networkx.algorithms.community import greedy_modularity_communities
def detect_families(
edges: list[tuple[str, str, float]], cfg: AffinityConfig
) -> dict[str, int]:
"""Partition the lift-weighted SKU graph into family_id labels."""
g = nx.Graph()
for a, b, lift in edges:
g.add_edge(a, b, weight=lift)
communities = greedy_modularity_communities(g, weight="weight", resolution=cfg.resolution)
labels: dict[str, int] = {}
oversized = 0
for family_id, members in enumerate(communities):
if len(members) > cfg.max_family_size:
oversized += 1 # flagged for downstream sub-splitting
for sku in members:
labels[sku] = family_id
logger.info(
"detected %d families (%d exceed max_family_size=%d)",
len(communities), oversized, cfg.max_family_size,
)
return labels
5. Emit Zone Reservations
A family label is inert until it becomes a reservation the assignment engine can honour. Emit, per family, its aggregate velocity weight (so the family lands in a zone matching its hottest members) and its member list, then hand it to the assignment layer as an atomic block. The engine only commits the reservation if a contiguous set of feasible bins exists; otherwise the family is sub-split along its weakest internal edges and re-offered. Crucially, affinity only re-ranks an already-feasible candidate list — it never overrides the physical or safety gate.
@dataclass
class FamilyReservation:
family_id: int
members: list[str]
aggregate_velocity: float
coherence: float # mean internal lift — a confidence score for the grouping
def build_reservations(
labels: dict[str, int],
velocity: dict[str, float],
edges: list[tuple[str, str, float]],
) -> list[FamilyReservation]:
"""Aggregate family membership into zone-reservation requests for the assigner."""
members: dict[int, list[str]] = defaultdict(list)
for sku, fid in labels.items():
members[fid].append(sku)
lift_by_family: dict[int, list[float]] = defaultdict(list)
for a, b, lift in edges:
if labels.get(a) == labels.get(b) and a in labels:
lift_by_family[labels[a]].append(lift)
reservations = []
for fid, skus in members.items():
lifts = lift_by_family.get(fid, [1.0])
reservations.append(FamilyReservation(
family_id=fid,
members=sorted(skus),
aggregate_velocity=sum(velocity.get(s, 0.0) for s in skus),
coherence=round(sum(lifts) / len(lifts), 3),
))
reservations.sort(key=lambda r: r.aggregate_velocity, reverse=True)
logger.info("emitted %d family reservations", len(reservations))
return reservations
Tuning & Calibration
The four levers that change family output the most are min_support_count, min_lift, resolution, and max_family_size. Support and lift jointly control the signal-to-noise of the edge set; resolution controls family granularity; and max size clamps families to what your bin geometry can actually hold contiguously. Externalise all of them so a re-tune never requires a code deploy. Both the YAML and its Python dict equivalent are shown so the config can be loaded from a file or embedded in a settings module.
affinity:
min_support_count: 8 # minimum co-picks before a pair is an edge
min_lift: 1.5 # discard pairs at or below chance co-occurrence
max_basket_size: 40 # cap kitting/wholesale outlier baskets
resolution: 1.0 # Louvain granularity; >1 => smaller, tighter families
max_family_size: 12 # ceiling set by an adjacent-bin neighbourhood
recompute_cadence_days: 14 # rolling re-derivation of families
membership_hysteresis: 2 # cycles a SKU must persist in a new family to move
CONFIG = {
"affinity": {
"min_support_count": 8,
"min_lift": 1.5,
"max_basket_size": 40,
"resolution": 1.0,
"max_family_size": 12,
"recompute_cadence_days": 14,
"membership_hysteresis": 2,
}
}
Parameter sensitivity worth knowing before you tune: min_lift is a hard cliff, not a slope — moving it from 1.2 to 1.5 typically prunes a broad tail of weak, seasonal-noise edges and sharply increases family coherence while shrinking family count. resolution interacts with max_family_size: if families keep overflowing the size ceiling and getting sub-split, raise resolution rather than lowering the ceiling, so the split happens inside modularity optimisation where edge weights are respected. Set max_family_size from real bin geometry — count the bins reachable within one reach-plus-step at a golden-zone face — and revisit it whenever the physical layout changes. The recompute_cadence_days should track how fast the order profile drifts; two weeks suits steady-state catalogues, but tie it to promotional calendars for seasonal ranges.
Validation & Testing
Affinity bugs are quiet — a wrong family still looks like a family. Assert the mathematical properties that must hold: lift is symmetric, an independent pair scores ≈ 1, no SKU belongs to two families, and every family respects the size ceiling before it reaches the assigner. Run these as pytest checks against a small fixture where the expected grouping is known by hand.
from datetime import datetime
def _line(order: str, sku: str) -> OrderLine:
return OrderLine(order, sku, qty=1, pick_ts=datetime(2026, 1, 1))
def test_lift_flags_independent_pairs_as_one():
# X and Y co-occur exactly as often as chance predicts -> lift ~ 1, pruned
baskets = [frozenset({"X", "Y"}), frozenset({"X", "Z"}), frozenset({"Y", "W"})]
pairs, marg, n = count_cooccurrence(baskets)
cfg = AffinityConfig(min_support_count=1, min_lift=1.5)
edges = score_edges(pairs, marg, n, cfg)
assert all(w > 1.5 for _, _, w in edges), "no edge should survive at chance lift"
def test_strong_pair_forms_an_edge():
baskets = [frozenset({"A", "B"})] * 10 + [frozenset({"A", "C"})]
pairs, marg, n = count_cooccurrence(baskets)
cfg = AffinityConfig(min_support_count=5, min_lift=1.2)
edges = score_edges(pairs, marg, n, cfg)
assert ("A", "B") in {(a, b) for a, b, _ in edges}, "A-B must clear the floors"
def test_no_sku_in_two_families():
edges = [("A", "B", 3.0), ("B", "C", 2.5), ("D", "E", 4.0)]
labels = detect_families(edges, AffinityConfig(max_family_size=12))
assert len(labels) == len(set(labels)), "labels dict keys are unique SKUs"
# each SKU maps to exactly one integer family id
assert all(isinstance(fid, int) for fid in labels.values())
Integration Points
Affinity grouping is a re-ranking layer inside the assignment pipeline, not a standalone product. Its inputs and outputs wire directly into sibling components:
- Upstream — velocity. Family reservations are prioritised by aggregate velocity, which comes from ABC Classification Tuning. Reconcile the two so affinity never drags a Class-A mover into a Class-C reserve just because it shares a family with slow items — cap a family’s placement zone by its hottest member, not its mean.
- Gate — physics. Before any adjacent-bin reservation is committed, it passes through Weight & Volume Constraint Modeling, which decides whether the family’s items actually fit the contiguous bins without violating fill, crush, or level rules. Affinity proposes; the constraint gate disposes.
- Downstream — move economics. Family membership changes every recompute, and blindly acting on each change causes churn. Feed reservation deltas through Threshold Optimization for Re-slotting so a SKU only physically moves when the co-pick improvement clears the move-cost break-even and the
membership_hysteresisband. - Consumer — assignment. The Location Assignment & ABC Classification Algorithms engine treats each
FamilyReservationas an atomic block when it searches for a contiguous feasible neighbourhood, which is what turns a multi-stop tour into a single-reach pick.
For the operational playbook on placing high-frequency consumer bundles in forward zones, follow Grouping Complementary Products for Faster Picking.
Failure Modes & Edge Cases
- Ubiquitous-SKU contamination. Grouping on raw co-count instead of lift pulls packaging inserts, filler, and Class-A staples into one giant family that co-occurs with everything. Remediation: score edges by lift (large marginal denominator neutralises the hub) and maintain an
exclude_skusset for known non-pickable filler. - Family exceeds bin geometry. A coherent family of twenty items cannot occupy a contiguous neighbourhood of twelve bins, so it silently gets a non-contiguous slot that defeats the purpose. Remediation: enforce
max_family_size, and sub-split overflow families along their weakest internal edges before reservation. - Affinity vs hazard segregation. A co-pick rule can prefer to seat two chemically incompatible SKUs together. Because affinity only re-ranks an already-feasible list, the segregation rule wins — but log the conflict so the grouping input can be corrected upstream rather than silently overridden every cycle.
- Seasonal ghost affinities. A holiday bundle produces strong lift for six weeks, then that family occupies prime real estate for the other forty-six. Remediation: apply time-decay to
pick_tsweighting and tierecompute_cadence_daysto the promotional calendar so expired affinities dissolve. - Membership thrashing. Recomputing families every cycle relocates the same SKUs back and forth as edge weights wobble near the lift floor. Remediation: require a SKU to persist in a new family for
membership_hysteresiscycles before authorising a physical move.
FAQ
Why use lift instead of raw co-pick counts?
Because raw counts reward popularity, not relationship. A Class-A staple that appears in most orders will co-occur frequently with nearly every other SKU purely by volume, so counting co-picks would group the entire catalogue around a handful of hubs. Lift divides the observed co-occurrence by what independence would predict, so only pairs that appear together more than chance survive — which is exactly the relationship you want to encode in physical adjacency.
How large should an affinity family be?
Bound it by physical geometry, not by the algorithm. max_family_size should equal the number of feasible bins a picker can service within one reach-plus-step at the target zone — often eight to twelve at a golden-zone face. A family larger than the contiguous neighbourhood it must occupy either gets a non-contiguous (useless) slot or must be sub-split, so size the ceiling to the floor plan and let community detection produce families under it.
How does affinity grouping interact with ABC velocity tiers?
They are complementary, and velocity has priority for zone selection. Velocity decides which zone class a family lands in; affinity decides adjacency within the candidate placements. Cap each family’s placement by its hottest member’s tier so a fast mover is never demoted into a slow zone just because it shares a family with Class-C items. The two signals are reconciled in the assignment engine, not fought over.
Won’t recomputing families constantly cause re-slot churn?
It will if you act on every recompute. That is why family membership deltas pass through a re-slotting hysteresis band: a SKU must persist in a new family for a configured number of cycles, and the projected co-pick improvement must clear the labour cost of the physical move, before any relocation is authorised. Recompute often to keep the model fresh, but relocate rarely and only when the economics clear.
Do I need FP-growth or association-rule mining, or is a graph enough?
A lift-weighted graph with community detection is sufficient — and usually preferable — for slotting, because it yields variable-size families that map onto variable-size bin neighbourhoods and scales to large sparse catalogues. Reach for FP-growth or Apriori when you specifically need human-readable itemset rules (for merchandising or directional “A implies B” logic); for physical adjacency, the graph partition is the more direct route from co-occurrence to a slottable block.
Related
- Grouping Complementary Products for Faster Picking — the forward-zone placement playbook for high-frequency bundles.
- ABC Classification Tuning — the velocity tiers that set each family’s target zone class.
- Weight & Volume Constraint Modeling — the feasibility gate a family reservation must pass before it is committed.
- Threshold Optimization for Re-slotting — the hysteresis and move-cost break-even that contain membership churn.
- Location Assignment & ABC Classification Algorithms — the parent assignment engine this grouping layer feeds.