简介:针对 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。所有验证都是在这个流程没问题之后才做。从那以后,我每次提交前都强制走一遍“行数检查、时间顺序检查、非负检查”这三件事,再对比一下最终验证分数和训练时的折均分数,能对得上才敢提交。希望帮到你。
本文还有配套的精品资源,点击获取