一次景点数据爬取复盘(续):我把「8.8% 脏数据」这个数字彻底推翻了
续自上一篇《一次景点数据爬取复盘从 499 条到 455 条我剔掉的 8.8% 脏数据》。上一篇写于项目早期 v1 阶段那一版清洗只做了一半结论也是错的。这篇讲我后来发现了什么、以及为什么我要把自己的结论推翻。完整代码https://github.com/koilxz/city-attraction-analysis先说结论上一篇我说“499 条数据里有 44 条脏数据污染率 8.8%清洗后剩 455 条。”这个数字是错的。真实情况是499 条里只有348 条属于我以为的那种脏数据跨城市重复抓取而真正致命的问题——景点根本不在这个城市——我一条都没查出来。最终清洗结果是151 条不是 455 条。污染率不是 8.8%而是69.7%clean.py日志原文合计剔除: 348 条剩余: 151 条/保留率: 30.3%。上图左边是逐城市对比右边是 348 条的剔除原因构成。可以看到十个城市几乎都是 50 条起步、清洗后只剩 11~18 条保留率最低的青岛只有 22%。上一篇v1这一版v3识别出的问题跨城市重复重复 系统性城市误标 伪造字段 伪零分清洗后剩余455 条151 条声称污染率8.8%69.7%结论可信度❌ 假干净✅ 双向校验通过一句话总结这个过程v1 的数据看起来很干净不是因为它干净而是因为我的检查规则太弱。一、被我放过的第一个信号重复抓取这部分上一篇写对了。旅游网站的列表页底部有「相关推荐 / 猜你喜欢」模块我的爬虫把这些推荐位的内容也抓了下来。同一个景点出现在多个城市的列表里sight_id完全重复的123 条涉及97 组按sight_id跨城市去重后499 → 376 条这一步我做对了但也就停在这里了。二、我漏掉的第二个信号字段是假的某天我突然想到一个问题我用来验证这个景点属于哪个城市的那个字段是真的吗我去翻自己的爬虫代码找到了这段# spider_real.pyv1 版本节选deffake_coord(city):按城市中心生成一个坐标centers{北京:(39.90,116.40),上海:(31.23,121.47),...}returncenters[city]# ← 同一个城市的所有景点坐标完全一样而distance距离市中心距离字段spot[distance]round(random.uniform(0.5,30),2)# ← 随机数也就是说latitude/longitude/distance这三个字段全是假的是脚本编出来的。我当时脑子里有一个很具体的计划用经纬度反查来校验景点归属。当我知道这件事的时候那个计划直接作废了。处理方式三个字段全部从数据集中删除README 里主动写明这个局限。这一条是整个项目里最让我后怕的。如果我当初用经纬度校验并且跑通了我会以为自己做了个很严谨的交叉验证——实际上我是在用一个自己造的假数据去验证真实数据。错得越自信越难发现。三、真正的问题系统性城市误标删掉假坐标之后我失去了自动校验归属的能力。只能换一条路逐条人工核验。在开始之前我先试着让规则去抓。写了三类规则去重规则sight_id出现在多个城市 → 误标地标规则名字里含其他城市名如杭州西湖出现在上海 → 误标省市信号规则简介里提到的省市与标注城市不符 → 误标跑完一看残留的疑似记录只有十几条。我以为快收工了。然后我去人工看。第一页就发现了漏网的。青岛的列表里有蜈支洲岛海南三亚蓬莱阁山东烟台刘公岛山东威海孔庙山东济宁这四个地名没有一条能被上面三类规则抓到规则为什么失效去重规则它们在本市列表里只出现一次不构成重复地标规则“蜈支洲岛”“蓬莱阁”“刘公岛”孔庙名字里不含任何城市名省市信号规则蓬莱、刘公岛、孔庙都在山东省内和标注城市青岛同省 ⇒ 信号一致这就是系统性三个字的含义这不是零散的脏数据是一整类我的规则设计根本没考虑过的情况——景点在省内但不在本市。clean.py的实际剔除日志src/clean.py运行输出[步骤3] 跨城市去重按 sight_id 剔除记录 : 167 条 [步骤4] 单条记录的定位冲突 剔除记录: 181 条 [重庆] 芙蓉镇 ← 地标归属冲突(湘西) [重庆] 凤凰古城 ← 地标归属冲突(湘西) [重庆] 沱江欣赏苗家吊脚楼 ← 核验表实属湖南湘西州 [重庆] 张家界国家森林公园 ← 地标归属冲突(张家界) [重庆] 黄龙洞景区 ← 核验表实属湖南张家界 [步骤4b] 「真实归属核验表」覆盖校验 核验表条目 : 128 条 据此剔除记录 : 156 条 合计剔除: 348 条 剩余: 151 条注意[步骤4]里两种剔除理由的分工「地标归属冲突」是规则自动抓到的「核验表」是我人工核验补上的。后者占了 156 条 —— 如果没有那张人工表这 156 条会全部漏掉而且日志上看不出任何异常。一旦找到第一个剩下的就是顺着往下看。我把 279 个景点逐条过了一遍产出一张128 条的「景点全名 → 真实归属」核验表。clean.py里这张表是这样参与清洗的fromforeign_mapimportFOREIGN_MAP# 128 条人工核验表# 精确匹配优先表里有记录的以表为准keyf{row[name]}ifkeyinFOREIGN_MAP:row[city]FOREIGN_MAP[key]关键在于用的是精确匹配而不是模糊匹配。因为我试过模糊匹配比如兵马俑去匹配兵马俑博物院结果把西安城墙误命中成西安——规则越智能错误越难查。四、清洗后我做了什么9 项双向校验数据删掉了 348 条我必须证明没有误杀。所以写了这个脚本# verify_clean.py节选checks[(核验表外地景点残留,...),# ① 该删的删干净了吗(跨城市重复 sight_id,...),# ② 重复清零了吗(城市数,...),# ③ 10 个城市还在吗(每城景点数,...),# ④ 会不会有城市被删空了(伪造字段残留,...),# ⑤ 假字段删干净了吗(伪零分记录,...),# ⑥ 0 分处理了吗(同城同名重复,...),# ⑦ 新的重复形态(关键景点误杀,...),# ⑧ 双向校验不该删的删了吗(污染样本残留,...),# ⑨ 已知脏样本清零了吗]实跑结果✅ ① 核验表外地景点残留 0应为 0 [] ✅ ② 跨城市重复 sight_id 0 组应为 0 ✅ ③ 城市数 10应为 10 ✅ ④ 每城景点数 11~18最少应 ≥8 ✅ ⑤ 伪造字段残留 []应为空 ✅ ⑥ 伪零分记录评分0 且评论0 0 ✅ ⑦ 同城同名重复 0 ✅ ⑧ 关键景点误杀 0 [] ✅ ⑨ 污染样本残留 0 [] 结果9 通过 / 0 失败注意 ④ 和 ⑧④ 检查没有城市被删空⑧ 检查关键景点没被误杀。只做该删的删了吗是不够的——清洗脚本最容易的失败方式就是删得太多而且自己不知道。五、还有个没注意到的坑0 分不等于差评清洗过程中我发现原始数据里有 13 条记录评分 0。查了才发现平台的编码逻辑rating 0 且 comment_count 0 → 实际是「从未有人评价过」它不是0 分差评是没有数据。这两件事混在一起会把所有评分统计拉低。处理置为缺失值按城市均值填补。这 13 条里有11 条在去重后仍留在最终数据集里另 2 条本来就属于被剔除的误标/重复记录全部按缺失处理。清洗后数据集中已不存在rating 0 且评论 0的记录对应verify_clean.py的第 ⑥ 项校验。六、最终数据长什么样清洗后151 个景点分布城市景点数平均评分累计评论量西安184.419179,970杭州174.50013,826重庆164.48537,162济南164.40011,088南京164.58137,162上海154.650—武汉154.60926,527北京144.644—成都134.66787,355青岛114.63044,974合计608,423条评论量151 个景点里有105 个有评论、46 个评论数为 0。顺带说清一个容易混淆的地方这里的608,423是平台字段comment_count的求和“累计有多少条评论”而我自己抓回来、实际读过内容的评论文本是1,812 条清洗前 499 条时是9,058 条。两个数字差了三个数量级因为爬虫只抓了每个景点页面前几页评论。这两个口径不能混着用—— 汇报数据规模用 60.8 万做文本分析用 1,812。我第一次写简历时把两者和景点数混在一句话里被自己面试假想追问了一次就露馅了。七、结论本身也不显著这里一并交代数据洗干净之后我做了相关性分析结果是两个假设都不成立假设 1热度高的景点评价更好Pearson r 0.1009 (p 0.2175) 不显著 Spearman ρ 0.0782 (p 0.3399) 不显著 高热度组评论数 Top 20%n31均分 4.671 低热度组评论数 Bottom 20%n46均分 4.646 Welch t 检验 p 0.6299 不显著高热度组只比低热度组高0.025 分。这个差距在 151 个样本上完全站不住。假设 2哪些城市口碑更好排名是成都 4.667 最高、济南 4.400 最低全网均分 4.550评分 ≥ 4.5 的景点占77.5%。但看到这个分布我不太敢下成都口碑最好的结论——因为每个城市的样本量只有 11~18 个这个量级的均分标准误太大。这个排名可以做描述不能用来支撑成都景点更好这种断言。八、一个我特意去验证的问题清洗有没有美化数据剔掉 348 条、留下 30.3%这动作本身就容易被质疑你是不是为了让图更好看才挑着删的所以我专门算了一下清洗前后的评分分布清洗前清洗后样本量排除伪零分467151评分均值4.53964.5496差异—0.0100差了 0.01 分。这说明什么说明我剔掉的那些记录在评分上几乎是随机分布的—— 如果我是挑好看的删均值一定会被明显抬高。我没有。这是我敢把清洗前后数据放在一起对比的底气数据清洗的目标是准不是好看。九、让数字可复现把结论写进脚本上面这些数字我一开始是写在 README 里的。但每次改清洗逻辑都要手动重算一遍——这是数字失控的开始。所以我把这些全部写进了src/stats.py里面带 11 项断言EXPECT{n:151,comment_total:608423,pearson_r:0.101,spearman_rho:0.078,n_hi:31,n_lo:46,hi_mean:4.671,lo_mean:4.646,ttest_p:0.630,overall_mean:4.550,pct_ge_45:77.5,}# ... 计算后逐项比对任何一项漂移即 sys.exit(1)跑python src/stats.py输出【自检】✅ 全部 11 项与 README 基准一致这样做的价值是如果哪天清洗逻辑改了、导致 README 里的数字不再是真实的脚本会直接报错而不是让一个错数字继续躺在仓库里。顺便修掉一个我自己的错误README 里原来写的是r0.101p0.340——r 是 Pearson 算的p 却是 Spearman 的。这种两个统计量张冠李戴的错误不写脚本根本发现不了。十、这一版的三条教训1. 看起来干净和真的干净是两件事。我 v1 的数据通过了去重规则、通过了我自己写的所有校验但我完全没发现 69.7% 的数据城市归属是错的。校验规则的天花板 你的认知天花板。人工逐条看一遍胜过跑一百条自认为很聪明的规则。2. 用假数据做交叉验证比不做验证更危险。我原计划用经纬度校验归属而经纬度是脚本自己编的。如果那条路跑通了我会得到一个完全错误但看起来很严谨的结论。做校验之前先确认被校验的字段本身是真的。3. 主动推翻自己的结论是这个项目里最有价值的部分。上一篇那篇文章我写得挺顺数据故事完整、结论明确。但它建立在错误的清洗结果上。如果我不回头看污染率 8.8%这个数字会跟着我进简历、进面试成为一个随时会塌的雷。技术栈Python、Requests、BeautifulSoup4、Pandas、NumPy、SciPy、Matplotlib、Seaborn、folium