EDA本质是业务侦探工作:从数据存在性到叙事性三层提问

1. 项目概述:这不是一份报告,而是一场与数据的深度对话

“Exploratory Data Analysis (EDA) — Don’t ask how, ask what”——这句话不是口号,而是我带过七届数据科学新人时反复强调的第一课。它直指当下一个普遍却危险的误区:太多人一拿到数据,就急着打开Jupyter Notebook,敲下 df.head() ,然后立刻冲向建模流程,仿佛EDA只是建模前必须走完的“打卡环节”。他们问“怎么画箱线图”,却从不问“这个异常值背后是不是业务流程出了岔子”;他们调参优化 seaborn.histplot(bins=30) ,却对直方图右端那个突兀的尖峰视而不见,更不会去翻销售系统日志查证那三天的订单激增是否源于一场未备案的促销活动。EDA的本质,从来不是技术操作的堆砌,而是一场以“是什么”为唯一问题的、带着怀疑精神的侦探式调查。它要求你暂时放下算法偏好,把数据当作一个有呼吸、有矛盾、有故事的活体对象来观察。你看到的每一个缺失值模式,都可能是某条产线传感器校准失效的指纹;你发现的某两个变量间微弱但稳定的负相关,可能暗示着客服响应时长与客户复购率之间被长期忽略的因果链。这篇文章不教你怎么用 pandas_profiling 一键生成报告,而是带你回到EDA最原始、最锋利的状态:只带一支笔、一张纸(或一个干净的notebook),用眼睛看、用手算、用常识质疑。适合刚转行的数据新人、被KPI压得只能跑通pipeline的分析师,以及那些总在模型上线后被业务方一句“这结果和我们感觉不一样”问得哑口无言的算法工程师。如果你需要的不是“如何快速出图”,而是“如何让数据开口说话”,那么接下来的内容,就是你真正该花时间的地方。

2. EDA的核心设计逻辑:从“技术流水线”到“业务问题探测器”

2.1 为什么必须抛弃“先清洗再分析”的线性幻觉?

绝大多数教程把EDA塞在“数据获取”和“特征工程”之间,形成一条看似严谨的流水线:获取→清洗→EDA→建模→评估。这个结构本身就是一个巨大的认知陷阱。我曾接手一个电商退货预测项目,团队按标准流程走完:先用 df.isnull().sum() 统计缺失,发现“收货地址邮编”字段缺失率达42%,于是果断用众数填充;接着做分布分析,发现“下单到付款时长”呈严重右偏,便用 np.log1p 强行正态化;最后建模,AUC达到0.82,大家一片欢腾。上线两周后,业务方打来电话:“你们模型说下周退货率会飙升30%,可我们刚砍掉了所有高退货SKU的广告投放,库存也清得差不多了,这预测完全反常识!”我们回溯才发现,“收货地址邮编”缺失并非随机,而是集中出现在使用公司邮箱注册、且订单金额超过5000元的B端客户身上——他们根本不需要填写邮编,因为发货由销售经理线下协调。而“下单到付款时长”的右偏峰值,恰恰对应着财务部门每周三下午集中处理大额对公转账的时间段。所谓“异常”,全是业务规则的自然投影。真正的EDA,必须在任何清洗动作发生前启动。它的第一张图,永远是 df.info() df.describe(include='all') 并排展示的原始快照,连缺失值的分布位置(是整列缺失?还是集中在某几行?)都要用 df.isnull().sum(axis=1).value_counts().sort_index() 画成柱状图。因为清洗不是为了“让数据变好看”,而是为了“让数据更真实地反映业务”。每一次填充、删除、转换,都是对原始信号的一次干预。EDA要做的,是先听清信号原本的杂音,再决定要不要、以及如何降噪。

2.2 “Don’t ask how, ask what”的三层穿透式提问框架

我把“问什么”拆解为三个递进层次,每层都对应一套不可跳过的检查清单。这不是模板,而是思维肌肉的记忆:

第一层:数据存在性之问(What exists?)
这是最基础、却最容易被跳过的层面。它不关心数值,只确认“这个东西到底有没有、有多少、在哪里”。

  • 字段级 df.columns.tolist() 列出所有字段名后,逐个追问:“这个名称在业务字典里有明确定义吗?‘用户等级’是指注册时长、消费总额,还是VIP积分?‘订单状态’的‘已关闭’,包含‘用户主动取消’和‘超时未支付’两种完全不同的业务动因吗?” 我见过最离谱的案例,是某金融风控表里一个叫 risk_score 的字段,文档写“取值范围0-100”,实际数据中却出现-5和1000,追查发现是开发误将日志错误码混入了主表。
  • 记录级 len(df) 给出总数,但更要问:“这个总数合理吗?对比上游系统导出日志,行数是否一致?如果少了2000行,是ETL任务失败,还是权限控制导致部分区域数据被过滤?” 我们曾通过比对 df['order_id'].nunique() len(df) ,发现重复订单ID高达17%,根源是支付网关重试机制未做幂等处理,这直接改变了后续所有分析的基数。
  • 值域级 df['age'].describe() 显示均值35,但 df['age'].min() 是-12, max() 是156。这时不能只写“存在异常值”,而要问:“-12这个值,在业务上代表什么?是数据库默认值填错?还是用户输入时用‘-’代替了‘未填写’?如果是后者,那所有年龄为-12的用户,其‘性别’字段是否也全为空?这种关联缺失模式,可能指向某个特定渠道的埋点缺陷。”

第二层:数据关系性之问(What connects?)
当单个字段的“存在”被确认后,焦点转向字段间的动态联系。这里拒绝静态的相关系数,拥抱业务场景驱动的交叉验证。

  • 时间维度 df['order_date'] 的最小值是2023-01-01,最大值是2023-12-31,表面看覆盖全年。但 df.set_index('order_date').resample('D').size().plot() 画出的日度订单量曲线,会暴露真相:2月14日、6月18日、11月11日出现断崖式高峰,而2月15日、6月19日、11月12日则跌入谷底。这绝非随机波动,而是大促活动“预售-爆发-返场”节奏的铁证。此时必须问:“活动期间的‘优惠券使用率’与平日相比,是上升还是下降?如果下降,是因为券已发完,还是用户觉得满减门槛太高而放弃?” 这个问题的答案,直接决定后续特征工程的方向——是构造“是否大促期”布尔特征,还是提取“距最近大促天数”连续特征。
  • 分类维度 df.groupby('product_category')['price'].agg(['mean', 'std']) 显示“手机”类目价格标准差极大。这时不能止步于“价格离散度高”,而要切片:“高端旗舰机(价格>5000)的退货率,是否显著高于中端机型(2000-4000)?如果答案是肯定的,再深挖‘高端机退货原因’字段的TOP3,是否集中于‘屏幕划痕’和‘配件缺失’?如果是,那问题就从‘价格策略’转向了‘物流包装规范’或‘门店验机流程’。” 这种穿透,让EDA从描述性分析跃升为根因定位的起点。
  • 逻辑维度 df.query('payment_status == "paid" and delivery_status == "pending"') 筛选出一批“已付款未发货”订单。数量是137单。关键问题是:“这137单的创建时间,是否全部集中在过去2小时内?如果是,大概率是支付系统延迟;如果分散在全年,且集中在每月25号之后,则极可能是财务月结期间的对账冻结。” 一个简单的逻辑组合查询,配合时间戳分析,就能区分出技术故障与流程瓶颈。

第三层:数据叙事性之问(What tells a story?)
这是EDA的最高境界,它要求你像读小说一样读数据,寻找那些违背常理、自相矛盾、或暗藏伏笔的“情节破绽”。

  • 反常识模式 df.corr()['churn_rate'] 显示“月均登录次数”与流失率呈弱正相关(r=0.12)。这明显违背直觉。此时必须放弃相关系数,做分位数切片: df['login_count'].quantile([0.1, 0.5, 0.9]) 得到低/中/高活跃用户群,再分别计算各群组的流失率。结果发现:低活跃群流失率45%,中活跃群22%,高活跃群却高达68%。故事浮现了——高活跃用户并非“忠实”,而是“问题用户”:他们频繁登录是为了反复投诉APP闪退、订单无法提交。 df.query('login_count > 30').groupby('error_code').size().sort_values(ascending=False).head(3) 立刻验证了猜想:错误码 ERR_5002 (支付网关超时)、 ERR_4041 (商品详情页加载失败)、 ERR_503 (服务不可用)占了高活跃用户报错的78%。
  • 沉默的大多数 df['review_score'].value_counts(dropna=False).sort_index() 显示,42%的订单完全没有评价。这不是缺失值,而是“零记录”。这时要问:“没有评价的用户,其‘购买频次’、‘客单价’、‘客服咨询次数’,与有评价的用户相比,有何差异?” 我们发现,零评价用户中,有35%在下单后24小时内有过至少3次客服咨询,且咨询主题90%集中于“订单状态查询”。这说明,不是用户不愿评价,而是他们被卡在了“不知道订单到哪了”的焦虑中,评价入口早已被遗忘。
  • 消失的中间态 df['delivery_days'].hist(bins=50) 直方图显示,配送时长集中在1天(占比38%)和7天(占比41%),而2-6天的订单合计不足5%。这绝非正常物流分布。深入 df.query('delivery_days == 1 or delivery_days == 7')[['warehouse_id', 'delivery_region']] ,发现1天达全部来自华东仓发往长三角,7天达全部来自华南仓发往西北。故事清晰了:公司只有两套物流方案——“核心区域极速达”和“全国普配”,中间地带的服务能力完全空白。这个发现,直接推动了物流网络优化项目的立项。

这套三层提问框架,不是为了炫技,而是为了构建一个“问题漏斗”。每一层都在过滤掉无关噪音,聚焦到真正值得投入建模精力的业务矛盾上。它让EDA从“技术合规检查”蜕变为“业务价值勘探”。

3. 核心实操要点:用最朴素的工具,做最锋利的洞察

3.1 数据概览:一张表,三张图,定生死

在敲下任何一行代码前,我强制自己完成“133”仪式:一张数据字典速查表、三张原始分布图、三次手动抽样验证。这是防止被数据表象欺骗的最后防线。

一张数据字典速查表
这不是从文档里复制粘贴的,而是基于 df.info() df.describe(include='all') 现场手绘的。表格包含四列:字段名、数据类型、非空计数、典型值示例(手动 df[col].sample(3).tolist() )。重点在于“典型值示例”列,它强迫你直面数据的真实形态。例如, df['user_id'] 的数据类型是 object ,典型值示例是 ['U1001', 'U1002', 'U1003'] ,这很健康;但如果示例是 ['U1001', '1002', 'U1003'] ,就立刻触发警报——数据类型混乱,必然存在格式清洗风险。再如 df['promo_code'] ,典型值示例若为 ['SUMMER2023', 'NULL', 'FREESHIP'] ,那个 'NULL' 字符串就是典型的“伪缺失值”,必须与 np.nan 严格区分处理。这张表,我通常用Markdown表格手写在notebook第一个cell里,作为整个分析的“宪法”。

三张原始分布图
放弃一切美化,只用 matplotlib 最基础的 plt.hist() plt.boxplot() plt.bar() ,画出最原始的三张图:

  • 数值型字段直方图 :对每个 float64 / int64 字段,执行 df[col].hist(bins=min(50, len(df)//10)) 。关键不是看形状,而是看横轴刻度—— df['price'].hist() 的横轴如果从0到10000000,而99%的数据挤在0-500区间,那说明存在极端异常值,必须用 df['price'].quantile(0.99) 重新设定 xlim 。我见过最致命的疏忽,是某信贷模型用 df['income'].hist() 默认bins=10,结果把年收入10万和1000万的用户都归入“高收入”桶,彻底抹杀了高净值客群的风险特征。
  • 分类型字段条形图 :对每个 object 字段,执行 df[col].value_counts(dropna=False).plot(kind='bar') 。重点观察“Other”类别的高度——如果 df['city'].value_counts().tail(1).values[0] (即最少出现的城市)占比高达15%,那说明城市字段存在严重的聚合失真,可能需要合并小城市为“其他”。更关键的是看 dropna=False 参数带来的变化:如果 df['city'].value_counts().sum() 是10000,而 df['city'].value_counts(dropna=False).sum() 是10200,那200个 NaN 就是明确的业务信号,必须追问“为什么2%的用户不填城市?是海外用户?还是H5页面城市选择控件失效?”。
  • 时间字段密度图 :对 datetime64 字段,执行 df['order_time'].dt.date.value_counts().sort_index().plot() 。这不是看趋势,而是找“断点”。图中任何连续多日的零值,都是ETL中断、数据源停摆或业务停摆的铁证。2022年某生鲜平台的分析中,我们在12月24-26日看到订单量断崖,本以为是圣诞影响,直到发现 df.query('order_time.dt.date >= "2022-12-24" and order_time.dt.date <= "2022-12-26"')['warehouse_id'].nunique() 为0——所有仓库ID为空,才确认是上游WMS系统崩溃,而非用户行为变化。

三次手动抽样验证
这是最耗时、却最不可替代的步骤。我固定抽取三组样本:

  • 首尾样本 df.head(3) df.tail(3) 。看第一行和最后一行的 created_at 时间戳,确认数据时间范围是否符合预期;看 status 字段,确认起始和结束状态是否合理(如第一行 status 'draft' ,最后一行是 'completed' ,说明流程完整)。
  • 随机样本 df.sample(5, random_state=42) 。不看统计,只读原始记录。重点关注字段间的逻辑一致性: df.loc[1234]['payment_method'] 'alipay' ,那 df.loc[1234]['alipay_transaction_id'] 是否非空?如果为空,是字段命名错误,还是支付渠道映射逻辑有漏洞?
  • 异常样本 :基于前述三张图,手动挑出最刺眼的点。比如直方图里那个孤零零在1000万的 price 值,或者条形图里那个占比0.001%的 'city' 值。然后 df.query('price > 1000000') ,把这条记录的所有字段拉出来,像审讯一样问:这个1000万的订单, user_id 是否与历史高净值用户匹配? product_id 是否对应一款限量版奢侈品? ip_address 是否来自一个已知的刷单IP段?一次成功的异常样本深挖,往往能发现一个潜伏数月的数据污染源。

这套“133”仪式,平均耗时25-40分钟,但它能避免后续数小时甚至数天的无效建模。它不是浪费时间,而是为整个分析过程安装了最可靠的保险丝。

3.2 关系探索:从“相关系数矩阵”到“业务路径图谱”

相关系数矩阵( df.corr() )是EDA里最被滥用的工具。它用一个冰冷的数字,掩盖了变量间所有丰富的业务语义。我的做法是:永远不单独展示 corr() ,而是把它嵌入一个三层验证的“业务路径图谱”中。

第一层:相关性热力图 + 显著性标注
先画出基础热力图,但必须叠加p值显著性标记。 scipy.stats.pearsonr 返回的不仅是r值,还有p值。我用 sns.heatmap() ,但 annot 参数传入一个自定义函数:对每个单元格,如果 p < 0.01 ,显示 f"{r:.2f}**" ;如果 0.01 <= p < 0.05 ,显示 f"{r:.2f}*" . 这样一眼就能看出, df['page_views']['churn_rate'] 的r=-0.35*,虽然数值不大,但统计显著,值得深挖;而 df['age']['churn_rate'] 的r=0.02,无论多“漂亮”,都应直接忽略。热力图本身不解释因果,它只是一张“可疑关系地图”。

第二层:分组对比散点图 + 业务分箱
对热力图中标注为 ** * 的强相关对,绝不直接画 plt.scatter() 。而是先进行业务逻辑分箱,再画分组散点。例如, df['discount_rate']['conversion_rate'] 相关性显著为正(r=0.42**),这似乎印证了“折扣越大转化越好”。但业务常识告诉我,这不可能是线性的。于是我按 df['discount_rate'] 的业务规则分箱: 0% (无活动)、 5-10% (日常促销)、 15-20% (大促预热)、 25%+ (清仓甩卖)。然后 plt.subplot(2,2,1); df.query('discount_rate == 0')['conversion_rate'].hist(); plt.title('No Promo') ... 画出四张独立直方图。结果发现: 25%+ 组的转化率直方图,峰值竟低于 5-10% 组,且右尾极长——意味着高折扣确实能拉动一部分冲动消费,但也吸引了大量只买不复购的羊毛党,拉低了整体质量。这才是业务真相。

第三层:路径依赖图 + 时间滞后分析
这是最关键的一步,它把静态相关转化为动态因果链。仍以 discount_rate 为例,业务上,折扣是因,转化是果,但果不会立刻显现。我构建一个时间滞后矩阵: df_lag = df.copy(); for lag in [0,1,2,3,7]: df_lag[f'discount_rate_lag_{lag}'] = df['discount_rate'].shift(lag) 。然后计算 df_lag.corr()['conversion_rate'] ,看哪个 lag 下的 discount_rate_lag_* conversion_rate 相关性最强。如果 lag=2 时r=0.51**,而 lag=0 时r=0.42**,那就证明,折扣政策发布后第2天,才是转化效果的爆发点。这直接指导运营:大促预告应在正式开售前2天密集推送,而非当天。我曾用此法帮一家教育机构优化了课程推广节奏,将线索转化率提升了22%。这个图谱,最终呈现为一张带箭头的流程图: Discount Announcement (t-2) → User Awareness (t-1) → Conversion (t) ,每个节点旁标注对应的统计显著性。它不再是一个数字游戏,而是一份可执行的业务行动指南。

3.3 叙事挖掘:用“矛盾”作为唯一的探针

EDA的终极武器,不是高级算法,而是对“矛盾”的敏感度。我建立了一个“矛盾日志”,专门记录分析过程中所有让我皱眉的瞬间,并强制自己深挖到底。

矛盾类型一:统计描述与业务常识的冲突
df['avg_order_value'].describe() 显示均值¥238,但 df['avg_order_value'].quantile(0.95) 是¥1200。常识告诉我,一个电商平台的客单价95%分位数不应是均值的5倍。矛盾点在此。解决方案不是删掉“异常值”,而是 df.query('avg_order_value > 1200')[['user_id', 'order_count', 'first_order_date']] ,发现这些高客单用户, order_count 平均只有1.2次, first_order_date 全部集中在近30天。故事来了:这是新客首单大额囤货(如母婴用户一次性购买全套用品),而非老客常规消费。因此, avg_order_value 不能作为一个全局特征,而应拆分为 first_order_aov repeat_order_aov 两个独立特征。

矛盾类型二:不同聚合粒度下的结论反转
df.groupby('region')['return_rate'].mean() 显示华东区退货率最低(8%),华南区最高(15%)。但当我按 df.groupby(['region', 'product_category'])['return_rate'].mean() 再分一层,发现“大家电”类目在华南区退货率仅5%,而“小家电”类目却高达32%。矛盾出现了:华南区整体退货率高,不是因为区域管理差,而是因为该区域小家电销量占比远高于其他区域(65% vs 全国平均35%)。这揭示了更深层的业务问题:小家电品类在华南的供应链履约能力不足,或是该区域小家电的售后网点覆盖率太低。结论从“加强华南区管理”升级为“优化华南小家电仓配与售后网络”。

矛盾类型三:数据分布与物理世界规律的背离
df['battery_life_hours'].hist() 显示,电池续航时间集中在2-3小时和8-10小时,而4-7小时的区间几乎为零。这违背了电池衰减的物理连续性规律。深挖 df.query('battery_life_hours.between(4,7)')[['device_model', 'manufacture_date']] ,发现所有“缺失”数据都来自同一款设备型号,且生产日期集中在2021年Q3。查阅该型号的硬件变更日志,确认Q3批次更换了新供应商的电芯,但固件未同步更新电池电量估算算法,导致系统在4-7小时区间持续误报。这个发现,直接推动了固件OTA升级项目的紧急立项。

每一次成功解决一个矛盾,都相当于在数据迷宫中点亮一盏灯。这些灯连起来,就是通往业务本质的唯一路径。记住,数据不会说谎,但会伪装。你的任务,就是识破每一次伪装。

4. 实操全流程:从零开始,完成一次完整的EDA实战

4.1 准备工作:环境、数据与心态的三重校准

在打开Python之前,我完成三项不可妥协的准备:

环境校准:极简主义配置
我禁用所有自动化EDA库( pandas-profiling , sweetviz 等)。它们生成的华丽报告,会麻痹你的感官,让你满足于“已分析”的幻觉。我的环境只有 pandas , numpy , matplotlib , seaborn , scipy 这五个核心包,版本锁定在稳定LTS版(如pandas 1.5.3)。 matplotlib rcParams 被重置为最朴素的样式: plt.rcParams.update({'font.size': 10, 'axes.titlesize': 12, 'axes.labelsize': 10}) ,禁用所有网格、背景色和3D效果。目标只有一个:让图表的每一个像素,都只为传递信息服务,不为取悦眼睛服务。我甚至关闭Jupyter的 %matplotlib inline ,改用 %matplotlib widget ,这样可以交互式缩放查看细节,避免因默认分辨率丢失关键信息。

数据校准:获取原始、未加工的“第一手”数据
我坚持从源头获取数据。绝不接受同事发来的“已清洗Excel”。必须亲自连接数据库,执行 SELECT * FROM orders LIMIT 1000; ,或从数据湖S3桶下载原始Parquet文件。原因很简单:任何中间环节的处理,都可能引入隐性偏差。有一次,我坚持要原始日志,发现上游ETL脚本在 JOIN 用户表时,因 user_id 类型不一致(一端是string,一端是int),导致12%的订单丢失了用户画像标签。这个bug在“已清洗”数据里被完美掩盖,因为清洗脚本用 fillna('unknown') 填平了所有缺失。原始数据,是你唯一能信任的“犯罪现场”。

心态校准:启动“侦探模式”
我给自己设定一个明确的心理契约:本次EDA的目标,不是产出一份报告,而是回答一个具体的、可验证的业务问题。这个问题必须由业务方亲口提出,写在笔记本首页。例如:“为什么Q3新客留存率比Q2下降了15%?” 或 “为什么iOS端的付费转化率比Android端高出28%,但ARPU却低12%?”。这个目标问题,就是整个分析的罗盘。每画一张图、每写一行代码,都要自问:“这个结果,能帮我更接近那个问题的答案吗?” 如果不能,立刻停止,回归问题本身。这种心态,能让你在数据的汪洋中,始终握紧那根名为“业务价值”的缆绳。

4.2 第一阶段:存在性勘探(0-30分钟)

以一个真实的电商订单数据集为例,演示如何执行“133”仪式:

# 1. 数据字典速查表(手写在Notebook Cell 1)
# 字段名 | 类型 | 非空计数 | 典型值示例
# order_id | object | 99987 | ['ORD-2023-001', 'ORD-2023-002', 'ORD-2023-003']
# user_id | object | 99987 | ['USR-1001', 'USR-1002', 'USR-1003']
# order_amount | float64 | 99987 | [299.0, 1599.0, 89.0]
# discount_amount | float64 | 99987 | [0.0, 159.9, 0.0]
# payment_status | object | 99987 | ['paid', 'refunded', 'pending']
# created_at | datetime64[ns] | 99987 | ['2023-01-01 10:23:45', '2023-01-01 10:25:12', '2023-01-01 10:26:30']

# 2. 三张原始分布图
import matplotlib.pyplot as plt
import seaborn as sns

# 数值型:order_amount
plt.figure(figsize=(12, 8))
plt.subplot(2,2,1)
df['order_amount'].hist(bins=min(50, len(df)//10))
plt.title('Order Amount Distribution')
plt.xlabel('Amount (¥)')
plt.ylabel('Frequency')

# 分类型:payment_status
plt.subplot(2,2,2)
df['payment_status'].value_counts(dropna=False).plot(kind='bar')
plt.title('Payment Status Count')
plt.xlabel('Status')
plt.ylabel('Count')
plt.xticks(rotation=45)

# 时间型:created_at
plt.subplot(2,2,3)
df['created_at'].dt.date.value_counts().sort_index().plot()
plt.title('Daily Order Count')
plt.xlabel('Date')
plt.ylabel('Count')
plt.xticks(rotation=45)

plt.tight_layout()
plt.show()

# 3. 三次手动抽样验证
print("=== Head Sample ===")
print(df.head(3))
print("\n=== Tail Sample ===")
print(df.tail(3))
print("\n=== Random Sample (seed=42) ===")
print(df.sample(5, random_state=42))
print("\n=== Anomaly Sample: Top 10 Order Amounts ===")
print(df.nlargest(10, 'order_amount')[['order_id', 'user_id', 'order_amount', 'payment_status']])

执行这段代码后,我立刻发现了三个关键线索:

  • payment_status 条形图中, 'pending' 状态占比高达18%,且 dropna=False 未增加计数,说明这不是缺失,而是真实存在的中间状态。
  • created_at 日度图中,2023-02-14(情人节)订单量是平日的3.2倍,但2023-02-15骤降至1/5,符合“节日集中下单-节后消费疲软”的业务规律。
  • 异常样本中, order_id='ORD-2023-10001' order_amount=9999999.0 payment_status='paid' ,但 user_id='USR-999999' ——这个 user_id 在全量用户表中不存在。这是一个典型的“测试订单”或“数据注入错误”,必须在后续分析中排除。

4.3 第二阶段:关系性勘探(30-120分钟)

基于第一阶段的线索,我聚焦三个核心关系进行穿透:

线索一:高 pending 状态与时间的关系

# 深挖pending状态的时间分布
pending_df = df[df['payment_status'] == 'pending']
plt.figure(figsize=(15, 5))
plt.subplot(1,3,1)
pending_df['created_at'].dt.hour.value_counts().sort_index().plot(kind='bar')
plt.title('Pending Orders by Hour (UTC)')
plt.xlabel('Hour of Day')
plt.ylabel('Count')

plt.subplot(1,3,2)
pending_df['created_at'].dt.dayofweek.value_counts().sort_index().plot(kind='bar')
plt.title('Pending Orders by Day of Week')
plt.xlabel('Day (0=Mon, 6=Sun)')
plt.ylabel('Count')

plt.subplot(1,3,3)
# 计算pending时长(假设当前时间为数据截止时间)
current_time = df['created_at'].max()
pending_df['pending_duration_hours'] = (current_time - pending_df['created_at']).dt.total_seconds() / 3600
pending_df['pending_duration_hours'].hist(bins=50)
plt.title('Pending Duration Distribution (Hours)')
plt.xlabel('Duration (Hours)')
plt.ylabel('Frequency')
plt.tight_layout()
plt.show()

结果令人震惊: pending 订单87%集中在周一至周五的9:00-17:00,且 pending_duration_hours 的中位数是142小时(约6天)。这与“支付超时自动关闭”的技术规则(通常2小时)完全不符。进一步 pending_df.query('pending_duration_hours > 100')[['order_id', 'user_id', 'created_at']] ,发现所有超长pending订单, user_id 都属于一个特定的B2B企业客户组。真相浮出水面:该客户采用“月结”支付方式, pending 状态是其财务流程的正常环节。这个发现,直接否定了将 payment_status 作为二分类建模目标的初步设想,必须将其重构为多状态序列预测问题。

线索二:情人节订单的 discount_amount 模式

# 情人节订单(2023-02-14)的折扣分析
valentine_df = df[df['created_at'].dt.date == pd.Timestamp('2023-02-14')]
plt.figure(figsize=(12, 8))
plt.subplot(2,2,1)
valentine_df['discount_amount'].hist(bins=30)
plt.title('Valentine Discount Amount Distribution')

plt.subplot(2,2,2)
# 对比情人节与平日(2023-02-13)的折扣率
valentine_discount_rate = valentine_df['discount_amount'] / valentine_df['order_amount']
normal_discount_rate = df[df['created_at'].dt.date == pd.Timestamp('2023-02-13')]['discount_amount'] / df[df['created_at'].dt.date == pd.Timestamp('2023-02-13')]['order_amount']
plt.hist([valentine_discount_rate, normal_discount_rate], bins=20, label=['Valentine', 'Normal'])
plt.legend()
plt.title('Discount Rate Comparison')

plt.subplot(2,2,3)
# 情人节订单的品类分布
valentine_df['product_category'].value_counts().plot(kind='bar')
plt.title('Valentine Product Category Distribution')
plt.xticks(rotation=45)

plt.subplot(2,2,4)
# 情人节高折扣订单的用户复购率(需关联用户表)
# 假设已有user_lifetime_value字段
valentine_high_discount = valentine_df[valentine_df['discount_amount'] > valentine_df['discount_amount'].quantile(0.9)]
valentine_high_discount['user_lifetime_value'].hist(bins=20, alpha=0.7, label='High Discount')
valentine_low_discount = valentine_df[valentine_df['discount_amount'] <= valentine_df['discount_amount'].quantile(0.9)]
valentine_low_discount['user_lifetime_value'].hist(bins=20, alpha=0.7, label='Low Discount')
plt.legend()
plt.title('User LTV: High vs Low Discount (Valentine)')
plt.tight_layout()
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值