news 2026/10/14 15:08:00

共享单车需求预测实战:特征工程与LightGBM全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
共享单车需求预测实战:特征工程与LightGBM全流程解析

简介:针对 Kaggle 共享单车需求竞赛的 Python 机器学习示例包,面向入门数据科学或正在学习机器学习实践的读者,目标是依据天气、时间、温度、是否工作日等特征,预测一天中各小时的自行车租用数量。代码来自华盛顿大学 Bill Howe 教授的 Coursera《数据科学导论》课程作业,演示了机器学习建模的基本流程。压缩包共 5 个文件,以 Python 脚本为主,配合 2 个 CSV 训练/测试数据集和 Markdown 说明文档,整体仅 180KB、结构简洁,轻量易用。使用者可指定来自 Python 库的 10 种不同机器学习算法之一,并明确哪些数据变量参与训练;既可以在完整训练集上训练并生成可直接提交 Kaggle 的 output.csv,也可以仅对全部可用数据的子集进行训练与验证。目前已有 419 人学习,适合想要通过完整赛题案例快速上手预测建模、理解特征选择与模型对比的数据科学初学者与课程学习者。

1. bike-sharing 不是一个“调模型”的比赛

很多第一次打 Kaggle 的开发者,拿到 bike-sharing 这个题之后第一反应是换个更厉害的模型,从随机森林换到 XGBoost,分数却一直卡在 0.6 左右的 RMSLE 下不去。这个赛题全称是 Bike Sharing Demand,给的是两年内每小时单车租借记录,要预测某个时间点的总租车量 count,官方用 RMSLE 打分。它真正训练的不是模型精度,而是特征工程能力和对数据现场的理解力:你能不能从 datetime、season、weather、windspeed 这些散装字段里,把周期、节假日、天气交互这些信号挖出来。适合两类人:第一次想完整走一遍回归赛全流程的新手,以及已经会调参但特征工程一直靠感觉的熟手。

2. 把原始字段变成能用的信号:共享单车数据特征工程的三个动作

2.1 数据字段里藏着什么

训练集一共 17 列,但真正作为预测输入的只有一小部分。datetime 是每小时一条的时间戳,count 是那个小时的总租车量,casual 和 registered 是 count 的两个分拆。关键点在于测试集里没有 casual 和 registered,因为这两个字段在真实提交里本来就不存在,模型必须直接学 count。剩下的 season、holiday、workingday、weather、temp、atemp、humidity、windspeed 全部是外部条件。

拿到数据第一件事不是建模,是先看结构。用 pandas 读进来之后跑一下 info 和 describe,确认数据的类型和空值情况。

import pandas as pd train = pd.read_csv("train.csv") test = pd.read_csv("test.csv") train["datetime"] = pd.to_datetime(train["datetime"]) test["datetime"] = pd.to_datetime(test["datetime"]) print(train.info()) print(train.describe())

两个 read_csv 之后把 datetime 转成 pandas 的 datetime 类型,这是整条特征管道的地基。info 看有没有缺失值,describe 看 count 的分布。实际跑起来你会发现训练数据几乎没有空值,测试集里 windspeed 却有一堆 0,这地方是后面要处理的坑,但先不展开。count 的最小值是 1,最大值接近 1000,分布严重右偏,这决定了标签要做 log1p 变换。

casual 和 registered 不需要单独处理。它们只是 count 的两个组成部分,如果你把它们当成输入特征,训练集还行,测试集里没有这两列,模型直接废掉。

2.2 时间特征是这只数据集的灵魂

bike-sharing 这个题和普通回归最大的区别是:时间不是背景,是主特征。每个样本是一小时,那么“几点钟”“星期几”“几月份”“是不是周末”就决定了租车需求的基本盘。凌晨两点和晚上八点的曲线完全不同,工作日和周末的高峰位置完全不同,冬天和夏天的整体量级也完全不同。

所以第一步就是把 datetime 拆成多个时间组件。常见做法是对 train 和 test 做同一套处理,避免后面提交时字段对不上。

import numpy as np for df in [train, test]: df["year"] = df["datetime"].dt.year df["month"] = df["datetime"].dt.month df["day"] = df["datetime"].dt.day df["hour"] = df["datetime"].dt.hour df["weekday"] = df["datetime"].dt.weekday

这里面 hour 是最重要的特征,没有之一。共享单车在工作日的早高峰和晚高峰有两个明显峰值,树模型会直接按 hour 切分数据,所以 hour 以整数形式给到 LightGBM 就够用。year 和 month 捕捉的是长期趋势和季节性,weekday 捕捉周期性。day 这个字段我一般会保留,虽然它对小时粒度的影响比较弱,但月初月尾和节假日的分布偶尔会有差异。

有一点需要注意:hour 作为整数对树模型是友好的,因为树做的是条件判断,不认为 23 是 22 的下一个数。但如果你后面要用线性模型,hour 就必须做 one-hot 或者周期编码,否则模型会强行学出一个“每小时租车量匀速增长”的错误关系。这里用 sin/cos 周期编码是常见补救手段,只是树模型收益不大,看情况用。

2.3 编码、分箱、节假日:刻意构造组合特征

season 和 weather 这两个字段看起来是数字 1 到 4,实际是离散编码,不是连续数值。直接用数值喂给模型,树模型还能凑合,线性模型就会把 4 当成 3 的 1.33 倍理解,完全错误。正确做法是转成 category 类型,或者做 one-hot。

holiday 和 workingday 要小心。数据里存在 holiday=1 同时 workingday=1 的情况,这说明那天的确放假但因为某种原因仍被标记成了工作日。我不去纠结语义矛盾,而是直接构造一个周末判定特征,把节假日和周末的逻辑重新合并。

for df in [train, test]: df["is_weekend"] = ((df["weekday"] >= 5) & (df["holiday"] == 0)).astype(int) df["season"] = df["season"].astype("category") df["weather"] = df["weather"].astype("category")

is_weekend 的作用是给模型一个明确的“非工作日”信号。因为 weekday 和 holiday 的组合比较混乱,单独拎出一个二值特征能减少模型去记忆噪声。你还可以更进一步构造“早晚高峰”“节日前后”这类组合特征,但每加一个特征都要用交叉验证验证收益,不然就是给自己增加过拟合空间。

humidity 和 temp 这个连续值部分,我的经验是树模型不需要刻意分箱,直接用原始数值就行。温度与租量的关系不是线性的,树模型可以自己切出多个阈值,分箱反而限制它的表达能力。只有在用线性模型时,分箱才有明显提升。所以不要看到网上有人做分箱就跟,先问自己用的是不是树模型。

windspeed 单独说一下。这个字段有大量 0,而且这些 0 并不是真实无风,而是原始数据里的缺失填充。后面避坑章会专门讲怎么处理,特征阶段先留个心眼即可。

3. 模型选型与训练配置:为什么梯度提升是默认答案

3.1 线性回归在这里为什么吃亏

如果你拿线性回归或者岭回归直接跑,RMSLE 会卡在 0.8 到 1.0 之间,然后你会觉得数据太差。其实不是数据差,是线性模型表达不了这个场景里的条件交互。

“夏天 + 傍晚 + 非工作日”三个条件同时成立时,租车量会暴涨。线性模型把每个特征独立加总,它对“同时成立”这种逻辑缺乏建模能力。随机森林和梯度提升树是按特征阈值切割的,天然能表达组合条件。打个比方:线性模型是列购物清单,每一项独立计价;树模型是写决策树,先判断是否周末,再判断几点钟,再判断天气好坏,层层嵌套。bike-sharing 的租量恰恰是这种嵌套关系,所以最后几乎所有高分方案都落在 gradient boosting 或随机森林上。

另一个原因是标签分布。count 高度右偏,直接优化 MSE 会被少数极大值主导。官方用 RMSLE,它实际上是在对数空间算 RMSE。对策就是训练前对 count 做 log1p,让目标分布更接近正态,预测完再 expm1 还原。

import numpy as np y_log = np.log1p(train["count"]) print(y_log.describe())

log1p 是对 count 加一再取自然对数,好处是原始数据里有 0 或者 1 也不会变成负无穷。训练时模型拟合的是 y_log,提交时对预测结果做 expm1 就能还原成真正的租车量。

3.2 LightGBM 训练流程与关键参数

选 LightGBM 是因为它在中小数据集上训练快,对 categorical 特征原生支持,调参成本比 XGBoost 低。下面这个训练片段是我在复现这个赛题时最常用的配置,直接复制到 notebook 里能跑通。

import lightgbm as lgb from sklearn.model_selection import TimeSeriesSplit import numpy as np FEATURES = [ "year", "month", "day", "hour", "weekday", "is_weekend", "season", "weather", "temp", "atemp", "humidity", "windspeed", ] X = train[FEATURES].copy() y = np.log1p(train["count"]) tss = TimeSeriesSplit(n_splits=5) log_scores = [] for train_idx, val_idx in tss.split(X): X_tr, X_va = X.iloc[train_idx], X.iloc[val_idx] y_tr, y_va = y.iloc[train_idx], y.iloc[val_idx] model = lgb.LGBMRegressor( n_estimators=3000, learning_rate=0.03, num_leaves=31, min_child_samples=20, subsample=0.8, subsample_freq=1, colsample_bytree=0.8, random_state=42, ) model.fit( X_tr, y_tr, eval_set=[(X_va, y_va)], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(200)], ) pred = model.predict(X_va) log_scores.append(np.sqrt(np.mean((pred - y_va) ** 2))) print("mean RMSLE in log space:", np.mean(log_scores))

这里有几个点要说清楚。n_estimators 设到 3000 但配了 early_stopping,所以模型通常在这个数值之前就停了,早停轮数 100 的意思是连续 100 轮验证分数不下降就终止。learning_rate 是 0.03,小学习率配合多棵树能提升精度,代价是训练时间变长,这个数据集只有 10000 多行,完全跑得动。

num_leaves=31 控制树复杂度,min_child_samples=20 防止叶子节点样本太少导致过拟合。subsample 和 colsample_bytree 分别是行采样和列采样,目的都是增加模型随机性、降低过拟合风险。random_state 固定住,保证每次跑出来的结果可复现,这个习惯对调试非常重要。

这段代码里我用的交叉验证是 TimeSeriesSplit,细节在下一小节展开。最终 log_scores 数组里的每个元素是单折验证集上的 RMSLE,取均值就是模型在这个特征集下的稳定表现。

3.3 交叉验证:用时间感知拆分替代随机 KFold

很多入门方案直接用 KFold 切分训练集,本地分数很好看,一提交就崩。原因很简单:bike-sharing 是时间序列数据,随机切分会让训练集里出现验证集之后的数据,模型相当于提前看到了未来。虽然评分指标是 RMSLE,但信息泄漏会让模型学到的规律泛化不到真正的未来数据上。

正确做法是让验证集始终在训练集之后。TimeSeriesSplit 就是按时间顺序切分的,第一次用最早的数据训练,最后一段最新数据做验证。训练时我把 log 空间 RMSE 当作 RMSLE 的近似,因为 log1p 处理后两者等价。

参数上,TimeSeriesSplit(n_splits=5) 会把数据切 5 次,每折验证集都比上一折更靠后。这种训练方式能让分数更接近真实提交表现。如果你拿到一份方案声称本地 0.38、提交 0.5,差异这么大基本就是切分方式错了。

4. 常见问题与避坑:五个容易翻车的细节

4.1 现象:本地 RMSLE 很低,提交后分数完全对不上

我在复现这个赛题时第一次遇到的情况是本地折均 0.42,提交后线上 0.55,差了 13 个点。原因基本锁定在交叉验证切分方式上。随机 KFold 把未来数据混进训练集,模型记住了时间趋势,验证分数虚低。一旦到测试集,也就是真正未来的数据,这套规律就失效。

解决方式只有一个:所有验证必须用时间感知拆分。如果用的是 sklearn,把 KFold 换成 TimeSeriesSplit。如果自己写切分,那就按 datetime 排序后取前 80% 训练、后 20% 验证,保证验证集的每一个样本时间上都晚于训练集。

4.2 现象:直接预测 count 输出了负数

你拿随机森林直接拟合 count 时,很容易在凌晨两点、大雨天这样的极端组合下预测出负数。这跟模型无关,是标签分布的问题。count 的最小值接近 0,最大值接近 1000,右偏分布让树模型在少数低值区间外推时毫无约束。

解决方式是训练前对标签做 log1p,预测后对结果做 expm1。这样输出永远大于等于原始对应值,不会出现负数。如果因为某些原因不想用 log1p,那就在提交前对预测数组做一次 np.maximum(0, pred),强行把负值截断。但这是补救手段,本质上还是在目标空间上吃亏。

4.3 现象:windspeed 大量为 0,特征权重虚高

测试集里 windspeed 有接近几十条记录是 0,看起来像没风。但训练集里真正风速为 0 的样本占比异常高,原因是数据采集时缺失值被填成了 0。树模型会把这个 0 值当成一个真实信号来切分,导致“风速为 0 一定租车多”这种假规律。

处理时先看分布。用分组众数替换训练集和测试集中 windspeed 的 0 值是常见做法,避免树模型过度利用这一条噪声路径。替换完之后再跑验证,如果 RMSLE 下降了说明有效;如果没变甚至上升,就保留原始 0。数据清洗这件事要反复试,不是看到 0 就要补。

4.4 现象:提交文件行数与测试集对不上,或者 datetime 错位

这个坑最容易发生在同时处理 train 和 test 的时候。比如你在训练集上做了 reset_index,测试集没有对齐,或者两个 DataFrame 在 concat 合并后索引乱掉,最后生成了 6493 行而不是 6494 行。等到提交时才报错,白白浪费一次提交机会。

我的做法是处理初期就把 test 的原始 datetime 单独存一份,所有特征工程都在完整 DataFrame 上操作,最后提交时不需要移动任何东西,只需要把模型预测值赋给 train 和 test 合并后的完整表格,并按原始顺序截取 test 部分。提交前检查三件事:行数是否等于测试集行数、datetime 是否单调递增、count 是否有负数。

4.5 现象:先预测 casual 和 registered 再相加,效果反而更差

有的方案会把 count 拆成 casual 和 registered 两个目标分别建模,然后相加得到最终 count。听起来很合理,实际效果很差。原因不难理解:测试集里没有这两个字段,你要训练两个独立模型,两个模型各自有误差,相加后误差会累积。而且 casual 和 registered 的分布比总数更稀疏,模型更容易过拟合。

我实际验证过,直接对 count 建模比先分拆再合并的结果稳定得多,RMSLE 大概能低 0.02 到 0.03。除非你只把 casual 和 registered 当作辅助特征来做多任务学习,否则不要碰这条路线。

5. 提交前强制走一遍的“时间感知”最终验证

5.1 用最近 20 天的数据模拟真实提交

交叉验证分数再稳,也不能完全代表提交结果。我自己的习惯是在所有调参结束之后,额外做一次“最终验证”:把训练集按时间排序,取最后 20 天当验证集,之前所有数据当训练集,跑一次单模。这个验证的分数更接近线上提交水平,因为它模拟的是“用过去预测未来”的真实场景。

cutoff = train["datetime"].max() - pd.Timedelta(days=20) tr = train[train["datetime"] < cutoff] va = train[train["datetime"] >= cutoff] final_model = lgb.LGBMRegressor( n_estimators=3000, learning_rate=0.03, num_leaves=31, min_child_samples=20, subsample=0.8, subsample_freq=1, colsample_bytree=0.8, random_state=42, ) final_model.fit( tr[FEATURES], np.log1p(tr["count"]), eval_set=[(va[FEATURES], np.log1p(va["count"]))], callbacks=[lgb.early_stopping(100), lgb.log_evaluation(200)], ) va_pred = final_model.predict(va[FEATURES]) va_rmsle = np.sqrt(np.mean((va_pred - np.log1p(va["count"])) ** 2)) print("last 20 days RMSLE:", va_rmsle)

这段代码的价值在于它给你一个独立的、没有任何数据泄漏的预期分数。后面如果还要调参,就以这个分数为准,而不是那一堆 TimeSeriesSplit 的折均。

5.2 顺手检查提交文件

最终提交时,我会把训练好的模型对完整测试集预测,然后输出成一个两列的 CSV:datetime 和 count。所有验证都是在这个流程没问题之后才做。从那以后,我每次提交前都强制走一遍“行数检查、时间顺序检查、非负检查”这三件事,再对比一下最终验证分数和训练时的折均分数,能对得上才敢提交。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 15:07:47

Ubuntu下Qt WebEngine与JS互调实现地图定位

简介&#xff1a;这份资源面向在Ubuntu平台使用Qt框架开发桌面应用的开发者&#xff0c;尤其是需要在界面中嵌入地图与定位功能的初中级人员。它提供了一套可运行的示例工程&#xff0c;通过QWebEngineView加载百度地图HTML页面&#xff0c;实现地图展示&#xff0c;并支持输入…

作者头像 李华
网站建设 2026/10/14 15:07:39

插件加载失败排查方法论:从报错到底层机制

同样一条插件的报错&#xff0c;凭什么有人十分钟定位问题&#xff0c;有人能折腾一下午&#xff1f;差别不在天赋&#xff0c;而在有没有把插件机制的底层逻辑吃透。这篇文章想聊的&#xff0c;就是我从一堆failed to load plugins这类报错里爬出来之后&#xff0c;总结出的一…

作者头像 李华
网站建设 2026/10/14 15:07:36

县城老师陪学记:免费背单词小程序怎么用出效果

去年秋天&#xff0c;一位在县城教初三英语的老师跟我聊起一件怪事。她班上有个男孩&#xff0c;其他科目都能排进年级前五十&#xff0c;唯独英语总在及格线晃。她翻了他的单词本&#xff0c;发现这孩子抄单词特别认真&#xff0c;每个词抄十遍&#xff0c;红笔标中文&#xf…

作者头像 李华
网站建设 2026/10/14 15:07:34

Qwen-Image-2.1本地部署实战:构建稳定可控的视觉生成服务

1. 这不是“跑个模型”那么简单&#xff1a;Qwen-Image-2.1本地部署的本质是构建一个可控、可集成、可扩展的视觉生成基础设施Qwen-Image-2.1&#xff0c;这个名字最近在开源多模态社区里频繁刷屏。它不是又一个“玩具级”的文生图模型&#xff0c;而是通义实验室推出的、具备强…

作者头像 李华
网站建设 2026/10/14 15:07:22

Excel数据源接入FastReport:从表格规整到批量出表完整指南

简介&#xff1a;一份面向Delphi开发者的FastReport报表开发资源&#xff0c;演示如何以Excel为数据源&#xff0c;在FastReport中完成数据连接、字段映射、报表设计及导出打印&#xff0c;解决Excel数据向专业报表迁移的常见需求。压缩包共10个文件&#xff0c;仅835KB&#x…

作者头像 李华
网站建设 2026/10/14 15:07:02

软件库源码实战:后端搭建、App对接与版本管理避坑指南

简介&#xff1a;这是一套面向移动应用开发初学者与个人站长的开源软件库源码合集&#xff0c;包含前端应用与后端服务两部分&#xff0c;适合想快速搭建应用分发平台、学习源码二次开发的技术爱好者。压缩包共184个文件&#xff0c;约17.64MB&#xff0c;以58个php后端脚本、3…

作者头像 李华