Python数据清洗实战:系统化处理手机号缺失与异常值

发布时间:2026/10/21 3:51:25
Python数据清洗实战:系统化处理手机号缺失与异常值 大概每个做用户运营、客户管理或者数据分析的人都碰到过这种情况业务方拉来一张表几千上万条记录拍着桌子问“这批客户怎么联系不上”你下个df.isna().sum()一跑好家伙手机号字段空了几百条。再仔细一看不光是空的还有一堆400-800-xxxx、010-xxxxxxx、138 OO OO 1234这种格式千奇百怪的“伪号码”。整个表里真正能直接打电话的可能只有七成。这种脏活累活听起来不难但真要处理好尤其是“处理”到让业务方满意、让数据能落库、能复用里面还是有不少门道的。今天这篇就来聊聊我在实际项目里是怎么用Python系统化处理手机号缺失和异常问题的。这不是什么高大上的算法就是一套实打实能跑、能复现的工程化实践。1. 冷启动先搞清楚你的数据到底“缺”在哪很多人拿到数据的第一反应是fillna直接拿个默认值或者随机数怼上。这是最大的误区。手机号不是什么均值能填充的指标它是强标识字段填错了后续所有基于手机号的关联分析、营销触达、去重逻辑就全崩了。1.1 识别“真缺失”和“假缺失”我先用一段代码把手机号字段的所有非标准状态拍平看看里头的真实分布。import pandas as pd import numpy as np def inspect_phone_data(df, colphone): # 先看空值 print(空值统计含None、NaN、空字符串) print(f 该字段总记录数: {len(df)}) print(f NaN/None: {df[col].isna().sum()}) # 空字符串和纯空格也是缺失的表现形式 tmp df[col].astype(str).str.strip() print(f 空字符串/纯空格: {tmp.eq(nan) | tmp.eq()})运行之后你会发现手机号字段的问题远不止NaN这一种明文缺失数据库里存了NULL这是最诚实的缺失。半缺失138****1234中间四位被脱敏了看着有号实际无法直接使用。格式污染86 138 0013 8000、138-0013-8000、13800138000各种分隔符和全角符号混入。语义错误存了座机号021-88888888、400电话、甚至填了个邮箱或微信号进去。这两类数据如果混在一起处理后面逻辑会乱。所以第一步永远是探查。把业务手里那张大宽表拉出来专门看这个字段跑完统计之后把数据切成几档规则完整的、可清洗的、缺失需补的、彻底没救的。1.2 手机号规则定义说清洗之前得先界定“合法手机号”。中国大陆手机号现在大致规则是长度11位。首位1。第二位通常是3-9但最新的号段已经扩展到2如192。保守校验用1[3-9]\d{9}激进校验用1[2-9]\d{9}。用正则先定个准绳后面所有策略都围绕这条准绳展开。我一般还会顺手做个简单的校验函数把号段、长度、纯数字这些都包进去。import re # 大陆手机号宽松校验正则 PHONE_REGEX_LAX re.compile(r^1[2-9]\d{9}$) def is_valid_phone(phone: str) - bool: 校验是否符合大陆手机号基本规则 if not isinstance(phone, str): return False phone phone.strip() if not phone.isdigit(): return False return bool(PHONE_REGEX_LAX.match(phone))这里注意isdigit()判断纯数字时会误伤一些上标数字或特殊unicode数字字符所以更稳妥的做法是先re.sub(r\D, , phone)把非数字都剔除再重新判断长度。我实际写的清洗函数里用的都是后者。2. 数据清洗方案选型别一上来就想着“补”在补缺失之前有一个优先级的判断手机号缺失的处理路径应该是“清洗 匹配 填补 标记”这个顺序不能乱。2.1 为什么不能直接随机生成或填充有新手会问缺了手机号直接随机生成几个不就行了反正也是测试数据。如果这是跑模型练手当然没问题。但在生产或者业务分析场景里手机号是用户的唯一外部标识牵扯到多渠道人群去重同一人可能同时在小程序、App、门店留下不同的联系方式。用户触达发短信、电话外呼。数据关联打通订单和会员体系。一旦用假号填充最直接的后果就是短信发送失败率飙升、无效外呼量暴增、人群包覆盖率虚高但真实触达量骤降。你说业务方看到漏斗数据缩水能不慌吗所以宁可标记为NaN也不要填错号。2.2 数据清洗与格式化第一步得把脏字符串清洗成规范的可解析格式。这一步的核心不是填缺失而是把“假缺失”格式污染还原成“真数据”。def clean_phone_field(raw_val) - str: 对原始手机号字段做清洗返回标准化手机号字符串。 清洗失败返回空字符串。 if pd.isna(raw_val): return s str(raw_val).strip() # 去除全角空格和常见分隔符 s s.replace(\u3000, ) # 全角空格 s re.sub(r[\s\-_()\[\].,;。、/], , s) # 去掉86或0086前缀 if s.startswith(86): s s[3:] elif s.startswith(0086): s s[4:] elif s.startswith(86) and len(s) 13: s s[2:] # 如果里面混了字母或者分机号x直接判定为不可清洗 if not s.isdigit(): return if s.startswith(0) and len(s) 11 and not s.startswith(01): # 区号开头的一般是座机不是手机号 return return s这段代码看起来简单但有一个关键点不要上来就isdigit()很多表里会带着86、-这类虚拟号段。去掉前缀、分隔符之后再判断是不是数字最后用长度和开头数字来做过滤。整个过程完全不需要用第三方库标准库加pandas就够了。洗完之后可以做一个简单的数据分布复查打印清洗前后的长度分布对比。如果发现大量号码长度是12位或13位多半是区号或86开头没扒干净如果大量是10位可能是号段缺了一位。这些都要视情况单独处理。3. 缺失数据填补的正确姿势三种可行的间接路径清洗完格式异常剩下的就是真少了。对于一个严谨的系统手机号缺失几乎不可能靠算法完美还原但可以通过多源关联的方式“间接补全”。以下三条路径是我在实战中验证过有效、且逻辑严谨的。3.1 路径一关联历史订单/反馈记录很多系统的用户表是主表但手机号可能在订单表、客服反馈表、短信发送记录表里出现过。一张用户主表与一张订单流水表做关联往往能拉回不少手机号。def fill_phone_from_orders(user_df, order_df): 通过订单表里的phone字段回填主表。 逻辑同一个用户ID在订单表里出现的最新手机号回填到主表。 注意如果订单表里有多个不同手机号取最近一次订单的。 # 确保订单表时间字段可用 order_df[order_date] pd.to_datetime(order_df[order_date], errorscoerce) # 按用户、时间排序取每个用户最新的手机号记录 latest (order_df.dropna(subset[phone]) .sort_values(order_date) .groupby(user_id)[phone] .last() .reset_index()) merged user_df.merge(latest, onuser_id, howleft, suffixes(_old, )) # 只填充主表中为空的那部分 mask merged[phone].isna() merged[phone_old].notna() merged.loc[mask, phone] merged.loc[mask, phone_old] return merged.drop(columns[phone_old])这里有几个细节值得注意取“最新”而不是“最旧”因为用户可能换号仓库里应该保留最新有效触达号码。同一个用户可能有多个手机号家里一个、工作一个我的原则是保留最后使用且通过手机号格式校验的那个。关联时一定要防M:N一个用户对应多行订单处理不好会产生笛卡尔积数据量翻倍后面全乱了。3.2 路径二同设备/同账号指纹关联比订单表更隐蔽的一个途径是行为日志里的设备ID、IMEI、OAID或者同一社交账号绑定的历史联系方式。比如两个用户ID共用过同一个设备ID其中一个有手机号另一个没有那么缺失的手机号可以被“继承”过来。这种方式的准确率不是100%但用于人群召回和定向运营已经足够有效。实现上主要是基于图关系或简单的两两共现匹配。小数据量直接分组聚合即可。def fill_phone_by_device(user_df, device_df): device_df字段: user_id, device_id 同一个device_id下如果存在合法手机号则尝试回填缺失用户 # 先和用户表关联拿到已知手机号 dev_info device_df.merge(user_df[[user_id, phone]], onuser_id, howleft) # 按设备ID聚合收集该设备下所有出现过且合法的手机号 dev_phone_map (dev_info.dropna(subset[phone]) .groupby(device_id)[phone] .agg(lambda x: x.mode().iloc[0]) # 取出现频率最高的手机号 .reset_index()) dev_phone_map.columns [device_id, phone_inferred] # 再次关联给缺失用户填充 missing user_df[user_df[phone].isna()] filled missing.merge(dev_phone_map, left_onuser_id, right_ondevice_id, howleft) mask filled[phone].isna() filled[phone_inferred].notna() filled.loc[mask, phone] filled.loc[mask, phone_inferred] return filled用mode()取众数而不是随便取一个能有效避免因为旧数据残留导致手机号乱跳。如果同一个设备下出现过两个合法手机号取频率最高那个如果两个号码频次一样我再加一个优先级逻辑优先取最新活跃时间对应的号码。3.3 路径三彻底无法找回时标记而非填充如果上面几种路径都走不通手机号仍然缺失那就别硬填了。我的做法是把这类记录打上标记比如phone_status missing_unfillable。营销场景里这波人可以走站内信、App推送等替代通道数据分析场景里把他们单独区分出来做缺失值敏感性分析看看这部分人群和其他人群是否有明显差异。这样做的好处是不会把“缺失”这个信息本身给抹掉。很多时候手机号缺失本身就是一种值得建模的标签。4. 实操全流程从脏数据到可落库的标准表前面把策略拆开了这一节我把从原始表到标准表的完整流程串一遍提供一个可以直接抄作业的模板。4.1 流程总览整个过程分五步字段探查统计空值、非法值、格式分布。强制清洗格式化所有字符串滤除明显脏数据。规则校验用正则刷掉或标记不合规号码。多源回填分别跑订单关联和设备关联。状态标记输出phone_status状态列方便下游各取所需。4.2 核心封装一个全流程处理函数def build_phone_master(df, user_id_coluser_id, phone_colphone, order_dfNone, device_dfNone): 输入 df: 用户主表至少含user_id和phone字段 order_df: 订单表含user_id, phone, order_date可选 device_df: 设备关联表含user_id, device_id可选 输出 处理后的DataFrame包含phone_final和phone_status字段 out df.copy() # 1. 原始字段转字符串并清洗 out[phone_cleaned] out[phone_col].map(clean_phone_field) # 2. 先判断清洗后是否合法 out[phone_status] np.where( out[phone_cleaned].map(is_valid_phone), valid, np.where(out[phone_cleaned].eq(), missing_blank, invalid_format) ) # 3. 对状态为valid的直接作为final out[phone_final] np.where(out[phone_status] valid, out[phone_cleaned], np.nan) # 4. 订单表回填 if order_df is not None: out fill_phone_from_orders(out, order_df) out[phone_status] np.where( out[phone_final].notna() out[phone_cleaned].isna(), filled_from_order, out[phone_status] ) # 5. 设备表回填 if device_df is not None: out fill_phone_by_device(out, device_df) out[phone_status] np.where( out[phone_final].notna() out[phone_status].isin([missing_blank, invalid_format]), filled_from_device, out[phone_status] ) # 6. 最终兜底所有未能赋值的一律标记为missing_unfillable out[phone_status] np.where( out[phone_final].isna(), missing_unfillable, out[phone_status] ) return out[[user_id, phone_cleaned, phone_final, phone_status]]这个函数是我很多脚本的母版。实际落地时我会把每个步骤单独拆出来分别记录中间表的行数和缺失率。每一步跑完都打一条日志比如“订单回填前缺失率15.2%回填后缺失率11.8%”。这样如果某个环节数据量不对能第一时间定位。4.3 流程的权衡与思考有人可能会问为什么不在第一步就把清洗后非法的数据直接置空而是保留invalid_format状态让业务方看到“有号但没法用”和“压根没号”是两回事前者可能意味着用户在表单里填错了后者意味着用户没留联系方式。给状态分层后续做用户运营时能更好地决策。另外三个路径的执行顺序也很重要。我的顺序是“订单表回填”先于“设备表回填”因为订单里的手机号是用户主动填写且经过支付验证的可信度最高。设备表是间接推断的可信度略低。后面如果某个用户同时命中两个路径以更可信的那个为准。5. 工程化要点与性能优化数据处理脚本最怕的是什么一是跑得慢二是跑到一半内存爆了三是跑完结果没人敢信。5.1 向量化替代循环处理十万级以上的数据时for循环遍历每一行是噩梦。上面所有代码我都用了Series.map和np.where就是刻意避开循环。map是向量化的C-level操作性能远好于Python loop。如果你的电话号码清洗规则很复杂实在绕不开循环退一步用Series.apply也比for快一些。5.2 dtype处理陷阱手机号字段在数据库里看着是varchar但用Pandas读出来经常变成int64。这时候astype(str)是必须的但要注意如果某行是NaNastype(str)会把NaN变成字符串nan这不是一个有效的手机号但要小心它会在合并和匹配时干扰正则判断。我的防护习惯是在读取Excel或CSV时就明确指定dtypephone_dtype {phone: str, user_id: str} df pd.read_csv(users.csv, dtypephone_dtype, keep_default_naFalse)注意这个keep_default_naFalse。如果不加像000这种字符在读入时会因为默认na解析被当成NaN其实它是有意义的数据。5.3 内存占用优化如果用户表有千万级建议别直接全量一把梭。按用户ID分片处理每片100万行处理完写回磁盘parquet格式再做下一片。这里涉及一个经验parquet文件对字符串类型有压缩比csv小很多处理过程中能节省大量内存。另外object列尽量不要反复astype(str)每转一次都会产生新对象内存会逐渐膨胀。5.4 可复现性保障数据清洗要“敢跑数敢负责”所以每一步都要留痕。我通常在脚本末尾加一句out.to_csv(phone_master_processed.csv, indexFalse) out.to_parquet(phone_master_processed.parquet, indexFalse)同时输出一份处理摘要JSON{ total_records: 100000, valid_original: 78500, cleaned_to_valid: 8900, filled_from_order: 4200, filled_from_device: 1600, missing_unfillable: 6800 }有了这个摘要不管是自己review还是交接给同事都能快速了解数据健康状况。6. 常见坑与排雷记录我在几个项目里踩过不少坑列几个最典型的给各位当个参考。6.1 Excel打开CSV后手机号变科学计数法这是最经典的一个。数据明明没问题同事用Excel打开后看到1.38001E11一口咬定数据坏了。处理方式是交付时保留一个.xlsx版本或要求下游不要直接双击打开csv而是通过数据导入功能指定文本类型。这不算技术问题但很影响体验值得提前预案。6.2 手机号前有不可见字符比如UTF-8的BOM头、零宽空格\u200b肉眼完全看不到strip()也去不掉。这类字符会让正则匹配失败但是打印出来又看不出来。排雷时先对疑似脏数据做bad_values df.loc[~df[phone_cleaned].map(is_valid_phone), phone_cleaned].unique() for v in bad_values[:50]: print(repr(v))用repr()把不可见字符打出来比肉眼判断靠谱得多。处理时在清洗函数里统一加一条re.sub(r[\u200b\u200c\u200d\ufeff], , s)。6.3 号码补全但不能过于激进网上有很多“手机号前3后4补中间4位”的做法常规思路是中间四位从号段库里随机补。我之前试过最后生成的手机号虽然看起来合法但大量是空号或停机号营销触达时被投诉率直接拉高。后来改成“保守方案”如果至少能确定前7位可以做小范围号码段匹配如果只有前3位或更少宁可标记缺失。准确率比覆盖率重要尤其涉及外呼、短信这类成本较高的渠道。6.4 不要把设备关联回填的结果当成事实设备ID关联有一个隐患一个设备可能是二手手机前后属于两个不同的人。如果不加限制地用设备号去继承号码很容易把A的号码安到B头上。所以我在设备回填后多做了一个校验回填的号码不能和服务器的注册时间相差太远。如果填过来的号码注册时间早于该用户首次活跃时间就判定为不可信回退为缺失。6.5 处理时效合理安排批处理任务手机号补全不是一次性的随着新订单、新日志的产生每天都会有新的可回填数据。建议写成定时任务每天凌晨低峰期增量跑一次。增量处理时定期快照主表新数据从上次处理位点往后拉避免每天算全量也不至于跑太慢。7. 实战效果回顾与后续扩展最后聊点实际收效。我用这套流程跑过一个模拟业务的用户表原始数据15万条手机号合法率只有72%一眼望去缺失很严重。按上面的流程走完清洗阶段把带分隔符、带86、全角符号的“隐藏有效数据”解出来合法率从72%提到81%。订单表回填关联近一年订单把9%的缺失用户补上合法率到90%。设备表回填通过设备ID众数匹配再补4%合法率约94%。最终真正彻底没号的只有6%。这6%的号码没有硬填而是在状态列里标记出来。后续运营这批人时走站内信和App推送不占用短信预算整体外呼接通率和短信送达率反而比之前直接用“脏数据”跑出来的高了很多。我个人在实际操作中的体会是数据缺失处理最大的工作量不在写代码而在理解每条缺失背后的业务语义。同一列缺失可能是新用户还未绑定手机、老用户注销了号码、客服录错了格式、系统迁移时丢了一段。只有把每种情况分门别类地设计策略代码写起来才顺手结果才解释得通。这套方法后面还可以继续扩展比如引入地址库校验号段、对接外部合法号码状态查询接口或者把状态标记做成一个可视化报表但整体思路已经足够稳定踩过几次坑之后现在我拿到一张脏表基本能很快判断出大概哪些环节需要下功夫这也算是一种“手感”吧。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询