1. 项目概述:为什么数据探索必须像侦探破案一样严谨
你有没有过这种感觉——打开一份新拿到的数据集,密密麻麻的列名、参差不齐的缺失值、突然跳出来的异常数字,像一扇没上锁却布满暗格的旧木门?你伸手去拉,门没开,反而从缝隙里飘出几张泛黄的纸片,上面写着“customer_id”“order_date”“amount”,但谁是客户?哪天下的单?金额单位是美元还是分?更糟的是,你发现“amount”这一列里混着几个“N/A”、一个“$1,234.56”、两个空格,还有一个写着“NULL”。这时候,你不是在做数据分析,你是在现场勘查。
这就是我第一次接手电商退货预测项目时的真实状态。当时团队给我的是一份标着“清洗完成”的CSV,结果我在前15分钟就揪出三处逻辑断层:退货时间戳比下单时间早了两年;同一用户ID在同一天有7次完全相同的退货记录;而最关键的“退货原因编码”字段,文档说有12个有效值,实际数据里却出现了17个从未定义过的编码。问题不在数据脏,而在我们默认它“应该干净”——这恰恰是所有分析事故的起点。
所以,“Let’s Explore the Data Like Sherlock Holmes”这个标题绝不是修辞游戏。福尔摩斯从不靠直觉下结论,他靠的是系统性观察:先看整体形态(head/tail),再查身份特征(info/describe),接着清点物证分布(value_counts),最后交叉验证线索链(missing pattern + correlation + domain logic)。这套方法论之所以有效,是因为它把数据当成一个有行为逻辑的“嫌疑人”,而不是等待被建模的“原材料”。它解决的不是“怎么跑通模型”,而是“我到底在跟什么打交道”这个根本问题。适合刚转行的数据新人,也适合做了五年建模却总在上线后翻车的算法工程师——因为90%的线上故障,根源都在探索阶段漏掉的一行异常日期格式,或一个被误认为离散型的连续型字段。
我试过跳过这一步直接建模:用pandas.read_csv()默认参数读入一份含逗号分隔的地址字段,结果整个地址被劈成三列,后续所有地理聚类全错;也试过只看describe()就下结论,结果发现中位数和均值差了8倍,回头一看是某供应商把“库存量”填成了“累计销量”,而describe()只会冷静地告诉你“std=12432.67”。真正的数据侦探,得同时带着显微镜(看单条记录)、望远镜(看全局分布)和逻辑罗盘(校验业务合理性)。接下来,我就带你用真实电商退货数据集,一帧一帧拆解这套侦查流程——不讲理论,只讲我在凌晨三点debug时真正用上的招数。
2. 数据侦查四步法:从现场勘查到线索串联
2.1 第一步:勘查现场——head()与tail()的隐藏信息
很多人把head()当成“看看前五行”,这就像福尔摩斯只扫了一眼凶案现场的地板就写结案报告。真正的勘查要分三层:结构层、内容层、异常层。
以我们手头的returns_2023.csv为例,执行df.head(5)后,第一反应不该是“哦,有5列”,而是立刻锁定三个关键坐标:
列名战场:
return_reason_code和return_reason_desc并存,但desc列里大量是空值,而code列全是数字。这暗示业务系统可能用code做主键,desc只是辅助显示——如果后续要做分类,必须确认code是否真能映射到唯一语义(后来发现code=7对应“尺寸不合适”和“颜色不喜欢”两种desc,属于设计缺陷)。数据类型陷阱:
return_date列显示为2023-01-15,但type却是object。我立刻用df['return_date'].apply(type).unique()检查,发现混入了<class 'float'>——原来是Excel导出时把空单元格转成了NaN,而pandas读取时把整列判为object。这会导致后续pd.to_datetime()报错,且错误提示极其隐蔽:“cannot convert float NaN to integer”。肉眼可见的异常:第五行
customer_id是CUST-8821,但order_id却是ORD-999999999(超长数字),而其他行都是ORD-12345格式。我马上用df[df['order_id'].str.len() > 10]筛选,发现0.3%的订单ID含非法字符,源头是客服手工录入时多打了连字符。这个细节在describe()里完全看不到,但它会让后续join操作产生笛卡尔积。
tail()的价值更常被低估。df.tail(5)暴露了另一类问题:时间序列的截断效应。最后两行return_date是2023-12-31,但processing_status却是pending。按业务规则,pending状态必须在T+3日内更新,而今天是2024年1月5日——这意味着有未处理的积压单,会影响我们对“平均处理时长”的统计。如果只看head(),你会以为数据截止到去年底,实际是截止到“当前未完成态”。
提示:head()/tail()的黄金组合是
df.head(n).append(df.tail(n)),但必须加.reset_index(drop=True),否则索引重复会掩盖问题。我习惯固定用n=3,因为3行足够暴露模式,又不会信息过载。
2.2 第二步:验明正身——info()与describe()的深度解读
info()和describe()常被并列使用,但它们解决的是完全不同的问题:info()回答“它是什么”,describe()回答“它像什么”。忽略这个区别,等于让法医只测体温不验DNA。
先看info()的隐藏字段。除了常规的non-null count和dtypes,重点盯住memory usage。我们的数据集info()显示内存占用1.2GB,但df.info(memory_usage='deep')暴增至2.8GB——说明存在大量字符串对象未做category转换。进一步用df.select_dtypes('object').nunique()发现product_category有217个唯一值,而业务实际只有12个大类,其余全是拼写变体("Electronics"、"electronics "、"ELECTRONICS")。这就是典型的“内存刺客”,不处理的话,后续groupby会慢3倍以上。
describe()的坑更多。默认df.describe()只统计数值列,但如果你加了include='all',会看到object列的unique、top、freq。这里有个致命误区:top不等于“最常见值”,而是“字典序排第一的高频值”。比如country列中,"USA"出现1200次,"Canada"出现1199次,但"Australia"因字母靠前被标为top——这会让你误判主力市场。正确做法是df['country'].value_counts().head(3)。
更关键的是describe()对异常值的“美化”。看amount列输出:
count 99821.000000 mean 124.321567 std 2345.678912 min 0.000000 25% 23.500000 50% 45.000000 75% 89.990000 max 99999.990000max值99999.99看起来合理?但结合业务:最高单价商品是$2999的笔记本,退货金额不可能超$3000。我立刻执行df[df['amount'] > 3000],发现17条记录,其中12条amount等于99999.99——这是系统默认的“未知金额”占位符,却被describe()当真值统计。这就是为什么我坚持在describe()后必加一句:df['amount'].quantile([0.99, 0.995, 0.999]),用分位数代替max看尾部。
注意:describe()的百分位数计算默认用线性插值,对离散型数据(如评分1-5分)会产生不存在的值(如3.72)。此时应改用
df['rating'].value_counts(normalize=True).cumsum()手动计算累积分布。
2.3 第三步:清点物证——value_counts()的进阶用法
value_counts()表面是数频次,实则是挖掘数据基因图谱。新手只用df['col'].value_counts(),老手会用三层嵌套:
第一层:基础频次+缺失洞察df['return_reason_code'].value_counts(dropna=False)中的dropna=False是关键。它会把NaN单独列为一行,显示NaN 237。如果只用默认参数,这237个缺失值就消失在统计外,而它们恰恰指向客服系统未强制填写原因码的漏洞。
第二层:分布诊断
对高基数字段(如customer_id),直接value_counts()会输出上万行。改用:
top_customers = df['customer_id'].value_counts().head(20) print(f"Top 20 customers account for {top_customers.sum()/len(df):.1%} of returns")结果发现前20客户占退货量37%,但他们的平均退货金额是全量均值的2.3倍——这提示需单独建模“高价值客户退货行为”,而非一刀切。
第三层:跨字段关联
真正的侦探思维在这里:return_reason_code和product_category的组合频次。用pd.crosstab(df['return_reason_code'], df['product_category'])生成列联表,立刻发现玄机:code=5(“物流损坏”)在"Furniture"类中占比68%,但在"Books"类中仅0.2%。这验证了业务假设——大家买书不怕摔,买沙发怕。但如果code=5在"Electronics"类中也高达45%,就反常了(电子产品应多是“功能故障”),倒查原始数据发现,这批订单的物流单号全以EXP-开头——是某家新合作快递,包装不合格。一个value_counts()的变形,直接定位到供应链风险点。
实操心得:对文本字段,务必加
normalize=True看比例而非绝对数。曾有同事分析用户评论情感,发现“excellent”出现1200次,“terrible”仅8次,结论是“用户满意度极高”。但加上normalize后,“terrible”占负面词的73%,而正面词中“good”“ok”等弱正面占89%——真实情绪是“不敢骂,但也不买账”。
2.4 第四步:线索串联——missingno与domain logic的交叉验证
缺失值不是孤立事件,而是业务流程的伤疤。missingno.matrix(df)能可视化缺失模式,但真正破案靠的是把图形和业务逻辑对齐。
运行msno.matrix(df)后,我看到return_date和processing_status两列缺失值完美重叠(同一行全白)。这本该是好事——说明数据一致。但结合业务规则:只要状态是pending,日期就必须为空;反之,只要有日期,状态就不能是pending。于是我写:
inconsistent = df[(df['return_date'].isna()) & (df['processing_status'] != 'pending')] | \ df[(df['return_date'].notna()) & (df['processing_status'] == 'pending')] print(f"Inconsistent records: {len(inconsistent)}")结果揪出42条“日期有值但状态pending”的脏数据——源头是ETL脚本bug:当状态更新为processed时,日期字段被错误覆盖为原pending时间。
更隐蔽的是隐性缺失。df['refund_amount'].isna().sum()显示0缺失,但df['refund_amount'] == 0有1273条。业务上,0退款意味着“仅退货不退款”(如换货),但财务系统要求所有退款必须填金额。这1273条实际是财务未录数据,属于“伪完整”。我建立规则:df['refund_amount'].between(0.01, df['amount'].max()),把0值标为'financial_pending',后续分析中单独处理。
最后是相关性陷阱。df.corr()显示amount和days_since_order相关系数-0.12,看似弱相关。但分箱后:df['days_since_order_bin'] = pd.cut(df['days_since_order'], bins=[0,30,90,365,10000], labels=['0-30d','31-90d','91-365d','>365d']),再用pd.crosstab(df['days_since_order_bin'], df['return_reason_code']),发现code=3(“改变主意”)在0-30d箱中占72%,而code=1(“商品缺陷”)在>365d箱中突增——这揭示了两类退货的本质差异:冲动型vs质量型。单纯看皮尔逊相关,会错过这个关键分层。
3. 实战推演:用真实退货数据集完成全流程侦查
3.1 数据加载与初步扫描:避开read_csv的十大暗坑
我们拿到的returns_2023.csv表面是标准CSV,实则暗流汹涌。直接pd.read_csv('returns_2023.csv')会踩中至少三个坑:
坑1:编码乱码
文件用ISO-8859-1编码,但pandas默认utf-8。df = pd.read_csv('returns_2023.csv', encoding='utf-8')会报错UnicodeDecodeError。正确解法是先用chardet探测:
import chardet with open('returns_2023.csv', 'rb') as f: rawdata = f.read(10000) # 只读前1万字节提速 encoding = chardet.detect(rawdata)['encoding'] # 输出:'ISO-8859-1' df = pd.read_csv('returns_2023.csv', encoding=encoding)坑2:分隔符混淆return_notes字段含英文逗号,导致pd.read_csv()把一条记录劈成多列。df.shape显示列数比header多12列。解决方案:
- 先用
csv.Sniffer()检测分隔符:sniffer = csv.Sniffer(); dialect = sniffer.sniff(open('returns_2023.csv').read(1024)) - 发现
dialect.delimiter是;,但文件头写的是,——这是导出工具的bug。 - 强制指定:
df = pd.read_csv('returns_2023.csv', sep=';', encoding=encoding)
坑3:数字千分位amount列显示1,234.56,pd.read_csv()默认解析为string。必须加参数:
df = pd.read_csv('returns_2023.csv', sep=';', encoding=encoding, thousands=',', # 关键!识别千分位 decimal='.') # 指定小数点加载后第一件事不是head(),而是df.info()看内存和类型。发现customer_id是object,但实际是纯数字ID(CUST-12345)。我立刻做:
# 提取数字部分并转int,保留原列作备份 df['customer_id_clean'] = df['customer_id'].str.extract(r'CUST-(\d+)').astype('Int64') # Int64支持NaN,比int64安全此时df.info()内存从1.8GB降至0.9GB——category转换和Int64是数据侦探的标配装备。
3.2 结构化侦查:构建你的数据健康度仪表盘
我把每次探索都固化为一个函数,输出结构化报告。核心是四个维度:
维度1:完整性(Completeness)
def completeness_report(df): total_cells = df.shape[0] * df.shape[1] missing_cells = df.isna().sum().sum() return { 'overall_missing_rate': f"{missing_cells/total_cells:.2%}", 'columns_with_missing': list(df.columns[df.isna().any()]), 'worst_column': df.isna().sum().idxmax(), 'worst_rate': f"{df.isna().sum().max()/len(df):.2%}" } # 执行结果:overall_missing_rate: 0.87%, worst_column: 'return_notes', worst_rate: 42.3%这比df.isna().mean()直观得多——42.3%的备注缺失,说明客服不填备注是常态,后续NLP分析必须降权。
维度2:一致性(Consistency)
检查逻辑矛盾:
# 退货日期不能早于下单日期 inconsistent_dates = df[df['return_date'] < df['order_date']] # 金额不能为负 negative_amounts = df[df['amount'] < 0] # 状态机合规性:pending→processed→completed,不可逆 valid_status_flow = {'pending': ['processed'], 'processed': ['completed']}结果揪出19条return_date < order_date,全部是order_date填错年份(2022写成2023),属录入错误。
维度3:唯一性(Uniqueness)df.duplicated(subset=['order_id', 'return_id']).sum()返回0,但df.duplicated(subset=['customer_id', 'order_id', 'return_reason_code']).sum()返回237——说明同一客户对同一订单因不同原因多次退货,属正常业务。但如果duplicated(subset=['order_id']).sum() > 0,就是数据重复导入。
维度4:有效性(Validity)
用正则校验关键字段:
# 邮箱格式 df['email_valid'] = df['customer_email'].str.contains(r'^[^\s@]+@[^\s@]+\.[^\s@]+$') # 电话号码(北美) df['phone_valid'] = df['customer_phone'].str.contains(r'^\(\d{3}\) \d{3}-\d{4}$')发现email_valid为False的占12%,其中83%是"N/A"或"null"字符串——需统一清洗为NaN。
这个仪表盘每行输出都是可行动的结论,不是“数据有缺失”,而是“return_notes缺失率42.3%,建议在ETL中添加默认值‘No notes provided’”。
3.3 深度模式挖掘:用pivot_table揭露业务真相
head()/describe()只能看单点,pivot_table才是侦探的放大镜。我们用它交叉分析三个维度:
场景:为什么高单价商品退货率更高?
直觉认为贵的东西退得多,但df.groupby('price_tier')['return_rate'].mean()显示> $500组退货率仅12%,低于$100-$500组的18%。矛盾点来了。
建透视表:
pivot = pd.pivot_table( df, index='product_category', columns='price_tier', values='return_flag', # 0/1标识是否退货 aggfunc='mean' )结果震惊:"Jewelry"在> $500组退货率31%,但"Electronics"仅5%。再加一层:
# 加入购买渠道 pivot_3d = pd.pivot_table( df, index='product_category', columns=['price_tier', 'purchase_channel'], values='return_flag', aggfunc='mean' )发现"Jewelry"在> $500且purchase_channel='Marketplace'时退货率飙升至47%——原来第三方卖家珠宝描述夸大,而自营店有严格质检。这个结论,单看任何一列的describe()都得不出。
场景:客服响应时长是否影响客户二次购买?df['response_time_hours']是客服首次回复时长。简单相关系数是-0.08,无意义。但分箱后:
df['response_bin'] = pd.cut(df['response_time_hours'], bins=[0,24,168,10000], labels=['<1d','1-7d','>7d']) # 透视客户生命周期价值(CLV) clv_pivot = pd.pivot_table( df, index='response_bin', values='customer_clv', aggfunc=['mean','count'] )结果:<1d组平均CLV是>7d组的2.3倍,且<1d组客户复购率高37%。这直接推动客服KPI从“响应量”转向“首响时效”。
pivot_table的威力在于,它强迫你把业务问题转化为交叉维度,而机器不会撒谎——它只反映数据中真实存在的模式。
3.4 终极验证:用业务规则引擎做数据审判
所有技术侦查终需回归业务。我搭建了一个轻量级规则引擎,把业务文档转化为可执行代码:
# 规则库:business_rules.py RULES = [ { 'name': 'return_date_after_order', 'condition': lambda df: df['return_date'] >= df['order_date'], 'severity': 'critical', 'message': 'Return date before order date' }, { 'name': 'refund_match_amount', 'condition': lambda df: (df['refund_amount'] > df['amount']) | (df['refund_amount'] < 0), 'severity': 'high', 'message': 'Refund amount invalid' } ] def run_rules(df, rules=RULES): violations = {} for rule in rules: mask = ~rule['condition'](df) # 条件不满足即违规 if mask.any(): violations[rule['name']] = { 'count': mask.sum(), 'rate': f"{mask.mean():.2%}", 'sample': df[mask].head(3)[['order_id','return_id']].to_dict('records') } return violations # 执行 violations = run_rules(df) # 输出:{'return_date_after_order': {'count': 19, 'rate': '0.02%', 'sample': [...]}}这个引擎每天自动运行,输出的不是“数据质量报告”,而是“业务风险清单”。当refund_match_amount违规率从0.1%升至1.2%时,运维立刻收到告警——查实是新上线的退款API未做金额校验。数据探索至此,已从技术动作升维为业务护城河。
4. 常见问题与排查技巧实录:那些没人告诉你的坑
4.1 “describe()显示std很大,但histogram看起来很集中”——浮点精度陷阱
现象:df['lat'].describe()显示std=12.3,但df['lat'].hist(bins=50)峰值在37.7,左右几乎对称。直觉矛盾。
根因:lat列混入了测试数据999.0(地理坐标无效值占位符)。describe()把它当真实值计算,而histogram因bins=50把999.0单独挤在最右一格,视觉上不明显。
排查:
# 查看极端值 df['lat'].quantile([0.01, 0.99, 0.999]) # 输出:0.01->34.2, 0.99->41.1, 0.999->999.0 → 锁定异常 # 定位记录 df[df['lat'] > 90][['order_id','lat','lon']] # 地理纬度绝对值≤90解决方案:业务规则中明确lat有效范围[-90,90],清洗时df.loc[df['lat'].abs() > 90, 'lat'] = np.nan。
实操心得:对空间/时间字段,永远先用
quantile([0.001, 0.999])看尾部,再决定是否剔除。我见过把GPS信号丢失时的0.0,0.0当真实坐标,导致整个城市热力图偏移。
4.2 “value_counts()结果和数据库count(*)不一致”——NULL与空字符串的战争
现象:数据库执行SELECT COUNT(*) FROM returns WHERE return_reason IS NULL返回237,但df['return_reason'].isna().sum()返回0。
根因:数据库中NULL被pandas读取为None,但某些ETL工具把NULL转成字符串'NULL'或空字符串''。isna()检测不到后者。
排查:
# 三重检查 print("isna():", df['return_reason'].isna().sum()) print("empty string:", (df['return_reason'] == '').sum()) print("'NULL' string:", (df['return_reason'] == 'NULL').sum()) # 输出:0, 237, 0 → 确认是空字符串解决方案:统一清洗df['return_reason'] = df['return_reason'].replace('', np.nan)。
注意:
replace('', np.nan)会把所有空字符串变NaN,但若业务中空字符串有含义(如“未选择原因”),则需df['return_reason'] = df['return_reason'].replace({'': 'not_specified'})。
4.3 “head()看没问题,但model.fit()报错”——类型隐式转换的幽灵
现象:df.head()显示order_date是2023-01-01,df.dtypes显示object,但pd.to_datetime(df['order_date'])报错ParserError: Unknown string format。
根因:head()只显示前5行,而这5行恰好是标准格式。第6行是'Jan 1, 2023',第1024行是'2023/01/01',第5001行是'2023-01-01 00:00:00'——混合格式。
排查:
# 查看所有唯一格式 from dateutil import parser def detect_date_format(date_str): try: parser.parse(date_str) return 'valid' except: return 'invalid' # 采样1000行检测 sample = df['order_date'].dropna().sample(1000, random_state=42) format_test = sample.apply(detect_date_format) print(format_test.value_counts()) # 输出:valid 982, invalid 18 # 定位无效值 invalid_dates = sample[format_test == 'invalid']解决方案:用pd.to_datetime(df['order_date'], errors='coerce'),自动把无法解析的转为NaT,并用df['order_date'].isna().sum()量化损失。
4.4 “missingno矩阵显示空白,但业务说数据不全”——隐性缺失的识别术
现象:msno.matrix(df)一片蓝色(无缺失),但业务方反馈“很多客户没填邮箱”。
根因:“没填”在数据库中记为NULL,但ETL时被转成字符串'NULL'或' '(空格),isna()检测不到。
排查:
# 对object列,检查空格和占位符 for col in df.select_dtypes('object').columns: empty_spaces = (df[col].str.strip() == '').sum() null_strings = (df[col].str.upper() == 'NULL').sum() if empty_spaces > 0 or null_strings > 0: print(f"{col}: {empty_spaces} empty, {null_strings} 'NULL'") # 输出:customer_email: 127 empty, 0 'NULL' → 确认是空格解决方案:df[col] = df[col].str.strip().replace('', np.nan)。
实操心得:对所有文本字段,清洗第一步永远是
str.strip()。我吃过亏——把' gold '当普通字符串,导致'gold' in df['product_name'].unique()返回False,实际是' gold '在列表里。
4.5 “corr()显示无关,但业务坚信有关”——非线性关系的破译
现象:df['customer_age'].corr(df['return_rate'])= -0.03,但业务说“年轻人退货多”。
根因:相关系数只捕获线性关系。实际可能是U型:18-25岁和55+岁退货率高,30-50岁低。
排查:
# 分箱后看均值 df['age_group'] = pd.cut(df['customer_age'], bins=[0,25,35,45,55,100], labels=['<25','25-35','35-45','45-55','>55']) age_return = df.groupby('age_group')['return_flag'].mean() print(age_return) # 输出:<25: 0.28, 25-35: 0.12, 35-45: 0.09, 45-55: 0.11, >55: 0.25 → U型成立进阶:用sklearn.preprocessing.PolynomialFeatures(degree=2)生成age^2特征,再算相关系数,age^2与return_flag相关系数达0.31。
5. 工具链与效率提升:让侦探工作自动化
5.1 自动化侦查流水线:pandera + great_expectations
手动检查易遗漏,我用pandera定义数据契约:
import pandera as pa from pandera import Column, DataFrameSchema, Check schema = DataFrameSchema({ "order_id": Column(str, checks=Check.str_length(min_value=5)), "return_date": Column(pa.DateTime, checks=Check.in_range( min_value=pd.Timestamp('2023-01-01'), max_value=pd.Timestamp('2023-12-31') )), "amount": Column(float, checks=Check.greater_than_or_equal_to(0.01)) }) # 验证 validated_df = schema.validate(df) # 报错则中断对复杂业务规则,用great_expectations:
from great_expectations.dataset import PandasDataset ge_df = PandasDataset(df) ge_df.expect_column_values_to_be_between('amount', 0.01, 10000) ge_df.expect_column_values_to_match_regex('order_id', r'^ORD-\d{5,8}$') ge_df.save_expectation_suite('returns_suite.json')每次新数据进来,自动运行ge_df.validate(expectation_suite='returns_suite.json'),输出HTML报告,红标违规项。
5.2 可视化侦查:plotly + seaborn的实战配置
matplotlib默认样式不适合侦查。我的配置:
import plotly.express as px import seaborn as sns # 缺失值热力图(带业务注释) fig = px.imshow(df.isna().T, labels={'x': 'Row Index', 'y': 'Column'}, color_continuous_scale='Blues', title="Missing Value Pattern (Rows are samples)") # 添加业务标注 fig.add_hline(y=2, line_dash="dot", annotation_text="return_notes: 42% missing") fig.show() # 分布对比(避免密度图失真) sns.histplot(data=df, x='amount', hue='return_flag', stat='density', common_norm=False, alpha=0.7) # common_norm=False确保退货/未退货组Y轴独立,避免小样本被淹没5.3 效率技巧:Jupyter中的魔法命令
%timeit df['amount'].describe()测速,避免慢操作%who_ls快速查看当前变量,防命名冲突%%capture捕获冗长输出,保持notebook整洁df.query('amount > 1000').head()替代df[df['amount']>1000].head(),语法更清晰
最后分享一个血泪教训:我曾用df.drop_duplicates()去重,结果删掉了合法的重复订单(同一客户同天同商品两次退货)。现在必加subset=['order_id'],且执行前df.duplicated(subset=['order_id']).sum()先看数量。数据侦探的终极守则只有一条:永远假设数据在说谎,直到它用证据自证清白。