Python作业4复盘:层次聚类与数据清洗可视化实战解析
“python作业4”这个标题交到我手上时我是有点恍惚的——它没有正文、没有背景说明就四个字加一个数字。但结合最近一直在带新人、帮人看作业的亲身经历我大概能猜到它背后的真实场景这是很多Python课程都会出现的第四次大作业前几次还在练基础语法到这一题突然要求独立完成一个“采集-清洗-分析-可视化”的完整小项目。我印象非常深自己当年就是在这份作业上第一次体会到什么叫“学过的知识全都用得上又全都差一点”。这篇文章我就拿当时做的一份作业来复盘题目是基于层次聚类的城市气候数据分析。全程涉及Python安装和VSCode配置、爬虫请求、类型转换、重复值筛选、层次聚类、多进程和协程提速以及最折磨人的画图坐标轴问题。希望能给正在啃python作业、准备做数据分析小项目的人一些实际可抄的作业。1. 作业题目背后的真实考点不是聚类是完整流程1.1 这作业到底要干什么先还原一下题目本身。老师给的要求很简短自选一个公开数据源用Python完成数据的获取、清洗、分析和可视化最后写一份不超过2000字的报告。看起来自由度很高但实际就是要逼你走一遍真实的数据分析流程。前三道作业都是处理老师给好的csv文件步骤也被限制得死死的到了“作业4”突然不设限很多人第一反应是慌第二反应是选一个看起来不容易翻车的方向。我见过不少同学最后选了最简单的那条路直接去Kaggle或者一些公开数据集网站下载现成的csv然后用pandas读进来画几张图。这样做不是不行但我个人建议到了“作业4”这个阶段强烈建议你选择需要自己写爬虫的数据源。原因很简单数据获取这一步是整个流程里最能体现工程能力的环节也是你以后工作中大概率要反复面对的场景。如果这次不练后面更没机会练。1.2 为什么我选了天气数据当时我最终选择的是公开天气接口请求特定城市近一个月的每日最高温、最低温、平均湿度和降水量。选天气数据有几个非常实际的好处数据本身开放、免费、无需申请密钥拿起来没有门槛字段干净但又有杂质非常适合练习字符串转数值、缺失值处理这些操作城市数量可以自己控制10个城市太少80个城市又能撑起聚类分析和可视化的篇幅最后画出来的树状图即使配色一般也天然容易让人看懂结论。这里还带出一个做题思路上的小技巧作业的数据源一定要选“适合讲故事的”。同样是做层次聚类如果你选的是股票收益率数据老师看着一堆百分之零点几的数还得猜半天但你选的是“哪些城市的气候更像”从树状图里一眼就能看到海口和三亚聚在一起、哈尔滨独自挂在一支每个人都能看懂这就是好结果。1.3 整体技术路线怎么搭我的做法是先把整个流程拆成五个模块然后再逐个写代码数据采集用 requests 请求接口解析 JSON落成本地文件数据清洗类型转换、去重、缺失值和异常值过滤特征构造把每天的天气记录汇总成每个城市的特征向量层次聚类计算距离矩阵尝试不同的链接准则选出解释性最好的结果可视化树状图加散点图重点解决标签重叠和横轴过密的问题。文件组织上我没有把所有代码堆在一个文件里而是分成collect.py、clean.py、cluster.py、plot.py四个脚本中间用csv作为临时存储。这个习惯是从这次作业开始养成的后来写项目一直受益。你如果还在用一次性Jupyter Notebook跑全部建议从“作业4”开始尝试拆模块因为任务复杂到一定程度后线性执行的 Notebook 会让调试变成灾难。2. 环境准备Python安装、VSCode配置踩到的三个硬坑2.1 环境现状检查那台电脑上其实早就装了 Python但打开终端一看是 3.8。问题在于这次作业要用的 pandas 和 scipy 新版本对 Python 版本有要求我又不想因为一次作业把系统里旧环境搞乱。于是决定新装一个 Python 3.11从这一步开始环境问题就接踵而至。如果你也打算新装 Python我想提醒你官网下载安装包的时候安装向导第一步有个 “Add Python to PATH” 的复选框默认是没勾的务必手动勾上。这一步直接决定了你在命令行里敲python能不能唤起解释器。我当时第一遍装完在 cmd 里输python毫无反应就是漏了这个勾。2.2 用venv隔离依赖别污染全局装完 Python 3.11 之后我没有直接pip install全局安装而是先建了一个虚拟环境python -m venv .venv .venv\Scripts\activate这里顺便解释一下为什么一定要用虚拟环境。你这份作业可能会装 requests、pandas、numpy、scipy、matplotlib光这几个包就可能带上来几十个依赖版本之间互相干扰是常态。比如当时我发现系统库里的 numpy 是 1.21但 scipy 最新版要求 numpy 版本不能低于 1.24如果全装在全局环境里很可能装完 scipy 把其他项目依赖的 numpy 给顶掉。venv 就是给每个项目开一个独立房间装乱了删掉重建就行。2.3 VSCode里最容易被忽略的两个配置环境建好后代码我是在 VSCode 里写的。第一次运行的时候import pandas直接报ModuleNotFoundError。原因很经典VSCode 显示的 Python 解释器还是全局的 3.8不是当前项目 .venv 里的 3.11。解决办法是打开命令面板CtrlShiftP输入Python: Select Interpreter在列表里选中.venv对应的那条路径。这个操作只需要一次但很多新手不知道导致明明按照教程装好了包一运行就是找不到模块。第二个配置是工作区里的 launch.json。如果你是用 F5 调试方式运行代码记得确认调试器用的是当前项目目录而不是打开了一个其他文件夹。我当时就是因为在 VSCode 里直接打开了整个硬盘某个大目录调试时解释器被切到了另一个环境又花了好几分钟才排查明白。建议你从一开始就给每个作业单独建一个文件夹用文件-打开文件夹的方式去打开它。3. 数据采集requests请求、JSON解析与提速方案3.1 抓什么、从哪抓、怎么存数据源是开放的公开接口返回内容是JSON格式。我用它请求了国内40个主要城市的天气数据每个城市拿最近30天的记录。请求URL大致长这样url https://api.example.com/v1/weather? params { city: city_name, days: 30 } resp requests.get(url, paramsparams, timeout10) data resp.json()第一次我直接用同步循环逐个请求40个城市每个请求算上网络延迟大约0.5秒一共耗时20多秒。这个时间对于一个单次运行的作业来说其实不算致命但是后续调试过程中要反复跑每跑一次干等20多秒就非常难受了。这时候就引出了“请求提速”的需求。调试的时候还有个小细节不要把请求结果直接打印到终端然后靠人眼去看。正确做法是落地成文件再复查。我当时每请求成功一个城市就追加写入一行JSON到临时文件跑完后统一用pandas读入。这种设计能保证即使后续代码跑挂了数据也已经保存下来不用重新请求一遍。3.2 单城市请求写通后再谈并发这是我想重点强调的做事顺序。当时和我一起做作业的同学一上来就用了multiprocessing.Pool并发请求结果各种报错最后连单城市能不能抓取成功都不确定。我的做法是先把单城市请求的函数写到完全可靠确定返回的 JSON 解析出来字段无误再考虑并发优化。单城市请求函数的最终版本长这样def fetch_city_weather(city): params {city: city, days: 30} resp requests.get(BASE_URL, paramsparams, timeout10) if resp.status_code ! 200: return city, None data resp.json() records [] for day in data.get(daily, []): records.append({ city: city, date: day[date], tmax: day[temp_max], tmin: day[temp_min], humidity: day[humidity], precip: day[precip_mm] }) return city, records这一步做好之后后面并发优化只需要把“循环逐个调用函数”改成“并发地调用同一个函数”逻辑完全不用动。这也算是我后来写代码的一个习惯先把同步版本跑通再做并发改造一步一个脚印。3.3 多进程和协程这次作业里我最终选了协程提速方案有两条常见路线多进程和协程。先说多进程。它特别适合CPU密集型任务比如你有上百万条数据要做计算每个CPU核分一块并行算完汇总。但爬虫的本质是网络I/O密集型请求发出去之后大部分时间在等待服务器响应CPU是闲着没事干的。用多进程做网络请求其实有点浪费因为每个进程都要消耗额外的资源而且进程间通信也比较麻烦。更合适的方案是协程也就是热搜词里的“python协程”。协程的思想是在等待网络响应的时候不阻塞整个程序而是切换去处理另一个请求。这种写法配合asyncio和aiohttp库效果非常好。当时我把请求函数改造成异步版本后40个城市的并发请求总耗时从20多秒直接降到3秒左右。改造后的核心逻辑大致如下import asyncio import aiohttp async def fetch_one(session, city): params {city: city, days: 30} async with session.get(BASE_URL, paramsparams, timeout10) as resp: data await resp.json() return await parse_city_weather(city, data) async def fetch_all(cities): async with aiohttp.ClientSession() as session: tasks [fetch_one(session, city) for city in cities] results await asyncio.gather(*tasks) return results # 如果你的代码是脚本文件直接这样运行 if __name__ __main__: result asyncio.run(fetch_all(cities))这三个点值得你留意aiohttp.ClientSession要复用不要每个请求都新建一个session否则性能提升有限asyncio.gather负责把多个协程任务汇总任何一个任务抛异常会导致整体中止所以我在fetch_one内部尽量捕获异常并返回None而不是让异常往上抛如果你是在 Jupyter Notebook 环境里跑asyncio.run()会报错因为 Notebook 内部已经有事件循环。解决办法是用await fetch_all(cities)直接执行或者在脚本文件里跑。我最后提交的版本为了展示两种方式在代码注释里也保留了多进程写法的示例。这个做法在报告里加分不少因为老师能看到你有意识地比较不同方案的适用场景而不是只会抄一种写法。4. 数据清洗类型转换、重复筛选、缺失值一步都不能省4.1 字符串转数值别想当然用float()数据抓下来以后第一眼看上去很规整但真要跑聚类算法就露馅了。接口返回的字段在JSON里明明看起来是数字可一旦存到csv再读回来或者有些字段本身带有文本格式全部变成了字符串。那种“明明看着是5.2一求和就变成字符串拼接”的情况几乎每个人都遇过。我的清洗代码里最核心的就是一段df[tmax] pd.to_numeric(df[tmax], errorscoerce) df[tmin] pd.to_numeric(df[tmin], errorscoerce)pd.to_numeric带errorscoerce参数的意思是能转成数字的转成数字转不了的变成NaN而不是像直接float(abc)那样抛ValueError把整个程序干掉。这一步用好后面就舒服得多。我当时遇到的具体坑有三个湿度字段回来是78%这种带百分号的直接用float()转不了得先把%去掉再转降水字段里有些值是Trace表示降水量微乎其微这在气象记录里是合法值但没法直接转成数字我用errorscoerce让它们变成缺失值再统一填充为0日期字段是2025-04-03这种字符串如果后续要排序必须转成pd.to_datetime否则字符串排序得到的结果会不对比如2025-02-01排在2025-01-10前面。如果你在处理数据时发现df.info()显示某列是object类型那基本就是字符串需要按照上面的思路处理。4.2 筛选“一样的”记录去重逻辑要仔细热搜词里有个“python筛选一样的”对应到这次作业就是重复记录处理。抓取数据时因为我调试过程中跑了不止一遍请求导致同一个城市在本地数据里出现了多条记录日期还是同一天只是请求时间不同。一开始我想得很简单直接drop_duplicates()结果发现去重后记录还是有重复。原因在于drop_duplicates默认比较所有列而我的表里还有一列fetch_time每次请求的时间都不一样所以每一行都“不一样”根本去不掉。正确的做法是明确指定按哪些列去重df df.drop_duplicates(subset[city, date], keeplast)subset指定“当城市和日期都相同时视为重复记录”keeplast表示保留最后抓取的那一条。这个坑特别典型也特别容易被人忽略但它几乎必然出现在真实数据里。把控好这一条你的数据集质量就已经超过很多人了。4.3 缺失值和异常值的处理策略清洗的最后一步是处理NaN和异常值。我先做的不是删而是查清楚哪些列有缺失、占多大比例print(df.isnull().sum()) print(df.describe())describe()能快速看到每列的最大值最小值。如果最高温出现一个99度不用想就知道是脏数据。我当时的处理策略分成两种数值型字段的缺失值如果只是零星几个为了后续做聚类时不产生偏差选择用该城市该列的中位数填充但如果一个城市整周数据都是缺失的那就干脆把整个城市删掉因为它已经没法代表那个城市的气候特征了。我在报告里写下了每一处清洗为什么这样做而不是简单说“我删除了缺失值”。这在老师看来是很加分的因为它说明你有数据质量意识而不只是会调函数。5. 层次聚类的原理与落地从距离矩阵到树状图5.1 一句话讲清楚层次聚类到底在做什么“层次聚类”在这份作业里是核心算法要求。它的目标是把特征相似的城市聚到同一类但是和KMeans那种预先指定类别数的做法不同层次聚类不需要你提前知道分成几类它是一层一层合并的。打个比方假设班里40个城市每个城市有30天的温度湿度数据。层次聚类第一步先找“哪两个城市最像”把它们合并成一组接下来再看“哪两个城市/组最像”继续合并。就这样一直往上合并直到最后所有城市都合并成一整棵大树。这个合并过程画出来就是树状图dendrogram。这棵树的横坐标是各个城市纵坐标是“被合并时的距离”距离越大说明两个城市越不像。你砍一刀横切这条树状图切得越低分出的类别越多切得越高类别越少。聚类数量可以事后决定这就是层次聚类最大的灵活性。5.2 特征向量怎么构造距离和链接怎么选进入代码之前我必须先把数据从“一行天记录”整理成“每个城市一行特征向量”。这一步是用pivot_table完成的city_feature df.pivot_table( indexcity, values[tmax, tmin, humidity, precip], aggfuncmean )这样就得到一个40行×4列的表每一行代表一个城市四列分别是该城市30天的平均值。为了让量纲不互相干扰比如湿度是0到100的数、降水量可能是几个毫米的级别我做了标准化from sklearn.preprocessing import StandardScaler scaler StandardScaler() city_feature_scaled scaler.fit_transform(city_feature)为什么必须标准化因为没有标准化的时候数值范围大的特征会在距离计算中占主导地位。湿度如果普遍在60到80之间降水量在0到10之间降水特征的差异就会被湿度“淹没”。标准化之后每个特征均值是0、方差是1距离计算才公平。接下来的操作是层次聚类的核心from scipy.cluster.hierarchy import linkage, dendrogram import matplotlib.pyplot as plt Z linkage(city_feature_scaled, methodward, metriceuclidean) plt.figure(figsize(12, 6)) dendrogram(Z, labelscity_feature.index, leaf_rotation90, leaf_font_size10) plt.title(City Hierarchical Clustering Dendrogram) plt.xlabel(City) plt.ylabel(Distance) plt.show()method参数决定“组与组之间的距离怎么定义”。当时我三种都试了链接方式特点我的实际结果single组间距离取两个组里最近的点容易产生链状结构树状图失衡不好看不太可读complete组间距离取最远的点得到的类比较紧凑聚类结果尚可但层次感一般ward合并时最小化类内方差的增加量树状图层次清晰类内一致性最高最终我选了methodward。对比的过程完全可以写进报告因为它体现的是你对算法参数的理解而不只是跑通代码。5.3 聚类结果怎么验证是否合理算法跑完后数据有没有告诉你合理的聚类数这时候可以用一个叫“肘部法则”的近似方法观察树状图找一条可以横切出明显分离的线。我最终选择把聚类数定位4类并用fcluster把每个城市打上簇标签from scipy.cluster.hierarchy import fcluster cluster_labels fcluster(Z, t4, criterionmaxclust) city_feature[cluster] cluster_labels验证合理性时我看了一眼每一簇的城市组成。结果是一簇全是南方湿热城市一簇是北方内陆城市一簇是高海拔城市还有一簇是少数特别干旱的城市。直观上完全符合常识说明特征构造和数据源选择是对的。这一步验证非常关键因为它告诉我们算法输出的结果不是玄学而是可以被现实解释的。6. 画图的终极折磨横坐标太密集、树状图挤成一团怎么办6.1 问题复现为什么画出来的图没法看作业做完分析后最后一步是输出配图。我第一版树状图直接出来整个图又窄又扁40个城市名密密麻麻挤在一起横坐标上的文字完全重叠成一条黑带别说老师了我自己都看不清哪个标签对应哪根枝干。这就是热搜词里“python画图横坐标太密集”问得最多的场景。核心原因是默认画布太窄、标签又默认水平排列城市名但凡长一点一定会互相遮挡。6.2 三招解决标签重叠问题第一招调整画布大小。plt.figure(figsize(16, 8))树状图的高度决定树根的清晰度宽度决定横轴标签的舒展度。我当时从12改成16再加调大高度立刻好了不少。第二招旋转标签。plt.xticks(rotation45, haright)rotation45把城市名旋转45度让标签之间不再水平拥挤haright让标签右对齐到刻度点这样视觉上更整齐。如果你城市数量特别多可以选60度甚至90度。第三招限制显示数量。如果你的图里有100个城市就算旋转了也还是挤此时应该思考每一根叶子都要显示标签吗对我这次40个城市来说其实全部显示问题不大但如果你做的是更多城市的聚类每隔几个刻度显示一个标签是更实用的做法。另外还有一个容易被忽略的点dendrogram函数自带leaf_rotation和leaf_font_size参数直接用它们可以省掉后面plt.xticks的配置。我的最终版本是dendrogram( Z, labelscity_feature.index, leaf_rotation90, leaf_font_size8 ) plt.tight_layout() plt.savefig(dendrogram.png, dpi150)tight_layout()会自动调整子图边距savefig里的dpi150保证导出图片够清晰这两行几乎每次画图都能用上。6.3 散点图怎么配合聚类结果光有树状图还不够我还额外画了一张聚类结果的散点图。由于特征向量是四维的人眼看不了四维我用PCA降到了二维再散布点from sklearn.decomposition import PCA pca PCA(n_components2) coords pca.fit_transform(city_feature_scaled)然后用不同颜色表示不同簇每个点标注城市名。这张图配合树状图一起看结论就非常立体树状图展示“合并的层次”散点图展示“最终分类在平面上的分布”。如果你的作业只要求画聚类图我强烈建议画这两张比单画一张树状图信息量大得多也显得你真的理解了结果。7. 作业之外这次python作业暴露出来的三个薄弱环节7.1 不写异常处理的代码都是定时炸弹整个作业写完后回头检查我发现采集脚本里最初的版本几乎没有异常处理。如果某个城市请求超时或者返回的不是预期JSON脚本会直接中断后面所有城市都跟着遭殃。后来我给fetch_city_weather加上了try-except遇到异常时打印一条日志并返回空结果用asyncio.gather把这些空结果过滤掉。这个改动看起来不起眼但正是从“能跑的代码”到“靠谱的代码”之间那道分水岭。一份没有异常处理的爬虫脚本在数据量小时侥幸能跑通一旦数据源波动立刻完蛋。7.2 可复现性比一次性跑通更重要作业提交前一天我想重新跑一遍完整流程结果发现因为数据源接口的某次变动采集到的数据和我分析时用的对不上。这提醒了我一个事数据落地要及时打快照清洗后的数据也单独存一份分析代码和数据文件要放在同一个目录里用相对路径读取。我后来养成了一个习惯每次做完一个关键节点就df.to_csv(data/clean_weather.csv, indexFalse)存一份中间结果。这样做的好处是哪怕后续代码写错了也不用重新爬一遍数据直接从中间结果接着调就行。这和写代码用git做版本控制是同一个道理。7.3 给自己的后续优化清单作业交完这份课题里其实还有好几个可以继续挖的方向比如把时间维度揉进聚类里做“按周聚类”来观察季节性或者引入动态时间规整距离来代替欧氏距离以解决“两个城市同一天温度不一样但变化趋势一致”这类相似性问题再比如用地图把聚类结果的簇标签标到城市地理位置上看空间分布。可惜当时时间有限只完成到基础版本这几个点我写在报告结尾的“后续可改进方向”里也算给老师一个信号我知道这版作业的边界在哪也知道往哪走。最后再分享一点个人体会像“python作业4”这种开放式作业最值钱的部分往往不是你用了多高深的算法而是你有没有走完一个完整、严谨的流程。从环境搭建到数据采集从清洗到建模从画图到报告每一步都能暴露问题而每解决一个问题你学到的东西都比单纯刷100道语法题多得多。这份作业给我的真正礼物不是学会了层次聚类而是第一次意识到用Python解决一个实际问题靠的不是某一个库用得熟而是整套流程里的取舍、判断和排错能力。