任务与数据
预测新加坡 HDB 公屋转售价格。主数据集是 Kaggle 上的一份 HDB 转售交易数据,辅助地理空间数据则从新加坡政府开放数据 API 实时拉取。
- 训练集:162,691 条交易;测试集:50,000 条交易
- 时间区间:2017 — 2025
- 目标:
RESALE_PRICE(SGD)—— 右偏,中位数约 S$488k,均值约 S$518k,尾部一直拉到 S$1.6M 以上
- 指标:log 价格空间下的 RMSE
价格分布尾部很重,时间趋势也很强:2017 年中位价 ≈ S$380k vs. 2025 年 ≈ S$620k(+60%)。这两个观察直接驱动了下面的设计选择 —— 时间衰减样本加权,以及面向高价段的二阶段精修。
辅助地理数据
5 类 POI 来自新加坡政府开放 API(data.gov.sg、LTA DataMall、OneMap、MOE、NEA),共约 774 个点:
| 文件 | 数量 | 来源 |
|---|
sg-mrt-stations.csv | 243 | LTA DataMall |
sg-primary-schools.csv | 182 | data.gov.sg / MOE |
sg-secondary-schools.csv | 153 | data.gov.sg / MOE |
sg-shopping-malls.csv | 89 | data.gov.sg / OneMap |
sg-gov-hawkers.csv | 107 | data.gov.sg / NEA |
sg-hdb-block-details.csv | 9,660 | data.gov.sg HDB Property Information |
HDB block 文件不算 POI —— 它是楼栋索引:每个 block 的经纬度、规划区域、MAX_FLOOR,用来给每条交易回填地理坐标(在做了一次合并验证分析之后只按 BLOCK 关联:BLOCK + TOWN 因为重复匹配率 115%、ADDRESS 因格式不统一匹配率 0%;按每个 block 保留 MAX_FLOOR 最大那一行去重就解决了)。
特征工程
时间特征
从 MONTH 字段(YYYY-MM)解析:
year、month_num
month_sin / month_cos —— 周期编码,让 12 月与 1 月成为邻居而不是相反
flat_age = year - lease_commence_data
lease_left = 99 - flat_age(HDB 租约 99 年)
is_new 标记 ≤ 5 年的新房
楼层特征
FLOOR_RANGE 是一个区间字符串,例如 "07 TO 09"。拆解为:
floor_num —— 下界
avg_floor —— 中点
floor_range —— 粗化分桶(Low / Mid-Low / Mid / Mid-High / High / Very High / Top)
地理空间特征 —— BallTree + Haversine
特征工程的核心。对每条交易,给每类 POI 的经纬度构建 BallTree,在 Haversine 度量下(球面大圆距离)跑最近邻查询。
为什么用 BallTree。 暴力对 16.2 万条交易 × 774 个 POI 跑最近邻是 O(N·M);BallTree 给出 O(N · log M) —— 在这个配置下大约 快 10×。
双半径设计。 单一最近距离区分不出 “800m 之外有一个地铁” 和 “附近有三条线、几个站聚在一起”。所以每类 POI 都用一个内圈半径表示「贴身覆盖」、一个外圈半径表示「广覆盖」:
| POI | 内圈 | 外圈 | 额外特征 |
|---|
| 地铁 MRT | 800 m | 1500 m | 最近距离 · 最近站名(类别) |
| 小学 | 600 m | 1000 m | 最近距离 · 最近校名(类别) |
| 中学 | 800 m | 1500 m | 最近距离 |
| 商场 | 1000 m | 2000 m | KNN-3 平均距离 · 最近商场名(类别) |
| 熟食中心 | 500 m | 1200 m | 最近距离 |
由此每个样本得到 约 20 个地理特征:5 个最近距离 + 10 个双半径计数 + 2 个 KNN-3 平均 + 3 个最近 POI 类别 ID。
分级标记
第二轮再扫一遍,标记 最近 那个 POI 是否「精英级」—— 类别密度本身抓不到名校 / 核心线 / 旗舰商场这种声誉信号:
nearest_primary_top —— 最近的小学是否在 Top Primary 名单上?
nearest_mrt_core_line —— 最近的地铁是否在核心线(DTL / TEL / NSL / EWL / CCL / NEL)?
nearest_mall_flagship —— VivoCity / Ion Orchard / 等?
类别编码
CatBoost 通过 Ordered Target Statistics 原生支持类别特征,所以大多数字段直接保留原值:town、flat_model、flat_type、floor_range。额外加了一个工程特征:model_rank,按市场中位价对 flat_model 做排序的序号 —— 给 GBDT 模型一份便宜的、价格感知的编码。
ECO_CATEGORY(100% 是 "uncategorized")直接丢弃。BLOCK 和 STREET 在合并出地理坐标后也丢弃。
样本加权 —— 时间 × 价格
两个因子相乘组成一个样本权重:
time_weight = exp(decay_rate × (year − min_year)) # 越近的交易权重越高
price_weight = 1 + α × 1[price > price_90th] # 高价加权
sample_weight = time_weight × price_weight
时间衰减反映了 EDA 里的发现:2023–2025 的价格行为与 2017–2018 不同。价格加权让模型把额外的容量花在重尾的顶部 10% —— 否则它们会被海量的中端 4 房单位平均掉。
单调性约束
CatBoost 的单调约束施加在:
floor_area_sqm —— 严格递增
lease_left —— 严格递增
防止模型学出像「+10 sqm → 更便宜」这类反直觉预测 —— 这种现象会在面积与其他特征局部相关(比如老楼栋恰好更大)时出现,把边际效应反过来。
Stacking 架构
Level-0 基学习器(5 折 OOF 预测)
├── CatBoost n_estimators=10000, lr=0.033, depth=8, od_wait=350
├── LightGBM n_estimators=5000, lr=0.035, num_leaves=64
└── XGBoost n_estimators=5000, lr=0.035, max_depth=8
Level-1 元学习器
└── 线性回归(无截距),作用于基学习器的 OOF 预测
OOF 预测避免元学习器看到同一行自己训练时的预测值(否则会泄露标签)。无截距是有意为之:每个基学习器本身已经能给出校准好的价格估计,元学习器只需要学最优的 近似凸权重,而不是再去平移均值。
高价段二阶段精修
顶部 10%(RESALE_PRICE > 90 分位,约 S$750k+)的误差总是更难控制。所以在这一段上 fine-tune 第二个 CatBoost,再做混合:
y_main = 主 stacking 预测(全数据)
y_seg = 仅高价段 fine-tune
y_final = w · y_seg + (1 − w) · y_main # w 在验证集上做 grid search
混合权重 w 用 grid search 选定,而不是端到端学 —— 避免高价段在主 stacker 的梯度里占主导。
多种子平均
最终提交对三个 CatBoost 种子(42、100、2025)做平均,由训练脚本里的 flag 控制。这降低了单次随机初始化的方差,几乎不增加额外算力,因为每个种子的 OOF 折是独立的。
结果
| 版本 | 描述 | 验证集 log-RMSE |
|---|
| v1 | 基线,无地理特征 | — |
| v2 | log 空间训练,单半径地理 | 0.061 |
| v2.5 | 价格空间 + stacking | — |
| v3(最终版) | 双半径地理 + 分级标记 + 高价段精修 | 0.050 |
相对 v2 大约 18% 的提升。
特征重要性(CatBoost,基础特征集)
| 特征 | 重要性 |
|---|
| floor_area_sqm | 29.36% |
| town | 23.17% |
| year | 18.39% |
| lease_commence_data | 12.84% |
| flat_model | 8.05% |
| avg_floor | 3.17% |
| flat_type | 2.52% |
| flat_age | 1.48% |
面积 + 区域 + 年份 + 租约一起占了约 83% 的重要性。地理特征是 叠加在 town 之上的额外信息,区分「武吉知马的房子贴近地铁」和「武吉知马规划区边缘的房子」。
关键设计决策
- 主学习器选 CatBoost,而不是 XGBoost —— 通过 Ordered Target Statistics 原生支持类别特征,避免对
town(26 类)、flat_model(21 类)、flat_type(去重后 12 类)做手工 one-hot / target encoding 时的噪声。
- v3 用价格空间,不是 log 空间 —— log 空间把高价误差压扁,但比赛指标看的是绝对值。用中位价归一化(
price / median_price)保持数值稳定,同时让模型直接优化真实误差。
- 双半径密度,不是单一最近距离 —— 同时捕捉邻近性和周边设施集合的丰富度。
- 只按 BLOCK 关联 + 按
MAX_FLOOR 去重 —— 在测试了 BLOCK + TOWN(115% 重复匹配)和 ADDRESS(0% 匹配)之后选定的;按 MAX_FLOOR 去重等价于偏向锚定在最高测量楼层的那条坐标(GPS 质量更好)。
接下来想做什么
- 基于时间的留出验证 —— 当前 5 折是随机切分;按时间切(如 train ≤ 2024、validate 2025)才能测出模型的前向外推能力,这才是生产 AVM 真正需要的。
- 分位数回归 —— 给出 P10 / P50 / P90 而不是点估计,把 HDB 价格本身的不确定性显式呈现出来。
- 轻量神经的 POI 检索 —— 用 attention over POI 替换半径计数,按户型条件化,让模型学每户型自己的「邻近」概念。