ABC vs ABCD Velocity Tiers: When to Use Four Bands
You have a working three-band ABC scheme, and someone asks whether a fourth band would slot the warehouse better. The honest answer is “sometimes” — a fourth cut buys real placement precision on some SKU distributions and buys nothing but reclassification thrash on others. This page gives you the decision criteria for choosing between 3-band ABC and 4-band ABCD, defines exactly what the D band should isolate, and hands you one config-driven classifier that produces either scheme from a band list. It is the “how many bands” question inside the ABC Classification Tuning guide, which sits under the Location Assignment & ABC Classification Algorithms architecture.
What the D Band Actually Buys You
ABC collapses every SKU into three velocity tiers: A (fastest, gets the golden zone), B (moderate), C (everything slow). The problem is that C is a dumping ground. A live-but-slow SKU picked twice a week and a dead SKU untouched for six months both land in C, yet they demand opposite placement: the slow-but-live item still needs an accessible reserve slot, while the dead item should be swept to bulk overflow or flagged for de-stocking. ABCD adds a fourth cut so those two populations stop sharing bins.
There are two conventions for where the D band sits, and you must pick one explicitly:
- D as the dead-stock tail (the common case). D splits the bottom of C into slow-but-live © and near-dead / obsolete (D). This is the convention this page defaults to, because it directly drives a de-stocking and reserve-consolidation decision that a flat C hides.
- D as the hyper-mover head (the rarer case). Some high-throughput e-commerce nodes carve a D band above A for a handful of hyper-velocity SKUs that justify dedicated pick-to-belt or forward-pick automation. Same machinery, opposite end of the curve.
The classifier below does not care which convention you use — the band list is just a partition of the cumulative-velocity axis, so you point the extra cut wherever your operation needs it.
The Decision: Three Bands or Four
Add the D band only when the tail is both large and operationally distinct. Four criteria decide it:
| Criterion | Favors 3-band ABC | Favors 4-band ABCD |
|---|---|---|
| SKU count | Under ~2,000 active SKUs; a 4th band splits into groups too small to slot differently | Tens of thousands of SKUs, where even the tail holds hundreds of items |
| Pareto steepness | Shallow curve — velocity is spread evenly, so extra cuts land on arbitrary boundaries | Steep curve with a long flat tail; the tail is a genuine population, not a rounding error |
| Golden-zone scarcity | Prime pick face is abundant relative to A-count; fine tail distinctions do not change placement | Prime slots are scarce and contested; you need to evict dead stock from reserve to free B/C bins |
| Dead-stock volume | Obsolete stock is swept by a separate FSN/obsolescence job already | No obsolescence process exists; the D band becomes your de-stocking trigger |
The cost of the extra band is not free. Each additional boundary is one more line SKUs can oscillate across, so a 4-band scheme generates more reclassification events — and therefore more re-slotting labor — than a 3-band one on the same data. It also adds a slot-policy branch to maintain everywhere downstream. If your operation cannot act differently on a D-band SKU than on a C-band SKU (different reserve strategy, de-stocking queue, or automation lane), the fourth band is pure complexity with no payback. Band count is a velocity-taxonomy decision, so align it with the scheme you set in SKU Velocity Taxonomy Design rather than choosing it in isolation.
Prerequisites
- Python 3.10+ — the classifier uses
list[...]generics,X | Noneunions, and frozendataclassrecords. - A per-SKU velocity score — a single
floatper SKU, already blended and normalized. The classifier consumes scores, not raw pick counts; produce them with the composite scoring in the parent tuning guide. - A chosen convention for D — dead-stock tail or hyper-mover head — decided before you write the band list.
- Capacity numbers — the pick-face and reserve counts that anchor each band’s cumulative-share ceiling, so the cuts reflect physical slots rather than a textbook 80/20.
Configuration Block
Both schemes are just a list of bands, each closing a slice of the cumulative-velocity axis. Keep both lists in one profile and select one with scheme; the final band must close the axis at 1.00.
# velocity_bands.yaml — one profile per facility; select a scheme by name
scheme: abcd # abc | abcd
schemes:
abc:
- {label: A, max_cumulative: 0.70} # golden-zone tier
- {label: B, max_cumulative: 0.90} # mid reserve
- {label: C, max_cumulative: 1.00} # everything slow (dead stock hidden here)
abcd:
- {label: A, max_cumulative: 0.70} # golden-zone tier (shared cut)
- {label: B, max_cumulative: 0.90} # mid reserve (shared cut)
- {label: C, max_cumulative: 0.98} # slow-but-live tail
- {label: D, max_cumulative: 1.00} # near-dead / obsolete -> de-stock queue
# Equivalent Python config dict consumed by the classifier
VELOCITY_BANDS = {
"scheme": "abcd",
"schemes": {
"abc": [
{"label": "A", "max_cumulative": 0.70},
{"label": "B", "max_cumulative": 0.90},
{"label": "C", "max_cumulative": 1.00},
],
"abcd": [
{"label": "A", "max_cumulative": 0.70},
{"label": "B", "max_cumulative": 0.90},
{"label": "C", "max_cumulative": 0.98},
{"label": "D", "max_cumulative": 1.00},
],
},
}
Implementation
One function drives either scheme. It sorts SKUs by descending velocity, walks the cumulative-share axis, and assigns each SKU to the first band whose ceiling it has not yet passed. Because the scheme is entirely in the band list, switching from ABC to ABCD is a config change, not a code change.
from __future__ import annotations
import logging
from dataclasses import dataclass
logger = logging.getLogger("velocity.banding")
@dataclass(frozen=True)
class Band:
"""One velocity band closing a slice of the cumulative-share axis."""
label: str
max_cumulative: float # inclusive upper bound in [0, 1]
@dataclass(frozen=True)
class BandedSku:
sku_id: str
velocity_score: float
cumulative_share: float
band: str
def classify_bands(
scored: list[tuple[str, float]],
bands: list[Band],
) -> list[BandedSku]:
"""Cut velocity-scored SKUs into config-driven bands (ABC, ABCD, or any N).
`scored` is a list of (sku_id, velocity_score). `bands` is an ordered list
whose ``max_cumulative`` ceilings partition [0, 1]; the final band must
close the axis at 1.0. The same call produces 3-band ABC or 4-band ABCD —
only the band list changes.
"""
if not bands or abs(bands[-1].max_cumulative - 1.0) > 1e-9:
raise ValueError("final band must close the axis at max_cumulative=1.0")
ordered = sorted(scored, key=lambda row: row[1], reverse=True)
total = sum(score for _, score in ordered)
if total <= 0:
raise ValueError("total velocity must be positive")
result: list[BandedSku] = []
counts: dict[str, int] = {b.label: 0 for b in bands}
running = 0.0
for sku_id, score in ordered:
running += score
share = running / total
band = next(b.label for b in bands if share <= b.max_cumulative + 1e-9)
counts[band] += 1
result.append(BandedSku(sku_id, score, share, band))
logger.info(
"Classified %d SKUs into %d bands (%s): %s",
len(result), len(bands), "/".join(b.label for b in bands), counts,
)
return result
Step-by-Step Walkthrough
- Validate the axis closes. The guard rejects any band list whose last
max_cumulativeis not1.0. A list that stops at0.98would leave the dead-stock tail unclassified — exactly the SKUs the D band exists to catch — so this fails fast rather than silently dropping them. - Sort descending, sum the total. SKUs are ordered fastest-first and
totalis the denominator for cumulative share. A non-positive total (an empty or zero-velocity window) raises rather than dividing by zero. - Walk the cumulative axis. For each SKU,
runningaccumulates its score andshareis the fraction of total velocity consumed so far. This is the y-axis of the Pareto curve in the figure above. - Assign to the first unclosed band.
next(b.label for b in bands if share <= b.max_cumulative)picks the first band whose ceiling the running share has not exceeded. Underabca share of0.99lands in C; underabcdthe same share lands in D. The+ 1e-9tolerance keeps the boundary SKU on the correct side under float rounding. - Log the band histogram. The
countsdict reports how many SKUs fell in each band. Watch it across runs — a D band holding a handful of SKUs is telling you the fourth cut is not earning its keep.
Verification
Drive the same 200-SKU distribution through both schemes and confirm the invariants: every SKU is banded, the shared A and B cuts produce identical counts, and ABCD’s C splits cleanly into C plus D.
import logging
logging.basicConfig(level=logging.INFO)
# Synthetic Pareto scores: geometric decay -> a few fast movers, a long slow tail.
scored = [(f"SKU{i}", 0.8 ** i) for i in range(200)]
abc = [Band(**b) for b in VELOCITY_BANDS["schemes"]["abc"]]
abcd = [Band(**b) for b in VELOCITY_BANDS["schemes"]["abcd"]]
out_abc = classify_bands(scored, abc)
out_abcd = classify_bands(scored, abcd)
def counts(rows: list[BandedSku]) -> dict[str, int]:
tally: dict[str, int] = {}
for r in rows:
tally[r.band] = tally.get(r.band, 0) + 1
return tally
c_abc, c_abcd = counts(out_abc), counts(out_abcd)
# Shared cuts: A and B populations are identical across schemes.
assert c_abc["A"] == c_abcd["A"]
assert c_abc["B"] == c_abcd["B"]
# ABCD only re-partitions the C tail — A+B+C+D must reconcile to ABC's C.
assert c_abcd["C"] + c_abcd["D"] == c_abc["C"]
print("ABC :", c_abc)
print("ABCD:", c_abcd)
Sample expected output:
INFO:velocity.banding:Classified 200 SKUs into 3 bands (A/B/C): {'A': 5, 'B': 5, 'C': 190}
INFO:velocity.banding:Classified 200 SKUs into 4 bands (A/B/C/D): {'A': 5, 'B': 5, 'C': 7, 'D': 183}
ABC : {'A': 5, 'B': 5, 'C': 190}
ABCD: {'A': 5, 'B': 5, 'C': 7, 'D': 183}
The output makes the decision concrete: ABC buries 190 SKUs in one undifferentiated C band, while ABCD peels off 183 near-dead SKUs into D — a population that large is worth a separate de-stock and reserve policy, which is exactly the signal that the fourth band pays off here.
Common Pitfalls
- Splitting a tail that is too small to act on. If D holds a dozen SKUs, you have added a boundary, a slot policy, and reclassification churn to move almost nothing. Check the band histogram: unless D is a population you route differently, drop back to three bands.
- Cutting by SKU count instead of velocity. “Bottom 10% of SKUs” is not the same as “bottom 2% of velocity.” Anchor
max_cumulativeon cumulative velocity share so the cut tracks throughput, and let the SKU count fall where the Pareto curve puts it. - Adding bands without adding hysteresis. Every new boundary is a new line to oscillate across. A 4-band scheme without a tolerance band thrashes more than a 3-band one; carry the hysteresis gate from the parent tuning guide onto all boundaries, not just A|B.
- Ignoring which end D lives on. Copy-pasting a dead-stock band list into a hyper-mover operation puts the extra precision at the wrong end of the curve. Fix the convention first, then write the ceilings.
Related
- ABC Classification Tuning — the parent guide: composite scoring, capacity-anchored thresholds, and the hysteresis gate every band boundary needs.
- SKU Velocity Taxonomy Design — where the band count and velocity definition belong as one taxonomy decision.
- Location Assignment & ABC Classification Algorithms — the parent architecture these velocity tiers feed into placement.