简介:这是一份面向数据科学初学者的Kaggle入门项目,围绕城市自行车共享系统使用状况数据,完成从数据探索、特征工程到需求预测的完整分析流程。资源适合计算机、数据科学与大数据技术、人工智能、物联网等专业的学生用于课程设计、毕业设计或初期演示,也适合希望快速上手实战的算法初学者。压缩包共8个文件,包含3个CSV格式的数据集、2个Python源码脚本、2个Jupyter Notebook交互式分析文档和1份Markdown说明,整包仅823KB,轻量但结构完整。其中,Python脚本实现了可直接运行的预测管线,Notebook则逐步展示神经网络模型的构建、训练与调优细节,项目说明文档梳理了项目背景与实现思路,方便对照学习。目前已有519人浏览学习,这套程式化的赛题流程和可复用代码框架,能帮助读者快速入门自行车共享需求预测这类典型回归任务。
1. Kaggle入门项目到底在练什么:从一辆共享单车看到预测建模全流程
第一次在Kaggle上打开城市自行车共享系统的题目,我对着hour.csv里的上万条骑行记录愣了很久。这个项目之所以被当作入门经典,是因为它把数据清洗、特征工程、模型选型和评估指标串成了一条完整的流水线。你要做的,是用华盛顿某共享单车系统的历史租赁数据,预测每个小时会有多少人租车。它不要求深度学习背景,Python基础加pandas、sklearn就够用;但想拿高分,又需要你认真处理时间特征和验证集切分。对刚注册Kaggle的新手来说,这是第一个能把手绕到发酸但确实能出成绩的实战项目。本文会顺着这份“python源码+项目说明”的常规结构,拆解从数据加载到模型提交的每一步,以及那些让新手反复翻车的坑。
2. 先让数据开口说话:自行车共享数据的加载与探索性分析
2.1 数据文件长什么样:hour.csv与day.csv的字段差异
常见项目源码包里会带一份数据说明,告诉你原始数据来自UCI的Bike Sharing Dataset。它由两个CSV文件组成:hour.csv是逐小时粒度,day.csv是逐日粒度。很多新手一上来就把day.csv当主表,结果预测的粒度完全不对。实际上,官方题目要求预测的是“每小时”的租车量,所以hour.csv才是主战场。day.csv只有731行,小时数据有17379行,样本量上hour.csv也更适合训练机器学习模型。
先用pandas把两份数据读进来,确认字段结构。你不需要额外安装包,pandas和numpy是跑这种项目最基础的两件套。
import pandas as pd import numpy as np # 加载小时级数据,sep默认是逗号 hour = pd.read_csv('hour.csv') day = pd.read_csv('day.csv') # 先看形状和表头 print('hour shape:', hour.shape) print('day shape:', day.shape) print(hour.head())从输出能看到,hour.csv里包含dteday、hr、season、yr、mnth、holiday、weekday、workingday、weathersit、temp、atemp、hum、windspeed、casual、registered、cnt字段。instant是行号,dteday是日期,hr是0到23的小时。season用1到4表示春夏秋冬,weathersit用1到4表示晴、雾、小雨、暴雨等天气等级。这两个字段本质上都是分类变量,不能直接当数字喂给模型。
为了后续统一处理,我会先构造一个真正的datetime列,同时检查缺失值。UCI这份数据一般没有缺失,但检查这一步不能省,尤其是hum和windspeed如果出现0,那往往是测量下限而不是缺失,不要顺手填充掉。
# 拼出完整时间戳,方便后续按时间排序和切分 hour['datetime'] = pd.to_datetime(hour['dteday'].astype(str) + ' ' + hour['hr'].astype(str) + ':00:00') # 缺失值检查:正常情况每个字段都是0 print(hour.isnull().sum())字段级业务含义会影响你的特征工程决策,下面这个表是这份数据里最关键的几个字段,我把建模时的处理建议也一并列出来。
| 字段 | 含义 | 原始类型 | 建模处理建议 |
|---|---|---|---|
| instant | 记录序号 | int | 直接删除 |
| dteday | 日期 | str | 转为datetime后派生特征 |
| hr | 小时0-23 | int | 周期编码或类别编码 |
| season | 季节1-4 | int | 转为类别特征 |
| yr | 年份0/1 | int | 可作为普通数值 |
| mnth | 月份1-12 | int | 转为类别特征 |
| holiday | 是否节假日 | int | 转为类别特征 |
| weekday | 星期几0-6 | int | 转为类别特征 |
| workingday | 是否工作日 | int | 转为类别特征 |
| weathersit | 天气状况1-4 | int | 转为类别特征 |
| temp | 归一化温度 | float | 保留数值特征 |
| atemp | 体感温度 | float | 与temp高度相关,二选一 |
| hum | 归一化湿度 | float | 保留数值特征 |
| windspeed | 归一化风速 | float | 保留数值特征 |
| casual | 临时用户数 | int | 预测时必须删除 |
| registered | 注册用户数 | int | 预测时必须删除 |
| cnt | 总租赁数 | int | 目标变量 |
这份表格里最容易埋雷的是casual和registered。它们俩加和正好等于cnt,如果把它们留在特征里,模型等于直接看到了答案。后面第5章会专门说这个坑。
2.2 用pandas快速查看目标分布与周期规律
拿到数据后不要急着建模,先花半小时做探索性分析。共享单车的需求不是随机波动,它明显受一天内的早晚高峰、一周内的工作日/周末、一年内的季节变化影响。用直方图和分组均值就能看出这些规律。
import matplotlib.pyplot as plt # 目标变量cnt的分布:长尾明显,多数时候租车量在0附近 plt.figure(figsize=(10, 4)) plt.hist(hour['cnt'], bins=60, edgecolor='k', alpha=0.8) plt.xlabel('total rentals per hour') plt.ylabel('count') plt.title('Distribution of cnt') plt.show() # 按小时和工作日分组,画平均租车量曲线 hour.pivot_table(index='hr', columns='workingday', values='cnt', aggfunc='mean').plot() plt.ylabel('average cnt') plt.legend(['non-workingday', 'workingday']) plt.show()这个pivot_table是整份数据里最值得看的图。工作日的cnt曲线在上午8点和下午18点会出现两个高峰,非工作日则是午后平缓的一条弧线。这直接证明hour是强特征,而且工作日与非工作日的行为模式不同,后面可以让模型自己学这种交互,也可以在特征工程里手动加“早高峰”这类标志。
再算一下数值特征与目标的相关性,能给特征筛选做参考。温度、体感温度与租车量正相关,湿度和风速有负相关趋势,但相关性不意味着因果关系,尤其是hum和windspeed之间可能存在交互作用,线性模型里要小心多重共线性。
# 温度、湿度等数值特征与cnt的相关性 corr = hour[['temp', 'atemp', 'hum', 'windspeed', 'cnt']].corr() print(corr['cnt'].sort_values(ascending=False))2.3 先按时间切分训练集和测试集:别让随机切分毁掉结果
许多新手习惯直接调用train_test_split,这在普通分类题上没毛病,但共享单车是时间序列预测。官方给的测试区间通常是一段连续的末尾日期,如果你用随机切分,训练集里混入未来数据,模型等于提前看到了答案,本地验证分数虚高,一提交就现原形。正确做法是按时间顺序,把最后一段数据留作验证。
from sklearn.model_selection import train_test_split # 错误做法:随机切分 X = hour.drop(['cnt'], axis=1) y = hour['cnt'] X_train_wrong, X_test_wrong, y_train_wrong, y_test_wrong = train_test_split( X, y, test_size=0.2, random_state=42) # 正确做法:按datetime排序,用连续时间点切分 hour = hour.sort_values('datetime').reset_index(drop=True) split_date = '2012-10-01' train = hour[hour['datetime'] < split_date].copy() test = hour[hour['datetime'] >= split_date].copy() print(f'train rows: {len(train)}, test rows: {len(test)}')这里train包含2011-01-01到2012-09-30的数据,test是从2012-10-01开始到最后,两者时间完全不相交。之后所有特征构造、滞后统计都只允许在train上计算,再merge到test。这是时间序列项目的第一条铁律。
3. 特征工程是这题的胜负手:时间特征与滞后特征怎么造
3.1 把日期拆成小时、星期、月份,并做周期编码
前面的探索已经说明时间因素决定租车量,所以特征工程的第一步,就是把datetime里的信息拆成模型认识的数值。注意train和test要用同一套逻辑,最好封装成函数,避免两边特征列不一致。
def extract_time_features(df): df = df.copy() df['hour'] = df['datetime'].dt.hour df['weekday'] = df['datetime'].dt.weekday df['month'] = df['datetime'].dt.month df['is_weekend'] = (df['weekday'] >= 5).astype(int) return df train = extract_time_features(train) test = extract_time_features(test)拆出来的hour、weekday、month可以直接当类别特征,也可以做周期编码。hour取值0到23,23点和0点在数值上相差23,但实际需求上它们很接近——深夜都是低谷,清晨刚开始回升。线性模型无法理解这种循环边界,因此对线性回归和神经网络,必须把周期变量展开成sin和cos。
# 周期编码:把小时转成二维坐标 train['hour_sin'] = np.sin(2 * np.pi * train['hour'] / 24) train['hour_cos'] = np.cos(2 * np.pi * train['hour'] / 24) train['month_sin'] = np.sin(2 * np.pi * train['month'] / 12) train['month_cos'] = np.cos(2 * np.pi * train['month'] / 12) test['hour_sin'] = np.sin(2 * np.pi * test['hour'] / 24) test['hour_cos'] = np.cos(2 * np.pi * test['hour'] / 24) test['month_sin'] = np.sin(2 * np.pi * test['month'] / 12) test['month_cos'] = np.cos(2 * np.pi * test['month'] / 12)树模型不一定要周期编码,因为决策树是逐点切分的,没准能从原始hour数值里直接学出“早上8点”和“傍晚18点”这两个切分点。但线性模型非常依赖这种编码,如果你打算先跑一个线性回归基线,周期特征几乎是必须的。
3.2 分类变量处理:season、weathersit的独热编码与业务顺序
season和weathersit虽然写成数字1到4,但数字大小没有数学意义,季节从1到4不等于数值翻倍。对于这类无序类别,常见做法是用pd.get_dummies做独热编码。hour数据里类别字段不多,独热后维度增加也不大,可以放心用。
# 先转成字符串再独热,避免pandas把数值类别误判成连续变量 cat_cols = ['season', 'weathersit', 'holiday', 'workingday', 'weekday'] for col in cat_cols: train[col] = train[col].astype('category') test[col] = test[col].astype('category') # 独热编码,drop_first=False保留全部类别 train = pd.get_dummies(train, columns=cat_cols, drop_first=False) test = pd.get_dummies(test, columns=cat_cols, drop_first=False)注意drop_first=False意味着每个类别都会生成一列,这在线性回归里会造成多重共线性,但对树模型影响不大。如果你用线性回归并且发现系数不稳定,可以把drop_first改成True。另一个容易被忽略的点是:train和test做完get_dummies后,列名必须完全对齐。用pd.get_dummies时,如果某个类别只出现在test里,就会多出一列,后面建模时会报错。一个简单的处理办法是join后reindex。
# 确保train和test特征列一致 X_train = train.drop(['cnt', 'datetime', 'dteday', 'hr', 'casual', 'registered'], axis=1) X_test = test.drop(['cnt', 'datetime', 'dteday', 'hr', 'casual', 'registered'], axis=1) X_test = X_test.reindex(columns=X_train.columns, fill_value=0)这里用reindex把test里缺失的列补0,出现的多余列剔除掉,是一个很实用的对格式技巧。
3.3 滞后特征与滑动统计:最容易泄漏的地方
想要把预测做准,光靠天气和时间还不够。共享单车有一个强惯性:今天下午5点的租车量,和昨天下午5点、上周一下午5点有很强的相关性。所以常见做法是构造滞后特征和滚动均值。但滞后特征在时间序列里非常容易踩中数据泄漏,需要特别小心。
最常见的错误是对全量数据直接shift。例如:
# 错误示范:对全量数据shift,测试集会用到未来真实值 wrong = hour.copy() wrong['cnt_lag1'] = wrong['cnt'].shift(1)这样构造的cnt_lag1在测试集里包含了训练集最后一天的真实成交量,但线上预测时,测试起始时刻的上一小时数据可能根本拿不到。正确方式是在train上计算滞后统计量,再merge到test。
# 在train上按时间排序,构造滞后列 train = train.sort_values('datetime').reset_index(drop=True) for lag in [1, 2, 24, 168]: train[f'cnt_lag_{lag}'] = train['cnt'].shift(lag) # 前168行会出现NaN,用训练集整体均值填掉 train = train.fillna({'cnt_lag_1': train['cnt'].mean(), 'cnt_lag_2': train['cnt'].mean(), 'cnt_lag_24': train['cnt'].mean(), 'cnt_lag_168': train['cnt'].mean()})lag=1是上一小时,lag=24是昨天同一小时,lag=168是上周同一小时。这几个特征能大幅拉低RMSLE,但前提是测试集也能获得这些滞后值。真实比赛中,官方一般会提供测试期间的过去天气特征,但不会给你过去一小时的cnt,所以lag特征只能作为训练时的辅助,不能直接作为测试集的预测输入。
更稳妥的做法是用“星期+小时”的历史均值作为冷启动特征。这种特征从train统计得到,测试时可以直接用,不会泄漏。
# 计算每个(星期, 小时)组合在训练集上的平均租车量 temp_agg = train.groupby(['weekday', 'hour'])['cnt'].mean().reset_index() temp_agg.columns = ['weekday', 'hour', 'mean_cnt_weekday_hour'] train = train.merge(temp_agg, on=['weekday', 'hour'], how='left') test = test.merge(temp_agg, on=['weekday', 'hour'], how='left')这个mean_cnt_weekday_hour特征相当于给模型一个“基准温度”:工作日早8点平均租车量是多少,深夜0点平均又是多少。测试集没有真实cnt也能用,而且效果往往比单纯shift的lag特征更稳定,因为它不会被离群点带偏。
4. 预测模型选型与训练:从线性回归到梯度提升的落地路径
4.1 基线模型:用线性回归先摸清数据量纲
拿到特征后,第一个模型不需要太复杂。线性回归虽然打不进排行榜前列,但能帮你验证特征构造是否正确、数据量纲是否合理。因为cnt长尾分布严重,直接拟合原始数值容易被大值主导,一个常见处理是对目标做log1p变换,让模型预测对数空间,最后再还原。
from sklearn.linear_model import LinearRegression from sklearn.metrics import mean_squared_log_error # 这里直接用上一章处理好的X_train和X_test y_train = train['cnt'] y_test = test['cnt'] model = LinearRegression() model.fit(X_train, np.log1p(y_train)) pred_lr = model.predict(X_test) pred_lr = np.expm1(pred_lr) pred_lr = np.clip(pred_lr, 0, None) # 防止负值 print('LR RMSLE:', mean_squared_log_error(y_test, pred_lr))注意np.log1p和np.expm1是一对互逆操作,np.log1p是log(x+1),因为cnt可能为0,直接log0会得负无穷。线性回归的预测值在对数空间里可能是负的,还原后也可能为负,所以必须clip到0。这段代码里,X_train包含所有独热、周期和冷启动特征,如果特征工程正确,RMSLE应该落在1.0到1.3左右,不算好,但已经不是瞎猜。
线性回归的短板很明显:它学不了早晚高峰这种非线性交互。哪怕加了周期编码,模型表达能力仍然有限。所以基线跑通后,下一步就把树模型搬上来。
4.2 树模型上场:随机森林与XGBoost的调参边界
随机森林对异常值不敏感,也不需要特征标准化,非常适合这种类别较多的表格数据。树模型在Kaggle里被用得烂大街,但参数设置不当同样会翻车。下面是我常用的随机森林配置。
from sklearn.ensemble import RandomForestRegressor rf = RandomForestRegressor( n_estimators=300, max_depth=12, min_samples_leaf=3, n_jobs=-1, random_state=42 ) rf.fit(X_train, y_train) pred_rf = rf.predict(X_test) pred_rf = np.clip(pred_rf, 0, None) print('RF RMSLE:', mean_squared_log_error(y_test, pred_rf))n_estimators设为300,再大收益甚微还拖慢训练;max_depth限制为12,防止树在17379个样本上学到过于琐碎的噪声;min_samples_leaf=3保证叶子节点至少3个样本,这个参数对稳定性影响很大。随机森林不需要对数变换目标,可以直接拟合原始cnt。
如果希望进一步压低分数,就得上梯度提升。XGBoost在这类表格数据上几乎是标配。我用它时会关注三个参数:learning_rate、subsample和colsample_bytree。
import xgboost as xgb xgb_model = xgb.XGBRegressor( n_estimators=500, learning_rate=0.05, max_depth=6, subsample=0.8, colsample_bytree=0.8, objective='reg:squarederror', random_state=42 ) xgb_model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False) pred_xgb = xgb_model.predict(X_test) pred_xgb = np.clip(pred_xgb, 0, None) print('XGB RMSLE:', mean_squared_log_error(y_test, pred_xgb))learning_rate=0.05搭配500棵树的组合是经验值。learning_rate调大后树数要相应减少,否则过拟合;subsample=0.8表示每棵树只用80%的样本,colsample_bytree=0.8表示每棵树只用80%的特征,这两个参数能有效抑制过拟合。XGBoost默认目标函数是reg:squarederror,也就是普通MSE。如果你想让误差在log空间更均衡,可以把目标改成reg:squaredlogerror,但必须先熟练默认目标函数。
4.3 评估指标:RMSLE为什么比RMSE更适合计数型目标
Kaggle这个项目用的评估指标是RMSLE,全称是Root Mean Squared Logarithmic Error。很多新手第一次用sklearn的mean_squared_log_error,但没搞懂它背后的意义。公式是sqrt(mean((log(pred+1) - log(true+1))^2)),相当于在log空间里算RMSE。
这个指标对“大值预测错一点”和“小值预测错一点”的处理完全不同。高峰期租车量300,预测成400,RMSE是100;凌晨租车量10,预测成40,RMSE是30。从RMSLE来看,前者是log(301)-log(401)约等于0.28,后者是log(11)-log(41)约等于1.35,后者惩罚重得多。这符合业务直觉:低峰的绝对误差虽然小,但相对误差大,代表模型完全误判了需求低谷。
因此做这个项目时,不要用平均绝对误差或RMSE做本地验证。邮件列表里常有人把验证集RMSE降得很低,但提交后排名反而不理想,就是因为优化方向错了。确认指标用RMSLE之后,还可以做一个简单的交叉验证:把训练集按时间切成三段,每段分别算RMSLE,取平均。但务必保持时间顺序,不能打乱。
5. 避坑指南:共享单车预测项目里最常见的5个翻车现场
5.1 现象:训练集RMSLE很漂亮,提交分数却崩盘
本地验证RMSLE在0.3以下,提交到Kaggle直接变成1.5,这在时间序列题里最常见。原因基本只有一个:验证集切分方式错了。很多人直接使用train_test_split随机打乱数据,让模型在训练时看到了未来数据。解决方法是把训练集最后20%按时间留出来,不要有任何随机因素。我用split_date='2012-10-01'切分后,本地分数才开始和线上保持一致。
5.2 现象:模型预测出现负值,RMSLE直接报错
线性回归在预测非负计数时经常输出负值,尤其是凌晨时段。原因是模型没有约束下限,而训练数据长尾又重,直线拟合可能在某些低值区间压到0以下。解决方法是每次预测后都加np.clip(pred, 0, None)。如果在用sklearn的mean_squared_log_error,它内部要求预测值非负,负值会直接报错。还有一个根治方案:把目标变换到log空间,就像4.1那样,负值问题会小很多,但依然要clip。
5.3 现象:把casual和registered留在特征里,自嗨式高分
有些初学者没仔细看字段说明,把casual和registered也当成特征,训练时RMSLE能低到0.05,几乎完美。这是因为cnt = casual + registered,模型直接学会了加法运算,等于开卷考试。线上预测时根本没有casual和registered这两个输入,所以分数必然崩溃。解决方法是建模前把所有与目标线性相关的字段全部删掉,包括instant、dteday、hr这类行号和原始时间戳。检查方法很简单:训练完打印特征重要性,如果casual或registered排在最前面,肯定是泄漏了。
5.4 现象:时间特征直接当数值,线性回归的预测曲线难看到哭
有人把hour、month直接当成连续数值特征喂给线性回归,得到的预测曲线是一条斜线,完全看不到早晚高峰的起伏。原因是线性模型假设数值大小与目标单调相关,但小时0到23是一个循环变量,16点不一定比15点高。解决方法是把hour做周期编码sin/cos,或者干脆转成类别特征并用独热编码。树模型可以不处理,但线性模型必须处理。
5.5 现象:滞回特征用了未来数据,模型在测试阶段“开天眼”
构造cnt_lag_1时,如果对全量数据shift,测试集的cnt_lag_1会包含训练集最后一天的真实值。本地验证时模型能用这个过去值,而线上评估时这些值不可用,导致本地和线上差异巨大。解决方法是只在训练集上构造lag特征,测试集使用merge过来的历史统计量,例如“星期+小时”的平均cnt。另外,训练集内做lag特征后要丢弃前几行NaN,否则fillna会引入训练集全局均值,虽然不泄漏,但会污染早期样本。
6. 收尾技巧:让源码和项目说明成为能放进简历的作品
6.1 生成提交文件,别让结果烂在Jupyter里
很多人做完模型只在Notebook里打印一个RMSLE就结束了,这浪费了项目价值。Kaggle项目的产出应当是能提交的CSV和一份干净的源码。常见提交格式是两列:datetime和predicted_cnt。如果你之前切分test时保留了datetime,直接输出即可。
submission = pd.DataFrame({ 'datetime': test['datetime'], 'predicted_cnt': np.clip(pred_xgb, 0, None) }) submission.to_csv('submission.csv', index=False)写提交文件时用整数还是浮点数没有硬性要求,RMSLE在log空间计算,浮点数对结果影响极小。但submission.csv的列名必须和官方要求完全一致,否则提交后会报“invalid submission”。这份CSV保存好,项目说明里附上模型参数和本地RMSLE,就能形成一份完整的作品集材料。
6.2 用特征重要性反向检查数据泄漏
树模型和XGBoost都自带feature_importances,它可以帮你验证特征工程是否合理。实际跑完XGBoost后,我会打印前10个特征,重点关注两类情况:一是casual和registered这类泄漏特征是否出现,二是滞后特征是否排在榜首。如果mean_cnt_weekday_hour排在第一位,这是好事;如果cnt_lag_1排第一,你就要警惕测试集是否真的能拿到这个值。特征重要性也是调参的航标,比如hour_sin比hour_cos重要很多,说明小时周期编码在模型决策里确实起了作用。
做完以上这些,这个项目就已经具备一个可复现、可说明的完整闭环了。我自己的习惯是最后写一段注释放在源码顶部,记录数据来源、切分日期、关键参数和最终RMSLE。这样过三个月再回来看,还能想起当时为什么把learning_rate从0.1改到0.05。这个项目最大的价值不在于刷一个多高的排名,而是让你在错误的数据泄漏、错误的时间切分、错误的评估指标里栽过跟头,以后再做任何时间序列任务,都会先条件反射地去看一眼验证集切得对不对。希望帮到你。
本文还有配套的精品资源,点击获取