每年毕业设计选题的时候,我都会收到十几封私信,问的都是同一个问题:有没有一个题目,能顺顺利利做完、答辩不卡壳、还能写进简历里?这套“Python一手房数据采集分析与预测系统”就是我从一堆题目里筛出来、可以放心推荐的那种。它不是纯爬虫课设,也不是调参型机器学习Demo,而是一条用Requests爬虫采集、用Pandas清洗、用Scikit-learn建模、最后用Flask做成可视化系统的完整链路。对计算机专业的学生来说,这个题目等于用一个月时间把大学四年最核心的几门课串了一遍,而且做出来的东西有明确用户场景:想买房的人想看区域楼盘价、做调研的人想批量拉数据、搞分析的人想看价格趋势,全都能落在这套系统上。
我写这篇文章,就是把我带学生做这个项目时踩过的坑、总结过的经验、现场翻过车的环节全部摆出来。不管你是准备抄这套题目的毕业生,还是想学Python全链路数据分析的初学者,按着这条路径走一遍,至少能少熬两个通宵。
1. 毕业设计选题:为什么推荐“一手房数据采集分析与预测”
1.1 这个题目真正的价值在于“链路完整”
市面上很多毕业设计题目,要么是单纯的爬虫抓数据,要么是拿现成数据集跑个模型,要么是做个CRUD后台。单看每一个部分都不难,但它们都不能叫“系统”。这个题目不一样,它要求你从零开始,自己找数据源、自己把数据弄干净、自己训练模型、自己把结果呈现给用户。四个环节缺一不可,任何一个环节掉了链子,系统就立不起来。
我之前辅导过一个学生,最开始想做的题目是“基于机器学习的房价预测”,他直接下载了一份公开数据集,在Jupyter里调好随机森林模型,觉得毕业设计已经完成了80%。结果到了系统集成阶段,发现根本没有活数据支撑预测——模型训练和实际使用完全脱节。后来改成这个一手房数据采集分析与预测系统,他才补上了“数据从哪来”和“用户怎么用”这两块。毕业答辩的时候,评阅老师连续问了好几个实战问题,比如“目标网站反爬怎么办”“模型上线后预测偏差怎么解释”,因为他真的从采集到预测都跑过一遍,所以每个问题都能接得住。
这也是我想强调的第一点:选择这个题目,选的不是某个单一技术点,而是一种全链路工程能力训练。它天然适配毕业设计的原因在于,难度可控,模块边界清晰,每一个阶段都能产出可见结果——爬虫跑完有数据库,模型训练完有评估指标,Web系统写完后有可视化页面,答辩素材根本不愁。
1.2 技术栈选型:Flask、Requests、Scikit-learn各自承担什么角色
这个项目的技术栈看起来“有点杂”,正好也是它的亮点。每个库都只负责自己最擅长的一件事,职责边界非常清楚:
- Requests:负责和房产信息平台打交道,发送HTTP请求、接收HTML/JSON数据。它是整个系统的数据入口。
- Pandas:负责把爬回来的半结构化数据变成规范的二维表格,完成脏数据清洗、字段类型转换、特征构造。
- Scikit-learn:负责训练房价预测模型,提供线性回归、随机森林、梯度提升等算法,还有train_test_split、mean_squared_error这套标准流程。
- Flask:负责把数据和模型包装成一个可以交互的Web系统,让用户能在页面上浏览统计图、填表预测。
- ECharts:在前端做可视化折线图、柱状图、散点图,把Pandas算出来的指标变成直观图表。
很多人会纠结一个问题:Web框架为什么选Flask而不是FastAPI?FastAPI这两年热度确实高,自带接口文档,异步性能更强。但放在毕业设计这个场景里,Flask的优势更明显:资料多、模板多、上手门槛低,一个app.py就能跑起来。而且答辩时老师大概率会问“为什么不用Django/FastAPI”,你可以理直气壮地说:这个项目的重点是数据分析与预测链路,Flask足够轻量,能快速把模型和可视化集成进来,不会引入不必要的复杂度。Flask和FastAPI在路由写法上差异不大,真到工作里要换,手改的成本也很低。
1.3 系统整体架构设计
整个系统按数据流向分成五个模块,我建议你把这张逻辑图画在论文里:
- 数据采集层:Request请求目标网站页面,解析出楼盘名称、区域、户型、面积、总价、单价、朝向、楼层、建筑年代等字段,存入CSV或SQLite。
- 数据治理层:Pandas做缺失值填充、重复记录去重、单位换算、文本字段清洗,并把非数值特征做编码。
- 模型训练层:在清洗后的数据集上训练多个回归模型,比较误差指标,选择最优模型保存为.pkl文件。
- 服务集成层:Flask加载模型和统计数据,对外开放路由。首页展示可视化大屏,预测页接受参数返回预测结果。
- 展示交互层:前端页面用ECharts渲染图表,通过Ajax和Flask接口通信,实现数据刷新、条件筛选和交互预测。
这个架构的关键点在于模块解耦。爬虫不用关心模型怎么训练,模型不依赖Web页面长什么样,每个部分都可以独立调试。我带学生做项目时都会强调:不要试图把代码写在一个文件里,按上面这五层去建目录,出问题的时候定位会快很多。
2. Requests爬虫实战:一手房数据采集中的请求与反爬细节
2.1 先确定要抓哪些字段
正式开始写爬虫之前,一定要先想清楚页面上的哪些数据是有用的。我让学生做这个项目时,目标字段清单通常是这样的:
- 楼盘/小区名称
- 所在区域(区、板块)
- 户型(几室几厅)
- 建筑面积(平方米)
- 总价(万元)
- 单价(元/平方米)
- 朝向(南、南北、东等)
- 楼层(低层/中层/高层)
- 建筑年代(如2015年)
- 楼盘状态(在售/售罄等)
为什么重点关注单价比总价多?因为不同面积段直接比总价没有意义,单价才是横向对比区域价格水平的稳定指标。这个字段设计会在后面的特征工程里直接决定模型效果。
2.2 Requests构造请求的要点
用Requests抓数据,很多人第一行代码就是requests.get(url),然后立刻收到一堆看不懂的返回。问题出在请求头缺失。目标网站通常靠User-Agent识别客户端,默认的python-requests字符串很容易被识别并拦截。我自己写采集脚本的时候,最少要带这些:
import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding # 避免中文乱码 html = resp.text有几个细节值得记住。第一,timeout一定要设置,否则某个页面卡住,整个采集脚本会一直挂着。第二,响应编码要处理,很多房产平台页面是GBK编码,直接取resp.text会出一堆乱码,用apparent_encoding自动识别更省事。第三,一次性抓取目标网站页面时,不要急着对返回结果做正则提取,先用status_code和len(html)确认请求成功、内容非空,再进入解析阶段。
2.3 遇到“429 Too Many Requests”怎么办
几乎所有抓过正式网站的人都见过这个报错:
Exceeded retry limit, last status: 429 Too Many Requests我第一次写爬虫时也踩过这个坑:脚本跑得飞快,几秒钟发了几十个请求,然后服务器直接返回429,脚本重试三次还是429,最终抛出异常退出。429的意思是“你的请求频率太快,服务器已经不想理你了”。这不是网站挂了,也不是代码逻辑错,是访问太频繁触发了限流。
解决思路不是暴力重试,而是做一个合规的请求节奏控制。我会在采集脚本里加一个带重试和退避的请求函数:
import time import random def fetch_url(url, max_retry=3): for attempt in range(max_retry): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp elif resp.status_code == 429: wait_time = 10 + attempt * 10 + random.uniform(0, 3) time.sleep(wait_time) else: time.sleep(3) except requests.RequestException as e: time.sleep(5) return None这段代码做的事情很简单:拿到429先等10秒、再等20秒、再等30秒,最多重试三次;遇到网络异常也等一下再试。这样既不会频繁撞限流,也能在目标网站短暂的“脾气发作期”后恢复采集。除此之外,每抓完一个页面对应的时刻加一个随机0.5到1.5秒的sleep,效果比固定sleep一秒更好,因为随机间隔更不容易被服务器识别成脚本。
补充一句,我是严格遵守目标网站的robots协议和访问频率限制的,数据只用于学习研究。做这个项目时,采集频率宁慢勿快,数据量够用于分析和建模就行。
2.4 数据落地与去重策略
爬虫抓下来的数据,建议直接用Pandas转成DataFrame保存,不要先存大量中间JSON文件再等下一步处理。我常用的是一个简单的增量保存方式:每次解析完一页数据,往CSV追加一行,但先检查楼盘ID或“楼盘名+所在区域”这个组合字段是否已经存在。这个去重逻辑不复杂,但能避免重复采集导致的数据膨胀。
import pandas as pd def save_records(new_data, csv_path="house_data.csv"): df_new = pd.DataFrame(new_data) try: df_old = pd.read_csv(csv_path) df_all = pd.concat([df_old, df_new], ignore_index=True) df_all = df_all.drop_duplicates(subset=["name", "district"], keep="last") except FileNotFoundError: df_all = df_new df_all.to_csv(csv_path, index=False, encoding="utf-8-sig")用utf-8-sig编码写CSV,是为了让Excel打开时中文不乱码。很多学生抓完数据后拿Excel一看全是乱码,其实就是编码没选对。另外,如果数据量到了一两万条以上,建议换成SQLite,Whisk the pandas read_csv/write_csv换成sqlite3连接。毕业设计数据量一般到不了这个规模,CSV足够。
3. 数据清洗与特征工程:预测模型的地基工程
3.1 一手房数据里最常见的脏数据
爬回来的数据很少能直接进模型,这一步花的精力甚至比建模还多。我总结过这个项目里频率最高的几种脏数据场景:
- 总价字段带“万”字:比如“350万”,需要用正则提取数字。
- 单价字段是字符串:“单价32000元/平”,要转成32000。
- 户型字段:“3室2厅1卫”,真正对模型有用的其实是“室”的数字。
- 楼层描述:“低层/中层/高层”,直接字符串没法建模,要映射成1/2/3。
- 建筑年代缺失:有些页面没展示,需要填充或删除。
- 面积字段出现“暂无数据”字样。
我习惯写一个clean_house_data函数,把这些清洗逻辑全部封装起来:
import re import pandas as pd def clean_house_data(df): # 提取总价数字(万元) df["total_price"] = df["price_text"].str.extract(r"(\d+\.?\d*)").astype(float) # 提取单价数字(元/平方米) df["unit_price"] = df["unit_price"].str.replace("元/平方米", "") df["unit_price"] = pd.to_numeric(df["unit_price"], errors="coerce") # 从户型提取居室数 df["bedrooms"] = df["layout"].str.extract(r"(\d)室").astype(float) # 面积转数值 df["area"] = df["area"].str.replace("㎡", "") df["area"] = pd.to_numeric(df["area"], errors="coerce") # 楼层映射 floor_map = {"低层": 1, "中层": 2, "高层": 3} df["floor_level"] = df["floor"].map(floor_map) # 缺失值处理 df = df.dropna(subset=["area", "unit_price", "bedrooms"]) return df这里要特别讲一下dropna的度。如果只是缺失楼层、朝向,可以填充成“未知”或者众数,但如果面积、单价这种预测必需字段缺失,整行删掉更省事。这个项目的数据量没必要为保留几十条残缺记录去编造特征。
3.2 类别特征编码:区域、朝向、户型怎么进模型
目前模型输入要求是数值,区域、朝向这类文本特征不能直接塞进去。常见的做法有两种:第一种是独热编码,用pandas的get_dummies,把“天河区”“海珠区”变成一个个0/1列;第二种是标签编码,用sklearn的LabelEncoder,把类别变成0、1、2、3这样的整数。
我的建议是:区域用独热编码,因为区域之间没有天然顺序,“天河区”不是“海珠区”的1.5倍;朝向也可以独热编码,南北通透和朝东本身不能比大小。楼层用刚才的有序映射就行,因为低中高本身有层级关系。你也不用担心独热编码会让特征维度爆炸,一手房数据的区域数量一般就几十个,完全可控。
df = pd.get_dummies(df, columns=["district", "orientation"], drop_first=True)drop_first=True是为了避免哑变量陷阱。这在毕业设计答辩时是一个很好的加分回答点:如果分类有k个取值,独热编码只需要k-1列,否则多重共线性会让模型解释性变差。
3.3 构建预测目标:预测总价还是单价
这个项目叫“房价预测”,但我们要先想清楚预测的是哪个指标。我推荐把单价(元/平方米)作为预测目标,原因有两点:
第一,单价是区域横向比较的稳定指标,总价会随面积剧烈变化,模型训练时容易过于依赖面积这一个特征,变成“面积乘以均价”的简单线性关系;第二,用户的实际决策场景往往是“我想在某个板块买一个100平左右的房子,大概什么单价”,单价预测更贴近需求。
当然,特征方面面积还是要放进模型里的,而且通常是最重要的特征之一。完整特征列表大概是:面积、卧室数、楼层级别、建筑年代、区域独热编码、朝向独热编码,目标变量是单价。这套特征设计既有数值特征也有类别特征,足以让随机森林这类模型学到非线性关系。
4. Scikit-learn房价预测模型:从线性回归到集成模型
4.1 先跑一个baseline,再上集成模型
很多学生拿到数据后第一件事就是跑随机森林,然后显示一个惊人的R²就完了。这不是正经建模流程。正确的顺序是:先用一个简单模型做baseline,理解数据的难度;再尝试复杂模型,看提升量在哪里。
我通常先跑线性回归:
from sklearn.model_selection import train_test_split from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_error, r2_score X = df[feature_cols] y = df["unit_price"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42 ) lr = LinearRegression() lr.fit(X_train, y_train) y_pred = lr.predict(X_test) print("RMSE:", mean_squared_error(y_test, y_pred, squared=False)) print("R2:", r2_score(y_test, y_pred))线性回归跑完后,记录下RMSE和R²,然后上随机森林回归:
from sklearn.ensemble import RandomForestRegressor rf = RandomForestRegressor(n_estimators=200, max_depth=10, random_state=42) rf.fit(X_train, y_train) y_pred_rf = rf.predict(X_test) print("RF RMSE:", mean_squared_error(y_test, y_pred_rf, squared=False)) print("RF R2:", r2_score(y_test, y_pred_rf))从我带项目的经验看,随机森林几乎总是比线性回归好,原因是一手房单价和面积、楼层、区域的关系并不是直线增长的。杭州余杭区和广州市中心的区板块溢价差异巨大,线性模型很难用一个系数表达这种区域间差异。
4.2 模型效果评估:不要只看R²
毕业设计答辩时,老师最常问的一句就是:“你这个模型效果怎么评价?”这时候如果你只会说“R²是0.9”,那是拿不到高分的。我建议你同时报告RMSE和R²,尤其是RMSE,因为它的单位就是元/平方米,直接对应预测误差。
假设你预测单价,模型RMSE是1800元/平方米,这意味着预测值和真实值平均差1800元左右。如果某板块真实单价是32000元/平方米,预测成33800或30200都属于正常区间。这个误差水平放在真实场景里,用户是能接受的,但你要能主动解释清楚:误差来源包括特征不够(缺少物业费、周边配套、学区属性)、房源挂牌价本身波动、数据量只有几千条等。能讲清误差来源,比模型分数高更打动人。
4.3 模型调参与保存
随机森林主要的可调参数是n_estimators和max_depth。毕业设计阶段不需要搞复杂的GridSearchCV,做一次浅层网格搜索就够了,否则训练时间会拖慢整个开发节奏:
best_params = {"n_estimators": 200, "max_depth": 10, "min_samples_split": 5} rf = RandomForestRegressor(**best_params, random_state=42) rf.fit(X_train, y_train)训练完成后,用joblib把模型保存下来,Flask启动时直接加载:
import joblib joblib.dump(rf, "models/house_price_model.pkl")这样训练和预测就分开了,Web系统不需要在每次启动时重新训练模型。这个细节在答辩演示时特别加分——你可以在论文里写“模型离线训练、在线服务”,这是很标准的工业部署思维。
5. Flask系统集成与可视化:让数据和模型“上台”
5.1 Flask项目结构怎么组织
到了这一步,项目已经变成一个标准的Web应用。我推荐的目录结构如下:
house_price_system/ ├── app.py # Flask主入口 ├── models/ │ └── house_price_model.pkl ├── data/ │ └── house_data.csv ├── templates/ │ ├── index.html # 可视化大屏页 │ └── predict.html # 房价预测页 ├── static/ │ ├── css/ │ ├── js/ │ └── echarts.min.js └── requirements.txtapp.py里的核心逻辑分成两部分:一部分是读取数据、计算聚合指标、输出JSON接口;另一部分是加载模型并提供预测接口。
from flask import Flask, render_template, request, jsonify import joblib import pandas as pd app = Flask(__name__) model = joblib.load("models/house_price_model.pkl") df = pd.read_csv("data/house_data.csv") @app.route("/") def index(): return render_template("index.html") @app.route("/api/summary") def summary(): summary_data = df.groupby("district")["unit_price"].mean().sort_values() return jsonify({ "districts": summary_data.index.tolist(), "avg_prices": summary_data.values.tolist() })这里把Pandas聚合结果转成JSON,再交给前端渲染。Flask的jsonify会自动处理类型转换,Python里的numpy.float64这种类型也能序列化,不会报错,这一点对新手很友好。
5.2 可视化大屏:ECharts让数据“看得见”
可视化是这个题目最直观的成果展示。我常用ECharts做四种图:
- 首页主图:不同区域一手房平均单价柱状图,一眼看清区域价差。
- 面积-单价散点图:看总价和面积的关系,也能发现异常高单价楼盘。
- 户型占比饼图:统计各户型在市场中占的比例。
- 价格区间分布直方图:让用户理解城市整体价格水平。
前端页面里,用Ajax拉取后端的JSON数据:
fetch("/api/summary") .then(res => res.json()) .then(data => { const chart = echarts.init(document.getElementById("main")); chart.setOption({ xAxis: { type: "category", data: data.districts }, yAxis: { type: "value", name: "均价(元/㎡)" }, series: [{ type: "bar", data: data.avg_prices }] }); });这个方案不需要页面刷新,数据变化时重新fetch就行。毕业设计演示时,我建议你准备一份静态数据和一个动态刷新开关,避免现场网络不稳导致页面空白。
5.3 预测功能的前后端联动:用户怎么用模型
系统最终要给用户提供一个可用的预测入口。我从实用角度设计的流程是:用户在表单里选择区域、朝向,填写面积、卧室数和楼层,后端把输入转成模型需要的特征格式,调用模型.predict返回预测单价。
@app.route("/predict", methods=["POST"]) def predict(): area = float(request.form["area"]) bedrooms = float(request.form["bedrooms"]) floor_level = float(request.form["floor_level"]) district = request.form["district"] orientation = request.form["orientation"] # 构造特征,和训练时保持一致 sample = pd.DataFrame([{ "area": area, "bedrooms": bedrooms, "floor_level": floor_level, "district_天河区": 1 if district == "天河区" else 0, "district_海珠区": 1 if district == "海珠区" else 0, "orientation_南": 1 if orientation == "南" else 0, }]) pred = model.predict(sample)[0] return jsonify({"predict_unit_price": round(float(pred), 2)})这里最容易出的bug是“训练特征和预测特征不一致”——训练时有20个独热编码列,预测时只构造了其中4列,模型直接报特征数量错误。解决办法是训练结束后保存一份feature_columns.pkl,预测时按这个列表补列:
feature_columns = joblib.load("models/feature_columns.pkl") sample = sample.reindex(columns=feature_columns, fill_value=0)这句代码能救你一命。在你演示的时候,如果后台报错,80%都是特征对齐问题。
6. 项目打包、部署与毕业设计答辩经验
6.1 答辩前的代码和文件整理
毕业设计不只是把功能写出来,还要把代码按时提交。我强烈建议在交材料前按下面清单检查一遍:
- requirements.txt里列全所有依赖库,Flask、requests、scikit-learn、pandas、matplotlib这些一个都不能少。
- README.md里写清楚运行步骤:先pip install -r requirements.txt,再运行爬虫脚本采集数据,然后跑train_model.py训练模型,最后python app.py启动系统。
- 数据库或CSV的数据文件要留一份,防止答辩现场临时采集失败。
- 模型文件.pkl要带上,不要现场训练。
- 所有代码能在一个干净的Python环境里直接跑通。
很多学生平时在自己电脑上运行正常,答辩前一晚换了机器,结果一堆依赖装不上。解决方式很简单:用requirements.txt在演示机器上先跑一遍,或至少提前在另一台电脑上测试过启动流程。
6.2 演示时最翻车的几个现场
我带过这么多答辩,见过最尴尬的翻车是现场采集数据时网站结构变了,爬虫解析失败,页面一片空白。所以我的建议永远是:演示以本地CSV数据为准,爬虫只是展示“数据采集过程”的模块,不进主流程。你可以现场演示爬虫脚本抓一页数据,但可视化页面跑的是已经存好的数据。
另一个高频问题是预测结果太离谱。比如用户填了一个200平的大户型,预测单价可能是28000元/㎡,算出来总价560万,但用户觉得“我家那边这个户型要800万”。这时候要冷静,你不能说模型错了,而是说:模型基于训练数据的分布,极端面积、极端区域的预测误差较大,属于正常现象,实际应用中可以结合真实成交记录修正。这一段话在答辩前多练几遍,非常有用。
还有一个细节:答辩演示前把浏览器缓存清一下,把Flask的服务端口固定好,不要开着调试模式(debug=True)给评委看。调试模式出现报错会把完整堆栈显示在页面上,观感很差。生产演示时用app.run(debug=False)即可。
6.3 这个项目还能怎么扩展
如果时间充裕,这个系统可以往几个方向加深:
- 加入时间维度,采集不同月份的数据,做房价趋势分析和预测。
- 增加二手房、租房数据源,做一个跨市场对比。
- 引入更多特征,比如小区周边配套POI数据、距离地铁站距离、学区信息。
- 把预测模型换成梯度提升树(XGBoost或LightGBM)或线性回归与随机森林的融合。
- 用FastAPI重写后端,增加用户登录、历史记录、数据下载功能。
以我辅导过的学生案例来看,做到前两个扩展方向,内容量就足够写一篇比较充实的毕业论文了。如果只是应付答辩,跑到6.3点之前的状态也完全够用。
我个人做这类项目最大的体会是:翻车不可怕,怕的是翻车后不明白为什么。回头看看这个系统,爬虫阶段被429打懵过,特征阶段被中文编码折磨过,模型阶段被预测误差问倒过,部署阶段被特征对齐坑过——每摔一次,你反而对这个系统的理解深了一层。这也是我推荐这个题目的终极原因:它像一个缩小版的真实数据项目,做完它,你既有一份能交差的毕设,也有了能讲的工程故事。最后分享一个实用的小技巧:把所有代码提交到Git仓库,每次完成一个模块就commit一次,答辩前回滚历史记录时你会感谢当时的自己。