1. 项目缘起:“山谷数”的来龙去脉
“山谷数”这个名字,是我给一个数据低谷自动标注小工具起的乳名。说白了,它做的事情就一句话:从一堆按时序排列的数字里,把那些像山谷一样凹陷下去的低谷点找出来,标清楚时间、数值和坡度,让你一眼看出数据曲线里哪些位置正在“触底”。2025年2月21日那天,我因为一个临时数据复盘需求把这个脚本从硬盘里翻出来改了改,顺手把效果截图发到行业交流群里,结果一个下午讨论量就没停,后来不少朋友直接管这类低代码数据标注方案叫“山谷数”,它就这么成了一个带有流传性质的圈内小热词。
先说清楚这个工具解决的痛点。大多数做过运营分析、数据复盘的人都有同感:我们盯着增长曲线的最高点,讨论新增峰值、爆发节点、历史新高,却很少有人认真对待曲线里的最低点。最高点意味着一次成功的方法论,但最低点往往才是隐患的开始——留存率骤降、销售额触底、活跃度滑坡,这些事情如果你不能在当天发现,等它蔓延到下一周,复盘成本至少翻三倍。我见过太多团队月底拉出折线图,一眼看见那个“大坑”,却不知道坑具体发生在哪一天、持续了多久、斜率有多陡,只能靠人工一格格去点坐标轴,效率极低。
所以“山谷数”的核心思路很简单:把人工扫图变成数学判断。程序自动扫描一段曲线,用“局部极小值”这个基础算法来定位真正的谷底,再通过灵敏度参数过滤掉那些不值得惊动的微小波动,最后把结果直接叠在原始折线图上,标注异常凹点。整个过程不依赖机器学习,不涉及云端服务,本地跑一个Python脚本就能完成,十分钟之内就能处理完一个月的数据。
这个项目特别适合三类人看:一是运营和数据分析岗的从业者,想快速定位业务低潮期;二是刚接触Python和pandas的编程学习者,拿它当练手的完整小项目非常合适;三是那些维护着自己开发的统计看板、想给图表加上自动提醒功能的人。接下来我会从算法思路、完整代码、参数调优到踩坑记录,把整个过程原原本本拆开讲。
2. 核心思路拆解:为什么低谷点比高峰点更值得自动化
2.1 低谷识别背后的业务逻辑
我最早想做这个工具,是因为一个很具体的场景。某产品团队做用户留存周报,每周一上午都要人工翻看过去七天的留存曲线,记录哪天掉得最狠。这个活儿重复性极高,而且人眼判断很容易被偶然波动带偏:某天留存率掉了0.3%,作图软件里显示成一个小缺口,新手可能立刻在周报里把它标红,但拉长时间看,这只是正常的随机浮动。
低谷点的价值恰恰就藏在这种“区分”里。一个数据低谷背后通常对应三类事件:要么是产品发了质量有问题的版本,要么是某个渠道投放突然中断,要么是外部环境导致用户行为异常。识别出真正的谷底,就等于给业务问题打了一个时间戳,顺着这个时间戳去翻操作记录、发布记录、渠道报表,通常很快能定位责任人或者诱因事件。而识别高峰点,更多是锦上添花地确认“我们做对了什么”,对于负面事件的预防价值远不如低谷点。
从决策链条来看,低谷点的自动化识别能真正减少人工环节。以前运营小组需要三人轮流复核周报曲线,现在脚本每天定时跑一遍,谁不小心漏看数据,第二天也能通过标注图快速弥补。这就是我把工具命名成“山谷数”的原因:数据里面那些像山谷一样凹陷的段落,每一个都值得被量化、被记录、被解释。
2.2 算法选型:为什么先用滑动窗口极小值
“山谷数”的核心算法不复杂,我一开始就没打算上深度学习或者时间序列分解模型。原因有三:第一,业务方要求的是“快”和“可解释”,你说你是用LSTM预测出来的低谷,对方第一反应是算法可信吗、参数怎么调的、换一组数据还准不准;但你说“每个点的左右邻居都比它高,所以它是谷底”,对方一听就懂,哪怕有少量误判,也能自己判断该不该采纳。第二,真实业务曲线绝大多数是二维表格数据,量级从几十条到几千条不等,用复杂模型反而要处理训练集、验证集、特征工程,项目周期拖到两周以上,完全不值得。第三,滑动窗口极小值方法天然支持流式处理,数据一行行来,窗口一个个滑,内存占用几乎为零。
但“局部极小值”也有个致命弱点:它对噪音极度敏感。比如一条日活跃曲线,正常情况下在每分钟都有几十次微小波动,如果你只看一个点和它左右两个邻居,十分钟里能标出十几个“伪山谷”。这就是为什么后面必须加滤波和阈值判断,这两个部分才是“山谷数”真正花心思的地方。
我用过一个生活化的比喻来解释这套流程:把曲线想成一段山路,你要找的是真正的“谷底”,而不是石头缝里的小坑。你会先站远一点看整体轮廓(平滑去噪),然后观察连续下坡再上坡的完整结构(窗口扫描),最后判断这个谷够不够深、够不够陡(阈值过滤)。三步走完,才敢说某个点是值得关注的山谷数。
2.3 方案对比:三种低谷识别策略的取舍
| 识别策略 | 基本原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 简单滑动窗口极小值 | 当前点小于左右固定窗口内所有点 | 实现简单、计算快、易解释 | 噪音多、无法处理平谷 | 数据量大、曲线相对平滑、快速筛查 |
| 移动平均线交叉法 | 曲线下穿长期均线后重新上穿,标记穿叉位置 | 抗噪较强、能识别持续低位阶段 | 滞后明显、无法给出精确谷点、参数challenge多 | 周级别以上业务指标分析 |
| 样条拟合后求导零交叉 | 先拟合平滑曲线,再对导数为零且二阶导为正的位置 | 精准识别谷点、抗噪优秀 | 计算开销大、拟合参数敏感、需要解释成本 | 研究型分析、精确值要求的报告 |
如果只做一版,我还是推荐方案一作为起点,先把流程跑通,再根据实际曲线形态往方案二或者方案三演进。“山谷数”本身很快,重跑一次不到一秒,所以你完全可以先把结果输出成表格,再人工抽样验证,确认方法可靠后再扩大应用范围。
3. 实操指南:从0到1快速搭建“山谷数”标注工具
3.1 环境准备
我平时用的组合是Python 3加pandas、numpy、matplotlib,这几个库都是数据分析标配,安装方式我直接贴一下:
pip install pandas numpy matplotlib如果你的机器上已经有Anaconda环境,那这几个库大概率都装好了,直接用就行。本项目不需要额外数据库、不需要申请任何接口,纯本地运行,数据安全上也不用担心外泄。
准备一张CSV格式的数据表,至少包含两列:一列是时间或者顺序编号,一列是数值。我建议表头命名为date和value,方便直接套用下面的代码。我用模拟数据来举例:假设某自媒体账号2025年2月期间的日阅读量数据,连续21天,中间有两处明显的“滑坡阅读低谷”,分别对应一次内容调整失误和一次平台推荐中断。
3.2 第一步:数据加载与预处理
不管什么分析,数据清洗永远是第一步。在“山谷数”这个项目里,我吃过最大的亏就是CSV里混了几行空值,导致滑动窗口计算时索引错位,标出来的“山谷”位置跟真实时间对不上,整个图看起来就像鬼画符。所以代码里我坚持做三步预处理:
import pandas as pd df = pd.read_csv("sample_data.csv") df["date"] = pd.to_datetime(df["date"]) df = df.dropna() df = df.drop_duplicates(subset="date", keep="last") df = df.sort_values("date").reset_index(drop=True)第一步,把日期字符串转成pandas可识别的时间对象,后面画图时坐标轴才会自动调整间距。第二步,删除缺失值行,缺失值在滑动窗口里会让比较逻辑失效,宁可不看那一天也不能让程序报错。第三步,按日期排序并重置索引,索引对了,后面所有窗口操作才能保证正确。
预处理完之后,我强烈建议打印一下数据总量和日期范围:
print(f"数据条数: {len(df)}") print(f"日期范围: {df['date'].min()} 至 {df['date'].max()}")我遇到过几次数据源导出的文件只有半天数据,读进来才发现日期范围不对,导致分析结论完全不能用。所以这条打印语句几乎是零成本的安全保障,一定不要省。
3.3 第二步:平滑去噪,把“小石子”扫掉
直接对原始数据跑极小值判断,你会得到一堆假山谷。比如阅读量在一天内波动5%非常正常,但这个波动足以让“左右邻居高于当前点”的条件成立,结果就是噪声全被当成低谷。解决方法是先对原始值做一次滚动平均,把微小的毛刺抹平。
df["smooth"] = df["value"].rolling(window=3, center=True, min_periods=1).mean()这里的window参数是平滑窗口大小,设为3表示取当前点和左右各一天的平均值。这是一个很轻量的平滑,能把小时级的毛刺抹掉,但不会改变大趋势的形状。如果曲线本身波动特别剧烈,比如电商订单量早晚高峰差好几倍,可以把窗口提到5,但要注意窗口越大,真实低谷的位置会被拉扯得越偏,后面找出来的“谷底时间”可能跟你实际业务发生时间差出两三天。
我自己的经验是:先从严开始,window=3跑一版,看识别结果里噪声点占多少;如果明显太多,再逐步加大到5或者7,直到结果中绝大多数被标出来的低谷都能在业务层面解释通。平滑后的数值,我放在新列smooth里,这样原始数据仍然保留着,后续做对比分析时还能用。
3.4 第三步:核心识别逻辑——滑动窗口极小值
核心算法分两部分:先找到所有候选谷点,再根据深度阈值过滤。
import numpy as np window_size = 2 # 左右各看2个点,共5个点窗口 valleys = [] values = df["smooth"].values dates = df["date"].values for i in range(window_size, len(values) - window_size): center = values[i] left_min = np.min(values[i - window_size:i]) right_min = np.min(values[i + 1:i + window_size + 1]) if left_min > center and right_min > center: valleys.append({ "date": dates[i], "value": round(center, 2), "left_min": round(left_min, 2), "right_min": round(right_min, 2), }) valley_df = pd.DataFrame(valleys) print(f"初步识别出 {len(valley_df)} 个候选谷点")这段代码的逻辑是一条比较宽的判断:当前点必须小于左边两个点的较小值,同时小于右边两个点的较小值。window_size设置成2,意味着一个真正被认定的谷底,至少要“凹”出至少两个时间单位的样子,在左右各两个观察点上都是最低的。这样就把那些只靠一个异常噪音点造成的假凹陷排除了大半。
如果要形更复杂的地形,比如一个V字形“V”的谷底正好落在两个点的正中间,那么这两个点谁都不满足条件,可能导致山谷漏检。遇到这种情况,把window_size调到1再跑一遍,看看新增的候选点是不是合理,然后人工介入做判断。
3.5 第四步:深度过滤,只留“值得看”的谷
候选谷点圈出来之后,还需要一个业务层面的筛选条件:这个谷到底深不深、陡不陡?如果一个候选谷只比左右邻居低0.1%,那可能是平滑后残余的轻微波动,不该占用分析精力。我加了两道闸门:
volume_threshold = 0.1 # 谷底相比两侧均值下跌比例阈值 slope_threshold = 0.05 # 左侧斜率阈值 def is_significant(row): baseline = (row["left_min"] + row["right_min"]) / 2 decline_ratio = (baseline - row["value"]) / max(baseline, 1e-9) if decline_ratio < volume_threshold: return False return True valley_df["significant"] = valley_df.apply(is_significant, axis=1) final_valleys = valley_df[valley_df["significant"] == True] print(f"通过深度过滤的谷点: {len(final_valleys)} 个")volume_threshold的意思是,谷底要比两侧较小值的均值低至少10%,才认为这是一个真正的“低洼区”。这个参数一定要依据业务特性来调。如果分析的是日活跃用户,10%可能太敏感,因为付出一半精力做活动才可能撬动10%的日活变化;如果分析的是页面响应耗时,10%的波动可能又太小,因为响应时间抖动20%都不一定说明系统有问题。还有一个经验公式可以帮你设初始阈值:先计算整个序列的标准差,然后让volume_threshold在0.5倍到1倍标准差附近试。
至于斜率判断,我后来简化掉了一部分,因为滑动窗口已经隐含了形态要求,再加上去会让代码又长又不好解释。但你可以在数据可视化阶段直接看标注点的连线坡度,用肉眼判断哪些候选谷过于“平坦”,再决定是否追加过滤逻辑。
3.6 第五步:可视化,一键出图
识别出来的数据如果只停留在表格里,价值很打折扣。最直接的方式是把原始曲线、平滑曲线和谷点标注画在同一张图上:
import matplotlib.pyplot as plt plt.figure(figsize=(12, 6)) plt.plot(df["date"], df["value"], label="原始值", color="#888", linewidth=1.5) plt.plot(df["date"], df["smooth"], label="平滑值", color="#333", linewidth=2) plt.scatter(final_valleys["date"], final_valleys["value"], color="red", s=60, zorder=5, label="识别谷点") for _, row in final_valleys.iterrows(): plt.annotate( f"谷底 {row['date'].strftime('%m-%d')}", xy=(row["date"], row["value"]), xytext=(8, 10), textcoords="offset points", fontsize=9, color="red", ) plt.legend() plt.title("山谷数:数据低谷自动识别结果") plt.xticks(rotation=45) plt.grid(alpha=0.4) plt.tight_layout() plt.savefig("valley_result.png", dpi=150) plt.show()注意,我用的是matplotlib的scatter散点图把谷点叠加在曲线上,这样眼睛一眼就能看到几个红点是否落在“谷底”的正确位置。如果红点标在了上坡位置,那大概率是平滑窗口和数据索引对不齐,回头看预处理步骤。如果红点扎堆出现在同一段区间,说明平滑力度不够,需要调大window。
出图之后我还会做一个动作:把最终识别结果导出一张CSV,包含日期、原始值、平滑值、左邻最小值、右邻最小值、跌幅比例这六列。这张表既是复盘的素材,也是给团队其他同事分配确认任务时的依据。
final_valleys.to_csv("valley_export.csv", index=False, encoding="utf-8-sig")加utf-8-sig编码是为了让Excel直接打开CSV时中文不乱码,这个小细节是我踩过坑才记住的。
4. 常见问题与排查技巧实录
4.1 候选谷点太多,噪声满天飞
最典型的现象是出图后红点密集排列,每隔一两天就有一个“谷”,看起来像锯齿上的一个个小缺口,根本分不清哪些值得管。这通常不是算法错了,而是数据本身噪音水平太高,平滑窗口又太小。
排查路径分三步走。第一步,检查平滑窗口,先按window=1、3、5、7四档分别出图对比,看哪个档位能让谷点数量发生数量级变化。第二步,检查数据源是不是有异常值,比如某天数据上报重复覆盖导致当天数值异常偏低,这种情况滚动平均解决不了,得先做异常值过滤。第三步,把volume_threshold调高,比如从0.1调到0.2甚至0.3,让程序只关注“深度显著”的谷,宁可漏掉一些浅谷,也不要让团队陷入“天天都在报警”的疲惫状态。
我自己实际测试下来,一套数据如果谷点数量超过总时间点的15%,那这套参数基本不适用,肯定有某个环节出了问题。
4.2 平滑力度过大,真实低谷被磨平
有次分析某产品七日留存曲线,我为了压噪声把window设成了9,结果算法竟然一个谷点都没找出来。后来画原始曲线才发现,原本应该在第4天出现的留存率暴跌被九点滚动平均“摊平”了,原先的深坑被前后几天的数据一平均,变成了一个缓坡,再也满足不了“左右邻居大于中心”的条件。
这个教训让我总结出一条原则:平滑窗口绝不应该超过你关心的最小时间跨度的三分之一。如果你关注的是周级别低谷,那平滑窗口最大到3;如果关注的是月度低谷,平滑窗口可以到7;如果要捕捉“突然一天暴跌”这种短期事件,甚至不该用滚动平均,而是直接用原始值配高阈值来过滤。
4.3 缺失值导致谷点误判
数据源不是百分之百完整的。周末不上报、接口偶尔宕机、数据仓库字段被清空,这些都会让CSV里出现空行。pandas的dropna会把这些行列掉,但问题来了:如果某一天缺失被删掉,原本相邻的两个点就被强行凑到了一起,滑动窗口看到的是一个“跨时间跳跃”的两点之间隐藏的凹陷,谷底位置就会和真实业务日期产生偏差。
我后来采用了一个更稳的处理方式:不直接删行,而是用前后两天的均值插值填充缺失日期,这样时间序列保持连续,滑动窗口的邻域逻辑不受影响。
df = df.set_index("date").resample("D").asfreq("D") df["value"] = df["value"].interpolate(method="linear").fillna(method="bfill").fillna(method="ffill") df = df.reset_index()注意,resample("D")要求日期列是DatetimeIndex,sample里的一天很重要,保证每天都能占一行。插值填完之后,最好也画图瞄一眼,因为如果连续缺失超过三天,线性插值会把一个极长的“平坦期”插出来,这在业务上基本等于这段区间无法分析,要在结果里标注“数据缺失区间”而不要硬解释。
4.4 边界点漏检与首尾段处理
滑动窗口遍历只能覆盖从第二个窗口位置到倒数第二个窗口位置之间的数据,最前面两个点和最后面两个点天然无法进入候选集。很多业务低谷恰好发生在月初或月底,比如每月最后一周做促销清仓,月末收入骤降,这如果漏检就非常尴尬。
我的处理方式是在循环结束后额外检查边界:
border_window = 1 if len(values) > 2 * border_window + 1: left_border_valley = values[border_window] if values[border_window] < values[border_window - 1] and values[border_window] < np.min(values[border_window + 1:border_window + window_size + 1]): 手动审视该点 right_border_valley = values[-border_window - 1] if values[-border_window - 1] < values[-border_window] and values[-border_window - 1] < np.min(values[-window_size - 1:-1]): 手动审视该点说白了,就是对于首尾区域,我用更小的窗口单独检查一遍,找到候选点后先不下结论,标记成“边界疑似谷”,供人工复核。这样既保证了覆盖范围,也不会因为放宽条件导致一堆噪音点混进来。
4.5 业务解读的常见误区
“山谷数”找到了一个谷底点,不代表那个点本身就是事故原因。很多时候谷底是一个滞后指标,比如你3号发了新版本,用户反馈不好,但由于口碑传播有延迟,真正的最低日活发生在6号。这时候如果只盯着“6号低谷”排查,可能把责任错怪在当天的运营活动上,而真正的问题藏在三天前。
所以我在交付分析报告时,每次都会提醒使用者:低谷点只是一个“时间戳”,它的价值是帮你把排查窗口缩窄到山谷前后3天,但真正要翻的是那个窗口里的所有变更记录。这个方法论层面的提醒,跟工具本身同样重要。
5. 参数调优实战笔记
“山谷数”最常用的三个参数是平滑窗口(window)、深度阈值(volume_threshold)和滑动窗口大小(window_size)。我整理了一份根据数据特征的参考表:
| 数据结构特征 | 平滑窗口建议 | 深度阈值建议 | 滑动窗口建议 | 排查重点 |
|---|---|---|---|---|
| 日粒度、波动小(如员工考勤率) | 3 | 0.15 | 2 | 周一/周五周期性低谷 |
| 日粒度、波动大(如电商销量) | 5-7 | 0.25 | 2-3 | 促销前后异常低谷 |
| 周粒度、数据稀疏(如周订单量) | 1 | 0.2 | 1-2 | 周与周之间跳变 |
| 小时粒度、在线曲线(如服务器负载) | 7-15 | 0.3 | 3-5 | 突发故障时段 |
| 月粒度、长期趋势(如用户规模) | 1 | 0.1 | 1 | 季度性政策影响 |
参数调整时,我习惯用一套“双图对照法”:在同一张画布上画两个子图,左边用宽松参数(小窗口、低阈值),右边用严格参数(大窗口、高阈值),然后并排观察同一组数据在不同参数下的表现。这个方法能快速确定一个合理的参数集合,而不需要反复手动跑几十轮。某团队最后确定下来的日活跃低谷识别参数是window=5、volume_threshold=0.2、window_size=2,这套组合在三个月的历史数据上能把真实业务低谷的命中率做到九成以上。
还有一个小技巧,把最终识别结果按“连续山谷趋势”做个分组:如果两次谷底之间间隔不超过三天,就把它们合并成一个“低谷期”,计算低谷期的起始、谷底、恢复点。这样交付给老板看的时候,不是一堆零散的红点,而是一个个完整的业务故事素材。
final_valleys["prev_date"] = final_valleys["date"].shift(1) final_valleys["gap"] = (final_valleys["date"] - final_valleys["prev_date"]).dt.days final_valleys["new_group"] = (final_valleys["gap"] > 3).cumsum()6. 进阶扩展方向
基础版本的“山谷数”已经能解决90%的“找出低谷在哪天”的问题,但如果你想让工具更顺手,下面这几个方向值得尝试。
一是自动提醒。把识别结果接入企业微信或者邮件通知,每天早上八点自动发送前一天的“低谷预警”。实现方式不复杂,只要在脚本最后加一个条件判断:当天数据被识别为谷点就直接发送通知。这样运营同事甚至不需要主动打开图表去看,异常信号直接推送到手里。
二是基于山谷恢复时间的分类。我后来发现,低谷之后的反弹速度比低谷深度更能说明业务韧性。有的谷底很深,但24小时内就完全恢复,这是典型的偶发因素;有的谷底虽然浅,但连续五六天都回不到正常水平,这大概率是结构性恶化。按“深度×恢复天数”两个维度把低谷分成四类,分析价值会再上一个台阶。
三是和监控看板集成。如果你用了现成的数据看板,可以把“山谷数”的标注坐标直接通过接口写到看板配置里,让低谷标记自动出现在企业内部的日常监控页面上。这个做法的门槛稍微高一点,但完成后的收益很稳定,毕竟团队每天第一眼看到的东西就是有数据敏锐度的地方。
四是多数据源对比。有时候单条曲线的低谷不好解释,但如果你同时看销量曲线、退货曲线、客服投诉曲线,会发现三个谷底发生在完全不同的日期段,交叉对比就能定位到更细的归因链条。这块我还在摸索,但思路基本是:对每个数据源分别跑“山谷数”,然后按“日期漂移容忍度3天”做相关匹配。
我个人的体会是,像“山谷数”这种小而实用的工具,真正的价值不在于算法有多高级,而在于它能稳定地把一个原本靠感觉判断的环节变成可量化的流程。只要你愿意花一下午把参数调好、把样例数据跑通,后续每次复盘都能省下至少半小时,这个投入产出比,怎么算都不亏。最后想说的是,数据曲线里的每一个低谷,其实都是在提醒你:系统出问题了,但问题还没大到不可收拾。抓得住触底信号,才有机会把后续的下滑拦在门外。