做市场营销组合建模(MMM)这行当,最高频的场景就是:你拿着一份回归结果给业务方看,电视广告的系数是负的,数字广告的置信区间横跨正负两个方向,然后被问"这个模型是不是有问题"。这套模型的本意是量化每个渠道投放对销售额的真实贡献,结果却经常因为统计假设没落地,变成了一张谁都不信的"鬼话报表"。
这篇文章我想完整梳理一遍用 Python 做 MMM 的整套思路,从数据构造、探索性分析、建模实现到预算分配解读,把那些代码背后"为什么要这么写"的逻辑一次性讲清楚。内容定位于有 Python 基础但没系统做过 MMM 的数据分析师、增长团队的数据科学同学,以及想从传统线性回归往贝叶斯建模走一步的从业者。看完之后你不仅能跑通一个完整流程,还能知道哪些环节容易翻车、翻车之后从哪排查。
1. 为什么回归做 MMM 总是翻车:三个隐蔽的统计陷阱
先别急着建模。市场营销组合建模的核心任务是拆解销售额的构成:多少是品牌自然需求、多少来自渠道投放、多少是节假日带动、多少是整体市场趋势。这个拆解本质上是回归问题,但直接拿普通最小二乘(OLS)去拟合销售与广告支出之间的关系,几乎必踩下面三个坑。
1.1 广告效果不是当天生效:滞后效应带来的自相关
广告从曝光到消费者产生购买行为,中间隔着一段消化时间。电视广告可能有两到三周的延滞期,信息流广告虽然短一些,但也绝对不是当天投放当天回收。如果建模时只用当天的广告支出作为特征,模型会认为当天投放、当天必须见效,一旦有几天衰减期,残差就出现明显的自相关结构。
自相关直接破坏 OLS 估计量有效性的前提,标准误被低估,你会得到一堆虚假显著的系数。解决思路是引入 adstock 变换,把历史投放按照衰减模式折算到当前时间,这本质上是一个加权滑动平均。
1.2 渠道之间挤牙膏:多重共线性吞噬可解释性
做品牌投放的都知道,大促前一周往往全渠道同时加码,电视、户外、社交、电商齐上。这一下就把建模逼到了墙角:渠道支出在时间维度上高度同涨同跌,特征矩阵近奇异,回归系数方差暴涨,系数估计值变得极不稳定。今天跑出来电视是正贡献,把随机种子换一下明天就变成负贡献。
解决这个问题的标准操作是先做设计矩阵的相关性检查,算变量膨胀因子(VIF),同时考虑用贝叶斯分层模型加上先验约束来稳住系数方向。
1.3 边际效益递减被强行设成了线性
传统回归假定每多投一块钱,带来的销售增量是一个常数。这在现实里完全不成立,广告预算翻一倍,增量效果往往只有原来的百分之五六十。忽略饱和效应,会让模型在大预算区间严重高估渠道价值,最后算出来的边际 ROI 不准,还容易给业务"继续加码"的错误信号。
要处理边际递减,一般对广告支出做对数变换,或者显式地在模型里加入饱和函数(本文用 Hill 函数,后面细讲)。这样模型输出的是"拟合到饱和曲线的增量收益",而不是一根不能拐弯的直线。
1.4 回归前的自查清单:数据层面先排雷
在进入建模代码之前,我强烈建议按顺序做这几件事:确认销售数据与广告支出的时间频率一致(日、周、月必须统一);检查是否存在巨幅异常值(一个双十一的脉冲可能让你整个模型失真);确认是否收集了价格、促销、节假日这些控制变量——遗漏变量偏差是 MMM 里最容易被忽视但杀伤力最大的问题。
实操中很多团队直接拉两列数据就开始 fit 模型,等结果不对劲再回头补控制变量,白白浪费几轮迭代。这里建议先列一张"变量四象限":你有哪些渠道变量、有哪些控制变量、哪些是强外生变量(天气、节假日)、哪些是被销售反向影响的变量(比如投放预算本身可能是根据销售动态调整的,这是内生性问题)——先把这些边界画清楚,模型再怎么调都有底。
2. 从零构造一套可复现的数据集:模拟比真实数据更会暴露问题
用真实脱敏数据讲 MMM 很容易陷入"拍脑袋解释系数"的窘境。更实用的做法是自己模拟一份数据:因为只有你自己知道真实系数是几,才能检验模型估得准不准。真实项目里我也一直建议先用模拟数据验证模型 pipeline,再接真实数据,省心得多。
2.1 生成带基期、季节性和广告脉冲的销售数据
下面的代码构造一份周度数据,包含趋势项、季节性、假日脉冲和两个渠道的广告效果(第二个渠道带饱和与滞后效应)。
import numpy as np import pandas as pd from scipy.signal import convolve np.random.seed(42) weeks = 120 index = pd.date_range(start="2022-01-01", periods=weeks, freq="W-MON") # 1. 背景需求:趋势 + 季节性 trend = np.linspace(100, 160, num=weeks) seasonality = 15 * np.sin(np.linspace(0, 6 * np.pi, weeks)) base = trend + seasonality # 2. 节假日脉冲:第 20 周附近(类比某电商大促) holiday_effect = np.zeros(weeks) holiday_effect_generator = np.random.default_rng(7) for i in range(weeks): if 18 <= i <= 23: holiday_effect[i] = holiday_effect_generator.uniform(30, 55) # 3. 渠道变量 tv_spend = np.random.gamma(shape=2.0, scale=0.8, size=weeks) * 2.0 + 5.0 digital_spend = np.random.gamma(shape=2.2, scale=0.5, size=weeks) * 3.0 + 4.0 # 4. 电视渠道:带 adstock 的滞后效果 def adstock_transform(x, rate=0.6): out = np.zeros_like(x, dtype=float) carry = 0.0 for i in range(len(x)): carry = rate * carry + x[i] out[i] = carry return out tv_adstock = adstock_transform(tv_spend, rate=0.7) # 数字渠道:较短衰减 + 饱和递减 digital_adstock = adstock_transform(digital_spend, rate=0.4) def hill_transform(x, ec=1.2, slope=1.0): return x**slope / (x**slope + ec**slope) digital_hill = hill_transform(digital_adstock, ec=8.0, slope=2.0) tv_hill = hill_transform(tv_adstock, ec=12.0, slope=2.0) # 5. 叠加噪声生成销售 noise = np.random.normal(0, 12, size=weeks) sales = base + holiday_effect + 6.0 * tv_hill + 13.0 * digital_hill + noise df_sim = pd.DataFrame({ "sales": sales, "tv_spend": tv_spend, "digital_spend": digital_spend, "holiday_feature": holiday_effect > 0, }, index=index) print(df_sim.head())注意这行代码里有一个容易被新人忽略的设计:两个渠道的真实系数分别只有 6.0 和 13.0,但数字渠道的单位消费能力是电视的三倍左右。模拟设定里,电视依赖长效应的 adstock,数字渠道则为饱和型增长。这决定了后续建模时,如果你忽略 adstock 和 Hill 变换直接做线性回归,电视的系数会被严重低估甚至变负。
2.2 探索性分析:先看图再建模,别做无脑回归
数据生成后我习惯先画四张图,花五分钟就能看清数据结构:销售时序图(看趋势与季节形态)、渠道支出堆叠图(看加码节奏是否一致)、销售与电视广告的散点(看是否存在明显的滞后关系)、销售自相关图(ACF)。
ACF 图尤其值得重点看:如果第 1~2 阶相关性显著存在,就说明销售本身带有短期惯性,或者残差会带自相关。这时你可以先不处理,把它当作之后模型诊断的一个基准线;也可以提前引入滞后销售作为自回归特征。真实项目里我不推荐一上来就把滞后期数拉满,因为这会吸收掉渠道的长期解释力,让广告 ROI 被低估。更合理的顺序是先在无自回归项的模型上诊断滞后形态,再决定是否加。
import matplotlib.pyplot as plt import statsmodels.api as sm from statsmodels.graphics.tsaplots import plot_acf fig, axes = plt.subplots(2, 2, figsize=(13, 8)) axes[0, 0].plot(df_sim.index, df_sim["sales"]) axes[0, 0].set_title("Raw Sales Time Series") axes[0, 0].grid(alpha=0.3) axes[0, 1].plot(df_sim.index, df_sim["tv_spend"], label="TV") axes[0, 1].plot(df_sim.index, df_sim["digital_spend"], label="Digital") axes[0, 1].legend() axes[0, 1].set_title("Channel Spend") # 销售滞后检查 s = df_sim["sales"].values lag_sales_pd = pd.Series(s, index=df_sim.index) plot_acf(lag_sales_pd, ax=axes[1, 0], lags=20) axes[1, 0].set_title("Sales ACF") # 渠道相关性 axes[1, 1].scatter(df_sim["tv_spend"], df_sim["digital_spend"], alpha=0.6) axes[1, 1].set_xlabel("TV spend") axes[1, 1].set_ylabel("Digital spend") axes[1, 1].set_title("Spend Correlation") plt.tight_layout() plt.show()你大概率会看到 TV 和 Digital 的支出相关系数在 0.5 以上(因为我们是用 gamma 分布同源随机生成的)。这个相关性在真实数据里通常更强,尤其是品牌在做全渠道Campaign 的时候。系数不稳定、VIF 飙高几乎是必然的,这时候就进入到模型设计的阶段了。
3. 回归模型的两个阶段:先用逻辑验证,再上贝叶斯
MMM 的建模本质是"在业务约束和统计推断之间找折中"。我建议按两条路线并行推进:第一条是快速可解释的线性回归基线,第二条是贝叶斯分层模型。它们不是互斥的,而是互为验证。
3.1 线性回归基线:statsmodels 里的可解释性优先
先直接跑一个普通最小二乘(OLS),因变量为销售额,特征包括趋势项、季节变量、假日哑变量,以及两个渠道的原始支出。为什么选statsmodels而不是scikit-learn?因为要的是系数的标准误、置信区间和诊断指标,而不是预测精度。
import statsmodels.formula.api as smf sim_df = df_sim.copy() sim_df["trend_idx"] = np.arange(len(sim_df)) sim_df["channel_tv"] = sim_df["tv_spend"] sim_df["channel_digital"] = sim_df["digital_spend"] formula_ols = ( "sales ~ trend_idx + channel_tv + channel_digital + holiday_feature" ) ols_model = smf.ols(formula_ols, data=sim_df).fit() print(ols_model.summary())这个基线模型几乎一定会出现两个问题:第一,channel_tv的系数不显著甚至为负;第二,残差 Durbin-Watson 统计量明显偏离 2。这就是前面提到的 adstock 缺失和共线性联手造成的典型症状。
接下来把 adstock 变换和 Hill 变换塞进去,再跑一次:
sim_df["tv_adstocked"] = adstock_transform(sim_df["tv_spend"].values, rate=0.7) sim_df["digital_adstocked"] = adstock_transform( sim_df["digital_spend"].values, rate=0.4 ) sim_df["tv_hill"] = hill_transform(sim_df["tv_adstocked"].values, ec=12.0, slope=2.0) sim_df["digital_hill"] = hill_transform(sim_df["digital_adstocked"].values, ec=8.0, slope=2.0) formula_transformed = ( "sales ~ trend_idx + tv_hill + digital_hill + holiday_feature" ) ols_fixed = smf.ols(formula_transformed, data=sim_df).fit() print(ols_fixed.summary())系数方向正确了,显著性也上来了,但这里的关键问题不是系数,而是变换参数(adstock 衰减率、Hill 函数的形状参数与拐点)是你主观设定的。设定的衰减率 0.7 和真实生成逻辑恰好一致时结果很好看;一旦业务实际衰减形态偏离假设,OLS 的严格假设会让错误从"变换层"传导到"系数层",而且没有任何机制回调。这也是传统回归做 MMM 的天然天花板。
3.2 各渠道 VIF 计算:把多重共线性放到台面上
用一个 10 行代码先量化一下共线性严重程度,可以作为是否转入贝叶斯的依据:
from statsmodels.stats.outliers_influence import variance_inflation_factor X_pre = sim_df[["trend_idx", "tv_hill", "digital_hill", "holiday_feature"]] X_pre = sm.add_constant(X_pre) vif_df = pd.DataFrame({ "feature": X_pre.columns, "VIF": [variance_inflation_factor(X_pre.values, i) for i in range(X_pre.shape[1])], }) print(vif_df)一般经验值:VIF 超过 5 就应该警惕,超过 10 表明这列的系数估计基本不可信。在真实营销数据里,如果你只有月度 36 个样本却放了 7 个渠道变量,VIF 破 10 稀松平常。
3.3 为什么贝叶斯 MMM 能比 OLS 更稳:先验就是业务约束
贝叶斯 MMM 的核心优势,不是"用了某种高级算法所以更准",而是它允许你把业务常识直接翻译成先验分布。
比如:电视广告的弹性系数不可能是个负值,那么给电视渠道的系数先验设一个只允许正数的半边正态分布(HalfNormal);促销对销售的拉升通常有上限,那么 Hill 函数的饱和参数可以设一个以经验均值为中心的宽先验;每个渠道的 adstock 衰减率在不同渠道之间有差异,但大概率落在 0.2~0.8 之间,用 Beta 分布来描述。这些先验约束既保留了不确定性表达,又不会让你输出一个"广告投放越多、销售越差"的可笑结论。
另一个容易忽略的贝叶斯收益是:它把 adstock 和 Hill 的参数当成随机变量来处理。也就是说,模型在估计渠道系数的同时,也在根据数据修正滞后衰减周期和饱和曲线的形状。传统两段式做法(先主观定变换参数、再拟合回归)变成了一个完整的生成模型,误差不再被刻意忽略。
4. PyMC 构建营销组合模型:从先验设计到后验诊断
下面进入本文的主菜:用 PyMC 构建一个带 adstock 变换、Hill 饱和效应和正则先验的贝叶斯 MMM。我用的是 PyMC 的典型代码结构,尽量把每一步注释清楚。
4.1 模型定义:半正态先验 + adstock + Hill 变换
import pymc as pm import arviz as az # 数据标准化:对销售和支出统一缩放到 0~1 区间,利于采样收敛 sales_arr = df_sim["sales"].values.astype(float) tv_arr = df_sim["tv_spend"].values.astype(float) digital_arr = df_sim["digital_spend"].values.astype(float) holiday_arr = df_sim["holiday_feature"].values.astype(int) # 把数据缩放到合理范围(对提升采样效率非常关键) sales_scaler = np.max(sales_arr) sales_s = sales_arr / sales_scaler tv_s = tv_arr / np.max(tv_arr) digital_s = digital_arr / np.max(digital_arr) # adstock 变换函数(用 theano / pytensor 实现,保证可以被梯度计算) import pytensor.tensor as pt def adstock_transform_pymc(x, rate): def step(i, carry, x): return rate * carry + x[i] result, _ = pytensor.scan( fn=step, sequences=[pt.arange(x.shape[0])], outputs_info=[pt.zeros((1,))], non_sequences=[x], n_steps=x.shape[0] ) return result.flatten() def hill_transform_pymc(x, ec, slope): return x**slope / (x**slope + ec**slope) with pm.Model() as mmm_model: # 基期与趋势 base_mu = pm.Normal("base_mu", mu=0.3, sigma=0.08) trend_rate = pm.Normal("trend_rate", mu=0.0, sigma=0.005) # 季节项/假日 holiday_eff = pm.HalfNormal("holiday_eff", sigma=0.05) # 渠道系数:强制为正 beta_tv = pm.HalfNormal("beta_tv", sigma=0.2) beta_digital = pm.HalfNormal("beta_digital", sigma=0.2) # 渠道衰减率 adstock_tv = pm.Beta("adstock_tv", alpha=2, beta=2) adstock_digital = pm.Beta("adstock_digital", alpha=1.5, beta=2.5) # 饱和曲线参数 ec_tv = pm.HalfNormal("ec_tv", sigma=0.5) slope_tv = pm.Gamma("slope_tv", alpha=3, beta=2) ec_digital = pm.HalfNormal("ec_digital", sigma=0.5) slope_digital = pm.Gamma("slope_digital", alpha=3, beta=2) # 变换 tv_ads = adstock_transform_pymc(pt.as_tensor_variable(tv_s), adstock_tv) dig_ads = adstock_transform_pymc( pt.as_tensor_variable(digital_s), adstock_digital ) tv_contrib = beta_tv * hill_transform_pymc(tv_ads, ec_tv, slope_tv) dig_contrib = beta_digital * hill_transform_pymc( dig_ads, ec_digital, slope_digital ) # 趋势项随时间变化 trend_effect = trend_rate * pt.arange(len(sales_s)) # 期望销售 mu = base_mu + trend_effect + holiday_eff * holiday_arr + tv_contrib + dig_contrib # 观测噪声 sigma = pm.HalfNormal("sigma", sigma=0.05) pm.Normal("sales_obs", mu=mu, sigma=sigma, observed=sales_s) trace = pm.sample( draws=1000, tune=1000, chains=4, target_accept=0.9, random_seed=42, )代码里有几个细节值得重点展开。第一个是数据缩放:销售和支出都缩放到 0~1 区间后再建模,否则beta的先验和 Hill 的ec参数会落入完全不适应的标度空间,采样效率会大幅下降,甚至出现大量发散警告。第二个是 pytensor 的scan,这是把时序变换变成一个符号计算过程的关键,如果你直接在外部先计算好 adstock 再传入贝叶斯模型,就回到了主观定参数的老路,失去了贝叶斯修正 adstock 参数的能力。第三个是target_accept=0.95这类采样器参数,对带非线性变换的模型来说不是可有可无的,大多数 MMM 模型都会遇到高曲率后验表面,默认的 0.8 接受率会让采样器很难探索。
如果采样结束出现大量Divergence警告,优先做两件事:把target_accept提高到 0.95 以上;减少sigma先验的尺度,让观测噪声不至于在变换曲线附近产生梯度爆炸。
4.2 结果解读:后验分布怎么转成业务语言
模型采样完成后,第一步不是看系数,而是用az.summary检查每个参数的收敛性。
az.summary(trace, var_names=["beta_tv", "beta_digital", "adstock_tv", "adstock_digital", "ec_tv", "ec_digital"])重点看两列:r_hat和ess_bulk/ess_tail。r_hat全接近 1.0 说明四根链收敛了;有效样本量至少要几百以上,否则后验描述可能不稳定。真实项目里我见过只跑 500 次迭代就把r_hat当唯一指标用的,但有效样本量过低时后验密度的边缘都很毛糙,结论不可靠。
如果结合参数后验做渠道贡献分解,可以这样算:
post = trace.posterior # 将缩放后的贡献还原回原始销售尺度 tv_contrib_posterior = ( post["beta_tv"] * hill_transform_pymc( adstock_transform_pymc(tv_s, post["adstock_tv"]), post["ec_tv"], post["slope_tv"] ) ) # 还原原始尺度 tv_contrib_raw = tv_contrib_posterior * sales_scaler这一步是 MMM 项目里最容易被低估的环节。数据缩放造成的单位改变,如果不还原,你给业务方看的是"标准化后的贡献",他们根本没法对账。还原之后,你可以按周画出电视渠道贡献的后验中位数和 90% 可信区间。好的呈现方式是堆叠一张"贡献构成瀑布图",而不是贴一堆az.plot_trace。非技术背景的决策者要的是"上周卖了 100 万,其中电视带动了 17 万,数字带动了 28 万,基线占 45 万,还剩 10 万算节假日",而不是一串希腊字母。
4.3 模型诊断不只靠 R_hat:预测检查才是硬道理
纯看系数和 R_hat 还不够。MMM 的最终评价标准是后验预测分布能否复现你观察到的销售形态。用pm.sample_posterior_predictive生成后验预测值,再跟真实销售序列对比:
with mmm_model: ppc = pm.sample_posterior_predictive(trace, random_seed=42) # 绘制后验预测区间 ppc_sales = ppc.posterior_predictive["sales_obs"].values * sales_scaler我习惯比较三个维度:整体水平的覆盖(真实值是否落在 90% 区间内)、波动峰的还原(双十一那周有没有估出来)、长期趋势的方向(不能后验预测是一根水平线)。如果预测区间太宽,说明模型对渠道效应的识别力弱;如果区间窄但真实值经常跑出区间外,说明模型结构有问题(比如漏了某个重要控制变量)。
5. 可解释性的价值:用贡献分解回答"钱花哪了"
模型拟合完之后,客户或老板的下一句一定是:"那你告诉我,钱花在哪最有效?"这一节讨论怎么把后验结果转化成决策可用的东西。
5.1 基线销售、渠道增量与节假日增量的拆解
基于模型对每周mu的期望,可以构造贡献分解:
# 用后验均值计算各组件的期望贡献 base_mu_post = post["base_mu"].mean(dim=("chain", "draw")) trend_post = post["trend_rate"].mean(dim=("chain", "draw")) holiday_post = post["holiday_eff"].mean(dim=("chain", "draw")) beta_tv_post = post["beta_tv"].mean(dim=("chain", "draw")) beta_digital_post = post["beta_digital"].mean(dim=("chain", "draw")) adstock_tv_post = post["adstock_tv"].mean(dim=("chain", "draw")) adstock_digital_post = post["adstock_digital"].mean(dim=("chain", "draw")) weeks_arr = np.arange(len(sales_s)) base_contrib = base_mu_post + trend_post * weeks_arr holiday_contrib = holiday_post * holiday_arr tv_contrib = beta_tv_post * hill_transform(tv_ads_mean, ec_tv_post, slope_tv_post)注意这里有个细节:trend_effect是逐周线性累加的,所以不同周的基线销售贡献不一样。基于这个拆解就可以画出一张"周贡献堆叠图":底部是趋势和季节的基线线,中间是电视的贡献,上部是数字贡献+节假日的脉冲峰值。这张图在业务汇报时比任何回归表都好用。
5.2 ROI 计算要区分"单点 ROI"和"边际 ROI"
很多团队把总增量销售额除以总广告花费,得出一个大而化之的 ROI。这在预算平稳期的参考价值还行,预算剧烈变化时就会出错,因为它完全无视边际递减。更合理的是用贝叶斯模型里的 Hill 函数求导,得到每个渠道当前投放水位下的边际 ROI(再花一块钱所带来的销售增量)。
对 Hill 变换求边际效应:
[ \frac{\partial y}{\partial x} = beta \cdot \frac{ec^{slope} \cdot slope \cdot x^{slope-1}}{(x^{slope} + ec^{slope})^2} ]
这条公式可以象征性地用代码逐点算:
def marginal_effect(beta, ec, slope, x): exp_part = ec**slope * slope * x**(slope - 1) return beta * exp_part / (x**slope + ec**slope)**2 tv_me = marginal_effect(beta_tv_post, ec_tv_post, slope_tv_post, tv_ads_mean)这样能明确看出一个关键事实:预算越高的渠道,下一块钱的回报越低。不光是不同渠道之间比较,连同一个渠道在不同水位下的回报也是动态的。这比"ROI=3.2"这种静态数字更能指导预算再分配。
6. 边际收益递减下的预算分配:把模型用起来
模型建完、贡献拆完,最后一步是把结论闭环成预算建议。这一节提供一个小而实用的优化器框架。
6.1 预算再分配:用梯度信息做等边际原则
经济学里有个常识:在预算约束下,最优配置发生在各个投入的边际产出相等时。放到 MMM 语境下,就是把各渠道的边际回报拉平。
假设预算总额固定为total_budget,最简单可靠的优化方法是:使用采样得到的边际效应,迭代地将预算从低边际收益渠道向高边际收益渠道小幅移动。也可以直接用scipy.optimize做带约束的数值优化:
from scipy.optimize import minimize total_budget = 100.0 # 初始分配:保持原有比例 init_alloc = np.array([55.0, 45.0]) def negative_total_margin(x, beta_list, ec_list, slope_list, adstock_rate): tv_marginal = marginal_effect(beta_list[0], ec_list[0], slope_list[0], x[0]) dig_marginal = marginal_effect(beta_list[1], ec_list[1], slope_list[1], x[1]) return -(tv_marginal + dig_marginal) # 最大化总边际 constraints = [{"type": "eq", "fun": lambda x: np.sum(x) - total_budget}] bounds = [(10, 80), (10, 80)] res = minimize( negative_total_margin, init_alloc, args=(beta_list, ec_list, slope_list, adstock_rate), bounds=bounds, constraints=constraints, method="SLSQP", ) print(res.x)这个优化器直接最大化总边际收益,而不是用物理意义上的拉格朗日乘子,胜在简单;如果你有更复杂的业务约束(比如某渠道单周最低投放量、其他渠道不降级),可以用线性事件约束再加几个条件。数值优化的结果再放回模型里模拟一遍,就能反复验证决策效果。
6.2 预算分配的不确定性:别拿一个点估计做全局决策
贝叶斯方法的一个独特优势是,你可以用整条后验链来做预算优化,而不只是用一个点估计。做法是:从后验中随机抽 500 组参数,对每一组分别做预算优化,得到 500 个"最优分配",然后看分配方案的分布。
如果某渠道在这 500 次优化里始终被分配到很高权重,说明这是稳健结论;如果某些方案间差异很大(电视从 20% 到 70% 都有人推荐),说明数据对渠道边际收益的识别力不足。这种情况下排第一位的不是继续调模型,而是补数据或降低决策置信度——这也是一个"统计学问题被转变成了决策管理问题"的典型案例。
7. 实战经验:从模型到决策的四条忠告
以上是 MMM 建模的完整链路。最后分享几条在实际项目里反复验证的经验。
第一,基线销售一定是模型里最重要的部分。很多团队把注意力全放在广告系数上,却忘了基期和趋势才解释了大部分销售方差。如果基线失真,广告贡献的估计会把基期误差吸收进去,结果整个模型都无法解释。建模之前一定要先跟业务对齐品牌自然销量的可信区间,把它作为一个强先验放进去,哪怕是粗略的。
第二,数据粒度决定模型用途。日粒度数据适合做 ROAS 监控,但对 MMM 来说会引入过多噪声和周内模式;月度数据虽然稳定,但样本量稀少导致系数识别困难。周粒度是绝大多数品牌的最佳折中。如果你的数据是日粒度的,先聚合成周,比用日数据加各种自回归项简单得多,且解释起来更顺。
第三,先验参数的设定要有溯源。没有历史数据的时候,可以基于行业基准报告或内部历史促销记录估算范围,并在汇报时明确标注"该先验来自 XX 基准"。贝叶斯建模最怕的是"拍脑袋指定先验然后让模型自动跑",这会把主观偏见包装成客观结果。正确的做法是先验要有依据,后验才有说服力。
第四,模型永远不是一次到位的。首版模型跑完一定会发现端倪:某个渠道后验出现负值尾部、某个控制变量的后验区间跨越一个很大的范围、预测检查显示峰值没有估出来。这不是模型失败,而是进入"数据-模型-业务认知"三向迭代的信号。这个迭代过程通常在第三版模型之后才开始进入稳定期。
一个 MMM 项目到最后,往往只有 30% 的时间在建模,70% 的时间在跟业务方对齐变量含义、修正数据口径、解释后验分布。但这恰恰是最有意思的部分——当你把一团乱麻的数据变成一个"渠道、基线、节假日各贡献多少销售"的清晰结构时,那种"原来钱是这样流动"的顿悟感,就是做这一行最大的成就感来源。