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.0andnumpy>=1.24— the implementation relies onDataFrameGroupBy.transformandSeries.ewm. - A weekly velocity table — one row per
sku_idperweek_ending, carryingpicks(units or lines) and a booleanis_stockoutflag. 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]ofsku_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
Step-by-Step Walkthrough
- Mask stockouts. Any week flagged
is_stockouthas itspicksset toNaNbefore 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. - 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. A0.7alpha weights the current week at 70%. - 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 toseasonal_index_clip. Multiplying velocity by this index yieldspeak_adjusted_velocity, so an off-season SKU is scored on what it does when in season, not on its current trough. - Flag seasonality by CV. Block 3 computes each SKU’s coefficient of variation over its history; anything above
cv_seasonal_thresholdis markedis_seasonalfor logging and downstream family segmentation. This is a label, not a gate — the adjustment already applied to everyone. - Anchor the cutoffs to capacity. Block 4 takes the A cutoff as the higher of the
a_percentileof the adjusted-velocity distribution andmin_a_picks. The floor is what stops a slow off-season from promoting SKUs that don’t clear real throughput. - Apply hysteresis. Block 5 lowers the entry gate by
hysteresis_pctonly 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_clipclamp on. - Running hysteresis without persisting prior state. If
prev_classresets 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.
Related
- ABC Classification Tuning — the parent layer that owns composite scoring, capacity-anchored thresholds, and validation this page specializes for seasonality.
- Threshold Optimization for Re-Slotting — decides when a tier change is worth the physical move these thresholds trigger.
- Grouping Complementary Products for Faster Picking — pre-segment mixed-demand families before running a single percentile cut.
- Weight & Volume Constraint Modeling — validate cube and weight fit before committing a promoted SKU to an A slot.