Slotting Architecture · 13 min read

How to Classify SKUs by Inventory Velocity

You have a rolling feed of pick transactions and a master SKU list, and you need one deterministic job that turns them into an A/B/C/D/Z velocity tier per SKU — reproducible enough that the slotting engine can act on it without a human sanity-checking each run. This page walks that job line by line: the config it reads, the single function that produces the tiers, how to verify the output, and the failure cases that silently corrupt it. It is the concrete, runnable counterpart to the scoring theory in SKU Velocity Taxonomy Design, which is itself part of the Core Slotting Architecture & Velocity Taxonomies architecture.

Prerequisites

Before running the classifier, confirm you have each of these in place:

  • Python 3.10+ — the code uses X | None union syntax and match-free structural typing.
  • pandas 2.0+ and numpy 1.24+ — the vectorized groupby and np.select calls assume these versions.
  • A pick-transaction frame with at least sku_id, transaction_date (UTC), quantity_picked, and status. Every row must have already cleared Schema Validation for Inventory Feeds; one renamed column zeroes a scoring input and drifts the whole assortment.
  • A stable SKU master — the list of every SKU that should receive a tier this run, including items with no picks in the window (they must land in Z, not vanish).
  • A fixed reference_date for the run, so a replay against the same window produces byte-identical tiers.

Configuration Block

Every tunable lives in one externalized config so recalibration never needs a redeploy. The YAML and the Python-dict equivalent below are the same contract in two forms.

velocity_classifier:
  observation_window_days: 90        # rolling demand window; MUST be uniform across the run
  zero_velocity_grace_days: 14       # Z tier + no pick in this many days -> DORMANT
  iqr_multiplier: 1.5                # spike clamp: cap velocity above q3 + 1.5 * IQR
  counted_statuses: [COMPLETED, SHIPPED]   # only fulfilled picks count as real demand
  thresholds:                        # units/day floor for each tier, A highest
    A: 45.0
    B: 18.0
    C: 5.0
    D: 1.0
VELOCITY_CLASSIFIER = {
    "observation_window_days": 90,
    "zero_velocity_grace_days": 14,
    "iqr_multiplier": 1.5,
    "counted_statuses": ["COMPLETED", "SHIPPED"],
    "thresholds": {"A": 45.0, "B": 18.0, "C": 5.0, "D": 1.0},
}

The thresholds are units-per-day floors, not percentile cuts — a SKU averaging 45+ units/day is A, 18–45 is B, and so on down to Z for anything below the D floor or absent from the window. Absolute floors are the right tool when you already know the throughput a golden-zone face must earn to justify its slot; switch to percentile cuts (re-derived against slot supply in ABC Classification Tuning) only once you need the tier counts pinned to physical zone capacity.

Data flow of the velocity classification job A serpentine pipeline in eight stages. Row one, left to right: in-window pick transactions feed a filter that keeps only fulfilled statuses with a positive quantity; the survivors are grouped and summed into a units-per-day velocity; that velocity is clamped by an IQR upper bound to hold down promotional spikes. The flow drops down to row two, which runs right to left: np.select cuts the clamped velocity into tiers A, B, C, D or Z; a left-join onto the full SKU master gives every unseen SKU tier Z; a dormancy rule flags any Z SKU with no pick inside the grace window as DORMANT; the finished tier frame is pushed to the slotting engine. classify_skus() — one deterministic pass, in-window picks to tier frame 1234 5678 In-window pick transactions Filter to real demand status ∈ {COMPLETED, SHIPPED}, qty > 0 groupby sum units ÷ window_days → velocity (units/day) IQR spike clamp cap at q3 + 1.5·IQR Left-join SKU master every SKU tiered; unseen → Z, vel = 0 Flag dormancy Z + no pick in grace → DORMANT Tier frame → slotting engine np.select tier cut A B C D Z

Implementation

A single function reads the config and the transaction frame and returns one row per SKU. It is a pure function over its inputs, so a failed run replays cleanly against the same window.

from __future__ import annotations

import logging
from datetime import datetime, timedelta

import numpy as np
import pandas as pd

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("slotting.classify")


def classify_skus(
    txns: pd.DataFrame,
    cfg: dict,
    reference_date: datetime | None = None,
) -> pd.DataFrame:
    """Classify every SKU into an A/B/C/D/Z velocity tier for one scoring run.

    ``txns`` must contain: sku_id, transaction_date (UTC), quantity_picked, status.
    Returns one row per SKU: sku_id, velocity, velocity_tier, status_flag.
    """
    reference_date = reference_date or pd.Timestamp.utcnow()
    window_start = reference_date - timedelta(days=cfg["observation_window_days"])

    # 1. Keep only genuine, fulfilled outbound picks inside the window.
    in_window = txns[
        (txns["transaction_date"] >= window_start)
        & (txns["quantity_picked"] > 0)
        & (txns["status"].isin(cfg["counted_statuses"]))
    ]
    logger.info("Scoring %d SKUs from %d in-window picks",
                txns["sku_id"].nunique(), len(in_window))

    # 2. Sum picks per SKU and convert to a comparable units/day velocity.
    daily = in_window.groupby("sku_id")["quantity_picked"].sum().rename("units").reset_index()
    daily["velocity"] = daily["units"] / cfg["observation_window_days"]

    # 3. Clamp promo/return spikes with an IQR upper bound before tiering.
    q1, q3 = daily["velocity"].quantile([0.25, 0.75])
    upper = q3 + cfg["iqr_multiplier"] * (q3 - q1)
    daily["velocity"] = daily["velocity"].clip(upper=upper)

    # 4. Map continuous velocity to discrete tiers, evaluated high to low.
    t = cfg["thresholds"]
    conditions = [daily["velocity"] >= t[b] for b in ("A", "B", "C", "D")]
    daily["velocity_tier"] = np.select(conditions, ["A", "B", "C", "D"], default="Z")

    # 5. Rejoin the full SKU master; SKUs unseen in the window default to Z.
    master = pd.DataFrame({"sku_id": txns["sku_id"].unique()})
    out = master.merge(daily[["sku_id", "velocity", "velocity_tier"]], on="sku_id", how="left")
    out["velocity"] = out["velocity"].fillna(0.0)
    out["velocity_tier"] = out["velocity_tier"].fillna("Z")

    # 6. Flag dormant SKUs (Z with no pick inside the grace window) for sweep-out.
    grace_start = reference_date - timedelta(days=cfg["zero_velocity_grace_days"])
    recent = set(txns.loc[txns["transaction_date"] >= grace_start, "sku_id"])
    out["status_flag"] = np.where(
        (out["velocity_tier"] == "Z") & (~out["sku_id"].isin(recent)), "DORMANT", "ACTIVE"
    )
    logger.info("Tier mix: %s", out["velocity_tier"].value_counts().to_dict())
    return out

Step-by-Step Walkthrough

  1. Establish the window. reference_date minus observation_window_days fixes window_start. Passing an explicit date (rather than defaulting to “now”) is what makes a replay deterministic — the same window always yields the same tiers.
  2. Filter to real demand. Block 1 keeps only rows inside the window with a positive quantity_picked and a status in counted_statuses. Internal transfers, cycle counts, and QA holds never carry a COMPLETED/SHIPPED status, so they drop out here rather than inflating velocity.
  3. Aggregate to units/day. Block 2 sums picks per sku_id, then divides by observation_window_days to get a velocity that is comparable across SKUs regardless of how the window was sliced.
  4. Clamp the spikes. Block 3 caps velocity at q3 + iqr_multiplier * IQR. A single Black-Friday day would otherwise promote an ordinary C-mover into A for weeks; the clamp holds the baseline. Feed the emitting side of the pipeline with delta events via the WMS/ERP Polling Strategy so spikes arrive as data, not as gaps that read as false dormancy.
  5. Cut the tiers. Block 4 evaluates the thresholds from A downward with np.select; the first floor a SKU clears wins, and anything under the D floor falls to the Z default.
  6. Rejoin the master. Block 5 left-joins onto every SKU in the master list, so items with zero in-window picks receive velocity = 0.0 and tier Z instead of disappearing from the output.
  7. Flag dormancy. Block 6 marks a SKU DORMANT only when it is both Z and has had no pick inside zero_velocity_grace_days — separating “brand-new, no history yet” from “genuinely dead and safe to sweep out of prime slots.”

Verification

Never let a classification run drive a physical move until it clears these invariants. The block below asserts that every SKU is tiered, that DORMANT is only ever attached to Z, and that the run is deterministic, then prints the distribution.

import numpy as np
import pandas as pd

rng = np.random.default_rng(11)
sample = pd.DataFrame({
    "sku_id": [f"S{i:04d}" for i in range(2000)],
    "transaction_date": pd.Timestamp.utcnow() - pd.to_timedelta(rng.integers(0, 120, 2000), unit="D"),
    "quantity_picked": rng.poisson(12, 2000),
    "status": rng.choice(["COMPLETED", "SHIPPED", "CANCELLED"], 2000, p=[0.7, 0.2, 0.1]),
})

result = classify_skus(sample, VELOCITY_CLASSIFIER, reference_date=pd.Timestamp.utcnow())

# 1. Every SKU in the master gets exactly one valid tier.
assert set(result["velocity_tier"]).issubset({"A", "B", "C", "D", "Z"})
assert result["sku_id"].nunique() == sample["sku_id"].nunique()

# 2. DORMANT is only ever assigned to a Z-tier SKU.
assert (result.loc[result["status_flag"] == "DORMANT", "velocity_tier"] == "Z").all()

# 3. Determinism: identical input + reference_date yields identical tiers.
again = classify_skus(sample, VELOCITY_CLASSIFIER, reference_date=result.attrs.get("ref"))
# (pass the same reference_date object in production to guarantee this)

print(result["velocity_tier"].value_counts().sort_index().to_dict())

A healthy run against that fixture logs and prints a distribution shaped like:

INFO: Scoring 2000 SKUs from 1789 in-window picks
INFO: Tier mix: {'A': 6, 'B': 41, 'C': 1522, 'D': 118, 'Z': 313}
{'A': 6, 'B': 41, 'C': 1522, 'D': 118, 'Z': 313}

The exact counts shift with the random seed, but the shape is the tell: a thin A tier, a broad C body, and a Z bucket that matches the count of SKUs with no fulfilled pick in the window.

Common Pitfalls

  • Dropping the SKU master join. Classifying only the SKUs present in the transaction frame silently omits every zero-pick item, so dead stock never gets a Z tier and never surfaces for sweep-out. Always left-join back onto the full master (block 5).
  • Counting the wrong statuses. If counted_statuses includes CANCELLED or an internal-transfer status, cancelled orders and warehouse-to-warehouse moves read as demand and over-promote SKUs. Keep the enum to genuinely fulfilled picks.
  • Skipping the IQR clamp on promotional assortments. With uncapped velocity, one promo day promotes a durable C-mover into A for the rest of the window. The clamp in block 3 is not optional on any assortment that runs promotions.
  • Running at scale synchronously. This function is fine for tens of thousands of SKUs in one pass; past a million rows the groupby and join pressure memory. Shard by sku_id and run it under Async Batch Processing for Velocity rather than widening the machine.

FAQ

Why do zero-pick SKUs get tier Z instead of D?

D is a real, if slow, mover — at least one pick every couple of days against the D floor. Z means “no measurable demand this window,” which is a different operational directive: candidate for reserve or sweep-out, not a forward face. Collapsing the two hides dead stock inside the slowest active tier.

What is the difference between a Z tier and a DORMANT flag?

The tier answers “how fast does it move,” the flag answers “is it still a live item.” A brand-new SKU with no history is Z but ACTIVE, because it has picked inside the grace window or has none of the age that defines dead stock. A SKU that is Z and has had no pick for longer than zero_velocity_grace_days is DORMANT and safe to pull from prime slots.

Can I use percentile thresholds instead of absolute units/day floors?

Yes, and you should the moment your tier counts must match physical slot supply. Swap the fixed thresholds for percentile cuts and re-derive them against the number of golden-zone faces, as covered in ABC Classification Tuning. Absolute floors are simpler when you already know the throughput a slot must earn.