Slotting Architecture · 11 min read

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.

Exponential decay weight versus a flat rolling window A chart with SKU age in days on the horizontal axis from 0 to 120 and pick weight on the vertical axis from 0.0 to 1.0. The exponential decay curve starts at weight 1.0 at age 0 and falls smoothly, passing through weight 0.5 at the 30-day half-life, weight 0.25 at 60 days, and approaching zero by 120 days. A dashed guide marks the half-life point where the weight equals one half. Overlaid for contrast, the flat 60-day rolling window holds every pick at weight 1.0 from age 0 to 60 days, then drops vertically to zero, weighting a 5-day-old pick and a 59-day-old pick identically before discarding everything older. Pick weight vs. SKU age — decay curve vs. flat window 1.0 0.5 0.0 0 30 60 90 120 SKU age (days since pick) half-life 30d → w = 0.5 exponential decay w = 0.5^(age/half_life) flat 60-day rolling window

Prerequisites

Confirm each of these before wiring decay weighting into your velocity engine:

  • Python 3.10+ — the implementation uses X | None unions, list[...] generics, and a dataclass record.
  • A clean pick-history feed — normalized PickEvent rows 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_of reference date — the anchor the age is measured against, normally today at the recalculation run. Keep it explicit rather than calling date.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

  1. Model the input as a frozen record. PickRecord carries only what the score needs — sku_id, pick_date, and qty_picked. Keeping it immutable means the same rows can be re-scored against different half_life_days values with no risk of mutation between runs.
  2. Measure age against an explicit as_of. age_days = (as_of - p.pick_date).days uses 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.
  3. Apply the half-life weight. 0.5 ** (age_days / half_life_days) is the core: age equal to half_life_days yields 0.5, two half-lives yield 0.25, and so on. Because it multiplies qty_picked, a large recent order still dominates a scatter of old singles.
  4. Clamp on floor. Any weight below the configured floor is dropped. With floor = 0.02 and 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.
  5. Emit the score with logging. The logger.info line 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_days to 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 floor set too low. Push floor toward 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. Keep floor around 0.020.05 so 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.