Slotting Architecture · 14 min read

How to Calculate Optimal ABC Thresholds for Seasonal SKUs

A static 80/15/5 Pareto cut assumes demand is stationary, so the moment velocity develops a seasonal shape it lies to you: a holiday fast-mover sits in a C reserve bin through November while a summer-only SKU squats in a golden A location it hasn’t earned since August. This page solves one specific task — taking a table of weekly pick history and emitting a per-SKU ABC tier whose boundaries are adjusted for seasonality, anchored to real pick-face capacity, and damped so items stop oscillating across a tier line every cycle. It is a reference implementation within ABC Classification Tuning, the layer of the Location Assignment & ABC Classification Algorithms system that decides where the tier boundaries sit and how often they are allowed to move.

Prerequisites

Before running the threshold job, confirm you have:

  • Python 3.10+ with pandas>=2.0 and numpy>=1.24 — the implementation relies on DataFrameGroupBy.transform and Series.ewm.
  • A weekly velocity table — one row per sku_id per week_ending, carrying picks (units or lines) and a boolean is_stockout flag. At least 52 weeks of history per SKU is required for the seasonality test to be meaningful.
  • A stockout signal — either a WMS availability flag or a derived “was on-hand zero for the majority of the week” boolean, so suppressed demand is not mistaken for a genuine slow week.
  • The previous cycle’s classification — a dict[str, str] of sku_id → prior tier, cached in a low-latency store (Redis or DynamoDB), so the hysteresis dead-band has a prior state to compare against across batch restarts.
  • A capacity floor — the minimum picks/day that justifies an A-zone slot at your facility, taken from current labor and pick-face throughput rather than a textbook percentage.

Velocity scores should already be de-noised of internal transfers and cycle counts upstream; this job assumes the feed produced by how to classify SKUs by inventory velocity as its input.

Configuration Block

Externalize every tunable so re-tiering never requires a code deploy. The percentile cutoffs and the capacity floor are the two levers you will actually turn during peak; keep them versioned and never recompute them mid-wave.

# seasonal_abc.yaml
seasonal_abc:
  decay_alpha: 0.7               # EWMA weight on the most recent week (0.7 recent / 0.3 history)
  cv_seasonal_threshold: 0.85    # coefficient of variation above this flags a SKU as seasonal
  a_percentile: 85               # top ~15% of peak-adjusted velocity -> tier A
  b_percentile: 55               # next ~30% -> tier B
  min_a_picks: 12.0              # capacity floor; the A cutoff never drops below this picks/day
  hysteresis_pct: 0.10           # +/-10% dead-band a SKU must clear before it is reclassified
  seasonal_index_clip: [0.4, 2.5]  # clamp the index so shoulder-month dips can't skew a tier
# Equivalent Python dict consumed by the function
SEASONAL_ABC = {
    "decay_alpha": 0.7,
    "cv_seasonal_threshold": 0.85,
    "a_percentile": 85,
    "b_percentile": 55,
    "min_a_picks": 12.0,
    "hysteresis_pct": 0.10,
    "seasonal_index_clip": (0.4, 2.5),
}

min_a_picks is the safety valve that keeps the whole scheme honest. In a deep off-season the 85th-percentile of adjusted velocity can fall below the throughput that actually warrants prime real estate; clamping the A cutoff to a capacity floor stops the algorithm from promoting genuinely slow SKUs just because everything around them is slower. seasonal_index_clip does the reverse job — it prevents a single anomalous peak or a near-zero shoulder month from inflating the index into a runaway multiplier.

Implementation

The function ingests the weekly table plus the config, masks stockouts, builds a smoothed velocity signal, derives a per-SKU seasonal index, and classifies the latest snapshot with hysteresis against the previous cycle’s tiers. It uses type hints, a dataclass for the result, and logs every seasonal flag and threshold it computes.

import logging
from dataclasses import dataclass
import pandas as pd
import numpy as np

logger = logging.getLogger("slotting.seasonal_abc")


@dataclass(frozen=True)
class TierResult:
    sku_id: str
    peak_adjusted_velocity: float
    is_seasonal: bool
    abc_class: str


def compute_seasonal_abc(
    velocity_df: pd.DataFrame,
    cfg: dict,
    prev_class: dict[str, str] | None = None,
) -> list[TierResult]:
    """Assign seasonal-adjusted ABC tiers to the latest week per SKU.

    velocity_df must contain: ['sku_id', 'week_ending', 'picks', 'is_stockout'].
    Hysteresis holds a SKU in its prior tier until it clears the dead-band.
    """
    prev_class = prev_class or {}
    lo, hi = cfg["seasonal_index_clip"]
    df = velocity_df.sort_values(["sku_id", "week_ending"]).copy()

    # 1. Mask stockouts so suppressed demand is not read as a slow week.
    df.loc[df["is_stockout"], "picks"] = np.nan

    # 2. Smooth to an EWMA velocity, then derive a mean-1 seasonal index.
    g = df.groupby("sku_id")["picks"]
    df["velocity"] = g.transform(lambda x: x.ewm(alpha=cfg["decay_alpha"], adjust=False).mean())
    baseline = df.groupby("sku_id")["velocity"].transform("mean").replace(0, np.nan)
    idx = (df["velocity"] / baseline)
    df["seasonal_index"] = idx.groupby(df["sku_id"]).transform(lambda x: (x / x.mean()).clip(lo, hi))
    df["peak_adjusted_velocity"] = (df["velocity"] * df["seasonal_index"]).fillna(0.0)

    # 3. Flag seasonal SKUs by coefficient of variation over their history.
    cv = df.groupby("sku_id")["picks"].transform(
        lambda x: x.std(ddof=1) / x.mean() if x.notna().sum() > 1 and x.mean() else 0.0
    )
    df["is_seasonal"] = cv > cfg["cv_seasonal_threshold"]

    # 4. Capacity-anchored cutoffs from the adjusted-velocity distribution.
    latest = df.groupby("sku_id").tail(1)
    pav = latest["peak_adjusted_velocity"]
    a_thr = max(np.percentile(pav, cfg["a_percentile"]), cfg["min_a_picks"])
    b_thr = np.percentile(pav, cfg["b_percentile"])
    logger.info("thresholds: A>=%.2f (floor %.1f), B>=%.2f", a_thr, cfg["min_a_picks"], b_thr)

    # 5. Classify the latest snapshot with a hysteresis dead-band.
    h = cfg["hysteresis_pct"]
    results: list[TierResult] = []
    for row in latest.itertuples():
        v, prior = row.peak_adjusted_velocity, prev_class.get(row.sku_id, "C")
        a_gate = a_thr * (1 - h) if prior == "A" else a_thr
        b_gate = b_thr * (1 - h) if prior in ("A", "B") else b_thr
        tier = "A" if v >= a_gate else "B" if v >= b_gate else "C"
        if row.is_seasonal:
            logger.info("seasonal SKU %s: pav=%.2f prior=%s -> %s", row.sku_id, v, prior, tier)
        results.append(TierResult(row.sku_id, round(v, 2), bool(row.is_seasonal), tier))
    return results
Seasonal ABC threshold pipeline: from weekly picks to a hysteresis-damped tier Weekly pick history carrying a per-SKU stockout flag enters a stockout mask that nulls picks on any flagged week, then an EWMA smoother weighted by decay_alpha, then a seasonal-index step that divides each SKU by its own long-run mean and clamps the result to the range 0.4 to 2.5. The product is a peak-adjusted-velocity distribution for the latest week, from which capacity-anchored cutoffs are drawn: the A boundary is the higher of the 85th percentile and a min_a_picks capacity floor, and B is the 55th percentile. A hysteresis dead-band then lowers the re-entry gate by ten percent for a SKU already in that tier, holding it in place before emitting a per-SKU A, B, or C class to the WMS. Two callouts annotate the min_a_picks floor branch on the A cutoff and the hold-in-prior-tier branch of the hysteresis step. Seasonal ABC: mask → smooth → adjust → anchor cutoffs → damp → tier Weekly Pick History picks · is_stockout · 52 wk Stockout Mask picks → NaN on flag EWMA Velocity decay_alpha 0.7 Seasonal Index ÷ own mean · clip [0.4, 2.5] Peak-Adjusted Velocity distribution · latest week Capacity-Anchored Cutoffs A = p85 · B = p55 Hysteresis Dead-Band vs prior-cycle tier ABC Class → WMS per SKU: A / B / C min_a_picks floor A = max( p85, 12 picks/day ) slow season can raise A, never lower it hold in prior tier gate × (1 − 0.10) if already there must clear the dead-band to move The seasonal index scores a SKU on its in-season peak; the floor and dead-band keep tiers honest and stop A↔B thrash.

Step-by-Step Walkthrough

  1. Mask stockouts. Any week flagged is_stockout has its picks set to NaN before smoothing (config-independent, block 1). A zero-availability week is unknown demand, not low demand — leaving it in would drag the smoothed velocity down and demote a fast-mover the week its bin ran dry.
  2. Smooth with decay_alpha. Block 2 builds an exponentially weighted velocity so a single promotional spike doesn’t jump a SKU a tier, while a genuine ramp still moves it. A 0.7 alpha weights the current week at 70%.
  3. Derive the seasonal index. Each SKU’s smoothed velocity is divided by its own long-run mean and renormalized to a mean of 1.0, then clamped to seasonal_index_clip. Multiplying velocity by this index yields peak_adjusted_velocity, so an off-season SKU is scored on what it does when in season, not on its current trough.
  4. Flag seasonality by CV. Block 3 computes each SKU’s coefficient of variation over its history; anything above cv_seasonal_threshold is marked is_seasonal for logging and downstream family segmentation. This is a label, not a gate — the adjustment already applied to everyone.
  5. Anchor the cutoffs to capacity. Block 4 takes the A cutoff as the higher of the a_percentile of the adjusted-velocity distribution and min_a_picks. The floor is what stops a slow off-season from promoting SKUs that don’t clear real throughput.
  6. Apply hysteresis. Block 5 lowers the entry gate by hysteresis_pct only for a SKU already in that tier, so an item must fall a full dead-band below the boundary before it is demoted — killing the A→B→A oscillation that spawns relocation labor exceeding the travel it saves.

Verification

Assert the two invariants that matter before pushing tiers to the WMS: a seasonal SKU currently in its trough is not demoted below its earned tier, and no SKU below the capacity floor is promoted to A. The check builds a tiny two-SKU history — one steady, one sharply seasonal — and confirms both.

logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s")

weeks = pd.date_range("2025-01-05", periods=52, freq="W")
steady = pd.DataFrame({"sku_id": "STEADY", "week_ending": weeks,
                       "picks": 20.0, "is_stockout": False})
# Seasonal SKU: dead most of the year, huge Q4 peak, currently mid-peak.
peaky = 2.0 + 60.0 * (weeks.month.isin([11, 12])).astype(float)
seasonal = pd.DataFrame({"sku_id": "PEAKY", "week_ending": weeks,
                         "picks": peaky, "is_stockout": False})

df = pd.concat([steady, seasonal], ignore_index=True)
# Pretend PEAKY was already an A last cycle; hysteresis should hold it.
results = compute_seasonal_abc(df, SEASONAL_ABC, prev_class={"PEAKY": "A"})

by_sku = {r.sku_id: r for r in results}
assert by_sku["PEAKY"].is_seasonal, "high-CV SKU must be flagged seasonal"
assert by_sku["PEAKY"].abc_class == "A", "seasonal SKU held in A by adjustment + hysteresis"
for r in results:
    logging.info("%s -> %s (pav=%.1f, seasonal=%s)",
                 r.sku_id, r.abc_class, r.peak_adjusted_velocity, r.is_seasonal)

Sample expected output:

INFO | thresholds: A>=12.00 (floor 12.0), B>=... 
INFO | seasonal SKU PEAKY: pav=... prior=A -> A
INFO | STEADY -> B (pav=20.0, seasonal=False)
INFO | PEAKY -> A (pav=..., seasonal=True)

PEAKY clears the A gate because the seasonal index scales its in-peak velocity upward and the prior-A hysteresis lowers its re-entry bar; STEADY lands in B because its flat 20/week never reaches the A cutoff. If PEAKY were demoted here, either the index clamp is too tight or the stockout mask is dropping its peak weeks.

Common Pitfalls

  • Normalizing the seasonal index over too short a window. Deriving the index from a 13-week trailing slice instead of a full annual cycle lets a shoulder-month dip skew the baseline and over-allocate A slots. Normalize across a full 52 weeks and keep the seasonal_index_clip clamp on.
  • Running hysteresis without persisting prior state. If prev_class resets to empty on every batch restart, every SKU is treated as a new C entrant and the dead-band never engages — thrash returns silently. Cache the prior tiers in Redis or DynamoDB and load them before the run.
  • Feeding a bimodal, mixed-family distribution into one percentile cut. When product families with divergent demand profiles share a threshold pass, the percentiles land between the two modes and mis-tier both. Pre-segment by affinity group first — see grouping complementary products for faster picking — then classify within each group.
  • Treating a zero-velocity SKU as a real C. A dead SKU with no picks yields a degenerate seasonal index; route zero-velocity items to a dead-stock pipeline before ABC evaluation rather than letting a NaN-fallback promote or bury them.

FAQ

Why adjust velocity by a seasonal index instead of just widening the evaluation window?

A longer flat window averages a seasonal SKU’s dead months into its peak, permanently understating it — a holiday item scored on a 52-week mean looks like a slow C all year. The seasonal index instead measures each SKU against its own rhythm, so an in-season SKU is scored on what it does when it moves, and the tier tracks the demand curve rather than lagging a quarter behind it.

How large should the hysteresis dead-band be?

Size it to your physical re-slotting cost, not a round number. A 0.10 band means a SKU must fall 10% below a boundary before it is demoted; widen it when relocation labor is expensive or the pick-face is congested, narrow it when re-slotting is cheap and you want tiers to track demand tightly. Pair any change with a minimum dwell time so a SKU can’t transition twice in one week.

Does the capacity floor override the percentile cut entirely?

Only on the A boundary, and only upward. min_a_picks is applied as max(percentile, floor), so it can raise the A cutoff during a slow season but never lower it during a busy one. This keeps prime real estate reserved for SKUs that clear genuine throughput while still letting the distribution set the bar when demand is high.