ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

链家二手房数据驱动的房价预测:从爬虫到Keras神经网络实战

链家二手房数据驱动的房价预测:从爬虫到Keras神经网络实战 简介完整项目以链家网真实房价数据为切入点面向计算机相关专业学生完成毕业设计或课程实践。内容覆盖数据爬取、清洗处理、特征工程与建模分析全流程核心采用sklearn逻辑回归和keras神经网络构建预测模型实验结果显示神经网络R^2约0.75比逻辑回归效果更佳。包内共646个文件包括12个Python源代码、6个CSV数据集、609个JSON地图/坐标数据、7个说明文本、2个Markdown文档以及h5模型文件、docx建模过程文档等压缩包整体66.8MB结构清晰便于按模块运行与二次修改。当前已有122人学习下载并配有README与建模过程说明适合需要从零搭建房价预测系统、理解爬虫与机器学习建模衔接的初学者也可作为毕设答辩的完整参考。1. 链家二手房数据驱动的房价预测从爬虫到Keras链家网每个房源页面上的总价、单价、朝向、楼层和位置信息其实就是一个天然的回归问题数据集。这套Python高分毕设把链家网的真实房源抓下来经过数据清洗后用sklearn线性模型和Keras神经网络分别建模最终神经网络在测试集上的R²稳定在0.75左右比线性基线的0.61高出不少。对正在做毕设或课程设计的同学来说它覆盖了数据采集、清洗、特征工程、模型对比和部署可视化整条链路尤其适合计算机、数据科学、人工智能专业的实训场景。下面按“数据怎么拿、特征怎么造、模型怎么选、效果怎么验证”的顺序把这套项目里值得复用的细节拆开讲顺便给出可直接改的代码和参数设置。2. 链家网数据采集与数据清洗坐标对齐与特征构造2.1 爬虫设计requests BeautifulSoup 解析链家房源列表链家的二手房列表页结构相对规整房源卡片都在ul.sellListContent下每个li里包含标题、链接、总价、单价、户型、面积和楼层信息。抓取时我一般用requests拉取页面再用BeautifulSoup做解析而不是去用 Selenium因为列表页是服务端渲染的普通 GET 请求就能拿到完整 HTML速度也快很多。import requests from bs4 import BeautifulSoup import time import pandas as pd def fetch_page(citysh, page1): url fhttps://{city}.lianjia.com/ershoufang/pg{page}/ headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36 } resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 return resp.text def parse_listing(html): soup BeautifulSoup(html, html.parser) items [] for li in soup.select(ul.sellListContent li): title_el li.select_one(.title a) total_el li.select_one(.totalPrice span) unit_el li.select_one(.unitPrice span) info_el li.select_one(.houseInfo) if not (title_el and total_el and info_el): continue info_text info_el.get_text(|, stripTrue).split(|) items.append({ title: title_el.get_text(stripTrue), link: title_el.get(href), total: float(total_el.get_text(stripTrue)), unit_price: float(unit_el.get_text(stripTrue).replace(元/平, )), house_info: info_text }) return items def crawl_pages(page_rangerange(1, 51)): all_data [] for page in page_range: html fetch_page(pagepage) items parse_listing(html) all_data.extend(items) time.sleep(1.5) if len(items) 0: break return pd.DataFrame(all_data)这段代码里有几个关键参数要说明一下。sleep(1.5)是对链家服务器的基础礼貌把请求频率控制在每秒 1 个以内避免 IP 被临时限制page_range控制抓取深度50 页大约能拿到 3000 条左右数据已经足够做回归建模。house_info是一个用竖线分隔的字符串例如2室1厅 | 89.6平米 | 南 北 | 中楼层(共6层) | 精装后续要从这里拆出室、厅、面积、朝向、楼层和装修状态。爬取完成后你会发现data_c.csv、data_r.csv这类文件其实就是不同批次爬取结果的合并版本。数据清洗的第一步是去掉重复的link字段同一套房源被重复上架很常见然后处理缺失值total和unit_price缺失的直接删除因为它们是预测目标漏掉了无法补。面积和户型字段则从house_info里用正则提取。def clean_df(df): df df.drop_duplicates(subsetlink).reset_index(dropTrue) df df.dropna(subset[total, unit_price]) import re def extract_area(info): if not info: return None text .join(info) m re.search(r([\d.])平米, text) return float(m.group(1)) if m else None def extract_rooms(info): if not info: return (None, None, None) text .join(info) m re.match(r(\d)室(\d)厅, text) if m: return int(m.group(1)), int(m.group(2)) return (None, None) df[area] df[house_info].apply(extract_area) df[[rooms, halls]] df[house_info].apply( lambda x: pd.Series(extract_rooms(x)) ) return df注意extract_rooms只匹配了室和厅没有管卫生间因为链家的户型描述里卫生间数量经常缺失。特征构造时rooms halls比单独用rooms对总价的影响更明显我在实际建模时把这两个字段相加得到room_count效果比分开两个特征更稳定。2.2 坐标数据合并与离群值过滤项目里的coordinate.csv存放的是小区或楼盘名对应的经纬度geo.js和金桥.json则是用于地图可视化的地理数据。房源数据本身只有小区名没有坐标所以要用coordinate.csv做一次字符串匹配把经纬度拼回主表。coord_df pd.read_csv(coordinate.csv) df df.merge(coord_df, oncommunity, howleft) df df.dropna(subset[lat, lng])这里有个容易踩的坑链家的小区名并不唯一不同行政区可能存在同名小区。更稳妥的做法是用区县 小区名作为关联键在coordinate.csv里增加一列district然后on[district, community]去 merge。如果你的原始数据里没有district就从title里提取板块名否则会出现经纬度错位画出来的地图点位会跑到另一个区去。清洗完的房价分布是典型的长尾分布总价 1000 万以上的房源数量很少却会严重拉高均方误差。训练前我会把总价超过 99% 分位数的样本过滤掉或者对total做log1p变换。这个项目的data_n.csv应该已经做过这一步所以直接用 pandas 看分布即可import numpy as np df[price_log] np.log1p(df[total]) q99 df[total].quantile(0.99) df df[df[total] q99]total分布右偏时线性模型和神经网络的收敛速度都会变慢。对目标值做log1p后预测结果再expm1还原能得到更接近真实市场的误差曲线。后面训练时统一使用price_log作为 y而不是原始总价。3. 特征工程与模型选型线性基线与Keras多层感知机3.1 特征编码与数据集划分房价预测既不是纯线性问题也不是纯分类问题。对于链家房源这类表格数据我通常会先把分类特征做有序编码或独热编码再统一做标准化。项目里用到的特征有area、room_count、floor_level、decoration、toward、district、lat、lng其中floor_level可以分成低/中/高楼层toward常见值有南、南北、东、西district作为城市功能区信息非常关键。from sklearn.model_selection import train_test_split from sklearn.preprocessing import OrdinalEncoder, StandardScaler import pandas as pd feature_cols [area, room_count, lat, lng, floor_level, decoration] categorical_cols [floor_level, decoration, district] df pd.get_dummies(df, columns[floor_level, decoration, district], drop_firstFalse) X df[[c for c in df.columns if c not in [total, price_log, house_info, link]]] y df[price_log] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.2, random_state42 ) scaler StandardScaler() X_train_scaled scaler.fit_transform(X_train) X_test_scaled scaler.transform(X_test)注意train_test_split里的random_state42是刻意固定的这样两次跑出来的结果可比。你在调参时如果发现 R² 上下波动超过 0.03先检查随机种子是不是变了而不是模型出了问题。StandardScaler必须先在训练集上fit再用同一个scaler去transform测试集这能防止测试集的信息泄露到训练过程里。数据划分有个细节链家同一个小区内的房源价格高度相关如果随机划分同一小区的房源会同时出现在训练集和测试集里模型评估出的 R² 会虚高。严谨一点的做法是按community分组进行GroupShuffleSplit让同一小区只出现在一边。这个项目的数据集没有强制分组但你在答辩时可以提这个点属于加分项。3.2 sklearn线性回归基线与LogisticRegression的误区很多初学者会把LogisticRegression直接拿来预测房价这是不对的。LogisticRegression是分类模型输出的是分类概率目标值必须是类别。房价是连续值在 sklearn 里做回归应该用LinearRegression或Ridge。项目文档里写的“逻辑回归机器学习模型”实际落地时我一般会改用Ridge回归因为链家房源特征之间存在共线性area和room_count相关性很高岭回归的 L2 正则能压住权重波动。from sklearn.linear_model import Ridge from sklearn.metrics import r2_score, mean_absolute_error ridge Ridge(alpha1.0) ridge.fit(X_train_scaled, y_train) pred_log ridge.predict(X_test_scaled) pred_price np.expm1(pred_log) print(R2:, r2_score(y_test, pred_log)) print(MAE:, mean_absolute_error(np.expm1(y_test), pred_price))alpha1.0是岭回归的正则强度越大权重越接近 0模型偏差越大但方差越小。调参时我一般从0.1、1.0、10.0三个量级试观察测试集 R² 的变化。如果alpha从 1 加到 10 R² 明显下降说明数据还不够复杂不需要太强的正则。这个基线的意义是给神经网络一个参照物。如果神经网络在测试集上的 R² 连岭回归都打不过就说明特征工程有问题或者网络结构太深、样本量不够。我见过不少项目直接拿 Keras 跑跑完 R² 只有 0.5回头一看特征只有面积和总价两个字段这种对比没法说明深度学习有效。3.3 Keras多层感知机结构与参数选择神经网络部分我推荐用三层全连接网络而不是直接堆很多隐层。表格数据的样本量通常只有几千条五层以上的网络很容易过拟合。项目里最终效果最好的结构是128-64-1激活函数用 ReLU隐层之间加 Dropout输出层不加激活函数因为这是回归任务。import tensorflow as tf from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Dropout model Sequential([ Dense(128, activationrelu, input_shape(X_train_scaled.shape[1],)), Dropout(0.2), Dense(64, activationrelu), Dropout(0.2), Dense(1) ]) model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), lossmse, metrics[mae] )input_shape要等于X_train_scaled.shape[1]也就是独热编码后的总特征数不能写死成固定值否则换成其他数据集就要改代码。Dropout(0.2)表示训练时随机丢弃 20% 的神经元丢得太多会欠拟合丢得太少起不到正则效果。Adam 的learning_rate从 0.001 起步如果 loss 震荡降到 0.0003如果收敛太慢升到 0.003。训练时我习惯保留 15% 的训练数据做验证集同时开启EarlyStopping监控验证集 loss连续 10 个 epoch 不下降就停止。这样就算你不太会调 epoch也能自动停在最佳位置。from tensorflow.keras.callbacks import EarlyStopping early_stop EarlyStopping( monitorval_loss, patience10, restore_best_weightsTrue ) history model.fit( X_train_scaled, y_train, validation_split0.15, epochs200, batch_size32, callbacks[early_stop], verbose1 )validation_split0.15会在训练集内部再切一块和前面的X_test无关这里要分清两层划分。patience10的意思是验证集 loss 连续 10 轮不降低才停止防止因为单轮抖动过早退出。restore_best_weightsTrue会回滚到验证集 loss 最低的那一轮权重而不是把最后一步训练出的权重保留下来。表格数据训练时的收敛曲线和图像任务不太一样经常前 10 个 epoch 就掉到接近最优后面 20 个 epoch 只是微调。建议画一下history.history[val_loss]如果曲线像是玩滑梯一样直线下降后横盘说明patience可以调小到 5省时间如果还在下降就被掐停再调大到 15。4. 模型训练与评估R²从0.6到0.75的调优过程4.1 完整训练脚本与model.h5导出把上面几段代码串起来就是一套完整的训练脚本。训练完成后要把模型存成module.h5Keras 的 HDF5 格式会把网络结构和权重一起打包加载时不需要再重新定义网络。import numpy as np import pandas as pd from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler from sklearn.metrics import r2_score from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Dense, Dropout from tensorflow.keras.callbacks import EarlyStopping df pd.read_csv(data_n.csv) feature_cols [area, room_count, lat, lng] for col in [floor_level, decoration, district]: df pd.concat([df, pd.get_dummies(df[col], prefixcol)], axis1) X df[[c for c in df.columns if c not in [total, price_log, floor_level, decoration, district]]] y df[price_log] X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.2, random_state42) scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test) model Sequential([ Dense(128, activationrelu, input_shape(X_train.shape[1],)), Dropout(0.2), Dense(64, activationrelu), Dropout(0.2), Dense(1) ]) model.compile(optimizeradam, lossmse, metrics[mae]) early_stop EarlyStopping(monitorval_loss, patience10, restore_best_weightsTrue) model.fit(X_train, y_train, validation_split0.15, epochs200, batch_size32, callbacks[early_stop], verbose0) model.save(module.h5) pred model.predict(X_test).ravel() r2 r2_score(y_test, pred) mae np.mean(np.abs(np.expm1(y_test) - np.expm1(pred))) print(fR2{r2:.4f}, MAE{mae:.2f})脚本最后用np.expm1把对数价格还原成实际总价再和真实总价算 MAE。这个 MAE 是给答辩评委看的直观指标比如 MAE 18.6 表示平均预测误差在 18.6 万元左右。R² 只能说明模型解释了百分之多少的方差不能直接告诉人误差多少钱。module.h5保存后不要只存训练后的模型最好把scaler也存下来否则上线预测新数据时还要重新 fit 一遍等于从零开始。可以这样保存import joblib joblib.dump(scaler, scaler.pkl)加载预测时直接joblib.load(scaler.pkl)再transform这样新数据走的预处理路径和训练时完全一致不会因为标准化参数不同导致预测偏差。这是很多初学者最容易漏掉的一步。4.2 模型对比为什么神经网络能到0.75同样的特征岭回归的 R² 一般在 0.61 到 0.65 之间而神经网络可以到 0.75 上下。差异来源不是网络多么高深而是链家数据里的价格和位置、朝向、楼层之间存在较多非线性关系。比如“南北通透”的附加值在小户型上比大户型更明显线性模型无法表达这种交互作用神经网络用隐层的非线性激活函数把组合特征自动学出来了。模型R²MAE(万元)训练耗时岭回归0.6324.8秒级决策树0.5827.3秒级随机森林0.7020.9分钟级Keras 128-640.7518.61~2分钟如果用随机森林R² 能达到 0.70说明树模型也能捕捉交互作用。但树模型没有利用经纬度这个连续空间信息神经网络可以学到“浦东边缘的价格洼地”这类位置模式所以分数更高。这也是我推荐保留lat、lng两个特征的原因。4.3 过拟合排查训练集R²和测试集R²差太多怎么办神经网络在训练集上 R² 常常能到 0.95测试集只有 0.75这个差距是正常的不用恐惧。但如果你发现测试集 R² 掉到 0.6 以下就说明过拟合严重了。先看 Dropout 是否加了再降低网络层数最粗暴的办法是把神经元从 128 减到 64。另一个容易被忽略的坑是数据泄漏爬虫数据里如果包含unit_price单价而预测目标是总价那模型其实是把总价/面积再乘面积R² 直接冲向 0.98。我在清洗时会第一时间把unit_price字段删掉因为它是总价的线性变换。你可以检查自己的data_c.csv里是否残留了单价字段用df[total] - df[area] * df[unit_price]验算一下差值基本为 0 就说明泄漏了。5. 用module.h5做新房源预测并联动geo.js可视化验证5.1 加载训练好的H5模型预测平均单价当你从别人手里拿到这套项目时可能没有训练环境只想快速看一下预测结果。这时直接加载module.h5就可以不需要重跑训练。from tensorflow.keras.models import load_model import numpy as np model load_model(module.h5) # 假设一条新房源89平2室1厅浦东金桥近中环 sample np.array([[89, 2, 31.24, 121.57, 1, 0]]) sample_scaled scaler.transform(sample) pred_log model.predict(sample_scaled).ravel()[0] pred_price np.expm1(pred_log) print(f预测总价: {pred_price:.2f} 万元)这里的[89, 2, 31.24, 121.57, 1, 0]分别对应area, room_count, lat, lng以及两个独热编码后的类别位。实际使用时你必须知道训练时特征怎么排的序最稳妥的办法是训练时把特征列名存进一个 JSON 文件预测时按同样的顺序读取。如果你只是复现可以直接用X_test.iloc[0:5]来替换 sample避免手动拼特征顺序出错。预测完成后把若干条房源的预测结果写入geo.js可以做成前端可以引用的数据文件。geo.js在这个项目里的作用是把预测总价挂到小区点上格式类似window.predictionData [ {name: 金桥新村四街坊, value: 395}, {name: 黄山新城, value: 428} ];5.2 金桥.json和geo.js联动验证预测分布与真实分布是否一致金桥.json是 ECharts 地图所需的 GeoJSON 数据里面存的是小区边界或多边形点线信息。验证模型效果不一定只能看 R²把预测结果画在地图上检查价格分布是否符合常识是一种非常直观的定性验证。比如金桥板块靠地铁 6 号线附近的预测价格如果显著低于周边那很可能爬虫坐标偏了而不是模型问题。fetch(金桥.json) .then(res res.json()) .then(geo { echarts.registerMap(jinqiao, geo); const chart echarts.init(document.getElementById(map)); chart.setOption({ series: [{ type: map, map: jinqiao, data: window.predictionData }] }); });这段前端代码先fetchGeoJSON注册成名为jinqiao的地图然后把predictionData按name匹配到地图对应的区域上。使用时有三个地方最容易出问题第一predictionData里的name必须和 GeoJSON 里properties.name完全一致不能多一个空格第二geo.js要在金桥.json之前加载否则window.predictionData还没定义第三ECharts 的 map 类型在 v5 之后需要单独引入echarts/map/js/china或自定义地图文件建议直接用你自己注册的jinqiao不要依赖内置地图。如果我拿到的是没跑通的原项目我会先用px或matplotlib画预测价格热力图确认模型输出没有荒谬值再挂到前端。如果前端地图空白优先检查浏览器 Console 里的跨域错误——直接用file://打开本地 HTML 时fetch本地 JSON 会被 CORS 拦掉最简单的办法是用python -m http.server在项目目录起一个本地静态服务用http://localhost:8000访问避免走file协议。本文还有配套的精品资源点击获取
返回列表