1 研究背景与目标
二手房挂牌价格同时受到区位、面积、户型、楼龄、楼层、朝向、装修与配套等因素影响。结构化数据分析可以帮助识别市场中主要价格梯度,并建立可重复的基准估价流程。本案例不追求复杂算法,而是用清晰的数据清洗、可解释特征和常见回归模型展示完整分析链路。
1.1 研究目标
- 构建可直接在 Jupyter Notebook 中运行的上海二手房分析流程。
- 将房源描述解析为户型、面积、楼层、朝向及文本卖点标签。
- 比较简单回归模型,并从多项指标评价预测质量。
- 逐图解释市场现象、可能原因及对建模的影响。
- 识别目标泄漏、样本偏差和挂牌价与成交价差异等局限。
2 数据来源与字段说明
交付数据整理自公开的上海链家二手房挂牌数据仓库。案例主题和分析结构参考 Kaggle 上的上海二手房价格数据集与常见房价预测流程。原始字段包含房源标题、区县、板块、小区、户型描述、挂牌总价、单位面积价格和建成年份等。为便于本地复现,最终 CSV 已统一为 UTF-8 编码,并加入从文本中解析的结构化字段。
数据来源说明:挂牌信息反映采集时点的卖方报价,不代表最终成交价;采集时间、覆盖范围和挂牌重复更新可能影响样本代表性。报告中的价格分析仅描述本数据集,不外推为当前上海全市场价格水平。
字段类别 | 主要字段 | 用途 |
位置字段 | 区县、板块、小区 | 分析空间差异;小区用于分组划分,不直接进入模型 |
房屋结构 | 室、厅、面积、总楼层、所在楼层、朝向 | 刻画房源物理属性 |
时间属性 | 建成年份、房龄 | 刻画楼龄及其非线性影响 |
价格字段 | 总价、单价 | 总价为目标;单价只用于探索,禁止进入模型 |
文本标签 | 地铁、南北通透、精装修、满五、采光等 | 从标题提取可解释的0/1卖点特征 |
3 数据清洗与特征工程
原始数据共73,316条、12个字段。清洗后保留70,613条记录,删除或排除2,703条明显异常或重复样本。主要规则包括:总价限定为50万至5000万元、面积限定为15至500平方米、总楼层限定为1至80层、单位面积价格限定为5000至250000元/平方米,并对关键结构字段进行正则解析。
3.1 目标泄漏控制
单位面积价格通常满足“单价≈总价÷面积”。若在预测总价时输入单价,模型几乎可以反推出目标,得到看似优秀但无法用于真实估价的成绩。因此单价仅用于描述性分析,绝不进入训练特征。
3.2 文本标签
对房源标题进行规则化关键词提取,形成地铁、南北通透、精装修、毛坯、满五、满二、采光、阳台、低楼层和景观等二元变量。该方法采用透明的关键词规则,容易解释、容易复现,也能保留部分非结构化信息。
4 探索性数据分析
4.1 清洗后字段缺失率
miss=(df.isna().mean()*100).sort_values(ascending=False); show=miss[miss>0] plt.figure(figsize=(9,5.4)); plt.barh(show.index,show.values,color=COLORS[0]); plt.xlabel('缺失比例(%)'); plt.ylabel('字段'); plt.title('清洗后字段缺失率'); plt.grid(axis='x',alpha=.2); plt.tight_layout(); plt.show()、
清洗后的结构化数据仅“建成年份”和由其计算的“房龄”存在缺失,共2,514条,占3.6%。其余建模字段均已完整解析。建模时对年份类字段采用训练集内中位数填补,避免直接删除样本导致区域结构发生偏移。
4.2 上海二手房挂牌总价分布
4.3 各区县挂牌房源数量![]()
浦东挂牌量最高,共16,947条,占样本的24.0%;闵行和宝山也具有较高样本量。金山、崇明和上海周边样本较少,因此这些区域的均价和模型误差更容易受个别房源影响,解读时不能只比较点估计。
4.4 房源建筑面积分布
房源面积中位数为81.0平方米,样本主体集中在中小户型区间,右侧仍有少量大面积改善型与豪宅房源。面积是总价最直接的数量基础,但面积与价格并非严格线性,位置和单价水平会造成相同面积房源的显著价差。
4.5 主要户型挂牌数量
4.6 主要朝向的挂牌单价中位数
朝向类别之间存在一定单价差异,但差异幅度明显小于区县和板块差异。朝向可以作为辅助特征,却不应被单独理解为决定性因素;其影响还可能与户型、楼层和采光标签共同发生。
4.7 文本标签与挂牌单价中位数差异
tag_cols=['地铁标签','南北通透标签','精装修标签','毛坯标签','满五标签','满二标签','采光标签','阳台标签','低楼层标签','景观标签']; rows=[] for t in tag_cols: m1=df.loc[df[t]==1,'单价_元每平'].median(); m0=df.loc[df[t]==0,'单价_元每平'].median(); rows.append((t.replace('标签',''),(m1/m0-1)*100)) s=pd.DataFrame(rows,columns=['标签','差异']).sort_values('差异'); plt.figure(figsize=(9,6)); plt.barh(s['标签'],s['差异'],color=[COLORS[3] if x<0 else COLORS[1] for x in s['差异']]); plt.axvline(0,color='#555555'); plt.xlabel('有标签房源相对单价差异(%)'); plt.ylabel('文本标签'); plt.title('文本标签与挂牌单价中位数差异'); plt.grid(axis='x',alpha=.2); plt.tight_layout(); plt.show()不同标签房源的单价中位数存在正负差异,但这些差异并非严格因果效应。例如“低楼层”标签可能更多出现在老旧小区,“景观”标签可能集中在高价板块。标签适合用于提升预测,而不宜直接解释为标签本身带来的价格溢价。
4.8 主要数值特征相关性热力图
总价与面积呈较强正相关,单价与总价也相关,但单价由总价和面积计算得到,因此被明确排除出预测特征。建成年份与房龄几乎完全负相关,二者同时保留主要为了展示和树模型非线性切分;线性模型中标准化与正则化可缓解共线性影响。
5 建模方案
5.1 数据划分
采用 GroupShuffleSplit 按小区分组划分数据,训练集56,574条,测试集14,039条;训练集和测试集的小区交集为0。与普通随机划分相比,该方式更严格,能够检验模型对未在训练集中出现过的小区的泛化能力。
5.2 模型设置
模型 | 作用 | 主要设置 |
中位数基线 | 提供最低可接受参照 | 所有测试样本预测为训练集总价中位数 |
岭回归 | 可解释的线性基准 | 类别独热编码,数值标准化,L2正则化 |
决策树 | 学习非线性和特征交互 | 最大深度16,叶节点最少8条样本 |
随机森林 | 降低单棵树方差 | 80棵树,最大深度18,叶节点最少3条样本 |
5.3 评价指标
MAE反映平均绝对万元偏差;RMSE对大误差更敏感;R²衡量解释的方差比例;RMSLE更关注相对误差并降低高价样本支配;MAPE便于以百分比解释,但在低总价样本上更容易放大。
6 模型结果与误差分析
num_features=['室','厅','面积_平米','总楼层','建成年份','房龄_年','地铁标签','南北通透标签','精装修标签','毛坯标签','满五标签','满二标签','采光标签','阳台标签','低楼层标签','景观标签'] cat_features=['区县','板块','所在楼层','朝向'] X=df[num_features+cat_features]; y=df['总价_万元']; groups=df['小区'] split=GroupShuffleSplit(n_splits=1,test_size=0.2,random_state=42) train_idx,test_idx=next(split.split(X,y,groups)) X_train,X_test=X.iloc[train_idx],X.iloc[test_idx] y_train,y_test=y.iloc[train_idx],y.iloc[test_idx] y_train_log=np.log1p(y_train) print(f'训练集:{len(train_idx):,} 条;测试集:{len(test_idx):,} 条') print('训练/测试小区交集:',len(set(groups.iloc[train_idx]) & set(groups.iloc[test_idx]))) def evaluate(name,pred,rows,preds): pred=np.maximum(np.asarray(pred),0) rows.append({'模型':name,'MAE':mean_absolute_error(y_test,pred),'RMSE':mean_squared_error(y_test,pred)**0.5,'R²':r2_score(y_test,pred),'RMSLE':mean_squared_log_error(y_test,pred)**0.5,'MAPE':np.mean(np.abs((y_test-pred)/y_test))}) preds[name]=pred rows=[]; preds={} evaluate('中位数基线',np.repeat(y_train.median(),len(y_test)),rows,preds) num_pipe=Pipeline([('填补',SimpleImputer(strategy='median')),('标准化',StandardScaler())]) cat_pipe=Pipeline([('填补',SimpleImputer(strategy='most_frequent')),('编码',OneHotEncoder(handle_unknown='ignore',min_frequency=20))]) pre_linear=ColumnTransformer([('数值',num_pipe,num_features),('类别',cat_pipe,cat_features)]) ridge=Pipeline([('预处理',pre_linear),('模型',Ridge(alpha=10.0,solver='lsqr'))]) ridge.fit(X_train,y_train_log); evaluate('岭回归',np.expm1(ridge.predict(X_test)),rows,preds) num_tree=Pipeline([('填补',SimpleImputer(strategy='median'))]) cat_tree=Pipeline([('填补',SimpleImputer(strategy='most_frequent')),('编码',OrdinalEncoder(handle_unknown='use_encoded_value',unknown_value=-1))]) pre_tree=ColumnTransformer([('数值',num_tree,num_features),('类别',cat_tree,cat_features)]) tree=Pipeline([('预处理',pre_tree),('模型',DecisionTreeRegressor(max_depth=16,min_samples_leaf=8,random_state=42))]) tree.fit(X_train,y_train_log); evaluate('决策树',np.expm1(tree.predict(X_test)),rows,preds) rf=Pipeline([('预处理',pre_tree),('模型',RandomForestRegressor(n_estimators=80,max_depth=18,min_samples_leaf=3,max_features=0.75,random_state=42,n_jobs=-1))]) rf.fit(X_train,y_train_log); evaluate('随机森林',np.expm1(rf.predict(X_test)),rows,preds) results_df=pd.DataFrame(rows).sort_values('RMSE').reset_index(drop=True) display(results_df.style.format({'MAE':'{:.2f}','RMSE':'{:.2f}','R²':'{:.3f}','RMSLE':'{:.3f}','MAPE':'{:.2%}'})) best_name=results_df.iloc[0]['模型']; best_pred=preds[best_name]模型 | MAE | RMSE | R² | RMSLE | MAPE |
随机森林 | 72.8 | 139.2 | 0.875 | 0.180 | 13.0% |
决策树 | 86.4 | 165.0 | 0.824 | 0.216 | 15.7% |
岭回归 | 76.5 | 236.4 | 0.639 | 0.170 | 12.5% |
中位数基线 | 232.6 | 413.8 | -0.106 | 0.572 | 41.3% |
6.1 不同模型的测试集误差对比
6.2 随机森林预测残差分布
6.3 随机森林主要特征重要性
面积、板块和区县等结构与位置变量通常位于重要性前列,符合二手房总价由“面积×区位价格水平”共同决定的市场逻辑。特征重要性表示模型分裂时的贡献,不等同于因果影响;特别是区县与板块存在层级关联,重要性会在相关变量之间分摊。
7 主要结论
- 清洗后数据包含70,613条房源,浦东、闵行和宝山挂牌样本较多,区域样本量差异明显。
- .。。。。。。。。。。。。。。。。。
8 局限性与改进方向
- 挂牌价不等于成交价:卖方报价受到议价预期影响,不能直接代表真实交易价格。
- 数据时间与覆盖范围有限:报告只描述交付数据集,不用于判断当前上海市场水平。
具体细节见原文
创造不易,谢谢各位多多点赞收藏!