How to Implement Exponential Decay Velocity Weighting
A flat rolling window treats every pick inside it as equal: a sale from 89 days ago counts exactly as much as one from this morning, and the instant a pick crosses the window edge it drops to zero. That step function is the wrong shape for demand. Velocity trends are continuous — a SKU accelerating this week should outrank one that was hot in March and has since cooled, even if both have the same raw count over the window. This page replaces the flat window with a half-life exponential decay: every pick is weighted by w = 0.5 ** (age_days / half_life_days), so a pick exactly one half-life old counts half as much as a same-day pick, and influence tapers smoothly toward zero instead of falling off a cliff. This is the recency-weighting technique that sharpens the SKU Velocity Taxonomy Design layer, itself part of the wider Core Slotting Architecture & Velocity Taxonomies architecture.
Prerequisites
Confirm each of these before wiring decay weighting into your velocity engine:
- Python 3.10+ — the implementation uses
X | Noneunions,list[...]generics, and adataclassrecord. - A clean pick-history feed — normalized
PickEventrows with a timezone-consistent pick date, produced and validated upstream by the Velocity Data Ingestion & WMS Sync Pipelines layer. Decay amplifies recent data, so a feed gap in the last half-life distorts the score more than an old one. - A chosen
as_ofreference date — the anchor the age is measured against, normally today at the recalculation run. Keep it explicit rather than callingdate.today()inside the scoring function, so historical backfills are reproducible. - A half-life estimate for this facility — start at 30 days for stable staples, 7–14 days for fashion or promotional catalogs. This is the single most consequential parameter; treat it as a tuning target, not a constant.
Configuration Block
Two levers control the whole behaviour: half_life_days sets how fast influence fades, and floor clamps negligible weights to zero so a decade of ancient picks cannot accrete a phantom score.
# decay_weighting.yaml — one profile per catalog segment
decay_weighting:
half_life_days: 30.0 # age at which a pick's weight halves; lower = more reactive
floor: 0.02 # weights below this are dropped (0.02 ≈ 5.6 half-lives old)
recalc_cadence: nightly # decay is continuous, so re-score on every ingestion cycle
# Equivalent Python config dict consumed by the scorer
DECAY_WEIGHTING = {
"half_life_days": 30.0,
"floor": 0.02,
"recalc_cadence": "nightly",
}
Implementation
The scorer is one pure function over a SKU’s pick lines. Each pick contributes qty_picked * w, where w decays geometrically with age. Weights below floor are skipped entirely, which also bounds the work: for a 30-day half-life, a weight of 0.02 is reached at roughly 168 days, so anything older is irrelevant and can be filtered before it ever reaches this function.
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
logger = logging.getLogger("velocity.decay")
@dataclass(frozen=True)
class PickRecord:
"""One confirmed pick line for a SKU, the atomic input to decay weighting."""
sku_id: str
pick_date: date
qty_picked: int
def decayed_velocity(
picks: list[PickRecord],
as_of: date,
half_life_days: float = 30.0,
floor: float = 0.02,
) -> float:
"""Compute a recency-weighted velocity score using half-life exponential decay.
Each pick contributes ``qty_picked * w`` where ``w = 0.5 ** (age_days / half_life_days)``.
A pick exactly ``half_life_days`` old counts half as much as a same-day pick; weights
below ``floor`` are treated as zero so ancient history cannot accrete a phantom score.
"""
if half_life_days <= 0:
raise ValueError("half_life_days must be positive")
score = 0.0
counted = 0
for p in picks:
age_days = (as_of - p.pick_date).days
if age_days < 0:
logger.warning("skip future-dated pick %s (%s > %s)", p.sku_id, p.pick_date, as_of)
continue
weight = 0.5 ** (age_days / half_life_days)
if weight < floor:
continue
score += p.qty_picked * weight
counted += 1
logger.info(
"sku=%s decayed_velocity=%.3f from %d/%d picks (half_life=%.0fd)",
picks[0].sku_id if picks else "?", score, counted, len(picks), half_life_days,
)
return score
Step-by-Step Walkthrough
- Model the input as a frozen record.
PickRecordcarries only what the score needs —sku_id,pick_date, andqty_picked. Keeping it immutable means the same rows can be re-scored against differenthalf_life_daysvalues with no risk of mutation between runs. - Measure age against an explicit
as_of.age_days = (as_of - p.pick_date).daysuses the passed reference date, not the wall clock, so a nightly job and a historical backfill produce identical numbers. A negative age (a future-dated confirmation) is logged and skipped rather than silently inflating the score. - Apply the half-life weight.
0.5 ** (age_days / half_life_days)is the core: age equal tohalf_life_daysyields0.5, two half-lives yield0.25, and so on. Because it multipliesqty_picked, a large recent order still dominates a scatter of old singles. - Clamp on
floor. Any weight below the configuredflooris dropped. Withfloor = 0.02and a 30-day half-life, picks older than ~168 days contribute nothing, so the score stays responsive to the trailing quarter instead of dragging a stale historical tail. - Emit the score with logging. The
logger.infoline records the final score and how many of the SKU’s picks actually cleared the floor — a fast diagnostic for a cold-start SKU (few counted picks) versus a mature one.
The resulting velocity_score is a drop-in replacement for a raw window count anywhere downstream. It feeds directly into ABC Classification Tuning, where the cut into A/B/C tiers is only as good as the velocity signal underneath it — a decay-weighted score sharpens tier boundaries precisely because it reflects current momentum rather than a flat historical average.
Verification
Assert the arithmetic directly: a same-day pick keeps full weight, a pick exactly one half-life old contributes half, and an ancient pick falls below the floor and is dropped. This runs with no WMS attached.
import logging
from datetime import date
logging.basicConfig(level=logging.INFO)
today = date(2025, 6, 1)
picks = [
PickRecord("SKU-1", date(2025, 6, 1), qty_picked=10), # age 0 -> w = 1.0
PickRecord("SKU-1", date(2025, 5, 2), qty_picked=10), # age 30 -> w = 0.5
PickRecord("SKU-1", date(2024, 1, 1), qty_picked=99), # ~517d -> below floor, dropped
]
score = decayed_velocity(picks, as_of=today, half_life_days=30.0, floor=0.02)
print(f"{score:.2f}") # 10*1.0 + 10*0.5 = 15.00
assert abs(score - 15.0) < 0.05
Sample expected output:
INFO:velocity.decay:sku=SKU-1 decayed_velocity=15.000 from 2/3 picks (half_life=30d)
15.00
The 517-day-old pick of 99 units contributes nothing — its weight 0.5 ** (517/30) ≈ 6.5e-6 is far below the 0.02 floor — which is exactly the point of decay: a huge order from last year cannot outweigh two modest picks from this month.
Common Pitfalls
- Half-life too short = thrash. Dropping
half_life_daysto 3–5 makes the score jump on every busy day, and if that score feeds tier assignment, SKUs oscillate across the A/B boundary and generate re-slotting labor that never pays back. Keep the half-life longer than your recalculation cadence, and damp downstream classification with a hysteresis band rather than reacting to raw daily swings. - Ignoring seasonality. Decay assumes recent means representative, which is false for seasonal SKUs — a decay score computed in July will demote a December-peaking item right before its season. Flag high-variability SKUs (coefficient of variation above ~0.85) out of the plain decay path and score them against a year-over-year index instead.
- Cold-start SKUs read as dead. A newly introduced SKU has almost no history, so its decay score is near zero and it lands in C, starving the very item you are trying to launch. Bootstrap new SKUs with a category-median score or a manual velocity floor for their first few weeks until real picks accumulate.
- A
floorset too low. Pushfloortoward zero and you re-introduce the stale history decay was meant to remove: every ancient pick contributes a sliver, the per-SKU loop scans the entire history, and stale demand leaks back into the score. Keepflooraround0.02–0.05so the effective window self-truncates at roughly five to six half-lives.
FAQ
How is this different from an EWMA or a simple moving average?
A simple moving average is the flat window this page replaces — equal weight inside, zero outside. An exponentially weighted moving average (EWMA) and half-life decay are the same family: an EWMA’s smoothing factor alpha maps to a half-life via half_life = ln(2) / -ln(1 - alpha). The half-life form used here is easier to reason about operationally because the parameter is stated in days — “how old before a pick counts half” — rather than an abstract alpha, which matters when a warehouse manager, not a data scientist, is tuning it.
What half-life should I start with?
Anchor it to how fast demand actually turns over in your catalog. Stable industrial or grocery staples tolerate 30–45 days; apparel, electronics, and promotional lines need 7–14 so the score keeps up with launches and markdowns. Set it once per catalog segment, watch the count of accepted tier changes per recalculation, and lengthen the half-life if you see churn or shorten it if the score lags a visible demand shift.
Related
- SKU Velocity Taxonomy Design — the parent guide defining what velocity means before you weight it by recency.
- How to Classify SKUs by Inventory Velocity — the end-to-end classification this weighted score feeds into.
- ABC Classification Tuning — where the recency-weighted score is cut into capacity-anchored A/B/C tiers.
- Core Slotting Architecture & Velocity Taxonomies — the parent architecture this velocity signal sits inside.