晚上十点十二分,班级群弹出一条“4.9作业记得提交”的消息,我才意识到那份数据分析大作业还躺在草稿箱里。任务发布时我觉得时间很充足,结果前一周完全没动,真正开始做的时候离截止只剩三天半。后来我把这段时间利用起来,从重新拆解需求到最终提交,总共花了大概12个小时,总算赶在4月9日晚上十一点前完成。这篇博文就把这次4.9作业的整个完成过程复盘一遍,包括怎么定题、怎么写采集脚本、怎么清洗数据、怎么画图、怎么写报告,以及中途踩过的那些坑。如果你正好也有一份类似的课程作业要赶,或者想入门数据分析的实际操作流程,可以参考一下我的做法。
1. 拿到4.9作业后,我重新做的需求拆解
1.1 课程作业背后真正要交付的五样东西
当时老师发的作业要求原文很长,我读完第一遍只记住了“截止时间4月9日”。后来重新逐句读,才把完整要求拆出来:采集不少于500条真实数据,完成数据清洗和统计分析,输出至少两种可视化图表,写一份不超过8页的分析报告。
这听起来像五个交付物,实际上是一整条流水线。采集只是第一步,清洗和统计决定了报告能不能写,图表则决定了报告好不好看。如果一开始只盯着“我要爬数据”,很容易在选题阶段就翻车。我后来把任务拆成五块:数据源、采集脚本、清洗脚本、统计图表、报告文档,每块单独排时间,这样不会到了截止前一天才发现漏了某个环节。
拆完需求我顺手做了一件事:把“500条数据”这个数字标成硬性指标。因为最后检查作业质量时,数据量是最容易验证的底线。数据不到500条,报告写得再好也显得课题太小。所以后面所有步骤都以“采集到超过500条有效数据”为第一目标。
1.2 定主题:为什么是空气质量和气温
选题我纠结了一个晚上,最后选了“全国主要城市近30天的空气质量与气温数据”。原因有三个:数据公开可访问、字段语义清晰、可以同时做定性和定量分析。
先说数据公开性。我必须强调一点,作业选题千万别选需要登录、验证码复杂或者数据藏在私有接口里的网站,不然你写的爬虫大概率会卡在第一步。我选的这个主题在公开的天气数据页面就能拿到,不需要账号,页面结构简单,直接解析HTML就能提取字段。
再说字段设计。我确定的字段包括城市、日期、空气质量指数AQI、空气质量等级、最高气温、最低气温,一共六列。这足够支撑多种分析方向:看城市间的空气质量分布、看AQI与气温的关系、看不同等级下的平均温度。字段太多反而容易分散精力,六个字段对一份课程作业来说刚刚好。
最后是可解释性。空气质量和气温都是大众熟悉的概念,写报告时不需要长篇大论解释背景,可以把篇幅留给数据分析和结论。很多同学喜欢选看起来很酷的主题,比如情感分析、网络评论关键词,结果光分词和清洗就耗掉大半时间,最后报告写得仓促。我的建议是:课程作业优先选“容易清洗、结构清晰、好讲故事”的数据,别在选题上赌难度。
1.3 数据源选型和一条底线
数据源我对比过三种形式:公开API接口、静态HTML页面、别人整理好的数据集。最终选了静态HTML页面,原因在于它最贴近“从零采集”的作业要求。
公开API接口通常返回JSON,解析简单,但有些接口对请求频率限制严格,而且需要阅读文档,对新手来说有额外学习成本。直接下载整理好的数据集虽然最省事,但交给老师时容易说不清数据来源和采集过程,作业的“技术含量”也打了折扣。静态HTML页面反而有两个好处:一是能完整展示爬虫的解析能力,二是数据更新相对稳定,不容易出现接口格式变化导致代码失效的问题。
这里必须说一条底线:只采集公开可访问的数据,不碰任何需要绕过认证才能拿到的东西。爬虫这行有句老话叫“爬虫写得好,狱饭吃得早”,虽然是玩笑,但提醒我们别为了作业去踩灰色地带。我当时还专门看了一下目标网站的页面底部有没有相关使用说明,确信是允许普通浏览访问的,才放心写代码。另外,请求频率一定要克制,别把人家网站爬挂了,这是最基本的礼貌。
2. 采集环节:爬虫脚本怎么写才能少返工
2.1 先判断页面结构,再决定用什么技术栈
我刚开始写爬虫时吃过亏,拿到URL就写requests.get,结果返回的HTML里根本找不到目标数据。后来才明白,很多页面是前端动态渲染的,数据在浏览器里能看到,但requests直接请求拿到的只是空壳。
所以这次动手前,我先在浏览器里打开目标页面,右键检查,看了两件事:数据是否直接存在于HTML里,以及翻页时URL的变化规律。如果数据在HTML里,用requests加BeautifulSoup就能解决;如果数据是通过XHR接口异步加载的,就要去Network面板里找到真正的JSON接口,直接请求接口反而更稳定。
对于我的主题,数据恰好直接在HTML表格中,所以技术栈定为requests加BeautifulSoup。很多人一上来就想用Scrapy,但这份作业的数据量只有几百到几千行,单线程、循环翻页完全够用。Scrapy学习成本高、配置复杂,对这种小体量任务属于杀鸡用牛刀。工具选型要匹配任务规模,这是我这次最深的体会之一。
2.2 一个能直接改着用的爬虫模板
这里分享我最终使用的爬虫核心代码,目标URL我用了占位符,你换成自己要采集的页面即可。整个脚本的逻辑非常简单:构造请求头、请求页面、解析HTML、提取字段、保存到CSV。
import requests from bs4 import BeautifulSoup import csv import time headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" } def fetch_page(city_code, page): url = "https://example-weather-site.com/air?city={}&page={}".format(city_code, page) resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding return resp.text def parse_html(html): soup = BeautifulSoup(html, "html.parser") rows = [] for tr in soup.select("table.data-table tbody tr"): cells = [td.get_text(strip=True) for td in tr.find_all("td")] if len(cells) >= 6: rows.append(cells) return rows if __name__ == "__main__": all_rows = [] city_codes = ["101010100", "101020100", "101030100"] for city in city_codes: for page in range(1, 5): try: html = fetch_page(city, page) data = parse_html(html) all_rows.extend(data) print("city {} page {} got {} rows".format(city, page, len(data))) except Exception as e: print("error:", city, page, e) time.sleep(1.5) with open("raw_data.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["city", "date", "aqi", "level", "temp_high", "temp_low"]) writer.writerows(all_rows)这段代码我实际运行时做了两个调整。第一,resp.encoding要设置成页面实际的编码,最稳妥的方式是使用resp.apparent_encoding,它会根据内容自动判断编码,能解决大部分中文乱码问题。第二,解析函数里我用了CSS选择器定位表格行,如果你的目标页面结构不同,需要换成对应的选择器,这一步是整个脚本最容易出错的地方,调试时可以把html先保存下来单独排查。
2.3 请求频率、结果校验和数据落盘
写爬虫时很多人只顾着跑通,忽略了对目标网站的影响。我在两个请求之间加了time.sleep(1.5),也就是每1.5秒请求一次。这个频率不会太激进,也能保证在截止前完成几百条数据的采集。如果请求太频繁,很容易触发网站的防护机制,IP被临时限制反而更慢。
跑完脚本后我没有直接开始清洗,而是先打开CSV文件看了一眼行数和字段。这一步叫“结果校验”,看起来简单,却能避免很多后续问题。比如有一次我只写了循环没写翻页,结果三个城市抓到的都是同一页的数据,数据量虽然够500条,但完全没有分析价值。所以校验的时候不仅要看行数,还要手动抽查几条记录,看看城市是否多样、日期是否有重叠。
数据落盘我选了CSV格式而不是数据库,原因很简单:数据量小、中间结果直观、pandas读起来方便。作业场景下CSV是性价比最高的存储方案。如果你在做一个长期项目、数据量会持续增长,再考虑SQLite也不迟。
2.4 看到CSV里有3000行时,我反而警觉了
第一次跑完,我得到的原始数据有3000多行,看到这个数字时我第一反应不是开心,而是警觉。因为我的目标是从三个城市各抓取30天数据,理论上应该只有90行,怎么会有3000行?
排查后发现,页面里的“近30天”是按城市站点拆分的历史记录,同一个城市在不同监测站会有多条数据,而且我翻页翻多了,抓到了重复的日期。这意味着原始数据里存在大量重复和冗余,必须在清洗阶段处理掉。3000行这个数字反而提醒了我:不能看到数据量大就觉得“采集成功”,数据的有效性和多样性更重要。
我给自己定了一个清洗目标:每个城市保留最多30天的记录,去除重复日期,最终保留大约90行核心数据。这样虽然总数看起来“变少”了,但每行都干净、可解释、不重复,比3000行充满噪声的数据更有说服力。后来老师在作业点评里特别提到,有效数据和原始数据要区分清楚,这恰好印证了我的处理方向。
3. 清洗与分析:数据不是越复杂越好
3.1 拿到CSV后先做这四件小事
很多同学拿到CSV就直接画图,结果图出来了才发现数据是错的。我这次用pandas读取数据后,强制自己先做四件小事:看前几行、看字段类型、看统计摘要、看缺失值情况。
import pandas as pd df = pd.read_csv("raw_data.csv") print(df.head()) print(df.info()) print(df.describe(include="all")) print(df.isnull().sum())这一步的信息量非常大。head能看出字段是否对应上;info能看出每列的数据类型是否正确,比如日期是不是object类型、AQI是不是int类型;describe能看出数值字段的分布范围,如果最高温度出现999这种明显异常值,就要回去检查原始数据;isnull则直接告诉我们哪些字段有缺失。
我这次发现日期列是object类型,AQI列有少量缺失值,温度列有一部分是字符串形式,比如“14℃”带着单位符号。这些都是后面要处理的脏数据。先看清问题再动手,比闷头清洗高效得多。
3.2 处理脏数据的几个典型场景
我的清洗脚本经历了三轮迭代,每一轮解决一类问题,最终版逻辑如下。
df["city"] = df["city"].str.strip() df["date"] = pd.to_datetime(df["date"], errors="coerce") df = df.dropna(subset=["date", "aqi"]) df["aqi"] = pd.to_numeric(df["aqi"], errors="coerce") df = df.dropna(subset=["aqi"]) df["temp_high"] = df["temp_high"].astype(int) df["temp_low"] = df["temp_low"].astype(int) df = df.drop_duplicates(subset=["city", "date"], keep="first")第一个典型场景是字符串格式不统一。城市名有的带空格,日期有一部分是“2025-04-01”这种标准格式,另一部分可能是“2025/4/1”,pd.to_datetime可以统一解析,但要把errors设为coerce,这样无法解析的值会变成NaN,方便后续处理。
第二个典型场景是缺失值。AQI列有几个缺失值,处理缺失值一般有两种思路:删除或填充。对于课程作业,缺失比例不高时直接删除不会有太大影响,但要记得保留下处理记录,写报告时说明“删除了多少条包含缺失值的记录以及为什么这么做”。
第三个场景是类型转换。AQI列刚开始读取时可能是字符串,直接用数值计算会报错或得到错误结果,需要先通过pd.to_numeric转成数值类型。温度字段则要注意单位符号,如果带“℃”就要先替换掉再转int。最后用drop_duplicates按城市和日期去重,保留每条记录的第一次出现即可。
3.3 用图表逆向倒推统计分析
很多人做统计分析时习惯先把所有指标算一遍,再考虑画图,结果最后大部分计算结果根本没用上。我这次换了个思路:先确定报告需要哪些图表,再倒推出需要什么统计量。
我规划了三张图。第一张是“各城市空气质量等级分布”,需要用pivot_table统计每个城市不同空气质量等级的天数;第二张是“AQI与温度的散点图”,需要两列数值字段;第三张是“AQI最高和最低的十个日期”,需要对AQI排序。想清楚这些后,统计分析的代码就非常聚焦了。
summary = df.groupby("city")["aqi"].agg(["mean", "min", "max", "count"]) print(summary) pivot = pd.crosstab(df["city"], df["level"]) print(pivot)groupby计算了每个城市的平均AQI、最低AQI、最高AQI和记录数,crosstab则统计了各城市在不同空气质量等级下的天数。这些统计量刚好能回答报告里最核心的几个问题:哪个城市整体空气质量最好?极端污染最严重的是哪一天?不同城市之间差异有多大?
3.4 从数据里读出能写进报告的结论
统计结果出来后,我没有急着画图,而是先写了几条数据结论。比如我观察到:沿海城市的平均AQI明显低于内陆城市;AQI与最低气温之间存在一定正相关,温度偏高的夜晚往往污染也更严重;空气质量等级中“良”的出现频率最高,“重度污染”较少。
这些结论看起来简单,但它们直接决定了报告的分析部分怎么写。我建议你也这样做:在跑可视化之前,先把统计输出的核心数字整理成3到5条文字结论,画图的时候再逐一用图表去验证和展示。这样做的好处是报告的逻辑链条非常清晰,每一张图都有对应的结论支撑,老师看起来会觉得“这个学生真的理解了数据”,而不是为了凑图片数量硬画。
4. 可视化与报告:图比字好看,但要讲人话
4.1 中文字体和图片导出的两个坑
matplotlib默认字体是不支持中文的,图里一旦出现“城市”或“空气质量”这样的汉字,就是一个个方框。这个问题网上到处都有解决方案,核心是设置中文字体:Windows上一般用SimHei,macOS上一般用PingFang SC或STHeiti。
import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei", "PingFang SC"] plt.rcParams["axes.unicode_minus"] = False第二行axes.unicode_minus设为False是为了解决负号显示成方块的问题。这两个配置我通常会放在绘图脚本的最顶部,确保整份脚本里的所有图表都生效。另一个坑是图片导出路径。在不支持GUI显示的环境里,plt.show()可能不生效甚至报错,所以我统一用plt.savefig保存图片到指定目录,再在报告里引用。dpi设置为150足够清晰,文件体积也不会太大。
4.2 三张图表,每张只回答一个问题
我画第一张图时给了完整的代码,后面两张只调整数据结构。
pivot = pd.crosstab(df["city"], df["level"]) pivot.plot(kind="bar", stacked=True, figsize=(10, 6)) plt.title("各城市空气质量等级分布") plt.ylabel("天数") plt.xlabel("城市") plt.xticks(rotation=0) plt.legend(title="空气质量等级") plt.tight_layout() plt.savefig("level_bar.png", dpi=150) plt.show()这张堆叠条形图回答的问题是“各城市的空气质量结构有什么差异”。通过不同色块的高度对比,能一眼看出哪个城市的“良”更多、哪个城市有“中度污染”出现。第二张散点图回答“AQI和气温是否存在关系”,我直接用df.plot.scatter,X轴设为最低温度,Y轴设为AQI,颜色用默认即可,点在图上越分散说明相关性越弱,有一定的聚集趋势就能说明问题。第三张条形图展示“AQI最高的前十个日期”,我用sort_values排序后取头部,这类榜单图很适合放进报告结论部分。
图表最重要的不是花哨,而是要能讲清楚一件事。一张图对应一个核心问题,报告读起来就会非常清爽。
4.3 Markdown报告模板,改一改就能用
报告我是用Markdown写的,用Typora打开编辑,最终导出PDF提交。Markdown的好处不用多说:排版简单、支持表格和图片引用、导出格式统一。我给自己的报告定了一个固定结构:
# 城市空气质量与气温数据分析报告 ## 1. 项目背景 ## 2. 数据说明与处理 ## 3. 统计结果 ## 4. 可视化分析 ## 5. 结论与不足“项目背景”里写清楚为什么选这个课题;“数据说明与处理”里列出数据来源、采集方式、清洗规则,包括删除了多少缺失值、如何去除重复数据;统计结果放groupby计算出的表格;可视化分析部分每张图配一段文字,写明图表说明了什么;结论与不足则总结主要发现,坦白数据量和时间限制带来的局限。
写报告最容易犯的错是只贴图不解释。图放了三四张,但没有任何分析文字,老师看完根本不知道你的思路。我在每张图下面都写了两三句话,包括图里最明显的趋势和它回答的业务问题。这个习惯后来被同学问到,我说其实不复杂,就是把图当作论文里的Figure,每张图必须有对应的Figure Caption。
5. 这次4.9作业中的问题排查速查表
5.1 采集阶段:403、空列表、乱码
采集阶段我遇到的最典型问题是403。第一次请求页面时我没有设置User-Agent,Python默认的UA很容易被服务器识别为脚本请求,直接拒绝访问。解决办法很简单,在headers里加上浏览器UA即可。如果还是被拒绝,可以适当降低请求频率,或者增加一个随机延时。
第二个典型问题是解析结果为空列表。选择器定位不到目标表格时,BeautifulSoup返回空结果。我当时的排查方法是用response.text和select去搜索目标表格的class名,发现class名里包含空格,导致CSS选择器匹配不到,改成只匹配表头关键字才解决。
第三个问题是中文乱码。有些页面是GBK编码,直接用requests的默认编码解析就会乱码。我设置了resp.encoding = resp.apparent_encoding,让程序根据内容自动判断编码,这个问题就消失了。这几个问题我汇总成一张表,方便以后排查。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 请求返回403 | 缺少User-Agent或频率过高 | 伪装浏览器UA,降低请求频率 |
| 解析结果为空列表 | 选择器匹配不到元素 | 检查class名、标签层级、是否动态渲染 |
| 输出乱码 | 页面编码不一致 | 设置resp.encoding为apparent_encoding |
| 数据重复 | 翻页逻辑或站点出现重复记录 | 清洗时按主键去重 |
5.2 数据处理阶段:类型、缺失值和重复行
数据处理阶段典型的报错是“unsupported operand type(s)”,原因通常是列类型不是数值。AQI列在读取时为字符串,直接用df["aqi"].mean()会报错或得到奇怪结果。我的经验是用pd.to_numeric先做类型转换,并对无法转换的值设置errors="coerce",统一变成NaN再处理。
另一个容易忽视的问题是缺失值导致的计算偏差。如果不清理缺失值就分组聚合,统计结果会和真实情况有很大偏差。我在报告里明确写了“原始数据3000余行,清洗去重后保留有效数据多少行,缺失值处理情况如何”,这部分透明度反而让报告显得严谨。
重复行我用了drop_duplicates(subset=["city", "date"])处理,因为对这份数据来说,城市和日期组合起来应该是唯一主键。如果你处理的是其他数据,要自己确定好主键,选错了可能导致该保留的数据被删除。
5.3 时间管理:前松后紧是最常见的翻车方式
这次作业让我最想吐槽自己的不是技术问题,而是时间安排。前四天完全没动,最后三天才集中突击。如果能把12个小时平均分配到五天的晚上,其实每天只需要两个多小时,根本不会那么焦虑。
后来我总结了一个“时间盒”策略:把大任务拆成若干个两小时的小盒子,每个盒子只允许专注一件事。第一个盒子拆需求和定主题,第二个盒子写爬虫,第三个盒子清洗数据,第四个盒子做统计,第五、第六个盒子画图和写报告。每个盒子之间休息十分钟。这个策略看似简单,实测下来却比我集中熬夜高效得多。
如果你也在赶一份类似的时间节点作业,我强烈建议至少提前两天完成技术部分,最后一天只做检查和优化。因为技术环节永远会出意外,留出缓冲时间才能应对那些“怎么也查不出来”的诡异bug。
6. 提交前检查清单与几点个人体会
6.1 提交前十分钟,我逐项检查了什么
临近提交时我做了一个逐项核对动作,避免因为低级失误扣分。我按这个顺序检查:CSV数据文件是否存在且行数超过500、图片能否正常打开、报告PDF导出是否正常、代码文件是否命名清晰、报告里是否写明了数据来源和处理方法。
代码文件命名我花了五分钟整理成规范的英文名,比如crawler.py、clean.py、analysis.py、report.md。这里有个小建议:文件名和文件内的变量名保持统一风格,代码注释写清楚每个步骤的意图。老师浏览你的项目文件时,第一印象往往来自文件组织的整洁程度。
6.2 合格作业和优秀作业差在哪里
我对比了好几份同学交的作业,发现合格的作业和优秀的作业之间差的不只是结果,而是思考的完整度。合格作业会展示采集了多少数据、画了几张图、最后得出什么结论;优秀作业则会解释为什么这样选数据源、清洗时做了什么决策、每张图到底想说明什么问题。
这个差别其实很好补:分析报告里多写几句“为什么”。为什么选这个主题?为什么用这个指标?为什么删掉这些缺失值?只要每个关键决策都有依据,作业质量就会明显提升。把这些决策过程写清楚,比堆技术术语更有说服力。
6.3 这次4.9作业教会我的三件事
第一件事是把截止时间前置一天。无论老师给的期限是4月9日还是哪一天,我都会在心里把个人截止时间提前24小时,最后一天专门做检查和修正。这种做法救了我很多次,因为意外总在最后一刻出现。
第二件事是用输出倒推输入。先确定报告里要放哪些图和哪些表,再倒推需要什么字段、什么统计量、什么样的清洗逻辑。这个习惯让我从“为了写作业而写作业”变成了“为了完成一个有说服力的分析而写作业”,效率和质量都提上来了。
第三件事是遇到问题别闷头折腾。我卡在选择器定位时浪费了四十分钟,后来打开浏览器开发者工具仔细观察页面结构,五分钟就解决了。工具的开发者工具是调试页面最直接的窗口,用它定位元素比盲目猜测高效太多。回过头看,这份4.9作业本身不算难,真正考验的是统筹能力。能把时间、工具和思路都安排明白,这类任务对你来说就不再是负担了。