简介:针对Kaggle共享单车需求竞赛的Python机器学习代码,源自华盛顿大学Bill Howe教授《数据科学导论》课程作业项目,面向数据科学初学者及竞赛新手,用于根据天气、时间、温度、是否工作日等特征预测每小时自行车租赁量。压缩包共5个文件、约180KB,核心是一个Python脚本,附带训练集与测试集两个CSV数据文件,以及说明与许可证两个Markdown文档,分别对应算法实现、数据输入、使用说明和许可协议,整体结构紧凑,便于快速上手修改。代码内置10种机器学习算法可供自由切换,使用时可明确指定训练变量,并选择在完整训练集上生成可直接提交的output.csv,或在小规模子集上进行快速实验,以比较不同模型和特征组合的效果。目前已有419人学习下载,适合作为课程作业参考、Kaggle入门练习和算法效果对比模板,能够帮助读者整体理解赛题数据处理、模型构建、结果验证与提交格式等关键环节。
1. bike-sharing Kaggle 比赛凭什么值得反复复现
bike-sharing 是 Kaggle 上相当经典的回归赛题,没有图像也没有文本,就是一张时间戳加天气的表,却让很多新手在同样的模型上反复吃瘪。我第一次复现时以为把 GBDT 调透就能进前 10%,结果卡在前 30% 很久,直到和某开发者交换了思路才意识到:这个赛题真正的门槛不是模型,而是小时粒度的周期特征、天气变量的非线性和验证集切分方式三者之间的配合。数据量不大、单机就能跑、指标又很刁钻,非常适合用来验证特征工程和时序建模的基本功。适合人群是刚做过一两个回归项目、想系统补上特征处理和验证方法论的人。
2. 先搞清评估指标和 baseline:RMSLE 才是 bike-sharing 的真正 KPI
很多人拿到这个赛题的第一步是直接看字段、列缺失、然后跑一个 RandomForest,这是典型的顺序错误。评价一个预测系统要先看它用什么尺子量,bike-sharing 的尺子是 RMSLE 而不是 RMSE,这一个差异会影响你对目标变量做不做对数变换、损失函数怎么设、甚至特征怎么构造。
2.1 评估指标 RMSLE:为什么只看 RMSE 会吃暗亏
RMSLE 的全称是 Root Mean Squared Logarithmic Error,计算时先对真实值和预测值分别做 log1p,再在 log 空间算均方根误差。先把它写成可复用的函数,后面所有验证都用它。
import numpy as np def rmsle(y_true, y_pred): y_pred = np.maximum(y_pred, 0) return float(np.sqrt(np.mean((np.log1p(y_true) - np.log1p(y_pred)) ** 2))) def rmse(y_true, y_pred): return float(np.sqrt(np.mean((y_true - y_pred) ** 2)))这里np.maximum(y_pred, 0)是必须的,因为 log1p 对负数会直接得到 NaN,而一些线性模型很容易吐出负的租赁量预测。RMSLE 和 RMSE 表面只差一个 log,实际含义差很远:RMSE 惩罚绝对误差,需求 100 预测成 110 的损失,是需求 10 预测成 20 的十倍;RMSLE 则惩罚相对误差,前者只差 10%,后者差了整整一倍,所以 RMSLE 反而会更严厉地惩罚低需求时段的绝对偏差。结论很直接:训练目标应该用np.log1p(count),而不是直接用count,这也是后续所有模型配置的出发点。
2.2 用 15 分钟做一轮 EDA:时间跨度、count 分布与天气取值
在写任何模型之前,我习惯把数据加载、缺失检查、目标分布三件事一口气做完。bike-sharing 的原始数据是小时粒度记录,train 和 test 字段一致,区别在于 train 里有 count,test 里没有。
import pandas as pd import numpy as np train = pd.read_csv('data/bike-sharing/train.csv', parse_dates=['datetime']) test = pd.read_csv('data/bike-sharing/test.csv', parse_dates=['datetime']) print(train.shape, test.shape) print('train missing:', train.isna().sum().sum()) print('test missing :', test.isna().sum().sum()) print('time range:', train['datetime'].min(), '->', train['datetime'].max()) print(train['count'].describe())从描述统计能看到两个关键事实。第一,count的分布严重右偏,绝大多数小时的需求很低,少数早晚高峰和好天气时段需求很高,这正好解释了为什么 RMSLE 选择 log 空间。第二,数据没有缺失,season、holiday、workingday、weather 都是类别字段,temp、atemp、humidity、windspeed 已经做了归一化到 0 到 1 之间。注意windspeed == 0的样本比例很高,这是原始记录方式造成的,不是缺失值,直接删除会丢掉一大部分信息,后面单独处理。
2.3 两个 baseline:纯均值预测为什么是必须的第一步
baseline 的意义不是刷成绩,而是给你一把尺子。如果后面费了半天劲做特征,验证分数还没有均值模型低,那说明方向就不对。用均值预测得到一个很高的 RMSLE 是正常的,关键是后续每个模型都要和它对比。
from sklearn.linear_model import Ridge from sklearn.model_selection import train_test_split mean_pred = np.full(len(train), train['count'].mean()) print('mean baseline rmsle:', rmsle(train['count'], mean_pred)) base = ['temp', 'humidity', 'windspeed', 'weather'] X = train[base].copy() y = train['count'].copy() X_tr, X_va, y_tr, y_va = train_test_split(X, y, test_size=0.2, random_state=42) ridge = Ridge(alpha=1.0) ridge.fit(X_tr, y_tr) pred_ridge = ridge.predict(X_va) print('ridge baseline rmsle:', rmsle(y_va, pred_ridge))这段代码里先做一个纯均值预测,再看只用天气相关字段的岭回归能压到多少。通常天气变量能说明一部分需求变化,但也只能解释一部分,因为 bicycle 需求最大的驱动是“什么时候”和“是不是工作日”。这里用了随机切分,只是为了让 baseline 能快速跑通,正式做模型对比时必须换成时间序列切分,原因在避坑章节会专门展开。岭回归的alpha起手设 1.0,如果验证分数不稳定再增大到 5 或 10,这个赛题里天气特征共线性不算严重,所以小一点更灵活。
3. 特征工程:把时间戳与天气变成 bike-sharing 模型爱吃的信号
这个赛题的特征工程决定了 80% 的分数。数据里真正有信息量的列就十来个,但能不能把 datetime 这一列拆成模型能理解的周期信号,才是拉开差距的地方。我一般把所有特征构造写成一个统一函数,train、val、test 一起过,避免后面切分时特征不一致。
3.1 时间字段拆解:hour、weekday、month 与周期编码怎么搭
datetime 是唯一一个原始时间字段,直接把它拆成年、月、日、小时、星期几。最核心的是 hour,因为共享单车的需求有极强的双峰形态,早晚高峰和夜间低谷完全是两个世界。
for df in (train, test): df['hour'] = df['datetime'].dt.hour df['weekday'] = df['datetime'].dt.weekday df['month'] = df['datetime'].dt.month df['year'] = df['datetime'].dt.year for df in (train, test): df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24) df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24)weekday返回 0 到 6,0 是周一,6 是周日,注意别搞反。month返回 1 到 12,这个字段和 season 有较强重叠,后面做特征筛选时可以二选一。hour_sin和hour_cos把 24 小时映射到圆上,目的是让模型知道 23 点和 0 点是相邻的,而不是数值上差了 23 个单位。不过对树模型来说,sin/cos 的帮助不如线性模型明显,它的更大作用是让周期边界更平滑。实际跑完这个小节,先别急着加更多特征,直接拿去跑一个决策树看验证分数,再逐步往上加,这样能感受到每一个特征带来的边际变化。
3.2 天气变量加工:temp、atemp、humidity、windspeed 的非线性处理
天气字段虽然已经归一化,但模型不能直接理解“湿度 0.9 意味着不愿意骑车”这种经验判断。温度对需求的影响也不是线性的:太冷和太热都会抑制需求,中间区域最舒适,所以构造平方项或分段标记都比直接用原值更合理。
for df in (train, test): df['temp_sq'] = df['temp'] ** 2 df['hum_high'] = (df['humidity'] > 0.85).astype(int) df['wind_0'] = (df['windspeed'] == 0).astype(int) df['wind_high'] = (df['windspeed'] > 0.3).astype(int)temp_sq给树模型一个捕捉温度倒 U 型关系的机会,虽然树本身能通过多次分裂近似非线性,但平方项能显著减少深度需求。hum_high标记湿度超过 0.85 的时刻,湿度过高时人明显不愿意出门。wind_0和wind_high分别标记无风和强风,因为原始 windspeed 字段里 0 值过多,直接当数值特征用会误导分裂点,拆成两个布尔特征反而干净。这里还有一个细节,temp和atemp的相关性非常高,两个都保留会让线性模型共线性明显,我习惯在树模型里都保留、在线性模型里只留temp。weather字段虽然取值 1 到 4,但取值 4 的样本极少,先不作处理,后面避坑章节会专门说。
3.3 高峰标记与训练集统计特征:做之前先分清 leak 边界
小时的周期性需要和是否为工作日组合起来才有意义。工作日早 8 点和晚 17 到 18 点是绝对高峰,周末则完全不是同一个曲线。单独把 hour 给模型,树模型要反复分裂才能学会这个交互,不如直接喂一个组合特征。
for df in (train, test): df['is_rush'] = ((df['hour'] == 8) | (df['hour'] == 17) | (df['hour'] == 18)).astype(int) df['workhour'] = ((df['workingday'] == 1) & (df['hour'].between(8, 18))).astype(int)is_rush是固定的通勤高峰标记,workhour是工作日白天的整体标记,两者角度不同,可以共存。接下来是统计特征,常见做法是把历史上每个小时的平均需求量作为新特征,这本质上是一种“周期性经验回填”。但这里非常容易踩泄漏:如果先用全量 train 算出 hour_mean,再切验证集,那么验证集时段的信息就已经混进训练集了,验证分数会虚低。
split_time = '2012-07-31 23:59:59' train_part = train[train['datetime'] <= split_time].copy() val_part = train[train['datetime'] > split_time].copy() hour_mean = train_part.groupby('hour')['count'].mean() for df in (train_part, val_part, test): df['hour_mean'] = df['hour'].map(hour_mean)这里先按时间切分,只用训练部分计算小时均值,再 map 回三个数据集。对验证集和测试集来说,这个值是“过去某个小时的平均水平”,不包含未来信息,符合时序逻辑。我见过很多人在这一步省事,直接用全量 train 计算,最后提交排名和本地验证对不上,根源就在这里。
4. LightGBM 在 bike-sharing 上的最小可用流程:时间切分、参数与提交
特征做完以后,模型部分反而可以很简洁。我常用的配置是 LightGBM 加岭回归融合,主力是 LightGBM。先不要急着叠模型,把时间序列验证切分、log 空间训练、提交格式这三件事一次做对。
4.1 时间序列验证:为什么随机 K 折在这里是错的
在一般表格赛题里随机 K 折是默认做法,但 bike-sharing 的数据是按小时记录的时间序列,相邻样本之间高度相关。随机切分会把某一个小时放进训练集、把相邻小时放进验证集,模型相当于偷偷看到了未来,验证分数自然漂亮,提交后立刻露馅。
features = ['season', 'holiday', 'workingday', 'weather', 'temp', 'atemp', 'humidity', 'windspeed', 'hour', 'weekday', 'month', 'year', 'hour_sin', 'hour_cos', 'is_rush', 'workhour', 'hour_mean'] X_tr, y_tr = train_part[features], train_part['count'] X_va, y_va = val_part[features], val_part['count']训练时只有一个切分点,把 2012 年 7 月 31 日作为边界,之前训练、之后验证。严格一点的做法是做滚动多折,例如切成三段,训练 1 验证 2、训练 1+2 验证 3,然后取平均。赛题数据只有两年,简单留后段验证足够,滚动多折更适合做特征筛选和参数稳定性检查。总之随机 K 折在这个赛题里不可用,这是我反复踩过并确认的结论。
4.2 LightGBM 核心参数与在 log1p 空间训练
LightGBM 在这个赛题上收敛快、调参简单,我用它作为主力。关键是把训练目标改成np.log1p(y),这样 log 空间里的 RMSE 就约等于原始空间的 RMSLE,整个训练过程都在和官方指标对齐。
import lightgbm as lgb model = lgb.LGBMRegressor( n_estimators=1000, learning_rate=0.05, num_leaves=31, subsample=0.8, subsample_freq=1, colsample_bytree=0.8, min_child_samples=20, random_state=42, ) model.fit( X_tr, np.log1p(y_tr), eval_set=(X_va, np.log1p(y_va)), eval_metric='rmse', callbacks=[lgb.early_stopping(50), lgb.log_evaluation(100)], )参数起手式有讲究。n_estimators=1000配合early_stopping(50),模型会在验证集连续 50 轮不提升时自动停下,所以初始值给大一点没关系。learning_rate=0.05是精度和速度的平衡点,降到 0.01 通常还能再压一点误差,但训练时间会明显变长。num_leaves=31控制树的复杂度,这个赛题数据量不大,超过 63 就很容易过拟合。subsample和colsample_bytree都是 0.8,相当于每次建树只用 80% 样本和 80% 特征,能稳定防止小数据上的抖动。min_child_samples=20让叶子节点至少覆盖 20 个样本,避免极端小时段被单独建模。
| 参数 | 起手值 | 调节方向 |
|---|---|---|
| n_estimators | 1000 | 配合早停,不用手调 |
| learning_rate | 0.05 | 验证集波动大就降到 0.02 |
| num_leaves | 31 | 分数不动再加到 63 |
| subsample / colsample | 0.8 | 过拟合时降到 0.7 |
| min_child_samples | 20 | 数据越少值越大 |
早期停止用的是验证集在 log 空间的 RMSE,和 RMSLE 同序,所以早停的轮次也基本可信。
4.3 验证预测、测试预测与提交格式
训练完成后,验证集和测试集都要走同一个还原流程:先用expm1把 log 空间的预测还原成数量,再截断负值。很多平台的评分脚本里会对负值取 log 得到 NaN,提交直接失败。
pred_val = np.expm1(model.predict(X_va)) pred_val = np.maximum(pred_val, 0) print('lgb val rmsle:', rmsle(y_va, pred_val)) pred_test = np.maximum(np.expm1(model.predict(test[features])), 0) sub = pd.DataFrame({ 'datetime': test['datetime'], 'count': pred_test, }) sub.to_csv('bike_sharing_submission.csv', index=False)np.expm1是np.log1p的逆操作,两者必须配对。测试集的特征必须和训练集完全一致,尤其是hour_mean这种统计特征,如果测试集没有 map 到同样的字典,预测出来的值会完全错位。提交文件只需要两列,列名保持datetime和count,顺序和 test 保持一致即可。到这里你已经有一条完整可用的模型流水线,接下来要处理的是那些让分数忽上忽下的坑。
5. 避坑:复现 bike-sharing 最容易翻车的五个现场与修法
这个赛题数据量不大,但隐藏的坑不少。以下五条是我复现过程中真实踩过的,每一条都会直接影响验证分数和最终名次。
5.1 验证集分数很好,提交名次却掉队:随机 K 折的隐藏泄漏
现象:用train_test_split(random_state=42)得到验证 RMSLE 只有 0.4 左右,感觉已经是不错的成绩,提交后排名却在中游都摸不到。
原因:随机切分把相邻时间段的小时样本同时分到训练与验证,模型见到了大量“未来”的邻近样本,天气和时间特征高度相似,导致验证误差被严重低估。
解决:改成纯时间序列切分,严格按时间排序留出最后 4 到 5 个月做验证。验证分数的第一目标不是低,而是和提交结果趋势一致。如果一定要用 K 折,就按时间顺序做滚动 K 折,而不是随机 K 折。
5.2 weekday 当作数值特征,周末和节假日预测形状明显不对
现象:模型整体误差不高,但如果画逐小时预测曲线,发现周六上午的预测形态和工作日几乎一样,完全看不出周末特征。
原因:把weekday直接以 0 到 6 的数值送入模型,树模型会认为周日和周一的距离是 1,而周五和周六的距离也是 1,这种线性距离假设对星期完全不成立。
解决:对weekday做 one-hot 编码,或者至少拆成工作日和周末两个标记。共享单车需求里真正的边界是工作日与非工作日,其次才是周五、周六、周日的各自差异。
for df in (train_part, val_part, test): df['is_weekend'] = (df['weekday'] >= 5).astype(int)weekday_dummies = pd.get_dummies(train_part['weekday'], prefix='wd')把is_weekend和workhour组合使用,比单独保留weekday数值特征稳定得多。
5.3 凌晨 0 到 5 点预测值整体偏高
现象:验证集整体 RMSLE 在下降,但单独看 0 点到 5 点这段,预测值普遍高于真实值,中位数接近 0 的时段被明显拉高。
原因:低需求时段count中位数非常接近 0,直接拟合count时,损失函数为了照顾小部分非零时段,会把整体预测均值抬高。另外凌晨 0 点在工作日和非工作日的形态差异也很大,树模型靠一个hour字段很难分开。
解决:把训练目标改成np.log1p(count),夜间样本被压缩后不会过分主导损失。同时增加hour与is_weekend、holiday的交叉特征,让模型有机会区分“周五晚 0 点”和“周日晚 0 点”。
5.4 提交成绩直接 NaN:负值预测在 log 里翻车
现象:本地验证一切正常,提交后分数显示 NaN,或者排行榜上带一个让人懵掉的空白成绩。
原因:测试集里有一些极端寒冷天气,模型预测出了负的租赁量,有些评分端在计算对数时没有做保护,负值进入log直接得到无效值。
解决:所有预测结果在写文件前统一做np.maximum(pred, 0),并且在提交前检查是否还存在非正数:
test['count'] = np.maximum(np.expm1(model.predict(test[features])), 0) assert (test['count'] >= 0).all() assert not test['count'].isna().any()这两行断言很便宜,但能挡住一次无效提交。
5.5 极端天气和超高风速样本把模型拉偏
现象:验证集整体分数不错,但把天气条件分开展示时,恶劣天气样本的误差远大于其他样本,模型几乎所有恶劣天气预测都比真实值低。
原因:weather取值 4 的样本在整个数据里只有个位数到几十行的量级,树模型很难从这么少的数据里学到稳定模式,反而会把这部分样本当噪声处理。风速字段在归一化后仍然存在异常高值,也会把树的切分点带偏。
解决:把样本量极少的weather == 4合并到weather == 3,风速做分位数截断,训练时可以剔除个别明显离群的恶劣天气样本。注意这个处理必须同时对 train 和 test 做,保持字段取值口径一致。
for df in (train_part, val_part, test): df['weather'] = df['weather'].replace(4, 3) df['windspeed'] = df['windspeed'].clip(upper=0.3)这里clip到 0.3 不是固定的,可以先看风速分布的分位数再定,重点是防止极端值主导分裂。
6. 把分数再压低一截的三个细节:对数空间、加权融合、特征裁剪
到这一步,验证 RMSLE 已经没有大幅下降的空间,剩下的都是细节。第一个细节是坚持在 log 空间做调参而不是最后再转换。有些人直接用count训练、报验证时再算 RMSLE,这会让你在早停和参数选择时被高峰时段的大误差干扰,两个模型可能验证分数完全一样,但在提交上差一个档次。正确做法是整个过程都用np.log1p(count)作为目标,早停指标也看 log 空间的 RMSE,这样每一步优化都直接对应官方指标。
第二个细节是把树模型和线性模型做加权融合。LightGBM 对非线性交互很敏感,但它在低温季节这种样本量小的区间容易抖动;岭回归虽然整体精度低,但它的预测非常稳定,不会出现个别极端值。把两者按权重相加,能抹平树模型的小幅抖动。
pred_lgb = np.expm1(model.predict(X_va)) pred_ridge = ridge.predict(X_va) pred_final = 0.8 * pred_lgb + 0.2 * pred_ridge pred_final = np.maximum(pred_final, 0) print('fusion val rmsle:', rmsle(y_va, pred_final))权重 0.8 和 0.2 是常用的起手值,之后可以在验证集上扫一遍 0.7 到 0.9 之间的步长,选最优。融合的前提是两个模型用同一套特征和同一个时序切分,否则融合结果没有意义。
第三个细节是特征裁剪只看验证误差变化,不看重要度图。很多人喜欢把 LightGBM 的 feature importance 前二十全留下,结果验证误差反而上升。特征重要度高只代表在训练集上分裂频繁,不代表对泛化一定有用。我常用的是逐步剔除法:每次去掉一个候选特征,用同样的切分和参数重训,看验证 RMSLE 是升是降。特征重要度图只能给我一个初筛方向,最终留哪些特征要以时间序列切分下的验证误差为准。
我之前在某次复现时吃过这个亏,加了三个重要度最高的特征,验证分数反而掉了,从那以后只信控制变量下的误差对比。先保证验证集切得干净,再在干净的地基上加加减减,分数才能稳定往前走。希望帮到你。
本文还有配套的精品资源,点击获取