☰
镜子AI工程实践:用 Mann-Kendall + Sen‘s slope 判断渠道趋势真假(零依赖)
2026/10/30 6:49:08 网站建设 项目流程

本文由镜子AI技术团队整理。内容为通用的时间序列趋势检验工程实现,与具体业务无关,代码可直接复用。

一、问题背景

做渠道分析时,最容易被"月度波动"骗:这个月咨询量涨了就以为策略对了,下个月跌了又慌着调方向。问题在于,单看两个月的涨跌,无法区分"趋势"和"噪声"。

统计上判断"某指标是否真的在单调变化",有几个经典的非参数方法:

  • Mann-Kendall 趋势检验:不假设数据服从正态分布,对异常值稳健,专门回答"有没有显著单调趋势";
  • Sen's slope:用所有点对斜率的中位数估计变化速度,抗离群值;
  • CUSUM 变点检测:定位"从哪一刻开始真的拐了"。

三者组合,可以完整回答三个问题:有没有趋势?变化多快?何时拐的?

本文给一个零依赖 Python 实现。

二、方法简述

Mann-Kendall。对所有点对 (i<j) 比较大小,统计"后值大于前值"的个数减"后值小于前值"的个数,得到统计量 S。S 服从近似正态分布,标准化后得到 Z,再算双侧 p 值。H0 是"无单调趋势":p < 0.05 拒绝原假设,认为存在显著趋势。

Sen's slope。计算所有点对的斜率(x[j]-x[i])/(j-i),取中位数。相比最小二乘,它对极端值不敏感——这对业务数据很重要,一次活动爆发不该扭曲整条趋势线。

CUSUM。计算每个点相对均值的累计偏差。若序列存在趋势,累计偏差会单调漂移;绝对值最大的位置就是拐点。

三、完整代码

# -*- coding: utf-8 -*- """channel_trend_detect.py —— MK + Sen's slope + CUSUM,零依赖""" import math MONTHS = ["25-10", "25-11", "25-12", "26-01", "26-02", "26-03", "26-04", "26-05", "26-06", "26-07", "26-08", "26-09"] SERIES = { "短视频推荐": [320, 335, 310, 300, 285, 270, 240, 225, 200, 185, 170, 158], "AI问答搜索": [12, 15, 20, 26, 34, 45, 58, 72, 88, 105, 122, 140], } def mann_kendall(x): """Mann-Kendall 趋势检验:返回 S, Z, p(双侧),H0=无趋势""" n = len(x) s = 0 for i in range(n - 1): for j in range(i + 1, n): s += (x[j] > x[i]) - (x[j] < x[i]) var = n * (n - 1) * (2 * n + 5) / 18.0 # 无并列值时的方差 if s > 0: z = (s - 1) / math.sqrt(var) elif s < 0: z = (s + 1) / math.sqrt(var) else: z = 0.0 p = 2 * (1 - 0.5 * (1 + math.erf(abs(z) / math.sqrt(2)))) # 正态近似双侧 p return s, z, p def sens_slope(x): """Sen's slope:所有点对斜率的中位数,抗异常值""" slopes = [] n = len(x) for i in range(n - 1): for j in range(i + 1, n): slopes.append((x[j] - x[i]) / (j - i)) slopes.sort() m = len(slopes) return slopes[m // 2] if m % 2 else (slopes[m // 2 - 1] + slopes[m // 2]) / 2 def cusum_changepoint(x): """CUSUM 变点检测:累计偏差绝对值最大处即拐点""" mean = sum(x) / len(x) cusum, acc, best_i, best_v = [], 0.0, 0, -1.0 for i, v in enumerate(x): acc += (v - mean) cusum.append(acc) if abs(acc) > best_v: best_v, best_i = abs(acc), i return best_i, cusum if __name__ == "__main__": for name, x in SERIES.items(): s, z, p = mann_kendall(x) slope = sens_slope(x) idx, cu = cusum_changepoint(x) direction = "上升" if s > 0 else ("下降" if s < 0 else "无") sig = "显著" if p < 0.05 else ("边缘" if p < 0.10 else "不显著") print(f"【{name}】") print(f" S={s}, Z={z:.4f}, p={p:.6f} → 趋势{direction}({sig})") print(f" Sen's slope = {slope:.3f} /月") print(f" 首月={x[0]} 末月={x[-1]} 累计变化={x[-1]-x[0]:+d} " f"({(x[-1]-x[0])/x[0]*100:+.1f}%)") print(f" CUSUM 变点:{MONTHS[idx]}(累计偏差={cu[idx]:.1f})") print() a, b = SERIES["短视频推荐"][-1], SERIES["AI问答搜索"][-1] tot = a + b print(f"末月结构:短视频 {a/tot*100:.1f}% / AI问答 {b/tot*100:.1f}%") a0, b0 = SERIES["短视频推荐"][0], SERIES["AI问答搜索"][0] t0 = a0 + b0 print(f"首月结构:短视频 {a0/t0*100:.1f}% / AI问答 {b0/t0*100:.1f}%")

四、实跑输出

【短视频推荐】 S=-64, Z=-4.3201, p=0.000016 → 趋势下降(显著) Sen's slope = -17.500 /月 首月=320 末月=158 累计变化=-162 (-50.6%) CUSUM 变点:26-03(累计偏差=321.0) 【AI问答搜索】 S=66, Z=4.4572, p=0.000008 → 趋势上升(显著) Sen's slope = 12.271 /月 首月=12 末月=140 累计变化=+128 (+1066.7%) CUSUM 变点:26-04(累计偏差=-219.9) 末月结构:短视频 53.0% / AI问答 47.0% 首月结构:短视频 96.4% / AI问答 3.6%

五、结果怎么读:三个关键点

第一,p 值告诉你"是不是真趋势"。

两条序列的 p 值分别是 0.000016 和 0.000008,都远小于 0.05。这意味着:如果真实情况是"没有趋势",出现这种数据排列的概率不到万分之二。可以判定为真实趋势,而非随机波动。

反过来,如果某渠道算出来 p=0.35,哪怕逐月看涨跌很热闹,也应判定为"无显著趋势"——别把噪声当信号,这是渠道分析最常见的错误。

第二,Sen's slope 告诉你"变化有多快"。

短视频-17.5 条/月,AI 问答+12.3 条/月。这个数字比百分比更有决策价值:它可以直接换算成"每季度会少多少咨询",用于排产、预算和人力规划。

注意这里有个容易误读的地方:AI 问答一年增长 1066.7%,看起来惊人,但 Sen's slope 只有 12.3/月。增长率高是因为基数低,绝对增量才是真实体感。做趋势分析时,两者必须同时看。

第三,CUSUM 告诉你"什么时候拐的"。

短视频的变点落在26-03,AI 问答落在26-04——两者相隔一个月,相互印证:这不是各自独立的偶然,而是一次结构性迁移的两个侧面。

变点的价值在于:它能帮你回查"那个月到底发生了什么"(政策?平台改版?模型升级?),从而判断这个变化会持续还是会回落。

六、踩坑记录

坑一:MK 的方差公式在有并列值时不适用。

标准公式n(n-1)(2n+5)/18假设数据无并列。业务数据里"两个月咨询量相同"很常见,此时方差需要减去并列修正项:

# 有并列值时的修正(t_i 为第 i 组并列值的个数) tie_sum = sum(t * (t - 1) * (2 * t + 5) for t in tie_groups) var = (n * (n - 1) * (2 * n + 5) - tie_sum) / 18.0

忽略这个修正,会让 Z 值偏大、p 值偏小,容易把不显著的趋势判成显著。

坑二:连续性校正的方向不能写反。

S>0 时是(S-1)/√var,S<0 时是(S+1)/√var——都是往 0 的方向收缩。我第一版写成了统一(S-1),导致负趋势的 Z 值被系统性低估,p 值偏大,把显著的下降判成了不显著。

坑三:CUSUM 找的是"累计偏差最大",不是"变化率最大"。

初版我想当然地去找"相邻两点差值最大"的月份,结果定位到的是某个月的异常波动,而不是趋势拐点。CUSUM 的语义是累计偏离均值最远——它衡量的是"漂移积累到了什么程度",不是"某一刻跳了多少"。

坑四:Sen's slope 的中位数取值要对齐。

点对总数m = n(n-1)/2。m 为奇数取正中间,偶数取中间两个的平均。我用m//2判断奇偶时要小心:Python 的//是向下取整,m=66(n=12)时m//2=33,取slopes[32]和slopes[33]求平均,索引不能写错,否则斜率会偏移。

七、方法边界

  • MK 检验的是"单调趋势",对"先涨后跌"的倒 U 型序列可能给出"无显著趋势",此时应分段检验或改用其他方法;
  • 它不解释原因:趋势显著只说明"在变",不说明"为什么变",因果仍需业务判断;
  • 对样本长度敏感:n 小于 8 时检验力很弱,建议至少 10 个观测点(月度数据即 10 个月以上);
  • 季节性未处理:若数据有明显季节周期(如寒暑假招生高峰),直接做 MK 会被季节成分干扰,需先做季节分解或改用 Seasonal Mann-Kendall;
  • 变点不等于因果拐点:CUSUM 定位的是统计意义上的偏移点,是否由某个具体事件引起,需外部信息印证。

八、小结

"Mann-Kendall 判有没有趋势 + Sen's slope 算变化速度 + CUSUM 找拐点",这套组合用不到一百行零依赖代码,把渠道分析从"看月度涨跌凭感觉",变成了可检验、可量化、可定位的过程。

它最大的价值不是给出一个漂亮结论,而是帮你否掉那些看起来像趋势、其实只是噪声的判断——在渠道投入这件事上,少犯一次错,比多抓一次机会更值钱。

作者:镜子AI技术团队。本文为通用工程方法分享,示例数据为脱敏构造,不涉及任何具体产品评估,也不构成效果或排名承诺。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询