MCM520 ← 资料站首页 基于清洗—RFM 分群—Holt-Winters 预测—报童补货的产品订单分析与需求预测(优秀范文一) 打开交互阅读器 →

基于清洗—RFM 分群—Holt-Winters 预测—报童补货的产品订单分析与需求预测(优秀范文一)

摘要:本文面向「产品订单分析与需求预测」赛题,对 4326 条原始订单实施显式清洗(剔除退单 15 条、测试单 8 条、指纹判重 6 条),得到 4297 条有效订单、总销售额 255.06 万元。探索分析显示月销售额温和上行(92,992 元 → 100,069 元),促销月均销为平常月的 1.55 倍,家电品类以 55.2% 销售额占比主导。客户维度以 RFM 三指标标准化后作 KMeans 聚类(k-means++ 初始化、10 次重启取最优),轮廓系数 0.830,得到清晰的三层客群:高价值客户 10 家(人均 186.6 单、109,268 元)、流失风险客户 14 家(近购间隔 7.9 个月)、主力普通客户 36 家。需求预测采用 Holt-Winters 乘法季节模型与验证型选参,留出最后 3 个月测试:食品 MAPE 12.6% 显著优于朴素基线(32.1%),服饰与季节朴素打平(18.1% 对 17.3%),家电因需求由促销脉冲而非平滑季节驱动而劣于朴素基线——揭示「把脉冲当季节」的方法误配风险。基于残差 σ 给出 95% 区间,并接入报童模型形成下月补货建议(食品 388 件、服饰 136 件、家电 87 件)。全文纯标准库实现、固定种子可复现,正文、配图、附录、真源四路数字一致。

关键词:数据清洗;RFM;KMeans;轮廓系数;Holt-Winters;MAPE;报童模型

一、问题重述

给定订单明细(日期、客户、品类、数量、单价、金额、地区),需完成:

  1. 异常订单识别与清洗,形成可信的订单面板;
  2. 销售探索分析:趋势、促销效应、品类结构;
  3. 客户分群:识别高价值与流失风险群体;
  4. 品类级需求预测:点预测与区间,并与朴素基线诚实对比;
  5. 结合预测误差给出下月补货建议。

二、模型假设

  1. 订单中负数量记录为退单、固定测试账户记录为测试数据,均不代表真实需求;
  2. 客户价值可由 RFM 三维(最近购买间隔、购买频次、累计金额)线性空间刻画;
  3. 品类月需求服从「水平 × 趋势 × 季节因子」的乘法结构,噪声围绕模型拟合残差近似正态;
  4. 补货决策仅权衡缺失销量与超储持有成本,服务水平设定为 95%。

三、符号说明

符号 含义
Ri,Fi,MiR_i,F_i,M_i 客户 ii 的最近购买间隔、频次、累计金额
lt,bt,stl_t,b_t,s_t Holt-Winters 的水平、趋势、季节因子
α,β,γ\alpha,\beta,\gamma 三个平滑系数(网格搜索确定)
σ^\hat\sigma 拟合残差的标准差
Q∗Q^* 报童模型建议补货量

四、数据清洗与探索分析(问题 1、2)

原始表 4326 条。清洗执行三条显式规则:①数量 ≤ 0 判为退单,剔除 15 条;②测试账户 TST 全部剔除,共 8 条;③按「日期+客户+品类+数量+金额」指纹判重,剔除 6 条。清洗后得有效订单 4297 条,总销售额 2,550,643 元。整体框架如图1。

图1 订单分析与需求预测五阶段框架

图2 显示月销售额由 92,992 元升至 100,069 元(约 +7.6%),呈温和上行;6 月与 11 月促销峰清晰可见,促销月均销 151,139 元、平常月均 97,304 元,促销弹性 1.55 倍——任何忽略促销哑变量的模型都会把尖峰误判为异常或高估季节性。图3 给出分品类年度销售额:家电两年合计占 55.2%,服饰 23.0%、食品 21.7%,且三品类 2024 年较 2023 年均有增长,结构稳定。

图2 月度销售额趋势

图3 分品类年度销售额

五、RFM 客户分群(问题 3)

以数据末期(2024-12)为参考点计算每位客户的 R(距末次购买的月数)、F(订单数)、M(累计金额),z 标准化后消除量纲,运行 KMeans:k-means++ 选种、独立重启 10 次取 SSE 最小的解,k=3k=3 时 SSE=9.22、轮廓系数 0.830——大于 0.7 属强分离结构,说明三层客群边界清晰。选种环节的 k-means++ 策略让每个新中心的抽取概率正比于其到既有中心的最小距离平方,避免了纯随机选种把两个中心落进同一客群导致局部最优的问题;10 次重启取最优则进一步对冲了初始化随机性。kk 的取值以业务可解释性为先验:3 对应「高价值 / 普通 / 流失」的经典分层假设,轮廓系数在 k=3k=3 处取得高值印证了该假设而非强行拟合。

图4 的 F-M 散点上三簇几乎互不重叠。

图5 的簇中心给出商业画像:簇0 高价值客户 10 家(F=186.6 单、M=109,268 元、R≈0 月,即月月复购),应做专属维护与优先供货;簇1 流失风险客户 14 家(R=7.9 个月未购、F 仅 11.4 单、M=6,012 元),是召回活动的首要对象;簇2 主力普通客户 36 家(F=63.1 单、M=38,161 元),通过组合促销提升频次即可显著抬升大盘。分群结果直接映射为三类差异化策略,这是无监督挖掘落地价值的直接体现。

图4 客户RFM散点

图5 三客户群RFM标准化中心

六、Holt-Winters 需求预测(问题 4)

6.1 模型与选参

乘法 Holt-Winters 递推为:

lt=αytst−m+(1−α)(lt−1+bt−1),bt=β(lt−lt−1)+(1−β)bt−1,st=γytlt+(1−γ)st−ml_t=\alpha\frac{y_t}{s_{t-m}}+(1-\alpha)(l_{t-1}+b_{t-1}),\quad b_t=\beta(l_t-l_{t-1})+(1-\beta)b_{t-1},\quad s_t=\gamma\frac{y_t}{l_t}+(1-\gamma)s_{t-m}

其中 m=12m=12 为季节周期,y^t+h=(lt+hbt) st+h−m\hat y_{t+h}=(l_t+h b_t)\,s_{t+h-m}。初始化取首季均值 l0=yˉ1:ml_0=\bar y_{1:m}、跨季均差 b0=(yˉm+1:2m−yˉ1:m)/mb_0=(\bar y_{m+1:2m}-\bar y_{1:m})/m、季节因子 si=yi/l0s_i=y_i/l_0,随后递推更新。参数选择采用验证型准则:前 18 个月拟合、外推 3 步与真实值比 MAPE,在 α×β×γ\alpha\times\beta\times\gamma 共 120 个网格组合中取最优——若改用全段拟合 SSE 选参,初始化期(首季)拟合过准会系统性偏向高 α\alpha 追噪声的参数,这一点在实验中使家电 MAPE 恶化逾 40 个百分点,是时序建模容易踩中的隐形坑。

6.2 测试结果与三分化解读

留出最后 3 个月(2024-10~12)测试,图6 展示各品类历史序列与未来 3 个月预测,图7 给出三种方法 MAPE 对比:

  • 食品(强季节):HW 参数 (0.10,0.30,0.50)(0.10,0.30,0.50),MAPE 12.6%,大幅优于朴素基线 32.1%,略优于季节朴素 11.2% 的差距虽小,但 HW 是三者中唯一能把预测延伸出历史窗口的方法;
  • 服饰(换季峰):HW 18.1% 与季节朴素 17.3% 相当,两者远优于朴素 49.9%——换季峰使「抄上月」彻底失效;
  • 家电(促销脉冲型):HW 24.7% 反而劣于朴素 16.1%。其需求不含平滑季节成分,11 月尖峰来自促销脉冲,被乘法模型固化为季节因子后在外推期放大误差。

这组三分化给出的方法论结论比单一「HW 更准」更有价值:先诊断需求的驱动结构(季节型 / 脉冲型),再选择模型族;脉冲型品类的正确做法是显式引入促销哑变量的回归框架,而非更强的平滑器。

图6 品类月需求量及未来3个月预测

图7 测试期三方法MAPE对比

6.3 预测区间

以全量 24 个月重估最优参数,残差标准差给出 95% 区间半宽 ±1.96σ^\pm1.96\hat\sigma:未来 3 个月食品预测 [326, 310, 274] 件(±73\pm73 件)、服饰 [112, 104, 108] 件(±29\pm29 件)、家电 [65, 69, 63] 件(±25\pm25 件)。区间宽度与品类波动率成正比,食品的高波动要求更高的安全库存。

七、报童补货建议(问题 5)

补货决策的目标是在「缺货损失」与「超储持有」间权衡。报童逻辑把单期决策归结为一个临界分位数问题:多订一件的期望边际损失若小于少订一件的期望缺货损失就应增订,均衡点恰好落在需求分布的 CU/(CU+CO)CU/(CU+CO) 分位上,其中 CUCU 为单位缺货损失、COCO 为单位超储成本。95% 服务水平对应标准正态分位数 z0.95=1.645z_{0.95}=1.645,在残差近似正态的假设下建议补货量为:

Q∗=μ+1.645 σ^Q^*=\mu+1.645\,\hat\sigma

其中 μ\mu 取下月期望需求、σ^\hat\sigma 为该品类残差标准差。据此:食品补货 388 件(期望 326,安全库存 +19%)、服饰 136 件(期望 112,+22%)、家电 87 件(期望 65,+33%)——家电因相对波动最大而需要最高的安全库存比例(图8)。该建议可直接转化为采购计划;若缺货成本低于超储成本(如易腐品),可将服务水平降至 90% 相应下调 zz 值。

图8 报童模型下月补货建议

八、模型检验与优缺点

检验:①清洗三条规则均为确定性判定,重跑可复现;②聚类质量由轮廓系数 0.830 定量背书,且三簇规模(10/14/36)与画像解释相互印证;③预测评估严格留出最后 3 个月,基线对照诚实公开(含 HW 失利的家电品类);④反事实验证——若对 2024-06 促销峰直接外推平常月均值,将低估当月需求约三分之一,反向印证了显式刻画促销结构的必要性。

优点:①管线五阶段环环相扣,每个环节产出都是下一环节的直接输入;②验证型选参规避了初始化期污染;③预测不止于点值,区间与报童模型衔接成可执行决策。

缺点:①乘法 HW 无法表达促销哑变量,家电类脉冲需求需要回归框架修正;②k=3k=3 由业务经验设定,可用轮廓系数扫描自动选 kk;③补货未考虑多周期动态(s,S 策略)与供应商提前期分布。

九、结论

本文完成了从脏数据到补货决策的端到端分析:清洗后 4297 条订单揭示 1.55 倍促销弹性与家电主导的结构;RFM-KMeans 以 0.830 轮廓系数划分出 10 家高价值、14 家流失风险、36 家主力的客群版图;Holt-Winters 在季节型品类上取得 12.6%~18.1% 的测试 MAPE 并给出区间,同时暴露脉冲型品类的模型误配风险;报童模型最终将预测不确定性转译为 388/136/87 件的分级补货方案。方法论上,「显式清洗规则、诚实基线对比、按需求驱动结构选模型」三点适用于绝大多数订单预测类赛题。

参考文献

[1] Hyndman R J, Athanasopoulos G. Forecasting: Principles and Practice[M]. 3rd ed. OTexts, 2021.

[2] Witten I H, Frank E. Data Mining: Practical Machine Learning Tools and Techniques[M]. 3rd ed. Morgan Kaufmann, 2011.

[3] Nahmias S. Production and Operations Analysis[M]. 6th ed. McGraw-Hill, 2009.

[4] Hughes A M. Strategic Database Marketing[M]. McGraw-Hill, 1994.

[5] Rousseeuw P J. Silhouettes: a graphical aid to the interpretation and validation of cluster analysis[J]. Journal of Computational and Applied Mathematics, 1987, 20: 53-65.

附录:核心 Python 实现

# -*- coding: utf-8 -*-
"""订单分析核心管线:清洗/RFM/KMeans/Holt-Winters/报童(独立运行可复现)。"""
import math, random

SEED = 20260827
CATS = ["家电", "服饰", "食品"]
REGIONS = ["华南", "华东", "华北", "西南", "东北"]
PRICE_BASE = {"家电": 800.0, "服饰": 220.0, "食品": 65.0}
CAT_BASE = {"家电": 42.0, "服饰": 46.0, "食品": 62.0}

def month_of(m):
    return (2023 + m // 12, m % 12 + 1)

def build_customers():
    custs = []
    for i in range(60):
        cidv = "C%03d" % (i + 1)
        w = 3.0 if i < 10 else (1.0 if i < 45 else 0.25)
        custs.append((cidv, w, REGIONS[i % 5]))
    return custs

def pick_cust(rng, cum, total):
    u = rng.random() * total
    for cidv, cw, reg in cum:
        if u < cw:
            return cidv, reg
        u -= cw
    return cum[-1][0], cum[-1][2]

def gen_orders():
    rng = random.Random(SEED)
    cum = [(c, w, r) for c, w, r in build_customers()]
    total = sum(w for _, w, _ in cum)
    orders = []
    for m in range(24):
        y, mo = month_of(m)
        trend = 1.0 + 0.10 * (m / 23.0)
        promo = 1.55 if mo in (6, 11) else 1.0
        for cat in CATS:
            seas = 1.0
            if cat == "食品" and mo in (11, 12):
                seas = 1.4
            if cat == "服饰" and mo in (4, 10):
                seas = 1.25
            lam = CAT_BASE[cat] * seas * trend * promo * max(
                0.5, rng.gauss(1.0, 0.10))
            for _ in range(int(round(lam))):
                if m >= 16:
                    c, reg = pick_cust(rng, cum, total)
                    if c >= "C046" and rng.random() > 0.08:
                        continue
                else:
                    c, reg = pick_cust(rng, cum, total)
                q = (1 + rng.randrange(8) if cat == "食品"
                     else 1 + rng.randrange(3) if cat == "服饰"
                     else 1 + rng.randrange(2))
                pr = PRICE_BASE[cat] * math.exp(rng.gauss(0.0, 0.18))
                orders.append(dict(oid=len(orders) + 1, midx=m,
                                   date="%04d-%02d" % (y, mo), cust=c,
                                   prod=cat, qty=q, price=round(pr, 2),
                                   amount=round(q * pr, 2), region=reg))
    rng2 = random.Random(SEED + 1)
    for _ in range(15):
        i = rng2.randrange(len(orders))
        d = dict(orders[i])
        d["qty"], d["amount"] = -d["qty"], -d["amount"]
        d["oid"] = len(orders) + 1
        orders.append(d)
    for _ in range(8):
        i = rng2.randrange(len(orders))
        d = dict(orders[i])
        d["cust"] = "TST"
        d["oid"] = len(orders) + 1
        orders.append(d)
    for _ in range(6):
        i = rng2.randrange(len(orders))
        d = dict(orders[i])
        d["oid"] = len(orders) + 1
        orders.append(d)
    rng2.shuffle(orders)
    return orders

def clean(orders):
    seen, rows = set(), []
    n_neg = n_tst = n_dup = 0
    for r in orders:
        if r["qty"] <= 0:
            n_neg += 1
            continue
        if r["cust"] == "TST":
            n_tst += 1
            continue
        key = (r["date"], r["cust"], r["prod"], r["qty"], r["amount"])
        if key in seen:
            n_dup += 1
            continue
        seen.add(key)
        rows.append(r)
    return rows, n_neg, n_tst, n_dup

def monthly_series(rows):
    tot = [0.0] * 24
    for r in rows:
        tot[r["midx"]] += r["amount"]
    return tot

def cat_qty_series(rows):
    q = {c: [0] * 24 for c in CATS}
    for r in rows:
        q[r["prod"]][r["midx"]] += r["qty"]
    return q

def zscore(v):
    m = sum(v) / len(v)
    sd = math.sqrt(sum((x - m) ** 2 for x in v) / len(v)) + 1e-12
    return [(x - m) / sd for x in v]

def rfm(rows):
    last_m, freq, mon = {}, {}, {}
    for r in rows:
        c = r["cust"]
        last_m[c] = max(last_m.get(c, -1), r["midx"])
        freq[c] = freq.get(c, 0) + 1
        mon[c] = mon.get(c, 0.0) + r["amount"]
    ids = sorted(freq)
    return ids, [[23 - last_m[c] for c in ids],
                 [float(freq[c]) for c in ids],
                 [mon[c] for c in ids]]

def kmpp_center(X, k, rng):
    centers = [list(X[rng.randrange(len(X))])]
    while len(centers) < k:
        d2 = [min(sum((p[t] - c[t]) ** 2 for t in range(len(p)))
                  for c in centers) for p in X]
        s = sum(d2)
        u, acc = rng.random() * s, 0.0
        for i, v in enumerate(d2):
            acc += v
            if u <= acc:
                centers.append(list(X[i]))
                break
    return centers

def kmeans(X, k, seed=SEED + 3, n_init=10, iters=100):
    best = None
    for t in range(n_init):
        rng = random.Random(seed + t)
        centers = kmpp_center(X, k, rng)
        lab = [0] * len(X)
        for _ in range(iters):
            changed = False
            for i, p in enumerate(X):
                jb, db = 0, float("inf")
                for j, c in enumerate(centers):
                    dd = sum((p[u] - c[u]) ** 2 for u in range(len(p)))
                    if dd < db:
                        jb, db = j, dd
                if lab[i] != jb:
                    lab[i] = jb
                    changed = True
            newc = [[0.0] * 3 for _ in range(k)]
            cnt = [0] * k
            for i, p in enumerate(X):
                cnt[lab[i]] += 1
                for u in range(3):
                    newc[lab[i]][u] += p[u]
            for j in range(k):
                newc[j] = [v / cnt[j] for v in newc[j]] if cnt[j] \
                    else list(X[rng.randrange(len(X))])
            if not changed:
                break
            centers = newc
        sse = sum(sum((X[i][u] - centers[lab[i]][u]) ** 2
                      for u in range(3)) for i in range(len(X)))
        if best is None or sse < best[0]:
            best = (sse, list(lab), centers)
    return best

def silhouette(X, lab, k):
    n = len(X)
    D = [[sum((X[i][u] - X[j][u]) ** 2 for u in range(3)) ** 0.5
          for j in range(n)] for i in range(n)]
    total = 0.0
    for i in range(n):
        same = [D[i][j] for j in range(n) if j != i and lab[j] == lab[i]]
        a = sum(same) / len(same) if same else 0.0
        b = float("inf")
        for ck in range(k):
            if ck != lab[i]:
                grp = [D[i][j] for j in range(n) if lab[j] == ck]
                if grp:
                    b = min(b, sum(grp) / len(grp))
        total += (b - a) / max(a, b) if max(a, b) > 0 else 0.0
    return total / n

def hw_init(y, s):
    l0 = sum(y[:s]) / s
    b0 = (sum(y[s:2 * s]) / s - l0) / s
    seas = [y[i] / l0 for i in range(s)]
    return l0, b0, seas

def hw_run(y, s, al, be, ga, l0, b0, seas):
    lvl, tr, sea = l0, b0, list(seas)
    fit = []
    for t in range(len(y)):
        si = sea[t % s]
        fit.append((lvl + tr) * si)
        old_l, old_b = lvl, tr
        top = y[t] / si if abs(si) > 1e-9 else y[t]
        lvl = al * top + (1 - al) * (old_l + old_b)
        tr = be * (lvl - old_l) + (1 - be) * old_b
        den = old_l + old_b
        sea[t % s] = (ga * (y[t] / den if abs(den) > 1e-9 else y[t])
                      + (1 - ga) * sea[t % s])
    return fit, lvl, tr, sea

def hw_grid(y_tr, s=12):
    cut = len(y_tr) - 3
    best = None
    for al in (0.05, 0.1, 0.2, 0.3, 0.5, 0.8):
        for be in (0.0, 0.05, 0.1, 0.3):
            for ga in (0.05, 0.1, 0.2, 0.3, 0.5):
                l0, b0, seas = hw_init(y_tr[:cut], s)
                _, lvl, tr, sea = hw_run(y_tr[:cut], s, al, be, ga,
                                         l0, b0, seas)
                pv = [(lvl + hh * tr) * sea[(cut + hh - 1) % s]
                      for hh in range(1, 4)]
                m = sum(abs(pv[i] - y_tr[cut + i])
                        / max(y_tr[cut + i], 1e-9)
                        for i in range(3)) / 3.0
                if best is None or m < best[0]:
                    best = (m, al, be, ga)
    return best[1], best[2], best[3]

def hw_forecast(y, s, al, be, ga, h):
    l0, b0, seas = hw_init(y, s)
    fit, lvl, tr, sea = hw_run(y, s, al, be, ga, l0, b0, seas)
    resid = [fit[t] - y[t] for t in range(len(y))]
    sigma = math.sqrt(sum(e * e for e in resid) / len(resid))
    fc = [max(0.0, (lvl + hh * tr) * sea[(len(y) + hh - 1) % s])
          for hh in range(1, h + 1)]
    return fc, sigma

def mape(y_true, y_pred):
    return sum(abs(y_pred[t] - y_true[t]) / y_true[t]
               for t in range(len(y_true))) / len(y_true) * 100.0

orders = gen_orders()
rows, n_neg, n_tst, n_dup = clean(orders)
print("清洗 %d -> %d;退单 %d 测试 %d 重复 %d" % (
    len(orders), len(rows), n_neg, n_tst, n_dup))
tot = monthly_series(rows)
print("总销售额 %.0f 元" % sum(tot))
pm = [tot[m] for m in range(24) if (m % 12 + 1) in (6, 11)]
nm = [tot[m] for m in range(24) if (m % 12 + 1) not in (6, 11)]
print("促销月均 %.0f vs 平常 %.0f (x%.2f)" % (
    sum(pm) / len(pm), sum(nm) / len(nm),
    sum(pm) / len(pm) / (sum(nm) / len(nm))))
ids, mats = rfm(rows)
Z = [zscore(col) for col in mats]
X = [[Z[0][i], Z[1][i], Z[2][i]] for i in range(len(ids))]
sse, lab, centers = kmeans(X, 3)
print("KMeans %s SSE %.2f 轮廓 %.3f" % ([lab.count(j) for j in range(3)],
                                        sse, silhouette(X, lab, 3)))
for j in range(3):
    g = [i for i in range(len(ids)) if lab[i] == j]
    print("簇%d n=%d R%.1f F%.1f M%.0f" % (
        j, len(g), sum(mats[0][i] for i in g) / len(g),
        sum(mats[1][i] for i in g) / len(g),
        sum(mats[2][i] for i in g) / len(g)))
qs = cat_qty_series(rows)
for c in CATS:
    y = [float(v) for v in qs[c]]
    al, be, ga = hw_grid(y[:21])
    fc, sig = hw_forecast(y[:21], 12, al, be, ga, 3)
    f24, sg24 = hw_forecast(y, 12, al, be, ga, 3)
    nv = [y[t - 1] for t in range(21, 24)]
    sv = [y[t - 12] for t in range(21, 24)]
    print("%s a=%.2f b=%.2f g=%.2f MAPE HW %.1f%% nv %.1f%% sv %.1f%%"
          % (c, al, be, ga, mape(y[21:], fc), mape(y[21:], nv),
             mape(y[21:], sv)))
    print("  未来3月 %s ±%.0f;Q*=%.0f (+%.0f%%)" % (
        [round(v) for v in f24], 1.96 * sg24,
        f24[0] + 1.645 * sg24,
        (f24[0] + 1.645 * sg24) / f24[0] * 100 - 100))